/
/
/
1"""The provider class for Open Subsonic."""
2
3from __future__ import annotations
4
5from asyncio import TaskGroup
6from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
7
8from libopensonic import AsyncConnection as SonicConnection
9from libopensonic.errors import (
10 AuthError,
11 CredentialError,
12 DataNotFoundError,
13 ParameterError,
14 SonicError,
15)
16from music_assistant_models.enums import ContentType, MediaType, StreamType
17from music_assistant_models.errors import (
18 ActionUnavailable,
19 LoginFailed,
20 MediaNotFoundError,
21 ProviderPermissionDenied,
22 UnsupportedFeaturedException,
23)
24from music_assistant_models.media_items import (
25 Album,
26 Artist,
27 AudioFormat,
28 MediaItemType,
29 Playlist,
30 Podcast,
31 PodcastEpisode,
32 ProviderMapping,
33 RecommendationFolder,
34 SearchResults,
35 Track,
36)
37from music_assistant_models.streamdetails import StreamDetails
38
39from music_assistant.constants import (
40 CONF_PASSWORD,
41 CONF_PATH,
42 CONF_PORT,
43 CONF_USERNAME,
44 UNKNOWN_ARTIST,
45)
46from music_assistant.models.music_provider import MusicProvider
47
48from .parsers import (
49 EP_CHAN_SEP,
50 NAVI_VARIOUS_PREFIX,
51 UNKNOWN_ARTIST_ID,
52 parse_album,
53 parse_artist,
54 parse_epsiode,
55 parse_playlist,
56 parse_podcast,
57 parse_track,
58)
59
60if TYPE_CHECKING:
61 from collections.abc import AsyncGenerator
62
63 from libopensonic.media import AlbumID3 as SonicAlbum
64 from libopensonic.media import ArtistID3 as SonicArtist
65 from libopensonic.media import Bookmark as SonicBookmark
66 from libopensonic.media import Child as SonicItem
67 from libopensonic.media import OpenSubsonicExtension, PodcastChannel
68 from libopensonic.media import Playlist as SonicPlaylist
69 from libopensonic.media import PodcastEpisode as SonicEpisode
70
71
72CONF_BASE_URL = "baseURL"
73CONF_ENABLE_PODCASTS = "enable_podcasts"
74CONF_ENABLE_LEGACY_AUTH = "enable_legacy_auth"
75CONF_OVERRIDE_OFFSET = "override_transcode_offest"
76CONF_RECO_FAVES = "recommend_favorites"
77CONF_NEW_ALBUMS = "recommend_new"
78CONF_PLAYED_ALBUMS = "recommend_played"
79CONF_RECO_SIZE = "recommendation_count"
80CONF_PAGE_SIZE = "pagination_size"
81
82CACHE_CATEGORY_PODCAST_CHANNEL = 1
83CACHE_CATEGORY_PODCAST_EPISODES = 2
84
85Param = ParamSpec("Param")
86RetType = TypeVar("RetType")
87
88
89class OpenSonicProvider(MusicProvider):
90 """Provider for Open Subsonic servers."""
91
92 conn: SonicConnection
93 _enable_podcasts: bool = True
94 _seek_support: bool = False
95 _ignore_offset: bool = False
96 _show_faves: bool = True
97 _show_new: bool = True
98 _show_played: bool = True
99 _reco_limit: int = 10
100 _pagination_size: int = 200
101
102 async def handle_async_init(self) -> None:
103 """Set up the music provider and test the connection."""
104 port = self.config.get_value(CONF_PORT)
105 port = int(str(port)) if port is not None else 443
106 path = self.config.get_value(CONF_PATH)
107 if path is None:
108 path = ""
109 self.conn = SonicConnection(
110 str(self.config.get_value(CONF_BASE_URL)),
111 username=str(self.config.get_value(CONF_USERNAME)),
112 password=str(self.config.get_value(CONF_PASSWORD)),
113 legacy_auth=bool(self.config.get_value(CONF_ENABLE_LEGACY_AUTH)),
114 port=port,
115 server_path=str(path),
116 app_name="Music Assistant",
117 )
118 try:
119 success = await self.conn.ping()
120 if not success:
121 raise CredentialError
122 except (AuthError, CredentialError) as e:
123 msg = (
124 f"Failed to connect to {self.config.get_value(CONF_BASE_URL)}, check your settings."
125 )
126 raise LoginFailed(msg) from e
127 self._enable_podcasts = bool(self.config.get_value(CONF_ENABLE_PODCASTS))
128 self._ignore_offset = bool(self.config.get_value(CONF_OVERRIDE_OFFSET))
129 try:
130 extensions: list[OpenSubsonicExtension] = await self.conn.get_open_subsonic_extensions()
131 for entry in extensions:
132 if entry.name == "transcodeOffset" and not self._ignore_offset:
133 self._seek_support = True
134 break
135 except OSError:
136 self.logger.info("Server does not support transcodeOffset, seeking in player provider")
137 self._show_faves = bool(self.config.get_value(CONF_RECO_FAVES))
138 self._show_new = bool(self.config.get_value(CONF_NEW_ALBUMS))
139 self._show_played = bool(self.config.get_value(CONF_PLAYED_ALBUMS))
140 self._reco_limit = int(str(self.config.get_value(CONF_RECO_SIZE)))
141 self._pagination_size = int(str(self.config.get_value(CONF_PAGE_SIZE)))
142 self._pagination_size = min(self._pagination_size, 500)
143
144 @property
145 def is_streaming_provider(self) -> bool:
146 """
147 Return True if the provider is a streaming provider.
148
149 This literally means that the catalog is not the same as the library contents.
150 For local based providers (files, plex), the catalog is the same as the library content.
151 It also means that data is if this provider is NOT a streaming provider,
152 data cross instances is unique, the catalog and library differs per instance.
153
154 Setting this to True will only query one instance of the provider for search and lookups.
155 Setting this to False will query all instances of this provider for search and lookups.
156 """
157 return False
158
159 async def _get_podcast_episode(self, eid: str) -> SonicEpisode:
160 chan_id, ep_id = eid.split(EP_CHAN_SEP)
161 chan = await self.conn.get_podcasts(inc_episodes=True, pid=chan_id)
162
163 if not chan[0].episode:
164 raise MediaNotFoundError(f"Missing episode list for podcast channel '{chan[0].id}'")
165
166 for episode in chan[0].episode:
167 if episode.id == ep_id:
168 return episode
169
170 msg = f"Can't find episode {ep_id} in podcast {chan_id}"
171 raise MediaNotFoundError(msg)
172
173 def _set_loudness(self, item: SonicItem) -> None:
174 if item.replay_gain and item.replay_gain.track_gain is not None:
175 # Convert ReplayGain values (gain in dB) to integrated loudness (LUFS)
176 track_loudness = -18 - item.replay_gain.track_gain
177 album_loudness = (
178 -18 - item.replay_gain.album_gain
179 if item.replay_gain.album_gain is not None
180 else None
181 )
182 self.mass.create_task(
183 self.mass.music.set_loudness(
184 item.id,
185 self.instance_id,
186 track_loudness,
187 album_loudness,
188 )
189 )
190
191 async def resolve_image(self, path: str) -> bytes | Any:
192 """Return the image."""
193 self.logger.debug("Requesting cover art for '%s'", path)
194
195 try:
196 art = await self.conn.get_cover_art(path)
197 return await art.content.read()
198 except DataNotFoundError:
199 self.logger.warning("Unable to locate a cover image for %s", path)
200 return None
201
202 async def search(
203 self, search_query: str, media_types: list[MediaType], limit: int = 20
204 ) -> SearchResults:
205 """Search the sonic library."""
206 artists = limit if MediaType.ARTIST in media_types else 0
207 albums = limit if MediaType.ALBUM in media_types else 0
208 songs = limit if MediaType.TRACK in media_types else 0
209 if not (artists or albums or songs):
210 return SearchResults()
211 answer = await self.conn.search3(
212 query=search_query,
213 artist_count=artists,
214 artist_offset=0,
215 album_count=albums,
216 album_offset=0,
217 song_count=songs,
218 song_offset=0,
219 )
220
221 if answer.artist:
222 ar = [parse_artist(self.instance_id, entry) for entry in answer.artist]
223 else:
224 ar = []
225
226 if answer.album:
227 al = [parse_album(self.logger, self.instance_id, entry) for entry in answer.album]
228 else:
229 al = []
230
231 if answer.song:
232 tr = []
233 for entry in answer.song:
234 self._set_loudness(entry)
235 tr.append(parse_track(self.logger, self.instance_id, entry))
236 else:
237 tr = []
238
239 return SearchResults(artists=ar, albums=al, tracks=tr)
240
241 async def set_favorite(self, prov_item_id: str, media_type: MediaType, favorite: bool) -> None:
242 """Set or clear favorite on the server."""
243 # The subsonic spec does not support favorite-ing anything but artists, albums, and tracks
244 if media_type not in (MediaType.ARTIST, MediaType.ALBUM, MediaType.TRACK):
245 return
246
247 track_ids: list[str] = []
248 album_ids: list[str] = []
249 artist_ids: list[str] = []
250
251 if media_type == MediaType.ARTIST:
252 artist_ids.append(prov_item_id)
253 elif media_type == MediaType.ALBUM:
254 album_ids.append(prov_item_id)
255 elif media_type == MediaType.TRACK:
256 track_ids.append(prov_item_id)
257
258 if favorite:
259 await self.conn.star(sids=track_ids, album_ids=album_ids, artist_ids=artist_ids)
260 else:
261 await self.conn.unstar(sids=track_ids, album_ids=album_ids, artist_ids=artist_ids)
262
263 async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
264 """Provide a generator for reading all artists."""
265 artists = await self.conn.get_artists()
266
267 if not artists.index:
268 return
269
270 for index in artists.index:
271 if not index.artist:
272 continue
273
274 for artist in index.artist:
275 yield parse_artist(self.instance_id, artist)
276
277 async def get_library_albums(self) -> AsyncGenerator[Album, None]:
278 """
279 Provide a generator for reading all artists.
280
281 Note the pagination, the open subsonic docs say that this method is limited to
282 returning 500 items per invocation.
283 """
284 offset = 0
285 size = self._pagination_size
286 albums = await self.conn.get_album_list2(
287 ltype="alphabeticalByArtist",
288 size=size,
289 offset=offset,
290 )
291 while albums:
292 for album in albums:
293 yield parse_album(self.logger, self.instance_id, album)
294 offset += size
295 albums = await self.conn.get_album_list2(
296 ltype="alphabeticalByArtist",
297 size=size,
298 offset=offset,
299 )
300
301 async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
302 """Provide a generator for library playlists."""
303 results = await self.conn.get_playlists()
304 for entry in results:
305 yield parse_playlist(self.instance_id, entry)
306
307 async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
308 """
309 Provide a generator for library tracks.
310
311 Note the lack of item count on this method.
312 """
313 query = ""
314 offset = 0
315 count = self._pagination_size
316 try:
317 results = await self.conn.search3(
318 query=query,
319 artist_count=0,
320 album_count=0,
321 song_offset=offset,
322 song_count=count,
323 )
324 except ParameterError:
325 # Older Navidrome does not accept an empty string and requires the empty quotes
326 query = '""'
327 results = await self.conn.search3(
328 query=query,
329 artist_count=0,
330 album_count=0,
331 song_offset=offset,
332 song_count=count,
333 )
334 while results.song:
335 album: Album | None = None
336 for entry in results.song:
337 aid = entry.album_id if entry.album_id else entry.parent
338 if aid is not None and (album is None or album.item_id != aid):
339 album = await self.get_album(prov_album_id=aid)
340 self._set_loudness(entry)
341 yield parse_track(self.logger, self.instance_id, entry, album=album)
342 offset += count
343 results = await self.conn.search3(
344 query=query,
345 artist_count=0,
346 album_count=0,
347 song_offset=offset,
348 song_count=count,
349 )
350
351 async def get_album(self, prov_album_id: str) -> Album:
352 """Return the requested Album."""
353 try:
354 sonic_album: SonicAlbum = await self.conn.get_album(prov_album_id)
355 sonic_info = await self.conn.get_album_info2(aid=prov_album_id)
356 except (ParameterError, DataNotFoundError) as e:
357 msg = f"Album {prov_album_id} not found"
358 raise MediaNotFoundError(msg) from e
359
360 return parse_album(self.logger, self.instance_id, sonic_album, sonic_info)
361
362 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
363 """Return a list of tracks on the specified Album."""
364 try:
365 sonic_album: SonicAlbum = await self.conn.get_album(prov_album_id)
366 except (ParameterError, DataNotFoundError) as e:
367 msg = f"Album {prov_album_id} not found"
368 raise MediaNotFoundError(msg) from e
369 tracks = []
370 if sonic_album.song:
371 for sonic_song in sonic_album.song:
372 self._set_loudness(sonic_song)
373 tracks.append(parse_track(self.logger, self.instance_id, sonic_song))
374 return tracks
375
376 async def get_artist(self, prov_artist_id: str) -> Artist:
377 """Return the requested Artist."""
378 if prov_artist_id == UNKNOWN_ARTIST_ID:
379 return Artist(
380 item_id=UNKNOWN_ARTIST_ID,
381 name=UNKNOWN_ARTIST,
382 provider=self.instance_id,
383 provider_mappings={
384 ProviderMapping(
385 item_id=UNKNOWN_ARTIST_ID,
386 provider_domain=self.domain,
387 provider_instance=self.instance_id,
388 )
389 },
390 )
391 if prov_artist_id.startswith(NAVI_VARIOUS_PREFIX):
392 # Special case for handling track artists on various artists album for Navidrome.
393 return Artist(
394 item_id=prov_artist_id,
395 name=prov_artist_id.removeprefix(NAVI_VARIOUS_PREFIX),
396 provider=self.instance_id,
397 provider_mappings={
398 ProviderMapping(
399 item_id=prov_artist_id,
400 provider_domain=self.domain,
401 provider_instance=self.instance_id,
402 )
403 },
404 )
405
406 try:
407 sonic_artist: SonicArtist = await self.conn.get_artist(artist_id=prov_artist_id)
408 sonic_info = await self.conn.get_artist_info2(aid=prov_artist_id)
409 except (ParameterError, DataNotFoundError) as e:
410 msg = f"Artist {prov_artist_id} not found"
411 raise MediaNotFoundError(msg) from e
412 return parse_artist(self.instance_id, sonic_artist, sonic_info)
413
414 async def get_track(self, prov_track_id: str) -> Track:
415 """Return the specified track."""
416 try:
417 sonic_song: SonicItem = await self.conn.get_song(prov_track_id)
418 except (ParameterError, DataNotFoundError) as e:
419 msg = f"Item {prov_track_id} not found"
420 raise MediaNotFoundError(msg) from e
421 aid = sonic_song.album_id if sonic_song.album_id else sonic_song.parent
422 album: Album | None = None
423 if not aid:
424 self.logger.warning("Unable to find album id for track %s", sonic_song.id)
425 else:
426 album = await self.get_album(prov_album_id=aid)
427 self._set_loudness(sonic_song)
428 return parse_track(self.logger, self.instance_id, sonic_song, album=album)
429
430 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
431 """Return a list of all Albums by specified Artist."""
432 if prov_artist_id == UNKNOWN_ARTIST_ID or prov_artist_id.startswith(NAVI_VARIOUS_PREFIX):
433 return []
434
435 try:
436 sonic_artist: SonicArtist = await self.conn.get_artist(prov_artist_id)
437 except (ParameterError, DataNotFoundError) as e:
438 msg = f"Album {prov_artist_id} not found"
439 raise MediaNotFoundError(msg) from e
440 albums = []
441 if sonic_artist.album:
442 for entry in sonic_artist.album:
443 albums.append(parse_album(self.logger, self.instance_id, entry))
444 return albums
445
446 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
447 """Return the specified Playlist."""
448 try:
449 sonic_playlist: SonicPlaylist = await self.conn.get_playlist(prov_playlist_id)
450 except (ParameterError, DataNotFoundError) as e:
451 msg = f"Playlist {prov_playlist_id} not found"
452 raise MediaNotFoundError(msg) from e
453 return parse_playlist(self.instance_id, sonic_playlist)
454
455 async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
456 """Get (full) podcast episode details by id."""
457 podcast_id, _ = prov_episode_id.split(EP_CHAN_SEP)
458 async for episode in self.get_podcast_episodes(podcast_id):
459 if episode.item_id == prov_episode_id:
460 return episode
461 msg = f"Episode {prov_episode_id} not found"
462 raise MediaNotFoundError(msg)
463
464 async def get_podcast_episodes(
465 self,
466 prov_podcast_id: str,
467 ) -> AsyncGenerator[PodcastEpisode, None]:
468 """Get all Episodes for given podcast id."""
469 if not self._enable_podcasts:
470 return
471 channels = await self.conn.get_podcasts(inc_episodes=True, pid=prov_podcast_id)
472 channel = channels[0]
473 if not channel.episode:
474 return
475
476 for episode in channel.episode:
477 self._set_loudness(episode)
478 yield parse_epsiode(self.instance_id, episode, channel)
479
480 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
481 """Get full Podcast details by id."""
482 if not self._enable_podcasts:
483 msg = "Podcasts are currently disabled in the provider configuration"
484 raise ActionUnavailable(msg)
485
486 channels = await self.conn.get_podcasts(inc_episodes=True, pid=prov_podcast_id)
487
488 return parse_podcast(self.instance_id, channels[0])
489
490 async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]:
491 """Retrieve library/subscribed podcasts from the provider."""
492 if self._enable_podcasts:
493 channels = await self.conn.get_podcasts(inc_episodes=True)
494
495 for channel in channels:
496 yield parse_podcast(self.instance_id, channel)
497
498 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
499 """Get playlist tracks."""
500 result: list[Track] = []
501 if page > 0:
502 # paging not supported, we always return the whole list at once
503 return result
504 try:
505 sonic_playlist: SonicPlaylist = await self.conn.get_playlist(prov_playlist_id)
506 except (ParameterError, DataNotFoundError) as e:
507 msg = f"Playlist {prov_playlist_id} not found"
508 raise MediaNotFoundError(msg) from e
509
510 if not sonic_playlist.entry:
511 return result
512
513 album: Album | None = None
514 for index, sonic_song in enumerate(sonic_playlist.entry, 1):
515 aid = sonic_song.album_id if sonic_song.album_id else sonic_song.parent
516 if not aid:
517 self.logger.warning("Unable to find album for track %s", sonic_song.id)
518 if aid is not None and (not album or album.item_id != aid):
519 album = await self.get_album(prov_album_id=aid)
520 self._set_loudness(sonic_song)
521 track = parse_track(self.logger, self.instance_id, sonic_song, album=album)
522 track.position = index
523 result.append(track)
524 return result
525
526 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
527 """Get the top listed tracks for a specified artist."""
528 # We have seen top tracks requested for the UNKNOWN_ARTIST ID, protect against that
529 if prov_artist_id == UNKNOWN_ARTIST_ID or prov_artist_id.startswith(NAVI_VARIOUS_PREFIX):
530 return []
531
532 try:
533 sonic_artist: SonicArtist = await self.conn.get_artist(prov_artist_id)
534 except DataNotFoundError as e:
535 msg = f"Artist {prov_artist_id} not found"
536 raise MediaNotFoundError(msg) from e
537 songs: list[SonicItem] = await self.conn.get_top_songs(sonic_artist.name)
538 tracks = []
539 for entry in songs:
540 self._set_loudness(entry)
541 tracks.append(parse_track(self.logger, self.instance_id, entry))
542 return tracks
543
544 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
545 """Get tracks similar to selected track."""
546 try:
547 songs: list[SonicItem] = await self.conn.get_similar_songs(
548 iid=prov_track_id, count=limit
549 )
550 except DataNotFoundError as e:
551 # Subsonic returns an error here instead of an empty list, I don't think this
552 # should be an exception but there we are. Return an empty list because this
553 # exception means we didn't find anything similar.
554 self.logger.info(e)
555 return []
556 tracks = []
557 for entry in songs:
558 self._set_loudness(entry)
559 tracks.append(parse_track(self.logger, self.instance_id, entry))
560 return tracks
561
562 async def create_playlist(self, name: str) -> Playlist:
563 """Create a new empty playlist on the server."""
564 if not await self.conn.create_playlist(name=name):
565 raise ProviderPermissionDenied(
566 "Please ensure you have permission to create playlists on your server"
567 )
568 pls: list[SonicPlaylist] = await self.conn.get_playlists()
569 for pl in pls:
570 if pl.name == name:
571 return parse_playlist(self.instance_id, pl)
572 raise MediaNotFoundError(f"Failed to create podcast with name '{name}'")
573
574 async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
575 """Append the listed tracks to the selected playlist.
576
577 Note that the configured user must own the playlist to edit this way.
578 """
579 try:
580 await self.conn.update_playlist(
581 lid=prov_playlist_id,
582 song_ids_to_add=prov_track_ids,
583 )
584 except SonicError as ex:
585 msg = f"Failed to add songs to {prov_playlist_id}, check your permissions."
586 raise ProviderPermissionDenied(msg) from ex
587
588 async def remove_playlist_tracks(
589 self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
590 ) -> None:
591 """Remove selected positions from the playlist."""
592 idx_to_remove = [pos - 1 for pos in positions_to_remove]
593 try:
594 await self.conn.update_playlist(
595 lid=prov_playlist_id,
596 song_indices_to_remove=idx_to_remove,
597 )
598 except SonicError as ex:
599 msg = f"Failed to remove songs from {prov_playlist_id}, check your permissions."
600 raise ProviderPermissionDenied(msg) from ex
601
602 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
603 """Get the details needed to process a specified track."""
604 item: SonicItem | SonicEpisode
605 if media_type == MediaType.TRACK:
606 try:
607 item = await self.conn.get_song(item_id)
608 except (ParameterError, DataNotFoundError) as e:
609 msg = f"Item {item_id} not found"
610 raise MediaNotFoundError(msg) from e
611
612 mime_type = item.transcoded_content_type or item.content_type
613
614 self.logger.debug(
615 "Fetching stream details for id %s '%s' with format '%s'",
616 item.id,
617 item.title,
618 mime_type,
619 )
620
621 elif media_type == MediaType.PODCAST_EPISODE:
622 item = await self._get_podcast_episode(item_id)
623
624 mime_type = item.transcoded_content_type or item.content_type
625
626 self.logger.debug(
627 "Fetching stream details for podcast episode '%s' with format '%s'",
628 item.id,
629 item.content_type,
630 )
631 else:
632 msg = f"Unsupported media type encountered '{media_type}'"
633 raise UnsupportedFeaturedException(msg)
634
635 if mime_type and mime_type.endswith("mp4"):
636 self.logger.warning(
637 "Due to the streaming method used by the subsonic API, M4A files "
638 "may fail. See provider documentation for more information."
639 )
640
641 # We believe that reporting the container type here is causing playback problems and ffmpeg
642 # should be capable of guessing the correct container type for any media supported by
643 # OpenSubsonic servers. Better to let ffmpeg figure things out than tell it something
644 # confusing. We still go through the effort of figuring out what the server thinks the
645 # container is to warn about M4A files.
646 mime_type = "?"
647
648 return StreamDetails(
649 item_id=item.id,
650 provider=self.instance_id,
651 allow_seek=True,
652 can_seek=self._seek_support,
653 media_type=media_type,
654 audio_format=AudioFormat(
655 content_type=ContentType.try_parse(mime_type),
656 sample_rate=item.sampling_rate if item.sampling_rate else 44100,
657 bit_depth=item.bit_depth if item.bit_depth else 16,
658 channels=item.channel_count if item.channel_count else 2,
659 ),
660 stream_type=StreamType.CUSTOM,
661 duration=item.duration if item.duration else 0,
662 )
663
664 async def on_played(
665 self,
666 media_type: MediaType,
667 prov_item_id: str,
668 fully_played: bool,
669 position: int,
670 media_item: MediaItemType,
671 is_playing: bool = False,
672 ) -> None:
673 """
674 Handle callback when a (playable) media item has been played.
675
676 This is called by the Queue controller when;
677 - a track has been fully played
678 - a track has been stopped (or skipped) after being played
679 - every 30s when a track is playing
680
681 Fully played is True when the track has been played to the end.
682
683 Position is the last known position of the track in seconds, to sync resume state.
684 When fully_played is set to false and position is 0,
685 the user marked the item as unplayed in the UI.
686
687 is_playing is True when the track is currently playing.
688
689 media_item is the full media item details of the played/playing track.
690 """
691 if media_type != MediaType.PODCAST_EPISODE:
692 # We don't handle audio books in this provider so this is the only resummable media
693 # type we should see.
694 return
695
696 _, ep_id = prov_item_id.split(EP_CHAN_SEP)
697
698 if fully_played:
699 # We completed the episode and should delete our bookmark
700 try:
701 await self.conn.delete_bookmark(mid=ep_id)
702 except DataNotFoundError:
703 # We probably raced with something else deleting this bookmark, not really a problem
704 self.logger.info("Bookmark for item '%s' has already been deleted.", ep_id)
705 return
706
707 # Otherwise, create a new bookmark for this item or update the existing one
708 # MA provides a position in seconds but expects it back in milliseconds
709 await self.conn.create_bookmark(
710 mid=ep_id,
711 position=position * 1000,
712 comment="Music Assistant Bookmark",
713 )
714
715 async def get_resume_position(self, item_id: str, media_type: MediaType) -> tuple[bool, int]:
716 """
717 Get progress (resume point) details for the given Audiobook or Podcast episode.
718
719 This is a separate call from the regular get_item call to ensure the resume position
720 is always up-to-date and because a lot providers have this info present on a dedicated
721 endpoint.
722
723 Will be called right before playback starts to ensure the resume position is correct.
724
725 Returns a boolean with the fully_played status
726 and an integer with the resume position in ms.
727 """
728 if media_type != MediaType.PODCAST_EPISODE:
729 raise NotImplementedError("AudioBooks are not supported by the Open Subsonic provider")
730
731 _, ep_id = item_id.split(EP_CHAN_SEP)
732
733 bookmarks: list[SonicBookmark] = await self.conn.get_bookmarks()
734
735 for mark in bookmarks:
736 if mark.entry.id == ep_id:
737 return (False, mark.position)
738 # If we get here, there is no bookmark
739 return (False, 0)
740
741 async def get_audio_stream(
742 self, streamdetails: StreamDetails, seek_position: int = 0
743 ) -> AsyncGenerator[bytes, None]:
744 """Provide a generator for the stream data."""
745 # ignore seek position if the server does not support it
746 # in that case we let the core handle seeking
747 if not self._seek_support:
748 seek_position = 0
749
750 self.logger.debug("Streaming %s", streamdetails.item_id)
751 try:
752 resp = await self.conn.stream(
753 streamdetails.item_id, time_offset=seek_position, estimate_length=True
754 )
755 except DataNotFoundError as err:
756 msg = f"Item '{streamdetails.item_id}' not found"
757 raise MediaNotFoundError(msg) from err
758 self.logger.debug("starting stream of item '%s'", streamdetails.item_id)
759 async with resp:
760 async for chunk in resp.content.iter_chunked(40960):
761 yield bytes(chunk)
762
763 self.logger.debug("Done streaming %s", streamdetails.item_id)
764
765 async def _get_podcast_channel_async(self, chan_id: str) -> PodcastChannel | None:
766 if cache := await self.mass.cache.get(
767 key=chan_id,
768 provider=self.instance_id,
769 category=CACHE_CATEGORY_PODCAST_CHANNEL,
770 ):
771 return cache
772 if channels := await self.conn.get_podcasts(inc_episodes=True, pid=chan_id):
773 channel = channels[0]
774 await self.mass.cache.set(
775 key=chan_id,
776 data=channel,
777 provider=self.instance_id,
778 expiration=600,
779 category=CACHE_CATEGORY_PODCAST_CHANNEL,
780 )
781 return channel
782 return None
783
784 async def _podcast_recommendations(self) -> RecommendationFolder:
785 podcasts: RecommendationFolder = RecommendationFolder(
786 item_id="subsonic_newest_podcasts",
787 provider=self.domain,
788 name="Newest Podcast Episodes",
789 )
790 sonic_episodes = await self.conn.get_newest_podcasts(count=self._reco_limit)
791 for ep in sonic_episodes:
792 if channel_info := await self._get_podcast_channel_async(ep.channel_id):
793 self._set_loudness(ep)
794 podcasts.items.append(parse_epsiode(self.instance_id, ep, channel_info))
795 return podcasts
796
797 async def _favorites_recommendation(self) -> RecommendationFolder:
798 faves: RecommendationFolder = RecommendationFolder(
799 item_id="subsonic_starred_albums", provider=self.domain, name="Starred Items"
800 )
801 starred = await self.conn.get_starred2()
802 if starred.album:
803 for sonic_album in starred.album[: self._reco_limit]:
804 faves.items.append(parse_album(self.logger, self.instance_id, sonic_album))
805 if starred.artist:
806 for sonic_artist in starred.artist[: self._reco_limit]:
807 faves.items.append(parse_artist(self.instance_id, sonic_artist))
808 if starred.song:
809 for sonic_song in starred.song[: self._reco_limit]:
810 self._set_loudness(sonic_song)
811 faves.items.append(parse_track(self.logger, self.instance_id, sonic_song))
812 return faves
813
814 async def _new_recommendations(self) -> RecommendationFolder:
815 new_stuff: RecommendationFolder = RecommendationFolder(
816 item_id="subsonic_new_albums", provider=self.domain, name="New Albums"
817 )
818 new_albums = await self.conn.get_album_list2(ltype="newest", size=self._reco_limit)
819 for sonic_album in new_albums:
820 new_stuff.items.append(parse_album(self.logger, self.instance_id, sonic_album))
821 return new_stuff
822
823 async def _played_recommendations(self) -> RecommendationFolder:
824 recent: RecommendationFolder = RecommendationFolder(
825 item_id="subsonic_most_played", provider=self.domain, name="Most Played Albums"
826 )
827 albums = await self.conn.get_album_list2(ltype="frequent", size=self._reco_limit)
828 for sonic_album in albums:
829 recent.items.append(parse_album(self.logger, self.instance_id, sonic_album))
830 return recent
831
832 async def recommendations(self) -> list[RecommendationFolder]:
833 """Provide recommendations.
834
835 These can provide favorited items, recently added albums, newest podcast episodes,
836 and most played albums. What is included is configured with the provider.
837 """
838 recos: list[RecommendationFolder] = []
839
840 podcasts = None
841 faves = None
842 new_stuff = None
843 played = None
844 async with TaskGroup() as grp:
845 if self._enable_podcasts:
846 podcasts = grp.create_task(self._podcast_recommendations())
847 if self._show_faves:
848 faves = grp.create_task(self._favorites_recommendation())
849 if self._show_new:
850 new_stuff = grp.create_task(self._new_recommendations())
851 if self._show_played:
852 played = grp.create_task(self._played_recommendations())
853
854 if podcasts:
855 recos.append(podcasts.result())
856 if faves:
857 recos.append(faves.result())
858 if new_stuff:
859 recos.append(new_stuff.result())
860 if played:
861 recos.append(played.result())
862
863 return recos
864