From 502de02840e2b1d18e6ec32eee545b99dfeebbbb Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sat, 4 Jul 2026 22:40:17 -0500 Subject: [PATCH 1/2] feat(v3 library): persistent "no match" badge + Unmatched quick filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Refresh-Metadata batch (#764) shows a transient per-tile "no match" only while a pass runs, so the unmatched pile goes quiet at rest. Two additions make it visible + reachable: - Persistent per-card "No match" badge: query_page now marks each row `unmatched` (a cheap failed-set membership like favs/estd), and enrichBadge paints a subtle resting marker for those cards — tracked in a `_unmatched` set so a batch tile clearing falls back to it instead of wiping it. A live batch tile still wins while a pass runs. - "Unmatched" toolbar toggle (local-only): one click applies the same filter as the drawer's Match → Unmatched (match_state='failed'), so the no-match pile is a click away right after a batch. Re-queries + reflects active state. Test: query_page flags a failed row + the match=unmatched filter returns it. Co-Authored-By: Claude Opus 4.8 (1M context) --- server.py | 22 ++++++++++++++++ static/v3/songs.js | 49 ++++++++++++++++++++++++++++------- tests/test_library_filters.py | 13 ++++++++++ 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/server.py b/server.py index 9e670f89..ed88eb8c 100644 --- a/server.py +++ b/server.py @@ -3038,6 +3038,22 @@ def enrichment_states_for(self, filenames: list[str]) -> dict: out[fn] = st return out + def _unmatched_set(self, filenames) -> set: + """The subset of `filenames` whose enrichment landed in the 'failed' + (no-match) state — feeds the grid's persistent per-card "no match" badge, + so the misses stay visible at rest (the batch tile only shows while a + refresh runs). Chunked set membership, like favorite_set.""" + fns = list(filenames) + out: set = set() + for i in range(0, len(fns), 400): + chunk = fns[i:i + 400] + if not chunk: + break + q = ("SELECT filename FROM song_enrichment WHERE match_state = 'failed' " + "AND filename IN (%s)" % ",".join("?" * len(chunk))) + out.update(r[0] for r in self.conn.execute(q, chunk).fetchall()) + return out + def enrichment_song_row(self, filename: str) -> dict | None: """The identity fields the matcher/scorer keys on, for one song.""" row = self.conn.execute( @@ -4042,6 +4058,11 @@ def query_page(self, q: str = "", page: int = 0, size: int = 24, fns = [s["filename"] for s in songs] udm = self.user_meta_map(fns) tgm = self.tags_map(fns) + # Enrichment "no match" (failed) set for the page, so a card can show a + # persistent "no match" badge — the Refresh-Metadata batch's transient + # 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) # 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 @@ -4051,6 +4072,7 @@ def query_page(self, q: str = "", page: int = 0, size: int = 24, for s in songs: s["user_difficulty"] = udm.get(s["filename"]) s["tags"] = tgm.get(s["filename"], []) + s["unmatched"] = s["filename"] in um if amap: s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist")) # Grouped rows carry the ⚑ N (chart_count) + the work_key from the diff --git a/static/v3/songs.js b/static/v3/songs.js index 9d6fe9d2..06c5ec98 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -498,21 +498,32 @@ // to before (keeps the windowed grid's height math untouched). Honest state // transitions, NOT a fake per-song %: a match is binary (design §11). const _metaTile = {}; // fn -> 'queued' | 'working' | 'done' | 'nochange' - function enrichBadge(fn) { - const st = _metaTile[fn]; + // Cards whose enrichment landed 'failed' (from the grid payload) — tracked so + // the PERSISTENT "no match" badge survives a batch tile clearing (a + // _patchCardEnrich with no flag falls back to this instead of wiping it). + // Populated as cards render (enrichBadge is called per card with the flag). + const _unmatched = new Set(); + function enrichBadge(fn, unmatched) { + if (unmatched !== undefined) { if (unmatched) _unmatched.add(fn); else _unmatched.delete(fn); } + // A live batch tile wins over the resting no-match marker (they never + // coexist — the batch clears its tiles when it finishes). + const st = _metaTile[fn] || (_unmatched.has(fn) ? 'nomatch' : null); if (!st) return ''; const M = { - queued: ['bg-black/60 text-fb-textDim', '• Queued'], - working: ['bg-fb-primary text-white', '⟳ Matching…'], - done: ['bg-fb-good/90 text-black', '✓ Updated'], - nochange: ['bg-black/60 text-fb-textDim', '— No match'], + queued: ['bg-black/60 text-fb-textDim', '• Queued', ''], + working: ['bg-fb-primary text-white', '⟳ Matching…', ''], + done: ['bg-fb-good/90 text-black', '✓ Updated', ''], + nochange: ['bg-black/60 text-fb-textDim', '— No match', ''], + // Resting indicator: subtle, so a mostly-unmatched library isn't a + // wall of loud badges; points at the manual fix. + nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'No metadata match found — right-click to fix it by hand'], }; const conf = M[st] || M.queued; // top-10 clears the tuning chip (top-2) in both normal and select mode; // z-20 sits it above the art. Non-interactive so it never eats a click. return '' + - conf[1] + ''; + ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight pointer-events-none"' + + (conf[2] ? ' title="' + conf[2] + '"' : '') + '>' + conf[1] + ''; } // After a song is scored, the badge for that card is stale until the next @@ -870,7 +881,7 @@ return '
' + '
' + '' + - tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key) + overlay + + tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key, song.unmatched) + overlay + '
' + inlineBtns + '' + @@ -3489,6 +3500,7 @@ '' + '' + '' + + '' + '' + '
' + // Practice-aware library home: a repertoire progress meter + a @@ -3559,6 +3571,7 @@ // Refresh Metadata: local-only, so hide it for remote providers. The // button doubles as its own Stop while a pass runs (see onMetaBtnClick). byId('v3-songs-refresh-meta')?.addEventListener('click', onMetaBtnClick); + byId('v3-songs-unmatched')?.addEventListener('click', toggleUnmatchedFilter); _updateMetaBtnVisibility(); // Reflect a scan already in progress (Settings button or a background // pass) on the Refresh button, so its state isn't just tied to clicks here. @@ -3808,8 +3821,24 @@ let _metaRunning = false; function _updateMetaBtnVisibility() { + const local = state.provider === 'local'; // enrichment + its filter are local-only const btn = document.getElementById('v3-songs-refresh-meta'); - if (btn) btn.style.display = (state.provider === 'local') ? '' : 'none'; + if (btn) btn.style.display = local ? '' : 'none'; + const um = document.getElementById('v3-songs-unmatched'); + if (um) um.style.display = local ? '' : 'none'; + } + + // Quick "Show unmatched" — the same filter as the drawer's Match → Unmatched, + // one click from the toolbar so the no-match pile is reachable right after a + // batch. Toggles the button + re-queries the grid. + function toggleUnmatchedFilter() { + const m = state.filters.match || (state.filters.match = []); + const i = m.indexOf('unmatched'); + const on = i < 0; + if (on) m.push('unmatched'); else m.splice(i, 1); + const btn = document.getElementById('v3-songs-unmatched'); + if (btn) { btn.classList.toggle('bg-fb-primary', on); btn.classList.toggle('text-white', on); } + reload(); } // The local filenames the grid is currently SHOWING (data-fn is the local diff --git a/tests/test_library_filters.py b/tests/test_library_filters.py index 25dcb184..0779db47 100644 --- a/tests/test_library_filters.py +++ b/tests/test_library_filters.py @@ -614,3 +614,16 @@ def test_artist_filter_is_case_insensitive(client, seeded): data = _get(client, artist="a band") assert data["total"] == 1 assert data["songs"][0]["filename"] == "a.archive" + + +def test_unmatched_flag_and_quick_filter(server_mod, client): + # A per-card "no match" badge needs the row to carry the enrichment state. + _put(server_mod, filename="a.archive", title="Matched", artist="A") + _put(server_mod, filename="b.archive", title="Missed", artist="B") + server_mod.meta_db.apply_enrichment_match("b.archive", "h", "failed") # no-match + rows = {s["filename"]: s for s in server_mod.meta_db.query_page()[0]} + assert rows["b.archive"]["unmatched"] is True + assert rows["a.archive"]["unmatched"] is False + # The "Unmatched" quick-filter (match=unmatched) returns only the failed song. + fns = [s["filename"] for s in client.get("/api/library?match=unmatched").json()["songs"]] + assert fns == ["b.archive"] From 9b3431db6bbb6308f5c55239c4e0ee82fa9269e1 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sun, 5 Jul 2026 12:54:18 +0200 Subject: [PATCH 2/2] fix(v3 library): repaint persistent no-match badge after metadata tile-clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _clearMetaTiles removed every .v3-meta-tile node — including the new persistent 'No match' resting badge, which derives from _unmatched rather than _metaTile. A metadata rescan's tile-clear therefore dropped the badge until the next scroll re-rendered the card. Repaint it from _unmatched. Co-Authored-By: Claude Opus 4.8 (1M context) --- static/v3/songs.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/static/v3/songs.js b/static/v3/songs.js index 06c5ec98..188053fa 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -3867,6 +3867,18 @@ function _clearMetaTiles() { Object.keys(_metaTile).forEach((fn) => { delete _metaTile[fn]; }); document.querySelectorAll('.v3-meta-tile').forEach((el) => el.remove()); + // The persistent "No match" badge derives from _unmatched (not _metaTile), + // yet shares the .v3-meta-tile class — so the blanket remove above strips it. + // Repaint the resting indicator on any rendered card so a metadata rescan's + // tile-clear doesn't silently drop it until the next scroll/re-render. + _unmatched.forEach((fn) => { + const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn; + document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => { + if (play.querySelector('.v3-meta-tile')) return; + const html = enrichBadge(fn); + if (html) play.insertAdjacentHTML('beforeend', html); + }); + }); }