v3 library: curated album (kind='album' + per-slot chart/arrangement pins + play-album) — P6 (#706)

* v3 library: curated album — your version of an album, one chart per slot — P6

A curated album is a hand-picked, ORDERED practice set of works with a
chosen chart per track (metadata-design 7.2) - the repeatable gameplay
loop. No new tables: a playlists row with kind='album' plus two per-slot
columns.

- Schema (additive, idempotent): playlists.kind ('album' | NULL=mix),
  playlist_songs.arrangement (the pinned arrangement NAME - names
  survive rescans; the index is resolved at play), playlist_songs.
  work_key (stamped at ADD time = "resolved to preferred once at add,
  pinned thereafter").
- Orphan-at-read self-heal: an album keeps every slot. A slot whose
  pinned chart was deleted resolves to the work's CURRENT keeper at
  read (marked "(auto)"; membership is never rewritten - if the file
  returns, the slot resolves back to itself), and reports missing when
  the whole work is gone so the set's denominator stays honest. Mixes
  keep hiding dead songs byte-identically.
- Slot editor: PATCH /api/playlists/{pid}/songs/{fn} pins/clears the
  arrangement and/or swaps the slot's chart - validated to the SAME
  work via the stored stamp, position + pin kept, duplicate members
  rejected. The per-slot pick is independent of the work's global
  preferred: a rehearsed set stays the same notes even if the global
  keeper is re-picked later.
- UI: "New album" on the Playlists screen (album chip + disc cover);
  the album detail adds a set-scoped "Album repertoire" meter (N of M
  mastered - per-track mastery, never one album score), per-track
  accuracy, and a per-row slot editor listing only the work's charts.
  "Play album" runs the play-queue front-to-back honoring pins (the
  queue already supported per-index arrangements); per-row play uses
  the resolved chart + pinned arrangement.

12 new tests; playlists/collections regressions green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3): count all album slots (list vs detail parity) + surface slot-edit PATCH failures (PR #706 review)

_playlist_count applied the mix "dead-filter" to every playlist, so an album's
list-card count dropped orphaned/missing slots that its detail view still
renders and plays (5-track album, 2 pins deleted → card "3" vs detail 5). Count
ALL slots for kind='album' (mirroring get_playlist's is_album discriminator);
mixes/other kinds keep the dead-filter. openSlotPicker's Apply now checks the
jsend return and, on a rejected PATCH (swap-to-other-work / duplicate pin),
shows an inline error and keeps the picker open instead of closing as success.
Adds album count-parity + mix dead-filter regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-02 13:38:06 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent e9d95ad190
commit f6d8e241eb
3 changed files with 595 additions and 42 deletions
+189 -25
View File
@@ -772,6 +772,23 @@ class MetadataDB:
self.conn.execute("ALTER TABLE playlists ADD COLUMN rules TEXT") self.conn.execute("ALTER TABLE playlists ADD COLUMN rules TEXT")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass 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 # Wishlist / "wanted" (feedBack#636 item 4): a persisted, actionable
# list of songs the user does NOT own yet — the *arr "Wanted/Monitored" # list of songs the user does NOT own yet — the *arr "Wanted/Monitored"
# analogue. Unlike playlists (which reference owned local songs by # analogue. Unlike playlists (which reference owned local songs by
@@ -1984,10 +2001,26 @@ class MetadataDB:
# ── Playlists ─────────────────────────────────────────────────────────-- # ── Playlists ─────────────────────────────────────────────────────────--
SAVED_KEY = "saved_for_later" SAVED_KEY = "saved_for_later"
def _playlist_count(self, pid: int) -> int: def _playlist_count(self, pid: int, kind: str | None = None) -> int:
# Count only songs that still exist (mirrors the stats read-filter — dead # An ALBUM keeps every slot in its denominator: get_playlist renders /
# songs are hidden, not deleted on scan), passing through when the songs # plays ALL slots — self-healing orphans and even fully-missing works
# table is empty. Single statement → no probe-then-read race. # (§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( return self.conn.execute(
"SELECT COUNT(*) FROM playlist_songs ps WHERE ps.playlist_id = ? " "SELECT COUNT(*) FROM playlist_songs ps WHERE ps.playlist_id = ? "
"AND EXISTS (SELECT 1 FROM songs s WHERE s.filename = ps.filename)", "AND EXISTS (SELECT 1 FROM songs s WHERE s.filename = ps.filename)",
@@ -2023,7 +2056,7 @@ class MetadataDB:
def list_playlists(self) -> list[dict]: def list_playlists(self) -> list[dict]:
from urllib.parse import quote from urllib.parse import quote
rows = self.conn.execute( 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 "WHERE rules IS NULL " # smart collections live in the source picker, not here
"ORDER BY (system_key IS NULL), name COLLATE NOCASE" "ORDER BY (system_key IS NULL), name COLLATE NOCASE"
).fetchall() ).fetchall()
@@ -2041,18 +2074,19 @@ class MetadataDB:
).fetchall() ).fetchall()
out.append({ out.append({
"id": pid, "name": r[1], "system_key": r[2], "id": pid, "name": r[1], "system_key": r[2],
"created_at": r[3], "updated_at": r[4], "created_at": r[3], "updated_at": r[4], "kind": r[5],
"count": self._playlist_count(pid), "count": self._playlist_count(pid, r[5]),
"art_urls": [f"/api/song/{quote(a[0])}/art" for a in arts], "art_urls": [f"/api/song/{quote(a[0])}/art" for a in arts],
}) })
return out 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: with self._lock:
cur = self.conn.execute( cur = self.conn.execute(
"INSERT INTO playlists (name, system_key, created_at, updated_at) " "INSERT INTO playlists (name, system_key, kind, created_at, updated_at) "
"VALUES (?, ?, datetime('now'), datetime('now'))", "VALUES (?, ?, ?, datetime('now'), datetime('now'))",
(name, system_key), (name, system_key, kind),
) )
self.conn.commit() self.conn.commit()
pid = cur.lastrowid pid = cur.lastrowid
@@ -2167,50 +2201,145 @@ class MetadataDB:
# get_playlist uniformly 404s on a collection id — collections are # get_playlist uniformly 404s on a collection id — collections are
# managed only through /api/collections. # managed only through /api/collections.
head = self.conn.execute( 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,) "WHERE id = ? AND rules IS NULL", (pid,)
).fetchone() ).fetchone()
if not head: if not head:
return None 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( 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 FROM playlist_songs ps LEFT JOIN songs s ON s.filename = ps.filename
WHERE ps.playlist_id = ? WHERE ps.playlist_id = ? {dead_filter}
-- hide dead songs (race-free; not deleted on scan)
AND s.filename IS NOT NULL
ORDER BY ps.position, ps.filename""", ORDER BY ps.position, ps.filename""",
(pid,), (pid,),
).fetchall() ).fetchall()
from urllib.parse import quote from urllib.parse import quote
songs = [{ songs = []
"filename": r[0], "position": r[1], for r in rows:
"title": r[2] or r[0], "artist": r[3] or "", "tuning_name": r[4] or "", entry = {
"art_url": f"/api/song/{quote(r[0])}/art", "filename": r[0], "position": r[1],
} for r in rows] "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 { return {
"id": head[0], "name": head[1], "system_key": head[2], "id": head[0], "name": head[1], "system_key": head[2],
"created_at": head[3], "updated_at": head[4], "songs": songs, "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): def add_playlist_song(self, pid: int, filename: str):
with self._lock: with self._lock:
# Re-check existence INSIDE the lock: the handler's earlier 404 check # Re-check existence INSIDE the lock: the handler's earlier 404 check
# is a separate step, so a concurrent delete_playlist could land # is a separate step, so a concurrent delete_playlist could land
# between them and leave an orphan playlist_songs row. Returning None # between them and leave an orphan playlist_songs row. Returning None
# lets the handler answer 404 instead of inserting an orphan. # 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 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( nxt = self.conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_songs WHERE playlist_id = ?", (pid,) "SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_songs WHERE playlist_id = ?", (pid,)
).fetchone()[0] ).fetchone()[0]
cur = self.conn.execute( cur = self.conn.execute(
"INSERT OR IGNORE INTO playlist_songs (playlist_id, filename, position) VALUES (?, ?, ?)", "INSERT OR IGNORE INTO playlist_songs (playlist_id, filename, position, work_key) "
(pid, filename, nxt), "VALUES (?, ?, ?, ?)",
(pid, filename, nxt, wk),
) )
self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,)) self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,))
self.conn.commit() self.conn.commit()
return cur.rowcount > 0 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: def remove_playlist_song(self, pid: int, filename: str) -> bool:
with self._lock: with self._lock:
cur = self.conn.execute( cur = self.conn.execute(
@@ -7041,7 +7170,12 @@ def api_create_playlist(data: dict):
name = _clean_str(data.get("name")) name = _clean_str(data.get("name"))
if not (1 <= len(name) <= 100): if not (1 <= len(name) <= 100):
return JSONResponse({"error": "Playlist name must be 1100 characters."}, status_code=400) return JSONResponse({"error": "Playlist name must be 1100 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}") @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) 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}") @app.delete("/api/playlists/{pid}/songs/{filename:path}")
def api_remove_playlist_song(pid: int, filename: str): def api_remove_playlist_song(pid: int, filename: str):
if meta_db.get_playlist(pid) is None: if meta_db.get_playlist(pid) is None:
+200 -17
View File
@@ -37,26 +37,59 @@
if (p.cover_url) return '<div class="' + box + '">' + img(p.cover_url, 'w-full h-full object-cover') + '</div>'; 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 : []; const arts = Array.isArray(p.art_urls) ? p.art_urls : [];
if (!arts.length) { if (!arts.length) {
return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.system_key ? '🔖' : '🎵') + '</div>'; return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.kind === 'album' ? '💿' : p.system_key ? '🔖' : '🎵') + '</div>';
} }
if (arts.length < 4) return '<div class="' + box + '">' + img(arts[0], 'w-full h-full object-cover') + '</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">' + 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>'; arts.slice(0, 4).map((u) => img(u, 'w-full h-full object-cover')).join('') + '</div>';
} }
// 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) { function songRow(s, opts) {
opts = opts || {}; opts = opts || {};
const handle = opts.draggable const handle = opts.draggable
? '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>' : ''; ? '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>' : '';
const tuning = s.tuning_name const tuning = s.tuning_name
? '<span class="ml-2 text-[10px] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : ''; ? '<span class="ml-2 text-[10px] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
return '<li data-fn="' + esc(s.filename) + '"' + (opts.draggable ? ' draggable="true"' : '') + // ── Curated-album slot extras (P6) — mixes/saved emit none of this ──
' class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-fb-card/50 group">' + // 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')
? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(opts.acc * 100) + '%</span>'
: '';
const pin = (isAlbum && s.arrangement)
? '<span class="ml-2 text-[10px] bg-fb-primary/20 text-fb-primary font-bold px-1.5 py-0.5 rounded-sm" title="Pinned arrangement">' + esc(s.arrangement) + '</span>' : '';
const orphan = (isAlbum && s.resolved_from_orphan)
? '<span class="ml-2 text-[10px] text-fb-textDim" title="The pinned chart is gone — playing this song\'s current keeper instead">(auto)</span>' : '';
const slotBtn = (isAlbum && !missing)
? '<button data-slot aria-label="Choose chart / arrangement" title="Choose chart / arrangement" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-text text-sm px-2">▾</button>' : '';
return '<li data-fn="' + esc(s.filename) + '"' + playAttrs + (opts.draggable ? ' draggable="true"' : '') +
' class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-fb-card/50 group' + (missing ? ' opacity-50' : '') + '">' +
handle + handle +
'<img src="' + esc(s.art_url) + '" alt="" class="w-10 h-10 rounded object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' + '<img src="' + esc(s.art_url) + '" alt="" class="w-10 h-10 rounded object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
'<span class="flex-1 min-w-0"><span class="block text-sm text-fb-text truncate">' + esc(s.title) + tuning + '</span>' + '<span class="flex-1 min-w-0"><span class="block text-sm text-fb-text truncate">' + esc(s.title) + tuning + pin + orphan + '</span>' +
'<span class="block text-xs text-fb-textDim truncate">' + esc(s.artist) + '</span></span>' + '<span class="block text-xs text-fb-textDim truncate">' + (missing ? 'Missing — no version of this song is in your library' : esc(s.artist)) + '</span></span>' +
'<button data-v3-play aria-label="Play" class="opacity-0 group-hover:opacity-100 text-fb-primary hover:text-fb-primaryHi text-sm px-2" title="Play">▶</button>' + acc +
(missing ? '' : '<button data-v3-play aria-label="Play" class="opacity-0 group-hover:opacity-100 text-fb-primary hover:text-fb-primaryHi text-sm px-2" title="Play">▶</button>') +
slotBtn +
'<button data-remove aria-label="Remove from playlist" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-accent text-sm px-2" title="Remove">✕</button>' + '<button data-remove aria-label="Remove from playlist" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-accent text-sm px-2" title="Remove">✕</button>' +
'</li>'; '</li>';
} }
@@ -68,7 +101,14 @@
// playSong decodeURIComponent()s its arg for the highway WS, so // playSong decodeURIComponent()s its arg for the highway WS, so
// pass an encoded filename (like the rest of v3) — a raw name // pass an encoded filename (like the rest of v3) — a raw name
// with %/#/?/ in it would otherwise misroute or throw. // 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 () => { li.querySelector('[data-remove]')?.addEventListener('click', async () => {
await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' }); await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' });
@@ -106,7 +146,10 @@
const lists = (await jget('/api/playlists')) || []; const lists = (await jget('/api/playlists')) || [];
root.innerHTML = root.innerHTML =
'<div class="max-w-5xl mx-auto px-6 md:px-8 pb-8">' + '<div class="max-w-5xl mx-auto px-6 md:px-8 pb-8">' +
'<div class="flex items-center justify-end mb-6">' + '<div class="flex items-center justify-end gap-2 mb-6">' +
// Curated album (P6): a hand-picked ORDERED set with a chosen chart
// per track — same machinery as a playlist, kind='album'.
'<button id="v3-pl-new-album" title="A hand-picked, ordered set of songs — your version of an album, with your chosen chart per track" class="bg-fb-card/80 hover:bg-fb-card border border-fb-border/60 text-fb-text px-4 py-2 rounded-md text-sm font-medium">💿 New album</button>' +
'<button id="v3-pl-new" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm font-medium shadow-lg shadow-fb-primary/20">New playlist</button>' + '<button id="v3-pl-new" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm font-medium shadow-lg shadow-fb-primary/20">New playlist</button>' +
'</div>' + '</div>' +
(lists.length (lists.length
@@ -114,7 +157,7 @@
'<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">' + '<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">' +
playlistCoverHtml(p) + playlistCoverHtml(p) +
'<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' + '<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>' + '<div class="text-xs text-fb-textDim">' + (p.kind === 'album' ? '💿 Album · ' : '') + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
'</button>').join('') + '</div>' '</button>').join('') + '</div>'
: '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') + : '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') +
'</div>'; '</div>';
@@ -124,6 +167,12 @@
await jsend('POST', '/api/playlists', { name }); await jsend('POST', '/api/playlists', { name });
renderPlaylists(); 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) => root.querySelectorAll('[data-pl]').forEach((b) =>
b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10)))); b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10))));
} }
@@ -134,13 +183,35 @@
const pl = await jget('/api/playlists/' + pid); const pl = await jget('/api/playlists/' + pid);
if (!pl) { renderPlaylists(); return; } if (!pl) { renderPlaylists(); return; }
const isSystem = !!pl.system_key; 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 =
'<div class="mb-6">' +
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
'<span class="text-sm font-semibold text-fb-text">Album repertoire</span>' +
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + tracks.length + ' mastered' +
(started ? ' &middot; ' + started + ' in progress' : '') + '</span></div>' +
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
'</div>';
}
root.innerHTML = root.innerHTML =
'<div class="max-w-3xl mx-auto p-6 md:p-8">' + '<div class="max-w-3xl mx-auto p-6 md:p-8">' +
'<button id="v3-pl-back" class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Playlists</button>' + '<button id="v3-pl-back" class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Playlists</button>' +
'<div class="flex items-center justify-between mb-6 gap-3">' + '<div class="flex items-center justify-between mb-6 gap-3">' +
'<h2 class="text-3xl font-bold text-fb-text truncate">' + esc(pl.name) + '</h2>' + '<h2 class="text-3xl font-bold text-fb-text truncate">' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '</h2>' +
'<div class="flex gap-2 shrink-0 items-center">' + '<div class="flex gap-2 shrink-0 items-center">' +
(pl.songs.length ? '<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play all</button>' : '') + (pl.songs.length ? '<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>' : '') +
(isSystem ? '' : (isSystem ? '' :
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' + '<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>' : '') + (pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
@@ -149,22 +220,46 @@
'<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden">') + '<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden">') +
'</div>' + '</div>' +
'</div>' + '</div>' +
meter +
(pl.songs.length (pl.songs.length
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem })).join('') + '</ul>' ? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem, album: isAlbum, acc: isAlbum ? slotAcc(s) : undefined })).join('') + '</ul>'
: '<p class="text-fb-textDim">Empty — add songs from the library.</p>') + : '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
'</div>'; '</div>';
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists); root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
// Play all: start the play-queue with this playlist's songs (auto-advances // 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 // 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', () => { 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 (!files.length) return;
if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.start(files, { source: pl.name }); if (window.feedBack && window.feedBack.playQueue) {
else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0])); 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'); const listEl = root.querySelector('#v3-pl-songs');
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid)); 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 () => { 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(); const name = ((await window.uiPrompt({ title: 'Rename Playlist', label: 'Playlist name', value: pl.name, okLabel: 'Rename' })) || '').trim();
if (!name) return; 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) =>
'<label class="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-fb-card/60 cursor-pointer">' +
'<input type="radio" name="' + name + '" value="' + esc(value) + '"' + (checked ? ' checked' : '') + ' class="accent-fb-primary mt-0.5">' +
'<span class="min-w-0 flex-1"><span class="block text-sm text-fb-text truncate">' + label + '</span>' +
(sub ? '<span class="block text-[10px] text-fb-textDim truncate">' + sub + '</span>' : '') +
'</span></label>';
// 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 ? ' <span class="text-[10px] text-fb-primary">● preferred</span>' : ''),
esc((c.tuning_name ? c.tuning_name + ' · ' : '') + c.filename))).join('');
const arrRows = [radio('slot-arr', '', !slot.arrangement, 'Full song <span class="text-[10px] text-fb-textDim">(default)</span>', '')]
.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 =
'<div class="bg-fb-card w-full max-w-md rounded-xl border border-fb-border/60 p-5 space-y-4 max-h-[80vh] overflow-y-auto v3-scroll">' +
'<div class="flex items-center justify-between gap-2">' +
'<h3 class="text-lg font-semibold text-fb-text truncate">' + esc(slot.title) + '</h3>' +
'<button type="button" data-x class="text-fb-textDim hover:text-fb-text text-xl leading-none" aria-label="Close">✕</button></div>' +
(chartRows
? '<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Chart for this slot</div>' + chartRows + '</div>'
: '') +
'<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Arrangement</div>' + arrRows + '</div>' +
'<div data-err class="hidden text-xs text-red-400"></div>' +
'<div class="flex justify-end gap-2">' +
'<button type="button" data-cancel class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Cancel</button>' +
'<button type="button" data-apply class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-5 py-2 rounded-md">Apply</button>' +
'</div></div>';
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 ─────────────────────────────────────────────────────────-- // ── #v3-saved ─────────────────────────────────────────────────────────--
async function renderSaved() { async function renderSaved() {
const root = document.getElementById('v3-saved'); const root = document.getElementById('v3-saved');
+206
View File
@@ -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