mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-13 12:20:09 +00:00
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:
co-authored by
Claude Opus 4.8
byrongamatos
parent
df2d660d1e
commit
8c7cde5d5c
@@ -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 (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 ─────────────────────────────────────────────────────────--
|
# ── 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
|
||||||
|
|||||||
@@ -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
@@ -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 ? ' · ' + 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 ? ' · ' + 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'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'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
|
||||||
|
|||||||
@@ -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 ACDC→AC/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"}
|
||||||
|
|||||||
@@ -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) == []
|
||||||
Reference in New Issue
Block a user