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>/ <cache_dir>/
├── index.json cheap: tags and structure. Thrown away freely. ├── 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 ├── covers/<album_id>.jpg medium: the album's art - out of an ID3 APIC
│ frame, or copied from a cover.jpg in the │ frame, or copied from a cover.jpg in the
│ folder - downscaled to MAX_COVER_PX on │ folder - downscaled to MAX_COVER_PX on
@@ -102,11 +103,32 @@ class LibraryCache:
def cover_path(self, album_id: str) -> Path: def cover_path(self, album_id: str) -> Path:
return self.covers / f"{album_id}.jpg" 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: def store_cover(self, album_id: str, data: bytes) -> Path:
path = self.cover_path(album_id) 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)) 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 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: def shrink_stored_covers(self) -> int:
"""Rewrite any already-stored cover that predates :data:`MAX_COVER_PX`. """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 dimensions only parses its header, so once every file is within the limit this
costs one small read per album and nothing else. 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 rewritten = 0
for path in sorted(self.covers.glob("*.jpg")): for path in sorted(self.covers.glob("*.jpg")):
if path.name.endswith(".thumb.jpg"):
continue
try: try:
with Image.open(path) as image: with Image.open(path) as image:
oversized = max(image.size) > MAX_COVER_PX oversized = max(image.size) > MAX_COVER_PX
except (OSError, UnidentifiedImageError): except (OSError, UnidentifiedImageError):
continue continue
if not oversized: if oversized:
continue
try: try:
shrunk = shrink_cover(path.read_bytes()) shrunk = shrink_cover(path.read_bytes())
path.write_bytes(shrunk) path.write_bytes(shrunk)
except OSError: # pragma: no cover - a cache we cannot write is not fatal except OSError: # pragma: no cover - a cache we cannot write is not fatal
continue continue
rewritten += 1 rewritten += 1
# Covers stored before thumbnails existed get theirs here, without a rescan.
self._refresh_thumb(path)
return rewritten return rewritten
# ------------------------------------------------------------------ analysis # ------------------------------------------------------------------ analysis
@@ -289,24 +315,48 @@ MAX_COVER_PX: Final = 384
#: file is a third of the size. #: file is a third of the size.
_COVER_JPEG_QUALITY: Final = 85 _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: #: Baseline JPEG, 4:2:0 (see :func:`shrink_cover`). libjpeg-turbo is the fastest decoder
"""Downscale cover art to :data:`MAX_COVER_PX` on its longest edge. #: 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 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 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 unchanged: a cover that is too big is a performance problem, a cover that is missing
is a visible one. 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: try:
with Image.open(io.BytesIO(data)) as image: with Image.open(io.BytesIO(data)) as image:
if max(image.size) <= MAX_COVER_PX: if max(image.size) <= max_px:
return data 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 = 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() 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() return buffer.getvalue()
except (OSError, UnidentifiedImageError, ValueError): except (OSError, UnidentifiedImageError, ValueError):
return data return data

View File

@@ -97,27 +97,32 @@ def build_router(
) )
@router.get("/albums/{album_id}/cover") @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) album = app.library.get(album_id)
if album is None or album.cover is None: if album is None or album.cover is None:
# Not an error: the client paints the album's own colours instead. # Not an error: the client paints the album's own colours instead.
raise HTTPException(status_code=404, detail="no cover") 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( return FileResponse(
album.cover, path,
# This used to send `public, max-age=604800`, on the reasoning that a cover # A URL carrying `v` (the file's mtime, from `AlbumOut.cover_v`) names one
# is "content-addressed by album id". It is not: the id addresses *which # exact set of bytes: reprocessing the art changes the library payload, so
# album* the art belongs to, and the bytes behind that URL change whenever # the client asks for a new URL. Those can be cached forever, and the
# the art is reprocessed. When `MAX_COVER_PX` arrived and every cover was # browser then never asks again - no conditional request per card.
# 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.
# #
# `no-cache` does not mean "do not store", it means "revalidate before # Without `v` the id alone is not enough (the bytes behind it change whenever
# reusing" - so the browser keeps the file and usually gets a 304. The cost # the art is reprocessed - when `MAX_COVER_PX` arrived, browsers went on
# is one conditional request per cover, and it is not on the paint path at # decoding old 3000px files out of their cache), so `no-cache` makes the
# all: the service worker answers covers from its own cache first and does # browser revalidate: it keeps the file and usually gets a 304.
# the revalidation behind the page (see web/public/sw.js). headers={
headers={"Cache-Control": "no-cache"}, "Cache-Control": (
"public, max-age=31536000, immutable" if v else "no-cache"
)
},
) )
@router.get("/tracks/{album_id}/{index}/analysis") @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): class AlbumOut(BaseModel):
id: str id: str
section: str section: str
@@ -131,6 +140,9 @@ class AlbumOut(BaseModel):
category: str category: str
colors: list[str] colors: list[str]
has_cover: bool 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 duration: float
tracks: list[TrackOut] tracks: list[TrackOut]
#: Every track is still locked - the browse view shows a question mark instead of #: Every track is still locked - the browse view shows a question mark instead of
@@ -151,6 +163,7 @@ class AlbumOut(BaseModel):
category=album.category, category=album.category,
colors=list(album.colors), colors=list(album.colors),
has_cover=album.cover is not None, has_cover=album.cover is not None,
cover_v=_cover_version(album),
duration=album.duration, duration=album.duration,
locked=lock.locked if lock is not None else False, locked=lock.locked if lock is not None else False,
tracks=[ tracks=[

View File

@@ -17,6 +17,7 @@ import httpx2
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.responses import Response
from musicmouse.app import App from musicmouse.app import App
from musicmouse.config import WebConfig from musicmouse.config import WebConfig
@@ -28,6 +29,18 @@ _log = logging.getLogger(__name__)
__all__ = ["WebService", "build_app"] __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( def build_app(
app: App, app: App,
config: WebConfig, config: WebConfig,
@@ -50,7 +63,7 @@ def build_app(
if config.static_dir.is_dir(): if config.static_dir.is_dir():
# Mounted last and at the root so every /api route still wins; html=True # 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. # 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: else:
_log.warning( _log.warning(
"web.static_dir %s does not exist; serving the API only " "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. # The untouched original still comes back, because colour extraction wants it.
assert art == (folder / "cover.jpg").read_bytes() 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: 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 """The scanner reuses an unchanged album without re-reading its tags, so a
cover stored before the limit existed would otherwise never be rewritten.""" 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: async def test_a_missing_cover_is_a_404_not_an_error(client: Client) -> None:
album = await album_by_title(client, "Fuchs") album = await album_by_title(client, "Fuchs")
assert album["has_cover"] is False assert album["has_cover"] is False
assert album["cover_v"] == 0
assert (await client.get(f"/api/albums/{album['id']}/cover")).status_code == 404 assert (await client.get(f"/api/albums/{album['id']}/cover")).status_code == 404