v3 library: personal per-song metadata (user-difficulty / notes / tags) — P1 (#691)

* v3 library: personal per-song metadata (user-difficulty / notes / tags) — P1

The local "your relationship to the song" layer: a per-song user-difficulty
(1–5, planning-only, distinct from the authored 1–10 difficulty bands),
free-form notes, and free-form practice tags. All kept OUT of the shared
feedpak file and OUT of the `songs` table, in two new never-clobbered tables
so a rescan's `INSERT OR REPLACE INTO songs` can't wipe them. Likes stay the
existing favorites heart — no rating column.

Backend only (the Details drawer + tag/difficulty filter UI come next).

Schema (additive, idempotent):
- song_user_meta(filename PK, user_difficulty INTEGER, notes TEXT, updated_at)
- song_tags(filename, tag, created_at, PK(filename, tag)) + idx on tag

API (DB-only — distinct from POST /api/song/{f}/meta, which writes catalog
fields back into the file):
- GET  /api/song/{f}/user-meta  → {user_difficulty, notes, tags}
- PUT  /api/song/{f}/user-meta  → partial update; user_difficulty (1–5 or
  null), notes (string or null), tags (full-replace array). Tag removal is a
  full-replace array rather than a DELETE sub-route because the greedy
  DELETE /api/song/{filename:path} already owns every DELETE under /api/song
  and would shadow it.
- GET  /api/tags → tags in use with counts (for the filter UI)

Read path:
- query_page rows embed user_difficulty + tags (like `favorite`); notes stay
  out of the list payload (per-song GET — they can be long).
- Read-time filters ?user_difficulty= and ?tags= threaded through _build_where
  exactly like the mastery filter — EXISTS-style predicates, so keyset paging,
  counts, and the A–Z rail are unaffected.
- delete_song purges both personal tables inside the existing lock.

Tags are normalized (trim + lowercase + collapse whitespace) so "Rock"/"rock"
don't split. New tests cover defaults, difficulty validation (rejects out-of-
range / non-integral / bool), notes trim, tag normalize/dedupe, grid embed,
both filters, never-clobber-on-rescan, and purge (21 tests). Neighboring
library tests (filters/keyset/providers/playlists/collections/stats) stay
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): cap per-song tags at 50 to bound writes (PR #691 review)

set_song_tags capped each tag at 60 chars but not the number of tags,
so one PUT could write unbounded rows. Cap the normalized-unique tag
list to the first 50 after dedup. Adds a test asserting >50 stores 50.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-02 13:25:18 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent 22d299959f
commit 58e7407c38
2 changed files with 466 additions and 2 deletions
+260 -2
View File
@@ -563,6 +563,34 @@ class MetadataDB:
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_tuning_sort_key ON songs(tuning_sort_key)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_tuning_sort_key ON songs(tuning_sort_key)")
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_year ON songs(year)") self.conn.execute("CREATE INDEX IF NOT EXISTS idx_songs_year ON songs(year)")
self.conn.execute("CREATE TABLE IF NOT EXISTS favorites (filename TEXT PRIMARY KEY)") self.conn.execute("CREATE TABLE IF NOT EXISTS favorites (filename TEXT PRIMARY KEY)")
# Personal, per-song metadata that must NEVER travel in the shared
# feedpak file: a light 15 user-difficulty (planning only — distinct
# from the authored 110 difficulty bands) + freeform notes. Likes are
# NOT here — they stay the existing `favorites` heart (Christian's call).
# A SEPARATE table (not `songs` columns) so a rescan's
# `INSERT OR REPLACE INTO songs` can't wipe it; keyed by the same on-disk
# filename as every other personal table. Additive + idempotent.
self.conn.execute("""
CREATE TABLE IF NOT EXISTS song_user_meta (
filename TEXT PRIMARY KEY,
user_difficulty INTEGER, -- 1..5, NULL = unset
notes TEXT,
updated_at TEXT
)
""")
# Free-form personal practice tags ("warm-ups", "riffs to nail") — an
# intent practice-set primitive (Play-all-over-a-tag comes later). Tags
# are normalized lowercase on write so "Rock"/"rock" don't split. Peer
# of song_user_meta; same never-clobber rationale.
self.conn.execute("""
CREATE TABLE IF NOT EXISTS song_tags (
filename TEXT NOT NULL,
tag TEXT NOT NULL,
created_at TEXT,
PRIMARY KEY (filename, tag)
)
""")
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_tags_tag ON song_tags(tag COLLATE NOCASE)")
self.conn.execute(""" self.conn.execute("""
CREATE TABLE IF NOT EXISTS loops ( CREATE TABLE IF NOT EXISTS loops (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -860,6 +888,116 @@ class MetadataDB:
self.conn.commit() self.conn.commit()
return True return True
# ── Personal per-song metadata: user-difficulty / notes / tags ───────────
# All keyed by the on-disk `songs` filename and kept OUT of the shared
# feedpak file. Likes are the `favorites` heart, deliberately NOT duplicated
# here. Reads are lock-free (WAL); writes take self._lock like the rest.
def get_song_user_meta(self, filename: str) -> dict:
"""{'user_difficulty', 'notes', 'tags'} for one song (tags sorted)."""
row = self.conn.execute(
"SELECT user_difficulty, notes FROM song_user_meta WHERE filename = ?",
(filename,)).fetchone()
tags = [r[0] for r in self.conn.execute(
"SELECT tag FROM song_tags WHERE filename = ? ORDER BY tag COLLATE NOCASE",
(filename,)).fetchall()]
return {
"user_difficulty": (row[0] if row else None),
"notes": ((row[1] if row else None) or ""),
"tags": tags,
}
def set_song_user_meta(self, filename: str, *,
user_difficulty="__keep__", notes="__keep__") -> dict:
"""Partial upsert of the personal fields. Pass a value to set it, None to
clear it, or leave it out (sentinel `__keep__`) to preserve the current
one. When nothing personal remains the row is dropped so an
unset-everything leaves no empty shell. Returns the merged meta."""
with self._lock:
cur = self.conn.execute(
"SELECT user_difficulty, notes FROM song_user_meta WHERE filename = ?",
(filename,)).fetchone()
cur_diff = cur[0] if cur else None
cur_notes = cur[1] if cur else None
new_diff = cur_diff if user_difficulty == "__keep__" else user_difficulty
new_notes = cur_notes if notes == "__keep__" else notes
if new_diff is None and not (new_notes or "").strip():
self.conn.execute("DELETE FROM song_user_meta WHERE filename = ?", (filename,))
else:
self.conn.execute(
"INSERT INTO song_user_meta (filename, user_difficulty, notes, updated_at) "
"VALUES (?, ?, ?, datetime('now')) "
"ON CONFLICT(filename) DO UPDATE SET "
"user_difficulty = excluded.user_difficulty, "
"notes = excluded.notes, updated_at = excluded.updated_at",
(filename, new_diff, (new_notes or None)))
self.conn.commit()
return self.get_song_user_meta(filename)
def set_song_tags(self, filename: str, tags) -> list:
"""Replace ALL of a song's tags with the given set (each normalized;
blanks + case-dupes dropped). Full-replace so the whole personal-meta
blob edits as a unit. Returns the stored tag list (sorted, like reads)."""
norm: list = []
seen: set = set()
for t in (tags or []):
nt = _normalize_tag(t)
if nt and nt not in seen:
seen.add(nt)
norm.append(nt)
# Bound the number of tags so one PUT can't write unbounded rows.
# Per-tag length is already capped in _normalize_tag; cap the count too.
norm = norm[:50]
with self._lock:
self.conn.execute("DELETE FROM song_tags WHERE filename = ?", (filename,))
if norm:
self.conn.executemany(
"INSERT OR IGNORE INTO song_tags (filename, tag, created_at) "
"VALUES (?, ?, datetime('now'))",
[(filename, t) for t in norm])
self.conn.commit()
return self.get_song_user_meta(filename)["tags"]
def all_tags(self) -> list:
"""[{tag, count}] over songs that still exist, most-used first — powers
the tag filter UI. Excludes tags whose only songs were deleted."""
rows = self.conn.execute(
"SELECT tag, COUNT(*) c FROM song_tags "
"WHERE filename IN (SELECT filename FROM songs) "
"GROUP BY tag ORDER BY c DESC, tag COLLATE NOCASE").fetchall()
return [{"tag": r[0], "count": r[1]} for r in rows]
def user_meta_map(self, filenames) -> dict:
"""Batch {filename: user_difficulty} for a page of rows (set values
only). Lets query_page embed difficulty without an N+1."""
fns = list(filenames)
if not fns:
return {}
ph = ",".join("?" * len(fns))
rows = self.conn.execute(
f"SELECT filename, user_difficulty FROM song_user_meta "
f"WHERE filename IN ({ph}) AND user_difficulty IS NOT NULL", fns).fetchall()
return {r[0]: r[1] for r in rows}
def tags_map(self, filenames) -> dict:
"""Batch {filename: [tags]} for a page of rows."""
fns = list(filenames)
if not fns:
return {}
ph = ",".join("?" * len(fns))
rows = self.conn.execute(
f"SELECT filename, tag FROM song_tags WHERE filename IN ({ph}) "
f"ORDER BY tag COLLATE NOCASE", fns).fetchall()
out: dict = {}
for fn, tag in rows:
out.setdefault(fn, []).append(tag)
return out
def purge_song_user_data(self, filename: str) -> None:
"""Drop all personal rows for a deleted song. Called by delete_song
INSIDE the caller's `meta_db._lock` — must not re-acquire the lock."""
self.conn.execute("DELETE FROM song_user_meta WHERE filename = ?", (filename,))
self.conn.execute("DELETE FROM song_tags WHERE filename = ?", (filename,))
# ── Player profile (fee[dB]ack v0.3.0) ───────────────────────────────── # ── Player profile (fee[dB]ack v0.3.0) ─────────────────────────────────
def get_profile(self) -> dict: def get_profile(self) -> dict:
row = self.conn.execute( row = self.conn.execute(
@@ -1930,6 +2068,8 @@ class MetadataDB:
has_lyrics: int | None = None, has_lyrics: int | None = None,
tunings: list[str] | None = None, tunings: list[str] | None = None,
mastery: list[str] | None = None, mastery: list[str] | None = None,
tags_has: list[str] | None = None,
user_difficulty_in: list[str] | None = None,
naming_mode: str = "legacy") -> tuple[str, list]: naming_mode: str = "legacy") -> tuple[str, list]:
"""Shared WHERE-clause builder for query_page / query_artists / """Shared WHERE-clause builder for query_page / query_artists /
query_stats. Returns (where_sql, params). Leading 'WHERE' is query_stats. Returns (where_sql, params). Leading 'WHERE' is
@@ -1961,6 +2101,29 @@ class MetadataDB:
_sel = [_bands[b] for b in mastery if b in _bands] _sel = [_bands[b] for b in mastery if b in _bands]
if _sel: if _sel:
where += " AND (" + " OR ".join(_sel) + ")" where += " AND (" + " OR ".join(_sel) + ")"
# Personal practice tags (song_tags) — any-of. EXISTS-style IN keeps it a
# predicate on `songs` (keyset-safe, no row multiplication). Normalized to
# match how tags are stored.
_tags = [t for t in (_normalize_tag(x) for x in (tags_has or [])) if t]
if _tags:
ph = ",".join(["?"] * len(_tags))
where += (" AND filename IN (SELECT filename FROM song_tags "
f"WHERE tag IN ({ph}))")
params += _tags
# Personal user-difficulty (song_user_meta) — any-of over the 1..5 set.
_diffs = []
for d in (user_difficulty_in or []):
try:
di = int(d)
except (TypeError, ValueError):
continue
if 1 <= di <= 5:
_diffs.append(di)
if _diffs:
ph = ",".join(["?"] * len(_diffs))
where += (" AND filename IN (SELECT filename FROM song_user_meta "
f"WHERE user_difficulty IN ({ph}))")
params += _diffs
if q: if q:
where += " AND (title LIKE ? COLLATE NOCASE OR artist LIKE ? COLLATE NOCASE OR album LIKE ? COLLATE NOCASE)" where += " AND (title LIKE ? COLLATE NOCASE OR artist LIKE ? COLLATE NOCASE OR album LIKE ? COLLATE NOCASE)"
params += [f"%{q}%"] * 3 params += [f"%{q}%"] * 3
@@ -2114,6 +2277,8 @@ class MetadataDB:
has_lyrics: int | None = None, has_lyrics: int | None = None,
tunings: list[str] | None = None, tunings: list[str] | None = None,
mastery: list[str] | None = None, mastery: list[str] | None = None,
tags_has: list[str] | None = None,
user_difficulty_in: list[str] | None = None,
after: str | None = None, after: str | None = None,
naming_mode: str = "legacy") -> tuple[list[dict], int]: naming_mode: str = "legacy") -> tuple[list[dict], int]:
"""Server-side paginated search. Returns (songs, total_count). """Server-side paginated search. Returns (songs, total_count).
@@ -2127,7 +2292,9 @@ class MetadataDB:
artist_filter=artist_filter, album_filter=album_filter, artist_filter=artist_filter, album_filter=album_filter,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks, arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks, stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery, naming_mode=naming_mode, has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
tags_has=tags_has, user_difficulty_in=user_difficulty_in,
naming_mode=naming_mode,
) )
sort_map = { sort_map = {
@@ -2237,6 +2404,16 @@ class MetadataDB:
"tuning_offsets": r[14] or "", "tuning_offsets": r[14] or "",
"has_estd": r[0] in estd, "favorite": r[0] in favs, "has_estd": r[0] in estd, "favorite": r[0] in favs,
}) })
# Personal layer (difficulty + tags) rides along like `favorite`, so a
# card can badge it without a second request. Notes stay OUT of the list
# payload (they can be long) — fetch per-song via /user-meta. Batched to
# avoid an N+1 over the page.
fns = [s["filename"] for s in songs]
udm = self.user_meta_map(fns)
tgm = self.tags_map(fns)
for s in songs:
s["user_difficulty"] = udm.get(s["filename"])
s["tags"] = tgm.get(s["filename"], [])
return songs, total return songs, total
def query_artists(self, letter: str = "", q: str = "", def query_artists(self, letter: str = "", q: str = "",
@@ -4887,6 +5064,9 @@ def delete_song(filename: str):
# surfacing in stats / recent / continue / playlists immediately. # surfacing in stats / recent / continue / playlists immediately.
meta_db.conn.execute("DELETE FROM song_stats WHERE filename = ?", (cache_key,)) meta_db.conn.execute("DELETE FROM song_stats WHERE filename = ?", (cache_key,))
meta_db.conn.execute("DELETE FROM playlist_songs WHERE filename = ?", (cache_key,)) meta_db.conn.execute("DELETE FROM playlist_songs WHERE filename = ?", (cache_key,))
# Personal difficulty / notes / tags for this song (we hold the
# lock, so purge is lock-free).
meta_db.purge_song_user_data(cache_key)
meta_db.conn.commit() meta_db.conn.commit()
_invalidate_song_caches(cache_key) _invalidate_song_caches(cache_key)
@@ -4913,6 +5093,15 @@ def _split_csv(raw: str) -> list[str]:
return [s.strip() for s in raw.split(",") if s.strip()] return [s.strip() for s in raw.split(",") if s.strip()]
def _normalize_tag(tag) -> str:
"""Canonical form for a personal practice tag: trimmed, lowercased,
internal whitespace collapsed, length-capped. Lowercasing is what keeps
"Rock"/"rock" from splitting into two tags. Non-strings ''."""
if not isinstance(tag, str):
return ""
return " ".join(tag.strip().lower().split())[:60]
def _parse_has_lyrics(raw: str) -> int | None: def _parse_has_lyrics(raw: str) -> int | None:
"""Tri-state parse for has_lyrics. `1` → require, `0` → exclude, """Tri-state parse for has_lyrics. `1` → require, `0` → exclude,
anything else (including empty) no filter.""" anything else (including empty) no filter."""
@@ -4979,7 +5168,8 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
arrangements_has: str = "", arrangements_lacks: str = "", arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "", stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", provider: str = "local", has_lyrics: str = "", tunings: str = "", provider: str = "local",
mastery: str = "", after: str = "", naming_mode: str = "legacy"): mastery: str = "", tags: str = "", user_difficulty: str = "",
after: str = "", naming_mode: str = "legacy"):
"""Paginated library search through the selected library provider. """Paginated library search through the selected library provider.
`after` is an opaque keyset cursor (feedBack#636 item 3): pass back the `after` is an opaque keyset cursor (feedBack#636 item 3): pass back the
@@ -5004,6 +5194,8 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
after=((after or None) if is_local else None), after=((after or None) if is_local else None),
naming_mode=naming_mode, naming_mode=naming_mode,
mastery=_split_csv(mastery), mastery=_split_csv(mastery),
tags_has=_split_csv(tags),
user_difficulty_in=_split_csv(user_difficulty),
**_library_filter_args( **_library_filter_args(
q=q, favorites=favorites, format=format, q=q, favorites=favorites, format=format,
artist=artist, album=album, artist=artist, album=album,
@@ -5101,6 +5293,72 @@ def toggle_favorite(data: dict):
return {"favorite": new_state} return {"favorite": new_state}
# ── Personal per-song metadata (difficulty / notes / tags) ───────────────────
# The local, never-shared layer. Distinct from POST /api/song/{f}/meta, which
# writes catalog fields (title/artist/album/year) BACK INTO the feedpak file;
# these endpoints are DB-only and never touch the file. Likes stay the heart
# (POST /api/favorites/toggle).
@app.get("/api/song/{filename:path}/user-meta")
def get_song_user_meta(filename: str):
"""Read {user_difficulty, notes, tags} for one song."""
return meta_db.get_song_user_meta(meta_db._canonical_song_filename(filename))
@app.put("/api/song/{filename:path}/user-meta")
def put_song_user_meta(filename: str, data: dict):
"""Partial update. Send any of: `user_difficulty` (int 15, or null/"" to
clear), `notes` (string, or null to clear), `tags` (a full-replace array of
strings). Omitted keys are preserved. Returns the merged meta.
Tag removal is a full-replace `tags` array (send the new set) rather than a
granular DELETE sub-route, because `DELETE /api/song/{filename:path}` already
owns every DELETE under /api/song and would shadow it."""
key = meta_db._canonical_song_filename(filename)
kwargs: dict = {}
if "user_difficulty" in data:
v = data["user_difficulty"]
if v is None or v == "":
kwargs["user_difficulty"] = None
else:
# Reject bools (int subclass) and non-integral floats so 2.5 / true
# can't silently truncate into a valid band.
if isinstance(v, bool) or (isinstance(v, float) and not v.is_integer()):
return JSONResponse({"error": "user_difficulty must be an integer 15 or null"}, 400)
try:
iv = int(v)
except (TypeError, ValueError):
return JSONResponse({"error": "user_difficulty must be an integer 15 or null"}, 400)
if not (1 <= iv <= 5):
return JSONResponse({"error": "user_difficulty must be 15 or null"}, 400)
kwargs["user_difficulty"] = iv
if "notes" in data:
n = data["notes"]
if n is None:
kwargs["notes"] = None
elif isinstance(n, str):
kwargs["notes"] = n.strip()[:4000]
else:
return JSONResponse({"error": "notes must be a string or null"}, 400)
tags = data.get("tags", "__absent__")
if tags != "__absent__" and not isinstance(tags, list):
return JSONResponse({"error": "tags must be an array of strings"}, 400)
if not kwargs and tags == "__absent__":
return JSONResponse({"error": "No fields to update"}, 400)
if kwargs:
meta_db.set_song_user_meta(key, **kwargs)
if tags != "__absent__":
meta_db.set_song_tags(key, tags)
return meta_db.get_song_user_meta(key)
@app.get("/api/tags")
def list_tags():
"""All personal tags in use (over still-present songs), most-used first —
powers the tag filter UI."""
return {"tags": meta_db.all_tags()}
# ── Player profile / unified XP / streak (fee[dB]ack v0.3.0) ────────────────── # ── Player profile / unified XP / streak (fee[dB]ack v0.3.0) ──────────────────
def _list_bundled_avatars() -> list[str]: def _list_bundled_avatars() -> list[str]:
+206
View File
@@ -0,0 +1,206 @@
"""Tests for the personal per-song metadata layer (P1): user-difficulty,
notes, and tags. This is the LOCAL, never-shared layer — distinct from
/api/song/{f}/meta (which writes catalog fields back into the feedpak file).
Likes stay the existing favorites heart and are not touched here."""
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 _seed(server, *filenames):
for fn in filenames:
server.meta_db.put(fn, 0, 0, {"title": fn.split(".")[0], "artist": "A"})
# ── Read defaults ────────────────────────────────────────────────────────────
def test_defaults_for_untouched_song(client, server):
_seed(server, "a.archive")
m = client.get("/api/song/a.archive/user-meta").json()
assert m == {"user_difficulty": None, "notes": "", "tags": []}
# ── Difficulty + notes write/read ────────────────────────────────────────────
def test_set_difficulty_and_notes(client, server):
_seed(server, "a.archive")
r = client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": 3, "notes": " bar chord section "})
assert r.status_code == 200
m = r.json()
assert m["user_difficulty"] == 3
assert m["notes"] == "bar chord section" # trimmed
# persisted
assert client.get("/api/song/a.archive/user-meta").json()["user_difficulty"] == 3
def test_partial_update_preserves_other_fields(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 4, "notes": "keep"})
# update only notes -> difficulty preserved
client.put("/api/song/a.archive/user-meta", json={"notes": "changed"})
m = client.get("/api/song/a.archive/user-meta").json()
assert m["user_difficulty"] == 4 and m["notes"] == "changed"
def test_clear_difficulty_with_null(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 2})
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": None})
assert client.get("/api/song/a.archive/user-meta").json()["user_difficulty"] is None
@pytest.mark.parametrize("bad", [0, 6, 99, -1, "x", 2.5])
def test_difficulty_out_of_range_is_400(client, server, bad):
_seed(server, "a.archive")
assert client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": bad}).status_code == 400
def test_empty_body_is_400(client, server):
_seed(server, "a.archive")
assert client.put("/api/song/a.archive/user-meta", json={}).status_code == 400
# ── Tags (full-replace) + normalization ──────────────────────────────────────
def test_tags_replace_and_normalize(client, server):
_seed(server, "a.archive")
r = client.put("/api/song/a.archive/user-meta",
json={"tags": ["Warm-ups", " RIFFS ", "warm-ups", ""]})
# lowercased, whitespace-trimmed, case-dupes + blanks dropped; returned
# sorted alphabetically (deterministic chip order, matches reads)
assert r.json()["tags"] == ["riffs", "warm-ups"]
# replace with a smaller set
client.put("/api/song/a.archive/user-meta", json={"tags": ["gig"]})
assert client.get("/api/song/a.archive/user-meta").json()["tags"] == ["gig"]
# clear
client.put("/api/song/a.archive/user-meta", json={"tags": []})
assert client.get("/api/song/a.archive/user-meta").json()["tags"] == []
def test_tags_capped_at_50(client, server):
_seed(server, "a.archive")
# Submit 80 distinct tags; only the first 50 normalized-unique are stored.
many = [f"tag{i}" for i in range(80)]
r = client.put("/api/song/a.archive/user-meta", json={"tags": many})
assert r.status_code == 200
assert len(r.json()["tags"]) == 50
assert len(client.get("/api/song/a.archive/user-meta").json()["tags"]) == 50
def test_tags_must_be_array(client, server):
_seed(server, "a.archive")
assert client.put("/api/song/a.archive/user-meta",
json={"tags": "warmups"}).status_code == 400
def test_tags_untouched_when_key_absent(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta", json={"tags": ["keep"]})
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 1})
assert client.get("/api/song/a.archive/user-meta").json()["tags"] == ["keep"]
def test_list_all_tags_with_counts(client, server):
_seed(server, "a.archive", "b.archive")
client.put("/api/song/a.archive/user-meta", json={"tags": ["warmups", "riffs"]})
client.put("/api/song/b.archive/user-meta", json={"tags": ["warmups"]})
tags = client.get("/api/tags").json()["tags"]
# most-used first
assert tags[0] == {"tag": "warmups", "count": 2}
assert {"tag": "riffs", "count": 1} in tags
# ── Grid embed (query_page) ──────────────────────────────────────────────────
def test_library_row_embeds_difficulty_and_tags_not_notes(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": 5, "notes": "secret", "tags": ["gig"]})
row = next(s for s in client.get("/api/library").json()["songs"]
if s["filename"] == "a.archive")
assert row["user_difficulty"] == 5
assert row["tags"] == ["gig"]
assert "notes" not in row # notes stay out of the list payload
# ── Read-time filters ────────────────────────────────────────────────────────
def test_filter_by_user_difficulty(client, server):
_seed(server, "a.archive", "b.archive", "c.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 2})
client.put("/api/song/b.archive/user-meta", json={"user_difficulty": 4})
got = {s["filename"] for s in client.get("/api/library?user_difficulty=2").json()["songs"]}
assert got == {"a.archive"}
# any-of set
got2 = {s["filename"] for s in client.get("/api/library?user_difficulty=2,4").json()["songs"]}
assert got2 == {"a.archive", "b.archive"}
def test_filter_by_tag(client, server):
_seed(server, "a.archive", "b.archive")
client.put("/api/song/a.archive/user-meta", json={"tags": ["warmups"]})
got = {s["filename"] for s in client.get("/api/library?tags=warmups").json()["songs"]}
assert got == {"a.archive"}
# case-insensitive match on the query side (normalized)
got_ci = {s["filename"] for s in client.get("/api/library?tags=WARMUPS").json()["songs"]}
assert got_ci == {"a.archive"}
def test_filter_total_count_reflects_filter(client, server):
_seed(server, "a.archive", "b.archive", "c.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 3})
body = client.get("/api/library?user_difficulty=3").json()
assert body["total"] == 1
# ── Never-clobber on rescan ──────────────────────────────────────────────────
def test_personal_data_survives_rescan(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": 3, "notes": "n", "tags": ["t"]})
# Simulate the scanner re-indexing the song (INSERT OR REPLACE INTO songs).
server.meta_db.put("a.archive", 1, 1, {"title": "a", "artist": "A"})
m = client.get("/api/song/a.archive/user-meta").json()
assert m["user_difficulty"] == 3 and m["notes"] == "n" and m["tags"] == ["t"]
# ── Purge on delete ──────────────────────────────────────────────────────────
def test_purge_removes_all_personal_rows(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": 3, "notes": "n", "tags": ["x", "y"]})
# delete_song calls this inside meta_db._lock after removing the file.
with server.meta_db._lock:
server.meta_db.purge_song_user_data("a.archive")
server.meta_db.conn.commit()
m = client.get("/api/song/a.archive/user-meta").json()
assert m == {"user_difficulty": None, "notes": "", "tags": []}
# and the tag drops out of the global vocabulary
assert client.get("/api/tags").json()["tags"] == []