From: Eric Munson Date: Sun, 4 May 2025 18:34:09 +0000 (-0400) Subject: Subsonic: Move to new api library (#2160) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=e8cb0dee72ea06effaec0be389cfa8402a33b5d6;p=music-assistant-server.git Subsonic: Move to new api library (#2160) --- diff --git a/music_assistant/providers/opensubsonic/manifest.json b/music_assistant/providers/opensubsonic/manifest.json index 8d8911b2..ef3f738e 100644 --- a/music_assistant/providers/opensubsonic/manifest.json +++ b/music_assistant/providers/opensubsonic/manifest.json @@ -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 diff --git a/music_assistant/providers/opensubsonic/parsers.py b/music_assistant/providers/opensubsonic/parsers.py index a9c29308..faef6d86 100644 --- a/music_assistant/providers/opensubsonic/parsers.py +++ b/music_assistant/providers/opensubsonic/parsers.py @@ -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 diff --git a/music_assistant/providers/opensubsonic/sonic_provider.py b/music_assistant/providers/opensubsonic/sonic_provider.py index 8afabe56..535f11da 100644 --- a/music_assistant/providers/opensubsonic/sonic_provider.py +++ b/music_assistant/providers/opensubsonic/sonic_provider.py @@ -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( diff --git a/requirements_all.txt b/requirements_all.txt index 2f654a33..14269f87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr b/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr index d08a9ca2..cfd3615f 100644 --- a/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr +++ b/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr @@ -439,3 +439,260 @@ '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 index 00000000..219a4d37 --- /dev/null +++ b/tests/providers/opensubsonic/fixtures/episodes/gonic-sample.episode.json @@ -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 index 00000000..a31b6b0f --- /dev/null +++ b/tests/providers/opensubsonic/fixtures/episodes/gonic-sample.podcast.json @@ -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 index 00000000..f11f4f7b --- /dev/null +++ b/tests/providers/opensubsonic/fixtures/playlists/gonic-sample.playlist.json @@ -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 index 00000000..a31b6b0f --- /dev/null +++ b/tests/providers/opensubsonic/fixtures/podcasts/gonic-sample.podcast.json @@ -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/test_parsers.py b/tests/providers/opensubsonic/test_parsers.py index 93fbb1ce..831558aa 100644 --- a/tests/providers/opensubsonic/test_parsers.py +++ b/tests/providers/opensubsonic/test_parsers.py @@ -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