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
+40
-3
@@ -28,6 +28,22 @@
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// Content-dependent playlist cover: a custom uploaded cover wins; otherwise
|
||||
// the playlist's own song art — the icon when empty, one cover for a few
|
||||
// songs, a 2×2 mosaic at 4+. `art_urls` / `cover_url` come from /api/playlists.
|
||||
function playlistCoverHtml(p) {
|
||||
const box = 'w-full aspect-square rounded-lg overflow-hidden bg-fb-bg/50 mb-3';
|
||||
const img = (u, cls) => '<img src="' + esc(u) + '" alt="" class="' + cls + '" onerror="this.style.visibility=\'hidden\'">';
|
||||
if (p.cover_url) return '<div class="' + box + '">' + img(p.cover_url, 'w-full h-full object-cover') + '</div>';
|
||||
const arts = Array.isArray(p.art_urls) ? p.art_urls : [];
|
||||
if (!arts.length) {
|
||||
return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.system_key ? '🔖' : '🎵') + '</div>';
|
||||
}
|
||||
if (arts.length < 4) return '<div class="' + box + '">' + img(arts[0], 'w-full h-full object-cover') + '</div>';
|
||||
return '<div class="' + box + ' grid grid-cols-2 grid-rows-2 gap-px">' +
|
||||
arts.slice(0, 4).map((u) => img(u, 'w-full h-full object-cover')).join('') + '</div>';
|
||||
}
|
||||
|
||||
function songRow(s, opts) {
|
||||
opts = opts || {};
|
||||
const handle = opts.draggable
|
||||
@@ -96,8 +112,7 @@
|
||||
(lists.length
|
||||
? '<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
|
||||
'<button data-pl="' + p.id + '" class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
|
||||
'<div class="w-full aspect-square rounded-lg bg-fb-bg/50 mb-3 flex items-center justify-center text-fb-textDim">' +
|
||||
(p.system_key ? '🔖' : '🎵') + '</div>' +
|
||||
playlistCoverHtml(p) +
|
||||
'<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim">' + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
|
||||
'</button>').join('') + '</div>'
|
||||
@@ -126,8 +141,11 @@
|
||||
'<h2 class="text-3xl font-bold text-fb-text truncate">' + esc(pl.name) + '</h2>' +
|
||||
(isSystem ? '' :
|
||||
'<div class="flex gap-2 shrink-0">' +
|
||||
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
|
||||
(pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
|
||||
'<button id="v3-pl-rename" class="text-sm text-fb-textDim hover:text-fb-text px-2">Rename</button>' +
|
||||
'<button id="v3-pl-delete" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Delete</button></div>') +
|
||||
'<button id="v3-pl-delete" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Delete</button>' +
|
||||
'<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden"></div>') +
|
||||
'</div>' +
|
||||
(pl.songs.length
|
||||
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem })).join('') + '</ul>'
|
||||
@@ -147,6 +165,25 @@
|
||||
await fetch('/api/playlists/' + pid, { method: 'DELETE' });
|
||||
renderPlaylists();
|
||||
});
|
||||
// Custom cover: pick an image → upload as a data URL → the playlist card
|
||||
// shows it (overriding the song-art cover). Re-render the detail so the
|
||||
// Remove-cover button appears; the grid picks up the new cover on return.
|
||||
const coverFile = root.querySelector('#v3-pl-cover-file');
|
||||
root.querySelector('#v3-pl-cover')?.addEventListener('click', () => coverFile && coverFile.click());
|
||||
coverFile?.addEventListener('change', () => {
|
||||
const f = coverFile.files && coverFile.files[0];
|
||||
if (!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
await jsend('POST', '/api/playlists/' + pid + '/cover', { image: e.target.result });
|
||||
renderPlaylistDetail(pid);
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
});
|
||||
root.querySelector('#v3-pl-cover-rm')?.addEventListener('click', async () => {
|
||||
await fetch('/api/playlists/' + pid + '/cover', { method: 'DELETE' });
|
||||
renderPlaylistDetail(pid);
|
||||
});
|
||||
}
|
||||
|
||||
// ── #v3-saved ─────────────────────────────────────────────────────────--
|
||||
|
||||
Reference in New Issue
Block a user