/
/
1"""The provider class for Open Subsonic."""
2
3from __future__ import annotations
4
5from datetime import datetime
6from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
7
8from libopensonic import AsyncConnection as SonicConnection
9from libopensonic import Extensions as OpenSubsonicExtensions
10from libopensonic.errors import (
11 AuthError,
12 CredentialError,
13 DataNotFoundError,
14 ParameterError,
15 SonicError,
16)
17from libopensonic.media import PodcastChannel
18from music_assistant_models.config_entries import ConfigEntry
19from music_assistant_models.enums import ConfigEntryType, ContentType, MediaType, StreamType
20from music_assistant_models.errors import (
21 ActionUnavailable,
22 LoginFailed,
23 MediaNotFoundError,
24 ProviderPermissionDenied,
25 UnsupportedFeaturedException,
26)
27from music_assistant_models.media_items import (
28 Album,
29 Artist,
30 AudioFormat,
31 BrowseFolder,
32 ItemMapping,
33 MediaItemType,
34 Playlist,
35 Podcast,
36 PodcastEpisode,
37 ProviderMapping,
38 Radio,
39 RecommendationFolder,
40 SearchResults,
41 Track,
42 UniqueList,
43)
44from music_assistant_models.streamdetails import StreamDetails
45
46from music_assistant.constants import (
47 CONF_PASSWORD,
48 CONF_PATH,
49 CONF_PORT,
50 CONF_USERNAME,
51 UNKNOWN_ARTIST,
52)
53from music_assistant.controllers.cache import use_cache
54from music_assistant.models.music_provider import MusicProvider
55
56from .parsers import (
57 EP_CHAN_SEP,
58 NAVI_VARIOUS_PREFIX,
59 UNKNOWN_ARTIST_ID,
60 parse_album,
61 parse_artist,
62 parse_epsiode,
63 parse_playlist,
64 parse_podcast,
65 parse_radio,
66 parse_structured_lyrics,
67 parse_track,
68)
69
70if TYPE_CHECKING:
71 from collections.abc import AsyncGenerator
72
73 from libopensonic.media import AlbumID3 as SonicAlbum
74 from libopensonic.media import ArtistWithAlbumsID3 as SonicArtist
75 from libopensonic.media import Bookmark as SonicBookmark
76 from libopensonic.media import Child as SonicItem
77 from libopensonic.media import InternetRadioStation as SonicRadio
78 from libopensonic.media import Lyrics as SonicLyrics
79 from libopensonic.media import OpenSubsonicExtension, StructuredLyrics
80 from libopensonic.media import Playlist as SonicPlaylist
81 from libopensonic.media import PodcastEpisode as SonicEpisode
82
83CONF_BASE_URL = "baseURL"
84CONF_API_KEY = "api_key"
85CONF_ENABLE_PODCASTS = "enable_podcasts"
86CONF_ENABLE_RADIO_STATIONS = "enable_radio_stations"
87CONF_ENABLE_LEGACY_AUTH = "enable_legacy_auth"
88CONF_RECO_FAVES = "recommend_favorites"
89CONF_NEW_ALBUMS = "recommend_new"
90CONF_PLAYED_ALBUMS = "recommend_played"
91CONF_RECO_SIZE = "recommendation_count"
92CONF_PAGE_SIZE = "pagination_size"
93CONF_RAW_FILE = "request_raw_file"
94
95CACHE_CATEGORY_PODCAST_CHANNEL = 1
96CACHE_CATEGORY_PODCAST_EPISODES = 2
97
98Param = ParamSpec("Param")
99RetType = TypeVar("RetType")
100
101
102class OpenSonicProvider(MusicProvider):
103 """Provider for Open Subsonic servers."""
104
105 conn: SonicConnection
106 _enable_podcasts: bool = True
107 _enable_radio_stations: bool = True
108 _show_faves: bool = True
109 _show_new: bool = True
110 _show_played: bool = True
111 _reco_limit: int = 10
112 _pagination_size: int = 200
113 _id_lyrics: bool = False
114 _direct_podcast_episode: bool = False
115 _raw_file: bool = True
116
117 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
118 """Return Config entries to setup this provider."""
119 return (
120 ConfigEntry(
121 key=CONF_ENABLE_PODCASTS,
122 type=ConfigEntryType.BOOLEAN,
123 required=True,
124 default_value=True,
125 ),
126 ConfigEntry(
127 key=CONF_ENABLE_RADIO_STATIONS,
128 type=ConfigEntryType.BOOLEAN,
129 required=True,
130 default_value=True,
131 ),
132 ConfigEntry(
133 key=CONF_ENABLE_LEGACY_AUTH,
134 type=ConfigEntryType.BOOLEAN,
135 required=True,
136 default_value=False,
137 ),
138 ConfigEntry(
139 key=CONF_RECO_FAVES,
140 type=ConfigEntryType.BOOLEAN,
141 required=True,
142 default_value=True,
143 ),
144 ConfigEntry(
145 key=CONF_NEW_ALBUMS,
146 type=ConfigEntryType.BOOLEAN,
147 required=True,
148 default_value=True,
149 ),
150 ConfigEntry(
151 key=CONF_PLAYED_ALBUMS,
152 type=ConfigEntryType.BOOLEAN,
153 required=True,
154 default_value=True,
155 ),
156 ConfigEntry(
157 key=CONF_RECO_SIZE,
158 type=ConfigEntryType.INTEGER,
159 required=True,
160 default_value=10,
161 ),
162 ConfigEntry(
163 key=CONF_RAW_FILE,
164 type=ConfigEntryType.BOOLEAN,
165 required=False,
166 default_value=True,
167 ),
168 ConfigEntry(
169 key=CONF_PAGE_SIZE,
170 type=ConfigEntryType.INTEGER,
171 required=True,
172 default_value=200,
173 advanced=True,
174 ),
175 )
176
177 async def handle_async_init(self) -> None:
178 """Set up the music provider and test the connection."""
179 port = self.get_setup_value(CONF_PORT)
180 port = int(str(port)) if port is not None else 443
181 path = self.get_setup_value(CONF_PATH)
182
183 if path is None:
184 path = ""
185
186 api_key = self.get_setup_value(CONF_API_KEY)
187 username = self.get_setup_value(CONF_USERNAME)
188 password = self.get_setup_value(CONF_PASSWORD)
189
190 if api_key:
191 self.conn = SonicConnection(
192 str(self.get_setup_value(CONF_BASE_URL)),
193 api_key=str(api_key),
194 port=port,
195 server_path=str(path),
196 use_get=True,
197 app_name="Music Assistant",
198 )
199 elif username and password:
200 self.conn = SonicConnection(
201 str(self.get_setup_value(CONF_BASE_URL)),
202 username=str(username),
203 password=str(password),
204 legacy_auth=bool(self.config.get_value(CONF_ENABLE_LEGACY_AUTH)),
205 port=port,
206 server_path=str(path),
207 use_get=True,
208 app_name="Music Assistant",
209 )
210 else:
211 msg = f"No credentials for {self.get_setup_value(CONF_BASE_URL)}, provide an API key or username and password."
212 raise LoginFailed(
213 msg,
214 translation_key="connect_failed",
215 translation_owner=self.translation_owner,
216 translation_args=[self.get_setup_value(CONF_BASE_URL)],
217 )
218
219 try:
220 success = await self.conn.ping()
221 if not success:
222 raise CredentialError
223 except (AuthError, CredentialError) as e:
224 msg = (
225 f"Failed to connect to {self.get_setup_value(CONF_BASE_URL)}, check your settings."
226 )
227 raise LoginFailed(
228 msg,
229 translation_key="connect_failed",
230 translation_owner=self.translation_owner,
231 translation_args=[self.get_setup_value(CONF_BASE_URL)],
232 ) from e
233
234 try:
235 extensions: list[OpenSubsonicExtension] = await self.conn.get_open_subsonic_extensions()
236 for entry in extensions:
237 if entry.name == OpenSubsonicExtensions.SONG_LYRICS:
238 self._id_lyrics = True
239 elif entry.name == OpenSubsonicExtensions.GET_PODCAST_EPISODE:
240 self._direct_podcast_episode = True
241 except OSError:
242 self.logger.info("Failed to query server for OpenSubsonic extensions")
243
244 self._enable_podcasts = bool(self.config.get_value(CONF_ENABLE_PODCASTS))
245 self._enable_radio_stations = bool(self.config.get_value(CONF_ENABLE_RADIO_STATIONS))
246 self._show_faves = bool(self.config.get_value(CONF_RECO_FAVES))
247 self._show_new = bool(self.config.get_value(CONF_NEW_ALBUMS))
248 self._show_played = bool(self.config.get_value(CONF_PLAYED_ALBUMS))
249 self._reco_limit = int(str(self.config.get_value(CONF_RECO_SIZE)))
250 self._pagination_size = int(str(self.config.get_value(CONF_PAGE_SIZE)))
251 self._pagination_size = min(self._pagination_size, 500)
252 self._raw_file = bool(self.config.get_value(CONF_RAW_FILE))
253
254 async def unload(self, is_removed: bool = False) -> None:
255 """Unload the provider."""
256 await super().unload(is_removed)
257 await self.conn.cleanup()
258
259 @property
260 def is_streaming_provider(self) -> bool:
261 """
262 Return True if the provider is a streaming provider.
263
264 This literally means that the catalog is not the same as the library contents.
265 For local based providers (files, plex), the catalog is the same as the library content.
266 It also means that data is if this provider is NOT a streaming provider,
267 data cross instances is unique, the catalog and library differs per instance.
268
269 Setting this to True will only query one instance of the provider for search and lookups.
270 Setting this to False will query all instances of this provider for search and lookups.
271 """
272 return False
273
274 async def get_recommendations(self) -> list[RecommendationFolder]:
275 """
276 Get this provider's available recommendation rows, without items.
277
278 These can be favorited items, recently added albums, newest podcast episodes,
279 and most played albums. What is included is configured with the provider.
280 """
281 recos: list[RecommendationFolder] = []
282 if self._enable_podcasts:
283 recos.append(
284 RecommendationFolder(
285 item_id="subsonic_newest_podcasts",
286 provider=self.instance_id,
287 name="Newest Podcast Episodes",
288 translation_key="episodes_recently_added",
289 )
290 )
291 if self._show_faves:
292 recos.append(
293 RecommendationFolder(
294 item_id="subsonic_starred_albums",
295 provider=self.instance_id,
296 name="Starred Items",
297 translation_key="starred_items",
298 )
299 )
300 if self._show_new:
301 recos.append(
302 RecommendationFolder(
303 item_id="subsonic_new_albums",
304 provider=self.instance_id,
305 name="New Albums",
306 translation_key="recently_added_albums",
307 )
308 )
309 if self._show_played:
310 recos.append(
311 RecommendationFolder(
312 item_id="subsonic_most_played",
313 provider=self.instance_id,
314 name="Most Played Albums",
315 translation_key="most_played_albums",
316 )
317 )
318 return recos
319
320 async def get_recommendation_items(
321 self, item_id: str
322 ) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
323 """
324 Get the items for a single recommendation row.
325
326 :param item_id: The item_id of the row, as returned by get_recommendations.
327 """
328 folder: RecommendationFolder | None = None
329 if item_id == "subsonic_newest_podcasts" and self._enable_podcasts:
330 folder = await self._podcast_recommendations()
331 elif item_id == "subsonic_starred_albums" and self._show_faves:
332 folder = await self._favorites_recommendation()
333 elif item_id == "subsonic_new_albums" and self._show_new:
334 folder = await self._new_recommendations()
335 elif item_id == "subsonic_most_played" and self._show_played:
336 folder = await self._played_recommendations()
337 if folder is None:
338 return UniqueList()
339 return folder.items
340
341 async def resolve_image(self, path: str) -> bytes | Any:
342 """Return the image."""
343 self.logger.debug("Requesting cover art for '%s'", path)
344
345 try:
346 art = await self.conn.get_cover_art(path)
347 return await art.content.read()
348 except DataNotFoundError:
349 self.logger.warning("Unable to locate a cover image for %s", path)
350 return None
351
352 @use_cache(3600 * 3) # cache for 3 hours
353 async def search(
354 self, search_query: str, media_types: list[MediaType], limit: int = 20
355 ) -> SearchResults:
356 """Search the sonic library."""
357 artists = limit if MediaType.ARTIST in media_types else 0
358 albums = limit if MediaType.ALBUM in media_types else 0
359 songs = limit if MediaType.TRACK in media_types else 0
360 if not (artists or albums or songs):
361 return SearchResults()
362 answer = await self.conn.search3(
363 query=search_query,
364 artist_count=artists,
365 artist_offset=0,
366 album_count=albums,
367 album_offset=0,
368 song_count=songs,
369 song_offset=0,
370 )
371
372 if answer.artist:
373 ar = [
374 parse_artist(self.instance_id, entry, logger=self.logger) for entry in answer.artist
375 ]
376 else:
377 ar = []
378
379 if answer.album:
380 al = [parse_album(self.logger, self.instance_id, entry) for entry in answer.album]
381 else:
382 al = []
383
384 if answer.song:
385 tr = []
386 for entry in answer.song:
387 self._set_loudness(entry)
388 tr.append(parse_track(self.logger, self.instance_id, entry))
389 else:
390 tr = []
391
392 return SearchResults(artists=ar, albums=al, tracks=tr)
393
394 async def set_favorite(self, prov_item_id: str, media_type: MediaType, favorite: bool) -> None:
395 """Set or clear favorite on the server."""
396 # The subsonic spec does not support favorite-ing anything but artists, albums, and tracks
397 if media_type not in (MediaType.ARTIST, MediaType.ALBUM, MediaType.TRACK):
398 return
399
400 track_ids: list[str] = []
401 album_ids: list[str] = []
402 artist_ids: list[str] = []
403
404 if media_type == MediaType.ARTIST:
405 artist_ids.append(prov_item_id)
406 elif media_type == MediaType.ALBUM:
407 album_ids.append(prov_item_id)
408 elif media_type == MediaType.TRACK:
409 track_ids.append(prov_item_id)
410
411 if favorite:
412 await self.conn.star(sids=track_ids, album_ids=album_ids, artist_ids=artist_ids)
413 else:
414 await self.conn.unstar(sids=track_ids, album_ids=album_ids, artist_ids=artist_ids)
415
416 async def get_library_artists(self) -> AsyncGenerator[Artist]:
417 """Provide a generator for reading all artists."""
418 artists = await self.conn.get_artists()
419
420 if not artists.index:
421 return
422
423 for index in artists.index:
424 if not index.artist:
425 continue
426
427 for artist in index.artist:
428 yield parse_artist(self.instance_id, artist, logger=self.logger)
429
430 async def get_library_albums(self) -> AsyncGenerator[Album]:
431 """
432 Provide a generator for reading all artists.
433
434 Note the pagination, the open subsonic docs say that this method is limited to
435 returning 500 items per invocation.
436 """
437 offset = 0
438 size = self._pagination_size
439 albums = await self.conn.get_album_list2(
440 ltype="alphabeticalByArtist",
441 size=size,
442 offset=offset,
443 )
444 while albums:
445 for album in albums:
446 yield parse_album(self.logger, self.instance_id, album)
447 offset += size
448 albums = await self.conn.get_album_list2(
449 ltype="alphabeticalByArtist",
450 size=size,
451 offset=offset,
452 )
453
454 async def get_library_playlists(self) -> AsyncGenerator[Playlist]:
455 """Provide a generator for library playlists."""
456 results = await self.conn.get_playlists()
457 for entry in results:
458 yield parse_playlist(self.instance_id, entry)
459
460 async def get_library_radios(self) -> AsyncGenerator[Radio]:
461 """Provide a generator for library radio stations."""
462 if not self._enable_radio_stations:
463 return
464 stations: list[SonicRadio] = await self.conn.get_internet_radio_stations()
465 for entry in stations:
466 yield parse_radio(self.instance_id, entry)
467
468 async def get_radio(self, prov_radio_id: str) -> Radio:
469 """Return the requested radio station."""
470 async for station in self.get_library_radios():
471 if station.item_id == prov_radio_id:
472 return station
473 msg = f"Radio {prov_radio_id} not found"
474 raise MediaNotFoundError(msg)
475
476 async def get_library_tracks(self) -> AsyncGenerator[Track]:
477 """
478 Provide a generator for library tracks.
479
480 Note the lack of item count on this method.
481 """
482 query = ""
483 offset = 0
484 count = self._pagination_size
485 try:
486 results = await self.conn.search3(
487 query=query,
488 artist_count=0,
489 album_count=0,
490 song_offset=offset,
491 song_count=count,
492 )
493 except ParameterError:
494 # Older Navidrome does not accept an empty string and requires the empty quotes
495 query = '""'
496 results = await self.conn.search3(
497 query=query,
498 artist_count=0,
499 album_count=0,
500 song_offset=offset,
501 song_count=count,
502 )
503 while results.song:
504 album: Album | None = None
505 for entry in results.song:
506 aid = entry.album_id or entry.parent
507 if aid is not None and (album is None or album.item_id != aid):
508 album = await self.get_album(prov_album_id=aid)
509 self._set_loudness(entry)
510 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(entry)
511 yield parse_track(self.logger, self.instance_id, entry, album=album, lyrics=lyrics)
512 offset += count
513 results = await self.conn.search3(
514 query=query,
515 artist_count=0,
516 album_count=0,
517 song_offset=offset,
518 song_count=count,
519 )
520
521 @use_cache(3600 * 3) # cache for 3 hours
522 async def get_album(self, prov_album_id: str) -> Album:
523 """Return the requested Album."""
524 try:
525 sonic_album: SonicAlbum = await self.conn.get_album(prov_album_id)
526 sonic_info = await self.conn.get_album_info2(aid=prov_album_id)
527 except (ParameterError, DataNotFoundError) as e:
528 msg = f"Album {prov_album_id} not found"
529 raise MediaNotFoundError(msg) from e
530
531 return parse_album(self.logger, self.instance_id, sonic_album, sonic_info)
532
533 @use_cache(3600 * 3) # cache for 3 hours
534 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
535 """Return a list of tracks on the specified Album."""
536 try:
537 sonic_album: SonicAlbum = await self.conn.get_album(prov_album_id)
538 except (ParameterError, DataNotFoundError) as e:
539 msg = f"Album {prov_album_id} not found"
540 raise MediaNotFoundError(msg) from e
541 tracks = []
542 if sonic_album.song:
543 for sonic_song in sonic_album.song:
544 self._set_loudness(sonic_song)
545 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(sonic_song)
546 tracks.append(parse_track(self.logger, self.instance_id, sonic_song, lyrics=lyrics))
547 return tracks
548
549 @use_cache(3600 * 3) # cache for 3 hours
550 async def get_artist(self, prov_artist_id: str) -> Artist:
551 """Return the requested Artist."""
552 if prov_artist_id == UNKNOWN_ARTIST_ID:
553 return Artist(
554 item_id=UNKNOWN_ARTIST_ID,
555 name=UNKNOWN_ARTIST,
556 provider=self.instance_id,
557 provider_mappings={
558 ProviderMapping(
559 item_id=UNKNOWN_ARTIST_ID,
560 provider_domain=self.domain,
561 provider_instance=self.instance_id,
562 )
563 },
564 )
565 if prov_artist_id.startswith(NAVI_VARIOUS_PREFIX):
566 # Special case for handling track artists on various artists album for Navidrome.
567 return Artist(
568 item_id=prov_artist_id,
569 name=prov_artist_id.removeprefix(NAVI_VARIOUS_PREFIX),
570 provider=self.instance_id,
571 provider_mappings={
572 ProviderMapping(
573 item_id=prov_artist_id,
574 provider_domain=self.domain,
575 provider_instance=self.instance_id,
576 )
577 },
578 )
579
580 try:
581 sonic_artist: SonicArtist = await self.conn.get_artist(artist_id=prov_artist_id)
582 sonic_info = await self.conn.get_artist_info2(aid=prov_artist_id)
583 except (ParameterError, DataNotFoundError) as e:
584 msg = f"Artist {prov_artist_id} not found"
585 raise MediaNotFoundError(msg) from e
586 return parse_artist(self.instance_id, sonic_artist, sonic_info, logger=self.logger)
587
588 @use_cache(3600 * 3) # cache for 3 hours
589 async def get_track(self, prov_track_id: str) -> Track:
590 """Return the specified track."""
591 try:
592 sonic_song: SonicItem = await self.conn.get_song(prov_track_id)
593 except (ParameterError, DataNotFoundError) as e:
594 msg = f"Item {prov_track_id} not found"
595 raise MediaNotFoundError(msg) from e
596 aid = sonic_song.album_id or sonic_song.parent
597 album: Album | None = None
598 if not aid:
599 self.logger.warning("Unable to find album id for track %s", sonic_song.id)
600 else:
601 album = await self.get_album(prov_album_id=aid)
602 self._set_loudness(sonic_song)
603 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(sonic_song)
604 return parse_track(self.logger, self.instance_id, sonic_song, album=album, lyrics=lyrics)
605
606 @use_cache(3600 * 3) # cache for 3 hours
607 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
608 """Return a list of all Albums by specified Artist."""
609 if prov_artist_id == UNKNOWN_ARTIST_ID or prov_artist_id.startswith(NAVI_VARIOUS_PREFIX):
610 return []
611
612 try:
613 sonic_artist: SonicArtist = await self.conn.get_artist(prov_artist_id)
614 except (ParameterError, DataNotFoundError) as e:
615 msg = f"Album {prov_artist_id} not found"
616 raise MediaNotFoundError(msg) from e
617 albums = []
618 if sonic_artist.album:
619 for entry in sonic_artist.album:
620 albums.append(parse_album(self.logger, self.instance_id, entry))
621 return albums
622
623 @use_cache(3600 * 3) # cache for 3 hours
624 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
625 """Return the specified Playlist."""
626 try:
627 sonic_playlist: SonicPlaylist = await self.conn.get_playlist(prov_playlist_id)
628 except (ParameterError, DataNotFoundError) as e:
629 msg = f"Playlist {prov_playlist_id} not found"
630 raise MediaNotFoundError(msg) from e
631 return parse_playlist(self.instance_id, sonic_playlist)
632
633 @use_cache(3600 * 3) # cache for 3 hours
634 async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
635 """Get (full) podcast episode details by id."""
636 podcast_id, _ = prov_episode_id.split(EP_CHAN_SEP)
637 async for episode in self.get_podcast_episodes(podcast_id):
638 if episode.item_id == prov_episode_id:
639 return episode
640 msg = f"Episode {prov_episode_id} not found"
641 raise MediaNotFoundError(msg)
642
643 async def get_podcast_episodes(
644 self,
645 prov_podcast_id: str,
646 ) -> AsyncGenerator[PodcastEpisode]:
647 """Get all Episodes for given podcast id."""
648 if not self._enable_podcasts:
649 return
650 channels = await self.conn.get_podcasts(inc_episodes=True, pid=prov_podcast_id)
651 channel = channels[0]
652 if not channel.episode:
653 return
654
655 for episode in channel.episode:
656 self._set_loudness(episode)
657 yield parse_epsiode(self.instance_id, episode, channel)
658
659 @use_cache(3600 * 3) # cache for 3 hours
660 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
661 """Get full Podcast details by id."""
662 if not self._enable_podcasts:
663 msg = "Podcasts are currently disabled in the provider configuration"
664 raise ActionUnavailable(msg)
665
666 channels = await self.conn.get_podcasts(inc_episodes=True, pid=prov_podcast_id)
667
668 return parse_podcast(self.instance_id, channels[0])
669
670 async def get_library_podcasts(self) -> AsyncGenerator[Podcast]:
671 """Retrieve library/subscribed podcasts from the provider."""
672 if self._enable_podcasts:
673 channels = await self.conn.get_podcasts(inc_episodes=True)
674
675 for channel in channels:
676 yield parse_podcast(self.instance_id, channel)
677
678 @use_cache(3600 * 3) # cache for 3 hours
679 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
680 """Get playlist tracks."""
681 result: list[Track] = []
682 if page > 0:
683 # paging not supported, we always return the whole list at once
684 return result
685 try:
686 sonic_playlist: SonicPlaylist = await self.conn.get_playlist(prov_playlist_id)
687 except (ParameterError, DataNotFoundError) as e:
688 msg = f"Playlist {prov_playlist_id} not found"
689 raise MediaNotFoundError(msg) from e
690
691 if not sonic_playlist.entry:
692 return result
693
694 for index, sonic_song in enumerate(sonic_playlist.entry, 1):
695 # A playlist can hold thousands of tracks, so we must not trigger a per-track
696 # metadata fetch here: parse_track derives the album reference from the playlist
697 # entry itself, and lyrics are fetched on demand when a track is played (get_track).
698 # Fetching album + lyrics per entry turned a single getPlaylist call into thousands
699 # of serial requests, making large playlists take minutes to start.
700 self._set_loudness(sonic_song)
701 track = parse_track(self.logger, self.instance_id, sonic_song)
702 track.position = index
703 result.append(track)
704 return result
705
706 @use_cache(3600 * 3) # cache for 3 hours
707 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
708 """Get the top listed tracks for a specified artist."""
709 # We have seen top tracks requested for the UNKNOWN_ARTIST ID, protect against that
710 if prov_artist_id == UNKNOWN_ARTIST_ID or prov_artist_id.startswith(NAVI_VARIOUS_PREFIX):
711 return []
712
713 try:
714 sonic_artist: SonicArtist = await self.conn.get_artist(prov_artist_id)
715 except DataNotFoundError as e:
716 msg = f"Artist {prov_artist_id} not found"
717 raise MediaNotFoundError(msg) from e
718 songs: list[SonicItem] = await self.conn.get_top_songs(sonic_artist.name)
719 tracks = []
720 for entry in songs:
721 self._set_loudness(entry)
722 tracks.append(parse_track(self.logger, self.instance_id, entry))
723 return tracks
724
725 @use_cache(3600 * 3) # cache for 3 hours
726 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
727 """Get tracks similar to selected track."""
728 try:
729 songs: list[SonicItem] = await self.conn.get_similar_songs(
730 iid=prov_track_id, count=limit
731 )
732 except DataNotFoundError as e:
733 # Subsonic returns an error here instead of an empty list, I don't think this
734 # should be an exception but there we are. Return an empty list because this
735 # exception means we didn't find anything similar.
736 self.logger.info(e)
737 return []
738 tracks = []
739 for entry in songs:
740 self._set_loudness(entry)
741 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(entry)
742 tracks.append(parse_track(self.logger, self.instance_id, entry, lyrics=lyrics))
743 return tracks
744
745 async def create_playlist(self, name: str, media_types: set[MediaType]) -> Playlist:
746 """Create a new empty playlist on the server."""
747 if not await self.conn.create_playlist(name=name):
748 raise ProviderPermissionDenied(
749 "Please ensure you have permission to create playlists on your server"
750 )
751 pls: list[SonicPlaylist] = await self.conn.get_playlists()
752 for pl in pls:
753 if pl.name == name:
754 return parse_playlist(self.instance_id, pl)
755 raise MediaNotFoundError(
756 f"Failed to create playlist with name '{name}'",
757 translation_key="create_playlist_failed",
758 translation_owner=self.translation_owner,
759 translation_args=[name],
760 )
761
762 async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
763 """
764 Append the listed tracks to the selected playlist.
765
766 Note that the configured user must own the playlist to edit this way.
767 """
768 try:
769 await self.conn.update_playlist(
770 lid=prov_playlist_id,
771 song_ids_to_add=prov_track_ids,
772 )
773 except SonicError as ex:
774 msg = f"Failed to add songs to {prov_playlist_id}, check your permissions."
775 raise ProviderPermissionDenied(msg) from ex
776
777 async def remove_playlist_tracks(
778 self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
779 ) -> None:
780 """Remove selected positions from the playlist."""
781 idx_to_remove = [pos - 1 for pos in positions_to_remove]
782 try:
783 await self.conn.update_playlist(
784 lid=prov_playlist_id,
785 song_indices_to_remove=idx_to_remove,
786 )
787 except SonicError as ex:
788 msg = f"Failed to remove songs from {prov_playlist_id}, check your permissions."
789 raise ProviderPermissionDenied(msg) from ex
790
791 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
792 """Get the details needed to process a specified track."""
793 item: SonicItem | SonicEpisode
794 if media_type == MediaType.TRACK:
795 try:
796 item = await self.conn.get_song(item_id)
797 except (ParameterError, DataNotFoundError) as e:
798 msg = f"Item {item_id} not found"
799 raise MediaNotFoundError(msg) from e
800
801 mime_type = item.transcoded_content_type or item.content_type
802
803 self.logger.debug(
804 "Fetching stream details for id %s '%s' with format '%s'",
805 item.id,
806 item.title,
807 mime_type,
808 )
809
810 elif media_type == MediaType.PODCAST_EPISODE:
811 item = await self._get_podcast_episode(item_id)
812
813 mime_type = item.transcoded_content_type or item.content_type
814
815 self.logger.debug(
816 "Fetching stream details for podcast episode '%s' with format '%s'",
817 item.id,
818 item.content_type,
819 )
820 elif media_type == MediaType.RADIO:
821 async for station in self.get_library_radios():
822 if station.item_id == item_id:
823 return StreamDetails(
824 item_id=item_id,
825 provider=self.instance_id,
826 allow_seek=False,
827 can_seek=False,
828 media_type=MediaType.RADIO,
829 audio_format=AudioFormat(content_type=ContentType.UNKNOWN),
830 stream_type=StreamType.HTTP,
831 path=station.uri or "",
832 )
833 msg = f"Radio {item_id} not found"
834 raise MediaNotFoundError(msg)
835 else:
836 msg = f"Unsupported media type encountered '{media_type}'"
837 raise UnsupportedFeaturedException(msg)
838
839 fmat = "raw" if self._raw_file else None
840 url, _ = self.conn.get_stream_url(item.id, tformat=fmat, estimate_length=True)
841
842 return StreamDetails(
843 item_id=item.id,
844 provider=self.instance_id,
845 allow_seek=True,
846 can_seek=True,
847 media_type=media_type,
848 audio_format=AudioFormat(
849 content_type=ContentType.try_parse(mime_type),
850 sample_rate=item.sampling_rate or 44100,
851 bit_depth=item.bit_depth or 16,
852 channels=item.channel_count or 2,
853 ),
854 stream_type=StreamType.HTTP,
855 path=url,
856 duration=item.duration or 0,
857 )
858
859 async def on_played(
860 self,
861 media_type: MediaType,
862 prov_item_id: str,
863 fully_played: bool,
864 position: int,
865 media_item: MediaItemType,
866 is_playing: bool = False,
867 ) -> None:
868 """
869 Handle callback when a (playable) media item has been played.
870
871 This is called by the Queue controller when;
872 - a track has been fully played
873 - a track has been stopped (or skipped) after being played
874 - every 30s when a track is playing
875
876 Fully played is True when the track has been played to the end.
877
878 Position is the last known position of the track in seconds, to sync resume state.
879 When fully_played is set to false and position is 0,
880 the user marked the item as unplayed in the UI.
881
882 is_playing is True when the track is currently playing.
883
884 media_item is the full media item details of the played/playing track.
885 """
886 if media_type != MediaType.PODCAST_EPISODE:
887 # We don't handle audio books in this provider so this is the only resummable media
888 # type we should see.
889 return
890
891 _, ep_id = prov_item_id.split(EP_CHAN_SEP)
892
893 if fully_played:
894 # We completed the episode and should delete our bookmark
895 try:
896 await self.conn.delete_bookmark(mid=ep_id)
897 except DataNotFoundError:
898 # We probably raced with something else deleting this bookmark, not really a problem
899 self.logger.info("Bookmark for item '%s' has already been deleted.", ep_id)
900 return
901
902 # Otherwise, create a new bookmark for this item or update the existing one
903 # MA provides a position in seconds but expects it back in milliseconds
904 await self.conn.create_bookmark(
905 mid=ep_id,
906 position=position * 1000,
907 comment="Music Assistant Bookmark",
908 )
909
910 async def get_resume_position(
911 self, item_id: str, media_type: MediaType
912 ) -> tuple[bool, int, datetime | None]:
913 """
914 Get progress (resume point) details for the given Audiobook or Podcast episode.
915
916 This is a separate call from the regular get_item call to ensure the resume position
917 is always up-to-date and because a lot providers have this info present on a dedicated
918 endpoint.
919
920 Will be called right before playback starts to ensure the resume position is correct.
921
922 Returns a boolean with the fully_played status
923 and an integer with the resume position in ms.
924 """
925 if media_type != MediaType.PODCAST_EPISODE:
926 raise NotImplementedError("AudioBooks are not supported by the Open Subsonic provider")
927
928 _, ep_id = item_id.split(EP_CHAN_SEP)
929
930 bookmarks: list[SonicBookmark] = await self.conn.get_bookmarks()
931
932 for mark in bookmarks:
933 if mark.entry.id == ep_id:
934 return (
935 False,
936 mark.position,
937 datetime.fromisoformat(mark.created) if mark.created else None,
938 )
939 # If we get here, there is no bookmark
940 return (False, 0, None)
941
942 async def get_track_lyrics(self, track: SonicItem) -> tuple[str, bool] | None:
943 """
944 Get lyrics for a track.
945
946 Fetches lyrics from Subsonic server. Returns the lyrics text in LRC format
947 if the Lyrics are synced (have time stamp info) or raw text if not
948 """
949 # Server doesn't support to newer lyrics retrieval, fall back to the old one
950 if not self._id_lyrics:
951 try:
952 ly: SonicLyrics = await self.conn.get_lyrics(track.title, track.artist)
953 except DataNotFoundError:
954 self.logger.debug("Lyrics not found for '%s' by '%s'", track.title, track.artist)
955 return None
956 return (ly.value, False)
957
958 try:
959 lyrics: list[StructuredLyrics] = await self.conn.get_lyrics_by_song_id(track.id)
960 except DataNotFoundError:
961 self.logger.debug("Lyrics not found for '%s'", track.id)
962 return None
963 if not lyrics:
964 return None
965 return parse_structured_lyrics(lyrics[0])
966
967 async def _get_podcast_episode(self, eid: str) -> SonicEpisode:
968 chan_id, ep_id = eid.split(EP_CHAN_SEP)
969
970 if self._direct_podcast_episode:
971 try:
972 return await self.conn.get_podcast_episode(ep_id)
973 except DataNotFoundError as e:
974 msg = f"Can't find episode {ep_id} in podcast {chan_id}"
975 raise MediaNotFoundError(msg) from e
976
977 chan = await self.conn.get_podcasts(inc_episodes=True, pid=chan_id)
978
979 if not chan[0].episode:
980 raise MediaNotFoundError(f"Missing episode list for podcast channel '{chan[0].id}'")
981
982 for episode in chan[0].episode:
983 if episode.id == ep_id:
984 return episode
985
986 msg = f"Can't find episode {ep_id} in podcast {chan_id}"
987 raise MediaNotFoundError(msg)
988
989 def _set_loudness(self, item: SonicItem) -> None:
990 if item.replay_gain and item.replay_gain.track_gain is not None:
991 # Convert ReplayGain values (gain in dB) to integrated loudness (LUFS)
992 track_loudness = -18 - item.replay_gain.track_gain
993 album_loudness = (
994 -18 - item.replay_gain.album_gain
995 if item.replay_gain.album_gain is not None
996 else None
997 )
998 self.mass.create_task(
999 self.mass.streams.audio_analysis.set_track_loudness(
1000 item.id,
1001 self.instance_id,
1002 track_loudness,
1003 album_loudness,
1004 )
1005 )
1006
1007 async def _get_podcast_channel_async(self, chan_id: str) -> PodcastChannel | None:
1008 if cache := await self.mass.cache.get(
1009 key=chan_id,
1010 provider=self.instance_id,
1011 category=CACHE_CATEGORY_PODCAST_CHANNEL,
1012 base_class=PodcastChannel,
1013 ):
1014 return cache
1015 if channels := await self.conn.get_podcasts(inc_episodes=True, pid=chan_id):
1016 channel = channels[0]
1017 await self.mass.cache.set(
1018 key=chan_id,
1019 data=channel.to_dict(),
1020 provider=self.instance_id,
1021 expiration=600,
1022 category=CACHE_CATEGORY_PODCAST_CHANNEL,
1023 )
1024 return channel
1025 return None
1026
1027 @use_cache(3600 * 3, cache_checksum="v2", base_class=RecommendationFolder)
1028 async def _podcast_recommendations(self) -> RecommendationFolder:
1029 podcasts: RecommendationFolder = RecommendationFolder(
1030 item_id="subsonic_newest_podcasts",
1031 provider=self.instance_id,
1032 name="Newest Podcast Episodes",
1033 translation_key="episodes_recently_added",
1034 )
1035 sonic_episodes = await self.conn.get_newest_podcasts(count=self._reco_limit)
1036 for ep in sonic_episodes:
1037 if channel_info := await self._get_podcast_channel_async(ep.channel_id):
1038 self._set_loudness(ep)
1039 podcasts.items.append(parse_epsiode(self.instance_id, ep, channel_info))
1040 return podcasts
1041
1042 @use_cache(3600 * 3, cache_checksum="v2", base_class=RecommendationFolder)
1043 async def _favorites_recommendation(self) -> RecommendationFolder:
1044 faves: RecommendationFolder = RecommendationFolder(
1045 item_id="subsonic_starred_albums",
1046 provider=self.instance_id,
1047 name="Starred Items",
1048 translation_key="starred_items",
1049 )
1050 starred = await self.conn.get_starred2()
1051 if starred.album:
1052 for sonic_album in starred.album[: self._reco_limit]:
1053 faves.items.append(parse_album(self.logger, self.instance_id, sonic_album))
1054 if starred.artist:
1055 for sonic_artist in starred.artist[: self._reco_limit]:
1056 faves.items.append(parse_artist(self.instance_id, sonic_artist, logger=self.logger))
1057 if starred.song:
1058 for sonic_song in starred.song[: self._reco_limit]:
1059 self._set_loudness(sonic_song)
1060 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(sonic_song)
1061 faves.items.append(
1062 parse_track(self.logger, self.instance_id, sonic_song, lyrics=lyrics)
1063 )
1064 return faves
1065
1066 @use_cache(3600 * 3, cache_checksum="v2", base_class=RecommendationFolder)
1067 async def _new_recommendations(self) -> RecommendationFolder:
1068 new_stuff: RecommendationFolder = RecommendationFolder(
1069 item_id="subsonic_new_albums",
1070 provider=self.instance_id,
1071 name="New Albums",
1072 translation_key="recently_added_albums",
1073 )
1074 new_albums = await self.conn.get_album_list2(ltype="newest", size=self._reco_limit)
1075 for sonic_album in new_albums:
1076 new_stuff.items.append(parse_album(self.logger, self.instance_id, sonic_album))
1077 return new_stuff
1078
1079 @use_cache(3600 * 3, cache_checksum="v2", base_class=RecommendationFolder)
1080 async def _played_recommendations(self) -> RecommendationFolder:
1081 recent: RecommendationFolder = RecommendationFolder(
1082 item_id="subsonic_most_played",
1083 provider=self.instance_id,
1084 name="Most Played Albums",
1085 translation_key="most_played_albums",
1086 )
1087 albums = await self.conn.get_album_list2(ltype="frequent", size=self._reco_limit)
1088 for sonic_album in albums:
1089 recent.items.append(parse_album(self.logger, self.instance_id, sonic_album))
1090 return recent
1091