Compare commits

..
Author SHA1 Message Date
byrongamatosandClaude Fable 5 4c5f6e59d9 review: Copilot round 1 — normalize bar_start_times GP3/4/5 parse failures to ValueError, document ImportError
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:02:52 +02:00
byrongamatosandClaude Fable 5 f5ac9163fb feat(gp_autosync): piecewise time-warp helpers + refine_sync onset pass
auto_sync computes per-bar sync points but consumers only ever applied the
scalar bar-1 audio_offset, so any tempo drift between the recording and the
tab's authored tempo accumulated over the song. Add the librosa-free helpers
needed to apply the full piecewise mapping:

- bar_start_times(gp_path): per-bar score times sharing auto_sync's axis
  (GPIF bar-resolution map, GP3/4/5 per-tick integration)
- build_warp_anchors(points, bar_starts): monotonic (score, audio) anchors
- warp_time(t, anchors): piecewise-linear map with edge-slope extrapolation
- warp_song_times(song, warp): retime a lib.song.Song in place (notes,
  sustains, chords, beats, sections, anchors, handshapes, phrase levels,
  tone changes, tempo overrides)
- gp_has_expandable_repeats(gp_path): detects GP3/4/5 repeat/volta/direction
  markup whose playback expansion auto_sync's as-written points cannot map

