fix(jellyfin): Add defensive checks for missing audio metadata (#2728)
authorericmammolenti <150827192+ericmammolenti@users.noreply.github.com>
Tue, 9 Dec 2025 15:28:10 +0000 (10:28 -0500)
committerGitHub <noreply@github.com>
Tue, 9 Dec 2025 15:28:10 +0000 (16:28 +0100)
* 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 <marvinschenkel@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
music_assistant/providers/jellyfin/parsers.py
tests/providers/jellyfin/test_parsers.py

index a657997a66bd552f0a18f833766afa278d3b3254..bac9aaf8c173309596fe3070fdade7a5c3e12602 100644 (file)
@@ -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),
index dc29cc5ecec4a6574dab5dbbbd32d854de757b63..8c686577e581442bbe889ef6d450cb2304d71778 100644 (file)
@@ -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