Commit Graph

214 Commits

Author SHA1 Message Date
OmikronApex
2ffeeaca0b fix(docker): repin FFmpeg to autobuild-2026-07-03-13-21
The previously pinned BtbN autobuild release (2026-06-19) was pruned
upstream, so the release build's curl download 404'd (exit 22).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:16:50 +02:00
Byron Gamatos
14eaad09e9
feat(library): add Star-Spangled Banner + Ode to Joy starter content (#744)
Add two more public-domain starter songs alongside Für Elise, wired into
_BUILTIN_STARTER_SOURCES so they seed into DLC_DIR/starter/ on first run:

- The Star-Spangled Banner (lead) — John Stafford Smith; cleaned the "Unknown"
  artist / placeholder album, author "Fee[dB]ack".
- Ode to Joy (lead/rhythm/bass + drums) — Beethoven. Replaces the raw
  "Ode to Joy (VST Cover)_The Adicts.feedpak" that was committed to main but
  never added to the seed list (so it bundled 23 MB of dead weight and never
  appeared). Fixed metadata (artist Beethoven, year 1824, author "Fee[dB]ack"),
  and repointed the stem from the 22 MB editor WAV to the byte-identical-render
  full.ogg (both exactly 85.324 s), shrinking the pack 23.8 MB -> 1.7 MB.

Add guard tests asserting every _BUILTIN_STARTER_SOURCES entry has its file
committed and that a seed run lands them all — this catches exactly the
listed-but-missing (or committed-but-unlisted) mismatch that left Ode to Joy
un-seeded.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:58:39 +02:00
OmikronApex
6ab1ed95c9
Add Ode to Joy (VST Cover) as new starter content 2026-07-03 22:00:51 +02:00
Byron Gamatos
7c873f5cc2
feat(library): seed bundled starter content into the library on first run (#743)
Ship a public-domain Für Elise (keys) feedpak as starter content so a fresh
install isn't an empty library. server._seed_builtin_starter_content() copies
bundled packs into DLC_DIR/starter/ exactly once, guarded by a marker in
CONFIG_DIR — unlike the always-reseeding diagnostic seed, a user who deletes
the starter song does not get it back. `starter/` is deliberately outside the
diagnostics/tutorials library carve-out so the song surfaces as a normal
library entry.

Extract the shared symlink-safe, mtime-aware copy loop into
_copy_builtin_packs() and route both the diagnostic and starter seeds through
it (diagnostic behavior unchanged; existing tests green).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:35:22 +02:00
Byron Gamatos
68e29a8b6e
fix(plugins): don't treat transient absence from /api/plugins as uninstall (#741)
* fix(plugins): don't treat transient absence from /api/plugins as uninstall

The backend clears its plugin registry at the start of load_plugins()
and repopulates it incrementally while HTTP stays up, so every backend
restart (desktop: Audio Quality soundfont switch, LAN toggle, update
restart) serves a window of partial — even empty — /api/plugins
responses. loadPlugins() treated absence from the current response as
an uninstall, with three destructive consequences for still-loaded
plugins:

1. Their settings-panel and screen DOM were wiped while their
   _loadedPluginScripts entry survived, so the NEXT refetch failed the
   DOM-existence check and re-evaluated the plugin's screen.js
   mid-session. For the desktop audio_engine plugin that re-ran init()
   against the surviving native audio chain and exactly duplicated
   every VST/NAM/IR stage (the alpha testers' "chain duplicates after
   leaving the Audio menu" / blown-out gain reports).
2. _reconcilePluginStyles dropped their stylesheet, leaving them
   visible but unstyled until they reappeared.
3. The stale-contribution sweep unmounted their UI contributions and
   unregistered their capability participant with no re-registration
   path (plugin scripts don't re-run thanks to the loadedScripts
   guard).

Absence is now a non-signal everywhere in loadPlugins: the DOM wipe and
style reconcile are scoped to plugins the response actually names, and
the absence sweep is removed. Present plugins still fully re-sync via
_registerLegacyPluginUiContributions each round; failed plugins are
present in the response and still cleaned up; nav is rebuilt from the
response so genuinely uninstalled plugins drop out of it, and their
(un-unloadable) already-evaluated scripts keep their DOM until reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: update idempotence contract to the absence-is-not-uninstall invariant

The removed-plugin sweep contract pinned the old behavior this branch
deletes; pin the new invariant instead (no absence sweep + respondedIds
scoping on the DOM/style reconcilers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:45:30 +02:00
OmikronApex
d2b2a7e9f7
fix(tests): re-green the JS suite — 18 stale source-shape tests + 1 real seek-reason violation (#740)
main's JS suite has been red since the recent v3-library and player
refactors landed. 17 of 18 failures were test harnesses/regexes that
went stale behind real, intentional code changes; one was a genuine
contract violation in the code.

Code fix:
- session-resume seek passed 'resume' as its _audioSeek reason; the
  documented contract (enforced by song_seek.test.js) requires
  multi-word kebab-case. Renamed to 'session-resume' — no consumer
  string-matches specific reasons, so this is rename-safe.

Test updates (each pins the CURRENT contract):
- highway_colors_facade: inject HWC_PRESETS + applyHighwayStringPreset
  (new preset feature); lock presets/applyPreset into the surface test
- loop_api: stub _updateEditRegionBtn (new edit-region UI hook)
- song_close: sandbox gets window.feedBack.playQueue; assert a real
  close abandons the queue (the new queue-aware behavior)
- v3_keep_practicing: the shelf moved from client-side /api/stats/recent
  dedupe+gating to the server-side practice-suggestions recommender —
  tests now pin that (fetch, arrangement-aware card click, Promise.all)
- v3_songs_tuning: card row variable renamed song → shown (grouped cards)
- live_guitar_tone_source: accept literal ’ where &rsquo; drifted in copy
- legacy_shim_hits: normalize CRLF before fixed-width region() slicing
  (Windows-only failure; char windows shrank by one char per line)

Suite: 987/987 locally (Windows), previously 968/987 (and 18 red on CI).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:35:07 +02:00
OmikronApex
b6442dda75
Merge pull request #737 from got-feedback/feat/playlist-shuffle
feat(v3): playlist shuffle toggle
2026-07-03 14:21:40 +02:00
OmikronApex
336132e049 fix(v3): keep shuffle toggle size stable across states
Off state had a 1px border, on state none — toggling grew/shrank the
button 2px and shifted the row. On state now carries a same-color
border (border-fb-primary, already in the prebuilt CSS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:10:16 +02:00
OmikronApex
a2f43009f7 fix(v3): match shuffle icon height to Play all button
w-4 icon (16px) vs text-sm line-height (20px) made the shuffle button
4px shorter than its neighbor at equal py-2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:08:54 +02:00
Byron Gamatos
d27cbe78ba
chore: remove stale root README (#739)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:49:03 +02:00
Byron Gamatos
803bd0cdf3
Bump version to 0.3.0-alpha.1 2026-07-03 13:41:50 +02:00
Byron Gamatos
9456790083
fix(release): lowercase the ghcr repo name in image tags (#738)
The repo is 'got-feedback/feedBack' (capital B) after the rename, so ${GITHUB_REPOSITORY} produced an invalid Docker tag ('repository name must be lowercase'). Use ${GITHUB_REPOSITORY,,}. nightly/rc already hardcode lowercase 'feedback'.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:16:49 +02:00
OmikronApex
425f72b33f feat(v3): playlist shuffle toggle
Crossing-arrows toggle next to Play all / Play album on the playlist
detail page. When on, playQueue.start Fisher-Yates-shuffles the queue
once at start — on a copy, so the stored playlist order is untouched —
swapping per-slot album arrangements in lockstep so each slot keeps its
pinned arrangement (#685 contract preserved). Prev-less queue semantics
are unchanged: auto-advance simply walks the shuffled order.

Preference is global, persisted as localStorage v3PlaylistShuffle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:07:47 +02:00
Byron Gamatos
286c59707b
fix(tests): isolate plugin routes modules + redact .feedpak filenames (#736)
Two pre-existing failures the segfault had been masking (the run aborted at ~25%, so they never ran until #735 let the suite complete):

1) Tuner group (~24): plugins ship a bare-named routes.py, so sys.modules['routes'] leaked between plugin test dirs (achievements ran first, tuner got its module). Each plugin conftest now pops the stale 'routes' and an autouse fixture binds sys.modules['routes'] to that plugin's module for the duration of its tests (covers runtime 'import routes' in test bodies).

2) Diagnostics group (5): _SONG_FILENAME_RE never matched the tests' .feedpak/.archive filenames — it also lacked 'feedpak' (the current primary format), a real redaction gap. Added feedpak to the regex and switched the tests off the fake .archive to the real .feedpak. Verified: full suite 2183 passed, 0 failed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:01:07 +02:00
Byron Gamatos
97a941c45d
fix(tests): join background scan/enrich workers before closing the DB (#735)
Root cause of the flaky pytest segfault (exit 139): the background scan and enrichment daemon threads (_scan_runner/_enrich_runner) use the shared MetadataDB connection, but test fixtures closed that connection in teardown without stopping them. A daemon thread mid-query on a freed SQLite conn is a native use-after-free → SIGSEGV. The app's startup kicks a scan, so almost any app-booting fixture was vulnerable. It only surfaced now because got-feedback/feedBack#728 added a push trigger, so ci/test runs on every push to main.

Fix: server.py retains the scan/enrich thread handles and adds _join_background_db_threads(); every test fixture now joins the workers before conn.close(). Verified: the full suite runs to completion (no segfault) where it previously crashed at ~25%.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:15:43 +02:00
Byron Gamatos
9d6fdfe232
feat(v3): use PNG logo in sidebar nav instead of the text wordmark (#734)
Replaces the fee[dB]ack text wordmark in the #v3-brand sidebar header with the exported PNG logo (static/v3/brand/feedback-logo-light.png, 664x165). Sized width:100% + height:auto so it fits the 256px sidebar's content width (~208px inside the p-6). Updated both the no-JS fallback (index.html) and the shell.js boot render. Inline style avoids introducing a new Tailwind utility (constitution P-II).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:39:27 +02:00
OmikronApex
005270608b
ci: adapt workflows to trunk-based development (#728)
Nightly builds main directly (old release/v* discovery pinned nightlies
to shipped branches forever). ship-ci adds push triggers on main and
release/** for post-merge signal. New rc.yml builds :rc images from
release branches during stabilization.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:25:44 +02:00
ChrisBeWithYou
be9e965001
v3 library: artist pages — in-your-library view, similar-in-library, links-only web links (#731)
* v3 library: artist pages — in-your-library view, similar-in-library, links-only web

The greenlit artist-pages feature. Every page renders from LOCAL data;
an optional, opt-in external-links strip is the only network surface.

Server:
- artist_enrichment table (mb_artist_id PK, url_rels JSON, genres JSON,
  fetched_at) — never purged; one row per matched MusicBrainz artist.
- GET /api/artist/{name}/page — all-local: canonical name (+ raw alias
  variants), song/album/mastered counts, album list, similar-in-library
  (top artists by shared genre, self excluded, empty is fine), and the
  artist's MB id when any matched/manual song carries one. THE DENOMINATOR
  LAW: "N mastered" counts songs you OWN (best_accuracy >= 0.9 across the
  artist's library songs), never a global discography — a stored score for
  a song no longer in the library does not count.
- GET /api/artist/{name}/links — lazy, cached-forever: returns the cached
  row, else (external links enabled + network + a known MB artist id) ONE
  throttled artist lookup (inc=url-rels+genres+tags), whitelisted into
  {official, tour, video, social[], wikipedia}. Every URL passes the same
  http(s) scheme gate as art redirects, so a hostile javascript:/data:/file:
  can never reach an href. POST .../links/refresh re-fetches. Offline /
  no-mbid / links-disabled → empty, no error. Both routes demo-blocked.
- Settings keys artist_pages_enabled (default ON — local-only) and
  artist_external_links (default OFF — opt-in per the dev-chat thread).

Frontend (static/v3/songs.js): an in-place sub-render mirroring openAlbum()
with a "← Song Library" back + scroll restore. 2x2 album-art mosaic header
(borrows the playlist-cover renderer), canonical name + "also shown as"
variants + a Matched·MusicBrainz pill when known; stats strip that omits
the mastered segment at zero (invitational, never "0 mastered"); Play all /
Shuffle (playQueue) + Save as smart playlist (collections rule {artist});
album rail → openAlbum; song list via the artist filter + wireCards;
"Similar in your library" chips → open that artist; and the external-links
row under an "On the web · opens your browser" divider, each link
target=_blank rel=noopener noreferrer with its domain shown — rendered only
when external links are on AND links exist. Empty modules hide.

Entry points: card ⋮ "Go to artist", the grid card artist line, and a
"View artist page" link in the Details drawer — all via
window.__fbOpenArtistPage.

Tests: tests/test_artist_page.py — page counts/albums/alias folding, the
denominator law (owned-only, best-across-arrangements), similar ranking +
empty, mb-id only from matched rows, links whitelist + scheme gate (a
javascript: and an ftp:// URL both rejected), disabled-by-default no
network, cache-no-second-fetch, refresh, demo block. 21 pass (35 with
artist_alias). node --check clean; tailwind rebuilt.

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

* fix(v3 artist pages): unfiltered album view + select-mode row guard on artist page

Artist-page album click no longer applies the global library filters: openAlbum
gains an ignoreFilters option that builds the /api/library request scoped only to
artist+album (no drawer/genre/tuning/search params), so the album view and
Play-album match the artist page's full-shelf counts. The normal albums-view
click path is unchanged (ignoreFilters defaults off).

Select-mode row clicks on the artist page now toggle selection instead of playing.
Extracted the grid/tree capture-phase select guard into a shared bindSelectGuard()
and attach it to the persistent artist-page host too. Each host is bound once at
shell build; innerHTML re-renders reuse the same element, so there is no
double-binding.

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>
2026-07-03 08:52:20 +02:00
ChrisBeWithYou
64a499975e
v3 library: multi-candidate cover picker — select from a populated list (#732)
* v3 library: multi-candidate cover picker — select from list, media-server style

Auto-best already ships; this adds the "pick from a populated list" surface
Christian asked for, as ONE reusable component (song covers now; album/artist
art reuse the same picker later).

Server:
- GET /api/song/{filename}/art/candidates — assembles WITHOUT hoarding: the
  Current image + its provenance, Pack art when present, and Cover Art Archive
  candidates for the matched release (and any release ids stored on a review
  row's candidates). New _caa_release_index() fetches the CAA release INDEX
  json (image list + types + thumb sizes) through the existing throttle +
  offline gate, cached as caa_index_{id}.json beside the covers (indexes are
  stable). Capped at 12; fetched on demand. Demo-blocked (it spends the rate
  budget) and offline → instant tiles only, no error.

Frontend: new static/v3/image-picker.js — window.__fbOpenImagePicker({filename,
title}), a body-appended singleton modal (match-review anatomy: overlay, focus
trap, Esc). Current image + provenance badge on the left; a tile grid on the
right whose instant tiles — Current, Pack original, Upload, Paste URL — work
immediately even offline, while CAA candidates load behind ONE /art/candidates
fetch with skeleton tiles + a "the source is rate-limited" caption. The fetch
is tied to an AbortController and cancelled when the modal closes.

Applying a pick reuses EXISTING routes so there's no new write path and the
design's key trick holds: a chosen cover POSTs to …/art/url (the override
lane — never evicted by the art-cache LRU, survives a re-match); "Pack
original" DELETEs the override; Upload POSTs …/art/upload (GIF stays
upload-only + local-only). Silent-on-success; the drawer/card art refreshes
via the existing cache-buster.

Entry points: the Details drawer art click (the old direct file dialog is now
the Upload tile) and a card ⋮ "Change cover…" action.

Tests: tests/test_art_candidates.py (matched row lists index images; review
row pulls in candidate releases; unmatched/offline → instant tiles only;
index cached, no second fetch; demo blocked) over a fake index seam.
30 pass with test_art_layer green (same seams). node --check clean; tailwind
rebuilt for the new file.

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

* fix(v3 cover picker): uiPrompt over window.prompt, visible-only focus trap, gate CAA to matched rows, index-cache lock, abort-on-reopen; +traversal tests

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>
2026-07-03 08:49:44 +02:00
ChrisBeWithYou
8c7cde5d5c
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>
2026-07-03 08:49:04 +02:00
Byron Gamatos
df2d660d1e
Re-enable the v3 "Support Us!" donate button (#727)
Funding was cleared to come back online 2026-06-30 (offending
functionality fully removed). Restore the v3 topbar donate button
(hand-re-applied revert of cad7885 — the topbar was refactored since,
so this re-adds the Support Us! anchor alongside the new v3-search-wrap)
pointing at the feedBack-branded Patreon page
https://patreon.com/got_feedback.

Rebuild static/tailwind.min.css: the button's utilities
(bg-fb-accent, hover:bg-red-600, shadow-fb-accent/20, sm:inline-flex)
were purged when the button was removed and are needed again.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:21:10 +02:00
Byron Gamatos
f5c9c34291
library: scraper options — per-source + per-field auto-apply, review-queue order (R1) (#726)
* library: scraper options — per-source + per-field auto-apply toggles, review-queue order (R1)

Grows the Settings→Library "Metadata matching" card into the full
scraper-options panel — not everybody needs the same things out of a
scraper:

- Sources: enrich_src_musicbrainz gates the background matcher (phase 2;
  identity hashes still stamp, and manual Fix-match/search stays
  available — same contract as the master toggle). enrich_src_caa gates
  the Cover Art Archive fetch (phase 3). Rows skipped by an off toggle
  stay unevaluated, so re-enabling picks them up on the next pass —
  nothing is permanently forfeited.
- Auto-apply fields: enrich_apply_names/year/genres filter what an
  AUTOMATIC match may canonicalize (_enrich_field_filter, applied on all
  three automatic paths: cache copy, mbid/isrc exact keys, text auto).
  MusicBrainz ids always stamp — they're identity, not display; the art
  fetch and future re-matching need them. A match the user confirms in
  the review modal applies in full. enrich_apply_art gates the art fetch
  alongside the CAA source toggle (two axes, one behaviour today —
  future art sources slot in without re-teaching the panel).
- Review queue order: enrich_review_order = missing_first (default,
  today's behaviour) | artist | recent, read by GET /api/enrichment/review;
  unknown stored values degrade to the default.
- Settings card: Sources / Auto-apply / Review-queue-order groups wired
  in match-review.js; the master toggle is relabelled "Match songs
  automatically" so it doesn't read the same as the new MusicBrainz
  source toggle. No tailwind rebuild needed — every class was already
  scanned from core source.

Tests: tests/test_scraper_options.py (9) — settings validation,
MB-source-off stamps-without-matching + re-enable, per-field stripping
on auto matches with ids preserved, review-accept full-apply despite
toggles, CAA gating on both axes, review-order modes incl. the
unknown-value fallback. Full-suite failure set A/B-identical to the
base (39 env/pre-existing).

Stacked on feat/enrichment-art (#715) — merge that first.

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

* library: per-field auto-apply honours "nothing forfeited" (backfill + no partial seeding)

The R1 per-FIELD auto-apply toggles settled a `matched` row with the
disabled fields stripped, but enrichment_pending() never revisits an
unchanged-hash matched row — so re-enabling a field never backfilled it,
and enrichment_cache_lookup() (which gates only on mb_recording_id) could
seed sibling charts with the stripped blanks. This broke the same
"nothing is permanently forfeited" contract the source (mb_on) and art
(art_on) toggles already keep.

Fix: persist an `apply_mask` marker (sorted blocked apply-keys) on every
AUTOMATIC match:
- migration: additive `apply_mask TEXT` column (idempotent ALTER).
- enrichment_pending(allowed_keys=...): re-queues a `matched` row whose
  apply_mask names a field that is now re-enabled → backfill on re-enable,
  converges (a fully-applied row is never re-queued).
- enrichment_cache_lookup: only fully-applied donors (apply_mask empty/NULL)
  may seed siblings; a partial row is skipped and the sibling falls through
  to its own re-filtered match.
- _enrich_apply_mask()/_enrich_blocked_apply_keys() helpers; threaded through
  _enrich_one → apply_enrichment_match. Review/manual writers leave it NULL
  (a confirmed pick applies in full).

Tests: re-enable-backfills-and-converges; partial row is not a cache donor
(fully-applied one is). 13 scraper-options tests pass; 170 enrichment/
settings tests green.

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

---------

Co-authored-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:57:42 +02:00
ChrisBeWithYou
7ca736d525
library: cover-art layer — CAA auto-fetch + user overrides, GIF local-only (P9) (#715)
* library: cover-art layer — CAA auto-fetch + user overrides, GIF local-only (R3/P9)

Third slice of the enrichment series (stacked on the matcher): covers.

- Serve chain for GET /api/song/{fn}/art: USER OVERRIDE -> PACK ART ->
  COVER ART ARCHIVE cache -> 404. Behaviour change, deliberate: a
  user-uploaded cover now OVERRIDES pack art (previously the upload only
  filled the no-art gap, which made custom art look broken on any song
  that already shipped a cover).
- GIF is allowed as an override and kept VERBATIM (animation intact) —
  a local-only bonus. Everything else normalizes to RGB PNG as before.
  One override per song (saving either kind removes the other), and
  nothing ever writes art INTO a pack file — test-pinned: the pack's
  cover.jpg is byte-identical after a GIF upload.
- Art by URL: POST /api/song/{fn}/art/url fetches server-side (http(s)
  only, 10 MB cap enforced while streaming) into the same override slot.
  DELETE /api/art/{fn}/override drops it — under /api/art because the
  greedy DELETE /api/song/{path} catch-all shadows anything beneath it
  (the same dodge the chart split/unsplit routes use).
- Cover Art Archive fetch as phase 3 of the enrichment pass: matched
  songs that LACK pack art get their release's front cover, throttled +
  identified + offline-guarded exactly like the MusicBrainz client
  (pytest can never reach the network; a transport error pauses the
  pass without burning the row). The cache is keyed by RELEASE MBID —
  ten charts of one album cost one fetch — and every outcome writes an
  art_state (pack/user/caa/none/error) so a row is evaluated once.
- LRU cap (200 MB) on the CAA side of the cache only; user overrides
  are never evicted, and evicted rows reset so a later pass may
  re-fetch. Deleting a song removes its override files (CAA files stay
  — they may be shared by other charts of the release).

No frontend changes: the grid, the review modal, and the player pick
the new art up through the same route they already use. The
upload/paste-a-link surfaces in the Details drawer land with the
context-menu slice once the drawer PR merges.

13 new tests (tests/test_art_layer.py) + demo-mode routes; full-suite
failure set byte-identical with the change stashed vs applied.

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

* library: harden cover-art layer — SSRF guard, demo/size caps, override-delete state reset

Follow-up hardening on the R3 cover-art layer:

- remove_song_art_override: reset the enrichment row (set_enrichment_art(fn,
  None, None)) when an override is deleted, so a row previously settled as
  'user' re-queues and the CAA fallback resumes. Previously a removed override
  stranded the row (enrichment_art_pending only re-queues art_state IS NULL),
  leaving the song with no art at all.
- Base64 art upload: block it in demo mode (was open — a write/disk-fill vector,
  worse now that GIFs are stored verbatim), validate the filename resolves to a
  real song (mirrors the url route), and cap the decoded payload at 10 MB.
- Art-by-URL: reject hosts that resolve to loopback/private/link-local/reserved/
  multicast/unspecified addresses (SSRF, e.g. cloud metadata) and stop following
  redirects (allow_redirects=False) so a redirect can't smuggle the request to an
  internal target. Fails closed on unresolvable/unparseable hosts.
- _caa_http_get: stream with a per-file 10 MB cap (bounds any one response
  independently of the aggregate LRU); guard release_id against a conservative
  token before interpolating it into a cache-file path (no separators/dots).

Tests: delete-override→CAA-fallback, upload unknown-song/oversize rejection,
SSRF internal-host guard, and a demo-mode block assertion for art/upload.

Note: art_state='error' rows are intentionally not auto-retried — there is no
per-row attempt counter on the art side, so an unbounded retry could storm CAA
for permanently-bad rows; a bounded retry would need extra state, left out here.

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>
2026-07-02 20:55:43 +02:00
ChrisBeWithYou
8e953e8bc4
library: opt-in gap-fill — write confirmed missing metadata into the pack (R4a) (#724)
* library: opt-in gap-fill — write confirmed missing metadata into the pack (R4a)

The write-back contract agreed with the spec chair (alignment doc §7),
made executable, now that feedpak-spec 1.14.0 (mbid/isrc) is merged:
opt-in + user-initiated, adds ABSENT keys only, spec'd-keys allowlist,
values only from a CONFIRMED identity, atomic write + .bak. Single-song
only — batch write-back stays an open question with the chair.

- songmeta.gap_fill_sloppak: append-only manifest writer. Every added
  key is absent by definition, so the new lines are APPENDED — the
  author's existing bytes (key order, comments, formatting) survive
  verbatim, unlike the metadata editor's full re-serialize. Directory
  form gets a one-time manifest.yaml.bak + temp + atomic replace; zip
  form reuses the editor's backup/temp/replace rewriter. Raises on any
  already-present key: the never-clobber rule lives in the writer, not
  just the callers.
- GET /api/song/{fn}/gap-fill: read-only preview — which of
  album/year/genres/mbid/isrc are missing from the file (absent or
  empty; year 0 = empty), with the values the enrichment match
  supplies. Only a CONFIRMED identity is eligible (matched or a user
  pin); review-tier rows are refused until a human confirms —
  wrong-match > fast, same as everywhere else in the enrichment layer.
- POST /api/song/{fn}/gap-fill {keys}: writes the user-confirmed
  subset. Proposals are RECOMPUTED under _song_io_lock, so a key that
  gained an author value between preview and confirm is skipped, never
  replaced. mbid/isrc written in canonical form only (validated).
  DB stays scanner-consistent (album/year/genre columns + mtime/size
  re-stat, cache invalidation + scan kick — the metadata editor's
  contract). Demo mode blocks the write.
- Details drawer (Identity section): "Write missing info to file…" →
  per-key checkbox confirm ("Only adds what's missing — nothing already
  in the file is changed. A backup (.bak) is kept.") → written
  confirmation; not-eligible states explain themselves. v3 only; no
  new tailwind classes.
- Rides along: _manifest_exact_ids now strips ISRC display separators
  (spec 1.14.0's strip rule) — a hand-authored "AU-AP0-90-00045" hits
  the exact-match tier instead of silently falling back to text.

Tests: tests/test_gap_fill.py (10) — preview eligibility incl.
review-refusal + empty-as-gap, author-bytes-preserved-verbatim on dir
AND zip (with .bak content pinned), skip-not-replace on the mixed
request, the writer's ValueError guard, key validation, demo block,
DB sync; +1 hyphenated-ISRC test in test_mb_enrichment.py. 46 targeted
green; full-suite failure set A/B-identical to the main base (39
env/pre-existing).

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

* gap-fill: align preview with append-only writer (no cleared-value 500)

The R4a preview offered present-but-empty manifest values (album: '',
genres: [], year: 0) as gaps, but the append-only writer's never-clobber
guard raises on ANY key already present — so a user-confirmed POST for
those keys turned into a 500 "write failed" instead of filling the gap.
Appending can't fill an empty-but-present key anyway (it would duplicate
the YAML key).

Fix: _gap_fill_manifest_absent now treats only genuinely-MISSING keys as
gaps; a present-but-empty value is left to the metadata editor (which
re-serializes and can replace in place). This closes the preview→POST
mismatch — the preview never offers what the writer would refuse.

Tests: test_preview_treats_empty_values_as_gaps replaced by
test_preview_excludes_present_but_empty_keys (present-but-empty not
offered; genuinely-absent still offered) + test_write_present_but_empty_
key_is_refused_not_500 (POST → clean 409, file untouched, no .bak; a
genuinely-absent key alongside still writes). Closes the write-path blind
spot in the original empty-value test.

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>
2026-07-02 20:52:11 +02:00
ChrisBeWithYou
7ef52cdd66
fix: Edit Metadata persists into .feedpak files (suffix gate predated the rename) (#725)
write_song_metadata dispatched zip-form packages on `suffix == ".sloppak"`
only, while core reads both suffixes everywhere else (sloppak.SONG_EXTS,
.feedpak being the current write extension). Editing a zip-form .feedpak's
title/artist/album/year therefore silently fell back to a DB-only update,
which looked fine until the next full library rescan re-derived metadata
from the file and reverted the edit — the exact failure this module exists
to prevent. Directory-form packages were unaffected (manifest-presence
dispatch, not suffix).

Gate on SONG_EXTS, add TestWriteSongMetadata regression coverage (both zip
suffixes, mixed-case suffix, directory form, unknown-suffix fallback), and
correct the stale scan_worker comment claiming .sloppak-suffix-only
detection (the code already accepts both via is_sloppak).


Claude-Session: https://claude.ai/code/session_01H1ZBEcZoJinde9ms5fAjwc

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:51:57 +02:00
ChrisBeWithYou
55060c4f67
v3 library: artist sort orders titles within an artist (tree-view feel) (#720)
* v3 library: artist sort orders titles within an artist (tree-view feel)

Tester report: "the list is set up by artist, but the cards are
alphabetical(-ish random)". Real: the tree orders artist -> album -> title,
while the grid's artist sort ordered within an artist by RAW FILENAME —
community-pack filename noise, so an artist's cards looked shuffled.

- artist / artist-desc gain a title secondary (direction baked per entry so
  the legacy `dir=desc` append can't land on the title term; titles stay
  A->Z under Z->A artists).
- The two-term (value, filename) keyset cursor can't seek a three-term
  order, so artist sorts leave _KEYSET_SORTS and page by OFFSET — measured
  trivial at real library sizes; title/recent keep their keyset. Restore
  via a composite sort-key column if 50k-song libraries ever hurt.
- The tree view says "List view groups by artist — the selected sort
  applies to the card grid" when a non-artist sort is active, instead of
  silently ignoring the picker.
- Keyset proof-tests repinned to the title sort (same property, a sort
  that still keysets); 2 new tests pin the title-within-artist order and
  the OFFSET pagination's no-skip/no-dupe across pages.

Full-suite failure set identical to the same-main baseline.

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

* v3 library: honor legacy sort=artist&dir=desc (fold dir into effective sort)

Codex/review follow-up to the title-within-artist change: the new artist
ORDER BY bakes in `ASC` (for the title secondary), so the global `dir=desc`
append is suppressed and `sort=artist&dir=desc` silently returned A->Z
instead of Z->A — a regression on the legacy /api/library dir contract.

Fold `dir=desc` into the canonical sort key BEFORE the sort_map lookup via
the existing _effective_keyset_sort helper (same fold the cursor side already
does), so the ORDER BY is built from the effective sort. Only artist/title
fold (they have `-desc` twins); title/recent/tuning/year/mastery are
unaffected — verified by the keyset/filter suites.

New test pins that legacy `sort=artist&dir=desc` matches the explicit
`artist-desc` ordering (Z->A artists, A->Z titles within each).

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>
2026-07-02 20:51:33 +02:00
ChrisBeWithYou
7564934d06
v3: rename sidebar "Songs" entry to "Song Library" (#721)
The LIBRARY group's main entry now reads "Song Library" in the sidebar
(and in the topbar page title, which mirrors nav labels). The nav key
and screen id are unchanged, so routing, saved hashes and promoted-
plugin anchors are untouched.


Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:50:59 +02:00
ChrisBeWithYou
28b0319e27
play-queue: peekNext() — expose the following track for queue-aware UIs (#719)
A results screen that offers "Up next: <song> — starting in 10s" needs to
know WHAT follows without reaching into queue internals. peekNext() returns
{filename, index, total} for the next track (null when nothing follows),
pure — peeking never plays or mutates.

First consumer: the note_detect results card's queue-advance strip (the
"Playlist Play All has no way to progress" tester issue).


Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:50:56 +02:00
K. O. A.
4b6cbe8b11
Fix 3D drum/keys highways not resizing on fullscreen under splitscreen (#723)
The guitar/bass highway_3d renderer self-detects panel-canvas size changes
in its draw() loop and re-runs applySize() every frame, because the
splitscreen host overrides hw.resize and never calls renderer.resize().
The drum and keys highways lacked that fallback — they only re-framed when
the host explicitly called resize(w, h) — so their panels stayed framed for
the pre-fullscreen size while the guitar/bass panels adapted. Symptom: a
too-small, off-center highway in the drum/keys panels after maximizing a
split-screen session.

Port highway_3d's per-frame drift check into both draw() loops: re-apply on
backing-store change (canvas.width/height) AND on CSS-box drift
(clientWidth/clientHeight vs the last applied logical size, throttled to
every 10th frame). Track _lastHwW/_lastHwH + _appliedW/_appliedH per
instance and reset them in destroy() so a reused instance re-frames on the
next song.

plugins/drum_highway_3d -> 0.3.1, plugins/keys_highway_3d -> 0.1.1.
Tests: tests/js/drum_keys_highway_3d_resize_reframe.test.js.

Signed-off-by: Kris Anderson <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:42:26 +02:00
ChrisBeWithYou
c7497c758d
v3 library: context-menu unification — Fix match, Refresh metadata, Get info, multi-version remove (#718)
* v3 library: context-menu unification — Fix match, Refresh metadata, Get info, multi-version remove (R2)

The ⋮ overflow and the native right-click menu already render from one
builder (openCardMenu), so every entry here lands in BOTH surfaces on
grid cards and tree rows alike — parity is structural, not maintained.

New entries (local library):
- Fix match… — opens the match modal in a single-song mode: no queue,
  no Skip, the search panel open and pre-filled; a pick pins the match
  exactly like the review flow (window.__fbFixMatch).
- Refresh metadata — POST /api/enrichment/refresh/{fn}: resets the
  song's match to unscanned (canonical values + candidates cleared,
  backoff zeroed) and kicks a pass. An EXPLICIT user action, so it may
  discard a manual pin — the automation never does, but the user asking
  for a re-match is the one party who owns that pin. Silent on success.
- Get info… — GET /api/chart/{fn}/fileinfo: file location + folder
  (selectable/copyable under the v3 no-select default), format, size,
  modified; for feedpaks the manifest summary (arrangements, stems,
  cover/lyrics presence, authors, and whichever identity keys are
  actually authored — mbid/isrc/genres/track/disc); plus the match
  verdict ("Matched (text, 96%)" / "Pinned by you" / "Not scanned").
  Under /api/chart because the GET /api/song/{path} catch-all would
  swallow the suffix.
- Remove from library — with the multi-version interstitial: on a
  multi-chart work, "remove the song" is ambiguous (a grouped card
  stands for several files), so a modal lists EVERY version with
  checkboxes (the card's own chart pre-ticked) and deletes exactly
  what was picked — one file or the batch. Single-chart songs keep the
  plain confirm.

Refresh + Get info are demo-mode blocked (cache mutation / path
exposure). apply_enrichment_match now zeroes `attempts` on an explicit
reset to unscanned, matching the stub upsert's identity-change rule.

5 new tests (refresh resets even a manual pin then re-matches via the
fake transport; 404s; fileinfo manifest/identity/match shapes;
traversal guard) + the demo-mode route list. Full-suite failure set
identical to the same-main baseline. tailwind.min.css regenerated.

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

* v3 context menu: review fixes — target displayed chart, harden Get-info

Three fixes from the PR review round:

- songs.js: Fix match / Refresh metadata / Get info now act on the DISPLAYED
  chart (playTarget) rather than the group representative, matching Play. On a
  grouped card where an intrinsic (tuning/arrangement) filter attached a
  display_chart, these three previously fixed/refreshed/showed-info for the
  wrong file. (__remove stays on `song`: it needs the group's work_key/
  chart_count and already pre-ticks the shown chart.)

- server.py fileinfo: 404 ("not a chart") unless the path is a sloppak or a
  loose song. The route previously stat'd ANY file under DLC_DIR, leaking its
  path/size/mtime for e.g. a notes.txt the user keeps there. `format` can no
  longer be "other".

- server.py fileinfo: the directory size sum skips symlinked entries so a link
  inside a song folder can't pull in (or leak the size of) a file outside it.
  Verified on the runtime that rglob does not descend symlinked subdirs.

+1 regression test (non-chart file -> 404).

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>
2026-07-02 18:36:00 +02:00
ChrisBeWithYou
0a8c8945ea
v3 library: Albums view — the client half of the album-condense work (#716)
* v3 library: Albums view — the client half of the album-condense work

Follow-up to the query_albums endpoint: the UI that consumes it, plus the
track-order plumbing the endpoint's track list needs.

- Albums view (a fourth view toggle next to grid/tree/folder): album cards
  (cover / title / artist / track count) from /api/library/albums,
  respecting the active filter drawer; clicking one opens the track list
  with per-track play and a Play-album button that feeds the play queue
  (falls back to plain playSong when the queue plugin is absent).
- Track order: the scanner now reads the feedpak `track`/`disc` fields
  (spec 1.12.0) into new nullable songs columns (idempotent ALTERs), and
  the album track list orders by the new `track` sort — disc, then track
  number, unauthored charts to the bottom by title. Charts without
  authored numbers keep working; they just sort alphabetically.
- The albums view persists like the other view choices.

3 new tests: manifest track/disc extraction (and unauthored -> None),
the disc->track->title sort order over /api/library, and the put()
round-trip. Full-suite failure set matches the known env baseline (one
tuner-config name swapped inside the suite-ordering flake family — the
file passes 25/25 in isolation on clean main).

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

* v3 albums: honour Genre/Match filters in album grid + detail (review fixes)

The Albums view only partially respected the filter drawer:

- /api/library/albums silently dropped the `genre` and `match` params the
  client sends via queryParams(), so with a Genre or Match filter active the
  album grid surfaced albums with zero matching tracks. Thread match_states/
  genre through the endpoint -> query_albums -> _build_where, mirroring the
  /api/library grid route. (SmartCollection/pass-through providers keep their
  existing kwarg handling.)

- The album-detail track list built its own params (provider/artist/album/
  sort only), so it ignored ALL active filters — the track list and the
  Play-album queue could include songs the user had filtered out. Reuse
  queryParams({...}, {catalog: true}) so detail honours the same filters as
  the grid while pinning this album's artist/album and track order.

+1 regression test (albums endpoint honours the Genre filter).

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>
2026-07-02 15:57:26 +02:00
Byron Gamatos
2e4383524f
fix(v3): drawer fav-sync honours data-fav-idle (no dim-heart on List View) (#717)
_patchCardFav (the Song Details drawer's like -> card heart sync) hardcoded
`classList.toggle('text-white', !fav)`, so toggling the like from the drawer
left List-View rows' `text-fb-textDim` idle class in place — the exact
dim-heart bug #654 fixed for the on-card click handler, reintroduced on the
drawer path. Read the per-heart `data-fav-idle` and swap that class instead,
mirroring wireCards. +regression assertion in v3_favorites_toggle.test.js.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:52:40 +02:00
Byron Gamatos
13db718bda
test(v3): sync A–Z rail assertion to the railParams refactor (#702) (#714)
refreshRail was refactored (PR #702 work-grouping) to build a
`railParams = { sort_letters: 1 }` object (adding group when grouping is
active) before calling queryParams(), instead of the inline
queryParams({ sort_letters: 1 }). Behaviour is unchanged — it still opts
into the active-sort breakdown — but the source-assertion test lagged and
went red on main. Point the assertion at the new railParams shape.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:03:05 +02:00
ChrisBeWithYou
727b8c8f24
feat(a11y): app-wide "Interface size" setting (Accessibility) (#664)
* feat(a11y): app-wide "Interface size" setting (Accessibility)

Adds a dedicated Accessibility -> Interface size control so users on large,
low-DPI displays can enlarge the app's menus, buttons and text (reported: eye
strain on a 32" 1440p panel with no OS scaling).

Mechanism: a host-owned scale capability (window.feedBack.scale) applies a
RELATIVE root font-size (a % of the user-agent base, never a px literal, so a
raised browser/OS base font is respected) and publishes an always-present
--fb-scale token. The rem-based v3 chrome scales together; the gameplay highway
canvas (device-pixel sized) is deliberately untouched, so playback resolution
and FPS are unchanged. Medium (100%) clears the override, so default rendering
is byte-identical to before -- zero blast radius.

- Settings -> new Accessibility tab: Small/Medium/Large/Extra-Large presets
  (0.90/1.00/1.15/1.30) + a fine-tune slider (to 150%).
- Applied pre-paint from an inline <head> script (mirrors the ss-follower
  pattern) so there is no flash-of-reflow on load.
- window.feedBack.scale read-API (get/set + scale:changed, fires once on load)
  so canvas/WebGL surfaces that cannot inherit rem can follow the size. Shape
  mirrors the working-tuning read-API; persists as a durable preference.
- Cosmetic px->rem sweep so text scales cleanly at the larger stops (2 v3.css
  font-sizes + 20 text-[Npx] utilities across 8 v3 files; tailwind.min.css
  rebuilt byte-stable via the pinned toolchain).
- One-time first-run nudge for the large/low-DPI display profile that deep-links
  to the control (never fires once the setting is touched, or on other displays).

v3-only. Verified headless: core apply/persist/reset/reload/UI-sync + nudge
gating, with no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): regenerate tailwind.min.css to satisfy tailwind-fresh CI (PR #664 review)

Regenerate static/tailwind.min.css via scripts/build-tailwind.sh
(tailwindcss@3.4.19) so a fresh build matches the committed artifact and
the tailwind-fresh CI job's `git diff --quiet` passes. Two consecutive
regenerations are byte-identical.

Also (Fix 2) switch the fine-tune interface-size slider to the documented
transient-preview path: oninput now calls scale.set(v, { persist:false })
so dragging previews without writing localStorage/emitting a commit each
tick, and a new onchange commits with persistence on release.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 14:00:30 +02:00
ChrisBeWithYou
15fabb62aa
Fix v3 Songs A–Z rail: reliable taps, precise drag, hittable size (#653)
* Fix v3 Songs A–Z rail: reliable taps, precise drag, hittable size

Follow-up to #634. Three rail bugs reported on macOS + Windows (0.3.0,
2026-06-29 — =Scr4tch=, MajorMokoto):

- Taps often did nothing ("clicked O, nothing happened"). pointerdown
  calls setPointerCapture, after which the browser retargets the
  follow-up click to the rail container, so the click handler's
  closest('.v3-azrail-letter') resolved null and a plain tap (no
  pointermove) had no other path. Drive the jump from pointerdown
  itself; reduce the click handler to keyboard activation only
  (e.detail === 0, Enter/Space).

- A drag landed short of the release ("where you release isn't where
  you get sent"). Every letter crossed fired jumpToLetter with
  behavior:'smooth'; stacked smooth-scroll animations over the
  virtualized grid lagged and settled imprecisely. jumpToLetter now
  takes a smooth flag and scrolls instantly ('auto') while scrubbing,
  animating only discrete taps/keyboard jumps, so the grid tracks the
  finger and the release lands on the let-go letter.

- The rail was too small at 1440p and didn't scale. Letters were a
  fixed .62rem glued at right:2px (~13px-tall target). They now scale
  with the viewport (clamp(.72rem, 1.4vh, 1.05rem)), sit off the edge
  with taller/wider equal-width hit targets and a hover/active
  highlight so the scrub target is visible.

Keyboard arrow-nav and present-letter gating are unchanged. Tests:
tests/js/v3_az_rail.test.js gains pointerdown-seek, keyboard-only click
guard, and instant-vs-smooth assertions (809 JS tests; the 13
pre-existing unrelated failures are unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): ignore non-primary buttons on A–Z rail pointerdown (PR #653 review)

Right- or middle-clicking the A–Z rail (or a secondary multi-touch
pointer) no longer triggers a seek; only the primary tap/drag scrubs.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:59:10 +02:00
ChrisBeWithYou
0d28886d46
Fix v3 Songs List View favorite heart staying dim until re-search (#654)
Favoriting from the tree / "List View" flipped the glyph ♡→♥ but the
heart stayed grey until a re-search — reported macOS+Windows, open since
0.3.0 / 2026-06-25.

One shared wireCards() [data-fav] handler serves both the grid card and
the List-View row, but they render with different idle colours (grid
text-white, List View text-fb-textDim) and the handler only ever removed
the grid's text-white. So in List View text-fb-textDim lingered next to
the freshly-added text-fb-accent and won by CSS source order — the glyph
changed but the colour didn't, until a re-search re-rendered the row.

Each heart now declares its idle colour via a data-fav-idle attribute;
the handler swaps exactly that class (so only one colour class is ever
present) and writes the new state back onto the in-memory song model so a
re-render / virtualized-grid recycle agrees instead of reverting.

Tests: tests/js/v3_favorites_toggle.test.js. Full JS suite 810 tests; the
13 pre-existing unrelated failures are unchanged.


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:58:21 +02:00
ChrisBeWithYou
fee85a14e7
v3 library: native right-click context menu on cards (#688)
* feat(v3): native right-click context menu on library cards

Right-clicking a song card now opens the same overflow (more) menu at the
pointer, so the actions (Play, Add to playlist, Save for later, and any plugin
card actions) are reachable without aiming for the small button. openCardMenu
gains an optional pointer position (fixed + viewport-clamped when opened via
right-click, the existing card-anchored absolute otherwise). Adds a "Save for
later" row to the menu too, closing the gap where Save was only the inline
button. Shares one menu definition + the libraryCardActions registry, so plugin
actions appear in both paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): valid JS emoji escape in card-menu toast + dismiss orphaned menu on re-render (PR #688 review)

Replace the Python-style `\U0001f3b5` escape (invalid in JS, rendered
literal text) with the 🎵 emoji character in the playlists-updated toast.
Also tear down any open card context menu at the start of render(), the
full filter/scan-refresh path: the right-click menu is appended to
document.body, so replacing root.innerHTML would otherwise leave it
floating orphaned until the next document click. Reuses _closeCardMenu.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:58:13 +02:00
ChrisBeWithYou
80caf78306
v3 library: genre filter facet (reads feedpak genres) (#690)
* feat(v3): genre filter facet (reads the feedpak genres field)

Adds a Genre facet to the library Filters drawer, populated from each song's
primary genre. Server: a genre column (idempotent ALTER, indexed) written from
the sloppak manifest's genres list on scan (primary = genres[0]); a genre band in
_build_where (OR within the selected set); GET /api/library/genres for the facet's
distinct list. Client: a multi-select Genre section mirroring the tuning/mastery
facets. Follows the merged spec 1.12.0 genres field (#40).

v1 stores only the PRIMARY genre (genres[0]); secondary genres aren't filterable
yet. Threaded like the mastery filter (separate query_page kwarg, so query_artists
/query_stats are unaffected) -- genre filters the grid view. Needs a rescan to
backfill genre on existing packs (only packs whose manifest carries genres).

Verified live: a sloppak tagged genres:[Metal, Rock] -> /api/library/genres
returns [Metal]; ?genre=Metal returns it; ?genre=Rock (secondary) returns none; a
plain song stays ungenred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): scope genre facet to local provider (PR #690 review)

The /api/library/genres facet always read the local meta DB, so a remote
provider showed local genres while the `genre` filter was a no-op on that
provider's grid. Make the endpoint provider-aware: return an empty facet
for remote providers (kind != "local") and keep serving genres for the
local library and its smart collections, which share the local DB. The
v3 client now passes the active provider, mirroring the tuning-names facet.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:58:05 +02:00
ChrisBeWithYou
d20b33348b
feat(v3): host theme read surface — window.feedBack.theme + always-present --fbv-* tokens (#646)
First slice of the host theme contract (#644): give plugins a host-owned way to
read the active theme + its device affordances, so a feature renders correctly
under any theme instead of binding to whichever one the dev saw.

theme-core.js previously only APPLIED themes and emitted --fbv-* vars only while
a theme was equipped (nothing to read in the default state), with no read API.
Now, all additive + feature-detected:

- Always-present default `fb` palette as `--fbv-*` on :root (the un-themed look
  is unchanged — fb-* utilities still use their compiled defaults; this only
  hands plugins a stable host token to read + derive surfaces from). Adds two
  keystone ROLES the palette lacked: `on-accent` (legible fg on the accent fill)
  and `focus-ring`.
- window.feedBack.theme.get() -> {id, isThemed, tokens}; .capabilities() ->
  {glow, gradients, motion} (the device-affordance signal; recolor-only themes
  report defaults, a theme may opt out via `capabilities` in its payload, motion
  is reduced-motion-gated); .prefersReducedMotion().
- Normalized `theme:changed` event from the single apply() chokepoint.

The apply side stays on window.v3Theme; the read surface is attached defensively
so it survives the feedBack bus being (re)built by capabilities.js regardless of
load order. Verified via a headless render (apply/unequip intact, defaults
present + restored, capability opt-out honored, event payload correct) +
tests/js/v3_theme_read_api.test.js. See docs/host-theme-contract.md.


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:57:58 +02:00
ChrisBeWithYou
7ff9261000
feat(v3): show "N added / M removed" after a library scan (#686)
Completes the Refresh feature: the scanner now reports a delta so the toast can
say what changed instead of a generic confirmation.

Server: delete_missing returns both deltas from its one query -- rows pruned
(removed) and current files not yet in the DB (added) -- and the scan retains
added/removed on the terminal scan-status (previously wiped to 0). Client: the
completion toast shows "N songs added / M removed" (or "up to date"), and a scan
we merely attached to (background / Settings) toasts only when it actually
changed something, so a periodic no-op pass stays silent. library:changed now
carries the delta too.

Verified end to end: empty rescan -> added 0; add a song -> added 1; remove it
-> removed 1.


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:57:50 +02:00
ChrisBeWithYou
11c0f0483f
feat(server): query_albums endpoint for album-condensed browse (#689)
Adds GET /api/library/albums + MetadataDB.query_albums (plus the local and
smart-collection provider delegations): distinct (artist, album) groups with a
track count and a representative cover song, paged by album, honoring the same
filters as /api/library (including the new mastery bands). Rows with no album
name are excluded. Album detail needs no new endpoint -- it reuses the existing
/api/library?artist=&album=.

Backend foundation for the album-condense "Albums" view + play-album; the client
view consumes it next.

Verified: 4 tracks sharing one album -> /api/library/albums returns one card
(artist, album, count=4, cover); /api/library?album=... lists its 4 tracks.


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:56:57 +02:00
ChrisBeWithYou
74cd08f765
library: MusicBrainz text matching + Match-Review UI (P8) (#710)
* library: MusicBrainz text matching + Match-Review UI — P8

Replaces the enrichment plumbing's no-op matcher (P7) with the real
pipeline, per the library-metadata design: a wrong match is worse than a
slow one, so medium confidence goes to a human review queue and never
straight to canonical values.

- lib/mb_match.py (new, pure — no network/DB/server imports): denoise
  (author credits, (440Hz)/(Live)/(No Lead)/(v2) parentheticals,
  diacritics/punctuation, ACDC / AC DC / AC/DC folding via compacted
  token equality), token-set similarity, scoring with year/duration
  corroboration bonuses, tier classification (auto needs combined
  >= 0.95 AND per-field floors — a perfect-title cover by the wrong
  artist, or a chart with no artist, can never auto-match), Lucene
  query building, MusicBrainz response normalization.
- Matcher precedence in _enrich_one: content-hash cache copy (another
  chart of the same recording matches with no network) -> manifest
  mbid (tier 0) / isrc (tier 1) exact keys, feature-detected and
  strictly shape-validated, read-only -> text search tiers
  (auto / review / failed).
- Lifecycle: review rows store their ranked candidate list (JSON) and
  write NO canonical fields until a human accepts; failed rows retry on
  an exponential backoff (1 h doubling, 7 d cap) via the attempts
  column; user-rejected rows never auto-retry; an identity edit
  re-queues anything and resets the backoff; never-overwrite-manual is
  enforced inside the single writer (apply_enrichment_match) so no call
  path can forget it.
- Network: _mb_http_get is the one transport seam — throttled to
  <= 1 req/s through P7's _enrich_throttle, identified with a real
  User-Agent from VERSION, and a 503 pauses the whole pass without
  burning attempts. Offline guard: no sockets under
  FEEDBACK_ENRICH_OFFLINE or FEEDBACK_SKIP_STARTUP_TASKS, so pytest can
  never reach MusicBrainz; the pass still stamps identity hashes
  (two-phase), which is why every P7 test passes unchanged.
- Routes: GET /api/enrichment/review, POST
  /api/enrichment/review/{filename}/accept|reject|pick, GET
  /api/enrichment/search (throttled manual-search proxy). All four are
  demo-mode blocked.
- Match facet: match= CSV accepted by /api/library AND
  /api/library/stats (the A-Z rail's letter counts stay lockstep with
  the grid) — review / matched (incl. manual) / unmatched / pending,
  the same EXISTS idiom as the mastery facet.
- UI: static/v3/match-review.js (new, self-contained) — an ambient
  "N to review" chip beside the song count (rendered only when
  non-zero; silent on success, no toasts), and a review drawer on the
  filter-drawer slide idiom (Escape + focus trap; row click accepts,
  "Not a match" rejects, "Search instead" is the fix-match escape
  hatch). songs.js gets the chip mount, a Match filter section, and
  session-only match state; also fixes the latent applySavedPrefs bug
  where restored filters dropped the mastery key, which made the
  filter drawer throw for anyone with saved prefs.
- static/tailwind.min.css regenerated (scripts/build-tailwind.sh) for
  the new utility classes; conflicts with sibling PRs resolve by
  re-running the script.

Nothing is ever written to pack files — canonical values live only in
the song_enrichment display cache. Cover art caching and acoustic
fingerprinting are follow-up slices.

22 pure unit tests + 19 server tests (fake transport injected over the
_mb_http_get seam) + demo-mode route cases; full-suite failure set
A/B-identical with the change stashed vs applied.

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

* library: match-review modal + configurable auto-apply confidence (P8 R0)

Follow-up to the initial P8 commit, folding in the first round of tester
feedback on the review surface and the matcher's knobs:

- Review GUI is a centred MODAL now, not a sidebar — one chart at a
  time (the scraper-review model from media-server / emulation-frontend
  apps): the chart's current metadata with explicit amber
  "Missing: album / year / cover art" chips (art detected via the art
  request failing), candidates each carrying "Adds: year - genres -
  ISRC" / "Shows as: ACDC -> AC/DC" per-field chips, and Skip /
  Not a match / Search instead / Use selected with prev-next + arrow-key
  navigation. Chip + window API surface unchanged, so songs.js needed no
  edits for the rework.
- Auto-apply confidence is a SETTING: default drops 0.95 -> 0.90
  (mb_match.AUTO_MIN; classify() takes an auto_min override). The
  per-field floors are untouched and threshold-independent — a
  perfect-title cover by the wrong artist still can't auto-match at any
  setting. New validated settings keys: enrich_enabled (bool) +
  enrich_auto_threshold (0.5–1.01; >1.0 = "Always review", since a
  capped score can equal exactly 1.0). Read once per pass; disabling
  gates only the BACKGROUND matcher — manual search/fix stays available.
- Settings -> Library -> "Metadata matching" card: enable toggle,
  confidence select (85 / 90 / 95 / Always review), a Match Now button
  (new POST /api/enrichment/kick, single-flight like every other kick,
  demo-mode blocked), and a live status line fed by the same fetch as
  the review chip. Markup in index.html per the v3 settings pattern,
  wired by match-review.js, null-guarded so v2 no-ops.
- Review queue orders missing-data charts first — confirming those has
  the most to gain; complete charts only stand to be re-labelled.

Tests: threshold moves the auto/review boundary via settings; the
enable toggle gates matching but not the manual proxy; settings
validation; kick route; queue ordering; classify(auto_min=...) floors.
Full-suite failure set byte-identical to the pre-change baseline.
tailwind.min.css regenerated for the modal's utility classes.

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

* fix(library): lock MusicBrainz throttle across sleep + de-dup enrich queue (PR #710 review)

Hold a module-level lock across _enrich_throttle's read/sleep/write so the
background daemon and threadpooled sync search route serialize outbound MB
requests instead of bursting past the 1 req/s limit. De-dup the enrich queue
by filename so a changed-hash failed row isn't processed twice per pass.

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>
2026-07-02 13:47:55 +02:00
ChrisBeWithYou
7c15cdda66
library: metadata-enrichment plumbing (cache table + worker lifecycle) — P7 (#707)
* library: metadata-enrichment plumbing (cache table + worker lifecycle) — P7

The pipeline around a deliberately NO-OP matcher, so the real
MusicBrainz text matcher (next slice) replaces exactly one function and
inherits the queue, throttle, lifecycle, and safety contracts:

- song_enrichment cache table: one row per song holding the match
  lifecycle (unscanned -> matched(source,score) | manual | failed) plus
  the canonical values a confident match supplies. A DISPLAY cache -
  canonical values are never auto-written into pack files. Never purged
  on rescan (only by the explicit per-song delete); dead rows filtered
  at read time; re-derivable, so a lost row just re-enriches.
- Identity hashing: sha1 of normalized artist|title|album|duration.
  Filename-free, so a renamed pack keeps its enrichment; unchanged hash
  makes re-enrichment a no-op (idempotent).
- Queue rules (test-pinned): no row / unscanned / identity-changed ->
  re-match; matched + current hash = settled; a MANUAL row is the
  user's pinned pick and is never auto-reset (state and hash both
  survive metadata edits); failed waits for the matcher's backoff
  policy (attempts column ready).
- Worker: _kick_enrich/_enrich_runner mirror the scan's single-flight +
  coalescing pattern, kicked when a scan pass fully completes (the scan
  pool is a no-network process pool by design; the 5-minute periodic
  rescan is the natural retry hook). One bounded pass per kick - no
  drain-loop, since the no-op matcher legitimately leaves rows
  unscanned. _enrich_throttle() is the <=1 req/s seam every matcher
  must call before a network request, and the never-hold-meta_db._lock-
  across-a-fetch rule is documented at the seam.
- CONFIG_DIR/art_cache dir helper (the cover-art slice adds the LRU
  cap) + GET /api/enrichment/status (worker flags + counts by state).

8 new tests; full-suite failure set identical to unmodified main.

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

* fix(library): lock enrichment reads on shared conn + skip redundant stub writes (PR #707 review)

Issue 1: wrap the SELECT+fetch in enrichment_pending, get_enrichment and
enrichment_state_counts in self._lock so request-thread reads no longer
interleave with the worker's execute+commit on the shared connection.

Issue 2: guard upsert_enrichment_stub so an already-settled row (manual
pick, or a non-manual row whose content_hash already matches) skips the
UPDATE/commit — stops the no-op matcher re-writing every song each pass.

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>
2026-07-02 13:39:10 +02:00
ChrisBeWithYou
f6d8e241eb
v3 library: curated album (kind='album' + per-slot chart/arrangement pins + play-album) — P6 (#706)
* v3 library: curated album — your version of an album, one chart per slot — P6

A curated album is a hand-picked, ORDERED practice set of works with a
chosen chart per track (metadata-design 7.2) - the repeatable gameplay
loop. No new tables: a playlists row with kind='album' plus two per-slot
columns.

- Schema (additive, idempotent): playlists.kind ('album' | NULL=mix),
  playlist_songs.arrangement (the pinned arrangement NAME - names
  survive rescans; the index is resolved at play), playlist_songs.
  work_key (stamped at ADD time = "resolved to preferred once at add,
  pinned thereafter").
- Orphan-at-read self-heal: an album keeps every slot. A slot whose
  pinned chart was deleted resolves to the work's CURRENT keeper at
  read (marked "(auto)"; membership is never rewritten - if the file
  returns, the slot resolves back to itself), and reports missing when
  the whole work is gone so the set's denominator stays honest. Mixes
  keep hiding dead songs byte-identically.
- Slot editor: PATCH /api/playlists/{pid}/songs/{fn} pins/clears the
  arrangement and/or swaps the slot's chart - validated to the SAME
  work via the stored stamp, position + pin kept, duplicate members
  rejected. The per-slot pick is independent of the work's global
  preferred: a rehearsed set stays the same notes even if the global
  keeper is re-picked later.
- UI: "New album" on the Playlists screen (album chip + disc cover);
  the album detail adds a set-scoped "Album repertoire" meter (N of M
  mastered - per-track mastery, never one album score), per-track
  accuracy, and a per-row slot editor listing only the work's charts.
  "Play album" runs the play-queue front-to-back honoring pins (the
  queue already supported per-index arrangements); per-row play uses
  the resolved chart + pinned arrangement.

12 new tests; playlists/collections regressions green.

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

* fix(v3): count all album slots (list vs detail parity) + surface slot-edit PATCH failures (PR #706 review)

_playlist_count applied the mix "dead-filter" to every playlist, so an album's
list-card count dropped orphaned/missing slots that its detail view still
renders and plays (5-track album, 2 pins deleted → card "3" vs detail 5). Count
ALL slots for kind='album' (mirroring get_playlist's is_album discriminator);
mixes/other kinds keep the dead-filter. openSlotPicker's Apply now checks the
jsend return and, on a rejected PATCH (swap-to-other-work / duplicate pin),
shows an inline error and keeps the picker open instead of closing as success.
Adds album count-parity + mix dead-filter regression tests.

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>
2026-07-02 13:38:06 +02:00
ChrisBeWithYou
e9d95ad190
v3 library: multi-chart work grouping, complete (engine + API + card + drawer + toggle/split/filter-law) — P5a–P5e (#702)
* v3 library: multi-chart work grouping engine + work-charts API — P5a/P5b

Charts of the same song (same normalized artist+title) now GROUP under a
computed work_key, with a materialized representative filter so the grid
can collapse them without breaking keyset paging:

- work_key = normalize(artist+title) (diacritics/punct/case folded,
  leading "The" folded on artist); resolves the effective artist via the
  artist_alias table when present (feature-detected, no hard dep).
- Sparse, never-purged-on-rescan tables: chart_group_pref(work_key,
  preferred_filename) + chart_group_split(filename, split_key); purged
  only by the explicit per-song delete.
- Materialized work_display(filename, work_key, effective_work_key,
  is_group_representative, group_size) read-model: lazy rebuild via a
  dirty flag set on put/delete; set_chart_preferred does an incremental
  re-flip (no full rebuild). Auto-pick representative = most
  arrangements -> most plays -> newest -> filename; a user pref wins and
  degrades to auto-pick if its file disappears.
- group=1 on query_page/query_stats = one extra representative
  predicate applied identically to page + total + sort_letters, so the
  keyset cursor (sort_value, filename) stays a valid total order and
  counts works, not charts. Grouped rows carry chart_count + work_key.
- Charts API: GET /api/work/{work_key}/charts (members + which is the
  keeper, your pick vs auto), PUT/DELETE .../preferred, and
  POST /api/chart/{filename}/split + /unsplit (under /api/chart so the
  DELETE /api/song catch-all can't shadow them).

Tests: 15 grouping-engine + 7 charts-API tests, including grouped
keyset pagination (no skip/dupe across pages).

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

* v3 library: grouped grid card + persistent "N charts" chip — P5c

Flip the v3 grid to the grouped library (group=1 on /api/library and the
rail's /api/library/stats fetch): one card per song, showing the
representative (preferred/auto-pick) chart. group rides page, total and
sort_letters identically so the A-Z rail's cumulative-seek math and the
virtualized sizer stay consistent, counting works not charts; the keyset
cursor chains with group on every page.

- New groupingActive() helper, default ON per the design; the persisted
  per-view toggle (P5e) lands there. Only the local provider implements
  group=; smart collections and remote providers ignore it and stay
  flat, so it is safe to send unconditionally.
- chartsChipHtml(): a "flag N charts" chip rendered ONLY when
  chart_count >= 2 - single-chart cards emit byte-identical markup.
  First in the fixed-height chip row + shrink-0 so it never clips and
  card height is unchanged.
- Chip click = feature-detected window.__fbOpenChartsDrawer (the Charts
  drawer arrives in P5d); until then a no-op. Plain-click / play / the
  arrangement chips are untouched and play the representative.
- The library-home repertoire meter's stats fetch deliberately stays
  ungrouped: its mastered numerator counts chart filenames, so a works
  denominator could exceed 100% - reconciling that is P5e's
  mastery-anchor work. The tree view stays flat (query_artists has no
  grouping; its opener is wired in P5d).

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

* v3 library: Charts drawer + openers — P5d

The single deep-management surface for a work's charts (design UX-2/3):
a body-appended slide-in drawer (filter-drawer idiom) listing every
chart of the work as a radiogroup — the checked row is the keeper the
grid card plays.

- Rows show format / tuning / arrangements / year / your accuracy (or
  "not played") plus the pack filename, usually the only human-readable
  distinguisher between duplicate charts. Keeper is labeled
  "Preferred (auto)" vs "Preferred - your pick".
- Row click (or Enter/Space) = one-tap Set-preferred; "Reset to auto
  pick" appears when the keeper is an explicit pick. Writes go through
  the work-charts API and the drawer re-renders from the response; the
  grid re-fetches in place since the representative may have flipped.
- Per-row Play (plays that exact chart) and Add-to-playlist (the picker
  is z-[200], layering over the z-50 drawer).
- a11y: Tab focus-trap, Escape closes, ArrowUp/Down move focus between
  rows (focus only - arrow-select would fire a preferred write per
  keystroke), focus restored to the opener on close.
- Openers: the "N charts" chip opens the drawer directly; the card's
  overflow menu gains "Charts (N)..." and "Play version >" (expands
  inline; picking one plays it as a one-off - the keeper/headline does
  not move). Tree rows ride the ungrouped artists endpoint, so the menu
  resolves their work lazily via the new GET /api/chart/{fn}/work
  ({work_key, chart_count}) and slots a "Charts (N)..." entry in when
  versions exist. A window.__fbOpenChartsDrawer global lets other views
  open the drawer. Right-click is deferred: the open native card
  context-menu PR should host that entry once both merge.
- tailwind.min.css rebuilt: carries the new utility classes from this
  and the previous commit (the grouped-card chip tint was missing).

Split keys contain '#', so clients MUST URL-encode work_key in paths
(the v3 client does; a test documents the round-trip). 4 new endpoint
tests; 26/26 grouping+charts tests green.

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

* v3 library: group toggle, split UI, the filter law + mastery-anchor rules — P5e

Completes the multi-chart grouping slice (design 7.1):

- Filter law under group=1: work-identity (artist/album/search) and
  practice-state (favorites/mastery/tags/difficulty) predicates stay on
  the representative row, while CHART-INTRINSIC predicates (format/
  arrangements/stems/lyrics/tuning) now match if ANY member of the work
  does - a song you own in Drop D is no longer hidden because your
  preferred chart is E Standard. Intrinsic clauses moved to an
  alias-aware builder and re-applied as a member EXISTS; identical in
  query_page and query_stats so counts and the A-Z rail stay in
  lockstep. A pure predicate - keyset paging is untouched (tested).
- Display-chart switch: when the representative itself doesn't match,
  the row carries a display_chart override (the matching member). The
  row stays the representative's - swapping rows wholesale would break
  the (sort_value, filename) cursor - and the card renders/plays the
  member while the accuracy badge and heart stay anchored on the
  preferred chart.
- Mastery sort aggregates MAX across the group ("a song surfaces on any
  chart you've touched"); OFFSET-paged, so cursor-safe. The
  Recently-Added aggregate is deliberately deferred: mtime IS a keyset
  sort, so its aggregate would need materializing into work_display.
- History-sticky auto-pick: most-played -> most-complete -> newest.
  A newer/"more complete" import can't silently take the pick from the
  chart your reps accrued on, and a one-off try of an alternate can't
  out-rank a practiced incumbent; all-unplayed groups still pick by
  completeness.
- Persisted "One card per song" toggle in the filter drawer (default
  ON; OFF = one card per chart). A view mode: never counted in the
  filter badge, never saved into collection rules, local provider only.
- Split escape hatch: "Split out" per drawer row gives a chart its own
  card; the split card's overflow menu offers "Rejoin other versions"
  (rows and the chart-work lookup now carry is_split).
- Mastery-anchor heads-up: after set-preferred the drawer shows a
  one-line ambient note that practice history stays with each chart
  (no toast - hearing-safe).

10 new filter-law tests; 38/38 grouping tests green. tailwind.min.css
rebuilt for the new utility classes.

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

* fix(v3): work-grouping — escape Charts-drawer meta (XSS), keep non-Latin titles distinct, guard mid-rebuild reads (PR #702 review)

- XSS: esc() the composed `meta` string in _chartRowHtml (arrangement/tuning
  names come from untrusted feedpak metadata) before innerHTML; acc stays HTML.
- Non-Latin titles: _norm_token falls back to raw lowercased whitespace-collapsed
  text when the NFKD+strip fold yields "" (CJK/Cyrillic/Greek/Arabic), so distinct
  non-Latin titles keep distinct _work_key values instead of collapsing into one
  bogus work. Latin names still hit the folded branch — behavior unchanged.
- Mid-rebuild reads: wrap the grouped representative SELECT in query_page and
  query_stats under self._lock (nullcontext when ungrouped, so lazy reads stay
  lock-free) so a reader can't observe work_display between rebuild_work_display's
  DELETE and INSERT/commit. _ensure_work_display stays OUTSIDE the lock — it
  self-locks the rebuild and self._lock is non-reentrant — so only the SELECT is
  guarded (rebuild fully completes before the guarded SELECT runs).

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>
2026-07-02 13:37:15 +02:00
ChrisBeWithYou
a47accd894
v3 library: artist aliases + Tidy-up merge UI — P4 (#705)
* v3 library: artist aliases + Tidy-up merge UI — P4

Fixes the "ACDC vs AC/DC" split without touching a single file or row:
a never-purged artist_alias table (raw_name -> canonical_name) applied
at DISPLAY time. The scanner keeps writing whatever the pack says; one
alias row fixes every matching song.

- query_artists dedupes/groups/orders on the effective artist, with a
  zero-cost fast path when no aliases exist; the artist filter expands
  a canonical name to its raw variants (index-friendly, keyset-safe);
  query_page re-labels row artists through the alias map.
- CRUD + merge API: list aliases, list raw artists (variants + counts
  for the picker), set/merge/remove; a self-alias clears (= un-merge).
- "Tidy up artists..." in the filter drawer (local library only): a
  searchable raw-variant checklist, merge-into-canonical, and a
  current-merges list with per-row un-merge. The artist dropdown + tree
  pick up canonical names with no dropdown code changes.
- Sort + A-Z rail stay on the RAW artist (keyset-safe): a cross-letter
  alias shows its canonical label but buckets under the raw letter
  until effective columns are materialized (the grouping engine's
  work_key already resolves aliases when this table exists, so merged
  artists group correctly there).

11 tests. tailwind.min.css regenerated (generated file - on a merge
conflict, re-run scripts/build-tailwind.sh).

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

* fix(v3): flatten transitive artist-alias chains + cycle guard so sequential merges unify (PR #705 review)

merge_artists looped set_artist_alias which stored one hop, so sequential
merges (ACDC->AC/DC then AC/DC->AC-DC) left a two-hop chain that the
single-hop effective_artist/grouping/filtering split into two groups. Add
_single_hop_canonical + _terminal_canonical (visited-set cycle break),
resolve the canonical to its terminal before storing, forward-flatten
existing rows that pointed at the raw name, and reject cycles (409). Batch
merge now runs under one lock + one commit for atomicity.

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>
2026-07-02 13:32:51 +02:00
ChrisBeWithYou
77e5a4982b
v3 library: growth-edge "practice next" recommender — P3 (#704)
* v3 library: growth-edge "practice next" recommender — P3

The "Keep practicing" shelf stops being recency-only: a new GET
/api/library/practice-suggestions ranks started-but-unmastered songs by
difficulty-appropriateness x mastery-proximity (the growth edge - the
mid-difficulty, closest-to-mastery material where practice pays off
fastest), and the shelf sources it instead of filtering /api/stats/recent.

- Score = difficulty band fit (your 1-5 rating; unrated degrades to the
  middle band so the shelf works before any ratings exist) x proximity
  to the 0.9 mastery threshold. Read-only - never writes difficulty.
- A shelf click opens the closest-to-mastery arrangement.
- Per-arrangement difficulty and seed-from-authored intentionally NOT
  faked: there is no authored/derived difficulty on songs yet (the
  feedpak difficulty spec is unmerged) and the personal rating is
  per-song - both revisit when that field lands.

9 endpoint tests. tailwind.min.css regenerated (generated file - on a
merge conflict, re-run scripts/build-tailwind.sh).

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

* fix(v3): deterministic tiebreak (filename) in practice-next ordering (PR #704 review)

Add r["filename"] as the final sort component so suggestions with equal
growth_score and equal/None last_played_at order deterministically instead
of by SQLite's unordered agg.items() scan.

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>
2026-07-02 13:31:19 +02:00
ChrisBeWithYou
feaaa5cd81
v3 library: song Details drawer + bulk edit — P2 (#703)
* v3 library: song Details drawer + bulk edit — P2

Evolves the per-song editing surface from the legacy modal into a v3
slide-in Details drawer (the filter-drawer idiom, body-appended): catalog
fields (title/artist/album/year, written through the existing atomic
manifest writer) plus the P1 personal layer - your difficulty (1-5),
tags, and notes - with the heart staying the existing favorite system.

- Cards badge the personal layer at rest (difficulty pip + tag count,
  top-right, fading on hover so the action buttons keep that corner);
  un-annotated cards render byte-identical to before.
- Bulk edit from the select-mode batch bar: POST
  /api/songs/user-meta/batch applies additive tag add/remove and a
  leave/set/clear difficulty across the selection (mixed-state aware).
- The core card action relabels to "Details" and opens the drawer via a
  feature-detected global, falling back to the legacy modal when the
  drawer isn't mounted.

16 batch tests new; the P1 user-meta suite stays green.
tailwind.min.css rebuilt for the drawer's utility classes.

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

* fix(v3): surface bulk-edit / save-details request failures instead of reporting success (PR #703 review)

Check the batch/write responses and show an fbNotify error (keeping selection and drawer) instead of unconditionally closing as success.

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>
2026-07-02 13:29:05 +02:00
ChrisBeWithYou
58e7407c38
v3 library: personal per-song metadata (user-difficulty / notes / tags) — P1 (#691)
* v3 library: personal per-song metadata (user-difficulty / notes / tags) — P1

The local "your relationship to the song" layer: a per-song user-difficulty
(1–5, planning-only, distinct from the authored 1–10 difficulty bands),
free-form notes, and free-form practice tags. All kept OUT of the shared
feedpak file and OUT of the `songs` table, in two new never-clobbered tables
so a rescan's `INSERT OR REPLACE INTO songs` can't wipe them. Likes stay the
existing favorites heart — no rating column.

Backend only (the Details drawer + tag/difficulty filter UI come next).

Schema (additive, idempotent):
- song_user_meta(filename PK, user_difficulty INTEGER, notes TEXT, updated_at)
- song_tags(filename, tag, created_at, PK(filename, tag)) + idx on tag

API (DB-only — distinct from POST /api/song/{f}/meta, which writes catalog
fields back into the file):
- GET  /api/song/{f}/user-meta  → {user_difficulty, notes, tags}
- PUT  /api/song/{f}/user-meta  → partial update; user_difficulty (1–5 or
  null), notes (string or null), tags (full-replace array). Tag removal is a
  full-replace array rather than a DELETE sub-route because the greedy
  DELETE /api/song/{filename:path} already owns every DELETE under /api/song
  and would shadow it.
- GET  /api/tags → tags in use with counts (for the filter UI)

Read path:
- query_page rows embed user_difficulty + tags (like `favorite`); notes stay
  out of the list payload (per-song GET — they can be long).
- Read-time filters ?user_difficulty= and ?tags= threaded through _build_where
  exactly like the mastery filter — EXISTS-style predicates, so keyset paging,
  counts, and the A–Z rail are unaffected.
- delete_song purges both personal tables inside the existing lock.

Tags are normalized (trim + lowercase + collapse whitespace) so "Rock"/"rock"
don't split. New tests cover defaults, difficulty validation (rejects out-of-
range / non-integral / bool), notes trim, tag normalize/dedupe, grid embed,
both filters, never-clobber-on-rescan, and purge (21 tests). Neighboring
library tests (filters/keyset/providers/playlists/collections/stats) stay
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): cap per-song tags at 50 to bound writes (PR #691 review)

set_song_tags capped each tag at 60 chars but not the number of tags,
so one PUT could write unbounded rows. Cap the normalized-unique tag
list to the first 50 after dedup. Adds a test asserting >50 stores 50.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:25:18 +02:00
K. O. A.
22d299959f
fix: restore .feedpak in the upload file-picker accept filter (#698)
PR #530 set accept=".feedpak,.sloppak" on the shared upload input, but a
later index.html edit reverted the attribute to ".sloppak" only. The
client-side extension filter (app.js) and the server upload endpoint both
accept .feedpak, so the only effect was the OS file-picker hiding .feedpak
files from the dialog. Restore the dual filter.

Signed-off-by: Kris Anderson <topkoa@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:44:10 +02:00