mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +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")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Curated album (P6, metadata-design §7.2): a playlists row with
|
||||
# kind='album' is a hand-picked, ORDERED practice set of works with a
|
||||
# chosen chart per slot — the repeatable gameplay loop. Reuses the
|
||||
# playlist machinery wholesale (membership/order/cover/queue); the whole
|
||||
# schema delta is this `kind` discriminator plus two per-slot columns:
|
||||
# `arrangement` = the pinned arrangement NAME (names survive rescans;
|
||||
# the client resolves name→index at play), `work_key` = stamped at
|
||||
# add-time so a slot whose pinned chart is later deleted can self-heal
|
||||
# to the work's CURRENT preferred at read (never rewritten). Additive,
|
||||
# idempotent — same pattern as `rules` above.
|
||||
for _ddl in ("ALTER TABLE playlists ADD COLUMN kind TEXT",
|
||||
"ALTER TABLE playlist_songs ADD COLUMN arrangement TEXT",
|
||||
"ALTER TABLE playlist_songs ADD COLUMN work_key TEXT"):
|
||||
try:
|
||||
self.conn.execute(_ddl)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Wishlist / "wanted" (feedBack#636 item 4): a persisted, actionable
|
||||
# list of songs the user does NOT own yet — the *arr "Wanted/Monitored"
|
||||
# analogue. Unlike playlists (which reference owned local songs by
|
||||
@@ -1984,10 +2001,26 @@ class MetadataDB:
|
||||
# ── Playlists ─────────────────────────────────────────────────────────--
|
||||
SAVED_KEY = "saved_for_later"
|
||||
|
||||
def _playlist_count(self, pid: int) -> int:
|
||||
# Count only songs that still exist (mirrors the stats read-filter — dead
|
||||
# songs are hidden, not deleted on scan), passing through when the songs
|
||||
# table is empty. Single statement → no probe-then-read race.
|
||||
def _playlist_count(self, pid: int, kind: str | None = None) -> int:
|
||||
# An ALBUM keeps every slot in its denominator: get_playlist renders /
|
||||
# plays ALL slots — self-healing orphans and even fully-missing works
|
||||
# (§7.2) stay visible — so the list-card count must agree with the detail
|
||||
# view and skip the dead-filter (is_album → no `AND s.filename IS NOT
|
||||
# NULL`, mirroring get_playlist). Mixes/other kinds count only songs that
|
||||
# still exist (mirrors the stats read-filter — dead songs are hidden, not
|
||||
# deleted on scan), passing through when the songs table is empty. Single
|
||||
# statement → no probe-then-read race. `kind` is passed by list_playlists
|
||||
# (already in hand); fetched here when a caller omits it.
|
||||
if kind is None:
|
||||
row = self.conn.execute(
|
||||
"SELECT kind FROM playlists WHERE id = ?", (pid,)
|
||||
).fetchone()
|
||||
kind = row[0] if row else None
|
||||
if kind == "album":
|
||||
return self.conn.execute(
|
||||
"SELECT COUNT(*) FROM playlist_songs WHERE playlist_id = ?",
|
||||
(pid,),
|
||||
).fetchone()[0]
|
||||
return self.conn.execute(
|
||||
"SELECT COUNT(*) FROM playlist_songs ps WHERE ps.playlist_id = ? "
|
||||
"AND EXISTS (SELECT 1 FROM songs s WHERE s.filename = ps.filename)",
|
||||
@@ -2023,7 +2056,7 @@ class MetadataDB:
|
||||
def list_playlists(self) -> list[dict]:
|
||||
from urllib.parse import quote
|
||||
rows = self.conn.execute(
|
||||
"SELECT id, name, system_key, created_at, updated_at FROM playlists "
|
||||
"SELECT id, name, system_key, created_at, updated_at, kind FROM playlists "
|
||||
"WHERE rules IS NULL " # smart collections live in the source picker, not here
|
||||
"ORDER BY (system_key IS NULL), name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
@@ -2041,18 +2074,19 @@ class MetadataDB:
|
||||
).fetchall()
|
||||
out.append({
|
||||
"id": pid, "name": r[1], "system_key": r[2],
|
||||
"created_at": r[3], "updated_at": r[4],
|
||||
"count": self._playlist_count(pid),
|
||||
"created_at": r[3], "updated_at": r[4], "kind": r[5],
|
||||
"count": self._playlist_count(pid, r[5]),
|
||||
"art_urls": [f"/api/song/{quote(a[0])}/art" for a in arts],
|
||||
})
|
||||
return out
|
||||
|
||||
def create_playlist(self, name: str, system_key: str | None = None) -> dict:
|
||||
def create_playlist(self, name: str, system_key: str | None = None,
|
||||
kind: str | None = None) -> dict:
|
||||
with self._lock:
|
||||
cur = self.conn.execute(
|
||||
"INSERT INTO playlists (name, system_key, created_at, updated_at) "
|
||||
"VALUES (?, ?, datetime('now'), datetime('now'))",
|
||||
(name, system_key),
|
||||
"INSERT INTO playlists (name, system_key, kind, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, datetime('now'), datetime('now'))",
|
||||
(name, system_key, kind),
|
||||
)
|
||||
self.conn.commit()
|
||||
pid = cur.lastrowid
|
||||
@@ -2167,50 +2201,145 @@ class MetadataDB:
|
||||
# get_playlist uniformly 404s on a collection id — collections are
|
||||
# managed only through /api/collections.
|
||||
head = self.conn.execute(
|
||||
"SELECT id, name, system_key, created_at, updated_at FROM playlists "
|
||||
"SELECT id, name, system_key, created_at, updated_at, kind FROM playlists "
|
||||
"WHERE id = ? AND rules IS NULL", (pid,)
|
||||
).fetchone()
|
||||
if not head:
|
||||
return None
|
||||
is_album = head[5] == "album"
|
||||
# Mixes hide dead songs (race-free; not deleted on scan). An ALBUM keeps
|
||||
# every slot: a slot whose pinned chart was deleted self-heals to the
|
||||
# work's current preferred at READ (§7.2 orphan-at-play — never a
|
||||
# membership rewrite), and reports `missing` when the whole work is gone
|
||||
# so the practice set keeps its denominator visible.
|
||||
dead_filter = "" if is_album else "AND s.filename IS NOT NULL"
|
||||
rows = self.conn.execute(
|
||||
"""SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name
|
||||
f"""SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name,
|
||||
ps.arrangement, ps.work_key, s.arrangements,
|
||||
(s.filename IS NULL) AS dead
|
||||
FROM playlist_songs ps LEFT JOIN songs s ON s.filename = ps.filename
|
||||
WHERE ps.playlist_id = ?
|
||||
-- hide dead songs (race-free; not deleted on scan)
|
||||
AND s.filename IS NOT NULL
|
||||
WHERE ps.playlist_id = ? {dead_filter}
|
||||
ORDER BY ps.position, ps.filename""",
|
||||
(pid,),
|
||||
).fetchall()
|
||||
from urllib.parse import quote
|
||||
songs = [{
|
||||
"filename": r[0], "position": r[1],
|
||||
"title": r[2] or r[0], "artist": r[3] or "", "tuning_name": r[4] or "",
|
||||
"art_url": f"/api/song/{quote(r[0])}/art",
|
||||
} for r in rows]
|
||||
songs = []
|
||||
for r in rows:
|
||||
entry = {
|
||||
"filename": r[0], "position": r[1],
|
||||
"title": r[2] or r[0], "artist": r[3] or "", "tuning_name": r[4] or "",
|
||||
"art_url": f"/api/song/{quote(r[0])}/art",
|
||||
}
|
||||
if is_album:
|
||||
entry["arrangement"] = r[5]
|
||||
entry["work_key"] = r[6]
|
||||
try:
|
||||
entry["arrangements"] = _ensure_smart_names(json.loads(r[7]) if r[7] else [])
|
||||
except Exception:
|
||||
entry["arrangements"] = []
|
||||
if r[8]:
|
||||
entry.update(self._resolve_album_orphan(r[6]))
|
||||
songs.append(entry)
|
||||
return {
|
||||
"id": head[0], "name": head[1], "system_key": head[2],
|
||||
"created_at": head[3], "updated_at": head[4], "songs": songs,
|
||||
**({"kind": head[5]} if head[5] else {}),
|
||||
}
|
||||
|
||||
def _resolve_album_orphan(self, work_key: str | None) -> dict:
|
||||
"""A deleted album slot resolves to its work's CURRENT preferred/auto
|
||||
pick at read (§7.2): the slot plays `resolved_filename` today, and if
|
||||
the pinned file reappears (rescan) it simply resolves back to itself —
|
||||
no rewrite in either direction. A work with no charts left reports
|
||||
`missing` (the row stays, dimmed, so the set's denominator is honest)."""
|
||||
if work_key:
|
||||
self._ensure_work_display()
|
||||
row = self.conn.execute(
|
||||
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements "
|
||||
"FROM work_display wd JOIN songs s ON s.filename = wd.filename "
|
||||
"WHERE wd.effective_work_key = ? AND wd.is_group_representative = 1",
|
||||
(work_key,)).fetchone()
|
||||
if row:
|
||||
from urllib.parse import quote
|
||||
try:
|
||||
arrs = _ensure_smart_names(json.loads(row[4]) if row[4] else [])
|
||||
except Exception:
|
||||
arrs = []
|
||||
return {"resolved_filename": row[0], "title": row[1] or row[0],
|
||||
"artist": row[2] or "", "tuning_name": row[3] or "",
|
||||
"arrangements": arrs,
|
||||
"art_url": f"/api/song/{quote(row[0])}/art",
|
||||
"resolved_from_orphan": True}
|
||||
return {"missing": True}
|
||||
|
||||
def add_playlist_song(self, pid: int, filename: str):
|
||||
with self._lock:
|
||||
# Re-check existence INSIDE the lock: the handler's earlier 404 check
|
||||
# is a separate step, so a concurrent delete_playlist could land
|
||||
# between them and leave an orphan playlist_songs row. Returning None
|
||||
# lets the handler answer 404 instead of inserting an orphan.
|
||||
if not self.conn.execute("SELECT 1 FROM playlists WHERE id = ?", (pid,)).fetchone():
|
||||
row = self.conn.execute("SELECT kind FROM playlists WHERE id = ?", (pid,)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
# Album slots stamp the work identity at ADD time (§7.2 "resolved to
|
||||
# preferred once at add, pinned thereafter") — it's what lets a
|
||||
# later-deleted chart's slot self-heal to the work's current keeper.
|
||||
wk = self.work_key_for(filename) if row[0] == "album" else None
|
||||
nxt = self.conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_songs WHERE playlist_id = ?", (pid,)
|
||||
).fetchone()[0]
|
||||
cur = self.conn.execute(
|
||||
"INSERT OR IGNORE INTO playlist_songs (playlist_id, filename, position) VALUES (?, ?, ?)",
|
||||
(pid, filename, nxt),
|
||||
"INSERT OR IGNORE INTO playlist_songs (playlist_id, filename, position, work_key) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(pid, filename, nxt, wk),
|
||||
)
|
||||
self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,))
|
||||
self.conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
_SLOT_KEEP = object() # sentinel: "leave the arrangement pin unchanged"
|
||||
|
||||
def update_playlist_slot(self, pid: int, filename: str,
|
||||
new_filename: str | None = None,
|
||||
arrangement=_SLOT_KEEP):
|
||||
"""Edit ONE album slot in place (§7.2): pin/clear its arrangement (a
|
||||
NAME — names survive rescans; None clears back to full-song) and/or swap
|
||||
the slot's chart for another chart of the SAME work, keeping position +
|
||||
pin — the per-slot pick is deliberately independent of the work's
|
||||
global preferred. Returns the slot's (possibly new) filename, or None
|
||||
when the slot doesn't exist, the swap target isn't a chart of the
|
||||
slot's work, or it's already in the playlist."""
|
||||
with self._lock:
|
||||
row = self.conn.execute(
|
||||
"SELECT position, work_key FROM playlist_songs "
|
||||
"WHERE playlist_id = ? AND filename = ?", (pid, filename)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
out_fn = filename
|
||||
if new_filename and new_filename != filename:
|
||||
# Same-work guard: the stored stamp wins (works even when the
|
||||
# pinned file is gone); fall back to computing from the row.
|
||||
wk_slot = row[1] or self.work_key_for(filename)
|
||||
if not wk_slot or self.work_key_for(new_filename) != wk_slot:
|
||||
return None
|
||||
if self.conn.execute(
|
||||
"SELECT 1 FROM playlist_songs WHERE playlist_id = ? AND filename = ?",
|
||||
(pid, new_filename)).fetchone():
|
||||
return None
|
||||
self.conn.execute(
|
||||
"UPDATE playlist_songs SET filename = ?, work_key = ? "
|
||||
"WHERE playlist_id = ? AND filename = ?",
|
||||
(new_filename, wk_slot, pid, filename))
|
||||
out_fn = new_filename
|
||||
if arrangement is not self._SLOT_KEEP:
|
||||
self.conn.execute(
|
||||
"UPDATE playlist_songs SET arrangement = ? "
|
||||
"WHERE playlist_id = ? AND filename = ?",
|
||||
(arrangement, pid, out_fn))
|
||||
self.conn.execute("UPDATE playlists SET updated_at = datetime('now') WHERE id = ?", (pid,))
|
||||
self.conn.commit()
|
||||
return out_fn
|
||||
|
||||
def remove_playlist_song(self, pid: int, filename: str) -> bool:
|
||||
with self._lock:
|
||||
cur = self.conn.execute(
|
||||
@@ -7041,7 +7170,12 @@ def api_create_playlist(data: dict):
|
||||
name = _clean_str(data.get("name"))
|
||||
if not (1 <= len(name) <= 100):
|
||||
return JSONResponse({"error": "Playlist name must be 1–100 characters."}, status_code=400)
|
||||
return meta_db.create_playlist(name)
|
||||
# kind='album' = a curated album (§7.2): hand-picked works, a chosen chart
|
||||
# per slot, played front-to-back on the queue. Absent/None = a regular mix.
|
||||
kind = _clean_str(data.get("kind")) or None
|
||||
if kind not in (None, "album"):
|
||||
return JSONResponse({"error": "kind must be 'album' or omitted"}, status_code=400)
|
||||
return meta_db.create_playlist(name, kind=kind)
|
||||
|
||||
|
||||
@app.get("/api/playlists/{pid}")
|
||||
@@ -7098,6 +7232,36 @@ def api_add_playlist_song(pid: int, data: dict):
|
||||
return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
|
||||
@app.patch("/api/playlists/{pid}/songs/{filename:path}")
|
||||
def api_update_playlist_slot(pid: int, filename: str, data: dict):
|
||||
"""Edit one curated-album slot: {"arrangement": name|null} pins/clears the
|
||||
slot's arrangement; {"chart_filename": fn} swaps the slot to another chart
|
||||
of the same work (position + pin kept). Albums only — a mix has no slots."""
|
||||
pl = meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if pl.get("kind") != "album":
|
||||
return JSONResponse({"error": "Slot editing is for albums."}, status_code=400)
|
||||
kwargs = {}
|
||||
if "chart_filename" in data:
|
||||
new_fn = _clean_str(data.get("chart_filename"))
|
||||
if not new_fn:
|
||||
return JSONResponse({"error": "chart_filename must be a filename"}, status_code=400)
|
||||
kwargs["new_filename"] = new_fn
|
||||
if "arrangement" in data:
|
||||
arr = data.get("arrangement")
|
||||
if arr is not None and not (isinstance(arr, str) and 1 <= len(arr.strip()) <= 100):
|
||||
return JSONResponse({"error": "arrangement must be a name or null"}, status_code=400)
|
||||
kwargs["arrangement"] = arr.strip() if isinstance(arr, str) else None
|
||||
if not kwargs:
|
||||
return JSONResponse({"error": "nothing to update"}, status_code=400)
|
||||
if meta_db.update_playlist_slot(pid, filename, **kwargs) is None:
|
||||
return JSONResponse(
|
||||
{"error": "no such slot, or the chart isn't a version of this song"},
|
||||
status_code=400)
|
||||
return meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@app.delete("/api/playlists/{pid}/songs/{filename:path}")
|
||||
def api_remove_playlist_song(pid: int, filename: str):
|
||||
if meta_db.get_playlist(pid) is None:
|
||||
|
||||
Reference in New Issue
Block a user