Subsonic: Move to new api library (#2160)
authorEric Munson <eric@munsonfam.org>
Sun, 4 May 2025 18:34:09 +0000 (14:34 -0400)
committerGitHub <noreply@github.com>
Sun, 4 May 2025 18:34:09 +0000 (20:34 +0200)
music_assistant/providers/opensubsonic/manifest.json
music_assistant/providers/opensubsonic/parsers.py
music_assistant/providers/opensubsonic/sonic_provider.py
requirements_all.txt
tests/providers/opensubsonic/__snapshots__/test_parsers.ambr
tests/providers/opensubsonic/fixtures/episodes/gonic-sample.episode.json [new file with mode: 0644]
tests/providers/opensubsonic/fixtures/episodes/gonic-sample.podcast.json [new file with mode: 0644]
tests/providers/opensubsonic/fixtures/playlists/gonic-sample.playlist.json [new file with mode: 0644]
tests/providers/opensubsonic/fixtures/podcasts/gonic-sample.podcast.json [new file with mode: 0644]
tests/providers/opensubsonic/test_parsers.py

index 8d8911b225e7df40657027cee64541d83592496c..ef3f738eacd22850ca86487c3d8fcc069166cb63 100644 (file)
@@ -7,7 +7,7 @@
     "@khers"
   ],
   "requirements": [
-    "py-opensonic==5.3.1"
+    "py-opensonic==7.0.1"
   ],
   "documentation": "https://music-assistant.io/music-providers/subsonic/",
   "multi_instance": true
index a9c2930878a5e0386a06bb4d231d9c5d156418ba..faef6d8626ce8a4e3bfb4243a3c93f00b398a985 100644 (file)
@@ -3,17 +3,18 @@
 from __future__ import annotations
 
 import logging
+from datetime import datetime
 from typing import TYPE_CHECKING
 
-from music_assistant_models.enums import (
-    ImageType,
-    MediaType,
-)
+from music_assistant_models.enums import ImageType, MediaType
 from music_assistant_models.media_items import (
     Album,
     Artist,
     ItemMapping,
     MediaItemImage,
+    Playlist,
+    Podcast,
+    PodcastEpisode,
     ProviderMapping,
 )
 from music_assistant_models.unique_list import UniqueList
@@ -25,10 +26,20 @@ if TYPE_CHECKING:
     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 SonicPodcast
+    from libopensonic.media import PodcastEpisode as SonicEpisode
+
 
 UNKNOWN_ARTIST_ID = "fake_artist_unknown"
 
 
+# Because of some subsonic API weirdness, we have to lookup any podcast episode by finding it in
+# the list of episodes in a channel, to facilitate, we will use both the episode id and the
+# channel id concatenated as an episode id to MA
+EP_CHAN_SEP = "$!$"
+
+
 def parse_artist(
     instance_id: str, sonic_artist: SonicArtist, sonic_info: SonicArtistInfo = None
 ) -> Artist:
@@ -48,11 +59,11 @@ def parse_artist(
     )
 
     artist.metadata.images = UniqueList()
