feat(v3): content-dependent playlist covers + custom art (#626)

* feat(v3): content-dependent playlist covers + custom art upload

Playlist cards were a tiny 🎵 emoji on an empty square. Now the cover reflects
the playlist's contents, and you can override it with a custom image.

Cover (in priority order):
- custom uploaded cover, else
- empty playlist  -> the icon
- a few songs     -> the first song's album art
- 4+ songs        -> a 2x2 album-art mosaic

Backend (server.py):
- MetadataDB.list_playlists() returns each playlist's first few still-present
  songs' art URLs (`art_urls`) for the content cover.
- GET /api/playlists and GET /api/playlists/{id} add `cover_url` when a custom
  cover exists.
- POST/GET/DELETE /api/playlists/{id}/cover — store a small PNG thumbnail under
  CONFIG_DIR/playlist_covers/ (PIL-converted, mirroring song-art upload); the
  cover is deleted with the playlist. Cover mutators added to _MUTATING_ROUTES.

Frontend (static/v3/playlists.js): playlistCoverHtml(p) renders the rules above;
the playlist detail view gets "Cover" (pick an image) + "Remove cover".

Tests: tests/test_playlists_api.py (art_urls + cover roundtrip / reject-non-image
/ delete-removes-cover — 11 pass) and tests/js/v3_playlist_cover.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(playlists): 400 (not 500) on non-string cover image + bust same-second cover cache

Two review follow-ups on the playlist-cover endpoints:

- POST /cover did `if "," in b64` before any type check, so a non-string
  image (e.g. {"image": 123} / null) raised TypeError -> 500. Guard with
  isinstance (mirrors the avatar/song-art upload) for a clean 400. +regression
  test covering number/null/object/list.

- The cover URL busted only on int(st_mtime) (1s granularity) and GET /cover
  sent no cache headers, so a same-second replace/remove/re-upload could serve
  a stale image. Use st_mtime_ns in the cache-bust token and add the shared
  no-cache header (_ART_CACHE_HEADERS), matching song art.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-06-28 14:10:15 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent 3d97c07b2b
commit 90fb2ee3bc
5 changed files with 247 additions and 8 deletions
+64
View File
@@ -106,3 +106,67 @@ def test_playlist_hides_dead_songs_when_library_populated(client, server):
names = [s["filename"] for s in pl["songs"]]
assert "live.archive" in names and "ghost.archive" not in names
assert [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]["count"] == 1
# ── Playlist covers (content-dependent art + custom upload) ──────────────────
def _png_b64():
"""A tiny base64 PNG with the data-URL prefix, like the browser sends."""
import base64
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (4, 4), (200, 30, 60)).save(buf, "PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
def test_list_includes_song_art_urls_for_content_cover(client, server):
for fn in ("x.archive", "y.archive"):
server.meta_db.put(fn, 0, 0, {})
pid = client.post("/api/playlists", json={"name": "Arts"}).json()["id"]
client.post(f"/api/playlists/{pid}/songs", json={"filename": "x.archive"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "y.archive"})
pl = [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]
assert pl["art_urls"] == ["/api/song/x.archive/art", "/api/song/y.archive/art"]
assert pl["cover_url"] is None # no custom cover yet
def test_custom_cover_roundtrip(client):
pid = client.post("/api/playlists", json={"name": "Cover"}).json()["id"]
r = client.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
assert r.status_code == 200 and r.json()["ok"] is True
assert r.json()["cover_url"].startswith(f"/api/playlists/{pid}/cover")
# list + detail both report it
assert [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]["cover_url"]
assert client.get(f"/api/playlists/{pid}").json()["cover_url"]
# served as a real PNG
img = client.get(f"/api/playlists/{pid}/cover")
assert img.status_code == 200 and img.headers["content-type"] == "image/png"
assert img.content[:8] == b"\x89PNG\r\n\x1a\n"
# removed
assert client.delete(f"/api/playlists/{pid}/cover").json() == {"ok": True}
assert client.get(f"/api/playlists/{pid}/cover").status_code == 404
assert client.get(f"/api/playlists/{pid}").json()["cover_url"] is None
def test_cover_rejects_non_image(client):
pid = client.post("/api/playlists", json={"name": "Bad"}).json()["id"]
assert client.post(f"/api/playlists/{pid}/cover",
json={"image": "data:text/plain;base64,bm90IGFuIGltYWdl"}).status_code == 400
assert client.post(f"/api/playlists/{pid}/cover", json={"image": ""}).status_code == 400
def test_cover_rejects_non_string_image_with_400_not_500(client):
# A non-string `image` (number / null / object) must be a clean 400, not a
# 500 from `"," in <non-str>` raising TypeError before the type check.
pid = client.post("/api/playlists", json={"name": "Typed"}).json()["id"]
for bad in (123, None, {"x": 1}, ["a"]):
assert client.post(f"/api/playlists/{pid}/cover", json={"image": bad}).status_code == 400
def test_deleting_playlist_removes_custom_cover(client, server):
pid = client.post("/api/playlists", json={"name": "Doomed"}).json()["id"]
client.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
assert server._playlist_cover_path(pid).exists()
client.delete(f"/api/playlists/{pid}")
assert not server._playlist_cover_path(pid).exists()