diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index c2f1378f9d..88be6db168 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +from functools import partial import hashlib import logging from pathlib import Path @@ -199,7 +200,7 @@ def _validate_supported_local_file(config: list[ConfigType]) -> list[ConfigType] CONFIG_SCHEMA = cv.All( cv.only_on_esp32, cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), - lambda c: download_web_files_in_config(c, _compute_local_file_path), + partial(download_web_files_in_config, path_for=_compute_local_file_path), _validate_supported_local_file, ) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index bbb51c03de..fbc83ef12f 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -1,5 +1,6 @@ """Speaker Media Player Setup.""" +from functools import partial import hashlib import logging from pathlib import Path @@ -277,7 +278,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), cv.Optional(CONF_FILES): cv.All( cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), - lambda c: download_web_files_in_config(c, _compute_local_file_path), + partial( + download_web_files_in_config, path_for=_compute_local_file_path + ), ), cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All( cv.boolean, cv.requires_component(psram.DOMAIN) diff --git a/esphome/external_files.py b/esphome/external_files.py index 3fb73b6b03..fbd0ff688d 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -216,13 +216,12 @@ def download_content_many( Items are de-duplicated by `path` -- two callers asking for the same cache file (e.g. the same URL referenced twice in a config) would - otherwise race on `download_content`'s non-atomic write. + otherwise race on `download_content`'s non-atomic write. When the + same `path` appears more than once, the last URL wins (standard dict + comprehension semantics); in practice duplicate paths only arise when + the URL is duplicated, so the choice doesn't matter. """ - seen: dict[Path, str] = {} - for url, path in items: - if path in seen: - continue - seen[path] = url + seen: dict[Path, str] = {path: url for url, path in items} if not seen: return if len(seen) == 1: diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index a5ba4e4009..69c3a15a0a 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -567,7 +567,9 @@ def test_download_content_many_dedupes_by_path( mock_download_content: MagicMock, setup_core: Path ) -> None: """Two items pointing at the same cache path must collapse to one - download -- otherwise concurrent writes race on the same file. + download -- otherwise concurrent writes race on the same file. Which + URL wins doesn't matter (in practice duplicate paths only arise when + the URL is duplicated), so we only assert the call count and path. """ path = setup_core / "shared" items = [ @@ -577,9 +579,7 @@ def test_download_content_many_dedupes_by_path( ] external_files.download_content_many(items) assert mock_download_content.call_count == 1 - # First-seen URL wins, matching dict-insertion order. args, _ = mock_download_content.call_args - assert args[0] == "https://example.com/a" assert args[1] == path