mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-24 13:51:39 +00:00
feat(library): persisted wishlist / "wanted" list (#640)
Closes feedBack#636 item 4 — the *arr "Wanted/Monitored" analogue FeedBack was missing. A wishlist entry is a song the user does NOT own yet, so unlike a playlist (which references owned local songs by filename) it can't reuse the playlist subsystem; it lives in a new `wanted` table keyed by descriptive identity (artist, title, source, source_ref, note, created_at). - New table + a UNIQUE index on (artist NOCASE, title NOCASE, source, source_ref); additive + idempotent (CREATE … IF NOT EXISTS). - MetadataDB.add_wanted (INSERT OR IGNORE + re-select under the write lock, so a re-run of an ownership-diff returns the existing row, never a dup), list_wanted (newest first), remove_wanted, count_wanted. - Routes GET/POST/DELETE /api/wanted. POST requires artist or title and defaults source to "manual"; idempotent on identity so producers (the find_more ownership-diff, or a manual add) can re-post freely. This is the core persistence primitive the charrette flagged as the missing piece; the consuming UI lives in the producing plugin (find_more / the_daily). Tests: tests/test_wanted_api.py (round-trip, identity idempotency incl. case-insensitive, distinct source_ref, ordering, validation, additive schema). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
07ab902604
commit
8ca7ea4002
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
- **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip).
|
||||
- **A persisted wishlist — keep a list of songs you want but don't own yet.** New `wanted` table + `GET`/`POST`/`DELETE /api/wanted` give FeedBack the *arr-style "Wanted/Monitored" primitive it was missing: an entry is a *not-owned* song (artist/title/source/source_ref/note), so it lives in its own table rather than the playlist subsystem (which references owned local files). The API is idempotent on identity (case-insensitive artist+title, plus source+source_ref), so a producer — the `find_more` ownership-diff, or a manual add — can re-post without duplicating. Newest-first. Backend primitive for the charrette's wishlist finding (got-feedback/feedBack#636 item 4); the consuming UI lives in the producing plugin. Tests: `tests/test_wanted_api.py`.
|
||||
- **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`.
|
||||
- **A–Z fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`.
|
||||
- **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`.
|
||||
|
||||
104
server.py
104
server.py
@ -576,6 +576,30 @@ class MetadataDB:
|
||||
)
|
||||
""")
|
||||
self.conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_system_key ON playlists(system_key) WHERE system_key IS NOT NULL")
|
||||
# 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
|
||||
# filename), a wanted entry has no local file, so it lives in its own
|
||||
# table keyed by descriptive identity. Producers (the find_more plugin's
|
||||
# ownership-diff, or a manual add) POST here; the consuming UI reads it.
|
||||
# Additive + idempotent.
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wanted (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '', -- e.g. 'find_more', 'manual'
|
||||
source_ref TEXT NOT NULL DEFAULT '', -- opaque id/url within that source
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT
|
||||
)
|
||||
""")
|
||||
# Identity = (artist, title, source, source_ref), case-insensitive on
|
||||
# the human fields, so re-running an ownership-diff doesn't duplicate.
|
||||
self.conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_wanted_identity "
|
||||
"ON wanted(artist COLLATE NOCASE, title COLLATE NOCASE, source, source_ref)"
|
||||
)
|
||||
# Progression (spec 010): instrument paths, challenges, quests, the
|
||||
# Decibels wallet, and the cosmetics shop. Targets/titles live in the
|
||||
# bundled content (data/progression/); these tables hold only player
|
||||
@ -1559,6 +1583,52 @@ class MetadataDB:
|
||||
self.conn.commit()
|
||||
return new_state
|
||||
|
||||
# ── Wishlist / "wanted" (feedBack#636 item 4) ─────────────────────────
|
||||
_WANTED_COLS = ("id", "artist", "title", "source", "source_ref", "note", "created_at")
|
||||
|
||||
def add_wanted(self, artist: str, title: str, source: str = "manual",
|
||||
source_ref: str = "", note: str = "") -> dict:
|
||||
"""Add a not-owned song to the wishlist (or return the existing row if
|
||||
an entry with the same identity is already wanted — idempotent, so a
|
||||
re-run of an ownership-diff doesn't duplicate). Returns the row."""
|
||||
artist = (artist or "").strip()
|
||||
title = (title or "").strip()
|
||||
source = (source or "manual").strip() or "manual"
|
||||
source_ref = (source_ref or "").strip()
|
||||
note = (note or "").strip()
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
"INSERT OR IGNORE INTO wanted (artist, title, source, source_ref, note, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, datetime('now'))",
|
||||
(artist, title, source, source_ref, note),
|
||||
)
|
||||
row = self.conn.execute(
|
||||
"SELECT " + ", ".join(self._WANTED_COLS) + " FROM wanted "
|
||||
"WHERE artist = ? COLLATE NOCASE AND title = ? COLLATE NOCASE "
|
||||
"AND source = ? AND source_ref = ?",
|
||||
(artist, title, source, source_ref),
|
||||
).fetchone()
|
||||
self.conn.commit()
|
||||
return dict(zip(self._WANTED_COLS, row)) if row else {}
|
||||
|
||||
def list_wanted(self) -> list[dict]:
|
||||
"""All wishlist entries, newest first."""
|
||||
rows = self.conn.execute(
|
||||
"SELECT " + ", ".join(self._WANTED_COLS) + " FROM wanted "
|
||||
"ORDER BY created_at DESC, id DESC"
|
||||
).fetchall()
|
||||
return [dict(zip(self._WANTED_COLS, r)) for r in rows]
|
||||
|
||||
def remove_wanted(self, wanted_id: int) -> bool:
|
||||
"""Drop a wishlist entry by id. Returns True if a row was removed."""
|
||||
with self._lock:
|
||||
cur = self.conn.execute("DELETE FROM wanted WHERE id = ?", (wanted_id,))
|
||||
self.conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
def count_wanted(self) -> int:
|
||||
return self.conn.execute("SELECT COUNT(*) FROM wanted").fetchone()[0]
|
||||
|
||||
def continue_session(self) -> dict | None:
|
||||
"""Most-recently-played song (from song_stats) + metadata, for the
|
||||
Continue-Playing card. Null when nothing has been played."""
|
||||
@ -5499,6 +5569,40 @@ def api_session_continue():
|
||||
return meta_db.continue_session()
|
||||
|
||||
|
||||
# ── Wishlist / "wanted" API (feedBack#636 item 4) ─────────────────────────────
|
||||
|
||||
@app.get("/api/wanted")
|
||||
def api_list_wanted():
|
||||
"""The wishlist — songs the user wants but doesn't own yet (newest first)."""
|
||||
return {"wanted": meta_db.list_wanted()}
|
||||
|
||||
|
||||
@app.post("/api/wanted")
|
||||
def api_add_wanted(data: dict):
|
||||
"""Add a not-owned song to the wishlist. `artist`/`title` are required (at
|
||||
least one non-empty); `source`/`source_ref`/`note` are optional. Idempotent
|
||||
on identity so producers (find_more ownership-diff, manual add) can re-post."""
|
||||
if not isinstance(data, dict):
|
||||
return JSONResponse({"error": "body must be an object"}, status_code=400)
|
||||
artist = _clean_str(data.get("artist"))
|
||||
title = _clean_str(data.get("title"))
|
||||
if not artist and not title:
|
||||
return JSONResponse({"error": "artist or title required"}, status_code=400)
|
||||
row = meta_db.add_wanted(
|
||||
artist=artist, title=title,
|
||||
source=_clean_str(data.get("source")) or "manual",
|
||||
source_ref=_clean_str(data.get("source_ref")),
|
||||
note=_clean_str(data.get("note")),
|
||||
)
|
||||
return {"ok": True, "wanted": row}
|
||||
|
||||
|
||||
@app.delete("/api/wanted/{wanted_id}")
|
||||
def api_remove_wanted(wanted_id: int):
|
||||
"""Remove a wishlist entry by id."""
|
||||
return {"ok": meta_db.remove_wanted(wanted_id)}
|
||||
|
||||
|
||||
# ── Loops API ────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/loops")
|
||||
|
||||
111
tests/test_wanted_api.py
Normal file
111
tests/test_wanted_api.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""Tests for the wishlist / "wanted" list (got-feedback/feedBack#636 item 4).
|
||||
|
||||
A wishlist entry is a song the user does NOT own yet (the *arr Wanted/Monitored
|
||||
analogue), so it lives in its own `wanted` table keyed by descriptive identity
|
||||
rather than a local filename. Producers (the find_more ownership-diff, or a
|
||||
manual add) POST entries; the API is idempotent on identity so a re-run of an
|
||||
ownership-diff can't duplicate.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server_mod):
|
||||
c = TestClient(server_mod.app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def test_add_list_remove_round_trip(client):
|
||||
assert client.get("/api/wanted").json() == {"wanted": []}
|
||||
|
||||
r = client.post("/api/wanted", json={"artist": "Tool", "title": "Lateralus",
|
||||
"source": "find_more", "source_ref": "cf:123"})
|
||||
assert r.status_code == 200
|
||||
row = r.json()["wanted"]
|
||||
assert (row["artist"], row["title"], row["source"]) == ("Tool", "Lateralus", "find_more")
|
||||
wid = row["id"]
|
||||
|
||||
listed = client.get("/api/wanted").json()["wanted"]
|
||||
assert [w["title"] for w in listed] == ["Lateralus"]
|
||||
|
||||
assert client.request("DELETE", f"/api/wanted/{wid}").json() == {"ok": True}
|
||||
assert client.get("/api/wanted").json() == {"wanted": []}
|
||||
# Deleting an already-gone id is a no-op, not an error.
|
||||
assert client.request("DELETE", f"/api/wanted/{wid}").json() == {"ok": False}
|
||||
|
||||
|
||||
def test_add_is_idempotent_on_identity(client, server_mod):
|
||||
payload = {"artist": "Rush", "title": "YYZ", "source": "find_more", "source_ref": "x1"}
|
||||
first = client.post("/api/wanted", json=payload).json()["wanted"]
|
||||
# Same identity (case-insensitive on artist/title) → no duplicate, same row.
|
||||
again = client.post("/api/wanted", json={**payload, "artist": "rush", "title": "yyz"}).json()["wanted"]
|
||||
assert first["id"] == again["id"]
|
||||
assert server_mod.meta_db.count_wanted() == 1
|
||||
|
||||
# A different source_ref is a distinct entry.
|
||||
client.post("/api/wanted", json={**payload, "source_ref": "x2"})
|
||||
assert server_mod.meta_db.count_wanted() == 2
|
||||
|
||||
|
||||
def test_newest_first_ordering(client, server_mod):
|
||||
for t in ("First", "Second", "Third"):
|
||||
server_mod.meta_db.add_wanted(artist="A", title=t, source="manual")
|
||||
titles = [w["title"] for w in client.get("/api/wanted").json()["wanted"]]
|
||||
assert titles == ["Third", "Second", "First"]
|
||||
|
||||
|
||||
def test_add_requires_artist_or_title(client):
|
||||
r = client.post("/api/wanted", json={"source": "manual"})
|
||||
assert r.status_code == 400
|
||||
r2 = client.post("/api/wanted", json={"artist": "", "title": " "})
|
||||
assert r2.status_code == 400
|
||||
|
||||
|
||||
def test_add_defaults_source_to_manual(client):
|
||||
row = client.post("/api/wanted", json={"title": "Untitled"}).json()["wanted"]
|
||||
assert row["source"] == "manual"
|
||||
assert row["artist"] == ""
|
||||
|
||||
|
||||
def test_non_dict_body_rejected(client):
|
||||
# FastAPI's `data: dict` validation rejects a JSON array (422) before the
|
||||
# handler's own defensive isinstance guard; either way it's not a 2xx.
|
||||
assert client.post("/api/wanted", json=[]).status_code in (400, 422)
|
||||
|
||||
|
||||
def test_table_creation_is_idempotent(server_mod):
|
||||
# Re-running the CREATE TABLE / CREATE INDEX must not error or wipe rows —
|
||||
# pin the additive + idempotent migration guarantee (constitution IV).
|
||||
server_mod.meta_db.add_wanted(artist="Keep", title="Me")
|
||||
server_mod.meta_db.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS wanted (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
artist TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '', source_ref TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '', created_at TEXT
|
||||
)
|
||||
""")
|
||||
server_mod.meta_db.conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_wanted_identity "
|
||||
"ON wanted(artist COLLATE NOCASE, title COLLATE NOCASE, source, source_ref)"
|
||||
)
|
||||
assert server_mod.meta_db.count_wanted() == 1
|
||||
Loading…
Reference in New Issue
Block a user