--- /dev/null
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg preserveAspectRatio="xMidYMid meet" version="1.0" viewBox="0 0 26.986579 20.7681" id="svg73"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <defs id="defs77" />
+ <metadata id="metadata67">Created by potrace 1.16, written by Peter Selinger 2001-2019</metadata>
+ <g fill="#000000" stroke="none" id="g71" transform="matrix(0.1,0,0,0.1,-1.5234206,-4.6112125)">
+ <path d="m 830,2529 c -13,-6 -28,-15 -32,-22 -4,-7 -5,-123 -1,-258 l 6,-246 -27,-24 c -28,-24 -28,-24 -24,-139 l 3,-115 -90,-44 -90,-43 -45,18 c -25,9 -68,19 -96,22 l -51,4 -1,-84 c 0,-69 -6,-101 -37,-188 -20,-58 -40,-120 -45,-139 -11,-42 -14,-45 -77,-65 -56,-19 -82,-41 -66,-57 5,-5 34,-22 63,-37 49,-26 55,-32 66,-76 17,-65 39,-108 74,-146 16,-18 30,-34 30,-37 0,-2 -20,-29 -45,-59 -82,-102 -84,-173 -4,-194 65,-18 169,6 251,57 46,28 44,28 131,-1 301,-100 636,-168 931,-188 348,-24 635,25 844,144 170,97 316,295 338,457 l 7,54 -89,-5 c -56,-3 -129,-17 -199,-36 -228,-65 -330,-83 -520,-91 -185,-8 -373,1 -403,19 -12,6 31,10 138,10 278,1 487,34 720,115 121,42 209,57 288,50 l 73,-7 -6,29 c -30,132 -58,183 -159,291 -63,66 -163,142 -253,192 -25,13 -43,32 -45,44 -4,30 -64,26 -388,-25 -128,-21 -179,-23 -440,-23 -424,0 -451,13 -74,35 311,18 442,36 557,79 l 58,22 -3,66 -3,66 -50,12 c -245,59 -240,58 -335,40 -130,-26 -230,-33 -344,-26 -88,6 -157,23 -144,36 3,2 94,8 204,14 109,5 221,16 247,23 l 48,12 -23,21 c -63,58 -422,78 -581,33 -21,-5 -51,-25 -67,-42 -16,-18 -31,-29 -35,-26 -3,4 -12,80 -20,170 -28,317 -24,297 -58,309 -36,12 -74,12 -107,-1 z M 523,1216 c 23,-59 18,-198 -8,-232 -13,-18 -15,-18 -37,4 -21,20 -23,32 -23,116 0,77 3,98 18,114 20,21 42,20 50,-2 z m 329,-13 c 30,-35 39,-65 39,-128 0,-100 -43,-171 -89,-146 -30,16 -41,35 -52,89 -23,124 46,249 102,185 z m 455,-3 c 114,-69 98,-343 -22,-366 -48,-9 -81,15 -111,79 -51,111 -12,268 76,300 23,8 23,8 57,-13 z" transform="matrix(0.1,0,0,-0.1,0,300)" id="path69" />
+ </g>
+ <path d="m 15.774389,20.742438 c -0.0486,-0.003 -0.23661,-0.0124 -0.41779,-0.0219 -1.98864,-0.10441 -4.37252,-0.47627 -6.6443396,-1.03643 -1.05621,-0.26043 -1.8816,-0.4979 -3.01202,-0.86657 -0.28567,-0.0932 -0.57681,-0.18103 -0.64698,-0.19525 -0.21922,-0.0444 -0.33365,-0.0113 -0.67148,0.19436 -0.44499,0.27088 -0.92696,0.46072 -1.43447,0.565 -0.19236,0.0395 -0.24539,0.0437 -0.55803,0.0438 -0.30639,1.5e-4 -0.36002,-0.004 -0.47403,-0.0351 -0.2776,-0.0762 -0.46947,-0.21041 -0.55171,-0.38585 -0.0386,-0.0824 -0.0428,-0.10955 -0.0428,-0.27615 0,-0.15023 0.007,-0.20582 0.0373,-0.29727 0.10221,-0.30844 0.29913,-0.63087 0.66245,-1.08462 0.11676,-0.14583 0.24751,-0.31507 0.29056,-0.3761 0.0782,-0.11088 0.0782,-0.11101 0.0502,-0.15523 -0.0155,-0.0243 -0.11127,-0.13976 -0.2129,-0.25647 -0.10164,-0.11671 -0.22331,-0.26378 -0.2704,-0.32682 -0.21167,-0.28341 -0.38021,-0.65818 -0.51809,-1.15205 -0.0455,-0.16295 -0.10495,-0.33845 -0.13213,-0.39002 -0.067,-0.12721 -0.17924,-0.22213 -0.42094001,-0.35617 -0.74619,-0.4138 -0.79556,-0.44676 -0.79556,-0.53116 0,-0.15388 0.25915,-0.31544 0.80603,-0.5025 0.32205001,-0.11016 0.41634001,-0.15412 0.49399001,-0.23029 0.0727,-0.0713 0.0976,-0.13135 0.20285,-0.48945 0.049,-0.16685 0.21149,-0.66853 0.36098,-1.11484 0.14949,-0.4463 0.28977,-0.88377 0.31174,-0.97214 0.0957,-0.38495 0.12159,-0.6881505 0.12197,-1.4266405 l 2.4e-4,-0.49466 0.0763,0.008 c 0.042,0.005 0.17033,0.0161 0.28522,0.0253 0.45078,0.0363 0.86347,0.1339 1.29729,0.3068 0.13022,0.0519 0.24674,0.0944 0.25894,0.0944 0.0248,0 0.59115,-0.26825 1.32572,-0.62798 l 0.48775,-0.23886 -0.01,-0.21334 c -0.0216,-0.47754 -0.0458,-1.59604 -0.0388,-1.79036 0.0108,-0.29854 0.0271,-0.3294 0.30728,-0.58151 l 0.2188,-0.19687 -0.008,-0.20419 c -0.01,-0.24922 -0.0662,-2.6984 -0.0791,-3.44201 -0.0119,-0.68278 0.01,-1.34978999 0.0458,-1.40470999 0.0366,-0.0558 0.24448,-0.18018 0.37593,-0.22489 0.30102,-0.10238 0.62538,-0.0976 0.99309,0.0145 0.22209,0.0677 0.25911,0.1237 0.31908,0.48224 0.0439,0.26247 0.0646,0.47201999 0.24263,2.45045999 0.0987,1.09677 0.18328,1.82051 0.21675,1.85422 0.0302,0.0303 0.126,-0.0354 0.31492,-0.21612 0.37764,-0.36118 0.59736,-0.46509 1.2035596,-0.56917 0.4912,-0.0844 0.92157,-0.11905 1.63095,-0.13154 1.60112,-0.0282 3.12332,0.1867 3.59099,0.50693 0.13462,0.0922 0.30763,0.2562 0.28057,0.26599 -0.0127,0.005 -0.14244,0.0377 -0.28827,0.0735 -0.478,0.11743 -1.21572,0.1923 -2.69148,0.27315 -1.08743,0.0596 -1.94049,0.11913 -1.97031,0.13756 -0.0278,0.0172 -0.0134,0.0694 0.0282,0.10207 0.0559,0.044 0.25224,0.10491 0.49346,0.15319 0.44017,0.0881 0.81776,0.12142 1.49691,0.13215 1.06497,0.0168 1.71485,-0.0502 3.09319,-0.31892 0.24372,-0.0475 0.29045,-0.0517 0.57847,-0.0513 0.36866,4.5e-4 0.5135,0.0225 1.25074,0.19069 0.56954,0.12993 1.76773,0.41789 1.77677,0.427 0.005,0.006 0.0474,0.84742 0.0604,1.21084 l 0.003,0.0954 -0.37666,0.14234 c -1.35533,0.5122 -2.36506,0.65962 -6.0328,0.8808 -1.26159,0.0761 -2.2227,0.16127 -2.34533,0.20789 -0.25973,0.0988 0.63341,0.13407 3.39783,0.13437 2.86908,3e-4 2.92927,-0.003 4.76432,-0.28638 1.75196,-0.27025 2.34828,-0.34766 2.86415,-0.3718 0.34046,-0.0159 0.53034,0.0246 0.57662,0.12316 0.0102,0.0216 0.033,0.0719 0.0507,0.11167 0.0699,0.15677 0.18476,0.24856 0.6542,0.52278 0.86218,0.50362 1.81319,1.25343 2.40035,1.8925305 0.53756,0.58511 0.81466,0.96149 1.03144,1.40098 0.20559,0.41678 0.36224,0.92156 0.50542,1.62863 l 0.0109,0.0539 -0.0619,-0.009 c -0.034,-0.005 -0.2246,-0.0248 -0.42344,-0.0435 -0.46224,-0.0436 -0.99229,-0.037 -1.34173,0.0166 -0.55118,0.0845 -0.98895,0.20022 -1.88002,0.49678 -1.42126,0.47302 -2.5305,0.73833 -3.80021,0.90895 -0.9804,0.13173 -1.74288,0.17976 -3.20734,0.20203 -1.20158,0.0183 -1.49233,0.0367 -1.4813,0.094 0.0159,0.0823 0.52051,0.16225 1.35441,0.21448 0.43289,0.0271 2.21905,0.0269 2.74772,-3.7e-4 1.84968,-0.0954 2.80122,-0.25549 4.7563,-0.80043 0.88324,-0.24619 1.39478,-0.35787 1.97643,-0.43151 0.19283,-0.0244 1.26342,-0.0919 1.27495,-0.0803 0.0122,0.0122 -0.0801,0.647 -0.11998,0.8257 -0.0895,0.40107 -0.19664,0.6917 -0.42401,1.15055 -0.31551,0.63675 -0.69981,1.19031 -1.21592,1.75148 -0.68633,0.74625 -1.44784,1.29474 -2.39935,1.72816 -1.41111,0.64278 -3.05174,1.00403 -5.02946,1.10742 -0.27395,0.0143 -1.68553,0.0187 -1.92822,0.006 z m -4.33851,-3.71729 c 0.45377,-0.15271 0.7664,-0.67598 0.86884,-1.45425 0.0314,-0.23844 0.0106,-0.75949 -0.0397,-0.99806 -0.12859,-0.60901 -0.37493,-0.99161 -0.8005,-1.24329 -0.21217,-0.12548 -0.25928,-0.13979 -0.37491,-0.11391 -0.11626,0.026 -0.31272,0.12581 -0.42935,0.21808 -0.32949,0.26069 -0.58529,0.79864 -0.65561,1.37876 -0.0273,0.22556 -0.0108,0.71513 0.0307,0.90664 0.0962,0.44435 0.31246,0.87966 0.55658,1.12021 0.10616,0.1046 0.2591,0.19259 0.37802,0.21747 0.11487,0.024 0.34744,0.008 0.46599,-0.0317 z m -4.5774596,-0.92019 c 0.22342,-0.11565 0.39532,-0.4376 0.49108,-0.9197 0.0375,-0.18907 0.0419,-0.93208 0.007,-1.10874 -0.0482,-0.24038 -0.1427,-0.45269 -0.2824,-0.63424 -0.14374,-0.1868 -0.25421,-0.25783 -0.40002,-0.25721 -0.30818,0.001 -0.59777,0.42986 -0.711,1.05214 -0.0404,0.22195 -0.0407,0.72625 -6.7e-4,0.93198 0.10145,0.52074 0.2242,0.75757 0.46609,0.89924 0.17966,0.10523 0.28106,0.11384 0.4304,0.0365 z m -3.29858,-0.46949 c 0.16836,-0.1521 0.25974,-0.49155 0.29098,-1.08087 0.0336,-0.63444 -0.0604,-1.28075 -0.20914,-1.43709 -0.0536,-0.0564 -0.0654,-0.0613 -0.1452,-0.0612 -0.14852,2.3e-4 -0.3154,0.12196 -0.38596,0.28147 -0.0701,0.15838 -0.0846,0.33076 -0.0853,1.0119 -7.6e-4,0.71065 0.009,0.82109 0.0875,0.98216 0.0648,0.13305 0.29235,0.35168 0.37323,0.35855 0.007,5.2e-4 0.0399,-0.0242 0.0739,-0.0549 z" id="path196" style="fill:#ffcc00;stroke-width:0.0160685" />
+</svg>
--- /dev/null
+"""The provider class for Open Subsonic."""
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncGenerator, Callable
+from typing import Any
+
+from libopensonic.connection import Connection as SonicConnection
+from libopensonic.errors import (
+ AuthError,
+ CredentialError,
+ DataNotFoundError,
+ ParameterError,
+ SonicError,
+)
+from libopensonic.media import Album as SonicAlbum
+from libopensonic.media import AlbumInfo as SonicAlbumInfo
+from libopensonic.media import Artist as SonicArtist
+from libopensonic.media import ArtistInfo as SonicArtistInfo
+from libopensonic.media import Playlist as SonicPlaylist
+from libopensonic.media import PodcastChannel as SonicPodcastChannel
+from libopensonic.media import PodcastEpisode as SonicPodcastEpisode
+from libopensonic.media import Song as SonicSong
+
+from music_assistant.common.models.enums import ContentType, ImageType, MediaType, ProviderFeature
+from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError
+from music_assistant.common.models.media_items import (
+ Album,
+ AlbumTrack,
+ AlbumType,
+ Artist,
+ AudioFormat,
+ ItemMapping,
+ MediaItemImage,
+ Playlist,
+ PlaylistTrack,
+ ProviderMapping,
+ SearchResults,
+ StreamDetails,
+ Track,
+)
+from music_assistant.constants import CONF_PASSWORD, CONF_PATH, CONF_PORT, CONF_USERNAME
+from music_assistant.server.models.music_provider import MusicProvider
+
+CONF_BASE_URL = "baseURL"
+CONF_ENABLE_PODCASTS = "enable_podcasts"
+
+
+class OpenSonicProvider(MusicProvider):
+ """Provider for Open Subsonic servers."""
+
+ _conn: SonicConnection = None
+ _enable_podcasts: bool = True
+
+ async def handle_setup(self) -> None:
+ """Set up the music provider and test the connection."""
+ port = self.config.get_value(CONF_PORT)
+ if port is None:
+ port = 443
+ path = self.config.get_value(CONF_PATH)
+ if path is None:
+ path = ""
+ self._conn = SonicConnection(
+ self.config.get_value(CONF_BASE_URL),
+ username=self.config.get_value(CONF_USERNAME),
+ password=self.config.get_value(CONF_PASSWORD),
+ port=port,
+ serverPath=path,
+ appName="Music Assistant",
+ )
+ try:
+ if not self._conn.ping():
+ raise LoginFailed(
+ f"Failed to connect to {self.config.get_value(CONF_BASE_URL)}, "
+ "check your settings."
+ )
+ except (AuthError, CredentialError) as e:
+ raise LoginFailed(
+ f"Failed to connect to {self.config.get_value(CONF_BASE_URL)}, check your settings."
+ ) from e
+ self._enable_podcasts = self.config.get_value(CONF_ENABLE_PODCASTS)
+
+ @property
+ def supported_features(self) -> tuple[ProviderFeature, ...]:
+ """Return a list of supported features."""
+ return (
+ ProviderFeature.LIBRARY_ARTISTS,
+ ProviderFeature.LIBRARY_ALBUMS,
+ ProviderFeature.LIBRARY_TRACKS,
+ ProviderFeature.LIBRARY_PLAYLISTS,
+ ProviderFeature.BROWSE,
+ ProviderFeature.SEARCH,
+ ProviderFeature.ARTIST_ALBUMS,
+ )
+
+ @property
+ def is_streaming_provider(self) -> bool:
+ """
+ Return True if the provider is a streaming provider.
+
+ This literally means that the catalog is not the same as the library contents.
+ For local based providers (files, plex), the catalog is the same as the library content.
+ It also means that data is if this provider is NOT a streaming provider,
+ data cross instances is unique, the catalog and library differs per instance.
+
+ Setting this to True will only query one instance of the provider for search and lookups.
+ Setting this to False will query all instances of this provider for search and lookups.
+ """
+ return False
+
+ def _get_item_mapping(self, media_type: MediaType, key: str, name: str) -> ItemMapping:
+ return ItemMapping(
+ media_type=media_type,
+ item_id=key,
+ provider=self.instance_id,
+ name=name,
+ )
+
+ def _parse_podcast_artist(self, sonic_channel: SonicPodcastChannel) -> Artist:
+ artist = Artist(
+ item_id=sonic_channel.id,
+ name=sonic_channel.title,
+ provider=self.instance_id,
+ provider_mappings={
+ ProviderMapping(
+ item_id=sonic_channel.id,
+ provider_domain=self.domain,
+ provider_instance=self.instance_id,
+ )
+ },
+ )
+ if sonic_channel.description is not None:
+ artist.metadata.description = sonic_channel.description
+ if sonic_channel.original_image_url is not None:
+ artist.metadata.images = [
+ MediaItemImage(type=ImageType.THUMB, path=sonic_channel.original_image_url)
+ ]
+ return artist
+
+ def _parse_podcast_album(self, sonic_channel: SonicPodcastChannel) -> Album:
+ album = Album(
+ item_id=sonic_channel.id,
+ provider=self.instance_id,
+ name=sonic_channel.title,
+ provider_mappings={
+ ProviderMapping(
+ item_id=sonic_channel.id,
+ provider_domain=self.domain,
+ provider_instance=self.instance_id,
+ available=True,
+ )
+ },
+ album_type=AlbumType.PODCAST,
+ )
+ return album
+
+ def _parse_podcast_episode(
+ self, sonic_episode: SonicPodcastEpisode, sonic_channel: SonicPodcastChannel
+ ) -> Track:
+ return Track(
+ item_id=sonic_episode.id,
+ provider=self.instance_id,
+ name=sonic_episode.title,
+ album=self._parse_podcast_album(sonic_channel=sonic_channel),
+ artists=[self._parse_podcast_artist(sonic_channel=sonic_channel)],
+ duration=sonic_episode.duration if sonic_episode.duration is not None else 0,
+ provider_mappings={
+ ProviderMapping(
+ item_id=sonic_episode.id,
+ provider_domain=self.domain,
+ provider_instance=self.instance_id,
+ available=True,
+ )
+ },
+ )
+
+ async def _get_podcast_artists(self) -> list[Artist]:
+ if not self._enable_podcasts:
+ return []
+
+ sonic_channels = await self._run_async(self._conn.getPodcasts, incEpisodes=False)
+ artists = []
+ for channel in sonic_channels:
+ artists.append(self._parse_podcast_artist(channel))
+ return artists
+
+ async def _get_podcasts(self) -> list[SonicPodcastChannel]:
+ if not self._enable_podcasts:
+ return []
+ return await self._run_async(self._conn.getPodcasts, incEpisodes=True)
+
+ def _parse_artist(
+ self, sonic_artist: SonicArtist, sonic_info: SonicArtistInfo = None
+ ) -> Artist:
+ artist = Artist(
+ item_id=sonic_artist.id,
+ name=sonic_artist.name,
+ provider=self.domain,
+ provider_mappings={
+ ProviderMapping(
+ item_id=sonic_artist.id,
+ provider_domain=self.domain,
+ provider_instance=self.instance_id,
+ )
+ },
+ )
+ artist.metadata.images = [
+ MediaItemImage(
+ type=ImageType.THUMB, path=sonic_artist.cover_id, provider=self.instance_id
+ )
+ ]
+ if sonic_info:
+ if sonic_info.biography:
+ artist.metadata.description = sonic_info.biography
+ if sonic_info.small_url:
+ artist.metadata.images.append(
+ MediaItemImage(type=ImageType.THUMB, path=sonic_info.small_url)
+ )
+ return artist
+
+ def _parse_album(self, sonic_album: SonicAlbum, sonic_info: SonicAlbumInfo = None) -> Album:
+ album_id = sonic_album.id
+ album = Album(
+ item_id=album_id,
+ provider=self.domain,
+ name=sonic_album.name,
+ provider_mappings={
+ ProviderMapping(
+ item_id=album_id,
+ provider_domain=self.domain,
+ provider_instance=self.instance_id,
+ )
+ },
+ year=sonic_album.year,
+ )
+ album.metadata.images = [
+ MediaItemImage(
+ type=ImageType.THUMB, path=sonic_album.cover_id, provider=self.instance_id
+ ),
+ ]
+ album.artists.append(
+ self._get_item_mapping(MediaType.ARTIST, sonic_album.artist_id, sonic_album.artist)
+ )
+
+ if sonic_info:
+ if sonic_info.small_url:
+ album.metadata.images.append(
+ MediaItemImage(type=ImageType.THUMB, path=sonic_info.small_url)
+ )
+ if sonic_info.notes:
+ album.metadata.description = sonic_info.notes
+
+ return album
+
+ def _parse_track(
+ self, sonic_song: SonicSong, extra_init_kwargs: dict[str, Any] | None = None
+ ) -> AlbumTrack | PlaylistTrack:
+ if extra_init_kwargs and "position" in extra_init_kwargs:
+ track_class = PlaylistTrack
+ else:
+ track_class = AlbumTrack
+ track = track_class(
+ item_id=sonic_song.id,
+ provider=self.instance_id,
+ name=sonic_song.title,
+ album=self._get_item_mapping(MediaType.ALBUM, sonic_song.album_id, sonic_song.album),
+ duration=sonic_song.duration if sonic_song.duration is not None else 0,
+ **extra_init_kwargs or {},
+ provider_mappings={
+ ProviderMapping(
+ item_id=sonic_song.id,
+ provider_domain=self.domain,
+ provider_instance=self.instance_id,
+ available=True,
+ audio_format=AudioFormat(
+ content_type=ContentType.try_parse(sonic_song.content_type)
+ ),
+ )
+ },
+ )
+
+ if not extra_init_kwargs:
+ track.track_number = int(sonic_song.track) if sonic_song.track is not None else 1
+
+ track.artists.append(
+ self._get_item_mapping(MediaType.ARTIST, sonic_song.artist_id, sonic_song.artist)
+ )
+ for entry in sonic_song.artists:
+ if entry.id == sonic_song.artist_id:
+ continue
+ track.artists.append(self._get_item_mapping(MediaType.ARTIST, entry.id, entry.name))
+ return track
+
+ def _parse_playlist(self, sonic_playlist: SonicPlaylist) -> Playlist:
+ playlist = Playlist(
+ item_id=sonic_playlist.id,
+ provider=self.domain,
+ name=sonic_playlist.name,
+ is_editable=True,
+ provider_mappings={
+ ProviderMapping(
+ item_id=sonic_playlist.id,
+ provider_domain=self.domain,
+ provider_instance=self.instance_id,
+ )
+ },
+ )
+ playlist.metadata.images = [
+ MediaItemImage(
+ type=ImageType.THUMB, path=sonic_playlist.cover_id, provider=self.instance_id
+ )
+ ]
+ return playlist
+
+ async def _run_async(self, call: Callable, *args, **kwargs):
+ return await self.mass.create_task(call, *args, **kwargs)
+
+ async def resolve_image(self, path: str) -> str | bytes | AsyncGenerator[bytes, None]:
+ """Return the image."""
+ with self._conn.getCoverArt(path) as art:
+ return art.content
+
+ async def search(
+ self, search_query: str, media_types: list[MediaType] | None = None, limit: int = 20
+ ) -> SearchResults:
+ """Search the sonic library."""
+ artists = limit
+ albums = limit
+ songs = limit
+ if media_types:
+ if MediaType.ARTIST not in media_types:
+ artists = 0
+ if MediaType.ALBUM not in media_types:
+ albums = 0
+ if MediaType.TRACK not in media_types:
+ songs = 0
+ answer = await self._run_async(
+ self._conn.search3,
+ query=search_query,
+ artistCount=artists,
+ artistOffset=0,
+ albumCount=albums,
+ albumOffset=0,
+ songCount=songs,
+ songOffset=0,
+ musicFolderId=None,
+ )
+ return SearchResults(
+ artists=[self._parse_artist(entry) for entry in answer["artists"]],
+ albums=[self._parse_album(entry) for entry in answer["albums"]],
+ tracks=[self._parse_track(entry) for entry in answer["songs"]],
+ )
+
+ async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
+ """Provide a generator for reading all artists."""
+ indices = await self._run_async(self._conn.getArtists)
+ for index in indices:
+ for artist in index.artists:
+ yield self._parse_artist(artist)
+
+ async def get_library_albums(self) -> AsyncGenerator[Album, None]:
+ """
+ Provide a generator for reading all artists.
+
+ Note the pagination, the open subsonic docs say that this method is limited to
+ returning 500 items per invocation.
+ """
+ offset = 0
+ size = 500
+ albums = await self._run_async(
+ self._conn.getAlbumList2, ltype="alphabeticalByArtist", size=size, offset=offset
+ )
+ while albums:
+ for album in albums:
+ yield self._parse_album(album)
+ offset += size
+ albums = await self._run_async(
+ self._conn.getAlbumList2, ltype="alphabeticalByArtist", size=size, offset=offset
+ )
+
+ async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
+ """Provide a generator for library playlists."""
+ results = await self._run_async(self._conn.getPlaylists)
+ for entry in results:
+ yield self._parse_playlist(entry)
+
+ async def get_library_tracks(self) -> AsyncGenerator[Track | AlbumTrack, None]:
+ """
+ Provide a generator for library tracks.
+
+ Note the lack of item count on this method.
+ """
+ results = await self._run_async(
+ self._conn.search3, query="", artistCount=0, albumCount=0, songCount=999999
+ )
+ for entry in results["songs"]:
+ yield self._parse_track(entry)
+
+ async def get_album(self, prov_album_id: str) -> Album:
+ """Return the requested Album."""
+ try:
+ sonic_album: SonicAlbum = await self._run_async(self._conn.getAlbum, prov_album_id)
+ sonic_info = await self._run_async(self._conn.getAlbumInfo2, aid=prov_album_id)
+ except (ParameterError, DataNotFoundError) as e:
+ if self._enable_podcasts:
+ # This might actually be a 'faked' album from podcasts, try that before giving up
+ try:
+ sonic_channel = await self._run_async(
+ self._conn.getPodcasts, incEpisodes=False, pid=prov_album_id
+ )
+ return self._parse_podcast_album(sonic_channel=sonic_channel)
+ except SonicError:
+ pass
+ raise MediaNotFoundError(f"Album {prov_album_id} not found") from e
+
+ return self._parse_album(sonic_album, sonic_info)
+
+ async def get_album_tracks(self, prov_album_id: str) -> list[AlbumTrack]:
+ """Return a list of tracks on the specified Album."""
+ try:
+ sonic_album: SonicAlbum = await self._run_async(self._conn.getAlbum, prov_album_id)
+ except (ParameterError, DataNotFoundError) as e:
+ raise MediaNotFoundError(f"Album {prov_album_id} not found") from e
+ tracks = []
+ for sonic_song in sonic_album.songs:
+ tracks.append(self._parse_track(sonic_song))
+ return tracks
+
+ async def get_artist(self, prov_artist_id: str) -> Artist:
+ """Return the requested Artist."""
+ try:
+ sonic_artist: SonicArtist = await self._run_async(
+ self._conn.getArtist, artist_id=prov_artist_id
+ )
+ sonic_info = await self._run_async(self._conn.getArtistInfo2, aid=prov_artist_id)
+ except (ParameterError, DataNotFoundError) as e:
+ if self._enable_podcasts:
+ # This might actually be a 'faked' artist from podcasts, try that before giving up
+ try:
+ sonic_channel = await self._run_async(
+ self._conn.getPodcasts, incEpisodes=False, pid=prov_artist_id
+ )
+ return self._parse_podcast_artist(sonic_channel=sonic_channel[0])
+ except SonicError:
+ pass
+ raise MediaNotFoundError(f"Artist {prov_artist_id} not found") from e
+ return self._parse_artist(sonic_artist, sonic_info)
+
+ async def get_track(self, prov_track_id: str) -> Track:
+ """Return the specified track."""
+ try:
+ sonic_song: SonicSong = await self._run_async(self._conn.getSong, prov_track_id)
+ except (ParameterError, DataNotFoundError) as e:
+ raise MediaNotFoundError(f"Item {prov_track_id} not found") from e
+ return self._parse_track(sonic_song)
+
+ async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
+ """Return a list of all Albums by specified Artist."""
+ try:
+ sonic_artist: SonicArtist = await self._run_async(self._conn.getArtist, prov_artist_id)
+ except (ParameterError, DataNotFoundError) as e:
+ raise MediaNotFoundError(f"Album {prov_artist_id} not found") from e
+ albums = []
+ for entry in sonic_artist.albums:
+ albums.append(self._parse_album(entry))
+ return albums
+
+ async def get_playlist(self, prov_playlist_id) -> Playlist:
+ """Return the specified Playlist."""
+ try:
+ sonic_playlist: SonicPlaylist = await self._run_async(
+ self._conn.getPlaylist, prov_playlist_id
+ )
+ except (ParameterError, DataNotFoundError) as e:
+ raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found") from e
+ return self._parse_playlist(sonic_playlist)
+
+ async def get_playlist_tracks(self, prov_playlist_id) -> AsyncGenerator[Track, None]:
+ """Provide a generator for the tracks on a specified Playlist."""
+ try:
+ sonic_playlist: SonicPlaylist = await self._run_async(
+ self._conn.getPlaylist, prov_playlist_id
+ )
+ except (ParameterError, DataNotFoundError) as e:
+ raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found") from e
+ for index, sonic_song in enumerate(sonic_playlist.songs):
+ yield self._parse_track(sonic_song, {"position": index + 1})
+
+ async def get_stream_details(self, item_id: str) -> StreamDetails | None:
+ """Get the details needed to process a specified track."""
+ try:
+ sonic_song: SonicSong = await self._run_async(self._conn.getSong, item_id)
+ except (ParameterError, DataNotFoundError) as e:
+ raise MediaNotFoundError(f"Item {item_id} not found") from e
+ mime_type = sonic_song.content_type
+ if mime_type.endswith("mpeg"):
+ mime_type = sonic_song.suffix
+ return StreamDetails(
+ item_id=sonic_song.id,
+ provider=self.instance_id,
+ audio_format=AudioFormat(content_type=ContentType.try_parse(mime_type)),
+ duration=sonic_song.duration if sonic_song.duration is not None else 0,
+ )
+
+ async def get_audio_stream(
+ self, streamdetails: StreamDetails, seek_position: int = 0
+ ) -> AsyncGenerator[bytes, None]:
+ """Provide a generator for the stream data."""
+ audio_buffer = asyncio.Queue(1)
+
+ def _streamer():
+ with self._conn.stream(
+ streamdetails.item_id, timeOffset=seek_position, estimateContentLength=True
+ ) as stream:
+ for chunk in stream.iter_content(chunk_size=40960):
+ asyncio.run_coroutine_threadsafe(
+ audio_buffer.put(chunk), self.mass.loop
+ ).result()
+ # send empty chunk when we're done
+ asyncio.run_coroutine_threadsafe(audio_buffer.put(b""), self.mass.loop).result()
+
+ # fire up an executor thread to put the audio chunks (threadsafe) on the audio buffer
+ streamer_task = self.mass.loop.run_in_executor(None, _streamer)
+ try:
+ while True:
+ # keep reading from the audio buffer until there is no more data
+ chunk = await audio_buffer.get()
+ if chunk == b"":
+ break
+ yield chunk
+ finally:
+ if not streamer_task.done():
+ streamer_task.cancel()