mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
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:
co-authored by
Claude Opus 4.8
byrongamatos
parent
e9d95ad190
commit
f6d8e241eb
@@ -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 1–100 characters."}, status_code=400)
|
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}")
|
@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
@@ -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 ? ' · ' + 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');
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user