From 9fd106b757f59be2a25724599b5a0177fe4e8c0e Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Mon, 21 Sep 2026 11:42:33 +0200 Subject: [PATCH] Backend: cover cache and web API changes Co-Authored-By: Claude Sonnet 5 --- python-backend/musicmouse/library/cache.py | 80 +++++++++++++++---- python-backend/musicmouse/services/web/api.py | 35 ++++---- .../musicmouse/services/web/schemas.py | 13 +++ .../musicmouse/services/web/service.py | 15 +++- python-backend/tests/test_library.py | 35 ++++++++ python-backend/tests/test_web.py | 1 + 6 files changed, 148 insertions(+), 31 deletions(-) diff --git a/python-backend/musicmouse/library/cache.py b/python-backend/musicmouse/library/cache.py index d7ea9c4..4eb3fd5 100644 --- a/python-backend/musicmouse/library/cache.py +++ b/python-backend/musicmouse/library/cache.py @@ -5,6 +5,7 @@ different amounts to produce:: / ├── index.json cheap: tags and structure. Thrown away freely. + ├── covers/.thumb.jpg the same art at THUMB_COVER_PX, for cards and rows ├── covers/.jpg medium: the album's art - out of an ID3 APIC │ frame, or copied from a cover.jpg in the │ folder - downscaled to MAX_COVER_PX on @@ -102,11 +103,32 @@ class LibraryCache: def cover_path(self, album_id: str) -> Path: return self.covers / f"{album_id}.jpg" + def thumb_path(self, album_id: str) -> Path: + return self.covers / f"{album_id}.thumb.jpg" + def store_cover(self, album_id: str, data: bytes) -> Path: path = self.cover_path(album_id) + # The thumb comes from the original art, not from the 384 px copy: one lossy + # generation fewer, and `Image.draft` lets JPEG decode at a fraction of the size. path.write_bytes(shrink_cover(data)) + self.thumb_path(album_id).write_bytes( + shrink_cover(data, max_px=THUMB_COVER_PX, quality=_THUMB_JPEG_QUALITY) + ) return path + def _refresh_thumb(self, cover: Path) -> bool: + """Make ``.thumb.jpg`` exist and be newer than the cover it came from.""" + thumb = cover.with_name(f"{cover.stem}.thumb.jpg") + try: + if thumb.exists() and thumb.stat().st_mtime_ns >= cover.stat().st_mtime_ns: + return False + thumb.write_bytes( + shrink_cover(cover.read_bytes(), max_px=THUMB_COVER_PX, quality=_THUMB_JPEG_QUALITY) + ) + except OSError: # pragma: no cover - a cache we cannot write is not fatal + return False + return True + def shrink_stored_covers(self) -> int: """Rewrite any already-stored cover that predates :data:`MAX_COVER_PX`. @@ -116,23 +138,27 @@ class LibraryCache: dimensions only parses its header, so once every file is within the limit this costs one small read per album and nothing else. - Returns the number of files actually rewritten. + Also backfills any missing (or stale) thumbnail. Returns the number of full-size + files actually rewritten. """ rewritten = 0 for path in sorted(self.covers.glob("*.jpg")): + if path.name.endswith(".thumb.jpg"): + continue try: with Image.open(path) as image: oversized = max(image.size) > MAX_COVER_PX except (OSError, UnidentifiedImageError): continue - if not oversized: - continue - try: - shrunk = shrink_cover(path.read_bytes()) - path.write_bytes(shrunk) - except OSError: # pragma: no cover - a cache we cannot write is not fatal - continue - rewritten += 1 + if oversized: + try: + shrunk = shrink_cover(path.read_bytes()) + path.write_bytes(shrunk) + except OSError: # pragma: no cover - a cache we cannot write is not fatal + continue + rewritten += 1 + # Covers stored before thumbnails existed get theirs here, without a rescan. + self._refresh_thumb(path) return rewritten # ------------------------------------------------------------------ analysis @@ -289,24 +315,48 @@ MAX_COVER_PX: Final = 384 #: file is a third of the size. _COVER_JPEG_QUALITY: Final = 85 +#: Longest edge of the card/row thumbnail. A grid card is about 217 CSS px wide on a +#: 1080p kiosk (five columns in the 1180 px grid), so 256 covers it at devicePixelRatio 1 +#: with a little to spare; only the play view asks for the full-size file. Decode cost +#: and texture upload go with pixel count, and this is 44% of the 384 px file's. +THUMB_COVER_PX: Final = 256 -def shrink_cover(data: bytes) -> bytes: - """Downscale cover art to :data:`MAX_COVER_PX` on its longest edge. +#: Baseline JPEG, 4:2:0 (see :func:`shrink_cover`). libjpeg-turbo is the fastest decoder +#: a browser on a Pi has, so JPEG stays the format; only the dimensions shrink. +_THUMB_JPEG_QUALITY: Final = 80 + + +def shrink_cover( + data: bytes, max_px: int = MAX_COVER_PX, quality: int = _COVER_JPEG_QUALITY +) -> bytes: + """Downscale cover art to ``max_px`` (default :data:`MAX_COVER_PX`) on its longest edge. Art that is already small enough is returned untouched rather than re-encoded, so repeated scans never degrade it. Anything Pillow cannot read is passed through unchanged: a cover that is too big is a performance problem, a cover that is missing is a visible one. + + The output is always a baseline (non-progressive) 4:2:0 JPEG: the cheapest layout + for a browser to decode. """ try: with Image.open(io.BytesIO(data)) as image: - if max(image.size) <= MAX_COVER_PX: + if max(image.size) <= max_px: return data - # `thumbnail` keeps the aspect ratio and never scales up. + # JPEG can decode straight to 1/2, 1/4 or 1/8 size; a no-op for other formats. + image.draft("RGB", (max_px, max_px)) image = image.convert("RGB") - image.thumbnail((MAX_COVER_PX, MAX_COVER_PX), Image.Resampling.LANCZOS) + # `thumbnail` keeps the aspect ratio and never scales up. + image.thumbnail((max_px, max_px), Image.Resampling.LANCZOS) buffer = io.BytesIO() - image.save(buffer, format="JPEG", quality=_COVER_JPEG_QUALITY, optimize=True) + image.save( + buffer, + format="JPEG", + quality=quality, + optimize=True, + progressive=False, + subsampling="4:2:0", + ) return buffer.getvalue() except (OSError, UnidentifiedImageError, ValueError): return data diff --git a/python-backend/musicmouse/services/web/api.py b/python-backend/musicmouse/services/web/api.py index df4646c..9b3c6ef 100644 --- a/python-backend/musicmouse/services/web/api.py +++ b/python-backend/musicmouse/services/web/api.py @@ -97,27 +97,32 @@ def build_router( ) @router.get("/albums/{album_id}/cover") - def get_cover(album_id: str) -> FileResponse: + def get_cover(album_id: str, size: str = "full", v: str | None = None) -> FileResponse: album = app.library.get(album_id) if album is None or album.cover is None: # Not an error: the client paints the album's own colours instead. raise HTTPException(status_code=404, detail="no cover") + path = album.cover + if size == "thumb": + thumb = path.with_name(f"{path.stem}.thumb.jpg") + if thumb.is_file(): + path = thumb return FileResponse( - album.cover, - # This used to send `public, max-age=604800`, on the reasoning that a cover - # is "content-addressed by album id". It is not: the id addresses *which - # album* the art belongs to, and the bytes behind that URL change whenever - # the art is reprocessed. When `MAX_COVER_PX` arrived and every cover was - # rewritten smaller, every browser that had loaded the page in the previous - # week went on decoding the old 3000px file out of its own disk cache, and - # a trace was the only way to see it. + path, + # A URL carrying `v` (the file's mtime, from `AlbumOut.cover_v`) names one + # exact set of bytes: reprocessing the art changes the library payload, so + # the client asks for a new URL. Those can be cached forever, and the + # browser then never asks again - no conditional request per card. # - # `no-cache` does not mean "do not store", it means "revalidate before - # reusing" - so the browser keeps the file and usually gets a 304. The cost - # is one conditional request per cover, and it is not on the paint path at - # all: the service worker answers covers from its own cache first and does - # the revalidation behind the page (see web/public/sw.js). - headers={"Cache-Control": "no-cache"}, + # Without `v` the id alone is not enough (the bytes behind it change whenever + # the art is reprocessed - when `MAX_COVER_PX` arrived, browsers went on + # decoding old 3000px files out of their cache), so `no-cache` makes the + # browser revalidate: it keeps the file and usually gets a 304. + headers={ + "Cache-Control": ( + "public, max-age=31536000, immutable" if v else "no-cache" + ) + }, ) @router.get("/tracks/{album_id}/{index}/analysis") diff --git a/python-backend/musicmouse/services/web/schemas.py b/python-backend/musicmouse/services/web/schemas.py index 9107554..e7dd0e9 100644 --- a/python-backend/musicmouse/services/web/schemas.py +++ b/python-backend/musicmouse/services/web/schemas.py @@ -120,6 +120,15 @@ class TrackOut(BaseModel): ) +def _cover_version(album: Album) -> int: + if album.cover is None: + return 0 + try: + return album.cover.stat().st_mtime_ns // 1_000_000 + except OSError: + return 0 + + class AlbumOut(BaseModel): id: str section: str @@ -131,6 +140,9 @@ class AlbumOut(BaseModel): category: str colors: list[str] has_cover: bool + #: Cache-busting version of the cover (its mtime in ms; 0 with no cover). Goes in the + #: cover URL's `v` so the browser may cache the image forever. + cover_v: int = 0 duration: float tracks: list[TrackOut] #: Every track is still locked - the browse view shows a question mark instead of @@ -151,6 +163,7 @@ class AlbumOut(BaseModel): category=album.category, colors=list(album.colors), has_cover=album.cover is not None, + cover_v=_cover_version(album), duration=album.duration, locked=lock.locked if lock is not None else False, tracks=[ diff --git a/python-backend/musicmouse/services/web/service.py b/python-backend/musicmouse/services/web/service.py index e6d27a9..42c94e2 100644 --- a/python-backend/musicmouse/services/web/service.py +++ b/python-backend/musicmouse/services/web/service.py @@ -17,6 +17,7 @@ import httpx2 import uvicorn from fastapi import FastAPI from fastapi.staticfiles import StaticFiles +from starlette.responses import Response from musicmouse.app import App from musicmouse.config import WebConfig @@ -28,6 +29,18 @@ _log = logging.getLogger(__name__) __all__ = ["WebService", "build_app"] +class _WebFiles(StaticFiles): + """Vite writes ``assets/-.``: a URL there names one exact + set of bytes, so the browser may keep it forever and never revalidate. Everything + else (``index.html``, ``sw.js``) has a stable name and keeps the default behaviour.""" + + def file_response(self, full_path, stat_result, scope, status_code=200) -> Response: # type: ignore[no-untyped-def] + response = super().file_response(full_path, stat_result, scope, status_code) + if scope.get("path", "").startswith("/assets/"): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + def build_app( app: App, config: WebConfig, @@ -50,7 +63,7 @@ def build_app( if config.static_dir.is_dir(): # Mounted last and at the root so every /api route still wins; html=True # falls back to index.html, which is what a client-side router needs. - api.mount("/", StaticFiles(directory=config.static_dir, html=True), name="web") + api.mount("/", _WebFiles(directory=config.static_dir, html=True), name="web") else: _log.warning( "web.static_dir %s does not exist; serving the API only " diff --git a/python-backend/tests/test_library.py b/python-backend/tests/test_library.py index 8125332..5367678 100644 --- a/python-backend/tests/test_library.py +++ b/python-backend/tests/test_library.py @@ -820,6 +820,41 @@ class TestCoverDownscaling: # The untouched original still comes back, because colour extraction wants it. assert art == (folder / "cover.jpg").read_bytes() + def test_store_cover_also_writes_a_smaller_baseline_thumbnail(self, tmp_path: Path) -> None: + from PIL import Image + + from musicmouse.library.cache import THUMB_COVER_PX, LibraryCache + + cache = LibraryCache(tmp_path) + cache.prepare() + cache.store_cover("abc123", self._jpeg((2400, 1800))) + with Image.open(cache.thumb_path("abc123")) as thumb: + assert max(thumb.size) == THUMB_COVER_PX + assert thumb.format == "JPEG" + assert "progressive" not in thumb.info + assert abs(thumb.width / thumb.height - 2400 / 1800) < 0.02 + + def test_thumbnails_are_backfilled_for_covers_stored_without_one( + self, tmp_path: Path + ) -> None: + from PIL import Image + + from musicmouse.library.cache import THUMB_COVER_PX, LibraryCache + + cache = LibraryCache(tmp_path) + cache.prepare() + cache.cover_path("old").write_bytes(self._jpeg((384, 384))) + assert not cache.thumb_path("old").exists() + + assert cache.shrink_stored_covers() == 0 # nothing oversized... + with Image.open(cache.thumb_path("old")) as thumb: # ...but the thumb appeared + assert max(thumb.size) == THUMB_COVER_PX + # Steady state: the thumbnail is not regenerated, and is not mistaken for a cover. + before = cache.thumb_path("old").stat().st_mtime_ns + assert cache.shrink_stored_covers() == 0 + assert cache.thumb_path("old").stat().st_mtime_ns == before + assert not cache.thumb_path("old.thumb").exists() + def test_covers_written_by_an_older_version_are_migrated(self, tmp_path: Path) -> None: """The scanner reuses an unchanged album without re-reading its tags, so a cover stored before the limit existed would otherwise never be rewritten.""" diff --git a/python-backend/tests/test_web.py b/python-backend/tests/test_web.py index 90ad1fb..a0ffe27 100644 --- a/python-backend/tests/test_web.py +++ b/python-backend/tests/test_web.py @@ -90,6 +90,7 @@ async def test_library_lists_every_album_with_its_tracks(client: Client) -> None async def test_a_missing_cover_is_a_404_not_an_error(client: Client) -> None: album = await album_by_title(client, "Fuchs") assert album["has_cover"] is False + assert album["cover_v"] == 0 assert (await client.get(f"/api/albums/{album['id']}/cover")).status_code == 404