Backend: cover cache and web API changes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user