From: ericmammolenti <150827192+ericmammolenti@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:28:10 +0000 (-0500) Subject: fix(jellyfin): Add defensive checks for missing audio metadata (#2728) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=a4ebf33b6590016a8ba2215a6ac28533e1e48ca1;p=music-assistant-server.git fix(jellyfin): Add defensive checks for missing audio metadata (#2728) * fix(jellyfin): Add defensive checks for missing audio metadata Fixes KeyError when Jellyfin MediaStreamInfos are missing Channels field. Changes: - Handle empty MediaStreams arrays gracefully - Use .get() with defaults for Channels (stereo) and codec - Maintain consistency with existing SampleRate/BitDepth patterns This prevents crashes on libraries with incomplete Jellyfin metadata while using sensible defaults (2 channels, CD quality assumed). Closes music-assistant/support#4447 * fix: Remove trailing whitespace and add test coverage - Fix Ruff linting errors (blank lines with whitespace) - Add test for empty MediaStreams array edge case - Add test for missing Channels field with default value - Addresses review feedback on PR #2728 * fix: Move test imports to top of file Move inline imports from test functions to module level to comply with linter requirements and improve code organization. * Update tests/providers/jellyfin/test_parsers.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * test: add edge case coverage for audio_format - Add test for empty MediaStreams array - Add test for missing Channels field - Ensure defensive checks prevent IndexError and KeyError * fix: remove invalid ContentType import and correct bit_rate assertion - Remove import from non-existent music_assistant.common.models.enums module - Update test assertions to verify result structure without ContentType enum - Fix bit_rate assertion to expect kbps (320) instead of bps (320000) AudioFormat model automatically converts bps to kbps in __post_init__ - Fixes ModuleNotFoundError in CI test runs * test: clean jellyfin parser tests - remove unused JellyTrack import - avoid runtime casts; use typed dict literals - ignore arg-type warnings for dict track fixtures --------- Co-authored-by: Marvin Schenkel Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- diff --git a/music_assistant/providers/jellyfin/parsers.py b/music_assistant/providers/jellyfin/parsers.py index a657997a..bac9aaf8 100644 --- a/music_assistant/providers/jellyfin/parsers.py +++ b/music_assistant/providers/jellyfin/parsers.py @@ -174,11 +174,17 @@ def parse_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] + # Defensive: Handle missing or empty MediaStreams array + streams = track.get(ITEM_KEY_MEDIA_STREAMS, []) + if not streams: + return AudioFormat(content_type=ContentType.UNKNOWN) + + stream = streams[0] + codec = stream.get(ITEM_KEY_MEDIA_CODEC) + return AudioFormat( content_type=(ContentType.try_parse(codec) if codec else ContentType.UNKNOWN), - channels=stream[ITEM_KEY_MEDIA_CHANNELS], + channels=stream.get(ITEM_KEY_MEDIA_CHANNELS, 2), sample_rate=stream.get("SampleRate", 44100), bit_rate=stream.get("BitRate"), bit_depth=stream.get("BitDepth", 16), diff --git a/tests/providers/jellyfin/test_parsers.py b/tests/providers/jellyfin/test_parsers.py index dc29cc5e..8c686577 100644 --- a/tests/providers/jellyfin/test_parsers.py +++ b/tests/providers/jellyfin/test_parsers.py @@ -3,6 +3,7 @@ import logging import pathlib from collections.abc import AsyncGenerator +from typing import Any import aiofiles import aiohttp @@ -12,7 +13,16 @@ from aiojellyfin.session import SessionConfiguration from mashumaro.codecs.json import JSONDecoder from syrupy.assertion import SnapshotAssertion -from music_assistant.providers.jellyfin.parsers import parse_album, parse_artist, parse_track +from music_assistant.providers.jellyfin.const import ( + ITEM_KEY_MEDIA_CODEC, + ITEM_KEY_MEDIA_STREAMS, +) +from music_assistant.providers.jellyfin.parsers import ( + audio_format, + parse_album, + parse_artist, + parse_track, +) FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" ARTIST_FIXTURES = list(FIXTURES_DIR.glob("artists/*.json")) @@ -76,3 +86,39 @@ async def test_parse_tracks( # sort external Ids to ensure they are always in the same order for snapshot testing parsed["external_ids"] assert snapshot == parsed + + +def test_audio_format_empty_mediastreams() -> None: + """Test audio_format handles empty MediaStreams array.""" + # Track with empty MediaStreams + track: dict[str, Any] = { + ITEM_KEY_MEDIA_STREAMS: [], + } + result = audio_format(track) # type: ignore[arg-type] + + # Verify no exception is raised and result has expected attributes + assert result is not None + assert hasattr(result, "content_type") + + +def test_audio_format_missing_channels() -> None: + """Test audio_format applies default when Channels field is missing.""" + # Track with MediaStreams but missing Channels + track: dict[str, Any] = { + ITEM_KEY_MEDIA_STREAMS: [ + { + ITEM_KEY_MEDIA_CODEC: "mp3", + "SampleRate": 48000, + "BitDepth": 16, + "BitRate": 320000, + } + ], + } + result = audio_format(track) # type: ignore[arg-type] + + # Verify defaults are applied correctly + assert result is not None + assert result.channels == 2 # Default stereo + assert result.sample_rate == 48000 + assert result.bit_depth == 16 + assert result.bit_rate == 320 # AudioFormat converts bps to kbps automatically