Backend: cover cache and web API changes

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 11:42:33 +02:00
parent 794126bfdb
commit 9fd106b757
6 changed files with 148 additions and 31 deletions

View File

@@ -5,6 +5,7 @@ different amounts to produce::
<cache_dir>/
├── index.json cheap: tags and structure. Thrown away freely.
├── covers/<album_id>.thumb.jpg the same art at THUMB_COVER_PX, for cards and rows
├── covers/<album_id>.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 ``<id>.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

View File

@@ -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")

View File

@@ -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=[

View File

@@ -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/<name>-<content hash>.<ext>``: 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 "

View File

@@ -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."""

View File

@@ -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