From: Eric Munson Date: Thu, 15 May 2025 20:15:19 +0000 (-0400) Subject: chore/fix: Subsonic: Update parsers and tests (#2181) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=996bf980f89d8f01adfb0a4656409e3988b32fb4;p=music-assistant-server.git chore/fix: Subsonic: Update parsers and tests (#2181) --- diff --git a/music_assistant/providers/opensubsonic/parsers.py b/music_assistant/providers/opensubsonic/parsers.py index 24a7f924..ddf0e332 100644 --- a/music_assistant/providers/opensubsonic/parsers.py +++ b/music_assistant/providers/opensubsonic/parsers.py @@ -6,27 +6,30 @@ 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 ContentType, ImageType, MediaType from music_assistant_models.errors import MediaNotFoundError from music_assistant_models.media_items import ( Album, Artist, + AudioFormat, ItemMapping, MediaItemImage, + MediaItemMetadata, Playlist, Podcast, PodcastEpisode, ProviderMapping, + Track, ) -from music_assistant_models.unique_list import UniqueList from music_assistant.constants import UNKNOWN_ARTIST if TYPE_CHECKING: - from libopensonic.media import Album as SonicAlbum + from libopensonic.media import AlbumID3 as SonicAlbum from libopensonic.media import AlbumInfo as SonicAlbumInfo - from libopensonic.media import Artist as SonicArtist + from libopensonic.media import ArtistID3 as SonicArtist from libopensonic.media import ArtistInfo as SonicArtistInfo + from libopensonic.media import Child as SonicSong from libopensonic.media import Playlist as SonicPlaylist from libopensonic.media import PodcastChannel as SonicPodcast from libopensonic.media import PodcastEpisode as SonicEpisode @@ -41,27 +44,186 @@ UNKNOWN_ARTIST_ID = "fake_artist_unknown" EP_CHAN_SEP = "$!$" -def parse_artist( - instance_id: str, sonic_artist: SonicArtist, sonic_info: SonicArtistInfo = None -) -> Artist: - """Parse artist and artistInfo into a Music Assistant Artist.""" - artist = Artist( - item_id=sonic_artist.id, - name=sonic_artist.name, - provider="opensubsonic", - favorite=bool(sonic_artist.starred), +# 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-" + + +SUBSONIC_DOMAIN = "opensubsonic" + + +def get_item_mapping(instance_id: str, media_type: MediaType, key: str, name: str) -> ItemMapping: + """Construct an ItemMapping for the specified media.""" + return ItemMapping( + media_type=media_type, + item_id=key, + provider=instance_id, + name=name, + ) + + +def parse_track( + logger: logging.Logger, + instance_id: str, + sonic_song: SonicSong, + album: Album | ItemMapping | None = None, +) -> Track: + """Parse an OpenSubsonic.Child into an MA Track.""" + # Unfortunately, the Song response type is not defined in the open subsonic spec so we have + # implementations which disagree about where the album id for this song should be stored. + # We accept either song.ablum_id or song.parent but prefer album_id. + if not album: + if sonic_song.album_id and sonic_song.album: + album = get_item_mapping( + instance_id, MediaType.ALBUM, sonic_song.album_id, sonic_song.album + ) + elif sonic_song.parent and sonic_song.album: + album = get_item_mapping( + instance_id, MediaType.ALBUM, sonic_song.parent, sonic_song.album + ) + + metadata: MediaItemMetadata = MediaItemMetadata() + + if sonic_song.explicit_status and sonic_song.explicit_status != "clean": + metadata.explicit = True + + if sonic_song.genre: + if not metadata.genres: + metadata.genres = set() + metadata.genres.add(sonic_song.genre) + + if sonic_song.genres: + if not metadata.genres: + metadata.genres = set() + for g in sonic_song.genres: + metadata.genres.add(g.name) + + if sonic_song.moods: + metadata.mood = sonic_song.moods[0] + + if sonic_song.contributors: + if not metadata.performers: + metadata.performers = set() + for c in sonic_song.contributors: + metadata.performers.add(c.artist.name) + + track = Track( + item_id=sonic_song.id, + provider=instance_id, + name=sonic_song.title, + album=album, + duration=sonic_song.duration if sonic_song.duration is not None else 0, + disc_number=sonic_song.disc_number or 0, + favorite=bool(sonic_song.starred), + metadata=metadata, provider_mappings={ ProviderMapping( - item_id=sonic_artist.id, - provider_domain="opensubsonic", + item_id=sonic_song.id, + provider_domain=SUBSONIC_DOMAIN, provider_instance=instance_id, + available=True, + audio_format=AudioFormat( + content_type=ContentType.try_parse(sonic_song.content_type), + sample_rate=sonic_song.sampling_rate if sonic_song.sampling_rate else 44100, + bit_depth=sonic_song.bit_depth if sonic_song.bit_depth else 16, + channels=sonic_song.channel_count if sonic_song.channel_count else 2, + bit_rate=sonic_song.bit_rate if sonic_song.bit_rate else None, + ), ) }, + track_number=sonic_song.track if sonic_song.track else 0, ) - artist.metadata.images = UniqueList() + if sonic_song.music_brainz_id: + track.mbid = sonic_song.music_brainz_id + + if sonic_song.sort_name: + track.sort_name = sonic_song.sort_name + + # We need to find an artist for this track but various implementations seem to disagree + # about where the artist with the valid ID needs to be found. We will add any artist with + # an ID and only use UNKNOWN if none are found. + + if sonic_song.artist_id: + track.artists.append( + get_item_mapping( + instance_id, + MediaType.ARTIST, + sonic_song.artist_id, + sonic_song.artist if sonic_song.artist else UNKNOWN_ARTIST, + ) + ) + + for entry in sonic_song.artists: + if entry.id == sonic_song.artist_id: + continue + if entry.id is not None and entry.name is not None: + track.artists.append( + get_item_mapping(instance_id, MediaType.ARTIST, entry.id, entry.name) + ) + + if not track.artists: + if sonic_song.artist and not sonic_song.artist_id: + # This is how Navidrome handles tracks from albums which are marked + # 'Various Artists'. Unfortunately, we cannot lookup this artist independently + # because it will not have an entry in the artists table so the best we can do it + # add a 'fake' id with the proper artist name and have get_artist() check for this + # id and handle it locally. + fake_id = f"{NAVI_VARIOUS_PREFIX}{sonic_song.artist}" + artist = Artist( + item_id=fake_id, + provider=SUBSONIC_DOMAIN, + name=sonic_song.artist, + provider_mappings={ + ProviderMapping( + item_id=fake_id, + provider_domain=SUBSONIC_DOMAIN, + provider_instance=instance_id, + ) + }, + ) + else: + logger.info( + "Unable to find artist ID for track '%s' with ID '%s'.", + sonic_song.title, + sonic_song.id, + ) + artist = Artist( + item_id=UNKNOWN_ARTIST_ID, + name=UNKNOWN_ARTIST, + provider=instance_id, + provider_mappings={ + ProviderMapping( + item_id=UNKNOWN_ARTIST_ID, + provider_domain=SUBSONIC_DOMAIN, + provider_instance=instance_id, + ) + }, + ) + + track.artists.append(artist) + return track + + +def parse_artist( + instance_id: str, sonic_artist: SonicArtist, sonic_info: SonicArtistInfo = None +) -> Artist: + """Parse artist and artistInfo into a Music Assistant Artist.""" + metadata: MediaItemMetadata = MediaItemMetadata() + + if sonic_artist.artist_image_url: + metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=sonic_artist.artist_image_url, + provider=instance_id, + remotely_accessible=True, + ) + ) + if sonic_artist.cover_art: - artist.metadata.images.append( + metadata.add_image( MediaItemImage( type=ImageType.THUMB, path=sonic_artist.cover_art, @@ -69,12 +231,11 @@ def parse_artist( remotely_accessible=False, ) ) - if sonic_info: if sonic_info.biography: - artist.metadata.description = sonic_info.biography + metadata.description = sonic_info.biography if sonic_info.small_image_url: - artist.metadata.images.append( + metadata.add_image( MediaItemImage( type=ImageType.THUMB, path=sonic_info.small_image_url, @@ -83,6 +244,25 @@ def parse_artist( ) ) + artist = Artist( + item_id=sonic_artist.id, + name=sonic_artist.name, + metadata=metadata, + provider=SUBSONIC_DOMAIN, + favorite=bool(sonic_artist.starred), + provider_mappings={ + ProviderMapping( + item_id=sonic_artist.id, + provider_domain=SUBSONIC_DOMAIN, + provider_instance=instance_id, + ) + }, + sort_name=sonic_artist.sort_name if sonic_artist.sort_name else None, + ) + + if sonic_artist.music_brainz_id: + artist.mbid = sonic_artist.music_brainz_id + return artist @@ -93,32 +273,66 @@ def parse_album( sonic_info: SonicAlbumInfo | None = None, ) -> Album: """Parse album and albumInfo into a Music Assistant Album.""" - album_id = sonic_album.id + metadata: MediaItemMetadata = MediaItemMetadata() + + if sonic_album.cover_art: + metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=sonic_album.cover_art, + provider=instance_id, + remotely_accessible=False, + ), + ) + + if sonic_info: + if sonic_info.small_image_url: + metadata.add_image( + MediaItemImage( + type=ImageType.THUMB, + path=sonic_info.small_image_url, + remotely_accessible=True, + provider=instance_id, + ) + ) + if sonic_info.notes: + metadata.description = sonic_info.notes + + if sonic_album.genre: + if not metadata.genres: + metadata.genres = set() + metadata.genres.add(sonic_album.genre) + + if sonic_album.genres: + if not metadata.genres: + metadata.genres = set() + for g in sonic_album.genres: + metadata.genres.add(g.name) + + if sonic_album.moods: + metadata.mood = sonic_album.moods[0] + album = Album( - item_id=album_id, - provider="opensubsonic", + item_id=sonic_album.id, + provider=SUBSONIC_DOMAIN, + metadata=metadata, name=sonic_album.name, favorite=bool(sonic_album.starred), provider_mappings={ ProviderMapping( - item_id=album_id, - provider_domain="opensubsonic", + item_id=sonic_album.id, + provider_domain=SUBSONIC_DOMAIN, provider_instance=instance_id, ) }, year=sonic_album.year, ) - album.metadata.images = UniqueList() - if sonic_album.cover_art: - album.metadata.images.append( - MediaItemImage( - type=ImageType.THUMB, - path=sonic_album.cover_art, - provider=instance_id, - remotely_accessible=False, - ), - ) + if sonic_album.sort_name: + album.sort_name = sonic_album.sort_name + + if sonic_album.music_brainz_id: + album.mbid = sonic_album.music_brainz_id if sonic_album.artist_id: album.artists.append( @@ -129,7 +343,7 @@ def parse_album( name=sonic_album.artist if sonic_album.artist else UNKNOWN_ARTIST, ) ) - else: + elif not sonic_album.artists: logger.info( "Unable to find an artist ID for album '%s' with ID '%s'.", sonic_album.name, @@ -143,25 +357,22 @@ def parse_album( provider_mappings={ ProviderMapping( item_id=UNKNOWN_ARTIST_ID, - provider_domain="opensubsonic", + provider_domain=SUBSONIC_DOMAIN, provider_instance=instance_id, ) }, ) ) - if sonic_info: - if sonic_info.small_image_url: - album.metadata.images.append( - MediaItemImage( - type=ImageType.THUMB, - path=sonic_info.small_image_url, - remotely_accessible=False, - provider=instance_id, + if sonic_album.artists: + for a in sonic_album.artists: + if a.id == sonic_album.artist_id: + continue + album.artists.append( + ItemMapping( + media_type=MediaType.ARTIST, item_id=a.id, provider=instance_id, name=a.name ) ) - if sonic_info.notes: - album.metadata.description = sonic_info.notes return album @@ -170,21 +381,20 @@ 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", + provider=SUBSONIC_DOMAIN, name=sonic_playlist.name, is_editable=True, provider_mappings={ ProviderMapping( item_id=sonic_playlist.id, - provider_domain="opensubsonic", + provider_domain=SUBSONIC_DOMAIN, provider_instance=instance_id, ) }, ) if sonic_playlist.cover_art: - playlist.metadata.images = UniqueList() - playlist.metadata.images.append( + playlist.metadata.add_image( MediaItemImage( type=ImageType.THUMB, path=sonic_playlist.cover_art, @@ -200,24 +410,23 @@ 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", + provider=SUBSONIC_DOMAIN, 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_domain=SUBSONIC_DOMAIN, provider_instance=instance_id, ) }, ) podcast.metadata.description = sonic_podcast.description - podcast.metadata.images = UniqueList() if sonic_podcast.cover_art: - podcast.metadata.images.append( + podcast.metadata.add_image( MediaItemImage( type=ImageType.THUMB, path=sonic_podcast.cover_art, @@ -245,14 +454,14 @@ def parse_epsiode( episode = PodcastEpisode( item_id=eid, - provider="opensubsonic", + provider=SUBSONIC_DOMAIN, name=sonic_episode.title, position=pos, podcast=parse_podcast(instance_id, sonic_channel), provider_mappings={ ProviderMapping( item_id=eid, - provider_domain="opensubsonic", + provider_domain=SUBSONIC_DOMAIN, provider_instance=instance_id, ) }, diff --git a/music_assistant/providers/opensubsonic/sonic_provider.py b/music_assistant/providers/opensubsonic/sonic_provider.py index 83e5d1c5..7875f02c 100644 --- a/music_assistant/providers/opensubsonic/sonic_provider.py +++ b/music_assistant/providers/opensubsonic/sonic_provider.py @@ -25,7 +25,6 @@ from music_assistant_models.media_items import ( Album, Artist, AudioFormat, - ItemMapping, MediaItemType, Playlist, Podcast, @@ -47,12 +46,14 @@ from music_assistant.models.music_provider import MusicProvider from .parsers import ( EP_CHAN_SEP, + NAVI_VARIOUS_PREFIX, UNKNOWN_ARTIST_ID, parse_album, parse_artist, parse_epsiode, parse_playlist, parse_podcast, + parse_track, ) if TYPE_CHECKING: @@ -73,11 +74,6 @@ CONF_ENABLE_LEGACY_AUTH = "enable_legacy_auth" CONF_OVERRIDE_OFFSET = "override_transcode_offest" -# 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-" - Param = ParamSpec("Param") RetType = TypeVar("RetType") @@ -164,111 +160,6 @@ class OpenSonicProvider(MusicProvider): """ return False - def _get_item_mapping(self, media_type: MediaType, key: str, name: str) -> ItemMapping: - return ItemMapping( - media_type=media_type, - item_id=key, - provider=self.instance_id, - name=name, - ) - - def _parse_track( - self, sonic_song: SonicSong, album: Album | ItemMapping | None = None - ) -> Track: - # Unfortunately, the Song response type is not defined in the open subsonic spec so we have - # implementations which disagree about where the album id for this song should be stored. - # We accept either song.ablum_id or song.parent but prefer album_id. - if not album: - if sonic_song.album_id and sonic_song.album: - album = self._get_item_mapping( - MediaType.ALBUM, sonic_song.album_id, sonic_song.album - ) - elif sonic_song.parent and sonic_song.album: - album = self._get_item_mapping(MediaType.ALBUM, sonic_song.parent, sonic_song.album) - - track = Track( - item_id=sonic_song.id, - provider=self.instance_id, - name=sonic_song.title, - album=album, - duration=sonic_song.duration if sonic_song.duration is not None else 0, - disc_number=sonic_song.disc_number or 0, - favorite=bool(sonic_song.starred), - provider_mappings={ - ProviderMapping( - item_id=sonic_song.id, - provider_domain=self.domain, - provider_instance=self.instance_id, - available=True, - audio_format=AudioFormat( - content_type=ContentType.try_parse(sonic_song.content_type) - ), - ) - }, - 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 - # about where the artist with the valid ID needs to be found. We will add any artist with - # an ID and only use UNKNOWN if none are found. - - if sonic_song.artist_id: - track.artists.append( - self._get_item_mapping( - MediaType.ARTIST, - sonic_song.artist_id, - sonic_song.artist if sonic_song.artist else UNKNOWN_ARTIST, - ) - ) - - for entry in sonic_song.artists: - if entry.id == sonic_song.artist_id: - continue - if entry.id is not None and entry.name is not None: - track.artists.append(self._get_item_mapping(MediaType.ARTIST, entry.id, entry.name)) - - if not track.artists: - if sonic_song.artist and not sonic_song.artist_id: - # This is how Navidrome handles tracks from albums which are marked - # 'Various Artists'. Unfortunately, we cannot lookup this artist independently - # because it will not have an entry in the artists table so the best we can do it - # add a 'fake' id with the proper artist name and have get_artist() check for this - # id and handle it locally. - fake_id = f"{NAVI_VARIOUS_PREFIX}{sonic_song.artist}" - artist = Artist( - item_id=fake_id, - provider=self.domain, - name=sonic_song.artist, - provider_mappings={ - ProviderMapping( - item_id=fake_id, - provider_domain=self.domain, - provider_instance=self.instance_id, - ) - }, - ) - else: - self.logger.info( - "Unable to find artist ID for track '%s' with ID '%s'.", - sonic_song.title, - sonic_song.id, - ) - artist = Artist( - item_id=UNKNOWN_ARTIST_ID, - name=UNKNOWN_ARTIST, - provider=self.instance_id, - provider_mappings={ - ProviderMapping( - item_id=UNKNOWN_ARTIST_ID, - provider_domain=self.domain, - provider_instance=self.instance_id, - ) - }, - ) - - track.artists.append(artist) - return track - 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.get_podcasts, inc_episodes=True, pid=chan_id) @@ -328,7 +219,9 @@ class OpenSonicProvider(MusicProvider): 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 [], + tracks=[parse_track(self.logger, self.instance_id, entry) for entry in answer.song] + if answer.song + else [], ) async def get_library_artists(self) -> AsyncGenerator[Artist, None]: @@ -412,7 +305,7 @@ class OpenSonicProvider(MusicProvider): 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) - yield self._parse_track(entry, album=album) + yield parse_track(self.logger, self.instance_id, entry, album=album) offset += count results = await self._run_async( self._conn.search3, @@ -444,7 +337,7 @@ class OpenSonicProvider(MusicProvider): tracks = [] if sonic_album.song: for sonic_song in sonic_album.song: - tracks.append(self._parse_track(sonic_song)) + tracks.append(parse_track(self.logger, self.instance_id, sonic_song)) return tracks async def get_artist(self, prov_artist_id: str) -> Artist: @@ -500,7 +393,7 @@ class OpenSonicProvider(MusicProvider): self.logger.warning("Unable to find album id for track %s", sonic_song.id) else: album = await self.get_album(prov_album_id=aid) - return self._parse_track(sonic_song, album=album) + return parse_track(self.logger, self.instance_id, sonic_song, album=album) async def get_artist_albums(self, prov_artist_id: str) -> list[Album]: """Return a list of all Albums by specified Artist.""" @@ -596,7 +489,7 @@ class OpenSonicProvider(MusicProvider): 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) + track = parse_track(self.logger, self.instance_id, sonic_song, album=album) track.position = index result.append(track) return result @@ -613,7 +506,7 @@ class OpenSonicProvider(MusicProvider): msg = f"Artist {prov_artist_id} not found" raise MediaNotFoundError(msg) from e songs: list[SonicSong] = await self._run_async(self._conn.get_top_songs, sonic_artist.name) - return [self._parse_track(entry) for entry in songs] + return [parse_track(self.logger, self.instance_id, 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.""" @@ -627,7 +520,7 @@ class OpenSonicProvider(MusicProvider): # exception means we didn't find anything similar. self.logger.info(e) return [] - return [self._parse_track(entry) for entry in songs] + return [parse_track(self.logger, self.instance_id, entry) for entry in songs] async def create_playlist(self, name: str) -> Playlist: """Create a new empty playlist on the server.""" diff --git a/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr b/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr index cfd3615f..30ba1af1 100644 --- a/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr +++ b/tests/providers/opensubsonic/__snapshots__/test_parsers.ambr @@ -18,8 +18,42 @@ 'uri': 'xx-instance-id-xx://artist/91c3901ac465b9efc439e4be4270c2b6', 'version': '', }), + dict({ + 'available': True, + 'external_ids': list([ + ]), + 'image': None, + 'is_playable': True, + 'item_id': 'ar-1', + 'media_type': 'artist', + 'name': 'Artist 1', + 'provider': 'xx-instance-id-xx', + 'sort_name': 'artist 1', + 'translation_key': None, + 'uri': 'xx-instance-id-xx://artist/ar-1', + 'version': '', + }), + dict({ + 'available': True, + 'external_ids': list([ + ]), + 'image': None, + 'is_playable': True, + 'item_id': 'ar-2', + 'media_type': 'artist', + 'name': 'Artist 2', + 'provider': 'xx-instance-id-xx', + 'sort_name': 'artist 2', + 'translation_key': None, + 'uri': 'xx-instance-id-xx://artist/ar-2', + 'version': '', + }), ]), 'external_ids': list([ + list([ + 'musicbrainz_albumid', + '189002e7-3285-4e2e-92a3-7f6c30d407a2', + ]), ]), 'favorite': True, 'is_playable': True, @@ -30,7 +64,10 @@ 'copyright': None, 'description': None, 'explicit': None, - 'genres': None, + 'genres': list([ + 'East coast', + 'Hip-Hop', + ]), 'images': list([ dict({ 'path': 'al-ad0f112b6dcf83de5e9cae85d07f0d35_640a93a8', @@ -45,7 +82,7 @@ 'links': None, 'lrc_lyrics': None, 'lyrics': None, - 'mood': None, + 'mood': 'slow', 'performers': None, 'popularity': None, 'preview': None, @@ -75,7 +112,7 @@ 'url': None, }), ]), - 'sort_name': '8-bit lagerfeuer', + 'sort_name': 'lagerfeuer (8-bit)', 'translation_key': None, 'uri': 'opensubsonic://album/ad0f112b6dcf83de5e9cae85d07f0d35', 'version': '', @@ -101,8 +138,42 @@ 'uri': 'xx-instance-id-xx://artist/91c3901ac465b9efc439e4be4270c2b6', 'version': '', }), + dict({ + 'available': True, + 'external_ids': list([ + ]), + 'image': None, + 'is_playable': True, + 'item_id': 'ar-1', + 'media_type': 'artist', + 'name': 'Artist 1', + 'provider': 'xx-instance-id-xx', + 'sort_name': 'artist 1', + 'translation_key': None, + 'uri': 'xx-instance-id-xx://artist/ar-1', + 'version': '', + }), + dict({ + 'available': True, + 'external_ids': list([ + ]), + 'image': None, + 'is_playable': True, + 'item_id': 'ar-2', + 'media_type': 'artist', + 'name': 'Artist 2', + 'provider': 'xx-instance-id-xx', + 'sort_name': 'artist 2', + 'translation_key': None, + 'uri': 'xx-instance-id-xx://artist/ar-2', + 'version': '', + }), ]), 'external_ids': list([ + list([ + 'musicbrainz_albumid', + '189002e7-3285-4e2e-92a3-7f6c30d407a2', + ]), ]), 'favorite': True, 'is_playable': True, @@ -113,7 +184,10 @@ 'copyright': None, 'description': 'Download the full release here (creative commons). These cripsy beats are ripe with thumping funk and techno influences, sample wizardry and daring shuffles. Composed with the help of unique sound plugins which were especially programmed to measure Comfort Fit’s needs and wishes, we think the chances aren’t bad that you’ll fall for the unique sound signature, bounce and elegance of this unusual Hip Hop production. Ltj bukem / Good looking Rec., UK: "Really love this music." Velanche / XLR8R, UK: "Awesome job he\'s done... overall production is dope." Kwesi / BBE Music, UK: "Wooooooowwwww... WHAT THE FUCK! THIS IS WHAT', 'explicit': None, - 'genres': None, + 'genres': list([ + 'East coast', + 'Hip-Hop', + ]), 'images': list([ dict({ 'path': 'al-ad0f112b6dcf83de5e9cae85d07f0d35_640a93a8', @@ -124,7 +198,7 @@ dict({ 'path': 'http://localhost:8989/play/art/0f8c3cbd6b0b22c3b5402141351ac812/album/21/thumb34.jpg', 'provider': 'xx-instance-id-xx', - 'remotely_accessible': False, + 'remotely_accessible': True, 'type': 'thumb', }), ]), @@ -134,7 +208,7 @@ 'links': None, 'lrc_lyrics': None, 'lyrics': None, - 'mood': None, + 'mood': 'slow', 'performers': None, 'popularity': None, 'preview': None, @@ -164,7 +238,7 @@ 'url': None, }), ]), - 'sort_name': '8-bit lagerfeuer', + 'sort_name': 'lagerfeuer (8-bit)', 'translation_key': None, 'uri': 'opensubsonic://album/ad0f112b6dcf83de5e9cae85d07f0d35', 'version': '', @@ -174,6 +248,10 @@ # name: test_parse_artists[spec-artistid3.artist] dict({ 'external_ids': list([ + list([ + 'musicbrainz_artistid', + '189002e7-3285-4e2e-92a3-7f6c30d407a2', + ]), ]), 'favorite': True, 'is_playable': True, @@ -186,6 +264,12 @@ 'explicit': None, 'genres': None, 'images': list([ + dict({ + 'path': 'https://demo.org/image.jpg', + 'provider': 'xx-instance-id-xx', + 'remotely_accessible': True, + 'type': 'thumb', + }), dict({ 'path': 'ar-37ec820ca7193e17040c98f7da7c4b51_0', 'provider': 'xx-instance-id-xx', @@ -229,7 +313,7 @@ 'url': None, }), ]), - 'sort_name': '2 mello', + 'sort_name': 'Mello (2)', 'translation_key': None, 'uri': 'opensubsonic://artist/37ec820ca7193e17040c98f7da7c4b51', 'version': '', @@ -238,6 +322,10 @@ # name: test_parse_artists[spec-artistid3.artist].1 dict({ 'external_ids': list([ + list([ + 'musicbrainz_artistid', + '189002e7-3285-4e2e-92a3-7f6c30d407a2', + ]), ]), 'favorite': True, 'is_playable': True, @@ -250,6 +338,12 @@ 'explicit': None, 'genres': None, 'images': list([ + dict({ + 'path': 'https://demo.org/image.jpg', + 'provider': 'xx-instance-id-xx', + 'remotely_accessible': True, + 'type': 'thumb', + }), dict({ 'path': 'ar-37ec820ca7193e17040c98f7da7c4b51_0', 'provider': 'xx-instance-id-xx', @@ -299,7 +393,7 @@ 'url': None, }), ]), - 'sort_name': '2 mello', + 'sort_name': 'Mello (2)', 'translation_key': None, 'uri': 'opensubsonic://artist/37ec820ca7193e17040c98f7da7c4b51', 'version': '', @@ -696,3 +790,322 @@ 'version': '', }) # --- +# name: test_parse_track[spec-child.track] + dict({ + 'album': dict({ + 'available': True, + 'external_ids': list([ + ]), + 'image': None, + 'is_playable': True, + 'item_id': 'e8a0685e3f3ec6f251649af2b58b8617', + 'media_type': 'album', + 'name': 'Live at The Casbah - 2005-04-29', + 'provider': 'xx-instance-id-xx', + 'sort_name': 'live at the casbah - 2005-04-29', + 'translation_key': None, + 'uri': 'xx-instance-id-xx://album/e8a0685e3f3ec6f251649af2b58b8617', + 'version': '', + }), + 'artists': list([ + dict({ + 'available': True, + 'external_ids': list([ + ]), + 'image': None, + 'is_playable': True, + 'item_id': '97e0398acf63f9fb930d7d4ce209a52b', + 'media_type': 'artist', + 'name': 'The New Deal', + 'provider': 'xx-instance-id-xx', + 'sort_name': 'new deal, the', + 'translation_key': None, + 'uri': 'xx-instance-id-xx://artist/97e0398acf63f9fb930d7d4ce209a52b', + 'version': '', + }), + dict({ + 'available': True, + 'external_ids': list([ + ]), + 'image': None, + 'is_playable': True, + 'item_id': 'ar-1', + 'media_type': 'artist', + 'name': 'Artist 1', + 'provider': 'xx-instance-id-xx', + 'sort_name': 'artist 1', + 'translation_key': None, + 'uri': 'xx-instance-id-xx://artist/ar-1', + 'version': '', + }), + dict({ + 'available': True, + 'external_ids': list([ + ]), + 'image': None, + 'is_playable': True, + 'item_id': 'ar-2', + 'media_type': 'artist', + 'name': 'Artist 2', + 'provider': 'xx-instance-id-xx', + 'sort_name': 'artist 2', + 'translation_key': None, + 'uri': 'xx-instance-id-xx://artist/ar-2', + 'version': '', + }), + ]), + 'disc_number': 1, + 'duration': 178, + 'external_ids': list([ + list([ + 'musicbrainz_recordingid', + '189002e7-3285-4e2e-92a3-7f6c30d407a2', + ]), + ]), + 'favorite': True, + 'is_playable': True, + 'item_id': '082f435a363c32c57d5edb6a678a28d4', + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': True, + 'genres': list([ + 'East coast', + 'Hip-Hop', + ]), + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': 'slow', + 'performers': list([ + 'Artist 3', + 'Artist 4', + 'Artist 5', + ]), + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': '"polar expedition"', + 'position': None, + 'provider': 'xx-instance-id-xx', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 880, + 'channels': 2, + 'codec_type': '?', + 'content_type': 'flac', + 'output_format_str': 'flac', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'item_id': '082f435a363c32c57d5edb6a678a28d4', + 'provider_domain': 'opensubsonic', + 'provider_instance': 'xx-instance-id-xx', + 'url': None, + }), + ]), + 'sort_name': 'Polar expedition', + 'track_number': 4, + 'translation_key': None, + 'uri': 'xx-instance-id-xx://track/082f435a363c32c57d5edb6a678a28d4', + 'version': '', + }) +# --- +# name: test_parse_track[spec-child.track].1 + dict({ + 'album': dict({ + 'artist': 'pornophonique', + 'artistId': '97e0398acf63f9fb930d7d4ce209a52b', + 'artists': list([ + dict({ + 'id': 'ar-1', + 'name': 'Artist 1', + }), + dict({ + 'id': 'ar-2', + 'name': 'Artist 2', + }), + ]), + 'coverArt': 'al-ad0f112b6dcf83de5e9cae85d07f0d35_640a93a8', + 'created': '2023-03-10T02:19:35.784818075Z', + 'discTitles': list([ + dict({ + 'disc': 0, + 'title': 'Disc 0 title', + }), + dict({ + 'disc': 2, + 'title': 'Disc 1 title', + }), + ]), + 'displayArtist': 'Artist 1 feat. Artist 2', + 'duration': 1954, + 'explicitStatus': 'explicit', + 'genre': 'Hip-Hop', + 'genres': list([ + dict({ + 'name': 'Hip-Hop', + }), + dict({ + 'name': 'East coast', + }), + ]), + 'id': 'e8a0685e3f3ec6f251649af2b58b8617', + 'isCompilation': False, + 'moods': list([ + 'slow', + 'cool', + ]), + 'musicBrainzId': '189002e7-3285-4e2e-92a3-7f6c30d407a2', + 'name': 'Live at The Casbah - 2005-04-29', + 'originalReleaseDate': dict({ + 'day': 10, + 'month': 3, + 'year': 2001, + }), + 'playCount': 97, + 'played': '2023-03-28T00:45:13Z', + 'releaseDate': dict({ + 'day': 10, + 'month': 3, + 'year': 2001, + }), + 'releaseTypes': list([ + 'Album', + 'Remixes', + ]), + 'songCount': 8, + 'sortName': 'lagerfeuer (8-bit)', + 'starred': '2023-03-22T01:51:06Z', + 'userRating': 4, + 'version': 'Deluxe Edition', + 'year': 2007, + }), + 'artists': list([ + dict({ + 'available': True, + 'external_ids': list([ + ]), + 'image': None, + 'is_playable': True, + 'item_id': '97e0398acf63f9fb930d7d4ce209a52b', + 'media_type': 'artist', + 'name': 'The New Deal', + 'provider': 'xx-instance-id-xx', + 'sort_name': 'new deal, the', + 'translation_key': None, + 'uri': 'xx-instance-id-xx://artist/97e0398acf63f9fb930d7d4ce209a52b', + 'version': '', + }), + dict({ + 'available': True, + 'external_ids': list([ + ]), + 'image': None, + 'is_playable': True, + 'item_id': 'ar-1', + 'media_type': 'artist', + 'name': 'Artist 1', + 'provider': 'xx-instance-id-xx', + 'sort_name': 'artist 1', + 'translation_key': None, + 'uri': 'xx-instance-id-xx://artist/ar-1', + 'version': '', + }), + dict({ + 'available': True, + 'external_ids': list([ + ]), + 'image': None, + 'is_playable': True, + 'item_id': 'ar-2', + 'media_type': 'artist', + 'name': 'Artist 2', + 'provider': 'xx-instance-id-xx', + 'sort_name': 'artist 2', + 'translation_key': None, + 'uri': 'xx-instance-id-xx://artist/ar-2', + 'version': '', + }), + ]), + 'disc_number': 1, + 'duration': 178, + 'external_ids': list([ + list([ + 'musicbrainz_recordingid', + '189002e7-3285-4e2e-92a3-7f6c30d407a2', + ]), + ]), + 'favorite': True, + 'is_playable': True, + 'item_id': '082f435a363c32c57d5edb6a678a28d4', + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': True, + 'genres': list([ + 'East coast', + 'Hip-Hop', + ]), + 'images': None, + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lrc_lyrics': None, + 'lyrics': None, + 'mood': 'slow', + 'performers': list([ + 'Artist 3', + 'Artist 4', + 'Artist 5', + ]), + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': '"polar expedition"', + 'position': None, + 'provider': 'xx-instance-id-xx', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 16, + 'bit_rate': 880, + 'channels': 2, + 'codec_type': '?', + 'content_type': 'flac', + 'output_format_str': 'flac', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'item_id': '082f435a363c32c57d5edb6a678a28d4', + 'provider_domain': 'opensubsonic', + 'provider_instance': 'xx-instance-id-xx', + 'url': None, + }), + ]), + 'sort_name': 'Polar expedition', + 'track_number': 4, + 'translation_key': None, + 'uri': 'xx-instance-id-xx://track/082f435a363c32c57d5edb6a678a28d4', + 'version': '', + }) +# --- diff --git a/tests/providers/opensubsonic/fixtures/tracks/spec-child.album.json b/tests/providers/opensubsonic/fixtures/tracks/spec-child.album.json new file mode 100644 index 00000000..2f81a242 --- /dev/null +++ b/tests/providers/opensubsonic/fixtures/tracks/spec-child.album.json @@ -0,0 +1,75 @@ + + +{ + "id": "e8a0685e3f3ec6f251649af2b58b8617", + "name": "Live at The Casbah - 2005-04-29", + "version": "Deluxe Edition", + "artist": "pornophonique", + "year": 2007, + "coverArt": "al-ad0f112b6dcf83de5e9cae85d07f0d35_640a93a8", + "starred": "2023-03-22T01:51:06Z", + "duration": 1954, + "playCount": 97, + "genre": "Hip-Hop", + "created": "2023-03-10T02:19:35.784818075Z", + "artistId": "97e0398acf63f9fb930d7d4ce209a52b", + "songCount": 8, + "played": "2023-03-28T00:45:13Z", + "userRating": 4, + "recordLabels": [ + { + "name": "Sony" + } + ], + "musicBrainzId": "189002e7-3285-4e2e-92a3-7f6c30d407a2", + "genres": [ + { + "name": "Hip-Hop" + }, + { + "name": "East coast" + } + ], + "artists": [ + { + "id": "ar-1", + "name": "Artist 1" + }, + { + "id": "ar-2", + "name": "Artist 2" + } + ], + "displayArtist": "Artist 1 feat. Artist 2", + "releaseTypes": [ + "Album", + "Remixes" + ], + "moods": [ + "slow", + "cool" + ], + "sortName": "lagerfeuer (8-bit)", + "originalReleaseDate": { + "year": 2001, + "month": 3, + "day": 10 + }, + "releaseDate": { + "year": 2001, + "month": 3, + "day": 10 + }, + "isCompilation": false, + "explicitStatus": "explicit", + "discTitles": [ + { + "disc": 0, + "title": "Disc 0 title" + }, + { + "disc": 2, + "title": "Disc 1 title" + } + ] +} diff --git a/tests/providers/opensubsonic/fixtures/tracks/spec-child.track.json b/tests/providers/opensubsonic/fixtures/tracks/spec-child.track.json new file mode 100644 index 00000000..acaae276 --- /dev/null +++ b/tests/providers/opensubsonic/fixtures/tracks/spec-child.track.json @@ -0,0 +1,108 @@ +{ + "id": "082f435a363c32c57d5edb6a678a28d4", + "parent": "e8a0685e3f3ec6f251649af2b58b8617", + "isDir": false, + "title": "\"polar expedition\"", + "album": "Live at The Casbah - 2005-04-29", + "artist": "The New Deal", + "track": 4, + "year": 2005, + "coverArt": "mf-082f435a363c32c57d5edb6a678a28d4_6410b3ce", + "size": 19866778, + "contentType": "audio/flac", + "suffix": "flac", + "starred": "2023-03-27T09:45:27Z", + "duration": 178, + "bitRate": 880, + "bitDepth": 16, + "samplingRate": 44100, + "channelCount": 2, + "path": "The New Deal/Live at The Casbah - 2005-04-29/04 - \"polar expedition\".flac", + "playCount": 8, + "played": "2023-03-26T22:27:46Z", + "discNumber": 1, + "created": "2023-03-14T17:51:22.112827504Z", + "albumId": "e8a0685e3f3ec6f251649af2b58b8617", + "artistId": "97e0398acf63f9fb930d7d4ce209a52b", + "type": "music", + "mediaType": "song", + "isVideo": false, + "bpm": 134, + "comment": "This is a song comment", + "sortName": "Polar expedition", + "musicBrainzId": "189002e7-3285-4e2e-92a3-7f6c30d407a2", + "genres": [ + { + "name": "Hip-Hop" + }, + { + "name": "East coast" + } + ], + "artists": [ + { + "id": "ar-1", + "name": "Artist 1" + }, + { + "id": "ar-2", + "name": "Artist 2" + } + ], + "displayArtist": "Artist 1 feat. Artist 2", + "albumArtists": [ + { + "id": "ar-6", + "name": "Artist 6" + }, + { + "id": "ar-7", + "name": "Artist 7" + } + ], + "displayAlbumArtist": "Artist 6 & Artist 7", + "contributors": [ + { + "role": "composer", + "artist": { + "id": "ar-3", + "name": "Artist 3" + } + }, + { + "role": "composer", + "artist": { + "id": "ar-4", + "name": "Artist 4" + } + }, + { + "role": "lyricist", + "artist": { + "id": "ar-5", + "name": "Artist 5" + } + }, + { + "role": "performer", + "subRole": "Bass", + "artist": { + "id": "ar-5", + "name": "Artist 5" + } + } + ], + "displayComposer": "Artist 3, Artist 4", + "moods": [ + "slow", + "cool" + ], + "explicitStatus": "explicit", + "replayGain": { + "trackGain": 0.1, + "albumGain": 1.1, + "trackPeak": 9.2, + "albumPeak": 9, + "baseGain": 0 + } +} diff --git a/tests/providers/opensubsonic/test_parsers.py b/tests/providers/opensubsonic/test_parsers.py index 831558aa..42bd742d 100644 --- a/tests/providers/opensubsonic/test_parsers.py +++ b/tests/providers/opensubsonic/test_parsers.py @@ -6,10 +6,11 @@ import pathlib import aiofiles import pytest from libopensonic.media import ( - Album, + AlbumID3, AlbumInfo, - Artist, + ArtistID3, ArtistInfo, + Child, Playlist, PodcastChannel, PodcastEpisode, @@ -22,6 +23,7 @@ from music_assistant.providers.opensubsonic.parsers import ( parse_epsiode, parse_playlist, parse_podcast, + parse_track, ) FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" @@ -30,6 +32,7 @@ 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")) +TRACK_FIXTURES = list(FIXTURES_DIR.glob("tracks/*.track.json")) _LOGGER = logging.getLogger(__name__) @@ -38,7 +41,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.from_json(await fp.read()) + artist = ArtistID3.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 @@ -60,11 +63,12 @@ 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.from_json(await fp.read()) + album = AlbumID3.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 + # sort external Ids and genres to ensure they are always in the same order for snapshot testing parsed["external_ids"].sort() + parsed["metadata"]["genres"].sort() assert snapshot == parsed # Find the corresponding info file @@ -73,8 +77,9 @@ async def test_parse_albums(example: pathlib.Path, snapshot: SnapshotAssertion) 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 + # sort external Ids and genres to ensure they are always in the same order for snapshot testing parsed["external_ids"].sort() + parsed["metadata"]["genres"].sort() assert snapshot == parsed @@ -116,3 +121,30 @@ async def test_parse_episode(example: pathlib.Path, snapshot: SnapshotAssertion) # 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", TRACK_FIXTURES, ids=lambda val: str(val.stem)) +async def test_parse_track(example: pathlib.Path, snapshot: SnapshotAssertion) -> None: + """Test we can parse Tracks.""" + async with aiofiles.open(example) as fp: + song = Child.from_json(await fp.read()) + + parsed = parse_track(_LOGGER, "xx-instance-id-xx", song).to_dict() + # sort external Ids, genres, and performers to ensure they are always in the same + # order for snapshot testing + parsed["external_ids"].sort() + parsed["metadata"]["genres"].sort() + parsed["metadata"]["performers"].sort() + assert snapshot == parsed + + example_album = example.with_suffix("").with_suffix(".album.json") + async with aiofiles.open(example_album) as fp: + album = AlbumID3.from_json(await fp.read()) + + parsed = parse_track(_LOGGER, "xx-instance-id-xx", song, album).to_dict() + # sort external Ids, genres, and performers to ensure they are always in the same + # order for snapshot testing + parsed["external_ids"].sort() + parsed["metadata"]["genres"].sort() + parsed["metadata"]["performers"].sort() + assert snapshot == parsed