v3 library: first-hour polish — zero-states, match progress, provenance, alias search (#730)

* v3 library: first-hour polish — zero-states, match progress, provenance, alias search

Six launch-eve fixes for a brand-new user's first hour with a fresh,
being-matched library. Each is small and reuses shipped idioms.

- Invitational repertoire meter: with no practice data yet, the home meter
  no longer reads "0 of N mastered" (debt framing) — it shows an empty bar
  with "grows as you master songs". A count of 0 read as failure on day one.
- "Start here" starter shelf: growth_edge_suggestions() distinguishes two
  empties — attempts exist but all mastered (honest empty shelf) vs nothing
  attempted yet (day one) → new starter_suggestions() returns up to 8
  approachable songs (90–480s, shortest first) flagged starter:true, and the
  client renders a "Start here" shelf instead of a blank home.
- Library-visible match progress: while the background pass runs, a quiet
  "Matching your library — X of Y" line sits by the review chip (5s poll,
  single guarded interval, cleared the moment the pass stops — no leak,
  no toast, silent completion).
- One-time transparency toast: the first time an install is seen matching a
  real library, one fbNotify names what's contacted (MusicBrainz / Cover Art
  Archive), that results are stored locally, that files aren't changed
  without you, and where the switch is. localStorage-gated, wrapped so a
  blocked notifier can't break the chip.
- Empty-library dead-end card: a genuinely empty local library (no songs, no
  query/filter) shows "Your library is empty" + drop-files hint + Open
  Settings, instead of a bare grid under dead dropdowns.
- Alias-aware search: searching a canonical name ("AC/DC") now also finds
  songs whose raw tag is a merged variant ("ACDC"), via the artist_alias
  table. Probe-guarded so a no-aliases library keeps the exact original
  3-term query; pure predicate, keyset-safe.
- Details-drawer provenance line: matched/manual rows show "Matched:
  <artist — title> (source) · Fix match" under the Identity fields — the
  wrong-match escape hatch at the point of the data, wired to the same
  fix-match flow the card menu uses. New read-only GET
  /api/enrichment/song/{filename} backs it.

Tests: tests/test_starter_suggestions.py (starter vs normal-shelf behaviour,
length window, attempts-exist path unchanged) + alias-search cases added to
tests/test_artist_alias.py. 34 targeted pass; node --check clean; no new
Tailwind classes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3 library): stop match-progress poll when leaving the library screen

The 5s enrichment poll (_pollTimer) was cleared on pass completion and on
fetch error, but not when the user navigated away from the library. Leaving
v3-songs mid-pass left the interval pinging /api/enrichment/status in the
background until the pass ended. Subscribe to the existing feedBack
'screen:changed' event: clear the poll when any non-v3-songs screen shows,
and refresh (re-arming if a pass is still running) on returning to v3-songs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-03 08:49:04 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent df2d660d1e
commit 8c7cde5d5c
5 changed files with 395 additions and 19 deletions
+57 -3
View File
@@ -2079,7 +2079,13 @@ class MetadataDB:
cands = [(fn, a) for fn, a in agg.items() cands = [(fn, a) for fn, a in agg.items()
if a["plays"] > 0 and a["acc"] is not None and a["acc"] < MASTERY_ACCURACY] if a["plays"] > 0 and a["acc"] is not None and a["acc"] < MASTERY_ACCURACY]
if not cands: if not cands:
return [] # Two different empties (launch polish): attempts exist but
# everything attempted is mastered → an empty shelf is honest;
# NOTHING attempted yet (day one) → "starter" picks instead, so
# the library home invites a first play rather than dead-ending.
if any(a["plays"] > 0 and a["acc"] is not None for a in agg.values()):
return []
return self.starter_suggestions(limit)
diffs = self.user_meta_map([fn for fn, _ in cands]) # {filename: 1..5} diffs = self.user_meta_map([fn for fn, _ in cands]) # {filename: 1..5}
out = [] out = []
for fn, a in cands: for fn, a in cands:
@@ -2095,6 +2101,28 @@ class MetadataDB:
out.sort(key=lambda r: (r["growth_score"], r["last_played_at"] or "", r["filename"]), reverse=True) out.sort(key=lambda r: (r["growth_score"], r["last_played_at"] or "", r["filename"]), reverse=True)
return out[:limit] return out[:limit]
def starter_suggestions(self, limit: int = 8) -> list[dict]:
"""Day-one 'Start here' picks for a library with no practice attempts
yet: up to 8 approachable songs sensible length (90s480s, so intros/
jingles and 10-minute epics don't lead), shortest first, filename as a
stable tiebreak. Same row shape as the growth-edge rows plus a
`starter: true` marker so the client renders the invitational 'Start
here' shelf instead of 'Keep practicing'. Read-only."""
limit = max(1, min(8, int(limit)))
rows = self.conn.execute(
"SELECT filename FROM songs WHERE title != '' "
"AND duration >= 90 AND duration <= 480 "
"ORDER BY duration ASC, filename ASC LIMIT ?", (limit,)).fetchall()
return [{
"filename": r[0],
"best_accuracy": None,
"arrangement": None,
"last_played_at": None,
"user_difficulty": None,
"growth_score": 0.0,
"starter": True,
} for r in rows]
# ── Playlists ─────────────────────────────────────────────────────────-- # ── Playlists ─────────────────────────────────────────────────────────--
SAVED_KEY = "saved_for_later" SAVED_KEY = "saved_for_later"
@@ -3129,8 +3157,21 @@ class MetadataDB:
if _msel: if _msel:
where += " AND (" + " OR ".join(_msel) + ")" where += " AND (" + " OR ".join(_msel) + ")"
if q: if q:
where += " AND (title LIKE ? COLLATE NOCASE OR artist LIKE ? COLLATE NOCASE OR album LIKE ? COLLATE NOCASE)" _qlike = f"%{q}%"
params += [f"%{q}%"] * 3 _qterms = ("title LIKE ? COLLATE NOCASE OR artist LIKE ? COLLATE NOCASE "
"OR album LIKE ? COLLATE NOCASE")
_qparams = [_qlike] * 3
# Alias-aware artist term (launch polish): searching the CANONICAL
# name ("AC/DC") must also find songs whose raw tag is a merged
# variant ("ACDC") — expand via the artist_alias table. Pure
# predicate (keyset-safe); probe-guarded so the common no-aliases
# library keeps the exact original 3-term query.
if self.conn.execute("SELECT 1 FROM artist_alias LIMIT 1").fetchone() is not None:
_qterms += (" OR artist COLLATE NOCASE IN (SELECT raw_name FROM artist_alias "
"WHERE canonical_name LIKE ? COLLATE NOCASE)")
_qparams.append(_qlike)
where += f" AND ({_qterms})"
params += _qparams
if include_intrinsic: if include_intrinsic:
ifrag, iparams = self._build_intrinsic_where( ifrag, iparams = self._build_intrinsic_where(
"songs", format_filter=format_filter, "songs", format_filter=format_filter,
@@ -6723,6 +6764,19 @@ def enrichment_status():
} }
@app.get("/api/enrichment/song/{filename:path}")
def api_enrichment_song(filename: str):
"""Read-only per-song match provenance for the Details drawer (launch
polish): which canonical identity this chart matched and how. A tiny
projection of the cache row no candidates, no cache paths."""
row = meta_db.get_enrichment(filename)
if not row:
raise HTTPException(status_code=404, detail="no enrichment row")
return {k: row.get(k) for k in
("match_state", "canon_artist", "canon_title",
"match_source", "match_score")}
@app.post("/api/enrichment/kick") @app.post("/api/enrichment/kick")
def api_enrichment_kick(): def api_enrichment_kick():
"""The Settings "Match now" button: request an enrichment pass without """The Settings "Match now" button: request an enrichment pass without
+87 -3
View File
@@ -35,9 +35,52 @@
// ── Ambient chip + the Settings card's status line ─────────────────────── // ── Ambient chip + the Settings card's status line ───────────────────────
// songs.js renders `#v3-songs-match-review` (hidden) in its toolbar and // songs.js renders `#v3-songs-match-review` (hidden) in its toolbar and
// calls window.__fbMatchReviewChip() after each toolbar build; review // calls window.__fbMatchReviewChip() after each toolbar build; review
// actions here re-call it. The same fetch feeds the Settings status line. // actions here re-call it. The same fetch feeds the Settings status line
// and, while a pass is running, a quiet toolbar progress line (below).
// Silent on failure — surfaces just stay as they are. // Silent on failure — surfaces just stay as they are.
let _chipBusy = false; let _chipBusy = false;
let _pollTimer = null; // 5s status poll, alive ONLY while a pass runs
// Quiet library-visible progress (launch polish): a plain text line next
// to the review chip while the background pass is working through the
// queue — "Matching your library — X of Y". No toast, no sound; it simply
// disappears when the pass finishes (hearing-safe, design §11).
function _setProgressLine(running, states, total) {
let el = document.getElementById('v3-songs-match-progress');
const unscanned = states.unscanned || 0;
if (!running || unscanned <= 0 || total <= 0) {
if (el) el.remove();
return;
}
if (!el) {
const chip = document.getElementById('v3-songs-match-review');
if (!chip || !chip.parentElement) return; // songs toolbar not on screen
el = document.createElement('span');
el.id = 'v3-songs-match-progress';
el.className = 'text-xs text-fb-textDim';
chip.insertAdjacentElement('afterend', el);
}
el.textContent = 'Matching your library — ' + Math.max(0, total - unscanned) + ' of ' + total;
}
// One-time transparency toast (launch polish): the first time this
// install is observed actually matching a real library, say plainly what
// is contacted, where results live, and where the switch is. Wrapped like
// app.js's fbNotify calls so a blocked localStorage / absent notifier can
// never break the chip.
function _announceOnce(running, total) {
try {
if (!running || total <= 0) return;
if (localStorage.getItem('fb_enrich_announce_v1')) return;
localStorage.setItem('fb_enrich_announce_v1', '1');
window.fbNotify?.show({
title: 'Library matching is on',
message: 'Song info and covers come from MusicBrainz and Cover Art Archive, stored locally. Your files are never changed unless you choose to write to them. Adjust in Settings → Library.',
icon: '📚',
});
} catch (_) { /* storage/notifier unavailable — skip quietly */ }
}
async function refreshChip() { async function refreshChip() {
if (_chipBusy) return; if (_chipBusy) return;
_chipBusy = true; _chipBusy = true;
@@ -62,7 +105,24 @@
if (st.unscanned) parts.push(st.unscanned + ' queued'); if (st.unscanned) parts.push(st.unscanned + ' queued');
line.textContent = (body.running ? 'Matching… · ' : '') + parts.join(' · '); line.textContent = (body.running ? 'Matching… · ' : '') + parts.join(' · ');
} }
} catch (_) { /* offline — leave as-is */ } finally { const running = !!body.running;
const total = body.total_songs || 0;
_setProgressLine(running, st, total);
_announceOnce(running, total);
// Poll only while a pass is actually running; a single guarded
// interval, cleared the moment the pass stops (no leaks).
if (running && !_pollTimer) {
_pollTimer = setInterval(refreshChip, 5000);
} else if (!running && _pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
} catch (_) {
// Offline — leave surfaces as they are, but stop any poll so a
// dead server isn't pinged every 5s forever (the next toolbar
// build / settings open restarts it if a pass is still running).
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
} finally {
_chipBusy = false; _chipBusy = false;
} }
} }
@@ -455,10 +515,34 @@
}); });
} }
// Stop the 5s poll when the library screen is left — the progress line and
// chip only live in the songs toolbar, so polling off-screen is pure waste
// (benign but tidy). Re-entering v3-songs re-arms it: songs.js re-calls
// window.__fbMatchReviewChip() on screen enter, and we also refresh here so
// this stays self-contained. Same single-guarded-interval invariant as
// refreshChip — no double-interval, cleared to null.
function wireScreenTeardown() {
const sm = window.feedBack;
if (!sm || typeof sm.on !== 'function') return;
sm.on('screen:changed', (e) => {
const id = e && e.detail && e.detail.id;
if (id === 'v3-songs') {
refreshChip(); // returning while a pass runs re-arms the poll
} else if (_pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
});
}
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wireSettingsCard, { once: true }); document.addEventListener('DOMContentLoaded', () => {
wireSettingsCard();
wireScreenTeardown();
}, { once: true });
} else { } else {
wireSettingsCard(); wireSettingsCard();
wireScreenTeardown();
} }
window.__fbMatchReviewChip = refreshChip; window.__fbMatchReviewChip = refreshChip;
+88 -13
View File
@@ -591,16 +591,34 @@
const shelf = Array.isArray(suggestions) ? suggestions : []; const shelf = Array.isArray(suggestions) ? suggestions : [];
const { mastered, learning } = _repertoireCounts(); const { mastered, learning } = _repertoireCounts();
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100))); // Day-one zero-state (launch polish): no practice data and no real
const meter = // growth-edge rows → an invitational meter, never "0 of N". Starter
'<div class="v3-rep-meter">' + // rows are the server's no-attempts fallback, so they count as "no
'<div class="flex items-baseline justify-between gap-3 mb-1">' + // practice yet" too.
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' + const starterShelf = shelf.length > 0 && !!shelf[0].starter;
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') + const invitational = (mastered + learning) === 0 && (!shelf.length || starterShelf);
(learning ? ' &middot; ' + learning + ' in progress' : '') + '</span>' + let meter;
'</div>' + if (invitational) {
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' + meter =
'</div>'; '<div class="v3-rep-meter">' +
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
'<span class="text-xs text-fb-textDim">grows as you master songs</span>' +
'</div>' +
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:0%"></div></div>' +
'</div>';
} else {
const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
meter =
'<div class="v3-rep-meter">' +
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
'<span class="text-sm font-semibold text-fb-text">Repertoire</span>' +
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') +
(learning ? ' &middot; ' + learning + ' in progress' : '') + '</span>' +
'</div>' +
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
'</div>';
}
let shelfHtml = ''; let shelfHtml = '';
if (shelf.length) { if (shelf.length) {
@@ -613,9 +631,14 @@
'<div class="mt-1 text-sm text-fb-text truncate">' + esc(r.title) + '</div>' + '<div class="mt-1 text-sm text-fb-text truncate">' + esc(r.title) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(r.artist) + '</div>' + '<div class="text-xs text-fb-textDim truncate">' + esc(r.artist) + '</div>' +
'</button>').join(''); '</button>').join('');
// Starter rows → the invitational "Start here" framing; real
// growth-edge rows → the usual "Keep practicing". Same cards.
const header = starterShelf
? '<h3 class="text-sm font-semibold text-fb-text">Start here</h3>' +
'<div class="text-xs text-fb-textDim mb-2">a few approachable songs to kick things off</div>'
: '<h3 class="text-sm font-semibold text-fb-text mb-2">Keep practicing</h3>';
shelfHtml = shelfHtml =
'<section class="v3-kp-shelf mt-4">' + '<section class="v3-kp-shelf mt-4">' + header +
'<h3 class="text-sm font-semibold text-fb-text mb-2">Keep practicing</h3>' +
'<div class="v3-kp-row">' + cards + '</div>' + '<div class="v3-kp-row">' + cards + '</div>' +
'</section>'; '</section>';
} }
@@ -1816,6 +1839,20 @@
} }
} }
// Empty-library dead-end card (launch polish): only for a genuinely empty
// LOCAL library — a search / filter / format narrowing that merely matched
// nothing keeps the plain blank grid (saying "empty" there would lie), and
// remote providers own their own emptiness. The inline grid-column style
// spans the card across the grid without a new Tailwind class.
function _emptyLibraryHtml() {
if (state.q || state.format || activeFilterCount() !== 0 || state.provider !== 'local') return '';
return '<div class="flex flex-col items-center justify-center text-center py-8 gap-2" style="grid-column:1/-1">' +
'<div class="text-lg font-semibold text-fb-text">Your library is empty</div>' +
'<div class="text-sm text-fb-textDim max-w-md">Drop .sloppak files into your library folder, or use Upload above.</div>' +
'<button data-lib-empty-settings class="mt-3 bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-xl text-sm font-semibold">Open Settings</button>' +
'</div>';
}
let _winRAF = 0; let _winRAF = 0;
function requestWindowRender() { function requestWindowRender() {
if (_winRAF) return; if (_winRAF) return;
@@ -1837,8 +1874,16 @@
const rows = Math.ceil(total / Math.max(1, cols)); const rows = Math.ceil(total / Math.max(1, cols));
sizer.style.height = (rows * rowH) + 'px'; sizer.style.height = (rows * rowH) + 'px';
if (total === 0) { if (total === 0) {
grid.innerHTML = ''; grid.style.top = '0px'; grid.innerHTML = _emptyLibraryHtml(); grid.style.top = '0px';
state.winRange = { start: 0, end: 0 }; state.winRange = { start: 0, end: 0 };
if (grid.innerHTML) {
// The grid is absolutely positioned inside the sizer — give the
// sizer the card's height so it participates in layout.
sizer.style.height = grid.offsetHeight + 'px';
grid.querySelector('[data-lib-empty-settings]')?.addEventListener('click', () => {
if (window.showScreen) window.showScreen('settings');
});
}
return; return;
} }
const sizerTop = _sizerTopInScroller(main, sizer); const sizerTop = _sizerTopInScroller(main, sizer);
@@ -2454,6 +2499,11 @@
try { const r = await fetch('/api/song/' + enc(fn) + '/user-meta'); if (r.ok) meta = await r.json(); } catch (_) { /* offline → row data */ } try { const r = await fetch('/api/song/' + enc(fn) + '/user-meta'); if (r.ok) meta = await r.json(); } catch (_) { /* offline → row data */ }
let vocab = []; let vocab = [];
try { const r = await fetch('/api/tags'); if (r.ok) vocab = (await r.json()).tags || []; } catch (_) { /* */ } try { const r = await fetch('/api/tags'); if (r.ok) vocab = (await r.json()).tags || []; } catch (_) { /* */ }
// Match provenance (launch polish): the drawer names what this chart
// matched, so a silently-wrong first match is visible where the
// metadata lives. 404 (no row yet) / offline → no line.
let enrich = null;
try { const r = await fetch('/api/enrichment/song/' + enc(fn)); if (r.ok) enrich = await r.json(); } catch (_) { /* offline → no provenance line */ }
if (_detailsEls) closeDetails(); // a concurrent open resolved first if (_detailsEls) closeDetails(); // a concurrent open resolved first
const st = { const st = {
@@ -2462,6 +2512,7 @@
notes: meta.notes || '', tags: (meta.tags || []).slice(), notes: meta.notes || '', tags: (meta.tags || []).slice(),
fav: !!song.favorite, artDataUrl: null, fav: !!song.favorite, artDataUrl: null,
gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys
enrich: enrich, // match provenance for the Identity section
}; };
const overlay = document.createElement('div'); const overlay = document.createElement('div');
@@ -2523,6 +2574,22 @@
'<button data-gapfill-cancel class="px-3 py-1.5 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-lg text-xs text-fb-text">Cancel</button></div></div>'; '<button data-gapfill-cancel class="px-3 py-1.5 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-lg text-xs text-fb-text">Cancel</button></div></div>';
} }
// Match-provenance line under the Identity fields (launch polish): names
// the canonical identity this chart matched — the invisible-first-wrong-
// match fix — with the same Fix-match escape hatch the card menu offers.
// Only for settled matches; pending/review/failed rows stay silent here
// (the review chip / match facet own those states).
function provenanceHtml(st) {
const e = st.enrich;
if (!e || (e.match_state !== 'matched' && e.match_state !== 'manual')) return '';
const who = [e.canon_artist, e.canon_title].filter(Boolean).join(' — ');
if (!who) return '';
const src = e.match_state === 'manual' ? 'your pick' : 'MusicBrainz';
return '<div class="flex items-baseline gap-2 text-xs text-fb-textDim">' +
'<span class="truncate">Matched: ' + esc(who) + ' (' + esc(src) + ')</span>' +
'<button data-det-fixmatch class="shrink-0 text-fb-primary hover:text-fb-primaryHi">Fix match</button></div>';
}
function detailsHtml(song, st, vocab) { function detailsHtml(song, st, vocab) {
const art = st.artDataUrl || artUrl(song); const art = st.artDataUrl || artUrl(song);
const diffBtns = [1, 2, 3, 4, 5].map((n) => const diffBtns = [1, 2, 3, 4, 5].map((n) =>
@@ -2556,6 +2623,7 @@
'<span class="text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700" title="These came from the song&#39;s feedpak. Editing them writes back to the file.">From pack</span></div>' + '<span class="text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700" title="These came from the song&#39;s feedpak. Editing them writes back to the file.">From pack</span></div>' +
field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) + field('det-album', 'Album', st.al) + field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) + field('det-album', 'Album', st.al) +
'<div><label for="det-year" class="text-xs text-fb-textDim mb-1 block">Year</label><input type="text" inputmode="numeric" id="det-year" value="' + esc(st.y) + '" placeholder="e.g. 2024" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60"></div>' + '<div><label for="det-year" class="text-xs text-fb-textDim mb-1 block">Year</label><input type="text" inputmode="numeric" id="det-year" value="' + esc(st.y) + '" placeholder="e.g. 2024" class="w-full bg-fb-card border border-fb-border/60 rounded-lg px-3 py-2 text-sm text-fb-text outline-none focus:border-fb-primary/60"></div>' +
provenanceHtml(st) +
'<div data-det-gapfill>' + gapFillHtml(st) + '</div></div>' + '<div data-det-gapfill>' + gapFillHtml(st) + '</div></div>' +
// Personal practice layer — local, never shared // Personal practice layer — local, never shared
@@ -2623,6 +2691,13 @@
$('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st)); $('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st));
$('[data-det-remove]')?.addEventListener('click', () => removeFromLibrary(song)); $('[data-det-remove]')?.addEventListener('click', () => removeFromLibrary(song));
// Fix match → the exact flow the card ⋮ menu uses (match-review.js).
// The drawer closes first: the match modal sits below the drawer's
// z-index, and the fix supersedes the edit anyway.
$('[data-det-fixmatch]')?.addEventListener('click', () => {
closeDetails();
if (window.__fbFixMatch) window.__fbFixMatch(song);
});
// Gap-fill (R4a): user-initiated write of CONFIRMED missing info into // Gap-fill (R4a): user-initiated write of CONFIRMED missing info into
// the pack file. The server recomputes proposals under its io lock, so // the pack file. The server recomputes proposals under its io lock, so
+44
View File
@@ -209,3 +209,47 @@ def test_list_aliases_sorted(client, server):
_alias(client, "guns n roses", "Guns N' Roses") _alias(client, "guns n roses", "Guns N' Roses")
aliases = client.get("/api/artist-aliases").json()["aliases"] aliases = client.get("/api/artist-aliases").json()["aliases"]
assert {a["raw_name"] for a in aliases} == {"ACDC", "guns n roses"} assert {a["raw_name"] for a in aliases} == {"ACDC", "guns n roses"}
# ── Search (q) matches merged aliases (launch polish) ─────────────────────────
def _search(client, q):
return {s["filename"] for s in
client.get("/api/library", params={"q": q}).json()["songs"]}
def test_search_canonical_finds_raw_variants(client, server):
"""Searching the canonical name must also find songs whose raw tag is a
merged variant after ACDCAC/DC, q="AC/DC" returns both."""
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "AC/DC")
_seed(server, "c.archive", "Other")
_alias(client, "ACDC", "AC/DC")
assert _search(client, "AC/DC") == {"a.archive", "b.archive"}
def test_search_partial_canonical_finds_raw_variants(client, server):
"""The alias term is a LIKE, matching the substring semantics of the
plain artist term."""
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "Other")
_alias(client, "ACDC", "AC/DC")
assert _search(client, "c/d") == {"a.archive"}
def test_search_without_aliases_unchanged(client, server):
"""No aliases → the fast path keeps the original 3-term search."""
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "AC/DC")
assert _search(client, "ACDC") == {"a.archive"}
def test_search_title_album_unaffected_by_alias_term(client, server):
"""With aliases present (extra placeholder appended), title/album search
still works guards the parameter order."""
_seed(server, "a.archive", "ACDC") # title "a"
_alias(client, "ACDC", "AC/DC")
server.meta_db.put("t.archive", 0, 0,
{"title": "Thunder Road", "artist": "Boss", "album": "Born"})
assert _search(client, "Thunder") == {"t.archive"}
assert _search(client, "Born") == {"t.archive"}
+119
View File
@@ -0,0 +1,119 @@
"""Tests for the 'Start here' starter shelf (launch polish) —
GET /api/library/practice-suggestions when NO practice attempts exist.
growth_edge_suggestions returns starter picks (sensible-length songs,
shortest first, flagged starter:true) only on a never-practiced library;
the moment any scored attempt exists the normal growth-edge behaviour is
unchanged including the honest empty shelf when everything attempted is
mastered. Read-only, like the recommender it falls back from."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _seed(server, fn, duration, title=None):
server.meta_db.put(fn, 0, 0, {
"title": title or fn.split(".")[0], "artist": "A", "duration": duration})
def _play(server, fn, acc, arr=0):
"""Record a scored attempt so the song has a best_accuracy."""
server.meta_db.record_session(fn, arr, score=int(acc * 1000), accuracy=acc)
def _suggest(client, limit=8):
return client.get(f"/api/library/practice-suggestions?limit={limit}").json()
# ── No attempts → starter picks, shortest sensible first ─────────────────────
def test_no_attempts_returns_starter_rows(client, server):
_seed(server, "long.archive", 600) # > 480s → not a starter
_seed(server, "jingle.archive", 30) # < 90s → not a starter
_seed(server, "mid.archive", 200)
_seed(server, "short.archive", 120)
rows = _suggest(client)
assert [r["filename"] for r in rows] == ["short.archive", "mid.archive"]
assert all(r["starter"] is True for r in rows)
def test_starter_duration_bounds_inclusive(client, server):
_seed(server, "at90.archive", 90)
_seed(server, "at480.archive", 480)
_seed(server, "under.archive", 89)
_seed(server, "over.archive", 481)
_seed(server, "nodur.archive", 0) # unknown length → never a starter
got = {r["filename"] for r in _suggest(client)}
assert got == {"at90.archive", "at480.archive"}
def test_starter_caps_at_eight(client, server):
for i in range(10):
_seed(server, f"s{i:02d}.archive", 100 + i)
assert len(_suggest(client)) == 8
# Even an explicit larger limit never exceeds the starter cap of 8.
assert len(_suggest(client, limit=20)) == 8
def test_starter_rows_are_enriched_and_growth_shaped(client, server):
"""Same row shape as the growth-edge rows (the client reuses the card
markup verbatim) plus the starter marker; enriched by the route."""
_seed(server, "song.archive", 150, title="My Song")
r = _suggest(client)[0]
assert r["starter"] is True
assert r["title"] == "My Song" and r["artist"] == "A"
assert r["art_url"].endswith("/art")
for key in ("filename", "best_accuracy", "arrangement", "last_played_at",
"user_difficulty", "growth_score"):
assert key in r
# No attempt yet → no accuracy/arrangement; the client passes an
# undefined arrangement so playSong picks the default.
assert r["best_accuracy"] is None
assert r["arrangement"] is None
# ── Attempts exist → normal growth-edge behaviour, unchanged ─────────────────
def test_attempts_exist_normal_behaviour_unchanged(client, server):
_seed(server, "inprog.archive", 150)
_seed(server, "fresh.archive", 150)
_play(server, "inprog.archive", 0.6)
rows = _suggest(client)
assert [r["filename"] for r in rows] == ["inprog.archive"]
assert not any(r.get("starter") for r in rows)
def test_all_mastered_returns_empty_not_starter(client, server):
"""Attempts exist and everything attempted is mastered → the shelf is
honestly empty; the starter fallback must NOT kick in."""
_seed(server, "done.archive", 150)
_seed(server, "fresh.archive", 150)
_play(server, "done.archive", 0.95)
assert _suggest(client) == []
def test_empty_library_returns_empty(client, server):
assert _suggest(client) == []