mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 14:29:21 -04:00
Return matching folders from local media search (#183388)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
dd0881aba1
commit
e48acea9d3
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user