music-assistant-server

11.6 KBPY
parsers.py
11.6 KB325 lines • python
1"""Parse Jellyfin metadata into Music Assistant models."""
2
3from __future__ import annotations
4
5import logging
6from logging import Logger
7from typing import TYPE_CHECKING
8
9from aiojellyfin import ImageType as JellyImageType
10from music_assistant_models.enums import ContentType, ExternalID, ImageType, MediaType
11from music_assistant_models.errors import InvalidDataError
12from music_assistant_models.media_items import (
13    Album,
14    Artist,
15    AudioFormat,
16    ItemMapping,
17    MediaItemImage,
18    Playlist,
19    ProviderMapping,
20    Track,
21    UniqueList,
22)
23
24from music_assistant.helpers.util import parse_title_and_version
25
26from .const import (
27    DOMAIN,
28    ITEM_KEY_ALBUM,
29    ITEM_KEY_ALBUM_ARTIST,
30    ITEM_KEY_ALBUM_ARTISTS,
31    ITEM_KEY_ALBUM_ID,
32    ITEM_KEY_ARTIST_ITEMS,
33    ITEM_KEY_CAN_DOWNLOAD,
34    ITEM_KEY_ID,
35    ITEM_KEY_IMAGE_TAGS,
36    ITEM_KEY_MEDIA_CHANNELS,
37    ITEM_KEY_MEDIA_CODEC,
38    ITEM_KEY_MEDIA_STREAMS,
39    ITEM_KEY_MUSICBRAINZ_ALBUM,
40    ITEM_KEY_MUSICBRAINZ_ARTIST,
41    ITEM_KEY_MUSICBRAINZ_RELEASE_GROUP,
42    ITEM_KEY_MUSICBRAINZ_TRACK,
43    ITEM_KEY_NAME,
44    ITEM_KEY_OVERVIEW,
45    ITEM_KEY_PARENT_INDEX_NUM,
46    ITEM_KEY_PRODUCTION_YEAR,
47    ITEM_KEY_PROVIDER_IDS,
48    ITEM_KEY_RUNTIME_TICKS,
49    ITEM_KEY_SORT_NAME,
50    ITEM_KEY_USER_DATA,
51    MEDIA_IMAGE_TYPES,
52    UNKNOWN_ARTIST_MAPPING,
53    USER_DATA_KEY_IS_FAVORITE,
54)
55
56if TYPE_CHECKING:
57    from aiojellyfin import Album as JellyAlbum
58    from aiojellyfin import Artist as JellyArtist
59    from aiojellyfin import Connection
60    from aiojellyfin import MediaItem as JellyMediaItem
61    from aiojellyfin import Playlist as JellyPlaylist
62    from aiojellyfin import Track as JellyTrack
63
64
65def parse_album(
66    logger: Logger, instance_id: str, connection: Connection, jellyfin_album: JellyAlbum
67) -> Album:
68    """Parse a Jellyfin Album response to an Album model object."""
69    album_id = jellyfin_album[ITEM_KEY_ID]
70    name, version = parse_title_and_version(jellyfin_album[ITEM_KEY_NAME])
71    album = Album(
72        item_id=album_id,
73        provider=DOMAIN,
74        name=name,
75        version=version,
76        provider_mappings={
77            ProviderMapping(
78                item_id=str(album_id),
79                provider_domain=DOMAIN,
80                provider_instance=instance_id,
81            )
82        },
83    )
84    if ITEM_KEY_PRODUCTION_YEAR in jellyfin_album:
85        album.year = jellyfin_album[ITEM_KEY_PRODUCTION_YEAR]
86    album.metadata.images = _get_artwork(instance_id, connection, jellyfin_album)
87    if ITEM_KEY_OVERVIEW in jellyfin_album:
88        album.metadata.description = jellyfin_album[ITEM_KEY_OVERVIEW]
89    if ITEM_KEY_MUSICBRAINZ_ALBUM in jellyfin_album[ITEM_KEY_PROVIDER_IDS]:
90        try:
91            album.add_external_id(
92                ExternalID.MB_ALBUM,
93                jellyfin_album[ITEM_KEY_PROVIDER_IDS][ITEM_KEY_MUSICBRAINZ_ALBUM],
94            )
95        except InvalidDataError as error:
96            logger.warning(
97                "Jellyfin has an invalid musicbrainz album id for album %s",
98                album.name,
99                exc_info=error if logger.isEnabledFor(logging.DEBUG) else None,
100            )
101    if ITEM_KEY_MUSICBRAINZ_RELEASE_GROUP in jellyfin_album[ITEM_KEY_PROVIDER_IDS]:
102        try:
103            album.add_external_id(
104                ExternalID.MB_RELEASEGROUP,
105                jellyfin_album[ITEM_KEY_PROVIDER_IDS][ITEM_KEY_MUSICBRAINZ_RELEASE_GROUP],
106            )
107        except InvalidDataError as error:
108            logger.warning(
109                "Jellyfin has an invalid musicbrainz id for album %s",
110                album.name,
111                exc_info=error if logger.isEnabledFor(logging.DEBUG) else None,
112            )
113    if ITEM_KEY_SORT_NAME in jellyfin_album:
114        album.sort_name = jellyfin_album[ITEM_KEY_SORT_NAME]
115    if ITEM_KEY_ALBUM_ARTIST in jellyfin_album:
116        for album_artist in jellyfin_album[ITEM_KEY_ALBUM_ARTISTS]:
117            album.artists.append(
118                ItemMapping(
119                    media_type=MediaType.ARTIST,
120                    item_id=album_artist[ITEM_KEY_ID],
121                    provider=instance_id,
122                    name=album_artist[ITEM_KEY_NAME],
123                )
124            )
125    elif len(jellyfin_album.get(ITEM_KEY_ARTIST_ITEMS, [])) >= 1:
126        for artist_item in jellyfin_album[ITEM_KEY_ARTIST_ITEMS]:
127            album.artists.append(
128                ItemMapping(
129                    media_type=MediaType.ARTIST,
130                    item_id=artist_item[ITEM_KEY_ID],
131                    provider=instance_id,
132                    name=artist_item[ITEM_KEY_NAME],
133                )
134            )
135    else:
136        album.artists.append(UNKNOWN_ARTIST_MAPPING)
137
138    user_data = jellyfin_album.get(ITEM_KEY_USER_DATA, {})
139    album.favorite = user_data.get(USER_DATA_KEY_IS_FAVORITE, False)
140    return album
141
142
143def parse_artist(
144    logger: Logger, instance_id: str, connection: Connection, jellyfin_artist: JellyArtist
145) -> Artist:
146    """Parse a Jellyfin Artist response to Artist model object."""
147    artist_id = jellyfin_artist[ITEM_KEY_ID]
148    artist = Artist(
149        item_id=artist_id,
150        name=jellyfin_artist[ITEM_KEY_NAME],
151        provider=DOMAIN,
152        provider_mappings={
153            ProviderMapping(
154                item_id=str(artist_id),
155                provider_domain=DOMAIN,
156                provider_instance=instance_id,
157            )
158        },
159    )
160    if ITEM_KEY_OVERVIEW in jellyfin_artist:
161        artist.metadata.description = jellyfin_artist[ITEM_KEY_OVERVIEW]
162    if ITEM_KEY_MUSICBRAINZ_ARTIST in jellyfin_artist[ITEM_KEY_PROVIDER_IDS]:
163        try:
164            artist.mbid = jellyfin_artist[ITEM_KEY_PROVIDER_IDS][ITEM_KEY_MUSICBRAINZ_ARTIST]
165        except InvalidDataError as error:
166            logger.warning(
167                "Jellyfin has an invalid musicbrainz id for artist %s",
168                artist.name,
169                exc_info=error if logger.isEnabledFor(logging.DEBUG) else None,
170            )
171    if ITEM_KEY_SORT_NAME in jellyfin_artist:
172        artist.sort_name = jellyfin_artist[ITEM_KEY_SORT_NAME]
173    artist.metadata.images = _get_artwork(instance_id, connection, jellyfin_artist)
174    user_data = jellyfin_artist.get(ITEM_KEY_USER_DATA, {})
175    artist.favorite = user_data.get(USER_DATA_KEY_IS_FAVORITE, False)
176    return artist
177
178
179def audio_format(track: JellyTrack) -> AudioFormat:
180    """Build an AudioFormat model from a Jellyfin track."""
181    # Defensive: Handle missing or empty MediaStreams array
182    streams = track.get(ITEM_KEY_MEDIA_STREAMS, [])
183    if not streams:
184        return AudioFormat(content_type=ContentType.UNKNOWN)
185
186    stream = streams[0]
187    codec = stream.get(ITEM_KEY_MEDIA_CODEC)
188
189    return AudioFormat(
190        content_type=(ContentType.try_parse(codec) if codec else ContentType.UNKNOWN),
191        channels=stream.get(ITEM_KEY_MEDIA_CHANNELS, 2),
192        sample_rate=stream.get("SampleRate", 44100),
193        bit_rate=stream.get("BitRate"),
194        bit_depth=stream.get("BitDepth", 16),
195    )
196
197
198def parse_track(
199    logger: Logger, instance_id: str, client: Connection, jellyfin_track: JellyTrack
200) -> Track:
201    """Parse a Jellyfin Track response to a Track model object."""
202    available = jellyfin_track[ITEM_KEY_CAN_DOWNLOAD]
203    name, version = parse_title_and_version(jellyfin_track[ITEM_KEY_NAME])
204    track = Track(
205        item_id=jellyfin_track[ITEM_KEY_ID],
206        provider=instance_id,
207        name=name,
208        version=version,
209        provider_mappings={
210            ProviderMapping(
211                item_id=jellyfin_track[ITEM_KEY_ID],
212                provider_domain=DOMAIN,
213                provider_instance=instance_id,
214                available=available,
215                audio_format=audio_format(jellyfin_track),
216                url=client.audio_url(jellyfin_track[ITEM_KEY_ID]),
217            )
218        },
219    )
220
221    track.disc_number = jellyfin_track.get(ITEM_KEY_PARENT_INDEX_NUM, 0)
222    track.track_number = jellyfin_track.get("IndexNumber", 0)
223    if track.track_number is not None and track.track_number >= 0:
224        track.position = track.track_number
225
226    track.metadata.images = _get_artwork(instance_id, client, jellyfin_track)
227
228    if jellyfin_track[ITEM_KEY_ARTIST_ITEMS]:
229        for artist_item in jellyfin_track[ITEM_KEY_ARTIST_ITEMS]:
230            track.artists.append(
231                ItemMapping(
232                    media_type=MediaType.ARTIST,
233                    item_id=artist_item[ITEM_KEY_ID],
234                    provider=instance_id,
235                    name=artist_item[ITEM_KEY_NAME],
236                )
237            )
238    else:
239        track.artists.append(UNKNOWN_ARTIST_MAPPING)
240
241    if ITEM_KEY_ALBUM_ID in jellyfin_track:
242        if not (album_name := jellyfin_track.get(ITEM_KEY_ALBUM)):
243            logger.debug("Track %s has AlbumID but no AlbumName", track.name)
244            album_name = f"Unknown Album ({jellyfin_track[ITEM_KEY_ALBUM_ID]})"
245        track.album = ItemMapping(
246            media_type=MediaType.ALBUM,
247            item_id=jellyfin_track[ITEM_KEY_ALBUM_ID],
248            provider=instance_id,
249            name=album_name,
250        )
251
252    if ITEM_KEY_RUNTIME_TICKS in jellyfin_track:
253        track.duration = int(
254            jellyfin_track[ITEM_KEY_RUNTIME_TICKS] / 10000000
255        )  # 10000000 ticks per millisecond
256    if ITEM_KEY_MUSICBRAINZ_TRACK in jellyfin_track[ITEM_KEY_PROVIDER_IDS]:
257        track_mbid = jellyfin_track[ITEM_KEY_PROVIDER_IDS][ITEM_KEY_MUSICBRAINZ_TRACK]
258        try:
259            track.mbid = track_mbid
260        except InvalidDataError as error:
261            logger.warning(
262                "Jellyfin has an invalid musicbrainz id for track %s",
263                track.name,
264                exc_info=error if logger.isEnabledFor(logging.DEBUG) else None,
265            )
266    user_data = jellyfin_track.get(ITEM_KEY_USER_DATA, {})
267    track.favorite = user_data.get(USER_DATA_KEY_IS_FAVORITE, False)
268    return track
269
270
271def parse_playlist(
272    instance_id: str, client: Connection, jellyfin_playlist: JellyPlaylist
273) -> Playlist:
274    """Parse a Jellyfin Playlist response to a Playlist object."""
275    playlistid = jellyfin_playlist[ITEM_KEY_ID]
276    playlist = Playlist(
277        item_id=playlistid,
278        provider=DOMAIN,
279        name=jellyfin_playlist[ITEM_KEY_NAME],
280        provider_mappings={
281            ProviderMapping(
282                item_id=playlistid,
283                provider_domain=DOMAIN,
284                provider_instance=instance_id,
285            )
286        },
287    )
288    if ITEM_KEY_OVERVIEW in jellyfin_playlist:
289        playlist.metadata.description = jellyfin_playlist[ITEM_KEY_OVERVIEW]
290    playlist.metadata.images = _get_artwork(instance_id, client, jellyfin_playlist)
291    user_data = jellyfin_playlist.get(ITEM_KEY_USER_DATA, {})
292    playlist.favorite = user_data.get(USER_DATA_KEY_IS_FAVORITE, False)
293    playlist.is_editable = False
294    return playlist
295
296
297def _get_artwork(
298    instance_id: str, client: Connection, media_item: JellyMediaItem
299) -> UniqueList[MediaItemImage]:
300    images: UniqueList[MediaItemImage] = UniqueList()
301
302    for i, _ in enumerate(media_item.get("BackdropImageTags", [])):
303        images.append(
304            MediaItemImage(
305                type=ImageType.FANART,
306                path=client.artwork(media_item[ITEM_KEY_ID], JellyImageType.Backdrop, index=i),
307                provider=instance_id,
308                remotely_accessible=False,
309            )
310        )
311
312    image_tags = media_item[ITEM_KEY_IMAGE_TAGS]
313    for jelly_image_type, image_type in MEDIA_IMAGE_TYPES.items():
314        if jelly_image_type in image_tags:
315            images.append(
316                MediaItemImage(
317                    type=image_type,
318                    path=client.artwork(media_item[ITEM_KEY_ID], jelly_image_type),
319                    provider=instance_id,
320                    remotely_accessible=False,
321                )
322            )
323
324    return images
325