/
/
/
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.helpers.podcast_parsers import rank_episodes_by_date
55from music_assistant.models.music_provider import MusicProvider
56
57from .parsers import (
58 EP_CHAN_SEP,
59 NAVI_VARIOUS_PREFIX,
60 UNKNOWN_ARTIST_ID,
61 parse_album,
62 parse_artist,
63 parse_epsiode,
64 parse_playlist,
65 parse_podcast,
66 parse_radio,
67 parse_structured_lyrics,
68 parse_track,
69)
70
71if TYPE_CHECKING:
72 from collections.abc import AsyncGenerator
73
74 from libopensonic.media import AlbumID3 as SonicAlbum
75 from libopensonic.media import ArtistWithAlbumsID3 as SonicArtist
76 from libopensonic.media import Bookmark as SonicBookmark
77 from libopensonic.media import Child as SonicItem
78 from libopensonic.media import InternetRadioStation as SonicRadio
79 from libopensonic.media import Lyrics as SonicLyrics
80 from libopensonic.media import OpenSubsonicExtension, StructuredLyrics
81 from libopensonic.media import Playlist as SonicPlaylist
82 from libopensonic.media import PodcastEpisode as SonicEpisode
83
84CONF_BASE_URL = "baseURL"
85CONF_API_KEY = "api_key"
86CONF_ENABLE_PODCASTS = "enable_podcasts"
87CONF_ENABLE_RADIO_STATIONS = "enable_radio_stations"
88CONF_ENABLE_LEGACY_AUTH = "enable_legacy_auth"
89CONF_RECO_FAVES = "recommend_favorites"
90CONF_NEW_ALBUMS = "recommend_new"
91CONF_PLAYED_ALBUMS = "recommend_played"
92CONF_RECO_SIZE = "recommendation_count"
93CONF_PAGE_SIZE = "pagination_size"
94CONF_RAW_FILE = "request_raw_file"
95
96CACHE_CATEGORY_PODCAST_CHANNEL = 1
97CACHE_CATEGORY_PODCAST_EPISODES = 2
98
99Param = ParamSpec("Param")
100RetType = TypeVar("RetType")
101
102
103class OpenSonicProvider(MusicProvider):
104 """Provider for Open Subsonic servers."""
105
106 conn: SonicConnection
107 _enable_podcasts: bool = True
108 _enable_radio_stations: bool = True
109 _show_faves: bool = True
110 _show_new: bool = True
111 _show_played: bool = True
112 _reco_limit: int = 10
113 _pagination_size: int = 200
114 _id_lyrics: bool = False
115 _direct_podcast_episode: bool = False
116 _raw_file: bool = True
117
118 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
119 """Return Config entries to setup this provider."""
120 return (
121 ConfigEntry(
122 key=CONF_ENABLE_PODCASTS,
123 type=ConfigEntryType.BOOLEAN,
124 required=True,
125 default_value=True,
126 ),
127 ConfigEntry(
128 key=CONF_ENABLE_RADIO_STATIONS,
129 type=ConfigEntryType.BOOLEAN,
130 required=True,
131 default_value=True,
132 ),
133 ConfigEntry(
134 key=CONF_ENABLE_LEGACY_AUTH,
135 type=ConfigEntryType.BOOLEAN,
136 required=True,
137 default_value=False,
138 ),
139 ConfigEntry(
140 key=CONF_RECO_FAVES,
141 type=ConfigEntryType.BOOLEAN,
142 required=True,
143 default_value=True,
144 ),
145 ConfigEntry(
146 key=CONF_NEW_ALBUMS,
147 type=ConfigEntryType.BOOLEAN,
148 required=True,
149 default_value=True,
150 ),
151 ConfigEntry(
152 key=CONF_PLAYED_ALBUMS,
153 type=ConfigEntryType.BOOLEAN,
154 required=True,
155 default_value=True,
156 ),
157 ConfigEntry(
158 key=CONF_RECO_SIZE,
159 type=ConfigEntryType.INTEGER,
160 required=True,
161 default_value=10,
162 ),
163 ConfigEntry(
164 key=CONF_RAW_FILE,
165 type=ConfigEntryType.BOOLEAN,
166 required=False,
167 default_value=True,
168 ),
169 ConfigEntry(
170 key=CONF_PAGE_SIZE,
171 type=ConfigEntryType.INTEGER,
172 required=True,
173 default_value=200,
174 advanced=True,
175 ),
176 )
177
178 async def handle_async_init(self) -> None:
179 """Set up the music provider and test the connection."""
180 port = self.get_setup_value(CONF_PORT)
181 port = int(str(port)) if port is not None else 443
182 path = self.get_setup_value(CONF_PATH)
183
184 if path is None:
185 path = ""
186
187 api_key = self.get_setup_value(CONF_API_KEY)
188 username = self.get_setup_value(CONF_USERNAME)
189 password = self.get_setup_value(CONF_PASSWORD)
190
191 if api_key:
192 self.conn = SonicConnection(
193 str(self.get_setup_value(CONF_BASE_URL)),
194 api_key=str(api_key),
195 port=port,
196 server_path=str(path),
197 use_get=True,
198 app_name="Music Assistant",
199 )
200 elif username and password:
201 self.conn = SonicConnection(
202 str(self.get_setup_value(CONF_BASE_URL)),
203 username=str(username),
204 password=str(password),
205 legacy_auth=bool(self.config.get_value(CONF_ENABLE_LEGACY_AUTH)),
206 port=port,
207 server_path=str(path),
208 use_get=True,
209 app_name="Music Assistant",
210 )
211 else:
212 msg = f"No credentials for {self.get_setup_value(CONF_BASE_URL)}, provide an API key or username and password."
213 raise LoginFailed(
214 msg,
215 translation_key="connect_failed",
216 translation_owner=self.translation_owner,
217 translation_args=[self.get_setup_value(CONF_BASE_URL)],
218 )
219
220 try:
221 success = await self.conn.ping()
222 if not success:
223 raise CredentialError
224 except (AuthError, CredentialError) as e:
225 msg = (
226 f"Failed to connect to {self.get_setup_value(CONF_BASE_URL)}, check your settings."
227 )
228 raise LoginFailed(
229 msg,
230 translation_key="connect_failed",
231 translation_owner=self.translation_owner,
232 translation_args=[self.get_setup_value(CONF_BASE_URL)],
233 ) from e
234
235 try:
236 extensions: list[OpenSubsonicExtension] = await self.conn.get_open_subsonic_extensions()
237 for entry in extensions:
238 if entry.name == OpenSubsonicExtensions.SONG_LYRICS:
239 self._id_lyrics = True
240 elif entry.name == OpenSubsonicExtensions.GET_PODCAST_EPISODE:
241 self._direct_podcast_episode = True
242 except OSError:
243 self.logger.info("Failed to query server for OpenSubsonic extensions")
244
245 self._enable_podcasts = bool(self.config.get_value(CONF_ENABLE_PODCASTS))
246 self._enable_radio_stations = bool(self.config.get_value(CONF_ENABLE_RADIO_STATIONS))
247 self._show_faves = bool(self.config.get_value(CONF_RECO_FAVES))
248 self._show_new = bool(self.config.get_value(CONF_NEW_ALBUMS))
249 self._show_played = bool(self.config.get_value(CONF_PLAYED_ALBUMS))
250 self._reco_limit = int(str(self.config.get_value(CONF_RECO_SIZE)))
251 self._pagination_size = int(str(self.config.get_value(CONF_PAGE_SIZE)))
252 self._pagination_size = min(self._pagination_size, 500)
253 self._raw_file = bool(self.config.get_value(CONF_RAW_FILE))
254
255 async def unload(self, is_removed: bool = False) -> None:
256 """Unload the provider."""
257 await super().unload(is_removed)
258 await self.conn.cleanup()
259
260 @property
261 def is_streaming_provider(self) -> bool:
262 """
263 Return True if the provider is a streaming provider.
264
265 This literally means that the catalog is not the same as the library contents.
266 For local based providers (files, plex), the catalog is the same as the library content.
267 It also means that data is if this provider is NOT a streaming provider,
268 data cross instances is unique, the catalog and library differs per instance.
269
270 Setting this to True will only query one instance of the provider for search and lookups.
271 Setting this to False will query all instances of this provider for search and lookups.
272 """
273 return False
274
275 async def get_recommendations(self) -> list[RecommendationFolder]:
276 """
277 Get this provider's available recommendation rows, without items.
278
279 These can be favorited items, recently added albums, newest podcast episodes,
280 and most played albums. What is included is configured with the provider.
281 """
282 recos: list[RecommendationFolder] = []
283 if self._enable_podcasts:
284 recos.append(
285 RecommendationFolder(
286 item_id="subsonic_newest_podcasts",
287 provider=self.instance_id,
288 name="Newest Podcast Episodes",
289 translation_key="episodes_recently_added",
290 )
291 )
292 if self._show_faves:
293 recos.append(
294 RecommendationFolder(
295 item_id="subsonic_starred_albums",
296 provider=self.instance_id,
297 name="Starred Items",
298 translation_key="starred_items",
299 )
300 )
301 if self._show_new:
302 recos.append(
303 RecommendationFolder(
304 item_id="subsonic_new_albums",
305 provider=self.instance_id,
306 name="New Albums",
307 translation_key="recently_added_albums",
308 )
309 )
310 if self._show_played:
311 recos.append(
312 RecommendationFolder(
313 item_id="subsonic_most_played",
314 provider=self.instance_id,
315 name="Most Played Albums",
316 translation_key="most_played_albums",
317 )
318 )
319 return recos
320
321 async def get_recommendation_items(
322 self, item_id: str
323 ) -> UniqueList[MediaItemType | ItemMapping | BrowseFolder]:
324 """
325 Get the items for a single recommendation row.
326
327 :param item_id: The item_id of the row, as returned by get_recommendations.
328 """
329 folder: RecommendationFolder | None = None
330 if item_id == "subsonic_newest_podcasts" and self._enable_podcasts:
331 folder = await self._podcast_recommendations()
332 elif item_id == "subsonic_starred_albums" and self._show_faves:
333 folder = await self._favorites_recommendation()
334 elif item_id == "subsonic_new_albums" and self._show_new:
335 folder = await self._new_recommendations()
336 elif item_id == "subsonic_most_played" and self._show_played:
337 folder = await self._played_recommendations()
338 if folder is None:
339 return UniqueList()
340 return folder.items
341
342 async def resolve_image(self, path: str) -> bytes | Any:
343 """Return the image."""
344 self.logger.debug("Requesting cover art for '%s'", path)
345
346 try:
347 art = await self.conn.get_cover_art(path)
348 return await art.content.read()
349 except DataNotFoundError:
350 self.logger.warning("Unable to locate a cover image for %s", path)
351 return None
352
353 @use_cache(3600 * 3) # cache for 3 hours
354 async def search(
355 self, search_query: str, media_types: list[MediaType], limit: int = 20
356 ) -> SearchResults:
357 """Search the sonic library."""
358 artists = limit if MediaType.ARTIST in media_types else 0
359 albums = limit if MediaType.ALBUM in media_types else 0
360 songs = limit if MediaType.TRACK in media_types else 0
361 if not (artists or albums or songs):
362 return SearchResults()
363 answer = await self.conn.search3(
364 query=search_query,
365 artist_count=artists,
366 artist_offset=0,
367 album_count=albums,
368 album_offset=0,
369 song_count=songs,
370 song_offset=0,
371 )
372
373 if answer.artist:
374 ar = [
375 parse_artist(self.instance_id, entry, logger=self.logger) for entry in answer.artist
376 ]
377 else:
378 ar = []
379
380 if answer.album:
381 al = [parse_album(self.logger, self.instance_id, entry) for entry in answer.album]
382 else:
383 al = []
384
385 if answer.song:
386 tr = []
387 for entry in answer.song:
388 self._set_loudness(entry)
389 tr.append(parse_track(self.logger, self.instance_id, entry))
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 # rank on the publish date, so the order the server returns the episodes in does
657 # not decide the ordering
658 positions = rank_episodes_by_date([ep.publish_date for ep in channel.episode])
659 for position, episode in zip(positions, channel.episode, strict=True):
660 self._set_loudness(episode)
661 yield parse_epsiode(self.instance_id, episode, channel, position)
662
663 @use_cache(3600 * 3) # cache for 3 hours
664 async def get_podcast(self, prov_podcast_id: str) -> Podcast:
665 """Get full Podcast details by id."""
666 if not self._enable_podcasts:
667 msg = "Podcasts are currently disabled in the provider configuration"
668 raise ActionUnavailable(msg)
669
670 channels = await self.conn.get_podcasts(inc_episodes=True, pid=prov_podcast_id)
671
672 return parse_podcast(self.instance_id, channels[0])
673
674 async def get_library_podcasts(self) -> AsyncGenerator[Podcast]:
675 """Retrieve library/subscribed podcasts from the provider."""
676 if self._enable_podcasts:
677 channels = await self.conn.get_podcasts(inc_episodes=True)
678
679 for channel in channels:
680 yield parse_podcast(self.instance_id, channel)
681
682 @use_cache(3600 * 3) # cache for 3 hours
683 async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
684 """Get playlist tracks."""
685 result: list[Track] = []
686 if page > 0:
687 # paging not supported, we always return the whole list at once
688 return result
689 try:
690 sonic_playlist: SonicPlaylist = await self.conn.get_playlist(prov_playlist_id)
691 except (ParameterError, DataNotFoundError) as e:
692 msg = f"Playlist {prov_playlist_id} not found"
693 raise MediaNotFoundError(msg) from e
694
695 if not sonic_playlist.entry:
696 return result
697
698 for index, sonic_song in enumerate(sonic_playlist.entry, 1):
699 # A playlist can hold thousands of tracks, so we must not trigger a per-track
700 # metadata fetch here: parse_track derives the album reference from the playlist
701 # entry itself, and lyrics are fetched on demand when a track is played (get_track).
702 # Fetching album + lyrics per entry turned a single getPlaylist call into thousands
703 # of serial requests, making large playlists take minutes to start.
704 self._set_loudness(sonic_song)
705 track = parse_track(self.logger, self.instance_id, sonic_song)
706 track.position = index
707 result.append(track)
708 return result
709
710 @use_cache(3600 * 3) # cache for 3 hours
711 async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
712 """Get the top listed tracks for a specified artist."""
713 # We have seen top tracks requested for the UNKNOWN_ARTIST ID, protect against that
714 if prov_artist_id == UNKNOWN_ARTIST_ID or prov_artist_id.startswith(NAVI_VARIOUS_PREFIX):
715 return []
716
717 try:
718 sonic_artist: SonicArtist = await self.conn.get_artist(prov_artist_id)
719 except DataNotFoundError as e:
720 msg = f"Artist {prov_artist_id} not found"
721 raise MediaNotFoundError(msg) from e
722 songs: list[SonicItem] = await self.conn.get_top_songs(sonic_artist.name)
723 tracks = []
724 for entry in songs:
725 self._set_loudness(entry)
726 tracks.append(parse_track(self.logger, self.instance_id, entry))
727 return tracks
728
729 @use_cache(3600 * 3) # cache for 3 hours
730 async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
731 """Get tracks similar to selected track."""
732 try:
733 songs: list[SonicItem] = await self.conn.get_similar_songs(
734 iid=prov_track_id, count=limit
735 )
736 except DataNotFoundError as e:
737 # Subsonic returns an error here instead of an empty list, I don't think this
738 # should be an exception but there we are. Return an empty list because this
739 # exception means we didn't find anything similar.
740 self.logger.info(e)
741 return []
742 tracks = []
743 for entry in songs:
744 self._set_loudness(entry)
745 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(entry)
746 tracks.append(parse_track(self.logger, self.instance_id, entry, lyrics=lyrics))
747 return tracks
748
749 async def create_playlist(self, name: str, media_types: set[MediaType]) -> Playlist:
750 """Create a new empty playlist on the server."""
751 if not await self.conn.create_playlist(name=name):
752 raise ProviderPermissionDenied(
753 "Please ensure you have permission to create playlists on your server"
754 )
755 pls: list[SonicPlaylist] = await self.conn.get_playlists()
756 for pl in pls:
757 if pl.name == name:
758 return parse_playlist(self.instance_id, pl)
759 raise MediaNotFoundError(
760 f"Failed to create playlist with name '{name}'",
761 translation_key="create_playlist_failed",
762 translation_owner=self.translation_owner,
763 translation_args=[name],
764 )
765
766 async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
767 """
768 Append the listed tracks to the selected playlist.
769
770 Note that the configured user must own the playlist to edit this way.
771 """
772 try:
773 await self.conn.update_playlist(
774 lid=prov_playlist_id,
775 song_ids_to_add=prov_track_ids,
776 )
777 except SonicError as ex:
778 msg = f"Failed to add songs to {prov_playlist_id}, check your permissions."
779 raise ProviderPermissionDenied(msg) from ex
780
781 async def remove_playlist_tracks(
782 self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
783 ) -> None:
784 """Remove selected positions from the playlist."""
785 idx_to_remove = [pos - 1 for pos in positions_to_remove]
786 try:
787 await self.conn.update_playlist(
788 lid=prov_playlist_id,
789 song_indices_to_remove=idx_to_remove,
790 )
791 except SonicError as ex:
792 msg = f"Failed to remove songs from {prov_playlist_id}, check your permissions."
793 raise ProviderPermissionDenied(msg) from ex
794
795 async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
796 """Get the details needed to process a specified track."""
797 item: SonicItem | SonicEpisode
798 if media_type == MediaType.TRACK:
799 try:
800 item = await self.conn.get_song(item_id)
801 except (ParameterError, DataNotFoundError) as e:
802 msg = f"Item {item_id} not found"
803 raise MediaNotFoundError(msg) from e
804
805 mime_type = item.transcoded_content_type or item.content_type
806
807 self.logger.debug(
808 "Fetching stream details for id %s '%s' with format '%s'",
809 item.id,
810 item.title,
811 mime_type,
812 )
813
814 elif media_type == MediaType.PODCAST_EPISODE:
815 item = await self._get_podcast_episode(item_id)
816
817 mime_type = item.transcoded_content_type or item.content_type
818
819 self.logger.debug(
820 "Fetching stream details for podcast episode '%s' with format '%s'",
821 item.id,
822 item.content_type,
823 )
824 elif media_type == MediaType.RADIO:
825 async for station in self.get_library_radios():
826 if station.item_id == item_id:
827 return StreamDetails(
828 item_id=item_id,
829 provider=self.instance_id,
830 allow_seek=False,
831 can_seek=False,
832 media_type=MediaType.RADIO,
833 audio_format=AudioFormat(content_type=ContentType.UNKNOWN),
834 stream_type=StreamType.HTTP,
835 path=station.uri or "",
836 )
837 msg = f"Radio {item_id} not found"
838 raise MediaNotFoundError(msg)
839 else:
840 msg = f"Unsupported media type encountered '{media_type}'"
841 raise UnsupportedFeaturedException(msg)
842
843 fmat = "raw" if self._raw_file else None
844 url, _ = self.conn.get_stream_url(item.id, tformat=fmat, estimate_length=True)
845
846 return StreamDetails(
847 item_id=item.id,
848 provider=self.instance_id,
849 allow_seek=True,
850 can_seek=True,
851 media_type=media_type,
852 audio_format=AudioFormat(
853 content_type=ContentType.try_parse(mime_type),
854 sample_rate=item.sampling_rate or 44100,
855 bit_depth=item.bit_depth or 16,
856 channels=item.channel_count or 2,
857 ),
858 stream_type=StreamType.HTTP,
859 path=url,
860 duration=item.duration or 0,
861 )
862
863 async def on_played(
864 self,
865 media_type: MediaType,
866 prov_item_id: str,
867 fully_played: bool,
868 position: int,
869 media_item: MediaItemType,
870 is_playing: bool = False,
871 ) -> None:
872 """
873 Handle callback when a (playable) media item has been played.
874
875 This is called by the Queue controller when;
876 - a track has been fully played
877 - a track has been stopped (or skipped) after being played
878 - every 30s when a track is playing
879
880 Fully played is True when the track has been played to the end.
881
882 Position is the last known position of the track in seconds, to sync resume state.
883 When fully_played is set to false and position is 0,
884 the user marked the item as unplayed in the UI.
885
886 is_playing is True when the track is currently playing.
887
888 media_item is the full media item details of the played/playing track.
889 """
890 if media_type != MediaType.PODCAST_EPISODE:
891 # We don't handle audio books in this provider so this is the only resummable media
892 # type we should see.
893 return
894
895 _, ep_id = prov_item_id.split(EP_CHAN_SEP)
896
897 if fully_played:
898 # We completed the episode and should delete our bookmark
899 try:
900 await self.conn.delete_bookmark(mid=ep_id)
901 except DataNotFoundError:
902 # We probably raced with something else deleting this bookmark, not really a problem
903 self.logger.info("Bookmark for item '%s' has already been deleted.", ep_id)
904 return
905
906 # Otherwise, create a new bookmark for this item or update the existing one
907 # MA provides a position in seconds but expects it back in milliseconds
908 await self.conn.create_bookmark(
909 mid=ep_id,
910 position=position * 1000,
911 comment="Music Assistant Bookmark",
912 )
913
914 async def get_resume_position(
915 self, item_id: str, media_type: MediaType
916 ) -> tuple[bool, int, datetime | None]:
917 """
918 Get progress (resume point) details for the given Audiobook or Podcast episode.
919
920 This is a separate call from the regular get_item call to ensure the resume position
921 is always up-to-date and because a lot providers have this info present on a dedicated
922 endpoint.
923
924 Will be called right before playback starts to ensure the resume position is correct.
925
926 Returns a boolean with the fully_played status
927 and an integer with the resume position in ms.
928 """
929 if media_type != MediaType.PODCAST_EPISODE:
930 raise NotImplementedError("AudioBooks are not supported by the Open Subsonic provider")
931
932 _, ep_id = item_id.split(EP_CHAN_SEP)
933
934 bookmarks: list[SonicBookmark] = await self.conn.get_bookmarks()
935
936 for mark in bookmarks:
937 if mark.entry.id == ep_id:
938 return (
939 False,
940 mark.position,
941 datetime.fromisoformat(mark.created) if mark.created else None,
942 )
943 # If we get here, there is no bookmark
944 return (False, 0, None)
945
946 async def get_track_lyrics(self, track: SonicItem) -> tuple[str, bool] | None:
947 """
948 Get lyrics for a track.
949
950 Fetches lyrics from Subsonic server. Returns the lyrics text in LRC format
951 if the Lyrics are synced (have time stamp info) or raw text if not
952 """
953 # Server doesn't support to newer lyrics retrieval, fall back to the old one
954 if not self._id_lyrics:
955 try:
956 ly: SonicLyrics = await self.conn.get_lyrics(track.title, track.artist)
957 except DataNotFoundError:
958 self.logger.debug("Lyrics not found for '%s' by '%s'", track.title, track.artist)
959 return None
960 return (ly.value, False)
961
962 try:
963 lyrics: list[StructuredLyrics] = await self.conn.get_lyrics_by_song_id(track.id)
964 except DataNotFoundError:
965 self.logger.debug("Lyrics not found for '%s'", track.id)
966 return None
967 if not lyrics:
968 return None
969 return parse_structured_lyrics(lyrics[0])
970
971 async def _get_podcast_episode(self, eid: str) -> SonicEpisode:
972 chan_id, ep_id = eid.split(EP_CHAN_SEP)
973
974 if self._direct_podcast_episode:
975 try:
976 return await self.conn.get_podcast_episode(ep_id)
977 except DataNotFoundError as e:
978 msg = f"Can't find episode {ep_id} in podcast {chan_id}"
979 raise MediaNotFoundError(msg) from e
980
981 chan = await self.conn.get_podcasts(inc_episodes=True, pid=chan_id)
982
983 if not chan[0].episode:
984 raise MediaNotFoundError(f"Missing episode list for podcast channel '{chan[0].id}'")
985
986 for episode in chan[0].episode:
987 if episode.id == ep_id:
988 return episode
989
990 msg = f"Can't find episode {ep_id} in podcast {chan_id}"
991 raise MediaNotFoundError(msg)
992
993 def _set_loudness(self, item: SonicItem) -> None:
994 if item.replay_gain and item.replay_gain.track_gain is not None:
995 # Convert ReplayGain values (gain in dB) to integrated loudness (LUFS)
996 track_loudness = -18 - item.replay_gain.track_gain
997 album_loudness = (
998 -18 - item.replay_gain.album_gain
999 if item.replay_gain.album_gain is not None
1000 else None
1001 )
1002 self.mass.create_task(
1003 self.mass.streams.audio_analysis.set_track_loudness(
1004 item.id,
1005 self.instance_id,
1006 track_loudness,
1007 album_loudness,
1008 )
1009 )
1010
1011 async def _get_podcast_channel_async(self, chan_id: str) -> PodcastChannel | None:
1012 if cache := await self.mass.cache.get(
1013 key=chan_id,
1014 provider=self.instance_id,
1015 category=CACHE_CATEGORY_PODCAST_CHANNEL,
1016 base_class=PodcastChannel,
1017 ):
1018 return cache
1019 if channels := await self.conn.get_podcasts(inc_episodes=True, pid=chan_id):
1020 channel = channels[0]
1021 await self.mass.cache.set(
1022 key=chan_id,
1023 data=channel.to_dict(),
1024 provider=self.instance_id,
1025 expiration=600,
1026 category=CACHE_CATEGORY_PODCAST_CHANNEL,
1027 )
1028 return channel
1029 return None
1030
1031 @use_cache(3600 * 3, cache_checksum="v2", base_class=RecommendationFolder)
1032 async def _podcast_recommendations(self) -> RecommendationFolder:
1033 podcasts: RecommendationFolder = RecommendationFolder(
1034 item_id="subsonic_newest_podcasts",
1035 provider=self.instance_id,
1036 name="Newest Podcast Episodes",
1037 translation_key="episodes_recently_added",
1038 )
1039 sonic_episodes = await self.conn.get_newest_podcasts(count=self._reco_limit)
1040 for ep in sonic_episodes:
1041 if channel_info := await self._get_podcast_channel_async(ep.channel_id):
1042 self._set_loudness(ep)
1043 podcasts.items.append(parse_epsiode(self.instance_id, ep, channel_info))
1044 return podcasts
1045
1046 @use_cache(3600 * 3, cache_checksum="v2", base_class=RecommendationFolder)
1047 async def _favorites_recommendation(self) -> RecommendationFolder:
1048 faves: RecommendationFolder = RecommendationFolder(
1049 item_id="subsonic_starred_albums",
1050 provider=self.instance_id,
1051 name="Starred Items",
1052 translation_key="starred_items",
1053 )
1054 starred = await self.conn.get_starred2()
1055 if starred.album:
1056 for sonic_album in starred.album[: self._reco_limit]:
1057 faves.items.append(parse_album(self.logger, self.instance_id, sonic_album))
1058 if starred.artist:
1059 for sonic_artist in starred.artist[: self._reco_limit]:
1060 faves.items.append(parse_artist(self.instance_id, sonic_artist, logger=self.logger))
1061 if starred.song:
1062 for sonic_song in starred.song[: self._reco_limit]:
1063 self._set_loudness(sonic_song)
1064 lyrics: tuple[str, bool] | None = await self.get_track_lyrics(sonic_song)
1065 faves.items.append(
1066 parse_track(self.logger, self.instance_id, sonic_song, lyrics=lyrics)
1067 )
1068 return faves
1069
1070 @use_cache(3600 * 3, cache_checksum="v2", base_class=RecommendationFolder)
1071 async def _new_recommendations(self) -> RecommendationFolder:
1072 new_stuff: RecommendationFolder = RecommendationFolder(
1073 item_id="subsonic_new_albums",
1074 provider=self.instance_id,
1075 name="New Albums",
1076 translation_key="recently_added_albums",
1077 )
1078 new_albums = await self.conn.get_album_list2(ltype="newest", size=self._reco_limit)
1079 for sonic_album in new_albums:
1080 new_stuff.items.append(parse_album(self.logger, self.instance_id, sonic_album))
1081 return new_stuff
1082
1083 @use_cache(3600 * 3, cache_checksum="v2", base_class=RecommendationFolder)
1084 async def _played_recommendations(self) -> RecommendationFolder:
1085 recent: RecommendationFolder = RecommendationFolder(
1086 item_id="subsonic_most_played",
1087 provider=self.instance_id,
1088 name="Most Played Albums",
1089 translation_key="most_played_albums",
1090 )
1091 albums = await self.conn.get_album_list2(ltype="frequent", size=self._reco_limit)
1092 for sonic_album in albums:
1093 recent.items.append(parse_album(self.logger, self.instance_id, sonic_album))
1094 return recent
1095