-    if sonic_artist.cover_id:
+    if sonic_artist.cover_art:
         artist.metadata.images.append(
             MediaItemImage(
                 type=ImageType.THUMB,
-                path=sonic_artist.cover_id,
+                path=sonic_artist.cover_art,
                 provider=instance_id,
                 remotely_accessible=False,
             )
@@ -61,11 +72,11 @@ def parse_artist(
     if sonic_info:
         if sonic_info.biography:
             artist.metadata.description = sonic_info.biography
-        if sonic_info.small_url:
+        if sonic_info.small_image_url:
             artist.metadata.images.append(
                 MediaItemImage(
                     type=ImageType.THUMB,
-                    path=sonic_info.small_url,
+                    path=sonic_info.small_image_url,
                     provider=instance_id,
                     remotely_accessible=True,
                 )
@@ -98,11 +109,11 @@ def parse_album(
     )
 
     album.metadata.images = UniqueList()
-    if sonic_album.cover_id:
+    if sonic_album.cover_art:
         album.metadata.images.append(
             MediaItemImage(
                 type=ImageType.THUMB,
-                path=sonic_album.cover_id,
+                path=sonic_album.cover_art,
                 provider=instance_id,
                 remotely_accessible=False,
             ),
@@ -139,11 +150,11 @@ def parse_album(
         )
 
     if sonic_info:
-        if sonic_info.small_url:
+        if sonic_info.small_image_url:
             album.metadata.images.append(
                 MediaItemImage(
                     type=ImageType.THUMB,
-                    path=sonic_info.small_url,
+                    path=sonic_info.small_image_url,
                     remotely_accessible=False,
                     provider=instance_id,
                 )
@@ -152,3 +163,102 @@ def parse_album(
             album.metadata.description = sonic_info.notes
 
     return album
+
+
+def parse_playlist(instance_id: str, sonic_playlist: SonicPlaylist) -> Playlist:
+    """Parse subsonic Playlist into MA Playlist."""
+    playlist = Playlist(
+        item_id=sonic_playlist.id,
+        provider="opensubsonic",
+        name=sonic_playlist.name,
+        is_editable=True,
+        provider_mappings={
+            ProviderMapping(
+                item_id=sonic_playlist.id,
+                provider_domain="opensubsonic",
+                provider_instance=instance_id,
+            )
+        },
+    )
+
+    if sonic_playlist.cover_art:
+        playlist.metadata.images = UniqueList()
+        playlist.metadata.images.append(
+            MediaItemImage(
+                type=ImageType.THUMB,
+                path=sonic_playlist.cover_art,
+                provider=instance_id,
+                remotely_accessible=False,
+            )
+        )
+
+    return playlist
+
+
+def parse_podcast(instance_id: str, sonic_podcast: SonicPodcast) -> Podcast:
+    """Parse Subsonic PodcastChannel into MA Podcast."""
+    podcast = Podcast(
+        item_id=sonic_podcast.id,
+        provider="opensubsonic",
+        name=sonic_podcast.title,
+        uri=sonic_podcast.url,
+        total_episodes=len(sonic_podcast.episode),
+        provider_mappings={
+            ProviderMapping(
+                item_id=sonic_podcast.id,
+                provider_domain="opensubsonic",
+                provider_instance=instance_id,
+            )
+        },
+    )
+
+    podcast.metadata.description = sonic_podcast.description
+    podcast.metadata.images = UniqueList()
+
+    if sonic_podcast.cover_art:
+        podcast.metadata.images.append(
+            MediaItemImage(
+                type=ImageType.THUMB,
+                path=sonic_podcast.cover_art,
+                provider=instance_id,
+                remotely_accessible=False,
+            )
+        )
+
+    return podcast
+
+
+def parse_epsiode(
+    instance_id: str, sonic_episode: SonicEpisode, sonic_channel: SonicPodcast
+) -> PodcastEpisode:
+    """Parse an Open Subsonic Podcast Episode into an MA PodcastEpisode."""
+    eid = f"{sonic_episode.channel_id}{EP_CHAN_SEP}{sonic_episode.id}"
+    pos = 1
+    for ep in sonic_channel.episode:
+        if ep.id == sonic_episode.id:
+            break
+        pos += 1
+
+    episode = PodcastEpisode(
+        item_id=eid,
+        provider="opensubsonic",
+        name=sonic_episode.title,
+        position=pos,
+        podcast=parse_podcast(instance_id, sonic_channel),
+        provider_mappings={
+            ProviderMapping(
+                item_id=eid,
+                provider_domain="opensubsonic",
+                provider_instance=instance_id,
+            )
+        },
+        duration=sonic_episode.duration,
+    )
+
+    if sonic_episode.publish_date:
+        episode.metadata.release_date = datetime.fromisoformat(sonic_episode.publish_date)
+
+    if sonic_episode.description:
+        episode.metadata.description = sonic_episode.description
+
+    return episode
index 8afabe5621c1c59e9736a7fea4470936058de9aa..535f11dad9d7859a8a545491dcf63294aacef9d9 100644 (file)
@@ -3,7 +3,6 @@
 from __future__ import annotations
 
 import asyncio
-from datetime import datetime
 from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
 
 from libopensonic.connection import Connection as SonicConnection
@@ -14,13 +13,7 @@ from libopensonic.errors import (
     ParameterError,
     SonicError,
 )
-from music_assistant_models.enums import (
-    ContentType,
-    ImageType,
-    MediaType,
-    ProviderFeature,
-    StreamType,
-)
+from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType
 from music_assistant_models.errors import (
     ActionUnavailable,
     LoginFailed,
@@ -33,7 +26,6 @@ from music_assistant_models.media_items import (
     Artist,
     AudioFormat,
     ItemMapping,
-    MediaItemImage,
     MediaItemType,
     Playlist,
     Podcast,
@@ -43,7 +35,6 @@ from music_assistant_models.media_items import (
     Track,
 )
 from music_assistant_models.streamdetails import StreamDetails
-from music_assistant_models.unique_list import UniqueList
 
 from music_assistant.constants import (
     CONF_PASSWORD,
@@ -54,37 +45,38 @@ from music_assistant.constants import (
 )
 from music_assistant.models.music_provider import MusicProvider
 
-from .parsers import parse_album, parse_artist
+from .parsers import (
+    EP_CHAN_SEP,
+    UNKNOWN_ARTIST_ID,
+    parse_album,
+    parse_artist,
+    parse_epsiode,
+    parse_playlist,
+    parse_podcast,
+)
 
 if TYPE_CHECKING:
     from collections.abc import AsyncGenerator, Callable
 
     from libopensonic.media import Album as SonicAlbum
     from libopensonic.media import Artist as SonicArtist
+    from libopensonic.media import Child as SonicSong
+    from libopensonic.media import OpenSubsonicExtension
     from libopensonic.media import Playlist as SonicPlaylist
-    from libopensonic.media import PodcastChannel as SonicPodcast
     from libopensonic.media import PodcastEpisode as SonicEpisode
-    from libopensonic.media import Song as SonicSong
+
 
 CONF_BASE_URL = "baseURL"
 CONF_ENABLE_PODCASTS = "enable_podcasts"
 CONF_ENABLE_LEGACY_AUTH = "enable_legacy_auth"
 CONF_OVERRIDE_OFFSET = "override_transcode_offest"
 
-UNKNOWN_ARTIST_ID = "fake_artist_unknown"
 
 # We need the following prefix because of the way that Navidrome reports artists for individual
 # tracks on Various Artists albums, see the note in the _parse_track() method and the handling
 # in get_artist()
 NAVI_VARIOUS_PREFIX = "MA-NAVIDROME-"
 
-
-# Because of some subsonic API weirdness, we have to lookup any podcast episode by finding it in
-# the list of episodes in a channel, to facilitate, we will use both the episode id and the
-# channel id concatenated as an episode id to MA
-EP_CHAN_SEP = "$!$"
-
-
 Param = ParamSpec("Param")
 RetType = TypeVar("RetType")
 
@@ -109,10 +101,10 @@ class OpenSonicProvider(MusicProvider):
             self.config.get_value(CONF_BASE_URL),
             username=self.config.get_value(CONF_USERNAME),
             password=self.config.get_value(CONF_PASSWORD),
-            legacyAuth=self.config.get_value(CONF_ENABLE_LEGACY_AUTH),
+            legacy_auth=self.config.get_value(CONF_ENABLE_LEGACY_AUTH),
             port=port,
-            serverPath=path,
-            appName="Music Assistant",
+            server_path=path,
+            app_name="Music Assistant",
         )
         try:
             success = await self._run_async(self._conn.ping)
@@ -126,10 +118,11 @@ class OpenSonicProvider(MusicProvider):
         self._enable_podcasts = bool(self.config.get_value(CONF_ENABLE_PODCASTS))
         self._ignore_offset = bool(self.config.get_value(CONF_OVERRIDE_OFFSET))
         try:
-            ret = await self._run_async(self._conn.getOpenSubsonicExtensions)
-            extensions = ret["openSubsonicExtensions"]
+            extensions: list[OpenSubsonicExtension] = await self._run_async(
+                self._conn.get_open_subsonic_extensions
+            )
             for entry in extensions:
-                if entry["name"] == "transcodeOffset" and not self._ignore_offset:
+                if entry.name == "transcodeOffset" and not self._ignore_offset:
                     self._seek_support = True
                     break
         except OSError:
@@ -211,7 +204,7 @@ class OpenSonicProvider(MusicProvider):
                     ),
                 )
             },
-            track_number=getattr(sonic_song, "track", 0),
+            track_number=sonic_song.track if sonic_song.track else 0,
         )
 
         # We need to find an artist for this track but various implementations seem to disagree
@@ -275,104 +268,11 @@ class OpenSonicProvider(MusicProvider):
             track.artists.append(artist)
         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,
-            favorite=bool(sonic_playlist.starred),
-            provider_mappings={
-                ProviderMapping(
-                    item_id=sonic_playlist.id,
-                    provider_domain=self.domain,
-                    provider_instance=self.instance_id,
-                )
-            },
-        )
-
-        if sonic_playlist.cover_id:
-            playlist.metadata.images = UniqueList()
-            playlist.metadata.images.append(
-                MediaItemImage(
-                    type=ImageType.THUMB,
-                    path=sonic_playlist.cover_id,
-                    provider=self.instance_id,
-                    remotely_accessible=False,
-                )
-            )
-        return playlist
-
-    def _parse_podcast(self, sonic_podcast: SonicPodcast) -> Podcast:
-        podcast = Podcast(
-            item_id=sonic_podcast.id,
-            provider=self.domain,
-            name=sonic_podcast.title,
-            uri=sonic_podcast.url,
-            total_episodes=len(sonic_podcast.episodes),
-            provider_mappings={
-                ProviderMapping(
-                    item_id=sonic_podcast.id,
-                    provider_domain=self.domain,
-                    provider_instance=self.instance_id,
-                )
-            },
-        )
-
-        podcast.metadata.description = sonic_podcast.description
-        podcast.metadata.images = UniqueList()
-
-        if sonic_podcast.cover_id:
-            podcast.metadata.images.append(
-                MediaItemImage(
-                    type=ImageType.THUMB,
-                    path=sonic_podcast.cover_id,
-                    provider=self.instance_id,
-                    remotely_accessible=False,
-                )
-            )
-
-        return podcast
-
-    def _parse_epsiode(
-        self, sonic_episode: SonicEpisode, sonic_channel: SonicPodcast
-    ) -> PodcastEpisode:
-        eid = f"{sonic_episode.channel_id}{EP_CHAN_SEP}{sonic_episode.id}"
-        pos = 1
-        for ep in sonic_channel.episodes:
-            if ep.id == sonic_episode.id:
-                break
-            pos += 1
-
-        episode = PodcastEpisode(
-            item_id=eid,
-            provider=self.domain,
-            name=sonic_episode.title,
-            position=pos,
-            podcast=self._parse_podcast(sonic_channel),
-            provider_mappings={
-                ProviderMapping(
-                    item_id=eid,
-                    provider_domain=self.domain,
-                    provider_instance=self.instance_id,
-                )
-            },
-            duration=sonic_episode.duration,
-        )
-
-        if sonic_episode.publish_date:
-            episode.metadata.release_date = datetime.fromisoformat(sonic_episode.publish_date)
-
-        if sonic_episode.description:
-            episode.metadata.description = sonic_episode.description
-
-        return episode
-
     async def _get_podcast_episode(self, eid: str) -> SonicEpisode:
         chan_id, ep_id = eid.split(EP_CHAN_SEP)
-        chan = await self._run_async(self._conn.getPodcasts, incEpisodes=True, pid=chan_id)
+        chan = await self._run_async(self._conn.get_podcasts, inc_episodes=True, pid=chan_id)
 
-        for episode in chan[0].episodes:
+        for episode in chan[0].episode:
             if episode.id == ep_id:
                 return episode
 
@@ -389,7 +289,7 @@ class OpenSonicProvider(MusicProvider):
 
         def _get_cover_art() -> bytes | Any:
             try:
-                with self._conn.getCoverArt(path) as art:
+                with self._conn.get_cover_art(path) as art:
                     return art.content
             except DataNotFoundError:
                 self.logger.warning("Unable to locate a cover image for %s", path)
@@ -409,27 +309,29 @@ class OpenSonicProvider(MusicProvider):
         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,
+            artist_count=artists,
+            artist_offset=0,
+            album_count=albums,
+            album_offset=0,
+            song_count=songs,
+            song_offset=0,
+            music_folder_id=None,
         )
         return SearchResults(
-            artists=[parse_artist(self.instance_id, entry) for entry in answer["artists"]],
-            albums=[
-                parse_album(self.logger, self.instance_id, entry) for entry in answer["albums"]
-            ],
-            tracks=[self._parse_track(entry) for entry in answer["songs"]],
+            artists=[parse_artist(self.instance_id, entry) for entry in answer.artist]
+            if answer.artist
+            else [],
+            albums=[parse_album(self.logger, self.instance_id, entry) for entry in answer.album]
+            if answer.album
+            else [],
+            tracks=[self._parse_track(entry) for entry in answer.song] if answer.song else [],
         )
 
     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:
+        artists = await self._run_async(self._conn.get_artists)
+        for index in artists.index:
+            for artist in index.artist:
                 yield parse_artist(self.instance_id, artist)
 
     async def get_library_albums(self) -> AsyncGenerator[Album, None]:
@@ -442,7 +344,7 @@ class OpenSonicProvider(MusicProvider):
         offset = 0
         size = 500
         albums = await self._run_async(
-            self._conn.getAlbumList2,
+            self._conn.get_album_list2,
             ltype="alphabeticalByArtist",
             size=size,
             offset=offset,
@@ -452,7 +354,7 @@ class OpenSonicProvider(MusicProvider):
                 yield parse_album(self.logger, self.instance_id, album)
             offset += size
             albums = await self._run_async(
-                self._conn.getAlbumList2,
+                self._conn.get_album_list2,
                 ltype="alphabeticalByArtist",
                 size=size,
                 offset=offset,
@@ -460,9 +362,9 @@ class OpenSonicProvider(MusicProvider):
 
     async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
         """Provide a generator for library playlists."""
-        results = await self._run_async(self._conn.getPlaylists)
+        results = await self._run_async(self._conn.get_playlists)
         for entry in results:
-            yield self._parse_playlist(entry)
+            yield parse_playlist(self.instance_id, entry)
 
     async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
         """
@@ -477,10 +379,10 @@ class OpenSonicProvider(MusicProvider):
             results = await self._run_async(
                 self._conn.search3,
                 query=query,
-                artistCount=0,
-                albumCount=0,
-                songOffset=offset,
-                songCount=count,
+                artist_count=0,
+                album_count=0,
+                song_offset=offset,
+                song_count=count,
             )
         except ParameterError:
             # Older Navidrome does not accept an empty string and requires the empty quotes
@@ -488,14 +390,14 @@ class OpenSonicProvider(MusicProvider):
             results = await self._run_async(
                 self._conn.search3,
                 query=query,
-                artistCount=0,
-                albumCount=0,
-                songOffset=offset,
-                songCount=count,
+                artist_count=0,
+                album_count=0,
+                song_offset=offset,
+                song_count=count,
             )
-        while results["songs"]:
+        while results.song:
             album: Album | None = None
-            for entry in results["songs"]:
+            for entry in results.song:
                 aid = entry.album_id if entry.album_id else entry.parent
                 if album is None or album.item_id != aid:
                     album = await self.get_album(prov_album_id=aid)
@@ -504,17 +406,17 @@ class OpenSonicProvider(MusicProvider):
             results = await self._run_async(
                 self._conn.search3,
                 query=query,
-                artistCount=0,
-                albumCount=0,
-                songOffset=offset,
-                songCount=count,
+                artist_count=0,
+                album_count=0,
+                song_offset=offset,
+                song_count=count,
             )
 
     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)
+            sonic_album: SonicAlbum = await self._run_async(self._conn.get_album, prov_album_id)
+            sonic_info = await self._run_async(self._conn.get_album_info2, aid=prov_album_id)
         except (ParameterError, DataNotFoundError) as e:
             msg = f"Album {prov_album_id} not found"
             raise MediaNotFoundError(msg) from e
@@ -524,12 +426,12 @@ class OpenSonicProvider(MusicProvider):
     async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
         """Return a list of tracks on the specified Album."""
         try:
-            sonic_album: SonicAlbum = await self._run_async(self._conn.getAlbum, prov_album_id)
+            sonic_album: SonicAlbum = await self._run_async(self._conn.get_album, prov_album_id)
         except (ParameterError, DataNotFoundError) as e:
             msg = f"Album {prov_album_id} not found"
             raise MediaNotFoundError(msg) from e
         tracks = []
-        for sonic_song in sonic_album.songs:
+        for sonic_song in sonic_album.song:
             tracks.append(self._parse_track(sonic_song))
         return tracks
 
@@ -565,9 +467,9 @@ class OpenSonicProvider(MusicProvider):
 
         try:
             sonic_artist: SonicArtist = await self._run_async(
-                self._conn.getArtist, artist_id=prov_artist_id
+                self._conn.get_artist, artist_id=prov_artist_id
             )
-            sonic_info = await self._run_async(self._conn.getArtistInfo2, aid=prov_artist_id)
+            sonic_info = await self._run_async(self._conn.get_artist_info2, aid=prov_artist_id)
         except (ParameterError, DataNotFoundError) as e:
             msg = f"Artist {prov_artist_id} not found"
             raise MediaNotFoundError(msg) from e
@@ -576,7 +478,7 @@ class OpenSonicProvider(MusicProvider):
     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)
+            sonic_song: SonicSong = await self._run_async(self._conn.get_song, prov_track_id)
         except (ParameterError, DataNotFoundError) as e:
             msg = f"Item {prov_track_id} not found"
             raise MediaNotFoundError(msg) from e
@@ -594,12 +496,12 @@ class OpenSonicProvider(MusicProvider):
             return []
 
         try:
-            sonic_artist: SonicArtist = await self._run_async(self._conn.getArtist, prov_artist_id)
+            sonic_artist: SonicArtist = await self._run_async(self._conn.get_artist, prov_artist_id)
         except (ParameterError, DataNotFoundError) as e:
             msg = f"Album {prov_artist_id} not found"
             raise MediaNotFoundError(msg) from e
         albums = []
-        for entry in sonic_artist.albums:
+        for entry in sonic_artist.album:
             albums.append(parse_album(self.logger, self.instance_id, entry))
         return albums
 
@@ -607,12 +509,12 @@ class OpenSonicProvider(MusicProvider):
         """Return the specified Playlist."""
         try:
             sonic_playlist: SonicPlaylist = await self._run_async(
-                self._conn.getPlaylist, prov_playlist_id
+                self._conn.get_playlist, prov_playlist_id
             )
         except (ParameterError, DataNotFoundError) as e:
             msg = f"Playlist {prov_playlist_id} not found"
             raise MediaNotFoundError(msg) from e
-        return self._parse_playlist(sonic_playlist)
+        return parse_playlist(self.instance_id, sonic_playlist)
 
     async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
         """Get (full) podcast episode details by id."""
@@ -631,11 +533,11 @@ class OpenSonicProvider(MusicProvider):
         if not self._enable_podcasts:
             return
         channels = await self._run_async(
-            self._conn.getPodcasts, incEpisodes=True, pid=prov_podcast_id
+            self._conn.get_podcasts, inc_episodes=True, pid=prov_podcast_id
         )
         channel = channels[0]
-        for episode in channel.episodes:
-            yield self._parse_epsiode(episode, channel)
+        for episode in channel.episode:
+            yield parse_epsiode(self.instance_id, episode, channel)
 
     async def get_podcast(self, prov_podcast_id: str) -> Podcast:
         """Get full Podcast details by id."""
@@ -644,18 +546,18 @@ class OpenSonicProvider(MusicProvider):
             raise ActionUnavailable(msg)
 
         channels = await self._run_async(
-            self._conn.getPodcasts, incEpisodes=True, pid=prov_podcast_id
+            self._conn.get_podcasts, inc_episodes=True, pid=prov_podcast_id
         )
 
-        return self._parse_podcast(channels[0])
+        return parse_podcast(self.instance_id, channels[0])
 
     async def get_library_podcasts(self) -> AsyncGenerator[Podcast, None]:
         """Retrieve library/subscribed podcasts from the provider."""
         if self._enable_podcasts:
-            channels = await self._run_async(self._conn.getPodcasts, incEpisodes=True)
+            channels = await self._run_async(self._conn.get_podcasts, inc_episodes=True)
 
             for channel in channels:
-                yield self._parse_podcast(channel)
+                yield parse_podcast(self.instance_id, channel)
 
     async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
@@ -665,17 +567,20 @@ class OpenSonicProvider(MusicProvider):
             return result
         try:
             sonic_playlist: SonicPlaylist = await self._run_async(
-                self._conn.getPlaylist, prov_playlist_id
+                self._conn.get_playlist, prov_playlist_id
             )
         except (ParameterError, DataNotFoundError) as e:
             msg = f"Playlist {prov_playlist_id} not found"
             raise MediaNotFoundError(msg) from e
 
+        if not sonic_playlist.entry:
+            return result
+
         album: Album | None = None
-        for index, sonic_song in enumerate(sonic_playlist.songs, 1):
+        for index, sonic_song in enumerate(sonic_playlist.entry, 1):
             aid = sonic_song.album_id if sonic_song.album_id else sonic_song.parent
             if not aid:
-                self.logger.warning("Unable to find albumd for track %s", sonic_song.id)
+                self.logger.warning("Unable to find album for track %s", sonic_song.id)
             if not album or album.item_id != aid:
                 album = await self.get_album(prov_album_id=aid)
             track = self._parse_track(sonic_song, album=album)
@@ -690,18 +595,18 @@ class OpenSonicProvider(MusicProvider):
             return []
 
         try:
-            sonic_artist: SonicArtist = await self._run_async(self._conn.getArtist, prov_artist_id)
+            sonic_artist: SonicArtist = await self._run_async(self._conn.get_artist, prov_artist_id)
         except DataNotFoundError as e:
             msg = f"Artist {prov_artist_id} not found"
             raise MediaNotFoundError(msg) from e
-        songs: list[SonicSong] = await self._run_async(self._conn.getTopSongs, sonic_artist.name)
+        songs: list[SonicSong] = await self._run_async(self._conn.get_top_songs, sonic_artist.name)
         return [self._parse_track(entry) for entry in songs]
 
     async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
         """Get tracks similar to selected track."""
         try:
             songs: list[SonicSong] = await self._run_async(
-                self._conn.getSimilarSongs, iid=prov_track_id, count=limit
+                self._conn.get_similar_songs, iid=prov_track_id, count=limit
             )
         except DataNotFoundError as e:
             # Subsonic returns an error here instead of an empty list, I don't think this
@@ -713,8 +618,8 @@ class OpenSonicProvider(MusicProvider):
 
     async def create_playlist(self, name: str) -> Playlist:
         """Create a new empty playlist on the server."""
-        playlist: SonicPlaylist = await self._run_async(self._conn.createPlaylist, name=name)
-        return self._parse_playlist(playlist)
+        playlist: SonicPlaylist = await self._run_async(self._conn.create_playlist, name=name)
+        return parse_playlist(self.instance_id, playlist)
 
     async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
         """Append the listed tracks to the selected playlist.
@@ -723,9 +628,9 @@ class OpenSonicProvider(MusicProvider):
         """
         try:
             await self._run_async(
-                self._conn.updatePlaylist,
+                self._conn.update_playlist,
                 lid=prov_playlist_id,
-                songIdsToAdd=prov_track_ids,
+                song_ids_to_add=prov_track_ids,
             )
         except SonicError as ex:
             msg = f"Failed to add songs to {prov_playlist_id}, check your permissions."
@@ -738,9 +643,9 @@ class OpenSonicProvider(MusicProvider):
         idx_to_remove = [pos - 1 for pos in positions_to_remove]
         try:
             await self._run_async(
-                self._conn.updatePlaylist,
+                self._conn.update_playlist,
                 lid=prov_playlist_id,
-                songIndexesToRemove=idx_to_remove,
+                song_indices_to_remove=idx_to_remove,
             )
         except SonicError as ex:
             msg = f"Failed to remove songs from {prov_playlist_id}, check your permissions."
@@ -751,7 +656,7 @@ class OpenSonicProvider(MusicProvider):
         item: SonicSong | SonicEpisode
         if media_type == MediaType.TRACK:
             try:
-                item = await self._run_async(self._conn.getSong, item_id)
+                item = await self._run_async(self._conn.get_song, item_id)
             except (ParameterError, DataNotFoundError) as e:
                 msg = f"Item {item_id} not found"
                 raise MediaNotFoundError(msg) from e
@@ -853,8 +758,8 @@ class OpenSonicProvider(MusicProvider):
             try:
                 with self._conn.stream(
                     streamdetails.item_id,
-                    timeOffset=seek_position,
-                    estimateContentLength=True,
+                    time_offset=seek_position,
+                    estimate_length=True,
                 ) as stream:
                     for chunk in stream.iter_content(chunk_size=40960):
                         asyncio.run_coroutine_threadsafe(
index 2f654a3312bee7aa2145e8ff88aad1fdb295c29a..14269f8714e3fd1d432d39adf37670392c9bf31f 100644 (file)
@@ -36,7 +36,7 @@ pillow==11.2.1
 pkce==1.0.3
 plexapi==4.17.0
 podcastparser==0.6.10
-py-opensonic==5.3.1
+py-opensonic==7.0.1
 pyblu==2.0.1
 PyChromecast==14.0.7
 pycryptodome==3.22.0
index d08a9ca2c2f7e27d33b0c5b7415561e2fd5cda6f..cfd3615f39965cd606db5101aae511350db2418f 100644 (file)
     'version': '',
   })
 # ---
+# name: test_parse_episode[gonic-sample.episode]
+  dict({
+    'duration': 1878,
+    'external_ids': list([
+    ]),
+    'favorite': False,
+    'fully_played': None,
+    'is_playable': True,
+    'item_id': 'pd-5$!$pe-1860',
+    'media_type': 'podcast_episode',
+    'metadata': dict({
+      'chapters': None,
+      'copyright': None,
+      'description': 'The history of The History of Rome...Why the Western Empire Fell when it did...Some thoughts on the future...Thank you, goodnight.',
+      'explicit': None,
+      'genres': None,
+      'images': None,
+      'label': None,
+      'languages': None,
+      'last_refresh': None,
+      'links': None,
+      'lrc_lyrics': None,
+      'lyrics': None,
+      'mood': None,
+      'performers': None,
+      'popularity': None,
+      'preview': None,
+      'release_date': '2012-05-06T18:18:38+00:00',
+      'review': None,
+      'style': None,
+    }),
+    'name': '179- The End',
+    'podcast': dict({
+      'external_ids': list([
+      ]),
+      'favorite': False,
+      'is_playable': True,
+      'item_id': 'pd-5',
+      'media_type': 'podcast',
+      'metadata': dict({
+        'chapters': None,
+        'copyright': None,
+        'description': 'A weekly podcast tracing the rise, decline and fall of the Roman Empire. Now complete!',
+        'explicit': None,
+        'genres': None,
+        'images': list([
+          dict({
+            'path': 'pd-5',
+            'provider': 'xx-instance-id-xx',
+            'remotely_accessible': False,
+            'type': 'thumb',
+          }),
+        ]),
+        'label': None,
+        'languages': None,
+        'last_refresh': None,
+        'links': None,
+        'lrc_lyrics': None,
+        'lyrics': None,
+        'mood': None,
+        'performers': None,
+        'popularity': None,
+        'preview': None,
+        'release_date': None,
+        'review': None,
+        'style': None,
+      }),
+      'name': 'The History of Rome',
+      'position': None,
+      'provider': 'opensubsonic',
+      'provider_mappings': list([
+        dict({
+          'audio_format': dict({
+            'bit_depth': 16,
+            'bit_rate': 0,
+            'channels': 2,
+            'codec_type': '?',
+            'content_type': '?',
+            'output_format_str': '?',
+            'sample_rate': 44100,
+          }),
+          'available': True,
+          'details': None,
+          'item_id': 'pd-5',
+          'provider_domain': 'opensubsonic',
+          'provider_instance': 'xx-instance-id-xx',
+          'url': None,
+        }),
+      ]),
+      'publisher': None,
+      'sort_name': 'history of rome, the',
+      'total_episodes': 5,
+      'translation_key': None,
+      'uri': 'http://feeds.feedburner.com/TheHistoryOfRome',
+      'version': '',
+    }),
+    'position': 5,
+    'provider': 'opensubsonic',
+    'provider_mappings': list([
+      dict({
+        'audio_format': dict({
+          'bit_depth': 16,
+          'bit_rate': 0,
+          'channels': 2,
+          'codec_type': '?',
+          'content_type': '?',
+          'output_format_str': '?',
+          'sample_rate': 44100,
+        }),
+        'available': True,
+        'details': None,
+        'item_id': 'pd-5$!$pe-1860',
+        'provider_domain': 'opensubsonic',
+        'provider_instance': 'xx-instance-id-xx',
+        'url': None,
+      }),
+    ]),
+    'resume_position_ms': None,
+    'sort_name': '179- the end',
+    'translation_key': None,
+    'uri': 'opensubsonic://podcast_episode/pd-5$!$pe-1860',
+    'version': '',
+  })
+# ---
+# name: test_parse_playlist[gonic-sample.playlist]
+  dict({
+    'cache_checksum': None,
+    'external_ids': list([
+    ]),
+    'favorite': False,
+    'is_editable': True,
+    'is_playable': True,
+    'item_id': 'Mi8xNzQzNzg5NTk5MzM1LTE3NDM3ODk1OTkzMzUubTN1',
+    'media_type': 'playlist',
+    'metadata': dict({
+      'chapters': None,
+      'copyright': None,
+      'description': None,
+      'explicit': None,
+      'genres': None,
+      'images': list([
+        dict({
+          'path': 'al-2250',
+          'provider': 'xx-instance-id-xx',
+          'remotely_accessible': False,
+          'type': 'thumb',
+        }),
+      ]),
+      'label': None,
+      'languages': None,
+      'last_refresh': None,
+      'links': None,
+      'lrc_lyrics': None,
+      'lyrics': None,
+      'mood': None,
+      'performers': None,
+      'popularity': None,
+      'preview': None,
+      'release_date': None,
+      'review': None,
+      'style': None,
+    }),
+    'name': "Guns N' Roses - Use Your Illusion",
+    'owner': '',
+    'position': None,
+    'provider': 'opensubsonic',
+    'provider_mappings': list([
+      dict({
+        'audio_format': dict({
+          'bit_depth': 16,
+          'bit_rate': 0,
+          'channels': 2,
+          'codec_type': '?',
+          'content_type': '?',
+          'output_format_str': '?',
+          'sample_rate': 44100,
+        }),
+        'available': True,
+        'details': None,
+        'item_id': 'Mi8xNzQzNzg5NTk5MzM1LTE3NDM3ODk1OTkzMzUubTN1',
+        'provider_domain': 'opensubsonic',
+        'provider_instance': 'xx-instance-id-xx',
+        'url': None,
+      }),
+    ]),
+    'sort_name': "guns n' roses - use your illusion",
+    'translation_key': None,
+    'uri': 'opensubsonic://playlist/Mi8xNzQzNzg5NTk5MzM1LTE3NDM3ODk1OTkzMzUubTN1',
+    'version': '',
+  })
+# ---
+# name: test_parse_podcast[gonic-sample.podcast]
+  dict({
+    'external_ids': list([
+    ]),
+    'favorite': False,
+    'is_playable': True,
+    'item_id': 'pd-5',
+    'media_type': 'podcast',
+    'metadata': dict({
+      'chapters': None,
+      'copyright': None,
+      'description': 'A weekly podcast tracing the rise, decline and fall of the Roman Empire. Now complete!',
+      'explicit': None,
+      'genres': None,
+      'images': list([
+        dict({
+          'path': 'pd-5',
+          'provider': 'xx-instance-id-xx',
+          'remotely_accessible': False,
+          'type': 'thumb',
+        }),
+      ]),
+      'label': None,
+      'languages': None,
+      'last_refresh': None,
+      'links': None,
+      'lrc_lyrics': None,
+      'lyrics': None,
+      'mood': None,
+      'performers': None,
+      'popularity': None,
+      'preview': None,
+      'release_date': None,
+      'review': None,
+      'style': None,
+    }),
+    'name': 'The History of Rome',
+    'position': None,
+    'provider': 'opensubsonic',
+    'provider_mappings': list([
+      dict({
+        'audio_format': dict({
+          'bit_depth': 16,
+          'bit_rate': 0,
+          'channels': 2,
+          'codec_type': '?',
+          'content_type': '?',
+          'output_format_str': '?',
+          'sample_rate': 44100,
+        }),
+        'available': True,
+        'details': None,
+        'item_id': 'pd-5',
+        'provider_domain': 'opensubsonic',
+        'provider_instance': 'xx-instance-id-xx',
+        'url': None,
+      }),
+    ]),
+    'publisher': None,
+    'sort_name': 'history of rome, the',
+    'total_episodes': 5,
+    'translation_key': None,
+    'uri': 'http://feeds.feedburner.com/TheHistoryOfRome',
+    'version': '',
+  })
+# ---
diff --git a/tests/providers/opensubsonic/fixtures/episodes/gonic-sample.episode.json b/tests/providers/opensubsonic/fixtures/episodes/gonic-sample.episode.json
new file mode 100644 (file)
index 0000000..219a4d3
--- /dev/null
@@ -0,0 +1,19 @@
+{
+    "id": "pe-1860",
+    "isDir": false,
+    "title": "179- The End",
+    "parent": "",
+    "year": 2012,
+    "genre": "Podcast",
+    "coverArt": "pd-5",
+    "size": 15032655,
+    "contentType": "audio/mpeg",
+    "suffix": "mp3",
+    "duration": 1878,
+    "path": "",
+    "channelId": "pd-5",
+    "status": "completed",
+    "streamId": "pe-1860",
+    "description": "The history of The History of Rome...Why the Western Empire Fell when it did...Some thoughts on the future...Thank you, goodnight.",
+    "publishDate": "2012-05-06T18:18:38Z"
+}
diff --git a/tests/providers/opensubsonic/fixtures/episodes/gonic-sample.podcast.json b/tests/providers/opensubsonic/fixtures/episodes/gonic-sample.podcast.json
new file mode 100644 (file)
index 0000000..a31b6b0
--- /dev/null
@@ -0,0 +1,106 @@
+{
+    "id": "pd-5",
+    "url": "http://feeds.feedburner.com/TheHistoryOfRome",
+    "status": "skipped",
+    "title": "The History of Rome",
+    "description": "A weekly podcast tracing the rise, decline and fall of the Roman Empire. Now complete!",
+    "coverArt": "pd-5",
+    "originalImageUrl": "https://static.libsyn.com/p/assets/1/2/f/c/12fc067020662e91/THoR_Logo_1500x1500.jpg",
+    "episode": [
+        {
+            "id": "pe-4805",
+            "isDir": false,
+            "title": "Ad-Free History of Rome Patreon",
+            "parent": "",
+            "year": 2024,
+            "genre": "Podcast",
+            "coverArt": "pd-5",
+            "size": 1717520,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 71,
+            "path": "",
+            "channelId": "pd-5",
+            "status": "completed",
+            "streamId": "pe-4805",
+            "description": "Become a patron and get the entire History of Rome backcatalog ad-fre, plus bonus content, behind the scenes peeks at the new book, plus a chat community where you can talk to me directly. Join today! Patreon: patreon.com/thehistoryofrome Merch Store: cottonbureau.com/mikeduncan",
+            "publishDate": "2024-11-05T02:35:00Z"
+        },
+        {
+            "id": "pe-1857",
+            "isDir": false,
+            "title": "The Storm Before The Storm: Chapter 1- The Beasts of Italy",
+            "parent": "",
+            "year": 2017,
+            "genre": "Podcast",
+            "coverArt": "pd-5",
+            "size": 80207374,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 3340,
+            "path": "",
+            "channelId": "pd-5",
+            "status": "completed",
+            "streamId": "pe-1857",
+            "description": "Audio excerpt from The Storm Before the Storm: The Beginning of the End of the Roman Republic by Mike Duncan. Forthcoming Oct. 24, 2017. Pre-order a copy today! Amazon Powells Barnes & Noble Indibound Books-a-Million Or visit us at: revolutionspodcast.com thehistoryofrome.com",
+            "publishDate": "2017-07-27T11:30:00Z"
+        },
+        {
+            "id": "pe-1858",
+            "isDir": false,
+            "title": "Revolutions Launch",
+            "parent": "",
+            "year": 2013,
+            "genre": "Podcast",
+            "coverArt": "pd-5",
+            "size": 253200,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 15,
+            "path": "",
+            "channelId": "pd-5",
+            "status": "completed",
+            "streamId": "pe-1858",
+            "description": "Available at revolutionspodcast.com, iTunes, or anywhere else fine podcasts can be found.",
+            "publishDate": "2013-09-16T15:39:57Z"
+        },
+        {
+            "id": "pe-1859",
+            "isDir": false,
+            "title": "Update- One Year Later",
+            "parent": "",
+            "year": 2013,
+            "genre": "Podcast",
+            "coverArt": "pd-5",
+            "size": 1588998,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 99,
+            "path": "",
+            "channelId": "pd-5",
+            "status": "completed",
+            "streamId": "pe-1859",
+            "description": "Next show coming soon!",
+            "publishDate": "2013-05-30T15:18:57Z"
+        },
+        {
+            "id": "pe-1860",
+            "isDir": false,
+            "title": "179- The End",
+            "parent": "",
+            "year": 2012,
+            "genre": "Podcast",
+            "coverArt": "pd-5",
+            "size": 15032655,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 1878,
+            "path": "",
+            "channelId": "pd-5",
+            "status": "completed",
+            "streamId": "pe-1860",
+            "description": "The history of The History of Rome...Why the Western Empire Fell when it did...Some thoughts on the future...Thank you, goodnight.",
+            "publishDate": "2012-05-06T18:18:38Z"
+        }
+    ]
+}
diff --git a/tests/providers/opensubsonic/fixtures/playlists/gonic-sample.playlist.json b/tests/providers/opensubsonic/fixtures/playlists/gonic-sample.playlist.json
new file mode 100644 (file)
index 0000000..f11f4f7
--- /dev/null
@@ -0,0 +1,495 @@
+{
+    "id": "Mi8xNzQzNzg5NTk5MzM1LTE3NDM3ODk1OTkzMzUubTN1",
+    "name": "Guns N' Roses - Use Your Illusion",
+    "songCount": 16,
+    "coverArt": "al-2250",
+    "duration": 5268,
+    "created": "2025-04-04T18:40:41.760062653Z",
+    "changed": "2025-04-04T18:40:41.760062653Z",
+    "comment": "",
+    "owner": "user",
+    "public": true,
+    "entry": [
+        {
+            "id": "tr-16009",
+            "isDir": false,
+            "title": "Right Next Door to Hell",
+            "parent": "al-2250",
+            "album": "Use Your Illusion I",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 1,
+            "year": 1991,
+            "coverArt": "al-2250",
+            "size": 5938059,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 182,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion I/01 Right Next Door to Hell.mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.574907642Z",
+            "type": "music",
+            "musicBrainzId": "471c43b3-9efb-4557-aef3-4ea8465b8390",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16010",
+            "isDir": false,
+            "title": "Dust N\\u2019 Bones",
+            "parent": "al-2250",
+            "album": "Use Your Illusion I",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 2,
+            "year": 1991,
+            "coverArt": "al-2250",
+            "size": 9655740,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 298,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion I/02 Dust N\\u2019 Bones.mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.586200432Z",
+            "type": "music",
+            "musicBrainzId": "982fde94-99b8-4743-bb61-a5c008c024ca",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16011",
+            "isDir": false,
+            "title": "Live and Let Die",
+            "parent": "al-2250",
+            "album": "Use Your Illusion I",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 3,
+            "year": 1991,
+            "coverArt": "al-2250",
+            "size": 5992442,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 184,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion I/03 Live and Let Die.mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.596262286Z",
+            "type": "music",
+            "musicBrainzId": "a13ba277-a7ed-44bb-9e42-598a5983e7f0",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16012",
+            "isDir": false,
+            "title": "Don\\u2019t Cry (original)",
+            "parent": "al-2250",
+            "album": "Use Your Illusion I",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 4,
+            "year": 1991,
+            "coverArt": "al-2250",
+            "size": 9222317,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 284,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion I/04 Don\\u2019t Cry (original).mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.613645878Z",
+            "type": "music",
+            "musicBrainzId": "3b6271d3-e0ad-408b-8886-d72de1ef783e",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16013",
+            "isDir": false,
+            "title": "Perfect Crime",
+            "parent": "al-2250",
+            "album": "Use Your Illusion I",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 5,
+            "year": 1991,
+            "coverArt": "al-2250",
+            "size": 4698806,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 143,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion I/05 Perfect Crime.mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.622707628Z",
+            "type": "music",
+            "musicBrainzId": "aa4ae4e7-94df-4c08-b0a0-514c050554ba",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16018",
+            "isDir": false,
+            "title": "November Rain",
+            "parent": "al-2250",
+            "album": "Use Your Illusion I",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 10,
+            "year": 1991,
+            "coverArt": "al-2250",
+            "size": 17316320,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 537,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion I/10 November Rain.mp3",
+            "isVideo": false,
+            "averageRating": 5.0,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.669479868Z",
+            "type": "music",
+            "musicBrainzId": "0681520a-6499-4cb8-91aa-7df5dfafea2e",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16019",
+            "isDir": false,
+            "title": "The Garden",
+            "parent": "al-2250",
+            "album": "Use Your Illusion I",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 11,
+            "year": 1991,
+            "coverArt": "al-2250",
+            "size": 10420531,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 322,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion I/11 The Garden.mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.67743051Z",
+            "type": "music",
+            "musicBrainzId": "c76a6a6e-1861-4e7c-8ded-4f3247804328",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16023",
+            "isDir": false,
+            "title": "Dead Horse",
+            "parent": "al-2250",
+            "album": "Use Your Illusion I",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 15,
+            "year": 1991,
+            "coverArt": "al-2250",
+            "size": 8349620,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 257,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion I/15 Dead Horse.mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.716120687Z",
+            "type": "music",
+            "musicBrainzId": "51f73984-da17-48b2-b83d-ecd867b8325e",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16025",
+            "isDir": false,
+            "title": "Civil War",
+            "parent": "al-2251",
+            "album": "Use Your Illusion II",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 1,
+            "year": 1991,
+            "coverArt": "al-2251",
+            "size": 14891547,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 462,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion II/01 Civil War.mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.747021832Z",
+            "type": "music",
+            "musicBrainzId": "24cbe6e3-50bb-487c-b477-eee77d268556",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16026",
+            "isDir": false,
+            "title": "14 Years",
+            "parent": "al-2251",
+            "album": "Use Your Illusion II",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 2,
+            "year": 1991,
+            "coverArt": "al-2251",
+            "size": 8461921,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 261,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion II/02 14 Years.mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.756107511Z",
+            "type": "music",
+            "musicBrainzId": "a7263180-522d-4920-9c7f-b2a02671218d",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16027",
+            "isDir": false,
+            "title": "Yesterdays",
+            "parent": "al-2251",
+            "album": "Use Your Illusion II",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 3,
+            "year": 1991,
+            "coverArt": "al-2251",
+            "size": 6375055,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 196,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion II/03 Yesterdays.mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.765576962Z",
+            "type": "music",
+            "musicBrainzId": "ed8ced59-66bb-4497-8ba9-d6bab7f1e950",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16028",
+            "isDir": false,
+            "title": "Knockin\\u2019 on Heaven\\u2019s Door",
+            "parent": "al-2251",
+            "album": "Use Your Illusion II",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 4,
+            "year": 1991,
+            "coverArt": "al-2251",
+            "size": 10851837,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 336,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion II/04 Knockin\\u2019 on Heaven\\u2019s Door.mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.777119143Z",
+            "type": "music",
+            "musicBrainzId": "60e32de8-b6f7-4919-9f57-ba9c9974cf89",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16035",
+            "isDir": false,
+            "title": "Estranged",
+            "parent": "al-2251",
+            "album": "Use Your Illusion II",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 11,
+            "year": 1991,
+            "coverArt": "al-2251",
+            "size": 18152440,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 563,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion II/11 Estranged.mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.847816462Z",
+            "type": "music",
+            "musicBrainzId": "85e01d1e-34b1-413a-8b20-bc4c0db56fb6",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16036",
+            "isDir": false,
+            "title": "You Could Be Mine",
+            "parent": "al-2251",
+            "album": "Use Your Illusion II",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 12,
+            "year": 1991,
+            "coverArt": "al-2251",
+            "size": 11096880,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 343,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion II/12 You Could Be Mine.mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.857372733Z",
+            "type": "music",
+            "musicBrainzId": "795602a9-94f5-43f5-bb87-d058ac9428ec",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16037",
+            "isDir": false,
+            "title": "Don\\u2019t Cry (alternate lyrics)",
+            "parent": "al-2251",
+            "album": "Use Your Illusion II",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 13,
+            "year": 1991,
+            "coverArt": "al-2251",
+            "size": 9194959,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 284,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion II/13 Don\\u2019t Cry (alternate lyrics).mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.865879798Z",
+            "type": "music",
+            "musicBrainzId": "de5479b3-522f-450f-a4d3-6636f593e5e3",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        },
+        {
+            "id": "tr-16024",
+            "isDir": false,
+            "title": "Coma",
+            "parent": "al-2250",
+            "album": "Use Your Illusion I",
+            "artist": "Guns N\\u2019 Roses",
+            "track": 16,
+            "year": 1991,
+            "coverArt": "al-2250",
+            "size": 20014868,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 616,
+            "bitRate": 256,
+            "path": "Guns N\\u2019 Roses/Use Your Illusion I/16 Coma.mp3",
+            "isVideo": false,
+            "discNumber": 1,
+            "created": "2024-08-18T03:38:39.726021Z",
+            "type": "music",
+            "musicBrainzId": "2146c769-d454-435d-a924-7960f9272540",
+            "artists": [
+                {
+                    "id": "ar-280",
+                    "name": "Guns N\\u2019 Roses"
+                }
+            ],
+            "displayArtist": "",
+            "displayAlbumArtist": ""
+        }
+    ]
+}
diff --git a/tests/providers/opensubsonic/fixtures/podcasts/gonic-sample.podcast.json b/tests/providers/opensubsonic/fixtures/podcasts/gonic-sample.podcast.json
new file mode 100644 (file)
index 0000000..a31b6b0
--- /dev/null
@@ -0,0 +1,106 @@
+{
+    "id": "pd-5",
+    "url": "http://feeds.feedburner.com/TheHistoryOfRome",
+    "status": "skipped",
+    "title": "The History of Rome",
+    "description": "A weekly podcast tracing the rise, decline and fall of the Roman Empire. Now complete!",
+    "coverArt": "pd-5",
+    "originalImageUrl": "https://static.libsyn.com/p/assets/1/2/f/c/12fc067020662e91/THoR_Logo_1500x1500.jpg",
+    "episode": [
+        {
+            "id": "pe-4805",
+            "isDir": false,
+            "title": "Ad-Free History of Rome Patreon",
+            "parent": "",
+            "year": 2024,
+            "genre": "Podcast",
+            "coverArt": "pd-5",
+            "size": 1717520,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 71,
+            "path": "",
+            "channelId": "pd-5",
+            "status": "completed",
+            "streamId": "pe-4805",
+            "description": "Become a patron and get the entire History of Rome backcatalog ad-fre, plus bonus content, behind the scenes peeks at the new book, plus a chat community where you can talk to me directly. Join today! Patreon: patreon.com/thehistoryofrome Merch Store: cottonbureau.com/mikeduncan",
+            "publishDate": "2024-11-05T02:35:00Z"
+        },
+        {
+            "id": "pe-1857",
+            "isDir": false,
+            "title": "The Storm Before The Storm: Chapter 1- The Beasts of Italy",
+            "parent": "",
+            "year": 2017,
+            "genre": "Podcast",
+            "coverArt": "pd-5",
+            "size": 80207374,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 3340,
+            "path": "",
+            "channelId": "pd-5",
+            "status": "completed",
+            "streamId": "pe-1857",
+            "description": "Audio excerpt from The Storm Before the Storm: The Beginning of the End of the Roman Republic by Mike Duncan. Forthcoming Oct. 24, 2017. Pre-order a copy today! Amazon Powells Barnes & Noble Indibound Books-a-Million Or visit us at: revolutionspodcast.com thehistoryofrome.com",
+            "publishDate": "2017-07-27T11:30:00Z"
+        },
+        {
+            "id": "pe-1858",
+            "isDir": false,
+            "title": "Revolutions Launch",
+            "parent": "",
+            "year": 2013,
+            "genre": "Podcast",
+            "coverArt": "pd-5",
+            "size": 253200,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 15,
+            "path": "",
+            "channelId": "pd-5",
+            "status": "completed",
+            "streamId": "pe-1858",
+            "description": "Available at revolutionspodcast.com, iTunes, or anywhere else fine podcasts can be found.",
+            "publishDate": "2013-09-16T15:39:57Z"
+        },
+        {
+            "id": "pe-1859",
+            "isDir": false,
+            "title": "Update- One Year Later",
+            "parent": "",
+            "year": 2013,
+            "genre": "Podcast",
+            "coverArt": "pd-5",
+            "size": 1588998,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 99,
+            "path": "",
+            "channelId": "pd-5",
+            "status": "completed",
+            "streamId": "pe-1859",
+            "description": "Next show coming soon!",
+            "publishDate": "2013-05-30T15:18:57Z"
+        },
+        {
+            "id": "pe-1860",
+            "isDir": false,
+            "title": "179- The End",
+            "parent": "",
+            "year": 2012,
+            "genre": "Podcast",
+            "coverArt": "pd-5",
+            "size": 15032655,
+            "contentType": "audio/mpeg",
+            "suffix": "mp3",
+            "duration": 1878,
+            "path": "",
+            "channelId": "pd-5",
+            "status": "completed",
+            "streamId": "pe-1860",
+            "description": "The history of The History of Rome...Why the Western Empire Fell when it did...Some thoughts on the future...Thank you, goodnight.",
+            "publishDate": "2012-05-06T18:18:38Z"
+        }
+    ]
+}
index 93fbb1ce4136bff4f580529eb408a17a2034d178..831558aa36d34bfa01d62f2447e82c6d1673d08a 100644 (file)
@@ -1,20 +1,35 @@
-"""Test we can parse Jellyfin models into Music Assistant models."""
+"""Test we can parse Open Subsonic models into Music Assistant models."""
 
-import json
 import logging
 import pathlib
 
 import aiofiles
 import pytest
-from libopensonic.media.album import Album, AlbumInfo
-from libopensonic.media.artist import Artist, ArtistInfo
+from libopensonic.media import (
+    Album,
+    AlbumInfo,
+    Artist,
+    ArtistInfo,
+    Playlist,
+    PodcastChannel,
+    PodcastEpisode,
+)
 from syrupy.assertion import SnapshotAssertion
 
-from music_assistant.providers.opensubsonic.parsers import parse_album, parse_artist
+from music_assistant.providers.opensubsonic.parsers import (
+    parse_album,
+    parse_artist,
+    parse_epsiode,
+    parse_playlist,
+    parse_podcast,
+)
 
 FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures"
 ARTIST_FIXTURES = list(FIXTURES_DIR.glob("artists/*.artist.json"))
 ALBUM_FIXTURES = list(FIXTURES_DIR.glob("albums/*.album.json"))
+PLAYLIST_FIXTURES = list(FIXTURES_DIR.glob("playlists/*.playlist.json"))
+PODCAST_FIXTURES = list(FIXTURES_DIR.glob("podcasts/*.podcast.json"))
+EPISODE_FIXTURES = list(FIXTURES_DIR.glob("episodes/*.episode.json"))
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -23,7 +38,7 @@ _LOGGER = logging.getLogger(__name__)
 async def test_parse_artists(example: pathlib.Path, snapshot: SnapshotAssertion) -> None:
     """Test we can parse artists."""
     async with aiofiles.open(example) as fp:
-        artist = Artist(json.loads(await fp.read()))
+        artist = Artist.from_json(await fp.read())
 
     parsed = parse_artist("xx-instance-id-xx", artist).to_dict()
     # sort external Ids to ensure they are always in the same order for snapshot testing
@@ -33,7 +48,7 @@ async def test_parse_artists(example: pathlib.Path, snapshot: SnapshotAssertion)
     # Find the corresponding info file
     example_info = example.with_suffix("").with_suffix(".info.json")
     async with aiofiles.open(example_info) as fp:
-        artist_info = ArtistInfo(json.loads(await fp.read()))
+        artist_info = ArtistInfo.from_json(await fp.read())
 
     parsed = parse_artist("xx-instance-id-xx", artist, artist_info).to_dict()
     # sort external Ids to ensure they are always in the same order for snapshot testing
@@ -45,7 +60,7 @@ async def test_parse_artists(example: pathlib.Path, snapshot: SnapshotAssertion)
 async def test_parse_albums(example: pathlib.Path, snapshot: SnapshotAssertion) -> None:
     """Test we can parse albums."""
     async with aiofiles.open(example) as fp:
-        album = Album(json.loads(await fp.read()))
+        album = Album.from_json(await fp.read())
 
     parsed = parse_album(_LOGGER, "xx-instance-id-xx", album).to_dict()
     # sort external Ids to ensure they are always in the same order for snapshot testing
@@ -55,9 +70,49 @@ async def test_parse_albums(example: pathlib.Path, snapshot: SnapshotAssertion)
     # Find the corresponding info file
     example_info = example.with_suffix("").with_suffix(".info.json")
     async with aiofiles.open(example_info) as fp:
-        album_info = AlbumInfo(json.loads(await fp.read()))
+        album_info = AlbumInfo.from_json(await fp.read())
 
     parsed = parse_album(_LOGGER, "xx-instance-id-xx", album, album_info).to_dict()
     # sort external Ids to ensure they are always in the same order for snapshot testing
     parsed["external_ids"].sort()
     assert snapshot == parsed
+
+
+@pytest.mark.parametrize("example", PLAYLIST_FIXTURES, ids=lambda val: str(val.stem))
+async def test_parse_playlist(example: pathlib.Path, snapshot: SnapshotAssertion) -> None:
+    """Test we can parse Playlists."""
+    async with aiofiles.open(example) as fp:
+        playlist = Playlist.from_json(await fp.read())
+
+    parsed = parse_playlist("xx-instance-id-xx", playlist).to_dict()
+    # sort external Ids to ensure they are always in the same order for snapshot testing
+    parsed["external_ids"].sort()
+    assert snapshot == parsed
+
+
+@pytest.mark.parametrize("example", PODCAST_FIXTURES, ids=lambda val: str(val.stem))
+async def test_parse_podcast(example: pathlib.Path, snapshot: SnapshotAssertion) -> None:
+    """Test we can parse Podcasts."""
+    async with aiofiles.open(example) as fp:
+        podcast = PodcastChannel.from_json(await fp.read())
+
+    parsed = parse_podcast("xx-instance-id-xx", podcast).to_dict()
+    # sort external Ids to ensure they are always in the same order for snapshot testing
+    parsed["external_ids"].sort()
+    assert snapshot == parsed
+
+
+@pytest.mark.parametrize("example", EPISODE_FIXTURES, ids=lambda val: str(val.stem))
+async def test_parse_episode(example: pathlib.Path, snapshot: SnapshotAssertion) -> None:
+    """Test we can parse Podcast Episodes."""
+    async with aiofiles.open(example) as fp:
+        episode = PodcastEpisode.from_json(await fp.read())
+
+    example_channel = example.with_suffix("").with_suffix(".podcast.json")
+    async with aiofiles.open(example_channel) as fp:
+        channel = PodcastChannel.from_json(await fp.read())
+
+    parsed = parse_epsiode("xx-instance-id-xx", episode, channel).to_dict()
+    # sort external Ids to ensure they are always in the same order for snapshot testing
+    parsed["external_ids"].sort()
+    assert snapshot == parsed