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 = - '
' + - '
' + - 'Repertoire' + - '' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') + - (learning ? ' · ' + learning + ' in progress' : '') + '' + - '
' + - '
' + - '
'; + // 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' + + '
' + + '
' + + '
'; + } else { + const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100))); + meter = + '
' + + '
' + + 'Repertoire' + + '' + mastered + ' of ' + total + ' song' + (total === 1 ? '' : 's') + + (learning ? ' · ' + learning + ' in progress' : '') + '' + + '
' + + '
' + + '
'; + } 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 '
' + + 'Matched: ' + esc(who) + ' (' + esc(src) + ')' + + '
'; + } + function detailsHtml(song, st, vocab) { const art = st.artDataUrl || artUrl(song); const diffBtns = [1, 2, 3, 4, 5].map((n) => @@ -2556,6 +2623,7 @@ 'From pack' + field('det-title', 'Title', st.t) + field('det-artist', 'Artist', st.a) + field('det-album', 'Album', st.al) + '
' + + provenanceHtml(st) + '
' + gapFillHtml(st) + '
' + // Personal practice layer — local, never shared @@ -2623,6 +2691,13 @@ $('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st)); $('[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 // the pack file. The server recomputes proposals under its io lock, so diff --git a/tests/test_artist_alias.py b/tests/test_artist_alias.py index ceacee0..62acba3 100644 --- a/tests/test_artist_alias.py +++ b/tests/test_artist_alias.py @@ -209,3 +209,47 @@ def test_list_aliases_sorted(client, server): _alias(client, "guns n roses", "Guns N' Roses") aliases = client.get("/api/artist-aliases").json()["aliases"] 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"} diff --git a/tests/test_starter_suggestions.py b/tests/test_starter_suggestions.py new file mode 100644 index 0000000..a281603 --- /dev/null +++ b/tests/test_starter_suggestions.py @@ -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) == []