diff --git a/server.py b/server.py index 8660931..632d800 100644 --- a/server.py +++ b/server.py @@ -772,6 +772,23 @@ class MetadataDB: self.conn.execute("ALTER TABLE playlists ADD COLUMN rules TEXT") except sqlite3.OperationalError: pass + # Curated album (P6, metadata-design §7.2): a playlists row with + # kind='album' is a hand-picked, ORDERED practice set of works with a + # chosen chart per slot — the repeatable gameplay loop. Reuses the + # playlist machinery wholesale (membership/order/cover/queue); the whole + # schema delta is this `kind` discriminator plus two per-slot columns: + # `arrangement` = the pinned arrangement NAME (names survive rescans; + # the client resolves name→index at play), `work_key` = stamped at + # add-time so a slot whose pinned chart is later deleted can self-heal + # to the work's CURRENT preferred at read (never rewritten). Additive, + # idempotent — same pattern as `rules` above. + for _ddl in ("ALTER TABLE playlists ADD COLUMN kind TEXT", + "ALTER TABLE playlist_songs ADD COLUMN arrangement TEXT", + "ALTER TABLE playlist_songs ADD COLUMN work_key TEXT"): + try: + self.conn.execute(_ddl) + except sqlite3.OperationalError: + pass # Wishlist / "wanted" (feedBack#636 item 4): a persisted, actionable # list of songs the user does NOT own yet — the *arr "Wanted/Monitored" # analogue. Unlike playlists (which reference owned local songs by @@ -1984,10 +2001,26 @@ class MetadataDB: # ── Playlists ─────────────────────────────────────────────────────────-- SAVED_KEY = "saved_for_later" - def _playlist_count(self, pid: int) -> int: - # Count only songs that still exist (mirrors the stats read-filter — dead - # songs are hidden, not deleted on scan), passing through when the songs - # table is empty. Single statement → no probe-then-read race. + def _playlist_count(self, pid: int, kind: str | None = None) -> int: + # An ALBUM keeps every slot in its denominator: get_playlist renders / + # plays ALL slots — self-healing orphans and even fully-missing works + # (§7.2) stay visible — so the list-card count must agree with the detail + # view and skip the dead-filter (is_album → no `AND s.filename IS NOT + # NULL`, mirroring get_playlist). Mixes/other kinds count only songs that + # still exist (mirrors the stats read-filter — dead songs are hidden, not + # deleted on scan), passing through when the songs table is empty. Single + # statement → no probe-then-read race. `kind` is passed by list_playlists + # (already in hand); fetched here when a caller omits it. + if kind is None: + row = self.conn.execute( + "SELECT kind FROM playlists WHERE id = ?", (pid,) + ).fetchone() + kind = row[0] if row else None + if kind == "album": + return self.conn.execute( + "SELECT COUNT(*) FROM playlist_songs WHERE playlist_id = ?", + (pid,), + ).fetchone()[0] return self.conn.execute( "SELECT COUNT(*) FROM playlist_songs ps WHERE ps.playlist_id = ? " "AND EXISTS (SELECT 1 FROM songs s WHERE s.filename = ps.filename)", @@ -2023,7 +2056,7 @@ class MetadataDB: 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 " + "SELECT id, name, system_key, created_at, updated_at, kind FROM playlists " "WHERE rules IS NULL " # smart collections live in the source picker, not here "ORDER BY (system_key IS NULL), name COLLATE NOCASE" ).fetchall() @@ -2041,18 +2074,19 @@ class MetadataDB: ).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), + "created_at": r[3], "updated_at": r[4], "kind": r[5], + "count": self._playlist_count(pid, r[5]), "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: + def create_playlist(self, name: str, system_key: str | None = None, + kind: str | None = None) -> dict: with self._lock: cur = self.conn.execute( - "INSERT INTO playlists (name, system_key, created_at, updated_at) " - "VALUES (?, ?, datetime('now'), datetime('now'))", - (name, system_key), + "INSERT INTO playlists (name, system_key, kind, created_at, updated_at) " + "VALUES (?, ?, ?, datetime('now'), datetime('now'))", + (name, system_key, kind), ) self.conn.commit() pid = cur.lastrowid @@ -2167,50 +2201,145 @@ class MetadataDB: # get_playlist uniformly 404s on a collection id — collections are # managed only through /api/collections. head = self.conn.execute( - "SELECT id, name, system_key, created_at, updated_at FROM playlists " + "SELECT id, name, system_key, created_at, updated_at, kind FROM playlists " "WHERE id = ? AND rules IS NULL", (pid,) ).fetchone() if not head: return None + is_album = head[5] == "album" + # Mixes hide dead songs (race-free; not deleted on scan). An ALBUM keeps + # every slot: a slot whose pinned chart was deleted self-heals to the + # work's current preferred at READ (§7.2 orphan-at-play — never a + # membership rewrite), and reports `missing` when the whole work is gone + # so the practice set keeps its denominator visible. + dead_filter = "" if is_album else "AND s.filename IS NOT NULL" rows = self.conn.execute( - """SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name + f"""SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name, + ps.arrangement, ps.work_key, s.arrangements, + (s.filename IS NULL) AS dead FROM playlist_songs ps LEFT JOIN songs s ON s.filename = ps.filename - WHERE ps.playlist_id = ? - -- hide dead songs (race-free; not deleted on scan) - AND s.filename IS NOT NULL + WHERE ps.playlist_id = ? {dead_filter} ORDER BY ps.position, ps.filename""", (pid,), ).fetchall() from urllib.parse import quote - songs = [{ - "filename": r[0], "position": r[1], - "title": r[2] or r[0], "artist": r[3] or "", "tuning_name": r[4] or "", - "art_url": f"/api/song/{quote(r[0])}/art", - } for r in rows] + songs = [] + for r in rows: + entry = { + "filename": r[0], "position": r[1], + "title": r[2] or r[0], "artist": r[3] or "", "tuning_name": r[4] or "", + "art_url": f"/api/song/{quote(r[0])}/art", + } + if is_album: + entry["arrangement"] = r[5] + entry["work_key"] = r[6] + try: + entry["arrangements"] = _ensure_smart_names(json.loads(r[7]) if r[7] else []) + except Exception: + entry["arrangements"] = [] + if r[8]: + entry.update(self._resolve_album_orphan(r[6])) + songs.append(entry) return { "id": head[0], "name": head[1], "system_key": head[2], "created_at": head[3], "updated_at": head[4], "songs": songs, + **({"kind": head[5]} if head[5] else {}), } + def _resolve_album_orphan(self, work_key: str | None) -> dict: + """A deleted album slot resolves to its work's CURRENT preferred/auto + pick at read (§7.2): the slot plays `resolved_filename` today, and if + the pinned file reappears (rescan) it simply resolves back to itself — + no rewrite in either direction. A work with no charts left reports + `missing` (the row stays, dimmed, so the set's denominator is honest).""" + if work_key: + self._ensure_work_display() + row = self.conn.execute( + "SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements " + "FROM work_display wd JOIN songs s ON s.filename = wd.filename " + "WHERE wd.effective_work_key = ? AND wd.is_group_representative = 1", + (work_key,)).fetchone() + if row: + from urllib.parse import quote + try: + arrs = _ensure_smart_names(json.loads(row[4]) if row[4] else []) + except Exception: + arrs = [] + return {"resolved_filename": row[0], "title": row[1] or row[0], + "artist": row[2] or "", "tuning_name": row[3] or "", + "arrangements": arrs, + "art_url": f"/api/song/{quote(row[0])}/art", + "resolved_from_orphan": True} + return {"missing": True} + def add_playlist_song(self, pid: int, filename: str): with self._lock: # Re-check existence INSIDE the lock: the handler's earlier 404 check # is a separate step, so a concurrent delete_playlist could land # between them and leave an orphan playlist_songs row. Returning None # lets the handler answer 404 instead of inserting an orphan. - if not self.conn.execute("SELECT 1 FROM playlists WHERE id = ?", (pid,)).fetchone(): + row = self.conn.execute("SELECT kind FROM playlists WHERE id = ?", (pid,)).fetchone() + if not row: return None + # Album slots stamp the work identity at ADD time (§7.2 "resolved to + # preferred once at add, pinned thereafter") — it's what lets a + # later-deleted chart's slot self-heal to the work's current keeper. + wk = self.work_key_for(filename) if row[0] == "album" else None nxt = self.conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_songs WHERE playlist_id = ?", (pid,) ).fetchone()[0] cur = self.conn.execute( - "INSERT OR IGNORE INTO playlist_songs (playlist_id, filename, position) VALUES (?, ?, ?)", - (pid, filename, nxt), + "INSERT OR IGNORE INTO playlist_songs (playlist_id, filename, position, work_key) " + "VALUES (?, ?, ?, ?)", + (pid, filename, nxt, wk), ) self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,)) self.conn.commit() return cur.rowcount > 0 + _SLOT_KEEP = object() # sentinel: "leave the arrangement pin unchanged" + + def update_playlist_slot(self, pid: int, filename: str, + new_filename: str | None = None, + arrangement=_SLOT_KEEP): + """Edit ONE album slot in place (§7.2): pin/clear its arrangement (a + NAME — names survive rescans; None clears back to full-song) and/or swap + the slot's chart for another chart of the SAME work, keeping position + + pin — the per-slot pick is deliberately independent of the work's + global preferred. Returns the slot's (possibly new) filename, or None + when the slot doesn't exist, the swap target isn't a chart of the + slot's work, or it's already in the playlist.""" + with self._lock: + row = self.conn.execute( + "SELECT position, work_key FROM playlist_songs " + "WHERE playlist_id = ? AND filename = ?", (pid, filename)).fetchone() + if not row: + return None + out_fn = filename + if new_filename and new_filename != filename: + # Same-work guard: the stored stamp wins (works even when the + # pinned file is gone); fall back to computing from the row. + wk_slot = row[1] or self.work_key_for(filename) + if not wk_slot or self.work_key_for(new_filename) != wk_slot: + return None + if self.conn.execute( + "SELECT 1 FROM playlist_songs WHERE playlist_id = ? AND filename = ?", + (pid, new_filename)).fetchone(): + return None + self.conn.execute( + "UPDATE playlist_songs SET filename = ?, work_key = ? " + "WHERE playlist_id = ? AND filename = ?", + (new_filename, wk_slot, pid, filename)) + out_fn = new_filename + if arrangement is not self._SLOT_KEEP: + self.conn.execute( + "UPDATE playlist_songs SET arrangement = ? " + "WHERE playlist_id = ? AND filename = ?", + (arrangement, pid, out_fn)) + self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,)) + self.conn.commit() + return out_fn + def remove_playlist_song(self, pid: int, filename: str) -> bool: with self._lock: cur = self.conn.execute( @@ -7041,7 +7170,12 @@ def api_create_playlist(data: dict): name = _clean_str(data.get("name")) if not (1 <= len(name) <= 100): return JSONResponse({"error": "Playlist name must be 1–100 characters."}, status_code=400) - return meta_db.create_playlist(name) + # kind='album' = a curated album (§7.2): hand-picked works, a chosen chart + # per slot, played front-to-back on the queue. Absent/None = a regular mix. + kind = _clean_str(data.get("kind")) or None + if kind not in (None, "album"): + return JSONResponse({"error": "kind must be 'album' or omitted"}, status_code=400) + return meta_db.create_playlist(name, kind=kind) @app.get("/api/playlists/{pid}") @@ -7098,6 +7232,36 @@ def api_add_playlist_song(pid: int, data: dict): return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404) +@app.patch("/api/playlists/{pid}/songs/{filename:path}") +def api_update_playlist_slot(pid: int, filename: str, data: dict): + """Edit one curated-album slot: {"arrangement": name|null} pins/clears the + slot's arrangement; {"chart_filename": fn} swaps the slot to another chart + of the same work (position + pin kept). Albums only — a mix has no slots.""" + pl = meta_db.get_playlist(pid) + if pl is None: + return JSONResponse({"error": "not found"}, status_code=404) + if pl.get("kind") != "album": + return JSONResponse({"error": "Slot editing is for albums."}, status_code=400) + kwargs = {} + if "chart_filename" in data: + new_fn = _clean_str(data.get("chart_filename")) + if not new_fn: + return JSONResponse({"error": "chart_filename must be a filename"}, status_code=400) + kwargs["new_filename"] = new_fn + if "arrangement" in data: + arr = data.get("arrangement") + if arr is not None and not (isinstance(arr, str) and 1 <= len(arr.strip()) <= 100): + return JSONResponse({"error": "arrangement must be a name or null"}, status_code=400) + kwargs["arrangement"] = arr.strip() if isinstance(arr, str) else None + if not kwargs: + return JSONResponse({"error": "nothing to update"}, status_code=400) + if meta_db.update_playlist_slot(pid, filename, **kwargs) is None: + return JSONResponse( + {"error": "no such slot, or the chart isn't a version of this song"}, + status_code=400) + return meta_db.get_playlist(pid) + + @app.delete("/api/playlists/{pid}/songs/{filename:path}") def api_remove_playlist_song(pid: int, filename: str): if meta_db.get_playlist(pid) is None: diff --git a/static/v3/playlists.js b/static/v3/playlists.js index f23bbe0..8fc6bb7 100644 --- a/static/v3/playlists.js +++ b/static/v3/playlists.js @@ -37,26 +37,59 @@ if (p.cover_url) return '
' + img(p.cover_url, 'w-full h-full object-cover') + '
'; const arts = Array.isArray(p.art_urls) ? p.art_urls : []; if (!arts.length) { - return '
' + (p.system_key ? '🔖' : '🎵') + '
'; + return '
' + (p.kind === 'album' ? '💿' : p.system_key ? '🔖' : '🎵') + '
'; } if (arts.length < 4) return '
' + img(arts[0], 'w-full h-full object-cover') + '
'; return '
' + arts.slice(0, 4).map((u) => img(u, 'w-full h-full object-cover')).join('') + '
'; } + // The slot's pinned-arrangement INDEX, resolved from the stored NAME + // against the slot's current chart (names survive rescans; an index + // wouldn't). null = no pin / the name isn't on this chart → full song. + function _slotArrIndex(s) { + if (!s.arrangement || !Array.isArray(s.arrangements)) return null; + const m = s.arrangements.find((a) => a && (a.smart_name === s.arrangement || a.name === s.arrangement)); + return (m && m.index != null) ? m.index : null; + } + function songRow(s, opts) { opts = opts || {}; const handle = opts.draggable ? '' : ''; const tuning = s.tuning_name ? '' + esc(s.tuning_name) + '' : ''; - return '
  • ' + + // ── Curated-album slot extras (P6) — mixes/saved emit none of this ── + // A slot plays its RESOLVED chart (data-play-fn: the pinned file, or + // the work's current keeper when the pinned file is gone) with its + // pinned arrangement (data-play-arr); `missing` = the whole work left + // the library, so the row dims and loses play (denominator stays + // honest). ▾ opens the slot editor (chart + arrangement pin). + const isAlbum = !!opts.album; + const missing = isAlbum && !!s.missing; + const playFn = s.resolved_filename || s.filename; + const arrIdx = isAlbum ? _slotArrIndex(s) : null; + const playAttrs = isAlbum && !missing + ? ' data-play-fn="' + esc(playFn) + '"' + (arrIdx != null ? ' data-play-arr="' + arrIdx + '"' : '') + : ''; + const acc = (isAlbum && typeof opts.acc === 'number') + ? '' + Math.round(opts.acc * 100) + '%' + : ''; + const pin = (isAlbum && s.arrangement) + ? '' + esc(s.arrangement) + '' : ''; + const orphan = (isAlbum && s.resolved_from_orphan) + ? '(auto)' : ''; + const slotBtn = (isAlbum && !missing) + ? '' : ''; + return '
  • ' + handle + '' + - '' + esc(s.title) + tuning + '' + - '' + esc(s.artist) + '' + - '' + + '' + esc(s.title) + tuning + pin + orphan + '' + + '' + (missing ? 'Missing — no version of this song is in your library' : esc(s.artist)) + '' + + acc + + (missing ? '' : '') + + slotBtn + '' + '
  • '; } @@ -68,7 +101,14 @@ // playSong decodeURIComponent()s its arg for the highway WS, so // pass an encoded filename (like the rest of v3) — a raw name // with %/#/?/ in it would otherwise misroute or throw. - if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(fn)); + // Album slots override the play target (data-play-fn = the + // orphan-resolved chart) + pass the pinned arrangement index; + // mix/saved rows carry neither attribute and behave as before. + const pfn = li.getAttribute('data-play-fn') || fn; + const pa = li.getAttribute('data-play-arr'); + if (typeof window.playSong === 'function') { + window.playSong(encodeURIComponent(pfn), pa == null ? undefined : Number(pa)); + } }); li.querySelector('[data-remove]')?.addEventListener('click', async () => { await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' }); @@ -106,7 +146,10 @@ const lists = (await jget('/api/playlists')) || []; root.innerHTML = '
    ' + - '
    ' + + '
    ' + + // Curated album (P6): a hand-picked ORDERED set with a chosen chart + // per track — same machinery as a playlist, kind='album'. + '' + '' + '
    ' + (lists.length @@ -114,7 +157,7 @@ '').join('') + '
    ' : '

    No playlists yet. Create one to group songs.

    ') + '
    '; @@ -124,6 +167,12 @@ await jsend('POST', '/api/playlists', { name }); renderPlaylists(); }); + root.querySelector('#v3-pl-new-album')?.addEventListener('click', async () => { + const name = ((await window.uiPrompt({ title: 'New Album', label: 'Album name', okLabel: 'Create', placeholder: 'My Album' })) || '').trim(); + if (!name) return; + await jsend('POST', '/api/playlists', { name, kind: 'album' }); + renderPlaylists(); + }); root.querySelectorAll('[data-pl]').forEach((b) => b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10)))); } @@ -134,13 +183,35 @@ const pl = await jget('/api/playlists/' + pid); if (!pl) { renderPlaylists(); return; } const isSystem = !!pl.system_key; + const isAlbum = pl.kind === 'album'; + // Set-scoped repertoire (P6, §7.2): an album is a bounded practice SET + // with a denominator — "N of M mastered", per-track accuracy, never one + // album score. Same 0.9 threshold as the library meter/green badge. + let best = {}; + if (isAlbum) best = (await jget('/api/stats/best')) || {}; + const slotAcc = (s) => best[s.resolved_filename || s.filename]; + let meter = ''; + if (isAlbum && pl.songs.length) { + const tracks = pl.songs.filter((s) => !s.missing); + const mastered = tracks.filter((s) => (slotAcc(s) || 0) >= 0.9).length; + const started = tracks.filter((s) => { const b = slotAcc(s); return typeof b === 'number' && b > 0 && b < 0.9; }).length; + const pct = tracks.length ? Math.max(0, Math.min(100, Math.round((mastered / tracks.length) * 100))) : 0; + meter = + '
    ' + + '
    ' + + 'Album repertoire' + + '' + mastered + ' of ' + tracks.length + ' mastered' + + (started ? ' · ' + started + ' in progress' : '') + '
    ' + + '
    ' + + '
    '; + } root.innerHTML = '
    ' + '' + '
    ' + - '

    ' + esc(pl.name) + '

    ' + + '

    ' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '

    ' + '
    ' + - (pl.songs.length ? '' : '') + + (pl.songs.length ? '' : '') + (isSystem ? '' : '' + (pl.cover_url ? '' : '') + @@ -149,22 +220,46 @@ '') + '
    ' + '
    ' + + meter + (pl.songs.length - ? '' - : '

    Empty — add songs from the library.

    ') + + ? '' + : '

    Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.

    ') + '
    '; root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists); // Play all: start the play-queue with this playlist's songs (auto-advances // track to track). Falls back to playing the first song on an older core - // without the queue, so the button always does something. + // without the queue, so the button always does something. An ALBUM plays + // each slot's resolved chart with its pinned arrangement (playQueue's + // per-index arrangements array, #685) and skips missing works. root.querySelector('#v3-pl-playall')?.addEventListener('click', () => { - const files = (pl.songs || []).map((s) => s.filename).filter(Boolean); + const files = [], arrs = []; + (pl.songs || []).forEach((s) => { + if (isAlbum && s.missing) return; + const fn = s.resolved_filename || s.filename; + if (!fn) return; + files.push(fn); + const idx = isAlbum ? _slotArrIndex(s) : null; + arrs.push(idx == null ? undefined : idx); + }); if (!files.length) return; - if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.start(files, { source: pl.name }); - else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0])); + if (window.feedBack && window.feedBack.playQueue) { + window.feedBack.playQueue.start(files, isAlbum + ? { source: pl.name, arrangements: arrs } + : { source: pl.name }); + } else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0])); }); const listEl = root.querySelector('#v3-pl-songs'); if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid)); + // Album slot editor (▾ per row): pick the slot's chart + arrangement. + if (listEl && isAlbum) { + listEl.querySelectorAll('li[data-fn]').forEach((li) => { + li.querySelector('[data-slot]')?.addEventListener('click', () => { + const fn = li.getAttribute('data-fn'); + const slot = (pl.songs || []).find((x) => x.filename === fn); + if (slot) openSlotPicker(pid, slot, () => renderPlaylistDetail(pid)); + }); + }); + } root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => { const name = ((await window.uiPrompt({ title: 'Rename Playlist', label: 'Playlist name', value: pl.name, okLabel: 'Rename' })) || '').trim(); if (!name) return; @@ -197,6 +292,94 @@ }); } + // ── Curated-album slot editor (P6, §7.2) ───────────────────────────────── + // Pins THIS slot's chart + arrangement. The per-slot pick is deliberately + // independent of the work's global preferred — a rehearsed set must stay + // the same notes even if the global keeper is re-picked later. Charts come + // from the work-charts API; the arrangement pin is stored as a NAME (it + // survives rescans; the index is resolved at play). A compact overlay for + // now — unifying with the library's Charts drawer (slot-scoped mode) is a + // follow-up once the in-flight drawer changes land. + async function openSlotPicker(pid, slot, onChange) { + const curFn = slot.resolved_filename || slot.filename; + let wk = slot.work_key; + if (!wk) { + const w = await jget('/api/chart/' + encodeURIComponent(curFn) + '/work'); + wk = w && w.work_key; + } + const charts = wk ? await jget('/api/work/' + encodeURIComponent(wk) + '/charts') : null; + const chartList = (charts && Array.isArray(charts.charts)) ? charts.charts : []; + const radio = (name, value, checked, label, sub) => + ''; + // Checked = the stored pin; an orphaned slot (stored file gone from the + // list) pre-checks the chart it currently resolves to, so Apply re-pins + // what's actually playing. + const slotInList = chartList.some((x) => x.filename === slot.filename); + const chartRows = chartList.map((c) => radio( + 'slot-chart', c.filename, + c.filename === slot.filename || (!slotInList && c.filename === curFn), + esc(c.title) + (c.is_representative ? ' ● preferred' : ''), + esc((c.tuning_name ? c.tuning_name + ' · ' : '') + c.filename))).join(''); + const arrRows = [radio('slot-arr', '', !slot.arrangement, 'Full song (default)', '')] + .concat((slot.arrangements || []).map((a) => { + const name = (a && (a.smart_name || a.name)) || ''; + if (!name) return ''; + return radio('slot-arr', name, + slot.arrangement === name || slot.arrangement === a.name, + esc(name), ''); + })).join(''); + const overlay = document.createElement('div'); + overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; + overlay.innerHTML = + '
    ' + + '
    ' + + '

    ' + esc(slot.title) + '

    ' + + '
    ' + + (chartRows + ? '
    Chart for this slot
    ' + chartRows + '
    ' + : '') + + '
    Arrangement
    ' + arrRows + '
    ' + + '' + + '
    ' + + '' + + '' + + '
    '; + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } }; + function close() { overlay.remove(); document.removeEventListener('keydown', onKey); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + overlay.querySelector('[data-x]').addEventListener('click', close); + overlay.querySelector('[data-cancel]').addEventListener('click', close); + overlay.querySelector('[data-apply]').addEventListener('click', async () => { + const chart = overlay.querySelector('input[name="slot-chart"]:checked'); + const arr = overlay.querySelector('input[name="slot-arr"]:checked'); + const body = {}; + if (chart && chart.value && chart.value !== slot.filename) body.chart_filename = chart.value; + if (arr) { + const v = arr.value || null; + if (v !== (slot.arrangement || null)) body.arrangement = v; + } + if (Object.keys(body).length) { + // jsend → null on a non-2xx (e.g. swap-to-other-work rejected, or + // the resolved target duplicates another slot's pin). Surfacing it + // and keeping the picker open beats silently closing "as saved". + const res = await jsend('PATCH', '/api/playlists/' + pid + '/songs/' + encodeURIComponent(slot.filename), body); + if (!res) { + const err = overlay.querySelector('[data-err]'); + if (err) { err.textContent = 'Could not update this slot.'; err.classList.remove('hidden'); } + return; // keep the picker open — not a success + } + } + close(); + onChange(); + }); + document.body.appendChild(overlay); + } + // ── #v3-saved ─────────────────────────────────────────────────────────-- async function renderSaved() { const root = document.getElementById('v3-saved'); diff --git a/tests/test_curated_album.py b/tests/test_curated_album.py new file mode 100644 index 0000000..e6546ff --- /dev/null +++ b/tests/test_curated_album.py @@ -0,0 +1,206 @@ +"""Tests for the curated album (P6, metadata-design §7.2): a playlists row with +kind='album' + per-slot pinned chart/arrangement, work_key stamped at add, +slot chart-swap validated to the same work, and orphan-at-read self-heal.""" + +import importlib +import sys + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def server(tmp_path, monkeypatch, isolate_logging): + monkeypatch.setenv("CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1") + sys.modules.pop("server", None) + srv = importlib.import_module("server") + try: + yield srv + finally: + conn = getattr(getattr(srv, "meta_db", None), "conn", None) + if conn is not None: + conn.close() + sys.modules.pop("server", None) + + +@pytest.fixture() +def client(server): + return TestClient(server.app) + + +def _put(server, fn, title, artist, arrangements=("Lead",)): + arr = [{"name": n, "index": i} for i, n in enumerate(arrangements)] + server.meta_db.put(fn, 0, 0, {"title": title, "artist": artist, "arrangements": arr}) + + +def _album(client, name="My Album"): + return client.post("/api/playlists", json={"name": name, "kind": "album"}).json() + + +def _add(client, pid, fn): + return client.post(f"/api/playlists/{pid}/songs", json={"filename": fn}).json() + + +# ── kind discriminator ──────────────────────────────────────────────────────── + +def test_create_album_kind(client): + pl = _album(client) + assert pl["kind"] == "album" + listed = {p["id"]: p for p in client.get("/api/playlists").json()} + assert listed[pl["id"]]["kind"] == "album" + + +def test_regular_playlist_payload_has_no_album_fields(client, server): + _put(server, "a.archive", "Song", "Artist") + pl = client.post("/api/playlists", json={"name": "Mix"}).json() + assert "kind" not in pl + body = _add(client, pl["id"], "a.archive") + assert "arrangement" not in body["songs"][0] + assert "work_key" not in body["songs"][0] + + +def test_bad_kind_rejected(client): + r = client.post("/api/playlists", json={"name": "X", "kind": "boss-fight"}) + assert r.status_code == 400 + + +# ── add stamps work identity; slot payload carries the album fields ────────── + +def test_album_add_stamps_work_key(client, server): + _put(server, "a.archive", "Song", "Artist") + pid = _album(client)["id"] + slot = _add(client, pid, "a.archive")["songs"][0] + assert slot["work_key"] == server.meta_db.work_key_for("a.archive") + assert slot["arrangement"] is None + assert [a["name"] for a in slot["arrangements"]] == ["Lead"] + + +# ── slot arrangement pin ────────────────────────────────────────────────────── + +def test_slot_arrangement_pin_and_clear(client, server): + _put(server, "a.archive", "Song", "Artist", arrangements=("Lead", "Bass")) + pid = _album(client)["id"] + _add(client, pid, "a.archive") + body = client.patch(f"/api/playlists/{pid}/songs/a.archive", + json={"arrangement": "Bass"}).json() + assert body["songs"][0]["arrangement"] == "Bass" + body = client.patch(f"/api/playlists/{pid}/songs/a.archive", + json={"arrangement": None}).json() + assert body["songs"][0]["arrangement"] is None + + +def test_slot_edit_rejected_for_mix(client, server): + _put(server, "a.archive", "Song", "Artist") + pid = client.post("/api/playlists", json={"name": "Mix"}).json()["id"] + _add(client, pid, "a.archive") + r = client.patch(f"/api/playlists/{pid}/songs/a.archive", json={"arrangement": "Lead"}) + assert r.status_code == 400 + + +# ── slot chart swap (same work only; position + pin kept) ──────────────────── + +def test_slot_chart_swap_same_work(client, server): + _put(server, "a.archive", "Song", "Artist") + _put(server, "b.archive", "Song", "Artist") + _put(server, "z.archive", "Closer", "Artist") + pid = _album(client)["id"] + _add(client, pid, "a.archive") + _add(client, pid, "z.archive") + client.patch(f"/api/playlists/{pid}/songs/a.archive", json={"arrangement": "Lead"}) + body = client.patch(f"/api/playlists/{pid}/songs/a.archive", + json={"chart_filename": "b.archive"}).json() + slots = body["songs"] + assert [s["filename"] for s in slots] == ["b.archive", "z.archive"] # position kept + assert slots[0]["arrangement"] == "Lead" # pin kept + assert slots[0]["work_key"] == server.meta_db.work_key_for("b.archive") + + +def test_slot_chart_swap_rejects_other_work(client, server): + _put(server, "a.archive", "Song", "Artist") + _put(server, "x.archive", "Other", "Artist") + pid = _album(client)["id"] + _add(client, pid, "a.archive") + r = client.patch(f"/api/playlists/{pid}/songs/a.archive", + json={"chart_filename": "x.archive"}) + assert r.status_code == 400 + + +def test_slot_chart_swap_rejects_duplicate_member(client, server): + _put(server, "a.archive", "Song", "Artist") + _put(server, "b.archive", "Song", "Artist") + pid = _album(client)["id"] + _add(client, pid, "a.archive") + _add(client, pid, "b.archive") + r = client.patch(f"/api/playlists/{pid}/songs/a.archive", + json={"chart_filename": "b.archive"}) + assert r.status_code == 400 + + +# ── orphan-at-read self-heal (§7.2) ────────────────────────────────────────── + +def test_orphan_slot_resolves_to_current_keeper(client, server): + _put(server, "a.archive", "Song", "Artist", arrangements=("Lead", "Rhythm")) + _put(server, "b.archive", "Song", "Artist") + pid = _album(client)["id"] + _add(client, pid, "b.archive") # pin the 1-arr chart + server.meta_db.delete_missing({"a.archive"}) # rescan: b's file is gone + slot = client.get(f"/api/playlists/{pid}").json()["songs"][0] + assert slot["filename"] == "b.archive" # membership NOT rewritten + assert slot["resolved_from_orphan"] is True + assert slot["resolved_filename"] == "a.archive" # the work's current keeper + assert slot["title"] == "Song" + assert "missing" not in slot + + +def test_orphan_slot_missing_when_work_gone(client, server): + _put(server, "a.archive", "Song", "Artist") + pid = _album(client)["id"] + _add(client, pid, "a.archive") + server.meta_db.delete_missing(set()) # library emptied + slot = client.get(f"/api/playlists/{pid}").json()["songs"][0] + assert slot["missing"] is True + assert "resolved_filename" not in slot + + +def test_mix_still_hides_dead_songs(client, server): + _put(server, "a.archive", "Song", "Artist") + pid = client.post("/api/playlists", json={"name": "Mix"}).json()["id"] + _add(client, pid, "a.archive") + server.meta_db.delete_missing(set()) + assert client.get(f"/api/playlists/{pid}").json()["songs"] == [] + + +# ── list-card count parity: albums count ALL slots (list vs detail) ────────── + +def test_album_list_count_includes_orphaned_and_missing_slots(client, server): + # The detail view renders / plays EVERY album slot — a self-healing orphan + # and a fully-missing work both stay in the denominator (§7.2) — so the + # list-card count must agree. Guards against the mix "dead-filter" (count + # only songs still in `songs`) leaking into albums, which undercounted a + # slot the moment its pinned file was deleted. + _put(server, "a.archive", "Song", "Artist") # work A keeper (survives) + _put(server, "b.archive", "Song", "Artist") # work A, pinned then deleted + _put(server, "c.archive", "Closer", "Artist") # work C, deleted whole + pid = _album(client)["id"] + _add(client, pid, "a.archive") # live slot + _add(client, pid, "b.archive") # → orphan (self-heals to a) + _add(client, pid, "c.archive") # → fully missing + server.meta_db.delete_missing({"a.archive"}) # rescan: only a survives + detail = client.get(f"/api/playlists/{pid}").json()["songs"] + assert len(detail) == 3 # detail keeps all 3 slots + listed = {p["id"]: p for p in client.get("/api/playlists").json()} + assert listed[pid]["count"] == 3 # list card agrees (was 1) + + +def test_mix_list_count_still_dead_filters(client, server): + # The other side of the discriminator: a mix's list count keeps hiding dead + # songs, so the album fix doesn't regress non-album playlists. + _put(server, "a.archive", "Song", "Artist") + _put(server, "b.archive", "Closer", "Artist") + pid = client.post("/api/playlists", json={"name": "Mix"}).json()["id"] + _add(client, pid, "a.archive") + _add(client, pid, "b.archive") + server.meta_db.delete_missing({"a.archive"}) # b's file gone + listed = {p["id"]: p for p in client.get("/api/playlists").json()} + assert listed[pid]["count"] == 1 # dead b not counted