Jellyfin: Add some basic parsing tests (#1397)
authorJc2k <john.carr@unrouted.co.uk>
Sat, 22 Jun 2024 10:15:05 +0000 (11:15 +0100)
committerGitHub <noreply@github.com>
Sat, 22 Jun 2024 10:15:05 +0000 (12:15 +0200)
13 files changed:
.github/workflows/test.yml
music_assistant/server/providers/jellyfin/__init__.py
music_assistant/server/providers/jellyfin/parsers.py [new file with mode: 0644]
pyproject.toml
tests/server/providers/jellyfin/__init__.py [new file with mode: 0644]
tests/server/providers/jellyfin/__snapshots__/test_parsers.ambr [new file with mode: 0644]
tests/server/providers/jellyfin/fixtures/albums/this_is_christmas.json [new file with mode: 0644]
tests/server/providers/jellyfin/fixtures/albums/yesterday_when_i_was_mad.json [new file with mode: 0644]
tests/server/providers/jellyfin/fixtures/artists/ash.json [new file with mode: 0644]
tests/server/providers/jellyfin/fixtures/tracks/thrown_away.json [new file with mode: 0644]
tests/server/providers/jellyfin/fixtures/tracks/where_the_bands_are.json [new file with mode: 0644]
tests/server/providers/jellyfin/fixtures/tracks/zombie_christmas.json [new file with mode: 0644]
tests/server/providers/jellyfin/test_parsers.py [new file with mode: 0644]

index dcf112179a3bf90b5e9377bbd6cb3b761cbe0603..8d4ea7edd46cdc1557c3193cc3228cb25c88546e 100644 (file)
@@ -51,6 +51,6 @@ jobs:
           sudo apt-get update
           sudo apt-get install ffmpeg
           python -m pip install --upgrade pip build setuptools
-          pip install .[server] .[test]
+          pip install .[server] .[test] -r requirements_all.txt
       - name: Pytest
         run: pytest --durations 10 --cov-report term-missing --cov=music_assistant --cov-report=xml tests/
index 2f9986dcb52b322faec4efd0a6019376d05fab25..4438744255ec2eefa39d52a4132caca395ac6c9c 100644 (file)
@@ -2,21 +2,15 @@
 
 from __future__ import annotations
 
-import logging
 import mimetypes
 import socket
 import uuid
 from asyncio import TaskGroup
 from collections.abc import AsyncGenerator
 
-from aiojellyfin import Album as JellyAlbum
-from aiojellyfin import Artist as JellyArtist
-from aiojellyfin import MediaItem as JellyMediaItem
 from aiojellyfin import MediaLibrary as JellyMediaLibrary
-from aiojellyfin import Playlist as JellyPlaylist
 from aiojellyfin import SessionConfiguration, authenticate_by_name
 from aiojellyfin import Track as JellyTrack
-from aiojellyfin.const import ImageType as JellyImageType
 
 from music_assistant.common.models.config_entries import (
     ConfigEntry,
@@ -26,65 +20,49 @@ from music_assistant.common.models.config_entries import (
 from music_assistant.common.models.enums import (
     ConfigEntryType,
     ContentType,
-    ImageType,
     MediaType,
     ProviderFeature,
     StreamType,
 )
-from music_assistant.common.models.errors import InvalidDataError, LoginFailed, MediaNotFoundError
+from music_assistant.common.models.errors import LoginFailed, MediaNotFoundError
 from music_assistant.common.models.media_items import (
     Album,
     Artist,
     AudioFormat,
-    ItemMapping,
-    MediaItemImage,
     Playlist,
     ProviderMapping,
     SearchResults,
     Track,
-    UniqueList,
 )
 from music_assistant.common.models.provider import ProviderManifest
 from music_assistant.common.models.streamdetails import StreamDetails
 from music_assistant.constants import UNKNOWN_ARTIST_ID_MBID
 from music_assistant.server.models import ProviderInstanceType
 from music_assistant.server.models.music_provider import MusicProvider
+from music_assistant.server.providers.jellyfin.parsers import (
+    parse_album,
+    parse_artist,
+    parse_playlist,
+    parse_track,
+)
 from music_assistant.server.server import MusicAssistant
 
 from .const import (
     ALBUM_FIELDS,
     ARTIST_FIELDS,
     CLIENT_VERSION,
-    ITEM_KEY_ALBUM,
-    ITEM_KEY_ALBUM_ARTIST,
-    ITEM_KEY_ALBUM_ARTISTS,
-    ITEM_KEY_ALBUM_ID,
-    ITEM_KEY_ARTIST_ITEMS,
-    ITEM_KEY_CAN_DOWNLOAD,
     ITEM_KEY_COLLECTION_TYPE,
     ITEM_KEY_ID,
-    ITEM_KEY_IMAGE_TAGS,
     ITEM_KEY_MEDIA_CHANNELS,
     ITEM_KEY_MEDIA_CODEC,
     ITEM_KEY_MEDIA_SOURCES,
     ITEM_KEY_MEDIA_STREAMS,
-    ITEM_KEY_MUSICBRAINZ_ARTIST,
-    ITEM_KEY_MUSICBRAINZ_RELEASE_GROUP,
-    ITEM_KEY_MUSICBRAINZ_TRACK,
     ITEM_KEY_NAME,
-    ITEM_KEY_OVERVIEW,
-    ITEM_KEY_PARENT_INDEX_NUM,
-    ITEM_KEY_PRODUCTION_YEAR,
-    ITEM_KEY_PROVIDER_IDS,
     ITEM_KEY_RUNTIME_TICKS,
-    ITEM_KEY_SORT_NAME,
-    ITEM_KEY_USER_DATA,
-    MEDIA_IMAGE_TYPES,
     SUPPORTED_CONTAINER_FORMATS,
     TRACK_FIELDS,
     UNKNOWN_ARTIST_MAPPING,
     USER_APP_NAME,
-    USER_DATA_KEY_IS_FAVORITE,
 )
 
 CONF_URL = "url"
@@ -195,14 +173,6 @@ class JellyfinProvider(MusicProvider):
         """Return True if the provider is a streaming provider."""
         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,
-        )
-
     async def _search_track(self, search_query: str, limit: int) -> list[Track]:
         resultset = await self._client.tracks(
             search_term=search_query,
@@ -212,7 +182,7 @@ class JellyfinProvider(MusicProvider):
         )
         tracks = []
         for item in resultset["Items"]:
-            tracks.append(self._parse_track(item))
+            tracks.append(parse_track(self.logger, self.instance_id, self._client, item))
         return tracks
 
     async def _search_album(self, search_query: str, limit: int) -> list[Album]:
@@ -229,7 +199,7 @@ class JellyfinProvider(MusicProvider):
         )
         albums = []
         for item in resultset["Items"]:
-            albums.append(self._parse_album(item))
+            albums.append(parse_album(self.logger, self.instance_id, self._client, item))
         return albums
 
     async def _search_artist(self, search_query: str, limit: int) -> list[Artist]:
@@ -241,7 +211,7 @@ class JellyfinProvider(MusicProvider):
         )
         artists = []
         for item in resultset["Items"]:
-            artists.append(self._parse_artist(item))
+            artists.append(parse_artist(self.logger, self.instance_id, self._client, item))
         return artists
 
     async def _search_playlist(self, search_query: str, limit: int) -> list[Playlist]:
@@ -252,196 +222,9 @@ class JellyfinProvider(MusicProvider):
         )
         playlists = []
         for item in resultset["Items"]:
-            playlists.append(self._parse_playlist(item))
+            playlists.append(parse_playlist(self.instance_id, self._client, item))
         return playlists
 
