/
/
/
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.STRUCTURED_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, True))
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 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(entry)
389 tr.append(parse_track(self.logger, self.instance_id, entry, lyrics=lyrics))
390 else:
391 tr = []
392
393 return SearchResults(artists=ar, albums=al, tracks=tr)
394
395 async def set_favorite(self, prov_item_id: str, media_type: MediaType, favorite: bool) -> None:
396 """Set or clear favorite on the server."""
397 # The subsonic spec does not support favorite-ing anything but artists, albums, and tracks
398 if media_type not in (MediaType.ARTIST, MediaType.ALBUM, MediaType.TRACK):
399 return
400
401 track_ids: list[str] = []
402 album_ids: list[str] = []
403 artist_ids: list[str] = []
404
405 if media_type == MediaType.ARTIST:
406 artist_ids.append(prov_item_id)
407 elif media_type == MediaType.ALBUM:
408 album_ids.append(prov_item_id)
409 elif media_type == MediaType.TRACK:
410 track_ids.append(prov_item_id)
411
412 if favorite:
413 await self.conn.star(sids=track_ids, album_ids=album_ids, artist_ids=artist_ids)
414 else:
415 await self.conn.unstar(sids=track_ids, album_ids=album_ids, artist_ids=artist_ids)
416
417 async def get_library_artists(self) -> AsyncGenerator[Artist]:
418 """Provide a generator for reading all artists."""
419 artists = await self.conn.get_artists()
420
421 if not artists.index:
422 return
423
424 for index in artists.index:
425 if not index.artist:
426 continue
427
428 for artist in index.artist:
429 yield parse_artist(self.instance_id, artist, logger=self.logger)
430
431 async def get_library_albums(self) -> AsyncGenerator[Album]:
432 """
433 Provide a generator for reading all artists.
434
435 Note the pagination, the open subsonic docs say that this method is limited to
436 returning 500 items per invocation.
437 """
438 offset = 0
439 size = self._pagination_size
440 albums = await self.conn.get_album_list2(
441 ltype="alphabeticalByArtist",
442 size=size,
443 offset=offset,
444 )
445 while albums:
446 for album in albums:
447 yield parse_album(self.logger, self.instance_id, album)
448 offset += size
449 albums = await self.conn.get_album_list2(
450 ltype="alphabeticalByArtist",
451 size=size,
452 offset=offset,
453 )
454
455 async def get_library_playlists(self) -> AsyncGenerator[Playlist]:
456 """Provide a generator for library playlists."""
457 results = await self.conn.get_playlists()
458 for entry in results:
459 yield parse_playlist(self.instance_id, entry)
460
461 async def get_library_radios(self) -> AsyncGenerator[Radio]:
462 """Provide a generator for library radio stations."""
463 if not self._enable_radio_stations:
464 return
465 stations: list[SonicRadio] = await self.conn.get_internet_radio_stations()
466 for entry in stations:
467 yield parse_radio(self.instance_id, entry)
468
469 async def get_radio(self, prov_radio_id: str) -> Radio:
470 """Return the requested radio station."""
471 async for station in self.get_library_radios():
472 if station.item_id == prov_radio_id:
473 return station
474 msg = f"Radio {prov_radio_id} not found"
475 raise MediaNotFoundError(msg)
476
477 async def get_library_tracks(self) -> AsyncGenerator[Track]:
478 """
479 Provide a generator for library tracks.
480
481 Note the lack of item count on this method.
482 """
483 query = ""
484 offset = 0
485 count = self._pagination_size
486 try:
487 results = await self.conn.search3(
488 query=query,
489 artist_count=0,
490 album_count=0,
491 song_offset=offset,
492 song_count=count,
493 )
494 except ParameterError:
495 # Older Navidrome does not accept an empty string and requires the empty quotes
496 query = '""'
497 results = await self.conn.search3(
498 query=query,
499 artist_count=0,
500 album_count=0,
501 song_offset=offset,
502 song_count=count,
503 )
504 while results.song:
505 album: Album | None = None
506 for entry in results.song:
507 aid = entry.album_id or entry.parent
508 if aid is not None and (album is None or album.item_id != aid):
509 album = await self.get_album(prov_album_id=aid)
510 self._set_loudness(entry)
511 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(entry)
512 yield parse_track(self.logger, self.instance_id, entry, album=album, lyrics=lyrics)
513 offset += count
514 results = await self.conn.search3(
515 query=query,
516 artist_count=0,
517 album_count=0,
518 song_offset=offset,
519 song_count=count,
520 )
521
522 @use_cache(3600 * 3) # cache for 3 hours
523 async def get_album(self, prov_album_id: str) -> Album:
524 """Return the requested Album."""
525 try:
526 sonic_album: SonicAlbum = await self.conn.get_album(prov_album_id)
527 sonic_info = await self.conn.get_album_info2(aid=prov_album_id)
528 except (ParameterError, DataNotFoundError) as e:
529 msg = f"Album {prov_album_id} not found"
530 raise MediaNotFoundError(msg) from e
531
532 return parse_album(self.logger, self.instance_id, sonic_album, sonic_info)
533
534 @use_cache(3600 * 3) # cache for 3 hours
535 async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
536 """Return a list of tracks on the specified Album."""
537 try:
538 sonic_album: SonicAlbum = await self.conn.get_album(prov_album_id)
539 except (ParameterError, DataNotFoundError) as e:
540 msg = f"Album {prov_album_id} not found"
541 raise MediaNotFoundError(msg) from e
542 tracks = []
543 if sonic_album.song:
544 for sonic_song in sonic_album.song:
545 self._set_loudness(sonic_song)
546 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(sonic_song)
547 tracks.append(parse_track(self.logger, self.instance_id, sonic_song, lyrics=lyrics))
548 return tracks
549
550 @use_cache(3600 * 3) # cache for 3 hours
551 async def get_artist(self, prov_artist_id: str) -> Artist:
552 """Return the requested Artist."""
553 if prov_artist_id == UNKNOWN_ARTIST_ID:
554 return Artist(
555 item_id=UNKNOWN_ARTIST_ID,
556 name=UNKNOWN_ARTIST,
557 provider=self.instance_id,
558 provider_mappings={
559 ProviderMapping(
560 item_id=UNKNOWN_ARTIST_ID,
561 provider_domain=self.domain,
562 provider_instance=self.instance_id,
563 )
564 },
565 )
566 if prov_artist_id.startswith(NAVI_VARIOUS_PREFIX):
567 # Special case for handling track artists on various artists album for Navidrome.
568 return Artist(
569 item_id=prov_artist_id,
570 name=prov_artist_id.removeprefix(NAVI_VARIOUS_PREFIX),
571 provider=self.instance_id,
572 provider_mappings={
573 ProviderMapping(
574 item_id=prov_artist_id,
575 provider_domain=self.domain,
576 provider_instance=self.instance_id,
577 )
578 },
579 )
580
581 try:
582 sonic_artist: SonicArtist = await self.conn.get_artist(artist_id=prov_artist_id)
583 sonic_info = await self.conn.get_artist_info2(aid=prov_artist_id)
584 except (ParameterError, DataNotFoundError) as e:
585 msg = f"Artist {prov_artist_id} not found"
586 raise MediaNotFoundError(msg) from e
587 return parse_artist(self.instance_id, sonic_artist, sonic_info, logger=self.logger)
588
589 @use_cache(3600 * 3) # cache for 3 hours
590 async def get_track(self, prov_track_id: str) -> Track:
591 """Return the specified track."""
592 try:
593 sonic_song: SonicItem = await self.conn.get_song(prov_track_id)
594 except (ParameterError, DataNotFoundError) as e:
595 msg = f"Item {prov_track_id} not found"
596 raise MediaNotFoundError(msg) from e
597 aid = sonic_song.album_id or sonic_song.parent
598 album: Album | None = None
599 if not aid:
600 self.logger.warning("Unable to find album id for track %s", sonic_song.id)
601 else:
602 album = await self.get_album(prov_album_id=aid)
603 self._set_loudness(sonic_song)
604 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(sonic_song)
605 return parse_track(self.logger, self.instance_id, sonic_song, album=album, lyrics=lyrics)
606
607 @use_cache(3600 * 3) # cache for 3 hours
608 async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
609 """Return a list of all Albums by specified Artist."""
610 if prov_artist_id == UNKNOWN_ARTIST_ID or prov_artist_id.startswith(NAVI_VARIOUS_PREFIX):
611 return []
612
613 try:
614 sonic_artist: SonicArtist = await self.conn.get_artist(prov_artist_id)
615 except (ParameterError, DataNotFoundError) as e:
616 msg = f"Album {prov_artist_id} not found"
617 raise MediaNotFoundError(msg) from e
618 albums = []
619 if sonic_artist.album:
620 for entry in sonic_artist.album:
621 albums.append(parse_album(self.logger, self.instance_id, entry))
622 return albums
623
624 @use_cache(3600 * 3) # cache for 3 hours
625 async def get_playlist(self, prov_playlist_id: str) -> Playlist:
626 """Return the specified Playlist."""
627 try:
628 sonic_playlist: SonicPlaylist = await self.conn.get_playlist(prov_playlist_id)
629 except (ParameterError, DataNotFoundError) as e:
630 msg = f"Playlist {prov_playlist_id} not found"
631 raise MediaNotFoundError(msg) from e
632 return parse_playlist(self.instance_id, sonic_playlist)
633
634 @use_cache(3600 * 3) # cache for 3 hours
635 async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
636 """Get (full) podcast episode details by id."""
637 podcast_id, _ = prov_episode_id.split(EP_CHAN_SEP)
638 async for episode in self.get_podcast_episodes(podcast_id):
639 if episode.item_id == prov_episode_id:
640 return episode
641 msg = f"Episode {prov_episode_id} not found"
642 raise MediaNotFoundError(msg)
643
644 async def get_podcast_episodes(
645 self,
646 prov_podcast_id: str,
647 ) -> AsyncGenerator[PodcastEpisode]:
648 """Get all Episodes for given podcast id."""
649 if not self._enable_podcasts:
650 return
651 channels = await self.conn.get_podcasts(inc_episodes=True, pid=prov_podcast_id)
652 channel = channels[0]
653 if not channel.episode:
654 return
655
656 for episode in channel.episode:
657 self._set_loudness(episode)
658 yield parse_epsiode(self.instance_id, episode, channel)
659
660 @use_cache(3600 * 3) # cache for 3 hours
661 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
662 """Get full Podcast details by id."""
663 if not self._enable_podcasts:
664 msg = "Podcasts are currently disabled in the provider configuration"
665 raise ActionUnavailable(msg)
666
667 channels = await self.conn.get_podcasts(inc_episodes=True, pid=prov_podcast_id)
668
669 return parse_podcast(self.instance_id, channels[0])
670
671 async def get_library_podcasts(self) -> AsyncGenerator[Podcast]:
672 """Retrieve library/subscribed podcasts from the provider."""
673 if self._enable_podcasts:
674 channels = await self.conn.get_podcasts(inc_episodes=True)
675
676 for channel in channels:
677 yield parse_podcast(self.instance_id, channel)
678
679 @use_cache(3600 * 3) # cache for 3 hours
680 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
681 """Get playlist tracks."""
682 result: list[Track] = []
683 if page > 0:
684 # paging not supported, we always return the whole list at once
685 return result
686 try:
687 sonic_playlist: SonicPlaylist = await self.conn.get_playlist(prov_playlist_id)
688 except (ParameterError, DataNotFoundError) as e:
689 msg = f"Playlist {prov_playlist_id} not found"
690 raise MediaNotFoundError(msg) from e
691
692 if not sonic_playlist.entry:
693 return result
694
695 album: Album | None = None
696 for index, sonic_song in enumerate(sonic_playlist.entry, 1):
697 aid = sonic_song.album_id or sonic_song.parent
698 if not aid:
699 self.logger.warning("Unable to find album for track %s", sonic_song.id)
700 if aid is not None and (not album or album.item_id != aid):
701 album = await self.get_album(prov_album_id=aid)
702 self._set_loudness(sonic_song)
703 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(sonic_song)
704 track = parse_track(
705 self.logger, self.instance_id, sonic_song, album=album, lyrics=lyrics
706 )
707 track.position = index
708 result.append(track)
709 return result
710
711 @use_cache(3600 * 3) # cache for 3 hours
712 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
713 """Get the top listed tracks for a specified artist."""
714 # We have seen top tracks requested for the UNKNOWN_ARTIST ID, protect against that
715 if prov_artist_id == UNKNOWN_ARTIST_ID or prov_artist_id.startswith(NAVI_VARIOUS_PREFIX):
716 return []
717
718 try:
719 sonic_artist: SonicArtist = await self.conn.get_artist(prov_artist_id)
720 except DataNotFoundError as e:
721 msg = f"Artist {prov_artist_id} not found"
722 raise MediaNotFoundError(msg) from e
723 songs: list[SonicItem] = await self.conn.get_top_songs(sonic_artist.name)
724 tracks = []
725 for entry in songs:
726 self._set_loudness(entry)
727 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(entry)
728 tracks.append(parse_track(self.logger, self.instance_id, entry, lyrics=lyrics))
729 return tracks
730
731 @use_cache(3600 * 3) # cache for 3 hours
732 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
733 """Get tracks similar to selected track."""
734 try:
735 songs: list[SonicItem] = await self.conn.get_similar_songs(
736 iid=prov_track_id, count=limit
737 )
738 except DataNotFoundError as e:
739 # Subsonic returns an error here instead of an empty list, I don't think this
740 # should be an exception but there we are. Return an empty list because this
741 # exception means we didn't find anything similar.
742 self.logger.info(e)
743 return []
744 tracks = []
745 for entry in songs:
746 self._set_loudness(entry)
747 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(entry)
748 tracks.append(parse_track(self.logger, self.instance_id, entry, lyrics=lyrics))
749 return tracks
750
751 async def create_playlist(self, name: str, media_types: set[MediaType]) -> Playlist:
752 """Create a new empty playlist on the server."""
753 if not await self.conn.create_playlist(name=name):
754 raise ProviderPermissionDenied(
755 "Please ensure you have permission to create playlists on your server"
756 )
757 pls: list[SonicPlaylist] = await self.conn.get_playlists()
758 for pl in pls:
759 if pl.name == name:
760 return parse_playlist(self.instance_id, pl)
761 raise MediaNotFoundError(
762 f"Failed to create playlist with name '{name}'",
763 translation_key="create_playlist_failed",
764 translation_owner=self.translation_owner,
765 translation_args=[name],
766 )
767
768 async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
769 """
770 Append the listed tracks to the selected playlist.
771
772 Note that the configured user must own the playlist to edit this way.
773 """
774 try:
775 await self.conn.update_playlist(
776 lid=prov_playlist_id,
777 song_ids_to_add=prov_track_ids,
778 )
779 except SonicError as ex:
780 msg = f"Failed to add songs to {prov_playlist_id}, check your permissions."
781 raise ProviderPermissionDenied(msg) from ex
782
783 async def remove_playlist_tracks(
784 self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
785 ) -> None:
786 """Remove selected positions from the playlist."""
787 idx_to_remove = [pos - 1 for pos in positions_to_remove]
788 try:
789 await self.conn.update_playlist(
790 lid=prov_playlist_id,
791 song_indices_to_remove=idx_to_remove,
792 )
793 except SonicError as ex:
794 msg = f"Failed to remove songs from {prov_playlist_id}, check your permissions."
795 raise ProviderPermissionDenied(msg) from ex
796
797 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
798 """Get the details needed to process a specified track."""
799 item: SonicItem | SonicEpisode
800 if media_type == MediaType.TRACK:
801 try:
802 item = await self.conn.get_song(item_id)
803 except (ParameterError, DataNotFoundError) as e:
804 msg = f"Item {item_id} not found"
805 raise MediaNotFoundError(msg) from e
806
807 mime_type = item.transcoded_content_type or item.content_type
808
809 self.logger.debug(
810 "Fetching stream details for id %s '%s' with format '%s'",
811 item.id,
812 item.title,
813 mime_type,
814 )
815
816 elif media_type == MediaType.PODCAST_EPISODE:
817 item = await self._get_podcast_episode(item_id)
818
819 mime_type = item.transcoded_content_type or item.content_type
820
821 self.logger.debug(
822 "Fetching stream details for podcast episode '%s' with format '%s'",
823 item.id,
824 item.content_type,
825 )
826 elif media_type == MediaType.RADIO:
827 async for station in self.get_library_radios():
828 if station.item_id == item_id:
829 return StreamDetails(
830 item_id=item_id,
831 provider=self.instance_id,
832 allow_seek=False,
833 can_seek=False,
834 media_type=MediaType.RADIO,
835 audio_format=AudioFormat(content_type=ContentType.UNKNOWN),
836 stream_type=StreamType.HTTP,
837 path=station.uri or "",
838 )
839 msg = f"Radio {item_id} not found"
840 raise MediaNotFoundError(msg)
841 else:
842 msg = f"Unsupported media type encountered '{media_type}'"
843 raise UnsupportedFeaturedException(msg)
844
845 fmat = "raw" if self._raw_file else None
846 url, _ = self.conn.get_stream_url(item.id, tformat=fmat, estimate_length=True)
847
848 return StreamDetails(
849 item_id=item.id,
850 provider=self.instance_id,
851 allow_seek=True,
852 can_seek=True,
853 media_type=media_type,
854 audio_format=AudioFormat(
855 content_type=ContentType.try_parse(mime_type),
856 sample_rate=item.sampling_rate or 44100,
857 bit_depth=item.bit_depth or 16,
858 channels=item.channel_count or 2,
859 ),
860 stream_type=StreamType.HTTP,
861 path=url,
862 duration=item.duration or 0,
863 )
864
865 async def on_played(
866 self,
867 media_type: MediaType,
868 prov_item_id: str,
869 fully_played: bool,
870 position: int,
871 media_item: MediaItemType,
872 is_playing: bool = False,
873 ) -> None:
874 """
875 Handle callback when a (playable) media item has been played.
876
877 This is called by the Queue controller when;
878 - a track has been fully played
879 - a track has been stopped (or skipped) after being played
880 - every 30s when a track is playing
881
882 Fully played is True when the track has been played to the end.
883
884 Position is the last known position of the track in seconds, to sync resume state.
885 When fully_played is set to false and position is 0,
886 the user marked the item as unplayed in the UI.
887
888 is_playing is True when the track is currently playing.
889
890 media_item is the full media item details of the played/playing track.
891 """
892 if media_type != MediaType.PODCAST_EPISODE:
893 # We don't handle audio books in this provider so this is the only resummable media
894 # type we should see.
895 return
896
897 _, ep_id = prov_item_id.split(EP_CHAN_SEP)
898
899 if fully_played:
900 # We completed the episode and should delete our bookmark
901 try:
902 await self.conn.delete_bookmark(mid=ep_id)
903 except DataNotFoundError:
904 # We probably raced with something else deleting this bookmark, not really a problem
905 self.logger.info("Bookmark for item '%s' has already been deleted.", ep_id)
906 return
907
908 # Otherwise, create a new bookmark for this item or update the existing one
909 # MA provides a position in seconds but expects it back in milliseconds
910 await self.conn.create_bookmark(
911 mid=ep_id,
912 position=position * 1000,
913 comment="Music Assistant Bookmark",
914 )
915
916 async def get_resume_position(
917 self, item_id: str, media_type: MediaType
918 ) -> tuple[bool, int, datetime | None]:
919 """
920 Get progress (resume point) details for the given Audiobook or Podcast episode.
921
922 This is a separate call from the regular get_item call to ensure the resume position
923 is always up-to-date and because a lot providers have this info present on a dedicated
924 endpoint.
925
926 Will be called right before playback starts to ensure the resume position is correct.
927
928 Returns a boolean with the fully_played status
929 and an integer with the resume position in ms.
930 """
931 if media_type != MediaType.PODCAST_EPISODE:
932 raise NotImplementedError("AudioBooks are not supported by the Open Subsonic provider")
933
934 _, ep_id = item_id.split(EP_CHAN_SEP)
935
936 bookmarks: list[SonicBookmark] = await self.conn.get_bookmarks()
937
938 for mark in bookmarks:
939 if mark.entry.id == ep_id:
940 return (
941 False,
942 mark.position,
943 datetime.fromisoformat(mark.created) if mark.created else None,
944 )
945 # If we get here, there is no bookmark
946 return (False, 0, None)
947
948 async def get_track_lyrics(self, track: SonicItem) -> tuple[str, bool] | None:
949 """
950 Get lyrics for a track.
951
952 Fetches lyrics from Subsonic server. Returns the lyrics text in LRC format
953 if the Lyrics are synced (have time stamp info) or raw text if not
954 """
955 # Server doesn't support to newer lyrics retrieval, fall back to the old one
956 if not self._id_lyrics:
957 try:
958 ly: SonicLyrics = await self.conn.get_lyrics(track.title, track.artist)
959 except DataNotFoundError:
960 self.logger.debug("Lyrics not found for '%s' by '%s'", track.title, track.artist)
961 return None
962 return (ly.value, False)
963
964 try:
965 lyrics: list[StructuredLyrics] = await self.conn.get_lyrics_by_song_id(track.id)
966 except DataNotFoundError:
967 self.logger.debug("Lyrics not found for '%s'", track.id)
968 return None
969 if not lyrics:
970 return None
971 return parse_structured_lyrics(lyrics[0])
972
973 async def _get_podcast_episode(self, eid: str) -> SonicEpisode:
974 chan_id, ep_id = eid.split(EP_CHAN_SEP)
975
976 if self._direct_podcast_episode:
977 try:
978 return await self.conn.get_podcast_episode(ep_id)
979 except DataNotFoundError as e:
980 msg = f"Can't find episode {ep_id} in podcast {chan_id}"
981 raise MediaNotFoundError(msg) from e
982
983 chan = await self.conn.get_podcasts(inc_episodes=True, pid=chan_id)
984
985 if not chan[0].episode:
986 raise MediaNotFoundError(f"Missing episode list for podcast channel '{chan[0].id}'")
987
988 for episode in chan[0].episode:
989 if episode.id == ep_id:
990 return episode
991
992 msg = f"Can't find episode {ep_id} in podcast {chan_id}"
993 raise MediaNotFoundError(msg)
994
995 def _set_loudness(self, item: SonicItem) -> None:
996 if item.replay_gain and item.replay_gain.track_gain is not None:
997 # Convert ReplayGain values (gain in dB) to integrated loudness (LUFS)
998 track_loudness = -18 - item.replay_gain.track_gain
999 album_loudness = (
1000 -18 - item.replay_gain.album_gain
1001 if item.replay_gain.album_gain is not None
1002 else None
1003 )
1004 self.mass.create_task(
1005 self.mass.streams.audio_analysis.set_track_loudness(
1006 item.id,
1007 self.instance_id,
1008 track_loudness,
1009 album_loudness,
1010 )
1011 )
1012
1013 async def _get_podcast_channel_async(self, chan_id: str) -> PodcastChannel | None:
1014 if cache := await self.mass.cache.get(
1015 key=chan_id,
1016 provider=self.instance_id,
1017 category=CACHE_CATEGORY_PODCAST_CHANNEL,
1018 base_class=PodcastChannel,
1019 ):
1020 return cache
1021 if channels := await self.conn.get_podcasts(inc_episodes=True, pid=chan_id):
1022 channel = channels[0]
1023 await self.mass.cache.set(
1024 key=chan_id,
1025 data=channel.to_dict(),
1026 provider=self.instance_id,
1027 expiration=600,
1028 category=CACHE_CATEGORY_PODCAST_CHANNEL,
1029 )
1030 return channel
1031 return None
1032
1033 @use_cache(3600 * 3, cache_checksum="v2", base_class=RecommendationFolder)
1034 async def _podcast_recommendations(self) -> RecommendationFolder:
1035 podcasts: RecommendationFolder = RecommendationFolder(
1036 item_id="subsonic_newest_podcasts",
1037 provider=self.instance_id,
1038 name="Newest Podcast Episodes",
1039 translation_key="episodes_recently_added",
1040 )
1041 sonic_episodes = await self.conn.get_newest_podcasts(count=self._reco_limit)
1042 for ep in sonic_episodes:
1043 if channel_info := await self._get_podcast_channel_async(ep.channel_id):
1044 self._set_loudness(ep)
1045 podcasts.items.append(parse_epsiode(self.instance_id, ep, channel_info))
1046 return podcasts
1047
1048 @use_cache(3600 * 3, cache_checksum="v2", base_class=RecommendationFolder)
1049 async def _favorites_recommendation(self) -> RecommendationFolder:
1050 faves: RecommendationFolder = RecommendationFolder(
1051 item_id="subsonic_starred_albums",
1052 provider=self.instance_id,
1053 name="Starred Items",
1054 translation_key="starred_items",
1055 )
1056 starred = await self.conn.get_starred2()
1057 if starred.album:
1058 for sonic_album in starred.album[: self._reco_limit]:
1059 faves.items.append(parse_album(self.logger, self.instance_id, sonic_album))
1060 if starred.artist:
1061 for sonic_artist in starred.artist[: self._reco_limit]:
1062 faves.items.append(parse_artist(self.instance_id, sonic_artist, logger=self.logger))
1063 if starred.song:
1064 for sonic_song in starred.song[: self._reco_limit]:
1065 self._set_loudness(sonic_song)
1066 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(sonic_song)
1067 faves.items.append(
1068 parse_track(self.logger, self.instance_id, sonic_song, lyrics=lyrics)
1069 )
1070 return faves
1071
1072 @use_cache(3600 * 3, cache_checksum="v2", base_class=RecommendationFolder)
1073 async def _new_recommendations(self) -> RecommendationFolder:
1074 new_stuff: RecommendationFolder = RecommendationFolder(
1075 item_id="subsonic_new_albums",
1076 provider=self.instance_id,
1077 name="New Albums",
1078 translation_key="recently_added_albums",
1079 )
1080 new_albums = await self.conn.get_album_list2(ltype="newest", size=self._reco_limit)
1081 for sonic_album in new_albums:
1082 new_stuff.items.append(parse_album(self.logger, self.instance_id, sonic_album))
1083 return new_stuff
1084
1085 @use_cache(3600 * 3, cache_checksum="v2", base_class=RecommendationFolder)
1086 async def _played_recommendations(self) -> RecommendationFolder:
1087 recent: RecommendationFolder = RecommendationFolder(
1088 item_id="subsonic_most_played",
1089 provider=self.instance_id,
1090 name="Most Played Albums",
1091 translation_key="most_played_albums",
1092 )
1093 albums = await self.conn.get_album_list2(ltype="frequent", size=self._reco_limit)
1094 for sonic_album in albums:
1095 recent.items.append(parse_album(self.logger, self.instance_id, sonic_album))
1096 return recent
1097