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()
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 (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 ─────────────────────────────────────────────────────────--
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