-    def _parse_album(self, jellyfin_album: JellyAlbum) -> Album:
-        """Parse a Jellyfin Album response to an Album model object."""
-        album_id = jellyfin_album[ITEM_KEY_ID]
-        album = Album(
-            item_id=album_id,
-            provider=self.domain,
-            name=jellyfin_album[ITEM_KEY_NAME],
-            provider_mappings={
-                ProviderMapping(
-                    item_id=str(album_id),
-                    provider_domain=self.domain,
-                    provider_instance=self.instance_id,
-                )
-            },
-        )
-        if ITEM_KEY_PRODUCTION_YEAR in jellyfin_album:
-            album.year = jellyfin_album[ITEM_KEY_PRODUCTION_YEAR]
-        album.metadata.images = self._get_artwork(jellyfin_album)
-        if ITEM_KEY_OVERVIEW in jellyfin_album:
-            album.metadata.description = jellyfin_album[ITEM_KEY_OVERVIEW]
-        if ITEM_KEY_MUSICBRAINZ_RELEASE_GROUP in jellyfin_album[ITEM_KEY_PROVIDER_IDS]:
-            try:
-                album.mbid = jellyfin_album[ITEM_KEY_PROVIDER_IDS][
-                    ITEM_KEY_MUSICBRAINZ_RELEASE_GROUP
-                ]
-            except InvalidDataError as error:
-                self.logger.warning(
-                    "Jellyfin has an invalid musicbrainz id for album %s",
-                    album.name,
-                    exc_info=error if self.logger.isEnabledFor(logging.DEBUG) else None,
-                )
-        if ITEM_KEY_SORT_NAME in jellyfin_album:
-            album.sort_name = jellyfin_album[ITEM_KEY_SORT_NAME]
-        if ITEM_KEY_ALBUM_ARTIST in jellyfin_album:
-            for album_artist in jellyfin_album[ITEM_KEY_ALBUM_ARTISTS]:
-                album.artists.append(
-                    self._get_item_mapping(
-                        MediaType.ARTIST,
-                        album_artist[ITEM_KEY_ID],
-                        album_artist[ITEM_KEY_NAME],
-                    )
-                )
-        elif len(jellyfin_album.get(ITEM_KEY_ARTIST_ITEMS, [])) >= 1:
-            for artist_item in jellyfin_album[ITEM_KEY_ARTIST_ITEMS]:
-                album.artists.append(
-                    self._get_item_mapping(
-                        MediaType.ARTIST,
-                        artist_item[ITEM_KEY_ID],
-                        artist_item[ITEM_KEY_NAME],
-                    )
-                )
-        else:
-            album.artists.append(UNKNOWN_ARTIST_MAPPING)
-
-        user_data = jellyfin_album.get(ITEM_KEY_USER_DATA, {})
-        album.favorite = user_data.get(USER_DATA_KEY_IS_FAVORITE, False)
-        return album
-
-    def _parse_artist(self, jellyfin_artist: JellyArtist) -> Artist:
-        """Parse a Jellyfin Artist response to Artist model object."""
-        artist_id = jellyfin_artist[ITEM_KEY_ID]
-        artist = Artist(
-            item_id=artist_id,
-            name=jellyfin_artist[ITEM_KEY_NAME],
-            provider=self.domain,
-            provider_mappings={
-                ProviderMapping(
-                    item_id=str(artist_id),
-                    provider_domain=self.domain,
-                    provider_instance=self.instance_id,
-                )
-            },
-        )
-        if ITEM_KEY_OVERVIEW in jellyfin_artist:
-            artist.metadata.description = jellyfin_artist[ITEM_KEY_OVERVIEW]
-        if ITEM_KEY_MUSICBRAINZ_ARTIST in jellyfin_artist[ITEM_KEY_PROVIDER_IDS]:
-            try:
-                artist.mbid = jellyfin_artist[ITEM_KEY_PROVIDER_IDS][ITEM_KEY_MUSICBRAINZ_ARTIST]
-            except InvalidDataError as error:
-                self.logger.warning(
-                    "Jellyfin has an invalid musicbrainz id for artist %s",
-                    artist.name,
-                    exc_info=error if self.logger.isEnabledFor(logging.DEBUG) else None,
-                )
-        if ITEM_KEY_SORT_NAME in jellyfin_artist:
-            artist.sort_name = jellyfin_artist[ITEM_KEY_SORT_NAME]
-        artist.metadata.images = self._get_artwork(jellyfin_artist)
-        user_data = jellyfin_artist.get(ITEM_KEY_USER_DATA, {})
-        artist.favorite = user_data.get(USER_DATA_KEY_IS_FAVORITE, False)
-        return artist
-
-    def _parse_track(self, jellyfin_track: JellyTrack) -> Track:
-        """Parse a Jellyfin Track response to a Track model object."""
-        available = False
-        content = None
-        available = jellyfin_track[ITEM_KEY_CAN_DOWNLOAD]
-        content = jellyfin_track[ITEM_KEY_MEDIA_STREAMS][0][ITEM_KEY_MEDIA_CODEC]
-        track = Track(
-            item_id=jellyfin_track[ITEM_KEY_ID],
-            provider=self.instance_id,
-            name=jellyfin_track[ITEM_KEY_NAME],
-            provider_mappings={
-                ProviderMapping(
-                    item_id=jellyfin_track[ITEM_KEY_ID],
-                    provider_domain=self.domain,
-                    provider_instance=self.instance_id,
-                    available=available,
-                    audio_format=AudioFormat(
-                        content_type=(
-                            ContentType.try_parse(content) if content else ContentType.UNKNOWN
-                        ),
-                    ),
-                    url=self._get_stream_url(jellyfin_track[ITEM_KEY_ID]),
-                )
-            },
-        )
-
-        track.disc_number = jellyfin_track.get(ITEM_KEY_PARENT_INDEX_NUM, 0)
-        track.track_number = jellyfin_track.get("IndexNumber", 0)
-        if track.track_number >= 0:
-            track.position = track.track_number
-
-        track.metadata.images = self._get_artwork(jellyfin_track)
-
-        if jellyfin_track[ITEM_KEY_ARTIST_ITEMS]:
-            for artist_item in jellyfin_track[ITEM_KEY_ARTIST_ITEMS]:
-                track.artists.append(
-                    self._get_item_mapping(
-                        MediaType.ARTIST,
-                        artist_item[ITEM_KEY_ID],
-                        artist_item[ITEM_KEY_NAME],
-                    )
-                )
-        else:
-            track.artists.append(UNKNOWN_ARTIST_MAPPING)
-
-        if ITEM_KEY_ALBUM_ID in jellyfin_track:
-            if not (album_name := jellyfin_track.get(ITEM_KEY_ALBUM)):
-                self.logger.debug("Track %s has AlbumID but no AlbumName", track.name)
-                album_name = f"Unknown Album ({jellyfin_track[ITEM_KEY_ALBUM_ID]})"
-            track.album = self._get_item_mapping(
-                MediaType.ALBUM,
-                jellyfin_track[ITEM_KEY_ALBUM_ID],
-                album_name,
-            )
-
-        if ITEM_KEY_RUNTIME_TICKS in jellyfin_track:
-            track.duration = int(
-                jellyfin_track[ITEM_KEY_RUNTIME_TICKS] / 10000000
-            )  # 10000000 ticks per millisecond
-        if ITEM_KEY_MUSICBRAINZ_TRACK in jellyfin_track[ITEM_KEY_PROVIDER_IDS]:
-            track_mbid = jellyfin_track[ITEM_KEY_PROVIDER_IDS][ITEM_KEY_MUSICBRAINZ_TRACK]
-            try:
-                track.mbid = track_mbid
-            except InvalidDataError as error:
-                self.logger.warning(
-                    "Jellyfin has an invalid musicbrainz id for track %s",
-                    track.name,
-                    exc_info=error if self.logger.isEnabledFor(logging.DEBUG) else None,
-                )
-        user_data = jellyfin_track.get(ITEM_KEY_USER_DATA, {})
-        track.favorite = user_data.get(USER_DATA_KEY_IS_FAVORITE, False)
-        return track
-
-    def _parse_playlist(self, jellyfin_playlist: JellyPlaylist) -> Playlist:
-        """Parse a Jellyfin Playlist response to a Playlist object."""
-        playlistid = jellyfin_playlist[ITEM_KEY_ID]
-        playlist = Playlist(
-            item_id=playlistid,
-            provider=self.domain,
-            name=jellyfin_playlist[ITEM_KEY_NAME],
-            provider_mappings={
-                ProviderMapping(
-                    item_id=playlistid,
-                    provider_domain=self.domain,
-                    provider_instance=self.instance_id,
-                )
-            },
-        )
-        if ITEM_KEY_OVERVIEW in jellyfin_playlist:
-            playlist.metadata.description = jellyfin_playlist[ITEM_KEY_OVERVIEW]
-        playlist.metadata.images = self._get_artwork(jellyfin_playlist)
-        user_data = jellyfin_playlist.get(ITEM_KEY_USER_DATA, {})
-        playlist.favorite = user_data.get(USER_DATA_KEY_IS_FAVORITE, False)
-        playlist.is_editable = False
-        return playlist
-
     async def search(
         self,
         search_query: str,
@@ -497,7 +280,7 @@ class JellyfinProvider(MusicProvider):
                 fields=ARTIST_FIELDS,
             )
             for artist in response["Items"]:
-                yield self._parse_artist(artist)
+                yield parse_artist(self.logger, self.instance_id, self._client, artist)
 
             while offset < response["TotalRecordCount"]:
                 response = await self._client.artists(
@@ -508,7 +291,7 @@ class JellyfinProvider(MusicProvider):
                     fields=ARTIST_FIELDS,
                 )
                 for artist in response["Items"]:
-                    yield self._parse_artist(artist)
+                    yield parse_artist(self.logger, self.instance_id, self._client, artist)
 
                 offset += limit
 
@@ -527,7 +310,7 @@ class JellyfinProvider(MusicProvider):
                 fields=ALBUM_FIELDS,
             )
             for artist in response["Items"]:
-                yield self._parse_album(artist)
+                yield parse_album(self.logger, self.instance_id, self._client, artist)
 
             while offset < response["TotalRecordCount"]:
                 response = await self._client.albums(
@@ -538,7 +321,7 @@ class JellyfinProvider(MusicProvider):
                     fields=ALBUM_FIELDS,
                 )
                 for artist in response["Items"]:
-                    yield self._parse_album(artist)
+                    yield parse_album(self.logger, self.instance_id, self._client, artist)
 
                 offset += limit
 
@@ -557,7 +340,7 @@ class JellyfinProvider(MusicProvider):
                 fields=TRACK_FIELDS,
             )
             for track in response["Items"]:
-                yield self._parse_track(track)
+                yield parse_track(self.logger, self.instance_id, self._client, track)
 
             while offset < response["TotalRecordCount"]:
                 response = await self._client.tracks(
@@ -568,7 +351,7 @@ class JellyfinProvider(MusicProvider):
                     fields=TRACK_FIELDS,
                 )
                 for track in response["Items"]:
-                    yield self._parse_track(track)
+                    yield parse_track(self.logger, self.instance_id, self._client, track)
 
                 offset += limit
 
@@ -580,14 +363,14 @@ class JellyfinProvider(MusicProvider):
             for playlist in playlists_obj["Items"]:
                 if "MediaType" in playlist:  # Only jellyfin has this property
                     if playlist["MediaType"] == "Audio":
-                        yield self._parse_playlist(playlist)
+                        yield parse_playlist(self.instance_id, self._client, playlist)
                 else:  # emby playlists are only audio type
-                    yield self._parse_playlist(playlist)
+                    yield parse_playlist(self.instance_id, self._client, playlist)
 
     async def get_album(self, prov_album_id: str) -> Album:
         """Get full album details by id."""
         if jellyfin_album := await self._client.get_album(prov_album_id):
-            return self._parse_album(jellyfin_album)
+            return parse_album(self.logger, self.instance_id, self._client, jellyfin_album)
         msg = f"Item {prov_album_id} not found"
         raise MediaNotFoundError(msg)
 
@@ -597,7 +380,7 @@ class JellyfinProvider(MusicProvider):
             prov_album_id, enable_user_data=True, fields=TRACK_FIELDS
         )
         return [
-            self._parse_track(jellyfin_album_track)
+            parse_track(self.logger, self.instance_id, self._client, jellyfin_album_track)
             for jellyfin_album_track in jellyfin_album_tracks["Items"]
         ]
 
@@ -620,21 +403,21 @@ class JellyfinProvider(MusicProvider):
             return artist
 
         if jellyfin_artist := await self._client.get_artist(prov_artist_id):
-            return self._parse_artist(jellyfin_artist)
+            return parse_artist(self.logger, self.instance_id, self._client, jellyfin_artist)
         msg = f"Item {prov_artist_id} not found"
         raise MediaNotFoundError(msg)
 
     async def get_track(self, prov_track_id: str) -> Track:
         """Get full track details by id."""
         if jellyfin_track := await self._client.get_track(prov_track_id):
-            return self._parse_track(jellyfin_track)
+            return parse_track(self.logger, self.instance_id, self._client, jellyfin_track)
         msg = f"Item {prov_track_id} not found"
         raise MediaNotFoundError(msg)
 
     async def get_playlist(self, prov_playlist_id: str) -> Playlist:
         """Get full playlist details by id."""
         if jellyfin_playlist := await self._client.get_playlist(prov_playlist_id):
-            return self._parse_playlist(jellyfin_playlist)
+            return parse_playlist(self.instance_id, self._client, jellyfin_playlist)
         msg = f"Item {prov_playlist_id} not found"
         raise MediaNotFoundError(msg)
 
@@ -655,7 +438,9 @@ class JellyfinProvider(MusicProvider):
             return result
         for index, jellyfin_track in enumerate(playlist_items["Items"], 1):
             try:
