From: Jc2k Date: Sat, 22 Jun 2024 10:15:05 +0000 (+0100) Subject: Jellyfin: Add some basic parsing tests (#1397) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=5277f18e5b28b76c1fea67b373dc55999e09fdac;p=music-assistant-server.git Jellyfin: Add some basic parsing tests (#1397) --- diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dcf11217..8d4ea7ed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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/ diff --git a/music_assistant/server/providers/jellyfin/__init__.py b/music_assistant/server/providers/jellyfin/__init__.py index 2f9986dc..44387442 100644 --- a/music_assistant/server/providers/jellyfin/__init__.py +++ b/music_assistant/server/providers/jellyfin/__init__.py @@ -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 index 00000000..a552fbc8 --- /dev/null +++ b/music_assistant/server/providers/jellyfin/parsers.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 2bef6cb9..e754e244 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 index 00000000..64080a61 --- /dev/null +++ b/tests/server/providers/jellyfin/__init__.py @@ -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 index 00000000..c9f2020b --- /dev/null +++ b/tests/server/providers/jellyfin/__snapshots__/test_parsers.ambr @@ -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 index 00000000..30a77430 --- /dev/null +++ b/tests/server/providers/jellyfin/fixtures/albums/this_is_christmas.json @@ -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 index 00000000..c63b49da --- /dev/null +++ b/tests/server/providers/jellyfin/fixtures/albums/yesterday_when_i_was_mad.json @@ -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 index 00000000..8df9ddb3 --- /dev/null +++ b/tests/server/providers/jellyfin/fixtures/artists/ash.json @@ -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 index 00000000..1f3c9f0f --- /dev/null +++ b/tests/server/providers/jellyfin/fixtures/tracks/thrown_away.json @@ -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 index 00000000..1403c9d8 --- /dev/null +++ b/tests/server/providers/jellyfin/fixtures/tracks/where_the_bands_are.json @@ -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 index 00000000..24207dbc --- /dev/null +++ b/tests/server/providers/jellyfin/fixtures/tracks/zombie_christmas.json @@ -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 index 00000000..b7dacf12 --- /dev/null +++ b/tests/server/providers/jellyfin/test_parsers.py @@ -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()