Return matching folders from local media search (#183388)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paulus Schoutsen
2026-09-28 11:12:39 +02:00
committed by GitHub
co-authored by Claude
parent dd0881aba1
commit e48acea9d3
2 changed files with 95 additions and 8 deletions
@@ -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:
@@ -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")