From ae965b48b6baa9dbb98eed14f865febcc205e394 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 27 Sep 2026 13:47:22 +0100 Subject: [PATCH] Map Jellyfin search filter classes to item types (#183252) Co-authored-by: Claude --- .../components/jellyfin/browse_media.py | 14 ++++- homeassistant/components/jellyfin/const.py | 11 ++++ .../components/jellyfin/test_media_player.py | 56 +++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/jellyfin/browse_media.py b/homeassistant/components/jellyfin/browse_media.py index 66e709851e57..36269cdf432b 100644 --- a/homeassistant/components/jellyfin/browse_media.py +++ b/homeassistant/components/jellyfin/browse_media.py @@ -20,6 +20,7 @@ from .const import ( CONTENT_TYPE_MAP, MEDIA_CLASS_MAP, MEDIA_TYPE_NONE, + SEARCH_ITEM_TYPE_MAP, SUPPORTED_COLLECTION_TYPES, ) @@ -165,9 +166,18 @@ async def search_items( items: list[dict[str, Any]] = [] # Search for items based on media filter classes (or all if none specified) - media_types: list[MediaClass] | list[None] = [] + media_types: list[str] | list[None] = [] if query.media_filter_classes: - media_types = query.media_filter_classes + # Jellyfin ignores unknown item types and returns unfiltered results, + # so skip classes that have no Jellyfin item type. Classes can share an + # item type, so search each item type once. + media_types = list( + dict.fromkeys( + ",".join(SEARCH_ITEM_TYPE_MAP[media_class]) + for media_class in query.media_filter_classes + if media_class in SEARCH_ITEM_TYPE_MAP + ) + ) else: media_types = [None] diff --git a/homeassistant/components/jellyfin/const.py b/homeassistant/components/jellyfin/const.py index 2e2dc37b601a..cba5ebf59e1b 100644 --- a/homeassistant/components/jellyfin/const.py +++ b/homeassistant/components/jellyfin/const.py @@ -82,7 +82,18 @@ MEDIA_CLASS_MAP = { "BoxSet": MediaClass.DIRECTORY, "Episode": MediaClass.EPISODE, "Season": MediaClass.SEASON, + "Playlist": MediaClass.PLAYLIST, + "Video": MediaClass.VIDEO, } +# The track class has no item type of its own, so it searches Audio items too. +SEARCH_ITEM_TYPE_MAP: dict[MediaClass, list[str]] = { + media_class: [ + item_type + for item_type, item_class in MEDIA_CLASS_MAP.items() + if item_class == media_class + ] + for media_class in set(MEDIA_CLASS_MAP.values()) +} | {MediaClass.TRACK: [ITEM_TYPE_AUDIO]} PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE, Platform.SENSOR] LOGGER = logging.getLogger(__package__) diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index d013abaec710..a452fb54b7ea 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -3,6 +3,8 @@ from datetime import timedelta from unittest.mock import MagicMock +import pytest + from homeassistant.components.jellyfin.const import DOMAIN from homeassistant.components.media_player import ( ATTR_MEDIA_ALBUM_ARTIST, @@ -531,6 +533,60 @@ async def test_search_media( ] +@pytest.mark.parametrize( + ("media_filter_classes", "expected_item_types"), + [ + pytest.param([], [None], id="no_filter"), + pytest.param(["album"], ["MusicAlbum"], id="album"), + pytest.param(["artist"], ["MusicArtist"], id="artist"), + pytest.param(["track"], ["Audio"], id="track"), + pytest.param(["movie"], ["Movie"], id="movie"), + pytest.param(["playlist"], ["Playlist"], id="playlist"), + pytest.param(["video"], ["Video"], id="video"), + pytest.param( + ["directory"], + ["CollectionFolder,AggregateFolder,Folder,BoxSet"], + id="multiple_item_types", + ), + pytest.param( + ["album", "tv_show"], ["MusicAlbum", "Series"], id="multiple_classes" + ), + pytest.param(["music", "track"], ["Audio"], id="shared_item_type"), + pytest.param(["podcast"], [], id="unmapped_class"), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_search_media_item_types( + hass_ws_client: WebSocketGenerator, + mock_api: MagicMock, + media_filter_classes: list[str], + expected_item_types: list[str | None], +) -> None: + """Test Jellyfin search maps media filter classes to Jellyfin item types.""" + client = await hass_ws_client() + + await client.send_json_auto_id( + { + "type": "media_player/search_media", + "entity_id": "media_player.jellyfin_device", + "media_content_id": "", + "media_content_type": "", + "search_query": "Fake Item 1", + "media_filter_classes": media_filter_classes, + } + ) + response = await client.receive_json() + assert response["success"] + # The filter classes arrive as a set, so the search order is not fixed. + assert sorted( + ( + search_call.kwargs["media"] + for search_call in mock_api.search_media_items.call_args_list + ), + key=str, + ) == sorted(expected_item_types, key=str) + + async def test_new_client_connected( hass: HomeAssistant, init_integration: MockConfigEntry,