chore/fix: Subsonic: Update parsers and tests (#2181)
authorEric Munson <eric@munsonfam.org>
Thu, 15 May 2025 20:15:19 +0000 (16:15 -0400)
committerGitHub <noreply@github.com>
Thu, 15 May 2025 20:15:19 +0000 (22:15 +0200)
music_assistant/providers/opensubsonic/parsers.py
music_assistant/providers/opensubsonic/sonic_provider.py
tests/providers/opensubsonic/__snapshots__/test_parsers.ambr
tests/providers/opensubsonic/fixtures/tracks/spec-child.album.json [new file with mode: 0644]
tests/providers/opensubsonic/fixtures/tracks/spec-child.track.json [new file with mode: 0644]
tests/providers/opensubsonic/test_parsers.py

index 24a7f9240f240f7c1feaffe966a2ce9746f53c2b..ddf0e33222ebf24b24d0ee7147562886020e6514 100644 (file)
@@ -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,
             )
         },
index 83e5d1c542a69b39a5389c2036fd4d1b73f23ded..7875f02c262363239551fb05f9447a0c19b77df1 100644 (file)
@@ -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."""
index cfd3615f39965cd606db5101aae511350db2418f..30ba1af169941e55cc65ebcc2d169c2757c94426 100644 (file)
         '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,
       '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,
         'url': None,
       }),
     ]),
-    'sort_name': '8-bit lagerfeuer',
+    'sort_name': 'lagerfeuer (8-bit)',
     'translation_key': None,
     'uri': 'opensubsonic://album/ad0f112b6dcf83de5e9cae85d07f0d35',
     'version': '',
         '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,
       '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',
         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',
         }),
       ]),
       'links': None,
       'lrc_lyrics': None,
       'lyrics': None,
-      'mood': None,
+      'mood': 'slow',
       'performers': None,
       'popularity': None,
       'preview': None,
         'url': None,
       }),
     ]),
-    'sort_name': '8-bit lagerfeuer',
+    'sort_name': 'lagerfeuer (8-bit)',
     'translation_key': None,
     'uri': 'opensubsonic://album/ad0f112b6dcf83de5e9cae85d07f0d35',
     'version': '',
 # name: test_parse_artists[spec-artistid3.artist]
   dict({
     'external_ids': list([
+      list([
+        'musicbrainz_artistid',
+        '189002e7-3285-4e2e-92a3-7f6c30d407a2',
+      ]),
     ]),
     'favorite': True,
     'is_playable': True,
       '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',
         'url': None,
       }),
     ]),
-    'sort_name': '2 mello',
+    'sort_name': 'Mello (2)',
     'translation_key': None,
     'uri': 'opensubsonic://artist/37ec820ca7193e17040c98f7da7c4b51',
     'version': '',
 # 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,
       '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',
         'url': None,
       }),
     ]),
-    'sort_name': '2 mello',
+    'sort_name': 'Mello (2)',
     'translation_key': None,
     'uri': 'opensubsonic://artist/37ec820ca7193e17040c98f7da7c4b51',
     'version': '',
     '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 (file)
index 0000000..2f81a24
--- /dev/null
@@ -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 (file)
index 0000000..acaae27
--- /dev/null
@@ -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
+  }
+}
index 831558aa36d34bfa01d62f2447e82c6d1673d08a..42bd742da15c257d99066f5c53e9c053fd38f3eb 100644 (file)
@@ -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