Compare commits

..
Author SHA1 Message Date
byrongamatosandClaude Opus 4.8 47085991de fix(pack overwrite): reject revert-original on non-package targets (mirror write-path guard)
The POST /api/song/{fn}/revert-original handler restored a .bak over its
target after only checking that a backup existed — unlike the write path
(gap-fill/overwrite), which refuses non-sloppak targets via is_sloppak. A
non-package path under DLC_DIR with a sibling .bak (or a plain directory
carrying manifest.yaml.bak) could therefore be mutated by a feature meant
only for song packages.

Add the same is_sloppak guard after path resolution and before any restore
or DB resync, returning the 404 the art/source endpoints use for a
non-package target. Existing behaviors (404 when no backup, .bak preserved
after a successful revert, demo-block, path-traversal safety) are intact.

Regression test: a non-package file under the DLC dir with a sibling .bak is
refused (404) and left byte-for-byte unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 08:13:33 +02:00
ChrisBeWithYouandClaude Fable 5 4db3db4622 v3 library: per-field pack overwrite (R4b) — fix wrong author values, with receipts
DRAFT — gated on the feedpak-spec §7 amendment (got-feedback/feedpak-spec#47).
Gap-fill (#724) fills absent keys; this fixes WRONG author-set values (a
typo'd artist, a wrong year) one field at a time, with provenance and an undo.
Built fully compliant with the amendment shape so it's ready the moment §7
is amended.

Server (extends the merged gap-fill endpoints, doesn't fork):
- Preview GET /api/song/{fn}/gap-fill now also returns `differs` =
  [{key, current, proposed}] over the allowlist (title/artist/album/year/
  genres) where the manifest HAS an author value and the confirmed match
  supplies a DIFFERENT one — plus `overwrite_allowed` (the Settings gate) and
  `has_backup`. differs is EMPTY unless match_state == 'manual': a
  user-confirmed pin can authorize a replace; an auto-match never can. mbid/
  isrc are never in differs (identity changes only via re-match).
- POST accepts `overwrite_keys` alongside gap-fill's `keys`; gated on the
  allow_pack_overwrite setting + manual state + the allowlist, proposals
  recomputed under _song_io_lock so a field that changed since preview is
  skipped. Writes via the re-serialize writer (extended to handle the genres
  list). Every written key (gap-fill AND overwrite) lands in a new write_log
  receipts table (filename, key, old, new, source, score, ts), pruned to the
  newest 5000 rows.
- POST /api/song/{fn}/revert-original restores the pack from its .bak (dir +
  zip forms), re-stats + re-syncs the DB; 404 when no backup; the .bak is
  PRESERVED after revert (re-appliable). Demo-blocked, like the write.
- GET /api/song/{fn}/write-log — the song's receipts, newest first.
- Setting allow_pack_overwrite (default OFF).

The .bak safety property is unchanged from the shipped writer: the backup is
written ONCE (first write) and is always the pristine author original — a
second overwrite never clobbers it, so Revert always returns the untouched
pack.

Frontend (Details drawer, extends the gap-fill block): when the preview
returns differs AND overwriting is enabled, an "Overwrite existing fields"
sub-block renders per-key rows `Artist: "ACDC" -> "AC/DC"`, ALL UNTICKED by
default, with the warning that it replaces what the author wrote and the
original is kept as a backup; the Write button carries the ticked keys. When
differs exist but the setting is off, one muted line points to Settings. A
"Revert file to original…" link (confirm dialog) appears whenever a backup
exists. Settings gains the allow_pack_overwrite checkbox.

Tests: tests/test_pack_overwrite.py — differs manual-only + identity-excluded
+ equal-skipped, refused when setting-off / not-manual / bad keys, dir + zip
overwrite, .bak stays the pristine original across a second write, write_log
records old+new (and gap-fills), prune at cap, revert dir+zip+404, demo
blocks write & revert. 30 pass with test_gap_fill green. node --check clean;
no new Tailwind classes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
2026-07-02 20:53:36 -05:00
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
5e78f2f7f7 fix(tuner): remove unused settings + fix sidebar panel position (#661)
Removes the Floating Button and Tuning Visibility settings sections and finishes retiring their still-live config: drops the disabledTunings menu filter and showFloatingButton gate from screen.js/ui.js and their persistence in routes.py (retired keys are stripped on write). Repositions the tuner panel opened from the v3 sidebar Plugins popover to anchor beside it via the host's stable plugin-control slot API (falling back to the popover id), clamped to the viewport so it can't open off-screen, and re-anchored on resize. Updates tuner config tests to the retired-key behavior; plugins/tuner 1.3.2 -> 1.3.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:35:18 +02:00
9b4bef3fd1 fix(drum_highway_3d): enlarge note gems ~20% for readability (#713)
Disc radius 3.6->4.3, cymbal 3.0->3.6 (heights proportional) — hands-on
feedback said the gems were hard to read at the default camera. All
variant shapes key off these radii; accents still fit the lane gap.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:22:02 +02:00
3e2703d8e1 feat(keys_highway_3d): audio-reactive ambience + score FX overlay (K4) (#709)
- BG_STYLES port (off/particles/lights/geometric; lights use the
  pitch-class palette) mounted behind the scene; _bgGetAnalyser/
  _bgReadBands (stems-first, one-shot #audio fallback, permanent-failure
  latch); Ambience intensity + Audio-reactive settings; remounts on
  style/intensity change
- Score-FX overlay canvas (drum_highway_3d pattern): +1 pops at the
  scored key, ring pulse every 10-combo, milestone bursts at 25/50/100,
  red wash on 3+ streak break (wrong notes AND swept misses); cleared
  when idle, removed in teardown
- Tests: style id validation + FX defaults (30 total)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:06:12 +02:00
d409d55615 feat(drum_highway_3d): audio-reactive ambience + score FX overlay (D4) (#708)
- BG_STYLES port (off/particles/lights/geometric) into a renderOrder -1
  group; _bgGetAnalyser/_bgReadBands (stems-first, one-shot #audio
  fallback, permanent-failure latch, 5ms bands cache); Ambience
  intensity slider + Audio-reactive toggle; remounts on style/intensity/
  palette change and across kit-change scene rebuilds
- Score-FX overlay canvas (guitar drawScoreFx adapted to internal
  scoring): +1 pops at the struck lane, ring pulse every 10-combo,
  milestone bursts at 25/50/100, red wash on 3+ streak break; pooled,
  cleared when idle, removed in teardown
- butterchurn/image/video deliberately out of scope
- Tests: style id validation + FX defaults (15 total)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:01:56 +02:00
4214ef365e feat(drum_highway_3d): materials & themes — studio env map, metal cymbals, BG_THEMES, cinematic, glow/vibrancy (D3) (#712)
- _makeStudioEnv: procedural PMREM environment (no RoomEnvironment addon
  vendored) -> scene.environment; rebuilt on kit-change, disposed both
  teardown paths
- Cymbals roughness 0.2 / metalness 0.85 / envMapIntensity 1.2 (metal
  finally reads); discs satin 0.45 + envInt 0.5; floor 0.7 + envInt 0.35;
  hit bar envInt 1.0
- BG_THEMES port (guitar ids/values; drum 'default' = original palette
  byte-for-byte; single pick drives clear/fog + board + lane stripes);
  drumH3dSetTheme + drum_h3d_bg_theme; live _applyTheme
- Cinematic lighting toggle (0.3/1.2 on, 0.4/1.0 off = stock)
- Glow slider (base * glow*2; 0.5 default = stock) across notes/hit
  bar/snare stripe; Lane vibrancy slider (stripe base 0.12+0.24v +
  halo/ghost opacities), stacking under the D2 approach highlight
- Fixed pre-existing floor/hit-bar geometry+material leak on kit change
- Tests: theme table parity + default preservation + fallbacks (13)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:54:17 +02:00
80fc371f11 feat(keys_highway_3d): anti-plastic materials, studio env, themes, gradient sky, cinematic + glow (K3) (#711)
- Note gems -> MeshPhysicalMaterial: clearcoat 1.0/0.18, roughness 0.32,
  envMapIntensity 0.9 — lacquered glass, not plastic (the explicit ask)
- _makeStudioEnv (PORTED drum_highway_3d): PMREM studio -> scene.environment;
  black keys 0.22 roughness / 1.3 envInt (glossy piano black), whites
  0.42/0.55 ivory, floor 0.55/0.15/0.4 stage sheen
- Vertical-gradient background (light horizon -> theme clear -> dark deck),
  sRGB-tagged for the composer path
- BG_THEMES port (guitar ids/values; keys 'default' = original palette;
  themes drive gradient/fog/floor/rails, never the pitch-class colors);
  keys3dSetTheme + keys3d_bg_theme, live _applyTheme
- Cinematic lighting toggle (0.55/1.3 on; stock 0.75/1.1 off); Glow slider
  across NOTE_EMISSIVE_BASE / consume-flash / key approach-glow
- Env RT + gradient texture disposed in teardown
- Tests: theme table parity + default preservation (28 total)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:51:48 +02:00
95b0786725 feat(keys_highway_3d): hit FX — vibrancy, timing-colored sparks, hit-line kick (K2) (#699)
- Note vibrancy: gem opacity 0.8 -> slider-driven (default 0.92),
  NOTE_EMISSIVE_BASE 0.08 -> 0.22, lane guides scale with the slider,
  _applyVibrancy retints the live scene without a chart rebuild
- Sparks (PORTED highway_3d, pool 96) at the struck key, colored by
  _timingHex/_classifyTiming (±100ms window, inner 40% = on-time; delta
  recovered from judgeHit's noteKey prefix — contract untouched);
  streak-scaled counts
- Hit-line brightness kick on scored presses (exp(-t*6) decay, hitFx
  slider), folded into the existing pulse incl. the bloom damp gate
- Settings: Hit sparks / Timing colours / Streak feedback + Hit feedback
  intensity + Note vibrancy sliders (keys3d_bg_*, live-applying)
- Tests: classifier boundaries, noteKey time round-trip, FX defaults
  (26 total)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:45:09 +02:00
749af31cc3 fix: correctly import and notate multi-staff (piano/keys) tracks from GP8 (#692)
* fix: correctly import and notate multi-staff (piano/keys) tracks from GP8

Fixes bass stave being dropped on import (bar-column enumeration bug)
and wrong hand-split heuristic in notation_lift for chords straddling
middle C.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>

* fix(gp-import): fold all grand-staff staves, per-stave tuning, playable hand-splits

Addresses review on #692 (topkoa):

- split_hands: only use the middle-C boundary when both resulting hands are
  within HAND_SPLIT_SPAN_SEMITONES, else fall back to the largest-gap
  heuristic — a hard middle-C split otherwise put a 19-semitone (unplayable)
  span in one hand for bass-under-treble voicings (e.g. E2+B3 under an Em7
  shape).
- Treat any multi-stave (grand-staff) track as keys end-to-end, so the
  stave-0 and folded stave-1+ notes share one encoding and note_count (which
  sums every stave column) matches what actually imports — closing the
  phantom-count case for grand-staff instruments the name/program heuristics
  miss (harp, celesta, marimba).
- Fold *every* extra stave (stave_columns[1:]), not just stave 1.
- Per-staff tuning fall-back to the track-level Tuning property so an untuned
  staff never yields an empty pitch list (silent note loss); via a shared
  _parse_tuning helper.
- Extract _collect_column_notes / _merge_lh_notes so the GPX LH/RH pair merge
  and the GP8 grand-staff fold share one implementation and can't drift in
  tie/timing/dedup handling.
- Rebuild filtered_to_raw from the already-computed stave_columns (one source
  of truth for the counting rule) and drop the dead num_raw_tracks/raw_tracks.

Tests: grand-staff fold + bar-column offset (test_gp2notation.py); both
middle-C split cases (test_notation_lift.py). CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>

---------

Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 08:40:20 +02:00
OmikronApexandGitHub 991eadeff6 Merge pull request #694 from got-feedback/perf/highway-frame-hotspots
perf: eliminate per-frame layout thrash, shader-compile spikes, and per-frame allocations in the player
2026-07-02 01:17:17 +02:00
56419dc789 feat(drum_highway_3d): hit FX — sparks, timing colors, lane flashes, kick pulse, open hi-hat (D2) (#697)
- Pooled spark bursts (PORTED highway_3d, pool 160) at the struck lane,
  timing-colored via _timingHex/_classifyTiming (±50ms window, inner 40%
  = on-time); streak-scaled counts (streakFx)
- Lane flashes resurrected as pooled additive gauss-tex quads at the hit
  line (timing-colored; red for wrong hits); kick = full-width quad
- Kick pulse: camera dip + amber floor wash, exp decay, hitFx-scaled
- Approach highlight: lane stripes brighten toward their next note
- Open hi-hat: hh_open renders a warm ring around the gem (closes TODO);
  orthogonal to accent/ghost/flam variants
- Settings: Hit sparks / Timing colours / Streak feedback toggles +
  Hit feedback intensity slider (drum_h3d_bg_*, live-applying)
- All FX resources pooled/shared, disposed in BOTH teardown paths
- Tests: _classifyTiming boundaries + FX defaults (10 total)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:07:50 +02:00
OmikronApexandClaude Fable 5 95cb51b2ad perf(highway_3d): forceSinglePass on transparent DoubleSide quads
The retrace after the label-swap fix showed getParameters unchanged
(~2.5s / ~4% throttled main thread) — the real driver is Three r158+'s
transparent-DoubleSide two-pass path: renderBufferDirect renders such
objects back side then front side, setting material.needsUpdate BOTH
times, i.e. a full getParameters/program-cache lookup twice per object
per frame, plus double draw calls. (Found by reading the two-pass
branch in the vendored three.module.min.js right next to the
getParameters call site.)

All 18 transparent DoubleSide materials in this renderer are flat
unlit quads — technique markers, sustain rails, chord frames, lane
planes, halo bars — where the two-pass self-occlusion ordering buys
nothing. Declare forceSinglePass: true on all of them.

Also corrects the _setLabelMap comment's churn attribution (that fix
removes the label-swap contribution; this one removes the dominant
source). Plugin 3.31.1 -> 3.31.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:48:52 +02:00
OmikronApexandClaude Fable 5 59aa70ce5a perf: remove throttled-trace residuals — program churn, per-frame rect, HUD clock
A 4x-CPU-throttled retrace (the honest weak-hardware proxy) surfaced
three residual per-frame costs; stack attribution pinned each:

- getParameters/getProgramCacheKey (~4% of main thread): every pooled
  label sprite map swap set material.needsUpdate, bumping
  material.version and forcing full program re-resolution next render.
  Swapping between two non-null cached textures never changes the
  compiled program (USE_MAP define unchanged) — new _setLabelMap()
  helper only flags needsUpdate on a null<->texture transition, used at
  all 7 swap sites.
- getBoundingClientRect (~1.2%): the 3D highway's per-frame canvas-size
  self-check forced a layout read every frame. The CSS-box drift read
  now runs every 10th frame (or when the wrap isn't pinned); the
  backing-store comparison stays per-frame with cheap property reads
  and forces an immediate box read + applySize when it fires.
- set textContent: the core 60 Hz HUD clock rewrote hud-time (and
  getElementById'd it) every tick for a display that changes 1/s — now
  write-on-change with a cached element ref.

(The remaining textContent writer in the trace is notedetect's
badges.js — external repo, to be filed there.)

tests/js: resize-reframe shape test updated for the hoisted _bsChanged
gate, incl. an assertion that the throttle can never delay the
backing-store path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:40:52 +02:00
OmikronApexandClaude Fable 5 1e9741043b review: address PR #694 findings
- isVisible() forces a fresh DOM sample (was serving the rAF loop's
  throttled cache, contradicting its 'live DOM check' docstring).
- v3 chrome: reconcile the edge-driven overControls hover flag against
  matches(':hover') on the throttled ~6 Hz tick — covers a missed
  mouseleave (flag stuck true, transport never hides) and a re-created
  #player-controls node with lost listeners.
- highway_3d pre-warm now also covers teachFg/teachSd label textures
  and the technique sprite factories (mute X, hammer/pull triangles,
  bend chevrons, slide arrows) per active-palette string colour, plus
  a maintenance note tying new label styles to the warm list.
- Document that the visibility throttle's manual invalidations are
  latency-only (periodic resample self-heals within ~10 frames), and
  why highway_3d keeps its local lowerBoundT (downlevel hosts).

External-repo audit (finding 1): staffview, tabview, piano, drums,
keys_highway_3d, drum_highway_3d grepped — no cross-frame bundle
retention or bundle-identity checks found.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:35:24 +02:00
OmikronApexandClaude Fable 5 77547af110 perf: allocation/scan hardening for weaker hardware
Static-analysis follow-ups to the trace-backed fixes; each is cheap
insurance on machines where the profiled headroom doesn't exist.

- highway.js: _makeBundle now mutates one persistent per-instance
  object instead of allocating a fresh ~35-field bundle every rAF
  frame (xN under splitscreen). Object identity is stable and
  meaningless; array fields still swap reference on chart changes,
  which field-identity caches rely on. Contract documented in both
  CLAUDE.mds.
- highway.js: new bsearchTime (lower-bound on .time) windows the
  default 2D renderer's beat-line scan (was O(all beats) per frame);
  bundle.lowerBoundT / bundle.lowerBoundTime expose the searches to
  custom viz so they stop reimplementing visible-window culling.
- highway_3d: localStorage 'h3d_full_sus' polled at ~1 Hz instead of
  every frame (synchronous storage read on the hot path).
- highway_3d: drawLyrics caches the measureText row layout keyed on
  (lyrics ref, line index, shown count, font size, width) — per-frame
  work is now just drawing over cached widths.
- tests/js: bundle source-shape assertions widened to accept the
  assignment form ([:=]) alongside the old object-literal form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:02:54 +02:00
OmikronApexandClaude Fable 5 5239665e2b perf(highway_3d): pre-warm shaders and label textures at init
Trace showed frame spikes from Three.js first-use costs mid-song:
shader program compilation (getParameters/getProgramCacheKey) and lazy
texture uploads (texSubImage2D) whenever a chord name, section banner,
or fret label first appeared.

- ren.compile(scene, cam) after initScene (pools already warmed by
  feedBack#226, board built, background mounted) so programs compile
  during the load spinner.
- Pre-rasterise + GPU-upload (ren.initTexture) the deterministic txtMat
  entries: fret numbers 0-24 in the noteFret/fretRow/ghostFret combos
  the per-frame paths request.
- Chart-dependent labels (chord template names, section names) prewarm
  once on the first draw() after each init, when bundle arrays are
  guaranteed populated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:53:36 +02:00
OmikronApexandClaude Fable 5 fd840011d2 perf(core): stop per-frame layout thrash in visibility check + v3 chrome loop
Chrome trace showed ~0.5s self-time in _isHighwayVisible (offsetParent
read every rAF frame forces style/layout recalc) and ~1.5s in the v3
player-chrome loop (matches(':hover') per frame, unconditional
textContent/width writes at 6 Hz -> ~1800 layout passes in 63s).

- highway.js: sample offsetParent every 10th frame, cached in between;
  fresh sample forced on init/canvas-replace/resize/override-clear.
- player-chrome.js: hover tracked via mouseenter/mouseleave; Up-Next
  refs cached, text written only on change (eta coarsened to 1s steps
  beyond 10s), progress bar moved from width to scaleX (compositor-only).
- v3.css: bar fill uses transform-origin:left + scaleX transition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:51:39 +02:00
83 changed files with 14784 additions and 939 deletions
+18
View File
@@ -7,7 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
### Fixed
- **Edit Metadata now writes into `.feedpak` files, not just legacy `.sloppak` ones.** `lib/songmeta.py`'s suffix gate predated the format rename — core reads both suffixes everywhere else (`sloppak.SONG_EXTS`), but the metadata writer only dispatched on `.sloppak`, so editing a zip-form `.feedpak`'s title/artist/album/year silently fell back to a DB-only update. That looked fine until the next **full library rescan** re-derived metadata from the file and reverted the edit (directory-form packages were unaffected — they dispatch on manifest presence, not suffix). The gate now accepts both package suffixes. Tests: `tests/test_songmeta.py` `TestWriteSongMetadata` (both zip suffixes, mixed-case suffix, directory form, unknown-suffix fallback).
- **3D Drum & Keys highways now re-frame on fullscreen/layout drift under splitscreen.** The guitar/bass `highway_3d` self-detects when its panel canvas changes size 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 (visible as a too-small, off-center highway after maximizing a split-screen session). Both draw loops now port `highway_3d`'s per-frame drift check: they 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), and reset the tracking 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`.
- **Tuner: finished the "remove unused settings" cleanup and fixed the sidebar panel position.** The Floating Button and Tuning Visibility settings sections were removed, but their config was still live: `disabledTunings` still filtered the tuner menu (with no UI left to re-enable a hidden tuning — a one-way trap) and `showFloatingButton` still gated the floating launcher. Both are now fully retired — the enforcement paths in `plugins/tuner/screen.js`/`utils/ui.js` and the persistence in `plugins/tuner/routes.py` are gone (and `routes.py` strips the retired keys on write, so stale values are purged). The tuner panel opened from the v3 sidebar Plugins rail popover now anchors beside it via the host's stable plugin-control slot API (falling back to the popover id), is **clamped to the viewport** so it can't open off the right/bottom edge on narrow/short windows, and re-anchors on window resize. `plugins/tuner` → 1.3.3.
### Added
- **Host theme read surface — `window.feedBack.theme` + always-present `--fbv-*` tokens — so a plugin feature can render correctly under any theme instead of binding to whichever one the developer happened to see.** The cosmetics applier (`static/v3/theme-core.js`) previously only *applied* themes and emitted `--fbv-*` vars **only while a theme was equipped** (nothing for a plugin to read in the default, un-themed state) with no read/capability API — so plugins reinvented their own theming and a new visual *device* (a glow, a gradient) silently bound to one look. It now: (1) emits the default `fb` palette as **always-present `--fbv-*` on `:root`** (additive — the un-themed look is unchanged; the `fb-*` utilities still use their compiled defaults; this only hands plugins a stable host token to read + derive surfaces from), plus two keystone ROLES the palette lacked — **`on-accent`** (a foreground legible *on* the accent fill — the missing piece behind white-on-accent contrast bugs) and **`focus-ring`**; (2) adds **`window.feedBack.theme`** — `get()``{id, isThemed, tokens}`, **`capabilities()`** → `{glow, gradients, motion}` (the device-affordance signal a feature reads to choose a glow vs. a solid device; recolor-only themes report defaults, a theme may opt out via a `capabilities` block in its payload, and `motion` is additionally reduced-motion-gated), and **`prefersReducedMotion()`** (one central matchMedia wrapper); and (3) emits a normalized **`theme:changed`** event (`{id, isThemed, tokens, capabilities}`) from the single `apply()` chokepoint. All additive + feature-detected (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). First slice of the host theme contract (got-feedback/feedBack#644) — the framework fix so a plugin UI feature can't accidentally carve itself into a single theme; see `docs/host-theme-contract.md`. Verified by a headless render (apply/unequip intact, defaults present + restored, capability opt-out honored, event payload correct). Tests: `tests/js/v3_theme_read_api.test.js`.
- **3D Keys Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the keys side of the visual-parity epic).** The gradient sky behind the highway gains the guitar's **background ambience styles** — drifting **Particles**, pitch-class-colored pulsing **Stage lights**, wireframe **Geometric** shapes, or Off — driven by the shared audio-analyser bridge (stems-first on sloppaks, one-shot `#audio` fallback; bass/mid/treble bands, 5 ms cache) with an **Ambience intensity** slider and an **Audio-reactive** toggle. And the score talks back: a **score-FX overlay** canvas draws rising **“+1” pops** off each scored key, an **expanding ring every 10-combo tier**, **milestone bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks (wrong notes and swept misses both count). All settings-gated and on by default (`keys3d_bg_*`), pooled, cleared when idle, torn down with the scene. Butterchurn/image/video remain out of scope. The `#audio` analyser tap is also **shared across visualizers** now (`window.__feedBackAudioTap` — whoever taps first publishes, everyone else adopts, `highway_3d` included), so switching between or splitting the guitar/drum/keys highways can't strand a permanently non-reactive backdrop, and the tap is never created before the page has user activation (a suspended AudioContext would silence live playback). Tests: bg-style id validation + FX defaults (30 total).
- **3D Keys Highway: the anti-plastic pass — lacquered note gems, glossy piano-black keys, a studio environment, scene themes and a gradient sky (parity slice 3).** The note gems move to `MeshPhysicalMaterial` with a full **clearcoat** (roughness 0.32, clearcoat 1.0/0.18, envMapIntensity 0.9): a sharp lacquer highlight over the colored body instead of the old dead matte surface — glass, not plastic. What sells it is **image-based lighting**: the same procedural PMREM "studio" environment as the drum highway (dark room + cool overhead / warm+cool side light strips, no addon dependency) feeds `scene.environment`, so the **black keys finally read as glossy piano black** (roughness 0.55 → 0.22, envMapIntensity 1.3) with visible strip reflections, whites keep an ivory sheen (0.42/0.55), and the highway floor gets a stage sheen (roughness 0.9 → 0.55, metalness 0.15). The flat background becomes a **vertical gradient** (lighter above the horizon → theme color → darker toward the keyboard), and the guitar highway's **11 scene themes** arrive (same names/values — your look carries across instruments; `default` preserves the original keys palette; pitch-class note/key colors are never themed — themes own the scene, Synthesia colors own the notes). Plus **Cinematic lighting** (ambient 0.55/key 1.3, on by default) and a **Glow strength** slider multiplying the note glow, key approach-glow and the sustain consume-flash (0.5 = stock). All live-applying from the Graphics settings (`keys3d_bg_theme` + `keys3d_bg_*`); the PMREM target and gradient texture are disposed with the scene. Tests: theme-table id parity + default-look preservation + fallbacks (28 total).
- **3D Drum Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the drum side of the visual-parity epic).** The empty fog band behind the kit gains the guitar highway's **background ambience styles**: drifting **Particles**, palette-colored pulsing **Stage lights**, and slowly-tumbling wireframe **Geometric** shapes (plus Off) — driven by the same audio-analyser bridge the guitar uses (prefers the stems plugin's per-song analyser on sloppaks, falls back to a one-shot `#audio` tap; bass/mid/treble bands with a 5 ms cache), with an **Ambience intensity** slider and an **Audio-reactive** toggle (off = the styles animate on time only; a permanently-tapped `#audio` in a mixed split degrades the same way). The guitar's butterchurn/image/video styles are deliberately out of scope (vendored megabytes / upload plumbing; the style enum is extensible). And your combo finally talks back: a **score-FX overlay** (2D canvas over the WebGL scene, guitar `drawScoreFx` adapted to this plugin's internal scoring) draws rising **“+1” pops** off each scored lane, an **expanding ring pulse every 10-combo tier**, **milestone particle bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks. Everything is settings-gated (Background ambience dropdown + intensity + reactive, Score effects toggle — all on by default, live-applying, `drum_h3d_bg_*` keys), pooled (zero per-frame allocation), and torn down with the scene across kit changes. Tests: bg-style id validation + FX defaults (15 total).
- **3D Drum Highway: real materials + scene themes (parity slice 3).** The scene gets **image-based lighting**: a procedural PMREM "studio" environment (dark room + three emissive light strips — cool overhead key, warm/cool side fills; no vendored-addon dependency) feeds `scene.environment`, so the cymbals' metalness **finally reads as metal** (retuned to roughness 0.2 / metalness 0.85 / envMapIntensity 1.2 — the old 0.7-metalness look was matte because there was nothing to reflect), drumheads get a satin sheen, and the floor (roughness 0.95 → 0.7) catches the strips without turning into a mirror. **Scene themes arrive** — the same 11 theme names as the guitar highway (Midnight, Charcoal, Deep Purple, Forest, Warm Slate, Deep Focus, Deep Sea, Cathode, Cathode Green, Hearth) retint the background/fog, floor and lane stripes so your look carries across instruments; `default` preserves the original drum palette byte-for-byte, and piece colours stay with the existing Palette picker (themes own the scene, palettes own the kit). Plus **Cinematic lighting** (dimmer ambient / stronger key, on by default), a **Glow strength** slider (01, 0.5 = stock) multiplying every emissive base — notes, hit line, snare wires — and a **Lane vibrancy** slider driving stripe/halo/ghost-ring strength (the hit-FX approach highlight stacks on top). Everything applies live from the plugin settings (`drum_h3d_bg_theme` + `drum_h3d_bg_*` keys); the PMREM render target is rebuilt across kit-change renderer recreation and disposed in both teardown paths — and the floor/hit-bar geometry+materials that previously leaked on every kit change are now tracked and disposed too. Tests: theme-table parity with the guitar ids + default-look preservation + fallbacks (13 total).
- **3D Drum Highway: hit FX — sparks, timing-colored lane flashes, kick camera pulse, approach glow, and open hi-hat notation (parity slice 2).** Striking a pad now *feels* struck: a pooled additive **spark burst** fires at the lane (ported from the guitar highway's Points-cloud system, pool 160), colored by **timing** — on-time green, early cyan, late amber (same `_timingHex` vocabulary as `highway_3d`, classified against the ±50 ms hit window with the inner 40% reading as on-time); with **Streak feedback** on, bursts grow with your combo. The **lane flash** feedback that was removed when note-recoloring landed is resurrected properly: pooled additive quads with a soft gaussian falloff light up the struck lane at the hit line (timing-colored; red for wrong-pad hits), and a **kick** hit fires triple amber bursts across the bar plus a subtle **camera dip + amber floor wash** that decays exponentially. Lanes also glow ahead of time: each stripe brightens as its next note approaches the hit line, so the eye is led to where the next hit lands. **Open hi-hat finally renders distinctly**`hh_open` chart hits get a thin warm ring around the cymbal gem (standard notation's "o"), closing the long-standing TODO; the flag is orthogonal to accents/ghosts/flams so combined cues stack. All of it is settings-gated (Graphics → Hit sparks / Timing colours / Streak feedback / a 01 **Hit feedback intensity** slider driving flashes, approach glow and the kick pulse; everything on by default, `drum_h3d_bg_*` keys, live-applying) and GPU-frugal: every new visual is pooled or shares geometry/materials — zero per-note allocation on top of the per-frame notes rebuild, all registered in both dispose paths (kit-change renderer recreation included). Tests: timing-classifier boundaries + FX defaults added to `plugins/drum_highway_3d/tests/data_layer.test.js` (10 total).
- **3D Keys Highway: hit FX — vibrant note gems, timing-colored sparks, and a hit-line that reacts to your playing (parity slice 2).** The washed-out note look is gone: gem opacity is now driven by a **Note vibrancy** slider (default 0.85 → opacity 0.92, up from a fixed 0.8; lane guides scale with it too, live-applying without a chart rebuild) and the resting emissive glow rises 0.08 → 0.22, so the falling notes finally read saturated against the dark floor. Scored key presses fire a pooled additive **spark burst** at the struck key (guitar-highway port, pool 96) **colored by timing** — on-time green, early cyan, late amber, classified against the ±100 ms window with the inner 40% reading as on-time (the timing delta is recovered from the matched note's key, so `judgeHit`'s tested contract is untouched); the per-pitch-class flame sprite keeps its identity color so pitch and timing stay separate signals. With **Streak feedback** on, bursts grow with the combo. The **hit line kicks brighter** for a beat on every scored press (exponential decay, scaled by a 01 **Hit feedback intensity** slider). All new controls live in the plugin's Graphics settings (on by default, `keys3d_bg_*` keys, live-applying), and the spark pool is disposed with the scene like every other GPU resource. Tests: timing-classifier boundaries, the noteKey time round-trip that the delta recovery relies on, and the new FX defaults (26 total).
- **3D Drum Highway: bloom glow + adaptive-resolution support — the first slice of visual parity with the guitar highway.** The drum highway now renders through the same post-processing path as `highway_3d`: an `UnrealBloomPass` (strength 0.65, radius 0.5, threshold 0.82 — high, so only emissive/bright surfaces bleed) on a multisampled HalfFloat target with ACES filmic tone mapping, so the white hit-line bar and proximity-lit notes get a real glow instead of a flat emissive tint. **On by default**, with a new **Graphics → "Glow (bloom)"** toggle in the plugin settings (`drum_h3d_bg_bloom`, applies live, no reload); if the vendored postprocessing addons can't load (older self-hosted core), the plugin silently falls back to the direct render path. The plugin also now honors the host's **adaptive render scale** (`bundle.renderScale` — the Quality/"Min res" controls that the guitar highway already respected), multiplying it into the device pixel ratio, and caps DPR at 1.25 when more than one viz instance is live (splitscreen) so two panels don't double the GPU fill cost. Groundwork for the rest of the parity series: an FX-settings scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.drumH3dSetFx`, `drum_h3d_bg_*` localStorage keys) that the sparks/themes/backgrounds PRs extend, plus a first node test suite for the plugin (`plugins/drum_highway_3d/tests/data_layer.test.js` — vm-loaded like the keys plugin's, covering the hit-variant precedence, the Auto-mode steal-guard predicate, and FX defaults; 8 tests, runs in CI via the `plugins/*/tests/*.test.js` glob).
- **3D Keys Highway: sharp HiDPI rendering, bloom glow, a live combo HUD, and a graphics settings panel — the first slice of visual parity with the guitar highway.** The biggest single fix is resolution: the plugin never called `setPixelRatio`, so on HiDPI/retina displays (and Windows display scaling) it rendered at CSS resolution and was upscaled — soft and aliased. It now multiplies the device pixel ratio (capped at 2, or 1.25 when two viz panels are live in splitscreen) with the host's **adaptive render scale** (`bundle.renderScale`, the Quality/"Min res" controls), exactly like `highway_3d`. On top of that: the same **bloom** post-processing path as the guitar highway (UnrealBloomPass 0.65/0.5/0.82 on a multisampled HalfFloat target + ACES tone mapping — the cyan hit-line, hit flames and the sustain "consume" glow finally bleed light instead of reading flat), **on by default** with a graceful direct-render fallback when the vendored addons can't load. The plugin gains its first **settings panel** (`settings.html`, Settings → graphics category, `"settings"` block in plugin.json) with a live-applying "Glow (bloom)" toggle (`keys3d_bg_bloom`), plus the FX scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.keys3dSetFx`, `keys3d_bg_*` keys) the later parity PRs extend. And the score state the plugin was already tracking is finally visible: a **combo / accuracy / best-streak HUD** overlay (drum-highway pattern), shown only while a MIDI keyboard session is wired so it never renders a frozen 0× combo. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (defaults, localStorage overrides + type coercion, setter persist/dispatch/unknown-key guard; 3 tests alongside the existing 20).
- **The 3D Drum Highway and 3D Keys Highway are now bundled core plugins** (`plugins/drum_highway_3d/`, `plugins/keys_highway_3d/`), imported from their former standalone repos (`feedBack-plugin-drum-highway-3d`, `feedBack-plugin-keys-highway-3d`, now archived) via `git subtree` so their history is preserved. They join the other in-tree plugins-as-plugins: the loader treats them identically to user-installed ones, both are marked `"bundled": true` in their manifests, and `.gitignore` gains the matching `!plugins/<id>/` exceptions. This puts all three 3D highways (guitar, drums, keys) in one repo ahead of a visual-parity pass that ports the guitar highway's polish (bloom, sparks, themes, reactive backgrounds) to the other two — shared helper code and theme tables can now be reviewed and kept in sync in a single place. The keys plugin's existing node test suite is wired into CI (the JS test step gains a `plugins/*/tests/*.test.js` glob, +20 tests), and `static/tailwind.min.css` is regenerated since the core Tailwind build scans `plugins/**`. One deliberate behavior change ships with the bundling: the drum highway's Auto-mode predicate is **narrowed** (it used to claim any pack with `has_drum_tab` — a pack-level flag — which, now that the plugin ships to everyone and sorts before `highway_3d` in first-match-wins Auto order, would have stolen full-band packs from the guitar highway even on Lead/Bass arrangements; it now claims only drum arrangements, or packs nothing more specific can render). Picking the drum highway manually from the viz picker is unchanged.
@@ -54,10 +69,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where``query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
### Fixed
- **GP8 multi-staff (piano/keys) tracks now import both hands — the bass stave was being silently dropped, and hand-splits landed on the wrong hand.** A GP8 grand-staff keyboard part is one `<Track>` with two `<Staff>` entries, and `MasterBar/Bars` lists one bar id per **stave**, not per track (`lib/gp2rs_gpx.py`). Two bugs fell out of assuming one stave per track: (1) the bar-column lookup used a raw `enumerate(Tracks)` index, so every track *after* a multi-stave track read the wrong column; (2) the string-tuning parse scanned all `.//Property` descendants and let the last stave's `<Tuning>` overwrite the first, so a treble note indexed against the 5-entry bass tuning fell out of range in `_note_midi` and was **dropped without a trace**. The importer now advances a bar-column counter by each track's stave count, reads tuning **per stave** (with a per-staff fall-back to the track-level property so an untuned staff never yields empty pitches), and folds **every** extra stave's notes into the arrangement (not just stave 1), keeping the `note_count` import-preview honest. A grand-staff track is now classified as keys end-to-end so the stave-0 and folded stave-1+ notes share one encoding. Separately, `notation_lift.split_hands` no longer forces a hard middle-C split when doing so produces a physically unplayable hand (e.g. a bass note under an Em7-shape voicing dipping below C4 would put a 19-semitone span in one hand) — it uses the middle-C boundary only when both resulting hands are within `HAND_SPLIT_SPAN_SEMITONES`, else falls back to the largest-gap heuristic. The GPX LH/RH pair merge and the GP8 stave fold now share one `_collect_column_notes` / `_merge_lh_notes` pair so the two formats can't drift in tie/timing/dedup handling. Companion editor change: got-feedback/feedBack-plugin-editor#38. Tests: `tests/test_gp2notation.py` (grand-staff fold + bar-column offset), `tests/test_notation_lift.py` (both middle-C split cases). Follow-up: `lib/gp_autosync.py` still carries the pre-fix bar-column + tuning logic (CLI/tests only, no production caller).
- **Tuner auto-open is now opt-in and persists instead of flashing open-then-shut.** When you entered a song (or switched arrangement) whose tuning differed from the last, the tuner auto-opened and — for some testers — vanished ~1s later (reported macOS+Windows, 0.3.0). Root cause: the tuner closes itself on `song:play` (`plugins/tuner/utils/ui.js` — you don't tune while playing), so a **song switch** fired autoplay → `song:play` → the just-auto-opened tuner closed; an **arrangement switch** (which never arms autoplay) had no `song:play`, so it stayed open — exactly why two testers saw opposite behaviour (it wasn't the mic). Now: (1) the feature is a **new opt-in setting** ("Auto-open on tuning change", in the tuner's Settings panel, persisted as `autoOpenOnTuningChange`, **default OFF**); (2) an **auto**-opened tuner *persists* — it ignores the autoplay `song:play`, stray outside-clicks, and same-screen re-emits, closing only via the new in-panel **`×`** / **"Skip"** buttons or when you leave the song. A *manually* opened tuner keeps its classic click-away / play-to-close behaviour. The panel previously had no in-box close at all; this adds one (`×` + contextual Skip). All in the tuner plugin (`routes.py` config, `screen.js` gate + persist, `utils/ui.js` buttons + `song:play` guard, `settings.html` toggle) — **no core `app.js` changes**. Tests: `tests/js/tuner_auto_open.test.js` (opt-in gate, `{ auto: true }` persist mode, play/click-proofing). **Default (opt-in vs opt-out) is teed up for Byron to decide — flip one boolean.**
- **Tuner auto-open is now tuning-coverage-aware — extended-range players aren't nagged for songs their instrument already covers.** With the opt-in auto-open on, it now prompts only when your **current physical tuning** (from your instrument selection in Settings) doesn't already cover the song. FeedBack is tune-to-song — the highway draws tab in the song's tuning — so the check aligns the song's open-string tuning string-for-string against your instrument: an **8-string F♯-standard** player gets **no** prompt for a 6- or 7-string standard song (its top strings already match those tunings), while a song needing an open string you don't have (e.g. a **Drop-A 7-string**, whose low A isn't an open string on an F♯ 8-string) **still** prompts. A whole-instrument reference difference also prompts — A440 vs A432, or an octave-down `centOffset` (which the auto-open now accounts for; it was previously ignored). The player's instrument is read from core **`/api/settings`** (the v3 instrument selector — a stable physical reference, not the tuner's song-tracking selection); when nothing's declared or the lookup is unavailable it falls back to a conservative prompt, so a real retune is never silently skipped. **v3-only** (the instrument selector is v3). All in the tuner plugin (`plugins/tuner/screen.js`) — **no core changes**. Tests: `tests/js/tuner_auto_open.test.js` (covered vs uncovered, the Drop-A case, reference-pitch mismatch, contiguous alignment). _Follow-up (E1.6): a passive "different tuning" badge cue that names the string(s) to retune, plus the splitscreen / no-usable-input guards._
- **The tuner badge now passively flags when a song needs a different tuning — and names the retune.** Building on the coverage check: when you enter a song your current instrument doesn't cover, the topbar tuner badge gets an amber ring and a tooltip that **names the change** — e.g. *"retune B→A"* for a Drop-A song on an F♯ 8-string, or *"the reference pitch"* for an A440-vs-A432 mismatch. It's purely **advisory** (it never auto-opens the panel — tap the badge to tune), recomputed on `song:ready` and cleared when a new song loads or you leave the player. The retune diff comes from the tuner plugin's coverage report (`window._tunerAutoOpen.coverageReport``{ covered, retune: [{ from, to }], reference, cantCover }`); the cue is CSS-free (an inline ring + native tooltip — no Tailwind rebuild) and no-ops when the tuner plugin isn't installed. **v3-only.** Touches `static/v3/badges.js` (the cue) + `plugins/tuner/screen.js` (the report). Tests: `tests/js/tuner_auto_open.test.js` (the report names the strings; reference mismatch; the badge wiring). _(The splitscreen-suppress and no-usable-input guards move to the playback-gate stage, where they matter for its no-trap rule.)_
- **Tuner auto-open can now gate playback until you've tuned — the "tune before you play" model — via a new core `holdAutoplay()` hook.** With the opt-in auto-open on, when a song needs a retune the tuner opens and **playback waits** for your choice — **Skip** (you've tuned → play, and record the song's tuning as your instrument's current working tuning), **Back to library** / **Esc** (leave the song; a gated retune is never a one-way trap), or press **Play** (always wins). For an auto-open the in-panel **×** is dropped — Skip / Back to library / Esc are its dismiss surface. Previously the song played with the tuner overlaid; now it holds — which also definitively kills the original flash, since autoplay's `song:play` can't fire while playback is held. Implemented as a small **core hook** `window.feedBack.holdAutoplay()` (mirrors the existing `holdAutoExit()`): a plugin claims it **synchronously on `song:loading`** (so it beats the `song:ready` autostart), and `release()` — or a **12-second fail-open backstop** — runs the deferred start. **Generation-guarded** (a new song invalidates a stale hold) and **fail-open** (a wedged or crashed plugin can never permanently strand a song); **manual Play always wins** (it doesn't flow through the autostart path). The tuner claims the gate only when the feature is on, and **releases it the instant** it decides not to open (song already covered / tuning unchanged) or when you Skip. Touches core `static/app.js` (the hook + an autostart refactor) and the tuner plugin (`plugins/tuner/screen.js` — the claim/release; `plugins/tuner/utils/ui.js` — the Skip / Back-to-library buttons, × dropped on auto-open); the hook is generic and shell-agnostic (a test asserts `app.js` still doesn't reference the tuner's internals). Tests: `tests/js/tuner_auto_open.test.js` (claim on `song:loading`, release on dismiss, feature-off no-claim, the core hook + fail-open backstop, the Skip / Back-to-library / Esc escape-hatch) + a `speed_reset.test.js` stub. ⚠️ **Needs a manual smoke-test before shipping** — this is a core playback change; verify on desktop that the tuner mic doesn't contend with note_detect's scoring input (ASIO/exclusive mode), per the design charrette.
- **v3 Songs List View: favoriting a song now turns the heart red immediately (no re-search needed).** In the tree / "List View" (Songs → List → expand an artist), clicking the heart flipped the glyph ♡→♥ but it stayed dim grey until you re-searched the library — reported on macOS + Windows, open since 0.3.0 / 2026-06-25. One shared `wireCards()` `[data-fav]` handler (`static/v3/songs.js`) serves both the grid card and the List-View row, but the two 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 the row kept `text-fb-textDim` alongside the freshly-added `text-fb-accent`, and the dim class won by CSS source order (glyph changed, colour didn't). Each heart now declares its idle colour via a `data-fav-idle` attribute and the handler swaps exactly that class, so only one colour class is ever present; the handler also 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`.
- **v3 Songs AZ rail: taps now land reliably, a drag releases exactly on the let-go letter, and the rail is large enough to hit on hi-res displays.** Follow-up to the rail's debut (#634); three bugs reported on macOS + Windows (0.3.0, 2026-06-29): a tap often did nothing ("clicked O, nothing happened"), a drag "got you kind of there but where you release isn't where you get sent," and the rail was "way too small" at 1440p and didn't scale with resolution. Root causes & fixes, all in `static/v3/songs.js` + `static/v3/v3.css` (`bindRailOnce`/`jumpToLetter`/`.v3-azrail`): (1) **taps**`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 → no-op. The jump is now driven from `pointerdown` itself (seek on press); the `click` handler is reduced to **keyboard activation only** (`e.detail === 0`, Enter/Space). (2) **drag precision** — every letter crossed fired `jumpToLetter` with `behavior:'smooth'`; stacked smooth-scroll animations over the virtualized grid lagged and settled short of the release. `jumpToLetter(letter, smooth)` now scrolls **instantly while scrubbing** (`'auto'`) and only animates discrete taps/keyboard jumps, so the grid tracks the finger and the release lands on the let-go letter. (3) **size** — the letters were a fixed `.62rem` glued at `right:2px` (~13px-tall target on the screen edge); 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 + the present-letter gating are unchanged. Reported by =Scr4tch= and MajorMokoto. Tests: `tests/js/v3_az_rail.test.js` (pointerdown-seek, keyboard-only click guard, instant-vs-smooth scroll).
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
+12
View File
@@ -231,6 +231,18 @@ window.feedBackViz_my_viz = function () {
// toneChanges, toneBase, mastery, hasPhraseData, inverted,
// lefty, renderScale, lyricsVisible, the 2D coordinate
// helpers project and fretX, and getNoteState (see below).
// The bundle OBJECT is reused across frames (mutated in
// place — no per-frame allocation): never cache it or
// compare its identity between frames; field values are
// only valid for the current draw call. Array FIELDS still
// swap reference when chart data changes, so field-identity
// caches (`myRef !== bundle.chords`) remain valid.
// Windowed-iteration helpers (stable fn refs): bundle
// .lowerBoundT(arr, time) is a lower-bound binary search on
// `.t` (notes/chords); bundle.lowerBoundTime(arr, time) on
// `.time` (beats/anchors/sections). Use these to cull to
// the visible window instead of full-scanning chart arrays
// per frame.
// `stringCount` is the active arrangement's string count (4
// for bass, 6 for guitar, 7+ for extended-range GP imports —
// size string-indexed geometry against this, not a hardcoded
+221 -140
View File
@@ -229,12 +229,29 @@ def _build_tempo_map(root: ET.Element) -> list[tuple[int, float]]:
return events
def _parse_tuning(el: ET.Element) -> list[int]:
"""Return the string-tuning MIDI pitches from the first ``Tuning`` Property
at or below ``el`` (a Track or a single Staff), high string first. ``[]`` if
there is no Tuning property or its Pitches text is unparseable."""
for prop in el.findall('.//Property'):
if prop.get('name') == 'Tuning':
pe = prop.find('Pitches')
if pe is not None and pe.text:
try:
return [int(p) for p in pe.text.split()]
except ValueError:
return []
break
return []
def _gpif_tracks(root: ET.Element) -> list[dict]:
"""Return a list of raw track dicts from the GPIF Tracks element."""
# Lookups for per-track note counting. MasterBar/Bars lists one bar id per
# track in raw Tracks order, so the enumerate index below (which counts
# skipped pseudo-tracks) is the correct bar-lookup index — same mapping
# convert_file uses via filtered_to_raw.
# *stave* (not per Track element) in document order. A multi-stave track
# (e.g. GP8 piano with treble + bass) occupies N consecutive columns; the
# bar_column counter below advances by num_staves per track so every track
# gets the correct column regardless of neighbour stave counts.
_masterbars = list(root.find('MasterBars') or [])
_bars_by_id = {b.get('id'): b for b in (root.find('Bars') or [])}
_voices_by_id = {v.get('id'): v for v in (root.find('Voices') or [])}
@@ -279,10 +296,17 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
return n
result = []
for raw_idx, t in enumerate(root.find('Tracks') or []):
bar_column = 0
for t in (root.find('Tracks') or []):
# Count staves: each Staff occupies one column in MasterBar/Bars.
# Default to 1 for tracks with no explicit <Staves> (GP3/4/5, old GPX).
num_staves = max(1, len(list(t.findall('Staves/Staff'))))
stave_columns = list(range(bar_column, bar_column + num_staves))
name = (t.findtext('Name') or '').strip()
if name.startswith('@$') and name.endswith('$@'):
continue # GP internal pseudo-tracks (raw_idx still advances)
bar_column += num_staves
continue # GP internal pseudo-tracks (bar_column still advances)
gm = t.find('GeneralMidi')
midi_program = 0
@@ -319,27 +343,37 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
except (ValueError, TypeError):
pass
# String tuning
string_pitches: list[int] = []
for prop in t.findall('.//Property'):
if prop.get('name') == 'Tuning':
pe = prop.find('Pitches')
if pe is not None and pe.text:
try:
string_pitches = [int(p) for p in pe.text.split()]
except ValueError:
pass
# String tuning — one list per stave, in stave order. Reading all
# `.//Property` descendants across every stave meant the last stave's
# tuning overwrote the first; for a GP8 piano (treble 6-string +
# bass 5-string) that caused stave-0 notes with String=5 to be
# out-of-range against the 5-entry bass tuning and silently dropped.
# A staff with no Tuning of its own falls back to the track-level
# property (never to []) — an empty list silently drops every fretted
# note on that stave in `_note_midi`. The list stays parallel to
# `stave_columns` so a per-stave column always has a matching tuning.
_track_tuning = _parse_tuning(t)
_staff_els = list(t.findall('Staves/Staff'))
if _staff_els:
stave_pitches = [(_parse_tuning(s) or _track_tuning) for s in _staff_els]
else:
# No <Staves> (GP3/4/5 or old GPX): single track-level tuning.
stave_pitches = [_track_tuning]
result.append({
'_el': t,
'id': t.get('id', ''),
'name': name,
'string_pitches': string_pitches,
'string_pitches': stave_pitches[0], # primary stave (existing key)
'num_staves': num_staves,
'stave_columns': stave_columns,
'stave_pitches': stave_pitches,
'is_drums': is_drums,
'midi_program': midi_program,
'midi_channel': midi_channel,
'note_count': _note_count_for_raw(raw_idx),
'note_count': sum(_note_count_for_raw(c) for c in stave_columns),
})
bar_column += num_staves
return result
@@ -367,6 +401,121 @@ def _beat_dur_secs(beat_el: ET.Element, rhythms_dict: dict, tempo_bpm: float) ->
return dur_qn * (60.0 / tempo_bpm)
def _collect_column_notes(
col: int,
string_pitches: list[int],
*,
masterbars: list,
bars_by_id: dict,
voices_dict: dict,
beats_dict: dict,
notes_dict: dict,
rhythms_dict: dict,
tempo_map: list,
tempo_bpm: float,
audio_offset: float,
) -> list['RsNote']:
"""Walk one ``MasterBar/Bars`` column (a single stave / hand) and return its
notes as keys-encoded ``RsNote`` (``string = midi // 24``, ``fret = midi %
24``). Tie destinations extend the matching prior note's sustain (keyed by
pitch, so polyphonic parts are handled) rather than emitting a new note —
mirroring the main ``convert_file`` builder, including its full-precision
timing and the 0.2s sustain threshold.
Shared by the GPX LH/RH pair merge and the GP8 multi-stave (grand-staff)
fold so the two code paths can never drift in tie / timing / dedup handling.
"""
from gp2rs import RsNote # lazy: gp2rs<->gpx circular import (see convert_file)
notes: list[RsNote] = []
last_per_key: dict[int, RsNote] = {}
tempo_iter = iter(tempo_map)
next_bar, next_bpm = next(tempo_iter, (999999, tempo_bpm))
cur_tempo = tempo_bpm
t_cursor = 0.0
for mb_idx, mb in enumerate(masterbars):
while mb_idx >= next_bar:
cur_tempo = next_bpm
next_bar, next_bpm = next(tempo_iter, (999999, cur_tempo))
ts = mb.findtext('Time', '4/4')
try:
nb, db = [int(x) for x in ts.split('/')]
except ValueError:
nb, db = 4, 4
bar_dur = nb * (4.0 / db) * (60.0 / cur_tempo)
bar_ids = mb.findtext('Bars', '').split()
bid = bar_ids[col] if col < len(bar_ids) else '-1'
if bid != '-1' and bid:
bar = bars_by_id.get(bid)
if bar is not None:
for vid in bar.findtext('Voices', '').split():
if vid == '-1':
continue
voice = voices_dict.get(vid)
if voice is None:
continue
vt = t_cursor
for beat_id in voice.findtext('Beats', '').split():
beat = beats_dict.get(beat_id)
if beat is None:
continue
dur = _beat_dur_secs(beat, rhythms_dict, cur_tempo)
for nid in beat.findtext('Notes', '').strip().split():
note_el = notes_dict.get(nid)
if note_el is None:
continue
if _note_is_tie(note_el):
tie_midi = _note_midi(note_el, string_pitches)
if tie_midi is not None:
prev = last_per_key.get(tie_midi)
tie_t = vt + audio_offset
if prev is not None and prev.time < tie_t:
prev.sustain = max(
prev.sustain, (tie_t + dur) - prev.time)
continue
midi = _note_midi(note_el, string_pitches)
if midi is None:
continue
rn = RsNote(
time=vt + audio_offset,
string=midi // 24,
fret=midi % 24,
sustain=dur if dur > 0.2 else 0.0,
)
notes.append(rn)
last_per_key[midi] = rn
vt += dur
t_cursor += bar_dur
return notes
def _merge_lh_notes(rs_notes: list, rs_chords: list, lh_notes: list) -> None:
"""Fold ``lh_notes`` (a second stave / left hand) into ``rs_notes`` in
place, de-duplicating simultaneous same-pitch notes and keeping the LONGER
sustain when both hands strike the same key at the same instant. Seeds the
dedup set from chord notes too (polyphonic RH beats live in
``rs_chords[*].notes``). No-op for an empty ``lh_notes``."""
if not lh_notes:
return
seen: dict[tuple, RsNote] = {}
for n in rs_notes:
seen.setdefault((round(n.time, 3), n.string, n.fret), n)
for c in rs_chords:
for cn in c.notes:
seen.setdefault((round(cn.time, 3), cn.string, cn.fret), cn)
for rn in lh_notes:
k = (round(rn.time, 3), rn.string, rn.fret)
existing = seen.get(k)
if existing is None:
rs_notes.append(rn)
seen[k] = rn
elif rn.sustain > existing.sustain:
# Mutating the RsNote also updates it in place inside any RH chord.
existing.sustain = rn.sustain
rs_notes.sort(key=lambda n: (n.time, n.string))
# ---------------------------------------------------------------------------
# Drum encoding tables — ported from alphaTab PercussionMapper (MIT licensed)
# ---------------------------------------------------------------------------
@@ -1366,17 +1515,16 @@ def convert_file(
rhythms_dict = {r.get('id'): r for r in (root.find('Rhythms') or [])}
_bend_divisor = _gpx_bend_scale(root) # GPIF bend value -> semitones
# Map filtered track index -> raw track index (needed for bar lookup)
raw_tracks = list(root.find('Tracks') or [])
filtered_to_raw: dict[int, int] = {}
filtered_pos = 0
for raw_idx, t_el in enumerate(raw_tracks):
name = (t_el.findtext('Name') or '').strip()
if name.startswith('@$') and name.endswith('$@'):
continue
filtered_to_raw[filtered_pos] = raw_idx
filtered_pos += 1
# Map filtered track index -> bar column (MasterBar/Bars position for
# stave 0 of that track). `_gpif_tracks` already computed the per-stave
# column layout (advancing by num_staves per track, pseudo-tracks skipped),
# so reuse its `stave_columns[0]` rather than re-deriving the counting rule
# here — divergence in stave counting *is* the bug class this fix closes.
# NB: despite the historical name, the value is a bar *column*, not a raw
# Track index — do not index `root.find('Tracks')` with it.
filtered_to_raw: dict[int, int] = {
i: t['stave_columns'][0] for i, t in enumerate(tracks)
}
# Detect and merge Piano LH+RH pairs into single full-keyboard arrangements
track_indices, _piano_merge_map = _find_piano_pairs(track_indices, tracks, names)
@@ -1468,7 +1616,16 @@ def convert_file(
is_keys = (
not is_drum and not is_vocal
and (
any(kw in track['name'].lower() for kw in ('piano', 'keys', 'keyboard', 'organ'))
# A multi-stave track is a grand staff (treble + bass) — i.e. a
# keyboard-family part. Treating it as keys end-to-end keeps the
# stave-0 encoding and the folded stave-1+ encoding consistent
# (both midi//24, midi%24) and makes the `note_count` preview
# (which sums every stave column) match what actually imports,
# even for instruments the name/program heuristics miss (harp,
# celesta, marimba). GPIF writes guitars as a single Staff, so
# this does not sweep in ordinary fretted tracks.
track.get('num_staves', 1) > 1
or any(kw in track['name'].lower() for kw in ('piano', 'keys', 'keyboard', 'organ'))
or arr_name.lower().startswith('keys')
or (
not track['string_pitches']
@@ -1534,7 +1691,6 @@ def convert_file(
pending_slides: list = [] # (RsNote, rs_string, gp_slide_flags) — resolved post-loop
current_time = 0.0
num_raw_tracks = len(raw_tracks)
# Resolve current tempo per bar from the tempo map
_tempo_iter = iter(tempo_map)
@@ -1866,112 +2022,20 @@ def convert_file(
tuning = _gpx_tuning(track)
# Merge Piano LH notes into this (RH) arrangement if a pair was detected
_walk_kwargs = dict(
masterbars=masterbars, bars_by_id=bars_by_id,
voices_dict=voices_dict, beats_dict=beats_dict,
notes_dict=notes_dict, rhythms_dict=rhythms_dict,
tempo_map=tempo_map, tempo_bpm=tempo_bpm, audio_offset=audio_offset,
)
if is_keys and track_idx in _piano_merge_map:
# GPX LH/RH pair: the left hand is a *separate* Track element. Walk
# its column and fold it into this (right-hand) arrangement.
lh_idx = _piano_merge_map[track_idx]
lh_track = tracks[lh_idx]
lh_raw_idx = filtered_to_raw.get(lh_idx, lh_idx)
_lh_notes: list[RsNote] = []
_lh_last_per_key: dict[int, RsNote] = {}
_lh_tempo_iter = iter(tempo_map)
_lh_next_bar, _lh_next_bpm = next(_lh_tempo_iter, (999999, tempo_bpm))
_lh_cur_tempo = tempo_bpm
_lh_time = 0.0
for _lh_mb_idx, _lh_mb in enumerate(masterbars):
while _lh_mb_idx >= _lh_next_bar:
_lh_cur_tempo = _lh_next_bpm
_lh_next_bar, _lh_next_bpm = next(_lh_tempo_iter, (999999, _lh_cur_tempo))
_lh_ts = _lh_mb.findtext('Time', '4/4')
try:
_lh_nb, _lh_db = [int(x) for x in _lh_ts.split('/')]
except ValueError:
_lh_nb, _lh_db = 4, 4
_lh_bar_dur = _lh_nb * (4.0 / _lh_db) * (60.0 / _lh_cur_tempo)
_lh_bar_ids = _lh_mb.findtext('Bars', '').split()
_lh_bid = _lh_bar_ids[lh_raw_idx] if lh_raw_idx < len(_lh_bar_ids) else '-1'
if _lh_bid != '-1' and _lh_bid:
_lh_bar = bars_by_id.get(_lh_bid)
if _lh_bar is not None:
for _lh_vid in _lh_bar.findtext('Voices', '').split():
if _lh_vid == '-1':
continue
_lh_voice = voices_dict.get(_lh_vid)
if _lh_voice is None:
continue
_lh_vt = _lh_time
for _lh_beat_id in _lh_voice.findtext('Beats', '').split():
_lh_beat = beats_dict.get(_lh_beat_id)
if _lh_beat is None:
continue
_lh_dur = _beat_dur_secs(_lh_beat, rhythms_dict, _lh_cur_tempo)
for _lh_nid in _lh_beat.findtext('Notes', '').strip().split():
_lh_note_el = notes_dict.get(_lh_nid)
if _lh_note_el is None:
continue
if _note_is_tie(_lh_note_el):
# Extend the matching prior note (same
# pitch), mirroring the main builder's
# last_note_per_key handling — blindly
# extending the last-emitted note
# mishandles polyphonic (chord) LH parts.
_tie_midi = _note_midi(_lh_note_el, lh_track['string_pitches'])
if _tie_midi is not None:
_prev = _lh_last_per_key.get(_tie_midi)
# Full-precision comparison (matching
# the main builder); rounding only
# happens at XML serialization. Rounding
# here could make a short note appear to
# start at the tie time and skip the
# sustain extension.
_tie_t = _lh_vt + audio_offset
if _prev is not None and _prev.time < _tie_t:
_prev.sustain = max(
_prev.sustain,
(_tie_t + _lh_dur) - _prev.time,
)
continue
_lh_midi = _note_midi(_lh_note_el, lh_track['string_pitches'])
if _lh_midi is None:
continue
# Keep full-precision time (like the main
# convert_file() builder — rounding happens at
# serialization); same 0.2s sustain threshold.
_lh_rn = RsNote(
time=_lh_vt + audio_offset,
string=_lh_midi // 24,
fret=_lh_midi % 24,
sustain=_lh_dur if _lh_dur > 0.2 else 0.0,
)
_lh_notes.append(_lh_rn)
_lh_last_per_key[_lh_midi] = _lh_rn
_lh_vt += _lh_dur
_lh_time += _lh_bar_dur
# Merge: combine and deduplicate simultaneous same-pitch notes, then
# sort by time. Map each (time, string, fret) to its existing RsNote
# so that when both hands hit the same key at the same instant we
# keep the LONGER sustain instead of arbitrarily discarding the LH
# one. Seed from both single notes and chord notes — polyphonic RH
# beats live in rs_chords[*].notes, so seeding from rs_notes alone
# would let an identical LH note slip in as a duplicate.
_seen: dict[tuple, RsNote] = {}
for _n in rs_notes:
_seen.setdefault((round(_n.time, 3), _n.string, _n.fret), _n)
for _c in rs_chords:
for _cn in _c.notes:
_seen.setdefault((round(_cn.time, 3), _cn.string, _cn.fret), _cn)
for _lh_rn in _lh_notes:
_k = (round(_lh_rn.time, 3), _lh_rn.string, _lh_rn.fret)
_existing = _seen.get(_k)
if _existing is None:
rs_notes.append(_lh_rn)
_seen[_k] = _lh_rn
elif _lh_rn.sustain > _existing.sustain:
# Same key both hands — preserve the longer sustain (mutating
# the RsNote also updates it in place inside any RH chord).
_existing.sustain = _lh_rn.sustain
rs_notes.sort(key=lambda n: (n.time, n.string))
_merge_lh_notes(rs_notes, rs_chords, _collect_column_notes(
lh_raw_idx, lh_track['string_pitches'], **_walk_kwargs))
# Collapse "Keys 2" -> "Keys": the merged LH+RH is a single
# keyboard arrangement. Keep the standard "Keys" name (not "Piano")
@@ -1979,6 +2043,18 @@ def convert_file(
# auto-select (which keys on arr_name.startswith("keys")) still work.
arr_name = re.sub(r'\s*\d+$', '', arr_name).strip() or 'Keys'
elif track.get('num_staves', 1) > 1:
# GP8 grand-staff keyboard: staves 1+ (bass clef, and any further
# staves) are extra MasterBar/Bars columns for the SAME Track
# element. Fold each one in, exactly like the GPX LH merge above.
# (num_staves > 1 implies is_keys, set above.) Iterating every
# extra column — not just stave_columns[1] — keeps the arrangement
# consistent with note_count, which sums all columns.
for _col, _sp in zip(track['stave_columns'][1:],
track['stave_pitches'][1:]):
_merge_lh_notes(rs_notes, rs_chords, _collect_column_notes(
_col, _sp, **_walk_kwargs))
# Resolve pending slides now that every note on each string is known.
# GPIF slide flags: 1=shift, 2=legato (both slide to the NEXT note on the
# string); 4=slide out downwards, 8=slide out upwards (unpitched).
@@ -2032,19 +2108,24 @@ def convert_file(
try:
import gp2notation as _gp2notation
_lh_idx = _piano_merge_map.get(track_idx)
if _lh_idx is not None:
# GPX LH/RH pair (two separate Track elements)
_nt_lh_raw = filtered_to_raw.get(_lh_idx, _lh_idx)
_nt_lh_sp = tracks[_lh_idx]['string_pitches']
elif track.get('num_staves', 1) > 1:
# GP8 two-stave piano (one Track with multiple <Staves>)
_nt_lh_raw = track['stave_columns'][1]
_nt_lh_sp = (track['stave_pitches'][1]
if len(track.get('stave_pitches', [])) > 1 else [])
else:
_nt_lh_raw, _nt_lh_sp = None, []
_payload = _gp2notation.convert_track_to_notation(
root, raw_idx, track['string_pitches'],
instrument='piano',
audio_offset=audio_offset,
track_name=track['name'],
lh_raw_idx=(
filtered_to_raw.get(_lh_idx, _lh_idx)
if _lh_idx is not None else None
),
lh_string_pitches=(
tracks[_lh_idx]['string_pitches']
if _lh_idx is not None else None
),
lh_raw_idx=_nt_lh_raw,
lh_string_pitches=_nt_lh_sp or None,
)
_gp2notation.write_notation_sidecar(filepath, _payload)
except Exception:
+295
View File
@@ -0,0 +1,295 @@
"""Text-matching engine for MusicBrainz metadata enrichment (P8).
Pure functions only no network, no database, no server imports so the
whole matching pipeline is unit-testable in isolation. server.py owns the
throttled HTTP transport and the song_enrichment writes; this module owns:
* denoise/tokenize: fold community chart-title noise (author suffixes,
``(440Hz)``/``(Live)``/``(No Lead)``/``(v2)`` parentheticals, punctuation,
diacritics, ``AC DC``/``ACDC``/``AC/DC`` spelling drift) into a comparable
token form,
* similarity + scoring: token-set similarity on artist+title with year and
duration proximity as corroborating bonuses,
* tier classification: auto (high) / review (medium) / none (low) the
design rule is that a WRONG match is worse than no match, so the auto
tier is deliberately strict and medium confidence goes to a human,
* MusicBrainz JSON parsing: normalize ``/ws/2`` recording documents into
the flat candidate dicts the review UI and song_enrichment store.
"""
import re
import unicodedata
# ── Tier thresholds ───────────────────────────────────────────────────────────
# Combined score = 0.5*artist_sim + 0.5*title_sim + corroboration bonuses
# (capped at 1.0). Wrong-match is worse than slow (design §5), so `auto`
# additionally requires BOTH fields to individually agree — a perfect title
# with a mismatched artist (a cover) must never auto-canonicalize, whatever
# the combined threshold is set to. AUTO_MIN is only the DEFAULT: the host
# surfaces it as the user-configurable "auto-apply confidence" setting and
# passes the chosen value into classify(auto_min=…).
AUTO_MIN = 0.90
AUTO_ARTIST_MIN = 0.8
AUTO_TITLE_MIN = 0.6
REVIEW_MIN = 0.65
YEAR_BONUS = 0.05 # candidate year within ±1 of the chart's year
DURATION_BONUS = 0.05 # candidate length within 5s of the chart's audio
DURATION_BONUS_LOOSE = 0.025 # …within 15s
_DURATION_TIGHT = 5
_DURATION_LOOSE = 15
# ── Denoise ───────────────────────────────────────────────────────────────────
# A parenthetical/bracketed group is dropped when it contains any of these
# noise terms as a whole word (chart-variant markers, tuning/pitch notes,
# performance qualifiers) or when it reads as an author credit ("by X",
# "charted by X"). Both sides of a comparison are denoised symmetrically, so
# over-stripping a meaningful group costs a little precision but never
# produces an asymmetric mismatch.
_NOISE_TERMS = (
r"440\s*hz", r"a440", r"432\s*hz",
r"live", r"acoustic", r"instrumental",
r"no\s+(?:lead|rhythm|bass|vocals?|drums)",
r"(?:lead|rhythm|bass)\s+only",
r"v\d+", r"ver(?:sion)?\s*\d+",
r"remaster(?:ed)?(?:\s*\d{4})?", r"re-?recorded?",
r"fix(?:ed)?", r"updated?",
r"bonus", r"custom",
)
_NOISE_GROUP_RE = re.compile(
r"[(\[][^)\]]*\b(?:" + "|".join(_NOISE_TERMS) + r")\b[^)\]]*[)\]]",
re.IGNORECASE,
)
# Author credits: "(by SomeCharter)", "[charted by X]", "(chart by X)".
_AUTHOR_GROUP_RE = re.compile(
r"[(\[]\s*(?:chart(?:ed)?\s+)?by\s+[^)\]]*[)\]]", re.IGNORECASE)
# Trailing "- by SomeCharter" outside parens.
_AUTHOR_TAIL_RE = re.compile(r"\s+-\s+(?:chart(?:ed)?\s+)?by\s+.+$", re.IGNORECASE)
_PUNCT_RE = re.compile(r"[^\w\s]|_")
_WS_RE = re.compile(r"\s+")
def _strip_diacritics(s: str) -> str:
return "".join(
ch for ch in unicodedata.normalize("NFKD", s)
if not unicodedata.combining(ch)
)
def denoise(s, *, strip_leading_the: bool = False) -> str:
"""Fold a community metadata string into its comparable form:
lowercase, diacritics stripped, noise parentheticals and author credits
removed, punctuation collapsed to spaces. ``strip_leading_the`` drops a
leading "The " used for ARTIST comparison only ("The Beatles" ==
"Beatles"), never titles ("The Trooper" must keep its "the")."""
s = str(s or "")
s = _NOISE_GROUP_RE.sub(" ", s)
s = _AUTHOR_GROUP_RE.sub(" ", s)
s = _AUTHOR_TAIL_RE.sub(" ", s)
s = _strip_diacritics(s).casefold()
s = s.replace("&", " and ")
s = _PUNCT_RE.sub(" ", s)
s = _WS_RE.sub(" ", s).strip()
if strip_leading_the and s.startswith("the "):
s = s[4:]
return s
def tokens(s, **kw) -> list[str]:
d = denoise(s, **kw)
return d.split() if d else []
def _compact(toks: list[str]) -> str:
return "".join(toks)
def similarity(a, b, *, artist: bool = False) -> float:
"""Token-set similarity in [0, 1]. Dice coefficient over the denoised
token sets, with a compacted-string equality fold so spelling drift that
only moves token boundaries ("ACDC" / "AC DC" / "AC/DC", "Greenday" /
"Green Day") counts as identical."""
kw = {"strip_leading_the": artist}
ta, tb = tokens(a, **kw), tokens(b, **kw)
if not ta or not tb:
return 0.0
if _compact(ta) == _compact(tb):
return 1.0
sa, sb = set(ta), set(tb)
return 2.0 * len(sa & sb) / (len(sa) + len(sb))
def _year_int(v):
try:
y = int(str(v)[:4])
return y if y > 0 else None
except (TypeError, ValueError):
return None
def _duration_int(v):
try:
d = int(round(float(v)))
return d if d > 0 else None
except (TypeError, ValueError):
return None
def score_candidate(song: dict, cand: dict) -> float:
"""Combined confidence that MusicBrainz candidate `cand` is the song the
chart transcribes. 0.5*artist + 0.5*title, plus small year/duration
corroboration bonuses, capped at 1.0. Missing fields score 0 on their
half classify() separately refuses to auto-match without both."""
artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True)
title_sim = similarity(song.get("title"), cand.get("title"))
score = 0.5 * artist_sim + 0.5 * title_sim
sy, cy = _year_int(song.get("year")), _year_int(cand.get("year"))
if sy and cy and abs(sy - cy) <= 1:
score += YEAR_BONUS
sd, cd = _duration_int(song.get("duration")), _duration_int(cand.get("duration"))
if sd and cd:
diff = abs(sd - cd)
if diff <= _DURATION_TIGHT:
score += DURATION_BONUS
elif diff <= _DURATION_LOOSE:
score += DURATION_BONUS_LOOSE
return min(score, 1.0)
def classify(song: dict, cand: dict, score: float, auto_min: float | None = None) -> str:
"""Tier for a scored candidate: 'auto' | 'review' | 'none'.
`auto` (tier-2) needs the combined score AND per-field agreement AND
both fields present a perfect-title/wrong-artist cover, or a chart
with no artist at all, is at best a review item, never an auto match.
`auto_min` overrides the default combined-score threshold (the user's
"auto-apply confidence" setting); the per-field floors always apply.
"""
if auto_min is None:
auto_min = AUTO_MIN
artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True)
title_sim = similarity(song.get("title"), cand.get("title"))
if (score >= auto_min and artist_sim >= AUTO_ARTIST_MIN
and title_sim >= AUTO_TITLE_MIN):
return "auto"
if score >= REVIEW_MIN:
return "review"
return "none"
def rank_candidates(song: dict, candidates: list[dict]) -> list[dict]:
"""Score every candidate against the song and return them sorted by our
score (MusicBrainz's own search score is only a tiebreak). Each returned
dict is a copy carrying `score` (rounded it's displayed and stored)."""
ranked = []
for cand in candidates or []:
c = dict(cand)
c["score"] = round(score_candidate(song, cand), 4)
ranked.append(c)
ranked.sort(key=lambda c: (c["score"], c.get("mb_score") or 0), reverse=True)
return ranked
# ── MusicBrainz query + response parsing ──────────────────────────────────────
def _lucene_escape_phrase(s: str) -> str:
"""Escape a string for use inside a quoted Lucene phrase."""
return s.replace("\\", "\\\\").replace('"', '\\"')
def build_recording_query(artist, title) -> str:
"""Lucene query for /ws/2/recording. Built from the DENOISED fields —
the noise we strip (author credits, "(Live)", "(v2)") would otherwise
poison the search server's own scoring."""
t = denoise(title)
a = denoise(artist)
parts = []
if t:
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
if a:
parts.append('artist:"%s"' % _lucene_escape_phrase(a))
return " AND ".join(parts)
def _artist_credit(doc: dict) -> tuple[str, str, str]:
"""(display name, artist mbid, sort name) from an artist-credit array."""
credits = doc.get("artist-credit") or []
name = ""
for part in credits:
if isinstance(part, dict):
name += str(part.get("name", "")) + str(part.get("joinphrase", "") or "")
else: # ws/2 can emit bare join strings in older serializations
name += str(part)
first = next((p for p in credits if isinstance(p, dict)), None) or {}
artist = first.get("artist") or {}
return name, str(artist.get("id", "") or ""), str(artist.get("sort-name", "") or "")
def _best_release(doc: dict) -> dict:
"""Pick the release used for canon album/year: prefer Official status and
an Album release-group, then the earliest date. Returns {} if none."""
releases = [r for r in (doc.get("releases") or []) if isinstance(r, dict)]
if not releases:
return {}
def sort_key(r):
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
rg = r.get("release-group") or {}
album_ok = 0 if str(rg.get("primary-type", "")).lower() == "album" else 1
date = str(r.get("date", "") or "9999")
return (status_ok, album_ok, date)
return sorted(releases, key=sort_key)[0]
def _genres(doc: dict, limit: int = 5) -> list[str]:
"""Genre names from a recording doc. Search results carry folksonomy
`tags`; lookups with inc=genres carry curated `genres`. Both are
[{name, count}] take the most-voted few."""
raw = doc.get("genres") or doc.get("tags") or []
entries = [e for e in raw if isinstance(e, dict) and e.get("name")]
entries.sort(key=lambda e: e.get("count") or 0, reverse=True)
return [str(e["name"]) for e in entries[:limit]]
def parse_recording_doc(doc: dict) -> dict | None:
"""Normalize one /ws/2 recording document (search hit or direct lookup)
into the flat candidate dict stored in song_enrichment.candidates and
rendered by the review drawer. Returns None for malformed docs."""
if not isinstance(doc, dict) or not doc.get("id") or not doc.get("title"):
return None
artist_name, artist_id, artist_sort = _artist_credit(doc)
release = _best_release(doc)
length = doc.get("length")
try:
duration = int(round(float(length) / 1000.0)) if length else None
except (TypeError, ValueError):
duration = None
isrcs = doc.get("isrcs") or []
isrcs = [str(i) for i in isrcs if isinstance(i, (str,))]
return {
"recording_id": str(doc["id"]),
"title": str(doc.get("title", "")),
"artist": artist_name,
"artist_id": artist_id,
"artist_sort": artist_sort,
"release_id": str(release.get("id", "") or ""),
"album": str(release.get("title", "") or ""),
"year": str(release.get("date", "") or "")[:4],
"duration": duration,
"isrc": isrcs[0] if isrcs else "",
"genres": _genres(doc),
"mb_score": int(doc.get("score") or 0),
}
def parse_search_response(body: dict) -> list[dict]:
"""Candidates from a /ws/2/recording search response."""
docs = (body or {}).get("recordings") or []
out = []
for doc in docs:
cand = parse_recording_doc(doc)
if cand:
out.append(cand)
return out
+21 -5
View File
@@ -114,11 +114,27 @@ def split_hands(notes: list[dict]) -> dict[str, list[dict]]:
pitches = sorted(n["midi"] for n in group)
span = pitches[-1] - pitches[0]
if len(pitches) > 1 and span > HAND_SPLIT_SPAN_SEMITONES:
# Largest internal gap; ties resolve to the lowest such gap so the
# left hand keeps the tight low cluster.
gaps = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
split_after = gaps.index(max(gaps))
threshold = pitches[split_after] # lh: midi <= threshold
# Prefer middle C as the split boundary when notes straddle it —
# this correctly handles bass+treble chords from piano imports where
# the largest-gap heuristic picks the wrong split point (e.g.
# [G2, E3, C4]: largest gap is G2→E3 but the real split is E3|C4).
# BUT only when both resulting hands are themselves playable: a bass
# note under a treble voicing that merely dips below C4 (e.g.
# [E2, B3, D4, G4]) would otherwise land E2+B3 in one hand — a
# 19-semitone span that re-violates HAND_SPLIT_SPAN_SEMITONES. When
# the middle-C split produces an unplayable hand, fall back to the
# largest internal gap (which correctly isolates E2 there).
threshold = None
if pitches[0] < MIDDLE_C <= pitches[-1]:
_lh = [p for p in pitches if p < MIDDLE_C]
_rh = [p for p in pitches if p >= MIDDLE_C]
if (_lh[-1] - _lh[0] <= HAND_SPLIT_SPAN_SEMITONES
and _rh[-1] - _rh[0] <= HAND_SPLIT_SPAN_SEMITONES):
threshold = MIDDLE_C - 1 # lh: midi < MIDDLE_C
if threshold is None:
gaps = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
split_after = gaps.index(max(gaps))
threshold = pitches[split_after]
for n in group:
hands["lh" if n["midi"] <= threshold else "rh"].append(n)
else:
+7 -6
View File
@@ -109,13 +109,14 @@ def _extract_meta_for_file(path: Path, dlc_root=None) -> dict:
the root it already resolved; in-process callers can pass the resolver
itself (e.g. `_get_dlc_dir`) to keep the lookup lazy.
FeedBack reads only its own `.sloppak` format and loose-folder XML
songs. Encrypted/proprietary archive formats are not supported and are
silently ignored (empty metadata) rather than decrypted.
FeedBack reads only its own song-package format (`.feedpak` / legacy
`.sloppak`) and loose-folder XML songs. Encrypted/proprietary archive
formats are not supported and are silently ignored (empty metadata)
rather than decrypted.
"""
# Sloppak is detected by `.sloppak` suffix only (cheap), so check it
# first — that way a user's loose folder named `foo.sloppak` still wins
# the sloppak branch instead of being misclassified.
# Packages are detected by suffix only (`.feedpak`/`.sloppak`, cheap), so
# check that first — that way a user's loose folder named `foo.feedpak`
# still wins the package branch instead of being misclassified.
if sloppak_mod.is_sloppak(path):
return _extract_meta_sloppak(path)
if loosefolder_mod.is_loose_song(path):
+6
View File
@@ -919,6 +919,12 @@ def extract_meta(path: Path) -> dict:
"artist": str(manifest.get("artist", "")),
"album": str(manifest.get("album", "")),
"year": str(manifest.get("year", "") or ""),
# Primary genre from the feedpak `genres` list (spec 1.12.0); [0] = primary.
"genre": (lambda g: str(g[0]) if isinstance(g, list) and g else "")(manifest.get("genres")),
# Album track order from the feedpak `track`/`disc` fields (spec 1.12.0);
# None when unauthored (the album view then falls back to title order).
"track_number": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("track")),
"disc": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("disc")),
"duration": float(manifest.get("duration", 0) or 0),
"tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name
"arrangements": arrangements,
+80 -6
View File
@@ -10,9 +10,9 @@ source of truth, so the change survives both incremental and full rescans.
only the keys present are overwritten, so an edit of just the title can't blank
out the artist.
Only feedBack's own ``.sloppak`` format (zip- or directory-form) is writable.
Unknown / unsupported shapes return False and the caller keeps the DB-only
update.
Only feedBack's own song-package format (zip- or directory-form, ``.feedpak``
or the legacy ``.sloppak`` suffix) is writable. Unknown / unsupported shapes
return False and the caller keeps the DB-only update.
"""
from __future__ import annotations
@@ -49,6 +49,15 @@ def _apply_to_sloppak_manifest(manifest: dict, fields: dict) -> bool:
if "year" in fields:
manifest["year"] = _coerce_year(fields["year"])
dirty = True
# `genres` is the feedpak list field (spec 1.12.0). Accepts a list/tuple
# (stringified item-wise) or a single name (wrapped); None leaves the
# existing value alone, mirroring the string fields above. Sent by the
# overwrite lane (R4b) — the manual Edit Metadata path never includes it.
if fields.get("genres") is not None:
raw = fields["genres"]
manifest["genres"] = ([str(g) for g in raw]
if isinstance(raw, (list, tuple)) else [str(raw)])
dirty = True
# Opportunistically declare the format version (spec §4) when we're already
# rewriting because a metadata field was supplied. Gated on `dirty` (i.e. a
# field was given) so this never forces a *standalone* rewrite with no fields
@@ -102,24 +111,89 @@ def write_sloppak_metadata(path: Path, fields: dict) -> bool:
mf = path / "manifest.yaml"
if not mf.exists() and (path / "manifest.yml").exists():
mf = path / "manifest.yml"
mf.write_text(dumped, encoding="utf-8")
# One-time backup + temp + atomic replace, exactly like the zip
# rewriter and gap_fill_sloppak's dir branch: the FIRST backup is the
# pristine author original and is never clobbered by a later write —
# it's what "Revert file to original" (R4b) restores.
backup = mf.with_name(mf.name + ".bak")
if mf.exists() and not backup.exists():
shutil.copy2(mf, backup)
tmp = mf.with_name(mf.name + ".tmp")
tmp.write_text(dumped, encoding="utf-8")
tmp.replace(mf)
return True
return _rewrite_zip_manifest(path, dumped)
def gap_fill_sloppak(path: Path, additions: dict) -> bool:
"""Append ABSENT top-level keys to a sloppak manifest (the gap-fill
contract: user-initiated, adds missing keys only, never replaces
anything the author set).
Unlike ``write_sloppak_metadata`` this does NOT re-serialize the
manifest because every added key is absent by definition, the new
lines can simply be appended, so the author's existing bytes (key
order, comments, formatting) survive verbatim. Directory form gets a
one-time ``manifest.yaml.bak`` + temp + atomic replace; zip form goes
through the same backup/temp/replace rewriter the metadata editor
uses. Returns True if anything was written; raises ``ValueError`` if
a requested key already exists (callers are expected to have checked
this is the last-line never-clobber guard)."""
import sloppak as sloppak_mod
path = Path(path)
if not additions:
return False
manifest = sloppak_mod.load_manifest(path) or {}
clash = sorted(k for k in additions if k in manifest)
if clash:
raise ValueError("gap-fill refused: key(s) already present: " + ", ".join(clash))
if path.is_dir():
mf = path / "manifest.yaml"
if not mf.exists() and (path / "manifest.yml").exists():
mf = path / "manifest.yml"
original = mf.read_text(encoding="utf-8")
else:
with zipfile.ZipFile(str(path), "r") as zin:
names = zin.namelist()
manifest_name = "manifest.yaml"
for cand in ("manifest.yaml", "manifest.yml"):
if cand in names:
manifest_name = cand
break
original = zin.read(manifest_name).decode("utf-8")
appended = original if original.endswith("\n") or not original else original + "\n"
appended += yaml.safe_dump(additions, sort_keys=False, allow_unicode=True)
if path.is_dir():
backup = mf.with_name(mf.name + ".bak")
if not backup.exists():
shutil.copy2(mf, backup)
tmp = mf.with_name(mf.name + ".tmp")
tmp.write_text(appended, encoding="utf-8")
tmp.replace(mf)
return True
return _rewrite_zip_manifest(path, appended)
def write_song_metadata(path: Path, fields: dict) -> bool:
"""Persist edited title/artist/album/year into the song's file.
Dispatches by shape: ``.sloppak`` files and sloppak directories
Dispatches by shape: zip-form song packages (``.feedpak`` / legacy
``.sloppak``, per ``sloppak.SONG_EXTS``) and package directories
(manifest.yaml present). Loose-folder and unknown shapes return False
(caller keeps the DB-only update). Returns True if the file was modified.
"""
from sloppak import SONG_EXTS
path = Path(path)
suffix = path.suffix.lower()
if path.is_dir():
if (path / "manifest.yaml").exists() or (path / "manifest.yml").exists():
return write_sloppak_metadata(path, fields)
return False
if suffix == ".sloppak":
if suffix in SONG_EXTS:
return write_sloppak_metadata(path, fields)
return False
+18
View File
@@ -71,6 +71,24 @@ tunes one of these, mirror the change here (and in `keys_highway_3d`):
mapping switch in `draw()`; addons dynamic-imported from
`/static/vendor/three/addons/` (no CDN fallback — direct render is the
graceful degrade)
- `_sparkBurst()` / `_sparkUpdate()` — pooled additive Points hit sparks
(pool 160 here vs the guitar's 256)
- `_makeGaussTex()` — soft-falloff DataTexture for the additive lane-flash
quads
- `_timingHex()` — early/late/on-time feedback colors (green/cyan/amber)
- `_ssActive()` — host splitscreen probe (minus the guitar's focus-API
checks, which it needs for input routing and we don't)
- `BG_THEMES` / `_bgThemeColors()` — the scene theme table (same ids/values
as the guitar's, except `default` which is this plugin's original
palette); one pick drives both of the guitar's background/highway axes
- `_applyCinematic()` — ambient/key rebalance (values tuned per plugin)
- `BG_STYLES` (off/particles/lights/geometric) + `_bgGetAnalyser()` /
`_bgReadBands()` — background ambience + the stems-first audio-analyser
bridge (guitar's diagnostics plumbing dropped; butterchurn/image/video
out of scope)
- `_drawScoreFx()` — the guitar's drawScoreFx overlay adapted to this
plugin's internal scoring (pops / tier rings / milestone bursts /
streak-break wash)
## Why a separate plugin (vs. drum mode inside highway_3d)?
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "drum_highway_3d",
"name": "3D Drum Highway",
"version": "0.3.0",
"version": "0.3.1",
"type": "visualization",
"bundled": true,
"script": "screen.js",
File diff suppressed because it is too large Load Diff
+165 -6
View File
@@ -45,7 +45,99 @@
<!-- Graphics -->
<div class="mt-6 border-t border-gray-800 pt-4">
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
<label for="drumh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
<label for="drumh3d-fx-theme" class="text-xs font-medium text-gray-400 mb-1 block">Scene theme</label>
<select id="drumh3d-fx-theme"
onchange="window.drumH3dSetTheme && window.drumH3dSetTheme(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="default">Default (original)</option>
<option value="midnight">Midnight</option>
<option value="charcoal">Charcoal</option>
<option value="deeppurple">Deep Purple</option>
<option value="forest">Forest</option>
<option value="warmslate">Warm Slate</option>
<option value="deepfocus">Deep Focus</option>
<option value="deepsea">Deep Sea</option>
<option value="cathode">Cathode</option>
<option value="cathodegreen">Cathode Green</option>
<option value="hearth">Hearth</option>
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
Background, floor and lane colours — the same theme names as the
guitar highway, so your look carries across instruments. Piece
colours stay with the Palette above.
</p>
<label for="drumh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
<input type="checkbox" id="drumh3d-fx-cinematic" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('cinematic', this.checked)">
Cinematic lighting
</label>
<p class="text-xs text-gray-500 mt-1">
Dimmer ambience, stronger key light — more depth on the cymbals
and drumheads.
</p>
<label for="drumh3d-fx-glow" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Glow strength <span id="drumh3d-fx-glow-val" class="text-gray-500 font-mono">0.50</span>
</label>
<input type="range" id="drumh3d-fx-glow"
min="0" max="1" step="0.05" value="0.5"
oninput="window.drumH3dSetFx && window.drumH3dSetFx('glow', this.value); document.getElementById('drumh3d-fx-glow-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
How hard the notes and hit line self-illuminate (0.5 = stock).
Pairs with bloom.
</p>
<label for="drumh3d-fx-vibrancy" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Lane vibrancy <span id="drumh3d-fx-vibrancy-val" class="text-gray-500 font-mono">0.85</span>
</label>
<input type="range" id="drumh3d-fx-vibrancy"
min="0" max="1" step="0.05" value="0.85"
oninput="window.drumH3dSetFx && window.drumH3dSetFx('vibrancy', this.value); document.getElementById('drumh3d-fx-vibrancy-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Lane stripe and accent-ring strength — lower for a calmer deck.
</p>
<label for="drumh3d-fx-bgstyle" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">Background ambience</label>
<select id="drumh3d-fx-bgstyle"
onchange="window.drumH3dSetBgStyle && window.drumH3dSetBgStyle(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="off">Off</option>
<option value="particles" selected>Particles</option>
<option value="lights">Stage lights</option>
<option value="geometric">Geometric</option>
</select>
<label for="drumh3d-fx-bgintensity" class="text-xs font-medium text-gray-400 mb-1 mt-2 block">
Ambience intensity <span id="drumh3d-fx-bgintensity-val" class="text-gray-500 font-mono">0.50</span>
</label>
<input type="range" id="drumh3d-fx-bgintensity"
min="0" max="1" step="0.05" value="0.5"
oninput="window.drumH3dSetFx && window.drumH3dSetFx('bgIntensity', this.value); document.getElementById('drumh3d-fx-bgintensity-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<label for="drumh3d-fx-bgreactive" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-2">
<input type="checkbox" id="drumh3d-fx-bgreactive" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('bgReactive', this.checked)">
Audio-reactive ambience
</label>
<p class="text-xs text-gray-500 mt-1">
The backdrop pulses with the mix (stems analyser when a sloppak
is loaded). Off = it animates on time only.
</p>
<label for="drumh3d-fx-scorefx" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="drumh3d-fx-scorefx" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('scoreFx', this.checked)">
Score effects
</label>
<p class="text-xs text-gray-500 mt-1">
+1 pops on hits, a ring pulse every 10-combo, milestone bursts at
25/50/100, and a brief red flicker when a streak breaks.
</p>
<label for="drumh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="drumh3d-fx-bloom" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('bloom', this.checked)">
Glow (bloom)
@@ -54,6 +146,46 @@
Soft light-bleed around the hit line and bright notes. Applies
live; turn off to reclaim GPU headroom on weak machines.
</p>
<label for="drumh3d-fx-sparks" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="drumh3d-fx-sparks" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('sparks', this.checked)">
Hit sparks
</label>
<p class="text-xs text-gray-500 mt-1">
A small particle burst on every scored pad hit.
</p>
<label for="drumh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="drumh3d-fx-timing" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('timingFx', this.checked)">
Timing colours
</label>
<p class="text-xs text-gray-500 mt-1">
Tint hit feedback by timing — on-time green, early cyan, late
amber. Off = always green.
</p>
<label for="drumh3d-fx-streak" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="drumh3d-fx-streak" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('streakFx', this.checked)">
Streak feedback
</label>
<p class="text-xs text-gray-500 mt-1">
Spark bursts grow with your combo.
</p>
<label for="drumh3d-fx-hitfx" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Hit feedback intensity <span id="drumh3d-fx-hitfx-val" class="text-gray-500 font-mono">0.70</span>
</label>
<input type="range" id="drumh3d-fx-hitfx"
min="0" max="1" step="0.05" value="0.7"
oninput="window.drumH3dSetFx && window.drumH3dSetFx('hitFx', this.value); document.getElementById('drumh3d-fx-hitfx-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Drives the lane flashes, approach glow and the kick camera pulse.
0 turns them off.
</p>
</div>
<!-- MIDI input -->
@@ -440,11 +572,38 @@
// FX toggles (drum_h3d_bg_* — guitar-parity graphics controls).
// Only explicit values override; absent/corrupt keys keep the
// default (ON), matching screen.js readFxSettings.
const storedBloom = localStorage.getItem('drum_h3d_bg_bloom');
if (storedBloom === '1' || storedBloom === 'true') {
document.getElementById('drumh3d-fx-bloom').checked = true;
} else if (storedBloom === '0' || storedBloom === 'false') {
document.getElementById('drumh3d-fx-bloom').checked = false;
const hydrateFxBool = (key, elId) => {
const raw = localStorage.getItem('drum_h3d_bg_' + key);
if (raw === '1' || raw === 'true') document.getElementById(elId).checked = true;
else if (raw === '0' || raw === 'false') document.getElementById(elId).checked = false;
};
hydrateFxBool('bloom', 'drumh3d-fx-bloom');
hydrateFxBool('sparks', 'drumh3d-fx-sparks');
hydrateFxBool('timingFx', 'drumh3d-fx-timing');
hydrateFxBool('streakFx', 'drumh3d-fx-streak');
hydrateFxBool('cinematic', 'drumh3d-fx-cinematic');
hydrateFxBool('bgReactive', 'drumh3d-fx-bgreactive');
hydrateFxBool('scoreFx', 'drumh3d-fx-scorefx');
const hydrateFxRange = (key, elId, valId) => {
const n = parseFloat(localStorage.getItem('drum_h3d_bg_' + key));
if (!Number.isFinite(n)) return;
const v = Math.min(1, Math.max(0, n));
document.getElementById(elId).value = String(v);
document.getElementById(valId).textContent = v.toFixed(2);
};
hydrateFxRange('hitFx', 'drumh3d-fx-hitfx', 'drumh3d-fx-hitfx-val');
hydrateFxRange('glow', 'drumh3d-fx-glow', 'drumh3d-fx-glow-val');
hydrateFxRange('vibrancy', 'drumh3d-fx-vibrancy', 'drumh3d-fx-vibrancy-val');
hydrateFxRange('bgIntensity', 'drumh3d-fx-bgintensity', 'drumh3d-fx-bgintensity-val');
const storedStyle = localStorage.getItem('drum_h3d_bg_style');
const styleSel = document.getElementById('drumh3d-fx-bgstyle');
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
styleSel.value = storedStyle;
}
const storedTheme = localStorage.getItem('drum_h3d_bg_theme');
const themeSel = document.getElementById('drumh3d-fx-theme');
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
themeSel.value = storedTheme;
}
} catch (e) {
console.warn('[Drum-Hwy3D settings] hydration failed:', e);
@@ -92,3 +92,73 @@ test('MIDI map: open hi-hat is a first-class piece (46 → hh_open)', () => {
// ±50 ms window matches the 2D drums plugin.
assert.equal(HIT_TOLERANCE_S, 0.05);
});
test('_classifyTiming: OK band is 40% of the window, sign maps early/late', () => {
const { _classifyTiming, HIT_TOLERANCE_S } = load().__test;
const tol = HIT_TOLERANCE_S; // 0.05
assert.equal(_classifyTiming(0, tol), 'OK');
assert.equal(_classifyTiming(tol * 0.4, tol), 'OK'); // boundary inclusive
assert.equal(_classifyTiming(-tol * 0.4, tol), 'OK');
// delta = note.t - now: positive → struck before the note → EARLY.
assert.equal(_classifyTiming(tol * 0.41, tol), 'EARLY');
assert.equal(_classifyTiming(-tol * 0.41, tol), 'LATE');
assert.equal(_classifyTiming(tol, tol), 'EARLY');
assert.equal(_classifyTiming(-tol, tol), 'LATE');
// Degenerate inputs read as on-time rather than throwing.
assert.equal(_classifyTiming(NaN, tol), 'OK');
});
test('FX defaults: hit-FX controls ship enabled', () => {
const { FX_DEFAULTS } = load().__test;
assert.equal(FX_DEFAULTS.sparks, true);
assert.equal(FX_DEFAULTS.timingFx, true);
assert.equal(FX_DEFAULTS.streakFx, true);
assert.equal(FX_DEFAULTS.hitFx, 0.7);
});
test('themes: table ids match the guitar highway, default is the stock palette', () => {
const { BG_THEMES, _bgThemeColors } = load().__test;
// Same id set as highway_3d's BG_THEMES (cross-instrument consistency).
assert.deepEqual(Object.keys(BG_THEMES), [
'default', 'midnight', 'charcoal', 'deeppurple', 'forest', 'warmslate',
'deepfocus', 'deepsea', 'cathode', 'cathodegreen', 'hearth',
]);
// 'default' preserves THIS plugin's original look byte-for-byte.
assert.equal(BG_THEMES.default.clear, 0x1a1a2e);
assert.equal(BG_THEMES.default.board, 0x0a0e1a);
assert.equal(BG_THEMES.default.lane, undefined); // stock stripes fallback
// Unknown ids fall back to default.
assert.equal(_bgThemeColors('nonsense'), BG_THEMES.default);
// Every non-default theme carries a lane pair (the axis differentiator).
for (const [id, t] of Object.entries(BG_THEMES)) {
if (id === 'default') continue;
assert.ok(t.lane != null && t.laneDim != null, id + ' lane pair');
assert.equal(t.clear, t.fog, id + ' clear==fog (horizon dissolve)');
}
});
test('readThemeSetting: defaults without localStorage; validates ids', () => {
const { readThemeSetting } = load().__test;
assert.equal(readThemeSetting(), 'default');
});
test('FX defaults: theme-PR controls ship enabled at stock-neutral values', () => {
const { FX_DEFAULTS } = load().__test;
assert.equal(FX_DEFAULTS.cinematic, true);
assert.equal(FX_DEFAULTS.glow, 0.5); // 0.5 = 1.0x multiplier (stock)
assert.equal(FX_DEFAULTS.vibrancy, 0.85); // ≈ the stock 0.32 stripe base
});
test('bg styles: validated id set, particles default, no out-of-scope styles', () => {
const { BG_STYLE_IDS, readBgStyleSetting } = load().__test;
// Host-realm copy — the vm array's foreign prototype trips deepEqual.
assert.deepEqual([...BG_STYLE_IDS], ['off', 'particles', 'lights', 'geometric']);
assert.equal(readBgStyleSetting(), 'particles'); // no localStorage in the vm
});
test('FX defaults: ambience + score FX ship enabled', () => {
const { FX_DEFAULTS } = load().__test;
assert.equal(FX_DEFAULTS.scoreFx, true);
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
assert.equal(FX_DEFAULTS.bgReactive, true);
});
+2
View File
@@ -157,6 +157,8 @@ Every per-frame renderer call receives a `bundle` from feedBack core. Fields use
`tuning` and `capo` aren't consumed by this plugin.
Core reuses the bundle OBJECT across frames (mutated in place); never cache it or compare its identity between frames — field values are only valid for the current draw call. Field-identity caches on ARRAY fields (this plugin's `_mergeCacheChordsRef === bundle.chords` etc.) remain valid: arrays still swap reference when chart data changes. Core also exposes `bundle.lowerBoundT(arr, time)` (lower-bound on `.t`, notes/chords) and `bundle.lowerBoundTime(arr, time)` (on `.time`, beats/anchors/sections) — prefer these over the local `lowerBoundT` helper when a downlevel-host fallback isn't needed.
### Score FX (notedetect game-scoring layer)
- **"+N" score pops** → `_fxSpawnPop()` from `drawNote()` (just after the provider verdict-override block), drawn by `drawScoreFx()` (called from the `lyricsCtx` block in `draw()`, right after `drawNotedetectLabels()`). Fixed 24-slot pool (`_fxPops`), deduped per `popKey` via the TTL'd `_fxSeen` map (pruned in `drawScoreFx`). Pops rise/fade over 700 ms; font size scales with the multiplier tier.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.30.2",
"version": "3.31.2",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+262 -89
View File
@@ -1236,6 +1236,11 @@
// Binary lower-bound: returns the first index i in arr where arr[i].t >= t.
// Assumes arr is sorted ascending by .t (bundle.notes / bundle.chords always are).
// Byte-identical to core's bundle.lowerBoundT — kept as a local because this
// plugin must run on downlevel hosts whose bundles don't carry the helper
// (it's called from ~30 sites incl. top-level helpers that don't receive a
// bundle). New code that already holds a bundle should prefer
// bundle.lowerBoundT / bundle.lowerBoundTime.
function lowerBoundT(arr, t) {
let lo = 0, hi = arr.length;
while (lo < hi) {
@@ -2191,6 +2196,24 @@
}
const audio = document.getElementById('audio');
if (!audio) return null;
// Shared tap: createMediaElementSource is one-shot per element, so
// the FIRST visualizer to tap #audio publishes it at
// window.__feedBackAudioTap and every later one (this plugin, the
// drum/keys 3D highways) adopts it instead of throwing
// InvalidStateError when visualizers are switched or mixed in
// splitscreen.
const sharedTap = window.__feedBackAudioTap;
if (sharedTap && sharedTap.analyser && sharedTap.mediaEl === audio) {
_bgAudio = {
ctx: sharedTap.ctx,
analyser: sharedTap.analyser,
freq: new Uint8Array(Math.max(BG_FREQ_BINS, sharedTap.analyser.frequencyBinCount)),
source: 'core',
};
_bgAudioCore = _bgAudio;
_bgRecordAudioBridge('audio-mix.analyser', 'shared #audio analyser tap', 'handled', '', 'core');
return _bgAudio;
}
// Hoist ctx out of the try so we can close() it if a later step
// throws (e.g. createMediaElementSource on an element that
// already has a source node). Otherwise the AudioContext leaks.
@@ -2205,6 +2228,7 @@
source.connect(analyser);
analyser.connect(ctx.destination);
_bgAudio = { ctx, analyser, freq: new Uint8Array(Math.max(BG_FREQ_BINS, analyser.frequencyBinCount)), source: 'core' };
try { window.__feedBackAudioTap = { ctx, analyser, mediaEl: audio }; } catch (_) {}
_bgRecordAudioBridge('audio-mix.analyser', 'HTMLAudioElement analyser tap', 'handled', '', 'core');
// Remember the core analyser so a later stems-then-back-to-core
// transition can re-use it instead of re-tapping #audio (which
@@ -3978,6 +4002,10 @@
let _laneRailBoundsRefTpl = null;
let _laneRailBoundsRefNotes = null;
let _lastHwW = 0, _lastHwH = 0;
// Frame counter for throttling the CSS-box drift check in draw()
// (getBoundingClientRect is a forced layout read; see the comment
// at the check).
let _boxCheckCountdown = 0;
// Last logical (CSS px) size handed to applySize(). #highway is a
// flex:1 item, so its real rendered box (canvasSize()) can change as
// the player layout settles after a song opens WITHOUT the backing
@@ -4677,10 +4705,12 @@
// the full look (re-enable the rail bloom) per browser, no rebuild:
// localStorage.h3d_full_sus = '1' // re-enable rail bloom halo
// delete localStorage.h3d_full_sus // back to lean default
// Read once per frame at the top of update() so the flag takes effect
// live. The bloom pool/material/gaussian texture are kept intact
// Polled at ~1 Hz at the top of update() (perf: localStorage reads
// are synchronous) so the console flag still takes effect live.
// The bloom pool/material/gaussian texture are kept intact
// (still pinned by the bloom unit tests and used by the opt-out path).
let _leanSus = true;
let _leanSusPollCounter = 0;
// Lifecycle flags
let _isReady = false;
@@ -5267,7 +5297,17 @@
// appearing on top without depthTest.
depthTest: false,
depthWrite: false,
side: T.DoubleSide,
// forceSinglePass accompanies EVERY transparent DoubleSide
// material in this file: without it, Three r158+ renders
// each such object in TWO passes (back side then front),
// setting material.needsUpdate on both — which forces a
// full getParameters/program-cache lookup per object per
// frame (profiled at ~4% of throttled main-thread time)
// and doubles the draw calls. The two-pass path exists to
// fix self-occlusion sorting on closed transparent meshes;
// all our DoubleSide materials are flat unlit quads
// (labels, rails, frames, lanes) where it buys nothing.
side: T.DoubleSide, forceSinglePass: true,
});
sm.userData.h3dTechMeshMat = base;
}
@@ -6250,6 +6290,12 @@
return boxH;
}
// Lyrics layout cache — measureText per syllable + row wrapping
// only changes when the displayed line(s), font size, or canvas
// width change, not per frame. Keyed below; the per-frame work is
// just drawing over the cached widths.
let _lyrRowsCache = null;
function drawLyrics(lyrics, currentTime, ctx, W, H) {
if (!lyrics._lines) {
const lines = [];
@@ -6297,37 +6343,51 @@
const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; };
ctx.font = `bold ${fontSize}px sans-serif`;
const spaceWidth = ctx.measureText(' ').width;
const maxWidth = W * 0.8;
let rows, spaceWidth, bgWidth;
const _lc = _lyrRowsCache;
if (_lc && _lc.lyricsRef === lyrics && _lc.idx === currentIdx
&& _lc.shown === linesToShow.length
&& _lc.fontSize === fontSize && _lc.W === W) {
rows = _lc.rows; spaceWidth = _lc.spaceWidth; bgWidth = _lc.bgWidth;
} else {
spaceWidth = ctx.measureText(' ').width;
const maxWidth = W * 0.8;
const rows = [];
for (const authoredLine of linesToShow) {
let row = [], rowWidth = 0;
for (const wordSyls of authoredLine.words) {
const parts = [];
let wordWidth = 0;
for (const s of wordSyls) {
const text = sylText(s);
const w = ctx.measureText(text).width;
parts.push({ syl: s, text, width: w });
wordWidth += w;
rows = [];
for (const authoredLine of linesToShow) {
let row = [], rowWidth = 0;
for (const wordSyls of authoredLine.words) {
const parts = [];
let wordWidth = 0;
for (const s of wordSyls) {
const text = sylText(s);
const w = ctx.measureText(text).width;
parts.push({ syl: s, text, width: w });
wordWidth += w;
}
const advance = wordWidth + spaceWidth;
if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; }
row.push({ parts, advance });
rowWidth += advance;
}
const advance = wordWidth + spaceWidth;
if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; }
row.push({ parts, advance });
rowWidth += advance;
if (row.length) rows.push(row);
}
if (row.length) rows.push(row);
bgWidth = 0;
for (const row of rows) {
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
if (rw > bgWidth) bgWidth = rw;
}
bgWidth = Math.min(bgWidth + 30, W * 0.85);
_lyrRowsCache = {
lyricsRef: lyrics, idx: currentIdx,
shown: linesToShow.length, fontSize, W,
rows, spaceWidth, bgWidth,
};
}
const rowHeight = fontSize + 6;
const totalHeight = rows.length * rowHeight + 10;
let bgWidth = 0;
for (const row of rows) {
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
if (rw > bgWidth) bgWidth = rw;
}
bgWidth = Math.min(bgWidth + 30, W * 0.85);
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.beginPath();
@@ -6629,7 +6689,7 @@
depthWrite: false,
depthTest: true,
blending: T.AdditiveBlending,
side: T.DoubleSide,
side: T.DoubleSide, forceSinglePass: true,
fog: true,
}));
mAccentHaloNear = mkAccentHaloMats(ACCENT_HALO_OP_NEAR);
@@ -6689,7 +6749,7 @@
new T.MeshBasicMaterial({
vertexColors: true,
transparent: true, opacity: 1.0, depthWrite: false,
blending: T.AdditiveBlending, side: T.DoubleSide, fog: false,
blending: T.AdditiveBlending, side: T.DoubleSide, forceSinglePass: true, fog: false,
}),
));
// Notedetect feedback outline (issue #9): hot magenta-red (0xff0066, hue
@@ -6829,7 +6889,7 @@
emissiveIntensity: 0.9,
transparent: true,
opacity: 0.85,
side: T.DoubleSide,
side: T.DoubleSide, forceSinglePass: true,
depthWrite: false,
depthTest: false,
});
@@ -6856,7 +6916,7 @@
color: CHORD_BOX_TEAL_HEX,
transparent: true, opacity: 0.85,
depthTest: false, depthWrite: false,
fog: false, side: T.DoubleSide,
fog: false, side: T.DoubleSide, forceSinglePass: true,
});
pSusRail = pool(noteG, () => {
const m = new T.Mesh(gSusRail, mSusRailBase.clone());
@@ -6877,7 +6937,7 @@
transparent: true, opacity: 0.55,
blending: T.AdditiveBlending,
depthTest: false, depthWrite: false,
fog: false, side: T.DoubleSide,
fog: false, side: T.DoubleSide, forceSinglePass: true,
});
pSusRailBloom = pool(noteG, () => {
const m = new T.Mesh(gSusRailBloom, mSusRailBloomBase.clone());
@@ -6891,7 +6951,7 @@
gTechPlane = new T.PlaneGeometry(1, 1);
pTechPlane = pool(noteG, () => {
const m = new T.Mesh(gTechPlane, new T.MeshBasicMaterial({
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide,
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide, forceSinglePass: true,
}));
m.renderOrder = 1000;
return m;
@@ -6945,7 +7005,7 @@
uniforms: { map: { value: spriteMat.map } },
vertexShader: _imTechVert,
fragmentShader: _imTechFrag,
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide,
transparent: true, depthTest: false, depthWrite: false, side: T.DoubleSide, forceSinglePass: true,
});
const im = new T.InstancedMesh(geo, mat, IM_TECH_CAP);
im.instanceMatrix.setUsage(T.DynamicDrawUsage);
@@ -7058,7 +7118,7 @@
depthWrite: false,
depthTest: false,
fog: false,
side: T.DoubleSide,
side: T.DoubleSide, forceSinglePass: true,
}),
));
pChordBox = pool(noteG, () => new T.Mesh(
@@ -7070,7 +7130,7 @@
depthWrite: false,
depthTest: false,
fog: false,
side: T.DoubleSide,
side: T.DoubleSide, forceSinglePass: true,
}),
));
@@ -7153,7 +7213,7 @@
_imPMXFillMat = new T.ShaderMaterial({
vertexShader: _imFillVert, fragmentShader: _imFillFrag,
transparent: true, depthTest: false, depthWrite: false,
fog: false, side: T.DoubleSide,
fog: false, side: T.DoubleSide, forceSinglePass: true,
});
imPMXFill = new T.InstancedMesh(gPMXFill, _imPMXFillMat, IM_STRUM_CAP);
imPMXFill.instanceMatrix.setUsage(T.DynamicDrawUsage);
@@ -7233,7 +7293,7 @@
_imFHXFillMat = new T.ShaderMaterial({
vertexShader: _imFillVert, fragmentShader: _imFillFrag,
transparent: true, depthTest: false, depthWrite: false,
fog: false, side: T.DoubleSide,
fog: false, side: T.DoubleSide, forceSinglePass: true,
});
imFHXFill = new T.InstancedMesh(gFHXFill, _imFHXFillMat, IM_STRUM_CAP);
imFHXFill.instanceMatrix.setUsage(T.DynamicDrawUsage);
@@ -7314,7 +7374,7 @@
_imPMXLinesMat = new T.ShaderMaterial({
vertexShader: _imLinesVert, fragmentShader: _imLinesFrag,
transparent: true, depthTest: false, depthWrite: false,
fog: false, side: T.DoubleSide,
fog: false, side: T.DoubleSide, forceSinglePass: true,
});
imPMXLines = new T.InstancedMesh(gPMXLines, _imPMXLinesMat, IM_STRUM_CAP);
imPMXLines.instanceMatrix.setUsage(T.DynamicDrawUsage);
@@ -7396,7 +7456,7 @@
_imFHXLinesMat = new T.ShaderMaterial({
vertexShader: _imLinesVert, fragmentShader: _imLinesFrag,
transparent: true, depthTest: false, depthWrite: false,
fog: false, side: T.DoubleSide,
fog: false, side: T.DoubleSide, forceSinglePass: true,
});
imFHXLines = new T.InstancedMesh(gFHXLines, _imFHXLinesMat, IM_STRUM_CAP);
imFHXLines.instanceMatrix.setUsage(T.DynamicDrawUsage);
@@ -7418,28 +7478,28 @@
gPMXFill,
new T.MeshBasicMaterial({
color: 0x000000, transparent: true, opacity: 1,
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide, forceSinglePass: true,
}),
));
pFHXFill = pool(noteG, () => new T.Mesh(
gFHXFill,
new T.MeshBasicMaterial({
color: 0x000000, transparent: true, opacity: 1,
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide, forceSinglePass: true,
}),
));
pMuteXLines = pool(noteG, () => new T.Mesh(
gPMXLines,
new T.MeshBasicMaterial({
color: 0xffffff, transparent: true, opacity: 1,
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide, forceSinglePass: true,
}),
));
pFHXLines = pool(noteG, () => new T.Mesh(
gFHXLines,
new T.MeshBasicMaterial({
color: 0xffffff, transparent: true, opacity: 1,
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide,
depthWrite: false, depthTest: false, fog: false, side: T.DoubleSide, forceSinglePass: true,
}),
));
@@ -9886,6 +9946,105 @@
}
/* ── Per-frame rendering ─────────────────────────────────────────── */
// ── GPU pre-warm (perf: first-appearance hitches) ─────────────────
// Three.js compiles a material's shader program and uploads a
// texture the first frame the owning object renders — profiled as
// mid-song frame spikes (getParameters / texSubImage2D). Pay those
// costs during init (load spinner) instead:
// _prewarmStatic() — ren.compile() over the fully-built scene
// + deterministic label textures (fret
// numbers in every per-frame style/colour
// combo).
// _prewarmChart(bundle) — chart-dependent labels (chord template
// names, section names); needs the ready
// bundle, so it runs once from the first
// draw() after each init.
// txtMat() rasterises into the unbounded cache these draws hit
// anyway; ren.initTexture() forces the GPU upload now.
// Swap a pooled label sprite's cached texture WITHOUT recompiling.
// Setting material.needsUpdate bumps material.version, which forces
// Three.js through getParameters/getProgramCacheKey on the next
// render. Swapping one non-null texture for another does NOT change
// the compiled program (the USE_MAP define is unchanged); only a
// null <-> non-null transition does, and pooled label sprites are
// constructed with a non-null map, so in practice this never
// recompiles. (Note: the DOMINANT getParameters churn turned out to
// be Three's transparent-DoubleSide two-pass path — see the
// forceSinglePass comment in _spriteMat2MeshMat — this helper
// removes the label-swap contribution on top of that.)
function _setLabelMap(sprite, srcMat) {
const m = sprite.material;
if (m.map === srcMat.map) return;
const nullnessChanged = (m.map == null) !== (srcMat.map == null);
m.map = srcMat.map;
if (nullnessChanged) m.needsUpdate = true;
}
let _chartPrewarmed = false;
function _prewarmTex(mat) {
if (mat && mat.map && ren) ren.initTexture(mat.map);
}
function _prewarmStatic() {
// MAINTENANCE NOTE: this list must cover every deterministic
// (chart-independent) material/texture the per-frame paths can
// request lazily. Adding a new label style or sprite factory to
// drawNote()/update() without warming it here silently
// reintroduces a first-appearance texSubImage2D/compile spike
// mid-song. Chart-dependent labels (chord names, section names)
// live in _prewarmChart.
try {
if (ren && scene && cam) ren.compile(scene, cam);
} catch (e) { console.warn('[3D-Hwy] prewarm compile:', e); }
try {
// Fret-number labels in the per-frame style/colour combos.
for (let f = 0; f <= NFRETS; f++) {
_prewarmTex(txtMat(f, FRET_LABEL_GOLD_HEX, false, 'noteFret'));
_prewarmTex(txtMat(f, FRET_LABEL_GOLD_HEX, false, 'fretRow'));
_prewarmTex(txtMat(f, FRET_LABEL_IDLE_HEX, false, 'fretRow'));
_prewarmTex(txtMat(f, '#ffffff', false, 'ghostFret'));
}
// Teaching marks (drawNote _drawTeachMark): finger hints
// T/1-4 (teachFg) and scale degrees 0-11 (teachSd).
_prewarmTex(txtMat('T', '#7fd1ff', false, 'teachFg'));
for (let i = 1; i <= 4; i++) _prewarmTex(txtMat(String(i), '#7fd1ff', false, 'teachFg'));
for (let i = 0; i <= 11; i++) _prewarmTex(txtMat(String(i), '#ffcc66', false, 'teachSd'));
// Technique sprite factories (own caches, keyed by packed
// number): PM/FH mute X, hammer/pull triangles, bend
// chevron stacks, slide direction arrows — per string
// colour of the active palette.
_prewarmTex(palmMuteXSpriteMat());
_prewarmTex(fretHandMuteXSpriteMat());
const _nWarm = Math.min(
Math.max(nStr, 6),
(activePalette && activePalette.length) || 0);
for (let s = 0; s < _nWarm; s++) {
const hex = activePalette[s] || 0xffffff;
_prewarmTex(triMat(true, hex));
_prewarmTex(triMat(false, hex));
for (let st = 1; st <= 4; st++) _prewarmTex(bendChevronMat(st, hex));
const arrowHex = darkenHex(hex, 0.55);
_prewarmTex(slideArrowMat(true, arrowHex));
_prewarmTex(slideArrowMat(false, arrowHex));
}
} catch (e) { console.warn('[3D-Hwy] prewarm labels:', e); }
}
function _prewarmChart(bundle) {
try {
const tpls = bundle && bundle.chordTemplates;
if (Array.isArray(tpls)) {
for (const tpl of tpls) {
if (tpl && tpl.name) _prewarmTex(txtMat(tpl.name, '#e8d080', true, 'chord'));
}
}
const secs = bundle && bundle.sections;
if (Array.isArray(secs)) {
for (const s of secs) {
if (s && s.name) _prewarmTex(txtMat(s.name, '#00cccc', true, 'section'));
}
}
} catch (e) { console.warn('[3D-Hwy] prewarm chart labels:', e); }
}
function update(bundle) {
pbBeg(0);
// [verdict glow] Apply the level-driven verdict brightness captured
@@ -9909,10 +10068,14 @@
// Lean sustain rendering is the default (see declaration above):
// the trail/ribbon outline always draws; only the additive rail
// bloom halo is dropped. The full look (with bloom) is an opt-out.
// Cheap per-frame read so the console flag takes effect live.
try {
_leanSus = localStorage.getItem('h3d_full_sus') !== '1';
} catch (_) { _leanSus = true; }
// localStorage.getItem is a synchronous storage read — polled at
// ~1 Hz instead of every frame; the console flag still takes
// effect live (within a second).
if ((_leanSusPollCounter++ % 60) === 0) {
try {
_leanSus = localStorage.getItem('h3d_full_sus') !== '1';
} catch (_) { _leanSus = true; }
}
// Materialize the text-size multiplier from the user's slider.
// textSize ∈ [0,1]; _textSizeMul ∈ [0.5, 1.5] with 0.5 ↦ 1.0×
// so default behaviour matches what the renderer did pre-slider.
@@ -11856,7 +12019,7 @@
const lblW = 28 * K, lblH = 9 * K;
const lbl = pChordLbl.get();
const mat = txtMat(chordName, '#e8d080', true, 'chord');
if (lbl.material.map !== mat.map) { lbl.material.map = mat.map; lbl.material.needsUpdate = true; }
_setLabelMap(lbl, mat);
lbl.material.opacity = Math.min(1, 0.3 + fade * 0.7) * chordTailMul;
// Gold chord name: slight +X shift from flush-left so it sits farther right.
const lblWS = lblW * _textSizeMul;
@@ -11892,7 +12055,7 @@
if (!text) return;
const s = pChordLbl.get();
const m = txtMat(text, colorHex, true, 'chord');
if (s.material.map !== m.map) { s.material.map = m.map; s.material.needsUpdate = true; }
_setLabelMap(s, m);
s.material.opacity = opacity;
s.position.set(baseX, hy, z);
s.scale.set(hlW, hlH, 1);
@@ -12016,10 +12179,7 @@
_seenChordFrets.add(f);
const lbl = pNoteFretLabel.get();
const mat = txtMat(f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
if (lbl.material.map !== mat.map) {
lbl.material.map = mat.map;
lbl.material.needsUpdate = true;
}
_setLabelMap(lbl, mat);
lbl.position.set(xFretMid(f), yMinF, z);
lbl.renderOrder = renderOrderForLayerAtZ(z, 'CHORD_FRET_LABEL');
const _flS = 7.0 * K * (1 + 0.4 * chDt / AHEAD) * _textSizeMul * fretLabelScaleForFret(f);
@@ -12642,10 +12802,7 @@
const color = '#888888';
const sp = pFretColMarker.get();
const m = txtMat(f, color, false, 'noteFret');
if (sp.material.map !== m.map) {
sp.material.map = m.map;
sp.material.needsUpdate = true;
}
_setLabelMap(sp, m);
sp.material.opacity = 0.85 * _colFadeIn;
sp.position.set(xFretMid(f), labelY, z);
// Z-proportional: sits between chord frame and note gem
@@ -13928,10 +14085,7 @@
_frameLabeledKeys.add(_flFrameKey);
const fretLabel = pNoteFretLabel.get();
const cachedMat = txtMat(n.f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
if (fretLabel.material.map !== cachedMat.map) {
fretLabel.material.map = cachedMat.map;
fretLabel.material.needsUpdate = true;
}
_setLabelMap(fretLabel, cachedMat);
fretLabel.position.set(x, labelY, noteZ);
fretLabel.renderOrder = renderOrderForLayerAtZ(noteZ,
_isArpNote
@@ -13956,10 +14110,7 @@
if (!text) return;
const spr = pTeachMarkLbl.get();
const m = txtMat(text, colorHex, false, cacheKey);
if (spr.material.map !== m.map) {
spr.material.map = m.map;
spr.material.needsUpdate = true;
}
_setLabelMap(spr, m);
spr.position.set(x + dx, labelY, noteZ);
spr.renderOrder = renderOrderForLayerAtZ(noteZ,
_isArpNote ? 'ARP_NOTE_FRET_LABEL' : 'NOTE_FRET_LABEL');
@@ -13993,10 +14144,7 @@
const _isArp2 = arpBounds !== null;
const fl2 = pNoteFretLabel.get();
const cm2 = txtMat(n.f, FRET_LABEL_GOLD_HEX, false, 'noteFret');
if (fl2.material.map !== cm2.map) {
fl2.material.map = cm2.map;
fl2.material.needsUpdate = true;
}
_setLabelMap(fl2, cm2);
fl2.position.set(x, _labelY2, noteZ);
fl2.renderOrder = renderOrderForLayerAtZ(noteZ,
_isArp2
@@ -14962,6 +15110,12 @@
_invertedForBoard = _invertedCached;
_leftyForBoard = _leftyCached;
if (!initScene()) { _unsubscribeFocus(); _rejectReady(new Error('initScene failed')); return; }
// Pre-compile shaders + upload deterministic label
// textures while the load spinner is still up; the
// chart-dependent half runs on first draw() (bundle
// arrays are only guaranteed populated post-ready).
_prewarmStatic();
_chartPrewarmed = false;
const sz = canvasSize(highwayCanvas);
// Mark ready before RAF so any resize(w,h) calls that arrive
// in the meantime (e.g. from sizeCanvases()) are applied directly.
@@ -15000,6 +15154,10 @@
draw(bundle) {
if (!_isReady) return;
if (!_chartPrewarmed) {
_chartPrewarmed = true;
_prewarmChart(bundle);
}
_invertedCached = !!bundle.inverted;
_leftyCached = !!bundle.lefty;
const newNStr = resolveStringCount(bundle);
@@ -15042,25 +15200,40 @@
// for the pre-settle (too-tall) size and crops the near strings
// / fret numbers until the user un/re-maximizes the window.
if (highwayCanvas) {
const box = canvasSize(highwayCanvas);
if (highwayCanvas.width !== _lastHwW || highwayCanvas.height !== _lastHwH) {
_lastHwW = highwayCanvas.width;
_lastHwH = highwayCanvas.height;
if (box.w > 0 && box.h > 0) applySize(box.w, box.h);
} else if (box.w > 0 && box.h > 0 &&
(Math.abs(box.w - _appliedW) > 1 || Math.abs(box.h - _appliedH) > 1)) {
applySize(box.w, box.h);
} else if (!_wrapPinned && box.w > 0 && box.h > 0 &&
highwayCanvas.offsetWidth > 0 && highwayCanvas.offsetHeight > 0) {
// 3. The overlay pin couldn't be applied at init because
// #highway had no layout yet (offsetWidth/Height === 0),
// so applySize() only set the wrap height. The canvas has
// now laid out but to the same logical size, so neither
// drift branch above fires — re-run applySize to pin the
// wrap to the canvas box now that its offsets are real.
// Otherwise the overlay stays at top:0;left:0;right:0 and
// a strip of #highway is exposed on first load / split.
applySize(box.w, box.h);
// Backing-store drift (branch 1) is detected with cheap
// property reads every frame. The CSS-box checks (branches
// 2/3) need canvasSize() → getBoundingClientRect(), a
// forced layout read — profiled at ~1.2% of throttled
// main-thread time when run per frame. Throttle the box
// read to every 10th frame (plus whenever the backing
// store changed or the wrap isn't pinned yet): the layout
// settle it exists to catch plays out over hundreds of ms
// right after a song opens, so a ~166 ms detection cadence
// loses nothing visible.
const _bsChanged = highwayCanvas.width !== _lastHwW
|| highwayCanvas.height !== _lastHwH;
_boxCheckCountdown = (_boxCheckCountdown + 1) % 10;
if (_bsChanged || !_wrapPinned || _boxCheckCountdown === 0) {
const box = canvasSize(highwayCanvas);
if (_bsChanged) {
_lastHwW = highwayCanvas.width;
_lastHwH = highwayCanvas.height;
if (box.w > 0 && box.h > 0) applySize(box.w, box.h);
} else if (box.w > 0 && box.h > 0 &&
(Math.abs(box.w - _appliedW) > 1 || Math.abs(box.h - _appliedH) > 1)) {
applySize(box.w, box.h);
} else if (!_wrapPinned && box.w > 0 && box.h > 0 &&
highwayCanvas.offsetWidth > 0 && highwayCanvas.offsetHeight > 0) {
// 3. The overlay pin couldn't be applied at init because
// #highway had no layout yet (offsetWidth/Height === 0),
// so applySize() only set the wrap height. The canvas has
// now laid out but to the same logical size, so neither
// drift branch above fires — re-run applySize to pin the
// wrap to the canvas box now that its offsets are real.
// Otherwise the overlay stays at top:0;left:0;right:0 and
// a strip of #highway is exposed on first load / split.
applySize(box.w, box.h);
}
}
}
update(bundle);
+17
View File
@@ -31,6 +31,23 @@ tunes one of these, mirror the change here (and in `drum_highway_3d`):
mapping switch in `draw()`; addons dynamic-imported from
`/static/vendor/three/addons/` (no CDN fallback — direct render is the
graceful degrade)
- `_sparkBurst()` / `_sparkUpdate()` — pooled additive Points hit sparks
(pool 96 here — the flame sprites carry most of the hit feedback)
- `_timingHex()` / `_classifyTiming()` — early/late/on-time feedback
colors (green/cyan/amber) + the 40%-window classifier
- `_ssActive()` — host splitscreen probe (minus the guitar's focus-API
checks, which it needs for input routing and we don't)
- `BG_THEMES` / `_bgThemeColors()` — the scene theme table (same ids/values
as the guitar's, except `default` which is this plugin's original
palette); one pick drives background gradient + floor + lane rails
- `_makeStudioEnv()` — procedural PMREM studio environment (shared with
drum_highway_3d; RoomEnvironment isn't vendored)
- `_applyCinematic()` — ambient/key rebalance (values tuned per plugin)
- `BG_STYLES` (off/particles/lights/geometric) + `_bgGetAnalyser()` /
`_bgReadBands()` — background ambience + the stems-first audio-analyser
bridge (shared with drum_highway_3d)
- `_drawScoreFx()` — score overlay (pops / tier rings / milestone bursts /
streak-break wash), drum_highway_3d pattern
## License
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "keys_highway_3d",
"name": "Keys Highway 3D",
"version": "0.1.0",
"version": "0.1.1",
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
"type": "visualization",
"bundled": true,
File diff suppressed because it is too large Load Diff
+167 -6
View File
@@ -11,7 +11,88 @@
<!-- Graphics -->
<div class="mt-3">
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
<label for="keysh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
<label for="keysh3d-fx-theme" class="text-xs font-medium text-gray-400 mb-1 block">Scene theme</label>
<select id="keysh3d-fx-theme"
onchange="window.keys3dSetTheme && window.keys3dSetTheme(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="default">Default (original)</option>
<option value="midnight">Midnight</option>
<option value="charcoal">Charcoal</option>
<option value="deeppurple">Deep Purple</option>
<option value="forest">Forest</option>
<option value="warmslate">Warm Slate</option>
<option value="deepfocus">Deep Focus</option>
<option value="deepsea">Deep Sea</option>
<option value="cathode">Cathode</option>
<option value="cathodegreen">Cathode Green</option>
<option value="hearth">Hearth</option>
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
Background gradient, floor and lane rails — the same theme names
as the guitar highway. Pitch-class note colours never change.
</p>
<label for="keysh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
<input type="checkbox" id="keysh3d-fx-cinematic" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('cinematic', this.checked)">
Cinematic lighting
</label>
<p class="text-xs text-gray-500 mt-1">
Dimmer ambience, stronger key light — deeper shading on the keys
and gems.
</p>
<label for="keysh3d-fx-glow" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Glow strength <span id="keysh3d-fx-glow-val" class="text-gray-500 font-mono">0.50</span>
</label>
<input type="range" id="keysh3d-fx-glow"
min="0" max="1" step="0.05" value="0.5"
oninput="window.keys3dSetFx && window.keys3dSetFx('glow', this.value); document.getElementById('keysh3d-fx-glow-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
How hard the notes, key glow and sustain consume-flash
self-illuminate (0.5 = stock). Pairs with bloom.
</p>
<label for="keysh3d-fx-bgstyle" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">Background ambience</label>
<select id="keysh3d-fx-bgstyle"
onchange="window.keys3dSetBgStyle && window.keys3dSetBgStyle(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="off">Off</option>
<option value="particles" selected>Particles</option>
<option value="lights">Stage lights</option>
<option value="geometric">Geometric</option>
</select>
<label for="keysh3d-fx-bgintensity" class="text-xs font-medium text-gray-400 mb-1 mt-2 block">
Ambience intensity <span id="keysh3d-fx-bgintensity-val" class="text-gray-500 font-mono">0.50</span>
</label>
<input type="range" id="keysh3d-fx-bgintensity"
min="0" max="1" step="0.05" value="0.5"
oninput="window.keys3dSetFx && window.keys3dSetFx('bgIntensity', this.value); document.getElementById('keysh3d-fx-bgintensity-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<label for="keysh3d-fx-bgreactive" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-2">
<input type="checkbox" id="keysh3d-fx-bgreactive" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('bgReactive', this.checked)">
Audio-reactive ambience
</label>
<p class="text-xs text-gray-500 mt-1">
The backdrop pulses with the mix (stems analyser when a sloppak
is loaded). Off = it animates on time only.
</p>
<label for="keysh3d-fx-scorefx" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-scorefx" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('scoreFx', this.checked)">
Score effects
</label>
<p class="text-xs text-gray-500 mt-1">
+1 pops on scored presses, a ring pulse every 10-combo,
milestone bursts at 25/50/100, and a brief red flicker when a
streak breaks.
</p>
<label for="keysh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-bloom" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('bloom', this.checked)">
Glow (bloom)
@@ -21,6 +102,59 @@
sustains. Applies live; turn off to reclaim GPU headroom on
weak machines.
</p>
<label for="keysh3d-fx-sparks" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-sparks" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('sparks', this.checked)">
Hit sparks
</label>
<p class="text-xs text-gray-500 mt-1">
A small particle burst on every scored key press, beside the
pitch-colored flame.
</p>
<label for="keysh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-timing" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('timingFx', this.checked)">
Timing colours
</label>
<p class="text-xs text-gray-500 mt-1">
Tint the sparks by timing — on-time green, early cyan, late
amber. Off = always green.
</p>
<label for="keysh3d-fx-streak" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-streak" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('streakFx', this.checked)">
Streak feedback
</label>
<p class="text-xs text-gray-500 mt-1">
Spark bursts grow with your combo.
</p>
<label for="keysh3d-fx-hitfx" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Hit feedback intensity <span id="keysh3d-fx-hitfx-val" class="text-gray-500 font-mono">0.70</span>
</label>
<input type="range" id="keysh3d-fx-hitfx"
min="0" max="1" step="0.05" value="0.7"
oninput="window.keys3dSetFx && window.keys3dSetFx('hitFx', this.value); document.getElementById('keysh3d-fx-hitfx-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Drives the hit-line brightness kick on scored presses. 0 turns
it off.
</p>
<label for="keysh3d-fx-vibrancy" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Note vibrancy <span id="keysh3d-fx-vibrancy-val" class="text-gray-500 font-mono">0.85</span>
</label>
<input type="range" id="keysh3d-fx-vibrancy"
min="0" max="1" step="0.05" value="0.85"
oninput="window.keys3dSetFx && window.keys3dSetFx('vibrancy', this.value); document.getElementById('keysh3d-fx-vibrancy-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Note-gem solidity and lane-guide strength — lower to see more of
the keyboard through the notes.
</p>
</div>
<script>
@@ -33,11 +167,38 @@
// FX toggles (keys3d_bg_* — guitar-parity graphics controls).
// Only explicit values override; absent/corrupt keys keep the
// default (ON), matching screen.js readFxSettings.
const storedBloom = localStorage.getItem('keys3d_bg_bloom');
if (storedBloom === '1' || storedBloom === 'true') {
document.getElementById('keysh3d-fx-bloom').checked = true;
} else if (storedBloom === '0' || storedBloom === 'false') {
document.getElementById('keysh3d-fx-bloom').checked = false;
const hydrateFxBool = (key, elId) => {
const raw = localStorage.getItem('keys3d_bg_' + key);
if (raw === '1' || raw === 'true') document.getElementById(elId).checked = true;
else if (raw === '0' || raw === 'false') document.getElementById(elId).checked = false;
};
hydrateFxBool('bloom', 'keysh3d-fx-bloom');
hydrateFxBool('sparks', 'keysh3d-fx-sparks');
hydrateFxBool('timingFx', 'keysh3d-fx-timing');
hydrateFxBool('streakFx', 'keysh3d-fx-streak');
hydrateFxBool('cinematic', 'keysh3d-fx-cinematic');
hydrateFxBool('bgReactive', 'keysh3d-fx-bgreactive');
hydrateFxBool('scoreFx', 'keysh3d-fx-scorefx');
const hydrateFxRange = (key, elId, valId) => {
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
if (!Number.isFinite(n)) return;
const v = Math.min(1, Math.max(0, n));
document.getElementById(elId).value = String(v);
document.getElementById(valId).textContent = v.toFixed(2);
};
hydrateFxRange('hitFx', 'keysh3d-fx-hitfx', 'keysh3d-fx-hitfx-val');
hydrateFxRange('vibrancy', 'keysh3d-fx-vibrancy', 'keysh3d-fx-vibrancy-val');
hydrateFxRange('glow', 'keysh3d-fx-glow', 'keysh3d-fx-glow-val');
hydrateFxRange('bgIntensity', 'keysh3d-fx-bgintensity', 'keysh3d-fx-bgintensity-val');
const storedStyle = localStorage.getItem('keys3d_bg_style');
const styleSel = document.getElementById('keysh3d-fx-bgstyle');
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
styleSel.value = storedStyle;
}
const storedTheme = localStorage.getItem('keys3d_bg_theme');
const themeSel = document.getElementById('keysh3d-fx-theme');
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
themeSel.value = storedTheme;
}
} catch (e) {
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
@@ -80,3 +80,71 @@ test('keys3dSetFx: persists, coerces, and ignores unknown keys', () => {
assert.equal(events.length, 4);
assert.ok(!('keys3d_bg_nonsense' in store));
});
test('_classifyTiming: OK band is 40% of the window, sign maps early/late', () => {
const { _classifyTiming } = load().slopsmithViz_keys_highway_3d.__test;
const tol = 0.10; // keys HIT_TOLERANCE_S
assert.equal(_classifyTiming(0, tol), 'OK');
assert.equal(_classifyTiming(tol * 0.4, tol), 'OK');
assert.equal(_classifyTiming(-tol * 0.4, tol), 'OK');
// delta = note.t - now: positive → struck before the note → EARLY.
assert.equal(_classifyTiming(tol * 0.41, tol), 'EARLY');
assert.equal(_classifyTiming(-tol * 0.41, tol), 'LATE');
assert.equal(_classifyTiming(NaN, tol), 'OK');
});
test('noteKey prefix round-trips the matched note time (timing-delta source)', () => {
const { noteKey } = load().slopsmithViz_keys_highway_3d.__test;
// _checkHit derives the timing delta as parseFloat(judgeHit's key) - t;
// this pins the serialization that makes that recovery valid.
assert.equal(parseFloat(noteKey(12.3456, 60)), 12.346);
assert.equal(parseFloat(noteKey(0, 21)), 0);
});
test('FX defaults: hit-FX + vibrancy controls ship enabled', () => {
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.sparks, true);
assert.equal(FX_DEFAULTS.timingFx, true);
assert.equal(FX_DEFAULTS.streakFx, true);
assert.equal(FX_DEFAULTS.hitFx, 0.7);
assert.equal(FX_DEFAULTS.vibrancy, 0.85);
});
test('themes: table ids match the guitar highway, default is the stock palette', () => {
const { BG_THEMES, _bgThemeColors, readThemeSetting } = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual(Object.keys(BG_THEMES), [
'default', 'midnight', 'charcoal', 'deeppurple', 'forest', 'warmslate',
'deepfocus', 'deepsea', 'cathode', 'cathodegreen', 'hearth',
]);
// 'default' preserves THIS plugin's original look.
assert.equal(BG_THEMES.default.clear, 0x1a1a2e);
assert.equal(BG_THEMES.default.board, 0x141422);
assert.equal(BG_THEMES.default.laneDim, 0x2a2a3e);
assert.equal(_bgThemeColors('nonsense'), BG_THEMES.default);
assert.equal(readThemeSetting(), 'default'); // no localStorage in the vm
for (const [id, t] of Object.entries(BG_THEMES)) {
assert.equal(t.clear, t.fog, id + ' clear==fog (horizon dissolve)');
assert.ok(t.laneDim != null, id + ' rail color');
}
});
test('FX defaults: theme-PR controls ship enabled at stock-neutral values', () => {
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.cinematic, true);
assert.equal(FX_DEFAULTS.glow, 0.5); // 0.5 = 1.0x multiplier (stock)
});
test('bg styles: validated id set, particles default', () => {
const { BG_STYLE_IDS, readBgStyleSetting } = load().slopsmithViz_keys_highway_3d.__test;
// Host-realm copy — the vm array's foreign prototype trips deepEqual.
assert.deepEqual([...BG_STYLE_IDS], ['off', 'particles', 'lights', 'geometric']);
assert.equal(readBgStyleSetting(), 'particles');
});
test('FX defaults: ambience + score FX ship enabled', () => {
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.scoreFx, true);
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
assert.equal(FX_DEFAULTS.bgReactive, true);
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "tuner",
"name": "Guitar/Bass Tuner",
"version": "1.3.2",
"version": "1.3.3",
"bundled": true,
"private": false,
"script": "screen.js",
+4 -14
View File
@@ -33,8 +33,6 @@ def setup(app: FastAPI, context: dict):
"lastInstrument": DEFAULT_INSTRUMENT,
"freeTune": False,
"customTunings": {},
"disabledTunings": [],
"showFloatingButton": True,
"visualizationMode": "default",
"audioInputMode": "auto",
"autoOpenOnTuningChange": False,
@@ -51,8 +49,6 @@ def setup(app: FastAPI, context: dict):
res["lastInstrument"] = str(data.get("lastInstrument", DEFAULT_INSTRUMENT))
res["freeTune"] = bool(data.get("freeTune", False))
res["customTunings"] = data.get("customTunings", {})
res["disabledTunings"] = data.get("disabledTunings", [])
res["showFloatingButton"] = bool(data.get("showFloatingButton", True))
res["visualizationMode"] = str(data.get("visualizationMode", "default"))
raw_mode = str(data.get("audioInputMode", "auto"))
res["audioInputMode"] = raw_mode if raw_mode in ("auto", "browser") else "auto"
@@ -64,8 +60,6 @@ def setup(app: FastAPI, context: dict):
if not isinstance(res["customTunings"], dict):
res["customTunings"] = {}
if not isinstance(res["disabledTunings"], list):
res["disabledTunings"] = []
# Migrate custom tunings from old flat-list format
res["customTunings"] = {
@@ -73,12 +67,6 @@ def setup(app: FastAPI, context: dict):
for name, val in res["customTunings"].items()
}
# Strip legacy disabledTunings entries that lack compound "instrument:name" format
res["disabledTunings"] = [
e for e in res["disabledTunings"]
if isinstance(e, str) and ":" in e
]
return res
except Exception:
return defaults
@@ -86,8 +74,10 @@ def setup(app: FastAPI, context: dict):
def _write(data: dict) -> None:
config_dir.mkdir(parents=True, exist_ok=True)
current = _read()
# Strip keys that belong to core, not to this plugin's config.
for key in ("defaultTunings", "referencePitch"):
# Strip keys that belong to core, not to this plugin's config, plus
# retired keys (disabledTunings/showFloatingButton — their settings UI
# was removed) so a stale client can't re-persist them.
for key in ("defaultTunings", "referencePitch", "disabledTunings", "showFloatingButton"):
data = {k: v for k, v in data.items() if k != key}
current.update(data)
config_file.write_text(json.dumps(current, indent=2), encoding="utf-8")
+1 -11
View File
@@ -42,7 +42,6 @@
_allTunings: {},
referencePitch: 440,
visualizationMode: 'default',
showFloatingButton: true,
currentSongOffsets: null,
currentSongIsBass: false,
currentSongStringCount: 0,
@@ -92,10 +91,6 @@
}
// ── Tuning helpers ────────────────────────────────────────────────
function _isTuningEnabled(instrument, name) {
return !((_state._serverConfig ? _state._serverConfig.disabledTunings : null) || []).includes(instrument + ':' + name);
}
function _instrumentForTuning(name) {
for (var key in _state._allTunings) {
if (_state._allTunings[key] && _state._allTunings[key][name]) return key;
@@ -104,11 +99,7 @@
}
function _buildTuningsForInstrument(instrument) {
const all = _state._allTunings[instrument] || {};
const disabled = (_state._serverConfig ? _state._serverConfig.disabledTunings : null) || [];
return Object.fromEntries(
Object.entries(all).filter(([name]) => !disabled.includes(instrument + ':' + name))
);
return { ...(_state._allTunings[instrument] || {}) };
}
function _tuningIdentityKey(songInfo) {
@@ -554,7 +545,6 @@
_state._serverConfig = config;
_state._allTunings = tuningsData.tunings || {};
_state.referencePitch = tuningsData.referencePitch || 440;
_state.showFloatingButton = config.showFloatingButton !== false;
_state.visualizationMode = config.visualizationMode || 'default';
_state.audioInputMode = config.audioInputMode || 'auto';
+1 -146
View File
@@ -1,15 +1,4 @@
<div class="space-y-6 py-2">
<div class="flex items-center justify-between bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
<div>
<h3 class="text-sm font-medium text-gray-200">Floating Button</h3>
<p class="text-[11px] text-gray-500">Show the tuner button on the main interface.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="tuner-show-floating" class="sr-only peer" onchange="window._tunerToggleFloating(this.checked)">
<div class="w-9 h-5 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-accent"></div>
</label>
</div>
<div class="flex items-center justify-between bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
<div>
<h3 class="text-sm font-medium text-gray-200">Auto-open on tuning change</h3>
@@ -38,14 +27,6 @@
</script>
<div>
<h3 class="text-sm font-medium text-gray-400 mb-3">Tuning Visibility</h3>
<p class="text-xs text-gray-500 mb-4">Toggle which built-in tunings appear in the tuner menu.</p>
<div id="tuner-visibility-list" class="space-y-2 pr-2">
<!-- Populated by JS -->
</div>
</div>
<div class="pt-4 border-t border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Custom Tunings</h3>
<div id="tuner-custom-list" class="space-y-2 mb-4">
<!-- Populated by JS -->
@@ -88,16 +69,7 @@
<script>
(function() {
let config = { customTunings: {}, disabledTunings: [], defaultTunings: {}, showFloatingButton: true, audioInputMode: 'auto' };
let expandedGroups = [];
var _INSTRUMENT_CAPTIONS = {
"guitar-6": "Guitar",
"guitar-7": "Guitar 7-string",
"guitar-8": "Guitar 8-string",
"bass-4": "Bass 4-string",
"bass-5": "Bass 5-string"
};
let config = { customTunings: {}, audioInputMode: 'auto' };
var _instrumentLabels = {
"guitar-6": "Guitar 6-string",
@@ -112,9 +84,6 @@
const resp = await fetch('/api/plugins/tuner/config');
config = await resp.json();
const floatingToggle = document.getElementById('tuner-show-floating');
if (floatingToggle) floatingToggle.checked = config.showFloatingButton !== false;
const browserAudioToggle = document.getElementById('tuner-force-browser-audio');
if (browserAudioToggle) browserAudioToggle.checked = config.audioInputMode === 'browser';
@@ -125,11 +94,6 @@
} catch (e) { console.error('Tuner settings: load failed', e); }
}
window._tunerToggleFloating = (enabled) => {
config.showFloatingButton = enabled;
save();
};
window._tunerToggleBrowserAudio = (forceBrowser) => {
config.audioInputMode = forceBrowser ? 'browser' : 'auto';
save();
@@ -153,115 +117,6 @@
}
function render() {
const visList = document.getElementById('tuner-visibility-list');
visList.innerHTML = '';
const defaultTunings = config.defaultTunings || {};
Object.keys(defaultTunings).forEach(groupName => {
const group = defaultTunings[groupName];
const groupTunings = Object.keys(group);
const instrument = groupName;
// Compound keys for all tunings in this group
const compoundKeys = groupTunings.map(n => instrument + ':' + n);
const groupWrapper = document.createElement('div');
groupWrapper.className = 'mb-4';
const header = document.createElement('div');
header.className = 'flex items-center justify-between p-2 mt-2 bg-dark-900/80 rounded-t-lg border-x border-t border-gray-800/50 cursor-pointer hover:bg-dark-900 transition-colors';
const left = document.createElement('div');
left.className = 'flex items-center gap-2';
const chevron = document.createElement('span');
chevron.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>';
chevron.className = 'text-gray-500 transition-transform duration-200 rotate-0';
const groupLabel = document.createElement('span');
groupLabel.className = 'text-[11px] font-bold text-gray-400 uppercase tracking-wider';
groupLabel.textContent = _INSTRUMENT_CAPTIONS[groupName] || groupName;
left.appendChild(chevron);
left.appendChild(groupLabel);
const groupToggle = document.createElement('input');
groupToggle.type = 'checkbox';
const allEnabled = compoundKeys.every(k => !config.disabledTunings.includes(k));
const someEnabled = compoundKeys.some(k => !config.disabledTunings.includes(k));
groupToggle.checked = allEnabled;
groupToggle.indeterminate = someEnabled && !allEnabled;
groupToggle.className = 'accent-accent';
groupToggle.onclick = (e) => e.stopPropagation();
groupToggle.onchange = () => {
if (groupToggle.checked) {
config.disabledTunings = config.disabledTunings.filter(k => !compoundKeys.includes(k));
} else {
compoundKeys.forEach(k => {
if (!config.disabledTunings.includes(k)) config.disabledTunings.push(k);
});
}
save();
render();
};
header.appendChild(left);
header.appendChild(groupToggle);
groupWrapper.appendChild(header);
const groupContainer = document.createElement('div');
groupContainer.className = 'border-x border-b border-gray-800/50 rounded-b-lg overflow-hidden';
const isExpanded = expandedGroups.includes(groupName);
if (!isExpanded) {
groupContainer.classList.add('hidden');
chevron.classList.remove('rotate-90');
} else {
chevron.classList.add('rotate-90');
}
header.onclick = () => {
const idx = expandedGroups.indexOf(groupName);
if (idx === -1) {
expandedGroups.push(groupName);
} else {
expandedGroups.splice(idx, 1);
}
render();
};
groupTunings.forEach((name, idx) => {
const compoundKey = instrument + ':' + name;
const div = document.createElement('div');
div.className = `flex items-center justify-between p-2 bg-dark-800/30 hover:bg-dark-800/50 transition-colors ${idx === 0 ? 'border-t-0' : 'border-t border-gray-800/20'}`;
const label = document.createElement('span');
label.className = 'text-xs text-gray-300';
label.textContent = name;
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = !config.disabledTunings.includes(compoundKey);
toggle.className = 'accent-accent';
toggle.onchange = () => {
if (toggle.checked) {
config.disabledTunings = config.disabledTunings.filter(k => k !== compoundKey);
} else {
if (!config.disabledTunings.includes(compoundKey)) config.disabledTunings.push(compoundKey);
}
save();
render();
};
div.appendChild(label);
div.appendChild(toggle);
groupContainer.appendChild(div);
});
groupWrapper.appendChild(groupContainer);
visList.appendChild(groupWrapper);
});
const customList = document.getElementById('tuner-custom-list');
customList.innerHTML = '';
const customNames = Object.keys(config.customTunings);
+42 -2
View File
@@ -441,7 +441,7 @@ window._tunerUI = function(state, actions) {
const btn = document.getElementById('tuner-toggle-btn');
if (!btn) return;
const isPlayer = document.querySelector('.screen.active')?.id === 'player';
if (!state.showFloatingButton || isPlayer || window.feedBack?.isPlaying) {
if (isPlayer || window.feedBack?.isPlaying) {
btn.classList.add('hidden');
} else {
btn.classList.remove('hidden');
@@ -727,6 +727,33 @@ window._tunerUI = function(state, actions) {
document.body.appendChild(state.uiContainer);
state.uiContainer.addEventListener('click', (e) => e.stopPropagation());
// Re-anchor while the panel is open: the popover it hugs is
// vertically centered, so its rect shifts with viewport height.
// initUI() runs once (guarded above), so this binds a single listener.
window.addEventListener('resize', () => {
if (state.uiContainer && !state.uiContainer.classList.contains('hidden')) positionPanel();
});
}
// Resolve the sidebar Plugins popover to anchor the panel beside it.
// Prefer the host's stable plugin-control slot API and derive its popover
// container; fall back to the known popover id only if that's unavailable.
// Returns a visible rect, or null (→ caller uses the fixed fallback slot).
function _pluginsPopoverRect() {
let pop = null;
try {
if (window.feedBack?.ui && typeof window.feedBack.ui.playerControlSlot === 'function') {
const slot = window.feedBack.ui.playerControlSlot();
if (slot instanceof Element) pop = slot.closest('.v3-rail-pop') || slot;
}
} catch (_e) { /* host slot API failure → fall back to id lookup */ }
if (!pop) pop = document.getElementById('v3-rail-pop-plugins');
// offsetParent === null covers display:none on the element or any
// ancestor (more robust than testing a specific `hidden` class).
if (!pop || pop.offsetParent === null) return null;
const rect = pop.getBoundingClientRect();
return (rect.width || rect.height) ? rect : null;
}
function positionPanel() {
@@ -746,7 +773,20 @@ window._tunerUI = function(state, actions) {
.replace('right-0', '')
.replace('top-full', '')
.trim();
state.uiContainer.style.cssText = 'top:5rem;right:11rem';
const anchor = _pluginsPopoverRect();
if (anchor) {
// Anchor to the right of the Plugins popover, clamped to the
// viewport so the panel never opens off-screen (right/bottom)
// on a narrow or short window.
const GAP = 8, MARGIN = 8;
const pw = state.uiContainer.offsetWidth || 288; // w-72
const ph = state.uiContainer.offsetHeight || 0;
const left = Math.max(MARGIN, Math.min(anchor.right + GAP, window.innerWidth - pw - MARGIN));
const top = Math.max(MARGIN, Math.min(anchor.top, window.innerHeight - ph - MARGIN));
state.uiContainer.style.cssText = `top:${top}px;left:${left}px`;
} else {
state.uiContainer.style.cssText = 'top:5rem;right:11rem';
}
return;
}
+3722 -155
View File
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -6778,6 +6778,14 @@ window.feedBack.playQueue = (function () {
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
source: function () { return source; },
remaining: function () { return active() ? list.length - idx - 1 : 0; },
// What's coming, for consumers that RENDER the queue (a results
// screen's "Up next: … starting in 10s" strip) without reaching into
// queue internals. Null when nothing follows.
peekNext: function () {
return hasNext()
? { filename: list[idx + 1], index: idx + 1, total: list.length }
: null;
},
};
})();
@@ -9777,6 +9785,12 @@ async function startSongCountIn() {
// Time display + highway sync
let lastAudioTime = 0;
// hud-time write cache: the 60 Hz tick below used to rewrite textContent
// (and getElementById) every tick even though the mm:ss display only
// changes once a second — each write invalidates layout. Write-on-change
// with a cached element ref (re-resolved if detached).
let _hudTimeEl = null;
let _hudTimeLast = '';
setInterval(() => {
let ct = _audioTime();
const dur = _audioDuration();
@@ -9804,7 +9818,12 @@ setInterval(() => {
ct = lastAudioTime;
}
lastAudioTime = ct;
document.getElementById('hud-time').textContent = `${formatTime(ct)} / ${formatTime(dur)}`;
const hudText = `${formatTime(ct)} / ${formatTime(dur)}`;
if (hudText !== _hudTimeLast) {
if (!_hudTimeEl || !_hudTimeEl.isConnected) _hudTimeEl = document.getElementById('hud-time');
if (_hudTimeEl) _hudTimeEl.textContent = hudText;
_hudTimeLast = hudText;
}
if (dur) {
_maybeRefreshSectionPracticeDuration(dur);
}
+119
View File
@@ -0,0 +1,119 @@
// Core interface-scale capability — the app-wide "Interface size" preference.
//
// Owns a single user setting: a multiplier applied to the ROOT font-size, so
// the rem-based v3 chrome (menus, buttons, text, spacing) scales together. This
// is the DOM lever — it deliberately does NOT touch the gameplay highway canvas
// (which is sized in device pixels, not rem), so scaling the UI never changes
// playback resolution or FPS.
//
// It is exposed as a host read/write API on `window.feedBack.scale` so surfaces
// that can't inherit `rem` — canvas / WebGL renderers such as the note-highway
// HUD or results scorecards — can read the number via `feedBack.scale.get()`
// and follow `scale:changed`. Shape mirrors the other host capabilities (a
// frozen, versioned object) and the working-tuning read-API: synchronous
// `get()`, a `set()` mutator, and a change event that also fires once on load.
//
// The visual apply ALSO runs pre-paint from a tiny inline <head> script (see
// index.html) so there is no flash-of-reflow on load; this module is the
// authoritative owner and re-applies idempotently.
(function () {
'use strict';
window.feedBack = window.feedBack || {};
if (window.feedBack.scale && window.feedBack.scale.version === 1) return;
var STORE_KEY = 'v3-interface-scale';
var MIN = 0.85, MAX = 1.50, DEFAULT = 1.0;
// The named presets rendered by the Settings segmented control. Kept here so
// the control and any consumer read the ladder from one source of truth.
var PRESETS = [
{ step: 'small', value: 0.90 },
{ step: 'medium', value: 1.00 },
{ step: 'large', value: 1.15 },
{ step: 'x-large', value: 1.30 },
];
function clamp(n) {
n = Number(n);
if (!isFinite(n)) return DEFAULT;
return Math.min(MAX, Math.max(MIN, n));
}
function stepFor(value) {
for (var i = 0; i < PRESETS.length; i++) {
if (Math.abs(PRESETS[i].value - value) < 0.001) return PRESETS[i].step;
}
return 'custom';
}
function read() {
try {
var raw = localStorage.getItem(STORE_KEY);
if (raw == null) return DEFAULT;
return clamp(parseFloat(raw));
} catch (_) { return DEFAULT; }
}
var current = read();
// Apply to the DOM. The lever is a RELATIVE root font-size (a percentage of
// the user-agent base), never a px literal — so a user who raised their
// browser/OS base font size is respected, not silently overridden. We also
// publish the always-present `--fb-scale` token for canvas consumers and CSS.
function apply(value) {
var el = document.documentElement;
if (!el) return;
el.style.setProperty('--fb-scale', String(value));
// Medium (1.0) clears the inline override so default rendering is
// byte-identical to before this feature existed (zero blast radius).
el.style.fontSize = (Math.abs(value - 1) < 0.001) ? '' : (value * 100).toFixed(2) + '%';
}
function persist(value) {
try {
if (Math.abs(value - DEFAULT) < 0.001) localStorage.removeItem(STORE_KEY);
else localStorage.setItem(STORE_KEY, String(value));
} catch (_) { /* private mode */ }
}
function announce() {
try {
if (typeof window.feedBack.emit === 'function') {
window.feedBack.emit('scale:changed', { value: current, step: stepFor(current) });
}
} catch (_) { /* noop */ }
}
// Hydrate on load (idempotent with the pre-paint inline script).
apply(current);
window.feedBack.scale = Object.freeze({
version: 1,
min: MIN,
max: MAX,
default: DEFAULT,
// Synchronous — valid immediately after this module parses.
get: function () { return { value: current, step: stepFor(current) }; },
// A copy of the preset ladder, so a UI can render it from one source.
presets: function () {
return PRESETS.map(function (p) { return { step: p.step, value: p.value }; });
},
// Set + apply + persist + announce. Pass { persist:false } for a
// transient preview (e.g. a live slider drag) that shouldn't be written.
set: function (value, opts) {
var v = clamp(value);
current = v;
apply(v);
if (!opts || opts.persist !== false) persist(v);
announce();
return current;
},
});
// Announce once after the document parses, so any listener wired during page
// load can sync without special-casing (consumers may also just call get()).
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', announce, { once: true });
} else {
announce();
}
})();
+169 -92
View File
@@ -122,6 +122,24 @@ function createHighway() {
// where offsetParent === null isn't enough.
let _visibleOverride = null;
let _lastVisible = null;
// Throttled DOM visibility sampling. Reading canvas.offsetParent
// every rAF frame forces a style/layout recalc — profiled at ~0.5 s
// main-thread self-time over a 63 s session. The displayed state
// changes rarely (navigate / splitscreen panel toggle), so the DOM
// is only re-sampled every _DOM_VIS_CHECK_FRAMES frames; the cached
// value serves the frames in between (worst-case transition latency
// ~10 frames ≈ 166 ms at 60 Hz — fine for a hide/show pause signal).
// Set _domVisSampledFrame to NaN to force a fresh sample on the next
// check (done on init, canvas replace, resize, and override-clear so
// deliberate transitions don't wait out the throttle window).
// NOTE those manual resets are LATENCY optimizations, not correctness
// requirements: the periodic re-sample runs every _DOM_VIS_CHECK_FRAMES
// frames regardless, so a visibility-affecting path that forgets to
// reset self-heals within ~10 frames — stale visibility can never be
// served indefinitely.
const _DOM_VIS_CHECK_FRAMES = 10;
let _domVisCached = false;
let _domVisSampledFrame = NaN;
let animFrame = null;
// Paused-render throttle (feedBack#654). The rAF loop runs
// unconditionally and only gates on visibility + ready, never on
@@ -733,103 +751,121 @@ function createHighway() {
// on the freshly-mounted canvas.
let _currentCanvasContextType = '2d';
// One persistent bundle object per createHighway() instance —
// _makeBundle() mutates its fields in place each call instead of
// allocating a fresh ~35-field object per rAF frame (steady GC churn
// on weak hardware, ×N under splitscreen). Consequences for
// consumers: the bundle OBJECT's identity is stable across frames
// and carries no meaning; its field values are only valid for the
// duration of the current draw call. Array fields (`notes`,
// `chords`, `handShapes`, `chordTemplates`, ...) still swap
// reference whenever chart data changes — field-identity caches
// (e.g. highway_3d's merge caches) rely on that invariant.
const _bundleReused = {};
function _makeBundle() {
// Snapshot of current factory state passed to each renderer call.
// Arrays and songInfo are LIVE references, not copies — the bundle
// itself is rebuilt each frame but its `notes`, `chords`,
// `anchors`, `beats`, etc. point at closure state. Renderers
// MUST NOT mutate these; treat them as read-only. We don't
// Object.freeze or deep-copy for per-frame allocation cost reasons.
return {
// Timing
currentTime,
songInfo,
isReady: ready,
// True while the chart clock is actively advancing; false when
// audio is paused / stalled / mid-seek (setTime has kept getting
// the same t for > _CHART_MAX_INTERP_MS). This is the same
// predicate getTime() uses to decide raw-vs-interpolated, and the
// no-anchor boot state reads as not-playing — matching getTime()
// returning raw chartTime there. Renderers that run their own
// sub-frame clock (highway_3d's smoothNow) gate on this to fall
// back to raw instead of extrapolating forward against a frozen
// audio sample. Undefined on downlevel hosts → those renderers
// keep their own staleness-based fallback.
isPlaying: !Number.isNaN(_chartAnchorPerfNow)
&& (performance.now() - _chartLastAdvanceAt) <= _CHART_MAX_INTERP_MS,
// Arrays and songInfo are LIVE references, not copies — the
// bundle's `notes`, `chords`, `anchors`, `beats`, etc. point at
// closure state. Renderers MUST NOT mutate these; treat them as
// read-only. We don't Object.freeze or deep-copy for per-frame
// cost reasons.
const b = _bundleReused;
// Timing
b.currentTime = currentTime;
b.songInfo = songInfo;
b.isReady = ready;
// True while the chart clock is actively advancing; false when
// audio is paused / stalled / mid-seek (setTime has kept getting
// the same t for > _CHART_MAX_INTERP_MS). This is the same
// predicate getTime() uses to decide raw-vs-interpolated, and the
// no-anchor boot state reads as not-playing — matching getTime()
// returning raw chartTime there. Renderers that run their own
// sub-frame clock (highway_3d's smoothNow) gate on this to fall
// back to raw instead of extrapolating forward against a frozen
// audio sample. Undefined on downlevel hosts → those renderers
// keep their own staleness-based fallback.
b.isPlaying = !Number.isNaN(_chartAnchorPerfNow)
&& (performance.now() - _chartLastAdvanceAt) <= _CHART_MAX_INTERP_MS;
// Chart content (filter-aware — difficulty-filtered arrays
// preferred; raw arrays are the fallback when no ladder data).
notes: _filteredNotes !== null ? _filteredNotes : notes,
chords: _filteredChords !== null ? _filteredChords : chords,
anchors: _filteredAnchors !== null ? _filteredAnchors : anchors,
beats,
sections,
chordTemplates,
stringCount,
// Mirrors song_info tuning capo offsets (±semitones from the
// instruments standard open-string layout). Live reference.
tuning: songInfo?.tuning,
capo: songInfo?.capo,
lyrics,
lyricsSource,
toneChanges,
toneBase,
// Drum tab payload (or null when the active arrangement has
// no drum_tab). Live reference — renderers MUST treat as
// read-only. Plugins should prefer this over decoding the
// standard `notes` stream when present; absence is the
// signal to fall back to legacy MIDI-encoded drums.
drumTab,
// Chart content (filter-aware — difficulty-filtered arrays
// preferred; raw arrays are the fallback when no ladder data).
b.notes = _filteredNotes !== null ? _filteredNotes : notes;
b.chords = _filteredChords !== null ? _filteredChords : chords;
b.anchors = _filteredAnchors !== null ? _filteredAnchors : anchors;
b.beats = beats;
b.sections = sections;
b.chordTemplates = chordTemplates;
b.stringCount = stringCount;
// Mirrors song_info tuning capo offsets (±semitones from the
// instruments standard open-string layout). Live reference.
b.tuning = songInfo?.tuning;
b.capo = songInfo?.capo;
b.lyrics = lyrics;
b.lyricsSource = lyricsSource;
b.toneChanges = toneChanges;
b.toneBase = toneBase;
// Drum tab payload (or null when the active arrangement has
// no drum_tab). Live reference — renderers MUST treat as
// read-only. Plugins should prefer this over decoding the
// standard `notes` stream when present; absence is the
// signal to fall back to legacy MIDI-encoded drums.
b.drumTab = drumTab;
// Master-difficulty (feedBack#48)
mastery: _mastery,
hasPhraseData: !!(_phrases && _phrases.length > 0),
// When phrase data authored ANY handshape, respect the filtered
// list strictly (even when this difficulty leaves it empty) —
// otherwise low-mastery levels would surface arp hints that
// don't belong. Only fall back to the flat list when the
// phrase data carries no handshapes at all (common on DLC
// where handshapes ship on the arrangement root).
handShapes: (_filteredHandShapes !== null && _phrasesHaveHandShapes)
? _filteredHandShapes
: handShapes,
// Master-difficulty (feedBack#48)
b.mastery = _mastery;
b.hasPhraseData = !!(_phrases && _phrases.length > 0);
// When phrase data authored ANY handshape, respect the filtered
// list strictly (even when this difficulty leaves it empty) —
// otherwise low-mastery levels would surface arp hints that
// don't belong. Only fall back to the flat list when the
// phrase data carries no handshapes at all (common on DLC
// where handshapes ship on the arrangement root).
b.handShapes = (_filteredHandShapes !== null && _phrasesHaveHandShapes)
? _filteredHandShapes
: handShapes;
// Display flags
inverted: _inverted,
lefty: _lefty,
renderScale: _effectiveRenderScale(),
lyricsVisible: showLyrics,
// Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers
// (e.g. the 3D highway) can mirror the 2D opt-in toggle. The fg
// finger-hint pref rides alongside (default on, independently hideable).
teachingMarksVisible: _showTeachingMarks,
fingerHintsVisible: _showFingerHints,
// Display flags
b.inverted = _inverted;
b.lefty = _lefty;
b.renderScale = _effectiveRenderScale();
b.lyricsVisible = showLyrics;
// Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers
// (e.g. the 3D highway) can mirror the 2D opt-in toggle. The fg
// finger-hint pref rides alongside (default on, independently hideable).
b.teachingMarksVisible = _showTeachingMarks;
b.fingerHintsVisible = _showFingerHints;
// 2D-style helpers (renderers that don't need these can ignore).
// `fillTextUnmirrored` is deliberately NOT exposed here —
// the factory-level version writes to the default renderer's
// closure ctx, which is null for custom renderers. Renderers
// that need lefty-aware text should check `bundle.lefty` and
// apply the mirror transform themselves on their own context.
project,
fretX,
// 2D-style helpers (renderers that don't need these can ignore).
// `fillTextUnmirrored` is deliberately NOT exposed here —
// the factory-level version writes to the default renderer's
// closure ctx, which is null for custom renderers. Renderers
// that need lefty-aware text should check `bundle.lefty` and
// apply the mirror transform themselves on their own context.
b.project = project;
b.fretX = fretX;
// Windowed-iteration helpers (stable references): lower-bound
// binary searches so custom viz don't full-scan chart arrays per
// frame. lowerBoundT keys on `.t` (notes / chords); lowerBoundTime
// keys on `.time` (beats / anchors / sections).
b.lowerBoundT = bsearch;
b.lowerBoundTime = bsearchTime;
// Per-note judgment overlay (feedBack#254). Renderers call
// this per visible note / chord-note to find out whether a
// scorer (note_detect) has flagged it hit / actively-held /
// missed, so the gem itself can light up instead of relying
// on an overlay ring. Returns null when no provider is set
// or it reports nothing for this note; otherwise
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
getNoteState: _noteState, // stable reference — no per-frame allocation
// Lets custom renderers (e.g. highway_3d) tell "is a provider
// attached" apart from "no provider, getNoteState always
// returns null" — `getNoteState` always exists on the bundle
// so its presence alone isn't a useful "detect mode" signal.
// Renderers gate verdict-window cull / draw extensions on this.
getNoteStateProvider: _getNoteStateProvider, // stable — see above
};
// Per-note judgment overlay (feedBack#254). Renderers call
// this per visible note / chord-note to find out whether a
// scorer (note_detect) has flagged it hit / actively-held /
// missed, so the gem itself can light up instead of relying
// on an overlay ring. Returns null when no provider is set
// or it reports nothing for this note; otherwise
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
b.getNoteState = _noteState; // stable reference
// Lets custom renderers (e.g. highway_3d) tell "is a provider
// attached" apart from "no provider, getNoteState always
// returns null" — `getNoteState` always exists on the bundle
// so its presence alone isn't a useful "detect mode" signal.
// Renderers gate verdict-window cull / draw extensions on this.
b.getNoteStateProvider = _getNoteStateProvider; // stable — see above
return b;
}
const _defaultRenderer = {
@@ -1047,6 +1083,7 @@ function createHighway() {
// suppress the first transition. Reset to null so the next
// rAF tick re-emits unconditionally.
_lastVisible = null;
_domVisSampledFrame = NaN; // fresh canvas → fresh DOM sample
// Defensive notify for plugins / overlays that cache the
// canvas element across events. Lazy lookups via
// getElementById('highway') do not need this — they'll pick
@@ -1246,7 +1283,14 @@ function createHighway() {
// hosts that need those use setVisible() instead.
function _isHighwayVisible() {
if (_visibleOverride !== null) return _visibleOverride;
return !!(canvas && canvas.offsetParent !== null);
// Throttled offsetParent read — see _DOM_VIS_CHECK_FRAMES above.
if (Number.isNaN(_domVisSampledFrame)
|| ((_frameIdx - _domVisSampledFrame) | 0) >= _DOM_VIS_CHECK_FRAMES
|| ((_frameIdx - _domVisSampledFrame) | 0) < 0) {
_domVisCached = !!(canvas && canvas.offsetParent !== null);
_domVisSampledFrame = _frameIdx;
}
return _domVisCached;
}
// Emit only on transition so renderer-side listeners aren't woken
@@ -1513,7 +1557,13 @@ function createHighway() {
}
function drawBeats(W, H) {
for (const beat of beats) {
// Window the beat scan — a long song carries thousands of beats
// and iterating (and projecting) all of them per frame was pure
// waste; project() culling stays as the safety net.
const lo = bsearchTime(beats, currentTime - 0.25);
const hi = bsearchTime(beats, currentTime + VISIBLE_SECONDS + 0.25);
for (let i = lo; i < hi; i++) {
const beat = beats[i];
const tOff = beat.time - currentTime;
const p = project(tOff);
if (!p || p.scale < 0.06) continue;
@@ -2624,6 +2674,19 @@ function createHighway() {
}
return lo;
}
// Lower-bound binary search for `.time`-keyed arrays (beats, anchors,
// sections) — bsearch/bsearchChords key on `.t` and would compare
// against undefined here. Exposed to custom viz as
// bundle.lowerBoundTime.
function bsearchTime(arr, time) {
let lo = 0, hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid].time < time) lo = mid + 1;
else hi = mid;
}
return lo;
}
// ── Chord rendering — chains, frames, fretline preview (feedBack#88) ──
//
@@ -2956,6 +3019,7 @@ function createHighway() {
init(canvasEl, container) {
canvas = canvasEl;
_resizeContainer = container || null;
_domVisSampledFrame = NaN; // new mount → fresh DOM sample
// Size the canvas BEFORE installing the renderer so
// _setRenderer's init/resize calls see the real dimensions
// instead of the default 300x150 backing store. Otherwise
@@ -2997,6 +3061,9 @@ function createHighway() {
resize() {
if (!canvas) return;
// Layout just changed (window resize / container swap) —
// re-sample DOM visibility on the next check.
_domVisSampledFrame = NaN;
let w, h;
if (_resizeContainer) {
const rect = _resizeContainer.getBoundingClientRect();
@@ -3771,6 +3838,10 @@ function createHighway() {
// rAF tick.
setVisible(v) {
_visibleOverride = (v === null || v === undefined) ? null : !!v;
// Clearing the override resumes DOM-based detection — force a
// fresh offsetParent sample so the resulting transition (if
// any) emits now, not after the throttle window.
if (_visibleOverride === null) _domVisSampledFrame = NaN;
_emitVisibilityIfChanged();
},
// Snapshot of the current visibility state (the override if
@@ -3779,6 +3850,12 @@ function createHighway() {
// can call this once to sync their initial state — the event
// is transition-only and won't re-fire for late subscribers.
isVisible() {
// Force a fresh DOM sample — this is a documented "live DOM
// check" for late subscribers seeding initial state, so it
// must not serve the rAF loop's throttled cache (up to
// ~166 ms stale). Called rarely; the layout-read cost that
// motivated the throttle only matters per-frame.
_domVisSampledFrame = NaN;
return _isHighwayVisible();
},
getNotes() { return notes; },
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -71,7 +71,7 @@
'<div class="flex items-center gap-2 text-xs text-fb-textDim mb-3">' +
'<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V5l12-2v13M9 13l12-2"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>' +
'<span>Audio Routing</span></div>' +
'<div class="flex items-center gap-2 text-[11px] text-fb-textDim">' +
'<div class="flex items-center gap-2 text-[0.6875rem] text-fb-textDim">' +
dot(st.inputAvailable) + '<span>Input</span>' +
'<span class="flex-1 border-t border-dashed border-fb-border/70"></span>' +
dot(st.effectActive) + '<span>VST/NAM/IR</span>' +
+6 -6
View File
@@ -296,7 +296,7 @@
'<div class="shrink-0 flex flex-col gap-[3px] items-center justify-center">' + meter + '</div>' +
'<div class="min-w-0 text-white text-center leading-none">' +
'<div data-tuner-note class="text-2xl font-black italic tracking-tighter leading-none">' + esc(initNote) + '</div>' +
'<div data-tuner-hz class="text-[9px] text-gray-400 mt-0.5 tracking-wider truncate">' + hz + 'hz</div>' +
'<div data-tuner-hz class="text-[0.5625rem] text-gray-400 mt-0.5 tracking-wider truncate">' + hz + 'hz</div>' +
'</div></button>' +
'</div>';
host.querySelector('[data-open-tuner]').addEventListener('click', (e) => {
@@ -399,7 +399,7 @@
// Live working-tuning label: dim while you're still in your home tuning,
// amber once you've retuned. Omitted if the host doesn't expose
// workingTuning (feature-detect → the card looks exactly as before).
(wt ? '<span class="text-[9px] leading-none font-semibold max-w-full truncate px-0.5 ' +
(wt ? '<span class="text-[0.5625rem] leading-none font-semibold max-w-full truncate px-0.5 ' +
(wt.isHome ? 'text-fb-textDim' : 'text-amber-400') + '">' + esc(wt.short) + '</span>' : '') +
'<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"/></svg>' +
'</button>' +
@@ -410,21 +410,21 @@
? '<div class="flex items-center justify-between gap-2 pb-1 border-b border-fb-border/40">' +
'<div class="min-w-0"><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim">Now in</div>' +
'<div class="text-xs font-semibold text-amber-400 truncate flex items-center gap-1">' + esc(wt.label) + ' ' + provenanceGlyph(wt.provenance) + '</div></div>' +
'<button type="button" data-inst-reset title="Reset this instrument to its home tuning" class="shrink-0 text-[11px] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-md px-2 py-1 transition-colors">Back to default</button>' +
'<button type="button" data-inst-reset title="Reset this instrument to its home tuning" class="shrink-0 text-[0.6875rem] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-md px-2 py-1 transition-colors">Back to default</button>' +
'</div>'
: '') +
instRow('Instrument', ['guitar', 'bass'].map((v) =>
pill('inst', v, v[0].toUpperCase() + v.slice(1), settings.instrument === v)).join('')) +
instRow('Strings', STRING_COUNTS[settings.instrument].map((v) =>
pill('strings', v, v + '', settings.string_count === v)).join('')) +
'<div><div class="text-[10px] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
'<select data-inst-tuning class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
// An offset-array tuning has no named option — surface it as a
// disabled, selected 'Custom' entry so the dropdown reflects reality
// (picking a named tuning still works and replaces the custom one).
(typeof settings.tuning === 'string' ? '' : '<option selected disabled>Custom</option>') +
_tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '<option' + (t === settings.tuning ? ' selected' : '') + '>' + esc(t) + '</option>').join('') + '</select></div>' +
'<div><div class="flex justify-between text-[10px] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
'<div><div class="flex justify-between text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
'<input data-inst-ref type="range" min="430" max="450" step="1" value="' + settings.reference_pitch + '" class="w-full slider-input"></div>' +
'</div></div>';
@@ -478,7 +478,7 @@
});
}
function instRow(label, inner) {
return '<div><div class="text-[10px] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
return '<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
}
function pill(group, val, label, active) {
return '<button type="button" data-pill="' + group + '" data-val="' + val + '" class="px-2 py-1 rounded-md text-xs ' +
+8 -1
View File
@@ -23,11 +23,18 @@
reg.register({
id: 'core.edit-metadata',
pluginId: 'core',
label: 'Edit metadata',
label: 'Details',
placement: 'menu',
order: 10,
applies: (song) => !!(song && song.filename),
run: (song) => {
// Prefer the v3 Details drawer (identity + personal difficulty / tags
// / notes); fall back to the legacy edit-metadata modal where the v3
// songs screen hasn't defined the opener (e.g. an older shell).
if (typeof window.__fbOpenSongDetails === 'function') {
window.__fbOpenSongDetails(song);
return;
}
if (typeof window.openEditModal !== 'function') return;
window.openEditModal({
f: song.filename, t: song.title || '', a: song.artist || '',
+2 -2
View File
@@ -67,7 +67,7 @@
const l = fmtName(song);
if (!l) return '';
const c = l === 'FEEDPAK' ? 'bg-fb-primary text-white' : 'bg-black/70 text-fb-textDim';
return '<span class="absolute bottom-0 left-0 ' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
return '<span class="absolute bottom-0 left-0 ' + c + ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-tr-md tracking-wide">' + l + '</span>';
}
// Inline pill for the hero (Pick/Continue) card, where the art is text-overlaid
// and a corner badge would collide — sits next to the card's label instead.
@@ -75,7 +75,7 @@
const l = fmtName(song);
if (!l) return '';
const c = l === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card/80 text-fb-textDim';
return '<span class="' + c + ' text-[9px] font-bold px-1.5 py-0.5 rounded tracking-wide shrink-0">' + l + '</span>';
return '<span class="' + c + ' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded tracking-wide shrink-0">' + l + '</span>';
}
// ── Continue-Playing resume ──────────────────────────────────────────---
+119
View File
@@ -60,6 +60,24 @@
} catch (_) { /* file:// or sandboxed iframe */ }
})();
</script>
<!-- Interface size (Accessibility): apply the saved UI scale to the root
font-size BEFORE any stylesheet paints, so there is no flash-of-reflow
on load. Mirrors capabilities/interface-scale.js, which is the
authoritative owner and re-applies once it parses. Relative % (never a
px literal) so a raised browser/OS base font size is respected. -->
<script>
(function () {
try {
var raw = localStorage.getItem('v3-interface-scale');
if (raw == null) return;
var v = Math.min(1.5, Math.max(0.85, parseFloat(raw)));
if (!isFinite(v) || Math.abs(v - 1) < 0.001) return;
var el = document.documentElement;
el.style.setProperty('--fb-scale', String(v));
el.style.fontSize = (v * 100).toFixed(2) + '%';
} catch (_) { /* file:// or private mode */ }
})();
</script>
<!-- fee[dB]ack mark (the [dB] motif). SVG primary, PNG fallback. -->
<link rel="icon" type="image/svg+xml" href="/static/v3/brand/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/static/v3/brand/favicon-32.png">
@@ -96,6 +114,7 @@
<script src="/static/capabilities/visualization.js"></script>
<script src="/static/capabilities/note-detection.js"></script>
<script src="/static/capabilities/midi-input.js"></script>
<script src="/static/capabilities/interface-scale.js"></script>
</head>
<body class="h-screen flex overflow-hidden bg-fb-sidebar text-fb-text font-display">
@@ -359,6 +378,7 @@
panels (manifest settings.category routes the others). -->
<div class="fb-tabbar" id="settings-tabbar">
<button type="button" class="fb-tab" data-tab="gameplay">Gameplay</button>
<button type="button" class="fb-tab" data-tab="accessibility">Accessibility</button>
<button type="button" class="fb-tab" data-tab="audio">Audio</button>
<button type="button" class="fb-tab" data-tab="graphics">Graphics</button>
<button type="button" class="fb-tab" data-tab="keybinds">Keybinds</button>
@@ -540,6 +560,40 @@
</div>
</div>
<!-- ══ ACCESSIBILITY ═══════════════════════════════════════════ -->
<div class="fb-tabpanel" data-tab="accessibility">
<div class="fb-tabpanel-head"><h3>Accessibility</h3></div>
<div class="fb-srows">
<!-- Interface size -->
<div class="fb-srow fb-srow-stack">
<div style="display:flex; align-items:center; gap:1rem;">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7V5a1 1 0 011-1h14a1 1 0 011 1v2M9 20h6M12 4v16"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Interface size</div>
<div class="fb-srow-desc">Make the app&rsquo;s menus, buttons and text larger or smaller. This changes the <strong>app interface</strong> &mdash; not the notes on the note highway (set those under <strong>Graphics</strong>). On desktop you can also press <strong>Ctrl&nbsp;+</strong> / <strong>Ctrl&nbsp;&minus;</strong> to zoom the whole window.</div>
</div>
</div>
<div class="fb-seg" id="setting-interface-size" role="group" aria-label="Interface size">
<button type="button" class="fb-seg-btn" data-scale="0.90" aria-pressed="false" onclick="window.feedBack.scale.set(0.90)">Small</button>
<button type="button" class="fb-seg-btn" data-scale="1.00" aria-pressed="false" onclick="window.feedBack.scale.set(1.00)">Medium</button>
<button type="button" class="fb-seg-btn" data-scale="1.15" aria-pressed="false" onclick="window.feedBack.scale.set(1.15)">Large</button>
<button type="button" class="fb-seg-btn" data-scale="1.30" aria-pressed="false" onclick="window.feedBack.scale.set(1.30)">Extra&nbsp;Large</button>
</div>
<p class="fb-srow-desc" style="margin-top:.55rem;">Larger sizes are recommended for large or high-resolution displays.</p>
<details class="fb-finetune">
<summary>Fine-tune size</summary>
<div class="fb-finetune-body">
<input type="range" id="setting-interface-size-slider" min="85" max="150" step="5" value="100"
oninput="window.feedBack.scale.set(this.value / 100, { persist: false })"
onchange="window.feedBack.scale.set(this.value / 100)" class="fb-srow-wide slider-input"
aria-label="Interface size percent">
<span class="fb-seg-val"><span id="setting-interface-size-val">100</span>%</span>
</div>
</details>
</div>
</div>
</div>
<!-- ══ AUDIO ═══════════════════════════════════════════════════ -->
<div class="fb-tabpanel" data-tab="audio">
<div class="fb-tabpanel-head"><h3>Audio Settings</h3></div>
@@ -681,6 +735,66 @@
<span id="rescan-status" class="text-xs text-gray-500"></span>
</div>
</div>
<!-- Metadata matching (P8 — wired by static/v3/match-review.js) -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
<div class="fb-srow-title">Metadata matching</div>
<div class="fb-srow-desc">Matches your charts against MusicBrainz in the background to tidy names, years and genres — display only, your files are never modified. Matches at or above the confidence level apply automatically; the rest wait in the library's review queue. Turning this off never disables manual match fixes.</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-enabled" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Match songs automatically</label>
<label class="flex items-center gap-2">Auto-apply confidence
<select id="enrich-threshold" class="bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
<option value="0.85">85% — more auto-matches</option>
<option value="0.9" selected>90% (recommended)</option>
<option value="0.95">95% — cautious</option>
<option value="1.01">Always review</option>
</select>
</label>
</div>
<!-- R1 scraper options: sources (who may be contacted) ×
auto-apply fields (what a confident match fills in). -->
<div class="fb-srow-wide mb-1">
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Sources</div>
<div class="grid grid-cols-2 gap-2 text-xs text-gray-400">
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-src-musicbrainz" checked class="rounded border-gray-600 bg-dark-700 text-accent"> MusicBrainz — names, years, genres</label>
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-src-caa" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Cover Art Archive — album covers</label>
</div>
</div>
<div class="fb-srow-wide mb-1">
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Auto-apply</div>
<div class="grid grid-cols-4 gap-2 text-xs text-gray-400">
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-names" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Names</label>
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-year" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Year</label>
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-genres" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Genres</label>
<label class="flex items-center gap-2"><input type="checkbox" id="enrich-apply-art" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Cover art</label>
</div>
<div class="text-[11px] text-gray-600 mt-1">What a confident match may fill in on its own — matches you confirm in the review queue always apply in full.</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
<label class="flex items-center gap-2">Review queue order
<select id="enrich-review-order" class="bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
<option value="missing_first" selected>Missing info first</option>
<option value="artist">Artist AZ</option>
<option value="recent">Recently added</option>
</select>
</label>
</div>
<div class="fb-srow-control">
<button id="enrich-match-now" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Match Now</button>
<span id="enrich-status" class="text-xs text-gray-500"></span>
</div>
</div>
<!-- Writing to your files (R4a gap-fill + R4b overwrite — wired by static/v3/match-review.js) -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
<div class="fb-srow-title">Writing to your files</div>
<div class="fb-srow-desc">"Write missing info to file" in a song's details only ever adds fields the pack is missing. Overwriting additionally lets a match you confirmed replace existing fields — always per-field, never automatic.</div>
</div>
<div class="fb-srow-wide">
<label class="flex items-center gap-2 text-xs text-gray-400"><input type="checkbox" id="allow-pack-overwrite" class="rounded border-gray-600 bg-dark-700 text-accent"> Allow overwriting existing pack fields — per-field confirmation, a backup of the original is always kept</label>
</div>
</div>
<!-- Backup -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
@@ -1118,14 +1232,19 @@
<script src="/static/v3/pedal-cables.js"></script>
<script src="/static/v3/plugins-page.js"></script>
<script src="/static/v3/card-actions-core.js"></script>
<!-- Before songs.js: the songs toolbar calls the match-review chip hook
on build, so the module must already be registered. -->
<script src="/static/v3/match-review.js"></script>
<script src="/static/v3/songs.js"></script>
<script src="/static/v3/lessons.js"></script>
<script src="/static/v3/dashboard.js"></script>
<script src="/static/v3/settings.js"></script>
<script src="/static/v3/interface-size-ui.js"></script>
<!-- First-run home tour: spotlights the home cards via the shared tour
engine (tour-engine.js, loaded above). Auto-runs once after onboarding
(triggered from profile.js finish()); replayable from the "?" menu. -->
<script src="/static/v3/onboarding-tour.js"></script>
<script src="/static/v3/interface-size-nudge.js"></script>
<script src="/static/v3/feedbarcade.js"></script>
<script src="/static/v3/player-chrome.js"></script>
<script>
+79
View File
@@ -0,0 +1,79 @@
// v3 first-run "Interface size" nudge.
//
// The Accessibility → Interface size control is the primary discovery path, but
// the exact person who needs it — someone on a large, low-DPI display (e.g. a
// 32" 1440p panel with no OS scaling) — is the one least likely to go hunting
// for it. So, ONCE, for that specific display profile, surface a gentle,
// dismissible toast that deep-links to the control. Never fires if the user has
// already touched the setting, on smaller/high-DPI displays, or more than once.
//
// Plain non-module script; degrades to a no-op without the bus, fbNotify, or DOM.
(function () {
'use strict';
var SEEN_KEY = 'v3-interface-size-nudged';
var SCALE_KEY = 'v3-interface-scale';
function alreadyHandled() {
try {
return localStorage.getItem(SEEN_KEY) === '1' || localStorage.getItem(SCALE_KEY) != null;
} catch (_) { return true; }
}
// The target profile: a physically large viewport rendered near 1:1 (so the
// OS isn't already enlarging things). This is the eye-strain case.
function isLargeLowDpiDisplay() {
var w = window.innerWidth || 0;
var dpr = window.devicePixelRatio || 1;
return w >= 1800 && dpr <= 1.25;
}
// Don't interrupt a first-run modal (e.g. profile onboarding). If one is up,
// leave the flag UNSET so the nudge gets another chance on a later launch.
function aModalIsOpen() {
var dialogs = document.querySelectorAll('[role="dialog"], .fixed.inset-0');
for (var i = 0; i < dialogs.length; i++) {
var el = dialogs[i];
if (el.offsetParent !== null && el.getClientRects().length) return true;
}
return false;
}
function openSetting() {
try {
if (typeof window.showScreen === 'function') window.showScreen('settings');
document.querySelectorAll('#settings-tabbar .fb-tab').forEach(function (b) {
if (b.dataset.tab === 'accessibility') b.click();
});
} catch (_) { /* noop */ }
}
function maybeNudge() {
if (alreadyHandled()) return;
if (!isLargeLowDpiDisplay()) return;
if (!window.fbNotify || typeof window.fbNotify.show !== 'function') return;
if (aModalIsOpen()) return; // try again next launch
try { localStorage.setItem(SEEN_KEY, '1'); } catch (_) { /* private mode */ }
var card = window.fbNotify.show({
title: 'Text looking small?',
message: 'Make the menus and text larger — tap to open Interface size.',
icon: '🔍',
accent: '#0ea5e9',
durationMs: 9000,
});
if (card && card.addEventListener) card.addEventListener('click', openSetting);
}
function start() {
// Let the app settle (boot, any onboarding) before offering the nudge.
setTimeout(maybeNudge, 4000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}
})();
+55
View File
@@ -0,0 +1,55 @@
// v3 Settings → Accessibility: keeps the "Interface size" control in sync with
// the host `feedBack.scale` capability. The buttons/slider WRITE via inline
// `feedBack.scale.set(...)`; this module only REFLECTS current state (active
// preset, slider position, % readout) so the control mirrors the live value on
// load, on every change, and each time Settings is opened.
//
// Plain non-module script, matching the rest of static/v3/*. Null-guarded so it
// no-ops on the classic v2 page (which has no Accessibility panel).
(function () {
'use strict';
function sync(state) {
if (!state) {
var cap = window.feedBack && window.feedBack.scale;
state = cap && typeof cap.get === 'function' ? cap.get() : null;
}
if (!state) return;
var seg = document.getElementById('setting-interface-size');
if (seg) {
seg.querySelectorAll('.fb-seg-btn').forEach(function (b) {
var on = Math.abs(parseFloat(b.dataset.scale) - state.value) < 0.001;
b.classList.toggle('active', on);
b.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
var slider = document.getElementById('setting-interface-size-slider');
if (slider && document.activeElement !== slider) {
slider.value = String(Math.round(state.value * 100));
}
var val = document.getElementById('setting-interface-size-val');
if (val) val.textContent = String(Math.round(state.value * 100));
}
if (window.feedBack && typeof window.feedBack.on === 'function') {
// Fires on every set() and once on load. The bus delivers a CustomEvent,
// so we ignore the arg and read the authoritative value via sync() → get().
window.feedBack.on('scale:changed', function () { sync(); });
// Re-sync when the user opens Settings (the panel may have re-rendered).
window.feedBack.on('screen:changed', function (e) {
var id = e && (e.detail ? e.detail.id : e.id);
if (id === 'settings') sync();
});
}
// Settings markup is static, but re-sync when settings.js signals it wired.
document.addEventListener('v3:settings-rendered', function () { sync(); });
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { sync(); }, { once: true });
} else {
sync();
}
window.feedBackInterfaceSize = { sync: sync };
})();
+2 -2
View File
@@ -56,8 +56,8 @@
const shown = arr.slice(0, max || 6);
const extra = arr.length - shown.length;
let html = shown.map((t) =>
'<span class="text-[10px] uppercase tracking-wider text-fb-textDim bg-black/30 border border-fb-border/50 rounded px-1.5 py-0.5">' + esc(t) + '</span>').join('');
if (extra > 0) html += '<span class="text-[10px] text-fb-textDim">+' + extra + '</span>';
'<span class="text-[0.625rem] uppercase tracking-wider text-fb-textDim bg-black/30 border border-fb-border/50 rounded px-1.5 py-0.5">' + esc(t) + '</span>').join('');
if (extra > 0) html += '<span class="text-[0.625rem] text-fb-textDim">+' + extra + '</span>';
return '<div class="flex flex-wrap gap-1">' + html + '</div>';
}
function progressBar(passed, total) {
+474
View File
@@ -0,0 +1,474 @@
// Match-Review UI (P8 — library-metadata design §5/§11). A self-contained
// module: the ambient "⚑ N to review" chip lives in the songs toolbar
// (songs.js renders the element and calls the hooks below); the review MODAL,
// the per-field available/missing detail, and the Settings → Library
// "Metadata matching" card behaviour all live here.
//
// The modal reviews ONE chart at a time (the scraper-review model from
// media-server / emulation-frontend apps): the chart's current metadata —
// with explicit "Missing: …" chips — above the candidate list, each
// candidate carrying "Adds / Shows as" chips, with Skip / Not a match /
// Search instead / Use selected plus navigation.
//
// Engagement guardrails (§11): opt-in tool-state, not a score. The chip only
// appears when there is something to review, matching is silent on success
// (no toasts, no sounds — hearing-safe), and nothing here ever writes to
// pack files; a confirmed match only improves the local display cache.
(function () {
'use strict';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
function artUrl(song) {
const v = song.mtime ? ('?v=' + Math.floor(song.mtime)) : '';
return '/api/song/' + enc(song.filename) + '/art' + v;
}
function fmtDur(sec) {
if (!sec && sec !== 0) return '';
const s = Math.max(0, Math.round(sec));
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
// ── Ambient chip + the Settings card's status line ───────────────────────
// songs.js renders `#v3-songs-match-review` (hidden) in its toolbar and
// calls window.__fbMatchReviewChip() after each toolbar build; review
// actions here re-call it. The same fetch feeds the Settings status line.
// Silent on failure — surfaces just stay as they are.
let _chipBusy = false;
async function refreshChip() {
if (_chipBusy) return;
_chipBusy = true;
try {
const r = await fetch('/api/enrichment/status');
if (!r.ok) return;
const body = await r.json();
const st = body.states || {};
const n = st.review || 0;
const chip = document.getElementById('v3-songs-match-review');
if (chip) {
chip.textContent = '⚑ ' + n + ' to review';
chip.classList.toggle('hidden', !n);
}
const line = document.getElementById('enrich-status');
if (line) {
const parts = [
((st.matched || 0) + (st.manual || 0)) + ' matched',
n + ' to review',
(st.failed || 0) + ' unmatched',
];
if (st.unscanned) parts.push(st.unscanned + ' queued');
line.textContent = (body.running ? 'Matching… · ' : '') + parts.join(' · ');
}
} catch (_) { /* offline — leave as-is */ } finally {
_chipBusy = false;
}
}
// ── Review modal (body-appended singleton, one chart at a time) ─────────
let _queue = [];
let _idx = 0;
let _lastFocus = null;
let _single = false; // Fix-match mode: one song, no queue navigation
function ensureModal() {
let m = document.getElementById('v3-match-modal');
if (m) return m;
const overlay = document.createElement('div');
overlay.id = 'v3-match-overlay';
overlay.className = 'fixed inset-0 bg-black/60 z-40 hidden';
overlay.addEventListener('click', closeModal);
document.body.appendChild(overlay);
m = document.createElement('div');
m.id = 'v3-match-modal';
m.className = 'fixed inset-0 z-50 hidden flex items-center justify-center p-4 pointer-events-none';
m.innerHTML = '<div id="v3-match-panel" class="pointer-events-auto w-full max-w-2xl max-h-[85vh] bg-fb-sidebar border border-fb-border/50 rounded-xl shadow-2xl flex flex-col" role="dialog" aria-label="Match review"></div>';
m.addEventListener('keydown', onModalKeydown);
document.body.appendChild(m);
return m;
}
function isTyping(e) {
const t = e.target;
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA');
}
function onModalKeydown(e) {
if (e.key === 'Escape') { e.stopPropagation(); closeModal(); return; }
if (e.key === 'ArrowLeft' && !isTyping(e)) { e.preventDefault(); nav(-1); return; }
if (e.key === 'ArrowRight' && !isTyping(e)) { e.preventDefault(); nav(1); return; }
if (e.key !== 'Tab') return;
// Light focus trap: cycle within the panel.
const panel = document.getElementById('v3-match-panel');
if (!panel) return;
const foci = panel.querySelectorAll('button, input, [tabindex="0"]');
if (!foci.length) return;
const first = foci[0], last = foci[foci.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
function openModal() {
_lastFocus = document.activeElement;
_single = false;
const m = ensureModal();
renderLoading();
m.classList.remove('hidden');
document.getElementById('v3-match-overlay')?.classList.remove('hidden');
loadQueue();
}
// Fix-match (R2): the same modal for ONE song — the escape hatch for a
// wrong (or missing) match, reachable from the card's ⋮ / right-click
// menu. No stored candidates are required: the search panel opens
// pre-filled, and a pick pins the match exactly like the review flow.
function fixMatch(song) {
if (!song || !song.filename) return;
_lastFocus = document.activeElement;
_single = true;
_queue = [{
filename: song.filename, title: song.title || song.filename,
artist: song.artist || '', album: song.album || '',
year: song.year || '', duration: song.duration,
mtime: song.mtime, candidates: [],
}];
_idx = 0;
const m = ensureModal();
m.classList.remove('hidden');
document.getElementById('v3-match-overlay')?.classList.remove('hidden');
renderCurrent();
// Straight to the point: the search panel is why this mode exists.
document.getElementById('v3-match-panel')
?.querySelector('[data-mr-search-toggle]')?.click();
}
function closeModal() {
document.getElementById('v3-match-modal')?.classList.add('hidden');
document.getElementById('v3-match-overlay')?.classList.add('hidden');
_single = false;
refreshChip();
if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { } }
_lastFocus = null;
}
function nav(step) {
if (!_queue.length) return;
_idx = Math.min(Math.max(_idx + step, 0), _queue.length - 1);
renderCurrent();
}
async function loadQueue() {
try {
const r = await fetch('/api/enrichment/review?limit=200');
_queue = r.ok ? ((await r.json()).songs || []) : [];
} catch (_) { _queue = []; }
_idx = 0;
renderCurrent();
}
function headerHtml() {
const counter = (_queue.length && !_single)
? '<span class="flex items-center gap-1 text-xs text-fb-textDim">' +
'<button data-mr-prev class="px-2 py-1 rounded hover:text-fb-text' + (_idx === 0 ? ' opacity-30' : '') + '" aria-label="Previous"></button>' +
(_idx + 1) + ' of ' + _queue.length +
'<button data-mr-next class="px-2 py-1 rounded hover:text-fb-text' + (_idx >= _queue.length - 1 ? ' opacity-30' : '') + '" aria-label="Next"></button></span>'
: '';
return '<div class="flex items-center justify-between gap-3 p-5 pb-3 border-b border-fb-border/40 shrink-0">' +
'<h3 class="text-lg font-semibold text-fb-text">' + (_single ? 'Fix match' : 'Match review') + '</h3>' + counter +
'<button data-mr-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>';
}
function renderLoading() {
const panel = document.getElementById('v3-match-panel');
if (!panel) return;
panel.innerHTML = headerHtml() +
'<div class="p-5"><p class="text-sm text-fb-textDim">Loading…</p></div>';
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
}
function renderDone() {
const panel = document.getElementById('v3-match-panel');
if (!panel) return;
panel.innerHTML = headerHtml() +
'<div class="p-5 space-y-2"><p class="text-sm text-fb-text">Nothing waiting for review.</p>' +
'<p class="text-xs text-fb-textDim">Medium-confidence matches queue here while the library is matched in the background. Matching options live in Settings → Library.</p></div>';
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
}
// Amber "what this chart lacks" chips. Album/year come from the library
// row; cover art is detected from the art request failing (flagged onto
// the song object by the <img> onerror handler, then re-rendered).
function missingChips(song) {
const missing = [];
if (!String(song.album || '').trim()) missing.push('album');
if (!String(song.year || '').trim()) missing.push('year');
if (song._artMissing) missing.push('cover art');
if (!missing.length) return '';
return '<div class="flex flex-wrap items-center gap-1 pt-1">' +
'<span class="text-xs text-fb-textDim">Missing:</span>' +
missing.map((f) => '<span class="text-xs px-1.5 py-0.5 rounded border border-amber-400/40 text-amber-300/90 bg-amber-400/10">' + esc(f) + '</span>').join('') +
'</div>';
}
// Per-candidate "what accepting this gets you": fields the chart lacks
// that the candidate supplies, and fields whose DISPLAYED value would
// change (never the file).
function diffChips(song, cand) {
const adds = [];
const changes = [];
const have = (v) => String(v == null ? '' : v).trim();
const differ = (a, b) => have(a) && have(b) && have(a).toLowerCase() !== have(b).toLowerCase();
if (have(cand.album)) { if (!have(song.album)) adds.push('album'); else if (differ(song.album, cand.album)) changes.push('album'); }
if (have(cand.year)) { if (!have(song.year)) adds.push('year'); else if (differ(song.year, cand.year)) changes.push('year'); }
if (cand.genres && cand.genres.length) adds.push('genres');
if (have(cand.isrc)) adds.push('ISRC');
if (differ(song.artist, cand.artist)) changes.push(have(song.artist) + ' → ' + have(cand.artist));
if (differ(song.title, cand.title)) changes.push('title');
let html = '';
if (adds.length) html += '<span class="text-xs text-fb-good">Adds: ' + esc(adds.join(' · ')) + '</span>';
if (changes.length) html += (html ? ' ' : '') + '<span class="text-xs text-fb-textDim">Shows as: ' + esc(changes.join(' · ')) + '</span>';
return html ? '<span class="block truncate pt-0.5">' + html + '</span>' : '';
}
function candRowHtml(song, c, i, selected) {
const meta = [c.artist, c.album, c.year, fmtDur(c.duration)].filter(Boolean).join(' · ');
const pct = c.score != null ? Math.round(c.score * 100) + '%' : '';
return '<button data-mr-cand="' + i + '" role="radio" aria-checked="' + (selected ? 'true' : 'false') + '" class="w-full text-left px-3 py-2 rounded-md border ' +
(selected ? 'border-fb-primary bg-fb-primary/10' : 'border-fb-border/50 bg-gray-800/50 hover:border-fb-primary/60') + '">' +
'<span class="flex items-baseline justify-between gap-2">' +
'<span class="text-sm text-fb-text truncate">' + esc(c.title) + '</span>' +
'<span class="text-xs text-fb-textDim shrink-0">' + esc(pct) + '</span></span>' +
'<span class="block text-xs text-fb-textDim truncate">' + esc(meta) + '</span>' +
diffChips(song, c) +
'</button>';
}
function renderCurrent() {
const panel = document.getElementById('v3-match-panel');
if (!panel) return;
if (!_queue.length) { renderDone(); return; }
_idx = Math.min(_idx, _queue.length - 1);
const song = _queue[_idx];
if (song._sel == null) song._sel = 0;
const sub = [song.artist, song.album, song.year, fmtDur(song.duration)].filter(Boolean).join(' · ');
panel.innerHTML = headerHtml() +
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll">' +
// The chart being matched
'<div class="flex items-start gap-3">' +
'<img data-mr-art src="' + esc(artUrl(song)) + '" alt="" loading="lazy" class="w-16 h-16 rounded-lg object-cover bg-fb-card shrink-0">' +
'<div class="min-w-0">' +
'<div class="text-base text-fb-text font-medium truncate">' + esc(song.title) + '</div>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(sub) + '</div>' +
'<div class="text-xs text-fb-textDim/70 truncate" title="' + esc(song.filename) + '">' + esc(song.filename) + '</div>' +
missingChips(song) +
'</div></div>' +
// Candidates (Fix-match mode arrives with none — the search panel
// is its whole point, so the empty header is suppressed).
((song.candidates || []).length
? '<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Candidates (MusicBrainz)</div>' +
song.candidates.map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') +
'</div>'
: '') +
// Search-instead panel
'<div data-mr-search-panel class="hidden space-y-2">' +
'<div class="flex gap-2">' +
'<input data-mr-search-input type="text" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary" placeholder="Artist Title">' +
'<button data-mr-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button></div>' +
'<div data-mr-search-results class="space-y-1"></div></div>' +
'</div>' +
// Footer actions. Fix-match mode drops Skip (no queue) and the
// accept button when there is nothing to accept — search-result
// rows carry their own pick action.
'<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
'<div class="flex items-center gap-3">' +
(_single ? '' : '<button data-mr-reject class="text-sm text-fb-textDim hover:text-fb-text">Not a match</button>') +
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button></div>' +
'<div class="flex items-center gap-2">' +
(_single ? '' : '<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>') +
((song.candidates || []).length ? '<button data-mr-accept class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Use selected</button>' : '') +
'</div></div>';
wireCurrent(panel, song);
}
function wireCurrent(panel, song) {
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
panel.querySelector('[data-mr-prev]')?.addEventListener('click', () => nav(-1));
panel.querySelector('[data-mr-next]')?.addEventListener('click', () => nav(1));
panel.querySelector('[data-mr-skip]')?.addEventListener('click', () => nav(1));
// Art failure → flag + re-render once so the "cover art" chip shows.
const img = panel.querySelector('[data-mr-art]');
if (img) img.onerror = () => {
img.style.visibility = 'hidden';
if (!song._artMissing) { song._artMissing = true; renderCurrent(); }
};
panel.querySelectorAll('[data-mr-cand]').forEach((btn) => {
btn.addEventListener('click', () => {
song._sel = Number(btn.getAttribute('data-mr-cand'));
renderCurrent();
});
});
panel.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
const cand = (song.candidates || [])[song._sel || 0];
if (!cand) return;
await post('/api/enrichment/review/' + enc(song.filename) + '/accept',
{ recording_id: cand.recording_id });
settle(song);
});
panel.querySelector('[data-mr-reject]')?.addEventListener('click', async () => {
await post('/api/enrichment/review/' + enc(song.filename) + '/reject');
settle(song);
});
const sp = panel.querySelector('[data-mr-search-panel]');
const input = panel.querySelector('[data-mr-search-input]');
panel.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => {
sp?.classList.toggle('hidden');
if (sp && !sp.classList.contains('hidden') && input && !input.value) {
input.value = [song.artist, song.title].filter(Boolean).join(' ');
input.focus();
}
});
const go = () => runSearch(panel, song);
panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } });
}
// Silent-on-success: the chart just leaves the queue and the next one
// renders; the last one renders the done state. No toasts, no sounds.
function settle(song) {
if (_single) { closeModal(); return; } // Fix-match: done means done
const i = _queue.indexOf(song);
if (i >= 0) _queue.splice(i, 1);
if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1);
refreshChip();
renderCurrent();
}
async function runSearch(panel, song) {
const input = panel.querySelector('[data-mr-search-input]');
const out = panel.querySelector('[data-mr-search-results]');
if (!input || !out) return;
const qRaw = input.value.trim();
if (!qRaw) return;
// "Artist Title" splits on the first dash; a plain phrase searches
// as a title, which MusicBrainz handles well enough.
const m = qRaw.split(/\s+[–—-]\s+/);
const artist = m.length > 1 ? m[0] : '';
const title = m.length > 1 ? m.slice(1).join(' - ') : qRaw;
out.innerHTML = '<p class="text-xs text-fb-textDim">Searching…</p>';
let body = null;
try {
const r = await fetch('/api/enrichment/search?artist=' + enc(artist) +
'&title=' + enc(title) + '&filename=' + enc(song.filename));
if (r.status === 503) {
out.innerHTML = '<p class="text-xs text-fb-textDim">MusicBrainz is unavailable — try again later.</p>';
return;
}
if (r.ok) body = await r.json();
} catch (_) { /* falls through to the no-results line */ }
const cands = (body && body.candidates) || [];
if (!cands.length) {
out.innerHTML = '<p class="text-xs text-fb-textDim">No results.</p>';
return;
}
out.innerHTML = cands.map((c, i) => candRowHtml(song, c, i, false)).join('');
out.querySelectorAll('[data-mr-cand]').forEach((btn) => {
btn.addEventListener('click', async () => {
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
if (!cand) return;
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
{ candidate: cand });
settle(song);
});
});
}
async function post(url, payload) {
try {
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload || {}),
});
} catch (_) { /* offline — the row simply stays queued */ }
}
// ── Settings → Library → "Metadata matching" card ────────────────────────
// Markup lives statically in index.html (the v3 settings pattern); this
// wires it. All null-guarded so v2 (which lacks the elements) no-ops.
function wireSettingsCard() {
const sel = document.getElementById('enrich-threshold');
const order = document.getElementById('enrich-review-order');
const btn = document.getElementById('enrich-match-now');
// Boolean toggles, element id → settings key. enrich-enabled is the
// master background switch; the rest are the R1 scraper options
// (per-source + per-field auto-apply) plus the R4b overwrite gate.
const toggles = [
['enrich-enabled', 'enrich_enabled'],
['enrich-src-musicbrainz', 'enrich_src_musicbrainz'],
['enrich-src-caa', 'enrich_src_caa'],
['enrich-apply-names', 'enrich_apply_names'],
['enrich-apply-year', 'enrich_apply_year'],
['enrich-apply-genres', 'enrich_apply_genres'],
['enrich-apply-art', 'enrich_apply_art'],
// R4b pack-overwrite gate — the one DEFAULT-OFF key in this list
// (see the load logic below).
['allow-pack-overwrite', 'allow_pack_overwrite'],
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
if (!toggles.length && !sel && !btn) return;
(async () => {
try {
const r = await fetch('/api/settings');
if (r.ok) {
const cfg = await r.json();
// The enrich keys default ON (absent → checked); the
// overwrite gate defaults OFF (only an explicit true ticks it).
for (const [el, key] of toggles) {
el.checked = key === 'allow_pack_overwrite' ? cfg[key] === true : cfg[key] !== false;
}
if (sel) {
const t = Number(cfg.enrich_auto_threshold);
const want = Number.isFinite(t) ? t : 0.9;
// Snap to the nearest offered option.
let best = sel.options[0];
for (const o of sel.options) {
if (Math.abs(Number(o.value) - want) < Math.abs(Number(best.value) - want)) best = o;
}
if (best) sel.value = best.value;
}
if (order) {
const v = String(cfg.enrich_review_order || 'missing_first');
order.value = ['missing_first', 'artist', 'recent'].includes(v) ? v : 'missing_first';
}
}
} catch (_) { /* leave markup defaults */ }
refreshChip(); // also fills #enrich-status
})();
const save = (key, value) => post('/api/settings', { [key]: value });
for (const [el, key] of toggles) {
el.addEventListener('change', () => save(key, !!el.checked));
}
sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value)));
order?.addEventListener('change', () => save('enrich_review_order', order.value));
btn?.addEventListener('click', async () => {
await post('/api/enrichment/kick');
const line = document.getElementById('enrich-status');
if (line) line.textContent = 'Matching…';
setTimeout(refreshChip, 1500);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wireSettingsCard, { once: true });
} else {
wireSettingsCard();
}
window.__fbMatchReviewChip = refreshChip;
window.__fbOpenMatchReview = openModal;
window.__fbFixMatch = fixMatch;
})();
+78 -14
View File
@@ -24,6 +24,18 @@
let rafId = null, running = false;
let lastMove = 0, lastUpNext = 0;
let openPop = null; // { btn, pop }
// Hover state over #player-controls, maintained by mouseenter/mouseleave
// (wired in start()). tickIdle previously called matches(':hover') every
// rAF frame, which forces a style recalc — profiled hot (feedBack perf).
let overControls = false;
const _onControlsEnter = () => { overControls = true; };
const _onControlsLeave = () => { overControls = false; };
// Up-Next pill: cached element refs (re-resolved when detached) and
// last-written values, so the 6 Hz recompute only touches the DOM when
// something actually changed — unconditional textContent/width writes
// re-triggered layout every tick.
let upnextEls = null; // { pill, nm, eta, fill }
let upnextLast = { name: null, eta: null, prog: -1, hidden: null };
// ── v3 UI signal + plugin-control slot API ───────────────────────────────
// Lets plugins detect v3 (window.feedBack.uiVersion === 'v3') and mount
@@ -169,28 +181,52 @@
}
// ── Up Next pill ─────────────────────────────────────────────────────────
function _upnextRefs() {
// Cache refs; re-resolve only when a node detached (screen re-mount).
if (!upnextEls || !upnextEls.pill || !upnextEls.pill.isConnected) {
const pill = $('v3-upnext');
if (!pill) return null;
upnextEls = {
pill,
nm: $('v3-upnext-name'),
eta: $('v3-upnext-eta'),
fill: $('v3-upnext-bar-fill'),
};
// Fresh nodes → forget last-written state so everything re-syncs.
upnextLast = { name: null, eta: null, prog: -1, hidden: null };
}
return upnextEls;
}
function _upnextSetHidden(els, hidden) {
if (upnextLast.hidden === hidden) return;
upnextLast.hidden = hidden;
els.pill.classList.toggle('hidden', hidden);
}
function updateUpNext() {
const pill = $('v3-upnext');
if (!pill) return;
const els = _upnextRefs();
if (!els) return;
// Gated by the core "Show 'Up Next'" pref (Gameplay tab, default ON).
if (window.feedBack && window.feedBack.showUpNext === false) { pill.classList.add('hidden'); return; }
if (window.feedBack && window.feedBack.showUpNext === false) { _upnextSetHidden(els, true); return; }
const hw = window.highway;
const secs = (hw && typeof hw.getSections === 'function') ? hw.getSections() : null;
const t = (hw && typeof hw.getTime === 'function') ? hw.getTime() : null;
if (!Array.isArray(secs) || !secs.length || t == null || isNaN(t)) { pill.classList.add('hidden'); return; }
if (!Array.isArray(secs) || !secs.length || t == null || isNaN(t)) { _upnextSetHidden(els, true); return; }
let next = null;
for (let i = 0; i < secs.length; i++) {
if (typeof secs[i].time === 'number' && secs[i].time > t + 0.05) { next = secs[i]; break; }
}
if (!next) { pill.classList.add('hidden'); return; }
if (!next) { _upnextSetHidden(els, true); return; }
const dt = Math.max(0, next.time - t);
const nm = $('v3-upnext-name'), eta = $('v3-upnext-eta');
if (nm) nm.textContent = next.name || '—';
if (eta) eta.textContent = 'in ' + dt.toFixed(1) + 's';
// Coarsened eta (whole seconds from 10 s out, 1 decimal inside) +
// write-on-change: drops textContent writes (each a layout pass)
// from ~6/s to ~1/s.
const name = next.name || '—';
const etaText = 'in ' + (dt >= 10 ? Math.round(dt) + '' : dt.toFixed(1)) + 's';
if (els.nm && name !== upnextLast.name) { upnextLast.name = name; els.nm.textContent = name; }
if (els.eta && etaText !== upnextLast.eta) { upnextLast.eta = etaText; els.eta.textContent = etaText; }
// Progress bar: fraction of the current section elapsed toward `next`.
// Previous boundary is the last section at/before now (else song start).
const fill = $('v3-upnext-bar-fill');
if (fill) {
if (els.fill) {
let prevT = 0;
for (let i = 0; i < secs.length; i++) {
if (typeof secs[i].time === 'number' && secs[i].time <= t) prevT = secs[i].time;
@@ -198,9 +234,15 @@
}
const span = next.time - prevT;
const prog = span > 0 ? Math.max(0, Math.min(1, (t - prevT) / span)) : 0;
fill.style.width = (prog * 100).toFixed(1) + '%';
const q = Math.round(prog * 1000) / 1000;
if (q !== upnextLast.prog) {
upnextLast.prog = q;
// scaleX is compositor-only — width writes re-ran layout.
// Pairs with transform-origin:left on #v3-upnext-bar-fill.
els.fill.style.transform = 'scaleX(' + q + ')';
}
}
pill.classList.remove('hidden');
_upnextSetHidden(els, false);
}
// ── Speed visual (bars + chevrons reflect #speed-slider) ──────────────────
@@ -230,8 +272,8 @@
if (!p) return;
const playBtn = $('btn-play');
const playing = playBtn && playBtn.getAttribute('aria-pressed') === 'true';
const controls = $('player-controls');
const overControls = controls && typeof controls.matches === 'function' && controls.matches(':hover');
// overControls maintained by mouseenter/mouseleave (see start()) —
// matches(':hover') here forced a per-frame style recalc.
// Keep the transport up while paused, hovering it, or a popover is open.
if (openPop || overControls || !playing) { lastMove = now(); return; }
if (now() - lastMove > IDLE_MS) {
@@ -249,6 +291,16 @@
// Re-sync the lyrics icon so programmatic highway.setLyricsVisible()
// (e.g. from lyrics_karaoke) isn't left stale; cheap + idempotent.
syncLyricsIcon();
// Reconcile the edge-driven hover flag against ground truth at
// this throttled cadence (~6 Hz, not per frame). Covers both
// failure modes of pure mouseenter/mouseleave tracking: a
// missed mouseleave (controls hidden/detached under the
// pointer → flag stuck true, transport never auto-hides) and
// a re-created #player-controls node whose listeners were
// lost (flag stuck false-ish / dead). matches(':hover') on a
// detached node is simply false, so this also self-clears.
const c = $('player-controls');
overControls = !!(c && typeof c.matches === 'function' && c.matches(':hover'));
}
tickIdle();
rafId = requestAnimationFrame(loop);
@@ -263,6 +315,12 @@
wireRail();
p.addEventListener('mousemove', revealChrome);
p.addEventListener('touchstart', revealChrome, { passive: true });
const c = $('player-controls');
if (c) {
overControls = typeof c.matches === 'function' && c.matches(':hover');
c.addEventListener('mouseenter', _onControlsEnter);
c.addEventListener('mouseleave', _onControlsLeave);
}
const s = $('speed-slider');
if (s && !s.dataset.pcVizWired) { s.dataset.pcVizWired = '1'; s.addEventListener('input', updateSpeedViz); }
updateSpeedViz();
@@ -281,6 +339,12 @@
p.removeEventListener('touchstart', revealChrome);
p.classList.remove('chrome-active', 'chrome-idle');
}
const c = $('player-controls');
if (c) {
c.removeEventListener('mouseenter', _onControlsEnter);
c.removeEventListener('mouseleave', _onControlsLeave);
}
overControls = false;
closePop();
}
function syncActivation() {
+201 -18
View File
@@ -37,26 +37,59 @@
if (p.cover_url) return '<div class="' + box + '">' + img(p.cover_url, 'w-full h-full object-cover') + '</div>';
const arts = Array.isArray(p.art_urls) ? p.art_urls : [];
if (!arts.length) {
return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.system_key ? '🔖' : '🎵') + '</div>';
return '<div class="' + box + ' flex items-center justify-center text-5xl text-fb-textDim">' + (p.kind === 'album' ? '💿' : p.system_key ? '🔖' : '🎵') + '</div>';
}
if (arts.length < 4) return '<div class="' + box + '">' + img(arts[0], 'w-full h-full object-cover') + '</div>';
return '<div class="' + box + ' grid grid-cols-2 grid-rows-2 gap-px">' +
arts.slice(0, 4).map((u) => img(u, 'w-full h-full object-cover')).join('') + '</div>';
}
// The slot's pinned-arrangement INDEX, resolved from the stored NAME
// against the slot's current chart (names survive rescans; an index
// wouldn't). null = no pin / the name isn't on this chart → full song.
function _slotArrIndex(s) {
if (!s.arrangement || !Array.isArray(s.arrangements)) return null;
const m = s.arrangements.find((a) => a && (a.smart_name === s.arrangement || a.name === s.arrangement));
return (m && m.index != null) ? m.index : null;
}
function songRow(s, opts) {
opts = opts || {};
const handle = opts.draggable
? '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>' : '';
const tuning = s.tuning_name
? '<span class="ml-2 text-[10px] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
return '<li data-fn="' + esc(s.filename) + '"' + (opts.draggable ? ' draggable="true"' : '') +
' class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-fb-card/50 group">' +
? '<span class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
// ── Curated-album slot extras (P6) — mixes/saved emit none of this ──
// A slot plays its RESOLVED chart (data-play-fn: the pinned file, or
// the work's current keeper when the pinned file is gone) with its
// pinned arrangement (data-play-arr); `missing` = the whole work left
// the library, so the row dims and loses play (denominator stays
// honest). ▾ opens the slot editor (chart + arrangement pin).
const isAlbum = !!opts.album;
const missing = isAlbum && !!s.missing;
const playFn = s.resolved_filename || s.filename;
const arrIdx = isAlbum ? _slotArrIndex(s) : null;
const playAttrs = isAlbum && !missing
? ' data-play-fn="' + esc(playFn) + '"' + (arrIdx != null ? ' data-play-arr="' + arrIdx + '"' : '')
: '';
const acc = (isAlbum && typeof opts.acc === 'number')
? '<span class="text-xs font-bold shrink-0 ' + (opts.acc >= 0.9 ? 'text-fb-good' : opts.acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') + '">' + Math.round(opts.acc * 100) + '%</span>'
: '';
const pin = (isAlbum && s.arrangement)
? '<span class="ml-2 text-[0.625rem] bg-fb-primary/20 text-fb-primary font-bold px-1.5 py-0.5 rounded-sm" title="Pinned arrangement">' + esc(s.arrangement) + '</span>' : '';
const orphan = (isAlbum && s.resolved_from_orphan)
? '<span class="ml-2 text-[0.625rem] text-fb-textDim" title="The pinned chart is gone — playing this song\'s current keeper instead">(auto)</span>' : '';
const slotBtn = (isAlbum && !missing)
? '<button data-slot aria-label="Choose chart / arrangement" title="Choose chart / arrangement" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-text text-sm px-2">▾</button>' : '';
return '<li data-fn="' + esc(s.filename) + '"' + playAttrs + (opts.draggable ? ' draggable="true"' : '') +
' class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-fb-card/50 group' + (missing ? ' opacity-50' : '') + '">' +
handle +
'<img src="' + esc(s.art_url) + '" alt="" class="w-10 h-10 rounded object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
'<span class="flex-1 min-w-0"><span class="block text-sm text-fb-text truncate">' + esc(s.title) + tuning + '</span>' +
'<span class="block text-xs text-fb-textDim truncate">' + esc(s.artist) + '</span></span>' +
'<button data-v3-play aria-label="Play" class="opacity-0 group-hover:opacity-100 text-fb-primary hover:text-fb-primaryHi text-sm px-2" title="Play">▶</button>' +
'<span class="flex-1 min-w-0"><span class="block text-sm text-fb-text truncate">' + esc(s.title) + tuning + pin + orphan + '</span>' +
'<span class="block text-xs text-fb-textDim truncate">' + (missing ? 'Missing — no version of this song is in your library' : esc(s.artist)) + '</span></span>' +
acc +
(missing ? '' : '<button data-v3-play aria-label="Play" class="opacity-0 group-hover:opacity-100 text-fb-primary hover:text-fb-primaryHi text-sm px-2" title="Play">▶</button>') +
slotBtn +
'<button data-remove aria-label="Remove from playlist" class="opacity-0 group-hover:opacity-100 text-fb-textDim hover:text-fb-accent text-sm px-2" title="Remove">✕</button>' +
'</li>';
}
@@ -68,7 +101,14 @@
// playSong decodeURIComponent()s its arg for the highway WS, so
// pass an encoded filename (like the rest of v3) — a raw name
// with %/#/?/ in it would otherwise misroute or throw.
if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(fn));
// Album slots override the play target (data-play-fn = the
// orphan-resolved chart) + pass the pinned arrangement index;
// mix/saved rows carry neither attribute and behave as before.
const pfn = li.getAttribute('data-play-fn') || fn;
const pa = li.getAttribute('data-play-arr');
if (typeof window.playSong === 'function') {
window.playSong(encodeURIComponent(pfn), pa == null ? undefined : Number(pa));
}
});
li.querySelector('[data-remove]')?.addEventListener('click', async () => {
await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' });
@@ -106,7 +146,10 @@
const lists = (await jget('/api/playlists')) || [];
root.innerHTML =
'<div class="max-w-5xl mx-auto px-6 md:px-8 pb-8">' +
'<div class="flex items-center justify-end mb-6">' +
'<div class="flex items-center justify-end gap-2 mb-6">' +
// Curated album (P6): a hand-picked ORDERED set with a chosen chart
// per track — same machinery as a playlist, kind='album'.
'<button id="v3-pl-new-album" title="A hand-picked, ordered set of songs — your version of an album, with your chosen chart per track" class="bg-fb-card/80 hover:bg-fb-card border border-fb-border/60 text-fb-text px-4 py-2 rounded-md text-sm font-medium">💿 New album</button>' +
'<button id="v3-pl-new" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm font-medium shadow-lg shadow-fb-primary/20">New playlist</button>' +
'</div>' +
(lists.length
@@ -114,7 +157,7 @@
'<button data-pl="' + p.id + '" class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
playlistCoverHtml(p) +
'<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' +
'<div class="text-xs text-fb-textDim">' + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
'<div class="text-xs text-fb-textDim">' + (p.kind === 'album' ? '💿 Album · ' : '') + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
'</button>').join('') + '</div>'
: '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') +
'</div>';
@@ -124,6 +167,12 @@
await jsend('POST', '/api/playlists', { name });
renderPlaylists();
});
root.querySelector('#v3-pl-new-album')?.addEventListener('click', async () => {
const name = ((await window.uiPrompt({ title: 'New Album', label: 'Album name', okLabel: 'Create', placeholder: 'My Album' })) || '').trim();
if (!name) return;
await jsend('POST', '/api/playlists', { name, kind: 'album' });
renderPlaylists();
});
root.querySelectorAll('[data-pl]').forEach((b) =>
b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10))));
}
@@ -134,13 +183,35 @@
const pl = await jget('/api/playlists/' + pid);
if (!pl) { renderPlaylists(); return; }
const isSystem = !!pl.system_key;
const isAlbum = pl.kind === 'album';
// Set-scoped repertoire (P6, §7.2): an album is a bounded practice SET
// with a denominator — "N of M mastered", per-track accuracy, never one
// album score. Same 0.9 threshold as the library meter/green badge.
let best = {};
if (isAlbum) best = (await jget('/api/stats/best')) || {};
const slotAcc = (s) => best[s.resolved_filename || s.filename];
let meter = '';
if (isAlbum && pl.songs.length) {
const tracks = pl.songs.filter((s) => !s.missing);
const mastered = tracks.filter((s) => (slotAcc(s) || 0) >= 0.9).length;
const started = tracks.filter((s) => { const b = slotAcc(s); return typeof b === 'number' && b > 0 && b < 0.9; }).length;
const pct = tracks.length ? Math.max(0, Math.min(100, Math.round((mastered / tracks.length) * 100))) : 0;
meter =
'<div class="mb-6">' +
'<div class="flex items-baseline justify-between gap-3 mb-1">' +
'<span class="text-sm font-semibold text-fb-text">Album repertoire</span>' +
'<span class="text-xs text-fb-textDim">' + mastered + ' of ' + tracks.length + ' mastered' +
(started ? ' &middot; ' + started + ' in progress' : '') + '</span></div>' +
'<div class="v3-rep-track"><div class="v3-rep-fill" style="width:' + pct + '%"></div></div>' +
'</div>';
}
root.innerHTML =
'<div class="max-w-3xl mx-auto p-6 md:p-8">' +
'<button id="v3-pl-back" class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Playlists</button>' +
'<div class="flex items-center justify-between mb-6 gap-3">' +
'<h2 class="text-3xl font-bold text-fb-text truncate">' + esc(pl.name) + '</h2>' +
'<h2 class="text-3xl font-bold text-fb-text truncate">' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '</h2>' +
'<div class="flex gap-2 shrink-0 items-center">' +
(pl.songs.length ? '<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play all</button>' : '') +
(pl.songs.length ? '<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>' : '') +
(isSystem ? '' :
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
(pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
@@ -149,22 +220,46 @@
'<input type="file" id="v3-pl-cover-file" accept="image/*" class="hidden">') +
'</div>' +
'</div>' +
meter +
(pl.songs.length
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem })).join('') + '</ul>'
: '<p class="text-fb-textDim">Empty — add songs from the library.</p>') +
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem, album: isAlbum, acc: isAlbum ? slotAcc(s) : undefined })).join('') + '</ul>'
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
'</div>';
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
// Play all: start the play-queue with this playlist's songs (auto-advances
// track to track). Falls back to playing the first song on an older core
// without the queue, so the button always does something.
// without the queue, so the button always does something. An ALBUM plays
// each slot's resolved chart with its pinned arrangement (playQueue's
// per-index arrangements array, #685) and skips missing works.
root.querySelector('#v3-pl-playall')?.addEventListener('click', () => {
const files = (pl.songs || []).map((s) => s.filename).filter(Boolean);
const files = [], arrs = [];
(pl.songs || []).forEach((s) => {
if (isAlbum && s.missing) return;
const fn = s.resolved_filename || s.filename;
if (!fn) return;
files.push(fn);
const idx = isAlbum ? _slotArrIndex(s) : null;
arrs.push(idx == null ? undefined : idx);
});
if (!files.length) return;
if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.start(files, { source: pl.name });
else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
if (window.feedBack && window.feedBack.playQueue) {
window.feedBack.playQueue.start(files, isAlbum
? { source: pl.name, arrangements: arrs }
: { source: pl.name });
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
});
const listEl = root.querySelector('#v3-pl-songs');
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
// Album slot editor (▾ per row): pick the slot's chart + arrangement.
if (listEl && isAlbum) {
listEl.querySelectorAll('li[data-fn]').forEach((li) => {
li.querySelector('[data-slot]')?.addEventListener('click', () => {
const fn = li.getAttribute('data-fn');
const slot = (pl.songs || []).find((x) => x.filename === fn);
if (slot) openSlotPicker(pid, slot, () => renderPlaylistDetail(pid));
});
});
}
root.querySelector('#v3-pl-rename')?.addEventListener('click', async () => {
const name = ((await window.uiPrompt({ title: 'Rename Playlist', label: 'Playlist name', value: pl.name, okLabel: 'Rename' })) || '').trim();
if (!name) return;
@@ -197,6 +292,94 @@
});
}
// ── Curated-album slot editor (P6, §7.2) ─────────────────────────────────
// Pins THIS slot's chart + arrangement. The per-slot pick is deliberately
// independent of the work's global preferred — a rehearsed set must stay
// the same notes even if the global keeper is re-picked later. Charts come
// from the work-charts API; the arrangement pin is stored as a NAME (it
// survives rescans; the index is resolved at play). A compact overlay for
// now — unifying with the library's Charts drawer (slot-scoped mode) is a
// follow-up once the in-flight drawer changes land.
async function openSlotPicker(pid, slot, onChange) {
const curFn = slot.resolved_filename || slot.filename;
let wk = slot.work_key;
if (!wk) {
const w = await jget('/api/chart/' + encodeURIComponent(curFn) + '/work');
wk = w && w.work_key;
}
const charts = wk ? await jget('/api/work/' + encodeURIComponent(wk) + '/charts') : null;
const chartList = (charts && Array.isArray(charts.charts)) ? charts.charts : [];
const radio = (name, value, checked, label, sub) =>
'<label class="flex items-start gap-2 px-2 py-1.5 rounded hover:bg-fb-card/60 cursor-pointer">' +
'<input type="radio" name="' + name + '" value="' + esc(value) + '"' + (checked ? ' checked' : '') + ' class="accent-fb-primary mt-0.5">' +
'<span class="min-w-0 flex-1"><span class="block text-sm text-fb-text truncate">' + label + '</span>' +
(sub ? '<span class="block text-[0.625rem] text-fb-textDim truncate">' + sub + '</span>' : '') +
'</span></label>';
// Checked = the stored pin; an orphaned slot (stored file gone from the
// list) pre-checks the chart it currently resolves to, so Apply re-pins
// what's actually playing.
const slotInList = chartList.some((x) => x.filename === slot.filename);
const chartRows = chartList.map((c) => radio(
'slot-chart', c.filename,
c.filename === slot.filename || (!slotInList && c.filename === curFn),
esc(c.title) + (c.is_representative ? ' <span class="text-[0.625rem] text-fb-primary">● preferred</span>' : ''),
esc((c.tuning_name ? c.tuning_name + ' · ' : '') + c.filename))).join('');
const arrRows = [radio('slot-arr', '', !slot.arrangement, 'Full song <span class="text-[0.625rem] text-fb-textDim">(default)</span>', '')]
.concat((slot.arrangements || []).map((a) => {
const name = (a && (a.smart_name || a.name)) || '';
if (!name) return '';
return radio('slot-arr', name,
slot.arrangement === name || slot.arrangement === a.name,
esc(name), '');
})).join('');
const overlay = document.createElement('div');
overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4';
overlay.innerHTML =
'<div class="bg-fb-card w-full max-w-md rounded-xl border border-fb-border/60 p-5 space-y-4 max-h-[80vh] overflow-y-auto v3-scroll">' +
'<div class="flex items-center justify-between gap-2">' +
'<h3 class="text-lg font-semibold text-fb-text truncate">' + esc(slot.title) + '</h3>' +
'<button type="button" data-x class="text-fb-textDim hover:text-fb-text text-xl leading-none" aria-label="Close">✕</button></div>' +
(chartRows
? '<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Chart for this slot</div>' + chartRows + '</div>'
: '') +
'<div><div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Arrangement</div>' + arrRows + '</div>' +
'<div data-err class="hidden text-xs text-red-400"></div>' +
'<div class="flex justify-end gap-2">' +
'<button type="button" data-cancel class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Cancel</button>' +
'<button type="button" data-apply class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-5 py-2 rounded-md">Apply</button>' +
'</div></div>';
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelector('[data-x]').addEventListener('click', close);
overlay.querySelector('[data-cancel]').addEventListener('click', close);
overlay.querySelector('[data-apply]').addEventListener('click', async () => {
const chart = overlay.querySelector('input[name="slot-chart"]:checked');
const arr = overlay.querySelector('input[name="slot-arr"]:checked');
const body = {};
if (chart && chart.value && chart.value !== slot.filename) body.chart_filename = chart.value;
if (arr) {
const v = arr.value || null;
if (v !== (slot.arrangement || null)) body.arrangement = v;
}
if (Object.keys(body).length) {
// jsend → null on a non-2xx (e.g. swap-to-other-work rejected, or
// the resolved target duplicates another slot's pin). Surfacing it
// and keeping the picker open beats silently closing "as saved".
const res = await jsend('PATCH', '/api/playlists/' + pid + '/songs/' + encodeURIComponent(slot.filename), body);
if (!res) {
const err = overlay.querySelector('[data-err]');
if (err) { err.textContent = 'Could not update this slot.'; err.classList.remove('hidden'); }
return; // keep the picker open — not a success
}
}
close();
onChange();
});
document.body.appendChild(overlay);
}
// ── #v3-saved ─────────────────────────────────────────────────────────--
async function renderSaved() {
const root = document.getElementById('v3-saved');
+3 -3
View File
@@ -82,10 +82,10 @@
'<span class="text-base font-extrabold tracking-tight leading-none">' + (p.current_streak || 0) + ' DAYS</span></div>' +
'<div class="flex items-end gap-2">' +
'<div class="flex flex-col leading-none">' +
'<span class="text-gray-400 text-[10px] font-medium">Rank:</span>' +
'<span class="text-gray-400 text-[0.625rem] font-medium">Rank:</span>' +
'<span class="text-xl font-bold leading-none">' + rank + '</span></div>' +
'<div class="flex items-end gap-1">' + bars + '</div>' +
'<span class="text-[10px] font-semibold text-fb-gold leading-none">' + Number(balance).toLocaleString() + ' dB</span>' +
'<span class="text-[0.625rem] font-semibold text-fb-gold leading-none">' + Number(balance).toLocaleString() + ' dB</span>' +
'</div></div></button>';
// Equipped avatar frame (spec 010 cosmetics).
if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') {
@@ -178,7 +178,7 @@
'<div id="v3-profile-bests" class="text-sm text-fb-textDim">Play a song to start tracking your accuracy and best scores.</div>' +
'</div>';
const playerIdFooter = (_profile && _profile.player_hash
? '<p class="text-center text-[10px] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
? '<p class="text-center text-[0.625rem] uppercase tracking-wider text-fb-textDim/60">player id ' + esc(_profile.player_hash.slice(0, 12)) + '</p>'
: '');
root.innerHTML =
'<div class="max-w-4xl mx-auto p-6 md:p-8">' +
+2 -2
View File
@@ -36,7 +36,7 @@
{ key: 'plugins', screen: 'v3-plugins', label: 'Plugins', group: 'HOME', icon: 'plug' },
{ key: 'settings', screen: 'settings', label: 'Settings', group: 'HOME', icon: 'gear' },
{ key: 'playlists', screen: 'v3-playlists', label: 'Playlists', group: 'LIBRARY', icon: 'list' },
{ key: 'songs', screen: 'v3-songs', label: 'Songs', group: 'LIBRARY', icon: 'disc' },
{ key: 'songs', screen: 'v3-songs', label: 'Song Library', group: 'LIBRARY', icon: 'disc' },
{ key: 'lessons', screen: 'v3-lessons', label: 'Lessons', group: 'LIBRARY', icon: 'lessons' },
{ key: 'favorites', screen: 'favorites', label: 'Favorites', group: 'LIBRARY', icon: 'star' },
{ key: 'saved', screen: 'v3-saved', label: 'Saved for Later', group: 'LIBRARY', icon: 'bookmark' },
@@ -179,7 +179,7 @@
const items = NAV.filter((n) => n.group === group);
if (!items.length) continue;
const itemsHTML = items.map((it) => navItemHTML(it) + promotedSlotHTML(it.key)).join('');
html += '<div><div class="v3-nav-group px-3 mb-1 text-[10px] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
html += '<div><div class="v3-nav-group px-3 mb-1 text-[0.625rem] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
group + '</div><div class="space-y-0.5">' + itemsHTML + '</div></div>';
}
nav.innerHTML = html;
+1395 -93
View File
File diff suppressed because it is too large Load Diff
+112 -2
View File
@@ -23,7 +23,24 @@
const OPACITY = { 95: '0.95', 90: '0.9', 80: '0.8', 70: '0.7', 60: '0.6',
50: '0.5', 40: '0.4', 30: '0.3', 20: '0.2', 10: '0.1' };
// Default fb palette (mirrors tailwind.config.js `fb`). Emitted as
// always-present `--fbv-*` on :root so `var(--fbv-accent)` resolves even
// un-themed — the un-themed look is unchanged (the fb-* utilities still use
// their compiled defaults; this only hands plugins a stable host token to
// read + derive from). Adds two keystone ROLES the palette lacked:
// `on-accent` (a foreground legible ON the accent fill — the missing piece
// behind white-on-accent contrast bugs) and `focus-ring`.
const DEFAULTS = {
bg: '#0f172a', sidebar: '#111827', card: '#1e293b', cardMuted: '#0b1220',
primary: '#0ea5e9', primaryHi: '#38bdf8', accent: '#ef4444',
text: '#f8fafc', textDim: '#94a3b8', border: '#334155',
good: '#22c55e', mid: '#eab308', low: '#ef4444', gold: '#e8c040',
'on-accent': '#f8fafc', 'focus-ring': '#38bdf8',
};
let _frameStyle = ''; // equipped avatar-frame CSS fragment ('' = none)
let _themeId = null; // equipped theme id (null = default look)
let _themeCaps = null; // equipped theme's declared device capabilities, or null
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
@@ -73,12 +90,14 @@
return 'html[data-fb-theme] {\n' + vars + '}\n' + rules;
}
function apply(payload) {
function apply(payload, meta) {
const colors = payload && payload.colors;
let styleEl = document.getElementById(STYLE_ID);
if (!colors) {
if (styleEl) styleEl.remove();
document.documentElement.removeAttribute('data-fb-theme');
_themeId = null; _themeCaps = null;
_emitThemeChanged();
return;
}
if (!styleEl) {
@@ -88,6 +107,13 @@
}
styleEl.textContent = cssFor(colors);
document.documentElement.setAttribute('data-fb-theme', '1');
// Track identity + declared device capabilities for the read API. A
// theme MAY carry `capabilities` (e.g. {glow:false}) in its payload;
// recolor-only themes carry none (→ default affordances reported).
_themeId = (meta && meta.id) || (payload && payload.id) || 'custom';
_themeCaps = (payload && payload.capabilities)
|| (meta && meta.capabilities) || null;
_emitThemeChanged();
}
function setFrame(payload) {
@@ -106,7 +132,7 @@
function applyCosmetics(cosmetics) {
cosmetics = cosmetics || {};
apply((cosmetics.theme || {}).payload || null);
apply((cosmetics.theme || {}).payload || null, cosmetics.theme || null);
setFrame((cosmetics.avatar_frame || {}).payload || null);
}
@@ -123,6 +149,83 @@
} catch (e) { /* offline — keep current look */ }
}
// ── Host theme READ surface (window.feedBack.theme) ──────────────────────
// The apply side stays on window.v3Theme; this is the read/capability side
// plugins consume so a feature can render correctly under any theme instead
// of binding to the one the dev happened to see. See docs/host-theme-contract.md.
// Always-present `--fbv-*` defaults on :root (see DEFAULTS). Additive: the
// un-themed look is unchanged; this only makes var(--fbv-*) resolve so a
// plugin can derive surfaces from host tokens whether or not a theme is on.
function _injectDefaults() {
if (document.getElementById('fb-theme-defaults')) return;
let vars = '';
for (const key in DEFAULTS) {
const rgb = hexToRgb(DEFAULTS[key]);
if (rgb) vars += ' --fbv-' + key + ': ' + rgb + ';\n';
}
const el = document.createElement('style');
el.id = 'fb-theme-defaults';
el.textContent = ':root {\n' + vars + '}\n';
(document.head || document.documentElement).appendChild(el);
}
function prefersReducedMotion() {
try {
return !!(window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches);
} catch (e) { return false; }
}
// Live snapshot of the resolved role tokens ("r g b" triplets; use as
// rgb(var(--fbv-<key>)) in CSS). Reflects the themed values when a theme is
// equipped, the :root defaults otherwise.
function _readTokens() {
const t = {};
try {
const cs = getComputedStyle(document.documentElement);
for (const key in DEFAULTS) {
const v = cs.getPropertyValue('--fbv-' + key).trim();
if (v) t[key] = v;
}
} catch (e) { /* no computed style available yet */ }
return t;
}
// Whether the ACTIVE theme permits decorative DEVICES — the signal a feature
// reads to pick a device (glow vs. solid) rather than hardcoding one. Today's
// recolor-only themes declare nothing → default affordances; a theme may opt
// out via `capabilities` in its payload (e.g. a clean theme = {glow:false}).
// `motion` is additionally gated by the OS reduced-motion preference.
function capabilities() {
const c = _themeCaps || {};
const allowMotion = c.motion !== undefined ? !!c.motion : true;
return {
glow: c.glow !== undefined ? !!c.glow : true,
gradients: c.gradients !== undefined ? !!c.gradients : true,
motion: allowMotion && !prefersReducedMotion(),
};
}
function get() {
return {
id: _themeId,
isThemed: document.documentElement.hasAttribute('data-fb-theme'),
tokens: _readTokens(),
};
}
function _emitThemeChanged() {
if (window.feedBack && typeof window.feedBack.emit === 'function') {
window.feedBack.emit('theme:changed', {
id: _themeId,
isThemed: document.documentElement.hasAttribute('data-fb-theme'),
tokens: _readTokens(),
capabilities: capabilities(),
});
}
}
window.v3Theme = {
apply, // preview/apply a theme payload directly (null = default)
applyCosmetics, // apply a {theme, avatar_frame} equipped map
@@ -131,6 +234,13 @@
refresh, // re-read equipped cosmetics from /api/profile
};
// Host-owned theme READ surface. Attached defensively so it survives the
// feedBack bus being (re)built by capabilities.js regardless of load order
// (the bus constructor copies pre-existing keys onto itself).
window.feedBack = window.feedBack || {};
window.feedBack.theme = { get, capabilities, prefersReducedMotion };
_injectDefaults();
refresh();
// Re-apply when an equip/unequip happens anywhere (shop screen, capability
// command from a plugin).
+55 -12
View File
@@ -4,6 +4,12 @@
* `fb` palette in tailwind.config.js.
*/
/* Interface-size (Accessibility): the always-present UI-scale token. JS
(capabilities/interface-scale.js) overrides it on :root and drives the
visible scaling through the root font-size; this default keeps
`var(--fb-scale)` resolvable for any canvas/CSS consumer even when unset. */
:root { --fb-scale: 1; }
/* Text-selection policy (v3)
Accidental drag/double-click selection of app chrome (sidebar, transport, the
note highway/HUD, buttons, labels) makes the UI look broken and is never
@@ -367,10 +373,15 @@ input, textarea, select,
}
#player-hud .v3-upnext .v3-upnext-bar-fill {
height: 100%;
width: 0%;
/* Fill is driven via transform: scaleX(0..1) from player-chrome.js
compositor-only, unlike the previous width writes which re-ran
layout on every update tick. */
width: 100%;
transform: scaleX(0);
transform-origin: left;
border-radius: inherit;
background: linear-gradient(90deg, #22d3ee, #a855f7, #f472b6);
transition: width .12s linear;
transition: transform .12s linear;
}
/* — Live performance HUD (top-right, read-only) — */
@@ -599,7 +610,7 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important;
align-items: center;
justify-content: center;
z-index: 1;
font-size: 32px;
font-size: 2rem;
line-height: 1;
}
#v3-player-rail .section-practice-control--v3 .section-practice-pill-icon .v3-rail-svg,
@@ -872,7 +883,7 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important;
padding: 0 4px;
display: flex; align-items: center; justify-content: center;
background: #22d3ee; color: #0f172a;
font-size: 10px; font-weight: 800; line-height: 1;
font-size: 0.625rem; font-weight: 800; line-height: 1;
border-radius: 999px; z-index: 2;
}
.v3-rail-badge[hidden] { display: none; }
@@ -1114,6 +1125,32 @@ body.font-display { font-family: Rubik, system-ui, sans-serif; }
.fb-srow-stack .fb-srow-control input[type="text"] { flex: 1 1 auto; min-width: 0; }
.fb-srow-wide { width: 100%; }
/* Segmented control + fine-tune (Accessibility → Interface size) */
.fb-seg {
display: inline-flex; flex-wrap: wrap; gap: .25rem;
background: #0f172a; border: 1px solid rgba(51, 65, 85, .55);
border-radius: .7rem; padding: .3rem;
}
.fb-seg-btn {
appearance: none; border: none; cursor: pointer;
padding: .5rem .9rem; min-height: 2rem; border-radius: .5rem;
font-size: .82rem; font-weight: 600; color: #94a3b8; background: transparent;
transition: background .15s, color .15s;
}
.fb-seg-btn:hover { color: #e2e8f0; }
.fb-seg-btn.active { background: #0ea5e9; color: #04263a; }
.fb-seg-btn:focus-visible { outline: 2px solid #38bdf8; outline-offset: 2px; }
.fb-finetune { margin-top: .6rem; }
.fb-finetune > summary {
font-size: .78rem; font-weight: 600; color: #94a3b8; cursor: pointer;
list-style: none; display: inline-flex; align-items: center; gap: .35rem;
}
.fb-finetune > summary::-webkit-details-marker { display: none; }
.fb-finetune > summary::before { content: "\25B8"; font-size: .7rem; transition: transform .15s; }
.fb-finetune[open] > summary::before { transform: rotate(90deg); }
.fb-finetune-body { display: flex; align-items: center; gap: 1rem; margin-top: .6rem; }
.fb-seg-val { font-size: .8rem; color: #94a3b8; min-width: 3rem; text-align: right; }
/* Toggle switch */
.fb-switch { position: relative; display: inline-block; width: 2.6rem; height: 1.5rem; flex: none; }
.fb-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
@@ -1200,14 +1237,14 @@ html.fb-immersive #v3-main > .screen.active {
centered. Shown only for the grid view + alphabetical (artist/title) sorts. */
.v3-azrail {
position: fixed;
right: 2px;
right: 4px; /* off the very edge so letters aren't clipped */
top: 50%;
transform: translateY(-50%);
z-index: 25;
display: flex;
flex-direction: column;
align-items: center;
max-height: 84vh;
align-items: stretch; /* equal-width buttons → one wide, even hit column */
max-height: 92vh;
padding: 4px 1px;
user-select: none;
-webkit-user-select: none;
@@ -1219,17 +1256,23 @@ html.fb-immersive #v3-main > .screen.active {
background: none;
border: 0;
color: #94a3b8; /* fb-textDim */
font-size: .62rem;
/* Scale with viewport height so the 27-letter rail grows on tall / hi-res
displays (a fixed size looked tiny at 1440p) while still fitting 27 rows
within max-height on short screens. */
font-size: clamp(.72rem, 1.4vh, 1.05rem);
font-weight: 700;
line-height: 1.05;
padding: 1px 4px;
line-height: 1.04;
/* Vertical padding fattens the tap target (was ~13px tall → easy to miss). */
padding: clamp(2px, .55vh, 6px) 9px;
margin: 0;
cursor: pointer;
border-radius: 4px;
text-align: center;
}
.v3-azrail-letter:hover:not([disabled]),
.v3-azrail-letter.is-active {
color: #0ea5e9; /* fb-primary */
background: rgba(14, 165, 233, .15); /* visible target under the scrub */
}
.v3-azrail-letter:focus-visible {
outline: 2px solid #38bdf8; /* fb-primaryHi */
@@ -1242,7 +1285,7 @@ html.fb-immersive #v3-main > .screen.active {
/* Drag indicator bubble (Android fast-scroll pattern). */
.v3-azbubble {
position: fixed;
right: 2.6rem;
right: 2.9rem; /* clear the (now wider) rail */
top: 50%;
transform: translateY(-50%);
z-index: 26;
@@ -1264,7 +1307,7 @@ html.fb-immersive #v3-main > .screen.active {
/* Coarse-pointer / short viewports: the 27-letter rail can crowd a phone edge.
Tighten it; a collapse-to-anchors pass is a follow-up. */
@media (max-height: 640px) {
.v3-azrail-letter { font-size: .55rem; padding: 0 4px; }
.v3-azrail-letter { font-size: clamp(.5rem, 1.3vh, .62rem); padding: 0 7px; }
}
/* — Practice-aware library home: repertoire meter + "Keep practicing" shelf — */
@@ -0,0 +1,83 @@
// Pins the auto-reframe-on-layout-drift behaviour ported into the drum and
// keys 3D highway renderers from plugins/highway_3d/screen.js.
//
// Bug it guards against: under splitscreen the host resizes each panel's
// canvas but overrides hw.resize and never calls renderer.resize(). The
// guitar/bass highway_3d self-detects this in draw(); the drum and keys
// highways originally only re-framed when the host called resize(w,h), so
// their panels did NOT resize when the app went fullscreen — they stayed
// framed for the pre-fullscreen size while the guitar/bass panels adapted.
//
// The fix ports highway_3d's per-frame drift check into both draw() loops:
// compare the live canvas backing store (canvas.width/height) AND the CSS box
// (clientWidth/Height) against the last applied logical size, re-running
// applySize() on either drift. A refactor that drops this check, stops
// recording _appliedW/_appliedH, or reverts to resize()-only sizing would
// silently bring the fullscreen-split bug back.
//
// Source-level only — same strategy as highway_3d_resize_reframe.test.js.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const PLUGINS = path.join(__dirname, '..', '..', 'plugins');
const CASES = [
{ name: 'drum_highway_3d', file: path.join(PLUGINS, 'drum_highway_3d', 'screen.js') },
{ name: 'keys_highway_3d', file: path.join(PLUGINS, 'keys_highway_3d', 'screen.js') },
];
for (const { name, file } of CASES) {
const src = fs.readFileSync(file, 'utf8');
test(`${name}: applied-size tracking is declared as instance state`, () => {
assert.match(
src,
/let\s+_appliedW\s*=\s*0\s*,\s*_appliedH\s*=\s*0\s*;/,
'_appliedW / _appliedH must be declared as per-instance state',
);
assert.match(
src,
/let\s+_lastHwW\s*=\s*0\s*,\s*_lastHwH\s*=\s*0\s*;/,
'_lastHwW / _lastHwH must be declared as per-instance state',
);
});
test(`${name}: applySize records the logical w/h it applied`, () => {
assert.match(
src,
/_appliedW\s*=\s*[wW]\s*;\s*_appliedH\s*=\s*[hH]\s*;/,
'applySize must record _appliedW / _appliedH',
);
});
test(`${name}: draw() preserves the backing-store drift branch (splitscreen path)`, () => {
assert.match(
src,
/const\s+_bsChanged\s*=\s*highwayCanvas\.width\s*!==\s*_lastHwW\s*\|\|\s*highwayCanvas\.height\s*!==\s*_lastHwH\s*;/,
'the backing-store (canvas.width/height) comparison must run every frame',
);
assert.match(
src,
/if\s*\(\s*_bsChanged\s*\)\s*\{\s*_lastHwW\s*=\s*highwayCanvas\.width\s*;\s*_lastHwH\s*=\s*highwayCanvas\.height\s*;[\s\S]*?applySize\(\s*_bw\s*,\s*_bh\s*\)\s*;/,
'the backing-store drift branch must re-apply',
);
});
test(`${name}: draw() re-applies on CSS-box drift without a backing-store change`, () => {
assert.match(
src,
/else if\s*\(\s*_bw\s*>\s*0\s*&&\s*_bh\s*>\s*0\s*&&\s*\(\s*Math\.abs\(\s*_bw\s*-\s*_appliedW\s*\)\s*>\s*1\s*\|\|\s*Math\.abs\(\s*_bh\s*-\s*_appliedH\s*\)\s*>\s*1\s*\)\s*\)\s*\{\s*applySize\(\s*_bw\s*,\s*_bh\s*\)\s*;/,
'draw() must re-apply when the live box drifts >1px from _appliedW/_appliedH',
);
});
test(`${name}: destroy() resets the applied-size tracking`, () => {
assert.match(
src,
/_lastHwW\s*=\s*0\s*;\s*_lastHwH\s*=\s*0\s*;\s*_appliedW\s*=\s*0\s*;\s*_appliedH\s*=\s*0\s*;/,
'destroy() must reset the drift-tracking state to 0',
);
});
}
+1 -1
View File
@@ -62,7 +62,7 @@ test('bundle exposes handShapes to renderers with flat-list fallback', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
assert.match(
src,
/\bhandShapes:\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/,
/\bhandShapes\s*[:=]\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/,
'bundle must expose handShapes with the _filteredHandShapes-vs-handShapes ternary fallback',
);
});
+1 -1
View File
@@ -20,7 +20,7 @@ function src(file) {
test('highway renderer bundles surface the core lefty flag', () => {
assert.match(
src(HIGHWAY_JS),
/lefty\s*:\s*_lefty/,
/lefty\s*[:=]\s*_lefty/,
'custom renderer bundles must include lefty: _lefty',
);
});
+17 -2
View File
@@ -61,10 +61,25 @@ test('draw() reads the live canvas box once per frame', () => {
test('backing-store drift branch is preserved (splitscreen path)', () => {
// The original check that catches the splitscreen hw.resize override
// resizing the element without calling renderer.resize() must remain.
// The comparison is hoisted into _bsChanged (checked with cheap property
// reads every frame, and it forces the throttled box read to run on the
// same frame); the branch body is unchanged.
assert.match(
src,
/if\s*\(\s*highwayCanvas\.width\s*!==\s*_lastHwW\s*\|\|\s*highwayCanvas\.height\s*!==\s*_lastHwH\s*\)\s*\{\s*_lastHwW\s*=\s*highwayCanvas\.width\s*;\s*_lastHwH\s*=\s*highwayCanvas\.height\s*;\s*if\s*\(\s*box\.w\s*>\s*0\s*&&\s*box\.h\s*>\s*0\s*\)\s*applySize\(\s*box\.w\s*,\s*box\.h\s*\)\s*;/,
'the backing-store (canvas.width/height) drift branch must still re-apply',
/const\s+_bsChanged\s*=\s*highwayCanvas\.width\s*!==\s*_lastHwW\s*\|\|\s*highwayCanvas\.height\s*!==\s*_lastHwH\s*;/,
'the backing-store (canvas.width/height) comparison must run every frame',
);
assert.match(
src,
/if\s*\(\s*_bsChanged\s*\)\s*\{\s*_lastHwW\s*=\s*highwayCanvas\.width\s*;\s*_lastHwH\s*=\s*highwayCanvas\.height\s*;\s*if\s*\(\s*box\.w\s*>\s*0\s*&&\s*box\.h\s*>\s*0\s*\)\s*applySize\(\s*box\.w\s*,\s*box\.h\s*\)\s*;/,
'the backing-store drift branch must still re-apply',
);
// The throttle must never delay the backing-store path: _bsChanged is
// part of the gate that forces the box read on the same frame.
assert.match(
src,
/if\s*\(\s*_bsChanged\s*\|\|\s*!_wrapPinned\s*\|\|\s*_boxCheckCountdown\s*===\s*0\s*\)/,
'the box-read gate must include _bsChanged so backing-store changes re-apply immediately',
);
});
@@ -43,13 +43,13 @@ test('core _makeBundle exposes isPlaying derived from the chart-clock anchor', (
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _makeBundle()');
// Field present in the bundle.
assert.match(fn, /\bisPlaying\s*:/, 'bundle must expose isPlaying');
assert.match(fn, /\bisPlaying\s*[:=]/, 'bundle must expose isPlaying');
// It is computed from the same anchor/advance state getTime() uses, not a
// hardcoded literal — anchor must exist AND the clock must have advanced
// within the interp cap.
assert.match(
fn,
/isPlaying\s*:\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/,
/isPlaying\s*[:=]\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/,
'isPlaying must gate on a live anchor (_chartAnchorPerfNow not NaN)',
);
assert.match(
+1 -1
View File
@@ -82,7 +82,7 @@ test('draw() only adapts during active playback and feeds the HUD', () => {
test('bundle + canvas sizing use the effective scale, not the raw user value', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /renderScale:\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale');
assert.match(src, /renderScale\s*[:=]\s*_effectiveRenderScale\(\)/, 'bundle.renderScale must be the effective scale');
assert.match(src, /canvas\.width\s*=\s*Math\.round\(w\s*\*\s*_effectiveRenderScale\(\)\)/, 'canvas backing store must use effective scale');
});
+2 -2
View File
@@ -51,7 +51,7 @@ test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)',
const fn = extractBlock(src, 'function _makeBundle()');
// The bundle field must point straight at _noteState — not a fresh
// arrow each frame (the per-frame allocation the review flagged).
assert.match(fn, /getNoteState:\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference');
assert.match(fn, /getNoteState\s*[:=]\s*_noteState\b/, 'bundle.getNoteState must be the stable _noteState reference');
});
test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#254)', () => {
@@ -64,7 +64,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
// identity-based guards in renderer code.
assert.match(
fn,
/getNoteStateProvider:\s*_getNoteStateProvider\b/,
/getNoteStateProvider\s*[:=]\s*_getNoteStateProvider\b/,
'bundle.getNoteStateProvider must be the stable _getNoteStateProvider reference (not a per-frame arrow)'
);
// Sanity: the stable accessor exists per-createHighway-instance
+51
View File
@@ -0,0 +1,51 @@
// playQueue.peekNext() (queue-advance UX): consumers that render "Up next"
// (the results card's countdown strip) need to know WHAT follows without
// reaching into queue internals. Extract the playQueue IIFE from app.js and
// drive it against a playSong stub.
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
function makeQueue() {
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
const start = src.indexOf('window.feedBack.playQueue = (function () {');
assert.ok(start !== -1, 'playQueue IIFE found in app.js');
const end = src.indexOf('})();', start);
assert.ok(end !== -1, 'playQueue IIFE terminator found');
const iife = src.slice(start, end + 5);
const played = [];
const sandbox = {
window: {
feedBack: {},
playSong: (fn, arr, opts) => played.push({ fn, arr, opts }),
fbNotify: null,
},
encodeURIComponent,
};
// eslint-disable-next-line no-new-func
new Function('window', 'encodeURIComponent', iife)(sandbox.window, encodeURIComponent);
return { q: sandbox.window.feedBack.playQueue, played };
}
test('peekNext exposes the following track without mutating the queue', () => {
const { q, played } = makeQueue();
assert.strictEqual(q.peekNext(), null); // idle queue → null
q.start(['a.sloppak', 'b.sloppak', 'c.sloppak'], { source: 'My list' });
assert.deepStrictEqual(q.peekNext(), { filename: 'b.sloppak', index: 1, total: 3 });
assert.deepStrictEqual(q.peekNext(), { filename: 'b.sloppak', index: 1, total: 3 }); // pure
assert.strictEqual(played.length, 1); // peeking never plays
q.advance();
assert.deepStrictEqual(q.peekNext(), { filename: 'c.sloppak', index: 2, total: 3 });
q.advance();
assert.strictEqual(q.peekNext(), null); // last track → nothing next
assert.strictEqual(q.remaining(), 0);
});
test('peekNext is null after clear', () => {
const { q } = makeQueue();
q.start(['a.sloppak', 'b.sloppak']);
q.clear();
assert.strictEqual(q.peekNext(), null);
});
-2
View File
@@ -56,14 +56,12 @@ function createTunerSandbox(opts) {
if (_u.includes('/config')) {
return Promise.resolve({
json: () => Promise.resolve({
showFloatingButton: true,
visualizationMode: 'default',
audioInputMode: 'auto',
autoOpenOnTuningChange: autoOpen,
lastInstrument: 'guitar-6',
lastTuning: 'Standard',
freeTune: false,
disabledTunings: [],
customTunings: {},
}),
});
+23 -1
View File
@@ -44,7 +44,7 @@ test('refreshRail reads present letters from the stats endpoint (sort-aware)', (
assert.match(src, /\/api\/library\/stats\?'\s*\+\s*queryParams/,
'refreshRail must query /api/library/stats with the active filter params');
// Opts into the active-sort breakdown so non-rail callers skip the scan.
assert.match(src, /queryParams\(\{\s*sort_letters:\s*1\s*\}\)/,
assert.match(src, /railParams\s*=\s*\{\s*sort_letters:\s*1\s*\}/,
'refreshRail must request the sort_letters breakdown');
assert.match(src, /letters\s*=\s*stats\s*&&\s*stats\.sort_letters/,
'refreshRail must prefer the active-sort breakdown (sort_letters)');
@@ -92,3 +92,25 @@ test('the rail supports pointer drag-scrub + keyboard arrows', () => {
assert.match(src, /ArrowUp'[\s\S]*?ArrowDown'|ArrowDown'[\s\S]*?ArrowUp'/,
'arrow keys must move between present letters');
});
test('pointer taps are driven by pointerdown, not the retarget-prone click', () => {
// A tap must seek on pointerdown (pointer capture retargets the follow-up
// click to the rail, so resolving a letter from click is unreliable — taps
// would no-op, "clicked O, nothing happened").
assert.match(src, /addEventListener\('pointerdown'[\s\S]*?seekToY\(/,
'pointerdown must seek immediately so a tap lands without a move');
// The click handler must ignore pointer-driven clicks (detail >= 1) and only
// handle keyboard Enter/Space activation (synthesized click has detail === 0).
assert.match(src, /addEventListener\('click'[\s\S]*?e\.detail\s*!==\s*0/,
'the rail click handler must guard on e.detail === 0 (keyboard only)');
});
test('drag scrubs seek instantly while taps/keys seek smoothly', () => {
assert.match(src, /async function\s+jumpToLetter\s*\(\s*letter\s*,\s*smooth/,
'jumpToLetter must take a smooth flag');
assert.match(src, /behavior:\s*smooth\s*\?\s*'smooth'\s*:\s*'auto'/,
'jumpToLetter must scroll instantly during a drag, smoothly on a tap');
// pointermove scrubs with smooth=false so RELEASE lands on the let-go letter.
assert.match(src, /addEventListener\('pointermove'[\s\S]*?seekToY\([^;]*?,\s*false\)/,
'pointermove must seek with smooth=false (precise drag tracking)');
});
+58
View File
@@ -0,0 +1,58 @@
// Pins the v3 Songs favorite-toggle colour swap in static/v3/songs.js.
//
// One shared wireCards() [data-fav] handler serves BOTH the grid card and the
// tree / "List View" row, but the two render sites use different idle colours
// (grid = text-white, tree = text-fb-textDim). The handler used to toggle a
// hardcoded text-white, so in List View it never removed text-fb-textDim — the
// heart changed glyph (♡→♥) but stayed dim and only turned red after a re-search
// re-rendered the row (reported macOS+Windows, 0.3.0, open since 06-25). Each
// button now declares its idle colour via data-fav-idle and the handler swaps
// exactly that class, so only one colour class is ever present.
//
// Source-level only — same strategy as tests/js/v3_az_rail.test.js.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
const src = fs.readFileSync(SONGS_JS, 'utf8');
test('both fav render sites declare their idle colour via data-fav-idle', () => {
// Grid card heart idles white; the tree / List View heart idles dim.
assert.match(src, /data-fav data-fav-idle="text-white"/,
'the grid fav button must declare data-fav-idle="text-white"');
assert.match(src, /data-fav data-fav-idle="text-fb-textDim"/,
'the tree/List-View fav button must declare data-fav-idle="text-fb-textDim"');
});
test('the shared fav handler swaps the declared idle colour, not a hardcoded one', () => {
// Reads the idle colour off the clicked button …
assert.match(src, /getAttribute\('data-fav-idle'\)/,
'the fav handler must read the idle colour from the button');
// … toggles fb-accent (red) on favorite and restores the context idle colour off it.
assert.match(src, /classList\.toggle\('text-fb-accent',\s*d\.favorite\)/);
assert.match(src, /classList\.toggle\(\s*idle\s*,\s*!d\.favorite\s*\)/,
'the fav handler must restore the per-context idle colour (idle), not text-white');
// The grid-only hardcoded idle toggle that stranded text-fb-textDim is gone.
assert.doesNotMatch(src, /classList\.toggle\('text-white',\s*!d\.favorite\)/,
'the hardcoded text-white idle toggle must be removed');
});
test('the drawer fav-sync (_patchCardFav) swaps the declared idle colour too', () => {
// Toggling the like from the Song Details drawer patches the rendered card's
// heart via _patchCardFav; it must honour each heart's data-fav-idle the same
// way the click handler does, or List-View rows keep the dim-heart bug (#654).
assert.match(src, /function _patchCardFav[\s\S]*?getAttribute\('data-fav-idle'\)[\s\S]*?classList\.toggle\(\s*idle\s*,\s*!fav\s*\)/,
'_patchCardFav must restore the per-context idle colour (idle), not a hardcoded text-white');
assert.doesNotMatch(src, /classList\.toggle\('text-white',\s*!fav\)/,
'_patchCardFav must not hardcode the text-white idle toggle');
});
test('the fav toggle keeps the in-memory song model in sync', () => {
// So a virtualized grid recycle / tree re-render renders the new state,
// not a stale favorite=false read from state.songsById.
assert.match(src, /song\.favorite\s*=\s*d\.favorite/,
'the fav handler must write the new favorite state back onto the song model');
});
+57
View File
@@ -0,0 +1,57 @@
// Guard for the host theme READ surface added to the cosmetics applier
// (static/v3/theme-core.js): always-present `--fbv-*` defaults on :root (so a
// plugin can read host tokens un-themed), the two keystone roles the palette
// lacked (on-accent / focus-ring), and the window.feedBack.theme read API +
// theme:changed event. Source-level guards on the contract surface; runtime
// behaviour (apply/unequip/defaults-restore/capabilities) is verified
// separately by a headless render. First slice of the host theme contract
// (got-feedback/feedBack#644).
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const TC = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'v3', 'theme-core.js'), 'utf8');
test('always-present --fbv-* defaults are injected on :root', () => {
assert.match(TC, /const DEFAULTS = \{/);
assert.match(TC, /function _injectDefaults\(\)/);
assert.match(TC, /id = 'fb-theme-defaults'/);
assert.match(TC, /':root \{/);
// injected before the first profile read so var(--fbv-*) resolves immediately
assert.match(TC, /_injectDefaults\(\);\s*\n\s*refresh\(\);/);
});
test('two keystone roles the palette lacked are added as defaults', () => {
assert.match(TC, /'on-accent':/);
assert.match(TC, /'focus-ring':/);
});
test('window.feedBack.theme read API is exposed (get/capabilities/prefersReducedMotion)', () => {
assert.match(TC, /window\.feedBack = window\.feedBack \|\| \{\};/);
assert.match(TC, /window\.feedBack\.theme = \{ get, capabilities, prefersReducedMotion \};/);
assert.match(TC, /function get\(\)/);
assert.match(TC, /function capabilities\(\)/);
assert.match(TC, /function prefersReducedMotion\(\)/);
});
test('capabilities reports device affordances and gates motion on reduced-motion', () => {
assert.match(TC, /glow:/);
assert.match(TC, /gradients:/);
assert.match(TC, /motion: allowMotion && !prefersReducedMotion\(\)/);
});
test('apply() tracks theme id + declared capabilities and emits theme:changed', () => {
assert.match(TC, /function apply\(payload, meta\)/);
assert.match(TC, /_themeId =/);
assert.match(TC, /_themeCaps =/);
assert.match(TC, /_emitThemeChanged\(\)/);
assert.match(TC, /emit\('theme:changed'/);
});
test('the apply side stays on window.v3Theme (read surface is additive)', () => {
assert.match(TC, /window\.v3Theme = \{/);
});
+21 -8
View File
@@ -54,16 +54,21 @@ class TestConfigDefaults:
assert body["lastTuning"] == "Standard"
assert body["lastInstrument"] == "guitar-6"
assert body["audioInputMode"] == "auto"
assert body["showFloatingButton"] is True
assert body["visualizationMode"] == "default"
assert body["customTunings"] == {}
assert body["disabledTunings"] == []
def test_get_does_not_include_default_tunings(self, client):
# defaultTunings moved to GET /api/tunings (core tuning.read capability).
body = client.get("/api/plugins/tuner/config").json()
assert "defaultTunings" not in body
def test_get_does_not_include_retired_keys(self, client):
# disabledTunings + showFloatingButton were retired along with their
# settings UI; the config must no longer expose them.
body = client.get("/api/plugins/tuner/config").json()
assert "disabledTunings" not in body
assert "showFloatingButton" not in body
class TestConfigPersistence:
def test_partial_update_persisted(self, client):
@@ -109,14 +114,22 @@ class TestConfigPersistence:
client.post("/api/plugins/tuner/config", json={"autoOpenOnTuningChange": bad})
assert client.get("/api/plugins/tuner/config").json()["autoOpenOnTuningChange"] is False, bad
def test_disabled_tunings_strips_entries_without_colon(self, client):
client.post("/api/plugins/tuner/config", json={
"disabledTunings": ["guitar-6:Drop D", "legacy-entry", "bass-4:Standard"]
def test_retired_keys_ignored_and_not_persisted(self, client, config_dir):
# disabledTunings + showFloatingButton were retired: POSTing them must
# not break the request, leak back in the response, or hit the file.
r = client.post("/api/plugins/tuner/config", json={
"disabledTunings": ["guitar-6:Drop D"],
"showFloatingButton": False,
"lastTuning": "Drop D",
})
assert r.status_code == 200
body = client.get("/api/plugins/tuner/config").json()
assert "legacy-entry" not in body["disabledTunings"]
assert "guitar-6:Drop D" in body["disabledTunings"]
assert "bass-4:Standard" in body["disabledTunings"]
assert "disabledTunings" not in body
assert "showFloatingButton" not in body
assert body["lastTuning"] == "Drop D"
saved = json.loads((config_dir / "tuner.json").read_text())
assert "disabledTunings" not in saved
assert "showFloatingButton" not in saved
def test_custom_tuning_old_format_migrated_on_read(self, client, config_dir):
(config_dir / "tuner.json").write_text(json.dumps({
+93
View File
@@ -0,0 +1,93 @@
"""Tests for the Albums-view follow-up (#689's client half): the feedpak
`track`/`disc` fields flowing scanner songs columns the `track` sort the
album track list orders by."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _put(server, fn, title, track=None, disc=None, album="The Album",
artist="Artist", genre=""):
server.meta_db.put(fn, 0, 0, {
"title": title, "artist": artist, "album": album, "year": "1990",
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
"track_number": track, "disc": disc, "genre": genre,
})
def test_sloppak_extract_meta_reads_track_and_disc(server):
d = server.DLC_DIR / "a.sloppak"
d.mkdir(parents=True)
(d / "manifest.yaml").write_text(
"title: Song\nartist: Artist\nduration: 100\n"
"arrangements: []\nstems: []\ntrack: 7\ndisc: 2\n", encoding="utf-8")
import sloppak
meta = sloppak.extract_meta(d)
assert meta["track_number"] == 7
assert meta["disc"] == 2
# Unauthored → None (the album view falls back to title order).
(d / "manifest.yaml").write_text(
"title: Song\nartist: Artist\nduration: 100\n"
"arrangements: []\nstems: []\n", encoding="utf-8")
meta = sloppak.extract_meta(d)
assert meta["track_number"] is None
assert meta["disc"] is None
def test_track_sort_orders_by_disc_then_track_nulls_last(server, client):
_put(server, "d2t1.sloppak", "Zeta", track=1, disc=2)
_put(server, "d1t2.sloppak", "Yankee", track=2, disc=1)
_put(server, "d1t1.sloppak", "Xray", track=1, disc=1)
_put(server, "nonum-b.sloppak", "Bravo") # unauthored → bottom,
_put(server, "nonum-a.sloppak", "Alpha") # ordered by title
body = client.get("/api/library", params={
"artist": "Artist", "album": "The Album", "sort": "track", "size": 50}).json()
assert [s["filename"] for s in body["songs"]] == [
"d1t1.sloppak", "d1t2.sloppak", "d2t1.sloppak",
"nonum-a.sloppak", "nonum-b.sloppak"]
def test_track_and_disc_survive_put_roundtrip(server):
_put(server, "a.sloppak", "Song", track=3, disc=1)
row = server.meta_db.conn.execute(
"SELECT track_number, disc FROM songs WHERE filename = 'a.sloppak'").fetchone()
assert row == (3, 1)
def test_albums_endpoint_honours_genre_filter(server, client):
"""The albums grid must respect the Genre drawer filter the client sends —
without this the /api/library/albums route silently dropped `genre` and
surfaced albums with no matching tracks."""
_put(server, "rock.sloppak", "Rocker", album="Rock LP", genre="Rock")
_put(server, "jazz.sloppak", "Smooth", album="Jazz LP", genre="Jazz")
all_albums = client.get("/api/library/albums", params={"artist": "Artist"}).json()
assert {a["album"] for a in all_albums["albums"]} == {"Rock LP", "Jazz LP"}
filtered = client.get("/api/library/albums",
params={"artist": "Artist", "genre": "Rock"}).json()
assert [a["album"] for a in filtered["albums"]] == ["Rock LP"]
+318
View File
@@ -0,0 +1,318 @@
"""Tests for the R3 art layer: the user>pack>CAA serve chain, user overrides
(upload / URL / delete, with GIF kept animated and LOCAL-ONLY), and the
enrichment art worker's Cover Art Archive fetch + LRU cache.
Both network seams (`_caa_http_get`, `_fetch_art_url`) are faked nothing
here opens a socket, and the offline default is itself asserted.
"""
import importlib
import io as _io
import sys
import pytest
from fastapi.testclient import TestClient
from PIL import Image
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def png_bytes(color=(200, 30, 30)):
buf = _io.BytesIO()
Image.new("RGB", (4, 4), color).save(buf, "PNG")
return buf.getvalue()
def gif_bytes():
"""A tiny 2-frame animated GIF."""
buf = _io.BytesIO()
frames = [Image.new("P", (4, 4), i) for i in (0, 255)]
frames[0].save(buf, "GIF", save_all=True, append_images=frames[1:], duration=100)
return buf.getvalue()
def make_sloppak(server, name, with_cover=False, title="Song", artist="Artist"):
d = server.DLC_DIR / name
d.mkdir(parents=True)
(d / "manifest.yaml").write_text(
f"title: {title}\nartist: {artist}\nduration: 100\n"
"arrangements: []\nstems: []\n", encoding="utf-8")
if with_cover:
(d / "cover.jpg").write_bytes(png_bytes((10, 200, 10)))
server.meta_db.put(name, 0, 0, {
"title": title, "artist": artist, "album": "", "year": "",
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
})
return d
def b64(data):
import base64
return base64.b64encode(data).decode()
# ── the serve chain: user > pack > CAA ────────────────────────────────────────
def test_user_override_beats_pack_art(server, client):
make_sloppak(server, "a.sloppak", with_cover=True)
r = client.get("/api/song/a.sloppak/art")
assert r.status_code == 200 # pack art serves
assert client.post("/api/song/a.sloppak/art/upload",
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
r = client.get("/api/song/a.sloppak/art")
assert r.status_code == 200
assert r.headers["content-type"] == "image/png" # the override, not the pack jpeg
# Removing the override falls back to pack art. (The route lives under
# /api/art — the DELETE /api/song/{path} catch-all would shadow it.)
assert client.delete("/api/art/a.sloppak/override").json()["removed"]
r = client.get("/api/song/a.sloppak/art")
assert r.status_code == 200
assert r.headers["content-type"] == "image/jpeg"
def test_gif_override_kept_animated_and_local_only(server, client):
make_sloppak(server, "a.sloppak", with_cover=True)
raw = gif_bytes()
body = client.post("/api/song/a.sloppak/art/upload", json={"image": b64(raw)}).json()
assert body == {"ok": True, "kind": "gif"}
r = client.get("/api/song/a.sloppak/art")
assert r.headers["content-type"] == "image/gif"
assert r.content == raw # VERBATIM — animation intact
# …and the pack file was never touched (GIF never reaches the feedpak).
assert (server.DLC_DIR / "a.sloppak" / "cover.jpg").read_bytes() == png_bytes((10, 200, 10))
assert not (server.DLC_DIR / "a.sloppak" / "cover.gif").exists()
# A later PNG upload replaces the GIF (one override per song).
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
assert len(server._art_override_paths("a.sloppak")) == 1
def test_bad_upload_rejected(server, client):
make_sloppak(server, "a.sloppak")
assert "error" in client.post("/api/song/a.sloppak/art/upload",
json={"image": b64(b"GIF89a not really a gif")}).json()
assert "error" in client.post("/api/song/a.sloppak/art/upload",
json={"image": b64(b"plain text")}).json()
# ── art by URL ────────────────────────────────────────────────────────────────
def test_art_url_fetches_and_overrides(server, client, monkeypatch):
make_sloppak(server, "a.sloppak", with_cover=True)
monkeypatch.setattr(server, "_fetch_art_url", lambda url: png_bytes((9, 9, 9)))
body = client.post("/api/song/a.sloppak/art/url",
json={"url": "https://example.com/cover.png"}).json()
assert body == {"ok": True, "kind": "png"}
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
def test_art_url_validation(server, client, monkeypatch):
make_sloppak(server, "a.sloppak")
assert client.post("/api/song/a.sloppak/art/url",
json={"url": "ftp://example.com/x.png"}).status_code == 400
assert client.post("/api/song/a.sloppak/art/url", json={}).status_code == 400
assert client.post("/api/song/ghost.sloppak/art/url",
json={"url": "https://example.com/x.png"}).status_code == 404
# Oversize → 400 (the seam raises ValueError at the cap).
def _huge(url):
raise ValueError("image larger than 10 MB")
monkeypatch.setattr(server, "_fetch_art_url", _huge)
assert client.post("/api/song/a.sloppak/art/url",
json={"url": "https://example.com/x.png"}).status_code == 400
def test_art_url_offline_by_default(server, client):
"""The real fetch seam refuses under the test env — pytest can never
reach the network even when a test forgets to fake it."""
make_sloppak(server, "a.sloppak")
r = client.post("/api/song/a.sloppak/art/url",
json={"url": "https://example.com/x.png"})
assert r.status_code == 502
# ── the enrichment art worker (Cover Art Archive) ─────────────────────────────
def _match_row(server, fn, release_id="rel-1"):
"""Seed a matched enrichment row with a release id (as the P8 matcher
would have written) so the art worker picks it up."""
song = server.meta_db.enrichment_song_row(fn)
h = server.meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
server.meta_db.apply_enrichment_match(
fn, h, "matched", source="text", score=1.0,
cand={"recording_id": "rec-1", "release_id": release_id,
"title": song["title"], "artist": song["artist"]})
@pytest.fixture()
def caa(server, monkeypatch):
"""Fake CAA transport + network flag on (mirrors the P8 test fixture)."""
calls = []
art = {"rel-1": png_bytes((60, 60, 200))}
def fake(release_id):
calls.append(release_id)
return art.get(release_id)
fake.calls, fake.art = calls, art
monkeypatch.setattr(server, "_caa_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def test_caa_fetch_fills_missing_art(server, client, caa):
make_sloppak(server, "a.sloppak") # no pack art
_match_row(server, "a.sloppak")
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["art_state"] == "caa"
assert row["art_cache_path"] and row["art_cache_path"].endswith("caa_rel-1.jpg")
r = client.get("/api/song/a.sloppak/art")
assert r.status_code == 200
assert r.headers["content-type"] == "image/jpeg"
# Settled: the next pass never re-fetches.
n = len(caa.calls)
server._background_enrich()
assert len(caa.calls) == n
def test_caa_skips_pack_art_and_dedupes_by_release(server, caa):
make_sloppak(server, "haspack.sloppak", with_cover=True, title="One")
make_sloppak(server, "b.sloppak", title="Two")
make_sloppak(server, "c.sloppak", title="Three")
_match_row(server, "haspack.sloppak")
_match_row(server, "b.sloppak") # same release as c
_match_row(server, "c.sloppak")
server._background_enrich()
assert server.meta_db.get_enrichment("haspack.sloppak")["art_state"] == "pack"
assert server.meta_db.get_enrichment("b.sloppak")["art_state"] == "caa"
assert server.meta_db.get_enrichment("c.sloppak")["art_state"] == "caa"
assert len(caa.calls) == 1 # one release → ONE fetch
def test_caa_404_marks_none(server, caa):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-missing")
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "none"
n = len(caa.calls)
server._background_enrich()
assert len(caa.calls) == n # never re-hammered
def test_caa_transport_error_leaves_row_unevaluated(server, caa, monkeypatch):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak")
def _down(release_id):
raise server.EnrichTransportError("down")
monkeypatch.setattr(server, "_caa_http_get", _down)
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
# Network back → next pass completes it.
monkeypatch.setattr(server, "_caa_http_get", caa)
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
def test_offline_default_skips_art_worker(server, monkeypatch):
"""Under the plain test env the whole art phase is skipped with the rest
of the network work."""
calls = []
monkeypatch.setattr(server, "_caa_http_get", lambda rid: calls.append(rid))
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak")
server._background_enrich()
assert calls == []
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
def test_lru_prune_evicts_oldest_and_resets_rows(server, caa, monkeypatch):
monkeypatch.setattr(server, "_CAA_CACHE_CAP_BYTES", 1) # everything over cap
make_sloppak(server, "a.sloppak", title="One")
make_sloppak(server, "b.sloppak", title="Two")
caa.art["rel-2"] = png_bytes((1, 1, 1))
_match_row(server, "a.sloppak", release_id="rel-1")
_match_row(server, "b.sloppak", release_id="rel-2")
server._background_enrich()
# With a 1-byte cap every fetch immediately evicts — the rows that pointed
# at evicted files were reset to unevaluated.
caa_files = list(server.ART_CACHE_DIR.glob("caa_*.jpg"))
assert len(caa_files) <= 1
states = {fn: server.meta_db.get_enrichment(fn)["art_state"]
for fn in ("a.sloppak", "b.sloppak")}
assert None in states.values() or list(states.values()).count("caa") <= 1
def test_delete_song_removes_override(server, client):
make_sloppak(server, "a.sloppak")
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
assert server._art_override_paths("a.sloppak")
r = client.delete("/api/song/a.sloppak")
assert r.status_code == 200
assert server._art_override_paths("a.sloppak") == []
def test_delete_override_restores_caa_fallback(server, client, caa):
"""Removing a user override that had settled the row as 'user' must reset
the enrichment state so the CAA fallback is fetched and served again
otherwise the song is stranded with no art at all."""
make_sloppak(server, "a.sloppak") # no pack art
_match_row(server, "a.sloppak")
# Pin an override BEFORE the art worker runs → the pass stamps art_state='user'.
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "user"
# Remove it → the row resets to unevaluated…
assert client.delete("/api/art/a.sloppak/override").json()["removed"]
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
# …and the next pass fetches + serves the release's front cover.
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
r = client.get("/api/song/a.sloppak/art")
assert r.status_code == 200
assert r.headers["content-type"] == "image/jpeg"
def test_upload_rejects_unknown_song_and_oversize(server, client):
# Unknown filename → 404 (no stray override file written).
assert client.post("/api/song/ghost.sloppak/art/upload",
json={"image": b64(png_bytes())}).status_code == 404
assert server._art_override_paths("ghost.sloppak") == []
# Oversize decoded payload → 400 (bounds the base64 upload path).
make_sloppak(server, "a.sloppak")
huge = b64(b"\x00" * (server._ART_URL_MAX_BYTES + 1))
assert client.post("/api/song/a.sloppak/art/upload",
json={"image": huge}).status_code == 400
def test_fetch_art_url_blocks_internal_hosts(server):
"""The SSRF guard refuses loopback / link-local / private targets before
any request is made (the real seam, not the faked one)."""
assert server._url_host_is_internal("http://127.0.0.1/x.png")
assert server._url_host_is_internal("http://localhost/x.png")
assert server._url_host_is_internal("http://169.254.169.254/latest/meta-data")
assert server._url_host_is_internal("http://10.0.0.5/x.png")
assert server._url_host_is_internal("http://[::1]/x.png")
assert server._url_host_is_internal("http://nonexistent.invalid/x.png") # unresolvable → closed
assert not server._url_host_is_internal("http://93.184.216.34/x.png") # public literal
+211
View File
@@ -0,0 +1,211 @@
"""Tests for artist-name canonicalization (P4): the artist_alias override that
merges messy artist tags ("ACDC" "AC/DC") AT DISPLAY the deduped dropdown/
tree (query_artists), the artist filter (canonical matches all raw variants),
and the grid card label without rewriting songs.artist or the feedpak files.
Sort/AZ stay on the raw artist (keyset-safe); that reindex is deferred to P5a."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _seed(server, fn, artist, album="Alb"):
server.meta_db.put(fn, 0, 0, {"title": fn.split(".")[0], "artist": artist, "album": album})
def _artist_names(client):
data = client.get("/api/library/artists?size=100").json()
return [a["name"] for a in data["artists"]]
def _grid_artist(client, fn):
row = next(s for s in client.get("/api/library").json()["songs"] if s["filename"] == fn)
return row["artist"]
def _alias(client, raw, canonical):
return client.post("/api/artist-aliases", json={"raw_name": raw, "canonical_name": canonical})
# ── No aliases: raw names, unchanged behaviour ───────────────────────────────
def test_no_aliases_lists_raw_distinct(client, server):
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "AC/DC")
assert set(_artist_names(client)) == {"ACDC", "AC/DC"}
# ── Dropdown/tree dedupe on the canonical name ───────────────────────────────
def test_alias_dedupes_artist_list(client, server):
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "AC/DC")
_alias(client, "ACDC", "AC/DC")
data = client.get("/api/library/artists?size=100").json()
assert [a["name"] for a in data["artists"]] == ["AC/DC"]
assert data["total_artists"] == 1
# ── Grid card shows the canonical label ──────────────────────────────────────
def test_grid_shows_canonical_artist(client, server):
_seed(server, "a.archive", "ACDC")
_alias(client, "ACDC", "AC/DC")
assert _grid_artist(client, "a.archive") == "AC/DC"
# ── Filtering by the canonical matches every raw variant ─────────────────────
def test_filter_by_canonical_matches_all_variants(client, server):
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "AC/DC")
_seed(server, "c.archive", "Other")
_alias(client, "ACDC", "AC/DC")
got = {s["filename"] for s in
client.get("/api/library", params={"artist": "AC/DC"}).json()["songs"]}
assert got == {"a.archive", "b.archive"}
# ── Merge endpoint ───────────────────────────────────────────────────────────
def test_merge_endpoint(client, server):
_seed(server, "a.archive", "Beatles")
_seed(server, "b.archive", "The Beatles")
r = client.post("/api/artist-aliases/merge",
json={"raw_names": ["Beatles", "The Beatles"], "canonical_name": "The Beatles"})
assert r.json()["merged"] == 1 # "The Beatles" self-skip
assert _artist_names(client) == ["The Beatles"]
def test_merge_requires_canonical_and_list(client, server):
assert client.post("/api/artist-aliases/merge", json={"raw_names": ["x"]}).status_code == 400
assert client.post("/api/artist-aliases/merge", json={"canonical_name": "y"}).status_code == 400
# ── Un-merge: self-alias clears + DELETE ─────────────────────────────────────
def test_self_alias_clears(client, server):
_seed(server, "a.archive", "ACDC")
_alias(client, "ACDC", "AC/DC")
_alias(client, "ACDC", "ACDC") # self → un-merge
assert client.get("/api/artist-aliases").json()["aliases"] == []
assert _grid_artist(client, "a.archive") == "ACDC"
def test_delete_alias_unmerges(client, server):
_seed(server, "a.archive", "ACDC")
_alias(client, "ACDC", "AC/DC")
assert client.delete("/api/artist-aliases/ACDC").json()["ok"] is True
assert _grid_artist(client, "a.archive") == "ACDC"
# ── Never purged when songs churn (separate, non-filename-keyed table) ────────
def test_alias_survives_song_reindex(client, server):
_seed(server, "a.archive", "ACDC")
_alias(client, "ACDC", "AC/DC")
# A rescan re-indexes the song (INSERT OR REPLACE INTO songs).
server.meta_db.put("a.archive", 1, 1, {"title": "a", "artist": "ACDC", "album": "Alb"})
assert _grid_artist(client, "a.archive") == "AC/DC"
assert len(client.get("/api/artist-aliases").json()["aliases"]) == 1
# ── Raw-artist picker (Tidy-up source) ───────────────────────────────────────
def test_raw_artists_lists_counts_and_canonical(client, server):
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "ACDC")
_seed(server, "c.archive", "AC/DC")
_alias(client, "ACDC", "AC/DC")
by_name = {a["name"]: a for a in client.get("/api/artists/raw").json()["artists"]}
assert by_name["ACDC"]["count"] == 2
assert by_name["ACDC"]["canonical"] == "AC/DC" # shows where it maps
assert by_name["AC/DC"]["count"] == 1
# ── Transitive chains flatten so sequential merges unify (PR #705 P2) ─────────
def test_sequential_merge_flattens_transitive_chain(client, server):
"""merge ACDC→AC/DC then AC/DC→AC-DC must unify ALL variants onto the terminal
canonical ("AC-DC") not leave a two-hop chain that grouping/filtering split."""
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "AC/DC")
_seed(server, "c.archive", "AC-DC")
client.post("/api/artist-aliases/merge",
json={"raw_names": ["ACDC"], "canonical_name": "AC/DC"})
client.post("/api/artist-aliases/merge",
json={"raw_names": ["AC/DC"], "canonical_name": "AC-DC"})
# Both original variants resolve (single hop) to the terminal canonical.
assert server.meta_db.effective_artist("ACDC") == "AC-DC"
assert server.meta_db.effective_artist("AC/DC") == "AC-DC"
# The song tagged "ACDC" displays the terminal, not the intermediate.
assert _grid_artist(client, "a.archive") == "AC-DC"
# Grouping shows ONE canonical, not two split groups.
assert _artist_names(client) == ["AC-DC"]
# Stored rows are already terminal (forward-flattened), never AC/DC.
canon = {a["canonical_name"] for a in client.get("/api/artist-aliases").json()["aliases"]}
assert canon == {"AC-DC"}
# Filtering by the terminal matches every original variant.
got = {s["filename"] for s in
client.get("/api/library", params={"artist": "AC-DC"}).json()["songs"]}
assert got == {"a.archive", "b.archive", "c.archive"}
def test_cycle_is_refused_and_state_intact(client, server):
"""A→B then B→A would close a cycle: the second set is refused (409) and the
existing AB mapping is left intact no loop, no corruption."""
_seed(server, "a.archive", "A")
_seed(server, "b.archive", "B")
client.post("/api/artist-aliases/merge", json={"raw_names": ["A"], "canonical_name": "B"})
r = _alias(client, "B", "A") # B → A closes the cycle
assert r.status_code == 409
# State unchanged: exactly one alias row, A → B.
assert client.get("/api/artist-aliases").json()["aliases"] == [
{"raw_name": "A", "canonical_name": "B", "mb_artist_id": None}]
assert server.meta_db.effective_artist("A") == "B"
assert server.meta_db.effective_artist("B") == "B"
def test_terminal_resolution_survives_a_stored_cycle(server):
"""Even if the table somehow holds a direct cycle (P↔Q), the visited-set makes
_terminal_canonical terminate instead of looping forever."""
db = server.meta_db
with db._lock:
db.conn.execute("INSERT INTO artist_alias (raw_name, canonical_name, updated_at) "
"VALUES ('P', 'Q', datetime('now'))")
db.conn.execute("INSERT INTO artist_alias (raw_name, canonical_name, updated_at) "
"VALUES ('Q', 'P', datetime('now'))")
db.conn.commit()
assert db._terminal_canonical("P") in ("P", "Q")
assert db._terminal_canonical("Q") in ("P", "Q")
def test_list_aliases_sorted(client, server):
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "guns n roses")
_alias(client, "ACDC", "AC/DC")
_alias(client, "guns n roses", "Guns N' Roses")
aliases = client.get("/api/artist-aliases").json()["aliases"]
assert {a["raw_name"] for a in aliases} == {"ACDC", "guns n roses"}
+81
View File
@@ -0,0 +1,81 @@
"""The grid's artist sort orders WITHIN an artist by title (tree-view feel),
not raw filename the tester's "list is organised by artist, but the cards
look alphabetical/random" report. Artist sorts page by OFFSET now (the title
secondary can't ride the two-term keyset cursor), so pagination across an
artist boundary is pinned too."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _put(server, fn, artist, title):
server.meta_db.put(fn, 0, 0, {
"title": title, "artist": artist, "album": "", "year": "",
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
})
def test_artist_sort_orders_titles_within_artist(server, client):
# Filenames deliberately REVERSE the title order, so filename ordering
# (the old behaviour) and title ordering disagree.
_put(server, "z1.sloppak", "Alpha Band", "Aardvark")
_put(server, "a9.sloppak", "Alpha Band", "Zebra")
_put(server, "m5.sloppak", "Alpha Band", "Mango")
_put(server, "q1.sloppak", "Beta Band", "Only Song")
body = client.get("/api/library", params={"sort": "artist", "size": 50}).json()
assert [s["title"] for s in body["songs"]] == ["Aardvark", "Mango", "Zebra", "Only Song"]
# Z→A flips the ARTIST order; titles stay A→Z within each artist.
body = client.get("/api/library", params={"sort": "artist-desc", "size": 50}).json()
assert [s["title"] for s in body["songs"]] == ["Only Song", "Aardvark", "Mango", "Zebra"]
def test_legacy_dir_desc_flips_artist_like_artist_desc(server, client):
# The legacy `sort=artist&dir=desc` shape must match the explicit
# `artist-desc` key: the artist clause now bakes in ASC (for the title
# secondary), so `dir=desc` is folded into the effective sort BEFORE the
# ORDER BY lookup — otherwise the append is suppressed and dir=desc would
# silently return A→Z.
_put(server, "z1.sloppak", "Alpha Band", "Aardvark")
_put(server, "a9.sloppak", "Alpha Band", "Zebra")
_put(server, "m5.sloppak", "Alpha Band", "Mango")
_put(server, "q1.sloppak", "Beta Band", "Only Song")
legacy = client.get("/api/library", params={"sort": "artist", "dir": "desc", "size": 50}).json()
explicit = client.get("/api/library", params={"sort": "artist-desc", "size": 50}).json()
assert [s["title"] for s in legacy["songs"]] == ["Only Song", "Aardvark", "Mango", "Zebra"]
assert [s["title"] for s in legacy["songs"]] == [s["title"] for s in explicit["songs"]]
def test_artist_sort_offset_pagination_no_skip_or_dupe(server, client):
for i in range(7):
_put(server, f"f{6 - i}.sloppak", "One Artist", f"Title {chr(65 + i)}")
seen = []
for page in range(4):
body = client.get("/api/library", params={"sort": "artist", "size": 2, "page": page}).json()
seen += [s["title"] for s in body["songs"]]
assert seen == [f"Title {chr(65 + i)}" for i in range(7)]
# And no keyset cursor is offered for artist sorts (OFFSET path).
body = client.get("/api/library", params={"sort": "artist", "size": 2}).json()
assert body["next_cursor"] is None
+152
View File
@@ -0,0 +1,152 @@
"""Tests for the bulk personal-meta endpoint (P2):
POST /api/songs/user-meta/batch the apply-to-all behind the Songs batch bar.
DB-only, never touches files. Additive by design: bulk tag edits ADD/REMOVE
(never full-replace) and an omitted `set_difficulty` leaves each song's own
value alone (mixed-state "leave unchanged")."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _seed(server, *filenames):
for fn in filenames:
server.meta_db.put(fn, 0, 0, {"title": fn.split(".")[0], "artist": "A"})
def _um(client, fn):
return client.get(f"/api/song/{fn}/user-meta").json()
BATCH = "/api/songs/user-meta/batch"
# ── Difficulty across a selection ────────────────────────────────────────────
def test_batch_set_difficulty_on_all(client, server):
_seed(server, "a.archive", "b.archive", "c.archive")
r = client.post(BATCH, json={"filenames": ["a.archive", "b.archive"],
"set_difficulty": 4})
assert r.status_code == 200
assert r.json()["updated"] == 2
assert _um(client, "a.archive")["user_difficulty"] == 4
assert _um(client, "b.archive")["user_difficulty"] == 4
# untouched song stays unset
assert _um(client, "c.archive")["user_difficulty"] is None
def test_batch_clear_difficulty_preserves_notes(client, server):
_seed(server, "a.archive", "b.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 3, "notes": "keep me"})
client.put("/api/song/b.archive/user-meta", json={"user_difficulty": 2})
r = client.post(BATCH, json={"filenames": ["a.archive", "b.archive"],
"set_difficulty": None})
assert r.status_code == 200
# a still has its notes; b (no notes) is fully cleared
a = _um(client, "a.archive")
assert a["user_difficulty"] is None and a["notes"] == "keep me"
assert _um(client, "b.archive") == {"user_difficulty": None, "notes": "", "tags": []}
def test_omitting_set_difficulty_leaves_each_value(client, server):
"""Mixed-state: no set_difficulty key → every song keeps its own difficulty
while tags still apply."""
_seed(server, "a.archive", "b.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 1})
client.put("/api/song/b.archive/user-meta", json={"user_difficulty": 5})
client.post(BATCH, json={"filenames": ["a.archive", "b.archive"],
"add_tags": ["gig"]})
assert _um(client, "a.archive")["user_difficulty"] == 1
assert _um(client, "b.archive")["user_difficulty"] == 5
assert _um(client, "a.archive")["tags"] == ["gig"]
# ── Tags: additive, never full-replace ───────────────────────────────────────
def test_batch_add_tags_does_not_clobber_existing(client, server):
_seed(server, "a.archive", "b.archive")
client.put("/api/song/a.archive/user-meta", json={"tags": ["existing"]})
client.post(BATCH, json={"filenames": ["a.archive", "b.archive"],
"add_tags": ["Warm-ups", "warm-ups"]}) # dupe-folds
assert _um(client, "a.archive")["tags"] == ["existing", "warm-ups"]
assert _um(client, "b.archive")["tags"] == ["warm-ups"]
def test_batch_remove_tags(client, server):
_seed(server, "a.archive", "b.archive")
client.put("/api/song/a.archive/user-meta", json={"tags": ["gig", "riffs"]})
client.put("/api/song/b.archive/user-meta", json={"tags": ["gig"]})
client.post(BATCH, json={"filenames": ["a.archive", "b.archive"],
"remove_tags": ["gig"]})
assert _um(client, "a.archive")["tags"] == ["riffs"]
assert _um(client, "b.archive")["tags"] == []
def test_add_wins_over_remove_conflict(client, server):
_seed(server, "a.archive")
client.post(BATCH, json={"filenames": ["a.archive"],
"add_tags": ["keep"], "remove_tags": ["keep"]})
assert _um(client, "a.archive")["tags"] == ["keep"]
def test_returned_tag_vocabulary_refreshes(client, server):
_seed(server, "a.archive", "b.archive")
body = client.post(BATCH, json={"filenames": ["a.archive", "b.archive"],
"add_tags": ["warmups"]}).json()
assert {"tag": "warmups", "count": 2} in body["tags"]
# ── Validation ───────────────────────────────────────────────────────────────
def test_empty_filenames_is_400(client, server):
assert client.post(BATCH, json={"filenames": [], "set_difficulty": 3}).status_code == 400
assert client.post(BATCH, json={"set_difficulty": 3}).status_code == 400
def test_nothing_to_apply_is_400(client, server):
_seed(server, "a.archive")
assert client.post(BATCH, json={"filenames": ["a.archive"]}).status_code == 400
@pytest.mark.parametrize("bad", [0, 6, 2.5, "x", True])
def test_bad_set_difficulty_is_400(client, server, bad):
_seed(server, "a.archive")
assert client.post(BATCH, json={"filenames": ["a.archive"],
"set_difficulty": bad}).status_code == 400
def test_tags_must_be_arrays(client, server):
_seed(server, "a.archive")
assert client.post(BATCH, json={"filenames": ["a.archive"],
"add_tags": "gig"}).status_code == 400
def test_batch_survives_rescan(client, server):
_seed(server, "a.archive")
client.post(BATCH, json={"filenames": ["a.archive"],
"set_difficulty": 3, "add_tags": ["t"]})
server.meta_db.put("a.archive", 1, 1, {"title": "a", "artist": "A"})
m = _um(client, "a.archive")
assert m["user_difficulty"] == 3 and m["tags"] == ["t"]
+141
View File
@@ -0,0 +1,141 @@
"""Tests for the R2 context-menu backend: per-song "Refresh metadata"
(explicit re-match reset + kick) and "Get info" (file location + pack
contents). The refresh flow reuses the P8 fake-transport pattern nothing
here opens a socket."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _put(server, fn, title="Thunderstruck", artist="AC/DC", album="", year="1990",
duration=292):
server.meta_db.put(fn, 0, 0, {
"title": title, "artist": artist, "album": album, "year": year,
"duration": duration, "arrangements": [{"name": "Lead", "index": 0}],
})
def make_sloppak(server, name, extra_yaml="", title="Thunderstruck", artist="AC/DC"):
d = server.DLC_DIR / name
d.mkdir(parents=True)
(d / "manifest.yaml").write_text(
f"title: {title}\nartist: {artist}\nduration: 292\n"
"arrangements:\n - name: Lead\n id: lead\n"
"stems:\n - id: full\n file: stems/full.ogg\n" + extra_yaml,
encoding="utf-8")
_put(server, name, title=title, artist=artist)
return d
# ── Refresh metadata ──────────────────────────────────────────────────────────
def test_refresh_resets_even_a_manual_pin_and_rematches(server, client, monkeypatch):
_put(server, "a.sloppak")
server.meta_db.set_enrichment_manual(
"a.sloppak", {"recording_id": "old-pin", "title": "Thunderstruck",
"artist": "AC/DC"}, source="search")
# The reset itself is synchronous; the kicked pass runs on a daemon
# thread, so assert the reset here and drive the re-match inline below.
r = client.post("/api/enrichment/refresh/a.sloppak")
assert r.status_code == 200
for _ in range(200):
if not client.get("/api/enrichment/status").json()["running"]:
break
import time as _t
_t.sleep(0.02)
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "unscanned" # the pin was discarded
assert row["mb_recording_id"] is None
assert row["attempts"] == 0
# …and the normal pass re-matches it (fake transport, network flag on).
def fake(path, params):
return {"recordings": [{
"id": "rec-new", "score": 100, "title": "Thunderstruck",
"length": 292000,
"artist-credit": [{"name": "AC/DC", "artist": {
"id": "art-1", "name": "AC/DC", "sort-name": "AC/DC"}}],
"releases": [{"id": "rel-1", "title": "The Razors Edge",
"status": "Official", "date": "1990-09-24",
"release-group": {"primary-type": "Album"}}],
}]}
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["mb_recording_id"] == "rec-new"
def test_refresh_unknown_song_404(server, client):
assert client.post("/api/enrichment/refresh/ghost.sloppak").status_code == 404
# ── Get info ──────────────────────────────────────────────────────────────────
def test_fileinfo_sloppak_contents(server, client):
make_sloppak(server, "a.sloppak",
extra_yaml="mbid: 12345678-abcd-4ef0-9876-0123456789ab\n"
"genres: [rock, hard rock]\ntrack: 3\n")
body = client.get("/api/chart/a.sloppak/fileinfo").json()
assert body["format"] == "sloppak"
assert body["filename"] == "a.sloppak"
assert body["path"].endswith("a.sloppak")
assert body["size"] > 0
m = body["manifest"]
assert m["title"] == "Thunderstruck"
assert m["arrangements"] == ["Lead"]
assert m["stems"] == ["full"]
assert m["has_cover"] is False
assert m["identity"]["mbid"] == "12345678-abcd-4ef0-9876-0123456789ab"
assert m["identity"]["genres"] == ["rock", "hard rock"]
assert m["identity"]["track"] == 3
assert "isrc" not in m["identity"] # only keys actually present
# No enrichment row yet → no match block (the panel shows "Not scanned").
assert "match" not in body
def test_fileinfo_includes_match_verdict(server, client):
make_sloppak(server, "a.sloppak")
server.meta_db.set_enrichment_manual(
"a.sloppak", {"recording_id": "rec-1", "title": "Thunderstruck",
"artist": "AC/DC", "album": "The Razors Edge"},
source="search")
body = client.get("/api/chart/a.sloppak/fileinfo").json()
assert body["match"]["match_state"] == "manual"
assert body["match"]["canon_album"] == "The Razors Edge"
def test_fileinfo_missing_and_traversal(server, client):
assert client.get("/api/chart/ghost.sloppak/fileinfo").status_code == 404
assert client.get("/api/chart/..%2f..%2fetc%2fpasswd/fileinfo").status_code in (403, 404)
def test_fileinfo_non_chart_file_is_404(server, client):
"""A stray non-song file the user keeps under DLC_DIR must not have its
path/size/mtime exposed the route is charts only, not a filesystem stat."""
(server.DLC_DIR / "private-notes.txt").write_text("secret", encoding="utf-8")
assert client.get("/api/chart/private-notes.txt/fileinfo").status_code == 404
+206
View File
@@ -0,0 +1,206 @@
"""Tests for the curated album (P6, metadata-design §7.2): a playlists row with
kind='album' + per-slot pinned chart/arrangement, work_key stamped at add,
slot chart-swap validated to the same work, and orphan-at-read self-heal."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _put(server, fn, title, artist, arrangements=("Lead",)):
arr = [{"name": n, "index": i} for i, n in enumerate(arrangements)]
server.meta_db.put(fn, 0, 0, {"title": title, "artist": artist, "arrangements": arr})
def _album(client, name="My Album"):
return client.post("/api/playlists", json={"name": name, "kind": "album"}).json()
def _add(client, pid, fn):
return client.post(f"/api/playlists/{pid}/songs", json={"filename": fn}).json()
# ── kind discriminator ────────────────────────────────────────────────────────
def test_create_album_kind(client):
pl = _album(client)
assert pl["kind"] == "album"
listed = {p["id"]: p for p in client.get("/api/playlists").json()}
assert listed[pl["id"]]["kind"] == "album"
def test_regular_playlist_payload_has_no_album_fields(client, server):
_put(server, "a.archive", "Song", "Artist")
pl = client.post("/api/playlists", json={"name": "Mix"}).json()
assert "kind" not in pl
body = _add(client, pl["id"], "a.archive")
assert "arrangement" not in body["songs"][0]
assert "work_key" not in body["songs"][0]
def test_bad_kind_rejected(client):
r = client.post("/api/playlists", json={"name": "X", "kind": "boss-fight"})
assert r.status_code == 400
# ── add stamps work identity; slot payload carries the album fields ──────────
def test_album_add_stamps_work_key(client, server):
_put(server, "a.archive", "Song", "Artist")
pid = _album(client)["id"]
slot = _add(client, pid, "a.archive")["songs"][0]
assert slot["work_key"] == server.meta_db.work_key_for("a.archive")
assert slot["arrangement"] is None
assert [a["name"] for a in slot["arrangements"]] == ["Lead"]
# ── slot arrangement pin ──────────────────────────────────────────────────────
def test_slot_arrangement_pin_and_clear(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=("Lead", "Bass"))
pid = _album(client)["id"]
_add(client, pid, "a.archive")
body = client.patch(f"/api/playlists/{pid}/songs/a.archive",
json={"arrangement": "Bass"}).json()
assert body["songs"][0]["arrangement"] == "Bass"
body = client.patch(f"/api/playlists/{pid}/songs/a.archive",
json={"arrangement": None}).json()
assert body["songs"][0]["arrangement"] is None
def test_slot_edit_rejected_for_mix(client, server):
_put(server, "a.archive", "Song", "Artist")
pid = client.post("/api/playlists", json={"name": "Mix"}).json()["id"]
_add(client, pid, "a.archive")
r = client.patch(f"/api/playlists/{pid}/songs/a.archive", json={"arrangement": "Lead"})
assert r.status_code == 400
# ── slot chart swap (same work only; position + pin kept) ────────────────────
def test_slot_chart_swap_same_work(client, server):
_put(server, "a.archive", "Song", "Artist")
_put(server, "b.archive", "Song", "Artist")
_put(server, "z.archive", "Closer", "Artist")
pid = _album(client)["id"]
_add(client, pid, "a.archive")
_add(client, pid, "z.archive")
client.patch(f"/api/playlists/{pid}/songs/a.archive", json={"arrangement": "Lead"})
body = client.patch(f"/api/playlists/{pid}/songs/a.archive",
json={"chart_filename": "b.archive"}).json()
slots = body["songs"]
assert [s["filename"] for s in slots] == ["b.archive", "z.archive"] # position kept
assert slots[0]["arrangement"] == "Lead" # pin kept
assert slots[0]["work_key"] == server.meta_db.work_key_for("b.archive")
def test_slot_chart_swap_rejects_other_work(client, server):
_put(server, "a.archive", "Song", "Artist")
_put(server, "x.archive", "Other", "Artist")
pid = _album(client)["id"]
_add(client, pid, "a.archive")
r = client.patch(f"/api/playlists/{pid}/songs/a.archive",
json={"chart_filename": "x.archive"})
assert r.status_code == 400
def test_slot_chart_swap_rejects_duplicate_member(client, server):
_put(server, "a.archive", "Song", "Artist")
_put(server, "b.archive", "Song", "Artist")
pid = _album(client)["id"]
_add(client, pid, "a.archive")
_add(client, pid, "b.archive")
r = client.patch(f"/api/playlists/{pid}/songs/a.archive",
json={"chart_filename": "b.archive"})
assert r.status_code == 400
# ── orphan-at-read self-heal (§7.2) ──────────────────────────────────────────
def test_orphan_slot_resolves_to_current_keeper(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=("Lead", "Rhythm"))
_put(server, "b.archive", "Song", "Artist")
pid = _album(client)["id"]
_add(client, pid, "b.archive") # pin the 1-arr chart
server.meta_db.delete_missing({"a.archive"}) # rescan: b's file is gone
slot = client.get(f"/api/playlists/{pid}").json()["songs"][0]
assert slot["filename"] == "b.archive" # membership NOT rewritten
assert slot["resolved_from_orphan"] is True
assert slot["resolved_filename"] == "a.archive" # the work's current keeper
assert slot["title"] == "Song"
assert "missing" not in slot
def test_orphan_slot_missing_when_work_gone(client, server):
_put(server, "a.archive", "Song", "Artist")
pid = _album(client)["id"]
_add(client, pid, "a.archive")
server.meta_db.delete_missing(set()) # library emptied
slot = client.get(f"/api/playlists/{pid}").json()["songs"][0]
assert slot["missing"] is True
assert "resolved_filename" not in slot
def test_mix_still_hides_dead_songs(client, server):
_put(server, "a.archive", "Song", "Artist")
pid = client.post("/api/playlists", json={"name": "Mix"}).json()["id"]
_add(client, pid, "a.archive")
server.meta_db.delete_missing(set())
assert client.get(f"/api/playlists/{pid}").json()["songs"] == []
# ── list-card count parity: albums count ALL slots (list vs detail) ──────────
def test_album_list_count_includes_orphaned_and_missing_slots(client, server):
# The detail view renders / plays EVERY album slot — a self-healing orphan
# and a fully-missing work both stay in the denominator (§7.2) — so the
# list-card count must agree. Guards against the mix "dead-filter" (count
# only songs still in `songs`) leaking into albums, which undercounted a
# slot the moment its pinned file was deleted.
_put(server, "a.archive", "Song", "Artist") # work A keeper (survives)
_put(server, "b.archive", "Song", "Artist") # work A, pinned then deleted
_put(server, "c.archive", "Closer", "Artist") # work C, deleted whole
pid = _album(client)["id"]
_add(client, pid, "a.archive") # live slot
_add(client, pid, "b.archive") # → orphan (self-heals to a)
_add(client, pid, "c.archive") # → fully missing
server.meta_db.delete_missing({"a.archive"}) # rescan: only a survives
detail = client.get(f"/api/playlists/{pid}").json()["songs"]
assert len(detail) == 3 # detail keeps all 3 slots
listed = {p["id"]: p for p in client.get("/api/playlists").json()}
assert listed[pid]["count"] == 3 # list card agrees (was 1)
def test_mix_list_count_still_dead_filters(client, server):
# The other side of the discriminator: a mix's list count keeps hiding dead
# songs, so the album fix doesn't regress non-album playlists.
_put(server, "a.archive", "Song", "Artist")
_put(server, "b.archive", "Closer", "Artist")
pid = client.post("/api/playlists", json={"name": "Mix"}).json()["id"]
_add(client, pid, "a.archive")
_add(client, pid, "b.archive")
server.meta_db.delete_missing({"a.archive"}) # b's file gone
listed = {p["id"]: p for p in client.get("/api/playlists").json()}
assert listed[pid]["count"] == 1 # dead b not counted
+15
View File
@@ -91,6 +91,21 @@ def test_demo_off_settings_post_not_blocked(tmp_path, monkeypatch):
("GET", "/api/plugins/updates"),
("POST", "/api/plugins/highway_3d/files"),
("DELETE", "/api/plugins/highway_3d/files"),
# Enrichment (P8): review writes + the MusicBrainz search proxy (the
# proxy would spend the shared rate limit for anonymous demo visitors).
("POST", "/api/enrichment/review/some-file/accept"),
("POST", "/api/enrichment/review/some-file/reject"),
("POST", "/api/enrichment/review/some-file/pick"),
("POST", "/api/enrichment/kick"),
("GET", "/api/enrichment/search"),
# Context menus (R2): per-song re-match + the path-exposing Get info.
("POST", "/api/enrichment/refresh/some-file"),
("GET", "/api/chart/some-file/fileinfo"),
# Art layer (R3): the base64 upload writes files, the server-side URL fetch
# touches the network, and the override delete removes files — all mutations.
("POST", "/api/song/some-file/art/upload"),
("POST", "/api/song/some-file/art/url"),
("DELETE", "/api/art/some-file/override"),
])
def test_demo_on_blocked_routes_return_403(tmp_path, monkeypatch, method, path):
server, client = _make_client(tmp_path, monkeypatch, demo=True)
+151
View File
@@ -0,0 +1,151 @@
"""Tests for the metadata-enrichment plumbing (P7): the song_enrichment cache
table + identity hashing + the queue/lifecycle around the (for now) no-op
matcher. The text matcher itself is the next slice; these tests pin the
contracts it will inherit: rename-survivable idempotent hashing, manual rows
never auto-reset, never purged on rescan, purged on explicit delete."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _put(server, fn, title="Song", artist="Artist", album="", duration=100):
server.meta_db.put(fn, 0, 0, {
"title": title, "artist": artist, "album": album, "duration": duration,
"arrangements": [{"name": "Lead", "index": 0}],
})
# ── identity hash ─────────────────────────────────────────────────────────────
def test_hash_is_rename_survivable_and_normalized(server):
h = server.meta_db.enrichment_content_hash
# keyed on metadata, not the filename → a renamed pack keeps its hash
assert h("Artist", "Song", "Album", 100) == h("Artist", "Song", "Album", 100)
# case + whitespace folded; duration rounded to whole seconds
assert h(" artist ", "SONG", "album", 100.4) == h("Artist", "Song", "Album", 100)
# a real identity change means a different hash
assert h("Artist", "Song", "Album", 100) != h("Artist", "Other", "Album", 100)
assert h("Artist", "Song", "Album", None) == h("Artist", "Song", "Album", 0)
# ── queue selection ───────────────────────────────────────────────────────────
def test_pending_covers_new_unscanned_and_changed(server):
_put(server, "a.archive")
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
# stubbed → still unscanned → still pending (the matcher hasn't run)
server._background_enrich()
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
# a matched row with the CURRENT hash is settled…
h = server.meta_db.enrichment_content_hash("Artist", "Song", "", 100)
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state = 'matched', content_hash = ? "
"WHERE filename = 'a.archive'", (h,))
server.meta_db.conn.commit()
assert server.meta_db.enrichment_pending() == []
# …until the song's identity changes, which re-queues it
_put(server, "a.archive", title="Song (Remastered)")
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
def test_hash_change_resets_matched_but_never_manual(server):
_put(server, "a.archive")
_put(server, "b.archive", title="Other")
server._background_enrich()
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state = 'matched' WHERE filename = 'a.archive'")
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state = 'manual' WHERE filename = 'b.archive'")
server.meta_db.conn.commit()
# identity edits…
_put(server, "a.archive", title="Song v2")
_put(server, "b.archive", title="Other v2")
server._background_enrich()
a = server.meta_db.get_enrichment("a.archive")
b = server.meta_db.get_enrichment("b.archive")
# …drop a stale MATCH back to unscanned with the fresh hash
assert a["match_state"] == "unscanned"
assert a["content_hash"] == server.meta_db.enrichment_content_hash("Artist", "Song v2", "", 100)
# …but a MANUAL pin survives untouched (state AND hash)
assert b["match_state"] == "manual"
assert b["content_hash"] == server.meta_db.enrichment_content_hash("Artist", "Other", "", 100)
def test_failed_rows_not_requeued_by_pending(server):
_put(server, "a.archive")
server._background_enrich()
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state = 'failed' WHERE filename = 'a.archive'")
server.meta_db.conn.commit()
# backoff/retry policy belongs to the matcher slice, not the queue walk
assert server.meta_db.enrichment_pending() == []
# ── worker pass ───────────────────────────────────────────────────────────────
def test_enrich_pass_stamps_every_song(server):
for i in range(5):
_put(server, f"s{i}.archive", title=f"Song {i}")
server._background_enrich()
for i in range(5):
row = server.meta_db.get_enrichment(f"s{i}.archive")
assert row is not None
assert row["match_state"] == "unscanned"
assert row["content_hash"] == server.meta_db.enrichment_content_hash(
"Artist", f"Song {i}", "", 100)
# ── lifecycle: rescan survival vs explicit delete ─────────────────────────────
def test_rescan_never_purges_enrichment(server):
_put(server, "a.archive")
server._background_enrich()
server.meta_db.delete_missing(set()) # file vanished from a scan snapshot
assert server.meta_db.get_enrichment("a.archive") is not None # row survives
# …and is invisible in the read-time-filtered counts
assert server.meta_db.enrichment_state_counts() == {}
_put(server, "a.archive") # the file comes back
assert server.meta_db.enrichment_state_counts() == {"unscanned": 1}
def test_status_endpoint_counts(client, server):
_put(server, "a.archive")
_put(server, "b.archive", title="Other")
server._background_enrich()
body = client.get("/api/enrichment/status").json()
assert body["states"] == {"unscanned": 2}
assert body["total_songs"] == 2
assert body["running"] is False
assert body["processed"] == 2
def test_art_cache_dir_created(server):
d = server._enrichment_art_dir()
assert d.is_dir()
assert d.name == "art_cache"
+270
View File
@@ -0,0 +1,270 @@
"""Tests for the R4a gap-fill write-back — the §7 contract made executable:
user-initiated, adds ABSENT keys only (author bytes preserved verbatim),
spec'd-keys allowlist, values only from a CONFIRMED match, atomic + .bak.
No network anywhere: matches are seeded straight into the enrichment cache
(as the P8 matcher would have written them).
"""
import importlib
import sys
import time
import zipfile
import pytest
import yaml
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
# A write endpoint kicks a background scan (which then kicks the
# enrichment worker); both daemon threads share meta_db's sqlite
# connection. Drain them before closing it — closing mid-scan
# crashes the thread with an access violation on Windows. Network
# is off (FEEDBACK_SKIP_STARTUP_TASKS), so both drain in ms.
deadline = time.time() + 10
while time.time() < deadline and (
srv._scan_status.get("running")
or srv._enrich_status.get("running")):
time.sleep(0.05)
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
BASE_MANIFEST = ("# my hand-made pack\n"
"title: Thunderstruck\n"
"artist: AC/DC # the real one\n"
"duration: 292\n"
"arrangements: []\n"
"stems: []\n")
def make_dir_sloppak(server, name, manifest=BASE_MANIFEST):
d = server.DLC_DIR / name
d.mkdir(parents=True)
(d / "manifest.yaml").write_text(manifest, encoding="utf-8")
_put_db(server, name)
return d
def make_zip_sloppak(server, name, manifest=BASE_MANIFEST):
p = server.DLC_DIR / name
with zipfile.ZipFile(p, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("manifest.yaml", manifest)
z.writestr("stems/full.ogg", b"OggS-fake")
_put_db(server, name)
return p
def _put_db(server, name):
server.meta_db.put(name, 0, 0, {
"title": "Thunderstruck", "artist": "AC/DC", "album": "", "year": "",
"duration": 292, "arrangements": [{"name": "Lead", "index": 0}],
})
def seed_match(server, fn, state="matched", **overrides):
"""Seed a confirmed enrichment row as the P8 matcher would have."""
cand = {"recording_id": "12345678-abcd-4ef0-9876-0123456789ab",
"release_id": "rel-1", "artist_id": "art-1",
"artist": "AC/DC", "title": "Thunderstruck",
"album": "The Razors Edge", "year": "1990",
"genres": ["hard rock", "rock"], "isrc": "AUAP09000045"}
cand.update(overrides)
song = server.meta_db.enrichment_song_row(fn)
h = server.meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
server.meta_db.apply_enrichment_match(
fn, h, state, source="text", score=1.0, cand=cand,
candidates=([cand] if state == "review" else None))
# ── preview ───────────────────────────────────────────────────────────────────
def test_preview_offers_only_absent_confirmed_keys(server, client):
make_dir_sloppak(server, "a.sloppak")
seed_match(server, "a.sloppak")
d = client.get("/api/song/a.sloppak/gap-fill").json()
assert d["eligible"] is True
got = {m["key"]: m["value"] for m in d["missing"]}
assert got == {"album": "The Razors Edge", "year": 1990,
"genres": ["hard rock", "rock"],
"mbid": "12345678-abcd-4ef0-9876-0123456789ab",
"isrc": "AUAP09000045"}
def test_preview_excludes_author_set_keys(server, client):
make_dir_sloppak(server, "a.sloppak",
BASE_MANIFEST + "album: Live Bootleg\nyear: 1991\n")
seed_match(server, "a.sloppak")
got = {m["key"] for m in client.get("/api/song/a.sloppak/gap-fill").json()["missing"]}
assert "album" not in got and "year" not in got
assert {"genres", "mbid", "isrc"} <= got
def test_preview_excludes_present_but_empty_keys(server, client):
"""Gap-fill is append-only, so a present-but-empty value (album: '',
year: 0) is NOT a gap the writer can fill appending would duplicate the
key, and the never-clobber guard refuses any present key. The preview must
therefore not offer it (those are the metadata editor's job to re-serialize),
while genuinely-absent keys are still offered."""
make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "album: ''\nyear: 0\n")
seed_match(server, "a.sloppak")
got = {m["key"] for m in client.get("/api/song/a.sloppak/gap-fill").json()["missing"]}
assert "album" not in got and "year" not in got
assert {"genres", "mbid", "isrc"} <= got
def test_write_present_but_empty_key_is_refused_not_500(server, client):
"""The preview↔writer contract must agree: a POST for a present-but-empty
key is turned away with a clean 409 (never offered skipped), never a 500
from the writer's never-clobber guard, and the file is left untouched."""
d = make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "album: ''\nyear: 0\n")
seed_match(server, "a.sloppak")
before = (d / "manifest.yaml").read_text(encoding="utf-8")
r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "year"]})
assert r.status_code == 409
assert sorted(r.json()["skipped"]) == ["album", "year"]
assert (d / "manifest.yaml").read_text(encoding="utf-8") == before
assert not (d / "manifest.yaml.bak").exists() # nothing written → no backup
# A genuinely-absent key alongside the empty ones still writes cleanly.
r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "genres"]})
assert r.status_code == 200
assert r.json() == {"ok": True, "written": {"genres": ["hard rock", "rock"]},
"skipped": ["album"]}
def test_preview_requires_confirmed_match(server, client):
make_dir_sloppak(server, "a.sloppak")
d = client.get("/api/song/a.sloppak/gap-fill").json()
assert d["eligible"] is False and d["reason"] == "no-match"
seed_match(server, "a.sloppak", state="review")
d = client.get("/api/song/a.sloppak/gap-fill").json()
assert d["eligible"] is False and d["reason"] == "review"
# A user-pinned match is confirmed.
seed_match(server, "a.sloppak", state="manual")
assert client.get("/api/song/a.sloppak/gap-fill").json()["eligible"] is True
# ── writing ───────────────────────────────────────────────────────────────────
def test_write_dir_form_appends_and_preserves_author_bytes(server, client):
d = make_dir_sloppak(server, "a.sloppak")
seed_match(server, "a.sloppak")
r = client.post("/api/song/a.sloppak/gap-fill",
json={"keys": ["album", "year", "genres", "mbid", "isrc"]})
assert r.status_code == 200
body = r.json()
assert set(body["written"]) == {"album", "year", "genres", "mbid", "isrc"}
text = (d / "manifest.yaml").read_text(encoding="utf-8")
# The author's original bytes — comments included — survive verbatim as a
# prefix; the additions are appended after them.
assert text.startswith(BASE_MANIFEST)
manifest = yaml.safe_load(text)
assert manifest["album"] == "The Razors Edge"
assert manifest["year"] == 1990
assert manifest["genres"] == ["hard rock", "rock"]
assert manifest["mbid"] == "12345678-abcd-4ef0-9876-0123456789ab"
assert manifest["isrc"] == "AUAP09000045"
# Backup + DB sync (the row must match what the scanner would derive).
assert (d / "manifest.yaml.bak").read_text(encoding="utf-8") == BASE_MANIFEST
row = client.get("/api/song/a.sloppak").json()
assert row["album"] == "The Razors Edge"
assert str(row["year"]) == "1990"
def test_write_zip_form_appends_with_backup(server, client):
p = make_zip_sloppak(server, "a.sloppak")
seed_match(server, "a.sloppak")
r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "mbid"]})
assert r.status_code == 200
with zipfile.ZipFile(p) as z:
text = z.read("manifest.yaml").decode("utf-8")
assert text.startswith(BASE_MANIFEST)
manifest = yaml.safe_load(text)
assert manifest["album"] == "The Razors Edge"
assert manifest["mbid"] == "12345678-abcd-4ef0-9876-0123456789ab"
assert "year" not in manifest # unrequested keys untouched
assert z.read("stems/full.ogg") == b"OggS-fake"
bak = p.with_name(p.name + ".bak")
assert bak.exists()
with zipfile.ZipFile(bak) as z:
assert z.read("manifest.yaml").decode("utf-8") == BASE_MANIFEST
def test_write_never_replaces_author_values(server, client):
d = make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "album: Live Bootleg\n")
seed_match(server, "a.sloppak")
# Requesting a present key: skipped, not replaced; nothing else requested
# → 409 and the file is untouched.
r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album"]})
assert r.status_code == 409
assert r.json()["skipped"] == ["album"]
assert (d / "manifest.yaml").read_text(encoding="utf-8").endswith("album: Live Bootleg\n")
# Mixed request: the gap is written, the author value survives.
r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "year"]})
assert r.status_code == 200
assert r.json() == {"ok": True, "written": {"year": 1990}, "skipped": ["album"]}
manifest = yaml.safe_load((d / "manifest.yaml").read_text(encoding="utf-8"))
assert manifest["album"] == "Live Bootleg"
assert manifest["year"] == 1990
def test_writer_last_line_guard(server):
"""The lib-level never-clobber guard holds even if a caller skips the
proposal check."""
import songmeta
d = make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "album: Kept\n")
with pytest.raises(ValueError):
songmeta.gap_fill_sloppak(d, {"album": "Clobber"})
assert "Kept" in (d / "manifest.yaml").read_text(encoding="utf-8")
def test_write_validates_keys(server, client):
make_dir_sloppak(server, "a.sloppak")
seed_match(server, "a.sloppak")
assert client.post("/api/song/a.sloppak/gap-fill",
json={"keys": ["title"]}).status_code == 400
assert client.post("/api/song/a.sloppak/gap-fill",
json={"keys": []}).status_code == 400
assert client.post("/api/song/a.sloppak/gap-fill", json={}).status_code == 400
def test_demo_mode_blocks_write(tmp_path, monkeypatch, isolate_logging):
"""The middleware turns the write route away before any handler runs —
demo visitors can never rewrite pack files."""
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
r = TestClient(srv.app).post("/api/song/a.sloppak/gap-fill",
json={"keys": ["album"]})
assert r.status_code == 403
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
+80
View File
@@ -454,6 +454,86 @@ def test_convert_file_writes_notation_sidecar_for_keys_only(tmp_path, monkeypatc
assert beats[0]["notes"] == [{"midi": 60}] # 48 + fret 12
# A GP8 grand-staff piano (one Track, two <Staff> entries → two MasterBar/Bars
# columns) followed by a guitar track. The treble stave carries a String=5 note
# that only decodes against the 6-entry treble tuning — under the old
# last-stave-tuning-wins bug it indexed out of range against the 5-entry bass
# tuning and was silently dropped. The guitar's single note sits at column 2,
# so it also exercises the bar-column offset after a multi-stave predecessor.
_GPIF_GRAND_STAFF_PIANO = """
<GPIF>
<Score><Title>T</Title><Artist>A</Artist></Score>
<Tracks>
<Track id="0"><Name>Piano</Name>
<Staves>
<Staff><Properties><Property name="Tuning">
<Pitches>76 71 67 62 57 52</Pitches></Property></Properties></Staff>
<Staff><Properties><Property name="Tuning">
<Pitches>50 45 40 35 30</Pitches></Property></Properties></Staff>
</Staves></Track>
<Track id="1"><Name>Lead Guitar</Name>
<Property name="Tuning"><Pitches>64 59 55 50 45 40</Pitches></Property></Track>
</Tracks>
<MasterBars>
<MasterBar><Time>4/4</Time><Bars>0 1 2</Bars></MasterBar>
</MasterBars>
<Bars>
<Bar id="0"><Voices>0</Voices></Bar>
<Bar id="1"><Voices>1</Voices></Bar>
<Bar id="2"><Voices>2</Voices></Bar>
</Bars>
<Voices>
<Voice id="0"><Beats>b0</Beats></Voice>
<Voice id="1"><Beats>b1</Beats></Voice>
<Voice id="2"><Beats>b2</Beats></Voice>
</Voices>
<Beats>
<Beat id="b0"><Rhythm ref="r0"/><Notes>n0</Notes></Beat>
<Beat id="b1"><Rhythm ref="r0"/><Notes>n1</Notes></Beat>
<Beat id="b2"><Rhythm ref="r0"/><Notes>n2</Notes></Beat>
</Beats>
<Notes>
<Note id="n0">
<Property name="String"><String>5</String></Property>
<Property name="Fret"><Fret>0</Fret></Property></Note>
<Note id="n1">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>0</Fret></Property></Note>
<Note id="n2">
<Property name="String"><String>0</String></Property>
<Property name="Fret"><Fret>3</Fret></Property></Note>
</Notes>
<Rhythms><Rhythm id="r0"><NoteValue>Quarter</NoteValue></Rhythm></Rhythms>
</GPIF>
"""
def test_convert_file_folds_grand_staff_and_offsets_bar_column(tmp_path, monkeypatch):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_GPIF_GRAND_STAFF_PIANO))
out_files = gp2rs_gpx.convert_file(
"dummy.gpx", str(tmp_path), track_indices=[0, 1],
arrangement_names={0: "Keys", 1: "Lead"},
)
keys_xml = next(p for p in out_files if "Keys" in p)
guitar_xml = next(p for p in out_files if "Lead" in p)
def _notes(xml_path):
root = ET.parse(xml_path).getroot()
return [(int(n.get("string")), int(n.get("fret"))) for n in root.iter("note")]
# Both staves are folded into the one keys arrangement (keys encoding =
# midi//24, midi%24): treble E3 (52, the String=5 note the old bug dropped)
# AND bass D3 (50, the stave-1 column).
keys_midi = sorted(s * 24 + f for s, f in _notes(keys_xml))
assert keys_midi == [50, 52]
# The guitar note lives at bar column 2 (after the 2-column piano). Its own
# bar carries fret 3; the bass column it would wrongly read has fret 0, so a
# single fret-3 note proves the multi-stave column offset landed correctly.
guitar = _notes(guitar_xml)
assert len(guitar) == 1 and guitar[0][1] == 3
def test_convert_file_sidecar_failure_does_not_break_conversion(tmp_path, monkeypatch):
monkeypatch.setattr(gp2rs_gpx, "_load_gpif",
lambda _p: ET.fromstring(_GPIF_KEYS_AND_GUITAR))
+200
View File
@@ -0,0 +1,200 @@
"""Tests for P5e: the grouped filter law (chart-intrinsic filters match ANY
member of a work + the display_chart switch), the group-aggregate mastery
sort, the history-sticky auto-pick, and the split annotations."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _put(server, fn, title, artist, arrangements=1, tuning="E Standard",
mtime=0, lyrics=False):
arr = [{"name": "Lead", "index": i} for i in range(arrangements)]
server.meta_db.put(fn, mtime, 0, {
"title": title, "artist": artist, "arrangements": arr,
"tuning_name": tuning, "has_lyrics": lyrics,
})
def _page(client, **params):
return client.get("/api/library", params={"group": 1, **params}).json()
# ── chart-intrinsic filters: match if ANY member matches ─────────────────────
def test_intrinsic_filter_matches_any_member(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=2, tuning="E Standard")
_put(server, "b.archive", "Song", "Artist", arrangements=1, tuning="Drop D")
body = _page(client, tunings="Drop D")
# the work qualifies through member b even though the representative (a,
# most arrangements) is E Standard…
assert body["total"] == 1
row = body["songs"][0]
# …and the ROW stays the representative (sort/cursor identity) while the
# card's display/play facts switch to the matching member.
assert row["filename"] == "a.archive"
assert row["display_chart"]["filename"] == "b.archive"
assert row["display_chart"]["tuning_name"] == "Drop D"
def test_no_display_chart_when_rep_matches(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=2, tuning="Drop D")
_put(server, "b.archive", "Song", "Artist", arrangements=1, tuning="Drop D")
row = _page(client, tunings="Drop D")["songs"][0]
assert row["filename"] == "a.archive"
assert "display_chart" not in row
def test_intrinsic_filter_ungrouped_unchanged(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=2, tuning="E Standard")
_put(server, "b.archive", "Song", "Artist", arrangements=1, tuning="Drop D")
body = client.get("/api/library", params={"tunings": "Drop D"}).json()
assert body["total"] == 1
assert body["songs"][0]["filename"] == "b.archive"
assert "display_chart" not in body["songs"][0]
def test_lyrics_filter_matches_any_member(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=2, lyrics=False)
_put(server, "b.archive", "Song", "Artist", arrangements=1, lyrics=True)
body = _page(client, has_lyrics=1)
assert body["total"] == 1
assert body["songs"][0]["display_chart"]["filename"] == "b.archive"
def test_stats_count_matches_page_under_filter(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=2, tuning="E Standard")
_put(server, "b.archive", "Song", "Artist", arrangements=1, tuning="Drop D")
_put(server, "c.archive", "Other", "Artist", tuning="E Standard") # no Drop D chart
page = _page(client, tunings="Drop D")
stats = client.get("/api/library/stats",
params={"group": 1, "tunings": "Drop D"}).json()
assert page["total"] == 1
assert stats["total_songs"] == page["total"]
# ── work-identity + practice-state stay on the representative ────────────────
def test_practice_state_anchors_on_preferred(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=2) # unplayed
_put(server, "b.archive", "Song", "Artist", arrangements=1)
server.meta_db.record_session("b.archive", 0, score=950, accuracy=0.95)
# pin the unplayed chart as YOUR pick (without a pin the played chart is
# the sticky auto-rep, so the anchor would legitimately read mastered)
wk = server.meta_db.work_key_for("a.archive")
server.meta_db.set_chart_preferred(wk, "a.archive")
# mastered band reads the PREFERRED chart (unplayed) — the work is not
# "mastered" just because an alternate chart is (§7.1 practice-state law)
assert _page(client, mastery="mastered")["total"] == 0
# …but the flat view still finds the mastered chart itself
flat = client.get("/api/library", params={"mastery": "mastered"}).json()
assert [s["filename"] for s in flat["songs"]] == ["b.archive"]
# ── mastery sort aggregates MAX across the group ─────────────────────────────
def test_mastery_sort_aggregates_across_group(client, server):
_put(server, "a1.archive", "One", "Artist", arrangements=2)
_put(server, "a2.archive", "One", "Artist", arrangements=1)
_put(server, "b.archive", "Two", "Artist") # singleton work 2
server.meta_db.record_session("a2.archive", 0, score=950, accuracy=0.95)
server.meta_db.record_session("b.archive", 0, score=500, accuracy=0.5)
# pin the UNPLAYED chart as work 1's keeper — the aggregate must still
# surface the work via its touched member ("a song surfaces on any chart
# you've touched"), even though the preferred chart has no stats
wk = server.meta_db.work_key_for("a1.archive")
server.meta_db.set_chart_preferred(wk, "a1.archive")
desc = [s["filename"] for s in _page(client, sort="mastery-desc")["songs"]]
assert desc == ["a1.archive", "b.archive"] # 0.95 > 0.5, row = keeper
asc = [s["filename"] for s in _page(client, sort="mastery")["songs"]]
assert asc == ["b.archive", "a1.archive"]
# ── history-sticky auto-pick ──────────────────────────────────────────────────
def test_autopick_sticks_with_played_chart(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=1, mtime=1)
server.meta_db.record_session("a.archive", 0, score=500, accuracy=0.5)
# a newer, more complete chart imports — the pick must NOT silently migrate
_put(server, "b.archive", "Song", "Artist", arrangements=3, mtime=2)
assert _page(client)["songs"][0]["filename"] == "a.archive"
def test_autopick_completeness_still_wins_when_unplayed(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=1, mtime=1)
_put(server, "b.archive", "Song", "Artist", arrangements=3, mtime=2)
assert _page(client)["songs"][0]["filename"] == "b.archive"
def test_autopick_one_off_try_does_not_steal_pick(client, server):
# incumbent: practiced repeatedly, minimal chart; a one-off try of a more
# complete alternate must NOT retarget the pick (casual try ≠ adopt)
_put(server, "a.archive", "Song", "Artist", arrangements=1, mtime=1)
for _ in range(3):
server.meta_db.record_session("a.archive", 0, score=500, accuracy=0.5)
_put(server, "b.archive", "Song", "Artist", arrangements=3, mtime=2)
server.meta_db.record_session("b.archive", 0, score=500, accuracy=0.5)
assert _page(client)["songs"][0]["filename"] == "a.archive"
# ── split annotations (the ⋮ "Rejoin other versions" undo) ───────────────────
def test_split_flags_on_rows_and_chart_work(client, server):
_put(server, "a.archive", "Song", "Artist")
_put(server, "b.archive", "Song", "Artist")
client.post("/api/chart/b.archive/split")
rows = {s["filename"]: s for s in _page(client)["songs"]}
assert rows["b.archive"]["is_split"] is True
assert rows["a.archive"]["is_split"] is False
assert client.get("/api/chart/b.archive/work").json()["is_split"] is True
assert client.get("/api/chart/a.archive/work").json()["is_split"] is False
# ── keyset paging stays exact under the member-match predicate ────────────────
def test_grouped_keyset_pagination_with_intrinsic_filter(client, server):
# 5 works; each has an E-Standard rep (2 arrangements) + a Drop-D member,
# so the Drop-D filter admits every work through a NON-representative chart.
for i in range(5):
_put(server, f"w{i}rep.archive", f"Song {i}", f"Artist {i}",
arrangements=2, tuning="E Standard")
_put(server, f"w{i}alt.archive", f"Song {i}", f"Artist {i}",
arrangements=1, tuning="Drop D")
seen, cursor = [], None
for _ in range(10):
# Title sort — the keyset proof needs a sort that still keysets
# (artist sorts page by OFFSET since the title-secondary change).
params = {"group": 1, "tunings": "Drop D", "size": 2, "sort": "title"}
if cursor:
params["after"] = cursor
body = client.get("/api/library", params=params).json()
if not body["songs"]:
break
seen += [s["filename"] for s in body["songs"]]
cursor = body["next_cursor"]
if not cursor:
break
assert seen == [f"w{i}rep.archive" for i in range(5)] # no skip, no dupe
assert all(s.startswith("w") and "rep" in s for s in seen)
+11 -6
View File
@@ -73,7 +73,10 @@ def _walk_offset(client, sort, size, total):
return seen
@pytest.mark.parametrize("sort", ["artist", "artist-desc", "title", "title-desc", "recent"])
# artist/artist-desc left out: their ORDER BY carries a title secondary
# (grid cards read alphabetically within an artist, like the tree), which
# the two-term cursor cannot seek — they page by OFFSET (covered below).
@pytest.mark.parametrize("sort", ["title", "title-desc", "recent"])
def test_keyset_matches_offset_exactly(client, server_mod, sort):
_seed(server_mod, 25)
offset_order = _walk_offset(client, sort, 7, 25)
@@ -87,15 +90,17 @@ def test_stable_tiebreak_on_equal_keys(client, server_mod):
# 25 songs, all the SAME artist → the artist sort is decided entirely by the
# filename tiebreak. Both pagers must still cover all 25 with no dupe.
_seed(server_mod, 25, shared_artist=True)
keyset_order = _walk_keyset(client, "artist", 6, 25)
keyset_order = _walk_offset(client, "artist", 6, 25)
assert len(keyset_order) == 25 and len(set(keyset_order)) == 25
assert keyset_order == sorted(keyset_order) # tiebreak is filename ASC
def test_first_page_has_cursor_and_no_after_is_offset(client, server_mod):
_seed(server_mod, 5)
body = client.get("/api/library", params={"sort": "title", "size": 2}).json()
assert body["next_cursor"] # keyset sort: cursor offered
body = client.get("/api/library", params={"sort": "artist", "size": 2}).json()
assert body["next_cursor"] # cursor offered
assert body["next_cursor"] is None # artist sorts page by OFFSET
assert [s["filename"] for s in body["songs"]] == ["song00.archive", "song01.archive"]
@@ -111,7 +116,7 @@ def test_legacy_dir_desc_keysets_correctly(client, server_mod):
_seed(server_mod, 20)
offset_order, page = [], 0
while True:
body = client.get("/api/library", params={"sort": "artist", "dir": "desc", "size": 6, "page": page}).json()
body = client.get("/api/library", params={"sort": "title", "dir": "desc", "size": 6, "page": page}).json()
if not body["songs"]:
break
offset_order.extend(s["filename"] for s in body["songs"])
@@ -119,7 +124,7 @@ def test_legacy_dir_desc_keysets_correctly(client, server_mod):
keyset, cursor, guard = [], "", 0
while len(keyset) < 20 and guard < 25:
guard += 1
params = {"sort": "artist", "dir": "desc", "size": 6}
params = {"sort": "title", "dir": "desc", "size": 6}
if cursor:
params["after"] = cursor
body = client.get("/api/library", params=params).json()
@@ -131,7 +136,7 @@ def test_legacy_dir_desc_keysets_correctly(client, server_mod):
assert len(set(keyset)) == 20
@pytest.mark.parametrize("sort", ["artist", "artist-desc", "recent"])
@pytest.mark.parametrize("sort", ["recent"])
def test_keyset_handles_null_sort_keys(client, server_mod, sort):
# NULL artist/mtime (corrupt/legacy rows past put()'s '' defaults) sort
# first in ASC / last in DESC; keyset must cover them exactly like OFFSET.
+526
View File
@@ -0,0 +1,526 @@
"""Server-level tests for the P8 MusicBrainz matcher + Match-Review flow.
The HTTP transport is a fake installed over `server._mb_http_get` the ONE
seam enrichment uses to reach the network so nothing here ever opens a
socket. The offline default is itself under test: without explicitly
enabling the network flag, a pass must skip matching entirely (pytest can
never hit MusicBrainz, whatever a test triggers).
"""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
class FakeMB:
"""Canned MusicBrainz: records every call, serves per-path responses."""
def __init__(self):
self.calls = []
self.search_response = {"recordings": []}
self.recording_lookups = {} # mbid → recording doc
self.isrc_lookups = {} # isrc → {"recordings": [...]}
self.raise_transport = False
def __call__(self, path, params):
if self.raise_transport:
raise self._srv.EnrichTransportError("fake network down")
self.calls.append((path, dict(params)))
if path == "recording":
return self.search_response
if path.startswith("recording/"):
return self.recording_lookups.get(path.split("/", 1)[1])
if path.startswith("isrc/"):
return self.isrc_lookups.get(path.split("/", 1)[1])
raise AssertionError(f"unexpected MB path {path!r}")
@property
def search_calls(self):
return [c for c in self.calls if c[0] == "recording"]
@pytest.fixture()
def mb(server, monkeypatch):
"""Install the fake transport AND enable the network flag (the test env
disables it by default see test_offline_default_skips_matching)."""
fake = FakeMB()
fake._srv = server
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def _put(server, fn, title="Thunderstruck (v2)", artist="ACDC", album="",
duration=292, year="1990"):
server.meta_db.put(fn, 0, 0, {
"title": title, "artist": artist, "album": album, "year": year,
"duration": duration, "arrangements": [{"name": "Lead", "index": 0}],
})
def mb_doc(rid="rec-1", title="Thunderstruck", artist="AC/DC", artist_id="art-1",
album="The Razors Edge", date="1990-09-24", length_ms=292000, score=100):
return {
"id": rid, "score": score, "title": title, "length": length_ms,
"isrcs": ["AUAP09000045"],
"artist-credit": [{"name": artist, "artist": {
"id": artist_id, "name": artist, "sort-name": artist}}],
"releases": [{"id": "rel-1", "title": album, "status": "Official",
"date": date, "release-group": {"primary-type": "Album"}}],
"tags": [{"name": "hard rock", "count": 7}],
}
# ── offline safety (the pytest-never-hits-network contract) ──────────────────
def test_offline_default_skips_matching(server, monkeypatch):
"""Under the test env (FEEDBACK_SKIP_STARTUP_TASKS) a pass stamps hashes
but never matches even with a transport installed."""
fake = FakeMB()
fake._srv = server
monkeypatch.setattr(server, "_mb_http_get", fake)
_put(server, "a.sloppak")
server._background_enrich()
assert fake.calls == []
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "unscanned"
def test_real_transport_refuses_when_offline(server):
"""_mb_http_get itself raises (before any socket) when the network is
disabled defence in depth under pytest."""
with pytest.raises(server.EnrichTransportError):
server._mb_http_get("recording", {"query": "x"})
def test_transport_error_pauses_pass_without_burning_attempts(server, mb):
_put(server, "a.sloppak")
_put(server, "b.sloppak", title="Other Song")
mb.raise_transport = True
server._background_enrich()
for fn in ("a.sloppak", "b.sloppak"):
row = server.meta_db.get_enrichment(fn)
assert row["match_state"] == "unscanned"
assert row["attempts"] == 0
# Network comes back → the next kick matches both.
mb.raise_transport = False
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
# ── text tiers ────────────────────────────────────────────────────────────────
def test_high_confidence_auto_matches_and_settles(server, mb):
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "text"
assert row["match_score"] >= 0.95
assert row["mb_recording_id"] == "rec-1"
assert row["canon_artist"] == "AC/DC"
assert row["canon_title"] == "Thunderstruck"
assert row["canon_album"] == "The Razors Edge"
assert row["canon_year"] == "1990"
assert row["genres"] == ["hard rock"]
# Settled: another pass makes NO further network calls…
n = len(mb.calls)
server._background_enrich()
assert len(mb.calls) == n
# …until the identity changes, which re-matches.
_put(server, "a.sloppak", title="Back in Black")
mb.search_response = {"recordings": [mb_doc(rid="rec-2", title="Back in Black",
album="Back in Black", date="1980-07-25")]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["mb_recording_id"] == "rec-2"
def test_medium_confidence_goes_to_review_not_canonical(server, mb):
# Partial artist agreement → medium confidence.
_put(server, "a.sloppak", artist="AC/DC ft Nobody")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "review"
assert row["match_source"] == "text"
# Review before auto-canonicalize: NO canonical values written yet.
assert row["canon_artist"] is None
assert row["mb_recording_id"] is None
assert row["candidates"] and row["candidates"][0]["recording_id"] == "rec-1"
# A review row is settled while its identity is unchanged — no re-query.
n = len(mb.calls)
server._background_enrich()
assert len(mb.calls) == n
def test_low_confidence_fails_with_backoff(server, mb):
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc(rid="rec-x", title="Sunrise",
artist="Norah Jones")]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "failed"
assert row["attempts"] == 1
assert row["last_attempt_at"] is not None
# Immediately after, the backoff hasn't elapsed → no retry, no network.
n = len(mb.calls)
server._background_enrich()
assert len(mb.calls) == n
assert server.meta_db.get_enrichment("a.sloppak")["attempts"] == 1
# Rewind the clock two hours → eligible again, attempts increments.
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET last_attempt_at = last_attempt_at - 7200")
server.meta_db.conn.commit()
server._background_enrich()
assert len(mb.calls) == n + 1
assert server.meta_db.get_enrichment("a.sloppak")["attempts"] == 2
def test_no_results_fails(server, mb):
_put(server, "a.sloppak")
mb.search_response = {"recordings": []}
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "failed"
# ── the content-hash match cache ──────────────────────────────────────────────
def test_cache_hit_copies_match_without_network(server, mb):
_put(server, "a.sloppak")
_put(server, "b.sloppak") # identical identity → same content_hash
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
assert len(mb.search_calls) == 1 # ONE search covered both charts
a = server.meta_db.get_enrichment("a.sloppak")
b = server.meta_db.get_enrichment("b.sloppak")
assert a["match_state"] == b["match_state"] == "matched"
assert {a["match_source"], b["match_source"]} == {"text", "cache"}
assert a["mb_recording_id"] == b["mb_recording_id"] == "rec-1"
# ── exact keys from the manifest (tier 0 / tier 1) ────────────────────────────
def _write_sloppak_manifest(server, name, extra_yaml=""):
d = server.DLC_DIR / name
d.mkdir(parents=True)
(d / "manifest.yaml").write_text(
"title: Thunderstruck\nartist: AC/DC\nduration: 292\n"
"arrangements: []\nstems: []\n" + extra_yaml,
encoding="utf-8")
def test_manifest_mbid_tier0(server, mb):
mbid = "12345678-abcd-4ef0-9876-0123456789ab"
_write_sloppak_manifest(server, "a.sloppak", f"mbid: {mbid}\n")
_put(server, "a.sloppak")
mb.recording_lookups[mbid] = mb_doc(rid=mbid)
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "mbid"
assert row["match_score"] == 1.0
assert row["mb_recording_id"] == mbid
assert mb.search_calls == [] # trusted key — no text search
def test_manifest_isrc_tier1(server, mb):
_write_sloppak_manifest(server, "a.sloppak", "isrc: AUAP09000045\n")
_put(server, "a.sloppak")
mb.isrc_lookups["AUAP09000045"] = {"recordings": [mb_doc()]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "isrc"
assert mb.search_calls == []
def test_manifest_isrc_display_hyphens_stripped(server, mb):
"""Spec 1.14.0: the hyphenated display form (AU-AP0-90-00045) is
presentation only the reader strips separators, so a hand-authored
manifest still hits the exact tier with the bare 12-char code."""
_write_sloppak_manifest(server, "a.sloppak", "isrc: AU-AP0-90-00045\n")
_put(server, "a.sloppak")
mb.isrc_lookups["AUAP09000045"] = {"recordings": [mb_doc()]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "isrc"
assert mb.search_calls == []
def test_bad_manifest_mbid_falls_through_to_text(server, mb):
mbid = "12345678-abcd-4ef0-9876-0123456789ab"
_write_sloppak_manifest(server, "a.sloppak", f"mbid: {mbid}\n")
_put(server, "a.sloppak")
mb.recording_lookups.clear() # lookup 404s (typo'd manifest)
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "text"
# ── manual is sacred ──────────────────────────────────────────────────────────
def test_manual_never_overwritten_by_matcher(server, mb):
_put(server, "a.sloppak")
server.meta_db.set_enrichment_manual(
"a.sloppak", {"recording_id": "user-pick", "title": "Thunderstruck",
"artist": "AC/DC"}, source="search")
mb.search_response = {"recordings": [mb_doc(rid="machine-pick")]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "manual"
assert row["mb_recording_id"] == "user-pick"
# The writer refuses machine writes onto manual outright.
ok = server.meta_db.apply_enrichment_match(
"a.sloppak", row["content_hash"], "matched", source="text", score=1.0,
cand={"recording_id": "machine-pick", "title": "X"})
assert ok is False
# ── review routes ─────────────────────────────────────────────────────────────
def _seed_review(server, mb, fn="a.sloppak", title="Thunderstruck (v2)"):
# Distinct raw titles give distinct content hashes (else the match cache
# legitimately copies an earlier row instead of running the text tiers).
_put(server, fn, title=title, artist="AC/DC ft Nobody")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
assert server.meta_db.get_enrichment(fn)["match_state"] == "review"
def test_review_queue_route(server, mb, client):
_seed_review(server, mb)
body = client.get("/api/enrichment/review").json()
assert body["total_review"] == 1
assert body["songs"][0]["filename"] == "a.sloppak"
assert body["songs"][0]["candidates"][0]["recording_id"] == "rec-1"
def test_review_accept_route(server, mb, client):
_seed_review(server, mb)
r = client.post("/api/enrichment/review/a.sloppak/accept",
json={"recording_id": "rec-1"})
assert r.status_code == 200
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "manual"
assert row["match_source"] == "review"
assert row["canon_artist"] == "AC/DC"
assert client.get("/api/enrichment/review").json()["total_review"] == 0
# Accepting a candidate that isn't in the stored list → 404.
_seed_review(server, mb, fn="b.sloppak", title="Thunderstruck (Live)")
r = client.post("/api/enrichment/review/b.sloppak/accept",
json={"recording_id": "nope"})
assert r.status_code == 404
def test_review_reject_route_never_retries(server, mb, client):
_seed_review(server, mb)
r = client.post("/api/enrichment/review/a.sloppak/reject")
assert r.status_code == 200
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "failed"
assert row["match_source"] == "rejected"
# Rejected rows are excluded from the retry backoff forever…
n = len(mb.calls)
server._background_enrich()
assert len(mb.calls) == n
# …but an identity edit re-queues (the user fixed the metadata).
_put(server, "a.sloppak", artist="AC/DC")
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
# Rejecting a manual row is refused.
r = client.post("/api/enrichment/review/a.sloppak/reject")
assert r.status_code == 200 # matched → rejectable
client.post("/api/enrichment/review/a.sloppak/pick",
json={"candidate": {"recording_id": "rec-9", "title": "T"}})
r = client.post("/api/enrichment/review/a.sloppak/reject")
assert r.status_code == 404
def test_pick_route_fix_match(server, mb, client):
_put(server, "a.sloppak")
r = client.post("/api/enrichment/review/a.sloppak/pick", json={"candidate": {
"recording_id": "rec-77", "title": "Thunderstruck", "artist": "AC/DC",
"album": "The Razors Edge", "year": "1990", "genres": ["hard rock"],
"junk_key": "dropped"}})
assert r.status_code == 200
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "manual"
assert row["match_source"] == "search"
assert row["mb_recording_id"] == "rec-77"
# Malformed candidate → 400; unknown song → 404.
assert client.post("/api/enrichment/review/a.sloppak/pick",
json={"candidate": {"title": "no id"}}).status_code == 400
assert client.post("/api/enrichment/review/ghost.sloppak/pick",
json={"candidate": {"recording_id": "x", "title": "t"}}
).status_code == 404
def test_search_proxy(server, mb, client, monkeypatch):
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
assert client.get("/api/enrichment/search").status_code == 400
body = client.get("/api/enrichment/search",
params={"title": "Thunderstruck", "artist": "AC/DC",
"filename": "a.sloppak"}).json()
assert body["candidates"][0]["recording_id"] == "rec-1"
assert body["candidates"][0]["score"] > 0.9
# Transport failure surfaces as 503, not a 500.
def _down(path, params):
raise server.EnrichTransportError("down")
monkeypatch.setattr(server, "_mb_http_get", _down)
r = client.get("/api/enrichment/search", params={"title": "x"})
assert r.status_code == 503
# ── the match facet on /api/library + /api/library/stats ─────────────────────
def test_match_facet_filters_grid_and_stats(server, mb, client, monkeypatch):
_put(server, "auto.sloppak") # → matched
_put(server, "rev.sloppak", title="Revsong", artist="AC/DC ft Nobody") # → review
_put(server, "fail.sloppak", title="Failsong", artist="Zzz") # → failed
_put(server, "pend.sloppak", title="Pendsong", artist="Yyy") # stays unscanned
def _routed(path, params):
q = params.get("query", "")
if "thunderstruck" in q:
return {"recordings": [mb_doc()]}
if "revsong" in q:
return {"recordings": [mb_doc(rid="rec-r", title="Revsong")]}
return {"recordings": []}
monkeypatch.setattr(server, "_mb_http_get", _routed)
server._background_enrich()
# Pendsong got failed by the pass (no results); reset it to unscanned to
# represent the not-yet-scanned band.
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state='unscanned', attempts=0, "
"last_attempt_at=NULL WHERE filename='pend.sloppak'")
server.meta_db.conn.commit()
def names(match):
return sorted(s["filename"] for s in client.get(
"/api/library", params={"match": match, "size": 50}).json()["songs"])
assert names("review") == ["rev.sloppak"]
assert names("matched") == ["auto.sloppak"]
assert names("unmatched") == ["fail.sloppak"]
assert names("pending") == ["pend.sloppak"]
assert names("review,matched") == ["auto.sloppak", "rev.sloppak"]
# Stats agree with the grid (the rail's lockstep contract).
total = client.get("/api/library/stats",
params={"match": "review"}).json()["total_songs"]
assert total == 1
# Unknown values are ignored → unfiltered.
assert len(names("bogus")) == 4
def test_status_counts_by_state(server, mb, client):
_seed_review(server, mb)
states = client.get("/api/enrichment/status").json()["states"]
assert states.get("review") == 1
# ── settings: enable toggle + auto-apply confidence ───────────────────────────
def test_auto_threshold_setting_moves_the_auto_review_boundary(server, mb, client):
# artist exact (1.0) + title 4/5 token overlap (0.8) and NO year/duration
# corroboration → combined exactly 0.90.
mb.search_response = {"recordings": [
mb_doc(title="Highway Hell", artist="AC/DC", date="", length_ms=None)]}
client.post("/api/settings", json={"enrich_auto_threshold": 0.95})
_put(server, "a.sloppak", title="Highway to Hell", artist="AC/DC",
year="", duration=0)
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "review"
# Lower the bar to the default 0.90 → an identity edit re-queues, and the
# same 0.90-scored candidate now auto-applies.
client.post("/api/settings", json={"enrich_auto_threshold": 0.9})
_put(server, "a.sloppak", title="Highway to Hell", artist="AC/DC",
year="", duration=0, album="Different Album")
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert abs(row["match_score"] - 0.9) < 1e-6
def test_enrich_enabled_setting_gates_background_matching(server, mb, client):
client.post("/api/settings", json={"enrich_enabled": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
assert mb.calls == []
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "unscanned"
# Manual search/fix stays available while the background matcher is off.
r = client.get("/api/enrichment/search", params={"title": "Thunderstruck"})
assert r.status_code == 200
# Re-enable → the next pass matches.
client.post("/api/settings", json={"enrich_enabled": True})
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
def test_settings_validation(server, client):
assert "error" in client.post(
"/api/settings", json={"enrich_enabled": "yes"}).json()
assert "error" in client.post(
"/api/settings", json={"enrich_auto_threshold": "high"}).json()
assert "error" in client.post(
"/api/settings", json={"enrich_auto_threshold": 2.5}).json()
ok = client.post("/api/settings", json={"enrich_auto_threshold": 1.01}).json()
assert "error" not in ok
assert client.get("/api/settings").json()["enrich_auto_threshold"] == 1.01
def test_kick_route(server, mb, client):
import time as _t
body = client.post("/api/enrichment/kick").json()
assert "started" in body
# Let the kicked pass settle so its daemon thread can't bleed into the
# fixture teardown (the DB connection closes there).
for _ in range(200):
if not client.get("/api/enrichment/status").json()["running"]:
break
_t.sleep(0.02)
def test_review_queue_orders_missing_data_first(server, mb, client):
# Complete chart first alphabetically, incomplete second — the queue must
# surface the incomplete (missing album + year) one first anyway.
_seed_review(server, mb, fn="aa.sloppak", title="Thunderstruck (v2)")
_put(server, "zz.sloppak", title="Thunderstruck (Live)",
artist="AC/DC ft Nobody", album="", year="")
server._background_enrich()
assert server.meta_db.get_enrichment("zz.sloppak")["match_state"] == "review"
songs = client.get("/api/enrichment/review").json()["songs"]
assert [s["filename"] for s in songs] == ["zz.sloppak", "aa.sloppak"]
+227
View File
@@ -0,0 +1,227 @@
"""Unit tests for lib/mb_match.py — the pure text-matching engine (P8).
No network, no database, no server import: denoise/tokenize, similarity,
scoring + tier classification, Lucene query building, and MusicBrainz
response parsing are all exercised as plain functions.
"""
import mb_match as m
# ── denoise / tokenize ────────────────────────────────────────────────────────
def test_denoise_lowercases_and_strips_punct_and_diacritics():
assert m.denoise("Motörhead") == "motorhead"
assert m.denoise("Beyoncé!!") == "beyonce"
assert m.denoise("Guns N' Roses") == "guns n roses"
assert m.denoise(" Weird spacing ") == "weird spacing"
def test_denoise_strips_noise_parentheticals():
# The design's explicit list: author suffixes + (440Hz)/(Live)/(No Lead)/(v2).
assert m.denoise("Thunderstruck (440Hz)") == "thunderstruck"
assert m.denoise("Thunderstruck (Live)") == "thunderstruck"
assert m.denoise("Thunderstruck (No Lead)") == "thunderstruck"
assert m.denoise("Thunderstruck (v2)") == "thunderstruck"
assert m.denoise("Thunderstruck [Remastered 2012]") == "thunderstruck"
assert m.denoise("One (Live at Wembley)") == "one"
def test_denoise_strips_author_credits():
assert m.denoise("Back in Black (by SomeCharter)") == "back in black"
assert m.denoise("Back in Black (charted by X99)") == "back in black"
assert m.denoise("Back in Black - by SomeCharter") == "back in black"
def test_denoise_keeps_meaningful_parentheticals():
# A parenthetical with no noise term survives (both sides get the same
# treatment, so symmetric content still matches).
assert m.denoise("Doin' It (All for My Baby)") == "doin it all for my baby"
def test_denoise_leading_the_is_artist_only():
assert m.denoise("The Beatles", strip_leading_the=True) == "beatles"
# Titles keep their "The" — never strip it there.
assert m.denoise("The Trooper") == "the trooper"
def test_ampersand_folds_to_and():
assert m.similarity("Angus & Julia Stone", "Angus and Julia Stone", artist=True) == 1.0
# ── similarity ────────────────────────────────────────────────────────────────
def test_similarity_exact_and_empty():
assert m.similarity("Back in Black", "Back In Black!") == 1.0
assert m.similarity("", "Anything") == 0.0
assert m.similarity(None, None) == 0.0
def test_similarity_folds_spelling_drift_via_compaction():
# The headline case: ACDC / AC DC / AC/DC all name the same artist.
assert m.similarity("ACDC", "AC/DC", artist=True) == 1.0
assert m.similarity("AC DC", "ACDC", artist=True) == 1.0
assert m.similarity("Greenday", "Green Day", artist=True) == 1.0
def test_similarity_partial_overlap():
s = m.similarity("Highway to Hell", "Highway Hell")
assert 0.7 < s < 1.0
assert m.similarity("Back in Black", "Paint It Black") < 0.5
# ── scoring + tiers ───────────────────────────────────────────────────────────
SONG = {"artist": "ACDC", "title": "Thunderstruck (v2)", "album": "The Razors Edge",
"year": "1990", "duration": 292}
def test_score_exact_match_is_high():
cand = {"artist": "AC/DC", "title": "Thunderstruck", "year": "1990", "duration": 292}
s = m.score_candidate(SONG, cand)
assert s == 1.0
assert m.classify(SONG, cand, s) == "auto"
def test_score_cover_never_auto():
# Perfect title, wrong artist (a cover) — must not auto-match.
cand = {"artist": "Some Cover Band", "title": "Thunderstruck"}
s = m.score_candidate(SONG, cand)
assert m.classify(SONG, cand, s) != "auto"
def test_missing_artist_caps_at_review():
song = {"artist": "", "title": "Thunderstruck", "duration": 292}
cand = {"artist": "AC/DC", "title": "Thunderstruck", "duration": 292}
s = m.score_candidate(song, cand)
# artist half scores 0 → combined ≤ 0.55 + bonuses → review at best.
assert m.classify(song, cand, s) != "auto"
def test_year_and_duration_corroborate():
# Fuzzy title so the base sits below the 1.0 cap and bonuses are visible.
base = {"artist": "AC/DC", "title": "Thunderstruck Thunder"}
plain = m.score_candidate(SONG, base)
with_year = m.score_candidate(SONG, dict(base, year="1990"))
with_dur = m.score_candidate(SONG, dict(base, duration=290))
assert with_year > plain
assert with_dur > plain
def test_fuzzy_title_with_corroboration_lands_review_or_auto():
cand = {"artist": "AC/DC", "title": "Thunderstruck Thunder"}
s = m.score_candidate(SONG, cand)
assert m.classify(SONG, cand, s) in ("review", "auto")
def test_unrelated_is_none():
cand = {"artist": "Norah Jones", "title": "Sunrise"}
s = m.score_candidate(SONG, cand)
assert m.classify(SONG, cand, s) == "none"
def test_classify_auto_min_override():
# The host's "auto-apply confidence" setting: a perfect match autos at
# any real threshold, and "Always review" (>1.0) sends even it to review.
cand = {"artist": "AC/DC", "title": "Thunderstruck", "year": "1990", "duration": 292}
s = m.score_candidate(SONG, cand)
assert s == 1.0
assert m.classify(SONG, cand, s, auto_min=0.9) == "auto"
assert m.classify(SONG, cand, s, auto_min=1.01) == "review"
# The per-field floors are independent of the threshold: a wrong-artist
# cover stays non-auto even at a permissive auto_min.
cover = {"artist": "Some Cover Band", "title": "Thunderstruck"}
cs = m.score_candidate(SONG, cover)
assert m.classify(SONG, cover, cs, auto_min=0.5) != "auto"
def test_rank_candidates_orders_by_our_score():
cands = [
{"recording_id": "b", "artist": "Someone Else", "title": "Thunderstruck", "mb_score": 100},
{"recording_id": "a", "artist": "AC/DC", "title": "Thunderstruck", "mb_score": 90},
]
ranked = m.rank_candidates(SONG, cands)
assert [c["recording_id"] for c in ranked] == ["a", "b"]
assert all("score" in c for c in ranked)
# ── query building ────────────────────────────────────────────────────────────
def test_build_recording_query_denoises_and_quotes():
q = m.build_recording_query("ACDC", 'Thunderstruck (v2)')
assert q == 'recording:"thunderstruck" AND artist:"acdc"'
def test_build_recording_query_escapes_and_handles_missing_artist():
q = m.build_recording_query("", 'Say "Hello"')
# Quotes are punct-stripped by denoise, so nothing to escape here — but
# the artist clause must be absent entirely.
assert q.startswith('recording:"')
assert "artist:" not in q
# ── MusicBrainz response parsing ──────────────────────────────────────────────
MB_DOC = {
"id": "rec-123",
"score": 98,
"title": "Thunderstruck",
"length": 292773,
"isrcs": ["AUAP09000045"],
"artist-credit": [
{"name": "AC/DC", "joinphrase": "",
"artist": {"id": "art-1", "name": "AC/DC", "sort-name": "AC/DC"}},
],
"releases": [
{"id": "rel-compilation", "title": "Greatest Hits", "status": "Official",
"date": "2005-01-01", "release-group": {"primary-type": "Compilation"}},
{"id": "rel-album", "title": "The Razors Edge", "status": "Official",
"date": "1990-09-24", "release-group": {"primary-type": "Album"}},
{"id": "rel-boot", "title": "Bootleg", "status": "Bootleg",
"date": "1989-01-01", "release-group": {"primary-type": "Album"}},
],
"tags": [{"name": "hard rock", "count": 10}, {"name": "rock", "count": 4}],
}
def test_parse_recording_doc_normalizes():
c = m.parse_recording_doc(MB_DOC)
assert c["recording_id"] == "rec-123"
assert c["title"] == "Thunderstruck"
assert c["artist"] == "AC/DC"
assert c["artist_id"] == "art-1"
# Official Album beats the compilation and the bootleg.
assert c["album"] == "The Razors Edge"
assert c["release_id"] == "rel-album"
assert c["year"] == "1990"
assert c["duration"] == 293
assert c["isrc"] == "AUAP09000045"
assert c["genres"] == ["hard rock", "rock"]
assert c["mb_score"] == 98
def test_parse_recording_doc_joined_artist_credit():
doc = dict(MB_DOC)
doc["artist-credit"] = [
{"name": "Queen", "joinphrase": " & ",
"artist": {"id": "q", "name": "Queen", "sort-name": "Queen"}},
{"name": "David Bowie",
"artist": {"id": "b", "name": "David Bowie", "sort-name": "Bowie, David"}},
]
c = m.parse_recording_doc(doc)
assert c["artist"] == "Queen & David Bowie"
assert c["artist_id"] == "q"
def test_parse_recording_doc_rejects_malformed():
assert m.parse_recording_doc({}) is None
assert m.parse_recording_doc({"id": "x"}) is None
assert m.parse_recording_doc(None) is None
def test_parse_search_response():
body = {"recordings": [MB_DOC, {"bogus": True}]}
cands = m.parse_search_response(body)
assert len(cands) == 1
assert m.parse_search_response({}) == []
assert m.parse_search_response(None) == []
+20
View File
@@ -85,6 +85,26 @@ def test_split_hands_narrow_group_goes_by_mean_vs_middle_c():
assert "rh" in high and "lh" not in high # mean ~65.5 ≥ 60
def test_split_hands_straddling_middle_c_splits_at_middle_c():
# [G2, E3, C4] = [43, 52, 60]: largest gap is G2→E3 (9) but the musically
# correct split is E3|C4. Middle-C boundary → lh=[G2,E3], rh=[C4].
notes = [{"t": 0.0, "midi": m, "sus": 0} for m in (43, 52, 60)]
hands = nl.split_hands(notes)
assert sorted(n["midi"] for n in hands["lh"]) == [43, 52]
assert [n["midi"] for n in hands["rh"]] == [60]
def test_split_hands_middle_c_split_falls_back_when_it_makes_unplayable_hand():
# Em7-shape RH voicing over a low bass note: [E2, B3, D4, G4] = [40,59,62,67].
# A hard middle-C split would put E2+B3 in the LH — a 19-semitone span that
# re-violates the 12-semitone threshold. Must fall back to the largest gap,
# isolating E2 in the LH and keeping the treble voicing in the RH.
notes = [{"t": 0.0, "midi": m, "sus": 0} for m in (40, 59, 62, 67)]
hands = nl.split_hands(notes)
assert [n["midi"] for n in hands["lh"]] == [40]
assert sorted(n["midi"] for n in hands["rh"]) == [59, 62, 67]
# ── Timing ───────────────────────────────────────────────────────────────────
def test_downbeat_times_filters_non_downbeats_and_sorts():
+361
View File
@@ -0,0 +1,361 @@
"""Tests for the R4b pack-field overwrite — the §7-AMENDMENT shape made
executable: per-field confirmation with values only from a match the user
EXPLICITLY confirmed (`manual` an automatic match may gap-fill but never
replace), a default-OFF Settings gate (allow_pack_overwrite), identity keys
(mbid/isrc) never overwritable, receipts in write_log (old/new/source/score,
pruned at 5000 rows), and .bak = the pristine author original with a working
Revert that preserves the backup.
Reuses the gap-fill fixtures/helpers (tests/test_gap_fill.py); no network
anywhere matches are seeded straight into the enrichment cache.
"""
import importlib
import sys
import zipfile
import yaml
from fastapi.testclient import TestClient
from tests.test_gap_fill import ( # noqa: F401 (server/client fixtures)
BASE_MANIFEST,
client,
make_dir_sloppak,
make_zip_sloppak,
seed_match,
server,
)
# Author-set values that all DIFFER from the seeded match (artist/album/year/
# genres), plus a title equal to the match (equal values are never offered).
DIFF_MANIFEST = ("# my hand-made pack\n"
"title: Thunderstruck\n"
"artist: ACDC # typo the match fixes\n"
"album: Razors Edge\n"
"year: 1991\n"
"genres:\n"
"- Rock\n"
"duration: 292\n"
"arrangements: []\n"
"stems: []\n")
def enable_overwrite(client):
r = client.post("/api/settings", json={"allow_pack_overwrite": True})
assert r.status_code == 200
# ── differs (preview) ─────────────────────────────────────────────────────────
def test_differs_only_for_manual_rows(server, client):
"""The librarian rule: an automatic match may gap-fill absent keys but is
not authority to replace author bytes differs is empty until the user
pins the match."""
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
seed_match(server, "a.sloppak", state="matched")
d = client.get("/api/song/a.sloppak/gap-fill").json()
assert d["differs"] == []
# The same row, user-pinned → the differences surface.
seed_match(server, "a.sloppak", state="manual")
d = client.get("/api/song/a.sloppak/gap-fill").json()
got = {x["key"]: x for x in d["differs"]}
assert set(got) == {"artist", "album", "year", "genres"}
assert got["artist"]["current"] == "ACDC" and got["artist"]["proposed"] == "AC/DC"
assert got["album"]["current"] == "Razors Edge"
assert got["album"]["proposed"] == "The Razors Edge"
assert got["year"]["current"] == "1991" and got["year"]["proposed"] == 1990
assert got["genres"]["current"] == ["Rock"]
assert got["genres"]["proposed"] == ["hard rock", "rock"]
def test_differs_excludes_identity_keys_and_equal_values(server, client):
"""mbid/isrc present in the file AND different from the match are still
never offered (identity changes only via explicit re-match); a value equal
to the match isn't a difference; an ABSENT key is a gap (missing), not a
differ."""
make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST +
"mbid: 00000000-0000-4000-8000-000000000000\n"
"isrc: USZZZ0000001\n")
seed_match(server, "a.sloppak", state="manual")
d = client.get("/api/song/a.sloppak/gap-fill").json()
assert d["differs"] == [] # title/artist equal; mbid/isrc barred
assert {m["key"] for m in d["missing"]} == {"album", "year", "genres"}
def test_preview_reports_gate_state_and_backup(server, client):
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
seed_match(server, "a.sloppak", state="manual")
d = client.get("/api/song/a.sloppak/gap-fill").json()
assert d["overwrite_allowed"] is False # default OFF
assert d["has_backup"] is False # nothing written yet
enable_overwrite(client)
assert client.get("/api/song/a.sloppak/gap-fill").json()["overwrite_allowed"] is True
# ── refusals ──────────────────────────────────────────────────────────────────
def test_overwrite_refused_when_setting_off(server, client):
"""The gate refuses the WHOLE request before anything is written."""
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
seed_match(server, "a.sloppak", state="manual")
before = (d / "manifest.yaml").read_text(encoding="utf-8")
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["artist"]})
assert r.status_code == 409
assert (d / "manifest.yaml").read_text(encoding="utf-8") == before
assert not (d / "manifest.yaml.bak").exists()
def test_overwrite_refused_when_not_manual(server, client):
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
seed_match(server, "a.sloppak", state="matched")
enable_overwrite(client)
before = (d / "manifest.yaml").read_text(encoding="utf-8")
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["artist"]})
assert r.status_code == 409
assert r.json()["skipped"] == ["artist"]
assert (d / "manifest.yaml").read_text(encoding="utf-8") == before
def test_overwrite_validates_keys(server, client):
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
seed_match(server, "a.sloppak", state="manual")
enable_overwrite(client)
# Identity keys and unknown keys are turned away wholesale (400).
for bad in (["mbid"], ["isrc"], ["nope"]):
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": bad})
assert r.status_code == 400
# Both lists empty is still a 400.
assert client.post("/api/song/a.sloppak/gap-fill",
json={"keys": [], "overwrite_keys": []}).status_code == 400
def test_overwrite_equal_value_is_skipped(server, client):
"""A requested key whose value already equals the match is not in differs
skipped, and with nothing else to write the request 409s untouched."""
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
seed_match(server, "a.sloppak", state="manual")
enable_overwrite(client)
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["title"]})
assert r.status_code == 409
assert r.json()["skipped"] == ["title"]
assert (d / "manifest.yaml").read_text(encoding="utf-8") == DIFF_MANIFEST
# ── happy path ────────────────────────────────────────────────────────────────
def test_overwrite_dir_form_replaces_and_keeps_pristine_bak(server, client):
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
seed_match(server, "a.sloppak", state="manual")
enable_overwrite(client)
r = client.post("/api/song/a.sloppak/gap-fill",
json={"overwrite_keys": ["artist", "genres"]})
assert r.status_code == 200
body = r.json()
assert body["overwritten"] == {
"artist": {"old": "ACDC", "new": "AC/DC"},
"genres": {"old": ["Rock"], "new": ["hard rock", "rock"]},
}
assert body["written"] == {}
manifest = yaml.safe_load((d / "manifest.yaml").read_text(encoding="utf-8"))
assert manifest["artist"] == "AC/DC"
assert manifest["genres"] == ["hard rock", "rock"]
assert manifest["album"] == "Razors Edge" # unrequested keys untouched
assert manifest["year"] == 1991
# The backup is the author's pristine original…
assert (d / "manifest.yaml.bak").read_text(encoding="utf-8") == DIFF_MANIFEST
# …and a SECOND write never clobbers it.
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["album"]})
assert r.status_code == 200
assert (d / "manifest.yaml.bak").read_text(encoding="utf-8") == DIFF_MANIFEST
manifest = yaml.safe_load((d / "manifest.yaml").read_text(encoding="utf-8"))
assert manifest["album"] == "The Razors Edge"
assert manifest["artist"] == "AC/DC" # the first write survives
# DB sync: the songs row reflects the replaced values.
row = client.get("/api/song/a.sloppak").json()
assert row["artist"] == "AC/DC"
assert row["album"] == "The Razors Edge"
def test_gap_fill_and_overwrite_in_one_request(server, client):
"""`keys` keeps working unchanged alongside `overwrite_keys`: absent keys
append (author bytes preserved into the one pristine backup), the differing
key is replaced."""
d = make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "year: 1991\n")
seed_match(server, "a.sloppak", state="manual")
enable_overwrite(client)
r = client.post("/api/song/a.sloppak/gap-fill",
json={"keys": ["album", "mbid"], "overwrite_keys": ["year"]})
assert r.status_code == 200
body = r.json()
assert body["written"] == {"album": "The Razors Edge",
"mbid": "12345678-abcd-4ef0-9876-0123456789ab"}
assert body["overwritten"] == {"year": {"old": "1991", "new": 1990}}
manifest = yaml.safe_load((d / "manifest.yaml").read_text(encoding="utf-8"))
assert manifest["album"] == "The Razors Edge"
assert manifest["mbid"] == "12345678-abcd-4ef0-9876-0123456789ab"
assert manifest["year"] == 1990
# One request, one pristine backup: the pre-request original bytes.
assert ((d / "manifest.yaml.bak").read_text(encoding="utf-8")
== BASE_MANIFEST + "year: 1991\n")
def test_zip_form_overwrite(server, client):
p = make_zip_sloppak(server, "a.sloppak", DIFF_MANIFEST)
seed_match(server, "a.sloppak", state="manual")
enable_overwrite(client)
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["artist"]})
assert r.status_code == 200
with zipfile.ZipFile(p) as z:
manifest = yaml.safe_load(z.read("manifest.yaml"))
assert manifest["artist"] == "AC/DC"
assert z.read("stems/full.ogg") == b"OggS-fake" # pack intact
bak = p.with_name(p.name + ".bak")
with zipfile.ZipFile(bak) as z:
assert z.read("manifest.yaml").decode("utf-8") == DIFF_MANIFEST
# ── write_log (provenance receipts) ───────────────────────────────────────────
def test_write_log_records_old_and_new(server, client):
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
seed_match(server, "a.sloppak", state="manual")
enable_overwrite(client)
r = client.post("/api/song/a.sloppak/gap-fill",
json={"overwrite_keys": ["artist", "year"]})
assert r.status_code == 200
rows = client.get("/api/song/a.sloppak/write-log").json()["rows"]
by_key = {x["key"]: x for x in rows}
assert by_key["artist"]["old_value"] == "ACDC"
assert by_key["artist"]["new_value"] == "AC/DC"
assert by_key["year"]["old_value"] == "1991"
assert by_key["year"]["new_value"] == "1990"
for x in rows:
assert x["source"] == "text" and x["score"] == 1.0 and x["ts"]
def test_write_log_records_gap_fills_too(server, client):
make_dir_sloppak(server, "a.sloppak")
seed_match(server, "a.sloppak")
r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "genres"]})
assert r.status_code == 200
rows = client.get("/api/song/a.sloppak/write-log").json()["rows"]
by_key = {x["key"]: x for x in rows}
assert by_key["album"]["old_value"] is None # was a gap — no old value
assert by_key["album"]["new_value"] == "The Razors Edge"
assert by_key["genres"]["new_value"] == '["hard rock", "rock"]'
def test_write_log_endpoint_shape_and_order(server, client):
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
seed_match(server, "a.sloppak", state="manual")
enable_overwrite(client)
assert client.post("/api/song/a.sloppak/gap-fill",
json={"overwrite_keys": ["artist"]}).status_code == 200
assert client.post("/api/song/a.sloppak/gap-fill",
json={"overwrite_keys": ["album"]}).status_code == 200
rows = client.get("/api/song/a.sloppak/write-log").json()["rows"]
assert [x["key"] for x in rows] == ["album", "artist"] # newest first
assert set(rows[0]) == {"id", "key", "old_value", "new_value",
"source", "score", "ts"}
# Another song's rows don't bleed in.
make_dir_sloppak(server, "b.sloppak")
assert client.get("/api/song/b.sloppak/write-log").json()["rows"] == []
def test_write_log_prunes_beyond_cap(server):
"""The receipts table stays bounded at 5000 rows — oldest pruned first."""
db = server.meta_db
db.add_write_log("bulk.sloppak",
[("album", None, str(i)) for i in range(5100)],
source="text", score=1.0)
n = db.conn.execute("SELECT COUNT(*) FROM write_log").fetchone()[0]
assert n == 5000
oldest = db.conn.execute(
"SELECT new_value FROM write_log ORDER BY id ASC LIMIT 1").fetchone()[0]
assert oldest == "100" # rows 0..99 fell off the bottom
# ── revert ────────────────────────────────────────────────────────────────────
def test_revert_dir_form_restores_original_and_resyncs_db(server, client):
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
seed_match(server, "a.sloppak", state="manual")
enable_overwrite(client)
assert client.post("/api/song/a.sloppak/gap-fill",
json={"overwrite_keys": ["artist"]}).status_code == 200
assert client.get("/api/song/a.sloppak").json()["artist"] == "AC/DC"
r = client.post("/api/song/a.sloppak/revert-original")
assert r.status_code == 200
# Author bytes restored verbatim; the backup is PRESERVED (re-apply later).
assert (d / "manifest.yaml").read_text(encoding="utf-8") == DIFF_MANIFEST
assert (d / "manifest.yaml.bak").exists()
row = client.get("/api/song/a.sloppak").json()
assert row["artist"] == "ACDC"
assert str(row["year"]) == "1991"
# And the preview still offers the revert + the differences again.
d2 = client.get("/api/song/a.sloppak/gap-fill").json()
assert d2["has_backup"] is True
assert {x["key"] for x in d2["differs"]} >= {"artist"}
def test_revert_zip_form(server, client):
p = make_zip_sloppak(server, "a.sloppak", DIFF_MANIFEST)
seed_match(server, "a.sloppak", state="manual")
enable_overwrite(client)
assert client.post("/api/song/a.sloppak/gap-fill",
json={"overwrite_keys": ["artist"]}).status_code == 200
assert client.post("/api/song/a.sloppak/revert-original").status_code == 200
with zipfile.ZipFile(p) as z:
assert z.read("manifest.yaml").decode("utf-8") == DIFF_MANIFEST
assert z.read("stems/full.ogg") == b"OggS-fake" # pack intact
assert p.with_name(p.name + ".bak").exists() # backup preserved
assert client.get("/api/song/a.sloppak").json()["artist"] == "ACDC"
def test_revert_without_backup_is_404(server, client):
make_dir_sloppak(server, "a.sloppak")
assert client.post("/api/song/a.sloppak/revert-original").status_code == 404
def test_revert_refuses_non_package_target_with_sibling_bak(server, client):
"""A non-package file under DLC_DIR with a sibling `.bak` must NOT be
reverted revert mirrors the write path's is_sloppak guard, so it never
restores a stray backup over a file the feature was not meant to touch."""
target = server.DLC_DIR / "notes.txt"
target.write_bytes(b"user notes, not a pack")
(server.DLC_DIR / "notes.txt.bak").write_bytes(b"stray backup")
r = client.post("/api/song/notes.txt/revert-original")
assert r.status_code == 404
# The target is left byte-for-byte untouched.
assert target.read_bytes() == b"user notes, not a pack"
def test_preview_reports_backup_after_gap_fill(server, client):
"""Plain gap-fill (R4a) also leaves the one-time backup — the preview
surfaces it so the drawer can offer Revert."""
make_dir_sloppak(server, "a.sloppak")
seed_match(server, "a.sloppak")
assert client.post("/api/song/a.sloppak/gap-fill",
json={"keys": ["album"]}).status_code == 200
assert client.get("/api/song/a.sloppak/gap-fill").json()["has_backup"] is True
def test_demo_mode_blocks_revert(tmp_path, monkeypatch, isolate_logging):
"""The middleware turns revert away before any handler runs — demo
visitors can never rewrite pack files."""
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
r = TestClient(srv.app).post("/api/song/a.sloppak/revert-original")
assert r.status_code == 403
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
+140
View File
@@ -0,0 +1,140 @@
"""Tests for the P3 growth-edge 'practice next' recommender —
GET /api/library/practice-suggestions + meta_db.growth_edge_suggestions().
The score is difficulty-appropriateness (mid band wins) × mastery-proximity
(closer to 0.9, not yet there). Read-only: it must never write difficulty.
Personal difficulty is per-filename (P1); authored/derived seeding + true
per-arrangement difficulty are deferred pending the feedpak difficulty spec."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _seed(server, fn, title=None):
server.meta_db.put(fn, 0, 0, {"title": title or fn.split(".")[0], "artist": "A"})
def _play(server, fn, acc, arr=0):
"""Record a scored attempt so the song has a best_accuracy."""
server.meta_db.record_session(fn, arr, score=int(acc * 1000), accuracy=acc)
def _diff(client, fn, d):
client.put(f"/api/song/{fn}/user-meta", json={"user_difficulty": d})
def _suggest(client, limit=8):
return client.get(f"/api/library/practice-suggestions?limit={limit}").json()
SUG = "/api/library/practice-suggestions"
# ── Gating: only attempted & not-yet-mastered ────────────────────────────────
def test_excludes_mastered_and_unattempted(client, server):
_seed(server, "mastered.archive"); _play(server, "mastered.archive", 0.95)
_seed(server, "inprog.archive"); _play(server, "inprog.archive", 0.6)
_seed(server, "fresh.archive") # never played
got = {r["filename"] for r in _suggest(client)}
assert got == {"inprog.archive"}
def test_empty_when_nothing_attempted(client, server):
_seed(server, "a.archive")
assert _suggest(client) == []
# ── Ordering: mid-difficulty preferred at equal accuracy ──────────────────────
def test_mid_difficulty_ranks_above_extremes(client, server):
for fn in ("mid.archive", "easy.archive", "hard.archive"):
_seed(server, fn); _play(server, fn, 0.6)
_diff(client, "mid.archive", 3)
_diff(client, "easy.archive", 1)
_diff(client, "hard.archive", 5)
order = [r["filename"] for r in _suggest(client)]
assert order[0] == "mid.archive"
# 1 and 5 share the same weight, so both trail mid
assert set(order[1:]) == {"easy.archive", "hard.archive"}
# ── Ordering: closer-to-mastery preferred at equal difficulty ─────────────────
def test_closer_to_mastery_ranks_higher(client, server):
_seed(server, "almost.archive"); _play(server, "almost.archive", 0.85)
_seed(server, "early.archive"); _play(server, "early.archive", 0.4)
# both unrated (→ treated as mid), so accuracy proximity decides
order = [r["filename"] for r in _suggest(client)]
assert order == ["almost.archive", "early.archive"]
def test_unrated_still_surfaces_as_mid(client, server):
"""Before anything is rated the shelf must still work (degrades to
closest-to-mastery). An unrated song outranks a very-easy rated one at
similar accuracy."""
_seed(server, "unrated.archive"); _play(server, "unrated.archive", 0.7)
_seed(server, "veryeasy.archive"); _play(server, "veryeasy.archive", 0.72)
_diff(client, "veryeasy.archive", 1)
order = [r["filename"] for r in _suggest(client)]
# unrated (weight 1.0 × 0.70 = 0.70) beats very-easy (0.6 × 0.72 = 0.432)
assert order[0] == "unrated.archive"
# ── Best arrangement = the one closest to mastery ────────────────────────────
def test_suggested_arrangement_is_best(client, server):
_seed(server, "multi.archive")
_play(server, "multi.archive", 0.5, arr=0)
_play(server, "multi.archive", 0.8, arr=1) # closer to mastery
r = _suggest(client)[0]
assert r["filename"] == "multi.archive"
assert r["arrangement"] == 1
assert r["best_accuracy"] == 0.8
# ── Enrichment + limit ───────────────────────────────────────────────────────
def test_rows_are_enriched(client, server):
_seed(server, "song.archive", title="My Song"); _play(server, "song.archive", 0.6)
r = _suggest(client)[0]
assert r["title"] == "My Song" and r["artist"] == "A"
assert r["art_url"].endswith("/art")
assert "growth_score" in r
def test_limit_is_respected(client, server):
for i in range(5):
fn = f"s{i}.archive"; _seed(server, fn); _play(server, fn, 0.5 + i * 0.05)
assert len(_suggest(client, limit=3)) == 3
# ── Read-only: never writes difficulty ───────────────────────────────────────
def test_recommender_never_writes_difficulty(client, server):
_seed(server, "a.archive"); _play(server, "a.archive", 0.6)
_suggest(client)
assert client.get("/api/song/a.archive/user-meta").json()["user_difficulty"] is None
+333
View File
@@ -0,0 +1,333 @@
"""Tests for the R1 scraper options: per-source toggles (MusicBrainz /
Cover Art Archive), per-field auto-apply toggles (names / year / genres /
cover art), and the review-queue order preference.
Same no-network contract as the P8/R3 suites: both transports are faked
over their single seams (`_mb_http_get`, `_caa_http_get`) and the network
flag is only force-enabled where a test needs the pipeline to run.
"""
import importlib
import io as _io
import sys
import pytest
from fastapi.testclient import TestClient
from PIL import Image
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
class FakeMB:
"""Canned MusicBrainz over the `_mb_http_get` seam (the P8 fixture)."""
def __init__(self):
self.calls = []
self.search_response = {"recordings": []}
def __call__(self, path, params):
self.calls.append((path, dict(params)))
if path == "recording":
return self.search_response
raise AssertionError(f"unexpected MB path {path!r}")
@pytest.fixture()
def mb(server, monkeypatch):
fake = FakeMB()
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def _put(server, fn, title="Thunderstruck (v2)", artist="ACDC", album="",
duration=292, year="1990", mtime=0):
server.meta_db.put(fn, mtime, 0, {
"title": title, "artist": artist, "album": album, "year": year,
"duration": duration, "arrangements": [{"name": "Lead", "index": 0}],
})
def mb_doc(rid="rec-1", title="Thunderstruck", artist="AC/DC", artist_id="art-1",
album="The Razors Edge", date="1990-09-24", length_ms=292000, score=100):
return {
"id": rid, "score": score, "title": title, "length": length_ms,
"isrcs": ["AUAP09000045"],
"artist-credit": [{"name": artist, "artist": {
"id": artist_id, "name": artist, "sort-name": artist}}],
"releases": [{"id": "rel-1", "title": album, "status": "Official",
"date": date, "release-group": {"primary-type": "Album"}}],
"tags": [{"name": "hard rock", "count": 7}],
}
# ── settings keys ─────────────────────────────────────────────────────────────
BOOL_KEYS = ("enrich_src_musicbrainz", "enrich_src_caa", "enrich_apply_names",
"enrich_apply_year", "enrich_apply_genres", "enrich_apply_art")
def test_scraper_option_keys_validate_and_persist(client):
for key in BOOL_KEYS:
bad = client.post("/api/settings", json={key: "yes"}).json()
assert "error" in bad
client.post("/api/settings", json={key: False})
assert client.get("/api/settings").json()[key] is False
assert "error" in client.post(
"/api/settings", json={"enrich_review_order": "random"}).json()
assert "error" in client.post(
"/api/settings", json={"enrich_review_order": 42}).json()
client.post("/api/settings", json={"enrich_review_order": "artist"})
assert client.get("/api/settings").json()["enrich_review_order"] == "artist"
def test_defaults_present(server):
d = server._default_settings()
for key in BOOL_KEYS:
assert d[key] is True
assert d["enrich_review_order"] == "missing_first"
# ── per-source: MusicBrainz ───────────────────────────────────────────────────
def test_musicbrainz_source_off_stamps_without_matching(server, mb, client):
client.post("/api/settings", json={"enrich_src_musicbrainz": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
assert mb.calls == []
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "unscanned" # hash stamped, no match
# Re-enabling picks the same row up on the next pass.
client.post("/api/settings", json={"enrich_src_musicbrainz": True})
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
# ── per-field auto-apply ──────────────────────────────────────────────────────
def test_field_toggles_strip_auto_applied_fields(server, mb, client):
client.post("/api/settings", json={"enrich_apply_year": False,
"enrich_apply_genres": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_artist"] == "AC/DC"
assert row["canon_title"] == "Thunderstruck"
assert row["canon_album"] == "The Razors Edge"
assert row["canon_year"] is None # toggled off
assert row["genres"] == [] # toggled off
# Identity ids always stamp — the art fetch and re-matching need them.
assert row["mb_recording_id"] == "rec-1"
assert row["mb_release_id"] == "rel-1"
def test_names_toggle_keeps_ids_and_other_fields(server, mb, client):
client.post("/api/settings", json={"enrich_apply_names": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_artist"] is None
assert row["canon_title"] is None
assert row["canon_album"] is None
assert row["canon_year"] == "1990"
assert row["genres"] == ["hard rock"]
assert row["mb_recording_id"] == "rec-1"
def test_review_accept_applies_all_fields_despite_toggles(server, mb, client):
"""A match the USER confirms is their intent — the auto-apply toggles
gate only what happens without them."""
client.post("/api/settings", json={"enrich_apply_names": False,
"enrich_apply_year": False,
"enrich_apply_genres": False})
# Partial artist agreement → review tier (candidates stored unfiltered).
_put(server, "a.sloppak", artist="AC/DC ft Nobody")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "review"
r = client.post("/api/enrichment/review/a.sloppak/accept",
json={"recording_id": "rec-1"})
assert r.status_code == 200
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "manual"
assert row["canon_artist"] == "AC/DC"
assert row["canon_year"] == "1990"
assert row["genres"] == ["hard rock"]
# ── per-field: nothing-forfeited (re-enable backfills, no partial seeding) ─────
def test_reenabling_field_backfills_matched_row(server, mb, client):
"""A field toggled OFF strips the value AND records it in apply_mask, so
turning the field back on re-queues the (unchanged-hash) matched row and
backfills the same "nothing forfeited" contract the source/art toggles
keep."""
client.post("/api/settings", json={"enrich_apply_year": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_year"] is None # suppressed
assert row["apply_mask"] == "enrich_apply_year" # …and remembered
# Re-enable → next pass re-queues and backfills the year (hash unchanged).
client.post("/api/settings", json={"enrich_apply_year": True})
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_year"] == "1990" # backfilled
assert row["apply_mask"] in (None, "") # fully applied now
# Converged: a fully-applied row is not re-queued again.
assert server.meta_db.enrichment_pending(
allowed_keys=frozenset(server._ENRICH_APPLY_FIELDS)) == []
def test_partial_match_is_not_a_cache_donor(server):
"""A row that suppressed a display field must not seed a sibling chart of
the same recording with its blank enrichment_cache_lookup skips it, so
the sibling falls through to its own (correctly-filtered) match. A fully-
applied row IS a donor."""
h = server.meta_db.enrichment_content_hash("ACDC", "Thunderstruck (v2)", "", 292)
# Partial donor: matched with the year suppressed (apply_mask set).
server.meta_db.apply_enrichment_match(
"a.sloppak", h, "matched", source="text", score=1.0,
cand={"recording_id": "rec-1", "artist": "AC/DC", "title": "Thunderstruck"},
apply_mask="enrich_apply_year")
assert server.meta_db.enrichment_cache_lookup(
h, exclude_filename="b.sloppak") is None
# Fully-applied donor (no apply_mask): offered, with its year intact.
server.meta_db.apply_enrichment_match(
"c.sloppak", h, "matched", source="text", score=1.0,
cand={"recording_id": "rec-1", "artist": "AC/DC",
"title": "Thunderstruck", "year": "1990"})
donor = server.meta_db.enrichment_cache_lookup(h, exclude_filename="b.sloppak")
assert donor is not None and donor["year"] == "1990"
# ── per-source / per-field: cover art ─────────────────────────────────────────
def png_bytes(color=(200, 30, 30)):
buf = _io.BytesIO()
Image.new("RGB", (4, 4), color).save(buf, "PNG")
return buf.getvalue()
def make_sloppak(server, name, title="Song", artist="Artist"):
d = server.DLC_DIR / name
d.mkdir(parents=True)
(d / "manifest.yaml").write_text(
f"title: {title}\nartist: {artist}\nduration: 100\n"
"arrangements: []\nstems: []\n", encoding="utf-8")
server.meta_db.put(name, 0, 0, {
"title": title, "artist": artist, "album": "", "year": "",
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
})
return d
def _match_row(server, fn, release_id="rel-1"):
song = server.meta_db.enrichment_song_row(fn)
h = server.meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
server.meta_db.apply_enrichment_match(
fn, h, "matched", source="text", score=1.0,
cand={"recording_id": "rec-1", "release_id": release_id,
"title": song["title"], "artist": song["artist"]})
@pytest.fixture()
def caa(server, monkeypatch):
calls = []
art = {"rel-1": png_bytes((60, 60, 200))}
def fake(release_id):
calls.append(release_id)
return art.get(release_id)
fake.calls = calls
monkeypatch.setattr(server, "_caa_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def test_caa_source_toggle_gates_art_fetch(server, client, caa):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak")
client.post("/api/settings", json={"enrich_src_caa": False})
server._background_enrich()
assert caa.calls == []
row = server.meta_db.get_enrichment("a.sloppak")
assert row["art_state"] is None # not forfeited, just skipped
# Re-enable → the same row is picked up.
client.post("/api/settings", json={"enrich_src_caa": True})
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
def test_apply_art_toggle_gates_art_fetch(server, client, caa):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak")
client.post("/api/settings", json={"enrich_apply_art": False})
server._background_enrich()
assert caa.calls == []
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
# ── review-queue order ────────────────────────────────────────────────────────
def _review_row(server, fn):
server.meta_db.apply_enrichment_match(
fn, "h", "review", source="text", score=0.7,
candidates=[{"recording_id": "rec-1", "title": "T", "artist": "A"}])
def _review_filenames(client):
return [s["filename"] for s in
client.get("/api/enrichment/review").json()["songs"]]
def test_review_queue_order_setting(server, client):
# a: complete, artist Alpha, oldest; b: missing album+year, artist Zeta,
# middle; c: missing year, artist Mid, newest — the three orders differ.
_put(server, "a.sloppak", title="Song A", artist="Alpha",
album="Full", year="1990", mtime=100)
_put(server, "b.sloppak", title="Song B", artist="Zeta",
album="", year="", mtime=200)
_put(server, "c.sloppak", title="Song C", artist="Mid",
album="Full", year="", mtime=300)
for fn in ("a.sloppak", "b.sloppak", "c.sloppak"):
_review_row(server, fn)
# Default: missing-data-first.
assert _review_filenames(client) == ["b.sloppak", "c.sloppak", "a.sloppak"]
client.post("/api/settings", json={"enrich_review_order": "artist"})
assert _review_filenames(client) == ["a.sloppak", "c.sloppak", "b.sloppak"]
client.post("/api/settings", json={"enrich_review_order": "recent"})
assert _review_filenames(client) == ["c.sloppak", "b.sloppak", "a.sloppak"]
# An unknown stored value degrades to the default order, never an error.
assert server.meta_db.enrichment_review_queue(order="bogus")[0]["filename"] == "b.sloppak"
+206
View File
@@ -0,0 +1,206 @@
"""Tests for the personal per-song metadata layer (P1): user-difficulty,
notes, and tags. This is the LOCAL, never-shared layer distinct from
/api/song/{f}/meta (which writes catalog fields back into the feedpak file).
Likes stay the existing favorites heart and are not touched here."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _seed(server, *filenames):
for fn in filenames:
server.meta_db.put(fn, 0, 0, {"title": fn.split(".")[0], "artist": "A"})
# ── Read defaults ────────────────────────────────────────────────────────────
def test_defaults_for_untouched_song(client, server):
_seed(server, "a.archive")
m = client.get("/api/song/a.archive/user-meta").json()
assert m == {"user_difficulty": None, "notes": "", "tags": []}
# ── Difficulty + notes write/read ────────────────────────────────────────────
def test_set_difficulty_and_notes(client, server):
_seed(server, "a.archive")
r = client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": 3, "notes": " bar chord section "})
assert r.status_code == 200
m = r.json()
assert m["user_difficulty"] == 3
assert m["notes"] == "bar chord section" # trimmed
# persisted
assert client.get("/api/song/a.archive/user-meta").json()["user_difficulty"] == 3
def test_partial_update_preserves_other_fields(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 4, "notes": "keep"})
# update only notes -> difficulty preserved
client.put("/api/song/a.archive/user-meta", json={"notes": "changed"})
m = client.get("/api/song/a.archive/user-meta").json()
assert m["user_difficulty"] == 4 and m["notes"] == "changed"
def test_clear_difficulty_with_null(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 2})
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": None})
assert client.get("/api/song/a.archive/user-meta").json()["user_difficulty"] is None
@pytest.mark.parametrize("bad", [0, 6, 99, -1, "x", 2.5])
def test_difficulty_out_of_range_is_400(client, server, bad):
_seed(server, "a.archive")
assert client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": bad}).status_code == 400
def test_empty_body_is_400(client, server):
_seed(server, "a.archive")
assert client.put("/api/song/a.archive/user-meta", json={}).status_code == 400
# ── Tags (full-replace) + normalization ──────────────────────────────────────
def test_tags_replace_and_normalize(client, server):
_seed(server, "a.archive")
r = client.put("/api/song/a.archive/user-meta",
json={"tags": ["Warm-ups", " RIFFS ", "warm-ups", ""]})
# lowercased, whitespace-trimmed, case-dupes + blanks dropped; returned
# sorted alphabetically (deterministic chip order, matches reads)
assert r.json()["tags"] == ["riffs", "warm-ups"]
# replace with a smaller set
client.put("/api/song/a.archive/user-meta", json={"tags": ["gig"]})
assert client.get("/api/song/a.archive/user-meta").json()["tags"] == ["gig"]
# clear
client.put("/api/song/a.archive/user-meta", json={"tags": []})
assert client.get("/api/song/a.archive/user-meta").json()["tags"] == []
def test_tags_capped_at_50(client, server):
_seed(server, "a.archive")
# Submit 80 distinct tags; only the first 50 normalized-unique are stored.
many = [f"tag{i}" for i in range(80)]
r = client.put("/api/song/a.archive/user-meta", json={"tags": many})
assert r.status_code == 200
assert len(r.json()["tags"]) == 50
assert len(client.get("/api/song/a.archive/user-meta").json()["tags"]) == 50
def test_tags_must_be_array(client, server):
_seed(server, "a.archive")
assert client.put("/api/song/a.archive/user-meta",
json={"tags": "warmups"}).status_code == 400
def test_tags_untouched_when_key_absent(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta", json={"tags": ["keep"]})
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 1})
assert client.get("/api/song/a.archive/user-meta").json()["tags"] == ["keep"]
def test_list_all_tags_with_counts(client, server):
_seed(server, "a.archive", "b.archive")
client.put("/api/song/a.archive/user-meta", json={"tags": ["warmups", "riffs"]})
client.put("/api/song/b.archive/user-meta", json={"tags": ["warmups"]})
tags = client.get("/api/tags").json()["tags"]
# most-used first
assert tags[0] == {"tag": "warmups", "count": 2}
assert {"tag": "riffs", "count": 1} in tags
# ── Grid embed (query_page) ──────────────────────────────────────────────────
def test_library_row_embeds_difficulty_and_tags_not_notes(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": 5, "notes": "secret", "tags": ["gig"]})
row = next(s for s in client.get("/api/library").json()["songs"]
if s["filename"] == "a.archive")
assert row["user_difficulty"] == 5
assert row["tags"] == ["gig"]
assert "notes" not in row # notes stay out of the list payload
# ── Read-time filters ────────────────────────────────────────────────────────
def test_filter_by_user_difficulty(client, server):
_seed(server, "a.archive", "b.archive", "c.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 2})
client.put("/api/song/b.archive/user-meta", json={"user_difficulty": 4})
got = {s["filename"] for s in client.get("/api/library?user_difficulty=2").json()["songs"]}
assert got == {"a.archive"}
# any-of set
got2 = {s["filename"] for s in client.get("/api/library?user_difficulty=2,4").json()["songs"]}
assert got2 == {"a.archive", "b.archive"}
def test_filter_by_tag(client, server):
_seed(server, "a.archive", "b.archive")
client.put("/api/song/a.archive/user-meta", json={"tags": ["warmups"]})
got = {s["filename"] for s in client.get("/api/library?tags=warmups").json()["songs"]}
assert got == {"a.archive"}
# case-insensitive match on the query side (normalized)
got_ci = {s["filename"] for s in client.get("/api/library?tags=WARMUPS").json()["songs"]}
assert got_ci == {"a.archive"}
def test_filter_total_count_reflects_filter(client, server):
_seed(server, "a.archive", "b.archive", "c.archive")
client.put("/api/song/a.archive/user-meta", json={"user_difficulty": 3})
body = client.get("/api/library?user_difficulty=3").json()
assert body["total"] == 1
# ── Never-clobber on rescan ──────────────────────────────────────────────────
def test_personal_data_survives_rescan(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": 3, "notes": "n", "tags": ["t"]})
# Simulate the scanner re-indexing the song (INSERT OR REPLACE INTO songs).
server.meta_db.put("a.archive", 1, 1, {"title": "a", "artist": "A"})
m = client.get("/api/song/a.archive/user-meta").json()
assert m["user_difficulty"] == 3 and m["notes"] == "n" and m["tags"] == ["t"]
# ── Purge on delete ──────────────────────────────────────────────────────────
def test_purge_removes_all_personal_rows(client, server):
_seed(server, "a.archive")
client.put("/api/song/a.archive/user-meta",
json={"user_difficulty": 3, "notes": "n", "tags": ["x", "y"]})
# delete_song calls this inside meta_db._lock after removing the file.
with server.meta_db._lock:
server.meta_db.purge_song_user_data("a.archive")
server.meta_db.conn.commit()
m = client.get("/api/song/a.archive/user-meta").json()
assert m == {"user_difficulty": None, "notes": "", "tags": []}
# and the tag drops out of the global vocabulary
assert client.get("/api/tags").json()["tags"] == []
+56
View File
@@ -1,13 +1,29 @@
"""Unit tests for lib/songmeta.py — metadata file-persistence helpers."""
from __future__ import annotations
import zipfile
import yaml
# songmeta lives in lib/ which is on PYTHONPATH via conftest / pyproject
from songmeta import (
_apply_to_sloppak_manifest,
_coerce_year,
write_song_metadata,
)
def _make_zip_pak(path, manifest: dict) -> None:
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("manifest.yaml", yaml.safe_dump(manifest))
z.writestr("arrangements/lead.json", "{}")
def _read_zip_manifest(path) -> dict:
with zipfile.ZipFile(path) as z:
return yaml.safe_load(z.read("manifest.yaml"))
class TestCoerceYear:
def test_int_passthrough(self):
assert _coerce_year(2024) == 2024
@@ -62,3 +78,43 @@ class TestApplyToSloppakManifest:
manifest = {"title": "T"}
dirty = _apply_to_sloppak_manifest(manifest, {})
assert not dirty
class TestWriteSongMetadata:
"""Suffix dispatch — zip-form packages are writable under BOTH the current
``.feedpak`` suffix and the legacy ``.sloppak`` one. The ``.feedpak`` gate
was missed when the format was renamed, silently downgrading Edit Metadata
to a DB-only update that a full rescan reverts."""
def test_zip_feedpak_persists(self, tmp_path):
pak = tmp_path / "song.feedpak"
_make_zip_pak(pak, {"title": "Old", "artist": "A"})
assert write_song_metadata(pak, {"title": "New"})
assert _read_zip_manifest(pak)["title"] == "New"
def test_zip_sloppak_persists(self, tmp_path):
pak = tmp_path / "song.sloppak"
_make_zip_pak(pak, {"title": "Old", "artist": "A"})
assert write_song_metadata(pak, {"title": "New"})
assert _read_zip_manifest(pak)["title"] == "New"
def test_zip_suffix_case_insensitive(self, tmp_path):
pak = tmp_path / "song.FeedPak"
_make_zip_pak(pak, {"title": "Old"})
assert write_song_metadata(pak, {"title": "New"})
assert _read_zip_manifest(pak)["title"] == "New"
def test_directory_form_persists(self, tmp_path):
pak = tmp_path / "song.feedpak"
pak.mkdir()
(pak / "manifest.yaml").write_text(
yaml.safe_dump({"title": "Old"}), encoding="utf-8"
)
assert write_song_metadata(pak, {"title": "New"})
got = yaml.safe_load((pak / "manifest.yaml").read_text(encoding="utf-8"))
assert got["title"] == "New"
def test_unknown_suffix_returns_false(self, tmp_path):
f = tmp_path / "song.txt"
f.write_text("not a package")
assert not write_song_metadata(f, {"title": "New"})
+164
View File
@@ -0,0 +1,164 @@
"""Tests for the multi-chart work API (P5b): GET /api/work/{work_key}/charts +
PUT/DELETE /preferred + POST /api/chart/{filename}/split|unsplit. HTTP surface
over the P5a grouping engine (what the P5d Charts drawer consumes)."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _put(server, fn, title, artist, arrangements=1):
arr = [{"name": "Lead", "index": i} for i in range(arrangements)]
server.meta_db.put(fn, 0, 0, {"title": title, "artist": artist, "arrangements": arr})
def _wk(server, fn):
return server.meta_db.work_key_for(fn)
def _rep(body):
return next(c for c in body["charts"] if c["is_representative"])
# ── grouped grid row carries work_key (so the drawer can address it) ─────────
def test_grouped_row_exposes_work_key(client, server):
_put(server, "a.archive", "Song", "Artist")
_put(server, "b.archive", "Song", "Artist")
row = client.get("/api/library", params={"group": 1}).json()["songs"][0]
assert row["work_key"] == _wk(server, "a.archive")
assert row["chart_count"] == 2
# ── GET charts ───────────────────────────────────────────────────────────────
def test_get_work_charts(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=2)
_put(server, "b.archive", "Song", "Artist", arrangements=1)
body = client.get(f"/api/work/{_wk(server, 'a.archive')}/charts").json()
assert body["count"] == 2
assert {c["filename"] for c in body["charts"]} == {"a.archive", "b.archive"}
assert _rep(body)["filename"] == "a.archive" # auto-pick = most arrangements
assert body["preferred_source"] == "auto"
assert body["preferred_filename"] is None
def test_charts_include_best_accuracy(client, server):
_put(server, "a.archive", "Song", "Artist")
_put(server, "b.archive", "Song", "Artist")
server.meta_db.record_session("a.archive", 0, score=900, accuracy=0.9)
charts = {c["filename"]: c for c in
client.get(f"/api/work/{_wk(server, 'a.archive')}/charts").json()["charts"]}
assert charts["a.archive"]["best_accuracy"] == 0.9
assert charts["b.archive"]["best_accuracy"] is None
# ── PUT / DELETE preferred ───────────────────────────────────────────────────
def test_set_preferred(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=2)
_put(server, "b.archive", "Song", "Artist", arrangements=1)
wk = _wk(server, "a.archive")
body = client.put(f"/api/work/{wk}/preferred", json={"filename": "b.archive"}).json()
assert body["preferred_filename"] == "b.archive" and body["preferred_source"] == "user"
assert _rep(body)["filename"] == "b.archive" and _rep(body)["is_preferred"]
# the grouped grid now shows b as the work's card
assert client.get("/api/library", params={"group": 1}).json()["songs"][0]["filename"] == "b.archive"
def test_set_preferred_nonmember_is_400(client, server):
_put(server, "a.archive", "Song", "Artist")
_put(server, "x.archive", "Other", "Artist")
wk = _wk(server, "a.archive")
assert client.put(f"/api/work/{wk}/preferred", json={"filename": "x.archive"}).status_code == 400
assert client.put(f"/api/work/{wk}/preferred", json={}).status_code == 400
def test_reset_preferred_returns_to_auto(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=2)
_put(server, "b.archive", "Song", "Artist", arrangements=1)
wk = _wk(server, "a.archive")
client.put(f"/api/work/{wk}/preferred", json={"filename": "b.archive"})
body = client.delete(f"/api/work/{wk}/preferred").json()
assert body["preferred_source"] == "auto"
assert _rep(body)["filename"] == "a.archive" # auto-pick again
# ── split / unsplit ──────────────────────────────────────────────────────────
def test_split_and_unsplit_endpoints(client, server):
_put(server, "a.archive", "Song", "Artist")
_put(server, "b.archive", "Song", "Artist")
wk = _wk(server, "a.archive")
assert client.get(f"/api/work/{wk}/charts").json()["count"] == 2
assert client.post("/api/chart/b.archive/split").json()["ok"] is True
assert {c["filename"] for c in client.get(f"/api/work/{wk}/charts").json()["charts"]} == {"a.archive"}
assert client.get("/api/library", params={"group": 1}).json()["total"] == 2 # two works now
assert client.post("/api/chart/b.archive/unsplit").json()["ok"] is True
assert client.get(f"/api/work/{wk}/charts").json()["count"] == 2 # rejoined
# ── GET chart work membership (P5d — tree/ungrouped opener resolve) ──────────
def test_chart_work_grouped_pair(client, server):
_put(server, "a.archive", "Song", "Artist")
_put(server, "b.archive", "Song", "Artist")
wk = _wk(server, "a.archive")
for fn in ("a.archive", "b.archive"):
body = client.get(f"/api/chart/{fn}/work").json()
assert body == {"filename": fn, "work_key": wk, "chart_count": 2,
"is_split": False}
def test_chart_work_singleton(client, server):
_put(server, "a.archive", "Song", "Artist")
body = client.get("/api/chart/a.archive/work").json()
assert body["work_key"] == _wk(server, "a.archive")
assert body["chart_count"] == 1
def test_chart_work_split_resolves_to_singleton_key(client, server):
_put(server, "a.archive", "Song", "Artist")
_put(server, "b.archive", "Song", "Artist")
wk = _wk(server, "a.archive")
client.post("/api/chart/b.archive/split")
body = client.get("/api/chart/b.archive/work").json()
# the split chart stands alone under its EFFECTIVE (split) key…
assert body["work_key"] != wk and body["chart_count"] == 1
# …and its key round-trips into the charts endpoint as its own work.
# Split keys contain '#' — clients MUST URL-encode the work_key in the
# path (the v3 client encodeURIComponent's it) or the '#' truncates the
# URL as a fragment.
from urllib.parse import quote
charts = client.get(f"/api/work/{quote(body['work_key'], safe='')}/charts").json()
assert [c["filename"] for c in charts["charts"]] == ["b.archive"]
# the chart left behind is a singleton too
assert client.get("/api/chart/a.archive/work").json()["chart_count"] == 1
def test_chart_work_unknown_file(client, server):
_put(server, "a.archive", "Song", "Artist")
body = client.get("/api/chart/nope.archive/work").json()
assert body["work_key"] is None and body["chart_count"] == 0
+202
View File
@@ -0,0 +1,202 @@
"""Tests for the multi-chart grouping engine (P5a): several charts (feedpak rows)
of the same WORK collapse to one representative card via a materialized
work_display read-model + a `group=1` `WHERE is_group_representative=1` predicate.
Counting works (not charts), auto-pick, chart_group_pref, split, and keyset
paging under grouping all stay correct."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _put(server, fn, title, artist, arrangements=1, mtime=0.0):
arr = [{"name": "Lead", "index": i} for i in range(arrangements)]
server.meta_db.put(fn, mtime, 0, {"title": title, "artist": artist, "arrangements": arr})
def _grouped(client, **params):
params["group"] = 1
return client.get("/api/library", params=params).json()
# ── Baseline: ungrouped shows every chart ────────────────────────────────────
def test_ungrouped_shows_all_charts(client, server):
_put(server, "a.archive", "Song", "Artist")
_put(server, "b.archive", "Song", "Artist")
body = client.get("/api/library").json()
assert body["total"] == 2
assert {s["filename"] for s in body["songs"]} == {"a.archive", "b.archive"}
# ungrouped rows carry no chart_count (only the grouped path attaches it)
assert "chart_count" not in body["songs"][0]
# ── Grouping collapses a work to one representative ───────────────────────────
def test_grouping_collapses_to_one_representative(client, server):
_put(server, "a.archive", "Song", "Artist", arrangements=1)
_put(server, "b.archive", "Song", "Artist", arrangements=2)
body = _grouped(client)
assert body["total"] == 1
assert len(body["songs"]) == 1
rep = body["songs"][0]
assert rep["filename"] == "b.archive" # most arrangements wins auto-pick
assert rep["chart_count"] == 2
def test_autopick_plays_tiebreak(client, server):
_put(server, "a.archive", "S", "A", arrangements=1)
_put(server, "b.archive", "S", "A", arrangements=1)
server.meta_db.record_session("a.archive", 0, score=100, accuracy=0.5) # a has a play
assert _grouped(client)["songs"][0]["filename"] == "a.archive"
def test_autopick_filename_tiebreak(client, server):
_put(server, "b.archive", "S", "A")
_put(server, "a.archive", "S", "A")
# equal arrangements + plays + mtime → lowest filename
assert _grouped(client)["songs"][0]["filename"] == "a.archive"
# ── chart_group_pref override ────────────────────────────────────────────────
def test_preferred_overrides_autopick(client, server):
_put(server, "a.archive", "S", "A", arrangements=2) # would auto-win
_put(server, "b.archive", "S", "A", arrangements=1)
wk = server.meta_db.work_key_for("b.archive")
server.meta_db.set_chart_preferred(wk, "b.archive")
assert _grouped(client)["songs"][0]["filename"] == "b.archive"
def test_set_preferred_incremental_reflip(client, server):
_put(server, "a.archive", "S", "A", arrangements=2)
_put(server, "b.archive", "S", "A", arrangements=1)
assert _grouped(client)["songs"][0]["filename"] == "a.archive" # builds read-model
wk = server.meta_db.work_key_for("a.archive")
server.meta_db.set_chart_preferred(wk, "b.archive") # incremental re-flip
body = _grouped(client)
assert body["songs"][0]["filename"] == "b.archive"
assert body["songs"][0]["chart_count"] == 2 # group_size unchanged
def test_clear_preferred_returns_to_autopick(client, server):
_put(server, "a.archive", "S", "A", arrangements=2)
_put(server, "b.archive", "S", "A", arrangements=1)
wk = server.meta_db.work_key_for("a.archive")
server.meta_db.set_chart_preferred(wk, "b.archive")
assert _grouped(client)["songs"][0]["filename"] == "b.archive"
server.meta_db.clear_chart_preferred(wk)
assert _grouped(client)["songs"][0]["filename"] == "a.archive" # auto again
# ── Split / un-split ─────────────────────────────────────────────────────────
def test_split_makes_singleton(client, server):
_put(server, "a.archive", "S", "A")
_put(server, "b.archive", "S", "A")
assert _grouped(client)["total"] == 1
server.meta_db.split_chart("b.archive")
body = _grouped(client)
assert body["total"] == 2 # two works now
assert {s["filename"] for s in body["songs"]} == {"a.archive", "b.archive"}
assert all(s["chart_count"] == 1 for s in body["songs"])
def test_unsplit_rejoins(client, server):
_put(server, "a.archive", "S", "A")
_put(server, "b.archive", "S", "A")
server.meta_db.split_chart("b.archive")
assert _grouped(client)["total"] == 2
server.meta_db.unsplit_chart("b.archive")
assert _grouped(client)["total"] == 1
# ── Stats + AZ count works, not charts ──────────────────────────────────────
def test_stats_counts_works_not_charts(client, server):
_put(server, "a.archive", "Song", "Artist")
_put(server, "b.archive", "Song", "Artist") # same work as a
_put(server, "c.archive", "Other", "Artist")
assert client.get("/api/library/stats").json()["total_songs"] == 3
assert client.get("/api/library/stats", params={"group": 1}).json()["total_songs"] == 2
def test_sort_letters_counts_works(client, server):
_put(server, "a.archive", "Song", "Alpha")
_put(server, "b.archive", "Song", "Alpha") # same work
_put(server, "c.archive", "Track", "Beta")
sl = client.get("/api/library/stats",
params={"group": 1, "sort_letters": 1, "sort": "artist"}).json()["sort_letters"]
assert sl.get("A") == 1 and sl.get("B") == 1
# ── Keyset paging under grouping (the critical integration) ───────────────────
def test_grouped_keyset_pagination(client, server):
for i in range(5):
_put(server, f"w{i}_a.archive", f"Song {i}", "Artist")
_put(server, f"w{i}_b.archive", f"Song {i}", "Artist") # 2 charts per work
# Title sort: artist sorts page by OFFSET now (title-secondary ordering),
# and this test PROVES the grouped keyset, so it pins a keyset sort.
body = client.get("/api/library", params={"group": 1, "size": 3, "sort": "title"}).json()
assert body["total"] == 5 and len(body["songs"]) == 3
cur = body["next_cursor"]
assert cur
body2 = client.get("/api/library", params={"group": 1, "size": 3, "sort": "title", "after": cur}).json()
assert len(body2["songs"]) == 2
p1 = {s["filename"] for s in body["songs"]}
p2 = {s["filename"] for s in body2["songs"]}
assert not (p1 & p2) # no skip / dupe across pages
# ── Freshness: adding a chart regroups; overrides survive reindex ─────────────
def test_new_chart_updates_group_size(client, server):
_put(server, "a.archive", "S", "A")
assert _grouped(client)["songs"][0]["chart_count"] == 1
_put(server, "b.archive", "S", "A") # dirties read-model
body = _grouped(client)
assert body["total"] == 1 and body["songs"][0]["chart_count"] == 2
def test_preferred_survives_reindex(client, server):
_put(server, "a.archive", "S", "A", arrangements=2)
_put(server, "b.archive", "S", "A", arrangements=1)
wk = server.meta_db.work_key_for("b.archive")
server.meta_db.set_chart_preferred(wk, "b.archive")
assert _grouped(client)["songs"][0]["filename"] == "b.archive"
# A rescan re-indexes both charts (INSERT OR REPLACE INTO songs).
_put(server, "a.archive", "S", "A", arrangements=2)
_put(server, "b.archive", "S", "A", arrangements=1)
assert _grouped(client)["songs"][0]["filename"] == "b.archive" # pref survived
# ── work_key normalization folds trivial differences ─────────────────────────
def test_work_key_folds_the_and_punctuation(server):
wk = server.meta_db._work_key
assert wk("The Beatles", "Hey Jude") == wk("beatles", "hey jude!")
assert wk("AC/DC", "T.N.T.") == wk("acdc", "tnt")
assert wk("Metallica", "One") != wk("Metallica", "Two")