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