From e48acea9d3962db8a5b30b1ed34944fe20c39e8f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 28 Sep 2026 05:12:39 -0400 Subject: [PATCH] Return matching folders from local media search (#183388) Co-authored-by: Claude --- .../components/media_source/local_source.py | 24 +++++- .../media_source/test_local_source.py | 79 +++++++++++++++++-- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 104ee1eb59ac..c64c7f2914ed 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -192,7 +192,7 @@ class LocalSource(MediaSource): async def async_search_media( self, item: MediaSourceItem, query: SearchMediaQuery ) -> SearchMedia: - """Search media by file name within the local media directories.""" + """Search media by file or folder name within the local media directories.""" if item.identifier: try: source_dir_id, location = self.async_parse_identifier(item) @@ -209,7 +209,7 @@ class LocalSource(MediaSource): def _search_media( self, search_dirs: list[tuple[str, str]], query: SearchMediaQuery ) -> SearchMedia: - """Search media files by name (runs in the executor).""" + """Search media files and folders by name (runs in the executor).""" query_str = query.search_query.casefold() filter_classes = set(query.media_filter_classes or ()) results: list[BrowseMedia] = [] @@ -229,7 +229,25 @@ class LocalSource(MediaSource): relative = path.relative_to(base_path) if any(part.startswith(".") for part in relative.parts): continue - if query_str not in path.name.casefold() or not path.is_file(): + if query_str not in path.name.casefold(): + continue + if path.is_dir(): + # A media class filter asks for media, which a folder is not + if not filter_classes: + results.append( + BrowseMediaSource( + domain=self.domain, + identifier=f"{source_dir_id}/{relative}", + media_class=MediaClass.DIRECTORY, + media_content_type="", + title=path.name, + can_play=False, + can_expand=True, + can_search=True, + ) + ) + continue + if not path.is_file(): continue mime_type, _ = mimetypes.guess_type(str(path)) if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES: diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index bd228c1e5903..2683ea5d4860 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -1,6 +1,6 @@ """Test Local Media Source.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable from http import HTTPStatus import io from pathlib import Path @@ -160,13 +160,23 @@ async def test_async_search_media(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize( + ("suffix", "create"), + [ + pytest.param(".mp3", Path.touch, id="files"), + pytest.param("", Path.mkdir, id="folders"), + ], +) async def test_async_search_media_limit_and_hidden( - hass: HomeAssistant, tmp_path: Path + hass: HomeAssistant, + tmp_path: Path, + suffix: str, + create: Callable[[Path], None], ) -> None: - """Test that search caps results and skips hidden files.""" + """Test that search caps results and skips hidden entries.""" for i in range(MAX_SEARCH_RESULTS + 20): - (tmp_path / f"song_{i}.mp3").touch() - (tmp_path / ".hidden_song.mp3").touch() + create(tmp_path / f"song_{i}{suffix}") + create(tmp_path / f".hidden_song{suffix}") await async_process_ha_core_config( hass, {"media_dirs": {"local": str(tmp_path), "recordings": str(tmp_path)}} @@ -186,6 +196,65 @@ async def test_async_search_media_limit_and_hidden( assert all(not item.title.startswith(".") for item in result.result) +FOLDER_RESULT = ("Albums/Best Of", MediaClass.DIRECTORY, "", False, True, True) +FILE_RESULT = ( + "Albums/Best Of Live.mp3", + MediaClass.MUSIC, + "audio/mpeg", + True, + False, + False, +) + + +@pytest.mark.parametrize( + ("media_filter_classes", "expected"), + [ + pytest.param(None, [FOLDER_RESULT, FILE_RESULT], id="no_filter"), + pytest.param([MediaClass.MUSIC], [FILE_RESULT], id="music_filter"), + ], +) +async def test_async_search_media_folders( + hass: HomeAssistant, + tmp_path: Path, + media_filter_classes: list[MediaClass] | None, + expected: list[tuple[str, MediaClass, str, bool, bool, bool]], +) -> None: + """Test that search returns matching folders unless a media class filter is set.""" + (tmp_path / "Albums" / "Best Of").mkdir(parents=True) + (tmp_path / "Albums" / "Best Of" / "track.mp3").touch() + (tmp_path / "Albums" / "Best Of Live.mp3").touch() + (tmp_path / ".hidden" / "Best Of").mkdir(parents=True) + + await async_process_ha_core_config(hass, {"media_dirs": {"local": str(tmp_path)}}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, const.DOMAIN, {}) + await hass.async_block_till_done() + + result = await media_source.async_search_media( + hass, + f"{const.URI_SCHEME}{const.DOMAIN}/local", + SearchMediaQuery( + search_query="best of", media_filter_classes=media_filter_classes + ), + ) + assert [ + ( + item.media_content_id, + item.media_class, + item.media_content_type, + item.can_play, + item.can_expand, + item.can_search, + ) + for item in result.result + ] == [ + (f"{const.URI_SCHEME}{const.DOMAIN}/local/{location}", *flags) + for location, *flags in expected + ] + + async def test_browse_media_can_search(hass: HomeAssistant) -> None: """Test that browsable directories advertise search support.""" local_media = hass.config.path("media")