The virtualized v3 Songs grid rebuilt its entire visible window
(grid.innerHTML = _renderCardsRange(...) + a full wireCards pass) every
time it slid by one row. Each row-boundary crossing was therefore a heavy
synchronous frame — reparse ~60 cards, re-attach hundreds of listeners,
reflow — that stalled the main thread and buffered held-arrow key-repeats,
flushing them in a burst. Testers saw the library "go super fast for a
second then slow down," skipping "every so many scrolls," up or down, at
the same spots each time. It hitched scrolling back up over already-loaded
songs too, because the cost was DOM teardown, not fetching.
renderWindow() now reconciles the window in place: it reuses the card
nodes that stay on-screen and builds only the row that enters/leaves
(~6 nodes per slide instead of ~60). Nodes are keyed by absolute index
(data-idx) with a real-vs-skeleton + select-mode signature (data-sig) so
hole-fills after a page fetch and select-mode toggles still rebuild
exactly the nodes that changed. wireCards()'s existing data-wired guard
then wires only the freshly-built nodes, so per-slide listener churn drops
with it. Everything keyed off data-fn (favorites, ⋮ menu, right-click,
selection, accuracy badges, A–Z rail) is unaffected.
Follow-up to the stage-2 virtualized grid (#636 item 3). Frontend-only.
Tests: tests/js/v3_songs_window_recycle.test.js — window stays [start,end)
contiguous and in-window node identity is reused across a down-then-up
scroll; select-mode toggle and a rail-seek jump rebuild correctly.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Adds a media-server-style "Refresh Metadata" control to the Songs toolbar
(beside "⟳ Refresh") — the metadata counterpart to a file scan.
- Re-matches the songs currently SHOWN (the visible grid window) against
MusicBrainz: a per-view refresh that's visible even on an already-matched
library. The button doubles as Stop while a pass runs; a batch progress bar
+ per-tile queued→working→done badges show what's happening. User-pinned
`manual` matches are never re-matched.
- Backend: POST /api/enrichment/{cancel,states,rematch}; /status gains
total/matched/current/cancelling; a cooperative cancel Event is checked
between songs in the match + art phases so Stop halts without waiting for
the whole queue. `states` is read-only (open); `cancel`/`rematch` are
demo-blocked.
- Matcher: when a pack's `artist` field is blank (common in community
charts), derive artist/title from the CDLC `Artist_Song-Title` filename
convention as a SEARCH SEED so text matching can identify it — the displayed
values still come only from the confirmed MusicBrainz match, nothing
estimated is shown as author-set. Rescues blank-artist packs that otherwise
always failed.
Tests: enrichment_states_for, the three new routes, cancel-halts-a-pass,
kick-clears-stale-cancel, filename parse, blank-artist seeding, and
present-artist-not-overridden.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(enrichment): AcoustID audio-fingerprint identification (opt-in)
Text search can only guess the version; the definitive fix is content-based —
fingerprint the actual audio with Chromaprint (fpcalc) and look it up on
AcoustID, which maps the fingerprint to the EXACT MusicBrainz recording (the
approach Lidarr uses). Sidesteps the studio-vs-live ambiguity entirely.
- lib/acoustid_match.py: pure response parsing + config gating (unit-tested);
normalizes AcoustID hits into the same candidate shape as mb_match so the
review UI + editor Match popup render fingerprint and text hits identically.
- server.py: _fpcalc (Chromaprint subprocess), _acoustid_lookup (throttled,
offline-guarded HTTP), _identify_by_fingerprint (also available to the
library-enrichment pipeline), and POST /api/enrichment/identify (upload the
master audio → candidates).
- Fully OPT-IN and graceful: absent the fpcalc binary or an ACOUSTID_API_KEY
the whole path is a no-op / 503 and the text matcher runs unchanged.
Requires (both optional): the `fpcalc` (Chromaprint) binary on PATH/$FPCALC,
and a free AcoustID application key in $ACOUSTID_API_KEY. Pure parsing/gating
is unit-tested; the fpcalc + live-lookup path needs those two to exercise.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(enrichment): make AcoustID self-serve — opt-in toggle + API key in settings
Fingerprinting was env-var only (ACOUSTID_API_KEY), so only an operator could
enable it. Add two core settings so a user can turn it on themselves:
- acoustid_enabled (bool, default OFF — opt-in)
- acoustid_api_key (string, ≤128 chars, trimmed; env var stays a fallback)
_acoustid_available()/_acoustid_lookup() now resolve (enabled, key) from
settings via _acoustid_settings(). /api/enrichment/identify distinguishes
"not set up" (412 needs_setup — the UI nudges the user to enable it) from
"set up but fpcalc/network missing" (503) so the client never fakes a match.
Verified: default off; POST round-trips + trims; 412 vs 503 gating; bad
types/over-length rejected. acoustid_match unit tests green (8/8).
* fix(enrichment): POST the AcoustID lookup instead of GET
A Chromaprint fingerprint is multi-KB (a 3.5-min track ≈ 3.5k chars), so
sending it as a GET query param overflows the request URL for longer songs and
fails spuriously. AcoustID accepts the same params form-encoded — POST them.
* fix(acoustid): space-separate the lookup `meta` (was silently dropping metadata)
The meta value was `+`-joined ("recordings+releasegroups+compress"). Sent over
the wire the literal `+` percent-encodes to %2B, which AcoustID does NOT split
into flags — so every hit came back with an empty `recordings` array and the
parser produced zero candidates (a fingerprint match that resolved to nothing).
AcoustID wants the flags space-separated. Verified against real fingerprints:
`+`-joined → 0 recordings; space-joined → 28, resolving Highway to Hell and
Living After Midnight to their canonical studio albums as the top hit.
* feat(acoustid): resolve the canonical original album + year from the fingerprint
AcoustID hits resolved the right recording but a weak album/blank year: the
album picker took the first studio-typed group (a later comp/soundtrack typed
"Album" could win) and the year took an arbitrary release (often a reissue).
Request the `releases` meta (which carries per-release dates) and use them to
(1) pick the EARLIEST original studio album among the groups and (2) fill the
year from that album's earliest release. Verified against real fingerprints:
Smoke on the Water → Machine Head (1972) not a later comp; Highway to Hell →
1979; Living After Midnight → British Steel (1980). +2 unit tests.
* feat(acoustid): per-song "Identify by audio" for the library metadata tooling
Add POST /api/enrichment/identify/{filename} — fingerprints an EXISTING library
song's own master audio (resolves the sloppak's original_audio or a loose
folder's audio), the library counterpart to the upload-based /identify used by
the editor. Wire an "Identify by audio" action into the match-review / Fix-match
modal: it renders fingerprint hits in the same candidate list and pins the pick
via the existing /review/{f}/pick. Shared _acoustid_gate() (412 needs_setup /
503) for both endpoints; 404 when a pack has no full mix. Both identify routes
added to the demo-mode block list (they spend fpcalc + the AcoustID budget) —
fixes a pre-existing miss on the upload route.
* fix(acoustid): regenerate stale tailwind CSS + cap identify upload
- static/tailwind.min.css was stale vs a fresh rebuild (ci/tailwind-fresh red);
regenerated with the pinned tailwindcss@3.4.19 (byte-stable).
- /api/enrichment/identify read the whole multipart upload into memory before
writing it; stream it to the temp file with a 256 MB cap (413 over) so an
oversized upload can't balloon RAM. fpcalc reads from the temp file anyway.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(acoustid): pre-parse upload guard + settings UI to enable it
- /api/enrichment/identify is now async: a pre-parse Content-Length check +
request.form(max_part_size=…) reject an oversized body BEFORE Starlette spools
the multipart to temp disk (mirrors the song-upload endpoint), and the blocking
fpcalc subprocess + AcoustID HTTP run off the event loop via run_in_executor.
- The v3 Metadata-matching settings card gains an 'Identify by audio' opt-in
toggle (acoustid_enabled, default OFF) + an AcoustID key input
(acoustid_api_key), wired in match-review.js — so the advertised feature is
reachable from the UI instead of only via a manual settings POST. Reuses
existing classes only; committed tailwind.min.css stays fresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(enrichment): rank the canonical studio take over live/comp versions
A flat MusicBrainz /recording text search ties every take of a song at the
same score, so "AC/DC — Highway to Hell" returns a wall of live bootlegs and
compilations with the 1979 studio version buried (or below the fetch limit).
- build_recording_query: drop live-ONLY recordings (`-secondarytype:Live`).
Compilations are deliberately kept — they REUSE the studio recording, so
filtering them cuts the very recording we want (verified against MB).
- _best_release / parse_recording_doc: pick the canonical studio album
(primary Album, no Live/Compilation/Remix/... secondary type) for the
displayed album/year, and expose a `studio` flag.
- rank_candidates: since the combined score caps at 1.0 (perfect text match
ties), break ties on the studio flag and — when the caller knows the audio
length — on duration proximity, so the studio take wins over live/extended
cuts. The studio distinction is intentionally NOT scored (a live take is
still the right SONG), only re-ordered.
- /api/enrichment/search: accept an optional `duration` param so a caller that
has the audio but no library row (the editor's create modal) can pass the
master-track length for the duration tiebreak.
Verified end-to-end against live MusicBrainz: AC/DC "Highway to Hell" now
returns the 1979 studio recording at #1 with the correct album + year.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(enrichment): official releases outrank unofficial studio albums
_best_release sorted (clean, status_ok, date), so an UNofficial bootleg Album
outranked an official Single/EP/comp — regressing canonical album/year and
seeding cover-art from a bootleg for single-only songs. Order status_ok before
clean: official first, then prefer a clean studio album among the official
releases (still surfaces the studio album over an official live/comp album).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(enrichment): keep live recordings for genuinely-live charts
build_recording_query unconditionally added -secondarytype:Live, but denoise()
strips a '(Live at …)' qualifier from the query — so a chart that IS a live take
had its only correct recording filtered out (both background enrichment and
manual search). Skip the live filter when the source title carries a
parenthetical live marker; a bare title word ('Live and Let Die') still filters.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(enrichment): drop the studio tiebreak when the chart is a live take
Follow-through on keeping live recordings for live charts: rank_candidates still
ranked the studio take ahead of a tied live one, so a live chart would auto-match
the studio recording. Skip the studio tiebreak when the source title has a live
marker — duration proximity + score then pick the right live version.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* settings: add host instrument profiles
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
* settings: add instrument pathway selection
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
* fix(settings): profile-aware saves/resets/switch, provider tunings, bass-5
Five regressions from the instrument-profiles rework:
1. save_settings canonicalized profiles on EVERY save -> empty/unrelated POST
froze default profiles into config.json (broke
test_empty_post_preserves_all_existing_keys). Gate on the save touching
instrument settings; GET already virtualizes profiles.
2. pathway is profile-mirrored, so the Gameplay reset (flat-key delete) was a
no-op. reset_settings now resets pathway inside the persisted profiles too.
3. Per-profile tuning validation rejected provider/custom tunings (tuner
plugin, /api/tunings). _valid_tuning_for_key now accepts a name unknown to
every built-in table while still rejecting a built-in misapplied to the
wrong key.
4. First-migration overwrote an explicit active_instrument_profile with the
legacy-inferred one, so a fresh-config switch to 'bass' was lost. Use
setdefault so an explicit request wins.
5. Pre-existing test_instrument_fields_persist used bass-5 + 'Drop D' (a
4-string tuning). Updated to the valid 'Drop A'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): partial-merge instrument_profiles; clamp tuning on string-count switch
Two partial-update follow-ups:
- save_settings normalized a POSTed instrument_profiles by FILLING every omitted
profile with defaults and replacing wholesale, so a one-profile update reset
the others. Validate each PROVIDED profile individually and merge the partial
over the persisted set inside the lock — /api/settings is partial-merge.
- the string-count picker posted only string_count, so the backend silently
reset a now-invalid tuning to Standard while the UI kept the old value
(settings/tuner desync). Clamp + post the valid tuning too, mirroring the
instrument-switch path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): serve art/load songs mounted through a library junction
A song library mounted through a directory JUNCTION/symlink subfolder (a
library shared across app installs; the desktop app's own mounts) had broken
album art and couldn't load: the scanner's rglob follows the junction and
indexes the songs, but _resolve_dlc_path (via safe_join's .resolve()) followed
the junction to its real target, saw it outside DLC_DIR, and rejected every
song reached through it → 403 on /art, 404 on /art/candidates, broken covers.
- _resolve_dlc_path now uses LEXICAL containment (os.path.normpath, no symlink
following) so an in-library junction is allowed, while `..` traversal and
absolute paths are still rejected (the traversal tests pin this).
- safe_join is left STRICT (.resolve()-based) — it is the zip-slip / plugin-
asset / avatar guard, where following a symlink out IS the defense — but
gains an explicit NUL guard (on Python 3.13/Windows resolve() no longer
raises on an embedded NUL, so the byte was leaking through; strictly-more-
rejection, no effect on the zip-slip contract).
Tests: test_dlc_junction (junction allowed; `..`/absolute/NUL rejected; the
safe_join-stays-strict contrast). Existing traversal/safepath/art-candidates
suites stay green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): reject Windows drive-letter paths in _resolve_dlc_path
The new test_absolute_path_rejected pins 'C:/Windows/system32/x' → None, but on
POSIX a drive-letter path isn't absolute, so Path(dlc)/'C:/…' becomes the
contained relative dir '<dlc>/C:/…' and slipped through the lexical containment
check (red on the Linux CI). Not an escape, but the traversal contract should
hold cross-platform (a shared library is reached from either OS). Reject a path
that is absolute or drive-qualified in either POSIX or Windows semantics before
the containment check. Legitimate relative/junction paths are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two commits (delete beethoven-ode_to_joy, re-add The Adicts' Ode to Joy) never
updated _BUILTIN_STARTER_SOURCES: it still listed the deleted pack and omitted
the added one. The listed-but-missing file made the all-present gate never fire,
so NO starter content seeded on first run — and the on-disk-but-unlisted pack
would bundle as dead weight. Both starter-seed guard tests were red on main,
reddening ci/test on every core PR. Sync the manifest to disk.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every real Guitar Pro 6 (.gpx) file failed to import with
"GPX BCFS sector pointer out of range (malformed file)".
A real .gpx's BCFZ-declared decompressed size isn't 0x1000-aligned, so
its last (small) container file lands in a partial trailing sector.
_parse_bcfs raised whenever a sector read would run past the buffer
end, rejecting the whole container before score.gpif could be extracted
-- so no GP6 file could be charted in the song editor. (GP7/GP8 .gp
files take the ZIP path, not BCFS, which is why this wasn't caught
earlier.)
Clamp the final sector read to the buffer end (the per-file size field
trims the padding anyway), matching canonical GPX readers (alphaTab /
PyGuitarPro). A sector whose start is past the end still raises, so the
malformed-file guard is preserved.
Verified against two real GP6 files -- both now unpack to valid GPIF
with all tracks. Adds the previously-missing positive BCFS round-trip
coverage: partial-final-sector, multi-file, sector-aligned baseline,
and the preserved out-of-range guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
* 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>
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 ’ 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>
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>
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>
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>
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>
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>
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>
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>
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>
* 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>
* 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>
* 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>
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>
* 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>
* 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>
* 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>
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>
* 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>
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>
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>
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>
* 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>
* 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>
_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>
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>
* 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>
* 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>
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>
* 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>