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

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