mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
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:
co-authored by
Claude Opus 4.8
byrongamatos
parent
92dc321fdf
commit
3e036e3db6
@@ -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
@@ -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 & more)" class="' + ctrl + '">🏷 Metadata</button>' +
|
'<button id="v3-songs-refresh-meta" title="Refresh metadata for the songs shown (re-match titles, artwork & 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user