Add Open Subsonic MusicProvider (#1006)
authorEric Munson <eric@munsonfam.org>
Sun, 21 Jan 2024 13:58:02 +0000 (08:58 -0500)
committerGitHub <noreply@github.com>
Sun, 21 Jan 2024 13:58:02 +0000 (14:58 +0100)
music_assistant/server/providers/opensubsonic/__init__.py [new file with mode: 0644]
music_assistant/server/providers/opensubsonic/icon.svg [new file with mode: 0644]
music_assistant/server/providers/opensubsonic/manifest.json [new file with mode: 0644]
music_assistant/server/providers/opensubsonic/sonic_provider.py [new file with mode: 0644]
requirements_all.txt

diff --git a/music_assistant/server/providers/opensubsonic/__init__.py b/music_assistant/server/providers/opensubsonic/__init__.py
new file mode 100644 (file)
index 0000000..46415b5
--- /dev/null
@@ -0,0 +1,79 @@
+"""Open Subsonic music provider support for MusicAssistant."""
+from __future__ import annotations
+
+from music_assistant.common.models.config_entries import (
+    ConfigEntry,
+    ConfigValueType,
+    ProviderConfig,
+)
+from music_assistant.common.models.enums import ConfigEntryType
+from music_assistant.common.models.provider import ProviderManifest
+from music_assistant.constants import CONF_PASSWORD, CONF_PATH, CONF_PORT, CONF_USERNAME
+from music_assistant.server import MusicAssistant
+from music_assistant.server.models import ProviderInstanceType
+
+from .sonic_provider import CONF_BASE_URL, CONF_ENABLE_PODCASTS, OpenSonicProvider
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = OpenSonicProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant,  # noqa: ARG001
+    instance_id: str | None = None,  # noqa: ARG001
+    action: str | None = None,  # noqa: ARG001
+    values: dict[str, ConfigValueType] | None = None,  # noqa: ARG001
+) -> tuple[ConfigEntry, ...]:
+    """Return Config entries to setup this provider."""
+    return (
+        ConfigEntry(
+            key=CONF_USERNAME,
+            type=ConfigEntryType.STRING,
+            label="Username",
+            required=True,
+            description="Your username for this Open Subsonic server",
+        ),
+        ConfigEntry(
+            key=CONF_PASSWORD,
+            type=ConfigEntryType.SECURE_STRING,
+            label="Password",
+            required=True,
+            description="The password associated with the username",
+        ),
+        ConfigEntry(
+            key=CONF_BASE_URL,
+            type=ConfigEntryType.STRING,
+            label="Base URL",
+            required=True,
+            description="Base URL for the server, e.g. " "https://subsonic.mydomain.tld",
+        ),
+        ConfigEntry(
+            key=CONF_PORT,
+            type=ConfigEntryType.INTEGER,
+            label="Port",
+            required=False,
+            description="Port Number for the server",
+        ),
+        ConfigEntry(
+            key=CONF_PATH,
+            type=ConfigEntryType.STRING,
+            label="Server Path",
+            required=False,
+            description="Path to append to base URL for Soubsonic server, this is likely "
+            "empty unless you are path routing on a proxy",
+        ),
+        ConfigEntry(
+            key=CONF_ENABLE_PODCASTS,
+            type=ConfigEntryType.BOOLEAN,
+            label="Enable Podcasts",
+            required=True,
+            description="Should the provider query for podcasts as well as music?",
+            default_value=True,
+        ),
+    )
diff --git a/music_assistant/server/providers/opensubsonic/icon.svg b/music_assistant/server/providers/opensubsonic/icon.svg
new file mode 100644 (file)
index 0000000..429336a
--- /dev/null
@@ -0,0 +1,11 @@
+<?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>
diff --git a/music_assistant/server/providers/opensubsonic/manifest.json b/music_assistant/server/providers/opensubsonic/manifest.json
new file mode 100644 (file)
index 0000000..cdc9904
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "type": "music",
+  "domain": "opensubsonic",
+  "name": "Open Subsonic Media Server Library",
+  "description": "Support for Open Subsonic based streaming providers in Music Assistant.",
+  "codeowners": ["@khers"],
+  "requirements": ["py-opensonic>=5.0.1"],
+  "documentation": "https://github.com/orgs/music-assistant/discussions/1806",
+  "multi_instance": true
+}
diff --git a/music_assistant/server/providers/opensubsonic/sonic_provider.py b/music_assistant/server/providers/opensubsonic/sonic_provider.py
new file mode 100644 (file)
index 0000000..4e8e5bb
--- /dev/null
@@ -0,0 +1,533 @@
+"""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()
index 2d7bab1b7f6920cdc8abe974ebdf0277e76a414f..7996535dff9e1aabef4c5fe93b80ee3362969e39 100644 (file)
@@ -22,6 +22,7 @@ music-assistant-frontend==2.1.0
 orjson==3.9.10
 pillow==10.2.0
 plexapi==4.15.7
+py-opensonic>=5.0.1
 PyChromecast==13.0.8
 pycryptodome==3.20.0
 python-ffmpeg==2.0.9