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/
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,
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"
"""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,
)
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]:
)
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]:
)
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]:
)
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,
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(
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
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(
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
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(
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
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)
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"]
]
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)
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)
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."""
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()
--- /dev/null
+"""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
"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",
]
--- /dev/null
+"""Tests for Jellyfin provider."""
--- /dev/null
+# 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': '',
+ })
+# ---
--- /dev/null
+{
+ "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
+}
--- /dev/null
+{
+ "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"
+}
--- /dev/null
+{
+ "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"
+}
--- /dev/null
+{
+ "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"
+}
--- /dev/null
+{
+ "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
+}
--- /dev/null
+{
+ "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
+}
--- /dev/null
+"""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()