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>/
|
<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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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=[
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user