Improve search inside Squeezebox browse results (#183394)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paulus Schoutsen
2026-09-28 10:51:17 +02:00
committed by GitHub
co-authored by Claude
parent d5e4532b30
commit 03e5fcd6ce
3 changed files with 140 additions and 14 deletions
@@ -46,6 +46,7 @@ MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = {
"genres": "genres",
"new music": "new music",
"album artists": "album artists",
"artist tracks": "titles",
MediaType.ALBUM: "album",
MediaType.ARTIST: "artist",
MediaType.TRACK: "title",
@@ -61,6 +62,7 @@ SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = {
"albums": "album_id",
MediaType.ARTIST: "artist_id",
"artists": "artist_id",
"artist tracks": "artist_id",
MediaType.TRACK: "track_id",
"tracks": "track_id",
MediaType.PLAYLIST: "playlist_id",
@@ -87,6 +89,7 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | str]] = {
"genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE},
"new music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
"album artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST},
"artist tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
MediaType.ALBUM: {"item": MediaClass.ALBUM, "children": MediaClass.TRACK},
MediaType.ARTIST: {"item": MediaClass.ARTIST, "children": MediaClass.ALBUM},
MediaType.TRACK: {"item": MediaClass.TRACK, "children": ""},
@@ -101,7 +104,7 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[
str | MediaType | None,
] = {
MediaType.ALBUM: MediaType.TRACK,
MediaType.PLAYLIST: MediaType.PLAYLIST,
MediaType.PLAYLIST: MediaType.TRACK,
MediaType.ARTIST: MediaType.ALBUM,
MediaType.GENRE: MediaType.ARTIST,
"artists": MediaType.ARTIST,
@@ -113,12 +116,20 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[
"radios": MediaClass.APP,
"new music": MediaType.ALBUM,
"album artists": MediaType.ARTIST,
"artist tracks": MediaType.TRACK,
MediaType.APPS: MediaType.APP,
MediaType.APP: MediaType.TRACK,
"favorite": None,
"track": MediaType.TRACK,
}
# LMS ignores the search query when it lists the tracks of a playlist
SEARCHABLE_TYPES: set[str | MediaType] = {
MediaType.ALBUM,
MediaType.ARTIST,
MediaType.GENRE,
}
@dataclass
class BrowseData:
@@ -358,6 +369,7 @@ async def build_item_response(
media_class=CONTENT_TYPE_MEDIA_CLASS[item_type]["item"],
can_expand=bool(CONTENT_TYPE_MEDIA_CLASS[item_type]["children"]),
can_play=True,
can_search=item_type in SEARCHABLE_TYPES,
)
assert child_media.media_class is not None
@@ -397,6 +409,7 @@ async def build_item_response(
can_play=any(child.can_play for child in children),
children=children,
can_expand=True,
can_search=search_type in SEARCHABLE_TYPES,
)
@@ -665,7 +665,7 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity):
_valid_type_list = [
key
for key in self._browse_data.content_type_media_class
if key not in ["apps", "app", "radios", "radio"]
if key not in ["apps", "app", "radios", "radio", "artist tracks"]
]
_media_content_type_list = (
@@ -687,6 +687,10 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity):
},
)
if query.media_content_id and MediaType.ARTIST in _media_content_type_list:
# LMS matches the albums of an artist by album title only
_media_content_type_list.append("artist tracks")
search_response_list: list[BrowseMedia] = []
for _content_type in _media_content_type_list:
+121 -12
View File
@@ -1,6 +1,7 @@
"""Test the media browser interface."""
from unittest.mock import MagicMock, patch
from typing import Any
from unittest.mock import ANY, MagicMock, call, patch
import pytest
@@ -70,17 +71,17 @@ async def test_async_browse_media_root(
@pytest.mark.parametrize(
("category", "child_count"),
("category", "child_count", "can_search"),
[
("favorites", 4),
("artists", 4),
("albums", 4),
("playlists", 4),
("genres", 4),
("new music", 4),
("album artists", 4),
("apps", 3),
("radios", 3),
("favorites", 4, False),
("artists", 4, True),
("albums", 4, True),
("playlists", 4, False),
("genres", 4, True),
("new music", 4, True),
("album artists", 4, True),
("apps", 3, False),
("radios", 3, False),
],
)
async def test_async_browse_media_with_subitems(
@@ -89,6 +90,7 @@ async def test_async_browse_media_with_subitems(
hass_ws_client: WebSocketGenerator,
category: str,
child_count: int,
can_search: bool,
) -> None:
"""Test each category with subitems."""
with patch(
@@ -110,6 +112,7 @@ async def test_async_browse_media_with_subitems(
category_level = response["result"]
assert category_level["title"] == MEDIA_TYPE_TO_SQUEEZEBOX[category]
assert category_level["children"][0]["title"] == "Fake Item 1"
assert category_level["children"][0]["can_search"] is can_search
assert len(category_level["children"]) == child_count
# Look up a subitem
@@ -128,6 +131,49 @@ async def test_async_browse_media_with_subitems(
assert response["success"]
search = response["result"]
assert search["title"] == "Fake Item 1"
assert search["can_search"] is can_search
async def test_async_browse_playlist(
hass: HomeAssistant,
lms: MagicMock,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test the children of a playlist are tracks that can be played."""
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "media_player/browse_media",
"entity_id": "media_player.test_player",
"media_content_id": FAKE_VALID_ITEM_ID,
"media_content_type": MediaType.PLAYLIST,
}
)
response = await client.receive_json()
assert response["success"]
child = response["result"]["children"][0]
assert child["media_class"] == MediaClass.TRACK
assert child["media_content_type"] == MediaType.TRACK
assert child["media_content_id"] == FAKE_VALID_ITEM_ID
assert not child["can_expand"]
assert child["can_play"]
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: "media_player.test_player",
ATTR_MEDIA_CONTENT_TYPE: child["media_content_type"],
ATTR_MEDIA_CONTENT_ID: child["media_content_id"],
},
blocking=True,
)
player = (await lms.async_get_players())[0]
player.async_browse.assert_called_with(
"titles", limit=ANY, browse_id=("track_id", FAKE_VALID_ITEM_ID)
)
player.async_load_playlist.assert_called_once()
async def test_async_browse_media_for_apps(
@@ -214,6 +260,60 @@ async def test_async_search_media(
assert category_level[0]["title"] == "Fake Item 1"
async def test_async_search_media_in_artist(
hass: HomeAssistant,
lms: MagicMock,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test a search inside an artist returns matching albums and tracks."""
async def mock_browse(
category: str,
limit: int,
browse_id: tuple[str, str] | None = None,
search_query: str | None = None,
) -> dict[str, Any]:
items = {
"artist": [{"id": "1", "title": "Love Album"}],
"titles": [{"id": "2", "title": "Love Song"}],
}
return {"title": category, "items": items[category]}
player = (await lms.async_get_players())[0]
player.async_browse.side_effect = mock_browse
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "media_player/search_media",
"entity_id": "media_player.test_player",
"media_content_id": "42",
"media_content_type": MediaType.ARTIST,
"search_query": "love",
}
)
response = await client.receive_json()
assert response["success"]
assert [
(
item["title"],
item["media_class"],
item["media_content_type"],
item["media_content_id"],
item["can_search"],
)
for item in response["result"]["result"]
] == [
("Love Album", MediaClass.ALBUM, MediaType.ALBUM, "1", True),
("Love Song", MediaClass.TRACK, MediaType.TRACK, "2", False),
]
assert player.async_browse.call_args_list == [
call("artist", limit=ANY, browse_id=("artist_id", "42"), search_query="love"),
call("titles", limit=ANY, browse_id=("artist_id", "42"), search_query="love"),
]
async def test_async_search_media_invalid_filter(
hass: HomeAssistant,
config_entry: MockConfigEntry,
@@ -241,10 +341,18 @@ async def test_async_search_media_invalid_filter(
assert len(response["result"]["result"]) == 0
@pytest.mark.parametrize(
"media_content_type",
[
pytest.param("Fake Type", id="unknown"),
pytest.param("artist tracks", id="internal"),
],
)
async def test_async_search_media_invalid_type(
hass: HomeAssistant,
config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
media_content_type: str,
) -> None:
"""Test search_media action with invalid media_content_type."""
with patch(
@@ -258,7 +366,7 @@ async def test_async_search_media_invalid_type(
"type": "media_player/search_media",
"entity_id": "media_player.test_player",
"media_content_id": "",
"media_content_type": "Fake Type",
"media_content_type": media_content_type,
"search_query": "Fake Item 1",
},
)
@@ -266,6 +374,7 @@ async def test_async_search_media_invalid_type(
assert not response["success"]
err_message = "If specified, Media content type must be one of"
assert err_message in response["error"]["message"]
assert "artist tracks" not in response["error"]["message"]
async def test_async_search_media_not_found(