mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +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
@@ -220,6 +220,8 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
|
||||
("POST", re.compile(r"^/api/playlists/[^/]+/songs$")),
|
||||
("DELETE", re.compile(r"^/api/playlists/[^/]+/songs/.+$")),
|
||||
("POST", re.compile(r"^/api/playlists/[^/]+/reorder$")),
|
||||
("POST", re.compile(r"^/api/playlists/[^/]+/cover$")),
|
||||
("DELETE", re.compile(r"^/api/playlists/[^/]+/cover$")),
|
||||
("POST", re.compile(r"^/api/saved/toggle$")),
|
||||
# Progression (spec 010) write endpoints — demo mode stays read-only.
|
||||
("POST", re.compile(r"^/api/progression/paths$")),
|
||||
@@ -1323,14 +1325,30 @@ class MetadataDB:
|
||||
return None
|
||||
|
||||
def list_playlists(self) -> list[dict]:
|
||||
from urllib.parse import quote
|
||||
rows = self.conn.execute(
|
||||
"SELECT id, name, system_key, created_at, updated_at FROM playlists "
|
||||
"ORDER BY (system_key IS NULL), name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return [{
|
||||
"id": r[0], "name": r[1], "system_key": r[2],
|
||||
"created_at": r[3], "updated_at": r[4], "count": self._playlist_count(r[0]),
|
||||
} for r in rows]
|
||||
out = []
|
||||
for r in rows:
|
||||
pid = r[0]
|
||||
# First few still-present songs (in order) → art URLs, for a
|
||||
# content-dependent playlist cover (single art / 2x2 mosaic). The
|
||||
# JOIN drops dead songs, matching get_playlist's visibility.
|
||||
arts = self.conn.execute(
|
||||
"SELECT ps.filename FROM playlist_songs ps "
|
||||
"JOIN songs s ON s.filename = ps.filename "
|
||||
"WHERE ps.playlist_id = ? ORDER BY ps.position LIMIT 4",
|
||||
(pid,),
|
||||
).fetchall()
|
||||
out.append({
|
||||
"id": pid, "name": r[1], "system_key": r[2],
|
||||
"created_at": r[3], "updated_at": r[4],
|
||||
"count": self._playlist_count(pid),
|
||||
"art_urls": [f"/api/song/{quote(a[0])}/art" for a in arts],
|
||||
})
|
||||
return out
|
||||
|
||||
def create_playlist(self, name: str, system_key: str | None = None) -> dict:
|
||||
with self._lock:
|
||||
@@ -5149,9 +5167,35 @@ def api_song_stats(filename: str):
|
||||
|
||||
# ── Playlists / Saved for Later / Continue-Playing (fee[dB]ack v0.3.0) ────────
|
||||
|
||||
def _playlist_cover_path(pid) -> Path | None:
|
||||
"""Filesystem path of a playlist's optional custom cover image (PNG),
|
||||
stored under CONFIG_DIR. Returns None for a non-integer id."""
|
||||
try:
|
||||
pid = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return CONFIG_DIR / "playlist_covers" / f"{pid}.png"
|
||||
|
||||
|
||||
def _playlist_cover_url(pid) -> str | None:
|
||||
cover = _playlist_cover_path(pid)
|
||||
if not cover or not cover.exists():
|
||||
return None
|
||||
try:
|
||||
# Nanosecond mtime so a same-second replace/remove/re-upload still
|
||||
# changes the cache-bust token (int seconds could collide → stale image).
|
||||
mt = cover.stat().st_mtime_ns
|
||||
except OSError:
|
||||
mt = 0
|
||||
return f"/api/playlists/{pid}/cover?v={mt}"
|
||||
|
||||
|
||||
@app.get("/api/playlists")
|
||||
def api_list_playlists():
|
||||
return meta_db.list_playlists()
|
||||
lists = meta_db.list_playlists()
|
||||
for pl in lists:
|
||||
pl["cover_url"] = _playlist_cover_url(pl["id"])
|
||||
return lists
|
||||
|
||||
|
||||
@app.post("/api/playlists")
|
||||
@@ -5167,6 +5211,7 @@ def api_get_playlist(pid: int):
|
||||
pl = meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
pl["cover_url"] = _playlist_cover_url(pid)
|
||||
return pl
|
||||
|
||||
|
||||
@@ -5193,6 +5238,12 @@ def api_delete_playlist(pid: int):
|
||||
return JSONResponse({"error": "System playlists cannot be deleted."}, status_code=400)
|
||||
if not meta_db.delete_playlist(pid): # vanished under us (concurrent delete)
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
cover = _playlist_cover_path(pid) # drop any custom cover with the playlist
|
||||
if cover and cover.exists():
|
||||
try:
|
||||
cover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -5239,6 +5290,64 @@ def api_reorder_playlist(pid: int, data: dict):
|
||||
return meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@app.post("/api/playlists/{pid}/cover")
|
||||
async def api_set_playlist_cover(pid: int, data: dict):
|
||||
"""Set a playlist's custom cover from a base64 / data-URL image (PNG/JPG).
|
||||
Overrides the content-dependent (song-art) cover. Stored as a small PNG
|
||||
thumbnail under CONFIG_DIR/playlist_covers/."""
|
||||
if meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
import base64
|
||||
import io
|
||||
b64 = data.get("image", "")
|
||||
# Guard the type before the `","` membership test — a non-string image
|
||||
# (e.g. {"image": 123} / null) would otherwise raise TypeError → 500.
|
||||
# Mirrors the avatar/song-art upload guard.
|
||||
if not isinstance(b64, str) or not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
if "," in b64:
|
||||
b64 = b64.split(",", 1)[1]
|
||||
if not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
try:
|
||||
img_data = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid base64"}, status_code=400)
|
||||
cover = _playlist_cover_path(pid)
|
||||
cover.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(img_data)).convert("RGB")
|
||||
img.thumbnail((640, 640)) # covers stay small
|
||||
tmp = cover.with_suffix(".png.tmp")
|
||||
img.save(str(tmp), "PNG")
|
||||
tmp.replace(cover)
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400)
|
||||
return {"ok": True, "cover_url": _playlist_cover_url(pid)}
|
||||
|
||||
|
||||
@app.get("/api/playlists/{pid}/cover")
|
||||
def api_get_playlist_cover(pid: int):
|
||||
cover = _playlist_cover_path(pid)
|
||||
if not cover or not cover.exists():
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
# no-cache (revalidate) like song art, so a replaced cover is never served
|
||||
# stale — pairs with the mtime-ns cache-bust token on the URL.
|
||||
return FileResponse(str(cover), media_type="image/png", headers=_ART_CACHE_HEADERS)
|
||||
|
||||
|
||||
@app.delete("/api/playlists/{pid}/cover")
|
||||
def api_delete_playlist_cover(pid: int):
|
||||
cover = _playlist_cover_path(pid)
|
||||
if cover and cover.exists():
|
||||
try:
|
||||
cover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/saved/toggle")
|
||||
def api_toggle_saved(data: dict):
|
||||
"""Add/remove a song on the reserved Saved-for-Later playlist."""
|
||||
|
||||
Reference in New Issue
Block a user