mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
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:
co-authored by
Claude Opus 4.8
byrongamatos
parent
3d97c07b2b
commit
90fb2ee3bc
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user