From c61aa008ba41060637f704fb451a06dc583e2bfe Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sat, 4 Jul 2026 16:23:53 -0500 Subject: [PATCH] feat(library): show per-song overrides in the grid (popup slice 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid now displays the user's per-song title/artist/album/year override in place of the pack value ("grid shows only overrides") — a matched MusicBrainz canon never silently re-titles a card; canon stays in the Details drawer + art. Overlaid in Python over the visible window, keyset-safe like the P4 artist-alias re-label: the seek still runs on the raw column, and the one overridable keyset column (title) stashes its raw value for the cursor so paging never skips/dupes. The private stash is dropped from the payload. Co-Authored-By: Claude Opus 4.8 (1M context) --- server.py | 33 +++++++++++++++- tests/test_field_overrides.py | 71 +++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/server.py b/server.py index 2250354..0a2facd 100644 --- a/server.py +++ b/server.py @@ -556,7 +556,13 @@ def next_library_cursor(sort: str, last_song: dict | None) -> str | None: key = "mtime" if col == "mtime" else col if key not in last_song or "filename" not in last_song: return None - return _encode_cursor([last_song[key], last_song["filename"]]) + # A title display-override (Fix-metadata popup) replaces last_song["title"] + # for the card, but the keyset seek runs on the RAW title column — resume + # from the raw value query_page stashed (present only when the last row's + # title was overridden), so paging never skips/dupes. + val = (last_song["_sort_title"] if (key == "title" and "_sort_title" in last_song) + else last_song[key]) + return _encode_cursor([val, last_song["filename"]]) # Song-level "mastered" threshold — best accuracy across a song's arrangements @@ -4156,6 +4162,15 @@ class MetadataDB: # per-tile state only paints while a pass runs. Cheap set membership like # favs/estd, so the misses stay visible at rest. um = self._unmatched_set(fns) + # Per-song display OVERRIDES (Fix-metadata popup, slice 3). "Grid shows + # only overrides": the effective cell is the user's override else the + # pack value — a matched MusicBrainz canon NEVER silently re-titles a + # card (canon lives in the Details drawer + art). Overlaid in Python + # over the visible window, keyset-safe exactly like the P4 alias re-label + # below: the seek still runs on the raw column (the one overridable + # keyset column, title, stashes its raw value for the cursor — see + # _sort_title / next_library_cursor). + omap = self.overrides_map(fns) # Canonical artist at display (P4): re-label the card's artist through the # alias override so "ACDC" reads as "AC/DC". Display-only — the row's sort # position (raw artist) is untouched, so a card can show a canonical name @@ -4168,6 +4183,18 @@ class MetadataDB: s["unmatched"] = s["filename"] in um if amap: s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist")) + # Override wins over the pack AND the alias re-label — it's the user's + # explicit per-song choice. Only a non-empty override VALUE replaces a + # cell; a lock-only row (value None) leaves the displayed value alone. + ov = omap.get(s["filename"]) + if ov: + for field in ("title", "artist", "album", "year"): + cell = ov.get(field) + val = cell.get("value") if cell else None + if val: + if field == "title": + s["_sort_title"] = s["title"] # raw title, for the keyset cursor + s[field] = val # Grouped rows carry the ⚑ N (chart_count) + the work_key from the # materialized read-model, so the card can render the "N charts" chip and # address the Charts drawer (GET /api/work/{work_key}/charts) without a @@ -8825,6 +8852,10 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = " # The cursor to resume after this page (effective sort folds in dir=desc). next_cursor = (next_library_cursor(_effective_keyset_sort(sort, dir), songs[-1]) if (is_local and songs) else None) + # Drop the private raw-title stash query_page attached for the cursor — it's + # an internal keyset detail, not part of the card payload. + for s in songs: + s.pop("_sort_title", None) return {"songs": songs, "total": total, "page": page, "size": size, "next_cursor": next_cursor} diff --git a/tests/test_field_overrides.py b/tests/test_field_overrides.py index dfeda73..fcd84f4 100644 --- a/tests/test_field_overrides.py +++ b/tests/test_field_overrides.py @@ -146,3 +146,74 @@ def test_compose_lock_filter_strips_locked_cand_keys(server): assert out["recording_id"] == "r" and out["title"] == "T" and out["album"] == "A" # no locks → base filter returned unchanged (zero-copy common path) assert server._compose_lock_filter(None, set()) is None + + +# ── display overlay in the grid (slice 3) ───────────────────────────────────── +# "Grid shows only overrides": the effective cell is the user's override else the +# pack value. Display-only + keyset-safe — the seek stays on the raw column. + +def _grid(server, **kw): + songs, _ = server.meta_db.query_page(**kw) + return {s["filename"]: s for s in songs} + + +def test_grid_shows_override_value_over_pack(server): + _put(server, "a.archive", title="Wrong Title", artist="Wrong", + album="Pack Album", year="1999") + server.meta_db.set_song_override("a.archive", "title", value="Right Title") + server.meta_db.set_song_override("a.archive", "artist", value="Right Artist") + server.meta_db.set_song_override("a.archive", "year", value="1979") + s = _grid(server)["a.archive"] + assert s["title"] == "Right Title" + assert s["artist"] == "Right Artist" + assert s["year"] == "1979" + assert s["album"] == "Pack Album" # no override → pack value shows + assert s["_sort_title"] == "Wrong Title" # raw title stashed for the cursor + + +def test_grid_ignores_lock_only_override(server): + _put(server, "a.archive", title="Pack Title") + server.meta_db.set_song_override("a.archive", "title", locked=True) # lock, no value + s = _grid(server)["a.archive"] + assert s["title"] == "Pack Title" # a lock without a value never retitles + assert "_sort_title" not in s # …and stashes nothing + + +def test_override_beats_alias_relabel_for_artist(server): + _put(server, "a.archive", artist="ACDC") + server.meta_db.set_artist_alias("ACDC", "AC/DC") # P4 alias + assert _grid(server)["a.archive"]["artist"] == "AC/DC" # alias applies alone + server.meta_db.set_song_override("a.archive", "artist", value="AC-DC (mine)") + assert _grid(server)["a.archive"]["artist"] == "AC-DC (mine)" # override wins over alias + + +def test_route_strips_private_sort_title(client, server): + _put(server, "a.archive", title="Pack") + server.meta_db.set_song_override("a.archive", "title", value="Shown") + row = next(s for s in client.get("/api/library?sort=title").json()["songs"] + if s["filename"] == "a.archive") + assert row["title"] == "Shown" + assert "_sort_title" not in row # private keyset stash never leaks to the client + + +def test_title_keyset_paging_is_complete_with_overrides(client, server): + # Raw titles A/B/C → title-sort order is A, B, C on the RAW column. + _put(server, "b.archive", title="B") + _put(server, "a.archive", title="A") + _put(server, "c.archive", title="C") + # Overrides that would reshuffle the order IF the cursor wrongly used the + # displayed value — the seek must stay on the raw title, so paging still + # covers every row exactly once (no skip/dupe). + server.meta_db.set_song_override("a.archive", "title", value="ZZZ") + server.meta_db.set_song_override("c.archive", "title", value="AAA") + seen, cursor = [], None + for _ in range(10): + url = "/api/library?sort=title&size=1" + (f"&after={cursor}" if cursor else "") + data = client.get(url).json() + if not data["songs"]: + break + seen.append(data["songs"][0]["filename"]) + cursor = data["next_cursor"] + if not cursor: + break + assert sorted(seen) == ["a.archive", "b.archive", "c.archive"] # each exactly once