Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
61 changes: 51 additions & 10 deletions static/v3/songs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<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">' +
conf[1] + '</span>';
' 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] + '</span>';
}

// After a song is scored, the badge for that card is stale until the next
Expand Down Expand Up @@ -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) + '">' +
'<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\'">' +
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">' +
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>' +
Expand Down Expand Up @@ -3489,6 +3500,7 @@
'<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-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>' +
'</div></div></div>' +
// Practice-aware library home: a repertoire progress meter + a
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3838,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);
});
});
}


Expand Down
13 changes: 13 additions & 0 deletions tests/test_library_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading