diff --git a/server.py b/server.py
index 818fdc7..8fc4005 100644
--- a/server.py
+++ b/server.py
@@ -2079,7 +2079,13 @@ class MetadataDB:
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 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}
out = []
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)
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 (90s–480s, 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 ─────────────────────────────────────────────────────────--
SAVED_KEY = "saved_for_later"
@@ -3129,8 +3157,21 @@ class MetadataDB:
if _msel:
where += " AND (" + " OR ".join(_msel) + ")"
if q:
- where += " AND (title LIKE ? COLLATE NOCASE OR artist LIKE ? COLLATE NOCASE OR album LIKE ? COLLATE NOCASE)"
- params += [f"%{q}%"] * 3
+ _qlike = f"%{q}%"
+ _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:
ifrag, iparams = self._build_intrinsic_where(
"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")
def api_enrichment_kick():
"""The Settings "Match now" button: request an enrichment pass without
diff --git a/static/v3/match-review.js b/static/v3/match-review.js
index 28b9e56..4a9ed07 100644
--- a/static/v3/match-review.js
+++ b/static/v3/match-review.js
@@ -35,9 +35,52 @@
// ── Ambient chip + the Settings card's status line ───────────────────────
// songs.js renders `#v3-songs-match-review` (hidden) in its toolbar and
// 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.
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() {
if (_chipBusy) return;
_chipBusy = true;
@@ -62,7 +105,24 @@
if (st.unscanned) parts.push(st.unscanned + ' queued');
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;
}
}
@@ -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') {
- document.addEventListener('DOMContentLoaded', wireSettingsCard, { once: true });
+ document.addEventListener('DOMContentLoaded', () => {
+ wireSettingsCard();
+ wireScreenTeardown();
+ }, { once: true });
} else {
wireSettingsCard();
+ wireScreenTeardown();
}
window.__fbMatchReviewChip = refreshChip;
diff --git a/static/v3/songs.js b/static/v3/songs.js
index 941f8b4..c8e5b6b 100644
--- a/static/v3/songs.js
+++ b/static/v3/songs.js
@@ -591,16 +591,34 @@
const shelf = Array.isArray(suggestions) ? suggestions : [];
const { mastered, learning } = _repertoireCounts();
- const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100)));
- const meter =
- '
';
+ // Day-one zero-state (launch polish): no practice data and no real
+ // growth-edge rows → an invitational meter, never "0 of N". Starter
+ // rows are the server's no-attempts fallback, so they count as "no
+ // practice yet" too.
+ const starterShelf = shelf.length > 0 && !!shelf[0].starter;
+ const invitational = (mastered + learning) === 0 && (!shelf.length || starterShelf);
+ let meter;
+ if (invitational) {
+ meter =
+ '
' +
+ '
' +
+ 'Repertoire' +
+ 'grows as you master songs' +
+ '
';
+ }
let shelfHtml = '';
if (shelf.length) {
@@ -613,9 +631,14 @@
'
' + esc(r.title) + '
' +
'
' + esc(r.artist) + '
' +
'').join('');
+ // Starter rows → the invitational "Start here" framing; real
+ // growth-edge rows → the usual "Keep practicing". Same cards.
+ const header = starterShelf
+ ? '
Start here
' +
+ '
a few approachable songs to kick things off
'
+ : '
Keep practicing
';
shelfHtml =
- '' +
- '
Keep practicing
' +
+ '' + header +
'
' + cards + '
' +
'';
}
@@ -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 '
' +
+ '
Your library is empty
' +
+ '
Drop .sloppak files into your library folder, or use Upload above.
' +
+ '' +
+ '
';
+ }
+
let _winRAF = 0;
function requestWindowRender() {
if (_winRAF) return;
@@ -1837,8 +1874,16 @@
const rows = Math.ceil(total / Math.max(1, cols));
sizer.style.height = (rows * rowH) + 'px';
if (total === 0) {
- grid.innerHTML = ''; grid.style.top = '0px';
+ grid.innerHTML = _emptyLibraryHtml(); grid.style.top = '0px';
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;
}
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 */ }
let vocab = [];
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
const st = {
@@ -2462,6 +2512,7 @@
notes: meta.notes || '', tags: (meta.tags || []).slice(),
fav: !!song.favorite, artDataUrl: null,
gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys
+ enrich: enrich, // match provenance for the Identity section
};
const overlay = document.createElement('div');
@@ -2523,6 +2574,22 @@
'';
}
+ // 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 '