feat(v3 library): persistent "no match" badge + Unmatched quick filter (#781)

* feat(v3 library): persistent "no match" badge + Unmatched quick filter

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) <noreply@anthropic.com>

* fix(v3 library): repaint persistent no-match badge after metadata tile-clear

_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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-05 21:00:30 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent 92dc321fdf
commit 3e036e3db6
3 changed files with 86 additions and 10 deletions
+22
View File
@@ -3038,6 +3038,22 @@ class MetadataDB:
out[fn] = st out[fn] = st
return out 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: def enrichment_song_row(self, filename: str) -> dict | None:
"""The identity fields the matcher/scorer keys on, for one song.""" """The identity fields the matcher/scorer keys on, for one song."""
row = self.conn.execute( row = self.conn.execute(
@@ -4042,6 +4058,11 @@ class MetadataDB:
fns = [s["filename"] for s in songs] fns = [s["filename"] for s in songs]
udm = self.user_meta_map(fns) udm = self.user_meta_map(fns)
tgm = self.tags_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 # 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 # 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 # position (raw artist) is untouched, so a card can show a canonical name
@@ -4051,6 +4072,7 @@ class MetadataDB:
for s in songs: for s in songs:
s["user_difficulty"] = udm.get(s["filename"]) s["user_difficulty"] = udm.get(s["filename"])
s["tags"] = tgm.get(s["filename"], []) s["tags"] = tgm.get(s["filename"], [])
s["unmatched"] = s["filename"] in um
if amap: if amap:
s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist")) s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist"))
# Grouped rows carry the ⚑ N (chart_count) + the work_key from the # Grouped rows carry the ⚑ N (chart_count) + the work_key from the
+51 -10
View File
@@ -498,21 +498,32 @@
// to before (keeps the windowed grid's height math untouched). Honest state // to before (keeps the windowed grid's height math untouched). Honest state
// transitions, NOT a fake per-song %: a match is binary (design §11). // transitions, NOT a fake per-song %: a match is binary (design §11).
const _metaTile = {}; // fn -> 'queued' | 'working' | 'done' | 'nochange' const _metaTile = {}; // fn -> 'queued' | 'working' | 'done' | 'nochange'
function enrichBadge(fn) { // Cards whose enrichment landed 'failed' (from the grid payload) — tracked so
const st = _metaTile[fn]; // 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 ''; if (!st) return '';
const M = { const M = {
queued: ['bg-black/60 text-fb-textDim', '• Queued'], queued: ['bg-black/60 text-fb-textDim', '• Queued', ''],
working: ['bg-fb-primary text-white', '⟳ Matching…'], working: ['bg-fb-primary text-white', '⟳ Matching…', ''],
done: ['bg-fb-good/90 text-black', '✓ Updated'], done: ['bg-fb-good/90 text-black', '✓ Updated', ''],
nochange: ['bg-black/60 text-fb-textDim', '— No match'], 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; const conf = M[st] || M.queued;
// top-10 clears the tuning chip (top-2) in both normal and select mode; // 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. // z-20 sits it above the art. Non-interactive so it never eats a click.
return '<span class="v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] + return '<span class="v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight pointer-events-none">' + ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight pointer-events-none"' +
conf[1] + '</span>'; (conf[2] ? ' title="' + conf[2] + '"' : '') + '>' + conf[1] + '</span>';
} }
// After a song is scored, the badge for that card is stale until the next // After a song is scored, the badge for that card is stale until the next
@@ -870,7 +881,7 @@
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' + return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' + '<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
'<img src="' + esc(artUrl(shown)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' + '<img src="' + esc(artUrl(shown)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key) + overlay + tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key, song.unmatched) + overlay +
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' + '<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
inlineBtns + inlineBtns +
'<button data-fav data-fav-idle="text-white" title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' + '<button data-fav data-fav-idle="text-white" title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
@@ -3489,6 +3500,7 @@
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' + '<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
'<button id="v3-songs-refresh" title="Refresh library (scan for new songs)" class="' + ctrl + '">⟳ Refresh</button>' + '<button id="v3-songs-refresh" title="Refresh library (scan for new songs)" class="' + ctrl + '">⟳ Refresh</button>' +
'<button id="v3-songs-refresh-meta" title="Refresh metadata for the songs shown (re-match titles, artwork &amp; more)" class="' + ctrl + '">🏷 Metadata</button>' + '<button id="v3-songs-refresh-meta" title="Refresh metadata for the songs shown (re-match titles, artwork &amp; more)" class="' + ctrl + '">🏷 Metadata</button>' +
'<button id="v3-songs-unmatched" title="Show only songs with no metadata match" class="' + ctrl + ((state.filters.match || []).includes('unmatched') ? ' bg-fb-primary text-white' : '') + '">Unmatched</button>' +
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' + '<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
'</div></div></div>' + '</div></div></div>' +
// Practice-aware library home: a repertoire progress meter + a // Practice-aware library home: a repertoire progress meter + a
@@ -3559,6 +3571,7 @@
// Refresh Metadata: local-only, so hide it for remote providers. The // Refresh Metadata: local-only, so hide it for remote providers. The
// button doubles as its own Stop while a pass runs (see onMetaBtnClick). // button doubles as its own Stop while a pass runs (see onMetaBtnClick).
byId('v3-songs-refresh-meta')?.addEventListener('click', onMetaBtnClick); byId('v3-songs-refresh-meta')?.addEventListener('click', onMetaBtnClick);
byId('v3-songs-unmatched')?.addEventListener('click', toggleUnmatchedFilter);
_updateMetaBtnVisibility(); _updateMetaBtnVisibility();
// Reflect a scan already in progress (Settings button or a background // 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. // pass) on the Refresh button, so its state isn't just tied to clicks here.
@@ -3808,8 +3821,24 @@
let _metaRunning = false; let _metaRunning = false;
function _updateMetaBtnVisibility() { function _updateMetaBtnVisibility() {
const local = state.provider === 'local'; // enrichment + its filter are local-only
const btn = document.getElementById('v3-songs-refresh-meta'); 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 // The local filenames the grid is currently SHOWING (data-fn is the local
@@ -3838,6 +3867,18 @@
function _clearMetaTiles() { function _clearMetaTiles() {
Object.keys(_metaTile).forEach((fn) => { delete _metaTile[fn]; }); Object.keys(_metaTile).forEach((fn) => { delete _metaTile[fn]; });
document.querySelectorAll('.v3-meta-tile').forEach((el) => el.remove()); 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);
});
});
} }
+13
View File
@@ -614,3 +614,16 @@ def test_artist_filter_is_case_insensitive(client, seeded):
data = _get(client, artist="a band") data = _get(client, artist="a band")
assert data["total"] == 1 assert data["total"] == 1
assert data["songs"][0]["filename"] == "a.archive" 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"]