/
/
/
1"""Tests for the imageproxy id system on the MetaDataController."""
2
3import asyncio
4import hashlib
5from collections.abc import Iterator
6from pathlib import Path
7from typing import Any
8from unittest.mock import AsyncMock, MagicMock
9
10import pytest
11from aiohttp import web
12from music_assistant_models.enums import ImageType
13from music_assistant_models.errors import MediaNotFoundError, ProviderUnavailableError
14from music_assistant_models.media_items import (
15 MediaItemImage,
16 MediaItemMetadata,
17 ProviderMapping,
18 Track,
19)
20from music_assistant_models.media_items.metadata import IMAGE_PROXY_ID_RESOLVER
21from music_assistant_models.unique_list import UniqueList
22from PIL import Image
23
24from music_assistant.controllers.metadata import MetaDataController
25from music_assistant.controllers.metadata.constants import (
26 _IMAGE_ID_CACHE_TTL,
27 _IMAGEPROXY_CONTENT_TYPES,
28 CACHE_CATEGORY_IMAGE_IDS,
29)
30from music_assistant.controllers.metadata.helpers import (
31 _normalize_imageproxy_format,
32)
33from music_assistant.helpers import images as images_helper
34from music_assistant.helpers.colors import get_palette
35from music_assistant.helpers.images import (
36 _THUMB_CACHE_VERSION,
37 _THUMB_FILENAME_RE,
38 _extract_imageproxy_id,
39 _has_alpha,
40 _thumb_cache_filename,
41 create_thumb_hash,
42 detect_image_content_format,
43 get_image_thumb,
44 is_svg_data,
45 player_image_url,
46)
47from music_assistant.providers.filesystem_local import LocalFileSystemProvider
48
49
50@pytest.fixture
51async def metadata_controller(
52 cache_database: None, # noqa: ARG001
53 metadata_controller: MetaDataController,
54) -> MetaDataController:
55 """
56 Construct a MetaDataController backed by the cache database.
57
58 The controller tests in this module all persist and resolve image ids, unlike the
59 pure helper tests here, which need neither a controller nor a cache database.
60 """
61 return metadata_controller
62
63
64@pytest.fixture(autouse=True)
65def _reset_image_caches() -> Iterator[None]:
66 """Isolate the module-level image caches between tests."""
67 images_helper._thumb_memory_cache.clear()
68 images_helper._source_memory_cache.clear()
69 images_helper._failed_sources.clear()
70 yield
71 images_helper._thumb_memory_cache.clear()
72 images_helper._source_memory_cache.clear()
73 images_helper._failed_sources.clear()
74
75
76def _fake_image_provider(instance_id: str, resolved_path: str) -> LocalFileSystemProvider:
77 """
78 Build a bare filesystem provider that resolves any image path to `resolved_path`.
79
80 A real provider instance is used rather than a mock because the image helpers narrow
81 on the concrete provider types before calling `resolve_image`.
82
83 :param instance_id: Instance id to register the provider under.
84 :param resolved_path: Absolute path every image path resolves to.
85 """
86 provider = LocalFileSystemProvider.__new__(LocalFileSystemProvider)
87 provider.config = MagicMock(instance_id=instance_id)
88 provider.manifest = MagicMock(domain="filesystem_local")
89 provider.logger = MagicMock()
90 provider.available = True
91 provider.resolve_image = AsyncMock(return_value=resolved_path) # type: ignore[method-assign]
92 return provider
93
94
95async def _wait_for_persisted_image_id(
96 controller: MetaDataController, image_id: str, timeout: float = 2.0
97) -> dict[str, str]:
98 """
99 Wait until `_persist_image_id` has written its row, or fail the test.
100
101 `_persist_image_id` runs on the loop but does real work (json dump on a
102 thread + sqlite write), so a `sleep(0)` busy-loop can race on slow CI.
103 """
104 loop = asyncio.get_running_loop()
105 deadline = loop.time() + timeout
106 while loop.time() < deadline:
107 raw = await controller.cache.get(
108 key=image_id,
109 category=CACHE_CATEGORY_IMAGE_IDS,
110 provider=controller.domain,
111 )
112 if raw is not None:
113 assert isinstance(raw, dict)
114 return raw
115 await asyncio.sleep(0.01)
116 raise AssertionError(f"image_id {image_id!r} not persisted within {timeout}s")
117
118
119async def test_compute_image_id_is_deterministic(metadata_controller: MetaDataController) -> None:
120 """The same (provider, path) must always produce the same hex id."""
121 image_id_a = metadata_controller.compute_image_id("filesystem", "/local/cover.jpg")
122 image_id_b = metadata_controller.compute_image_id("filesystem", "/local/cover.jpg")
123 assert image_id_a == image_id_b
124 expected = hashlib.sha256(b"filesystem//local/cover.jpg").hexdigest()
125 assert image_id_a == expected
126
127
128async def test_image_id_matches_thumb_and_palette_cache_key(
129 metadata_controller: MetaDataController,
130) -> None:
131 """
132 image_id must equal create_thumb_hash(provider, path).
133
134 The thumbnail and color-palette caches both key off
135 `create_thumb_hash(provider, path)`. If `compute_image_id` ever diverged
136 from that, palette lookups for /imageproxy/<id> URLs would miss and the
137 on-disk thumbnail cache would split into two buckets.
138 """
139 image_id = metadata_controller.compute_image_id("filesystem", "/local/cover.jpg")
140 assert image_id == create_thumb_hash("filesystem", "/local/cover.jpg")
141
142
143async def test_compute_image_id_differs_per_input(metadata_controller: MetaDataController) -> None:
144 """Different (provider, path) pairs must produce different ids."""
145 assert metadata_controller.compute_image_id("x", "/a") != metadata_controller.compute_image_id(
146 "x", "/b"
147 )
148 assert metadata_controller.compute_image_id("x", "/a") != metadata_controller.compute_image_id(
149 "y", "/a"
150 )
151
152
153async def test_resolve_image_id_via_in_memory_lru(
154 metadata_controller: MetaDataController,
155) -> None:
156 """A freshly computed id resolves from the in-memory LRU without hitting DB."""
157 image_id = metadata_controller.compute_image_id("filesystem", "/local/cover.jpg")
158 resolved = await metadata_controller.resolve_image_id(image_id)
159 assert resolved == ("filesystem", "/local/cover.jpg")
160
161
162async def test_resolve_image_id_via_cache_db(metadata_controller: MetaDataController) -> None:
163 """After the async persist runs, the mapping resolves from cache even with empty LRU."""
164 image_id = metadata_controller.compute_image_id("filesystem", "/local/cover.jpg")
165 raw = await _wait_for_persisted_image_id(metadata_controller, image_id)
166 assert raw == {"provider": "filesystem", "path": "/local/cover.jpg"}
167 # wipe the in-memory layer so we hit the cache db
168 metadata_controller._image_id_lru.clear()
169 resolved = await metadata_controller.resolve_image_id(image_id)
170 assert resolved == ("filesystem", "/local/cover.jpg")
171
172
173async def test_resolve_image_id_returns_none_for_unknown(
174 metadata_controller: MetaDataController,
175) -> None:
176 """An id that was never registered must resolve to None (â 404)."""
177 resolved = await metadata_controller.resolve_image_id("0" * 64)
178 assert resolved is None
179
180
181async def test_image_id_persists_with_persistent_flag(
182 metadata_controller: MetaDataController,
183) -> None:
184 """Mappings must survive a `clear()` without include_persistent."""
185 image_id = metadata_controller.compute_image_id("filesystem", "/persistent.jpg")
186 await _wait_for_persisted_image_id(metadata_controller, image_id)
187 # simulate the user-facing "Reset cache" action: clear() without include_persistent
188 await metadata_controller.cache.clear()
189 metadata_controller._image_id_lru.clear()
190 resolved = await metadata_controller.resolve_image_id(image_id)
191 assert resolved == ("filesystem", "/persistent.jpg")
192
193
194def _track_with_image(item_id: str, image: MediaItemImage) -> Track:
195 """Build a minimal serializable Track carrying the given image."""
196 return Track(
197 item_id=item_id,
198 provider="library",
199 name=f"Track {item_id}",
200 provider_mappings={
201 ProviderMapping(
202 item_id=item_id,
203 provider_domain="filesystem",
204 provider_instance="filesystem--test",
205 )
206 },
207 metadata=MediaItemMetadata(images=UniqueList([image])),
208 )
209
210
211async def _wait_for_expiration_refresh(
212 controller: MetaDataController, image_id: str, old_expires: int, timeout: float = 2.0
213) -> int:
214 """Wait until the stored row's expiration moved past `old_expires`, or fail."""
215 loop = asyncio.get_running_loop()
216 deadline = loop.time() + timeout
217 while loop.time() < deadline:
218 expires = await controller.cache.get_expiration(
219 key=image_id,
220 category=CACHE_CATEGORY_IMAGE_IDS,
221 provider=controller.domain,
222 )
223 if expires is not None and expires > old_expires:
224 return expires
225 await asyncio.sleep(0.01)
226 raise AssertionError(f"expiration for {image_id!r} not refreshed within {timeout}s")
227
228
229async def test_serialize_twice_hashes_once_and_persists_once(
230 metadata_controller: MetaDataController, monkeypatch: pytest.MonkeyPatch
231) -> None:
232 """
233 Serializing the same item list twice hashes and persists each unique image only once.
234
235 The proxy_id injection runs per image occurrence per outbound message, so
236 repeat serializations must be served from the forward memo: no hashing at
237 all on the second pass and exactly one cache write per unique image.
238 """
239 hash_calls: list[tuple[str, str]] = []
240
241 def counting_thumb_hash(provider: str, path: str) -> str:
242 hash_calls.append((provider, path))
243 return create_thumb_hash(provider, path)
244
245 monkeypatch.setattr(
246 "music_assistant.controllers.metadata.images.create_thumb_hash", counting_thumb_hash
247 )
248 persist_calls: list[str] = []
249 real_cache_set = metadata_controller.cache.set
250
251 async def counting_cache_set(*args: Any, **kwargs: Any) -> None:
252 if kwargs.get("category") == CACHE_CATEGORY_IMAGE_IDS:
253 persist_calls.append(kwargs["key"])
254 await real_cache_set(*args, **kwargs)
255
256 monkeypatch.setattr(metadata_controller.cache, "set", counting_cache_set)
257
258 # three image occurrences per pass, but only two unique images
259 image_a = MediaItemImage(type=ImageType.THUMB, path="/album_a.jpg", provider="filesystem")
260 image_b = MediaItemImage(type=ImageType.THUMB, path="/album_b.jpg", provider="filesystem")
261 tracks = [
262 _track_with_image("1", image_a),
263 _track_with_image("2", image_a),
264 _track_with_image("3", image_b),
265 ]
266 token = IMAGE_PROXY_ID_RESOLVER.set(metadata_controller.compute_image_id)
267 try:
268 first_pass = [track.to_dict() for track in tracks]
269 assert len(hash_calls) == 2
270 second_pass = [track.to_dict() for track in tracks]
271 finally:
272 IMAGE_PROXY_ID_RESOLVER.reset(token)
273 # zero hashing on the second pass: everything came from the forward memo
274 assert len(hash_calls) == 2
275 assert first_pass == second_pass
276 expected_ids = sorted(
277 create_thumb_hash("filesystem", path) for path in ("/album_a.jpg", "/album_b.jpg")
278 )
279 for track_dict in first_pass:
280 assert track_dict["metadata"]["images"][0]["proxy_id"] in expected_ids
281 # exactly one persist per unique image across both passes
282 for image_id in expected_ids:
283 await _wait_for_persisted_image_id(metadata_controller, image_id)
284 assert sorted(persist_calls) == expected_ids
285
286
287async def test_persist_skipped_when_row_already_fresh(
288 metadata_controller: MetaDataController, monkeypatch: pytest.MonkeyPatch
289) -> None:
290 """A mapping already stored with plenty of TTL left must not be rewritten."""
291 provider, path = "filesystem", "/prestored.jpg"
292 image_id = create_thumb_hash(provider, path)
293 # simulate a previous process run that already stored the mapping
294 await metadata_controller.cache.set(
295 key=image_id,
296 data={"provider": provider, "path": path},
297 category=CACHE_CATEGORY_IMAGE_IDS,
298 provider=metadata_controller.domain,
299 expiration=_IMAGE_ID_CACHE_TTL,
300 persistent=True,
301 )
302 probes: list[str] = []
303 real_get_expiration = metadata_controller.cache.get_expiration
304
305 async def recording_get_expiration(*args: Any, **kwargs: Any) -> int | None:
306 result = await real_get_expiration(*args, **kwargs)
307 probes.append(kwargs.get("key") or args[0])
308 return result
309
310 writes: list[str] = []
311 real_cache_set = metadata_controller.cache.set
312
313 async def recording_cache_set(*args: Any, **kwargs: Any) -> None:
314 writes.append(kwargs.get("key") or args[0])
315 await real_cache_set(*args, **kwargs)
316
317 monkeypatch.setattr(metadata_controller.cache, "get_expiration", recording_get_expiration)
318 monkeypatch.setattr(metadata_controller.cache, "set", recording_cache_set)
319
320 assert metadata_controller.compute_image_id(provider, path) == image_id
321 # wait for the freshness probe, then give the persist task time to (not) write
322 loop = asyncio.get_running_loop()
323 deadline = loop.time() + 2.0
324 while not probes and loop.time() < deadline:
325 await asyncio.sleep(0.01)
326 assert probes == [image_id]
327 await asyncio.sleep(0.05)
328 assert writes == []
329 # the pre-stored row keeps the id resolvable, also without the in-memory layer
330 metadata_controller._image_id_lru.clear()
331 assert await metadata_controller.resolve_image_id(image_id) == (provider, path)
332
333
334async def test_persist_refreshes_stale_row(metadata_controller: MetaDataController) -> None:
335 """A stored row past half its TTL is rewritten so the id stays resolvable."""
336 provider, path = "filesystem", "/stale.jpg"
337 image_id = create_thumb_hash(provider, path)
338 # a row whose remaining TTL is way below half of _IMAGE_ID_CACHE_TTL
339 await metadata_controller.cache.set(
340 key=image_id,
341 data={"provider": provider, "path": path},
342 category=CACHE_CATEGORY_IMAGE_IDS,
343 provider=metadata_controller.domain,
344 expiration=1000,
345 persistent=True,
346 )
347 old_expires = await metadata_controller.cache.get_expiration(
348 key=image_id,
349 category=CACHE_CATEGORY_IMAGE_IDS,
350 provider=metadata_controller.domain,
351 )
352 assert old_expires is not None
353 assert metadata_controller.compute_image_id(provider, path) == image_id
354 await _wait_for_expiration_refresh(metadata_controller, image_id, old_expires)
355
356
357async def test_imageproxy_resolves_after_restart(
358 metadata_controller: MetaDataController, monkeypatch: pytest.MonkeyPatch
359) -> None:
360 """
361 An id persisted by one process run must resolve on the imageproxy of the next.
362
363 Simulates a restart with a second controller instance on the same cache
364 database: cold in-memory maps, so resolution can only come from the
365 persisted row.
366 """
367 provider, path = "filesystem", "/restart/cover.jpg"
368 image_id = metadata_controller.compute_image_id(provider, path)
369 await _wait_for_persisted_image_id(metadata_controller, image_id)
370
371 restarted = MetaDataController(metadata_controller.mass)
372 assert not restarted._image_id_forward
373 assert not restarted._image_id_lru
374 served: list[tuple[str, str]] = []
375
376 async def fake_serve_thumbnail(
377 path_arg: str, provider_arg: str, _size: int, _image_format: str
378 ) -> web.Response:
379 served.append((provider_arg, path_arg))
380 return web.Response(status=200)
381
382 monkeypatch.setattr(restarted, "_serve_thumbnail", fake_serve_thumbnail)
383 request = MagicMock()
384 request.path = f"/imageproxy/{image_id}"
385 request.query = {}
386 response = await restarted.handle_imageproxy(request)
387 assert response.status == 200
388 assert served == [(provider, path)]
389
390
391async def test_no_repersist_after_in_memory_eviction(
392 metadata_controller: MetaDataController, monkeypatch: pytest.MonkeyPatch
393) -> None:
394 """Re-encountering an image after memo/LRU eviction must not hit the cache db again."""
395 provider, path = "filesystem", "/evicted.jpg"
396 image_id = metadata_controller.compute_image_id(provider, path)
397 await _wait_for_persisted_image_id(metadata_controller, image_id)
398 # simulate LRU pressure evicting the in-memory maps (markers survive)
399 metadata_controller._image_id_forward.clear()
400 metadata_controller._image_id_lru.clear()
401
402 calls: list[str] = []
403
404 async def recording_get_expiration(*_args: object, **_kwargs: object) -> int | None:
405 calls.append("probe")
406 return None
407
408 async def recording_cache_set(*_args: object, **_kwargs: object) -> None:
409 calls.append("write")
410
411 monkeypatch.setattr(metadata_controller.cache, "get_expiration", recording_get_expiration)
412 monkeypatch.setattr(metadata_controller.cache, "set", recording_cache_set)
413
414 # re-encounter re-hashes (memo was evicted) but the persist marker is fresh,
415 # so no persist work is scheduled at all
416 assert metadata_controller.compute_image_id(provider, path) == image_id
417 await asyncio.sleep(0.05)
418 assert calls == []
419
420
421def test_normalize_imageproxy_format() -> None:
422 """Client `fmt` values are lowercased / trimmed and validated."""
423 assert _normalize_imageproxy_format("jpg") == "jpg"
424 assert _normalize_imageproxy_format("JPG") == "jpg"
425 assert _normalize_imageproxy_format("jpeg") == "jpeg"
426 assert _normalize_imageproxy_format("PNG") == "png"
427 assert _normalize_imageproxy_format("svg") == "svg"
428 assert _normalize_imageproxy_format(" png ") == "png"
429 # invalid / empty input falls through so caller can detect from path
430 assert _normalize_imageproxy_format("") is None
431 assert _normalize_imageproxy_format(None) is None
432 assert _normalize_imageproxy_format("gif") is None
433 assert _normalize_imageproxy_format("bmp") is None
434
435
436def test_imageproxy_content_types_are_standards_compliant() -> None:
437 """Every accepted fmt must produce a standards-compliant MIME type."""
438 assert _IMAGEPROXY_CONTENT_TYPES["jpg"] == "image/jpeg"
439 assert _IMAGEPROXY_CONTENT_TYPES["jpeg"] == "image/jpeg"
440 assert _IMAGEPROXY_CONTENT_TYPES["png"] == "image/png"
441 assert _IMAGEPROXY_CONTENT_TYPES["svg"] == "image/svg+xml"
442
443
444def test_extract_imageproxy_id_matches_path_only() -> None:
445 """Only URLs whose path begins with /imageproxy/ should yield an id."""
446 valid = "http://mass.local/imageproxy/" + "a" * 64 + "?size=256"
447 assert _extract_imageproxy_id(valid) == "a" * 64
448 # an id-shaped substring inside a query string must not match
449 decoy = "http://other.example.com/foo?next=/imageproxy/" + "b" * 64
450 assert _extract_imageproxy_id(decoy) is None
451 # invalid id length / charset
452 assert _extract_imageproxy_id("http://mass.local/imageproxy/short") is None
453 assert _extract_imageproxy_id("http://mass.local/imageproxy/" + "g" * 64) is None
454 # extra path segments must not be accepted (matches handle_imageproxy)
455 assert _extract_imageproxy_id("http://mass.local/imageproxy/" + "a" * 64 + "/extra") is None
456 assert _extract_imageproxy_id("http://mass.local/imageproxy/extra/" + "a" * 64) is None
457 # canonical form with trailing slash is fine
458 assert _extract_imageproxy_id("http://mass.local/imageproxy/" + "a" * 64 + "/") == "a" * 64
459
460
461async def test_handle_imageproxy_rejects_extra_path_segments(
462 metadata_controller: MetaDataController,
463) -> None:
464 """`/imageproxy/<id>` is the only shape that should validate."""
465 image_id = metadata_controller.compute_image_id("filesystem", "/local/cover.jpg")
466
467 def _fake_request(path: str) -> MagicMock:
468 request = MagicMock()
469 request.path = path
470 request.query = {}
471 return request
472
473 # canonical form passes the path check and reaches resolve (404 because
474 # the (provider, path) is not actually fetchable in this minimal fixture)
475 assert (
476 await metadata_controller.handle_imageproxy(_fake_request(f"/imageproxy/{image_id}"))
477 ).status in (200, 404)
478 # extra leading segment must not be accepted
479 bad = await metadata_controller.handle_imageproxy(
480 _fake_request(f"/imageproxy/extra/{image_id}")
481 )
482 assert bad.status == 400
483 # trailing extra segment likewise
484 bad = await metadata_controller.handle_imageproxy(
485 _fake_request(f"/imageproxy/{image_id}/extra")
486 )
487 assert bad.status == 400
488 # double slash after the prefix must not be accepted either
489 bad = await metadata_controller.handle_imageproxy(_fake_request(f"/imageproxy//{image_id}"))
490 assert bad.status == 400
491
492
493async def test_handle_imageproxy_rejects_unsupported_size(
494 metadata_controller: MetaDataController,
495) -> None:
496 """A size outside the allowed set is rejected with a non-empty reason body."""
497 image_id = metadata_controller.compute_image_id("filesystem", "/local/cover.jpg")
498 request = MagicMock()
499 request.path = f"/imageproxy/{image_id}"
500 request.query = {"size": "300"}
501 bad = await metadata_controller.handle_imageproxy(request)
502 assert bad.status == 400
503 assert bad.text
504
505
506def test_is_svg_data() -> None:
507 """SVG bytes are detected by content, regardless of file extension."""
508 # plain root element
509 assert is_svg_data(b'<svg xmlns="http://www.w3.org/2000/svg"></svg>')
510 # XML declaration before the root element
511 assert is_svg_data(b'<?xml version="1.0"?>\n<svg viewBox="0 0 10 10"></svg>')
512 # leading whitespace and a comment before the root element
513 assert is_svg_data(b" \n<!-- a logo -->\n<svg></svg>")
514 # doctype before the root element
515 assert is_svg_data(b'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN">\n<svg></svg>')
516 # raster formats and arbitrary data are not SVG
517 assert not is_svg_data(b"\xff\xd8\xff\xe0JFIF") # jpeg magic
518 assert not is_svg_data(b"\x89PNG\r\n\x1a\n") # png magic
519 assert not is_svg_data(b"")
520 assert not is_svg_data(b"<html><body>not svg</body></html>")
521 # an XML document that never declares an <svg> element is not SVG
522 assert not is_svg_data(b'<?xml version="1.0"?><rss></rss>')
523
524
525def test_detect_image_content_format() -> None:
526 """Image format is sniffed from leading magic bytes (png/jpg/svg)."""
527 assert detect_image_content_format(b"\x89PNG\r\n\x1a\n....") == "png"
528 assert detect_image_content_format(b"\xff\xd8\xff\xe0JFIF") == "jpg"
529 assert detect_image_content_format(b'<svg xmlns="http://www.w3.org/2000/svg"></svg>') == "svg"
530 assert detect_image_content_format(b"") is None
531 assert detect_image_content_format(b"GIF89a") is None
532 # every sniffed value must map to a known content type
533 for fmt in ("png", "jpg", "svg"):
534 assert fmt in _IMAGEPROXY_CONTENT_TYPES
535
536
537def test_thumb_cache_filename_separates_flatten_variants() -> None:
538 """Flattened (player-compat) and transparency-preserving variants differ."""
539 thumb_hash = "a" * 64
540 plain = _thumb_cache_filename(thumb_hash, 512, "jpeg", flatten_transparency=False)
541 flat = _thumb_cache_filename(thumb_hash, 512, "jpeg", flatten_transparency=True)
542 assert plain == f"{thumb_hash}_512_v{_THUMB_CACHE_VERSION}.jpg"
543 assert flat == f"{thumb_hash}_512_v{_THUMB_CACHE_VERSION}_flat.jpg"
544 assert plain != flat
545 # png requests keep their own extension/bucket
546 png_name = _thumb_cache_filename(thumb_hash, 0, "png")
547 assert png_name == f"{thumb_hash}_0_v{_THUMB_CACHE_VERSION}.png"
548
549
550def test_has_alpha() -> None:
551 """Only images that actually use transparency are detected."""
552 assert _has_alpha(Image.new("RGBA", (4, 4), (0, 0, 0, 0)))
553 assert _has_alpha(Image.new("LA", (4, 4)))
554 palette_img = Image.new("P", (4, 4))
555 palette_img.info["transparency"] = 0
556 assert _has_alpha(palette_img)
557 # a fully-opaque alpha channel does not count as transparent
558 assert not _has_alpha(Image.new("RGBA", (4, 4), (1, 2, 3, 255)))
559 # opaque modes have no alpha
560 assert not _has_alpha(Image.new("RGB", (4, 4), (10, 20, 30)))
561 assert not _has_alpha(Image.new("L", (4, 4)))
562 assert not _has_alpha(Image.new("P", (4, 4)))
563
564
565def test_thumb_cache_filename_includes_cache_version() -> None:
566 """The version is in the filename so pre-upgrade thumbnails can't be reused."""
567 thumb_hash = "a" * 64
568 name = _thumb_cache_filename(thumb_hash, 512, "jpeg")
569 assert f"_v{_THUMB_CACHE_VERSION}" in name
570 # the unversioned (pre-PR) filename must not collide with the current one
571 assert name != f"{thumb_hash}_512.jpg"
572 # the sanitizer must accept the versioned shape it now produces
573 assert _THUMB_FILENAME_RE.fullmatch(name)
574 assert _THUMB_FILENAME_RE.fullmatch(
575 _thumb_cache_filename(thumb_hash, 0, "png", flatten_transparency=True)
576 )
577
578
579async def test_serve_thumbnail_sets_csp_for_svg(
580 metadata_controller: MetaDataController, monkeypatch: pytest.MonkeyPatch
581) -> None:
582 """SVG responses carry a script-blocking CSP; raster responses do not."""
583 served: dict[str, tuple[bytes, str]] = {"value": (b"<svg></svg>", "svg")}
584
585 async def _fake_resolve(*_args: object, **_kwargs: object) -> tuple[bytes, str]:
586 return served["value"]
587
588 monkeypatch.setattr(metadata_controller, "_resolve_thumbnail", _fake_resolve)
589
590 svg_resp = await metadata_controller._serve_thumbnail("p", "builtin", 0, "svg")
591 assert svg_resp.headers["Content-Security-Policy"] == (
592 "default-src 'none'; style-src 'unsafe-inline'; sandbox"
593 )
594 assert svg_resp.headers["X-Content-Type-Options"] == "nosniff"
595
596 served["value"] = (b"\xff\xd8\xff", "jpg")
597 jpg_resp = await metadata_controller._serve_thumbnail("p", "builtin", 256, "jpeg")
598 assert "Content-Security-Policy" not in jpg_resp.headers
599 assert "X-Content-Type-Options" not in jpg_resp.headers
600
601
602async def test_invalidate_image_cache_end_to_end(
603 metadata_controller: MetaDataController, tmp_path: Any, monkeypatch: pytest.MonkeyPatch
604) -> None:
605 """
606 Invalidation drops thumbs, source bytes and palette; the next request re-fetches.
607
608 This is the retag scenario: a local file's artwork is replaced while its
609 (provider, path) identity stays the same, so every derived artifact keyed
610 on that identity must be regenerated.
611 """
612 image_path = str(tmp_path / "cover.png")
613 Image.new("RGB", (300, 300), (200, 30, 30)).save(image_path, "PNG")
614 mass = metadata_controller.mass
615 fetches = 0
616 real_fetch = images_helper._fetch_source_image
617
618 async def counting_fetch(*args: Any, **kwargs: Any) -> tuple[bytes, bool]:
619 nonlocal fetches
620 fetches += 1
621 return await real_fetch(*args, **kwargs)
622
623 monkeypatch.setattr(images_helper, "_fetch_source_image", counting_fetch)
624
625 # derive two thumb variants and a palette from one single source fetch
626 await get_image_thumb(mass, image_path, 80, "builtin")
627 await get_image_thumb(mass, image_path, 256, "builtin")
628 palette = await get_palette(mass, image_path, "builtin")
629 assert palette is not None
630 assert palette.primary is not None
631 assert fetches == 1
632
633 thumb_hash = create_thumb_hash("builtin", image_path)
634 thumb_dir = Path(mass.cache_path, "thumbnails")
635 assert [f for f in thumb_dir.iterdir() if f.name.startswith(thumb_hash)]
636 assert await mass.cache.get(thumb_hash, provider="palette") is not None
637
638 await metadata_controller.invalidate_image_cache("builtin", image_path)
639
640 assert not [f for f in thumb_dir.iterdir() if f.name.startswith(thumb_hash)]
641 assert thumb_hash not in images_helper._source_memory_cache.entries
642 assert not any(key.startswith(f"{thumb_hash}_") for key in images_helper._thumb_memory_cache)
643 assert await mass.cache.get(thumb_hash, provider="palette") is None
644
645 # replace the artwork on disk: the next request serves the new content
646 Image.new("RGB", (300, 300), (30, 30, 200)).save(image_path, "PNG")
647 new_palette = await get_palette(mass, image_path, "builtin")
648 assert new_palette is not None
649 assert new_palette.primary != palette.primary
650
651
652async def test_cached_thumb_survives_an_unavailable_provider(
653 metadata_controller: MetaDataController, tmp_path: Any
654) -> None:
655 """
656 An already-rendered thumbnail is served while its owning provider is unavailable.
657
658 A filesystem provider marks itself unavailable after a single failed scan and only
659 recovers on a later full sync, so art that is already cached must keep being served
660 for the whole of that window.
661 """
662 mass = metadata_controller.mass
663 source_path = tmp_path / "cover.png"
664 Image.new("RGB", (300, 300), (200, 30, 30)).save(str(source_path), "PNG")
665 provider = _fake_image_provider("filesystem_local--abcd1234", str(source_path))
666 mass._providers[provider.instance_id] = provider
667
668 # the relative path is only resolvable through the provider
669 image_id = metadata_controller.compute_image_id(provider.instance_id, "Artist/Album/cover.jpg")
670 request = MagicMock()
671 request.path = f"/imageproxy/{image_id}"
672 request.query = {"size": "256"}
673
674 warm = await metadata_controller.handle_imageproxy(request)
675 assert warm.status == 200
676 assert warm.body
677
678 provider.available = False
679 assert mass.get_provider(provider.instance_id) is None
680 # drop the memory tier so the on-disk thumbnail is what has to answer: that is the
681 # reported scenario, where the art was rendered long before the provider went down
682 images_helper._thumb_memory_cache.clear()
683
684 cold = await metadata_controller.handle_imageproxy(request)
685 assert cold.status == 200
686 assert cold.body == warm.body
687
688
689async def test_unavailable_provider_is_not_cached_as_a_missing_image(
690 metadata_controller: MetaDataController, tmp_path: Any, monkeypatch: pytest.MonkeyPatch
691) -> None:
692 """An unavailable provider is not remembered as a failed source, so recovery is instant."""
693 mass = metadata_controller.mass
694 source_path = tmp_path / "cover.png"
695 Image.new("RGB", (300, 300), (30, 30, 200)).save(str(source_path), "PNG")
696 provider = _fake_image_provider("filesystem_local--efgh5678", str(source_path))
697 provider.available = False
698 mass._providers[provider.instance_id] = provider
699
700 # a provider-relative path is unresolvable without its provider, so none of the
701 # fallback routes may be tried - probing one costs an ffmpeg spawn per request
702 async def _no_embedded_probe(*_args: Any, **_kwargs: Any) -> bytes | None:
703 raise AssertionError("embedded-image probe attempted without a provider")
704
705 monkeypatch.setattr(images_helper, "get_embedded_image", _no_embedded_probe)
706
707 image_path = "Artist/Album/never-fetched.jpg"
708 with pytest.raises(ProviderUnavailableError):
709 await get_image_thumb(mass, image_path, 256, provider.instance_id)
710
711 cache_key = create_thumb_hash(provider.instance_id, image_path)
712 assert cache_key not in images_helper._failed_sources
713
714 # the provider coming back is enough; nothing has to expire first
715 provider.available = True
716 assert await get_image_thumb(mass, image_path, 256, provider.instance_id)
717
718
719async def test_absolute_path_is_read_without_its_provider(
720 metadata_controller: MetaDataController, tmp_path: Any
721) -> None:
722 """
723 An absolute image path stays readable while its provider is unavailable.
724
725 Providers that write their own image files (playlist artwork, collages) pair an
726 absolute path with their instance id, and such a file needs no provider to be read.
727 """
728 mass = metadata_controller.mass
729 source_path = tmp_path / "playlist-art.png"
730 Image.new("RGB", (300, 300), (200, 200, 10)).save(str(source_path), "PNG")
731 provider = _fake_image_provider("playlist_metadata--mnop3456", str(source_path))
732 provider.available = False
733 mass._providers[provider.instance_id] = provider
734
735 assert await get_image_thumb(mass, str(source_path), 256, provider.instance_id)
736
737
738async def test_get_thumbnail_reports_unavailable_as_media_not_found(
739 metadata_controller: MetaDataController, tmp_path: Any
740) -> None:
741 """
742 `get_thumbnail` keeps normalizing to one typed error when a provider is unavailable.
743
744 Callers that only mean to skip the artwork catch `MediaNotFoundError`; leaking a
745 different type aborts the whole send they were part of.
746 """
747 mass = metadata_controller.mass
748 source_path = tmp_path / "cover.png"
749 Image.new("RGB", (300, 300), (10, 200, 10)).save(str(source_path), "PNG")
750 provider = _fake_image_provider("filesystem_local--ijkl9012", str(source_path))
751 provider.available = False
752 mass._providers[provider.instance_id] = provider
753
754 with pytest.raises(MediaNotFoundError):
755 await metadata_controller.get_thumbnail(
756 "Artist/Album/cover.jpg", provider.instance_id, size=256
757 )
758
759
760def test_player_image_url_forces_jpeg_on_imageproxy_urls() -> None:
761 """Player-bound imageproxy URLs move to the streams server and force fmt=jpeg."""
762 mass = MagicMock()
763 mass.webserver.base_url = "http://192.168.1.2:8095"
764 mass.streams.base_url = "http://192.168.1.2:8097"
765 image_id = "a" * 64
766
767 url = f"http://192.168.1.2:8095/imageproxy/{image_id}?size=512&fmt=png"
768 result = player_image_url(mass, url)
769 assert result == f"http://192.168.1.2:8097/imageproxy/{image_id}?size=512&fmt=jpeg"
770
771 # non-imageproxy urls (e.g. remote radio artwork) pass through unchanged
772 remote = "https://example.com/art.png"
773 assert player_image_url(mass, remote) == remote
774 assert player_image_url(mass, None) is None
775