From: Jc2k Date: Mon, 20 Jan 2025 20:48:30 +0000 (+0000) Subject: fix: add missing AudioFormat metadata to Jellyfin provider (#1890) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=baa445a79db1352a26521545aab169a6c2ac97a5;p=music-assistant-server.git fix: add missing AudioFormat metadata to Jellyfin provider (#1890) * feat: populate more stream details in Jellyfin * fix: defensive parsing of optional fields. tests. * chore: refactor and unify how we get an AudioFormat from a jellyfin Track * chore: more refactoring * fix: breaking api change --- diff --git a/music_assistant/providers/jellyfin/__init__.py b/music_assistant/providers/jellyfin/__init__.py index e5f934bd..f3e53ac6 100644 --- a/music_assistant/providers/jellyfin/__init__.py +++ b/music_assistant/providers/jellyfin/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import hashlib -import mimetypes import socket from asyncio import TaskGroup from collections.abc import AsyncGenerator @@ -11,21 +10,13 @@ from typing import TYPE_CHECKING from aiojellyfin import MediaLibrary as JellyMediaLibrary from aiojellyfin import NotFound, authenticate_by_name -from aiojellyfin import Track as JellyTrack from aiojellyfin.session import SessionConfiguration from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, ProviderConfig -from music_assistant_models.enums import ( - ConfigEntryType, - ContentType, - MediaType, - ProviderFeature, - StreamType, -) +from music_assistant_models.enums import ConfigEntryType, MediaType, ProviderFeature, StreamType from music_assistant_models.errors import LoginFailed, MediaNotFoundError from music_assistant_models.media_items import ( Album, Artist, - AudioFormat, Playlist, ProviderMapping, SearchResults, @@ -38,6 +29,7 @@ from music_assistant.mass import MusicAssistant from music_assistant.models import ProviderInstanceType from music_assistant.models.music_provider import MusicProvider from music_assistant.providers.jellyfin.parsers import ( + audio_format, parse_album, parse_artist, parse_playlist, @@ -49,9 +41,6 @@ from .const import ( ARTIST_FIELDS, ITEM_KEY_COLLECTION_TYPE, ITEM_KEY_ID, - ITEM_KEY_MEDIA_CHANNELS, - ITEM_KEY_MEDIA_CODEC, - ITEM_KEY_MEDIA_SOURCES, ITEM_KEY_MEDIA_STREAMS, ITEM_KEY_NAME, ITEM_KEY_RUNTIME_TICKS, @@ -451,20 +440,13 @@ class JellyfinProvider(MusicProvider): ) -> StreamDetails: """Return the content details for the given track when it will be streamed.""" jellyfin_track = await self._client.get_track(item_id) - mimetype = self._media_mime_type(jellyfin_track) - media_stream = jellyfin_track[ITEM_KEY_MEDIA_STREAMS][0] - url = self._client.audio_url(jellyfin_track[ITEM_KEY_ID], SUPPORTED_CONTAINER_FORMATS) - if ITEM_KEY_MEDIA_CODEC in media_stream: - content_type = ContentType.try_parse(media_stream[ITEM_KEY_MEDIA_CODEC]) - else: - content_type = ContentType.try_parse(mimetype) if mimetype else ContentType.UNKNOWN + url = self._client.audio_url( + jellyfin_track[ITEM_KEY_ID], container=SUPPORTED_CONTAINER_FORMATS + ) return StreamDetails( item_id=jellyfin_track[ITEM_KEY_ID], provider=self.lookup_key, - audio_format=AudioFormat( - content_type=content_type, - channels=jellyfin_track[ITEM_KEY_MEDIA_STREAMS][0][ITEM_KEY_MEDIA_CHANNELS], - ), + audio_format=audio_format(jellyfin_track), stream_type=StreamType.HTTP, duration=int( jellyfin_track[ITEM_KEY_RUNTIME_TICKS] / 10000000 @@ -504,18 +486,3 @@ class JellyfinProvider(MusicProvider): ): result.append(library) return result - - def _media_mime_type(self, media_item: JellyTrack) -> str | None: - """Return the mime type of a media item.""" - if not media_item.get(ITEM_KEY_MEDIA_SOURCES): - return None - - media_source = media_item[ITEM_KEY_MEDIA_SOURCES][0] - - if "Path" not in media_source: - return None - - path = media_source["Path"] - mime_type, _ = mimetypes.guess_type(path) - - return mime_type diff --git a/music_assistant/providers/jellyfin/manifest.json b/music_assistant/providers/jellyfin/manifest.json index af85a57b..d895263f 100644 --- a/music_assistant/providers/jellyfin/manifest.json +++ b/music_assistant/providers/jellyfin/manifest.json @@ -4,7 +4,7 @@ "name": "Jellyfin Media Server Library", "description": "Support for the Jellyfin streaming provider in Music Assistant.", "codeowners": ["@lokiberra", "@Jc2k"], - "requirements": ["aiojellyfin==0.11.2"], + "requirements": ["aiojellyfin==0.14.1"], "documentation": "https://music-assistant.io/music-providers/jellyfin/", "multi_instance": true } diff --git a/music_assistant/providers/jellyfin/parsers.py b/music_assistant/providers/jellyfin/parsers.py index 96545118..a657997a 100644 --- a/music_assistant/providers/jellyfin/parsers.py +++ b/music_assistant/providers/jellyfin/parsers.py @@ -31,6 +31,7 @@ from .const import ( ITEM_KEY_CAN_DOWNLOAD, ITEM_KEY_ID, ITEM_KEY_IMAGE_TAGS, + ITEM_KEY_MEDIA_CHANNELS, ITEM_KEY_MEDIA_CODEC, ITEM_KEY_MEDIA_STREAMS, ITEM_KEY_MUSICBRAINZ_ALBUM, @@ -171,14 +172,25 @@ def parse_artist( return artist +def audio_format(track: JellyTrack) -> AudioFormat: + """Build an AudioFormat model from a Jellyfin track.""" + stream = track[ITEM_KEY_MEDIA_STREAMS][0] + codec = stream[ITEM_KEY_MEDIA_CODEC] + return AudioFormat( + content_type=(ContentType.try_parse(codec) if codec else ContentType.UNKNOWN), + channels=stream[ITEM_KEY_MEDIA_CHANNELS], + sample_rate=stream.get("SampleRate", 44100), + bit_rate=stream.get("BitRate"), + bit_depth=stream.get("BitDepth", 16), + ) + + 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, @@ -189,11 +201,7 @@ def parse_track( provider_domain=DOMAIN, provider_instance=instance_id, available=available, - audio_format=AudioFormat( - content_type=( - ContentType.try_parse(content) if content else ContentType.UNKNOWN - ), - ), + audio_format=audio_format(jellyfin_track), url=client.audio_url(jellyfin_track[ITEM_KEY_ID]), ) }, diff --git a/requirements_all.txt b/requirements_all.txt index 115c5812..3aefb689 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -4,7 +4,7 @@ Brotli>=1.0.9 aiodns>=3.0.0 aiofiles==24.1.0 aiohttp==3.11.6 -aiojellyfin==0.11.2 +aiojellyfin==0.14.1 aiorun==2024.8.1 aioslimproto==3.1.0 aiosonos==0.1.7 diff --git a/tests/providers/jellyfin/__snapshots__/test_parsers.ambr b/tests/providers/jellyfin/__snapshots__/test_parsers.ambr index 9d4901b7..e585da90 100644 --- a/tests/providers/jellyfin/__snapshots__/test_parsers.ambr +++ b/tests/providers/jellyfin/__snapshots__/test_parsers.ambr @@ -316,6 +316,97 @@ 'version': '', }) # --- +# name: test_parse_tracks[do_i_wanna_know] + dict({ + 'album': dict({ + 'available': True, + 'external_ids': list([ + ]), + 'image': None, + 'item_id': 'd42d74e134693184e7adc73106238e89', + 'media_type': 'album', + 'name': 'AM', + 'provider': 'xx-instance-id-xx', + 'sort_name': 'am', + 'uri': 'xx-instance-id-xx://album/d42d74e134693184e7adc73106238e89', + 'version': '', + }), + 'artists': list([ + dict({ + 'available': True, + 'external_ids': list([ + ]), + 'image': None, + 'item_id': 'cc940aeb8a99149f159fe9794f136071', + 'media_type': 'artist', + 'name': 'Arctic Monkeys', + 'provider': 'xx-instance-id-xx', + 'sort_name': 'arctic monkeys', + 'uri': 'xx-instance-id-xx://artist/cc940aeb8a99149f159fe9794f136071', + 'version': '', + }), + ]), + 'disc_number': 1, + 'duration': 272, + 'external_ids': list([ + ]), + 'favorite': False, + 'item_id': 'da9c458e425584680765ddc3a89cbc0c', + 'media_type': 'track', + 'metadata': dict({ + 'chapters': None, + 'copyright': None, + 'description': None, + 'explicit': None, + 'genres': None, + 'images': list([ + dict({ + 'path': 'http://localhost:1234/Items/da9c458e425584680765ddc3a89cbc0c/Images/Primary?api_key=ACCESS_TOKEN', + 'provider': 'xx-instance-id-xx', + 'remotely_accessible': False, + 'type': 'thumb', + }), + ]), + 'label': None, + 'languages': None, + 'last_refresh': None, + 'links': None, + 'lyrics': None, + 'mood': None, + 'performers': None, + 'popularity': None, + 'preview': None, + 'release_date': None, + 'review': None, + 'style': None, + }), + 'name': 'Do I Wanna Know?', + 'position': 1, + 'provider': 'xx-instance-id-xx', + 'provider_mappings': list([ + dict({ + 'audio_format': dict({ + 'bit_depth': 24, + 'bit_rate': 1546, + 'channels': 2, + 'content_type': 'flac', + 'output_format_str': 'flac', + 'sample_rate': 44100, + }), + 'available': True, + 'details': None, + 'item_id': 'da9c458e425584680765ddc3a89cbc0c', + 'provider_domain': 'jellyfin', + 'provider_instance': 'xx-instance-id-xx', + 'url': 'http://localhost:1234/Audio/da9c458e425584680765ddc3a89cbc0c/universal?userId=USER_ID&deviceId=X&maxStreamingBitrate=140000000&api_key=ACCESS_TOKEN', + }), + ]), + 'sort_name': 'do i wanna know?', + 'track_number': 1, + 'uri': 'xx-instance-id-xx://track/da9c458e425584680765ddc3a89cbc0c', + 'version': '', + }) +# --- # name: test_parse_tracks[thrown_away] dict({ 'album': dict({ @@ -381,7 +472,7 @@ dict({ 'audio_format': dict({ 'bit_depth': 16, - 'bit_rate': 0, + 'bit_rate': 156, 'channels': 2, 'content_type': 'mp3', 'output_format_str': 'mp3', @@ -392,7 +483,7 @@ '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', + 'url': 'http://localhost:1234/Audio/b5319fb11cde39fca2023184fcfa9862/universal?userId=USER_ID&deviceId=X&maxStreamingBitrate=140000000&api_key=ACCESS_TOKEN', }), ]), 'sort_name': '11 thrown away', @@ -460,7 +551,7 @@ dict({ 'audio_format': dict({ 'bit_depth': 16, - 'bit_rate': 0, + 'bit_rate': 278, 'channels': 2, 'content_type': 'aac', 'output_format_str': 'aac', @@ -471,7 +562,7 @@ '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', + '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)', @@ -568,7 +659,7 @@ dict({ 'audio_format': dict({ 'bit_depth': 16, - 'bit_rate': 0, + 'bit_rate': 267, 'channels': 2, 'content_type': 'aac', 'output_format_str': 'aac', @@ -579,7 +670,7 @@ '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', + 'url': 'http://localhost:1234/Audio/fb12a77f49616a9fc56a6fab2b94174c/universal?userId=USER_ID&deviceId=X&maxStreamingBitrate=140000000&api_key=ACCESS_TOKEN', }), ]), 'sort_name': 'zombie christmas', diff --git a/tests/providers/jellyfin/fixtures/tracks/do_i_wanna_know.json b/tests/providers/jellyfin/fixtures/tracks/do_i_wanna_know.json new file mode 100644 index 00000000..2466a96f --- /dev/null +++ b/tests/providers/jellyfin/fixtures/tracks/do_i_wanna_know.json @@ -0,0 +1,265 @@ +{ + "Name": "Do I Wanna Know?", + "ServerId": "a9038ca202a94606b3764740074fec3a", + "Id": "da9c458e425584680765ddc3a89cbc0c", + "Etag": "6a569c1c1d153acad95ffcaf531b6ccb", + "DateCreated": "2025-01-07T20:04:36.5322321Z", + "CanDelete": false, + "CanDownload": true, + "HasLyrics": true, + "Container": "flac", + "SortName": "0001 - 0001 - Do I Wanna Know?", + "PremiereDate": "2013-01-01T00:00:00.0000000Z", + "ExternalUrls": [], + "MediaSources": [ + { + "Protocol": "File", + "Id": "da9c458e425584680765ddc3a89cbc0c", + "Path": "/media/music/Arctic Monkeys/AM/01-01 Do I Wanna Know.flac", + "Type": "Default", + "Container": "flac", + "Size": 52672047, + "Name": "01-01 Do I Wanna Know", + "IsRemote": false, + "ETag": "09f1d9b8dcbcd5b61c65d4fdc2aadc32", + "RunTimeTicks": 2723941040, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "UseMostCompatibleTranscodingProfile": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "MediaStreams": [ + { + "Codec": "flac", + "TimeBase": "1/44100", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "FLAC - Stereo", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 1546936, + "BitDepth": 24, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "mjpeg", + "ColorSpace": "bt470bg", + "Comment": "Cover (front)", + "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, + "ReferenceFrameRate": 90000, + "Profile": "Baseline", + "Type": "EmbeddedImage", + "AspectRatio": "1:1", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuvj444p", + "Level": -99, + "IsAnamorphic": false + }, + { + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "IsInterlaced": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Lyric", + "Index": 2, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Path": "/var/lib/jellyfin/metadata/library/da/da9c458e425584680765ddc3a89cbc0c/01-01 Do I Wanna Know.lrc" + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 1546936, + "RequiredHttpHeaders": {}, + "TranscodingSubProtocol": "http", + "DefaultAudioStreamIndex": 0, + "HasSegments": false + } + ], + "Path": "/media/music/Arctic Monkeys/AM/01-01 Do I Wanna Know.flac", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": ["Alternative \u0026 Indie"], + "RunTimeTicks": 2723941040, + "PlayAccess": "Full", + "ProductionYear": 2013, + "IndexNumber": 1, + "ParentIndexNumber": 1, + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "d42d74e134693184e7adc73106238e89", + "Type": "Audio", + "People": [], + "Studios": [], + "GenreItems": [ + { + "Name": "Alternative \u0026 Indie", + "Id": "daf2f5737c11cea90c2ef7376801d804" + } + ], + "ParentLogoItemId": "cc940aeb8a99149f159fe9794f136071", + "ParentBackdropItemId": "cc940aeb8a99149f159fe9794f136071", + "ParentBackdropImageTags": ["c0bbf52814fd50ec5417617da2766e43"], + "LocalTrailerCount": 0, + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 4, + "IsFavorite": false, + "LastPlayedDate": "2025-01-18T12:09:39.3190466Z", + "Played": true, + "Key": "Arctic Monkeys-AM-0001-0001Do I Wanna Know?", + "ItemId": "00000000000000000000000000000000" + }, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "61bba315f137702baa296a1c417faada", + "Tags": [], + "PrimaryImageAspectRatio": 1, + "Artists": ["Arctic Monkeys"], + "ArtistItems": [ + { "Name": "Arctic Monkeys", "Id": "cc940aeb8a99149f159fe9794f136071" } + ], + "Album": "AM", + "AlbumId": "d42d74e134693184e7adc73106238e89", + "AlbumPrimaryImageTag": "0e9e1da3e4efc8f37917cc6a4d8f626d", + "AlbumArtist": "Arctic Monkeys", + "AlbumArtists": [ + { "Name": "Arctic Monkeys", "Id": "cc940aeb8a99149f159fe9794f136071" } + ], + "MediaStreams": [ + { + "Codec": "flac", + "TimeBase": "1/44100", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "FLAC - Stereo", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 1546936, + "BitDepth": 24, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "mjpeg", + "ColorSpace": "bt470bg", + "Comment": "Cover (front)", + "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, + "ReferenceFrameRate": 90000, + "Profile": "Baseline", + "Type": "EmbeddedImage", + "AspectRatio": "1:1", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuvj444p", + "Level": -99, + "IsAnamorphic": false + }, + { + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "IsInterlaced": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Lyric", + "Index": 2, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Path": "/var/lib/jellyfin/metadata/library/da/da9c458e425584680765ddc3a89cbc0c/01-01 Do I Wanna Know.lrc" + } + ], + "ImageTags": { "Primary": "7225206d6903fc4ab214375fd25d9308" }, + "BackdropImageTags": [], + "ParentLogoImageTag": "3203dbc243aec2cc2e17d054a13a51cf", + "ImageBlurHashes": { + "Primary": { + "7225206d6903fc4ab214375fd25d9308": "eG7_4nofayofIUozfQj[j[WB00WBj[WB%MM{ayWBayt7_3j[WBofRj", + "0e9e1da3e4efc8f37917cc6a4d8f626d": "eG7_4nt7kCt7D%xuj[ofofWB00WBf6Rj%MD%WVRjWBt7?bofWBofM{" + }, + "Logo": { + "3203dbc243aec2cc2e17d054a13a51cf": "OlJ8V0M{00ofD%xuRjIUM{j[xuoft7t7D%ayxut7t7RjWB" + }, + "Backdrop": { + "c0bbf52814fd50ec5417617da2766e43": "WYEVg7?Hoeo}X9%L~qxuV@s:W=oz%gozRPjFWBWB%3xaR*S2R*ae" + } + }, + "LocationType": "FileSystem", + "MediaType": "Audio", + "LockedFields": [], + "LockData": false, + "NormalizationGain": -8.4 +}