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
+28
View File
@@ -0,0 +1,28 @@
// Guard for the content-dependent playlist cover (playlists.js). A custom
// uploaded cover wins; otherwise the playlist's song art decides: icon when
// empty, a single cover for a few songs, a 2×2 mosaic at 4+. (Rendering is DOM
// glue, so this is a source-level guard on the decision branches.)
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const PL = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'v3', 'playlists.js'), 'utf8');
test('custom cover_url takes priority', () => {
assert.match(PL, /function playlistCoverHtml\(p\)/);
assert.match(PL, /if \(p\.cover_url\) return/);
});
test('empty → icon, <4 → single art, 4+ → 2×2 mosaic', () => {
assert.match(PL, /if \(!arts\.length\)[\s\S]{0,160}(🔖|🎵)/); // empty → icon
assert.match(PL, /arts\.length < 4\) return[\s\S]{0,120}arts\[0\]/); // a few → single cover
assert.match(PL, /grid-cols-2 grid-rows-2[\s\S]{0,120}slice\(0, 4\)/); // 4+ → mosaic
});
test('the card uses playlistCoverHtml (not the old static emoji box)', () => {
assert.match(PL, /playlistCoverHtml\(p\)/);
});