-                if track := self._parse_track(jellyfin_track):
+                if track := parse_track(
+                    self.logger, self.instance_id, self._client, jellyfin_track
+                ):
                     if not track.position:
                         track.position = offset + index
                     result.append(track)
@@ -672,7 +457,10 @@ class JellyfinProvider(MusicProvider):
         albums = await self._client.albums(
             prov_artist_id, fields=ALBUM_FIELDS, enable_user_data=True
         )
-        return [self._parse_album(album) for album in albums["Items"]]
+        return [
+            parse_album(self.logger, self.instance_id, self._client, album)
+            for album in albums["Items"]
+        ]
 
     async def get_stream_details(self, item_id: str) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
@@ -698,39 +486,6 @@ class JellyfinProvider(MusicProvider):
             path=url,
         )
 
-    def _get_artwork(self, media_item: JellyMediaItem) -> UniqueList[MediaItemImage]:
-        images: UniqueList[MediaItemImage] = UniqueList()
-
-        for i, _ in enumerate(media_item.get("BackdropImageTags", [])):
-            images.append(
-                MediaItemImage(
-                    type=ImageType.FANART,
-                    path=self._client.artwork(
-                        media_item[ITEM_KEY_ID], JellyImageType.Backdrop, index=i
-                    ),
-                    provider=self.instance_id,
-                    remotely_accessible=False,
-                )
-            )
-
-        image_tags = media_item[ITEM_KEY_IMAGE_TAGS]
-        for jelly_image_type, image_type in MEDIA_IMAGE_TYPES.items():
-            if jelly_image_type in image_tags:
-                images.append(
-                    MediaItemImage(
-                        type=image_type,
-                        path=self._client.artwork(media_item[ITEM_KEY_ID], jelly_image_type),
-                        provider=self.instance_id,
-                        remotely_accessible=False,
-                    )
-                )
-
-        return images
-
-    def _get_stream_url(self, media_item: str) -> str:
-        """Return the stream URL for a media item."""
-        return self._client.audio_url(media_item)
-
     async def _get_music_libraries(self) -> list[JellyMediaLibrary]:
         """Return all supported libraries a user has access to."""
         response = await self._client.get_media_folders()
diff --git a/music_assistant/server/providers/jellyfin/parsers.py b/music_assistant/server/providers/jellyfin/parsers.py
new file mode 100644 (file)
index 0000000..a552fbc
--- /dev/null
@@ -0,0 +1,294 @@
+"""Parse Jellyfin metadata into Music Assistant models."""
+
+from __future__ import annotations
+
+import logging
+from logging import Logger
+from typing import TYPE_CHECKING
+
+from aiojellyfin.const import ImageType as JellyImageType
+
+from music_assistant.common.models.enums import (
+    ContentType,
+    ImageType,
+    MediaType,
+)
+from music_assistant.common.models.errors import InvalidDataError
+from music_assistant.common.models.media_items import (
+    Album,
+    Artist,
+    AudioFormat,
+    ItemMapping,
+    MediaItemImage,
+    Playlist,
+    ProviderMapping,
+    Track,
+    UniqueList,
+)
+
+from .const import (
+    DOMAIN,
+    ITEM_KEY_ALBUM,
+    ITEM_KEY_ALBUM_ARTIST,
+    ITEM_KEY_ALBUM_ARTISTS,
+    ITEM_KEY_ALBUM_ID,
+    ITEM_KEY_ARTIST_ITEMS,
+    ITEM_KEY_CAN_DOWNLOAD,
+    ITEM_KEY_ID,
+    ITEM_KEY_IMAGE_TAGS,
+    ITEM_KEY_MEDIA_CODEC,
+    ITEM_KEY_MEDIA_STREAMS,
+    ITEM_KEY_MUSICBRAINZ_ARTIST,
+    ITEM_KEY_MUSICBRAINZ_RELEASE_GROUP,
+    ITEM_KEY_MUSICBRAINZ_TRACK,
+    ITEM_KEY_NAME,
+    ITEM_KEY_OVERVIEW,
+    ITEM_KEY_PARENT_INDEX_NUM,
+    ITEM_KEY_PRODUCTION_YEAR,
+    ITEM_KEY_PROVIDER_IDS,
+    ITEM_KEY_RUNTIME_TICKS,
+    ITEM_KEY_SORT_NAME,
+    ITEM_KEY_USER_DATA,
+    MEDIA_IMAGE_TYPES,
+    UNKNOWN_ARTIST_MAPPING,
+    USER_DATA_KEY_IS_FAVORITE,
+)
+
+if TYPE_CHECKING:
+    from aiojellyfin import Album as JellyAlbum
+    from aiojellyfin import Artist as JellyArtist
+    from aiojellyfin import Connection
+    from aiojellyfin import MediaItem as JellyMediaItem
+    from aiojellyfin import Playlist as JellyPlaylist
+    from aiojellyfin import Track as JellyTrack
+
+
+def parse_album(
+    logger: Logger, instance_id: str, connection: Connection, jellyfin_album: JellyAlbum
+) -> Album:
+    """Parse a Jellyfin Album response to an Album model object."""
+    album_id = jellyfin_album[ITEM_KEY_ID]
+    album = Album(
+        item_id=album_id,
+        provider=DOMAIN,
+        name=jellyfin_album[ITEM_KEY_NAME],
+        provider_mappings={
+            ProviderMapping(
+                item_id=str(album_id),
+                provider_domain=DOMAIN,
+                provider_instance=instance_id,
+            )
+        },
+    )
+    if ITEM_KEY_PRODUCTION_YEAR in jellyfin_album:
+        album.year = jellyfin_album[ITEM_KEY_PRODUCTION_YEAR]
+    album.metadata.images = _get_artwork(instance_id, connection, jellyfin_album)
+    if ITEM_KEY_OVERVIEW in jellyfin_album:
+        album.metadata.description = jellyfin_album[ITEM_KEY_OVERVIEW]
+    if ITEM_KEY_MUSICBRAINZ_RELEASE_GROUP in jellyfin_album[ITEM_KEY_PROVIDER_IDS]:
+        try:
+            album.mbid = jellyfin_album[ITEM_KEY_PROVIDER_IDS][ITEM_KEY_MUSICBRAINZ_RELEASE_GROUP]
+        except InvalidDataError as error:
+            logger.warning(
+                "Jellyfin has an invalid musicbrainz id for album %s",
+                album.name,
+                exc_info=error if logger.isEnabledFor(logging.DEBUG) else None,
+            )
+    if ITEM_KEY_SORT_NAME in jellyfin_album:
+        album.sort_name = jellyfin_album[ITEM_KEY_SORT_NAME]
+    if ITEM_KEY_ALBUM_ARTIST in jellyfin_album:
+        for album_artist in jellyfin_album[ITEM_KEY_ALBUM_ARTISTS]:
+            album.artists.append(
+                ItemMapping(
+                    media_type=MediaType.ARTIST,
+                    item_id=album_artist[ITEM_KEY_ID],
+                    provider=instance_id,
+                    name=album_artist[ITEM_KEY_NAME],
+                )
+            )
+    elif len(jellyfin_album.get(ITEM_KEY_ARTIST_ITEMS, [])) >= 1:
+        for artist_item in jellyfin_album[ITEM_KEY_ARTIST_ITEMS]:
+            album.artists.append(
+                ItemMapping(
+                    media_type=MediaType.ARTIST,
+                    item_id=artist_item[ITEM_KEY_ID],
+                    provider=instance_id,
+                    name=artist_item[ITEM_KEY_NAME],
+                )
+            )
+    else:
+        album.artists.append(UNKNOWN_ARTIST_MAPPING)
+
+    user_data = jellyfin_album.get(ITEM_KEY_USER_DATA, {})
+    album.favorite = user_data.get(USER_DATA_KEY_IS_FAVORITE, False)
+    return album
+
+
+def parse_artist(
+    logger: Logger, instance_id: str, connection: Connection, jellyfin_artist: JellyArtist
+) -> Artist:
+    """Parse a Jellyfin Artist response to Artist model object."""
+    artist_id = jellyfin_artist[ITEM_KEY_ID]
+    artist = Artist(
+        item_id=artist_id,
+        name=jellyfin_artist[ITEM_KEY_NAME],
+        provider=DOMAIN,
+        provider_mappings={
+            ProviderMapping(
+                item_id=str(artist_id),
+                provider_domain=DOMAIN,
+                provider_instance=instance_id,
+            )
+        },
+    )
+    if ITEM_KEY_OVERVIEW in jellyfin_artist:
+        artist.metadata.description = jellyfin_artist[ITEM_KEY_OVERVIEW]
+    if ITEM_KEY_MUSICBRAINZ_ARTIST in jellyfin_artist[ITEM_KEY_PROVIDER_IDS]:
+        try:
+            artist.mbid = jellyfin_artist[ITEM_KEY_PROVIDER_IDS][ITEM_KEY_MUSICBRAINZ_ARTIST]
+        except InvalidDataError as error:
+            logger.warning(
+                "Jellyfin has an invalid musicbrainz id for artist %s",
+                artist.name,
+                exc_info=error if logger.isEnabledFor(logging.DEBUG) else None,
+            )
+    if ITEM_KEY_SORT_NAME in jellyfin_artist:
+        artist.sort_name = jellyfin_artist[ITEM_KEY_SORT_NAME]
+    artist.metadata.images = _get_artwork(instance_id, connection, jellyfin_artist)
+    user_data = jellyfin_artist.get(ITEM_KEY_USER_DATA, {})
+    artist.favorite = user_data.get(USER_DATA_KEY_IS_FAVORITE, False)
+    return artist
+
+
+def parse_track(
+    logger: Logger, instance_id: str, client: Connection, jellyfin_track: JellyTrack
+) -> Track:
+    """Parse a Jellyfin Track response to a Track model object."""
+    available = False
+    content = None
+    available = jellyfin_track[ITEM_KEY_CAN_DOWNLOAD]
+    content = jellyfin_track[ITEM_KEY_MEDIA_STREAMS][0][ITEM_KEY_MEDIA_CODEC]
+    track = Track(
+        item_id=jellyfin_track[ITEM_KEY_ID],
+        provider=instance_id,
+        name=jellyfin_track[ITEM_KEY_NAME],
+        provider_mappings={
+            ProviderMapping(
+                item_id=jellyfin_track[ITEM_KEY_ID],
+                provider_domain=DOMAIN,
+                provider_instance=instance_id,
+                available=available,
+                audio_format=AudioFormat(
+                    content_type=(
+                        ContentType.try_parse(content) if content else ContentType.UNKNOWN
+                    ),
+                ),
+                url=client.audio_url(jellyfin_track[ITEM_KEY_ID]),
+            )
+        },
+    )
+
+    track.disc_number = jellyfin_track.get(ITEM_KEY_PARENT_INDEX_NUM, 0)
+    track.track_number = jellyfin_track.get("IndexNumber", 0)
+    if track.track_number >= 0:
+        track.position = track.track_number
+
+    track.metadata.images = _get_artwork(instance_id, client, jellyfin_track)
+
+    if jellyfin_track[ITEM_KEY_ARTIST_ITEMS]:
+        for artist_item in jellyfin_track[ITEM_KEY_ARTIST_ITEMS]:
+            track.artists.append(
+                ItemMapping(
+                    media_type=MediaType.ARTIST,
+                    item_id=artist_item[ITEM_KEY_ID],
+                    provider=instance_id,
+                    name=artist_item[ITEM_KEY_NAME],
+                )
+            )
+    else:
+        track.artists.append(UNKNOWN_ARTIST_MAPPING)
+
+    if ITEM_KEY_ALBUM_ID in jellyfin_track:
+        if not (album_name := jellyfin_track.get(ITEM_KEY_ALBUM)):
+            logger.debug("Track %s has AlbumID but no AlbumName", track.name)
+            album_name = f"Unknown Album ({jellyfin_track[ITEM_KEY_ALBUM_ID]})"
+        track.album = ItemMapping(
+            media_type=MediaType.ALBUM,
+            item_id=jellyfin_track[ITEM_KEY_ALBUM_ID],
+            provider=instance_id,
+            name=album_name,
+        )
+
+    if ITEM_KEY_RUNTIME_TICKS in jellyfin_track:
+        track.duration = int(
+            jellyfin_track[ITEM_KEY_RUNTIME_TICKS] / 10000000
+        )  # 10000000 ticks per millisecond
+    if ITEM_KEY_MUSICBRAINZ_TRACK in jellyfin_track[ITEM_KEY_PROVIDER_IDS]:
+        track_mbid = jellyfin_track[ITEM_KEY_PROVIDER_IDS][ITEM_KEY_MUSICBRAINZ_TRACK]
+        try:
+            track.mbid = track_mbid
+        except InvalidDataError as error:
+            logger.warning(
+                "Jellyfin has an invalid musicbrainz id for track %s",
+                track.name,
+                exc_info=error if logger.isEnabledFor(logging.DEBUG) else None,
+            )
+    user_data = jellyfin_track.get(ITEM_KEY_USER_DATA, {})
+    track.favorite = user_data.get(USER_DATA_KEY_IS_FAVORITE, False)
+    return track
+
+
+def parse_playlist(
+    instance_id: str, client: Connection, jellyfin_playlist: JellyPlaylist
+) -> Playlist:
+    """Parse a Jellyfin Playlist response to a Playlist object."""
+    playlistid = jellyfin_playlist[ITEM_KEY_ID]
+    playlist = Playlist(
+        item_id=playlistid,
+        provider=DOMAIN,
+        name=jellyfin_playlist[ITEM_KEY_NAME],
+        provider_mappings={
+            ProviderMapping(
+                item_id=playlistid,
+                provider_domain=DOMAIN,
+                provider_instance=instance_id,
+            )
+        },
+    )
+    if ITEM_KEY_OVERVIEW in jellyfin_playlist:
+        playlist.metadata.description = jellyfin_playlist[ITEM_KEY_OVERVIEW]
+    playlist.metadata.images = _get_artwork(instance_id, client, jellyfin_playlist)
+    user_data = jellyfin_playlist.get(ITEM_KEY_USER_DATA, {})
+    playlist.favorite = user_data.get(USER_DATA_KEY_IS_FAVORITE, False)
+    playlist.is_editable = False
+    return playlist
+
+
+def _get_artwork(
+    instance_id: str, client: Connection, media_item: JellyMediaItem
+) -> UniqueList[MediaItemImage]:
+    images: UniqueList[MediaItemImage] = UniqueList()
+
+    for i, _ in enumerate(media_item.get("BackdropImageTags", [])):
+        images.append(
+            MediaItemImage(
+                type=ImageType.FANART,
+                path=client.artwork(media_item[ITEM_KEY_ID], JellyImageType.Backdrop, index=i),
+                provider=instance_id,
+                remotely_accessible=False,
+            )
+        )
+
+    image_tags = media_item[ITEM_KEY_IMAGE_TAGS]
+    for jelly_image_type, image_type in MEDIA_IMAGE_TYPES.items():
+        if jelly_image_type in image_tags:
+            images.append(
+                MediaItemImage(
+                    type=image_type,
+                    path=client.artwork(media_item[ITEM_KEY_ID], jelly_image_type),
+                    provider=instance_id,
+                    remotely_accessible=False,
+                )
+            )
+
+    return images
index 2bef6cb94bf12f78adb68610ff5548d0f6dcd76d..e754e2442db8d6150e8717340e9a626962b65431 100644 (file)
@@ -52,6 +52,7 @@ test = [
   "pytest==8.2.2",
   "pytest-aiohttp==1.0.5",
   "pytest-cov==5.0.0",
+  "syrupy==4.6.1",
   "tomli==2.0.1",
   "ruff==0.4.9",
 ]