Also implement refine_sync() — the editor's refine-sync endpoint has imported
it since the snapshot but it never existed in lib, so the Refine button 500'd.
It densifies the DTW points to every Nth bar and re-times each with a local
onset phase sweep (radius clamped under half a beat to avoid one-beat locks,
short scoring grid + median residual snap against the first beats). Synthetic
click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input
across 117-123 BPM recordings of a 120 BPM tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:00:47 +02:00
74cff4e0d6 feat(enrichment): alias-aware scoring — auto-confirm non-Latin-primary artists (#772)
ship-ci / ci (push) Waiting to run
* feat(enrichment): loose MusicBrainz search fallback (find aliased artists)

The MB text search used a strict field-phrase query
(`recording:"<title>" AND artist:"<artist>"`). A field phrase only matches
MusicBrainz's *primary* artist/title — it never searches ALIASES — so a
recording stored under a non-Latin primary name (大橋純子) whose romanized
form ("Junko Ohashi") is only an alias returns ZERO results, even though MB
has it. Whole swaths of a community library (e.g. romanized J-pop / city-pop
charts) were unsearchable.

- `build_recording_query(..., loose=True)` drops the field scoping + phrases
  for plain AND-ed term groups (`(telephone number) AND (junko ohashi)`),
  which searches the whole document incl. aliases.
- `_mb_search_recordings` runs the strict query first (unchanged, high
  precision) and only on an EMPTY result retries once with the loose query —
  so mainstream matches are untouched and the extra throttled request is spent
  only on a miss. Results are re-scored by rank_candidates, so recall goes up
  without lowering match quality (auto-accept still needs the per-field floors).

Verified live: "Junko Ohashi / Telephone Number" and "Anri / Windy Summer"
(both 0 under the strict query) now surface the real records; "AC/DC /
Highway to Hell" still hits strict at score 1.0 with no loose retry.

Follow-up (separate): alias-aware SCORING so these can auto-confirm, not just
appear as manual candidates.

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

* feat(enrichment): alias-aware scoring (auto-confirm non-Latin-primary artists)

Builds on the loose-search fallback: that surfaces a recording stored under a
Japanese primary name (大橋純子) via its romanized alias, but the SCORER still
compared the reference ("Junko Ohashi") against the primary only → artist
similarity 0 → below the auto floor, so it could only ever be a manual
candidate, never an auto-fill.

- mb_match: `cand_artist_sim` takes the best similarity over the candidate's
  primary name AND its `artist_aliases`; score_candidate + classify use it.
- server: `_mb_artist_aliases(id)` fetches an artist's aliases (one throttled
  lookup, process-cached — a one-artist discography costs ONE request) and
  `_alias_enrich` attaches them ONLY to promising near-misses (title agrees,
  primary artist doesn't) so a normal pass spends zero extra requests. Wired
  into both the auto-matcher (_enrich_one) and the manual search proxy.

Verified live: "Junko Ohashi / Telephone Number" → 大橋純子 candidate goes from
score 0.5 (loose-only) to 1.0 (auto-confirmable), ranked #1; "AC/DC / Highway
to Hell" unchanged at 1.0 with no alias lookup.

Stacks on #771 (feat/mb-loose-search-fallback).

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

* fix(enrichment): keep live exclusion in the loose search fallback

The loose fallback dropped the strict path's -secondarytype:Live filter, so a
studio chart whose strict query missed could fall back to — and, since
score_candidate doesn't penalize live takes, auto-confirm — a live-only
recording. Apply the same live gate to the loose query (skipped only when the
source title is itself a live take).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 01:11:38 +02:00
18c4e229e1 feat(enrichment): loose MusicBrainz search fallback (find aliased/romanized artists) (#771)
* feat(enrichment): loose MusicBrainz search fallback (find aliased artists)

The MB text search used a strict field-phrase query
(`recording:"<title>" AND artist:"<artist>"`). A field phrase only matches
MusicBrainz's *primary* artist/title — it never searches ALIASES — so a
recording stored under a non-Latin primary name (大橋純子) whose romanized
form ("Junko Ohashi") is only an alias returns ZERO results, even though MB
has it. Whole swaths of a community library (e.g. romanized J-pop / city-pop
charts) were unsearchable.

- `build_recording_query(..., loose=True)` drops the field scoping + phrases
  for plain AND-ed term groups (`(telephone number) AND (junko ohashi)`),
  which searches the whole document incl. aliases.
- `_mb_search_recordings` runs the strict query first (unchanged, high
  precision) and only on an EMPTY result retries once with the loose query —
  so mainstream matches are untouched and the extra throttled request is spent
  only on a miss. Results are re-scored by rank_candidates, so recall goes up
  without lowering match quality (auto-accept still needs the per-field floors).

Verified live: "Junko Ohashi / Telephone Number" and "Anri / Windy Summer"
(both 0 under the strict query) now surface the real records; "AC/DC /
Highway to Hell" still hits strict at score 1.0 with no loose retry.

Follow-up (separate): alias-aware SCORING so these can auto-confirm, not just
appear as manual candidates.

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

* fix(enrichment): keep live exclusion in the loose search fallback

The loose fallback dropped the strict path's -secondarytype:Live filter, so a
studio chart whose strict query missed could fall back to — and, since
score_candidate doesn't penalize live takes, auto-confirm — a live-only
recording. Apply the same live gate to the loose query (skipped only when the
source title is itself a live take).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 01:09:48 +02:00
bde25c0bc8 fix(gpx): clamp partial final BCFS sector so GP6 .gpx import works (#749)
Every real Guitar Pro 6 (.gpx) file failed to import with
"GPX BCFS sector pointer out of range (malformed file)".

A real .gpx's BCFZ-declared decompressed size isn't 0x1000-aligned, so
its last (small) container file lands in a partial trailing sector.
_parse_bcfs raised whenever a sector read would run past the buffer
end, rejecting the whole container before score.gpif could be extracted
-- so no GP6 file could be charted in the song editor. (GP7/GP8 .gp
files take the ZIP path, not BCFS, which is why this wasn't caught
earlier.)

Clamp the final sector read to the buffer end (the per-file size field
trims the padding anyway), matching canonical GPX readers (alphaTab /
PyGuitarPro). A sector whose start is past the end still raises, so the
malformed-file guard is preserved.

Verified against two real GP6 files -- both now unpack to valid GPIF
with all tracks. Adds the previously-missing positive BCFS round-trip
coverage: partial-final-sector, multi-file, sector-aligned baseline,
and the preserved out-of-range guard.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 00:20:31 +02:00
c7aa5a10b0 fix(v3): recycle library grid cards on scroll instead of rebuilding the window (#742)
The virtualized v3 Songs grid rebuilt its entire visible window
(grid.innerHTML = _renderCardsRange(...) + a full wireCards pass) every
time it slid by one row. Each row-boundary crossing was therefore a heavy
synchronous frame — reparse ~60 cards, re-attach hundreds of listeners,
reflow — that stalled the main thread and buffered held-arrow key-repeats,
flushing them in a burst. Testers saw the library "go super fast for a
second then slow down," skipping "every so many scrolls," up or down, at
the same spots each time. It hitched scrolling back up over already-loaded
songs too, because the cost was DOM teardown, not fetching.

renderWindow() now reconciles the window in place: it reuses the card
nodes that stay on-screen and builds only the row that enters/leaves
(~6 nodes per slide instead of ~60). Nodes are keyed by absolute index
(data-idx) with a real-vs-skeleton + select-mode signature (data-sig) so
hole-fills after a page fetch and select-mode toggles still rebuild
exactly the nodes that changed. wireCards()'s existing data-wired guard
then wires only the freshly-built nodes, so per-slide listener churn drops
with it. Everything keyed off data-fn (favorites, ⋮ menu, right-click,
selection, accuracy badges, A–Z rail) is unaffected.

Follow-up to the stage-2 virtualized grid (#636 item 3). Frontend-only.

Tests: tests/js/v3_songs_window_recycle.test.js — window stays [start,end)
contiguous and in-window node identity is reused across a down-then-up
scroll; select-mode toggle and a rail-seek jump rebuild correctly.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 00:19:46 +02:00
fa2d12222a feat(v3 library): "Refresh Metadata" button + per-view re-match + filename-artist seed (#764)
Adds a media-server-style "Refresh Metadata" control to the Songs toolbar
(beside "⟳ Refresh") — the metadata counterpart to a file scan.

- Re-matches the songs currently SHOWN (the visible grid window) against
  MusicBrainz: a per-view refresh that's visible even on an already-matched
  library. The button doubles as Stop while a pass runs; a batch progress bar
  + per-tile queued→working→done badges show what's happening. User-pinned
  `manual` matches are never re-matched.
- Backend: POST /api/enrichment/{cancel,states,rematch}; /status gains
  total/matched/current/cancelling; a cooperative cancel Event is checked
  between songs in the match + art phases so Stop halts without waiting for
  the whole queue. `states` is read-only (open); `cancel`/`rematch` are
  demo-blocked.
- Matcher: when a pack's `artist` field is blank (common in community
  charts), derive artist/title from the CDLC `Artist_Song-Title` filename
  convention as a SEARCH SEED so text matching can identify it — the displayed
  values still come only from the confirmed MusicBrainz match, nothing
  estimated is shown as author-set. Rescues blank-artist packs that otherwise
  always failed.

Tests: enrichment_states_for, the three new routes, cancel-halts-a-pass,
kick-clears-stale-cancel, filename parse, blank-artist seeding, and
present-artist-not-overridden.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:18:18 +02:00
73c5ab149e feat(enrichment): AcoustID audio-fingerprint identification (opt-in) (#759)
* feat(enrichment): AcoustID audio-fingerprint identification (opt-in)

Text search can only guess the version; the definitive fix is content-based —
fingerprint the actual audio with Chromaprint (fpcalc) and look it up on
AcoustID, which maps the fingerprint to the EXACT MusicBrainz recording (the
approach Lidarr uses). Sidesteps the studio-vs-live ambiguity entirely.

- lib/acoustid_match.py: pure response parsing + config gating (unit-tested);
  normalizes AcoustID hits into the same candidate shape as mb_match so the
  review UI + editor Match popup render fingerprint and text hits identically.
- server.py: _fpcalc (Chromaprint subprocess), _acoustid_lookup (throttled,
  offline-guarded HTTP), _identify_by_fingerprint (also available to the
  library-enrichment pipeline), and POST /api/enrichment/identify (upload the
  master audio → candidates).
- Fully OPT-IN and graceful: absent the fpcalc binary or an ACOUSTID_API_KEY
  the whole path is a no-op / 503 and the text matcher runs unchanged.

Requires (both optional): the `fpcalc` (Chromaprint) binary on PATH/$FPCALC,
and a free AcoustID application key in $ACOUSTID_API_KEY. Pure parsing/gating
is unit-tested; the fpcalc + live-lookup path needs those two to exercise.

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

* feat(enrichment): make AcoustID self-serve — opt-in toggle + API key in settings

Fingerprinting was env-var only (ACOUSTID_API_KEY), so only an operator could
enable it. Add two core settings so a user can turn it on themselves:
  - acoustid_enabled (bool, default OFF — opt-in)
  - acoustid_api_key (string, ≤128 chars, trimmed; env var stays a fallback)

_acoustid_available()/_acoustid_lookup() now resolve (enabled, key) from
settings via _acoustid_settings(). /api/enrichment/identify distinguishes
"not set up" (412 needs_setup — the UI nudges the user to enable it) from
"set up but fpcalc/network missing" (503) so the client never fakes a match.

Verified: default off; POST round-trips + trims; 412 vs 503 gating; bad
types/over-length rejected. acoustid_match unit tests green (8/8).

* fix(enrichment): POST the AcoustID lookup instead of GET

A Chromaprint fingerprint is multi-KB (a 3.5-min track ≈ 3.5k chars), so
sending it as a GET query param overflows the request URL for longer songs and
fails spuriously. AcoustID accepts the same params form-encoded — POST them.

* fix(acoustid): space-separate the lookup `meta` (was silently dropping metadata)

The meta value was `+`-joined ("recordings+releasegroups+compress"). Sent over
the wire the literal `+` percent-encodes to %2B, which AcoustID does NOT split
into flags — so every hit came back with an empty `recordings` array and the
parser produced zero candidates (a fingerprint match that resolved to nothing).
AcoustID wants the flags space-separated. Verified against real fingerprints:
`+`-joined → 0 recordings; space-joined → 28, resolving Highway to Hell and
Living After Midnight to their canonical studio albums as the top hit.

* feat(acoustid): resolve the canonical original album + year from the fingerprint

AcoustID hits resolved the right recording but a weak album/blank year: the
album picker took the first studio-typed group (a later comp/soundtrack typed
"Album" could win) and the year took an arbitrary release (often a reissue).
Request the `releases` meta (which carries per-release dates) and use them to
(1) pick the EARLIEST original studio album among the groups and (2) fill the
year from that album's earliest release. Verified against real fingerprints:
Smoke on the Water → Machine Head (1972) not a later comp; Highway to Hell →
1979; Living After Midnight → British Steel (1980). +2 unit tests.

* feat(acoustid): per-song "Identify by audio" for the library metadata tooling

Add POST /api/enrichment/identify/{filename} — fingerprints an EXISTING library
song's own master audio (resolves the sloppak's original_audio or a loose
folder's audio), the library counterpart to the upload-based /identify used by
the editor. Wire an "Identify by audio" action into the match-review / Fix-match
modal: it renders fingerprint hits in the same candidate list and pins the pick
via the existing /review/{f}/pick. Shared _acoustid_gate() (412 needs_setup /
503) for both endpoints; 404 when a pack has no full mix. Both identify routes
added to the demo-mode block list (they spend fpcalc + the AcoustID budget) —
fixes a pre-existing miss on the upload route.

* fix(acoustid): regenerate stale tailwind CSS + cap identify upload

- static/tailwind.min.css was stale vs a fresh rebuild (ci/tailwind-fresh red);
  regenerated with the pinned tailwindcss@3.4.19 (byte-stable).
- /api/enrichment/identify read the whole multipart upload into memory before
  writing it; stream it to the temp file with a 256 MB cap (413 over) so an
  oversized upload can't balloon RAM. fpcalc reads from the temp file anyway.

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

* feat(acoustid): pre-parse upload guard + settings UI to enable it

- /api/enrichment/identify is now async: a pre-parse Content-Length check +
  request.form(max_part_size=…) reject an oversized body BEFORE Starlette spools
  the multipart to temp disk (mirrors the song-upload endpoint), and the blocking
  fpcalc subprocess + AcoustID HTTP run off the event loop via run_in_executor.
- The v3 Metadata-matching settings card gains an 'Identify by audio' opt-in
  toggle (acoustid_enabled, default OFF) + an AcoustID key input
  (acoustid_api_key), wired in match-review.js — so the advertised feature is
  reachable from the UI instead of only via a manual settings POST. Reuses
  existing classes only; committed tailwind.min.css stays fresh.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:17:43 +02:00
a65d8cfa13 fix(enrichment): rank the canonical studio take over live/comp versions (#758)
* fix(enrichment): rank the canonical studio take over live/comp versions

A flat MusicBrainz /recording text search ties every take of a song at the
same score, so "AC/DC — Highway to Hell" returns a wall of live bootlegs and
compilations with the 1979 studio version buried (or below the fetch limit).

- build_recording_query: drop live-ONLY recordings (`-secondarytype:Live`).
  Compilations are deliberately kept — they REUSE the studio recording, so
  filtering them cuts the very recording we want (verified against MB).
- _best_release / parse_recording_doc: pick the canonical studio album
  (primary Album, no Live/Compilation/Remix/... secondary type) for the
  displayed album/year, and expose a `studio` flag.
- rank_candidates: since the combined score caps at 1.0 (perfect text match
  ties), break ties on the studio flag and — when the caller knows the audio
  length — on duration proximity, so the studio take wins over live/extended
  cuts. The studio distinction is intentionally NOT scored (a live take is
  still the right SONG), only re-ordered.
- /api/enrichment/search: accept an optional `duration` param so a caller that
  has the audio but no library row (the editor's create modal) can pass the
  master-track length for the duration tiebreak.

Verified end-to-end against live MusicBrainz: AC/DC "Highway to Hell" now
returns the 1979 studio recording at #1 with the correct album + year.

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

* fix(enrichment): official releases outrank unofficial studio albums

_best_release sorted (clean, status_ok, date), so an UNofficial bootleg Album
outranked an official Single/EP/comp — regressing canonical album/year and
seeding cover-art from a bootleg for single-only songs. Order status_ok before
clean: official first, then prefer a clean studio album among the official
releases (still surfaces the studio album over an official live/comp album).

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

* fix(enrichment): keep live recordings for genuinely-live charts

build_recording_query unconditionally added -secondarytype:Live, but denoise()
strips a '(Live at …)' qualifier from the query — so a chart that IS a live take
had its only correct recording filtered out (both background enrichment and
manual search). Skip the live filter when the source title carries a
parenthetical live marker; a bare title word ('Live and Let Die') still filters.

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

* fix(enrichment): drop the studio tiebreak when the chart is a live take

Follow-through on keeping live recordings for live charts: rank_candidates still
ranked the studio take ahead of a tied live one, so a live chart would auto-match
the studio recording. Skip the studio tiebreak when the source title has a live
marker — duration proximity + score then pick the right live version.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:17:05 +02:00
a86abadb14 settings: add host instrument profiles (#753)
* settings: add host instrument profiles

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* settings: add instrument pathway selection

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* fix(settings): profile-aware saves/resets/switch, provider tunings, bass-5

Five regressions from the instrument-profiles rework:

1. save_settings canonicalized profiles on EVERY save -> empty/unrelated POST
   froze default profiles into config.json (broke
   test_empty_post_preserves_all_existing_keys). Gate on the save touching
   instrument settings; GET already virtualizes profiles.
2. pathway is profile-mirrored, so the Gameplay reset (flat-key delete) was a
   no-op. reset_settings now resets pathway inside the persisted profiles too.
3. Per-profile tuning validation rejected provider/custom tunings (tuner
   plugin, /api/tunings). _valid_tuning_for_key now accepts a name unknown to
   every built-in table while still rejecting a built-in misapplied to the
   wrong key.
4. First-migration overwrote an explicit active_instrument_profile with the
   legacy-inferred one, so a fresh-config switch to 'bass' was lost. Use
   setdefault so an explicit request wins.
5. Pre-existing test_instrument_fields_persist used bass-5 + 'Drop D' (a
   4-string tuning). Updated to the valid 'Drop A'.

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

* fix(settings): partial-merge instrument_profiles; clamp tuning on string-count switch

Two partial-update follow-ups:
- save_settings normalized a POSTed instrument_profiles by FILLING every omitted
  profile with defaults and replacing wholesale, so a one-profile update reset
  the others. Validate each PROVIDED profile individually and merge the partial
  over the persisted set inside the lock — /api/settings is partial-merge.
- the string-count picker posted only string_count, so the backend silently
  reset a now-invalid tuning to Standard while the UI kept the old value
  (settings/tuner desync). Clamp + post the valid tuning too, mirroring the
  instrument-switch path.

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

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:16:42 +02:00
41e907fa52 fix(library): serve art/load for songs mounted through a library junction (#766)
* fix(library): serve art/load songs mounted through a library junction

A song library mounted through a directory JUNCTION/symlink subfolder (a
library shared across app installs; the desktop app's own mounts) had broken
album art and couldn't load: the scanner's rglob follows the junction and
indexes the songs, but _resolve_dlc_path (via safe_join's .resolve()) followed
the junction to its real target, saw it outside DLC_DIR, and rejected every
song reached through it → 403 on /art, 404 on /art/candidates, broken covers.

- _resolve_dlc_path now uses LEXICAL containment (os.path.normpath, no symlink
  following) so an in-library junction is allowed, while `..` traversal and
  absolute paths are still rejected (the traversal tests pin this).
- safe_join is left STRICT (.resolve()-based) — it is the zip-slip / plugin-
  asset / avatar guard, where following a symlink out IS the defense — but
  gains an explicit NUL guard (on Python 3.13/Windows resolve() no longer
  raises on an embedded NUL, so the byte was leaking through; strictly-more-
  rejection, no effect on the zip-slip contract).

Tests: test_dlc_junction (junction allowed; `..`/absolute/NUL rejected; the
safe_join-stays-strict contrast). Existing traversal/safepath/art-candidates
suites stay green.

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

* fix(library): reject Windows drive-letter paths in _resolve_dlc_path

The new test_absolute_path_rejected pins 'C:/Windows/system32/x' → None, but on
POSIX a drive-letter path isn't absolute, so Path(dlc)/'C:/…' becomes the
contained relative dir '<dlc>/C:/…' and slipped through the lexical containment
check (red on the Linux CI). Not an escape, but the traversal contract should
hold cross-platform (a shared library is reached from either OS). Reject a path
that is absolute or drive-qualified in either POSIX or Windows semantics before
the containment check. Legitimate relative/junction paths are unaffected.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:15:50 +02:00
2c1c6f7eac fix(starter): sync _BUILTIN_STARTER_SOURCES with content/starter on disk (#775)
Two commits (delete beethoven-ode_to_joy, re-add The Adicts' Ode to Joy) never
updated _BUILTIN_STARTER_SOURCES: it still listed the deleted pack and omitted
the added one. The listed-but-missing file made the all-present gate never fire,
so NO starter content seeded on first run — and the on-disk-but-unlisted pack
would bundle as dead weight. Both starter-seed guard tests were red on main,
reddening ci/test on every core PR. Sync the manifest to disk.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:15:29 +02:00
OmikronApexandGitHub b6169af6aa Delete content/starter/beethoven-ode_to_joy.feedpak 2026-07-03 23:42:45 +02:00
OmikronApexandGitHub a3f1bceb15 Readded Ode to Joy by The adicts 2026-07-03 23:42:22 +02:00
OmikronApexandGitHub c2153b277b Merge pull request #745 from got-feedBack/fix/ffmpeg-autobuild-repin
fix(docker): repin FFmpeg to autobuild-2026-07-03-13-21
2026-07-03 23:20:31 +02:00
OmikronApexandClaude Fable 5 2ffeeaca0b fix(docker): repin FFmpeg to autobuild-2026-07-03-13-21
The previously pinned BtbN autobuild release (2026-06-19) was pruned
upstream, so the release build's curl download 404'd (exit 22).

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

Preference is global, persisted as localStorage v3PlaylistShuffle.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 08:52:20 +02:00
64a499975e v3 library: multi-candidate cover picker — select from a populated list (#732)
* v3 library: multi-candidate cover picker — select from list, media-server style

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 08:49:44 +02:00
8c7cde5d5c v3 library: first-hour polish — zero-states, match progress, provenance, alias search (#730)
* v3 library: first-hour polish — zero-states, match progress, provenance, alias search

Six launch-eve fixes for a brand-new user's first hour with a fresh,
being-matched library. Each is small and reuses shipped idioms.

- Invitational repertoire meter: with no practice data yet, the home meter
  no longer reads "0 of N mastered" (debt framing) — it shows an empty bar
  with "grows as you master songs". A count of 0 read as failure on day one.
- "Start here" starter shelf: growth_edge_suggestions() distinguishes two
  empties — attempts exist but all mastered (honest empty shelf) vs nothing
  attempted yet (day one) → new starter_suggestions() returns up to 8
  approachable songs (90–480s, shortest first) flagged starter:true, and the
  client renders a "Start here" shelf instead of a blank home.
- Library-visible match progress: while the background pass runs, a quiet
  "Matching your library — X of Y" line sits by the review chip (5s poll,
  single guarded interval, cleared the moment the pass stops — no leak,
  no toast, silent completion).
- One-time transparency toast: the first time an install is seen matching a
  real library, one fbNotify names what's contacted (MusicBrainz / Cover Art
  Archive), that results are stored locally, that files aren't changed
  without you, and where the switch is. localStorage-gated, wrapped so a
  blocked notifier can't break the chip.
- Empty-library dead-end card: a genuinely empty local library (no songs, no
  query/filter) shows "Your library is empty" + drop-files hint + Open
  Settings, instead of a bare grid under dead dropdowns.
- Alias-aware search: searching a canonical name ("AC/DC") now also finds
  songs whose raw tag is a merged variant ("ACDC"), via the artist_alias
  table. Probe-guarded so a no-aliases library keeps the exact original
  3-term query; pure predicate, keyset-safe.
- Details-drawer provenance line: matched/manual rows show "Matched:
  <artist — title> (source) · Fix match" under the Identity fields — the
  wrong-match escape hatch at the point of the data, wired to the same
  fix-match flow the card menu uses. New read-only GET
  /api/enrichment/song/{filename} backs it.

Tests: tests/test_starter_suggestions.py (starter vs normal-shelf behaviour,
length window, attempts-exist path unchanged) + alias-search cases added to
tests/test_artist_alias.py. 34 targeted pass; node --check clean; no new
Tailwind classes.

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

* fix(v3 library): stop match-progress poll when leaving the library screen

The 5s enrichment poll (_pollTimer) was cleared on pass completion and on
fetch error, but not when the user navigated away from the library. Leaving
v3-songs mid-pass left the interval pinging /api/enrichment/status in the
background until the pass ended. Subscribe to the existing feedBack
'screen:changed' event: clear the poll when any non-v3-songs screen shows,
and refresh (re-arming if a pass is still running) on returning to v3-songs.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 08:49:04 +02:00
df2d660d1e Re-enable the v3 "Support Us!" donate button (#727)
Funding was cleared to come back online 2026-06-30 (offending
functionality fully removed). Restore the v3 topbar donate button
(hand-re-applied revert of cad7885 — the topbar was refactored since,
so this re-adds the Support Us! anchor alongside the new v3-search-wrap)
pointing at the feedBack-branded Patreon page
https://patreon.com/got_feedback.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:21:10 +02:00
96 changed files with 6474 additions and 428 deletions
+9 -28
View File
@@ -1,5 +1,9 @@
name: Nightly
# Trunk-based: nightly always builds main — the release-branch discovery
# from the old release-centric flow is gone (it pinned nightlies to the
# highest release/v* branch forever, even after it shipped). Stabilization
# builds from release/** come from rc.yml instead.
on:
schedule:
- cron: '0 2 * * *'
@@ -9,33 +13,7 @@ permissions:
contents: read
jobs:
setup:
runs-on: ubuntu-latest
outputs:
branch: ${{ steps.branch.outputs.branch }}
date: ${{ steps.date.outputs.date }}
steps:
- name: Find active release branch
id: branch
env:
GH_TOKEN: ${{ github.token }}
run: |
branch=$(gh api "repos/${{ github.repository }}/git/matching-refs/heads/release/v" \
--jq '[.[].ref | ltrimstr("refs/heads/")] | map(ltrimstr("refs/heads/")) | .[]' \
| sort -V | tail -1 || true)
if [[ -z "$branch" ]]; then
branch="main"
fi
echo "branch=$branch" >> "$GITHUB_OUTPUT"
echo "Active branch: $branch"
- name: Get date
id: date
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
build-docker:
needs: setup
runs-on: ubuntu-latest
permissions:
contents: read
@@ -44,9 +22,12 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.setup.outputs.branch }}
persist-credentials: false
- name: Get date
id: date
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -65,6 +46,6 @@ jobs:
push: true
tags: |
ghcr.io/got-feedback/feedback:nightly
ghcr.io/got-feedback/feedback:nightly-${{ needs.setup.outputs.date }}
ghcr.io/got-feedback/feedback:nightly-${{ steps.date.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
+63
View File
@@ -0,0 +1,63 @@
name: rc
# Release-candidate images for stabilization: every push to a release/**
# branch builds and pushes ghcr.io tags :rc (moving) and
# :rc-<version>-<date> (pinned). Final versioned images still come from
# release.yml on tag push.
on:
push:
branches: ['release/**']
permissions:
contents: read
# One build per branch at a time; a newer push supersedes an in-flight one.
concurrency:
group: rc-${{ github.ref }}
cancel-in-progress: true
jobs:
build-docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Derive RC tags
id: meta
run: |
# release/v0.3.0 -> 0.3.0 (tolerate a missing v prefix too)
version="${GITHUB_REF_NAME#release/}"
version="${version#v}"
date="$(date -u +%Y%m%d)"
{
echo "tags<<TAGS_EOF"
echo "ghcr.io/got-feedback/feedback:rc"
echo "ghcr.io/got-feedback/feedback:rc-${version}-${date}"
echo "TAGS_EOF"
} >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2 -2
View File
@@ -35,9 +35,9 @@ jobs:
# stable releases (no pre-release suffix).
{
echo "tags<<TAGS_EOF"
echo "ghcr.io/${GITHUB_REPOSITORY}:${version}"
echo "ghcr.io/${GITHUB_REPOSITORY,,}:${version}"
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "ghcr.io/${GITHUB_REPOSITORY}:latest"
echo "ghcr.io/${GITHUB_REPOSITORY,,}:latest"
fi
echo "TAGS_EOF"
} >> "$GITHUB_OUTPUT"
+5
View File
@@ -8,6 +8,11 @@ name: ship-ci
on:
pull_request:
branches: [main, 'release/**']
# Trunk-based: post-merge CI on main catches semantic conflicts between
# independently-green PRs; push on release/** covers stabilization
# cherry-picks that land without a PR.
push:
branches: [main, 'release/**']
permissions:
contents: read
+7
View File
@@ -7,10 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
### 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
- **Guitar Pro 6 (`.gpx`) import no longer fails on every real file.** The GPX BCFS container reader (`lib/gp2rs_gpx.py`) rejected any file whose final sector wasn't a full `0x1000` block — but a real `.gpx`'s BCFZ-declared decompressed size isn't sector-aligned, so the last (small) container file always lands in a partial trailing sector. The bounds check *raised* `GPX BCFS sector pointer out of range (malformed file)` instead of clamping the tail read, so `_load_gpif` threw before `score.gpif` could be extracted and **no GP6 file could be imported into the song editor** (both real test files failed identically — this wasn't file-specific). GP7/GP8 `.gp` files were unaffected — they take the ZIP path, not BCFS, which is why prior GP-import work didn't surface it. The reader now **clamps the final sector read to the buffer end** (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro); a sector whose *start* is past the end still raises, preserving the malformed-file guard. Verified against two real GP6 files — both now unpack to valid GPIF with all tracks. Tests: `tests/test_gp2rs_gpx.py` (partial-final-sector round-trip, multi-file container, sector-aligned baseline, and the preserved out-of-range guard).
- **v3 Songs grid: fixed the scroll stutter that "skips every so many scrolls," up or down.** The virtualized grid rebuilt its **entire** visible window (`grid.innerHTML = …` + a full `wireCards` pass) every time it slid by one row, so each row-boundary crossing was a heavy synchronous frame that stalled the main thread and buffered held-arrow key-repeats into a visible lurch (a tester's "super fast for a second then slowed down") at fixed scroll offsets — in **both directions and regardless of whether the page was already loaded** (the cost was DOM teardown, not fetching, which is why scrolling back up over cached songs hitched too). `renderWindow()` now **reconciles the window in place**: it reuses the card nodes that stay on-screen and builds only the row that enters/leaves (~6 nodes per slide instead of ~60), keyed by absolute index with a real-vs-skeleton + select-mode signature so hole-fills (after a page fetch) and select-mode toggles still rebuild exactly the nodes that changed. `wireCards`'s `data-wired` guard then wires only the freshly-built nodes, so per-slide listener churn drops with it. Follow-up to the stage-2 virtualized grid (got-feedback/feedBack#636 item 3). Frontend-only: `static/v3/songs.js`. Tests: `tests/js/v3_songs_window_recycle.test.js` (window stays `[start,end)` contiguous + in-window node identity reused across a down-then-up scroll; select-mode toggle and rail-seek jump rebuild correctly).
- **Starter content seeds again (and now ships The Adicts' "Ode to Joy").** `_BUILTIN_STARTER_SOURCES` still listed `beethoven-ode_to_joy.feedpak` after that pack was deleted, and never wired up its replacement `the_adicts-ode-to-joy_vst_cover.feedpak` that landed on disk. The listed-but-missing file made the all-present gate never fire, so **no** starter songs seeded on first run. Synced the manifest to what's on disk (Für Elise, Star Spangled Banner, The Adicts' Ode to Joy). Tests: `tests/test_builtin_starter_seed.py` (the present/unlisted guards were red on `main`).
- **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.
+8 -8
View File
@@ -47,11 +47,11 @@ RUN cmake -S /tmp/vgmstream -B /tmp/vgmstream/build \
# and update FFMPEG_RELEASE + both SHA256 ARGs below.
FROM alpine:3.20 AS ffmpeg-fetcher
ARG TARGETARCH
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
ARG FFMPEG_SHA256_AMD64=03c0431e0d1aa75cc343d83bda9d2d4cd8eaa37f35b7b93465e9ff6864f5d7f8
ARG FFMPEG_SHA256_ARM64=74629b88342fd94eea12b7481c8b8560ca6d497744123c0a27b98f39d767fd93
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
ARG FFMPEG_SHA256_AMD64=1390e1c320a1e38dae106d6d0b05a6f08eb8b30f732bc1aa0d45a4aa17f13795
ARG FFMPEG_SHA256_ARM64=53b2e30df04d56932b7782234c9bc97abfe0bb242192ca50346474a41b100ab0
RUN apk add --no-cache curl xz \
&& arch="${TARGETARCH:-$(apk --print-arch)}" \
&& case "$arch" in \
@@ -94,9 +94,9 @@ FROM python:3.12-slim
# Re-declare the ffmpeg ARGs so their values are available to LABEL below.
# ARG values don't cross stage boundaries in multi-stage builds; defaults
# must be repeated here to take effect when no --build-arg is supplied.
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
# Apply latest security updates to base packages (clears glibc deb13u3 and
# similar). Done first so any subsequent installs resolve against the
-46
View File
@@ -1,46 +0,0 @@
# fee[dB]ack
## Plugins
| Plugin | Description | Install |
|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| [Create from Tab](https://github.com/got-feedback/feedBack-plugin-ug) | Search Ultimate Guitar for GP tabs and convert to playable songs | `git clone ...feedBack-plugin-ug.git ultimate_guitar` |
| [Import Tab](https://github.com/got-feedback/feedBack-plugin-tabimport) | Drag and drop Guitar Pro files to create songs | `git clone ...feedBack-plugin-tabimport.git tab_import` |
| [Practice Journal](https://github.com/got-feedback/feedBack-plugin-practice) | Auto-track practice time, speed, loops. Dashboard with charts | `git clone ...feedBack-plugin-practice.git practice_journal` |
| [Setlist Builder](https://github.com/got-feedback/feedBack-plugin-setlist) | Create ordered playlists with sequential playback | `git clone ...feedBack-plugin-setlist.git setlist` |
| [Metronome](https://github.com/got-feedback/feedBack-plugin-metronome) | Audible click and visual beat flash synced to song tempo | `git clone ...feedBack-plugin-metronome.git metronome` |
| [Tone Player](https://github.com/got-feedback/feedBack-plugin-tones) | View amp/pedal/cab signal chains with gear artwork | `git clone ...feedBack-plugin-tones.git tones` |
| [Fretboard View](https://github.com/got-feedback/feedBack-plugin-fretboard) | Live fretboard overlay showing active notes in real-time | `git clone ...feedBack-plugin-fretboard.git fretboard` |
| [Tab View](https://github.com/got-feedback/feedBack-plugin-tabview) | Scrolling guitar tablature notation via alphaTab | `git clone ...feedBack-plugin-tabview.git tab_view` |
| [MIDI Amp Control](https://github.com/got-feedback/feedBack-plugin-midi) | Auto-switch amp/modeler presets via MIDI on tone changes | `git clone ...feedBack-plugin-midi.git midi_amp` |
| [Section Map](https://github.com/got-feedback/feedBack-plugin-sectionmap) | Color-coded song structure minimap with clickable navigation | `git clone ...feedBack-plugin-sectionmap.git section_map` |
| [Arrangement Editor](https://github.com/got-feedback/feedBack-plugin-editor) | DAW-like visual editor for creating and editing song note charts | `git clone ...feedBack-plugin-editor.git editor` |
| [MIDI Capo](https://github.com/masc0t/slopsmith-plugin-midi-capo) | MIDI capo control for real-time transposition | `git clone ...slopsmith-plugin-midi-capo.git midi_capo` |
| [Note Detection](https://github.com/got-feedback/feedBack-plugin-notedetect) | Real-time pitch detection and scoring against highway notes | `git clone ...feedBack-plugin-notedetect.git note_detect` |
| [Find More](https://github.com/masc0t/slopsmith-plugin-find-more) | Search for more songs by the same artist | `git clone ...slopsmith-plugin-find-more.git find_more` |
| [Piano Highway](https://github.com/got-feedback/feedBack-plugin-piano) | Scrolling piano/keyboard view for Keys arrangements with MIDI input | `git clone ...feedBack-plugin-piano.git piano` |
| [Studio](https://github.com/got-feedback/feedBack-plugin-studio) | Collaborative band recording and multi-track mixing | `git clone ...feedBack-plugin-studio.git studio` |
| [Drum Highway](https://github.com/got-feedback/feedBack-plugin-drums) | Lane-based drum highway with MIDI drum pad input and built-in sounds | `git clone ...feedBack-plugin-drums.git drums` |
| [Invert Highway](https://github.com/masc0t/slopsmith-plugin-invert-highway) | Flip the highway note direction | `git clone ...slopsmith-plugin-invert-highway.git invert_highway` |
| [Jumping Tab](https://github.com/renanboni/slopsmith-plugin-jumpingtab) | Yousician-style 2D horizontal tab with trajectory arcs and hopping ball | `git clone ...slopsmith-plugin-jumpingtab.git jumpingtab` |
| [Step Mode](https://github.com/got-feedback/feedBack-plugin-stepmode) | Step-by-step practice mode — highway freezes at each note until played (via Note Detection) or Space | `git clone ...feedBack-plugin-stepmode.git step_mode` |
| [Lyrics Sync](https://github.com/got-feedback/feedBack-plugin-lyrics-sync) | Generate synced LRC lyrics from text + vocals stem via Whisper alignment | `git clone ...feedBack-plugin-lyrics-sync.git lyrics_sync` |
| [Lyrics Karaoke](https://github.com/got-feedback/feedBack-plugin-lyrics-karaoke) | Per-syllable karaoke pitch ribbon for sloppak songs (Whisper alignment + librosa pYIN) | `git clone ...feedBack-plugin-lyrics-karaoke.git lyrics_karaoke` |
| [NAM Tone Engine](https://github.com/got-feedback/feedBack-plugin-nam-tone) | In-browser amp modeling with NAM WASM, cabinet IRs, tone auto-switching | `git clone ...feedBack-plugin-nam-tone.git nam_tone` |
| [Guitar Theory Lab](https://github.com/topkoa/slopsmith-plugin-guitar-theory) | Explore scales, chords, intervals, tunings, and voicings on a fully interactive fretboard | `git clone ...slopsmith-plugin-guitar-theory.git guitar-theory-lab` |
| [Themes](https://github.com/masc0t/slopsmith-plugin-themes) | Offers several basic recolorings of the interface | `git clone ...slopsmith-plugin-themes.git themes` |
| [Update Manager](https://github.com/masc0t/slopsmith-update-manager) | Installs, updates, and uninstalls other plugins and the feedBack core itself | `git clone ...slopsmith-update-manager.git update_manager` |
| [Simplify Chords](https://github.com/bkranendonk/slopsmith-plugin-simplify-chords) | Changes complex chords on the note highway to simpler ones. Inspired by Ultimate Guitar's Simplify button. | `git clone ...slopsmith-plugin-simplify-chords.git simplify-chords` |
| [Key Bindings](https://github.com/jackipicco/slopsmith-plugin-key-bindings) | Highway key bindings for keyboard and TV remote | `git clone ...slopsmith-plugin-key-bindings.git key_bindings` |
| [Virtuoso](https://github.com/got-feedback/feedBack-plugin-virtuoso) | Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen. | `git clone ...feedBack-plugin-virtuoso.git virtuoso` |
| [Audio Preview](https://github.com/saleemk/slopsmith-plugin-audio-preview) | Quick audio previews from library cards with configurable start time, volume, and duration | `git clone ...slopsmith-plugin-audio-preview.git audio_preview` |
| [Song Mastery](https://github.com/jamesgaiser/slopsmith-plugin-song-mastery) | Auto-adjusts difficulty based on your rolling note accuracy and saves the slider position per song | `git clone ...slopsmith-plugin-song-mastery.git song_mastery` |
| [Mobile Note Highway](https://github.com/saleemk/slopsmith-plugin-mobile-note-highway) | Touch-optimized player with collapsible controls, highway gestures, and device-adaptive layouts for phones and tablets | `git clone ...slopsmith-plugin-mobile-note-highway.git mobile_note_highway` |
Install any plugin by cloning it into your `plugins/` directory and restarting:
```bash
cd plugins
git clone https://github.com/got-feedback/feedBack-plugin-ug.git ultimate_guitar
docker compose restart
```
+1 -1
View File
@@ -1 +1 @@
0.3.0
0.3.0-alpha.1
Binary file not shown.
Binary file not shown.
+152
View File
@@ -0,0 +1,152 @@
"""AcoustID audio-fingerprint identification for MusicBrainz enrichment.
A flat MusicBrainz *text* search ties every take of a song at the same score —
studio, a dozen live bootlegs, and every compilation — so "AC/DC — Highway to
Hell" returns junk (see lib/mb_match.py's canonical re-ranking, which mitigates
it). The definitive fix is content-based: fingerprint the actual audio with
Chromaprint (`fpcalc`) and look it up on AcoustID, which maps the fingerprint
straight to the *exact* MusicBrainz recording — the same approach Lidarr uses.
This module is the PURE half (no network, no subprocess): response parsing +
config gating, so it is unit-testable in isolation. server.py owns the `fpcalc`
subprocess and the throttled HTTP GET to api.acoustid.org.
Operational requirements (both optional — absent ⇒ this path is a graceful
no-op and the text matcher still runs):
* `fpcalc` (Chromaprint) on PATH or at $FPCALC — generates the fingerprint.
* an AcoustID application API key in $ACOUSTID_API_KEY — free from
https://acoustid.org/new-application ; AcoustID etiquette limits to ~3 req/s.
"""
import os
ACOUSTID_API_ROOT = "https://api.acoustid.org/v2"
# The `meta` fields we ask AcoustID to return so a hit resolves to displayable
# metadata without a second MusicBrainz round-trip. SPACE-separated, not
# `+`-joined: a literal `+` in the value gets percent-encoded to %2B, which
# AcoustID does NOT split into flags — it then attaches no recording metadata
# and every hit comes back empty (verified: `+` → 0 recordings, space → 28).
# `releases` is what carries the per-release DATE (nested under each
# releasegroup), which we need to pick the earliest original album + fill year.
LOOKUP_META = "recordings releasegroups releases compress"
# Mirror mb_match._SECONDARY_SKIP: release-group secondary types that mark a
# non-canonical (live/comp/remix) release, so we can flag the studio take.
_SECONDARY_SKIP = {
"live", "compilation", "remix", "dj-mix", "mixtape/street",
"demo", "interview", "audiobook", "spokenword",
}
def api_key(explicit: str | None = None) -> str:
"""The AcoustID application API key: an explicit value (e.g. a host setting)
wins, else $ACOUSTID_API_KEY, else "" (⇒ fingerprinting disabled)."""
return (explicit or os.environ.get("ACOUSTID_API_KEY") or "").strip()
def is_configured(explicit_key: str | None = None) -> bool:
"""True when an API key is available. `fpcalc` presence is checked by
server.py (it owns the binary lookup); both are required to actually run."""
return bool(api_key(explicit_key))
def _rg_is_studio(rg: dict) -> bool:
if str(rg.get("type", "")).lower() != "album":
return False
secs = {str(s).lower() for s in (rg.get("secondarytypes") or [])}
return not (secs & _SECONDARY_SKIP)
def _rg_earliest_year(rg: dict) -> "int | None":
"""Earliest release YEAR in a release-group (min over its nested releases'
dates). None when no release carries a date. This is what separates the
original pressing from later reissues/comps sharing the same group."""
years = []
for rel in (rg.get("releases") or []):
d = (rel or {}).get("date")
if isinstance(d, dict) and d.get("year"):
try:
years.append(int(d["year"]))
except (TypeError, ValueError):
pass
return min(years) if years else None
def _best_group(recording: dict) -> dict:
"""Pick the display album: a clean studio Album first, and among those the
EARLIEST-released one — the original, not a later reissue or a compilation
that happens to be typed 'Album' (e.g. a soundtrack). This is what pulls
"Machine Head" ahead of a later comp for "Smoke on the Water". Falls back to
the first group when nothing is a studio album or nothing carries a date."""
groups = [g for g in (recording.get("releasegroups") or []) if isinstance(g, dict)]
if not groups:
return {}
def sort_key(g):
yr = _rg_earliest_year(g)
# studio (0) before non-studio (1); then earliest year (undated last).
return (0 if _rg_is_studio(g) else 1, yr if yr is not None else 9999)
return sorted(groups, key=sort_key)[0]
def _first_artist(recording: dict) -> str:
for a in (recording.get("artists") or []):
if isinstance(a, dict) and a.get("name"):
return str(a["name"])
return ""
def parse_lookup_response(body: dict) -> list[dict]:
"""Normalize an AcoustID /v2/lookup response into the same flat candidate
shape as mb_match (recording_id / title / artist / album / year / duration /
studio / mb_score / score), so the review UI and the editor's Match popup
render fingerprint hits and text hits identically. `mb_score` carries the
AcoustID confidence (0-100) — a fingerprint hit is high-signal by nature."""
if not isinstance(body, dict) or body.get("status") != "ok":
return []
out: list[dict] = []
seen: set[str] = set()
for result in (body.get("results") or []):
if not isinstance(result, dict):
continue
try:
score = float(result.get("score") or 0.0)
except (TypeError, ValueError):
score = 0.0
for rec in (result.get("recordings") or []):
if not isinstance(rec, dict) or not rec.get("id"):
continue
rid = str(rec["id"])
if rid in seen:
continue
seen.add(rid)
rg = _best_group(rec)
_yr = _rg_earliest_year(rg)
year = str(_yr) if _yr else ""
dur = rec.get("duration")
try:
duration = int(round(float(dur))) if dur else None
except (TypeError, ValueError):
duration = None
out.append({
"recording_id": rid,
"title": str(rec.get("title", "") or ""),
"artist": _first_artist(rec),
"album": str(rg.get("title", "") or ""),
"year": year,
"duration": duration,
"isrc": "",
"genres": [],
"studio": _rg_is_studio(rg),
"acoustid_score": round(score, 4),
# Fingerprint hits are content-verified, not text-guessed — carry
# the AcoustID confidence as the display score band.
"mb_score": int(round(score * 100)),
"score": round(score, 4),
"source": "acoustid",
})
# Best AcoustID confidence first; studio take breaks ties.
out.sort(key=lambda c: (c["acoustid_score"], 1 if c["studio"] else 0), reverse=True)
return out
+1 -1
View File
@@ -34,7 +34,7 @@ _QSTRING_SECRET_RE = re.compile(
r"(?i)\b(api[_-]?key|key|token|secret|password|pwd|auth)=([^\s&\"']+)"
)
_SONG_FILENAME_RE = re.compile(
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|wem|ogg|mp3|wav)\b",
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|feedpak|wem|ogg|mp3|wav)\b",
re.IGNORECASE,
)
+15 -3
View File
@@ -121,10 +121,18 @@ def _parse_bcfs(bcfs: bytes) -> dict:
while sc <= max_sectors:
s = _gi(po + 4 * sc); sc += 1
if s == 0: break
so = s * SECTOR
if HDR + so + SECTOR > len(data):
start = HDR + s * SECTOR
# Real .gpx files' final sector is a few bytes short of a full
# 0x1000 block: the BCFZ-declared decompressed size isn't
# sector-aligned, so the last (small) container file lands in a
# partial trailing sector. Clamp the read to the buffer end —
# the per-file size field (`fs`, applied below) trims any
# padding — matching canonical GPX readers (alphaTab /
# PyGuitarPro slice-and-clamp). Only a sector whose *start* is
# past the end is genuinely malformed.
if start < 0 or start >= len(data):
raise ValueError("GPX BCFS sector pointer out of range (malformed file)")
fb.extend(data[HDR + so: HDR + so + SECTOR])
fb.extend(data[start: min(start + SECTOR, len(data))])
else:
raise ValueError("GPX BCFS sector chain too long (malformed file)")
files[fn] = bytes(fb[:fs])
@@ -1498,6 +1506,10 @@ def convert_file(
# Surface that to the caller rather than only the docstring: if the score
# actually uses repeats, the produced bar count/timing will differ from the
# equivalent .gp5. Warn once so plugin code/logs don't silently drift.
# NB: lib/gp_autosync.gp_has_expandable_repeats() encodes this single-pass
# behaviour (.gp/.gpx never expand). Implementing GPIF expansion here MUST
# update that helper in the same change, or the editor's per-bar sync warp
# would silently retime repeated sections onto the wrong bars.
if expand_repeats and any(
mb.find('Repeat') is not None or mb.find('AlternateEndings') is not None
for mb in masterbars
+500 -28
View File
@@ -18,8 +18,22 @@ plugin is installed; graceful ImportError otherwise with clear message).
Public API:
is_available() -> bool
auto_sync(gp_path, audio_path, ...) -> GpSyncData
refine_sync(sync, audio_path, ...) -> GpSyncData
estimate_audio_offset(gp_path,
audio_path) -> float
bar_start_times(gp_path) -> list[float]
gp_has_expandable_repeats(gp_path) -> bool
build_warp_anchors(sync_points,
bar_starts) -> list[tuple[float, float]]
warp_time(t, anchors) -> float
warp_song_times(song, warp) -> None
The warp helpers (bar_start_times / build_warp_anchors / warp_time /
warp_song_times) are librosa-free: they turn a GpSyncData produced by
auto_sync (or extracted from a GP8 file) into a piecewise-linear
score-time -> audio-time mapping and apply it to a lib.song.Song, so
converted charts follow the recording's actual tempo drift instead of a
single scalar offset.
"""
from __future__ import annotations
@@ -353,15 +367,22 @@ def _synthesise_score_chroma(
return chroma
_GP345_TICKS_PER_QUARTER = 960
# PyGuitarPro absolute ticks start at quarterTime (measure 1 begins at tick
# 960, not 0). All tick math in this module runs on a 0-based axis (cumulative
# measure starts), so raw beat.start values must be shifted by this origin —
# mixing the two axes applied every mid-song tempo change a quarter note late
# and skewed the synthesised chroma against the bar timeline.
_GP345_TICK_ORIGIN = 960
def _gp345_tempo_events(song) -> list[tuple[int, float]]:
"""Sorted, tick-deduplicated ``[(tick, bpm)]`` tempo events for a GP3/4/5 song.
Seeds with the song's initial tempo at tick 0, then appends every
``mixTableChange`` tempo. Shared by chroma synthesis and bar-time
computation so both use one identical tempo model (mirrors
``gp2rs._build_tempo_map``).
``mixTableChange`` tempo. Ticks are normalised to the 0-based axis
(raw ``beat.start`` minus ``_GP345_TICK_ORIGIN``). Shared by chroma
synthesis and bar-time computation so both use one identical tempo
model (mirrors ``gp2rs._build_tempo_map``).
"""
events: list[tuple[int, float]] = [(0, float(song.tempo))]
for track in song.tracks:
@@ -371,7 +392,10 @@ def _gp345_tempo_events(song) -> list[tuple[int, float]]:
if beat.effect and beat.effect.mixTableChange:
mtc = beat.effect.mixTableChange
if mtc.tempo and mtc.tempo.value > 0:
events.append((beat.start, float(mtc.tempo.value)))
events.append((
max(0, beat.start - _GP345_TICK_ORIGIN),
float(mtc.tempo.value),
))
events.sort(key=lambda e: e[0])
seen_ticks: set[int] = set()
unique: list[tuple[int, float]] = []
@@ -459,8 +483,9 @@ def _synthesise_score_chroma_gp345(
for beat in voice.beats:
if not beat.notes:
continue
beat_secs = tick_to_secs(beat.start)
cur_tempo = tempo_at_tick(beat.start)
beat_tick = max(0, beat.start - _GP345_TICK_ORIGIN)
beat_secs = tick_to_secs(beat_tick)
cur_tempo = tempo_at_tick(beat_tick)
dur_secs = duration_to_secs(beat.duration, cur_tempo)
for note in beat.notes:
@@ -520,6 +545,45 @@ def _dtw_align(
# ── Sync point extraction from DTW path ──────────────────────────────────────
def _gpif_bar_starts(root: ET.Element) -> list[float]:
"""Score-time (seconds) at the start of each masterbar in a GPIF score.
Integrates bar durations from the bar-resolution tempo map and each
masterbar's time signature — the same time model _synthesise_score_chroma
uses, so bar times land where the bars sit in the synthesised chroma.
"""
tempo_map = _get_tempo_map(root)
masterbars = _children(root, 'MasterBars')
tempo_iter = iter(tempo_map)
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
ct = tempo_map[0][1]
t_cur = 0.0
bar_starts: list[float] = []
for mb_idx, mb in enumerate(masterbars):
while mb_idx >= next_tb:
ct = next_bpm
next_tb, next_bpm = next(tempo_iter, (999999, ct))
bar_starts.append(t_cur)
ts = mb.findtext('Time', '4/4')
try:
n_b, d_b = [int(x) for x in ts.split('/')]
except ValueError:
n_b, d_b = 4, 4
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
return bar_starts
def _gp345_measure_start_ticks(song) -> list[int]:
"""Cumulative start tick of each measure in a PyGuitarPro song."""
starts: list[int] = []
cum = 0
for mh in song.measureHeaders:
starts.append(cum)
ts = mh.timeSignature
cum += int(ts.numerator * (4.0 / ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
return starts
def _extract_sync_points(
wp: 'np.ndarray',
root: ET.Element,
@@ -565,22 +629,7 @@ def _extract_sync_points(
if bar_starts_override is not None:
bar_starts_score = list(bar_starts_override)
else:
tempo_iter = iter(tempo_map)
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
ct = tempo_map[0][1]
t_cur = 0.0
bar_starts_score = []
for mb_idx, mb in enumerate(masterbars):
while mb_idx >= next_tb:
ct = next_bpm
next_tb, next_bpm = next(tempo_iter, (999999, ct))
bar_starts_score.append(t_cur)
ts = mb.findtext('Time', '4/4')
try:
n_b, d_b = [int(x) for x in ts.split('/')]
except ValueError:
n_b, d_b = 4, 4
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
bar_starts_score = _gpif_bar_starts(root)
# Map each sampled bar to its audio time via the DTW path
sync_points: list[SyncPoint] = []
@@ -663,6 +712,224 @@ def _tempo_at_bar(tempo_map: list[tuple[int, float]], bar: int) -> float:
# ── Audio offset estimation ───────────────────────────────────────────────────
# ── Piecewise time warp (librosa-free) ───────────────────────────────────────
#
# auto_sync's per-bar sync points describe where each sampled bar of the tab
# falls in the real recording. Applying only the scalar audio_offset (bar 1)
# assumes the recording holds the authored tempo for the whole song — any
# drift accumulates. These helpers build the full piecewise-linear
# score-time -> audio-time mapping and apply it to a converted Song, so the
# chart follows the recording bar by bar (Songsterr-style sync).
def bar_start_times(gp_path: str) -> list[float]:
"""Score-time (seconds) at the start of every bar of a GP file.
Uses the same tempo models as auto_sync's chroma synthesis (GPIF
bar-resolution map for .gp/.gpx, per-tick integration for .gp3/4/5), so
the returned times share an axis with auto_sync's sync points.
Raises ValueError if the file cannot be parsed, ImportError if the file
is GP3/4/5 and PyGuitarPro is not installed.
"""
try:
root = _load_gpif(gp_path)
except _Gp345FileError:
import guitarpro
try:
song = guitarpro.parse(gp_path)
except Exception as exc:
raise ValueError(f"Cannot parse GP3/4/5 file {gp_path!r}: {exc}") from exc
tempo_events = _gp345_tempo_events(song)
return [
_gp345_tick_to_secs(tempo_events, tick)
for tick in _gp345_measure_start_ticks(song)
]
return _gpif_bar_starts(root)
def gp_has_expandable_repeats(gp_path: str) -> bool:
"""True when converting `gp_path` expands repeats into a longer timeline
than the as-written score auto_sync aligned against.
gp2rs.convert_file walks the GP3/4/5 playback graph (repeat brackets,
voltas, D.S./D.C. directions), so a file using any of those produces an
as-performed timeline that auto_sync's as-written sync points cannot be
mapped onto. GPIF (.gp/.gpx) conversion is single-pass as-written today,
so those files always return False both sides share one bar order.
Returns False when the file cannot be parsed (callers fall back to
offset-only sync on parse failure anyway).
"""
if Path(gp_path).suffix.lower() in ('.gp', '.gpx'):
return False
try:
import guitarpro
song = guitarpro.parse(gp_path)
except Exception:
return False
for mh in song.measureHeaders:
if mh.isRepeatOpen or mh.repeatClose >= 0 or mh.repeatAlternative:
return True
# Both jump SOURCES (fromDirection: D.C., D.S., Da Coda) and jump
# TARGETS (direction: Segno, Coda, Fine) count — a plain Da Capo
# needs no target marker, so checking `direction` alone would miss
# it while gp2rs's playback walker still expands the jump.
if (getattr(mh, 'direction', None) is not None
or getattr(mh, 'fromDirection', None) is not None):
return True
return False
def build_warp_anchors(
sync_points: list[SyncPoint],
bar_starts: list[float],
) -> list[tuple[float, float]]:
"""Turn sync points into (score_secs, audio_secs) anchor pairs.
Drops points whose bar index is out of range, points that would break
strict monotonicity on either axis (DTW can locally fold on noisy audio;
a non-monotonic anchor would make the warp non-invertible and reorder
notes), and points whose segment slope implies a physically implausible
tempo ratio (outside 0.2x-5x authored). Returns [] when fewer than 2
usable anchors remain callers should fall back to scalar-offset sync
in that case.
"""
anchors: list[tuple[float, float]] = []
for sp in sorted(sync_points, key=lambda p: p.bar):
if not 0 <= sp.bar < len(bar_starts):
continue
score_t = bar_starts[sp.bar]
audio_t = float(sp.time_secs)
if anchors and (score_t <= anchors[-1][0] + 1e-6
or audio_t <= anchors[-1][1] + 1e-3):
continue
if anchors:
# Slope sanity gate: a segment whose audio/score tempo ratio is
# outside [0.2, 5] is not a performance — it's a DTW fold onto a
# repeated section, an abridged recording, or a run of
# monotonicity-clamped refine points. Keeping it would crush (or
# absurdly stretch) every bar in the span, which is far worse
# than interpolating through from the neighbouring anchors.
slope = (audio_t - anchors[-1][1]) / (score_t - anchors[-1][0])
if not 0.2 <= slope <= 5.0:
continue
anchors.append((score_t, audio_t))
return anchors if len(anchors) >= 2 else []
def warp_time(t: float, anchors: list[tuple[float, float]]) -> float:
"""Map a score-time (seconds) to audio-time via piecewise-linear anchors.
Between anchors: linear interpolation. Outside the anchor range: the
nearest segment's slope is extended, so a count-in before bar 1 and the
tail after the last sampled bar keep the local tempo ratio.
`anchors` must be the >=2-point strictly-monotonic list produced by
build_warp_anchors.
"""
lo = 0
hi = len(anchors) - 1
if t <= anchors[0][0]:
seg = (anchors[0], anchors[1])
elif t >= anchors[hi][0]:
seg = (anchors[hi - 1], anchors[hi])
else:
# Binary search for the segment containing t
while hi - lo > 1:
mid = (lo + hi) // 2
if anchors[mid][0] <= t:
lo = mid
else:
hi = mid
seg = (anchors[lo], anchors[hi])
(s0, a0), (s1, a1) = seg
slope = (a1 - a0) / (s1 - s0)
return a0 + (t - s0) * slope
def warp_song_times(song, warp) -> None:
"""Apply a monotonic time-mapping callable to every absolute time in a
lib.song.Song, in place.
Covers beats, sections, song_length, and per-arrangement notes (onset +
sustain), chords (incl. chord notes), anchors, hand shapes, per-phrase
difficulty levels, tone changes, and tempo overrides. Durations (note
sustain, handshape span) are warped as end-start so they stretch with the
local tempo ratio; sub-second intra-note envelopes (bend curves, which are
relative to the note onset) are left untouched.
Duck-typed: accepts any object with the lib.song.Song surface.
Identity-safe: parse_arrangement shares the SAME Note/Chord/Anchor/
HandShape objects between the flat arrangement lists and the
max-difficulty phrase level, so each object is warped at most once no
matter how many containers reference it.
"""
seen: set[int] = set()
def _once(obj) -> bool:
key = id(obj)
if key in seen:
return False
seen.add(key)
return True
def _warp_notes(notes):
for n in notes or []:
if not _once(n):
continue
end = warp(n.time + n.sustain)
n.time = warp(n.time)
n.sustain = max(0.0, end - n.time)
def _warp_chords(chords):
for c in chords or []:
if not _once(c):
continue
c.time = warp(c.time)
_warp_notes(c.notes)
def _warp_anchors(anchors):
for a in anchors or []:
if _once(a):
a.time = warp(a.time)
def _warp_handshapes(shapes):
for h in shapes or []:
if not _once(h):
continue
start = warp(h.start_time)
end = warp(h.end_time)
h.start_time = start
h.end_time = max(start, end)
song.song_length = max(0.0, warp(song.song_length))
for b in song.beats:
b.time = warp(b.time)
for s in song.sections:
s.start_time = warp(s.start_time)
for arr in song.arrangements:
_warp_notes(arr.notes)
_warp_chords(arr.chords)
_warp_anchors(arr.anchors)
_warp_handshapes(arr.hand_shapes)
for ph in arr.phrases or []:
ph.start_time = warp(ph.start_time)
ph.end_time = warp(ph.end_time)
for lvl in ph.levels or []:
_warp_notes(lvl.notes)
_warp_chords(lvl.chords)
_warp_anchors(lvl.anchors)
_warp_handshapes(lvl.hand_shapes)
if arr.tones and isinstance(arr.tones, dict):
for change in arr.tones.get('changes') or []:
if isinstance(change, dict) and isinstance(change.get('t'), (int, float)):
change['t'] = warp(float(change['t']))
for tempo_ev in arr.tempos or []:
if isinstance(tempo_ev, dict) and isinstance(tempo_ev.get('time'), (int, float)):
tempo_ev['time'] = warp(float(tempo_ev['time']))
def _estimate_audio_offset(
root: ET.Element,
audio_path: str,
@@ -932,12 +1199,7 @@ def auto_sync(
# below line up with the chroma timeline.
_tempo_events_gp345 = _gp345_tempo_events(_gp345x_song)
# Convert tick events to bar events using actual measure start ticks
_measure_starts = [] # cumulative tick at start of each bar
_cum = 0
for _mh2 in _gp345x_song.measureHeaders:
_measure_starts.append(_cum)
_ts = _mh2.timeSignature
_cum += int(_ts.numerator * (4.0 / _ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
_measure_starts = _gp345_measure_start_ticks(_gp345x_song)
def _tick_to_bar(tick):
"""Return 0-based bar index for a given tick position."""
@@ -1024,6 +1286,216 @@ def auto_sync(
sync_points=sync_points,
)
def refine_sync(
sync: GpSyncData,
audio_path: str,
bars_per_point: int = 8,
gp_path: str | None = None,
sr: int = _SR,
search_radius: float = 0.35,
phase_step: float = 0.005,
onset_tolerance: float = 0.05,
) -> GpSyncData:
"""Refine coarse DTW sync points with a per-bar onset phase sweep.
auto_sync's mid-song points inherit the DTW frame granularity (~186ms at
the default hop). This pass re-times a denser grid of bars every
`bars_per_point`-th bar plus the first and last by sweeping a local
beat grid (±`search_radius`s in `phase_step` steps) against detected
onsets and keeping the phase that aligns best, narrowing each kept point
to roughly the phase-step resolution on percussive material.
Args:
sync: Coarse sync data from auto_sync (or a prior refine).
audio_path: The same audio file auto_sync aligned against.
bars_per_point: Refined-point density; every Nth bar gets a point.
gp_path: Optional path to the GP file. When given, exact
per-bar score times (bar_start_times) drive the
densified grid; without it the grid is limited to
a 4/4 approximation built from the points' authored
tempos, and accuracy degrades on odd meters.
sr: Analysis sample rate.
search_radius: ±seconds around each coarse estimate to sweep.
phase_step: Sweep resolution in seconds.
onset_tolerance: Max onset-to-click distance that counts as aligned.
Returns:
A new GpSyncData with the refined (and usually denser) points and a
recomputed audio_offset. Returns `sync` unchanged when it has no
usable points. Quiet bars (fewer than 4 onsets nearby) keep their
coarse interpolated time rather than locking onto noise.
"""
if not sync.sync_points:
return sync
pts = sorted(sync.sync_points, key=lambda p: p.bar)
bar_starts: list[float] | None = None
if gp_path:
try:
bar_starts = bar_start_times(gp_path)
except Exception as exc:
_log.warning("refine_sync: bar_start_times(%s) failed (%s) — "
"falling back to 4/4 tempo model", gp_path, exc)
if bar_starts is None:
# Approximate score bar starts from the points' authored tempos,
# assuming 4 beats per bar (all GpSyncData carries without the file).
max_bar = pts[-1].bar
bar_starts = [0.0]
ti = 0
cur_bpm = pts[0].original_tempo or 120.0
for b in range(1, max_bar + 1):
while ti + 1 < len(pts) and pts[ti + 1].bar <= b - 1:
ti += 1
cur_bpm = pts[ti].original_tempo or cur_bpm
bar_starts.append(bar_starts[-1] + 4 * 60.0 / max(cur_bpm, 1e-3))
anchors = build_warp_anchors(pts, bar_starts)
if len(anchors) < 2:
_log.warning("refine_sync: fewer than 2 usable anchors — returning "
"input unchanged")
return sync
# Authored-tempo lookup via the shared bar-map scan (_tempo_at_bar) so
# boundary semantics can't drift from the rest of the module.
_orig_map = [(p.bar, p.original_tempo or 120.0) for p in pts]
def _orig_bpm_at(bar: int) -> float:
return max(_tempo_at_bar(_orig_map, bar), 1e-3)
n_bars = len(bar_starts)
step = max(1, int(bars_per_point))
targets = sorted(set(range(0, n_bars, step)) | {n_bars - 1})
# Deferred past the pure early-return paths above so degenerate inputs
# (no points, <2 anchors) resolve without librosa installed.
import librosa
import numpy as np
y, _ = librosa.load(audio_path, sr=sr, mono=True)
audio_dur = len(y) / sr
hop = 512 # ~23ms at 22050Hz — fine enough for onset timing
onset_frames = librosa.onset.onset_detect(
y=y, sr=sr, hop_length=hop, backtrack=True
)
onset_times = np.asarray(
librosa.frames_to_time(onset_frames, sr=sr, hop_length=hop)
)
refined: list[tuple[int, float]] = []
for b in targets:
score_t = bar_starts[b]
coarse = warp_time(score_t, anchors)
if coarse > audio_dur + 1.0:
break # bar falls past the end of the recording
# Local beat period in AUDIO time: authored beat period scaled by the
# local warp slope (recording tempo / authored tempo around this bar).
slope = warp_time(score_t + 1.0, anchors) - coarse
slope = min(max(slope, 0.25), 4.0)
beat_period = (60.0 / _orig_bpm_at(b)) * slope
# Keep the scoring grid short: beat_period is estimated from the
# coarse anchors (a few % off), and grid drift grows linearly with
# distance — 16 beats at 2% error is already ~150ms of skew at the
# far end, which drags the sweep. 8 beats bounds that to ~beat noise.
grid_span = 8 * beat_period
# Clamp the sweep window below half a beat so the neighbouring beat
# is never a candidate — on periodic material (steady drums) a grid
# shifted by one whole beat scores identically and the sweep could
# lock a full beat off. DTW coarse error is ~1 analysis frame, which
# this window still covers at all but extreme tempos.
radius = min(search_radius, 0.45 * beat_period)
w_lo = coarse - radius - onset_tolerance
w_hi = coarse + radius + grid_span + onset_tolerance
local = onset_times[(onset_times >= w_lo) & (onset_times <= w_hi)]
if len(local) < 4:
refined.append((b, coarse))
continue
best_t, best_score, best_dist = coarse, -1, 0.0
for phase in np.arange(coarse - radius, coarse + radius + 1e-9,
phase_step):
clicks = np.arange(phase, phase + grid_span, beat_period)
score = int(sum(
1 for t in local
if float(np.min(np.abs(clicks - t))) < onset_tolerance
))
dist = abs(float(phase) - coarse)
# Ties break toward the coarse estimate so a flat score surface
# (sustained pads, sparse onsets) can't drag the point sideways.
if score > best_score or (score == best_score and dist < best_dist):
best_score, best_t, best_dist = score, float(phase), dist
# A sweep that matched almost nothing found a spurious edge
# alignment, not the beat grid — this happens when the true phase
# lies outside the (ambiguity-clamped) window, e.g. fast tempos
# where the DTW coarse error exceeds half a beat. Keeping the
# coarse estimate degrades gracefully instead of locking a
# fraction of a beat off.
if best_score < 3:
refined.append((b, coarse))
continue
# The onset-count score is flat within ±onset_tolerance of the true
# phase, so the sweep alone can be off by up to the tolerance. Snap
# inside that plateau: shift by the median residual between matched
# onsets and their nearest grid click. Only the first few beats
# count here — they are nearly insensitive to beat_period error,
# while far clicks would leak that error into the residuals.
if best_score > 0:
clicks = np.arange(best_t, best_t + 4 * beat_period + 1e-9,
beat_period)
residuals = []
for t in local:
d = clicks - float(t)
j = int(np.argmin(np.abs(d)))
if abs(d[j]) < onset_tolerance:
residuals.append(-float(d[j])) # onset minus click
if residuals:
best_t += float(np.median(residuals))
refined.append((b, best_t))
if not refined:
return sync
# Enforce monotonicity: a point refined earlier than its predecessor
# would fold the warp. Clamp to a small positive gap.
mono: list[tuple[int, float]] = []
prev_t: float | None = None
for b, t in refined:
t = max(t, 0.0)
if prev_t is not None and t <= prev_t + 0.02:
t = prev_t + 0.02
mono.append((b, t))
prev_t = t
# Recompute per-segment modified tempos from the refined times (same
# formula _extract_sync_points uses; the last point carries the previous
# segment's tempo forward).
new_points: list[SyncPoint] = []
for i, (b, t) in enumerate(mono):
obpm = _orig_bpm_at(b)
if i + 1 < len(mono):
b2, t2 = mono[i + 1]
score_seg = bar_starts[b2] - bar_starts[b]
audio_seg = t2 - t
mod = obpm * (score_seg / audio_seg) if audio_seg > 1e-3 else obpm
mod = max(20.0, min(300.0, mod))
else:
mod = new_points[-1].modified_tempo if new_points else obpm
new_points.append(SyncPoint(
bar=b, time_secs=t, modified_tempo=mod, original_tempo=obpm,
))
_log.info("refine_sync: %d points (was %d), audio_offset=%.3fs",
len(new_points), len(pts), -new_points[0].time_secs)
return GpSyncData(
audio_offset=-new_points[0].time_secs,
audio_asset_id=sync.audio_asset_id,
sync_points=new_points,
)
def estimate_audio_offset(gp_path: str, audio_path: str) -> float:
"""
Estimate the audio_offset for a GP file aligned to an audio file.
+125 -14
View File
@@ -39,6 +39,14 @@ DURATION_BONUS_LOOSE = 0.025 # …within 15s
_DURATION_TIGHT = 5
_DURATION_LOOSE = 15
# Release-group secondary types that mark a NON-canonical release (a live album,
# a greatest-hits comp, a remix/DJ set, …). Used both to pick the canonical
# studio album for display and to reward studio recordings in ranking.
_SECONDARY_SKIP = {
"live", "compilation", "remix", "dj-mix", "mixtape/street",
"demo", "interview", "audiobook", "spokenword",
}
# ── 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,
@@ -136,12 +144,31 @@ def _duration_int(v):
return None
def cand_artist_sim(song: dict, cand: dict) -> float:
"""Best artist similarity between the song's reference artist and the
candidate's PRIMARY name OR any of its `artist_aliases` (romanized/alternate
names). MusicBrainz stores many artists under a non-Latin primary name
(大橋純子) with the romanized form ("Junko Ohashi") only as an alias, so a
reference typed/derived in romaji scores 0 against the primary but 1.0
against the alias. The caller (server) attaches `artist_aliases` only for
promising near-misses, so this is a plain max when they're present and the
original single comparison when they're not."""
best = similarity(song.get("artist"), cand.get("artist"), artist=True)
for alias in cand.get("artist_aliases") or []:
if best >= 1.0:
break
s = similarity(song.get("artist"), alias, artist=True)
if s > best:
best = s
return best
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)
artist_sim = cand_artist_sim(song, cand)
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"))
@@ -154,6 +181,10 @@ def score_candidate(song: dict, cand: dict) -> float:
score += DURATION_BONUS
elif diff <= _DURATION_LOOSE:
score += DURATION_BONUS_LOOSE
# NB: the studio-vs-live distinction is deliberately NOT scored here — a live
# take is still the RIGHT SONG (same title/artist), so it must not change the
# auto/review confidence. Canonical-version preference lives in the RANK sort
# (rank_candidates) instead, where it only reorders same-song candidates.
return min(score, 1.0)
@@ -168,7 +199,7 @@ def classify(song: dict, cand: dict, score: float, auto_min: float | None = None
"""
if auto_min is None:
auto_min = AUTO_MIN
artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True)
artist_sim = cand_artist_sim(song, cand)
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):
@@ -179,15 +210,34 @@ def classify(song: dict, cand: dict, score: float, auto_min: float | None = 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)."""
"""Score every candidate against the song and return them sorted best-first.
The combined `score` caps at 1.0, so a perfect-text-match query (every "AC/DC
Highway to Hell" recording) ties at the top — there the studio flag and, when
the caller knows the audio length, the duration match break the tie so the
canonical studio take wins over live/promo/extended cuts. Each returned dict
is a copy carrying `score` (rounded it's displayed and stored)."""
sd = _duration_int(song.get("duration"))
# For a chart that IS a live take (build_recording_query keeps live
# recordings for these) the studio take is the WRONG recording, so drop the
# studio tiebreak — duration proximity + text/mb score then pick the right
# live version instead of auto-matching the studio one.
prefer_studio = not _LIVE_GROUP_RE.search(str(song.get("title") or ""))
def _dur_diff(c):
cd = _duration_int(c.get("duration"))
return abs(sd - cd) if (sd and cd) else 10 ** 6
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)
ranked.sort(
key=lambda c: (c["score"],
(1 if c.get("studio") else 0) if prefer_studio else 0,
-_dur_diff(c), # closest to the audio length
c.get("mb_score") or 0),
reverse=True)
return ranked
@@ -198,18 +248,63 @@ def _lucene_escape_phrase(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"')
def build_recording_query(artist, title) -> str:
# A parenthetical/bracketed "(Live …)" marker — the live signal denoise() strips
# from the title. Mirrors _NOISE_GROUP_RE but for the `live` term only.
_LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE)
def build_recording_query(artist, title, *, loose: bool = False) -> 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."""
poison the search server's own scoring.
``loose=True`` drops the field-scoped quoted PHRASES for plain AND-ed
term groups (``(telephone number) AND (junko ohashi)``). The point:
a field phrase like ``artist:"Junko Ohashi"`` only matches MusicBrainz's
*primary* artist name it never searches ALIASES so a recording stored
under a non-Latin primary (大橋純子) whose romanized name is only an alias
is invisible to the strict query. A loose term query searches the whole
document, aliases included, and surfaces it. Lower precision by design: it
is a FALLBACK for when the strict query returns nothing, and its results
are re-scored by ``rank_candidates`` (and, for auto-match, gated by the
per-field floors), so noise never auto-applies."""
t = denoise(title)
a = denoise(artist)
if loose:
# denoise() already reduced each field to lowercase [a-z0-9 and] tokens
# (punctuation → spaces, diacritics stripped, & → "and"), so no
# Lucene-special character survives to need escaping. Group each field's
# terms and require both groups.
q = " AND ".join("(%s)" % g for g in (t, a) if g)
# Keep the SAME live exclusion as the strict path: the loose query is
# lower-precision, and score_candidate doesn't penalize a live take, so
# without this a studio chart whose strict query missed could fall back
# to — and auto-confirm — a live-only recording. Skipped only when the
# source title is itself a live take (mirrors the strict path).
if q and not _LIVE_GROUP_RE.search(str(title or "")):
q += " AND -secondarytype:Live"
return q
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)
q = " AND ".join(parts)
# Drop live-ONLY recordings (bootlegs, live albums) — the canonical studio
# take is never tagged Live, and this is the single biggest source of junk in
# a flat recording search. Compilations are deliberately NOT excluded: they
# REUSE the studio recording, so filtering them would drop the very recording
# we want (verified against MusicBrainz — `-secondarytype:Compilation` cut the
# AC/DC studio "Highway to Hell" recording entirely).
#
# EXCEPT when the source chart is itself a live take: denoise() strips the
# "(Live at …)" qualifier from the query, so filtering Live would leave the
# genuinely-live chart with NO correct recording. Only a parenthetical marker
# counts — a bare title word ("Live and Let Die") is a real word, not a live
# tag — mirroring what denoise removes.
if q and not _LIVE_GROUP_RE.search(str(title or "")):
q += " AND -secondarytype:Live"
return q
def _artist_credit(doc: dict) -> tuple[str, str, str]:
@@ -226,19 +321,33 @@ def _artist_credit(doc: dict) -> tuple[str, str, str]:
return name, str(artist.get("id", "") or ""), str(artist.get("sort-name", "") or "")
def _is_clean_studio_album(rg: dict) -> bool:
"""A release-group that is a primary-type Album with NO non-canonical
secondary type (Live / Compilation / Remix / ) i.e. a studio album."""
if str(rg.get("primary-type", "")).lower() != "album":
return False
secs = {str(s).lower() for s in (rg.get("secondary-types") or [])}
return not (secs & _SECONDARY_SKIP)
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."""
"""Pick the release used for canon album/year: prefer an OFFICIAL studio
Album (primary Album with no Live/Compilation/ secondary type), then the
earliest date. Falls back to any release when none is clean. {} 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
clean = 0 if _is_clean_studio_album(rg) else 1
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
date = str(r.get("date", "") or "9999")
return (status_ok, album_ok, date)
# Official FIRST, then prefer a clean studio album: this still surfaces
# the studio album over an (official) live/comp album for the display
# album/year, but never lets an UNofficial bootleg album outrank an
# official single/EP/comp — which `(clean, status_ok, …)` would.
return (status_ok, clean, date)
return sorted(releases, key=sort_key)[0]
@@ -261,6 +370,7 @@ def parse_recording_doc(doc: dict) -> dict | None:
return None
artist_name, artist_id, artist_sort = _artist_credit(doc)
release = _best_release(doc)
studio = _is_clean_studio_album(release.get("release-group") or {})
length = doc.get("length")
try:
duration = int(round(float(length) / 1000.0)) if length else None
@@ -281,6 +391,7 @@ def parse_recording_doc(doc: dict) -> dict | None:
"isrc": isrcs[0] if isrcs else "",
"genres": _genres(doc),
"mb_score": int(doc.get("score") or 0),
"studio": studio,
}
+7
View File
@@ -26,6 +26,13 @@ def safe_join(root: Path, name: str) -> Path | None:
"""
if not name:
return None
# Reject embedded NULs explicitly. This used to ride on `.resolve()`
# raising ValueError, but on Python 3.13 (Windows) resolve() no longer
# raises for an embedded NUL, so the byte would otherwise leak through
# containment. An explicit guard is strictly-more-rejection (no effect on
# the zip-slip / traversal contract).
if "\x00" in name:
return None
safe = name.replace("\\", "/")
try:
root_resolved = root.resolve()
+1605 -139
View File
File diff suppressed because it is too large Load Diff
+52 -23
View File
@@ -6215,7 +6215,7 @@ window.feedBack.on('song:ready', () => {
setSpeed(pend.speed);
}
} catch (_) { /* speed restore is best-effort */ }
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'resume'))
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'session-resume'))
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
.catch((err) => console.warn('[app] resume failed:', err));
});
@@ -6781,7 +6781,16 @@ window.feedBack.playQueue = (function () {
if (!files.length) return false;
list = files.slice(); idx = 0;
source = (opts && opts.source) || '';
arrangements = (opts && opts.arrangements) || null;
arrangements = (opts && opts.arrangements) ? opts.arrangements.slice() : null;
if (opts && opts.shuffle && list.length > 1) {
// Fisher-Yates, once at start. Swap arrangements in lockstep so an
// album slot's pinned arrangement stays glued to its file (#685).
for (let i = list.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[list[i], list[j]] = [list[j], list[i]];
if (arrangements) [arrangements[i], arrangements[j]] = [arrangements[j], arrangements[i]];
}
}
if (window.fbNotify) {
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
}
@@ -10988,20 +10997,19 @@ async function loadPlugins() {
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
});
const livePluginIds = new Set(plugins.map((plugin) => plugin.id));
for (const [pluginId, contributions] of _pluginUiContributions) {
if (livePluginIds.has(pluginId)) continue;
const stalePlugin = { id: pluginId };
for (const contribution of contributions) {
await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution);
}
try {
window.feedBack?.capabilities?.unregisterParticipant?.(pluginId);
} catch (e) {
console.warn(`capability participant unregister failed for ${pluginId}:`, e);
}
_pluginUiContributions.delete(pluginId);
}
// NOTE deliberately NO stale-contribution sweep for plugins absent
// from this response. Absent ≠ uninstalled: the backend clears its
// plugin registry at the start of load_plugins() and repopulates it
// incrementally while HTTP stays up, so every backend restart serves a
// window of partial (even empty) responses. The old sweep unmounted UI
// contributions and unregistered capability participants on mere
// absence, permanently breaking still-loaded plugins — their scripts
// don't re-run (loadedScripts guard below), so nothing ever
// re-registered. A genuine mid-session uninstall now leaves the
// (already-evaluated, un-unloadable) script's contributions in place
// until reload; its nav entry still disappears because nav is rebuilt
// from the response each round. Same invariant as the settings/screen
// DOM wipe and _reconcilePluginStyles below.
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
try {
@@ -11143,17 +11151,23 @@ async function loadPlugins() {
loadedStyles.set(plugin.id, wantedVersion);
};
const _reconcilePluginStyles = (currentPlugins) => {
// Drop stylesheets for plugins that vanished from /api/plugins or are
// no longer ready+styled this round. _injectPluginStyles below only
// visits plugins still returned by the API, so an uninstalled or
// newly-not-ready plugin would otherwise keep its <link> applying.
// Drop stylesheets for plugins the response KNOWS about but that
// are no longer ready+styled this round. _injectPluginStyles below
// only visits plugins still returned by the API, so a newly-not-
// ready or unstyled plugin would otherwise keep its <link>
// applying. Plugins merely ABSENT from the response keep their
// stylesheet — a transient partial response during a backend
// restart is not an uninstall (same invariant as the screen/
// settings wipe below), and stripping the <link> would leave a
// still-loaded plugin visible but unstyled.
const responded = new Set(currentPlugins.map((p) => p.id));
const styled = new Set(
currentPlugins
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
.map((p) => p.id),
);
for (const id of Array.from(loadedStyles.keys())) {
if (!styled.has(id)) {
if (responded.has(id) && !styled.has(id)) {
_removePluginStyleTags(id);
loadedStyles.delete(id);
}
@@ -11166,6 +11180,18 @@ async function loadPlugins() {
if (pid) existingSettingsByPluginId.set(pid, child);
}
}
// Plugins named in THIS response. A plugin can be transiently absent
// from /api/plugins — the backend clears its registry at the start of
// load_plugins() and repopulates it incrementally while HTTP stays up,
// so every backend restart serves a window of partial (even empty)
// responses. The wipe loops below must never treat that absence as an
// uninstall: stripping a still-loaded plugin's DOM while keeping its
// loadedScripts entry made the NEXT refetch fail the DOM check and
// re-evaluate its screen.js mid-session — which duplicated the desktop
// audio_engine's native signal chain (its init re-ran against the
// surviving engine chain). Absent plugins keep their DOM and script;
// they're re-reconciled when they reappear in a later response.
const respondedIds = new Set(plugins.map((p) => p.id));
const alreadyHydrated = new Set();
for (const p of plugins) {
if (!p.has_script) continue;
@@ -11193,7 +11219,10 @@ async function loadPlugins() {
for (const container of _pluginSettingsContainers()) {
[...container.children].forEach((el) => {
const pid = el.dataset ? el.dataset.pluginId : null;
if (!pid || !alreadyHydrated.has(pid)) el.remove();
// Remove junk (no plugin id) and plugins the response KNOWS
// about but that failed hydration; leave plugins absent from
// the response untouched (see respondedIds above).
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
});
}
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
@@ -11202,7 +11231,7 @@ async function loadPlugins() {
// change shipped — both forms strip a single leading "plugin-".
const pid = (el.dataset && el.dataset.pluginId)
|| el.id.replace(/^plugin-/, '');
if (!alreadyHydrated.has(pid)) el.remove();
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
});
// Plugin settings area hosts both "Plugin Updates" and per-plugin
+1 -1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+295
View File
@@ -0,0 +1,295 @@
// Cover-art picker (PR-C — multi-candidate "change cover", media-server
// style). ONE component: window.__fbOpenImagePicker({filename, title}),
// reached from the Details drawer's art click and the card ⋮ "Change cover…".
//
// Anatomy mirrors match-review.js (body-appended singleton: overlay +
// centred panel, light focus trap, Esc closes, overlay click closes) but
// layers at z-[200] — the songs.js centered-modal tier — because one of its
// openers is the details drawer (z-[61]), which sits above match-review's
// z-40/50 pair.
//
// The design's key trick (§7-§9/§11 of the launch charrette): a pick never
// grows a new write path. Choosing a CAA candidate POSTs its thumb URL to
// the EXISTING …/art/url route (the override lane: never evicted, survives
// a re-match); "Pack original" DELETEs the override; Upload POSTs the
// existing …/art/upload (GIF stays upload-only + local-only; the server's
// 10MB / http(s) guards apply to URLs). Success is silent (hearing-safe,
// like the match layer): the modal just closes and the art refreshes.
(function () {
'use strict';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
// Provenance badge text — same vocabulary as the match layer.
const PROV_LABEL = { yours: 'Yours', pack: 'Pack', matched: 'Matched' };
let _cur = null; // {filename, title} while the picker is open
let _abort = null; // in-flight candidates fetch — cancelled on close
let _busy = false; // an apply is running — ignore further tile clicks
let _lastFocus = null;
const artBase = (fn) => '/api/song/' + enc(fn) + '/art';
// Post-apply refresh — the grid's cache-buster idiom (`?v=`): re-src
// every rendered <img> pointing at this song's art with a fresh v so the
// new pick paints everywhere it's currently shown (grid card, drawer
// preview, list row) without a full reload.
function refreshArt(fn) {
const base = artBase(fn);
document.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('src') || '';
if (src.split('?')[0] === base) {
img.src = base + '?v=' + Date.now();
img.style.visibility = 'visible';
}
});
}
function ensureModal() {
let m = document.getElementById('v3-imgpick-modal');
if (m) return m;
const overlay = document.createElement('div');
overlay.id = 'v3-imgpick-overlay';
overlay.className = 'fixed inset-0 bg-black/60 z-[200] hidden';
overlay.addEventListener('click', close);
document.body.appendChild(overlay);
m = document.createElement('div');
m.id = 'v3-imgpick-modal';
// Appended after the overlay: same z tier, DOM order paints it above.
m.className = 'fixed inset-0 z-[200] hidden flex items-center justify-center p-4 pointer-events-none';
m.innerHTML = '<div id="v3-imgpick-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="Change cover"></div>';
m.addEventListener('keydown', onKeydown);
document.body.appendChild(m);
return m;
}
function onKeydown(e) {
if (e.key === 'Escape') { e.stopPropagation(); close(); return; }
if (e.key !== 'Tab') return;
// Light focus trap: cycle within the panel (mirrors match-review).
const panel = document.getElementById('v3-imgpick-panel');
if (!panel) return;
// Only trap VISIBLE focusables: hidden tiles (?source=pack 404 →
// onerror .hidden, unloadable candidates, .hidden buttons) must never
// catch a Tab. offsetParent is null for display:none / .hidden.
const foci = Array.from(
panel.querySelectorAll('button:not(.hidden), input:not(.hidden), [tabindex="0"]'),
).filter((el) => el.offsetParent !== null);
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 close() {
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } _abort = null; }
document.getElementById('v3-imgpick-modal')?.classList.add('hidden');
document.getElementById('v3-imgpick-overlay')?.classList.add('hidden');
_cur = null;
_busy = false;
if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { /* */ } }
_lastFocus = null;
}
// One tile: a 6rem square art/icon face + a caption underneath.
function tileHtml(attrs, face, label, hidden) {
return '<button ' + attrs + ' class="group w-24 shrink-0 text-center' + (hidden ? ' hidden' : '') + '">' +
'<span class="w-24 h-24 rounded-lg overflow-hidden bg-fb-card border border-fb-border/50 hover:border-fb-primary/60 flex items-center justify-center">' + face + '</span>' +
'<span class="block text-xs text-fb-textDim group-hover:text-fb-text truncate pt-1">' + esc(label) + '</span></button>';
}
const imgFace = (src) => '<img src="' + esc(src) + '" alt="" loading="lazy" class="w-full h-full object-cover">';
const iconFace = (glyph) => '<span class="text-2xl text-fb-textDim">' + glyph + '</span>';
const SKELETON_TILE = '<span class="w-24 h-24 rounded-lg bg-fb-card animate-pulse shrink-0"></span>';
function render(panel) {
const fn = _cur.filename;
// Fresh ?v so a reopened picker never shows a stale "current".
const curSrc = artBase(fn) + '?v=' + Date.now();
panel.innerHTML =
'<div class="flex items-center justify-between gap-3 p-5 pb-3 border-b border-fb-border/40 shrink-0">' +
'<div class="min-w-0"><h3 class="text-lg font-semibold text-fb-text">Change cover</h3>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(_cur.title || fn) + '</div></div>' +
'<button data-ip-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>' +
'<div class="p-5 flex flex-col sm:flex-row items-start gap-5 overflow-y-auto v3-scroll">' +
// Left: the current cover + its provenance.
'<div class="shrink-0">' +
'<img data-ip-current src="' + esc(curSrc) + '" alt="" class="w-24 h-24 rounded-lg object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
'<div class="pt-1 flex items-center gap-1.5">' +
'<span class="text-xs text-fb-textDim">Current</span>' +
'<span data-ip-prov class="hidden text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700"></span>' +
'</div></div>' +
// Right: the candidate tiles. First row acts instantly; CAA
// candidates land behind the one /art/candidates fetch.
'<div class="min-w-0 flex-1 space-y-3">' +
'<div class="flex flex-wrap gap-3">' +
tileHtml('data-ip-act="keep"', imgFace(curSrc), 'Current') +
// Pack tile renders instantly and self-hides when the song ships
// no art of its own (?source=pack 404s → img onerror); the
// candidates response reconciles it either way.
tileHtml('data-ip-act="pack"', imgFace(artBase(fn) + '?source=pack'), 'Pack original') +
tileHtml('data-ip-act="upload"', iconFace('⤒'), 'Upload') +
tileHtml('data-ip-act="url"', iconFace('🔗'), 'Paste URL') +
'</div>' +
'<div data-ip-caa>' +
'<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + SKELETON_TILE + '</div>' +
'<div class="text-xs text-fb-textDim pt-2">Fetching covers… the source is rate-limited.</div>' +
'</div>' +
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
'</div></div>' +
'<input type="file" accept="image/*" data-ip-file class="hidden">';
wire(panel);
}
function wire(panel) {
panel.querySelector('[data-ip-close]')?.addEventListener('click', close);
// The pack tile self-hides when there is no pack art to show.
const packTile = panel.querySelector('[data-ip-act="pack"]');
const packImg = packTile ? packTile.querySelector('img') : null;
if (packImg) packImg.onerror = () => packTile.classList.add('hidden');
const file = panel.querySelector('[data-ip-file]');
file?.addEventListener('change', () => {
const f = file.files && file.files[0];
if (!f) return;
const rd = new FileReader();
rd.onload = (e) => apply('upload', e.target.result);
rd.readAsDataURL(f);
});
panel.querySelectorAll('[data-ip-act]').forEach((btn) => {
btn.addEventListener('click', async () => {
if (_busy) return;
const act = btn.getAttribute('data-ip-act');
if (act === 'keep') { close(); return; }
if (act === 'pack') { apply('pack'); return; }
if (act === 'upload') { file?.click(); return; }
if (act === 'url') {
// window.prompt is a silent no-op in Electron — use the
// project's injection-safe async modal; fall back to prompt
// only if it isn't loaded (mirrors other v3 callers' guard).
const ask = (typeof window.uiPrompt === 'function')
? window.uiPrompt({
title: 'Paste URL',
label: 'Paste an image link (http or https)',
okLabel: 'Set cover',
placeholder: 'https://…',
})
: Promise.resolve(window.prompt('Paste an image link (http or https)'));
const u = String((await ask) || '').trim();
if (u) apply('url', u);
}
});
});
panel.querySelector('[data-ip-close]')?.focus();
}
// The one candidates fetch, cancelled if the modal closes first. Failure
// (offline, demo mode, aborted) is silent: the skeletons just clear and
// the instant tiles remain — never an error wall.
function loadCandidates(panel) {
const fn = _cur.filename;
// Reopening without an intervening close() can leave a prior fetch in
// flight — cancel it so only the newest request settles the tiles.
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } }
_abort = new AbortController();
fetch('/api/song/' + enc(fn) + '/art/candidates', { signal: _abort.signal })
.then((r) => (r.ok ? r.json() : null))
.then((body) => { if (_cur && _cur.filename === fn) patchCandidates(panel, body); })
.catch(() => { if (_cur && _cur.filename === fn) patchCandidates(panel, null); });
}
function patchCandidates(panel, body) {
const wrap = panel.querySelector('[data-ip-caa]');
if (!wrap) return;
const list = (body && body.candidates) || [];
// Reconcile the instant tiles with what the server actually knows.
const cur = list.find((c) => c.kind === 'current');
const badge = panel.querySelector('[data-ip-prov]');
if (badge && cur && PROV_LABEL[cur.provenance]) {
badge.textContent = PROV_LABEL[cur.provenance];
badge.classList.remove('hidden');
}
const packTile = panel.querySelector('[data-ip-act="pack"]');
if (packTile) packTile.classList.toggle('hidden', !list.some((c) => c.kind === 'pack'));
const caa = list.filter((c) => c.kind === 'caa' && c.thumb_url);
if (!caa.length) { wrap.innerHTML = ''; return; }
wrap.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim pb-2">Online covers</div>' +
'<div class="flex flex-wrap gap-3">' +
caa.map((c, i) => tileHtml(
'data-ip-cand="' + i + '"',
imgFace(c.thumb_url),
c.label || 'Cover')).join('') +
'</div>';
wrap.querySelectorAll('[data-ip-cand]').forEach((btn) => {
// A candidate whose thumb can't load isn't offerable — hide it
// rather than let a click apply an image nobody saw.
const img = btn.querySelector('img');
if (img) img.onerror = () => btn.classList.add('hidden');
btn.addEventListener('click', () => {
if (_busy) return;
const c = caa[Number(btn.getAttribute('data-ip-cand'))];
if (c) apply('url', c.thumb_url);
});
});
}
// Apply a pick through the EXISTING routes; silent on success (close +
// cache-busted refresh), inline note on failure (the modal stays open so
// another tile can be tried).
async function apply(kind, arg) {
const fn = _cur && _cur.filename;
if (!fn || _busy) return;
_busy = true;
let ok = false;
try {
let r = null;
if (kind === 'url') {
r = await fetch('/api/song/' + enc(fn) + '/art/url', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: arg }),
});
} else if (kind === 'upload') {
r = await fetch('/api/song/' + enc(fn) + '/art/upload', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: arg }),
});
} else if (kind === 'pack') {
r = await fetch('/api/art/' + enc(fn) + '/override', { method: 'DELETE' });
}
if (r && r.ok) {
// The art routes report soft failures as {error} bodies.
const body = await r.json().catch(() => ({}));
ok = !body.error;
}
} catch (_) { ok = false; }
_busy = false;
if (ok) { close(); refreshArt(fn); return; }
const status = document.querySelector('#v3-imgpick-panel [data-ip-status]');
if (status) {
status.textContent = 'Couldnt set that cover — try another image.';
status.classList.remove('hidden');
}
}
function openImagePicker(opts) {
const filename = opts && opts.filename;
if (!filename) return;
_lastFocus = document.activeElement;
_cur = { filename: filename, title: (opts && opts.title) || filename };
_busy = false;
const m = ensureModal();
const panel = document.getElementById('v3-imgpick-panel');
render(panel);
m.classList.remove('hidden');
document.getElementById('v3-imgpick-overlay')?.classList.remove('hidden');
loadCandidates(panel);
}
window.__fbOpenImagePicker = openImagePicker;
})();
+24 -1
View File
@@ -122,7 +122,7 @@
the inline brand is the no-JS fallback. -->
<aside id="v3-sidebar" class="w-64 border-r border-fb-border/50 flex-col shrink-0 hidden md:flex">
<div id="v3-brand" class="p-6">
<span class="font-extrabold tracking-tight text-fb-text text-xl">fee<span class="text-fb-primary">[dB]</span>ack</span>
<img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">
</div>
<nav id="v3-nav" class="flex-1 overflow-y-auto px-3 pb-6 space-y-6" aria-label="Primary"></nav>
</aside>
@@ -788,6 +788,13 @@
</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>
<!-- Audio fingerprint (AcoustID) — opt-in, default OFF. Wired by match-review.js. -->
<div class="fb-srow-wide mb-1">
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Audio fingerprint (AcoustID)</div>
<label class="flex items-center gap-2 text-xs text-gray-400 mb-1"><input type="checkbox" id="acoustid-enabled" class="rounded border-gray-600 bg-dark-700 text-accent"> Identify by audio — reads the recording itself for the exact version (studio vs live/extended)</label>
<input type="text" id="acoustid-api-key" placeholder="AcoustID application key" class="w-full bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
<div class="text-[11px] text-gray-600 mt-1">Opt-in. Get a free key at acoustid.org/new-application; the fpcalc (Chromaprint) binary must be on the server's PATH.</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">
@@ -802,6 +809,19 @@
<span id="enrich-status" class="text-xs text-gray-500"></span>
</div>
</div>
<!-- Artist pages (PR-B — wired by static/v3/match-review.js). Sits
beside the Metadata matching card; the Settings→Library tab
regroup is a separate PR. -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
<div class="fb-srow-title">Artist pages</div>
<div class="fb-srow-desc">A page for every artist in your library — their songs, albums and your practice progress, built entirely from your local collection. External links (official site, tour dates, videos, social) come from one MusicBrainz lookup per matched artist and always open in your browser — nothing plays in-app, and they stay off until you opt in.</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="artist-pages-enabled" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Artist pages</label>
<label class="flex items-center gap-2"><input type="checkbox" id="artist-external-links" class="rounded border-gray-600 bg-dark-700 text-accent"> Show external links (opens your browser)</label>
</div>
</div>
<!-- Backup -->
<div class="fb-srow fb-srow-stack">
<div class="fb-srow-main">
@@ -1242,6 +1262,9 @@
<!-- 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>
<!-- Before songs.js: the drawer art click + card ⋮ "Change cover…" open
the cover picker (window.__fbOpenImagePicker). -->
<script src="/static/v3/image-picker.js"></script>
<script src="/static/v3/songs.js"></script>
<script src="/static/v3/lessons.js"></script>
<script src="/static/v3/dashboard.js"></script>
+152 -6
View File
@@ -35,9 +35,52 @@
// ── Ambient chip + the Settings card's status line ───────────────────────
// songs.js renders `#v3-songs-match-review` (hidden) in its toolbar and
// calls window.__fbMatchReviewChip() after each toolbar build; review
// actions here re-call it. The same fetch feeds the Settings status line.
// actions here re-call it. The same fetch feeds the Settings status line
// and, while a pass is running, a quiet toolbar progress line (below).
// Silent on failure — surfaces just stay as they are.
let _chipBusy = false;
let _pollTimer = null; // 5s status poll, alive ONLY while a pass runs
// Quiet library-visible progress (launch polish): a plain text line next
// to the review chip while the background pass is working through the
// queue — "Matching your library — X of Y". No toast, no sound; it simply
// disappears when the pass finishes (hearing-safe, design §11).
function _setProgressLine(running, states, total) {
let el = document.getElementById('v3-songs-match-progress');
const unscanned = states.unscanned || 0;
if (!running || unscanned <= 0 || total <= 0) {
if (el) el.remove();
return;
}
if (!el) {
const chip = document.getElementById('v3-songs-match-review');
if (!chip || !chip.parentElement) return; // songs toolbar not on screen
el = document.createElement('span');
el.id = 'v3-songs-match-progress';
el.className = 'text-xs text-fb-textDim';
chip.insertAdjacentElement('afterend', el);
}
el.textContent = 'Matching your library — ' + Math.max(0, total - unscanned) + ' of ' + total;
}
// One-time transparency toast (launch polish): the first time this
// install is observed actually matching a real library, say plainly what
// is contacted, where results live, and where the switch is. Wrapped like
// app.js's fbNotify calls so a blocked localStorage / absent notifier can
// never break the chip.
function _announceOnce(running, total) {
try {
if (!running || total <= 0) return;
if (localStorage.getItem('fb_enrich_announce_v1')) return;
localStorage.setItem('fb_enrich_announce_v1', '1');
window.fbNotify?.show({
title: 'Library matching is on',
message: 'Song info and covers come from MusicBrainz and Cover Art Archive, stored locally. Your files are never changed unless you choose to write to them. Adjust in Settings → Library.',
icon: '📚',
});
} catch (_) { /* storage/notifier unavailable — skip quietly */ }
}
async function refreshChip() {
if (_chipBusy) return;
_chipBusy = true;
@@ -62,7 +105,24 @@
if (st.unscanned) parts.push(st.unscanned + ' queued');
line.textContent = (body.running ? 'Matching… · ' : '') + parts.join(' · ');
}
} catch (_) { /* offline — leave as-is */ } finally {
const running = !!body.running;
const total = body.total_songs || 0;
_setProgressLine(running, st, total);
_announceOnce(running, total);
// Poll only while a pass is actually running; a single guarded
// interval, cleared the moment the pass stops (no leaks).
if (running && !_pollTimer) {
_pollTimer = setInterval(refreshChip, 5000);
} else if (!running && _pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
} catch (_) {
// Offline — leave surfaces as they are, but stop any poll so a
// dead server isn't pinged every 5s forever (the next toolbar
// build / settings open restarts it if a pass is still running).
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
} finally {
_chipBusy = false;
}
}
@@ -286,7 +346,8 @@
'<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>' +
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button>' +
'<button data-mr-identify class="text-sm text-fb-primary hover:text-fb-primaryHi" title="Fingerprint this song\'s audio to find the exact recording">Identify by audio</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>' : '') +
@@ -335,6 +396,7 @@
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(); } });
panel.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(panel, song));
}
// Silent-on-success: the chart just leaves the queue and the next one
@@ -387,6 +449,52 @@
});
}
// "Identify by audio" — fingerprint the song's OWN master audio (AcoustID)
// and render the hits into the same search-results area. The reliable path
// when text search can't tell the studio take from live/comp versions.
async function runIdentify(panel, song) {
const out = panel.querySelector('[data-mr-search-results]');
const sp = panel.querySelector('[data-mr-search-panel]');
if (!out) return;
sp?.classList.remove('hidden'); // give the results somewhere to render
out.innerHTML = '<p class="text-xs text-fb-textDim">Fingerprinting audio…</p>';
let body = null, status = 0;
try {
const r = await fetch('/api/enrichment/identify/' + enc(song.filename), { method: 'POST' });
status = r.status;
body = await r.json().catch(() => null);
} catch (_) { /* falls through to the no-results line */ }
// Honest states — never a fake hit.
if (status === 412 || (body && body.needs_setup)) {
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is off — enable AcoustID and add a free API key to use it.</p>';
return;
}
if (status === 404) {
out.innerHTML = "<p class=\"text-xs text-fb-textDim\">No full-mix audio to fingerprint for this song.</p>";
return;
}
if (status === 503) {
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is unavailable right now — try again.</p>';
return;
}
const cands = (body && body.candidates) || [];
if (!cands.length) {
out.innerHTML = '<p class="text-xs text-fb-textDim">No fingerprint match — try text search.</p>';
return;
}
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Fingerprint matches (AcoustID)</div>' +
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, {
@@ -415,14 +523,27 @@
['enrich-apply-year', 'enrich_apply_year'],
['enrich-apply-genres', 'enrich_apply_genres'],
['enrich-apply-art', 'enrich_apply_art'],
// Artist pages (PR-B): the page itself — local-only, default ON.
['artist-pages-enabled', 'artist_pages_enabled'],
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
if (!toggles.length && !sel && !btn) return;
// Default-OFF toggles load with the opposite absent-key semantic
// (checked only when explicitly true): the external-links row is
// opt-IN per the dev-chat thread.
const optInToggles = [
['artist-external-links', 'artist_external_links'],
// Audio fingerprinting is opt-in (needs a key + fpcalc), default OFF.
['acoustid-enabled', 'acoustid_enabled'],
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
const acoustidKeyEl = document.getElementById('acoustid-api-key');
if (!toggles.length && !optInToggles.length && !sel && !btn) return;
(async () => {
try {
const r = await fetch('/api/settings');
if (r.ok) {
const cfg = await r.json();
for (const [el, key] of toggles) el.checked = cfg[key] !== false;
for (const [el, key] of optInToggles) el.checked = cfg[key] === true;
if (acoustidKeyEl) acoustidKeyEl.value = cfg.acoustid_api_key || '';
if (sel) {
const t = Number(cfg.enrich_auto_threshold);
const want = Number.isFinite(t) ? t : 0.9;
@@ -442,11 +563,12 @@
refreshChip(); // also fills #enrich-status
})();
const save = (key, value) => post('/api/settings', { [key]: value });
for (const [el, key] of toggles) {
for (const [el, key] of toggles.concat(optInToggles)) {
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));
acoustidKeyEl?.addEventListener('change', () => save('acoustid_api_key', acoustidKeyEl.value.trim()));
btn?.addEventListener('click', async () => {
await post('/api/enrichment/kick');
const line = document.getElementById('enrich-status');
@@ -455,10 +577,34 @@
});
}
// Stop the 5s poll when the library screen is left — the progress line and
// chip only live in the songs toolbar, so polling off-screen is pure waste
// (benign but tidy). Re-entering v3-songs re-arms it: songs.js re-calls
// window.__fbMatchReviewChip() on screen enter, and we also refresh here so
// this stays self-contained. Same single-guarded-interval invariant as
// refreshChip — no double-interval, cleared to null.
function wireScreenTeardown() {
const sm = window.feedBack;
if (!sm || typeof sm.on !== 'function') return;
sm.on('screen:changed', (e) => {
const id = e && e.detail && e.detail.id;
if (id === 'v3-songs') {
refreshChip(); // returning while a pass runs re-arms the poll
} else if (_pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wireSettingsCard, { once: true });
document.addEventListener('DOMContentLoaded', () => {
wireSettingsCard();
wireScreenTeardown();
}, { once: true });
} else {
wireSettingsCard();
wireScreenTeardown();
}
window.__fbMatchReviewChip = refreshChip;
+28 -3
View File
@@ -211,7 +211,12 @@
'<div class="flex items-center justify-between mb-6 gap-3">' +
'<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 ' + (isAlbum ? 'album' : 'all') + '</button>' : '') +
(pl.songs.length
? '<button id="v3-pl-shuffle" class="px-2 py-2 rounded-md" aria-pressed="false">' +
'<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/></svg>' +
'</button>' +
'<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>' : '') +
@@ -226,6 +231,26 @@
: '<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);
// Shuffle toggle (crossing arrows, next to Play). Persisted globally —
// one preference, not per playlist. The queue is shuffled once when
// Play starts (playQueue.start's shuffle opt); the stored playlist
// order is never touched.
const shuffleBtn = root.querySelector('#v3-pl-shuffle');
const shuffleOn = () => { try { return localStorage.getItem('v3PlaylistShuffle') === '1'; } catch (_) { return false; } };
const paintShuffle = () => {
if (!shuffleBtn) return;
const on = shuffleOn();
shuffleBtn.className = on
? 'px-2 py-2 rounded-md border border-fb-primary bg-fb-primary hover:bg-fb-primaryHi text-white'
: 'px-2 py-2 rounded-md border border-fb-border text-fb-textDim hover:text-fb-text';
shuffleBtn.title = on ? 'Shuffle: on' : 'Shuffle: off';
shuffleBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
};
paintShuffle();
shuffleBtn?.addEventListener('click', () => {
try { localStorage.setItem('v3PlaylistShuffle', shuffleOn() ? '0' : '1'); } catch (_) { /* private mode */ }
paintShuffle();
});
// 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. An ALBUM plays
@@ -244,8 +269,8 @@
if (!files.length) return;
if (window.feedBack && window.feedBack.playQueue) {
window.feedBack.playQueue.start(files, isAlbum
? { source: pl.name, arrangements: arrs }
: { source: pl.name });
? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() }
: { source: pl.name, shuffle: shuffleOn() });
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
});
const listEl = root.querySelector('#v3-pl-songs');
+11 -1
View File
@@ -192,6 +192,9 @@
}
// ── Topbar ───────────────────────────────────────────────────────────---
// Funding cleared to come back online 2026-06-30 (offending functionality
// removed). feedBack-branded Patreon page.
const PATREON_URL = 'https://patreon.com/got_feedback';
function renderTopbar() {
const bar = document.getElementById('v3-topbar');
if (!bar) return;
@@ -209,6 +212,12 @@
'<input id="v3-search" type="search" placeholder="Search songs…" aria-label="Search songs" ' +
'class="w-full bg-gray-800/50 border border-gray-700 rounded-md pl-10 pr-4 py-2 text-sm ' +
'text-fb-text placeholder-fb-textDim focus:border-fb-primary focus:ring-1 focus:ring-fb-primary outline-none"></div>' +
// Support Us! — stays on this top utility row (NOT the title row),
// pushed to the right with ml-auto; hidden on the smallest widths.
'<a href="' + PATREON_URL + '" target="_blank" rel="noopener" class="ml-auto ' +
'hidden sm:inline-flex items-center gap-2 bg-fb-accent hover:bg-red-600 text-white text-sm font-medium px-4 py-2 rounded-md shadow-lg shadow-fb-accent/20 transition-colors">' +
'<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14.8 3c-3 0-5.4 2.4-5.4 5.4S11.8 13.9 14.8 13.9 20.2 11.5 20.2 8.4 17.8 3 14.8 3zM3.8 3h3.4v18H3.8z"/></svg>' +
'Support Us!</a>' +
'</div>' +
// Row 2 — page header: title + ONLY the tuner/instrument/profile
// badge cluster on the same line as the header.
@@ -329,7 +338,8 @@
// ── Boot ────────────────────────────────────────────────────────────────
async function boot() {
if (window.fbBrand) window.fbBrand.renderWordmark(document.getElementById('v3-brand'), { size: 'text-xl' });
var _v3brand = document.getElementById('v3-brand');
if (_v3brand) _v3brand.innerHTML = '<img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">';
renderSidebar();
renderTopbar();
ensureBackdrop();
+809 -84
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -45,7 +45,7 @@ module.exports = {
cardMuted: '#0b1220', // inset wells
primary: '#0ea5e9', // sky — primary actions, active nav, progress fill
primaryHi: '#38bdf8', // hover
accent: '#ef4444', // red — destructive, low-accuracy
accent: '#ef4444', // red — Support Us, destructive, low-accuracy
text: '#f8fafc', // primary text
textDim: '#94a3b8', // secondary text
border: '#334155', // hairlines / card borders
+13 -5
View File
@@ -35,10 +35,11 @@ function buildFacade() {
'return _hwcInstallFacade;',
].join('\n');
const params = [
'window', 'HWC_SLOTS', 'console',
'window', 'HWC_SLOTS', 'HWC_PRESETS', 'console',
'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors',
'_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape',
'applyHighwayStringColors', 'encodeHighwayColorShare', 'decodeHighwayColorShare',
'applyHighwayStringColors', 'applyHighwayStringPreset',
'encodeHighwayColorShare', 'decodeHighwayColorShare',
];
const listeners = {};
@@ -64,14 +65,19 @@ function buildFacade() {
_hwcEffectiveIndexColors: (map, sc, isBass) => ['eff', sc, isBass],
_hwcChartShape: () => ({ sc: 6, isBass: false }),
applyHighwayStringColors: (m) => { calls.push(['apply', m]); },
applyHighwayStringPreset: (id) => { calls.push(['preset', id]); return true; },
encodeHighwayColorShare: (n, m) => 'SLOPHWY2.CODE',
decodeHighwayColorShare: (c) => ({ name: 'x', colors: {} }),
};
const HWC_PRESETS = [
{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } },
];
const installer = new Function(...params, body)(
win, HWC_SLOTS, console,
win, HWC_SLOTS, HWC_PRESETS, console,
stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors,
stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape,
stubs.applyHighwayStringColors, stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
stubs.applyHighwayStringColors, stubs.applyHighwayStringPreset,
stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
);
installer();
return { api: win.feedBack.highwayColors, win, bus, calls, installer, stubs };
@@ -87,11 +93,13 @@ test('facade exposes the documented surface', () => {
const { api } = buildFacade();
assert.equal(api.version, 1);
for (const m of ['get', 'getDefaults', 'getResolved', 'keysForChart', 'toEffective',
'getCurrent', 'apply', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
'getCurrent', 'apply', 'applyPreset', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
assert.equal(typeof api[m], 'function', `highwayColors.${m} must be a function`);
}
assert.deepEqual(api.slots.map((s) => s.key),
['highE', 'B', 'G', 'D', 'A', 'lowE', 'low7', 'low8'], 'slots in display order');
// One-click presets: exposed as detached [{ id, label, colors }] copies.
assert.deepEqual(api.presets, [{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } }]);
});
test('facade read methods delegate to the manager', () => {
+4 -1
View File
@@ -74,7 +74,10 @@ const APP_JS = path.join(ROOT, 'static', 'app.js');
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
function source(file) {
return fs.readFileSync(file, 'utf8');
// Normalize CRLF: region() slices fixed CHARACTER windows, so on a
// Windows checkout (autocrlf) every line costs one extra char and the
// assertion target can fall outside the window.
return fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
}
function region(src, needle, length = 1200) {
+3 -1
View File
@@ -40,7 +40,9 @@ test('settings UI exposes tone source select with all options', () => {
assert.match(html, /value="external_hardware"/);
assert.match(html, /value="spark_control_x"/);
assert.match(html, /Live guitar tone source/);
assert.match(html, /won&rsquo;t warn that no internal amp tone is loaded/);
// Apostrophe form drifted from the &rsquo; entity to the literal in a
// copy pass — accept entity, typographic, or plain apostrophe.
assert.match(html, /won(?:&rsquo;||')t warn that no internal amp tone is loaded/);
});
test('player audio rail exposes tone source select', () => {
+1
View File
@@ -107,6 +107,7 @@ function loadFunctions(sandbox, src) {
sectionPracticeModeCalls.push({ on, opts: opts || {} });
}
function _updateSectionPracticeHighlight(ct) {}
function _updateEditRegionBtn() {}
${extractFunction(src, 'function clearLoop(')}
${extractFunction(src, 'function _syncSavedLoopSelection()')}
${extractFunction(src, 'async function setLoop(')}
+86
View File
@@ -0,0 +1,86 @@
// playQueue.start({ shuffle: true }): the queue is Fisher-Yates-shuffled ONCE
// at start. Per-slot arrangements must swap in lockstep with their files
// (albums pass arrangements aligned by index, #685), the caller's arrays must
// not be mutated, and shuffle:false / absent must preserve order. 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: decodeURIComponent(fn), arr, opts }),
fbNotify: null,
},
};
// eslint-disable-next-line no-new-func
new Function('window', 'encodeURIComponent', iife)(sandbox.window, encodeURIComponent);
return { q: sandbox.window.feedBack.playQueue, played };
}
function drain(q, played) {
while (q.hasNext()) q.advance();
return played.map((p) => p.fn);
}
test('shuffle: same multiset, order from the seeded RNG, arrangements follow files', () => {
const files = ['a.sloppak', 'b.sloppak', 'c.sloppak', 'd.sloppak'];
const arrs = [0, 1, 2, 3]; // arrangement i belongs to files[i]
const origRandom = Math.random;
try {
// Deterministic RNG so the expected order is checkable.
let calls = 0;
const seq = [0.1, 0.9, 0.5];
Math.random = () => seq[calls++ % seq.length];
const { q, played } = makeQueue();
q.start(files.slice(), { arrangements: arrs.slice(), shuffle: true });
const order = drain(q, played);
assert.deepStrictEqual(order.slice().sort(), files.slice().sort()); // nothing lost/duplicated
// Each played file carries the arrangement it started with.
played.forEach((p) => {
assert.strictEqual(p.arr, arrs[files.indexOf(p.fn)]);
});
} finally {
Math.random = origRandom;
}
});
test('shuffle can change the order', () => {
const origRandom = Math.random;
try {
Math.random = () => 0; // j = 0 every swap → deterministic rotation, ≠ input order
const { q, played } = makeQueue();
q.start(['a', 'b', 'c'], { shuffle: true });
const order = drain(q, played);
assert.notDeepStrictEqual(order, ['a', 'b', 'c']);
} finally {
Math.random = origRandom;
}
});
test('no shuffle opt preserves order and caller arrays are never mutated', () => {
const files = ['a', 'b', 'c'];
const arrs = [2, 0, 1];
const { q, played } = makeQueue();
q.start(files, { arrangements: arrs });
assert.deepStrictEqual(drain(q, played), ['a', 'b', 'c']);
assert.deepStrictEqual(files, ['a', 'b', 'c']);
assert.deepStrictEqual(arrs, [2, 0, 1]);
// shuffle:true must also leave the caller's arrays alone (start slices).
const { q: q2 } = makeQueue();
q2.start(files, { arrangements: arrs, shuffle: true });
assert.deepStrictEqual(files, ['a', 'b', 'c']);
assert.deepStrictEqual(arrs, [2, 0, 1]);
});
+115
View File
@@ -0,0 +1,115 @@
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
// merely ABSENT from the current /api/plugins response (transient partial
// response while the backend's plugin registry is repopulating after a
// restart) must keep its settings panel and screen DOM. Wiping it while its
// _loadedPluginScripts entry survives made the next refetch fail the
// DOM-existence check and re-evaluate the plugin's screen.js mid-session —
// which duplicated the desktop audio_engine's native signal chain. Plugins
// the response knows about but that failed hydration are still wiped, as is
// junk DOM carrying no plugin id.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
// nav reset that opens it to the comment introducing the next section.
function extractWipeBlock(src) {
const start = src.indexOf("navContainer.innerHTML = '';");
assert.ok(start !== -1, 'wipe block start (nav reset) not found');
const end = src.indexOf('// Plugin settings area hosts', start);
assert.ok(end !== -1, 'wipe block end marker not found');
return src.slice(start, end);
}
function makeEl(pluginId, id) {
return {
dataset: pluginId != null ? { pluginId } : {},
id: id || (pluginId != null ? `plugin-${pluginId}` : ''),
removed: false,
remove() {
this.removed = true;
const idx = this._parent ? this._parent.indexOf(this) : -1;
if (idx >= 0) this._parent.splice(idx, 1);
},
};
}
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
const src = fs.readFileSync(APP_JS, 'utf8');
const block = extractWipeBlock(src);
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
const container = { children: settingsChildren };
const sandbox = {
navContainer: { innerHTML: 'seed' },
mobileNavContainer: { innerHTML: 'seed' },
_pluginSettingsContainers: () => [container],
respondedIds,
alreadyHydrated,
document: {
querySelectorAll: (sel) => {
assert.equal(sel, '.screen[id^="plugin-"]');
return screens.slice();
},
},
};
vm.runInNewContext(block, sandbox, { filename: 'wipe-block.js' });
return sandbox;
}
test('plugin absent from the response keeps its settings + screen DOM', () => {
const settings = makeEl('audio_engine');
const screen = makeEl('audio_engine');
runWipe({
respondedIds: new Set(), // partial response: plugin missing
alreadyHydrated: new Set(), // scan loop never saw it either
settingsChildren: [settings],
screens: [screen],
});
assert.equal(settings.removed, false, 'settings panel must survive a partial response');
assert.equal(screen.removed, false, 'screen must survive a partial response');
});
test('plugin present in the response but not hydrated is wiped', () => {
const settings = makeEl('stale_plugin');
const screen = makeEl('stale_plugin');
runWipe({
respondedIds: new Set(['stale_plugin']),
alreadyHydrated: new Set(),
settingsChildren: [settings],
screens: [screen],
});
assert.equal(settings.removed, true);
assert.equal(screen.removed, true);
});
test('hydrated plugin present in the response is preserved', () => {
const settings = makeEl('audio_engine');
const screen = makeEl('audio_engine');
runWipe({
respondedIds: new Set(['audio_engine']),
alreadyHydrated: new Set(['audio_engine']),
settingsChildren: [settings],
screens: [screen],
});
assert.equal(settings.removed, false);
assert.equal(screen.removed, false);
});
test('junk DOM without a plugin id is still removed', () => {
const junkSettings = makeEl(null);
// Screen whose id strips to '' (no dataset.pluginId, bare "plugin-" id).
const junkScreen = makeEl(null, 'plugin-');
runWipe({
respondedIds: new Set(['whatever']),
alreadyHydrated: new Set(),
settingsChildren: [junkSettings],
screens: [junkScreen],
});
assert.equal(junkSettings.removed, true);
assert.equal(junkScreen.removed, true);
});
+9 -4
View File
@@ -204,15 +204,20 @@ test('does not collide tags across two different plugins', () => {
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
});
test('reconcile removes the <link> of a plugin that vanished from /api/plugins', () => {
test('reconcile keeps the <link> of a plugin absent from a partial response', () => {
const { inject, reconcile, headLinks } = setupSandbox();
inject(plug({ id: 'a' }));
inject(plug({ id: 'b' }));
assert.equal(headLinks.length, 2);
// `a` is no longer returned (uninstalled) — its stylesheet must be dropped.
// `a` is missing from this response. That happens transiently during a
// backend restart (the plugin registry repopulates while HTTP stays up),
// so absence is NOT an uninstall signal — the still-loaded plugin must
// keep its stylesheet or it renders visible-but-unstyled until it
// reappears. Explicit removal still happens via the not-ready/unstyled
// paths (tests below).
reconcile([plug({ id: 'b' })]);
assert.equal(headLinks.length, 1);
assert.equal(headLinks[0].dataset.pluginId, 'b');
assert.equal(headLinks.length, 2);
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
});
test('reconcile removes the <link> of a plugin that is no longer ready', () => {
+4
View File
@@ -42,7 +42,10 @@ function loadClose(sandbox, src) {
globalThis.__seekCalls = 0;
globalThis.__playSongCalls = 0;
globalThis.__clearLoopCalls = 0;
globalThis.__queueClearCalls = 0;
globalThis.__audioCurrentTimeSets = [];
// closeCurrentSong abandons any play-queue before leaving the player.
var window = { feedBack: { playQueue: { clear() { globalThis.__queueClearCalls++; } } } };
var audio = {
_t: 42,
get currentTime() { return this._t; },
@@ -75,6 +78,7 @@ test('closeCurrentSong uses _playerOriginScreen when set', async () => {
await sandbox.__closeCurrentSong();
assert.equal(sandbox.__showScreenCalls.length, 1);
assert.equal(sandbox.__showScreenCalls[0], 'favorites');
assert.equal(sandbox.__queueClearCalls, 1, 'a real close abandons the play-queue');
assert.equal(sandbox.__restartCalls, 0);
assert.equal(sandbox.__seekCalls, 0);
assert.equal(sandbox.__playSongCalls, 0);
+11 -9
View File
@@ -31,21 +31,23 @@ test('the home is the unfiltered grid front door, local provider only', () => {
);
});
test('the shelf is recently-played, not-yet-mastered songs (per-song, deduped)', () => {
assert.match(src, /\/api\/stats\/recent\?limit=/);
// Mastery is gated on the per-SONG best (state.accuracy, what the badge
// shows), not the per-arrangement recents row, and each filename appears
// once — so no green-badged "keep practicing" card and no duplicates.
test('the shelf is the server-side practice-suggestions recommender', () => {
// The old client-side pipeline (fetch /api/stats/recent, dedupe by
// filename, gate on state.accuracy) moved server-side: the growth-edge
// recommender gates (not-mastered) + aggregates per song and picks the
// arrangement closest to mastery. The client renders its rows as-is.
assert.match(src, /\/api\/library\/practice-suggestions\?limit=/);
// A shelf card click opens the row's recommended arrangement, not the
// song's default.
assert.match(
src,
/const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/,
'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY',
/data-arr="[\s\S]*?getAttribute\('data-arr'\)[\s\S]*?playSong\(enc\(fn\), arr === '' \? undefined : Number\(arr\)\)/,
'shelf cards must pass the recommended arrangement to playSong',
);
assert.match(src, /seen\.has\(r\.filename\)/, 'the shelf must dedupe recents by filename');
});
test('the meter + shelf fetch together and a stale render is discarded', () => {
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?stats\/recent/,
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?practice-suggestions/,
'the two reads must be issued together (Promise.all), not sequentially');
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
'a stale render must be superseded by a newer one via a token');
+3 -1
View File
@@ -64,7 +64,9 @@ const helpers = loadTuningHelpers();
test('v3 songs.js uses display helpers for album-art tuning badge', () => {
const src = fs.readFileSync(SONGS_JS, 'utf8');
assert.match(src, /displayTuningName\(song\.tuning_name \|\| song\.tuning\)/);
// The card renderer's row variable was renamed song → shown when grouped
// cards landed (the badge reads the representative chart); accept either.
assert.match(src, /displayTuningName\((?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning\)/);
assert.match(src, /displayTuningTargets/);
assert.match(src, /parseRawTuningOffsets/);
});
+143
View File
@@ -0,0 +1,143 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
// Mirror of static/v3/songs.js _cardSig / _buildCardNode / _syncWindow (the
// windowed-grid recycle path, #636 item 3 follow-up) — keep in sync. Exercised
// against a minimal DOM shim so the reconcile invariants are covered off-browser:
// (1) after every slide the grid's children are exactly [start,end) ascending,
// (2) card nodes for indices that stay in-window are REUSED (identity kept) —
// i.e. sliding one row never tears down + rebuilds the whole window (the
// per-slide stall behind the "skips every so many scrolls" report), and
// (3) a select-mode toggle rebuilds the visible window (checkbox/ring change).
let NODE_SEQ = 0;
function makeNode() {
const attrs = {};
return {
_uid: ++NODE_SEQ,
parent: null,
getAttribute(k) { return k in attrs ? attrs[k] : null; },
setAttribute(k, v) { attrs[k] = String(v); },
get nextSibling() {
const p = this.parent; if (!p) return null;
const i = p._kids.indexOf(this);
return i >= 0 && i + 1 < p._kids.length ? p._kids[i + 1] : null;
},
remove() {
const p = this.parent; if (!p) return;
const i = p._kids.indexOf(this);
if (i >= 0) p._kids.splice(i, 1);
this.parent = null;
},
};
}
function makeGrid() {
return {
_kids: [],
get children() { return this._kids.slice(); },
get firstChild() { return this._kids[0] || null; },
insertBefore(node, ref) {
if (node.parent) node.remove();
if (ref == null) this._kids.push(node);
else { const i = this._kids.indexOf(ref); this._kids.splice(i < 0 ? this._kids.length : i, 0, node); }
node.parent = this;
return node;
},
};
}
// --- state + the three helpers, mirrored from songs.js ---
const state = { songs: [], selectMode: false };
for (let i = 0; i < 5000; i++) state.songs[i] = { filename: 'song' + i };
function _cardSig(i) { return (state.songs[i] ? 'r' : 's') + (state.selectMode ? '1' : '0'); }
function _buildCardNode(i) {
const node = makeNode();
node.setAttribute('data-idx', String(i));
node.setAttribute('data-sig', _cardSig(i));
return node;
}
function _syncWindow(grid, start, end) {
for (const el of Array.from(grid.children)) {
const a = el.getAttribute('data-idx');
const idx = a == null ? NaN : Number(a);
if (!(idx >= start && idx < end) || el.getAttribute('data-sig') !== _cardSig(idx)) el.remove();
}
const existing = new Map();
for (const el of grid.children) existing.set(Number(el.getAttribute('data-idx')), el);
let ref = grid.firstChild;
for (let i = start; i < end; i++) {
let node = existing.get(i);
if (!node) node = _buildCardNode(i);
if (node === ref) ref = ref.nextSibling;
else grid.insertBefore(node, ref);
}
}
const idxOf = (g) => g._kids.map((n) => Number(n.getAttribute('data-idx')));
const uidOf = (g) => { const m = new Map(); for (const n of g._kids) m.set(Number(n.getAttribute('data-idx')), n._uid); return m; };
function assertContig(g, start, end) {
const a = idxOf(g);
assert.strictEqual(a.length, end - start, `len == ${end - start}`);
for (let k = 0; k < a.length; k++) assert.strictEqual(a[k], start + k, `child ${k} == ${start + k}`);
}
const COLS = 6, WIN = 12 * COLS; // 12 rows visible
test('window stays [start,end) contiguous scrolling down, one row at a time', () => {
const grid = makeGrid();
for (let row = 0; row < 40; row++) {
const start = row * COLS;
_syncWindow(grid, start, start + WIN);
assertContig(grid, start, start + WIN);
}
});
test('in-window card nodes are reused across a slide (no whole-window teardown)', () => {
const grid = makeGrid();
_syncWindow(grid, 0, WIN);
const before = uidOf(grid);
_syncWindow(grid, COLS, COLS + WIN); // slide down one row
const after = uidOf(grid);
let reused = 0, built = 0;
for (const [i, uid] of after) (before.get(i) === uid ? reused++ : built++);
assert.strictEqual(built, COLS, `only the entering row is built (${COLS}), got ${built}`);
assert.strictEqual(reused, WIN - COLS, 'every overlapping card node is reused');
});
test('scrolling back UP reuses nodes too and keeps order', () => {
const grid = makeGrid();
for (let row = 0; row < 30; row++) _syncWindow(grid, row * COLS, row * COLS + WIN);
let prev = uidOf(grid);
for (let row = 29; row >= 0; row--) {
const start = row * COLS;
_syncWindow(grid, start, start + WIN);
assertContig(grid, start, start + WIN);
const now = uidOf(grid);
for (const [i, uid] of prev) if (i >= start && i < start + WIN) assert.strictEqual(now.get(i), uid, `idx ${i} reused going up`);
prev = now;
}
});
test('a select-mode toggle rebuilds the visible window', () => {
const grid = makeGrid();
const start = 6 * COLS;
_syncWindow(grid, start, start + WIN);
const before = uidOf(grid);
state.selectMode = true;
_syncWindow(grid, start, start + WIN);
const after = uidOf(grid);
let rebuilt = 0;
for (const [i, uid] of before) if (after.get(i) !== uid) rebuilt++;
assert.strictEqual(rebuilt, WIN, 'select-mode change rebuilds every visible card');
assertContig(grid, start, start + WIN);
state.selectMode = false;
});
test('a large jump (rail seek) rebuilds cleanly with no stale survivors', () => {
const grid = makeGrid();
_syncWindow(grid, 0, WIN);
_syncWindow(grid, 1000 * COLS, 1000 * COLS + WIN); // non-overlapping jump
assertContig(grid, 1000 * COLS, 1000 * COLS + WIN);
});
+16
View File
@@ -7,6 +7,8 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
sys.modules.pop('routes', None)
import routes as ach_routes
@@ -26,3 +28,17 @@ def client(tmp_path):
app = FastAPI()
ach_routes.setup(app, {"config_dir": str(tmp_path)})
return TestClient(app)
@pytest.fixture(autouse=True)
def _bind_ach_routes():
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these tests."""
prev = sys.modules.get('routes')
sys.modules['routes'] = ach_routes
try:
yield
finally:
if prev is not None:
sys.modules['routes'] = prev
else:
sys.modules.pop('routes', None)
+18
View File
@@ -5,6 +5,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' /
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
sys.modules.pop('routes', None)
import routes as tuner_routes
@@ -22,3 +24,19 @@ def client(config_dir):
"unregister_tuning_provider": lambda pid: None,
})
return TestClient(app)
@pytest.fixture(autouse=True)
def _bind_tuner_routes():
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these
tests, so a runtime `import routes` in a test body resolves correctly
regardless of which other plugin's bare-named routes ran first."""
prev = sys.modules.get('routes')
sys.modules['routes'] = tuner_routes
try:
yield
finally:
if prev is not None:
sys.modules['routes'] = prev
else:
sys.modules.pop('routes', None)
+120
View File
@@ -0,0 +1,120 @@
"""Pure-function tests for AcoustID fingerprint response parsing + config
gating. No network, no fpcalc binary server.py owns those seams."""
import acoustid_match as a
def _resp(score=0.97, rec_id="rec-1", title="Highway to Hell", artist="AC/DC",
rg_title="Highway to Hell", rg_type="Album", secondary=None,
year=1979, duration=208.4):
return {
"status": "ok",
"results": [{
"id": "acoustid-uuid",
"score": score,
"recordings": [{
"id": rec_id,
"title": title,
"duration": duration,
"artists": [{"id": "a1", "name": artist}],
"releasegroups": [{
"id": "rg1", "title": rg_title, "type": rg_type,
"secondarytypes": secondary or [],
"releases": [{"date": {"year": year}}],
}],
}],
}],
}
def test_parse_maps_the_studio_recording():
out = a.parse_lookup_response(_resp())
assert len(out) == 1
c = out[0]
assert c["recording_id"] == "rec-1"
assert c["title"] == "Highway to Hell"
assert c["artist"] == "AC/DC"
assert c["album"] == "Highway to Hell"
assert c["year"] == "1979"
assert c["duration"] == 208
assert c["studio"] is True
assert c["source"] == "acoustid"
assert c["mb_score"] == 97 # 0.97 → 0..100 confidence band
assert c["score"] == 0.97
def test_live_release_group_is_not_studio():
out = a.parse_lookup_response(_resp(rg_type="Album", secondary=["Live"]))
assert out[0]["studio"] is False
def test_compilation_is_not_studio():
out = a.parse_lookup_response(_resp(secondary=["Compilation"]))
assert out[0]["studio"] is False
def test_prefers_studio_group_for_album_display():
resp = _resp()
# Add a comp release-group first; the studio one must win the album pick.
resp["results"][0]["recordings"][0]["releasegroups"].insert(0, {
"id": "rg0", "title": "Greatest Hits", "type": "Album",
"secondarytypes": ["Compilation"], "releases": [{"date": {"year": 2000}}],
})
c = a.parse_lookup_response(resp)[0]
assert c["album"] == "Highway to Hell"
assert c["studio"] is True
def test_earliest_studio_album_wins_over_later_one():
# Two studio "Album" groups (e.g. a later soundtrack typed Album). The
# ORIGINAL — earliest release year — must win the album pick, not whichever
# AcoustID happened to list first. (Real case: "Machine Head" over a later
# comp for "Smoke on the Water".)
resp = _resp(rg_title="Machine Head", year=1972)
resp["results"][0]["recordings"][0]["releasegroups"].insert(0, {
"id": "rg-late", "title": "Later Studio Album", "type": "Album",
"secondarytypes": [], "releases": [{"date": {"year": 1997}}],
})
c = a.parse_lookup_response(resp)[0]
assert c["album"] == "Machine Head"
assert c["year"] == "1972"
def test_year_is_earliest_release_not_a_reissue():
# A group's first-listed release is often a reissue; the year must be the
# EARLIEST across the group's releases (real case: British Steel's 1980
# original, not a 2010 reissue listed first).
resp = _resp(rg_title="British Steel", year=2010)
resp["results"][0]["recordings"][0]["releasegroups"][0]["releases"].append(
{"date": {"year": 1980}})
c = a.parse_lookup_response(resp)[0]
assert c["year"] == "1980"
def test_dedupes_recording_across_results():
resp = _resp()
resp["results"].append(dict(resp["results"][0])) # same recording again
assert len(a.parse_lookup_response(resp)) == 1
def test_non_ok_status_and_garbage_return_empty():
assert a.parse_lookup_response({"status": "error"}) == []
assert a.parse_lookup_response({}) == []
assert a.parse_lookup_response(None) == []
assert a.parse_lookup_response({"status": "ok", "results": []}) == []
def test_higher_acoustid_score_ranks_first():
resp = _resp(score=0.55, rec_id="low")
resp["results"].append(_resp(score=0.99, rec_id="high")["results"][0])
out = a.parse_lookup_response(resp)
assert out[0]["recording_id"] == "high"
def test_config_gating(monkeypatch):
monkeypatch.delenv("ACOUSTID_API_KEY", raising=False)
assert a.api_key() == ""
assert a.is_configured() is False
assert a.is_configured("explicit-key") is True
monkeypatch.setenv("ACOUSTID_API_KEY", "envkey")
assert a.api_key() == "envkey"
assert a.is_configured() is True
+1
View File
@@ -23,6 +23,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+371
View File
@@ -0,0 +1,371 @@
"""Tests for the PR-C cover picker's server side: the /art/candidates
assembly (current + pack + Cover Art Archive index candidates), the
`caa_index_{id}.json` TTL-less cache around the new `_caa_release_index`
seam, the `?source=pack` art-route variant, and the redirect-following
art-by-URL fetch that lets a CAA pick apply through the existing
override lane.
Both network seams (`_caa_release_index`, `requests.get` under
`_fetch_art_url`) are faked nothing here opens a socket, and the
offline default is itself asserted. Fixture patterns mirror
tests/test_art_layer.py.
"""
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:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: 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 b64(data):
import base64
return base64.b64encode(data).decode()
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 _match_row(server, fn, release_id="rel-1", state="matched"):
"""Seed a matched/manual enrichment row with a release id (as the P8
matcher would have written)."""
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={"recording_id": "rec-1", "release_id": release_id,
"title": song["title"], "artist": song["artist"]})
def _review_row(server, fn, candidates):
"""Seed a review-tier row: no canonical release of its own, releases
live only in the stored candidates JSON."""
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, "review", source="text", score=0.75, candidates=candidates)
def _img(img_id, *, front=False, approved=True, sizes=("500",)):
"""One CAA index image dict, with thumbnails for the given size keys."""
return {
"id": img_id,
"front": front,
"approved": approved,
"types": ["Front"] if front else ["Back"],
"image": f"https://caa.example/full/{img_id}.jpg",
"thumbnails": {s: f"https://caa.example/{img_id}-{s}.jpg" for s in sizes},
}
@pytest.fixture()
def caa_index(server, monkeypatch):
"""Fake CAA index transport + network flag on (mirrors the art-layer
`caa` fixture; this is the picker's own seam)."""
calls = []
indexes = {
"rel-1": {"images": [_img(101, front=True),
_img(102, approved=False, sizes=("250",))]},
"rel-2": {"images": [_img(201, front=True)]},
}
def fake(release_id):
calls.append(release_id)
return indexes.get(release_id) # unknown release → None (a CAA 404)
fake.calls, fake.indexes = calls, indexes
monkeypatch.setattr(server, "_caa_release_index", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def _get(client, fn="a.sloppak"):
r = client.get(f"/api/song/{fn}/art/candidates")
assert r.status_code == 200
return r.json()
def _caa(body):
return [c for c in body["candidates"] if c["kind"] == "caa"]
def _current(body):
return next(c for c in body["candidates"] if c["kind"] == "current")
# ── candidate assembly ────────────────────────────────────────────────────────
def test_matched_row_lists_index_images(server, client, caa_index):
make_sloppak(server, "a.sloppak") # no pack art
_match_row(server, "a.sloppak", release_id="rel-1")
body = _get(client)
assert body["pending"] is False
cur = _current(body)
assert cur["provenance"] == "none" # nothing served yet
assert not any(c["kind"] == "pack" for c in body["candidates"])
caa = _caa(body)
assert [c["thumb_url"] for c in caa] == [
"https://caa.example/101-500.jpg", # front, 500px
"https://caa.example/102-250.jpg", # 250 fallback
]
assert caa[0]["provenance"] == "matched"
assert caa[0]["approved"] is True and caa[1]["approved"] is False
assert caa[0]["release_id"] == "rel-1"
assert caa_index.calls == ["rel-1"] # one index fetch
def test_review_row_includes_candidate_releases(server, client, caa_index):
make_sloppak(server, "a.sloppak")
_review_row(server, "a.sloppak", [
{"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"},
{"recording_id": "rec-2", "title": "Song", "release_id": "rel-2"},
{"recording_id": "rec-3", "title": "Song", "release_id": "rel-1"}, # dupe
{"recording_id": "rec-4", "title": "Song"}, # no release — skipped
])
body = _get(client)
assert caa_index.calls == ["rel-1", "rel-2"] # deduped, in order
assert {c["release_id"] for c in _caa(body)} == {"rel-1", "rel-2"}
assert len(_caa(body)) == 3
def test_rejected_row_skips_caa_fetch(server, client, caa_index):
"""A row the user rejected (failed/rejected) has no accepted match, so the
picker must not spend the shared CAA budget on its stale candidates. The
Current tile still serves; the index seam is never asked."""
make_sloppak(server, "a.sloppak")
_review_row(server, "a.sloppak", [
{"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"}])
assert server.meta_db.set_enrichment_rejected("a.sloppak")
body = _get(client)
assert _caa(body) == []
assert caa_index.calls == []
assert _current(body)["kind"] == "current"
def test_unmatched_instant_tiles_only(server, client, caa_index):
"""No enrichment row at all → current (+ pack when it exists), empty
caa list, and the index seam is never asked."""
make_sloppak(server, "a.sloppak", with_cover=True)
body = _get(client)
kinds = [c["kind"] for c in body["candidates"]]
assert kinds == ["current", "pack"]
assert _current(body)["provenance"] == "pack"
pack = body["candidates"][1]
assert pack["thumb_url"].endswith("?source=pack")
assert caa_index.calls == []
def test_override_provenance_is_yours(server, client, caa_index):
make_sloppak(server, "a.sloppak", with_cover=True)
assert client.post("/api/song/a.sloppak/art/upload",
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
body = _get(client)
assert _current(body)["provenance"] == "yours"
# Pack original stays offered even while the override is what serves.
assert any(c["kind"] == "pack" for c in body["candidates"])
def test_offline_empty_caa_list_no_error(server, client):
"""Under the plain test env the REAL index seam refuses (offline guard);
the endpoint still answers 200 with the instant tiles and caches
nothing (a later open retries)."""
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-1")
body = _get(client)
assert _caa(body) == []
assert _current(body)["kind"] == "current"
assert list(server.ART_CACHE_DIR.glob("caa_index_*.json")) == []
def test_index_cached_second_call_no_refetch(server, client, caa_index):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-1")
first = _get(client)
assert len(caa_index.calls) == 1
cache = server.ART_CACHE_DIR / "caa_index_rel-1.json"
assert cache.is_file() # TTL-less on-disk cache
# Even a changed upstream index is not re-asked — indexes are stable.
caa_index.indexes["rel-1"] = {"images": []}
second = _get(client)
assert len(caa_index.calls) == 1 # no refetch
assert _caa(second) == _caa(first)
def test_404_release_cached_as_empty(server, client, caa_index):
"""A coverless release (CAA 404 → seam returns None) yields no tiles and
is never re-asked either."""
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-missing")
assert _caa(_get(client)) == []
assert _caa(_get(client)) == []
assert caa_index.calls == ["rel-missing"]
def test_caa_candidates_capped_at_12(server, client, caa_index):
make_sloppak(server, "a.sloppak")
caa_index.indexes["rel-big"] = {
"images": [_img(300 + i, front=(i == 0)) for i in range(20)]}
_match_row(server, "a.sloppak", release_id="rel-big")
assert len(_caa(_get(client))) == server._ART_PICKER_MAX_CAA == 12
def test_demo_mode_blocks_candidates(server, client, monkeypatch):
"""Read-only, but it spends the shared CAA rate budget — blocked in demo
like enrichment search/kick."""
make_sloppak(server, "a.sloppak")
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
r = client.get("/api/song/a.sloppak/art/candidates")
assert r.status_code == 403
assert r.json() == {"error": "demo mode: read-only"}
def test_unknown_song_404(server, client):
assert client.get("/api/song/ghost.sloppak/art/candidates").status_code == 404
# ── traversal / injection hardening ───────────────────────────────────────────
def test_malicious_release_id_rejected_no_fetch_no_write(server, caa_index):
"""A crafted release id (path traversal) never matches _CAA_ID_RE, so it
yields no images, opens no socket, and writes no cache file inside the
art dir or anywhere else."""
art_dir = server._enrichment_art_dir()
before = set(art_dir.glob("*"))
assert not server._CAA_ID_RE.match("../../etc/x")
assert server._caa_index_cached("../../etc/x") == []
assert caa_index.calls == [] # the seam was never asked
assert set(art_dir.glob("*")) == before # nothing written
# And nothing landed at the traversal target beside the cache dir either.
assert not (art_dir.parent / "etc").exists()
def test_candidates_route_rejects_traversal_filename(server, client, caa_index):
"""A traversal filename resolves outside DLC_DIR → _resolve_dlc_path
refuses it, the route 404s, and the CAA seam is never touched."""
for path in ("..%2F..%2Fsecret", "%2e%2e%2f%2e%2e%2fsecret", "../../secret"):
r = client.get(f"/api/song/{path}/art/candidates")
assert r.status_code == 404, path
assert caa_index.calls == []
# ── the ?source=pack serve variant ────────────────────────────────────────────
def test_pack_source_serves_pack_under_override(server, client):
"""The Pack-original tile's thumb must show the pack's own art even while
an override is what the plain route serves and 404 when the song ships
no art of its own."""
make_sloppak(server, "a.sloppak", with_cover=True)
assert client.post("/api/song/a.sloppak/art/upload",
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
r = client.get("/api/song/a.sloppak/art?source=pack")
assert r.status_code == 200
assert r.headers["content-type"] == "image/jpeg" # the pack cover, not the override
make_sloppak(server, "bare.sloppak")
assert client.get("/api/song/bare.sloppak/art?source=pack").status_code == 404
# ── art-by-URL redirect handling (what makes a CAA pick applyable) ────────────
class _FakeResp:
def __init__(self, status, headers=None, chunks=()):
self.status_code = status
self.headers = headers or {}
self._chunks = chunks
def iter_content(self, _size):
return iter(self._chunks)
def __enter__(self):
return self
def __exit__(self, *a):
return False
def test_fetch_art_url_follows_redirects_validating_each_hop(server, monkeypatch):
import requests
fetched, checked = [], []
def fake_get(url, **kw):
fetched.append(url)
assert kw.get("allow_redirects") is False # hops stay manual
if "coverartarchive.example" in url:
return _FakeResp(307, {"Location": "https://archive.example/img.png"})
return _FakeResp(200, chunks=[b"IMGDATA"])
monkeypatch.setattr(requests, "get", fake_get)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal",
lambda u: (checked.append(u), False)[1])
data = server._fetch_art_url("https://coverartarchive.example/release/x/front-500")
assert data == b"IMGDATA"
assert fetched == ["https://coverartarchive.example/release/x/front-500",
"https://archive.example/img.png"]
assert checked == fetched # every hop was gated
def test_fetch_art_url_blocks_redirect_to_internal(server, monkeypatch):
import requests
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
302, {"Location": "http://internal.example/x.png"}))
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal",
lambda u: "internal" in u)
with pytest.raises(ValueError):
server._fetch_art_url("https://public.example/x.png")
def test_fetch_art_url_redirect_budget(server, monkeypatch):
import requests
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
307, {"Location": "https://public.example/next.png"}))
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal", lambda u: False)
with pytest.raises(server.EnrichTransportError):
server._fetch_art_url("https://public.example/x.png")
+1
View File
@@ -29,6 +29,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+45
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@@ -209,3 +210,47 @@ def test_list_aliases_sorted(client, server):
_alias(client, "guns n roses", "Guns N' Roses")
aliases = client.get("/api/artist-aliases").json()["aliases"]
assert {a["raw_name"] for a in aliases} == {"ACDC", "guns n roses"}
# ── Search (q) matches merged aliases (launch polish) ─────────────────────────
def _search(client, q):
return {s["filename"] for s in
client.get("/api/library", params={"q": q}).json()["songs"]}
def test_search_canonical_finds_raw_variants(client, server):
"""Searching the canonical name must also find songs whose raw tag is a
merged variant after ACDCAC/DC, q="AC/DC" returns both."""
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "AC/DC")
_seed(server, "c.archive", "Other")
_alias(client, "ACDC", "AC/DC")
assert _search(client, "AC/DC") == {"a.archive", "b.archive"}
def test_search_partial_canonical_finds_raw_variants(client, server):
"""The alias term is a LIKE, matching the substring semantics of the
plain artist term."""
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "Other")
_alias(client, "ACDC", "AC/DC")
assert _search(client, "c/d") == {"a.archive"}
def test_search_without_aliases_unchanged(client, server):
"""No aliases → the fast path keeps the original 3-term search."""
_seed(server, "a.archive", "ACDC")
_seed(server, "b.archive", "AC/DC")
assert _search(client, "ACDC") == {"a.archive"}
def test_search_title_album_unaffected_by_alias_term(client, server):
"""With aliases present (extra placeholder appended), title/album search
still works guards the parameter order."""
_seed(server, "a.archive", "ACDC") # title "a"
_alias(client, "ACDC", "AC/DC")
server.meta_db.put("t.archive", 0, 0,
{"title": "Thunder Road", "artist": "Boss", "album": "Born"})
assert _search(client, "Thunder") == {"t.archive"}
assert _search(client, "Born") == {"t.archive"}
+408
View File
@@ -0,0 +1,408 @@
"""Server tests for the artist-pages layer (PR-B, artist-pages launch charrette).
Two halves, mirroring the design's split:
* GET /api/artist/{name}/page the all-LOCAL payload. Covers the counts /
albums / alias variants, the DENOMINATOR LAW (mastered counts songs YOU OWN,
never anything external locked position 2), similar-in-library genre
co-occurrence (in-library artists only, self excluded, empty empty), and
mb_artist_id resolution from matched/manual rows only.
* GET /api/artist/{name}/links + POST .../links/refresh the lazy, cached,
opt-in external-links layer. The HTTP transport is a fake over
`server._mb_http_get` (the ONE network seam same pattern as
tests/test_mb_enrichment.py), so nothing here opens a socket. Covers the
url-rel whitelist mapping, the http(s) scheme gate (a hostile javascript:
resource never reaches a link slot), cache-hit second calls making no
network call, the offline guard, the default-OFF setting gate, and the
demo-mode blocks.
"""
import importlib
import json
import sys
from urllib.parse import quote
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:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
MBID = "66c662b6-6e2f-4930-8610-912e24c63ed1"
def _put(server, fn, title=None, artist="AC/DC", album="", year="",
genre="", duration=200):
server.meta_db.put(fn, 0, 0, {
"title": title or fn.split(".")[0], "artist": artist, "album": album,
"year": year, "genre": genre, "duration": duration,
"arrangements": [{"name": "Lead", "index": 0}],
})
def _pin_match(server, fn, artist_id=MBID):
"""Give a song a user-pinned (manual) match carrying an artist MBID."""
assert server.meta_db.set_enrichment_manual(fn, {
"recording_id": "rec-1", "title": "T", "artist": "AC/DC",
"artist_id": artist_id,
})
def _page(client, name="AC/DC"):
r = client.get("/api/artist/" + quote(name, safe="") + "/page")
assert r.status_code == 200
return r.json()
class FakeMBArtist:
"""Canned MusicBrainz artist lookup over the _mb_http_get seam."""
def __init__(self, srv):
self._srv = srv
self.calls = []
self.doc = artist_doc()
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 == f"artist/{MBID}":
return self.doc
raise AssertionError(f"unexpected MB path {path!r}")
@pytest.fixture()
def mb_artist(server, monkeypatch):
"""Install the fake transport AND enable the network flag (the test env
disables it by default see test_links_offline_returns_empty)."""
fake = FakeMBArtist(server)
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def artist_doc():
"""An MB artist doc exercising the whole whitelist: a hostile javascript:
URL and an ftp:// URL (both must be scheme-gated out), non-whitelisted rel
types (must be dropped), one of each slot, and both wiki rels (wikipedia
must win over wikidata)."""
rel = lambda rtype, url: {"type": rtype, "url": {"resource": url}}
return {
"id": MBID,
"name": "AC/DC",
"relations": [
rel("official homepage", "javascript:alert(1)"), # scheme-gated
rel("official homepage", "https://www.acdc.com"), # first valid wins
rel("official homepage", "https://second.example"),
rel("setlistfm", "https://www.setlist.fm/setlists/acdc"),
rel("youtube", "https://www.youtube.com/acdc"),
rel("social network", "https://www.instagram.com/acdc"),
rel("bandcamp", "ftp://bad.example/acdc"), # scheme-gated
rel("soundcloud", "https://soundcloud.com/acdc"),
rel("wikidata", "https://www.wikidata.org/wiki/Q27593"),
rel("wikipedia", "https://en.wikipedia.org/wiki/AC/DC"),
rel("streaming", "https://stream.example/acdc"), # not whitelisted
rel("purchase for download", "https://store.example"), # not whitelisted
],
"genres": [{"name": "hard rock", "count": 10}, {"name": "rock", "count": 5}],
}
def _enable_links(client):
r = client.post("/api/settings", json={"artist_external_links": True})
assert r.status_code == 200 and "error" not in r.json()
# ── /page: counts, albums, variants ──────────────────────────────────────────
def test_page_counts_albums_and_files(client, server):
_put(server, "a.sloppak", album="The Razors Edge", year="1990")
_put(server, "b.sloppak", album="The Razors Edge", year="1990")
_put(server, "c.sloppak", album="Back in Black", year="1980")
_put(server, "d.sloppak", album="") # loose, no album
_put(server, "x.sloppak", artist="Other Band", album="Elsewhere")
page = _page(client)
assert page["artist"] == "AC/DC"
assert page["song_count"] == 4 # never the other artist
assert page["album_count"] == 2 # empty album ≠ an album
albums = {a["name"]: a for a in page["albums"]}
assert albums["The Razors Edge"]["count"] == 2
assert albums["The Razors Edge"]["year"] == "1990"
assert albums["Back in Black"]["count"] == 1
assert set(page["files"]) == {"a.sloppak", "b.sloppak", "c.sloppak", "d.sloppak"}
# Mosaic art comes from the artist's own songs.
assert page["art_urls"] and all("/art" in u for u in page["art_urls"])
def test_page_unknown_artist_is_zero_count_not_error(client, server):
page = _page(client, "Nobody Here")
assert page["artist"] == "Nobody Here"
assert page["song_count"] == 0
assert page["albums"] == [] and page["similar"] == []
assert page["mb_artist_id"] is None
def test_page_canonicalizes_aliases_and_lists_variants(client, server):
_put(server, "a.sloppak", artist="ACDC", album="Alb")
_put(server, "b.sloppak", artist="AC/DC", album="Alb")
r = client.post("/api/artist-aliases",
json={"raw_name": "ACDC", "canonical_name": "AC/DC"})
assert r.status_code == 200
# Asking by the RAW name lands on the same canonical page.
for name in ("AC/DC", "ACDC"):
page = _page(client, name)
assert page["artist"] == "AC/DC"
assert page["song_count"] == 2 # both variants counted
assert page["variants"] == [{"name": "ACDC", "count": 1}]
# ── /page: the denominator law ────────────────────────────────────────────────
def test_mastered_counts_only_owned_songs(client, server):
"""Locked position 2: 'N mastered' is over songs in YOUR library — a
song_stats row whose file left the library can never inflate it."""
_put(server, "a.sloppak")
_put(server, "b.sloppak")
_put(server, "c.sloppak")
server.meta_db.record_session("a.sloppak", 0, score=100, accuracy=0.95) # mastered
server.meta_db.record_session("b.sloppak", 0, score=50, accuracy=0.5) # in progress
# A mastered score for a song NOT in the library (deleted / renamed) —
# must not count: the denominator is ownership.
server.meta_db.record_session("gone.sloppak", 0, score=100, accuracy=0.99)
page = _page(client)
assert page["song_count"] == 3
assert page["mastered_count"] == 1
assert page["has_stats"] is True
def test_mastered_uses_best_accuracy_across_arrangements(client, server):
_put(server, "a.sloppak")
server.meta_db.record_session("a.sloppak", 0, score=10, accuracy=0.4)
server.meta_db.record_session("a.sloppak", 1, score=90, accuracy=0.93)
assert _page(client)["mastered_count"] == 1
def test_no_practice_data_reports_zero_and_flag(client, server):
"""The frontend omits the mastered segment when it is 0 (invitational —
never '0 mastered'); the payload carries the honest numbers + flag."""
_put(server, "a.sloppak")
page = _page(client)
assert page["mastered_count"] == 0
assert page["has_stats"] is False
# ── /page: similar-in-library ─────────────────────────────────────────────────
def test_similar_ranks_genre_overlap_in_library_only(client, server):
_put(server, "a1.sloppak", artist="AC/DC", genre="Rock")
_put(server, "a2.sloppak", artist="AC/DC", genre="Blues")
_put(server, "b1.sloppak", artist="Band B", genre="rock") # case folds
_put(server, "b2.sloppak", artist="Band B", genre="Blues") # 2 shared genres
_put(server, "c1.sloppak", artist="Band C", genre="Rock") # 1 shared genre
_put(server, "d1.sloppak", artist="Band D", genre="Jazz") # no overlap
similar = _page(client)["similar"]
names = [s["artist"] for s in similar]
assert names[0] == "Band B" # most shared genres
assert "Band C" in names
assert "Band D" not in names # never non-overlapping
assert "AC/DC" not in names # never self
def test_similar_empty_without_genre_data(client, server):
_put(server, "a.sloppak", genre="")
_put(server, "b.sloppak", artist="Band B", genre="Rock")
assert _page(client)["similar"] == []
def test_similar_folds_alias_variants(client, server):
_put(server, "a.sloppak", artist="AC/DC", genre="Rock")
_put(server, "b.sloppak", artist="Band B", genre="Rock")
_put(server, "b2.sloppak", artist="band b", genre="Rock")
client.post("/api/artist-aliases",
json={"raw_name": "band b", "canonical_name": "Band B"})
similar = _page(client)["similar"]
assert [s["artist"] for s in similar] == ["Band B"] # one entry, folded
assert similar[0]["count"] == 2
# ── /page: mb_artist_id resolution ────────────────────────────────────────────
def test_page_mb_artist_id_from_matched_rows(client, server):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
assert _page(client)["mb_artist_id"] == MBID
def test_page_ignores_unmatched_rows_artist_id(client, server):
"""Only matched/manual rows are identity authority — a failed row's
leftover artist_id must not resurface."""
_put(server, "a.sloppak")
server.meta_db.conn.execute(
"INSERT INTO song_enrichment (filename, match_state, mb_artist_id) "
"VALUES ('a.sloppak', 'failed', ?)", (MBID,))
server.meta_db.conn.commit()
assert _page(client)["mb_artist_id"] is None
# ── /links: setting gate, whitelist, scheme gate ─────────────────────────────
def test_links_disabled_by_default_no_network(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
r = client.get("/api/artist/AC%2FDC/links")
assert r.status_code == 200
body = r.json()
assert body["links"] == {} and body.get("disabled") is True
assert mb_artist.calls == [] # opt-in means opt-in
def test_links_whitelist_mapping_and_scheme_gate(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
r = client.get("/api/artist/AC%2FDC/links")
assert r.status_code == 200
body = r.json()
assert body["matched"] is True and body["cached"] is False
links = body["links"]
# The javascript: homepage is scheme-gated out; the first VALID one wins.
assert links["official"] == "https://www.acdc.com"
assert links["tour"] == "https://www.setlist.fm/setlists/acdc"
assert links["video"] == "https://www.youtube.com/acdc"
# Social collects; the ftp:// bandcamp is scheme-gated out.
assert links["social"] == ["https://www.instagram.com/acdc",
"https://soundcloud.com/acdc"]
# Wikipedia preferred over wikidata when both exist.
assert links["wikipedia"] == "https://en.wikipedia.org/wiki/AC/DC"
# Nothing hostile or non-whitelisted anywhere in the payload.
dumped = json.dumps(body)
for bad in ("javascript:", "ftp://", "stream.example", "store.example"):
assert bad not in dumped
# One throttled lookup, with the url-rels include.
assert len(mb_artist.calls) == 1
path, params = mb_artist.calls[0]
assert path == f"artist/{MBID}"
assert "url-rels" in params.get("inc", "")
def test_links_wikidata_fallback_when_no_wikipedia(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
mb_artist.doc = {"id": MBID, "relations": [
{"type": "wikidata", "url": {"resource": "https://www.wikidata.org/wiki/Q27593"}},
], "genres": []}
links = client.get("/api/artist/AC%2FDC/links").json()["links"]
assert links["wikipedia"] == "https://www.wikidata.org/wiki/Q27593"
def test_links_cached_second_call_makes_no_network_call(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
first = client.get("/api/artist/AC%2FDC/links").json()
assert first["cached"] is False and len(mb_artist.calls) == 1
second = client.get("/api/artist/AC%2FDC/links").json()
assert second["cached"] is True
assert second["links"] == first["links"]
assert len(mb_artist.calls) == 1 # cache hit — no re-fetch
def test_links_refresh_refetches_and_updates_cache(client, server, mb_artist):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
client.get("/api/artist/AC%2FDC/links")
mb_artist.doc = {"id": MBID, "relations": [
{"type": "official homepage", "url": {"resource": "https://new.example"}},
], "genres": []}
r = client.post("/api/artist/AC%2FDC/links/refresh")
assert r.status_code == 200
assert r.json()["links"]["official"] == "https://new.example"
assert len(mb_artist.calls) == 2
# And the refreshed value is what the next GET serves from cache.
again = client.get("/api/artist/AC%2FDC/links").json()
assert again["cached"] is True
assert again["links"]["official"] == "https://new.example"
# ── /links: offline / unmatched / hostile-id guards ──────────────────────────
def test_links_offline_returns_empty(client, server):
"""The test env's offline default (FEEDBACK_SKIP_STARTUP_TASKS) doubles as
the kill-switch test: matched artist + links on, but no network empty
links, no error, nothing cached."""
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
_enable_links(client)
body = client.get("/api/artist/AC%2FDC/links").json()
assert body["links"] == {} and body.get("offline") is True
assert server.meta_db.get_artist_enrichment(MBID) is None
def test_links_unmatched_artist_reports_matched_false(client, server, mb_artist):
_put(server, "a.sloppak") # no enrichment match
_enable_links(client)
body = client.get("/api/artist/AC%2FDC/links").json()
assert body == {"links": {}, "matched": False}
assert mb_artist.calls == []
def test_links_rejects_malformed_stored_mbid(client, server, mb_artist):
"""A hand-rolled /pick body can stuff junk into mb_artist_id — the strict
MBID shape gate must keep it off the MB request line."""
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak", artist_id="evil/../../path")
_enable_links(client)
body = client.get("/api/artist/AC%2FDC/links").json()
assert body == {"links": {}, "matched": False}
assert mb_artist.calls == []
# ── demo mode ─────────────────────────────────────────────────────────────────
def test_links_routes_demo_blocked_page_stays_open(client, server, monkeypatch):
_put(server, "a.sloppak")
_pin_match(server, "a.sloppak")
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
assert client.get("/api/artist/AC%2FDC/links").status_code == 403
assert client.post("/api/artist/AC%2FDC/links/refresh").status_code == 403
# The all-local page read stays available to demo visitors.
assert client.get("/api/artist/AC%2FDC/page").status_code == 200
# ── settings keys ─────────────────────────────────────────────────────────────
def test_artist_page_settings_defaults_and_validation(client, server):
cfg = client.get("/api/settings").json()
assert cfg["artist_pages_enabled"] is True # page is local-only → ON
assert cfg["artist_external_links"] is False # links are opt-in → OFF
# Bool pattern: non-bool shapes return a structured error, not a 500.
for key in ("artist_pages_enabled", "artist_external_links"):
assert "error" in client.post("/api/settings", json={key: "yes"}).json()
assert "error" not in client.post("/api/settings", json={key: True}).json()
assert client.get("/api/settings").json()[key] is True
+1
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -18,6 +18,7 @@ def client(tmp_path, monkeypatch):
for attr in ("meta_db", "audio_effect_mappings"):
conn = getattr(getattr(server, attr, None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+2
View File
@@ -34,6 +34,7 @@ def client_and_server(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -60,6 +61,7 @@ def non_loopback_client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+234
View File
@@ -0,0 +1,234 @@
"""Tests for one-time builtin starter-content seeding into DLC."""
from __future__ import annotations
import importlib
import sys
import pytest
@pytest.fixture()
def server_mod(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
(tmp_path / "config").mkdir()
monkeypatch.delenv("DLC_DIR", raising=False)
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
def _source(server_mod):
return (
server_mod._feedBack_server_root()
/ server_mod._BUILTIN_STARTER_SOURCES[0][1]
)
def _dest(server_mod, dlc):
return (
dlc
/ server_mod._BUILTIN_STARTER_SUBDIR
/ server_mod._BUILTIN_STARTER_SOURCES[0][0]
)
def test_seed_creates_starter_content_and_marker(tmp_path, server_mod):
"""First run copies the bundled feedpak into starter/ and writes the marker."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
dest = _dest(server_mod, dlc)
assert dest.is_file()
assert dest.stat().st_size == source.stat().st_size
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_seed_preserves_source_mtime(tmp_path, server_mod):
"""The seeded pack keeps the bundle's mtime so the diagnostic refresh check
(source newer than dest -> update) stays correct across both write paths."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
assert _dest(server_mod, dlc).stat().st_mtime_ns == source.stat().st_mtime_ns
def test_starter_is_not_carved_out_of_the_library():
"""`starter/` must NOT collide with the diagnostics/tutorials carve-out —
otherwise seeded songs would never appear in the library listing."""
assert "starter" not in {"diagnostics-builtin", "tutorials-builtin"}
def test_seed_runs_only_once_and_respects_deletion(tmp_path, server_mod):
"""After the first seed, deleting the song does NOT bring it back: the
marker makes starter seeding a one-time welcome."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
dest = _dest(server_mod, dlc)
assert dest.is_file()
# User removes the starter song.
dest.unlink()
# A subsequent launch must not re-seed it.
server_mod._seed_builtin_starter_content(dlc)
assert not dest.exists()
def test_seed_deferred_until_dlc_configured(tmp_path, server_mod):
"""With no DLC folder, seeding is skipped WITHOUT writing the marker, so it
retries once a library folder exists."""
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
# dlc is None and DLC_DIR unset -> _get_dlc_dir() returns None.
server_mod._seed_builtin_starter_content(None)
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
# Now a DLC is configured: the deferred seed runs.
dlc = tmp_path / "dlc"
dlc.mkdir()
server_mod._seed_builtin_starter_content(dlc)
assert _dest(server_mod, dlc).is_file()
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_seed_refuses_symlinked_seed_directory(tmp_path, server_mod):
"""A symlinked starter/ dir is refused so copies can't escape the DLC tree."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
(dlc / server_mod._BUILTIN_STARTER_SUBDIR).symlink_to(outside_dir)
server_mod._seed_builtin_starter_content(dlc)
assert list(outside_dir.iterdir()) == []
# An incomplete seed must NOT write the marker, so a later launch retries.
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
def test_seed_never_overwrites_an_existing_user_file(tmp_path, server_mod):
"""One-time starter seeding must never replace a user's own file at the
destination, even if the bundled pack has a newer mtime."""
import os as _os
dlc = tmp_path / "dlc"
dlc.mkdir()
dest = _dest(server_mod, dlc)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(b"user's own edited pack")
_os.utime(dest, (1_000_000, 1_000_000)) # far older than the bundled source
server_mod._seed_builtin_starter_content(dlc)
assert dest.read_bytes() == b"user's own edited pack" # untouched
# counted as already-present, so the one-time seed considers itself done
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_seed_does_not_mark_when_destination_is_a_directory(tmp_path, server_mod):
"""A directory sitting at the destination name is neither clobbered nor
counted as present, so the marker stays unwritten and seeding retries."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
bogus = _dest(server_mod, dlc)
bogus.parent.mkdir(parents=True, exist_ok=True)
bogus.mkdir() # user (or junk) placed a directory where the pack goes
server_mod._seed_builtin_starter_content(dlc)
assert bogus.is_dir() # untouched
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
def test_seed_does_not_mark_when_source_missing(tmp_path, server_mod, monkeypatch):
"""If a starter source can't be found, the marker stays unwritten and the
seed is retried on the next launch (rather than permanently skipped)."""
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setattr(
server_mod,
"_BUILTIN_STARTER_SOURCES",
[("missing.feedpak", "content/starter/does-not-exist.feedpak")],
)
server_mod._seed_builtin_starter_content(dlc)
assert not (dlc / server_mod._BUILTIN_STARTER_SUBDIR / "missing.feedpak").exists()
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
def test_every_starter_source_file_is_present(server_mod):
"""Every entry in _BUILTIN_STARTER_SOURCES must have its bundled file on
disk otherwise the all-present gate never fires and NOTHING seeds (a
listed-but-missing pack silently disables starter seeding entirely). In CI
the checkout is clean, so "on disk" == committed."""
root = server_mod._feedBack_server_root()
missing = [
rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES
if not (root / rel).is_file()
]
assert not missing, f"listed starter sources missing on disk: {missing}"
def test_seed_lands_every_listed_starter_pack(tmp_path, server_mod):
"""A real seed run copies every listed pack into starter/ and marks done."""
root = server_mod._feedBack_server_root()
for _, rel in server_mod._BUILTIN_STARTER_SOURCES:
if not (root / rel).is_file():
pytest.skip(f"starter source not present in checkout: {rel}")
dlc = tmp_path / "dlc"
dlc.mkdir()
server_mod._seed_builtin_starter_content(dlc)
for dest_name, _ in server_mod._BUILTIN_STARTER_SOURCES:
dest = dlc / server_mod._BUILTIN_STARTER_SUBDIR / dest_name
assert dest.is_file(), f"pack not seeded: {dest_name}"
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_no_unlisted_starter_pack_on_disk(server_mod):
"""The inverse guard: every content/starter/*.feedpak on disk must be wired
into _BUILTIN_STARTER_SOURCES. An unlisted pack bundles into builds as dead
weight and never seeds exactly how the raw Ode-to-Joy pack slipped onto
main before being wired up. In CI the checkout is clean, so this flags any
stray/committed pack that isn't listed."""
root = server_mod._feedBack_server_root()
listed = {rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES}
if not listed:
pytest.skip("no starter sources declared")
content_dir = (root / next(iter(listed))).parent # all sources share this dir
if not content_dir.is_dir():
pytest.skip(f"starter content dir absent: {content_dir}")
on_disk = {p.relative_to(root).as_posix() for p in content_dir.glob("*.feedpak")}
unlisted = on_disk - listed
assert not unlisted, (
"committed but not in _BUILTIN_STARTER_SOURCES (would bundle as dead "
f"weight and never seed): {sorted(unlisted)}"
)
+1
View File
@@ -21,6 +21,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -24,6 +24,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+2
View File
@@ -36,6 +36,7 @@ def client(tmp_path, monkeypatch):
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -169,6 +170,7 @@ def test_server_app_request_id_propagated_to_logs(monkeypatch, tmp_path):
]
conn = getattr(getattr(server_mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
lines = [ln for ln in buf.getvalue().splitlines() if "server_probe_event" in ln]
+1
View File
@@ -20,6 +20,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+2
View File
@@ -60,6 +60,7 @@ def _cleanup(server, client):
server._DEMO_JANITOR_HOOKS.clear()
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -311,6 +312,7 @@ def test_register_demo_janitor_hook_in_plugin_context(tmp_path, monkeypatch):
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
# Clean up janitor state so it doesn't bleed into other tests.
server._DEMO_JANITOR_STOP.set()
+4 -4
View File
@@ -204,7 +204,7 @@ def test_client_audio_session_contribution_redacts_paths(tmp_path):
kw["client_contributions"] = {
"note_detect": {
"schema": "feedBack.audio_session.diagnostics.v1",
"session": {"sessionId": str(home_path / "DLC" / "private-song.archive")},
"session": {"sessionId": str(home_path / "DLC" / "private-song.feedpak")},
"domains": {"audio-input": {"sources": [{"label": str(home_path / "devices" / "raw-id")}]}},
}
}
@@ -1541,7 +1541,7 @@ def test_console_error_object_args_are_redacted(tmp_path):
kw = _basic_kwargs(tmp_path)
kw["include"]["console"] = True
kw["redact"] = True
secret_path = "/home/alice/Music/DLC/my_song.archive"
secret_path = "/home/alice/Music/DLC/my_song.feedpak"
kw["client_console"] = [
{
"level": "error",
@@ -1567,13 +1567,13 @@ def test_console_string_args_still_redacted(tmp_path):
kw["include"]["console"] = True
kw["redact"] = True
kw["client_console"] = [
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.archive ok"]},
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.feedpak ok"]},
]
zip_bytes, _name, _m = db.build_bundle(**kw)
with _open_zip(zip_bytes) as zf:
console = json.loads(zf.read("client/console.json"))
# The song filename should be replaced with a hash token, not appear verbatim.
assert "my_song.archive" not in console["entries"][0]["args"][0]
assert "my_song.feedpak" not in console["entries"][0]["args"][0]
def test_console_non_string_non_dict_args_pass_through(tmp_path):
+5 -5
View File
@@ -5,7 +5,7 @@ from diagnostics_redact import Redactor
def test_dlc_path_replaced():
r = Redactor(dlc_dir=Path("/dlc/songs"))
out = r.redact_text("loaded from /dlc/songs/foo.archive")
out = r.redact_text("loaded from /dlc/songs/foo.feedpak")
assert "<DLC_DIR>" in out
assert "/dlc/songs" not in out
assert r.counts["paths_replaced"] == 1
@@ -13,8 +13,8 @@ def test_dlc_path_replaced():
def test_song_filename_redacted_consistently():
r = Redactor()
a = r.redact_text("Loading Test-Artist_Test-Song.archive")
b = r.redact_text("Replaying Test-Artist_Test-Song.archive again")
a = r.redact_text("Loading Test-Artist_Test-Song.feedpak")
b = r.redact_text("Replaying Test-Artist_Test-Song.feedpak again")
token_a = a.split("Loading ")[1].strip()
token_b = b.split("Replaying ")[1].split(" ")[0]
assert token_a == token_b
@@ -63,8 +63,8 @@ def test_home_dir_replaced():
def test_different_redactors_produce_different_tokens():
a = Redactor()
b = Redactor()
out_a = a.redact_text("Foo.archive")
out_b = b.redact_text("Foo.archive")
out_a = a.redact_text("Foo.feedpak")
out_b = b.redact_text("Foo.feedpak")
assert out_a != out_b
+92
View File
@@ -0,0 +1,92 @@
"""Unit tests for ``server._resolve_dlc_path`` — the DLC-library containment
guard.
It must (1) allow a library mounted through a directory JUNCTION/symlink (the
shared-library-across-installs / desktop-app case that a ``.resolve()``-based
check wrongly rejected, breaking album art + song load), while (2) still
rejecting ``..`` traversal and absolute paths the only escapes a ``:path``
filename can express. ``safe_join`` stays strict on purpose (zip-slip guard),
so the contrast is pinned here too.
"""
import importlib
import os
import sys
import pytest
@pytest.fixture()
def server(tmp_path, monkeypatch):
(tmp_path / "cfg").mkdir()
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "cfg"))
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:
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
def _dlc(tmp_path):
d = tmp_path / "dlc"
d.mkdir()
return d
# ── still-rejected escapes (the security contract) ────────────────────────────
def test_dotdot_traversal_rejected(server, tmp_path):
dlc = _dlc(tmp_path)
assert server._resolve_dlc_path(dlc, "../../etc/passwd") is None
# a Windows-style backslash traversal is normalised + rejected identically
assert server._resolve_dlc_path(dlc, "..\\..\\secret") is None
assert server._resolve_dlc_path(dlc, "a/../../b") is None
def test_absolute_path_rejected(server, tmp_path):
dlc = _dlc(tmp_path)
assert server._resolve_dlc_path(dlc, "/etc/passwd") is None
assert server._resolve_dlc_path(dlc, "C:/Windows/system32/x") is None
def test_empty_and_nul_rejected(server, tmp_path):
dlc = _dlc(tmp_path)
assert server._resolve_dlc_path(dlc, "") is None
assert server._resolve_dlc_path(dlc, "a\x00b") is None
# ── allowed: legitimate in-library paths ──────────────────────────────────────
def test_safe_relative_allowed(server, tmp_path):
dlc = _dlc(tmp_path)
p = server._resolve_dlc_path(dlc, "CDLC/City Pop/song.feedpak")
assert p is not None
assert p.is_relative_to(dlc.resolve())
def test_junction_subfolder_allowed(server, tmp_path):
"""A library mounted through a directory junction/symlink must resolve —
the case that broke album art for Christian's shared city-pop library."""
dlc = _dlc(tmp_path)
real = tmp_path / "real_library"
real.mkdir()
(real / "song.feedpak").write_bytes(b"pack")
link = dlc / "CDLC"
try:
os.symlink(real, link, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlink/junction creation not permitted on this host")
p = server._resolve_dlc_path(dlc, "CDLC/song.feedpak")
assert p is not None, "a junctioned library subfolder was wrongly rejected"
assert p.exists(), "the resolved path should reach the file through the junction"
# Contrast: safe_join stays strict (it .resolve()s and follows the junction
# to its real target outside the root), which is correct for its zip-slip
# callers but is exactly why _resolve_dlc_path can't reuse it here.
assert server.safe_join(dlc, "CDLC/song.feedpak") is None
+150
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@@ -149,3 +150,152 @@ def test_art_cache_dir_created(server):
d = server._enrichment_art_dir()
assert d.is_dir()
assert d.name == "art_cache"
# ── Refresh Metadata batch: per-tile states, progress, Stop ───────────────────
def test_states_for_returns_only_known_filenames(server):
_put(server, "a.archive")
server._background_enrich()
got = server.meta_db.enrichment_states_for(["a.archive", "nope.archive"])
assert got == {"a.archive": "unscanned"} # unknown filename absent
assert server.meta_db.enrichment_states_for([]) == {}
def test_states_endpoint(client, server):
_put(server, "a.archive")
_put(server, "b.archive", title="Other")
server._background_enrich()
body = client.post("/api/enrichment/states",
json={"filenames": ["a.archive", "zzz.missing"]}).json()
assert body["states"] == {"a.archive": "unscanned"}
assert body["running"] is False
assert body["current"] is None
def test_status_exposes_progress_fields(client, server):
_put(server, "a.archive")
server._background_enrich()
body = client.get("/api/enrichment/status").json()
for k in ("total", "matched", "current", "cancelling"):
assert k in body
assert body["cancelling"] is False
def test_cancel_is_noop_when_idle(client, server):
body = client.post("/api/enrichment/cancel").json()
assert body == {"ok": True, "was_running": False}
# A no-op must not arm the flag (which would then poison the next pass).
assert server._enrich_cancel.is_set() is False
def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
for i in range(4):
_put(server, f"s{i}.archive", title=f"Song {i}")
# Force the matcher path on (the test env is offline by default) and stub the
# per-song matcher so nothing touches the network — it just trips Stop after
# the first song, exactly as the /cancel route would mid-pass.
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
calls = []
def fake_enrich_one(row, **_kw):
calls.append(row["filename"])
server._enrich_cancel.set()
monkeypatch.setattr(server, "_enrich_one", fake_enrich_one)
server._enrich_cancel.clear()
server._background_enrich()
# The loop checks cancel BEFORE each song, so exactly one is processed before
# it breaks — not the whole 4-row queue.
assert calls == ["s0.archive"]
assert server._enrich_status["total"] == 4
assert server._enrich_status["matched"] == 1
def test_rematch_requeues_visible_but_skips_manual(server, client):
_put(server, "a.archive") # will be 'matched'
_put(server, "b.archive", title="Other") # will be 'failed'
_put(server, "c.archive", title="Pinned") # will be 'manual' — untouchable
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='failed' WHERE filename='b.archive'")
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state='manual' WHERE filename='c.archive'")
server.meta_db.conn.commit()
body = client.post("/api/enrichment/rematch", json={
"filenames": ["a.archive", "b.archive", "c.archive", "nope.archive"]}).json()
# A per-view refresh re-runs everything shown EXCEPT the manual pin (and an
# unknown filename); matched + failed are both re-queued.
assert set(body["queued"]) == {"a.archive", "b.archive"}
assert body["count"] == 2
server._join_background_db_threads()
assert server.meta_db.get_enrichment("a.archive")["match_state"] == "unscanned"
assert server.meta_db.get_enrichment("b.archive")["match_state"] == "unscanned"
assert server.meta_db.get_enrichment("c.archive")["match_state"] == "manual"
# ── filename-derived artist/title fallback (blank-artist packs) ───────────────
def test_filename_artist_title_parse(server):
f = server._artist_title_from_filename
assert f("CDLC/0 - City Pop/Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak") == \
{"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
assert f("Anri_Windy-Summer_v1_p.feedpak") == {"artist": "Anri", "title": "Windy Summer"}
# a trailing "(440Hz)" retune tag is stripped before parsing
assert f("Cindy_Watashitachi-o-Shinjite-Ite_v1_p (440Hz).feedpak") == \
{"artist": "Cindy", "title": "Watashitachi o Shinjite Ite"}
# doesn't fit the convention → no guess
assert f("nounderscore.feedpak") is None
def test_blank_artist_seeds_match_from_filename(server, monkeypatch):
server.meta_db.put("Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak", 0, 0, {
"title": "Tatsuro-Yamashita_Ride-On-Time_v1_p", "artist": "", "album": "",
"duration": 240, "arrangements": [{"name": "Bass", "index": 0}]})
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
seen = {}
def fake_search(artist, title, limit=8):
seen["artist"], seen["title"] = artist, title
return []
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
row = next(r for r in server.meta_db.enrichment_pending()
if r["filename"].startswith("Tatsuro"))
server._enrich_one(row)
# the blank pack artist was replaced by the filename-derived identity for
# the search (this is exactly what rescues the 'failed' pile)
assert seen == {"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
def test_present_artist_is_not_overridden_by_filename(server, monkeypatch):
server.meta_db.put("Weird-Filename_x_y.feedpak", 0, 0, {
"title": "Real Title", "artist": "Real Artist", "album": "", "duration": 100,
"arrangements": [{"name": "Lead", "index": 0}]})
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
seen = {}
def fake_search(artist, title, limit=8):
seen["artist"], seen["title"] = artist, title
return []
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
row = next(r for r in server.meta_db.enrichment_pending()
if r["filename"].startswith("Weird"))
server._enrich_one(row)
# a pack that DOES carry an artist keeps it — the filename is never consulted
assert seen == {"artist": "Real Artist", "title": "Real Title"}
def test_kick_clears_a_stale_cancel(server):
# A cancelled-then-rekicked pass must start clean: _kick_enrich clears the
# flag so the fresh pass isn't aborted the instant it checks.
server._enrich_cancel.set()
server._kick_enrich()
server._join_background_db_threads()
assert server._enrich_cancel.is_set() is False
+3
View File
@@ -100,6 +100,7 @@ def scan_server(tmp_path, monkeypatch, isolate_logging):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -161,6 +162,7 @@ def upload_client(tmp_path, monkeypatch):
tc.close()
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -228,6 +230,7 @@ def settings_server(tmp_path, monkeypatch):
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+2
View File
@@ -29,6 +29,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@@ -255,5 +256,6 @@ def test_demo_mode_blocks_write(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+73
View File
@@ -122,6 +122,79 @@ def test_parse_bcfs_rejects_bad_magic():
_parse_bcfs(b"NOPE" + b"\x00" * 16)
# ── _parse_bcfs container round-trip (GP6 .gpx partial final-sector) ─────────
def _build_bcfs(entries, short_by=0):
"""Assemble a minimal in-memory BCFS container for _parse_bcfs.
``entries`` is ``[(name: bytes, payload: bytes, data_sector: int), ...]``.
The directory entry for entry *i* is written to sector ``i + 1``; each
entry's payload goes in the sector index it names. ``short_by`` truncates
the final buffer by N bytes to emulate a real .gpx's partial trailing
sector (the BCFZ-declared decompressed size isn't 0x1000-aligned). Layout
mirrors the reader: a 4-byte ``BCFS`` header, then 0x1000-byte sectors,
with every value read at ``HDR + sector * 0x1000``.
"""
SECTOR = 0x1000
HDR = 4
max_sector = max([e[2] for e in entries] + [len(entries)])
buf = bytearray(b"BCFS" + b"\x00" * ((max_sector + 1) * SECTOR))
def put_u32(off, val):
struct.pack_into("<I", buf, HDR + off, val)
for i, (name, payload, data_sector) in enumerate(entries):
dir_off = (i + 1) * SECTOR # directory entry -> sector i+1
put_u32(dir_off + 0x00, 2) # entry type: file
nm = name[:127]
buf[HDR + dir_off + 0x04: HDR + dir_off + 0x04 + len(nm)] = nm
put_u32(dir_off + 0x8C, len(payload)) # declared file size
put_u32(dir_off + 0x94, data_sector) # first data-sector pointer
put_u32(dir_off + 0x94 + 4, 0) # chain terminator
dpos = HDR + data_sector * SECTOR
buf[dpos: dpos + len(payload)] = payload
if short_by:
del buf[len(buf) - short_by:]
return bytes(buf)
def test_parse_bcfs_reads_short_final_sector():
"""The regression: a real .gpx ends a byte short of a full 0x1000 sector,
so its last (small) container file lands in a partial trailing sector. The
reader must clamp that read, not reject the whole container rejecting it
is what made every GP6 .gpx fail to import with 'sector pointer out of
range'."""
bcfs = _build_bcfs([(b"score.gpif", b"hello", 2)], short_by=1)
assert (len(bcfs) - 4) % 0x1000 == 0x1000 - 1 # final sector is 1 short
assert _parse_bcfs(bcfs)["score.gpif"] == b"hello"
def test_parse_bcfs_full_sector_round_trip():
"""A sector-aligned container round-trips unchanged (baseline)."""
assert _parse_bcfs(_build_bcfs([(b"misc.xml", b"<x/>", 2)]))["misc.xml"] == b"<x/>"
def test_parse_bcfs_multi_file_short_final_sector():
"""Real-world shape: score.gpif plus small config files, the last one in
the partial trailing sector."""
out = _parse_bcfs(_build_bcfs([
(b"score.gpif", b"<GPIF/>", 3),
(b"LayoutConfiguration", b"AB", 4),
], short_by=1))
assert out["score.gpif"] == b"<GPIF/>"
assert out["LayoutConfiguration"] == b"AB"
def test_parse_bcfs_rejects_sector_starting_past_end():
"""A sector pointer whose *start* is beyond the container is genuinely
malformed and must still raise the clamp tolerates a partial final
sector, not arbitrary out-of-range pointers."""
bcfs = bytearray(_build_bcfs([(b"x", b"y", 2)]))
struct.pack_into("<I", bcfs, 4 + 0x1000 + 0x94, 9999) # absurd data-sector ptr
with pytest.raises(ValueError, match="out of range"):
_parse_bcfs(bytes(bcfs))
# ── _note_is_tie ────────────────────────────────────────────────────────────
def test_note_is_tie_destination():
+284
View File
@@ -0,0 +1,284 @@
"""Tests for the librosa-free piecewise time-warp helpers in lib/gp_autosync.py
(bar_start_times / build_warp_anchors / warp_time / warp_song_times /
gp_has_expandable_repeats) plus refine_sync's pure fallbacks.
Fixture-free, matching tests/test_gp_audio_sync.py: every test drives a pure
helper with hand-built inputs (in-memory GPIF zips, synthetic sync points,
hand-rolled Song objects). The librosa-backed sweep inside refine_sync needs
real audio and is covered by manual validation in the PR.
"""
import io
import zipfile
import xml.etree.ElementTree as ET
import pytest
import gp_autosync as ga
from gp8_audio_sync import GpSyncData, SyncPoint
from song import (
Anchor,
Arrangement,
Beat,
Chord,
HandShape,
Note,
Phrase,
PhraseLevel,
Section,
Song,
)
# ── helpers ───────────────────────────────────────────────────────────────────
def _gpif_zip(gpif_xml: str) -> bytes:
"""Build an in-memory .gp container holding the given score.gpif."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("Content/score.gpif", gpif_xml)
return buf.getvalue()
def _gpif(tempo_autos: list[tuple[int, float]], bar_sigs: list[str]) -> str:
autos = "".join(
f"<Automation><Type>Tempo</Type><Bar>{bar}</Bar>"
f"<Value>{bpm} 2</Value></Automation>"
for bar, bpm in tempo_autos
)
bars = "".join(f"<MasterBar><Time>{sig}</Time></MasterBar>" for sig in bar_sigs)
return (
"<GPIF><MasterTrack><Automations>"
f"{autos}</Automations></MasterTrack>"
f"<MasterBars>{bars}</MasterBars></GPIF>"
)
def _sp(bar, t, mod=120.0, orig=120.0):
return SyncPoint(bar=bar, time_secs=t, modified_tempo=mod, original_tempo=orig)
# ── bar_start_times ───────────────────────────────────────────────────────────
def test_bar_start_times_constant_tempo(tmp_path):
# 120 BPM, 4/4 → every bar is exactly 2s
gp = tmp_path / "song.gp"
gp.write_bytes(_gpif_zip(_gpif([(0, 120.0)], ["4/4"] * 4)))
assert ga.bar_start_times(str(gp)) == pytest.approx([0.0, 2.0, 4.0, 6.0])
def test_bar_start_times_tempo_change_and_meter(tmp_path):
# Bar 0-1 at 120 (4/4 → 2s each), bar 2 switches to 60 in 3/4 (3s)
gp = tmp_path / "song.gp"
gp.write_bytes(
_gpif_zip(_gpif([(0, 120.0), (2, 60.0)], ["4/4", "4/4", "3/4", "3/4"]))
)
assert ga.bar_start_times(str(gp)) == pytest.approx([0.0, 2.0, 4.0, 7.0])
# ── build_warp_anchors ────────────────────────────────────────────────────────
def test_build_warp_anchors_maps_bars_to_score_time():
bar_starts = [0.0, 2.0, 4.0, 6.0]
points = [_sp(0, 1.0), _sp(2, 5.4)]
assert ga.build_warp_anchors(points, bar_starts) == [(0.0, 1.0), (4.0, 5.4)]
def test_build_warp_anchors_drops_nonmonotonic_and_out_of_range():
bar_starts = [0.0, 2.0, 4.0, 6.0]
points = [
_sp(0, 1.0),
_sp(1, 0.5), # audio time goes backwards — dropped
_sp(2, 5.4),
_sp(99, 9.9), # bar out of range — dropped
]
assert ga.build_warp_anchors(points, bar_starts) == [(0.0, 1.0), (4.0, 5.4)]
def test_build_warp_anchors_drops_implausible_slopes():
# 2s score bars. A DTW fold (or a run of monotonicity-clamped refine
# points) can produce a near-flat audio segment — slope far below the
# 0.2x plausibility floor — which would crush every bar in the span.
bar_starts = [float(2 * b) for b in range(11)]
points = [
_sp(0, 1.0),
_sp(4, 9.0), # slope 1.0 — kept
_sp(8, 9.1), # slope 0.0125 over 8s of score — dropped
_sp(10, 21.0), # slope 1.0 vs the last KEPT anchor — kept
]
assert ga.build_warp_anchors(points, bar_starts) == [
(0.0, 1.0), (8.0, 9.0), (20.0, 21.0)
]
def test_build_warp_anchors_requires_two_points():
assert ga.build_warp_anchors([_sp(0, 1.0)], [0.0, 2.0]) == []
assert ga.build_warp_anchors([], [0.0, 2.0]) == []
# ── warp_time ─────────────────────────────────────────────────────────────────
def test_warp_time_interpolates_between_anchors():
anchors = [(0.0, 1.0), (10.0, 21.0)] # slope 2, offset 1
assert ga.warp_time(0.0, anchors) == pytest.approx(1.0)
assert ga.warp_time(5.0, anchors) == pytest.approx(11.0)
assert ga.warp_time(10.0, anchors) == pytest.approx(21.0)
def test_warp_time_piecewise_segments():
# First half plays at authored speed, second half at half speed
anchors = [(0.0, 0.0), (10.0, 10.0), (20.0, 30.0)]
assert ga.warp_time(5.0, anchors) == pytest.approx(5.0)
assert ga.warp_time(15.0, anchors) == pytest.approx(20.0)
def test_warp_time_extrapolates_with_edge_slopes():
anchors = [(10.0, 20.0), (20.0, 40.0)] # slope 2
assert ga.warp_time(5.0, anchors) == pytest.approx(10.0) # before first
assert ga.warp_time(25.0, anchors) == pytest.approx(50.0) # after last
def test_warp_time_preserves_order():
anchors = [(0.0, 0.5), (4.0, 4.1), (8.0, 9.3), (12.0, 12.9)]
times = [i * 0.37 for i in range(40)]
warped = [ga.warp_time(t, anchors) for t in times]
assert warped == sorted(warped)
# ── warp_song_times ───────────────────────────────────────────────────────────
def _shifted_double(t):
return 2.0 * t + 1.0
def test_warp_song_times_covers_all_time_fields():
song = Song(
song_length=100.0,
beats=[Beat(time=0.0, measure=1), Beat(time=1.0, measure=-1)],
sections=[Section(name="verse", number=1, start_time=10.0)],
arrangements=[
Arrangement(
name="Lead",
notes=[Note(time=2.0, string=0, fret=3, sustain=1.0)],
chords=[
Chord(
time=4.0,
chord_id=0,
notes=[Note(time=4.0, string=1, fret=2, sustain=0.5)],
)
],
anchors=[Anchor(time=6.0, fret=3)],
hand_shapes=[HandShape(chord_id=0, start_time=4.0, end_time=5.0)],
phrases=[
Phrase(
start_time=0.0,
end_time=8.0,
max_difficulty=0,
levels=[
PhraseLevel(
difficulty=0,
notes=[Note(time=3.0, string=0, fret=0, sustain=2.0)],
)
],
)
],
tones={"base": "clean", "changes": [{"t": 7.0, "name": "lead"}]},
tempos=[{"time": 0.0, "bpm": 120.0}],
)
],
)
ga.warp_song_times(song, _shifted_double)
assert song.song_length == pytest.approx(201.0)
assert [b.time for b in song.beats] == pytest.approx([1.0, 3.0])
assert song.sections[0].start_time == pytest.approx(21.0)
arr = song.arrangements[0]
n = arr.notes[0]
assert n.time == pytest.approx(5.0)
assert n.sustain == pytest.approx(2.0) # (2+1)*2+1 - 5
ch = arr.chords[0]
assert ch.time == pytest.approx(9.0)
assert ch.notes[0].time == pytest.approx(9.0)
assert ch.notes[0].sustain == pytest.approx(1.0)
assert arr.anchors[0].time == pytest.approx(13.0)
hs = arr.hand_shapes[0]
assert (hs.start_time, hs.end_time) == (pytest.approx(9.0), pytest.approx(11.0))
ph = arr.phrases[0]
assert (ph.start_time, ph.end_time) == (pytest.approx(1.0), pytest.approx(17.0))
assert ph.levels[0].notes[0].time == pytest.approx(7.0)
assert ph.levels[0].notes[0].sustain == pytest.approx(4.0)
assert arr.tones["changes"][0]["t"] == pytest.approx(15.0)
assert arr.tempos[0]["time"] == pytest.approx(1.0)
def test_warp_song_times_clamps_negative_sustain():
# A non-monotonic warp callable must not produce negative sustains
song = Song(arrangements=[
Arrangement(name="Lead",
notes=[Note(time=1.0, string=0, fret=0, sustain=1.0)])
])
ga.warp_song_times(song, lambda t: 5.0 - t) # decreasing map
assert song.arrangements[0].notes[0].sustain == 0.0
# ── gp_has_expandable_repeats ─────────────────────────────────────────────────
def test_gpif_files_never_expand_repeats(tmp_path):
# GPIF conversion is single-pass as-written, so .gp/.gpx are always False
gp = tmp_path / "song.gp"
gp.write_bytes(_gpif_zip(_gpif([(0, 120.0)], ["4/4"])))
assert ga.gp_has_expandable_repeats(str(gp)) is False
def test_gp345_unparseable_returns_false(tmp_path):
bad = tmp_path / "song.gp5"
bad.write_bytes(b"not a real gp5 file")
assert ga.gp_has_expandable_repeats(str(bad)) is False
def test_gp345_repeats_detected(tmp_path):
guitarpro = pytest.importorskip("guitarpro")
song = guitarpro.models.Song()
track = guitarpro.models.Track(song)
song.tracks = [track]
# Bar 2 of 3 opens a repeat
for _ in range(2):
header = guitarpro.models.MeasureHeader()
song.addMeasureHeader(header)
song.measureHeaders[1].isRepeatOpen = True
for header in song.measureHeaders:
track.measures.append(guitarpro.models.Measure(track, header))
path = tmp_path / "repeat.gp5"
guitarpro.write(song, str(path))
assert ga.gp_has_expandable_repeats(str(path)) is True
def test_gp345_plain_song_no_repeats(tmp_path):
guitarpro = pytest.importorskip("guitarpro")
song = guitarpro.models.Song()
track = guitarpro.models.Track(song)
song.tracks = [track]
for _ in range(2):
header = guitarpro.models.MeasureHeader()
song.addMeasureHeader(header)
for header in song.measureHeaders:
track.measures.append(guitarpro.models.Measure(track, header))
path = tmp_path / "plain.gp5"
guitarpro.write(song, str(path))
assert ga.gp_has_expandable_repeats(str(path)) is False
# ── refine_sync pure fallbacks ────────────────────────────────────────────────
def test_refine_sync_empty_points_returns_input():
sync = GpSyncData(audio_offset=0.0, audio_asset_id="", sync_points=[])
assert ga.refine_sync(sync, "/nonexistent.ogg") is sync
def test_refine_sync_single_point_returns_input():
# One point → fewer than 2 warp anchors → unchanged, no audio load
sync = GpSyncData(audio_offset=-1.0, audio_asset_id="",
sync_points=[_sp(0, 1.0)])
assert ga.refine_sync(sync, "/nonexistent.ogg") is sync
+1
View File
@@ -20,6 +20,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+2
View File
@@ -30,6 +30,7 @@ def server_mod(monkeypatch, tmp_path):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -124,6 +125,7 @@ def make_client(tmp_path, monkeypatch):
server = sys.modules.get("server")
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -103,6 +103,7 @@ def make_client(tmp_path, monkeypatch):
server = sys.modules.get("server")
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -130,6 +130,7 @@ def make_client(tmp_path, monkeypatch):
server = sys.modules.get("server")
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -24,6 +24,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -23,6 +23,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+2
View File
@@ -15,6 +15,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -298,4 +299,5 @@ def test_library_provider_registration_is_available_to_plugins(tmp_path, monkeyp
assert captured["unregister_library_provider"] is server.unregister_library_provider
finally:
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -37,6 +37,7 @@ def dlc_client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+89
View File
@@ -28,6 +28,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@@ -96,6 +97,94 @@ def mb_doc(rid="rec-1", title="Thunderstruck", artist="AC/DC", artist_id="art-1"
}
# ── strict-then-loose search fallback ────────────────────────────────────────
def test_search_falls_back_to_loose_when_strict_is_empty(server, monkeypatch):
"""The strict field-phrase query misses a non-Latin-primary artist; the
loose retry (no field scoping) searches aliases and finds it."""
calls = []
def _routed(path, params):
q = params.get("query", "")
calls.append(q)
if q.startswith("recording:"): # strict phrase → nothing
return {"recordings": []}
return {"recordings": [mb_doc(rid="rec-x", title="Telephone Number")]}
monkeypatch.setattr(server, "_mb_http_get", _routed)
cands = server._mb_search_recordings("Junko Ohashi", "Telephone Number")
assert len(cands) == 1
assert len(calls) == 2 # strict first, then the loose retry
assert calls[0].startswith("recording:") # strict is the field-phrase form
assert "artist:" not in calls[1] and '"' not in calls[1] # loose retry
def test_search_does_not_retry_when_strict_hits(server, monkeypatch):
"""A strict hit must not spend a second (throttled) request on the loose
query."""
calls = []
def _routed(path, params):
calls.append(params.get("query", ""))
return {"recordings": [mb_doc()]}
monkeypatch.setattr(server, "_mb_http_get", _routed)
cands = server._mb_search_recordings("AC/DC", "Thunderstruck")
assert len(cands) == 1
assert len(calls) == 1
# ── alias-aware scoring (non-Latin-primary artists) ──────────────────────────
_AID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
def test_artist_aliases_fetched_and_cached(server, monkeypatch):
calls = []
def fake(path, params):
calls.append(path)
return {"sort-name": "Ohashi, Junko",
"aliases": [{"name": "Junko Ohashi"}, {"name": "大橋 純子"}]}
monkeypatch.setattr(server, "_mb_http_get", fake)
names = server._mb_artist_aliases(_AID)
assert "Junko Ohashi" in names and "Ohashi, Junko" in names
server._mb_artist_aliases(_AID) # cached → no second request
assert len(calls) == 1
def test_artist_aliases_rejects_bad_id(server, monkeypatch):
def boom(path, params):
raise AssertionError("must not fetch for a non-UUID id")
monkeypatch.setattr(server, "_mb_http_get", boom)
assert server._mb_artist_aliases("not-a-uuid") == []
def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
# A pack whose (romanized) artist MB stores under a Japanese primary name.
_put(server, "x.sloppak", title="Telephone Number", artist="Junko Ohashi")
def _routed(path, params):
if path.startswith("artist/"): # alias lookup
return {"sort-name": "Ohashi, Junko",
"aliases": [{"name": "Junko Ohashi"}]}
q = params.get("query", "")
if q.startswith("recording:"): # strict phrase → nothing
return {"recordings": []}
return {"recordings": [mb_doc(rid="rec-jp", title="Telephone Number",
artist="大橋純子", artist_id=_AID)]} # loose hit
monkeypatch.setattr(server, "_mb_http_get", _routed)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
server._background_enrich()
row = server.meta_db.get_enrichment("x.sloppak")
# The romanized alias lifts the artist over the auto floor → auto-confirmed.
assert row["match_state"] == "matched"
assert row["mb_recording_id"] == "rec-jp"
# ── offline safety (the pytest-never-hits-network contract) ──────────────────
def test_offline_default_skips_matching(server, monkeypatch):
+101 -1
View File
@@ -145,11 +145,40 @@ def test_rank_candidates_orders_by_our_score():
assert all("score" in c for c in ranked)
def test_rank_candidates_studio_preference_is_dropped_for_live_charts():
"""Tied-score candidates: a studio chart prefers the studio take, but a
LIVE chart must NOT be forced to the studio recording."""
studio = {"recording_id": "studio", "artist": "AC/DC", "title": "Highway to Hell",
"studio": True, "mb_score": 90}
live = {"recording_id": "live", "artist": "AC/DC", "title": "Highway to Hell",
"studio": False, "mb_score": 95}
# Studio chart -> studio take wins the tie (studio flag), despite lower mb_score.
studio_song = {"artist": "AC/DC", "title": "Highway to Hell"}
assert m.rank_candidates(studio_song, [live, studio])[0]["recording_id"] == "studio"
# Live chart -> studio preference dropped, so the higher-mb_score live take wins.
live_song = {"artist": "AC/DC", "title": "Highway to Hell (Live at Donington)"}
assert m.rank_candidates(live_song, [studio, live])[0]["recording_id"] == "live"
# ── 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"'
# Live-only recordings are excluded — the studio take is never tagged Live,
# and it's the biggest source of junk in a flat recording search.
assert q == 'recording:"thunderstruck" AND artist:"acdc" AND -secondarytype:Live'
def test_build_recording_query_keeps_live_for_live_charts():
"""A chart that IS a live take must NOT get the live filter, or its only
correct recording is excluded. A bare title word ("Live and Let Die") is a
real word, not a marker, so it still filters."""
live = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)")
assert "-secondarytype:Live" not in live
assert 'recording:"highway to hell"' in live
# A real word "live" in the title is not a live marker → still filtered.
bare = m.build_recording_query("Wings", "Live and Let Die")
assert "-secondarytype:Live" in bare
def test_build_recording_query_escapes_and_handles_missing_artist():
@@ -160,6 +189,54 @@ def test_build_recording_query_escapes_and_handles_missing_artist():
assert "artist:" not in q
def test_build_recording_query_loose_drops_field_phrases():
# The strict form locks to the *primary* artist/title phrase (and drops
# live-only recordings — the chart isn't a live take).
assert m.build_recording_query("Junko Ohashi", "Telephone Number") == \
'recording:"telephone number" AND artist:"junko ohashi" AND -secondarytype:Live'
# The loose form has no field scoping and no phrases, so MusicBrainz also
# searches artist ALIASES — rescues non-Latin-primary artists (大橋純子) —
# but keeps the same live exclusion (a studio chart must not fall back to a
# live-only recording).
loose = m.build_recording_query("Junko Ohashi", "Telephone Number", loose=True)
assert loose == "(telephone number) AND (junko ohashi) AND -secondarytype:Live"
assert "artist:" not in loose and '"' not in loose
def test_build_recording_query_loose_missing_artist():
assert m.build_recording_query("", "Fantasy", loose=True) == \
"(fantasy) AND -secondarytype:Live"
def test_build_recording_query_loose_keeps_live_for_live_charts():
# A live chart's loose fallback must NOT exclude live recordings (same gate
# as the strict path) — else its only correct recording is filtered out.
loose = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)", loose=True)
assert "-secondarytype:Live" not in loose
assert loose == "(highway to hell) AND (ac dc)"
# ── alias-aware artist scoring ────────────────────────────────────────────────
def test_cand_artist_sim_uses_aliases():
song = {"artist": "Junko Ohashi", "title": "Telephone Number"}
# primary is the Japanese name → romanized reference scores 0…
assert m.cand_artist_sim(song, {"artist": "大橋純子"}) == 0.0
# …but a romanized alias confirms it
assert m.cand_artist_sim(
song, {"artist": "大橋純子", "artist_aliases": ["Ohashi Junko", "Junko Ohashi"]}) == 1.0
def test_alias_lifts_candidate_to_auto():
song = {"artist": "Junko Ohashi", "title": "Telephone Number"}
jp = {"artist": "大橋純子", "title": "Telephone Number"}
# Without the alias: title matches but the artist floor fails → never auto.
assert m.classify(song, jp, m.score_candidate(song, jp)) != "auto"
# With the romanized alias attached: artist clears the floor → auto.
jp_alias = dict(jp, artist_aliases=["Junko Ohashi"])
assert m.classify(song, jp_alias, m.score_candidate(song, jp_alias)) == "auto"
# ── MusicBrainz response parsing ──────────────────────────────────────────────
MB_DOC = {
@@ -200,6 +277,29 @@ def test_parse_recording_doc_normalizes():
assert c["mb_score"] == 98
def test_best_release_prefers_official_single_over_unofficial_album():
"""An OFFICIAL single/EP must outrank an UNofficial bootleg album for the
canonical album/year: official comes before the studio-album preference, so
a single-only song is never seeded from a bootleg. (`(clean, status_ok, )`
would wrongly pick the bootleg.)"""
doc = {
"id": "rec-x", "title": "One-Off", "score": 90,
"artist-credit": [
{"name": "A", "joinphrase": "",
"artist": {"id": "a", "name": "A", "sort-name": "A"}}],
"releases": [
{"id": "rel-boot", "title": "Boot LP", "status": "Bootleg",
"date": "1990-01-01", "release-group": {"primary-type": "Album"}},
{"id": "rel-single", "title": "The Single", "status": "Official",
"date": "1988-01-01", "release-group": {"primary-type": "Single"}},
],
}
c = m.parse_recording_doc(doc)
assert c["release_id"] == "rel-single"
assert c["album"] == "The Single"
assert c["studio"] is False # a Single isn't a clean studio ALBUM
def test_parse_recording_doc_joined_artist_credit():
doc = dict(MB_DOC)
doc["artist-credit"] = [
+1
View File
@@ -409,6 +409,7 @@ def test_db_uses_wal_journal_mode(setup_routes):
row = conn.execute("PRAGMA journal_mode").fetchone()
assert row[0] == "wal"
finally:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -18,6 +18,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+17 -7
View File
@@ -38,15 +38,25 @@ def test_plugin_loader_unmounts_previous_ui_contributions_before_reregistering()
assert "await _commandUiDomain(contribution.domain, 'mount', plugin, contribution)" in source
def test_plugin_loader_unmounts_contributions_for_removed_plugins():
def test_plugin_loader_does_not_treat_response_absence_as_uninstall():
# A plugin transiently absent from /api/plugins (the backend clears its
# registry at the start of load_plugins() and repopulates incrementally
# while HTTP stays up, so restarts serve partial responses) must NOT be
# torn down: the old absence sweep unmounted UI contributions and
# unregistered the capability participant with no re-registration path
# (plugin scripts don't re-run), and the DOM/style wipes forced a
# mid-session screen.js re-evaluation that duplicated the desktop
# audio_engine's native signal chain.
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
assert "const livePluginIds = new Set(plugins.map((plugin) => plugin.id))" in source
assert "for (const [pluginId, contributions] of _pluginUiContributions)" in source
assert "const stalePlugin = { id: pluginId }" in source
assert "await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution)" in source
assert "window.feedBack?.capabilities?.unregisterParticipant?.(pluginId)" in source
assert "_pluginUiContributions.delete(pluginId)" in source
# The absence-triggered sweep is gone (rationale comment in its place)...
assert "const livePluginIds" not in source
assert "const stalePlugin = { id: pluginId }" not in source
assert "deliberately NO stale-contribution sweep" in source
# ...and the DOM/style reconcilers only act on plugins the response names.
assert "const respondedIds = new Set(plugins.map((p) => p.id))" in source
assert "respondedIds.has(pid) && !alreadyHydrated.has(pid)" in source
assert "responded.has(id) && !styled.has(id)" in source
+1
View File
@@ -24,6 +24,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -21,6 +21,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -73,6 +73,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -30,6 +30,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+9
View File
@@ -75,6 +75,7 @@ def client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -296,6 +297,7 @@ def server_module(tmp_path, monkeypatch):
meta_db = getattr(mod, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -324,6 +326,7 @@ def test_get_dlc_dir_uses_config_when_env_empty(tmp_path, monkeypatch):
finally:
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -345,6 +348,7 @@ def test_get_dlc_dir_env_takes_precedence(tmp_path, monkeypatch):
finally:
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -362,6 +366,7 @@ def test_get_dlc_dir_env_dot_is_valid(tmp_path, monkeypatch):
finally:
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -404,6 +409,7 @@ def scan_module(tmp_path, monkeypatch, isolate_logging):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -525,6 +531,7 @@ def api_client(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
_restore_loaded_plugins(plugins_snapshot)
@@ -653,6 +660,7 @@ def test_skip_startup_tasks_does_not_call_load_plugins_or_scan(tmp_path, monkeyp
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
_restore_loaded_plugins(plugins_snapshot)
@@ -698,6 +706,7 @@ def test_skip_startup_tasks_clears_stale_plugin_registry(tmp_path, monkeypatch,
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None) if server else None
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
_restore_loaded_plugins(plugins_snapshot)
+1
View File
@@ -28,6 +28,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+3
View File
@@ -31,6 +31,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -87,6 +88,7 @@ def test_export_includes_consistent_library_db_snapshot(client, server_mod, tmp_
"SELECT title FROM songs WHERE filename = ?", ("snap.archive",)
).fetchall()
finally:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
assert rows == [("SnapSong",)]
@@ -271,6 +273,7 @@ def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
"SELECT title FROM songs WHERE filename = ?", ("keepme.archive",)
).fetchall()
finally:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
assert rows == [("KeepMe",)]
assert not (tmp_path / "web_library.db.restore").exists()
+1
View File
@@ -19,6 +19,7 @@ def env(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -116,6 +116,7 @@ def dlc_client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -45,6 +45,7 @@ def dlc_client(tmp_path, monkeypatch):
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -18,6 +18,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -21,6 +21,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the 'Start here' starter shelf (launch polish) —
GET /api/library/practice-suggestions when NO practice attempts exist.
growth_edge_suggestions returns starter picks (sensible-length songs,
shortest first, flagged starter:true) only on a never-practiced library;
the moment any scored attempt exists the normal growth-edge behaviour is
unchanged including the honest empty shelf when everything attempted is
mastered. Read-only, like the recommender it falls back from."""
import importlib
import sys
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def _seed(server, fn, duration, title=None):
server.meta_db.put(fn, 0, 0, {
"title": title or fn.split(".")[0], "artist": "A", "duration": duration})
def _play(server, fn, acc, arr=0):
"""Record a scored attempt so the song has a best_accuracy."""
server.meta_db.record_session(fn, arr, score=int(acc * 1000), accuracy=acc)
def _suggest(client, limit=8):
return client.get(f"/api/library/practice-suggestions?limit={limit}").json()
# ── No attempts → starter picks, shortest sensible first ─────────────────────
def test_no_attempts_returns_starter_rows(client, server):
_seed(server, "long.archive", 600) # > 480s → not a starter
_seed(server, "jingle.archive", 30) # < 90s → not a starter
_seed(server, "mid.archive", 200)
_seed(server, "short.archive", 120)
rows = _suggest(client)
assert [r["filename"] for r in rows] == ["short.archive", "mid.archive"]
assert all(r["starter"] is True for r in rows)
def test_starter_duration_bounds_inclusive(client, server):
_seed(server, "at90.archive", 90)
_seed(server, "at480.archive", 480)
_seed(server, "under.archive", 89)
_seed(server, "over.archive", 481)
_seed(server, "nodur.archive", 0) # unknown length → never a starter
got = {r["filename"] for r in _suggest(client)}
assert got == {"at90.archive", "at480.archive"}
def test_starter_caps_at_eight(client, server):
for i in range(10):
_seed(server, f"s{i:02d}.archive", 100 + i)
assert len(_suggest(client)) == 8
# Even an explicit larger limit never exceeds the starter cap of 8.
assert len(_suggest(client, limit=20)) == 8
def test_starter_rows_are_enriched_and_growth_shaped(client, server):
"""Same row shape as the growth-edge rows (the client reuses the card
markup verbatim) plus the starter marker; enriched by the route."""
_seed(server, "song.archive", 150, title="My Song")
r = _suggest(client)[0]
assert r["starter"] is True
assert r["title"] == "My Song" and r["artist"] == "A"
assert r["art_url"].endswith("/art")
for key in ("filename", "best_accuracy", "arrangement", "last_played_at",
"user_difficulty", "growth_score"):
assert key in r
# No attempt yet → no accuracy/arrangement; the client passes an
# undefined arrangement so playSong picks the default.
assert r["best_accuracy"] is None
assert r["arrangement"] is None
# ── Attempts exist → normal growth-edge behaviour, unchanged ─────────────────
def test_attempts_exist_normal_behaviour_unchanged(client, server):
_seed(server, "inprog.archive", 150)
_seed(server, "fresh.archive", 150)
_play(server, "inprog.archive", 0.6)
rows = _suggest(client)
assert [r["filename"] for r in rows] == ["inprog.archive"]
assert not any(r.get("starter") for r in rows)
def test_all_mastered_returns_empty_not_starter(client, server):
"""Attempts exist and everything attempted is mastered → the shelf is
honestly empty; the starter fallback must NOT kick in."""
_seed(server, "done.archive", 150)
_seed(server, "fresh.archive", 150)
_play(server, "done.archive", 0.95)
assert _suggest(client) == []
def test_empty_library_returns_empty(client, server):
assert _suggest(client) == []
+5
View File
@@ -85,6 +85,7 @@ def client(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -127,6 +128,7 @@ def startup_harness(tmp_path, monkeypatch, isolate_logging):
server._DEMO_JANITOR_THREAD = None
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -692,6 +694,7 @@ def test_startup_status_e2e_real_plugin_loader(tmp_path, monkeypatch, isolate_lo
server._DEMO_JANITOR_THREAD = None
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
with plugins_mod.PLUGINS_LOCK:
plugins_mod.LOADED_PLUGINS.clear()
@@ -782,6 +785,7 @@ def test_startup_status_endpoint_background_thread_path(tmp_path, monkeypatch, i
server._DEMO_JANITOR_THREAD = None
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
@@ -830,6 +834,7 @@ def test_startup_status_endpoint_background_thread_failure(tmp_path, monkeypatch
server._DEMO_JANITOR_THREAD = None
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -44,6 +44,7 @@ def client(tmp_path, monkeypatch):
finally:
conn = getattr(getattr(server, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -22,6 +22,7 @@ def server_mod(tmp_path, monkeypatch):
yield mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
+1
View File
@@ -20,6 +20,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
+1
View File
@@ -22,6 +22,7 @@ def server(tmp_path, monkeypatch, isolate_logging):
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)