diff --git a/tests/server/providers/jellyfin/__init__.py b/tests/server/providers/jellyfin/__init__.py
new file mode 100644 (file)
index 0000000..64080a6
--- /dev/null
@@ -0,0 +1 @@
+"""Tests for Jellyfin provider."""
diff --git a/tests/server/providers/jellyfin/__snapshots__/test_parsers.ambr b/tests/server/providers/jellyfin/__snapshots__/test_parsers.ambr
new file mode 100644 (file)
index 0000000..c9f2020
--- /dev/null
@@ -0,0 +1,495 @@
+# serializer version: 1
+# name: test_parse_albums[this_is_christmas]
+  dict({
+    'album_type': 'unknown',
+    'artists': list([
+      dict({
+        'available': True,
+        'external_ids': list([
+        ]),
+        'image': None,
+        'item_id': '555b36f7d310d1b7405557a8775c6878',
+        'media_type': 'artist',
+        'name': 'Emmy the Great & Tim Wheeler',
+        'provider': 'xx-instance-id-xx',
+        'sort_name': 'emmy the great & tim wheeler',
+        'uri': 'xx-instance-id-xx://artist/555b36f7d310d1b7405557a8775c6878',
+        'version': '',
+      }),
+    ]),
+    'external_ids': list([
+      list([
+        'musicbrainz',
+        'f002d6b7-17af-4f9e-8d30-5486548ffe6f',
+      ]),
+    ]),
+    'favorite': False,
+    'item_id': '32ed6a0091733dcff57eae67010f3d4b',
+    'media_type': 'album',
+    'metadata': dict({
+      'cache_checksum': None,
+      'chapters': None,
+      'copyright': None,
+      'description': None,
+      'explicit': None,
+      'genres': None,
+      'images': list([
+        dict({
+          'path': 'http://localhost:1234/Items/32ed6a0091733dcff57eae67010f3d4b/Images/Primary?api_key=ACCESS_TOKEN',
+          'provider': 'xx-instance-id-xx',
+          'remotely_accessible': False,
+          'type': 'thumb',
+        }),
+      ]),
+      'label': None,
+      'last_refresh': None,
+      'links': None,
+      'lyrics': None,
+      'mood': None,
+      'performers': None,
+      'popularity': None,
+      'preview': None,
+      'review': None,
+      'style': None,
+    }),
+    'name': 'This Is Christmas',
+    'position': None,
+    'provider': 'jellyfin',
+    'provider_mappings': list([
+      dict({
+        'audio_format': dict({
+          'bit_depth': 16,
+          'bit_rate': 320,
+          'channels': 2,
+          'content_type': '?',
+          'output_format_str': '?',
+          'sample_rate': 44100,
+        }),
+        'available': True,
+        'details': None,
+        'item_id': '32ed6a0091733dcff57eae67010f3d4b',
+        'provider_domain': 'jellyfin',
+        'provider_instance': 'xx-instance-id-xx',
+        'url': None,
+      }),
+    ]),
+    'sort_name': 'this is christmas',
+    'uri': 'jellyfin://album/32ed6a0091733dcff57eae67010f3d4b',
+    'version': '',
+    'year': 2011,
+  })
+# ---
+# name: test_parse_albums[yesterday_when_i_was_mad]
+  dict({
+    'album_type': 'unknown',
+    'artists': list([
+      dict({
+        'available': True,
+        'external_ids': list([
+        ]),
+        'image': None,
+        'item_id': '[unknown]',
+        'media_type': 'artist',
+        'name': '[unknown]',
+        'provider': 'jellyfin',
+        'sort_name': '[unknown]',
+        'uri': 'jellyfin://artist/[unknown]',
+        'version': '',
+      }),
+    ]),
+    'external_ids': list([
+    ]),
+    'favorite': False,
+    'item_id': '7c8d0bd55291c7fc0451d17ebef30017',
+    'media_type': 'album',
+    'metadata': dict({
+      'cache_checksum': None,
+      'chapters': None,
+      'copyright': None,
+      'description': None,
+      'explicit': None,
+      'genres': None,
+      'images': list([
+      ]),
+      'label': None,
+      'last_refresh': None,
+      'links': None,
+      'lyrics': None,
+      'mood': None,
+      'performers': None,
+      'popularity': None,
+      'preview': None,
+      'review': None,
+      'style': None,
+    }),
+    'name': 'Yesterday, When I Was Mad [Disc 2]',
+    'position': None,
+    'provider': 'jellyfin',
+    'provider_mappings': list([
+      dict({
+        'audio_format': dict({
+          'bit_depth': 16,
+          'bit_rate': 320,
+          'channels': 2,
+          'content_type': '?',
+          'output_format_str': '?',
+          'sample_rate': 44100,
+        }),
+        'available': True,
+        'details': None,
+        'item_id': '7c8d0bd55291c7fc0451d17ebef30017',
+        'provider_domain': 'jellyfin',
+        'provider_instance': 'xx-instance-id-xx',
+        'url': None,
+      }),
+    ]),
+    'sort_name': 'yesterday when i was mad [disc 0000000002]',
+    'uri': 'jellyfin://album/7c8d0bd55291c7fc0451d17ebef30017',
+    'version': '',
+    'year': None,
+  })
+# ---
+# name: test_parse_artists[ash]
+  dict({
+    'external_ids': list([
+      list([
+        'musicbrainz',
+        '99164692-c02d-407c-81c9-25d338dd21f4',
+      ]),
+    ]),
+    'favorite': False,
+    'item_id': 'dd954bbf54398e247d803186d3585b79',
+    'media_type': 'artist',
+    'metadata': dict({
+      'cache_checksum': None,
+      'chapters': None,
+      'copyright': None,
+      'description': None,
+      'explicit': None,
+      'genres': None,
+      'images': list([
+        dict({
+          'path': 'http://localhost:1234/Items/dd954bbf54398e247d803186d3585b79/Images/Backdrop/0?api_key=ACCESS_TOKEN',
+          'provider': 'xx-instance-id-xx',
+          'remotely_accessible': False,
+          'type': 'fanart',
+        }),
+        dict({
+          'path': 'http://localhost:1234/Items/dd954bbf54398e247d803186d3585b79/Images/Primary?api_key=ACCESS_TOKEN',
+          'provider': 'xx-instance-id-xx',
+          'remotely_accessible': False,
+          'type': 'thumb',
+        }),
+        dict({
+          'path': 'http://localhost:1234/Items/dd954bbf54398e247d803186d3585b79/Images/Logo?api_key=ACCESS_TOKEN',
+          'provider': 'xx-instance-id-xx',
+          'remotely_accessible': False,
+          'type': 'logo',
+        }),
+      ]),
+      'label': None,
+      'last_refresh': None,
+      'links': None,
+      'lyrics': None,
+      'mood': None,
+      'performers': None,
+      'popularity': None,
+      'preview': None,
+      'review': None,
+      'style': None,
+    }),
+    'name': 'Ash',
+    'position': None,
+    'provider': 'jellyfin',
+    'provider_mappings': list([
+      dict({
+        'audio_format': dict({
+          'bit_depth': 16,
+          'bit_rate': 320,
+          'channels': 2,
+          'content_type': '?',
+          'output_format_str': '?',
+          'sample_rate': 44100,
+        }),
+        'available': True,
+        'details': None,
+        'item_id': 'dd954bbf54398e247d803186d3585b79',
+        'provider_domain': 'jellyfin',
+        'provider_instance': 'xx-instance-id-xx',
+        'url': None,
+      }),
+    ]),
+    'sort_name': 'ash',
+    'uri': 'jellyfin://artist/dd954bbf54398e247d803186d3585b79',
+    'version': '',
+  })
+# ---
+# name: test_parse_tracks[thrown_away]
+  dict({
+    'album': dict({
+      'available': True,
+      'external_ids': list([
+      ]),
+      'image': None,
+      'item_id': '70b7288088b42d318f75dbcc41fd0091',
+      'media_type': 'album',
+      'name': 'Unknown Album (70b7288088b42d318f75dbcc41fd0091)',
+      'provider': 'xx-instance-id-xx',
+      'sort_name': 'unknown album (70b7288088b42d318f75dbcc41fd0091)',
+      'uri': 'xx-instance-id-xx://album/70b7288088b42d318f75dbcc41fd0091',
+      'version': '',
+    }),
+    'artists': list([
+      dict({
+        'available': True,
+        'external_ids': list([
+        ]),
+        'image': None,
+        'item_id': '[unknown]',
+        'media_type': 'artist',
+        'name': '[unknown]',
+        'provider': 'jellyfin',
+        'sort_name': '[unknown]',
+        'uri': 'jellyfin://artist/[unknown]',
+        'version': '',
+      }),
+    ]),
+    'disc_number': 0,
+    'duration': 577,
+    'external_ids': list([
+    ]),
+    'favorite': False,
+    'item_id': 'b5319fb11cde39fca2023184fcfa9862',
+    'media_type': 'track',
+    'metadata': dict({
+      'cache_checksum': None,
+      'chapters': None,
+      'copyright': None,
+      'description': None,
+      'explicit': None,
+      'genres': None,
+      'images': list([
+      ]),
+      'label': None,
+      'last_refresh': None,
+      'links': None,
+      'lyrics': None,
+      'mood': None,
+      'performers': None,
+      'popularity': None,
+      'preview': None,
+      'review': None,
+      'style': None,
+    }),
+    'name': '11 Thrown Away',
+    'position': 0,
+    'provider': 'xx-instance-id-xx',
+    'provider_mappings': list([
+      dict({
+        'audio_format': dict({
+          'bit_depth': 16,
+          'bit_rate': 320,
+          'channels': 2,
+          'content_type': 'mp3',
+          'output_format_str': 'mp3',
+          'sample_rate': 44100,
+        }),
+        'available': True,
+        'details': None,
+        'item_id': 'b5319fb11cde39fca2023184fcfa9862',
+        'provider_domain': 'jellyfin',
+        'provider_instance': 'xx-instance-id-xx',
+        'url': 'http://localhost:1234/Audio/b5319fb11cde39fca2023184fcfa9862/universal?UserId=USER_ID&DeviceId=X&MaxStreamingBitrate=140000000&api_key=ACCESS_TOKEN',
+      }),
+    ]),
+    'sort_name': '11 thrown away',
+    'track_number': 0,
+    'uri': 'xx-instance-id-xx://track/b5319fb11cde39fca2023184fcfa9862',
+    'version': '',
+  })
+# ---
+# name: test_parse_tracks[where_the_bands_are]
+  dict({
+    'album': None,
+    'artists': list([
+      dict({
+        'available': True,
+        'external_ids': list([
+        ]),
+        'image': None,
+        'item_id': '94875b0dd58cbf5245a135982133651a',
+        'media_type': 'artist',
+        'name': 'Dead Like Harry',
+        'provider': 'xx-instance-id-xx',
+        'sort_name': 'dead like harry',
+        'uri': 'xx-instance-id-xx://artist/94875b0dd58cbf5245a135982133651a',
+        'version': '',
+      }),
+    ]),
+    'disc_number': 1,
+    'duration': 246,
+    'external_ids': list([
+    ]),
+    'favorite': False,
+    'item_id': '54918f75ee8f6c8b8dc5efd680644f29',
+    'media_type': 'track',
+    'metadata': dict({
+      'cache_checksum': None,
+      'chapters': None,
+      'copyright': None,
+      'description': None,
+      'explicit': None,
+      'genres': None,
+      'images': list([
+        dict({
+          'path': 'http://localhost:1234/Items/54918f75ee8f6c8b8dc5efd680644f29/Images/Primary?api_key=ACCESS_TOKEN',
+          'provider': 'xx-instance-id-xx',
+          'remotely_accessible': False,
+          'type': 'thumb',
+        }),
+      ]),
+      'label': None,
+      'last_refresh': None,
+      'links': None,
+      'lyrics': None,
+      'mood': None,
+      'performers': None,
+      'popularity': None,
+      'preview': None,
+      'review': None,
+      'style': None,
+    }),
+    'name': 'Where the Bands Are (2018 Version)',
+    'position': 1,
+    'provider': 'xx-instance-id-xx',
+    'provider_mappings': list([
+      dict({
+        'audio_format': dict({
+          'bit_depth': 16,
+          'bit_rate': 320,
+          'channels': 2,
+          'content_type': 'aac',
+          'output_format_str': 'aac',
+          'sample_rate': 44100,
+        }),
+        'available': True,
+        'details': None,
+        'item_id': '54918f75ee8f6c8b8dc5efd680644f29',
+        'provider_domain': 'jellyfin',
+        'provider_instance': 'xx-instance-id-xx',
+        'url': 'http://localhost:1234/Audio/54918f75ee8f6c8b8dc5efd680644f29/universal?UserId=USER_ID&DeviceId=X&MaxStreamingBitrate=140000000&api_key=ACCESS_TOKEN',
+      }),
+    ]),
+    'sort_name': 'where the bands are (2018 version)',
+    'track_number': 1,
+    'uri': 'xx-instance-id-xx://track/54918f75ee8f6c8b8dc5efd680644f29',
+    'version': '',
+  })
+# ---
+# name: test_parse_tracks[zombie_christmas]
+  dict({
+    'album': dict({
+      'available': True,
+      'external_ids': list([
+      ]),
+      'image': None,
+      'item_id': '32ed6a0091733dcff57eae67010f3d4b',
+      'media_type': 'album',
+      'name': 'This Is Christmas',
+      'provider': 'xx-instance-id-xx',
+      'sort_name': 'this is christmas',
+      'uri': 'xx-instance-id-xx://album/32ed6a0091733dcff57eae67010f3d4b',
+      'version': '',
+    }),
+    'artists': list([
+      dict({
+        'available': True,
+        'external_ids': list([
+        ]),
+        'image': None,
+        'item_id': 'a0c459294295710546c81c20a8d9abfc',
+        'media_type': 'artist',
+        'name': 'Emmy the Great',
+        'provider': 'xx-instance-id-xx',
+        'sort_name': 'emmy the great',
+        'uri': 'xx-instance-id-xx://artist/a0c459294295710546c81c20a8d9abfc',
+        'version': '',
+      }),
+      dict({
+        'available': True,
+        'external_ids': list([
+        ]),
+        'image': None,
+        'item_id': '1952db245ddef4e41dcd016475379190',
+        'media_type': 'artist',
+        'name': 'Tim Wheeler',
+        'provider': 'xx-instance-id-xx',
+        'sort_name': 'tim wheeler',
+        'uri': 'xx-instance-id-xx://artist/1952db245ddef4e41dcd016475379190',
+        'version': '',
+      }),
+    ]),
+    'disc_number': 1,
+    'duration': 224,
+    'external_ids': list([
+      list([
+        'musicbrainz',
+        '17d1019d-d4f4-326c-b4bb-d8aec2607bd7',
+      ]),
+    ]),
+    'favorite': False,
+    'item_id': 'fb12a77f49616a9fc56a6fab2b94174c',
+    'media_type': 'track',
+    'metadata': dict({
+      'cache_checksum': None,
+      'chapters': None,
+      'copyright': None,
+      'description': None,
+      'explicit': None,
+      'genres': None,
+      'images': list([
+        dict({
+          'path': 'http://localhost:1234/Items/fb12a77f49616a9fc56a6fab2b94174c/Images/Primary?api_key=ACCESS_TOKEN',
+          'provider': 'xx-instance-id-xx',
+          'remotely_accessible': False,
+          'type': 'thumb',
+        }),
+      ]),
+      'label': None,
+      'last_refresh': None,
+      'links': None,
+      'lyrics': None,
+      'mood': None,
+      'performers': None,
+      'popularity': None,
+      'preview': None,
+      'review': None,
+      'style': None,
+    }),
+    'name': 'Zombie Christmas',
+    'position': 8,
+    'provider': 'xx-instance-id-xx',
+    'provider_mappings': list([
+      dict({
+        'audio_format': dict({
+          'bit_depth': 16,
+          'bit_rate': 320,
+          'channels': 2,
+          'content_type': 'aac',
+          'output_format_str': 'aac',
+          'sample_rate': 44100,
+        }),
+        'available': True,
+        'details': None,
+        'item_id': 'fb12a77f49616a9fc56a6fab2b94174c',
+        'provider_domain': 'jellyfin',
+        'provider_instance': 'xx-instance-id-xx',
+        'url': 'http://localhost:1234/Audio/fb12a77f49616a9fc56a6fab2b94174c/universal?UserId=USER_ID&DeviceId=X&MaxStreamingBitrate=140000000&api_key=ACCESS_TOKEN',
+      }),
+    ]),
+    'sort_name': 'zombie christmas',
+    'track_number': 8,
+    'uri': 'xx-instance-id-xx://track/fb12a77f49616a9fc56a6fab2b94174c',
+    'version': '',
+  })
+# ---
diff --git a/tests/server/providers/jellyfin/fixtures/albums/this_is_christmas.json b/tests/server/providers/jellyfin/fixtures/albums/this_is_christmas.json
new file mode 100644 (file)
index 0000000..30a7743
--- /dev/null
@@ -0,0 +1,57 @@
+{
+    "Name": "This Is Christmas",
+    "ServerId": "58f180d8d2b34927bcfd73eee400ffad",
+    "Id": "32ed6a0091733dcff57eae67010f3d4b",
+    "SortName": "this is christmas",
+    "PremiereDate": "2011-11-21T00:00:00.0000000Z",
+    "ChannelId": null,
+    "RunTimeTicks": 18722017229,
+    "ProductionYear": 2011,
+    "ProviderIds": {
+        "MusicBrainzAlbum": "b13a174d-527d-44a1-b8f8-a4c78b03b7d9",
+        "MusicBrainzReleaseGroup": "f002d6b7-17af-4f9e-8d30-5486548ffe6f",
+        "MusicBrainzAlbumArtist": "60bbceb2-0ddc-403b-970b-b4e9c3b2de5c/827b9ff1-56f8-4614-9261-a08de5fc1be0"
+    },
+    "IsFolder": true,
+    "Type": "MusicAlbum",
+    "UserData": {
+        "PlaybackPositionTicks": 0,
+        "PlayCount": 0,
+        "IsFavorite": false,
+        "Played": false,
+        "Key": "MusicAlbum-MusicBrainzReleaseGroup-f002d6b7-17af-4f9e-8d30-5486548ffe6f"
+    },
+    "Artists": [
+        "Emmy the Great",
+        "Tim Wheeler"
+    ],
+    "ArtistItems": [
+        {
+            "Name": "Emmy the Great",
+            "Id": "a0c459294295710546c81c20a8d9abfc"
+        },
+        {
+            "Name": "Tim Wheeler",
+            "Id": "1952db245ddef4e41dcd016475379190"
+        }
+    ],
+    "AlbumArtist": "Emmy the Great & Tim Wheeler",
+    "AlbumArtists": [
+        {
+            "Name": "Emmy the Great & Tim Wheeler",
+            "Id": "555b36f7d310d1b7405557a8775c6878"
+        }
+    ],
+    "ImageTags": {
+        "Primary": "b685ba2b9247aca1ea66dda557bb8f54"
+    },
+    "BackdropImageTags": [],
+    "ImageBlurHashes": {
+        "Primary": {
+            "b685ba2b9247aca1ea66dda557bb8f54": "VGEB:8ogOrxt9G_MkC-AxaR*w1xaI:oe?GS~%1ixs:kC"
+        }
+    },
+    "LocationType": "FileSystem",
+    "MediaType": "Unknown",
+    "NormalizationGain": -12.3
+}
diff --git a/tests/server/providers/jellyfin/fixtures/albums/yesterday_when_i_was_mad.json b/tests/server/providers/jellyfin/fixtures/albums/yesterday_when_i_was_mad.json
new file mode 100644 (file)
index 0000000..c63b49d
--- /dev/null
@@ -0,0 +1,39 @@
+{
+    "Name": "Yesterday, When I Was Mad [Disc 2]",
+    "ServerId": "58f180d8d2b34927bcfd73eee400ffad",
+    "Id": "7c8d0bd55291c7fc0451d17ebef30017",
+    "SortName": "yesterday when i was mad [disc 0000000002]",
+    "ChannelId": null,
+    "RunTimeTicks": 0,
+    "ProviderIds": {},
+    "IsFolder": true,
+    "Type": "MusicAlbum",
+    "ParentLogoItemId": "87dff4e376665b79ff3fb0e3e69594e4",
+    "ParentBackdropItemId": "87dff4e376665b79ff3fb0e3e69594e4",
+    "ParentBackdropImageTags": [
+        "c8d58817f36f1a3337d14307e9b22ef3"
+    ],
+    "UserData": {
+        "PlaybackPositionTicks": 0,
+        "PlayCount": 0,
+        "IsFavorite": false,
+        "Played": false,
+        "Key": "7c8d0bd5-5291-c7fc-0451-d17ebef30017"
+    },
+    "Artists": [],
+    "ArtistItems": [],
+    "AlbumArtists": [],
+    "ImageTags": {},
+    "BackdropImageTags": [],
+    "ParentLogoImageTag": "ef313161af6195475d4ba26b245640b0",
+    "ImageBlurHashes": {
+        "Logo": {
+            "ef313161af6195475d4ba26b245640b0": "OmPGZ|R+Xlo{oNxve.x]4mNFbIf5s;t8t,tQDiM_tRoMbI"
+        },
+        "Backdrop": {
+            "c8d58817f36f1a3337d14307e9b22ef3": "W$Pi;m?b_Noeofx]~CRjNvxuofozs;ofRjRjofof-;xuoyRjWBoJ"
+        }
+    },
+    "LocationType": "FileSystem",
+    "MediaType": "Unknown"
+}
diff --git a/tests/server/providers/jellyfin/fixtures/artists/ash.json b/tests/server/providers/jellyfin/fixtures/artists/ash.json
new file mode 100644 (file)
index 0000000..8df9ddb
--- /dev/null
@@ -0,0 +1,40 @@
+{
+    "Name": "Ash",
+    "ServerId": "58f180d8d2b34927bcfd73eee400ffad",
+    "Id": "dd954bbf54398e247d803186d3585b79",
+    "SortName": "ash",
+    "ChannelId": null,
+    "RunTimeTicks": 509234691363,
+    "ProviderIds": {
+        "MusicBrainzArtist": "99164692-c02d-407c-81c9-25d338dd21f4"
+    },
+    "IsFolder": true,
+    "Type": "MusicArtist",
+    "UserData": {
+        "PlaybackPositionTicks": 0,
+        "PlayCount": 0,
+        "IsFavorite": false,
+        "Played": false,
+        "Key": "Artist-Musicbrainz-99164692-c02d-407c-81c9-25d338dd21f4"
+    },
+    "ImageTags": {
+        "Primary": "8a543e58fda6d2f374263a4dcd0d2fbd",
+        "Logo": "662f82868ad2964190daea171f3fcf08"
+    },
+    "BackdropImageTags": [
+        "8a4c3c67629b28673de7af433a1efd68"
+    ],
+    "ImageBlurHashes": {
+        "Backdrop": {
+            "8a4c3c67629b28673de7af433a1efd68": "WOE2-2Tdahs=KjwJ?w%2NGkBoMkCE%n+j?jErqNwo#Nwsmo1oejF"
+        },
+        "Primary": {
+            "8a543e58fda6d2f374263a4dcd0d2fbd": "eNHd?IWD4;~qI;#5~U-;D*-:^fxUM{xa%K-;RkIW%MWA4:Si%Mngn}"
+        },
+        "Logo": {
+            "662f82868ad2964190daea171f3fcf08": "OXD]o8j[00WBt7t7ayofWBWBt7ofRjayM{ofofRjRjj[t7"
+        }
+    },
+    "LocationType": "FileSystem",
+    "MediaType": "Unknown"
+}
diff --git a/tests/server/providers/jellyfin/fixtures/tracks/thrown_away.json b/tests/server/providers/jellyfin/fixtures/tracks/thrown_away.json
new file mode 100644 (file)
index 0000000..1f3c9f0
--- /dev/null
@@ -0,0 +1,131 @@
+{
+    "Name": "11 Thrown Away",
+    "ServerId": "58f180d8d2b34927bcfd73eee400ffad",
+    "Id": "b5319fb11cde39fca2023184fcfa9862",
+    "CanDownload": true,
+    "HasLyrics": false,
+    "Container": "mp3",
+    "SortName": "0000 - 0000 - 11 Thrown Away",
+    "MediaSources": [
+        {
+            "Protocol": "File",
+            "Id": "b5319fb11cde39fca2023184fcfa9862",
+            "Path": "/media/music/Papa Roach/Infest/11 Thrown Away.m4a",
+            "Type": "Default",
+            "Container": "mp3",
+            "Size": 11283443,
+            "Name": "11 Thrown Away",
+            "IsRemote": false,
+            "ETag": "d76cb3d88267e21a9a5a7b43e5981c99",
+            "RunTimeTicks": 5777763270,
+            "ReadAtNativeFramerate": false,
+            "IgnoreDts": false,
+            "IgnoreIndex": false,
+            "GenPtsInput": false,
+            "SupportsTranscoding": true,
+            "SupportsDirectStream": true,
+            "SupportsDirectPlay": true,
+            "IsInfiniteStream": false,
+            "RequiresOpening": false,
+            "RequiresClosing": false,
+            "RequiresLooping": false,
+            "SupportsProbing": true,
+            "MediaStreams": [
+                {
+                    "Codec": "mp3",
+                    "TimeBase": "1/14112000",
+                    "VideoRange": "Unknown",
+                    "VideoRangeType": "Unknown",
+                    "AudioSpatialFormat": "None",
+                    "DisplayTitle": "MP3 - Stereo",
+                    "IsInterlaced": false,
+                    "IsAVC": false,
+                    "ChannelLayout": "stereo",
+                    "BitRate": 156231,
+                    "Channels": 2,
+                    "SampleRate": 44100,
+                    "IsDefault": false,
+                    "IsForced": false,
+                    "IsHearingImpaired": false,
+                    "Type": "Audio",
+                    "Index": 0,
+                    "IsExternal": false,
+                    "IsTextSubtitleStream": false,
+                    "SupportsExternalStream": false,
+                    "Level": 0
+                }
+            ],
+            "MediaAttachments": [],
+            "Formats": [],
+            "Bitrate": 156232,
+            "RequiredHttpHeaders": {},
+            "TranscodingSubProtocol": "http",
+            "DefaultAudioStreamIndex": 0
+        }
+    ],
+    "ChannelId": null,
+    "RunTimeTicks": 5777763270,
+    "IndexNumber": 0,
+    "ParentIndexNumber": 0,
+    "ProviderIds": {},
+    "IsFolder": false,
+    "Type": "Audio",
+    "ParentLogoItemId": "e439648e08ade14e27d5de48fa97c88e",
+    "ParentBackdropItemId": "e439648e08ade14e27d5de48fa97c88e",
+    "ParentBackdropImageTags": [
+        "c3d584db117d4c2bba5a975f391a965e"
+    ],
+    "UserData": {
+        "PlaybackPositionTicks": 0,
+        "PlayCount": 0,
+        "IsFavorite": false,
+        "Played": false,
+        "Key": "0000-000011 Thrown Away"
+    },
+    "Artists": [],
+    "ArtistItems": [],
+    "AlbumId": "70b7288088b42d318f75dbcc41fd0091",
+    "AlbumPrimaryImageTag": "bcbe1ac159b0522743c9a0fe5401f948",
+    "AlbumArtists": [],
+    "MediaStreams": [
+        {
+            "Codec": "mp3",
+            "TimeBase": "1/14112000",
+            "VideoRange": "Unknown",
+            "VideoRangeType": "Unknown",
+            "AudioSpatialFormat": "None",
+            "DisplayTitle": "MP3 - Stereo",
+            "IsInterlaced": false,
+            "IsAVC": false,
+            "ChannelLayout": "stereo",
+            "BitRate": 156231,
+            "Channels": 2,
+            "SampleRate": 44100,
+            "IsDefault": false,
+            "IsForced": false,
+            "IsHearingImpaired": false,
+            "Type": "Audio",
+            "Index": 0,
+            "IsExternal": false,
+            "IsTextSubtitleStream": false,
+            "SupportsExternalStream": false,
+            "Level": 0
+        }
+    ],
+    "ImageTags": {},
+    "BackdropImageTags": [],
+    "ParentLogoImageTag": "d58ea3bfadfb34e66033f55b8b2198c4",
+    "ImageBlurHashes": {
+        "Logo": {
+            "d58ea3bfadfb34e66033f55b8b2198c4": "OQBftnWXD%WBNHoft7xaWBaej[fkoLay0Lax-:ofxZazRj"
+        },
+        "Backdrop": {
+            "c3d584db117d4c2bba5a975f391a965e": "W%F~5FodtRNGkCt6~Woet8Rkazs:-;j@ofoLWBkCxuWBays:axof"
+        },
+        "Primary": {
+            "bcbe1ac159b0522743c9a0fe5401f948": "ecQb^8vf.S_2xY*0%hxDV[kXyYx^IUNGxt=ZsSNGV@njxuxuaKayS2"
+        }
+    },
+    "LocationType": "FileSystem",
+    "MediaType": "Audio"
+}
diff --git a/tests/server/providers/jellyfin/fixtures/tracks/where_the_bands_are.json b/tests/server/providers/jellyfin/fixtures/tracks/where_the_bands_are.json
new file mode 100644 (file)
index 0000000..1403c9d
--- /dev/null
@@ -0,0 +1,198 @@
+{
+    "Name": "Where the Bands Are (2018 Version)",
+    "ServerId": "58f180d8d2b34927bcfd73eee400ffad",
+    "Id": "54918f75ee8f6c8b8dc5efd680644f29",
+    "CanDownload": true,
+    "HasLyrics": false,
+    "Container": "mov,mp4,m4a,3gp,3g2,mj2",
+    "SortName": "0001 - 0001 - Where the Bands Are (2018 Version)",
+    "PremiereDate": "2018-01-01T00:00:00.0000000Z",
+    "MediaSources": [
+        {
+            "Protocol": "File",
+            "Id": "54918f75ee8f6c8b8dc5efd680644f29",
+            "Path": "/media/music/Dead Like Harry/01 Where the Bands Are (2018 Version).m4a",
+            "Type": "Default",
+            "Container": "m4a",
+            "Size": 9167268,
+            "Name": "01 Where the Bands Are (2018 Version)",
+            "IsRemote": false,
+            "ETag": "7a60d53d522c32d2659150c99f0b8ed6",
+            "RunTimeTicks": 2464333790,
+            "ReadAtNativeFramerate": false,
+            "IgnoreDts": false,
+            "IgnoreIndex": false,
+            "GenPtsInput": false,
+            "SupportsTranscoding": true,
+            "SupportsDirectStream": true,
+            "SupportsDirectPlay": true,
+            "IsInfiniteStream": false,
+            "RequiresOpening": false,
+            "RequiresClosing": false,
+            "RequiresLooping": false,
+            "SupportsProbing": true,
+            "MediaStreams": [
+                {
+                    "Codec": "aac",
+                    "CodecTag": "mp4a",
+                    "Language": "eng",
+                    "TimeBase": "1/44100",
+                    "VideoRange": "Unknown",
+                    "VideoRangeType": "Unknown",
+                    "AudioSpatialFormat": "None",
+                    "DisplayTitle": "English - AAC - Stereo - Default",
+                    "IsInterlaced": false,
+                    "IsAVC": false,
+                    "ChannelLayout": "stereo",
+                    "BitRate": 278038,
+                    "Channels": 2,
+                    "SampleRate": 44100,
+                    "IsDefault": true,
+                    "IsForced": false,
+                    "IsHearingImpaired": false,
+                    "Profile": "LC",
+                    "Type": "Audio",
+                    "Index": 0,
+                    "IsExternal": false,
+                    "IsTextSubtitleStream": false,
+                    "SupportsExternalStream": false,
+                    "Level": 0
+                },
+                {
+                    "Codec": "mjpeg",
+                    "ColorSpace": "bt470bg",
+                    "TimeBase": "1/90000",
+                    "VideoRange": "Unknown",
+                    "VideoRangeType": "Unknown",
+                    "AudioSpatialFormat": "None",
+                    "IsInterlaced": false,
+                    "IsAVC": false,
+                    "BitDepth": 8,
+                    "RefFrames": 1,
+                    "IsDefault": false,
+                    "IsForced": false,
+                    "IsHearingImpaired": false,
+                    "Height": 600,
+                    "Width": 600,
+                    "RealFrameRate": 90000,
+                    "Profile": "Baseline",
+                    "Type": "EmbeddedImage",
+                    "AspectRatio": "1:1",
+                    "Index": 1,
+                    "IsExternal": false,
+                    "IsTextSubtitleStream": false,
+                    "SupportsExternalStream": false,
+                    "PixelFormat": "yuvj420p",
+                    "Level": -99,
+                    "IsAnamorphic": false
+                }
+            ],
+            "MediaAttachments": [],
+            "Formats": [],
+            "Bitrate": 297598,
+            "RequiredHttpHeaders": {},
+            "TranscodingSubProtocol": "http",
+            "DefaultAudioStreamIndex": 0
+        }
+    ],
+    "ChannelId": null,
+    "RunTimeTicks": 2464333790,
+    "ProductionYear": 2018,
+    "IndexNumber": 1,
+    "ParentIndexNumber": 1,
+    "ProviderIds": {},
+    "IsFolder": false,
+    "Type": "Audio",
+    "UserData": {
+        "PlaybackPositionTicks": 0,
+        "PlayCount": 0,
+        "IsFavorite": false,
+        "Played": false,
+        "Key": "Dead Like Harry-Where the Bands Are (2018 Version) - Single-0001-0001Where the Bands Are (2018 Version)"
+    },
+    "Artists": [
+        "Dead Like Harry"
+    ],
+    "ArtistItems": [
+        {
+            "Name": "Dead Like Harry",
+            "Id": "94875b0dd58cbf5245a135982133651a"
+        }
+    ],
+    "Album": "Where the Bands Are (2018 Version) - Single",
+    "AlbumArtist": "Dead Like Harry",
+    "AlbumArtists": [
+        {
+            "Name": "Dead Like Harry",
+            "Id": "94875b0dd58cbf5245a135982133651a"
+        }
+    ],
+    "MediaStreams": [
+        {
+            "Codec": "aac",
+            "CodecTag": "mp4a",
+            "Language": "eng",
+            "TimeBase": "1/44100",
+            "VideoRange": "Unknown",
+            "VideoRangeType": "Unknown",
+            "AudioSpatialFormat": "None",
+            "DisplayTitle": "English - AAC - Stereo - Default",
+            "IsInterlaced": false,
+            "IsAVC": false,
+            "ChannelLayout": "stereo",
+            "BitRate": 278038,
+            "Channels": 2,
+            "SampleRate": 44100,
+            "IsDefault": true,
+            "IsForced": false,
+            "IsHearingImpaired": false,
+            "Profile": "LC",
+            "Type": "Audio",
+            "Index": 0,
+            "IsExternal": false,
+            "IsTextSubtitleStream": false,
+            "SupportsExternalStream": false,
+            "Level": 0
+        },
+        {
+            "Codec": "mjpeg",
+            "ColorSpace": "bt470bg",
+            "TimeBase": "1/90000",
+            "VideoRange": "Unknown",
+            "VideoRangeType": "Unknown",
+            "AudioSpatialFormat": "None",
+            "IsInterlaced": false,
+            "IsAVC": false,
+            "BitDepth": 8,
+            "RefFrames": 1,
+            "IsDefault": false,
+            "IsForced": false,
+            "IsHearingImpaired": false,
+            "Height": 600,
+            "Width": 600,
+            "RealFrameRate": 90000,
+            "Profile": "Baseline",
+            "Type": "EmbeddedImage",
+            "AspectRatio": "1:1",
+            "Index": 1,
+            "IsExternal": false,
+            "IsTextSubtitleStream": false,
+            "SupportsExternalStream": false,
+            "PixelFormat": "yuvj420p",
+            "Level": -99,
+            "IsAnamorphic": false
+        }
+    ],
+    "ImageTags": {
+        "Primary": "dbd792d6c27313d01ed7c2dce85f785b"
+    },
+    "BackdropImageTags": [],
+    "ImageBlurHashes": {
+        "Primary": {
+            "dbd792d6c27313d01ed7c2dce85f785b": "eXI|wC^*={t6-o_3o#o#oft7~WtRbwNHS5?bS$ozaeR-o}WXt7jYR+"
+        }
+    },
+    "LocationType": "FileSystem",
+    "MediaType": "Audio",
+    "NormalizationGain": -10.7
+}
diff --git a/tests/server/providers/jellyfin/fixtures/tracks/zombie_christmas.json b/tests/server/providers/jellyfin/fixtures/tracks/zombie_christmas.json
new file mode 100644 (file)
index 0000000..24207db
--- /dev/null
@@ -0,0 +1,212 @@
+{
+    "Name": "Zombie Christmas",
+    "ServerId": "58f180d8d2b34927bcfd73eee400ffad",
+    "Id": "fb12a77f49616a9fc56a6fab2b94174c",
+    "CanDownload": true,
+    "HasLyrics": false,
+    "Container": "mov,mp4,m4a,3gp,3g2,mj2",
+    "SortName": "0001 - 0008 - Zombie Christmas",
+    "PremiereDate": "2011-11-21T00:00:00.0000000Z",
+    "MediaSources": [
+        {
+            "Protocol": "File",
+            "Id": "fb12a77f49616a9fc56a6fab2b94174c",
+            "Path": "/media/music/Emmy the Great & Tim Wheeler/This Is Christmas/8. Zombie Christmas.m4a",
+            "Type": "Default",
+            "Container": "m4a",
+            "Size": 8225981,
+            "Name": "8. Zombie Christmas",
+            "IsRemote": false,
+            "ETag": "0185e75e1fdad95cb227ce8d815d8cb5",
+            "RunTimeTicks": 2249317010,
+            "ReadAtNativeFramerate": false,
+            "IgnoreDts": false,
+            "IgnoreIndex": false,
+            "GenPtsInput": false,
+            "SupportsTranscoding": true,
+            "SupportsDirectStream": true,
+            "SupportsDirectPlay": true,
+            "IsInfiniteStream": false,
+            "RequiresOpening": false,
+            "RequiresClosing": false,
+            "RequiresLooping": false,
+            "SupportsProbing": true,
+            "MediaStreams": [
+                {
+                    "Codec": "aac",
+                    "CodecTag": "mp4a",
+                    "Language": "eng",
+                    "TimeBase": "1/44100",
+                    "VideoRange": "Unknown",
+                    "VideoRangeType": "Unknown",
+                    "AudioSpatialFormat": "None",
+                    "DisplayTitle": "English - AAC - Stereo - Default",
+                    "IsInterlaced": false,
+                    "IsAVC": false,
+                    "ChannelLayout": "stereo",
+                    "BitRate": 267933,
+                    "Channels": 2,
+                    "SampleRate": 44100,
+                    "IsDefault": true,
+                    "IsForced": false,
+                    "IsHearingImpaired": false,
+                    "Profile": "LC",
+                    "Type": "Audio",
+                    "Index": 0,
+                    "IsExternal": false,
+                    "IsTextSubtitleStream": false,
+                    "SupportsExternalStream": false,
+                    "Level": 0
+                },
+                {
+                    "Codec": "mjpeg",
+                    "ColorSpace": "bt470bg",
+                    "TimeBase": "1/90000",
+                    "VideoRange": "Unknown",
+                    "VideoRangeType": "Unknown",
+                    "AudioSpatialFormat": "None",
+                    "IsInterlaced": false,
+                    "IsAVC": false,
+                    "BitDepth": 8,
+                    "RefFrames": 1,
+                    "IsDefault": false,
+                    "IsForced": false,
+                    "IsHearingImpaired": false,
+                    "Height": 449,
+                    "Width": 500,
+                    "RealFrameRate": 90000,
+                    "Profile": "Baseline",
+                    "Type": "EmbeddedImage",
+                    "AspectRatio": "500:449",
+                    "Index": 1,
+                    "IsExternal": false,
+                    "IsTextSubtitleStream": false,
+                    "SupportsExternalStream": false,
+                    "PixelFormat": "yuvj420p",
+                    "Level": -99,
+                    "IsAnamorphic": false
+                }
+            ],
+            "MediaAttachments": [],
+            "Formats": [],
+            "Bitrate": 292568,
+            "RequiredHttpHeaders": {},
+            "TranscodingSubProtocol": "http",
+            "DefaultAudioStreamIndex": 0
+        }
+    ],
+    "ChannelId": null,
+    "RunTimeTicks": 2249317010,
+    "ProductionYear": 2011,
+    "IndexNumber": 8,
+    "ParentIndexNumber": 1,
+    "ProviderIds": {
+        "MusicBrainzArtist": "60bbceb2-0ddc-403b-970b-b4e9c3b2de5c/827b9ff1-56f8-4614-9261-a08de5fc1be0",
+        "MusicBrainzAlbumArtist": "60bbceb2-0ddc-403b-970b-b4e9c3b2de5c/827b9ff1-56f8-4614-9261-a08de5fc1be0",
+        "MusicBrainzAlbum": "b13a174d-527d-44a1-b8f8-a4c78b03b7d9",
+        "MusicBrainzReleaseGroup": "f002d6b7-17af-4f9e-8d30-5486548ffe6f",
+        "MusicBrainzTrack": "17d1019d-d4f4-326c-b4bb-d8aec2607bd7"
+    },
+    "IsFolder": false,
+    "Type": "Audio",
+    "UserData": {
+        "PlaybackPositionTicks": 0,
+        "PlayCount": 0,
+        "IsFavorite": false,
+        "Played": false,
+        "Key": "Emmy the Great & Tim Wheeler-This Is Christmas-0001-0008Zombie Christmas"
+    },
+    "Artists": [
+        "Emmy the Great",
+        "Tim Wheeler"
+    ],
+    "ArtistItems": [
+        {
+            "Name": "Emmy the Great",
+            "Id": "a0c459294295710546c81c20a8d9abfc"
+        },
+        {
+            "Name": "Tim Wheeler",
+            "Id": "1952db245ddef4e41dcd016475379190"
+        }
+    ],
+    "Album": "This Is Christmas",
+    "AlbumId": "32ed6a0091733dcff57eae67010f3d4b",
+    "AlbumPrimaryImageTag": "b685ba2b9247aca1ea66dda557bb8f54",
+    "AlbumArtist": "Emmy the Great & Tim Wheeler",
+    "AlbumArtists": [
+        {
+            "Name": "Emmy the Great & Tim Wheeler",
+            "Id": "555b36f7d310d1b7405557a8775c6878"
+        }
+    ],
+    "MediaStreams": [
+        {
+            "Codec": "aac",
+            "CodecTag": "mp4a",
+            "Language": "eng",
+            "TimeBase": "1/44100",
+            "VideoRange": "Unknown",
+            "VideoRangeType": "Unknown",
+            "AudioSpatialFormat": "None",
+            "DisplayTitle": "English - AAC - Stereo - Default",
+            "IsInterlaced": false,
+            "IsAVC": false,
+            "ChannelLayout": "stereo",
+            "BitRate": 267933,
+            "Channels": 2,
+            "SampleRate": 44100,
+            "IsDefault": true,
+            "IsForced": false,
+            "IsHearingImpaired": false,
+            "Profile": "LC",
+            "Type": "Audio",
+            "Index": 0,
+            "IsExternal": false,
+            "IsTextSubtitleStream": false,
+            "SupportsExternalStream": false,
+            "Level": 0
+        },
+        {
+            "Codec": "mjpeg",
+            "ColorSpace": "bt470bg",
+            "TimeBase": "1/90000",
+            "VideoRange": "Unknown",
+            "VideoRangeType": "Unknown",
+            "AudioSpatialFormat": "None",
+            "IsInterlaced": false,
+            "IsAVC": false,
+            "BitDepth": 8,
+            "RefFrames": 1,
+            "IsDefault": false,
+            "IsForced": false,
+            "IsHearingImpaired": false,
+            "Height": 449,
+            "Width": 500,
+            "RealFrameRate": 90000,
+            "Profile": "Baseline",
+            "Type": "EmbeddedImage",
+            "AspectRatio": "500:449",
+            "Index": 1,
+            "IsExternal": false,
+            "IsTextSubtitleStream": false,
+            "SupportsExternalStream": false,
+            "PixelFormat": "yuvj420p",
+            "Level": -99,
+            "IsAnamorphic": false
+        }
+    ],
+    "ImageTags": {
+        "Primary": "c8e39ff125c3ba39a5791570dffa4b83"
+    },
+    "BackdropImageTags": [],
+    "ImageBlurHashes": {
+        "Primary": {
+            "c8e39ff125c3ba39a5791570dffa4b83": "VGEB:8ogOrxt9G_MkC-AxaR*w1xaI:oe?GS~%1ixs:kC",
+            "b685ba2b9247aca1ea66dda557bb8f54": "VGEB:8ogOrxt9G_MkC-AxaR*w1xaI:oe?GS~%1ixs:kC"
+        }
+    },
+    "LocationType": "FileSystem",
+    "MediaType": "Audio",
+    "NormalizationGain": -11.9
+}
diff --git a/tests/server/providers/jellyfin/test_parsers.py b/tests/server/providers/jellyfin/test_parsers.py
new file mode 100644 (file)
index 0000000..b7dacf1
--- /dev/null
@@ -0,0 +1,71 @@
+"""Test we can parse Jellyfin models into Music Assistant models."""
+
+import logging
+import pathlib
+from collections.abc import AsyncGenerator
+
+import aiofiles
+import aiohttp
+import pytest
+from aiojellyfin import Artist, Connection, SessionConfiguration
+from mashumaro.codecs.json import JSONDecoder
+from syrupy.assertion import SnapshotAssertion
+
+from music_assistant.server.providers.jellyfin.parsers import parse_album, parse_artist, parse_track
+
+FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures"
+ARTIST_FIXTURES = list(FIXTURES_DIR.glob("artists/*.json"))
+ALBUM_FIXTURES = list(FIXTURES_DIR.glob("albums/*.json"))
+TRACK_FIXTURES = list(FIXTURES_DIR.glob("tracks/*.json"))
+
+ARTIST_DECODER = JSONDecoder(Artist)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@pytest.fixture
+async def connection() -> AsyncGenerator[Connection, None]:
+    """Spin up a dummy connection."""
+    async with aiohttp.ClientSession() as session:
+        session_config = SessionConfiguration(
+            session=session,
+            url="http://localhost:1234",
+            app_name="X",
+            app_version="0.0.0",
+            device_id="X",
+            device_name="localhost",
+        )
+        yield Connection(session_config, "USER_ID", "ACCESS_TOKEN")
+
+
+@pytest.mark.parametrize("example", ARTIST_FIXTURES, ids=lambda val: str(val.stem))
+async def test_parse_artists(
+    example: pathlib.Path, connection: Connection, snapshot: SnapshotAssertion
+) -> None:
+    """Test we can parse artists."""
+    async with aiofiles.open(example) as fp:
+        raw_data = ARTIST_DECODER.decode(await fp.read())
+    parsed = parse_artist(_LOGGER, "xx-instance-id-xx", connection, raw_data)
+    assert snapshot == parsed.to_dict()
+
+
+@pytest.mark.parametrize("example", ALBUM_FIXTURES, ids=lambda val: str(val.stem))
+async def test_parse_albums(
+    example: pathlib.Path, connection: Connection, snapshot: SnapshotAssertion
+) -> None:
+    """Test we can parse albums."""
+    async with aiofiles.open(example) as fp:
+        raw_data = ARTIST_DECODER.decode(await fp.read())
+    parsed = parse_album(_LOGGER, "xx-instance-id-xx", connection, raw_data)
+    assert snapshot == parsed.to_dict()
+
+
+@pytest.mark.parametrize("example", TRACK_FIXTURES, ids=lambda val: str(val.stem))
+async def test_parse_tracks(
+    example: pathlib.Path, connection: Connection, snapshot: SnapshotAssertion
+) -> None:
+    """Test we can parse tracks."""
+    async with aiofiles.open(example) as fp:
+        raw_data = ARTIST_DECODER.decode(await fp.read())
+    parsed = parse_track(_LOGGER, "xx-instance-id-xx", connection, raw_data)
+    assert snapshot == parsed.to_dict()