Compare commits

...
Author SHA1 Message Date
byrongamatosandClaude Opus 4.8 d3e6357b28 refactor(app): the host seam + carve section practice out of app.js (R3a)
static/js/host.js (99) + static/js/section-practice.js (1,214).
app.js 9,461 → 8,409.

THE FIRST SLICE OUT OF THE STRONGLY-CONNECTED CORE. What is left in app.js is not
a tree, it is a cycle: seeding a dependency closure from section-practice, from
loops, from count-in, or from the JUCE seek shim all return the SAME 178-function
set, and setLoop() and practiceSection() call each other directly. No closure-based
carve can cut it at any seed. So it is cut BY NAME, and the calls back into app.js
go through a host seam.

  61 functions + its own 24 _sectionPractice*/_sectionParents* scalars (read nowhere
  else) move out. 11 hooks come back in. 4 of those are read-only GETTERS —
  loopA/loopB/_audioSeekGen/_loopMutationGen are only ever READ here, never written,
  so app.js keeps owning them and NO state container is needed (a 977-site lift
  avoided).

app.js used to reach IN and reset the module's state by hand (clearLoop() zeroed the
selection; changeArrangement() invalidated the parent count). It cannot now — an
imported binding is read-only — so those are exported as resetSelection() and
invalidateParentCount(). Strictly better: the module owns its own invariants instead
of trusting two callers on the far side of the file to zero the right three fields.

═══ THE SILENT-NO-OP PROBLEM, SOLVED ═══
The obvious host seam is an object of no-op defaults. That is a TRAP and we walked
into it once: the plugin loader's seam defaulted populateVizPicker to `() => {}`, so
a dropped wiring line would have left the viz picker quietly not refreshing with NO
test, boot check, or bot noticing. Two layers stop it here:

  1. RUNTIME — host.js is a Proxy with NO defaults and NO stubs. Reading an unwired
     hook THROWS. An unwired hook cannot degrade into a no-op because there is
     nothing to degrade INTO. configureHost() also rejects a non-function at WIRE
     time, and refuses to run twice.

  2. STATIC — tests/js/host_contract.test.js asserts the hooks the modules USE are
     exactly the hooks app.js WIRES. This is the layer that matters: a runtime throw
     only fires if the broken path executes, and the whole danger of a seam is the
     paths that never run in a smoke test. VERIFIED TO BITE in all three drift
     directions: drop a hook from configureHost -> fails; rename host.setLoop in the
     module -> fails; wire a hook nobody uses -> fails.

Writing that guard took three tries and each failure is instructive: (a) the
configureHost regex anchored `});` at column 0, ran past the indented close, and
swallowed app.js's 66-name window contract — 77 "hooks"; (b) an import-stripping
regex with `[\s\S]*?` ate 14,000 characters INCLUDING the drift the bite test was
meant to catch — a guard with a hole is worse than no guard, because you trust it;
(c) `host.js'` in the import path backtracked from `js` to a "hook" called `j`.
The bite tests are what surfaced all three.

CODEX FOUND A REAL RACE [P2]. configureHost() was inside the async boot function,
after several awaits — but the window handlers (onPhraseNext, …) go live during
app.js's SYNCHRONOUS module evaluation. A user clicking one in that window would hit
"[host] … was read before configureHost() ran". It is now a bare top-level statement
sitting immediately before the window contract, so the seam is always wired before a
handler can be reached. Verified live: invoking a handler 1.2s in — well before the
boot awaits settle — works.

VERIFIED. A/B against origin/main in two browsers with a REAL song loaded: popover
toggle, practice-mode change, phrase-next, and clearLoop (all of which cross the seam
— setLoop/clearLoop/_audioTime/loopA/loopB) — IDENTICAL, zero page errors. Since an
unwired hook throws, a live app is itself proof the seam is wired.

Harnesses: section_practice_dismiss retargeted; loop_api's clearLoop sandbox gains a
resetSelection SPY (not a stub) and ASSERTS it fires — the guarantee is still tested,
just through the seam.

pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:50:43 +02:00
f53d566dbc refactor(app): give the <audio> element a module of its own (R3a) (#886)
static/js/audio-el.js — one exported const. app.js's diff is 5 lines.
This is a HINGE, not a carve: nothing shrinks, but almost everything left in
app.js is blocked behind it.

WHY. `audio` is `document.getElementById('audio')` with 162 references in app.js
and 173 outside any one cluster. Every remaining cluster measured — settings,
app-updates, count-in (208 fns), exit-confirm (212), library-render (220) — lists
`audio` among its inbound symbols, because they all touch playback and playback
reaches for the element directly. A module that needs it cannot import app.js to
get it (that closes a cycle and fails import-x/no-cycle), so today the only way to
carve any of them would be a host seam — the exact thing #878 had to build and
#880 had to tear out.

WHY IT'S SAFE. `audio` is a `const` and is NEVER reassigned anywhere in core, so a
read-only import binding is exactly right and no state container is needed. The
162 call sites are untouched — the binding keeps its name, it is just imported
instead of declared. (Contrast the reassigned scalars — isPlaying, _avOffsetMs —
which CANNOT be shared this way: an imported binding cannot be written to. Those
still need containers, and that is the next problem, not this one.)

TIMING. app.js is <script type="module">, so it evaluates after the HTML is parsed
and its imports evaluate just before its body — the same moment app.js used to run
this exact lookup. If the element had not been in the document, `audio` would be
null and app.js's top-level `audio.addEventListener(...)` calls would throw and
kill the module. They don't.

VERIFIED WITH REAL PLAYBACK, not a boot check. A/B against origin/main in two
browsers: app alive with zero page errors (which is itself the proof the import
resolved), #audio is an AUDIO element, togglePlay/seekBy live, and playSong() on a
real library song sets audio.src and the element reports a duration — IDENTICAL on
both sides.

pytest 2396, node 1038/1038, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:44:01 +02:00
b5dd585d25 refactor(app): carve settings backup + plugin updates out of app.js (R3a) (#885)
Two leaves, one PR. app.js 9,651 → 9,457.

static/js/settings-io.js (155) — exportSettings + importSettings, the Settings
backup bundle. Imports nothing. The two-phase rationale comment (server first and
atomic; then a best-effort localStorage merge) is the contract and moved with the
code.

plugin-updates → INTO static/js/plugin-loader.js, not a module of its own.
checkPluginUpdates + updatePlugin are plugin MANAGEMENT; they belong with the code
that loads plugins. A new file for 50 lines would have been a file for its own
sake.

All four are inline handlers on the Settings screen and already in app.js's window
contract, so app.js re-exposes the imported bindings unchanged.

VERIFIED BY DRIVING BOTH FLOWS. A/B against origin/main in two browsers:
  * checkPluginUpdates() -> hits the API and settles the button back to
    "Check for Updates" — IDENTICAL
  * exportSettings() -> POSTs /api/settings/export and writes
    "Exported feedBack-settings…" to #backup-status — IDENTICAL (fetch intercepted
    so the assertion is on the real call, not a stub)
  * all four resolve on window — IDENTICAL
  * zero console/page errors either side

Zero harnesses broke. pytest 2396, node 1038/1038, ESLint 0, tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:42:54 +02:00
d47883c5e5 refactor(app): carve the tuning-display helpers out of app.js (R3a) (#884)
static/js/tuning-display.js (228 lines) — bodies VERBATIM. app.js 9,838 → 9,650.
A LEAF: imports nothing.

Tuning NAME resolution (Drop D / Eb Standard / raw-offset fallback), bass
detection, effective string count, and the target FREQUENCIES + note names the
tuner checks against. Pure functions over a small MIDI/note-name table; the 3
_TUNING_* tables are read nowhere else and move in.

NOT A SLICE — a node-level extract. The span 2309-2535 INTERLEAVES the functions
with the `window.*` / `window.feedBack.*` assignments that publish them, and one
of those is `window.feedBack = window.feedBack || {}` — the BUS BOOTSTRAP, not
tuning code at all. Every ExpressionStatement stays exactly where it was; only the
16 functions and 3 tables move. app.js re-exposes the imported bindings from the
same lines, so the public surface and its ordering are untouched (constitution II
names window.feedBack).

  app.js -> { plugin-loader, viz, diagnostics-export, dom, highway-colors,
              tuning-display }

HARNESSES — 4 broke, and 3 of them broke in the SAME informative way: they sliced
app.js from `function isBassArrangement(` UP TO the marker
`window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;` — an end-marker
that (correctly) stayed behind in app.js. The module is now nothing BUT the tuning
helpers, so there is no block to slice: they read it whole and strip `export ` so
the vm sandbox still evaluates it as a script.
  tuner_auto_open is SPLIT — its autoplay-gate test still reads app.js, so it keeps
  APP_JS and gains TUNING_JS. Retargeting its path wholesale (my first attempt)
  silently pointed the autoplay test at the wrong file.

VERIFIED BY DRIVING THE CONTRACT. A/B against origin/main in two browsers, through
the real window surface: displayTuningName -> "E Standard" / "Drop D" /
"Eb Standard", parseRawTuningOffsets('-2,0,0,0,0,0') -> [-2,0,0,0,0,0],
isBassArrangement, effectiveStringCount, displayTuningTargets, and
window.feedBack.displayTuningName / .songTuningContext — IDENTICAL on both, zero
console/page errors either side.

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:37:44 +02:00
ebbfc8da6f refactor(app): carve the highway string-colours out of app.js (R3a) (#883)
static/js/highway-colors.js (601 lines) — bodies VERBATIM.
app.js 10,415 → 9,837. Under 10k.

DECOMPOSED, not sliced. The "settings" blob measured 47 fns / 19 inbound and was
not carvable as-is. Seeding the closure from a FUNCTION (initHighwayColors) found
only 18 fns and left 4 HWC_* constants used outside it — i.e. the seed was wrong,
not the cluster. Re-seeding from the STATE (every function touching HWC_*/`_hwc*`)
found the true cluster: 45 top-level nodes, lines 2751-3331, CONTIGUOUS, with ZERO
foreign nodes inside the span.

  INBOUND: 0.  EXPORTS: 2 (initHighwayColors, hwcInitSettingsUI).

The other 43 symbols — the HWC_* tables, the 12 presets, the theme store, the
share codec, the picker handlers, the window.feedBack.highwayColors facade — are
used nowhere else in core and stay private. No inline on*= handlers here (the
Settings buttons are wired by addEventListener inside hwcInitSettingsUI), so
nothing needed re-exposing on window. The three bus listeners register inside
initHighwayColors, which app.js calls — not at module top level — so no ordering
change.

THE no-undef GATE EARNED ITS KEEP. My closure said INBOUND=0; the module actually
uses `uiPrompt` (the "name this theme" prompt). It was missed because uiPrompt is
no longer an app.js DECLARATION — it's an IMPORT BINDING (from #882's dom.js), and
I was collecting declarations only. `no-undef` with typeof:true caught it.
  => Lesson for the next carve: seed `tops` from ImportDeclaration bindings too.
  => And it VALIDATES carving dom.js early: this module just imports uiPrompt from
     it. Had dom.js still been stranded in app.js, this carve would have needed a
     host seam.

  app.js -> { plugin-loader, viz, diagnostics-export, dom, highway-colors }
  plugin-loader -> viz
  highway-colors -> dom
  viz, diagnostics-export, dom -> (leaves)

VERIFIED BY DRIVING THE FACADE. A/B against origin/main in two browsers:
window.feedBack.highwayColors installed, identical method surface, 12 presets,
identical default slot colours, and a share-code encode→decode round-trip
returning #112233 — IDENTICAL on both, zero console/page errors either side.

Harnesses: highway_colors_facade + highway_string_colors retargeted (both
brace-extract blocks out of the source by signature).

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:22:14 +02:00
14b4058bc6 refactor(app): carve the DOM/modal primitives out of app.js (R3a) (#882)
static/js/dom.js (203 lines) — esc, _escAttr, _isElementVisible, _trapFocusInModal,
_confirmDialog, uiPrompt. Bodies VERBATIM. app.js 10,593 → 10,414.

A GATHER, not a slice — the six lived in six different places (108, 635, 659,
2617, 2623, 8892). They belong together because they are the BOTTOM of the UI
stack: `esc` alone has 25 call sites and `_escAttr` 23, and every later carve that
renders HTML will need them.

That is the actual point of doing this one now. Give them a home and the next
carve imports them; leave them in app.js and the next carve that renders HTML has
to invent a host seam to reach back into app.js — exactly the trap the
plugin-loader carve had to work around until the viz layer became a module. This
is the cheapest possible way to stop that recurring.

  app.js -> { plugin-loader, viz, diagnostics-export, dom }
  plugin-loader -> viz
  viz, diagnostics-export, dom -> (nothing)

Zero imports. Six exports (every one is used outside the cluster).

VERIFIED BY DRIVING THE MODALS, not just booting — they are interactive, so a
green suite says little. A/B against origin/main in two browsers:
  * window.uiPrompt / _confirmDialog / _trapFocusInModal all resolve
  * uiPrompt() mounts its modal, accepts typed input, and resolves with the typed
    value ('typed') — IDENTICAL on both
  * _confirmDialog() mounts and resolves true on confirm — IDENTICAL
  * zero console/page errors either side

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:59:44 +02:00
bfb31a8b89 refactor(app): carve the diagnostics-bundle export out of app.js (R3a) (#881)
static/js/diagnostics-export.js (280 lines) — bodies VERBATIM.
app.js 10,858 → 10,592.

Chosen BY MEASUREMENT, not by eye. Ran the transitive closure over four candidate
clusters and took the one with the smallest interface:

  diagnostics       7 fns   235 lines  span 4110-4378  imports 1  exports 2
  shortcuts-modal   5 fns   235 lines  span  104-9897  imports 4  exports 5
  settings+updates 47 fns  1012 lines  span 1409-7636  imports 19 exports 30
  library-render  220 fns  4064 lines  span   20-10536 imports 126 exports 117

diagnostics is contiguous and nearly closed; its one inbound symbol
(_DIAG_FILE_LABELS) lives inside the region and is read only by _renderDiagPreview,
so it moves in and the module ends up a LEAF — imports nothing.

  app.js -> { plugin-loader, viz, diagnostics-export }
  plugin-loader -> viz
  viz, diagnostics-export -> (nothing)

Exports exactly 2: previewDiagnostics + exportDiagnostics, both already in app.js's
window contract (they're inline handlers in the Settings screen) — so app.js keeps
re-exposing them, now as imported bindings. The preview renderer, the file-label
table, and the byte/HTML formatters are used NOWHERE else in core and stay private.

VERIFIED BY DRIVING IT, not just booting. Zero harnesses broke — because the
diagnostics export flow had NO source-level test at all, which is exactly why a
green suite proves nothing here. So the flow was exercised for real: A/B against
origin/main in two browsers, window.previewDiagnostics() invoked, the preview
container rendered identical content on both, both entry points resolve on window,
zero console/page errors either side.

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean, Codex 0.

NOTE for the next carve: library-render is NOT a cluster — 220 functions and 126
inbound symbols is most of app.js entangled together. It cannot be carved as a
unit; it needs decomposing from the inside first.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:52:47 +02:00
a222b45c02 refactor(app): carve the viz layer out of app.js — and delete the loader seam (R3a) (#880)
static/js/viz.js (770 lines) — the viz picker, renderer selection, Auto-match,
the WebGL2 probe, the 3D-promotion nag, the notation hints. Bodies VERBATIM.
app.js 11,603 → 10,857.

THE SEAM IS GONE. #878's plugin-loader needed configurePluginLoader({
populateVizPicker }) purely because _populateVizPicker lived in app.js and
importing app.js would have closed a cycle. viz.js is a LEAF — it imports NOTHING
— so plugin-loader now imports _populateVizPicker straight from it. The _host
object, the configure function, its loud-default guard, and the wiring line in
app.js are all deleted. The second carve simplifies the first.

  app.js -> { plugin-loader, viz }
  plugin-loader -> viz
  viz -> (nothing)

NOT A PURE MOVE — one listener block had to be SPLIT. app.js had a single
top-level `if (window.feedBack) { … }` registering four handlers, and only two
were viz. song:loaded / arrangement:changed / song:ready (the mastery slider)
stay in app.js and now call the imported _autoMatchViz / _maybeShowNotationViewHint.
The viz:reverted handler MOVES, because it REASSIGNS _cancelPendingAutoLabel and
an imported binding is read-only — `_cancelPendingAutoLabel = null` would throw if
the listener stayed behind while the state moved.

ORDER CHECKED, NOT ASSUMED: viz.js's song:ready listener now registers BEFORE
app.js's own (imports evaluate first). Safe — _pendingPromotionNag is only ever
set inside _populateVizPicker, which runs at boot/plugin-refresh, never from
inside the other song:ready handler, so the two are independent.

VERIFIED — the listeners are the risk here, so they were DRIVEN, not just booted.
A/B against origin/main in two browsers:
  * viz picker: 6 options (auto|default|venue|drum_highway_3d|keys_highway_3d|
    highway_3d), selected highway_3d, Auto label — IDENTICAL. This alone proves
    plugin-loader's direct import of viz.js works.
  * emit('viz:reverted') -> picker resets to default, localStorage resets to
    default, the warning logs — IDENTICAL. The MOVED listener fires.
  * emit('song:ready') -> mastery slider enables, no throw — IDENTICAL. The SPLIT
    listener still does both halves.
  * plugin screens, module injections, 37 capability participants — IDENTICAL.
  * zero console/page errors on both.

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean. no-cycle re-bitten on
the 3-module graph (viz -> plugin-loader fails).

Codex preflight raised a [P2] claiming viz.js's top-level bus guards would be
false because "app.js only creates the event bus later" — FALSE POSITIVE. app.js
does not create the bus; capabilities.js does, from its own <script type="module">
at index.html:122, and module scripts execute in document order, so the bus exists
long before app.js's import graph evaluates. Instrumented the setter: by viz.js's
turn `window.feedBack.on` is already a function, and the viz:reverted listener is
provably attached (firing it resets the picker). The ordering is also enforced by
test_app_shell_loads_capability_registry_before_app_runtime.

Harnesses: 5 tests retargeted to viz.js across legacy_shim_hits, venue_scene_3d,
venue_viz (each SPLIT — their non-viz tests still read app.js).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:41:52 +02:00
5b904706d0 feat(audio): loopback feeder mode + static no-cache — all app audio under exclusive/ASIO (#877)
* feat(audio): route feedpak full-mix natively under exclusive output

Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

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

* feat(audio): renderer-bus feeder — mix renderer song audio into engine output (Phase 2)

Under exclusive-style output the native backing transport (Phase 1, #824)
carries loose /audio/ songs and feedpak full-mixes, but not the stems
plugin's multi-stem WebAudio graph or tracks JUCE rejected. The feeder taps
the renderer-side master with an AudioWorklet, re-points the owning
AudioContext at a null sink so it keeps rendering without a device, and
pushes ~10 ms chunks over IPC into the desktop engine's renderer bus
(feedBack-desktop#90 follow-up). Inert in the Docker sphere and in shared
mode. Validated by the fix12 tester spike: null-sink rendering works,
clocks hold, no overflow.

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

* feat(diag): --debug ASIO routing diagnostics in static bundle

Gated on window.feedBackDesktop.audio.debugEnabled() (desktop --debug);
inert in the Docker sphere and normal desktop runs.

- [asio-diag] getCurrentDevice= full device object on outputType change
  (catches ASIO drivers reporting a non-'ASIO' type name)
- [asio-diag] renderer-bus: full feeder decision vector, change-gated
  (running/exclusive/stems/juceMode/elementSong/want/mode)
- [asio-diag] setSink: every sink flip with ctx state + rate

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

* feat(audio): loopback feeder mode — all app audio under exclusive/ASIO

Tester-confirmed (2026-07-11 log): song previews and other
plugin-private audio bypass the per-surface feeder taps and leak to the
default WASAPI device under ASIO output. Also confirmed: the element
capture path poisons itself when highway_3d already owns #audio's
one-shot MediaElementSource (InvalidStateError with _elCtx assigned
pre-throw → TypeError every later tick).

- New preferred mode 'loopback': one getDisplayMedia frame-audio capture
  (desktop main answers with the app's own frame) covers song, previews,
  and UI sounds for the whole exclusive session — engages even with no
  song loaded. Local playback silenced via suppressLocalAudioPlayback,
  page-mute IPC fallback otherwise.
- Sticky fallback to the existing stems/element surface modes when
  capture is unavailable (old desktop main, denied, Docker sphere).
- Element capture: assign module state only after the whole chain
  succeeds; close the context on failure — collision now retries clean.
- Failed engage now disables the bus and tears down loopback (no more
  bus-enabled-with-no-producer stranding).
- Tests: 12 (5 new — loopback engage/preference/mute-fallback/sticky
  fallback, collision retry).

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

* feat(audio): loopback feeder mode — all app audio under exclusive/ASIO

Tester-confirmed (2026-07-11 log): song previews and other
plugin-private audio bypass the per-surface feeder taps and leak to the
default WASAPI device under ASIO output. Also confirmed: the element
capture path poisons itself when highway_3d already owns #audio's
one-shot MediaElementSource (InvalidStateError with _elCtx assigned
pre-throw → TypeError every later tick).

- New preferred mode 'loopback': one getDisplayMedia frame-audio capture
  (desktop main answers with the app's own frame) covers song, previews,
  and UI sounds for the whole exclusive session — engages even with no
  song loaded. Local playback silenced via suppressLocalAudioPlayback,
  page-mute IPC fallback otherwise.
- Sticky fallback to the existing stems/element surface modes when
  capture is unavailable (old desktop main, denied, Docker sphere).
- Element capture: assign module state only after the whole chain
  succeeds; close the context on failure — collision now retries clean.
- Failed engage now disables the bus and tears down loopback (no more
  bus-enabled-with-no-producer stranding).
- Tests: 12 (5 new — loopback engage/preference/mute-fallback/sticky
  fallback, collision retry).

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

* fix(audio): close loopback capture context on teardown (release tap worklet)

The loopback context was reused across engages (_lbCtx || new), but teardown
only stopped the stream + deactivated the tap — never closing the context or
detaching the worklet node. Each exclusive<->shared switch orphaned a live
tap worklet on the long-lived context. Use a fresh context per session and
close it on disengage. Adds a test asserting the context is closed on teardown.

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

* feat(diag): install-time + uncaught-error diagnostics for the reroute chain

2026-07-11 tester log showed the routing watcher and renderer-bus feeder
never installed (zero [feedpak-route]/[renderer-bus] lines) plus an
uncaught SyntaxError with no source location — nothing in the log said
why. New:

- global error/unhandledrejection tap logging message + filename:line:col
  (error events carry the location even for parse errors in other scripts)
- explicit install / NOT-installed lines for watcher and feeder (incl.
  loopback capability probe)
- DOMException detail (name/message/stack head) in the feeder retry warn
  — the console-message forward stringified it to [object DOMException]

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

* fix(static): force conditional revalidation on /static (Cache-Control: no-cache)

Without Cache-Control Chromium's heuristic freshness (10% of file age)
serves /static/app.js from disk cache for hours-to-days without
revalidating. Desktop consequence: a new build's window ran the previous
build's app.js — the 2026-07-11 ASIO investigation traced 'routing
watcher never installed' + a stems module-plugin SyntaxError to exactly
this (stale loader predating scriptType support). no-cache keeps caching
but revalidates via ETag — unchanged files still cost only a 304.

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

* fix(diag): gate install-time + uncaught-error [asio-diag] lines on --debug

The error tap and install lines from the previous diag commit were
unconditional. Now: error/rejection taps check _asioDiagEnabled() at
event time; install lines log deferred once the async debugEnabled()
resolves true. The NOT-installed anomaly lines stay bridge-gated
(window.feedBackDesktop present) instead — a broken bridge can't deliver
the debug flag, they fire at most once, and only in the broken state
they exist to witness. Docker sphere: fully silent.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-11 18:22:25 +02:00
38772f604a refactor(app): carve the plugin loader out of app.js into static/js/ (R3a) (#878)
The first carve, and deliberately the riskiest: app.js IS the plugin loader (the
R0 host rails), so it goes first while the module graph is still one edge deep.

static/js/plugin-loader.js (829 lines) — bodies VERBATIM. app.js 12,217 → 11,439.
Core's first `static/js/` module, exactly as constitution II anticipates.

CLOSURE (measured with acorn, not regex — brace-matching stripped source drifted):
the block at app.js:11246-12031 is contiguous and self-contained. It needs only
TWO things from the rest of app.js, and exports only TWO:
  exports: loadPlugins (the window contract), bootstrapPluginsAndUi (boot)
  inbound: window.showScreen — already the public host contract (constitution II),
           so it is called through `window`, not re-coupled as an import
           _populateVizPicker — injected via configurePluginLoader()

WHY A SEAM, NOT AN IMPORT. plugin-loader must not import app.js: app.js imports
it, so that would close a cycle. I checked whether _populateVizPicker could just
move into the module instead (which would delete the seam entirely) — it drags 9
further symbols (_canRun3D, _autoMatchViz, _showPromotionNag, …), i.e. a whole
viz cluster. That is its own carve, so the seam stays.

THE SEAM'S DEFAULT IS LOUD, ON PURPOSE. A no-op stub is the classic silent
failure for this pattern (see the editor's setHostHooks trap, hit twice): drop the
wiring call and the loader keeps working while the viz picker quietly stops
refreshing — no test, no boot check says a word. The default now console.errors,
so the smoke harness catches it. VERIFIED BY BITE TEST: removing
configurePluginLoader() from app.js surfaces
"[plugin-loader] host seam not configured" at boot. The seam IS exercised on the
plugin-startup path, so an unwired hook cannot pass silently.

no-cycle is now LIVE on core's own graph for the first time. eslint.config.js
gains `static/app.js` + `static/js/**` to the module block — app.js now `import`s,
so parsing it as a script would be a syntax error. VERIFIED BY BITE TEST: making
plugin-loader import app.js back fails with "Dependency cycle detected".

HARNESSES (the R3a note said budget one conversion per carve — it was five):
retargeted capability_inspector_nav, plugin_hydration_wipe,
plugin_loader_script_type, plugin_style_injection, legacy_shim_hits (SPLIT — one
test needs the loader, one still needs app.js) + test_plugin_runtime_idempotence.
legacy_shim_hits was missed by a symbol-name grep because it greps for a code
STRING; only the failing run found it. test_capability_events' NEGATIVE asserts
now span app.js + the loader — carving code out of app.js would otherwise make
them vacuous instead of failing.

VERIFIED: A/B against origin/main in two browsers — mounted plugin screens, 14
loaded plugin scripts, the 3 module plugins injected as <script type="module">,
37 capability participants, 14 shims, window.loadPlugins: IDENTICAL, zero
console/page errors on both. /static/js/plugin-loader.js serves 200; R0 rails
intact (src/main.js 200, conditional GET 304, script_type passthrough).
pytest 2396, node 1032/1032, ESLint 0, Codex 0.

Codex preflight caught a REAL [P1] first pass: static/js/plugin-loader.js was
untracked, so a checkout would have served an app.js importing a nonexistent
module — a failed static import kills the whole module and every window handler
with it. Now tracked.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:18:00 +02:00
92c86f5393 refactor(ui): load app.js as an ES module (R3a) (#876)
One attribute. #871/#872/#874/#875 exist to make this line safe.

app.js's 385 top-level `function` declarations stop being implicit `window`
properties: 87 stay reachable via the explicit contract (#874's Object.assign
block + the 47 pre-existing `window.X = X` assignments), and 298 become
module-private. Verified NO unexposed name is read from outside app.js.

Strict mode (modules are always strict) checked ahead of the flip: app.js parses
clean as `sourceType: module` (no octal, dup params, `with`), and has no implicit
globals, no `eval`/`new Function`, no top-level `this`. `registerShortcut` is
called bare at 15 top-level sites but is assigned at `window.registerShortcut`
(app.js:10387) before its first call (10648), and a bare identifier in a module
still resolves through the global object — verified `typeof
window.registerShortcut === 'function'` in the browser.

HARD GATE — app.js IS the plugin loader:
  - /api/plugins script_type passthrough: editor/stems/studio = "module"
  - /api/plugins/stems/src/main.js -> 200; conditional GET -> 304 (live-edit ETag)
  - deep graph: stems/src/transport.js, editor/src/state.js -> 200
  - window.loadPlugins present; 5 plugin screens mount; the 3 migrated plugins
    injected as <script type="module">
  - 37 capability participants, 14 compatibility shims, bus + capabilities v1

Every one of the shell's 336 inline handlers resolves on window under module
scope, and the A-Z rail / pagination execute 6/6 with no ReferenceError. A/B
against origin/main: the ONLY unresolvable handler is `editorToggleStemMixer`,
which is equally broken on main (a dead handler in the editor plugin — not
defined anywhere in its source; pre-existing, flagged separately).

Codex preflight raised a [P1] claiming restartCurrentSong / requestExitSong /
editRegionInEditor / returnToEditorFromHighway would ReferenceError — FALSE
POSITIVE. It scanned only #874's new Object.assign block and missed app.js's 47
scattered `window.X = X` assignments; all four are at app.js:7086/7204/8492/8511
and all four resolve as `function` in the browser with app.js loaded as a module.

pytest 2396, node 1032/1032, ESLint 0 errors, tailwind-fresh clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:38:33 +02:00
c223ace419 refactor(ui): load the capabilities as ES modules (R3a) (#875)
ship-ci / ci (push) Waiting to run
The 12 capability <script> tags become type="module". No JS changes — the
capability scripts already self-register on the window.feedBack bus,
version-negotiate (`capabilities.version !== 1` → bail), and self-guard for
idempotency. They never import or call app.js; it is pure pub/sub.

Verified they export nothing by name: no top-level declaration in
capabilities.js or capabilities/*.js is read by any other script, so losing
global scope costs nothing.

This is the first REAL exercise of the ordering fix from #872. A module defers to
after HTML parse, so the capabilities now execute AFTER the document is parsed —
while app.js still calls `window.feedBack.on(...)` at its top level. That only
works because #872 put every classic script into the same deferred queue, where
document order IS execution order: capabilities.js (line 122) still runs before
app.js (line 1237). Had app.js stayed a plain classic script it would have run
during parse, hit a bare `{}`, and died on `.on is not a function`.

A/B against origin/main, 11 probes — capabilities.version, registered
participants (37), compatibility shims (14), the bus, workingTuning, theme,
setViz/showScreen/playSong, mounted plugin screens: IDENTICAL, zero console/page
errors on both. 12 module tags served and executed; pytest 2396, node 1032/1032,
ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:15:38 +02:00
ff7e855e35 refactor(app): make app.js's window contract explicit — 66 names (R3a) (#874)
app.js is a classic script, so each of its 385 top-level `function foo()` decls
is implicitly a property of `window`. As an ES module it will not be — module
scope is not global scope — and every name reached from outside this file would
silently vanish. This adds the explicit `window.*` assignments BEFORE the flip.

Provably a NO-OP: all 66 are top-level function declarations, so while app.js is
still a classic script `Object.assign(window, {...})` only re-assigns what
`window` already has. That is what makes it safe to land on its own, ahead of
the flip that needs it.

The consumers are wider than the inline handlers in index.html:
  - inline on*= handlers in static/v3/index.html
  - on*= handlers app.js BUILDS inside template literals (goFavPage,
    updatePlugin, hideScanBanner, ...) — they resolve against window at CLICK
    time, but live in a JS string, so scanning the HTML alone never finds them
  - static/v3/*.js (showScreen alone has 17 consumers), capabilities
  - feedback-desktop and the external plugin repos — easy to miss, they live in
    other repos and no core test covers them
  - capabilities/visualization.js reads window.setViz behind a `typeof` guard,
    so losing it DEGRADES IN SILENCE rather than throwing

Constitution II names window.playSong / window.showScreen / window.feedBack as
the public extension contract, so this is an obligation, not a convenience.

FOUR names are invisible to every static tool. app.js:2156-2157 picks the
handler NAME at runtime —
    const letterFn = favoritesOnly ? 'filterFavTreeLetter' : 'filterTreeLetter';
— and interpolates it into `onclick="${letterFn}('A')"`. The names exist only
inside string literals, so ESLint, no-undef, and any grep for `onclick="fn` all
miss them. They are the library A-Z rail and its pagination: drop one and those
buttons throw at click time and nowhere else.

New tests/js/window_contract.test.js scrapes the HTML's handlers AND app.js's
template-literal handlers, and pins the 4 runtime-composed names by hand.
Verified to BITE: dropping showScreen, goTreePage, or setViz each fails it with
the right message.

On-device: 28 A-Z rail buttons render with their real onclick sources
(filterTreeLetter('A'), ...) and 8/8 execute with no ReferenceError; all 66
names resolve on window in the browser.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:09:18 +02:00
4b4c156fce refactor(ui): defer every classic script, keep boot() on DOMContentLoaded (R3a) (#872)
Puts every external `<script>` in the v3 shell into the deferred queue, and
keeps each script's boot() firing at DOMContentLoaded exactly as it does today.
Behaviourally a no-op; it is what makes the ES-module flips safe.

WHY. `type="module"` defers execution to after HTML parse. Classic-`defer` and
module scripts share ONE "execute after parsing" list and run in DOCUMENT ORDER,
but a plain classic script runs DURING parse — ahead of all of them. So the
moment capabilities.js becomes a module while app.js is still plain, app.js runs
FIRST, and its 11 top-level `window.feedBack.on(...)` calls (app.js:6245-6722)
hit a bare `{}` — `_ensureFeedBackEventBus()` (capabilities.js:33), which
attaches .on/.emit/.off, would not have run yet. TypeError, app.js dies
mid-parse. Deferring everything now keeps document order == execution order
through the rest of the migration.

THE CATCH (Codex preflight caught this — a real ordering change). 22 scripts
guard their boot with `if (document.readyState === 'loading')`. A deferred
script runs at readyState 'interactive', so that test is FALSE and the else-branch
fires boot() immediately, at the script's position in document order — instead of
at DOMContentLoaded, after every script has evaluated.

That matters far more than one call site: a scan of the shell's scripts found
**43 forward references** where a script's boot() reads a global that a LATER
script defines (shell.js -> profile.js's window.v3Onboarding, songs.js ->
settings.js's window._confirmDialog, badges.js -> songs.js's
window.displayTuningName, ...). Every one of them resolves today only because
all boots happen at DOMContentLoaded. So the guards now treat 'interactive' as
not-ready (`!== 'complete'`), restoring that exactly.

Codex's specific finding (first-run onboarding silently skipped) did NOT
reproduce — shell.js's boot() awaits /api/profile, and that yield lets the
remaining deferred scripts run first. But the race it described is real, the
guard is silent when it fails (`&& window.v3Onboarding`), and the other 42
forward refs have no such await protecting them. Fixed at the root rather than
at the one site.

VERIFIED. A/B against origin/main on a fresh profile, 13 probes (onboarding
overlay, v3Onboarding/v3Songs/v3Profile/fbNotify/v3Badges/uiPrompt/showScreen,
bus, capabilities.version, createHighway, plugin scripts, mounted screens):
IDENTICAL, zero console/page errors on both. pytest 2396, node 1028/1028,
ESLint 0 errors, Codex 0.

New guard: test_every_external_script_defers_so_document_order_is_execution_order
fails if any external tag is plain classic — verified to fail on a single
reverted tag, so it actually bites.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:57:04 +02:00
9d0bf95716 refactor(ui)!: remove the classic v2 shell — v3 is the only UI (R3a) (#871)
* refactor(ui)!: remove the classic v2 shell — v3 is the only UI (R3a)

Deletes `static/index.html`, the `/v2` route, and the `FEEDBACK_UI` v2/legacy
opt-out. `/` and `/v3` both serve `static/v3/index.html`, which has been the
default since 0.3.0.

This is step 0 of the core-frontend ES-module migration (R3a). Both shells load
the same `static/app.js`, so every later step of that migration — exposing the
window contract, the `defer` ordering fix, the `type="module"` flips — would
otherwise have to be made and verified twice. Removing the fallback now halves
that surface before any of it is touched.

Incidentally fixes a latent bug in `index()`: its guard read
`if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy")`,
whose left operand is truthy for *any* non-empty value — so `FEEDBACK_UI=v3`
actually served the **v2** shell.

- `static/tailwind.min.css` regenerated: the content globs scanned the deleted
  file, so v2-only utility classes are now purged (CI's tailwind-fresh job
  rebuilds and diffs it).
- Constitution amended to 1.3.0 — Principle II's frontend file list now names
  `static/v3/index.html`.
- Tests: 4 suites read the v2 shell (3 via a constructed `path.join` that a
  literal grep misses). Their v2 halves are paired duplicates of v3 tests that
  stay, so they are dropped; `alpha_warning_banner` and the capability-registry
  script-order test retarget to `static/v3/index.html`.

BREAKING CHANGE: `FEEDBACK_UI=v2` / `=legacy` and the `/v2` route are gone.
Unset the variable and use `/`. No chart, settings, or plugin data changes, and
no plugin API changes — v3 reuses the same engine.

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

* docs: drop the stale '/ is v2' plugin-verification guidance (CodeRabbit)

The v3-only rewrite updated the intro paragraphs but left three lines that
still instructed plugin authors to verify in 'both / (v2) and /v3' — now the
same shell. Historical 'in v2 it was X' contrasts are kept: they still orient
authors whose plugins also ship to users on older cores.

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-11 16:33:03 +02:00
5e30138c87 refactor(server): extract the artist routes into routers/artist.py (R3) (#870)
The artist page + external-links payload (/api/artist/{name}/page, /links,
/links/refresh) plus their exclusive helpers (_artist_links_payload,
_artist_links_from_mb, the URL-slot table) move to lib/routers/artist.py. Bodies
verbatim except @app->@router and the seam reads (meta_db->appstate.meta_db,
CONFIG_DIR->appstate.config_dir, _default_settings->appstate.default_settings).
MB link enrichment is reached as enrichment.X; the URL-safety validator is
imported from lib/library_registry.py. No new seams.

server.py: 2,507 -> 2,413 (-94).

Verified: pyflakes clean; route set unchanged (143); full pytest 2395 passed.
eslint 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:49:37 +02:00
0547f55844 refactor(server): extract the media/file-serving routes into routers/media.py (R3) (#869)
Song audio (/audio/{f}), the local-audio-path resolver (/api/audio-local-path),
and raw sloppak-member serving (/api/sloppak/{f}/file/{rel}) — plus the shared
_resolve_sloppak_local_file helper — move to lib/routers/media.py. Bodies verbatim
except @app->@router and the cache/static path seams (AUDIO_CACHE_DIR->
appstate.audio_cache_dir, STATIC_DIR->appstate.static_dir, SLOPPAK_CACHE_DIR->
appstate.sloppak_cache_dir — all already in the seam). No new slots.

The two test fixtures that redirect STATIC_DIR to a temp dir now also patch
appstate.static_dir (the moved routes read the seam, not server's global).

server.py: 2,638 -> 2,507 (-131).

Verified: pyflakes clean; route set identical (143), all unique-path (no
catch-all, no shadowing); full pytest 2395 passed (the audio-local-path +
sloppak-file-traversal cases). eslint 0. Boot smoke: /audio, /api/sloppak/{}/file
serve 404 for unknown, 0 tracebacks.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:36:22 +02:00
b7624b7e65 refactor(server): extract the enrichment route handlers into routers/enrichment.py (R3) (#868)
The 14 /api/enrichment/* routes — status, kick/cancel, per-song state, the
Match-Review queue (accept/reject/pick/search/rematch/refresh/states), and the
AcoustID fingerprint identify endpoints — plus the route-exclusive candidate
sanitizer move to lib/routers/enrichment.py. Bodies verbatim except @app->@router
and the seam reads (meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir).
The enrichment engine (transport, matcher, worker, upload caps) already lives in
lib/enrichment.py from the earlier subsystem move and is reached as enrichment.X.
No new seams.

server.py: 2,925 -> 2,638 (-287).

Verified: pyflakes clean; route set identical (143); full pytest 2396 passed (the
enrichment route + Match-Review + identify cases, which fake the network on the
enrichment module). eslint 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:19:34 +02:00
Byron GamatosandGitHub f00ba2217d Revert "feat(audio): loopback feeder mode — all app audio under exclusive/ASIO (#865)" (#867)
This reverts commit 9a58a55fe8.
2026-07-11 14:56:35 +02:00
f09c4a217f refactor(server): extract library + collections routes + the provider registry (R3) (#866)
The library query surface (songs/albums/artists/stats/genres/tuning-names/
practice-suggestions), the provider list/art/sync endpoints, and collection CRUD
move to lib/routers/library.py. The registry itself — LibraryProviderRegistry,
LocalLibraryProvider, SmartCollectionProvider, the collection-provider lifecycle
(_sync/_unregister_collection_provider), and the shared query/collection helpers
(_library_filter_args, _split_csv, _sanitize_collection_rules, _safe_art_redirect_url,
the filter-key sets) — moves to lib/library_registry.py.

The PLUGIN CONTRACT is untouched: server.py still constructs the singleton
(LocalLibraryProvider needs meta_db), still exposes register_library_provider /
unregister_library_provider to plugins via plugin_context (with the per-plugin
ownership scoping in plugins/__init__.py), and injects the registry + local
provider into appstate. The router reads appstate.library_providers /
appstate.local_library_provider at call time; the provider classes are duck-typed
so no plugin imports a base class. Acyclic: library_registry imports
routers.art (for LocalLibraryProvider.get_art) + appstate, never server.

server.py: 3,692 -> 2,925 (-767).

Verified: pyflakes clean; ORDERED route table identical set (library block mounts
at one site — all exact/specific-path, no catch-all, no shadowing); full pytest
2397 passed (incl the plugin register/unregister + collection-as-provider tests).
eslint 0. Boot smoke: /api/library + providers list "local"; a created collection
surfaces as a `collection:N` provider through the seam; collection CRUD clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:56:12 +02:00
9a58a55fe8 feat(audio): loopback feeder mode — all app audio under exclusive/ASIO (#865)
* feat(audio): loopback feeder mode — all app audio under exclusive/ASIO

Tester-confirmed (2026-07-11 log): song previews and other
plugin-private audio bypass the per-surface feeder taps and leak to the
default WASAPI device under ASIO output. Also confirmed: the element
capture path poisons itself when highway_3d already owns #audio's
one-shot MediaElementSource (InvalidStateError with _elCtx assigned
pre-throw → TypeError every later tick).

- New preferred mode 'loopback': one getDisplayMedia frame-audio capture
  (desktop main answers with the app's own frame) covers song, previews,
  and UI sounds for the whole exclusive session — engages even with no
  song loaded. Local playback silenced via suppressLocalAudioPlayback,
  page-mute IPC fallback otherwise.
- Sticky fallback to the existing stems/element surface modes when
  capture is unavailable (old desktop main, denied, Docker sphere).
- Element capture: assign module state only after the whole chain
  succeeds; close the context on failure — collision now retries clean.
- Failed engage now disables the bus and tears down loopback (no more
  bus-enabled-with-no-producer stranding).
- Tests: 12 (5 new — loopback engage/preference/mute-fallback/sticky
  fallback, collision retry).

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

* fix(audio): close loopback capture context on teardown (release tap worklet)

The loopback context was reused across engages (_lbCtx || new), but teardown
only stopped the stream + deactivated the tap — never closing the context or
detaching the worklet node. Each exclusive<->shared switch orphaned a live
tap worklet on the long-lived context. Use a fresh context per session and
close it on disengage. Adds a test asserting the context is closed on teardown.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-11 14:54:40 +02:00
bbdff4e10f refactor(server): extract the song routes into routers/song.py (R3) (#864)
Upload/delete, the catalog-metadata write-back, user-meta, overrides, gap-fill,
and the per-song info payload (11 routes) move to lib/routers/song.py with their
exclusive helpers (the atomic upload commit + the song-IO lock, the upload caps,
the gap-fill proposal builders). Bodies verbatim except @app->@router and the
seam reads: meta_db->appstate.meta_db, art_override_paths->appstate.art_override_paths,
and the scan/ingest helpers that stay in server.py (the scan lifecycle owns them)
-> new appstate seam callables: kick_scan, invalidate_song_caches, stat_for_cache,
and scan_status() (a getter — the underlying dict is reassigned). The gap-fill
MBID/ISRC regexes are reached as enrichment.X; _MULTIPART_OVERHEAD_SLACK (shared
with the staying AcoustID-identify route) moves to lib/enrichment.py beside
_ACOUSTID_MAX_UPLOAD_BYTES.

ROUTE ORDER: song_router mounts AFTER art_router — get_song_info's catch-all
`/api/song/{filename:path}` would otherwise shadow `/api/song/{path}/art*`
(Starlette matches first-registered; the :path converter is greedy).

server.py: 4,478 -> 3,692 (-786).

Verified: pyflakes clean; ORDERED route table preserves the specific-before-catch-all
invariant; full pytest 2397 passed (incl the art/cover 304 + CAA-fetch tests that
caught the shadowing before it was fixed). eslint 0.

BEHAVIORAL — needs an on-device pass (upload a sloppak, edit metadata write-back,
delete a song) before merge.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:14:52 +02:00
7258e1066a refactor(server): extract the settings routes into routers/settings.py (R3) (#863)
GET/POST /api/settings, /api/settings/reset, and the two-phase atomic
export/import bundle (/api/settings/export|import) move to lib/routers/settings.py
with their exclusive helpers (the relpath allowlist validator, the atomic writer,
the library-DB snapshot + sqlite integrity gate, the config-type validator, the
bundle schema). Bodies verbatim except @app->@router and the seam reads:
meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir,
_running_version->appstate.running_version(), and _default_settings->
appstate.default_settings (the canonical defaults builder stays in server.py —
the scan + artist-links code share it — and is injected as a new seam callable).

server.py: 5,539 -> 4,478 (-1,061).

Verified: pyflakes clean (bar the pre-existing File/safe_join/tuning_name/ET);
route table IDENTICAL (143); full pytest 2397 passed (154 settings cases incl the
export→import round-trip + library-DB snapshot/restore + relpath-allowlist SSRF/
traversal guards, retargeted onto the settings module). eslint 0.

BEHAVIORAL — needs an on-device settings export→import round-trip sign-off before
merge (do not merge on green CI alone).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:35:05 +02:00
73127d5416 refactor(server): extract the album-art routes into routers/art.py (R3) (#862)
The six song-art routes — GET /api/song/{f}/art, .../art/cover-search,
.../art/candidates, POST .../art/upload, .../art/url, DELETE /api/art/{f}/override
— plus their exclusive helpers (the ETag/304 response machinery, _save_art_override,
_url_host_is_internal, _fetch_art_url + the art size/redirect caps) move to
lib/routers/art.py. Bodies verbatim except @app->@router and the seam reads:
meta_db->appstate.meta_db, ART_CACHE_DIR->appstate.art_cache_dir, and the three
shared art helpers that stay in server.py (used by the song/delete routes too)
-> appstate.<callable> (_song_pack_art_exists, _art_override_paths — already
seam-injected for the enrichment worker — plus a new art_safe_name slot). The
CAA / release-search transport lives in lib/enrichment.py and is reached as
enrichment.X. LocalLibraryProvider.get_art now calls art_router.get_song_art.

server.py: 5,988 -> 5,540 (-448).

Verified: pyflakes clean (bar the pre-existing File/safe_join/tuning_name/ET);
route table IDENTICAL (143); full pytest 2399 passed (33 art serve/candidates/
override/url cases incl the SSRF-guard _url_host_is_internal + _fetch_art_url
size-cap tests, retargeted onto the art module); test_packaging 43; eslint 0.
Boot smoke: /art 404, /art/candidates 404, DELETE /override 200 from the router.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:56:53 +02:00
165475d115 refactor(server): move the metadata-enrichment subsystem into lib/enrichment.py (R3) (#861)
* refactor(server): move the metadata-enrichment subsystem into lib/enrichment.py (R3)

MusicBrainz / Cover-Art-Archive / AcoustID transport, the match-scorer glue, and
the background enrichment worker (~930 lines, 61 defs) leave server.py as one
cohesive unit. Bodies are verbatim; the only changes are seam reads:

  meta_db / config_dir / sloppak_cache_dir / art_cache_dir -> appstate.<slot>
  _song_pack_art_exists / _art_override_paths (stay in server.py for the art +
    delete routes) -> appstate.<callable> (new seam slots, injected by reference)
  _env_flag -> env_compat.env_flag_compat (the existing identical helper)
  _artist_title_from_filename -> imported from metadata_db (its home)
  the User-Agent VERSION lookup: Path(__file__).parent ->
    Path(__file__).resolve().parents[1] (lib/enrichment.py -> app root)

server.py drives the worker through the module (import enrichment; the routes +
scan lifecycle call enrichment.X). Tests that faked the network on `server`
(_mb_http_get, _enrich_network_enabled, _caa_http_get, ...) now patch the same
names on `enrichment` — the module attribute is resolved at call time, so one
setattr reaches both the routes and the worker's internal callers. Acyclic:
enrichment imports appstate/appconfig/dlc_paths/metadata_db/mb_match/
acoustid_match/sloppak/loosefolder, never server.

server.py: 6,917 -> 5,988 (-929).

Verified: pyflakes clean (bar the pre-existing File/safe_join/tuning_name/ET);
route table IDENTICAL (143); full pytest 2400 passed (140 enrichment/art cases
incl the offline-safety + transport-error-pauses-pass contracts that fake the
network); test_packaging 44 passed (enrichment.py resolves under lib/); eslint 0.
Boot smoke: /api/enrichment/status + POST /kick serve from the new module.

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

* test: reset enrichment worker state between tests (CodeRabbit)

lib/enrichment.py now owns the worker, and it stays imported for the whole
session while the `server` fixtures pop-and-reimport `server` — so the cancel
Event / status dict / caches would leak across tests, and a stale `_enrich_cancel`
could short-circuit a later direct `_background_enrich()`. An autouse conftest
fixture clears that process-global state before each test.

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

* test: tighten the enrichment-reset fixture (CodeRabbit)

Narrow the import guard to ImportError (not blind Exception, BLE001), and stop
clearing _caa_index_locks — it's guarded by _caa_index_locks_guard, so an
unlocked clear() would race a still-alive worker, and its per-release mutexes
carry no test state.

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-11 11:32:21 +02:00
508829c012 refactor(server): extract /api/tunings into routers/tunings.py + lib/appconfig.py (R3) (#858)
ship-ci / ci (push) Waiting to run
The merged-tuning-catalog route moves to lib/routers/tunings.py, verbatim except
@app->@router, CONFIG_DIR->appstate.config_dir, and the two seam substrates it
needed:

  - lib/appconfig.py — the pure config.json reader `_load_config` (used by ~11
    server sites + future config-reading routers). server.py re-imports it, so
    those call sites and any `server._load_config` test reference are unchanged.
  - appstate.tuning_providers — the TuningProviderRegistry instance injected by
    reference (a stable object mutated in place via register()/unregister()), so
    the router reads the same registry plugins populate through plugin_context.
    The instance stays defined in server.py, so `server.tuning_providers` still
    resolves — zero test retargets.

The tuning constants (DEFAULT_REFERENCE_PITCH, TUNING_PRESET_MIDIS,
freqs_to_midis) already live in lib/tunings.py and are imported directly.

server.py: 6,960 -> 6,917.

Verified: pyflakes clean (bar the pre-existing unused `tuning_name` import);
route table IDENTICAL (143); full pytest 2400 passed (110 tuning/config cases);
eslint 0. Boot smoke: /api/tunings serves referencePitch + tunings + tuningMidis.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:43:19 +02:00
cce95cbd1e refactor(server): extract the diagnostics routes into routers/diagnostics.py (R3) (#857)
The three /api/diagnostics/* routes (export, preview, hardware) plus their
exclusive payload-cap helpers and the `_diag_*` normalisers. Bodies verbatim
except @app->@router, CONFIG_DIR->appstate.config_dir, _running_version()->
appstate.running_version() (a new seam slot; the impl stays in server.py where
the settings region also calls it), and the builtin-plugins lookup in
_diag_plugins_roots: Path(__file__).parent -> Path(__file__).resolve().parents[2]
(routers -> lib -> app root; plugins/ ships at the app root in every packaging
path).

The pure caps/normalisers (_diag_cap_console/_dict/_contributions,
_diag_coerce_bool, _diag_normalize_include, _DIAG_MAX_*) are re-exported from
server.py so the existing `server._diag_*` / `server._DIAG_*` tests keep
resolving — none of them monkeypatch these, so no test retargets.

server.py: 7,216 -> 6,960.

Verified: pyflakes clean (bar the intentional re-export lines); route table
IDENTICAL (143); full pytest 2399 passed (77 diag/packaging + 122 diagnostic-
matched cases incl the cap/coerce/normalize suites); eslint 0; Codex pending.
Boot smoke: /hardware, /preview, and POST /export (200 application/zip) all
serve from the new router location.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:27:52 +02:00
32ebc7671e refactor(server): extract the version route into routers/version.py (R3) (#856)
ship-ci / ci (push) Waiting to run
GET /api/version + its exclusive _safe_http_url URL validator. Bodies verbatim
except @app -> @router and the VERSION-file lookup: Path(__file__).parent (the
app root when this lived at the top level) -> Path(__file__).resolve().parents[2]
(routers -> lib -> app root). VERSION ships at the app root in every packaging
path (Dockerfile COPY VERSION /app/, desktop bundle).

server.py: 7,275 -> 7,214.

Verified: pyflakes clean; route table IDENTICAL (143); pytest 2401 passed (33 in
test_version_endpoint, incl the URL-validation + env-override cases); eslint 0.
Boot smoke: /api/version returns the real version + validated source/license URLs.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 00:56:36 +02:00
97 changed files with 11490 additions and 10104 deletions
+2 -2
View File
@@ -34,7 +34,7 @@ but not the primary supported path.
### II. Vanilla Frontend — No Frameworks
The frontend (`static/app.js`, `static/highway.js`, `static/index.html`,
The frontend (`static/app.js`, `static/highway.js`, `static/v3/index.html`,
`static/style.css`) is plain JavaScript with the `fetch` API, direct DOM
manipulation, and the Canvas 2D / WebGL2 APIs. The only style framework
is Tailwind CSS, served as a prebuilt static stylesheet
@@ -284,4 +284,4 @@ no `..`, no absolute paths).
higher-numbered principle's escape hatch is to live in a plugin
with its own bundled assets.
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-08
**Version**: 1.3.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-11
+21 -1
View File
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Removed
- **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the
`/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve
`static/v3/index.html`, which has been the default since 0.3.0. This is the first step of
the core-frontend ES-module migration (R3a): both shells load the same `static/app.js`, so
every subsequent step of that migration would otherwise have to be made, and verified,
twice. Removing the fallback now halves that surface before any of it is touched.
Incidentally fixes a latent bug in the old `index()` route — its guard read
`if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy")`,
whose left operand is truthy for *any* non-empty value, so `FEEDBACK_UI=v3` actually served
the **v2** shell. `static/tailwind.min.css` is regenerated (the content globs scanned the
deleted file, so v2-only utility classes are now purged). Constitution amended to 1.3.0:
Principle II's frontend file list now names `static/v3/index.html`.
**Migration notes:** if you set `FEEDBACK_UI=v2` (or `=legacy`), or bookmarked `/v2`, there
is no longer a classic shell to fall back to — unset the variable and use `/`. The env var
itself is no longer read; the `SLOPSMITH_*``FEEDBACK_*` compat shim is unaffected. No
chart, settings, or plugin data changes, and no plugin API changes: v3 reuses the same
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
### Fixed
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
@@ -27,7 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`.
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `media` (the file-serving routes — song audio `/audio/{f}`, the local-audio-path resolver `/api/audio-local-path`, and raw sloppak-member serving `/api/sloppak/{f}/file/{rel}` → `lib/routers/media.py`; the cache/static path seams were already in `appstate`), and `artist` (the artist page + external-links payload `/api/artist/{name}/page|links|links/refresh` → `lib/routers/artist.py`; MB link enrichment reached as `enrichment.X`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
- **`routers/` — the first extracted route module (R3).** The five audio-effects mapping
endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a
`fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file
@@ -40,6 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the second slot. The `_demo_mode_guard` middleware still blocks all four moved write
routes with 403, and `Query(...)` validation still 422s — both checked against a running
server. `server.py`: **9,445 → 9,386 lines**.
- **`lib/enrichment.py` — the metadata-enrichment subsystem leaves `server.py` (R3, move-only).** MusicBrainz / Cover-Art-Archive / AcoustID transport, the match-scorer glue, and the background enrichment worker (~930 lines, 61 defs) move out as one cohesive unit. Bodies are verbatim; the only changes are seam reads — `meta_db`/`config_dir`/`sloppak_cache_dir`/`art_cache_dir` and the two shared art helpers (`song_pack_art_exists`, `art_override_paths`, which stay in `server.py` for the art/delete routes) are reached through `appstate` at call time, and the User-Agent VERSION lookup is corrected for the module's new depth. `server.py` drives the worker through the module (`import enrichment`; the routes + scan lifecycle call `enrichment.X`); tests that faked the network on `server` now patch the same names on `enrichment` (module attribute resolved at call time, so one `setattr` reaches both the routes and the worker's internal callers). Acyclic — `enrichment` imports no `server`. Route table byte-identical; full suite green. `server.py`: 6,917 → 5,988.
- **`appstate.py` — the router seam (R3).** Route modules moving out of `server.py`
need `meta_db` and friends but must not `import server`, or the import graph goes
circular the moment `server` imports them back. So `server.py` keeps *constructing*
+6 -6
View File
@@ -125,13 +125,13 @@ Notes:
### v3 UI (fee[dB]ack v0.3.0) — player-chrome contract
v0.3.0 ships a redesigned UI behind a flag (`FEEDBACK_UI=v3` or the `/v3` route);
the classic UI (v2) stays the default until 0.3.0 ships, so **plugins must work in
both**. v3 reuses the same engine (`server.py`, `app.js`, `highway.js`, `playSong`,
v0.3.0's redesigned UI is **the only UI** — the classic v2 shell and its
`FEEDBACK_UI` / `/v2` opt-outs are gone, so there is no second shell to support.
v3 reuses the same engine (`server.py`, `app.js`, `highway.js`, `playSong`,
`showScreen`, capabilities, library providers, the `window.feedBackViz_<id>` /
`setRenderer` contract), so a plugin's **backend, capabilities, `nav`/`screen`,
visualization renderers, diagnostics, and settings export work unchanged** — v3
surfaces `nav` in its sidebar and mounts screens exactly as v2 does.
surfaces `nav` in its sidebar and mounts screens as before.
**The only thing that changed is the player chrome.** If your plugin injects a
control into it, you must adapt:
@@ -157,7 +157,7 @@ control into it, you must adapt:
popovers 40).
Full guide + the canonical snippet: **[docs/plugin-v3-ui.md](docs/plugin-v3-ui.md)**.
Verify any player-injecting plugin in **both** `/` (v2) and `/v3`.
Verify any player-injecting plugin at `/` — it and `/v3` serve the same v3 shell.
### Performance — never run DOM queries on a per-frame path
@@ -566,7 +566,7 @@ a local pointer + code map.
- **Storage** — `localStorage` for all user preferences
- **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (feedBack-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II.
- **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable. (v2 is unchanged.)
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable.
- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it.
## Backend Conventions
+4 -4
View File
@@ -1,9 +1,9 @@
# Plugin styling — the `styles` capability
> Building for the redesigned **v3 UI** (`FEEDBACK_UI=v3` / `/v3`)? v3 uses `fb-*`
> design tokens and a restructured player chrome with a dedicated plugin-control
> slot. See **[plugin-v3-ui.md](plugin-v3-ui.md)** for the player-chrome contract
> plugins must follow in v3.
> The **v3 UI** is the only UI — it uses `fb-*` design tokens and a restructured
> player chrome with a dedicated plugin-control slot. See
> **[plugin-v3-ui.md](plugin-v3-ui.md)** for the player-chrome contract plugins
> must follow.
FeedBack serves Tailwind as a **prebuilt** stylesheet
(`static/tailwind.min.css`), never the runtime Play CDN. The CDN's on-the-fly
+11 -11
View File
@@ -1,16 +1,16 @@
# Building plugins for the v3 UI (fee[dB]ack v0.3.0)
v0.3.0 ("fee[dB]ack") ships a redesigned UI **behind a flag**`FEEDBACK_UI=v3`
or the `/v3` route. The classic UI (v2) remains the default until 0.3.0 ships, so
plugins must work in **both**.
v0.3.0 ("fee[dB]ack") ships a redesigned UI. It is **the only UI** — the classic v2
shell and its `FEEDBACK_UI` / `/v2` opt-outs have been removed, so there is no
longer a second shell to support.
The good news: v3 **reuses the same engine** as v2 — same `server.py`, `app.js`,
`highway.js`, `playSong`, `showScreen`, capability registry, library providers,
and the `window.feedBackViz_<id>` / `setRenderer` visualization contract. So your
plugin's **backend, capabilities, library providers, `nav`/`screen`, visualization
renderers, diagnostics, and settings export all work unchanged in v3.** v3 surfaces
your `nav` entry in the new sidebar (via `shell.js` `renderPluginNav`) and your
screen mounts exactly as before.
The good news: v3 **reuses the same engine** the classic UI did — same `server.py`,
`app.js`, `highway.js`, `playSong`, `showScreen`, capability registry, library
providers, and the `window.feedBackViz_<id>` / `setRenderer` visualization contract.
So your plugin's **backend, capabilities, library providers, `nav`/`screen`,
visualization renderers, diagnostics, and settings export all work unchanged.** v3
surfaces your `nav` entry in the new sidebar (via `shell.js` `renderPluginNav`) and
your screen mounts exactly as before.
**The one thing that changed is the player chrome** — and only if your plugin
injects controls into it.
@@ -188,4 +188,4 @@ out of the capability graph.
- [ ] Dropdowns positioned via `getBoundingClientRect()`, not `#player-controls`.
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
rail 30, popovers 40).
- [ ] Verify in **both** `/` (v2) and `/v3`.
- [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell.
+2 -2
View File
@@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable.
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
(7,275 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
extractions and twelve `routers/` modules) ·
(2,413 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
extractions and twenty-two `routers/` modules, plus lib/library_registry.py for the provider-registry classes (album-art in `lib/routers/art.py`, the settings + export/import bundle in `lib/routers/settings.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) ·
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
and is a monolith in its own right, to be split per-table once the router train
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
+12 -5
View File
@@ -43,12 +43,19 @@ module.exports = [
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
rules: { 'max-lines': sizeRule(1500) },
},
// ES-module graphs (a plugin's src/ tree, .mjs tests): module parsing + the
// acyclic-imports hard gate + the size norm. A migrated bundled plugin's
// entry `import './src/main.js'` screen.js must parse as a module — add its
// glob here in that plugin's migration PR (classic screen.js stays a script).
// ES-module graphs (a plugin's src/ tree, .mjs tests, core's own static/js/
// tree): module parsing + the acyclic-imports hard gate + the size norm. A
// migrated bundled plugin's entry `import './src/main.js'` screen.js must
// parse as a module — add its glob here in that plugin's migration PR
// (classic screen.js stays a script).
//
// `static/app.js` is listed explicitly: it is served as
// <script type="module"> (R3a) and now `import`s its carved-out modules, so
// parsing it as a script would be a syntax error. It is the ENTRY of core's
// module graph, which is what makes no-cycle meaningful here — a carved
// module that imports app.js back would close a cycle and fail this gate.
{
files: ['**/src/**/*.js', '**/*.mjs'],
files: ['**/src/**/*.js', '**/*.mjs', 'static/app.js', 'static/js/**/*.js'],
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: { 'import-x': importX },
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
+28
View File
@@ -0,0 +1,28 @@
"""Reading the app's config.json — the one shared, pure helper (R3).
Extracted verbatim from server.py so route modules that need a config value
(reference pitch, server_config, …) can read it without reaching back into the
host file. server.py re-imports it, so its ~11 call sites and any
`server._load_config` test reference keep resolving unchanged.
"""
import json
def _load_config(config_file):
"""Read and parse config.json. Returns the parsed dict, or None if
the file is missing, unreadable, invalid JSON, or parses to a
non-dict (e.g. the file contains `[]` or `42`). Callers treat None
as "fall back to defaults". Shared between GET and POST so both
handle bad files the same way."""
if not config_file.exists():
return None
try:
# Explicit UTF-8: save_settings()/import write config.json as
# UTF-8 bytes, so the read must not depend on the platform's
# default text encoding (cp1252 on Windows would mojibake or
# UnicodeDecodeError on a non-ASCII DLC path).
parsed = json.loads(config_file.read_text(encoding="utf-8"))
except Exception:
return None
return parsed if isinstance(parsed, dict) else None
+35 -1
View File
@@ -61,6 +61,16 @@ copies a hardcoded file list — that regression is what moved this file here.
# The singletons routers may read. Every name here must also be a `_SLOTS` key.
meta_db = None
audio_effect_mappings = None
# The tuning-provider registry instance (built-ins + plugin-contributed). A
# stable object mutated in place via register()/unregister() — injected here by
# reference so routers read the same registry plugins populate.
tuning_providers = None
# The library-provider registry instance + the local provider, constructed in
# server.py (LocalLibraryProvider needs meta_db) and injected by reference. The
# classes live in lib/library_registry.py; plugins register their own providers
# through the registry via plugin_context.
library_providers = None
local_library_provider = None
# Config paths. server.py derives these from the environment (fresh on every
# import, so the ~49 pop-and-reimport fixtures keep working) and injects them
@@ -87,12 +97,36 @@ audio_cache_dir = None
# in server.py (its `setattr(server, "_progression_content")` test is untouched).
get_progression_content = None
builtin_diagnostic_filename = None
running_version = None
# Art helpers that stay in server.py (shared with the art/delete routes) but are
# also called by the enrichment worker in lib/enrichment.py — injected as
# callables to keep enrichment acyclic. art_cache_dir is server's ART_CACHE_DIR.
art_cache_dir = None
song_pack_art_exists = None
art_override_paths = None
art_safe_name = None
# The canonical settings-defaults builder — stays in server.py (shared with the
# scan/artist-links code) but the settings router calls it through the seam.
default_settings = None
# Scan/ingest seam for the song routes (routers/song.py). kick_scan/
# invalidate_song_caches/stat_for_cache stay in server.py (scan lifecycle owns
# them); scan_status is a GETTER (the underlying dict is reassigned, so a value
# would go stale) — call appstate.scan_status() to read the live status.
kick_scan = None
invalidate_song_caches = None
stat_for_cache = None
scan_status = None
_SLOTS = frozenset({
"meta_db", "audio_effect_mappings",
"meta_db", "audio_effect_mappings", "tuning_providers",
"library_providers", "local_library_provider",
"config_dir", "dlc_dir", "dlc_dir_env",
"static_dir", "sloppak_cache_dir", "audio_cache_dir",
"get_progression_content", "builtin_diagnostic_filename",
"running_version",
"art_cache_dir", "song_pack_art_exists", "art_override_paths", "art_safe_name",
"default_settings",
"kick_scan", "invalidate_song_caches", "stat_for_cache", "scan_status",
})
+1107
View File
File diff suppressed because it is too large Load Diff
+417
View File
@@ -0,0 +1,417 @@
"""The library-provider registry — the plugin extension point for song sources.
`LocalLibraryProvider` wraps the local `MetadataDB`; third-party plugins register
their own providers (duck-typed: any object with the advertised methods) through
`LibraryProviderRegistry`, and smart collections are surfaced as
`SmartCollectionProvider`s over the local one. server.py constructs the singleton
(`library_providers`), injects it + the local provider into appstate, and exposes
`register_library_provider`/`unregister_library_provider` to plugins via
plugin_context (with per-plugin ownership scoping in plugins/__init__.py).
Moved verbatim out of server.py (R3). The shared query/collection helpers live
here too so routers/library.py can import them without reaching into server.
"""
import re
import threading
from typing import ClassVar
import appstate
from metadata_db import MetadataDB, _tuning_group_key_sql
from routers import art as art_router
import logging
log = logging.getLogger("feedBack.server")
def _safe_art_redirect_url(url: str) -> str | None:
"""Return the URL if it is safe to redirect to (http/https only), else None."""
from urllib.parse import urlparse
if not url or not isinstance(url, str):
return None
try:
parsed = urlparse(url)
if parsed.scheme.lower() not in ("http", "https"):
return None
if not parsed.hostname:
return None
return url
except Exception:
return None
_TUNING_GROUP_KEY_SQL = _tuning_group_key_sql("songs")
class LocalLibraryProvider:
id = "local"
label = "My Library"
kind = "local"
capabilities = (
"library.read",
"art.read",
"song.play",
"favorite.write",
"metadata.write",
)
def __init__(self, db: MetadataDB):
self._db = db
def query_page(self, **kwargs) -> tuple[list[dict], int]:
return self._db.query_page(**kwargs)
def query_artists(self, **kwargs) -> tuple[list[dict], int]:
return self._db.query_artists(**kwargs)
def query_albums(self, **kwargs) -> tuple[list[dict], int]:
return self._db.query_albums(**kwargs)
def query_stats(self, **kwargs) -> dict:
return self._db.query_stats(**kwargs)
def tuning_names(self) -> dict:
# Group custom tunings on their raw offsets so distinct ones stay
# distinct (tuning_name collapses them all to "Custom Tuning"); named
# tunings keep grouping by name (stable across the rescan boundary, no
# offsets/name split). `key` is the value the client sends back as the
# filter selector — equal to the name for named tunings, the offsets
# string for customs; offsets also feed the client's custom-pill label.
with self._db._lock:
rows = self._db.conn.execute(
f"SELECT tuning_name, {_TUNING_GROUP_KEY_SQL} AS gkey, "
"MIN(tuning_sort_key), COUNT(*), MIN(tuning_offsets) "
"FROM songs WHERE title != '' AND COALESCE(tuning_name, '') != '' "
"GROUP BY gkey COLLATE NOCASE "
"ORDER BY ABS(COALESCE(MIN(tuning_sort_key), 0)), "
"COALESCE(MIN(tuning_sort_key), 0) ASC, "
"tuning_name COLLATE NOCASE"
).fetchall()
return {
"tunings": [
{"name": name, "key": gkey, "offsets": offs or "",
"sort_key": int(sk or 0), "count": count}
for name, gkey, sk, count, offs in rows
],
}
async def get_art(self, song_id: str):
return await art_router.get_song_art(song_id)
class LibraryProviderRegistry:
# Methods required per declared capability — only validated when the
# provider advertises the corresponding capability so action-only providers
# (e.g. art.read + song.sync without library.read) don't need to implement
# unused stubs.
_CAPABILITY_METHODS: ClassVar[dict[str, tuple[str, ...]]] = {
"library.read": ("query_page", "query_artists", "query_stats", "tuning_names"),
"art.read": ("get_art",),
"song.sync": ("sync_song",),
}
_ID_RE: ClassVar[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
def __init__(self):
self._providers: dict[str, object] = {}
# Capabilities inferred at registration for legacy providers that omit
# the `capabilities` field. Merged with provider_capabilities() so that
# runtime capability checks see the complete effective capability set.
self._inferred_caps: dict[str, set[str]] = {}
self._owner_plugin_ids: dict[str, str] = {}
self._lock = threading.RLock()
def register(self, provider: object, *, replace: bool = False, owner_plugin_id: str | None = None) -> object:
provider_id = self.provider_id(provider)
if not self._ID_RE.match(provider_id):
raise ValueError(
"library provider id must start with an alphanumeric character "
"and contain only letters, digits, _, ., :, or -"
)
if not self.provider_label(provider):
raise ValueError("library provider label must be a non-empty string")
# Use declared-only caps during validation — never include stale inferred
# caps from a previous provider registered under the same id (replace=True).
caps = self._declared_capabilities(provider)
# Backward compatibility: providers that predate explicit capability
# declarations may omit `capabilities` entirely. If the browse methods
# are all present, infer `library.read` so they still work unchanged.
# If capabilities are absent but the browse surface is also absent,
# raise a clear error rather than letting the provider register and
# then fail on every API call with a late 501.
inferred: set[str] = set()
if not caps:
browse_methods = self._CAPABILITY_METHODS["library.read"]
if all(callable(self.provider_method(provider, m)) for m in browse_methods):
# Legacy provider without explicit capabilities — infer library.read
# from the presence of all browse methods. Store in _inferred_caps
# so that runtime capability checks see the full effective set.
inferred = {"library.read"}
caps = inferred
else:
raise TypeError(
f"library provider {provider_id!r} must declare at least one capability "
f"(or implement the {browse_methods!r} browse methods for backward compatibility)"
)
for cap, methods in self._CAPABILITY_METHODS.items():
if cap not in caps:
continue
for method_name in methods:
if not callable(self.provider_method(provider, method_name)):
raise TypeError(f"library provider {provider_id!r} declares {cap!r} but is missing callable {method_name}()")
with self._lock:
if provider_id == "local" and provider_id in self._providers and self._providers[provider_id] is not provider:
raise ValueError("the local library provider cannot be replaced")
if provider_id in self._providers and not replace:
raise ValueError(f"library provider {provider_id!r} is already registered")
self._providers[provider_id] = provider
# owner_plugin_id is attribution that flows into the browser
# capability participant id. The scoped register_library_provider
# wrappers force it to the trusted loading plugin id, so the spoof
# vector is closed there. Here we only normalize: trim and require a
# non-empty string. We deliberately do NOT apply the provider-id
# grammar (_ID_RE) — plugin ids aren't constrained to it at load
# time, so that would silently drop attribution for valid plugins.
owner = owner_plugin_id.strip() if isinstance(owner_plugin_id, str) else ""
owner = owner or None
if owner:
self._owner_plugin_ids[provider_id] = owner
else:
self._owner_plugin_ids.pop(provider_id, None)
if inferred:
self._inferred_caps[provider_id] = inferred
else:
self._inferred_caps.pop(provider_id, None)
return provider
def unregister(self, provider_id: str) -> bool:
if provider_id == "local":
raise ValueError("the local library provider cannot be unregistered")
with self._lock:
self._inferred_caps.pop(provider_id, None)
self._owner_plugin_ids.pop(provider_id, None)
return self._providers.pop(provider_id, None) is not None
def get(self, provider_id: str = "local") -> object | None:
with self._lock:
return self._providers.get(provider_id or "local")
def list(self) -> list[dict]:
with self._lock:
providers = list(self._providers.values())
return [self.describe(provider) for provider in providers]
def describe(self, provider: object) -> dict:
provider_id = self.provider_id(provider)
with self._lock:
owner_plugin_id = self._owner_plugin_ids.get(provider_id)
return {
"id": provider_id,
"label": self.provider_label(provider),
"kind": self.provider_field(provider, "kind", "local" if provider_id == "local" else "remote"),
"capabilities": sorted(self.provider_capabilities(provider)),
"owner_plugin_id": owner_plugin_id,
"default": provider_id == "local",
}
def provider_field(self, provider: object, name: str, default=None):
if isinstance(provider, dict):
return provider.get(name, default)
return getattr(provider, name, default)
def provider_id(self, provider: object) -> str:
provider_id = self.provider_field(provider, "id", "")
if not isinstance(provider_id, str) or not provider_id:
raise ValueError("library provider id must be a non-empty string")
return provider_id
def provider_label(self, provider: object) -> str:
label = self.provider_field(provider, "label", self.provider_field(provider, "name", ""))
if not isinstance(label, str):
return ""
return label.strip()
def _declared_capabilities(self, provider: object) -> set[str]:
"""Return only the capabilities explicitly declared on the provider object."""
raw = self.provider_field(provider, "capabilities", ())
if raw is None:
raw = ()
if isinstance(raw, str):
raw = (raw,) if raw else ()
return {str(cap) for cap in raw if cap}
def provider_capabilities(self, provider: object) -> set[str]:
# Guard against a common plugin authoring mistake: passing a single string
# instead of a list/tuple. Iterating a string produces individual characters,
# none of which would match a valid capability name.
declared = self._declared_capabilities(provider)
# Merge with any capabilities inferred at registration time for legacy
# providers that omit the `capabilities` field but implement browse methods.
provider_id = self.provider_id(provider)
with self._lock:
inferred = self._inferred_caps.get(provider_id, set())
return declared | inferred
def provider_method(self, provider: object, name: str):
if isinstance(provider, dict):
return provider.get(name)
return getattr(provider, name, None)
# Keys `_library_filter_args` (and a smart collection's stored `rules`) accept.
_LIBRARY_FILTER_PARAM_KEYS = frozenset((
"q", "favorites", "format", "artist", "album",
"arrangements_has", "arrangements_lacks", "stems_has", "stems_lacks",
"has_lyrics", "tunings",
))
# Rules mirror the raw /api/library query params (so the provider can feed them
# straight through `_library_filter_args`, and the frontend can build a rule from
# the same query string it already constructs). Multi-value filters are CSV
# strings; `favorites` is 0/1; the rest are plain strings.
_RULE_CSV_KEYS = frozenset((
"tunings", "arrangements_has", "arrangements_lacks", "stems_has", "stems_lacks",
))
_RULE_STR_KEYS = frozenset(("q", "format", "artist", "album", "has_lyrics", "sort"))
def _sanitize_collection_rules(raw) -> dict:
"""Normalize rules to the raw query-param format, keeping only known keys. A
list for a multi-value filter is joined to CSV; `favorites` becomes 0/1.
Unknown keys are dropped so a rule survives a filter-vocab change rather than
500-ing. Applied at API ingress AND when a provider loads a persisted row, so
a hand-edited / imported bad value (e.g. an int where a string is expected,
or a list for `sort`) can never crash a query."""
if not isinstance(raw, dict):
return {}
out: dict = {}
for k, v in raw.items():
if k in _RULE_CSV_KEYS:
if isinstance(v, list):
vals = [str(x) for x in v if isinstance(x, (str, int)) and not isinstance(x, bool)]
elif isinstance(v, str):
vals = [s for s in (p.strip() for p in v.split(",")) if s]
else:
continue
if vals:
out[k] = ",".join(vals)
elif k == "favorites":
if v:
out[k] = 1
elif k in _RULE_STR_KEYS:
if isinstance(v, (str, int)) and not isinstance(v, bool):
s = str(v).strip()
if s:
out[k] = s
return out
class SmartCollectionProvider:
"""A saved library filter, surfaced as a source (#636 item 2). Browse/stats
delegate to the local DB with the collection's stored `rules` applied — so
selecting it in the v3 source picker shows exactly that filtered slice with
the whole Songs UI (paging, stats, AZ rail, art) for free. P1: the rules
ARE the query (live in-collection search is a P2 nicety). The matched songs
are local rows, so `kind="local"` keeps the client's play/art paths on the
local (not remote-sync) branch and art delegates straight through."""
kind = "local"
capabilities = ("library.read", "art.read")
def __init__(self, collection: dict, local: "LocalLibraryProvider"):
self._local = local
self.update(collection)
def update(self, collection: dict) -> None:
self.id = f"collection:{collection['id']}"
self.collection_id = collection["id"]
self.label = collection.get("name") or "Collection"
# Re-sanitize on load: persisted JSON may predate the current vocab or
# have been hand-edited; never let a bad value reach a query.
self._rules = _sanitize_collection_rules(collection.get("rules") or {})
def _filter_kwargs(self) -> dict:
return _library_filter_args(**{k: v for k, v in self._rules.items()
if k in _LIBRARY_FILTER_PARAM_KEYS})
def _sort(self, fallback: str) -> str:
# A collection may pin its own sort (e.g. "recently added"); query_page
# falls back safely for an unknown value, so no validation needed here.
return self._rules.get("sort") or fallback
def query_page(self, *, page=0, size=24, sort="artist", direction="asc",
naming_mode="legacy", **_ignore):
return self._local._db.query_page(
page=page, size=size, sort=self._sort(sort), direction=direction,
naming_mode=naming_mode, **self._filter_kwargs())
def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy", **_ignore):
return self._local._db.query_artists(
letter=letter, page=page, size=size, naming_mode=naming_mode,
**self._filter_kwargs())
def query_albums(self, *, page=0, size=120, naming_mode="legacy", **_ignore):
return self._local._db.query_albums(
page=page, size=size, naming_mode=naming_mode, **self._filter_kwargs())
def query_stats(self, *, sort="artist", want_sort_letters=False,
naming_mode="legacy", **_ignore):
return self._local._db.query_stats(
sort=self._sort(sort), want_sort_letters=want_sort_letters,
naming_mode=naming_mode, **self._filter_kwargs())
def tuning_names(self):
return self._local.tuning_names()
async def get_art(self, song_id: str):
return await self._local.get_art(song_id)
def _split_csv(raw: str) -> list[str]:
"""Parse a comma-separated query-string list. Empty / whitespace-only
entries are dropped so `arrangements_has=` (no value) and
`arrangements_has=,` both mean 'no filter'."""
if not raw:
return []
return [s.strip() for s in raw.split(",") if s.strip()]
def _parse_has_lyrics(raw: str) -> int | None:
"""Tri-state parse for has_lyrics. `1` → require, `0` → exclude,
anything else (including empty) → no filter."""
if raw == "1":
return 1
if raw == "0":
return 0
return None
def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
artist: str = "", album: str = "",
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "") -> dict:
fmt = format if format in ("archive", "sloppak", "loose") else ""
return {
"q": q,
"favorites_only": bool(favorites),
"format_filter": fmt,
"artist_filter": (artist or "").strip(),
"album_filter": (album or "").strip(),
"arrangements_has": _split_csv(arrangements_has),
"arrangements_lacks": _split_csv(arrangements_lacks),
"stems_has": _split_csv(stems_has),
"stems_lacks": _split_csv(stems_lacks),
"has_lyrics": _parse_has_lyrics(has_lyrics),
"tunings": _split_csv(tunings),
}
def _sync_collection_provider(collection: dict) -> None:
"""Register (or replace) the provider for one collection."""
appstate.library_providers.register(
SmartCollectionProvider(collection, appstate.local_library_provider), replace=True)
def _unregister_collection_provider(pid: int) -> None:
appstate.library_providers.unregister(f"collection:{pid}")
+513
View File
@@ -0,0 +1,513 @@
"""Album-art routes: serve / cover-search / candidates / upload / url / remove
(/api/song/{filename}/art*, /api/art/{filename}/override).
Extracted verbatim from server.py (R3). Only the decorators (@app -> @router) and
the seam reads change: meta_db -> appstate.meta_db, ART_CACHE_DIR ->
appstate.art_cache_dir, and the three shared art helpers that stay in server.py
(they are also used by the song/delete routes) -> appstate.<callable>
(_song_pack_art_exists, _art_override_paths, _art_safe_name). The CAA / release
search transport lives in lib/enrichment.py and is reached as enrichment.X.
"""
import asyncio
import hashlib
import ipaddress
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, Response
import appstate
import enrichment
import loosefolder as loosefolder_mod
import sloppak as sloppak_mod
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
import logging
log = logging.getLogger("feedBack.server")
router = APIRouter()
def _if_none_match_hits(header: str | None, etag: str) -> bool:
"""True if an If-None-Match header matches `etag` (weak comparison).
Handles the `*` wildcard and comma-separated lists, and ignores a weak
`W/` prefix on either side — the standard semantics for a conditional GET.
"""
if not header:
return False
bare = etag.removeprefix("W/")
for tok in header.split(","):
t = tok.strip()
if t == "*" or t.removeprefix("W/") == bare:
return True
return False
# Album art is served with a strong validator (an ETag on the sloppak byte
# path; FileResponse's own ETag/Last-Modified on the file paths) and revalidated
# with `no-cache`. That keeps re-scroll cheap — a conditional GET returns a
# bodyless 304 — without ever serving a stale cover. A long `immutable` max-age
# was rejected: the frontend's `?v=<mtime>` buster is only second-resolution, so
# a same-second cover rewrite would keep the URL and pin the old bytes for the
# cache lifetime. Validation cost is negligible for a localhost backend.
_ART_CACHE_HEADERS = {"Cache-Control": "no-cache"}
def _art_etag(path: Path) -> str | None:
"""Strong validator for an art file: nanosecond mtime + size (so a
same-second rewrite still changes it). None if the file can't be stat'd."""
try:
st = path.stat()
return f'"{st.st_mtime_ns}-{st.st_size}"'
except OSError:
return None
def _art_conditional(etag: str | None, request: Request | None):
"""Return (headers, not_modified) for an art response. `not_modified` is
True when the client's If-None-Match already matches `etag` → caller should
return a bodyless 304. Starlette's FileResponse emits an ETag but does NOT
itself evaluate If-None-Match, so every art path routes through here to get
real conditional handling."""
headers = dict(_ART_CACHE_HEADERS)
if etag:
headers["ETag"] = etag
inm = request.headers.get("if-none-match") if request is not None else None
return headers, bool(etag) and _if_none_match_hits(inm, etag)
def _file_art_response(path: Path, media_type: str, request: Request | None):
"""FileResponse for an on-disk art file, with no-cache + ETag and a bodyless
304 when the client's validator still matches."""
headers, not_modified = _art_conditional(_art_etag(path), request)
if not_modified:
return Response(status_code=304, headers=headers)
return FileResponse(str(path), media_type=media_type, headers=headers)
@router.get("/api/song/{filename:path}/art")
async def get_song_art(filename: str, request: Request = None, source: str = ""):
"""Serve album art for a song, walking the R3 override chain:
1. USER OVERRIDE (upload / URL-fetch, {safe_name}.gif|.png in the art
cache) — art the user explicitly pinned outranks everything, pack
art included. GIF is allowed HERE only: an animated cover is a
local-only bonus; packs stay jpg/png/webp and nothing ever writes
art into a pack file.
2. PACK ART — sloppak cover (single member read, no full unpack) or
the loose folder's discovered image.
3. COVER ART ARCHIVE cache — fetched by the enrichment art worker for
matched songs that lack pack art, keyed by release MBID.
`?source=pack` narrows the chain to step 2 only (no override, no CAA):
the cover picker's "Pack original" tile must show the pack's own art
even while a user override is what the plain route serves. 404 when the
song ships no art of its own.
"""
dlc = _get_dlc_dir()
if not dlc:
return JSONResponse({"error": "not configured"}, 404)
song_path = _resolve_dlc_path(dlc, filename)
if song_path is None:
return JSONResponse({"error": "forbidden"}, 403)
if not song_path.exists():
return JSONResponse({"error": "not found"}, 404)
pack_only = source == "pack"
# 1. User override — GIF first (it wins over a stale PNG override).
if not pack_only:
for cached in appstate.art_override_paths(filename):
mt = "image/gif" if cached.suffix == ".gif" else "image/png"
return _file_art_response(cached, mt, request)
# 2a. Sloppak: read the cover (manifest-declared or default) straight from
# the package. For a zip-form sloppak this opens just the cover member —
# NOT the whole archive — so the library grid never triggers a full unpack
# of stems just to paint a thumbnail.
if sloppak_mod.is_sloppak(song_path):
# Read the cover (cheap — single member, no full unpack) and validate by
# its CONTENT. A stat-based ETag would be wrong for directory-form
# sloppaks: editing cover.jpg in place changes the file's mtime, not the
# directory's, so a dir-stat ETag could emit a stale 304. Content hashing
# is correct for both dir- and zip-form. Raw byte Response lacks
# FileResponse's validators, so we attach the ETag + honor If-None-Match.
try:
art = await asyncio.to_thread(sloppak_mod.read_cover_bytes, song_path)
except Exception:
art = None
if art is not None:
data, mt = art
etag = f'"{hashlib.sha1(data).hexdigest()}"'
headers, not_modified = _art_conditional(etag, request)
if not_modified:
return Response(status_code=304, headers=headers)
return Response(content=data, media_type=mt, headers=headers)
# 2b. Loose folder: serve the discovered art file directly.
# song_path is already validated against DLC_DIR by _resolve_dlc_path.
elif loosefolder_mod.is_loose_song(song_path):
art_path = loosefolder_mod.find_art(song_path)
if art_path:
# Re-resolve in case the matched file is a symlink — a crafted
# custom song could put `album_art.jpg` as a symlink to anywhere on
# disk. Insist the final target stays inside the song folder.
art_resolved = art_path.resolve()
try:
art_resolved.relative_to(song_path)
except ValueError:
return JSONResponse({"error": "forbidden"}, 403)
if art_resolved.is_file():
mt = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".webp": "image/webp",
}.get(art_resolved.suffix.lower(), "image/jpeg")
return _file_art_response(art_resolved, mt, request)
# 3. Cover Art Archive cache (the enrichment art worker's fetch).
if not pack_only:
row = appstate.meta_db.get_enrichment(filename)
if row and row.get("art_state") == "caa" and row.get("art_cache_path"):
caa = Path(row["art_cache_path"])
if caa.is_file():
return _file_art_response(caa, "image/jpeg", request)
return JSONResponse({"error": "no art"}, 404)
# ── Cover picker (PR-C): candidate assembly ───────────────────────────────────
# Enumerated ON OPEN, never at scan time (charrette §8), and NO image bytes
# are fetched here — Cover Art Archive release INDEX jsons only (1-3 throttled
# calls on a cache miss); the tiles' thumbnails load straight from the archive
# in the client. Applying a pick never grows a new write path: the client
# POSTs the chosen thumb URL to the EXISTING …/art/url route (the override
# lane — never evicted, survives a re-match), "Pack original" DELETEs the
# override, uploads keep the existing upload route.
_ART_PICKER_MAX_CAA = 12
@router.get("/api/song/{filename:path}/art/cover-search")
def api_art_cover_search(filename: str, q: str = ""):
"""Search Cover Art Archive (via MusicBrainz release-groups) for album covers
— powers the Change-cover picker's search box, so a cover can be found even
for a song with no metadata match (the unmatched city-pop pile, where
/art/candidates is empty). `q` defaults to the song's own artist + album/
title (romaji fallback applied). Read-only; the picker renders the thumbs and
applies a pick through the existing /art/url route."""
query = (q or "").strip()
if not query:
pack = appstate.meta_db.pack_fields(appstate.meta_db._canonical_song_filename(filename))
query = " ".join(x for x in (pack.get("artist"), pack.get("album") or pack.get("title")) if x).strip()
if not query:
return {"query": "", "covers": []}
try:
return {"query": query, "covers": enrichment._mb_search_release_groups(query, limit=8)}
except enrichment.EnrichTransportError:
return {"query": query, "covers": [], "error": "unavailable"}
@router.get("/api/song/{filename:path}/art/candidates")
def get_song_art_candidates(filename: str):
"""Everything the cover picker can offer for one song, without fetching a
single image: the current cover (with its provenance), the pack original
when the song ships art, and CAA candidates for the matched/manual
release plus any distinct releases among the stored review candidates.
Sync route on purpose (the CAA index fetch sleeps in the shared
throttle — FastAPI runs `def` routes in the threadpool). One response,
`pending` always False — the client shows a spinner for the request's own
latency; offline / CAA-down just means an empty caa tail (the instant
tiles keep working), never an error."""
from urllib.parse import quote
dlc = _get_dlc_dir()
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
if song_path is None or not song_path.exists():
raise HTTPException(status_code=404, detail="unknown song")
row = appstate.meta_db.get_enrichment(filename) or {}
has_pack = appstate.song_pack_art_exists(filename)
art_url = f"/api/song/{quote(filename)}/art"
# What the plain art route would serve right now — the serve chain's
# order (override > pack > CAA cache) restated as provenance.
if appstate.art_override_paths(filename):
provenance = "yours"
elif has_pack:
provenance = "pack"
elif row.get("art_state") == "caa" and row.get("art_cache_path"):
provenance = "matched"
else:
provenance = "none"
candidates: list[dict] = [{
"id": "current", "kind": "current", "label": "Current",
"thumb_url": art_url, "provenance": provenance,
}]
if has_pack:
candidates.append({
"id": "pack", "kind": "pack", "label": "Pack original",
"thumb_url": art_url + "?source=pack", "provenance": "pack",
})
# Releases worth asking the archive about: the matched/manual release
# first (it seeds the best candidates), then any distinct release among
# the stored review candidates (a review row has no mb_release_id of its
# own — its releases live in the candidates JSON).
# Only spend the shared CAA rate budget on rows whose match warrants it:
# a matched/manual release seeds the best candidates, and a review row's
# stored candidates are still live proposals. A failed/rejected (or
# unscanned) row has no accepted match — asking would burn the budget and
# surface releases already rejected as non-matches. The Current + Pack
# tiles above serve regardless, so those songs still get a picker.
rids: list[str] = []
if row.get("match_state") in ("matched", "manual", "review"):
if row.get("match_state") in ("matched", "manual") and row.get("mb_release_id"):
rids.append(str(row["mb_release_id"]))
for cand in (row.get("candidates") or []):
rid = str(cand.get("release_id") or "") if isinstance(cand, dict) else ""
if rid and rid not in rids:
rids.append(rid)
caa_entries: list[dict] = []
for rid in rids:
if len(caa_entries) >= _ART_PICKER_MAX_CAA:
break
try:
imgs = enrichment._caa_index_cached(rid)
except enrichment.EnrichTransportError:
# Offline / archive down — stop asking (each further miss would
# only burn a timeout). The instant tiles still serve; a later
# picker-open retries naturally (failures are never cached).
break
# Front covers first, approved before pending, otherwise index order
# (the picker grammar is a RANKED list — §7/§9).
def _rank(img):
types = img.get("types") or []
is_front = bool(img.get("front")) or "Front" in types
return (not is_front, not bool(img.get("approved")))
for img in sorted((i for i in imgs if isinstance(i, dict)), key=_rank):
if len(caa_entries) >= _ART_PICKER_MAX_CAA:
break
thumbs = img.get("thumbnails") or {}
if not isinstance(thumbs, dict):
continue
thumb = (thumbs.get("500") or thumbs.get("large")
or thumbs.get("250") or thumbs.get("small"))
if not thumb:
continue
types = [str(t) for t in (img.get("types") or []) if isinstance(t, str)]
caa_entries.append({
"id": f"caa-{rid}-{img.get('id', '')}",
"kind": "caa",
"label": ", ".join(types) or "Cover",
"thumb_url": str(thumb),
"provenance": "matched",
"types": types,
"approved": bool(img.get("approved")),
"release_id": rid,
})
return {"candidates": candidates + caa_entries, "pending": False}
def _save_art_override(filename: str, img_data: bytes) -> dict:
"""Persist a user art override into the art cache (R3). One override per
song: GIF input is validated and kept VERBATIM as .gif (animation intact —
the local-only bonus; it is never written into the pack file), everything
else is normalized to RGB PNG via PIL. Saving either kind removes the
other so the serve chain has exactly one user file to find."""
appstate.art_cache_dir.mkdir(parents=True, exist_ok=True)
stem = appstate.art_safe_name(filename)
png_path = appstate.art_cache_dir / f"{stem}.png"
gif_path = appstate.art_cache_dir / f"{stem}.gif"
from PIL import Image
import io as _io
if img_data[:6] in (b"GIF87a", b"GIF89a"):
try:
probe = Image.open(_io.BytesIO(img_data))
probe.verify() # decodes headers/frames without keeping the image
if probe.format != "GIF":
raise ValueError("not a GIF")
except Exception as e:
return {"error": f"Invalid image: {e}"}
gif_path.write_bytes(img_data)
png_path.unlink(missing_ok=True)
return {"ok": True, "kind": "gif"}
try:
img = Image.open(_io.BytesIO(img_data)).convert("RGB")
img.save(str(png_path), "PNG")
except Exception as e:
return {"error": f"Invalid image: {e}"}
gif_path.unlink(missing_ok=True)
return {"ok": True, "kind": "png"}
@router.post("/api/song/{filename:path}/art/upload")
async def upload_song_art_b64(filename: str, data: dict):
"""Upload a custom cover as base64 (PNG/JPG/WebP → normalized PNG;
GIF → kept animated, local-only). The override outranks pack art in the
serve chain; remove it via DELETE …/art/override."""
import base64
# Reject art for a filename that doesn't resolve to a real song (mirrors the
# url route's guard) — no writing stray override files for unknown keys.
dlc = _get_dlc_dir()
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
if song_path is None or not song_path.exists():
raise HTTPException(status_code=404, detail="unknown song")
b64 = data.get("image", "")
if not b64:
return {"error": "No image data"}
# Strip data URL prefix if present
if "," in b64:
b64 = b64.split(",", 1)[1]
try:
img_data = base64.b64decode(b64)
except Exception:
return {"error": "Invalid base64"}
if len(img_data) > _ART_URL_MAX_BYTES:
raise HTTPException(status_code=400, detail="image larger than 10 MB")
return _save_art_override(filename, img_data)
# Art-by-URL fetch cap — a cover, not a wallpaper pack.
_ART_URL_MAX_BYTES = 10 * 1024 * 1024
def _url_host_is_internal(url: str) -> bool:
"""True when a user-supplied URL's host resolves to a loopback, private,
link-local, reserved, multicast or unspecified address — an SSRF target we
refuse to fetch on the user's behalf (e.g. 169.254.169.254 metadata, LAN
services). Fails CLOSED: an unresolvable or unparseable host is treated as
internal. Every resolved address must be public for the URL to pass."""
from urllib.parse import urlparse
import socket
host = urlparse(url).hostname
if not host:
return True
try:
infos = socket.getaddrinfo(host, None)
except OSError:
return True
if not infos:
return True
for info in infos:
raw = info[4][0].split("%", 1)[0] # strip any zone id
try:
ip = ipaddress.ip_address(raw)
except ValueError:
return True
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
return True
return False
# Art-by-URL redirect budget. Cover hosts commonly answer with a redirect —
# the Cover Art Archive (whose thumbs the cover picker applies through this
# very route) 307s every image to archive.org — so redirects must work; 5
# hops is generous for any real CDN chain while still bounding the walk.
_ART_URL_MAX_REDIRECTS = 5
def _fetch_art_url(url: str) -> bytes:
"""The one place art-by-URL touches the network (tests fake this seam).
User-initiated, so not throttled like the background workers — but the
same offline guard applies (pytest can never fetch), the host is checked
against internal/reserved ranges (SSRF), redirects are followed MANUALLY
with the scheme + internal-host guard re-applied to every hop (so a
redirect can't smuggle the request to an internal target — a blanket
no-redirect rule would break every Cover Art Archive pick, which always
redirects to archive.org), and the size cap is enforced while streaming
so a huge response never fully downloads.
Residual, accepted: each hop's host is resolved here and again by
requests, so a rebinding DNS name is a theoretical TOCTOU. Not closed
with an IP-pinned connection because (a) this is a single-user, no-auth
app (constitution §I) and the route is demo-blocked, so there is no
untrusted submission path, and (b) no other in-tree client (MusicBrainz,
CAA) pins either — a bespoke pinned+SNI adapter here would be
inconsistent and disproportionate. The cheap guards above still stop the
realistic vectors (direct internal URL, redirect-to-internal)."""
if not enrichment._enrich_network_enabled():
raise enrichment.EnrichTransportError("art fetch disabled (offline)")
import requests
from urllib.parse import urljoin, urlparse
for _hop in range(_ART_URL_MAX_REDIRECTS + 1):
# Re-validate EVERY hop, not just the user's original URL: the whole
# point of handling redirects ourselves is that each target gets the
# same scheme + SSRF gate before any request is made.
if urlparse(url).scheme not in ("http", "https"):
raise ValueError("url must be http(s)")
if _url_host_is_internal(url):
raise ValueError("url host is not allowed")
try:
with requests.get(url, timeout=15, stream=True, allow_redirects=False,
headers={"User-Agent": enrichment._enrich_user_agent()}) as resp:
if resp.status_code in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location") or ""
if not loc:
raise enrichment.EnrichTransportError(
f"HTTP {resp.status_code} without a Location")
url = urljoin(url, loc)
continue
if resp.status_code != 200:
raise enrichment.EnrichTransportError(f"HTTP {resp.status_code}")
data = b""
for chunk in resp.iter_content(65536):
data += chunk
if len(data) > _ART_URL_MAX_BYTES:
raise ValueError("image larger than 10 MB")
return data
except requests.RequestException as e:
raise enrichment.EnrichTransportError(str(e)) from e
raise enrichment.EnrichTransportError("too many redirects")
@router.post("/api/song/{filename:path}/art/url")
def set_song_art_from_url(filename: str, data: dict):
"""Paste-a-link cover art (the media-server idiom): the server fetches the
image and stores it as this song's local override — identical result to an
upload, including the GIF-stays-local rule. http(s) only."""
url = str((data or {}).get("url") or "").strip()
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise HTTPException(status_code=400, detail="url must be http(s)")
dlc = _get_dlc_dir()
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
if song_path is None or not song_path.exists():
raise HTTPException(status_code=404, detail="unknown song")
try:
img_data = _fetch_art_url(url)
except enrichment.EnrichTransportError as e:
return JSONResponse({"error": "could not fetch image", "detail": str(e)},
status_code=502)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return _save_art_override(filename, img_data)
@router.delete("/api/art/{filename:path}/override")
def remove_song_art_override(filename: str):
"""Drop the user art override — the serve chain falls back to pack art,
then the Cover Art Archive cache. Lives under /api/art (NOT /api/song) so
the greedy DELETE /api/song/{path} catch-all can't shadow it — the same
dodge the chart split/unsplit routes use."""
removed = False
for p in appstate.art_override_paths(filename):
try:
p.unlink()
removed = True
except OSError:
pass
if removed:
# The art worker may have settled this row as 'user' (override present,
# no pack art). Reset it so the next enrichment pass re-evaluates and the
# CAA fallback resumes — otherwise a removed override strands the row
# (enrichment_art_pending only re-queues art_state IS NULL) and the song
# is left with no art at all.
try:
appstate.meta_db.set_enrichment_art(filename, None, None)
except Exception:
log.exception("art override delete: failed to reset enrichment state")
return {"ok": True, "removed": removed}
+126
View File
@@ -0,0 +1,126 @@
"""Artist routes: the artist page + external-links payload
(/api/artist/{name}/page, /links, /links/refresh).
Extracted verbatim from server.py (R3) except @app->@router and the seam reads
(meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir, _default_settings->
appstate.default_settings). MusicBrainz link enrichment is reached as
enrichment.X; the shared URL-safety validator lives in lib/library_registry.py.
"""
from fastapi import APIRouter
import appstate
import enrichment
from appconfig import _load_config
from library_registry import _safe_art_redirect_url
import logging
log = logging.getLogger("feedBack.server")
router = APIRouter()
# MB artist url-relation types → the page's link slots (locked position 4:
# whitelist only, links-only forever). Everything not listed is dropped.
_ARTIST_URL_REL_SLOTS = {
"official homepage": "official",
"setlistfm": "tour",
"concerts": "tour",
"youtube": "video",
"video channel": "video",
"social network": "social",
"bandcamp": "social",
"soundcloud": "social",
"wikipedia": "wikipedia",
"wikidata": "wikipedia",
}
def _artist_links_from_mb(body: dict) -> tuple[dict, list]:
"""Whitelist an MB artist doc's url-relations into the page's link slots:
{official, tour, video, social: [...], wikipedia}. Every URL passes the
same http(s)-scheme gate as art redirects (_safe_art_redirect_url) so a
hostile javascript:/data:/file: resource can never reach an href. First
URL wins per single slot; social collects up to 5; wikipedia is preferred
over wikidata when both exist. Also returns MB's genre names (capped)."""
links: dict = {}
social: list = []
wikidata_url = None
for rel in (body or {}).get("relations") or []:
if not isinstance(rel, dict):
continue
rtype = str(rel.get("type") or "").strip().lower()
slot = _ARTIST_URL_REL_SLOTS.get(rtype)
if not slot:
continue
url = rel.get("url")
url = url.get("resource") if isinstance(url, dict) else url
if _safe_art_redirect_url(url) is None:
continue
if slot == "social":
if url not in social and len(social) < 5:
social.append(url)
elif rtype == "wikidata":
wikidata_url = wikidata_url or url
elif slot not in links:
links[slot] = url
if social:
links["social"] = social
if "wikipedia" not in links and wikidata_url:
links["wikipedia"] = wikidata_url
genres = [str(g.get("name")) for g in (body or {}).get("genres") or []
if isinstance(g, dict) and g.get("name")]
return links, genres[:8]
def _artist_links_payload(name: str, force: bool = False) -> dict:
"""Shared by GET links + POST refresh. Order of gates: the user's opt-in
setting (external links are OFF by default — the dev-chat thread's call),
then a known mb_artist_id (no id → nothing to look up), then the cache
(unless force), then the offline guard, then ONE throttled fetch."""
cfg = _load_config(appstate.config_dir / "config.json") or appstate.default_settings()
if cfg.get("artist_external_links") is not True:
return {"links": {}, "matched": False, "disabled": True}
canonical = appstate.meta_db._terminal_canonical((name or "").strip())
mbid = appstate.meta_db.artist_known_mb_id(appstate.meta_db._raw_variants_for(canonical))
mbid = (mbid or "").strip().lower()
# The id is interpolated into the MB request path — same strict-shape rule
# as the manifest identity keys (_MBID_RE), so a junk/hostile value stored
# via a hand-rolled /pick body can never reach the request line.
if not mbid or not enrichment._MBID_RE.match(mbid):
return {"links": {}, "matched": False}
if not force:
cached = appstate.meta_db.get_artist_enrichment(mbid)
if cached:
return {"links": cached["url_rels"], "genres": cached["genres"],
"matched": True, "cached": True, "mb_artist_id": mbid}
if not enrichment._enrich_network_enabled():
return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid}
try:
body = enrichment._mb_http_get(f"artist/{mbid}", {"inc": "url-rels+genres+tags"})
except enrichment.EnrichTransportError:
return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid}
links, genres = _artist_links_from_mb(body or {})
appstate.meta_db.put_artist_enrichment(mbid, links, genres)
return {"links": links, "genres": genres, "matched": True, "cached": False,
"mb_artist_id": mbid}
@router.get("/api/artist/{name:path}/page")
def api_artist_page(name: str):
"""The artist page's all-LOCAL payload — counts, albums, aliases, similar-
in-library, mosaic art, play-all seed. Never touches the network; an
unmatched or even unknown artist still returns a functional page."""
return appstate.meta_db.artist_page(name)
@router.get("/api/artist/{name:path}/links")
def api_artist_links(name: str):
"""External links for a matched artist — cached after the first call.
Sync route on purpose (like /api/enrichment/search): FastAPI runs it in
the threadpool so the MB throttle's sleep never blocks the event loop."""
return _artist_links_payload(name)
@router.post("/api/artist/{name:path}/links/refresh")
def api_artist_links_refresh(name: str):
"""Explicit re-fetch of the cached links (the page's manual Refresh)."""
return _artist_links_payload(name, force=True)
+295
View File
@@ -0,0 +1,295 @@
"""Diagnostic bundle export + hardware probe (/api/diagnostics/*).
One-click "Export Diagnostics" in Settings produces a redacted zip combining
server logs, system info, hardware (CPU/GPU/RAM), plugin inventory, and the
browser-side console transcript + hardware probe. Bundle format is specified in
docs/diagnostics-bundle-spec.md.
Extracted verbatim from server.py (R3) except:
- the decorators (@app -> @router),
- CONFIG_DIR -> appstate.config_dir and _running_version() ->
appstate.running_version() (both read through the appstate seam),
- the builtin-plugins lookup in _diag_plugins_roots: Path(__file__).parent
(the app root when this lived at the top level) ->
Path(__file__).resolve().parents[2] (routers -> lib -> app root). The
plugins/ dir ships at the app root in every packaging path.
The pure helpers + caps here are re-exported from server.py so the existing
`server._diag_*` / `server._DIAG_*` tests keep resolving (none monkeypatch them).
"""
import json
import logging
import os
from pathlib import Path
from fastapi import APIRouter, Body, Response
import appstate
from dlc_paths import _get_dlc_dir
from diagnostics_bundle import build_bundle as _diag_build, preview_bundle as _diag_preview
from diagnostics_hardware import collect as _diag_hardware
from env_compat import getenv_compat
log = logging.getLogger("feedBack.server")
router = APIRouter()
def _diag_log_file() -> Path | None:
raw = os.environ.get("LOG_FILE", "").strip()
if not raw:
return None
return Path(raw)
def _diag_plugins_roots() -> list[Path]:
"""Return all plugin root directories for orphan scanning.
Includes both the built-in ``plugins/`` directory and
``FEEDBACK_PLUGINS_DIR`` when set, so user-installed plugins and
orphans in the external dir are reflected in the bundle.
"""
roots: list[Path] = []
user_dir = getenv_compat("FEEDBACK_PLUGINS_DIR", "").strip()
if user_dir:
p = Path(user_dir)
if p.is_dir():
roots.append(p)
builtin = Path(__file__).resolve().parents[2] / "plugins" # R3: app root from lib/routers/
if builtin not in roots:
roots.append(builtin)
return roots
def _diag_coerce_bool(v, *, default: bool = True) -> bool:
"""Coerce a request-side value to bool, accepting both JSON booleans and
string representations.
- Falsy strings: ``"false"``, ``"0"``, ``"no"``, ``""`` → ``False``
- ``None`` → *default*
- Everything else (including ``"true"``, ``"1"``) → ``True``
"""
if v is None:
return default
if isinstance(v, bool):
return v
if isinstance(v, str):
return v.strip().lower() not in ("false", "0", "no", "")
return bool(v)
def _diag_normalize_include(include: dict | None) -> dict:
"""Coerce request-side flags to the booleans build_bundle expects.
Missing keys default to True so a bare {} request still produces
the full bundle.
Accepts both JSON booleans (``true``/``false``) and string
representations so callers that serialize flags as strings behave
consistently with the preview endpoint:
- Falsy strings: ``"false"``, ``"0"``, ``"no"``, ``""`` → ``False``
- Everything else (including ``"true"``, ``"1"``, ``"yes"``) → ``True``
"""
keys = ("system", "hardware", "logs", "console", "plugins")
if not isinstance(include, dict):
return {k: True for k in keys}
return {k: _diag_coerce_bool(include.get(k), default=True) for k in keys}
# Server-side caps on client-supplied payload sections. diagnostics.js
# enforces a 500-entry / ~250 KB ring buffer on the browser side; these
# bounds give generous headroom while still preventing a crafted POST from
# forcing the server to allocate arbitrarily large in-memory bundles.
_DIAG_MAX_CONSOLE_ENTRIES = 1000 # hard cap: truncate silently
_DIAG_MAX_CONSOLE_BYTES = 2 * 1024 * 1024 # 2 MB hard cap on total console list
_DIAG_MAX_CLIENT_PAYLOAD_BYTES = 2 * 1024 * 1024 # 2 MB per dict section
_DIAG_MAX_CONTRIBUTIONS_BYTES = 4 * 1024 * 1024 # 4 MB aggregate cap for contributions
def _diag_cap_console(v) -> list | None:
"""Return *v* if it is a list, truncated to _DIAG_MAX_CONSOLE_ENTRIES entries
and _DIAG_MAX_CONSOLE_BYTES total. Entries are accumulated until either cap
is reached; no partial-entry splitting occurs."""
if not isinstance(v, list):
return None
result = v[:_DIAG_MAX_CONSOLE_ENTRIES]
# Also enforce a byte cap — the count cap alone does not bound memory when
# entries contain arbitrarily large strings.
try:
out = []
total = 0
for entry in result:
encoded = json.dumps(entry, separators=(",", ":")).encode("utf-8", errors="replace")
if total + len(encoded) > _DIAG_MAX_CONSOLE_BYTES:
break
out.append(entry)
total += len(encoded)
return out
except (TypeError, ValueError):
return None
def _diag_cap_dict(v) -> dict | None:
"""Return *v* if it is a dict whose JSON serialisation fits within
_DIAG_MAX_CLIENT_PAYLOAD_BYTES, otherwise return None."""
if not isinstance(v, dict):
return None
try:
encoded = json.dumps(v, separators=(",", ":")).encode("utf-8", errors="replace")
except (TypeError, ValueError) as e:
log.warning("diagnostics client payload is not JSON-serialisable, dropping: %s", e)
return None
if len(encoded) > _DIAG_MAX_CLIENT_PAYLOAD_BYTES:
return None
return v
def _diag_cap_contributions(v, known_ids=None) -> dict | None:
"""Apply per-plugin and aggregate size caps on client_contributions.
Unlike _diag_cap_dict(), which drops the whole dict when any plugin
exceeds the limit, this function caps each plugin independently so
one noisy plugin does not silence every other plugin's contribution.
Parameters
----------
v:
The raw contributions dict from the POST payload.
known_ids:
When provided, contributions from plugins not in this set are
skipped *before* serialisation, preventing a malicious caller
from forcing the server to JSON-encode hundreds of near-limit
payloads that ``build_bundle()`` would later discard anyway.
``None`` means "accept all plugin ids" (used in tests / preview).
"""
if not isinstance(v, dict):
return None
result = {}
total_bytes = 0
for pid, contribution in v.items():
if not isinstance(pid, str):
continue
# Filter unknown plugin ids early — before serialising — so a
# crafted request cannot force large allocations for plugins that
# build_bundle() would drop.
if known_ids is not None and pid not in known_ids:
continue
try:
encoded = json.dumps(contribution, separators=(",", ":")).encode("utf-8", errors="replace")
except (TypeError, ValueError) as e:
log.warning(
"client_contributions[%r] is not JSON-serialisable, dropping: %s", pid, e
)
continue
if len(encoded) > _DIAG_MAX_CLIENT_PAYLOAD_BYTES:
log.warning(
"client_contributions[%r] exceeds %d bytes, dropping",
pid, _DIAG_MAX_CLIENT_PAYLOAD_BYTES,
)
continue
if total_bytes + len(encoded) > _DIAG_MAX_CONTRIBUTIONS_BYTES:
log.warning(
"client_contributions aggregate size limit (%d bytes) reached, "
"dropping remaining entries",
_DIAG_MAX_CONTRIBUTIONS_BYTES,
)
break
result[pid] = contribution
total_bytes += len(encoded)
return result or None
@router.post("/api/diagnostics/export")
def export_diagnostics(payload: dict = Body(default_factory=dict)):
"""Build a diagnostic bundle and stream it back as a zip download.
The browser layers in `client_console`, `client_hardware`,
`client_ua`, and `local_storage` before posting; the server adds
server logs, hardware, plugin inventory, and packages everything
into a single zip.
Errors during plugin diagnostics callables are caught and logged
to the bundle's manifest `notes` rather than failing the export.
"""
from plugins import LOADED_PLUGINS, PLUGINS_LOCK
redact = _diag_coerce_bool(payload.get("redact", True), default=True)
include = _diag_normalize_include(payload.get("include"))
client_console = _diag_cap_console(payload.get("client_console"))
client_hardware = _diag_cap_dict(payload.get("client_hardware"))
client_ua = _diag_cap_dict(payload.get("client_ua"))
local_storage = _diag_cap_dict(payload.get("local_storage"))
# Fetch the plugin list first so we can filter contributions to known
# plugin ids before serialising — prevents a crafted request from
# forcing large allocations for plugins build_bundle() would drop.
with PLUGINS_LOCK:
plugins_snapshot = list(LOADED_PLUGINS)
known_ids = {p.get("id") for p in plugins_snapshot if isinstance(p.get("id"), str)}
client_contributions = _diag_cap_contributions(
payload.get("client_contributions"), known_ids=known_ids
)
zip_bytes, filename, _manifest = _diag_build(
feedBack_version=appstate.running_version(),
config_dir=appstate.config_dir,
dlc_dir=_get_dlc_dir(),
log_file=_diag_log_file(),
loaded_plugins=plugins_snapshot,
include=include,
redact=redact,
client_console=client_console,
client_hardware=client_hardware,
client_ua=client_ua,
local_storage=local_storage,
client_contributions=client_contributions,
log=log,
plugins_root=_diag_plugins_roots(),
)
return Response(
content=zip_bytes,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/api/diagnostics/preview")
def preview_diagnostics(
redact: bool = True,
system: bool = True,
hardware: bool = True,
logs: bool = True,
console: bool = True,
plugins: bool = True,
):
"""Return what `/api/diagnostics/export` would produce, minus the
actual file contents — file tree, sizes, schemas, redaction counts.
Lets the Settings UI show the user what's about to be sent."""
from plugins import LOADED_PLUGINS, PLUGINS_LOCK
include = {
"system": system,
"hardware": hardware,
"logs": logs,
"console": console,
"plugins": plugins,
}
with PLUGINS_LOCK:
plugins_snapshot = list(LOADED_PLUGINS)
return _diag_preview(
feedBack_version=appstate.running_version(),
config_dir=appstate.config_dir,
dlc_dir=_get_dlc_dir(),
log_file=_diag_log_file(),
loaded_plugins=plugins_snapshot,
include=include,
redact=redact,
log=log,
plugins_root=_diag_plugins_roots(),
)
@router.get("/api/diagnostics/hardware")
def diagnostics_hardware():
"""Backend hardware probe (cross-platform). Reusable independently
of the bundle export — handy for "what's my GPU" plugin queries."""
return _diag_hardware()
+346
View File
@@ -0,0 +1,346 @@
"""Metadata-enrichment route handlers (/api/enrichment/*): status, kick/cancel,
per-song state, the Match-Review queue (accept/reject/pick/search), and AcoustID
fingerprint identify.
Extracted verbatim from server.py (R3) except @app->@router and the seam reads
(meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir). The enrichment
engine itself — transport, matcher, the background worker, and the upload caps —
lives in lib/enrichment.py and is reached here as enrichment.X.
"""
import asyncio
import os
import shutil
from pathlib import Path
from fastapi import APIRouter, Body, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse
import appstate
import enrichment
import mb_match
from appconfig import _load_config
import logging
log = logging.getLogger("feedBack.server")
router = APIRouter()
@router.get("/api/enrichment/status")
def enrichment_status():
"""Enrichment pipeline state: worker flags + row counts by match_state.
Ambient tool-state for the match-review UI (never a home-screen score —
design §11); also what tests poke."""
return {
"running": enrichment._enrich_status["running"],
"processed": enrichment._enrich_status["processed"],
"last_pass_at": enrichment._enrich_status["last_pass_at"],
"states": appstate.meta_db.enrichment_state_counts(),
"total_songs": appstate.meta_db.count(),
# Per-pass matching progress for the "Refresh Metadata" batch bar +
# per-tile badges (total = songs queued to match this pass, matched =
# done so far, current = the one being matched now).
"total": enrichment._enrich_status.get("total", 0),
"matched": enrichment._enrich_status.get("matched", 0),
"current": enrichment._enrich_status.get("current"),
"cancelling": enrichment._enrich_cancel.is_set(),
}
@router.get("/api/enrichment/song/{filename:path}")
def api_enrichment_song(filename: str):
"""Read-only per-song match provenance for the Details drawer (launch
polish): which canonical identity this chart matched and how. A tiny
projection of the cache row — no candidates, no cache paths."""
row = appstate.meta_db.get_enrichment(filename)
if not row:
raise HTTPException(status_code=404, detail="no enrichment row")
return {k: row.get(k) for k in
("match_state", "canon_artist", "canon_title",
"match_source", "match_score")}
@router.post("/api/enrichment/kick")
def api_enrichment_kick():
"""The Settings "Match now" button AND the library's "Refresh Metadata"
button: request an enrichment pass without waiting for a scan to complete.
Processes the songs that still need it (unscanned/changed + retriable
failures) — already-matched songs are left alone, so on a fully-matched
library this is a fast no-op. Single-flight + coalescing like every other
kick — spamming it queues at most one follow-up pass."""
return {"started": enrichment._kick_enrich()}
@router.post("/api/enrichment/cancel")
def api_enrichment_cancel():
"""Stop button on the "Refresh Metadata" batch: signal the running pass to
halt after the current song (an in-flight ≤1/s lookup can't be interrupted,
but no new one is started) and drop any coalesced follow-up. A no-op when
nothing is running."""
was_running = enrichment._enrich_status["running"]
if was_running:
enrichment._enrich_cancel.set()
return {"ok": True, "was_running": was_running}
@router.post("/api/enrichment/rematch")
def api_enrichment_rematch(data: dict = Body(...)):
"""The library "Refresh Metadata" button: force a fresh re-match of the
songs the grid is SHOWING (its visible/filtered window). Resets each to
`unscanned` so the next pass re-fetches it from scratch — EXCEPT user-pinned
`manual` rows, which are never auto-overwritten (apply_enrichment_match
guards that) — then kicks one pass. Scoped to the visible set on purpose:
fast (dozens of songs), visible (tiles animate), and it can't blow the whole
≤1/s rate budget on a 1000-song library the way a full re-sweep would.
Returns the filenames actually queued so the UI badges exactly those."""
raw = (data or {}).get("filenames") or []
fns = [str(f) for f in raw if isinstance(f, str)][:500]
queued: list[str] = []
for fn in fns:
song = appstate.meta_db.enrichment_song_row(fn)
if not song:
continue
h = appstate.meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
# allow_manual_overwrite=False → a manual pin is left as-is (returns
# False), everything else resets to unscanned (returns True).
if appstate.meta_db.apply_enrichment_match(fn, h, "unscanned",
allow_manual_overwrite=False):
queued.append(fn)
started = enrichment._kick_enrich() if queued else False
return {"queued": queued, "count": len(queued), "started": started}
@router.post("/api/enrichment/states")
def api_enrichment_states(data: dict = Body(...)):
"""Per-tile match states for the grid's VISIBLE window during a metadata
refresh: the client posts the filenames it is showing and gets back each
one's match_state (+ the song being matched right now, + whether a pass is
running), so a card can animate queued→working→result without a per-song
round-trip. Read-only — safe for demo visitors (no network, no mutation)."""
raw = (data or {}).get("filenames") or []
# Bound the batch: a visible grid window is dozens of cards; cap defensively.
fns = [str(f) for f in raw if isinstance(f, str)][:500]
return {
"states": appstate.meta_db.enrichment_states_for(fns),
"current": enrichment._enrich_status.get("current"),
"running": enrichment._enrich_status["running"],
}
@router.post("/api/enrichment/refresh/{filename:path}")
def api_enrichment_refresh(filename: str):
"""The context menu's "Refresh metadata": reset THIS song's match to
unscanned (canonical values + candidates cleared, backoff zeroed) and
kick a pass so it re-matches immediately. An EXPLICIT user action, so it
may discard a manual pin — the automation never does, but the user
asking for a re-match is the one party who owns that pin."""
song = appstate.meta_db.enrichment_song_row(filename)
if not song:
raise HTTPException(status_code=404, detail="unknown song")
h = appstate.meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
appstate.meta_db.apply_enrichment_match(filename, h, "unscanned",
allow_manual_overwrite=True)
return {"ok": True, "started": enrichment._kick_enrich()}
@router.get("/api/enrichment/review")
def api_enrichment_review(limit: int = 200):
"""The Match-Review queue: songs whose text match landed in the medium-
confidence review tier, each with its stored candidate list — the drawer
renders straight from this, no MusicBrainz round-trip. Ordered by the
user's enrich_review_order setting."""
limit = max(1, min(int(limit), 500))
cfg = _load_config(appstate.config_dir / "config.json") or {}
order = cfg.get("enrich_review_order", "missing_first")
return {
"songs": appstate.meta_db.enrichment_review_queue(limit=limit, order=order),
"total_review": appstate.meta_db.enrichment_state_counts().get("review", 0),
}
@router.post("/api/enrichment/review/{filename:path}/accept")
def api_enrichment_accept(filename: str, data: dict = Body(...)):
"""Accept one of the stored review candidates: the row becomes a
user-pinned `manual` match (never auto-reset). Display-only, like every
enrichment write — nothing touches the pack file."""
recording_id = str((data or {}).get("recording_id") or "")
row = appstate.meta_db.get_enrichment(filename)
if not row or row["match_state"] != "review":
raise HTTPException(status_code=404, detail="no review row for this song")
cand = next((c for c in (row.get("candidates") or [])
if c.get("recording_id") == recording_id), None)
if not cand:
raise HTTPException(status_code=404, detail="candidate not in the stored list")
if not appstate.meta_db.set_enrichment_manual(filename, cand, source="review"):
raise HTTPException(status_code=404, detail="unknown song")
return {"ok": True, "enrichment": appstate.meta_db.get_enrichment(filename)}
@router.post("/api/enrichment/review/{filename:path}/reject")
def api_enrichment_reject(filename: str):
""""None of these" — clears any canonical values and parks the row as
failed/rejected (never auto-retried; editing the song's metadata
re-queues it). Valid from `review` or `matched`, never from `manual`."""
if not appstate.meta_db.set_enrichment_rejected(filename):
raise HTTPException(status_code=404, detail="no rejectable match for this song")
return {"ok": True, "enrichment": appstate.meta_db.get_enrichment(filename)}
# The candidate fields a manual pick is allowed to carry — the payload comes
# from our own /api/enrichment/search proxy, but the route re-sanitizes so a
# hand-rolled client can't stuff arbitrary keys/types into the cache row.
_CAND_STR_FIELDS = ("recording_id", "title", "artist", "artist_id",
"artist_sort", "release_id", "album", "year", "isrc")
def _sanitize_candidate(raw: dict) -> dict | None:
if not isinstance(raw, dict):
return None
out = {k: str(raw.get(k) or "") for k in _CAND_STR_FIELDS}
if not out["recording_id"] or not out["title"]:
return None
genres = raw.get("genres") or []
out["genres"] = [str(g) for g in genres if isinstance(g, str)][:5] \
if isinstance(genres, list) else []
return out
@router.post("/api/enrichment/review/{filename:path}/pick")
def api_enrichment_pick(filename: str, data: dict = Body(...)):
"""Fix-match / manual search-and-pick: pin a candidate the user found via
/api/enrichment/search (not limited to the stored review list — this is
the escape hatch for a wrong auto-match too). Sets `manual`, the
highest-authority state."""
cand = _sanitize_candidate((data or {}).get("candidate"))
if not cand:
raise HTTPException(status_code=400, detail="candidate needs recording_id + title")
if not appstate.meta_db.set_enrichment_manual(filename, cand, source="search"):
raise HTTPException(status_code=404, detail="unknown song")
return {"ok": True, "enrichment": appstate.meta_db.get_enrichment(filename)}
@router.get("/api/enrichment/search")
def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
filename: str = "", duration: float = 0.0):
"""Manual-search proxy to MusicBrainz (throttled + identified like the
background matcher — a user typing in the drawer must not sidestep the
rate limit). `filename` optionally scores results against that song's
stored identity (year/duration corroboration) instead of just the typed
text. `duration` (seconds) lets a caller that HAS the audio but no library
row — e.g. the editor's create modal, which holds the master track — pass
its length so the studio take ranks above live/extended cuts. Sync route on
purpose: FastAPI runs it in the threadpool, so the throttle's sleep never
blocks the event loop."""
if not (artist.strip() or title.strip()):
raise HTTPException(status_code=400, detail="artist or title required")
limit = max(1, min(int(limit), 25))
try:
cands = enrichment._mb_search_recordings(artist, title, limit=limit)
except enrichment.EnrichTransportError as e:
return JSONResponse({"error": "musicbrainz unavailable", "detail": str(e)},
status_code=503)
ref = None
if filename:
ref = appstate.meta_db.enrichment_song_row(filename)
if ref is None:
ref = {"artist": artist, "title": title}
# A caller-supplied duration corroborates the take even without a library row.
if duration and duration > 0 and not ref.get("duration"):
ref = dict(ref)
ref["duration"] = duration
# Alias-enrich so a non-Latin-primary artist (大橋純子) ranks by its
# romanized alias against the typed query ("Junko Ohashi") instead of
# sinking to the bottom with a 0 artist score.
try:
enrichment._alias_enrich(ref, cands)
except enrichment.EnrichTransportError:
pass # aliases are a ranking nicety here; fall back to primary-name scoring
return {"candidates": mb_match.rank_candidates(ref, cands)}
@router.post("/api/enrichment/identify")
async def api_enrichment_identify(request: Request):
"""Identify a song by AUDIO FINGERPRINT (AcoustID) rather than text — the
reliable way to get the EXACT recording/version (the studio take, not a live
bootleg or an extended cut). Upload the master audio; returns candidates in
the same shape as /search, so the review UI and the editor's Match popup can
render fingerprint hits identically. 412 `needs_setup` when the user hasn't
opted in / has no key (the UI nudges them to Settings); 503 when it's set up
but the fpcalc Chromaprint binary is missing or the network is off. Async so
the multipart is size-capped BEFORE spooling; the blocking fpcalc subprocess
+ AcoustID HTTP run in the threadpool via run_in_executor."""
gate = enrichment._acoustid_gate()
if gate is not None:
return gate
# Pre-parse Content-Length guard — reject an oversized body before Starlette
# spools the multipart to temp disk (mirrors the song-upload endpoint). The
# per-part cap below is the authoritative limit; this is the fast up-front no.
cl = request.headers.get("content-length")
if cl is not None:
try:
cl_int = int(cl)
except ValueError:
return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400)
if cl_int > enrichment._ACOUSTID_MAX_UPLOAD_BYTES + enrichment._MULTIPART_OVERHEAD_SLACK:
return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413)
try:
form = await request.form(max_part_size=enrichment._ACOUSTID_MAX_UPLOAD_BYTES)
except Exception:
return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413)
file = form.get("file")
if not isinstance(file, UploadFile):
raise HTTPException(status_code=400, detail="missing file upload")
import tempfile
ext = (Path(file.filename or "").suffix or ".bin").lower()
tmpdir = tempfile.mkdtemp(prefix="feedback_acoustid_")
tmp = os.path.join(tmpdir, "audio" + ext)
try:
total = 0
with open(tmp, "wb") as fh:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > enrichment._ACOUSTID_MAX_UPLOAD_BYTES:
return JSONResponse(
{"error": "audio upload too large (256 MB max)"}, status_code=413)
fh.write(chunk)
if total == 0:
raise HTTPException(status_code=400, detail="empty upload")
# fpcalc subprocess + AcoustID HTTP are blocking — off the event loop.
cands = await asyncio.get_event_loop().run_in_executor(
None, enrichment._identify_by_fingerprint, tmp)
except enrichment.EnrichTransportError as e:
return JSONResponse({"error": "acoustid unavailable", "detail": str(e)},
status_code=503)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
return {"candidates": cands}
@router.post("/api/enrichment/identify/{filename:path}")
def api_enrichment_identify_song(filename: str):
"""Identify an EXISTING library song by AUDIO FINGERPRINT — the library-side
counterpart to /api/enrichment/identify (which takes an upload). Fingerprints
the song's own master audio on disk (the manual "Identify by audio" action in
the Fix-metadata / match-review flow). Same candidate shape as /search, so the
review UI renders fingerprint hits like text hits. Same 412/503 gating; 404
when the song has no full-mix audio to fingerprint."""
gate = enrichment._acoustid_gate()
if gate is not None:
return gate
audio = enrichment._song_audio_file(filename)
if not audio:
return JSONResponse(
{"error": "no audio",
"detail": "couldn't find this song's master audio to fingerprint "
"(a stems-only pack has no full mix to identify)."},
status_code=404)
try:
cands = enrichment._identify_by_fingerprint(audio)
except enrichment.EnrichTransportError as e:
return JSONResponse({"error": "acoustid unavailable", "detail": str(e)},
status_code=503)
return {"candidates": cands}
+485
View File
@@ -0,0 +1,485 @@
"""Library + smart-collection routes: the provider list/art/sync endpoints, the
library query surface (songs, albums, artists, stats, genres, tuning-names,
practice-suggestions), and collection CRUD.
Extracted verbatim from server.py (R3) except @app->@router and the seam reads:
meta_db->appstate.meta_db, and the registry singletons ->
appstate.library_providers / appstate.local_library_provider (constructed +
owned by server.py; plugins register providers through plugin_context). The
provider classes + shared query/collection helpers live in lib/library_registry.py.
"""
import inspect
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
from starlette.concurrency import run_in_threadpool
import appstate
from library_registry import (
_library_filter_args, _sanitize_collection_rules,
_safe_art_redirect_url, _split_csv, _sync_collection_provider,
_unregister_collection_provider,
)
from metadata_db import _effective_keyset_sort, next_library_cursor
from reqfields import _clean_str
import logging
log = logging.getLogger("feedBack.server")
router = APIRouter()
def _get_library_provider(provider: str = "local") -> object:
library_provider = appstate.library_providers.get(provider or "local")
if library_provider is None:
raise HTTPException(status_code=404, detail=f"Unknown library provider: {provider}")
return library_provider
def _require_library_provider_capability(provider: object, capability: str) -> None:
if capability in appstate.library_providers.provider_capabilities(provider):
return
provider_id = appstate.library_providers.provider_id(provider)
raise HTTPException(
status_code=501,
detail=f"Library provider {provider_id!r} does not declare capability {capability!r}",
)
_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters", "after",
"mastery", "match_states")
def _filter_provider_kwargs(method: object, kwargs: dict) -> dict:
"""Drop kwargs that the method's signature does not declare.
Provides backward-compat for third-party library providers whose
query_page/query_artists/query_stats methods were written before
naming_mode was added — calling them with the extra kwarg would
raise TypeError and return a 500 to the client.
When ``inspect.signature`` cannot introspect the method (rare: C
extensions / built-ins / exotic callables), fall back to stripping
only the kwargs we know were added later — older providers won't
accept them, anything else stays so the call still works.
"""
try:
sig = inspect.signature(method) # type: ignore[arg-type]
for p in sig.parameters.values():
if p.kind == inspect.Parameter.VAR_KEYWORD:
return kwargs # method accepts **kwargs, pass everything
return {k: v for k, v in kwargs.items() if k in sig.parameters}
except (ValueError, TypeError):
return {k: v for k, v in kwargs.items() if k not in _OPTIONAL_NEW_PROVIDER_KWARGS}
def _call_library_provider(provider: object, method_name: str, **kwargs) -> Any:
method = appstate.library_providers.provider_method(provider, method_name)
if not callable(method):
provider_id = appstate.library_providers.provider_id(provider)
raise HTTPException(
status_code=501,
detail=f"Library provider {provider_id!r} does not support {method_name}",
)
try:
return method(**_filter_provider_kwargs(method, kwargs))
except HTTPException:
raise
except Exception as exc:
provider_id = appstate.library_providers.provider_id(provider)
# A provider with an explicit kind="local" is treated as local even if
# its id is not "local" (e.g. a kind="local" plugin variant). Otherwise
# fall back to provider_id comparison so providers that omit `kind` are
# still wrapped correctly — the safe default for unknown providers is to
# surface an offline message rather than leaking raw exceptions.
provider_kind = str(appstate.library_providers.provider_field(provider, "kind", "") or "")
if provider_kind:
is_remote = provider_kind not in ("", "local")
else:
is_remote = provider_id != "local"
if is_remote:
detail = f"This source appears to be offline ({provider_id})."
message = str(exc).strip()
if message:
detail = f"{detail} {message}"
raise HTTPException(status_code=503, detail=detail) from exc
raise
def _is_async_callable(obj: object) -> bool:
"""Return True if obj is an async function or a callable object with an async __call__.
``inspect.iscoroutinefunction`` only recognises bare coroutine functions; it returns
False for class instances whose ``__call__`` method is defined as ``async def``.
Checking both handles the common plugin pattern of wrapping an async method in a
callable object.
"""
if inspect.iscoroutinefunction(obj):
return True
_call = getattr(obj, "__call__", None)
return _call is not None and inspect.iscoroutinefunction(_call)
async def _call_library_provider_async(provider: object, method_name: str, **kwargs) -> Any:
method = appstate.library_providers.provider_method(provider, method_name)
if _is_async_callable(method):
# Async provider method — call directly on the event loop.
try:
return await method(**_filter_provider_kwargs(method, kwargs))
except HTTPException:
raise
except Exception as exc:
provider_id = appstate.library_providers.provider_id(provider)
provider_kind = str(appstate.library_providers.provider_field(provider, "kind", "") or "")
if provider_kind:
is_remote = provider_kind not in ("", "local")
else:
is_remote = provider_id != "local"
if is_remote:
detail = f"This source appears to be offline ({provider_id})."
message = str(exc).strip()
if message:
detail = f"{detail} {message}"
raise HTTPException(status_code=503, detail=detail) from exc
raise
# Synchronous provider method — run in a threadpool so the event loop stays free.
return await run_in_threadpool(_call_library_provider, provider, method_name, **kwargs)
def _library_art_response(result: Any) -> Response:
if result is None:
raise HTTPException(status_code=404, detail="Library provider returned no art")
if isinstance(result, Response):
return result
if isinstance(result, (bytes, bytearray, memoryview)):
return Response(content=bytes(result), media_type="image/png")
if isinstance(result, str):
safe_url = _safe_art_redirect_url(result)
if safe_url is not None:
return RedirectResponse(safe_url)
# If the string looks like a URL (contains a scheme separator) but
# didn't pass the http/https check, refuse it rather than treating
# it as a filesystem path — a provider returning ftp:// or file://
# should get a 400, not a 500 from FileResponse failing on a URL.
if "://" in result:
raise HTTPException(
status_code=400,
detail="Library provider returned an unsupported URL scheme for art",
)
if not Path(result).is_file():
raise HTTPException(status_code=404, detail="Library provider returned an unreadable art path")
return FileResponse(result)
if isinstance(result, Path):
if not result.is_file():
raise HTTPException(status_code=404, detail="Library provider returned an unreadable art path")
return FileResponse(str(result))
if isinstance(result, dict):
url = result.get("url") or result.get("art_url") or result.get("artUrl")
if isinstance(url, str) and url:
safe_url = _safe_art_redirect_url(url)
if safe_url is None:
raise HTTPException(status_code=400, detail="Library provider returned an unsafe art URL")
return RedirectResponse(safe_url)
path = result.get("path") or result.get("file")
if isinstance(path, (str, Path)):
media_type = result.get("media_type") or result.get("content_type")
if not Path(path).is_file():
raise HTTPException(status_code=404, detail="Library provider returned an unreadable art path")
return FileResponse(str(path), media_type=media_type)
content = result.get("content") or result.get("bytes")
if isinstance(content, (bytes, bytearray, memoryview)):
media_type = result.get("media_type") or result.get("content_type") or "image/png"
return Response(content=bytes(content), media_type=media_type)
raise HTTPException(status_code=500, detail="Library provider returned unsupported art data")
@router.get("/api/library/providers")
def list_library_providers():
"""List registered library providers."""
return {"providers": appstate.library_providers.list()}
@router.get("/api/library/providers/{provider_id}/songs/{song_id:path}/art")
async def get_library_provider_song_art(provider_id: str, song_id: str):
"""Return album art for a song owned by a library provider."""
library_provider = _get_library_provider(provider_id)
_require_library_provider_capability(library_provider, "art.read")
result = await _call_library_provider_async(library_provider, "get_art", song_id=song_id)
return _library_art_response(result)
@router.post("/api/library/providers/{provider_id}/songs/{song_id:path}/sync")
async def sync_library_provider_song(provider_id: str, song_id: str):
"""Ask a provider to sync a remote song into the local library/cache."""
library_provider = _get_library_provider(provider_id)
_require_library_provider_capability(library_provider, "song.sync")
result = await _call_library_provider_async(library_provider, "sync_song", song_id=song_id)
if result is None:
return {"ok": True}
if isinstance(result, dict):
return result
return {"ok": True, "result": result}
@router.get("/api/library")
async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "artist",
dir: str = "asc", favorites: int = 0, format: str = "",
artist: str = "", album: str = "",
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", provider: str = "local",
mastery: str = "", tags: str = "", user_difficulty: str = "",
match: str = "", genre: str = "", after: str = "", group: int = 0,
naming_mode: str = "legacy"):
"""Paginated library search through the selected library provider.
`after` is an opaque keyset cursor (feedBack#636 item 3): pass back the
`next_cursor` from the previous response to fetch the next page with a
WHERE-seek instead of OFFSET. Providers that don't support it ignore it and
page by OFFSET, so the client can always fall back."""
size = min(size, 100)
library_provider = _get_library_provider(provider)
_require_library_provider_capability(library_provider, "library.read")
# Only the true local provider keysets: it's the one whose effective sort is
# exactly the request `sort`. A smart collection may pin its own sort and
# remote providers don't keyset — both must page by OFFSET, so never hand
# them a cursor (a mismatched one would mis-seek).
is_local = getattr(library_provider, "id", "") == "local"
songs, total = await _call_library_provider_async(
library_provider,
"query_page",
page=page,
size=size,
sort=sort,
direction=dir,
after=((after or None) if is_local else None),
group=bool(group),
naming_mode=naming_mode,
mastery=_split_csv(mastery),
tags_has=_split_csv(tags),
user_difficulty_in=_split_csv(user_difficulty),
match_states=_split_csv(match),
genre=_split_csv(genre),
**_library_filter_args(
q=q, favorites=favorites, format=format,
artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings,
),
)
# The cursor to resume after this page (effective sort folds in dir=desc).
next_cursor = (next_library_cursor(_effective_keyset_sort(sort, dir), songs[-1])
if (is_local and songs) else None)
# Drop the private raw-title stash query_page attached for the cursor — it's
# an internal keyset detail, not part of the card payload.
for s in songs:
s.pop("_sort_title", None)
return {"songs": songs, "total": total, "page": page, "size": size,
"next_cursor": next_cursor}
@router.get("/api/library/albums")
async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
favorites: int = 0, format: str = "",
artist: str = "", album: str = "",
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", mastery: str = "",
match: str = "", genre: str = "",
provider: str = "local"):
"""Album-condensed browse: distinct (artist, album) groups with a track count
and a representative cover song. Paged by album. Same filters as /api/library."""
size = min(size, 500)
library_provider = _get_library_provider(provider)
_require_library_provider_capability(library_provider, "library.read")
albums, total = await _call_library_provider_async(
library_provider, "query_albums",
page=page, size=size, mastery=_split_csv(mastery),
match_states=_split_csv(match), genre=_split_csv(genre),
**_library_filter_args(
q=q, favorites=favorites, format=format, artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings,
),
)
return {"albums": albums, "total": total, "page": page, "size": size}
@router.get("/api/library/artists")
async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page: int = 0,
size: int = 50, format: str = "",
artist: str = "", album: str = "",
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", provider: str = "local",
naming_mode: str = "legacy"):
"""Get artists grouped by letter with albums and songs (for tree view)."""
size = min(size, 100)
library_provider = _get_library_provider(provider)
_require_library_provider_capability(library_provider, "library.read")
artists, total = await _call_library_provider_async(
library_provider,
"query_artists",
letter=letter,
page=page,
size=size,
naming_mode=naming_mode,
**_library_filter_args(
q=q, favorites=favorites, format=format,
artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings,
),
)
return {"artists": artists, "total_artists": total, "page": page, "size": size}
@router.get("/api/library/stats")
async def library_stats(favorites: int = 0, q: str = "", format: str = "",
artist: str = "", album: str = "",
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", provider: str = "local",
match: str = "",
sort: str = "artist", sort_letters: int = 0,
group: int = 0, naming_mode: str = "legacy"):
"""Aggregate stats for the UI. Accepts the same filter params as
/api/library so the letter bar mirrors the active grid filter set.
`sort` selects the column the jump rail's `sort_letters` keys on;
`sort_letters=1` opts into that breakdown (the rail), so non-rail
callers skip the extra per-letter aggregate. `group=1` counts works not
charts (mirrors the grouped grid)."""
library_provider = _get_library_provider(provider)
_require_library_provider_capability(library_provider, "library.read")
return await _call_library_provider_async(
library_provider,
"query_stats",
naming_mode=naming_mode,
sort=sort,
want_sort_letters=bool(sort_letters),
group=bool(group),
# The match facet rides the stats call too — the AZ rail's letter
# counts must agree with the grid under the facet or its cumulative
# seek + sizer geometry break.
match_states=_split_csv(match),
**_library_filter_args(
q=q, favorites=favorites, format=format,
artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings,
),
)
@router.get("/api/library/genres")
def library_genres(provider: str = "local"):
"""Distinct non-empty genres for the filter facet.
Genres are a local-library facet: they're populated from the feedpak
`genres` field at scan time and live in the local meta DB. Local-backed
providers (the local library and its smart collections, kind="local")
share that DB, so they surface the same set. Remote providers don't
expose genres here, so return an empty facet for them — the client then
hides the filter rather than offering local genres that don't apply to
the remote grid. Mirrors the local/remote gating used elsewhere for
provider calls (see `_call_library_provider`)."""
library_provider = _get_library_provider(provider)
kind = str(appstate.library_providers.provider_field(library_provider, "kind", "") or "")
is_remote = kind not in ("", "local") if kind else provider != "local"
if is_remote:
return {"genres": []}
with appstate.meta_db._lock:
g = appstate.meta_db._effective_genre_expr()
rows = appstate.meta_db.conn.execute(
f"SELECT g FROM (SELECT DISTINCT ({g}) AS g FROM songs) "
"WHERE g IS NOT NULL AND g != '' ORDER BY g COLLATE NOCASE"
).fetchall()
return {"genres": [r[0] for r in rows]}
@router.get("/api/library/tuning-names")
async def list_tuning_names(provider: str = "local"):
"""Distinct tuning names present in the library, with per-tuning
counts. Powers the tuning multi-select. Sorted by `tuning_sort_key`
so names appear in the same musical order the sort uses
(feedBack#22) — E Standard first, then nearest neighbors."""
library_provider = _get_library_provider(provider)
_require_library_provider_capability(library_provider, "library.read")
return await _call_library_provider_async(library_provider, "tuning_names")
@router.get("/api/library/practice-suggestions")
def api_practice_suggestions(limit: int = 8):
"""Growth-edge 'practice next' shelf (P3): attempted-but-not-mastered songs
ranked by difficulty-appropriateness × mastery-proximity, joined to song
metadata. Replaces the recency-only 'Keep practicing' shelf ordering. Local
library only — reads local practice stats."""
from urllib.parse import quote
out = []
for r in appstate.meta_db.growth_edge_suggestions(limit):
meta = appstate.meta_db.conn.execute(
"SELECT title, artist, tuning_name FROM songs WHERE filename = ?",
(r["filename"],),
).fetchone()
title, artist, tuning_name = meta if meta else (None, None, None)
out.append({
**r,
"title": title or r["filename"],
"artist": artist or "",
"tuning_name": tuning_name or "",
"art_url": f"/api/song/{quote(r['filename'])}/art",
})
return out
@router.get("/api/collections")
def api_list_collections():
"""Smart/dynamic collections (saved live library filters)."""
return {"collections": appstate.meta_db.list_collections()}
@router.post("/api/collections")
def api_create_collection(data: dict):
"""Create a collection from a name + a set of library filter rules. It
immediately appears as a source in the library provider picker."""
if not isinstance(data, dict):
return JSONResponse({"error": "body must be an object"}, status_code=400)
name = _clean_str(data.get("name"))
if not name:
return JSONResponse({"error": "name required"}, status_code=400)
col = appstate.meta_db.create_collection(name, _sanitize_collection_rules(data.get("rules")))
_sync_collection_provider(col)
return {"ok": True, "collection": col}
@router.put("/api/collections/{pid}")
def api_update_collection(pid: int, data: dict):
"""Rename a collection and/or replace its rules."""
if not isinstance(data, dict):
return JSONResponse({"error": "body must be an object"}, status_code=400)
name = _clean_str(data.get("name")) or None
rules = _sanitize_collection_rules(data["rules"]) if "rules" in data else None
col = appstate.meta_db.update_collection(pid, name=name, rules=rules)
if col is None:
return JSONResponse({"error": "collection not found"}, status_code=404)
_sync_collection_provider(col)
return {"ok": True, "collection": col}
@router.delete("/api/collections/{pid}")
def api_delete_collection(pid: int):
"""Delete a collection and unregister its provider."""
if not appstate.meta_db.is_collection(pid):
return JSONResponse({"error": "collection not found"}, status_code=404)
appstate.meta_db.delete_playlist(pid)
_unregister_collection_provider(pid)
return {"ok": True}
+162
View File
@@ -0,0 +1,162 @@
"""Media/file-serving routes: song audio (/audio/{f}), the local-audio-path
resolver (/api/audio-local-path), and raw sloppak member serving
(/api/sloppak/{f}/file/{rel}).
Extracted verbatim from server.py (R3) except @app->@router and the cache/static
path seams (AUDIO_CACHE_DIR->appstate.audio_cache_dir, STATIC_DIR->
appstate.static_dir, SLOPPAK_CACHE_DIR->appstate.sloppak_cache_dir).
"""
import ipaddress
import re
from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, JSONResponse
import appstate
import sloppak as sloppak_mod
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
import logging
log = logging.getLogger("feedBack.server")
router = APIRouter()
def _resolve_sloppak_local_file(filename: str, rel_path: str):
"""Resolve a file inside a sloppak to its on-disk path.
Applies the same containment guards as ``serve_sloppak_file``. Returns the
resolved ``Path`` on success, or an ``(error, status)`` tuple on failure so
callers can produce their endpoint-appropriate response.
"""
dlc = _get_dlc_dir()
if not dlc:
return ("not configured", 404)
# `filename` is caller-controlled. Contain it under DLC_DIR before it
# reaches the resolver (see serve_sloppak_file for the traversal rationale).
resolved = _resolve_dlc_path(dlc, filename)
if resolved is None:
return ("forbidden", 403)
# Confine to actual sloppak bundles — otherwise any plain subdirectory
# would become a read-any-file-under-DLC_DIR source.
if not sloppak_mod.is_sloppak(resolved):
return ("not found", 404)
# Canonicalise the cache key against the resolved path so equivalent URL
# forms of the same sloppak converge on one _source_cache entry.
try:
filename = resolved.relative_to(dlc.resolve()).as_posix()
except ValueError:
# safe_join already proved containment; fail closed regardless.
return ("forbidden", 403)
src = sloppak_mod.get_cached_source_dir(filename)
if src is None:
try:
src = sloppak_mod.resolve_source_dir(filename, dlc, appstate.sloppak_cache_dir)
except Exception:
return ("not found", 404)
# Prevent path traversal within the sloppak.
target = (src / rel_path).resolve()
try:
target.relative_to(src.resolve())
except ValueError:
return ("forbidden", 403)
if not target.exists() or not target.is_file():
return ("not found", 404)
return target
@router.get("/api/sloppak/{filename:path}/file/{rel_path:path}")
def serve_sloppak_file(filename: str, rel_path: str):
"""Serve a file from inside a sloppak (stems, cover, etc.)."""
result = _resolve_sloppak_local_file(filename, rel_path)
if isinstance(result, tuple):
error, status = result
return JSONResponse({"error": error}, status)
target = result
ext = target.suffix.lower()
mt = {
".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg",
".mp3": "audio/mpeg", ".wav": "audio/wav", ".flac": "audio/flac",
".m4a": "audio/mp4",
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".webp": "image/webp",
".json": "application/json",
}.get(ext)
return FileResponse(str(target), media_type=mt) if mt else FileResponse(str(target))
@router.get("/api/audio-local-path")
def audio_local_path(url: str, request: Request):
"""Return absolute local filesystem path for a song URL (Electron desktop only).
Accepts ``/audio/<path>`` where ``<path>`` may include subdirectory segments —
no scheme, no host, no query string, no fragment. The resolved path must stay
inside appstate.audio_cache_dir or appstate.static_dir; ``..`` traversal, backslashes, and
absolute ``filename`` values are rejected.
Also accepts ``/api/sloppak/<filename>/file/<rel>`` (percent-encoded, as
emitted by the highway song payload) and resolves it to the unpacked
sloppak cache file via the same containment guards as
``serve_sloppak_file`` — this lets the desktop engine play a feedpak
full-mix natively under WASAPI-exclusive output.
This endpoint returns a raw filesystem path and is intended exclusively for
the Electron desktop process (which runs on loopback). Requests from non-
loopback clients are rejected with 403.
"""
# Loopback-only — only the local Electron process should call this
client_host = request.client.host if request.client else None
try:
is_loopback = bool(client_host and ipaddress.ip_address(client_host).is_loopback)
except ValueError:
is_loopback = client_host == "localhost"
if not is_loopback:
return JSONResponse({"error": "forbidden"}, status_code=403)
# Sloppak in-pack file (feedpak full-mix): /api/sloppak/<fn>/file/<rel>.
# Both segments arrive percent-encoded (built with urllib quote() in the
# highway payload); decode before handing to the shared resolver, which
# re-applies all containment guards on the decoded values.
slop_match = re.fullmatch(r"/api/sloppak/([^?#]+)/file/([^?#]+)", url)
if slop_match:
from urllib.parse import unquote
result = _resolve_sloppak_local_file(
unquote(slop_match.group(1)), unquote(slop_match.group(2))
)
if isinstance(result, tuple):
error, status = result
return JSONResponse({"error": error}, status_code=status)
return JSONResponse({"path": str(result)})
# Accept only simple /audio/<filename> — no scheme, no host, no query/fragment
if not re.fullmatch(r"/audio/[^?#]+", url):
return JSONResponse({"error": "invalid url"}, status_code=400)
filename = url[len("/audio/"):]
# Reject traversal, absolute paths, and backslash separators
if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename:
return JSONResponse({"error": "invalid url"}, status_code=400)
for d in [appstate.audio_cache_dir, appstate.static_dir]:
candidate = (d / filename).resolve()
# Ensure resolved path is inside the allowed directory
try:
candidate.relative_to(d.resolve())
except ValueError:
continue
if candidate.is_file():
return JSONResponse({"path": str(candidate)})
return JSONResponse({"error": "not found"}, status_code=404)
@router.get("/audio/{filename:path}")
def serve_audio(filename: str):
"""Serve audio files from the writable audio cache directory."""
# Reject traversal attempts and absolute-path components
if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename:
return JSONResponse({"error": "not found"}, status_code=404)
for d in [appstate.audio_cache_dir, appstate.static_dir]:
candidate = (d / filename).resolve()
try:
candidate.relative_to(d.resolve())
except ValueError:
continue
if candidate.is_file():
return FileResponse(str(candidate))
return JSONResponse({"error": "not found"}, status_code=404)
File diff suppressed because it is too large Load Diff
+867
View File
@@ -0,0 +1,867 @@
"""Song routes: upload / delete / metadata (user-meta, overrides, catalog meta
write-back), gap-fill proposals, and the per-song info payload.
Extracted verbatim from server.py (R3) except @app->@router and the seam reads:
meta_db->appstate.meta_db, and the scan/ingest helpers that stay in server.py
(the scan lifecycle owns them) -> appstate.<callable>: kick_scan,
invalidate_song_caches, stat_for_cache, scan_status() (a getter — the underlying
dict is reassigned), plus art_override_paths. The gap-fill MBID/ISRC regexes live
in lib/enrichment.py and are reached as enrichment.X.
"""
import os
import shutil
import tempfile
import threading
from pathlib import Path
from fastapi import APIRouter, Request, UploadFile
from fastapi.responses import JSONResponse
from starlette.concurrency import run_in_threadpool
import appstate
import enrichment
import loosefolder as loosefolder_mod
import sloppak as sloppak_mod
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
from scan_worker import _extract_meta_for_file
import logging
log = logging.getLogger("feedBack.server")
router = APIRouter()
_ALLOWED_SONG_EXTS = set(sloppak_mod.SONG_EXTS)
_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB — covers sloppaks bundled with stems
# Per-request batch cap. Lets a user drop a whole album of sloppaks at once
# without giving a hostile client a 1000-file DoS surface via Starlette's
# default max_files=1000. The pre-parse Content-Length guard is sized as
# _MAX_UPLOAD_FILES * _MAX_UPLOAD_BYTES + slack.
_MAX_UPLOAD_FILES = 50
# Serializes the mutating step of upload (os.replace into DLC_DIR) with
# delete_song so the two endpoints can't interleave on the same path —
# e.g. an upload finishing right after a concurrent delete shouldn't
# resurrect a song the user just removed, and a delete arriving mid-
# overwrite shouldn't strand a half-written file. threading.Lock (not
# asyncio.Lock) because delete_song is sync (runs in the threadpool);
# upload acquires it inside ``run_in_threadpool`` for the same reason.
_song_io_lock = threading.Lock()
def _commit_uploaded_song(tmp_path: Path, dest: Path, overwrite: bool, base: str):
"""Atomically move a validated temp upload into ``dest`` under ``_song_io_lock``.
Returns ``None`` on success or an error result dict matching the upload
endpoint's contract. Holds the lock across the directory re-check and
the final ``os.replace`` so a concurrent delete or upload can't slip
between them. Always cleans up the temp file on the error paths.
"""
with _song_io_lock:
if dest.exists():
if not overwrite:
# Lost the race against a concurrent upload of the same name.
try:
tmp_path.unlink()
except OSError:
pass
return {"status": "exists", "filename": base,
"error": "A file with this name already exists"}
# Re-check directory state under the lock — the pre-check
# may have raced an unrelated mkdir, and a sloppak directory
# has to be removed before os.replace() can write over it.
if dest.is_dir():
if not sloppak_mod.is_sloppak(dest):
try:
tmp_path.unlink()
except OSError:
pass
return {"status": "exists", "filename": base,
"error": "A directory with this name exists and is not "
"a sloppak — refusing to overwrite"}
shutil.rmtree(str(dest))
os.replace(str(tmp_path), str(dest))
return None
@router.post("/api/songs/upload")
async def upload_song(request: Request):
"""Upload one or more .sloppak files into the configured DLC folder.
Multipart body with one or more ``file`` fields (up to ``_MAX_UPLOAD_FILES``
per request). Query string:
``overwrite=1`` — replace existing files with the same name.
Response shape (always HTTP 200 once we've gotten past request-level guards
like DLC-not-configured / payload-too-large):
``{"results": [{"filename": "...", "status": "ok" | "exists" | "error",
"error"?: "...", "size"?: N, "format"?: "sloppak"}, ...]}``
Per-file conflicts surface as ``status: "exists"`` so a batch upload can
surface ALL conflicts at once instead of bailing on the first one. The
client re-POSTs just the conflicting files with ``overwrite=1`` if the
user opts in.
The DLC directory is resolved via ``_get_dlc_dir()`` which honours the
``DLC_DIR`` env var first and falls back to ``dlc_dir`` in
``config.json`` — so uploads land in whichever folder the rest of the
app already considers the library root, regardless of which mechanism
configured it.
"""
dlc = _get_dlc_dir()
if dlc is None:
return JSONResponse(
{"error": "DLC folder is not configured. Set DLC_DIR or configure it in Settings."},
status_code=503,
)
if not os.access(str(dlc), os.W_OK):
return JSONResponse(
{"error": f"DLC folder {dlc} is not writable by the server process."},
status_code=500,
)
# Pre-parse Content-Length guard — fail fast before reading any body.
# Multipart Content-Length is file bytes + boundary + per-part headers, so
# we can't use _MAX_UPLOAD_BYTES as an exact cap here (a file right at the
# advertised max would be rejected before _save_uploaded_song() can apply
# the real per-file byte cap). For batch uploads we allow up to
# _MAX_UPLOAD_FILES files at _MAX_UPLOAD_BYTES each; the parser still
# enforces per-part size via max_part_size and per-batch count via
# max_files. The streaming check inside _save_uploaded_song() is the
# authoritative per-file size cap.
max_total = _MAX_UPLOAD_FILES * _MAX_UPLOAD_BYTES + enrichment._MULTIPART_OVERHEAD_SLACK
cl = request.headers.get("content-length")
if cl is not None:
try:
cl_int = int(cl)
except ValueError:
return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400)
if cl_int < 0:
return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400)
if cl_int > max_total:
return JSONResponse(
{"error": f"Batch upload exceeds {_MAX_UPLOAD_FILES} files × "
f"{_MAX_UPLOAD_BYTES // (1024 * 1024)} MB limit"},
status_code=413,
)
overwrite = request.query_params.get("overwrite") == "1"
# Tighten the parser to the handler's contract: up to _MAX_UPLOAD_FILES
# file parts, no text parts (overwrite comes from query params).
# Starlette's defaults of max_files=1000 / max_fields=1000 would
# otherwise let a client force the parser to spool far more parts than
# the endpoint is willing to process.
form = await request.form(
max_files=_MAX_UPLOAD_FILES,
max_fields=0,
max_part_size=_MAX_UPLOAD_BYTES,
)
try:
from starlette.datastructures import UploadFile as _StarletteUploadFile
# form.getlist("file") returns all parts named "file" in submission
# order. Filter to file parts only — Starlette would yield strings
# for text parts, but we've capped max_fields=0 so any non-file part
# is already a parser error before reaching here.
uploads = [u for u in form.getlist("file") if isinstance(u, _StarletteUploadFile)]
if not uploads:
return JSONResponse(
{"error": "Expected one or more files in multipart field 'file'"},
status_code=400,
)
results = []
any_saved = False
for upload in uploads:
try:
result = await _save_uploaded_song(upload, dlc, overwrite)
results.append(result)
if result.get("status") == "ok":
any_saved = True
except Exception as e:
# Per-file failure must not abort the batch — record and
# continue so the client gets a complete report.
log.exception("upload failed for %r", getattr(upload, "filename", "?"))
results.append({
"filename": Path(getattr(upload, "filename", "") or "").name or "?",
"status": "error",
"error": f"Upload failed: {e}",
})
finally:
try:
await upload.close()
except Exception:
log.debug("failed to close upload file handle", exc_info=True)
if any_saved:
appstate.kick_scan()
return {"results": results}
finally:
try:
await form.close()
except Exception:
log.debug("failed to close form", exc_info=True)
async def _save_uploaded_song(upload: UploadFile, dlc: Path, overwrite: bool) -> dict:
"""Save one upload into ``dlc``. Returns a per-file result dict (never
a JSONResponse) so batch uploads can aggregate.
Shape:
ok: ``{"status": "ok", "filename": base, "size": N, "format": "sloppak"}``
exists: ``{"status": "exists", "filename": base, "error": "..."}``
error: ``{"status": "error", "filename": base, "error": "..."}``
"""
# Strip any path components a client may have included in the filename —
# only the basename lands in the DLC root. Path traversal would otherwise
# let a crafted upload escape the library directory.
raw_name = upload.filename or ""
base = Path(raw_name).name
if not base or base in (".", "..") or "/" in base or "\\" in base:
return {"status": "error", "filename": raw_name or "?", "error": "Invalid filename"}
suffix = Path(base).suffix.lower()
if suffix not in _ALLOWED_SONG_EXTS:
return {"status": "error", "filename": base,
"error": "Only .feedpak files are accepted"}
dest = dlc / base
if dest.exists():
if not overwrite:
return {"status": "exists", "filename": base,
"error": "A file with this name already exists"}
# overwrite=1 must handle directory-form sloppaks (the scanner and
# delete path both treat them as song entries). os.replace() can't
# clobber a non-empty directory, so without the rmtree below the
# whole upload would write to a temp file and then surface a late
# 500 at the os.replace() call. Refuse other directories so an
# unrelated folder isn't blown away by a same-named upload.
if dest.is_dir() and not sloppak_mod.is_sloppak(dest):
return {"status": "exists", "filename": base,
"error": "A directory with this name exists and is not a sloppak — "
"refusing to overwrite"}
# Temp file in the DLC dir itself so os.replace is atomic (same filesystem).
# Dot-prefix keeps it out of the rglob("*.sloppak") scan glob.
fd, tmp_name = await run_in_threadpool(
tempfile.mkstemp, dir=str(dlc), prefix=".upload-", suffix=".part"
)
tmp_path = Path(tmp_name)
bytes_read = 0
head = b""
error_result: dict | None = None
try:
try:
tmpf = await run_in_threadpool(os.fdopen, fd, "wb")
except BaseException:
try:
await run_in_threadpool(os.close, fd)
except OSError:
pass
raise
try:
while True:
chunk = await upload.read(1024 * 1024)
if not chunk:
break
bytes_read += len(chunk)
if bytes_read > _MAX_UPLOAD_BYTES:
error_result = {
"status": "error", "filename": base,
"error": f"Upload exceeds {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB cap",
}
break
if len(head) < 4:
head += chunk[: 4 - len(head)]
await run_in_threadpool(tmpf.write, chunk)
finally:
await run_in_threadpool(tmpf.close)
if error_result is None:
if bytes_read == 0:
error_result = {"status": "error", "filename": base,
"error": "Empty upload — file is 0 bytes"}
elif suffix in _ALLOWED_SONG_EXTS:
if head[:2] != b"PK":
error_result = {"status": "error", "filename": base,
"error": "Not a valid feedpak file (expected zip archive)"}
else:
# ZIP magic alone admits any renamed zip — verify the sloppak
# loader can actually parse a manifest.yaml inside. Without
# this, /api/songs/upload returns "ok" for files the rest of
# the backend would refuse to scan or load.
try:
await run_in_threadpool(sloppak_mod.load_manifest, tmp_path)
except Exception as e:
error_result = {"status": "error", "filename": base,
"error": f"Not a valid sloppak file: {e}"}
if error_result is not None:
try:
await run_in_threadpool(tmp_path.unlink)
except OSError:
pass
return error_result
# Single sync helper so the lock is held for the whole commit —
# ``async with _upload_lock`` would have released between every
# ``run_in_threadpool`` and let a concurrent delete or upload slip
# in between the dir check and the final ``os.replace``.
commit_result = await run_in_threadpool(
_commit_uploaded_song, tmp_path, dest, overwrite, base
)
if commit_result is not None:
return commit_result
except BaseException:
try:
await run_in_threadpool(tmp_path.unlink)
except OSError:
pass
raise
# Even on a fresh (non-overwrite) upload, evict any stale entries left
# over from a previous delete+re-upload of the same name.
await run_in_threadpool(appstate.invalidate_song_caches, base)
log.info("Uploaded %s (%d bytes) to %s", base, bytes_read, dlc)
return {"status": "ok", "filename": base, "size": bytes_read,
"format": suffix.lstrip(".")}
@router.delete("/api/song/{filename:path}")
def delete_song(filename: str):
"""Remove a song from the DLC folder and clear its cache entries.
Works for both formats: ``.sloppak`` files OR directories, and
loose-folder songs (the directory containing the chart). The path is
resolved through ``_resolve_dlc_path`` so URL-encoded ``..`` segments
cannot escape the library root.
"""
dlc = _get_dlc_dir()
if dlc is None:
return JSONResponse({"error": "DLC folder not configured"}, status_code=503)
resolved = _resolve_dlc_path(dlc, filename)
if resolved is None:
return JSONResponse({"error": "forbidden"}, status_code=403)
if not resolved.exists():
return JSONResponse({"error": "File not found"}, status_code=404)
if resolved == dlc.resolve():
return JSONResponse({"error": "Refusing to delete the DLC root"}, status_code=400)
# Only delete actual song entries. Without this, DELETE /api/song/ArtistName
# would recursively wipe a whole artist subfolder — far broader than the
# UI's per-song contract. Sloppak detection wins over loose because a
# sloppak dir can also contain WEM/XML (matches the scanner's precedence).
is_sloppak = sloppak_mod.is_sloppak(resolved)
is_loose = (
resolved.is_dir()
and not is_sloppak
and loosefolder_mod.is_loose_song(resolved)
)
if not (is_sloppak or is_loose):
return JSONResponse(
{"error": "Not a song entry — only sloppaks "
"or loose-folder songs can be deleted"},
status_code=400,
)
# Hold ``_song_io_lock`` across the filesystem removal AND the DB/cache
# eviction. Without it, an upload of the same filename could ``os.replace``
# a new file into place between our removal and DB delete, leaving the
# new generation stranded with no library row; or the reverse, where
# delete runs between an upload's directory check and its replace and
# the upload then resurrects the song we just removed.
with _song_io_lock:
try:
if resolved.is_dir():
shutil.rmtree(resolved)
else:
resolved.unlink()
except OSError as e:
log.error("Failed to delete %s: %s", resolved, e)
return JSONResponse({"error": f"Delete failed: {e}"}, status_code=500)
# Canonicalise the cache key the same way update_song_meta does so we
# hit the row the scanner indexed under.
try:
cache_key = resolved.relative_to(dlc.resolve()).as_posix()
except ValueError:
cache_key = filename
with appstate.meta_db._lock:
appstate.meta_db.conn.execute("DELETE FROM songs WHERE filename = ?", (cache_key,))
appstate.meta_db.conn.execute("DELETE FROM favorites WHERE filename = ?", (cache_key,))
appstate.meta_db.conn.execute("DELETE FROM loops WHERE filename = ?", (cache_key,))
# Purge the v3 filename-keyed state too, so the deleted song stops
# surfacing in stats / recent / continue / playlists immediately.
appstate.meta_db.conn.execute("DELETE FROM song_stats WHERE filename = ?", (cache_key,))
appstate.meta_db.conn.execute("DELETE FROM playlist_songs WHERE filename = ?", (cache_key,))
# Personal difficulty / notes / tags for this song (we hold the
# lock, so purge is lock-free).
appstate.meta_db.purge_song_user_data(cache_key)
# Multi-chart grouping (P5a): drop this chart's split + read-model rows,
# and any preferred-chart pointer that named it (the work re-auto-picks).
# work_key-keyed prefs for OTHER charts survive. Mark the read-model
# dirty so the affected work regroups on the next grouped query.
appstate.meta_db.conn.execute("DELETE FROM chart_group_split WHERE filename = ?", (cache_key,))
appstate.meta_db.conn.execute("DELETE FROM work_display WHERE filename = ?", (cache_key,))
appstate.meta_db.conn.execute("DELETE FROM chart_group_pref WHERE preferred_filename = ?", (cache_key,))
appstate.meta_db._work_display_dirty = True
# Enrichment is never purged on rescan (delete_missing), only here
# on the explicit per-song delete — the never-clobber contract.
appstate.meta_db.conn.execute("DELETE FROM song_enrichment WHERE filename = ?", (cache_key,))
appstate.meta_db.conn.commit()
# User art overrides go with the song (CAA cache files are keyed by
# RELEASE and may be shared with other charts — the LRU owns those).
for _p in appstate.art_override_paths(cache_key):
try:
_p.unlink()
except OSError:
pass
appstate.invalidate_song_caches(cache_key)
log.info("Deleted song %s", cache_key)
# If a scan was mid-flight when we removed the row, it may already have
# listed (and not yet processed) the file and will call ``appstate.meta_db.put()``
# for it after our DB delete — reinserting a ghost row. Coalesce a
# follow-up pass via ``appstate.kick_scan`` so the next scan's ``delete_missing()``
# purges that entry. Cheap no-op when no scan is running.
if appstate.scan_status()["running"]:
appstate.kick_scan()
return {"ok": True, "filename": cache_key}
@router.get("/api/song/{filename:path}/user-meta")
def get_song_user_meta(filename: str):
"""Read {user_difficulty, notes, tags} for one song."""
return appstate.meta_db.get_song_user_meta(appstate.meta_db._canonical_song_filename(filename))
@router.put("/api/song/{filename:path}/user-meta")
def put_song_user_meta(filename: str, data: dict):
"""Partial update. Send any of: `user_difficulty` (int 15, or null/"" to
clear), `notes` (string, or null to clear), `tags` (a full-replace array of
strings). Omitted keys are preserved. Returns the merged meta.
Tag removal is a full-replace `tags` array (send the new set) rather than a
granular DELETE sub-route, because `DELETE /api/song/{filename:path}` already
owns every DELETE under /api/song and would shadow it."""
key = appstate.meta_db._canonical_song_filename(filename)
kwargs: dict = {}
if "user_difficulty" in data:
v = data["user_difficulty"]
if v is None or v == "":
kwargs["user_difficulty"] = None
else:
# Reject bools (int subclass) and non-integral floats so 2.5 / true
# can't silently truncate into a valid band.
if isinstance(v, bool) or (isinstance(v, float) and not v.is_integer()):
return JSONResponse({"error": "user_difficulty must be an integer 15 or null"}, 400)
try:
iv = int(v)
except (TypeError, ValueError):
return JSONResponse({"error": "user_difficulty must be an integer 15 or null"}, 400)
if not (1 <= iv <= 5):
return JSONResponse({"error": "user_difficulty must be 15 or null"}, 400)
kwargs["user_difficulty"] = iv
if "notes" in data:
n = data["notes"]
if n is None:
kwargs["notes"] = None
elif isinstance(n, str):
kwargs["notes"] = n.strip()[:4000]
else:
return JSONResponse({"error": "notes must be a string or null"}, 400)
tags = data.get("tags", "__absent__")
if tags != "__absent__" and not isinstance(tags, list):
return JSONResponse({"error": "tags must be an array of strings"}, 400)
if not kwargs and tags == "__absent__":
return JSONResponse({"error": "No fields to update"}, 400)
if kwargs:
appstate.meta_db.set_song_user_meta(key, **kwargs)
if tags != "__absent__":
appstate.meta_db.set_song_tags(key, tags)
return appstate.meta_db.get_song_user_meta(key)
# Catalog fields the Fix-metadata popup may override/lock — the intersection of
# "displayable identity" and "safe to correct locally". Guitar/practice facts
# and personal fields are never overrides.
_OVERRIDE_FIELDS = frozenset({"title", "artist", "album", "year", "genre"})
@router.get("/api/song/{filename:path}/overrides")
def get_song_overrides(filename: str):
"""Per-field metadata overrides + locks for one song (Fix-metadata popup):
{"overrides": {field: {"value": str|null, "locked": bool}},
"pack": {field: str}}. `pack` is the stored value each override sits on top
of — the popup's Details tab renders it as the revert-to-pack reference and
the Yours/Pack provenance."""
key = appstate.meta_db._canonical_song_filename(filename)
return {"overrides": appstate.meta_db.get_song_overrides(key),
"pack": appstate.meta_db.pack_fields(key)}
@router.put("/api/song/{filename:path}/overrides")
def put_song_overrides(filename: str, data: dict):
"""Set/clear per-field overrides + locks. Body:
`{"overrides": {field: {"value": str|null, "locked": bool}}}`. Only catalog
fields (title/artist/album/year/genre) are accepted. A field left with no
value and unlocked is removed. Returns the merged override map.
Clearing rides this PUT (send value:null, locked:false) rather than a DELETE
sub-route, because `DELETE /api/song/{filename:path}` already owns every
DELETE under /api/song and would shadow it (same reason as tags)."""
ov = (data or {}).get("overrides")
if not isinstance(ov, dict) or not ov:
return JSONResponse({"error": "overrides must be a non-empty object"}, 400)
bad = sorted(f for f in ov if f not in _OVERRIDE_FIELDS)
if bad:
return JSONResponse({"error": "unknown field(s): " + ", ".join(bad)}, 400)
key = appstate.meta_db._canonical_song_filename(filename)
for field, spec in ov.items():
if not isinstance(spec, dict):
return JSONResponse({"error": f"'{field}' must be an object with value/locked"}, 400)
kwargs: dict = {}
if "value" in spec:
v = spec["value"]
if v is None:
kwargs["value"] = None
elif isinstance(v, (str, int, float)) and not isinstance(v, bool):
kwargs["value"] = str(v).strip()[:500]
else:
return JSONResponse({"error": f"'{field}' value must be a string or null"}, 400)
if "locked" in spec:
kwargs["locked"] = bool(spec["locked"])
if kwargs:
appstate.meta_db.set_song_override(key, field, **kwargs)
return {"overrides": appstate.meta_db.get_song_overrides(key)}
@router.post("/api/songs/user-meta/batch")
def batch_song_user_meta(data: dict):
"""Bulk personal-meta edit over a selection — one request instead of N×2
per-song round-trips (the batch bar's apply-to-all). DB-only; never touches
files. Body:
{"filenames": [...], # required, non-empty
"set_difficulty": 1-5 | null, # optional: set on all / clear on all
"add_tags": [...], # optional: add to all (never full-replace)
"remove_tags": [...]} # optional: remove from all
Omit `set_difficulty` entirely to leave each song's difficulty as-is
(mixed-state "leave unchanged"). Returns {"updated": N, "tags": [...]} so the
caller can refresh the tag-filter list without a second call."""
fns = data.get("filenames")
if not isinstance(fns, list) or not fns:
return JSONResponse({"error": "filenames must be a non-empty array"}, 400)
if not all(isinstance(f, str) and f for f in fns):
return JSONResponse({"error": "filenames must be non-empty strings"}, 400)
kwargs: dict = {}
if "set_difficulty" in data:
v = data["set_difficulty"]
if v is None or v == "":
kwargs["set_difficulty"] = None
else:
if isinstance(v, bool) or (isinstance(v, float) and not v.is_integer()):
return JSONResponse({"error": "set_difficulty must be an integer 15 or null"}, 400)
try:
iv = int(v)
except (TypeError, ValueError):
return JSONResponse({"error": "set_difficulty must be an integer 15 or null"}, 400)
if not (1 <= iv <= 5):
return JSONResponse({"error": "set_difficulty must be 15 or null"}, 400)
kwargs["set_difficulty"] = iv
add_tags = data.get("add_tags")
remove_tags = data.get("remove_tags")
for name, val in (("add_tags", add_tags), ("remove_tags", remove_tags)):
if val is not None and not isinstance(val, list):
return JSONResponse({"error": f"{name} must be an array of strings"}, 400)
if "set_difficulty" not in data and not add_tags and not remove_tags:
return JSONResponse({"error": "Nothing to apply"}, 400)
keys = [appstate.meta_db._canonical_song_filename(f) for f in fns]
n = appstate.meta_db.batch_user_meta(keys, add_tags=add_tags, remove_tags=remove_tags, **kwargs)
return {"updated": n, "tags": appstate.meta_db.all_tags()}
@router.post("/api/song/{filename:path}/meta")
def update_song_meta(filename: str, data: dict):
"""Update song metadata, persisting it back into the underlying file.
The library scanner re-derives title/artist/album/year from the file
(archive manifest Attributes / sloppak manifest.yaml) on every full rescan,
so a DB-only edit reverts. We write the edit into the file first, then
refresh the cache row (including mtime/size) to match. Loose-folder and
unwritable songs fall back to a DB-only update (which still survives an
incremental rescan via the mtime/size cache hit).
"""
# Canonicalise to the same key get_song_info uses so an update via
# one URL form (e.g. with `..` segments) lands on the row that
# later reads will see.
dlc = _get_dlc_dir()
cache_key = filename
resolved = None
if dlc:
resolved = _resolve_dlc_path(dlc, filename)
if resolved is None:
return JSONResponse({"error": "forbidden"}, 403)
try:
cache_key = resolved.relative_to(dlc.resolve()).as_posix()
except ValueError:
pass
fields = {k: data[k] for k in ("title", "artist", "album", "year") if k in data}
if not fields:
return {"error": "No fields to update"}
# Normalise the year value so the DB and file stay in sync. The file
# writer (songmeta) coerces empty/non-numeric years to 0, which the
# scanner reads back as "". Store "" in the DB instead of a raw
# non-numeric string so that if the mtime/size are updated (making the
# row cache-fresh) the DB still matches what the scanner would derive.
if "year" in fields:
try:
_yr_int = int(fields["year"])
except (TypeError, ValueError):
_yr_int = 0
fields = {**fields, "year": str(_yr_int) if _yr_int else ""}
# Persist into the file so the edit survives a full rescan.
# Hold _song_io_lock across the existence check and file write so a
# concurrent delete cannot remove the file between our check and the
# repack's atomic replace, and so a concurrent upload cannot be clobbered
# by our atomic rename. archive repack is slow — the lock is held longer
# than a simple upload/delete, but correctness requires serialisation.
persisted = False
with _song_io_lock:
if resolved is not None and resolved.exists():
try:
import songmeta
persisted = songmeta.write_song_metadata(resolved, fields)
except Exception:
log.warning("metadata file write failed for %s", cache_key, exc_info=True)
with appstate.meta_db._lock:
updates = [f"{field} = ?" for field in fields]
params = list(fields.values())
if persisted:
# The file changed — re-stat so an incremental rescan sees a
# consistent cache row instead of re-reading the (now matching)
# file.
try:
mtime, size = appstate.stat_for_cache(resolved)
updates += ["mtime = ?", "size = ?"]
params += [mtime, size]
except OSError:
pass
params.append(cache_key)
appstate.meta_db.conn.execute(
f"UPDATE songs SET {', '.join(updates)} WHERE filename = ?", params
)
appstate.meta_db.conn.commit()
if persisted:
appstate.invalidate_song_caches(cache_key)
# Coalesce a follow-up scan so a mid-flight scan's stale appstate.meta_db.put()
# for this file can't win: if a scan is running appstate.kick_scan() queues a
# pending pass; if not it starts a fresh one. Unconditional to avoid a
# race where the scan finishes between our DB commit and a guarded check.
appstate.kick_scan()
return {"ok": True, "persisted": persisted}
# ── Gap-fill: write CONFIRMED missing metadata into the pack (R4a) ────────────
# The agreed write-back contract (spec-alignment §7): opt-in + user-initiated
# (nothing here runs in the background), adds ABSENT keys only (never replaces
# an author-set value — the writer refuses, and existing manifest bytes are
# preserved verbatim by appending), spec'd-keys allowlist, values only from a
# CONFIRMED identity (an auto/exact match or a user pin — review-tier rows are
# not eligible until a human confirms), atomic write + .bak. Single-song only;
# batch write-back stays an open question with the spec chair.
_GAP_FILL_KEYS = ("album", "year", "genres", "mbid", "isrc")
def _gap_fill_manifest_absent(manifest: dict, key: str) -> bool:
"""A key is a GAP only when it's genuinely MISSING from the manifest.
Gap-fill is append-only: the writer's never-clobber guard raises on ANY
key already present, and appending a second `album:` line to a manifest
that already carries `album: ''` would just create a duplicate YAML key.
So a present-but-empty value (None / '' / [] / year 0) is NOT a gap the
append-only writer can fill — offering it in the preview would only lead
to a POST the writer refuses. Present-but-empty keys are therefore left
to the metadata editor (which re-serializes and can replace in place)."""
return key not in manifest
def _gap_fill_proposals(cache_key: str, resolved) -> tuple[dict, str]:
"""What gap-fill could add for this song: (proposals, reason). Empty
proposals explain themselves via reason — 'not-sloppak', 'no-match'
(nothing confirmed yet), 'review' (a human hasn't confirmed the match),
or 'nothing-missing'."""
if resolved is None or not resolved.exists() or not sloppak_mod.is_sloppak(resolved):
return {}, "not-sloppak"
row = appstate.meta_db.get_enrichment(cache_key)
if not row or row.get("match_state") not in ("matched", "manual"):
state = (row or {}).get("match_state")
return {}, ("review" if state == "review" else "no-match")
try:
manifest = sloppak_mod.load_manifest(resolved) or {}
except Exception:
return {}, "not-sloppak"
# A LOCKED field (Fix-metadata popup) is never gap-filled — the user pinned
# it away from the matched value, so writing that value to the file would
# be exactly the clobber the lock exists to prevent. (The lock field name is
# `genre`; the manifest/gap-fill key is `genres`.)
locked = appstate.meta_db.locked_fields(cache_key)
out = {}
album = (row.get("canon_album") or "").strip()
if album and "album" not in locked and _gap_fill_manifest_absent(manifest, "album"):
out["album"] = album
year = (row.get("canon_year") or "").strip()
if (year.isdigit() and int(year) and "year" not in locked
and _gap_fill_manifest_absent(manifest, "year")):
out["year"] = int(year)
genres = [str(g) for g in (row.get("genres") or []) if isinstance(g, str) and g.strip()]
if genres and "genre" not in locked and _gap_fill_manifest_absent(manifest, "genres"):
out["genres"] = genres
# Identity keys (feedpak spec 1.14.0) — written in canonical form only.
mbid = (row.get("mb_recording_id") or "").strip().lower()
if enrichment._MBID_RE.match(mbid) and _gap_fill_manifest_absent(manifest, "mbid"):
out["mbid"] = mbid
isrc = (row.get("isrc") or "").strip().upper().replace("-", "").replace(" ", "")
if enrichment._ISRC_RE.match(isrc) and _gap_fill_manifest_absent(manifest, "isrc"):
out["isrc"] = isrc
return out, ("" if out else "nothing-missing")
@router.get("/api/song/{filename:path}/gap-fill")
def get_song_gap_fill(filename: str):
"""Preview what "Write missing info to file" would add — the Details
drawer renders its confirm list straight from this. Read-only."""
dlc = _get_dlc_dir()
cache_key, resolved = filename, None
if dlc:
resolved = _resolve_dlc_path(dlc, filename)
if resolved is None:
return JSONResponse({"error": "forbidden"}, 403)
try:
cache_key = resolved.relative_to(dlc.resolve()).as_posix()
except ValueError:
pass
proposals, reason = _gap_fill_proposals(cache_key, resolved)
row = appstate.meta_db.get_enrichment(cache_key) or {}
return {
"eligible": bool(proposals),
"reason": reason,
"match_state": row.get("match_state"),
"missing": [{"key": k, "value": v} for k, v in proposals.items()],
}
@router.post("/api/song/{filename:path}/gap-fill")
def post_song_gap_fill(filename: str, data: dict):
"""Write the user-confirmed subset of the preview into the pack file.
Proposals are recomputed under the io lock, so a key that gained an
author value between preview and confirm is skipped, never replaced."""
keys = (data or {}).get("keys")
if not isinstance(keys, list) or not keys:
return JSONResponse({"error": "keys must be a non-empty list"}, 400)
bad = [k for k in keys if k not in _GAP_FILL_KEYS]
if bad:
return JSONResponse(
{"error": "unknown key(s): " + ", ".join(sorted(set(map(str, bad))))}, 400)
dlc = _get_dlc_dir()
cache_key, resolved = filename, None
if dlc:
resolved = _resolve_dlc_path(dlc, filename)
if resolved is None:
return JSONResponse({"error": "forbidden"}, 403)
try:
cache_key = resolved.relative_to(dlc.resolve()).as_posix()
except ValueError:
pass
with _song_io_lock:
proposals, reason = _gap_fill_proposals(cache_key, resolved)
additions = {k: proposals[k] for k in _GAP_FILL_KEYS if k in keys and k in proposals}
skipped = sorted(set(keys) - set(additions))
if not additions:
return JSONResponse({"error": "nothing to write", "reason": reason,
"skipped": skipped}, 409)
try:
import songmeta
songmeta.gap_fill_sloppak(resolved, additions)
except Exception:
log.warning("gap-fill write failed for %s", cache_key, exc_info=True)
return JSONResponse({"error": "write failed"}, 500)
# Keep the cache row consistent with what the scanner would now derive
# (same contract as the metadata editor above): sync the columns the
# scan reads from the keys we appended, then re-stat so the row stays
# cache-fresh.
fields = {}
if "album" in additions:
fields["album"] = additions["album"]
if "year" in additions:
fields["year"] = str(additions["year"])
if "genres" in additions:
fields["genre"] = additions["genres"][0]
with appstate.meta_db._lock:
updates = [f"{field} = ?" for field in fields]
params = list(fields.values())
try:
mtime, size = appstate.stat_for_cache(resolved)
updates += ["mtime = ?", "size = ?"]
params += [mtime, size]
except OSError:
pass
if updates:
params.append(cache_key)
appstate.meta_db.conn.execute(
f"UPDATE songs SET {', '.join(updates)} WHERE filename = ?", params)
appstate.meta_db.conn.commit()
appstate.invalidate_song_caches(cache_key)
appstate.kick_scan()
return {"ok": True, "written": additions, "skipped": skipped}
@router.get("/api/song/{filename:path}")
async def get_song_info(filename: str):
"""Return song metadata, from cache or by extracting it from the song source."""
import asyncio
dlc = _get_dlc_dir()
if not dlc:
return JSONResponse({"error": "DLC folder not configured"}, 404)
song_path = _resolve_dlc_path(dlc, filename)
if song_path is None:
return JSONResponse({"error": "forbidden"}, 403)
if not song_path.exists():
return JSONResponse({"error": "File not found"}, 404)
# Canonicalise the cache key against the resolved path so two URL
# forms of the same physical file (e.g. `Artist/song.sloppak` vs
# `Artist/../Artist/song.sloppak`) converge on a single row instead
# of fragmenting / shadowing each other in appstate.meta_db.
try:
cache_key = song_path.relative_to(dlc.resolve()).as_posix()
except ValueError:
cache_key = filename
mtime, size = appstate.stat_for_cache(song_path)
cached = appstate.meta_db.get(cache_key, mtime, size)
if cached:
return cached
# Extract in thread pool
def _extract():
meta = _extract_meta_for_file(song_path, dlc)
appstate.meta_db.put(cache_key, mtime, size, meta)
return meta
meta = await asyncio.get_event_loop().run_in_executor(None, _extract)
return meta
+46
View File
@@ -0,0 +1,46 @@
"""The merged tuning catalog (/api/tunings).
Extracted verbatim from server.py (R3) except @app->@router, CONFIG_DIR->
appstate.config_dir, _load_config imported from lib/appconfig, and the tuning
registry read through the appstate seam (appstate.tuning_providers — the same
instance plugins register into via the plugin_context in server.py).
"""
from fastapi import APIRouter
import appstate
from appconfig import _load_config
from tunings import DEFAULT_REFERENCE_PITCH, TUNING_PRESET_MIDIS, freqs_to_midis
router = APIRouter()
@router.get("/api/tunings")
def get_tunings():
cfg = _load_config(appstate.config_dir / "config.json") or {}
ref = cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH)
try:
ref = float(ref)
if not (430.0 <= ref <= 450.0):
ref = DEFAULT_REFERENCE_PITCH
except (TypeError, ValueError):
ref = DEFAULT_REFERENCE_PITCH
merged = appstate.tuning_providers.get_merged(ref)
# tuningMidis: the same catalog as exact integer MIDI notes (low → high).
# Built-ins come straight from TUNING_PRESET_MIDIS (no float round-trip);
# provider-contributed entries are recovered from their frequencies at the
# served reference pitch. Every consumer today (the v3 badges, plugins)
# reconstructs midis client-side via log2 — a rounding footgun at non-440
# references — so serve the integers once, host-side. Additive: the
# existing referencePitch/tunings shape is unchanged.
tuning_midis: dict[str, dict[str, list[int]]] = {}
for key, names in merged.items():
builtin = TUNING_PRESET_MIDIS.get(key, {})
resolved: dict[str, list[int]] = {}
for name, freqs in names.items():
midis = builtin.get(name) or freqs_to_midis(freqs, ref)
if midis:
resolved[name] = list(midis)
if resolved:
tuning_midis[key] = resolved
return {"referencePitch": ref, "tunings": merged, "tuningMidis": tuning_midis}
+81
View File
@@ -0,0 +1,81 @@
"""App version + source/license URLs (/api/version).
Extracted verbatim from ``server.py`` (R3) except the decorator (``@app`` ->
``@router``) and the VERSION-file lookup: ``Path(__file__).parent`` (app root
when this lived at the top level) -> ``Path(__file__).resolve().parents[2]``
(routers -> lib -> app root). VERSION ships at the app root in every packaging
path (Dockerfile COPY, desktop bundle).
"""
import os
from pathlib import Path
from fastapi import APIRouter
router = APIRouter()
def _safe_http_url(raw):
"""Return `raw` stripped + trailing-slash-stripped if it parses as an
http(s) URL with a non-empty host; else None.
Used to validate operator-supplied `APP_SOURCE_URL` / `APP_LICENSE_URL`
env vars before they reach `<a href>` in the UI. A bare prefix check
like `startswith(("http://","https://"))` accepts malformed inputs
such as `"https://"` (no host) or `"https:///foo"` (empty host) that
still produce broken hrefs — and, when used as a base for the default
`license_url`, garbage like `"https:///blob/main/LICENSE"`.
"""
from urllib.parse import urlsplit
if not raw:
return None
s = raw.strip().rstrip("/")
if not s:
return None
try:
parsed = urlsplit(s)
except ValueError:
return None
if parsed.scheme.lower() not in ("http", "https"):
return None
# `netloc` includes any `user:pass@` and `:port` — strings like
# "http://:80/path" have non-empty netloc (":80") but no real
# hostname. Validate `hostname` so only URLs with an actual host
# are accepted.
if not parsed.hostname:
return None
return s
@router.get("/api/version")
def get_version():
env_version = os.environ.get("APP_VERSION", "").strip()
if env_version:
version = env_version
else:
version_file = Path(__file__).resolve().parents[2] / "VERSION" # R3: app root from lib/routers/
version = "unknown"
if version_file.exists():
try:
version = version_file.read_text().strip()
except (OSError, UnicodeDecodeError):
pass
default_source_url = "https://github.com/got-feedback/feedBack"
# APP_SOURCE_URL / APP_LICENSE_URL flow straight into <a href> in the UI,
# so validate with urllib.parse rather than a bare prefix check — a prefix
# check accepts malformed values like "https://" (no host) which produce
# broken hrefs (and a constructed license_url like "https:///blob/main/LICENSE").
# _safe_http_url requires scheme in {http,https} AND a non-empty hostname
# (not just netloc — that would still accept port-only authorities like
# "http://:80/path"); fall back to the safe default otherwise.
source_url = _safe_http_url(os.environ.get("APP_SOURCE_URL")) or default_source_url
# APP_LICENSE_URL: explicit override for the LICENSE link. The default
# constructed value (source_url + "/blob/main/LICENSE") is GitHub-
# specific and assumes the repo's default branch is `main`; non-GitHub
# hosts (GitLab, Gitea, self-hosted) need an explicit value.
license_url = _safe_http_url(os.environ.get("APP_LICENSE_URL")) or (source_url + "/blob/main/LICENSE")
return {
"version": version,
"source_url": source_url,
"license_url": license_url,
}
+153 -5010
View File
File diff suppressed because it is too large Load Diff
+339 -4084
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -519,7 +519,9 @@ window.feedBack.audio = Object.assign(window.feedBack.audio || {}, {
readSongVolume: _readSongVolume,
});
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', _init);
} else {
_init();
+3 -1
View File
@@ -111,7 +111,9 @@
// Announce once after the document parses, so any listener wired during page
// load can sync without special-casing (consumers may also just call get()).
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', announce, { once: true });
} else {
announce();
+1 -1
View File
@@ -282,7 +282,7 @@ function createHighway() {
// it's only "full resolution" when Quality is HD; otherwise it pins at the
// chosen Quality. Read once here; live changes come through
// api.setMinRenderScale(), surfaced as the "Min res" control next to Quality
// in the player controls (static/index.html).
// in the player controls (static/v3/index.html).
hwState._autoScaleMin = (function () {
const v = parseFloat(localStorage.getItem('highwayMinRenderScale'));
return Number.isFinite(v) ? Math.max(_AUTO_SCALE_MIN, Math.min(1, v)) : _AUTO_SCALE_MIN;
-621
View File
@@ -1,621 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FeedBack</title>
<!-- Placeholder favicon — emoji SVG data URI. Swap for a real logo later. See #55. -->
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='14' font-size='14'%3E%F0%9F%8E%B8%3C/text%3E%3C/svg%3E">
<!-- Tailwind utility classes are served from a prebuilt static
stylesheet (regenerated by scripts/build-tailwind.sh). The old
Play CDN (cdn.tailwindcss.com) JIT scanned the DOM ~1.8x/sec
on the main thread, dropping ~26% of frames with the 3D
highway running — see feedBack-desktop#110. Theme extensions
(dark/accent/gold colors, Inter font) live in tailwind.config.js. -->
<link rel="stylesheet" href="/static/tailwind.min.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/vendor/shepherd.css">
<link rel="stylesheet" href="/static/tour-engine.css">
<!-- Diagnostics console capture must wrap console.* before any other
script logs anything; load it as early as possible. See
docs/diagnostics-bundle-spec.md (feedBack#166). -->
<script src="/static/diagnostics.js"></script>
<script src="/static/capabilities.js"></script>
<script src="/static/capabilities/library.js"></script>
<script src="/static/capabilities/tuning.js"></script>
<script src="/static/capabilities/working-tuning.js"></script>
<script src="/static/capabilities/audio-session.js"></script>
<script src="/static/capabilities/audio-effects.js"></script>
<script src="/static/capabilities/playback.js"></script>
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
<script src="/static/capabilities/library-card-actions.js"></script>
<script src="/static/capabilities/visualization.js"></script>
<script src="/static/capabilities/note-detection.js"></script>
<script src="/static/capabilities/midi-input.js"></script>
</head>
<body class="bg-dark-900 text-gray-200 font-display">
<!-- Navigation -->
<nav id="navbar" class="fixed top-0 w-full z-50 transition-all duration-300">
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<div class="flex items-end gap-1.5">
<a href="#" onclick="showScreen('home');return false" class="text-xl font-bold bg-gradient-to-r from-accent-light to-purple-400 bg-clip-text text-transparent">
FeedBack
</a>
<span id="app-version" class="text-xs text-gray-600 mb-0.5"></span>
</div>
<div class="hidden md:flex items-center gap-8">
<a href="#" onclick="showScreen('home');return false" class="text-sm text-gray-400 hover:text-white transition">Library</a>
<a href="#" onclick="showScreen('favorites');return false" class="text-sm text-gray-400 hover:text-white transition">Favorites</a>
<a href="#" onclick="document.getElementById('upload-songs-file').click();return false" class="text-sm text-gray-400 hover:text-white transition">Upload</a>
<span id="nav-plugins" class="contents"></span>
<a href="#" onclick="showScreen('settings');return false" class="text-sm text-gray-400 hover:text-white transition">Settings</a>
</div>
<!-- Mobile menu -->
<button onclick="document.getElementById('mobile-menu').classList.toggle('hidden')" class="md:hidden text-gray-400">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
</div>
<div id="mobile-menu" class="hidden md:hidden bg-dark-800/95 backdrop-blur border-t border-gray-800">
<div class="px-6 py-4 flex flex-col gap-3">
<a href="#" onclick="showScreen('home');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Library</a>
<a href="#" onclick="showScreen('favorites');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Favorites</a>
<a href="#" onclick="document.getElementById('upload-songs-file').click();this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Upload</a>
<span id="mobile-nav-plugins" class="flex flex-col gap-2 border-t border-b border-gray-800 py-2 my-1">
<span class="text-xs text-gray-600 uppercase tracking-wider">Plugins</span>
</span>
<a href="#" onclick="showScreen('settings');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Settings</a>
</div>
</div>
</nav>
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
level so it stays reachable regardless of which screen is active. -->
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
<!-- ══ HOME (Hero + Library) ══════════════════════════════════════════ -->
<div id="home" class="screen active">
<!-- Library -->
<section id="library-section" class="max-w-7xl mx-auto px-6 pt-24 pb-16">
<div id="alpha-warning-banner" class="hidden mb-6 px-4 py-3 bg-amber-900/30 border border-amber-500/30 rounded-xl flex items-start gap-3" role="status">
<span class="text-amber-400 text-lg leading-none mt-0.5" aria-hidden="true"></span>
<div class="text-sm text-amber-100">
<strong class="text-amber-300">Heads up — this is an alpha build.</strong>
Some things may be broken or change without warning. If you hit a bug, please file an issue. Thanks for trying it out!
</div>
</div>
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-10">
<div>
<h2 id="lib-title" class="text-3xl font-bold text-white">Your Library</h2>
<p class="text-gray-500 mt-1" id="lib-count"></p>
</div>
<div class="flex gap-3 w-full md:w-auto flex-wrap">
<select id="lib-provider" onchange="setLibraryProvider(this.value)"
aria-label="Library source"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none" title="Library source">
<option value="local">My Library</option>
</select>
<!-- View toggle -->
<div class="flex bg-dark-700 border border-gray-800 rounded-xl overflow-hidden">
<button id="view-grid-btn" onclick="setLibView('grid')" class="px-3 py-2.5 text-sm transition" title="Grid view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
</button>
<button id="view-tree-btn" onclick="setLibView('tree')" class="px-3 py-2.5 text-sm transition" title="Artist/Album view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>
</button>
<button id="view-folder-btn" onclick="setLibView('folder')" class="px-3 py-2.5 text-sm transition" title="Folder view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg>
</button>
</div>
<!-- Grid controls -->
<select id="lib-sort" onchange="sortLibrary()"
class="lib-nontree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="artist">Artist A-Z</option>
<option value="artist-desc">Artist Z-A</option>
<option value="title">Title A-Z</option>
<option value="title-desc">Title Z-A</option>
<option value="recent">Recently Added</option>
<option value="year-desc">Year (newest)</option>
<option value="year">Year (oldest)</option>
<option value="tuning">Tuning</option>
<option value="difficulty">Difficulty (easiest first)</option>
<option value="difficulty-desc">Difficulty (hardest first)</option>
</select>
<!-- Format filter (shared) -->
<select id="lib-format" onchange="sortLibrary()"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none" title="Filter by format">
<option value="">All formats</option>
<option value="sloppak">Feedpak</option>
<option value="loose">Folder</option>
</select>
<!-- Tree controls -->
<button onclick="toggleAllArtists(true)" class="lib-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Expand All</button>
<button onclick="toggleAllArtists(false)" class="lib-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Collapse All</button>
<!-- Filters drawer toggle (feedBack#129) -->
<button onclick="toggleLibFilters()" id="btn-lib-filters"
class="bg-dark-700 border border-gray-800 hover:border-accent/40 rounded-xl px-4 py-2.5 text-sm text-gray-300 transition flex items-center gap-2"
title="Filter by parts, tuning, lyrics">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h18M6 12h12M10 20h4"/></svg>
<span>Filters</span>
<span id="lib-filters-count" class="hidden bg-accent/30 text-accent-light text-xs font-semibold rounded-full px-1.5 py-0.5 min-w-[1.25rem] text-center">0</span>
</button>
<!-- Shared -->
<input type="text" id="lib-filter" placeholder="Search songs..." oninput="filterLibrary()"
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 focus:ring-1 focus:ring-accent/30 outline-none flex-1 md:w-60 transition">
</div>
</div>
<!-- Active-filter chip row (only visible when filters are set, feedBack#129) -->
<div id="lib-filter-chips" class="hidden flex flex-wrap gap-2 mb-5"></div>
<div id="lib-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
<!-- Cards populated by JS -->
</div>
<div id="lib-tree" class="space-y-2 hidden">
<!-- Tree populated by JS -->
</div>
<div id="lib-folder-tree" class="space-y-1 hidden">
<!-- Folder tree populated by JS when Folders source is active -->
</div>
</section>
<!-- ══ Filters drawer (feedBack#129/#69/#22) ═════════════════════ -->
<div id="lib-filter-overlay" class="fixed inset-0 bg-black/40 z-40 hidden"
onclick="toggleLibFilters(false)"></div>
<aside id="lib-filter-drawer"
class="fixed top-0 right-0 h-full w-full sm:w-96 bg-dark-800 border-l border-gray-800 z-50 transform translate-x-full transition-transform duration-200 overflow-y-auto">
<div class="p-6 space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white">Filters</h3>
<button onclick="toggleLibFilters(false)" class="text-gray-500 hover:text-white" title="Close">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<section>
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Arrangements</div>
<p class="text-xs text-gray-600 mb-3">Click cycles: any → require → exclude</p>
<div id="filter-arrangements" class="flex flex-wrap gap-2"></div>
</section>
<section id="filter-stems-section">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Stems <span class="text-gray-600 normal-case font-normal">(sloppak)</span></div>
<p class="text-xs text-gray-600 mb-3">Click cycles: any → require → exclude</p>
<div id="filter-stems" class="flex flex-wrap gap-2"></div>
</section>
<section>
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Lyrics</div>
<div id="filter-lyrics" class="flex flex-wrap gap-2"></div>
</section>
<section>
<details>
<summary class="cursor-pointer flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">
<span>Tuning</span>
<span id="filter-tunings-summary" class="text-gray-600 normal-case font-normal text-xs">All tunings</span>
</summary>
<div id="filter-tunings" class="mt-3 space-y-1 max-h-64 overflow-y-auto pr-1"></div>
</details>
</section>
<div class="flex items-center justify-between pt-4 border-t border-gray-800">
<button onclick="clearLibFilters()" class="text-sm text-gray-400 hover:text-white transition">Clear all</button>
<button onclick="toggleLibFilters(false)" class="bg-accent hover:bg-accent-light px-4 py-2 rounded-lg text-sm font-medium text-white transition">Done</button>
</div>
</div>
</aside>
</div>
<!-- ══ FAVORITES ════════════════════════════════════════════════════ -->
<div id="favorites" class="screen">
<section class="max-w-7xl mx-auto px-6 pt-24 pb-16">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-10">
<div>
<h2 class="text-3xl font-bold text-white">Favorites</h2>
<p class="text-gray-500 mt-1" id="fav-count"></p>
</div>
<div class="flex gap-3 w-full md:w-auto flex-wrap">
<div class="flex bg-dark-700 border border-gray-800 rounded-xl overflow-hidden">
<button id="fav-view-grid-btn" onclick="setFavView('grid')" class="px-3 py-2.5 text-sm transition" title="Grid view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
</button>
<button id="fav-view-tree-btn" onclick="setFavView('tree')" class="px-3 py-2.5 text-sm transition" title="Artist/Album view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>
</button>
</div>
<select id="fav-sort" onchange="sortFavorites()"
class="fav-grid-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="artist">Artist A-Z</option>
<option value="artist-desc">Artist Z-A</option>
<option value="title">Title A-Z</option>
<option value="title-desc">Title Z-A</option>
<option value="recent">Recently Added</option>
<option value="tuning">Tuning</option>
</select>
<button onclick="toggleAllFavoriteArtists(true)" class="fav-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Expand All</button>
<button onclick="toggleAllFavoriteArtists(false)" class="fav-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Collapse All</button>
<input type="text" id="fav-filter" placeholder="Search favorites..." oninput="filterFavorites()"
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 focus:ring-1 focus:ring-accent/30 outline-none flex-1 md:w-60 transition">
</div>
</div>
<div id="fav-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
</div>
<div id="fav-tree" class="space-y-2 hidden">
</div>
</section>
</div>
<!-- ══ Plugin screens injected dynamically by loadPlugins() ══════════ -->
<!-- ══ SETTINGS ═══════════════════════════════════════════════════════ -->
<div id="settings" class="screen">
<div class="max-w-2xl mx-auto px-6 pt-24 pb-16">
<button onclick="showScreen('home')" class="text-gray-500 hover:text-white text-sm mb-6 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Back
</button>
<h2 class="text-3xl font-bold text-white mb-8">Settings</h2>
<div class="space-y-10">
<!-- App Updates — Velopack auto-update, desktop only. Stays
hidden in the plain web app; setupAppUpdates() unhides
this block when window.feedBackDesktop.update exists,
and shows a disabled "not available on Linux" fallback
when running on Linux. -->
<div id="app-updates-block" class="hidden border border-gray-800 rounded-xl bg-dark-800/40 p-5">
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">App Updates</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block" for="app-update-channel">Update channel</label>
<select id="app-update-channel"
class="w-full bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="stable">Stable</option>
<option value="rc">Release candidate</option>
<option value="beta">Beta</option>
<option value="alpha">Alpha</option>
</select>
</div>
<div class="flex items-end">
<button id="app-update-check-now"
class="bg-accent hover:bg-accent-light px-4 py-2.5 rounded-xl text-sm font-medium text-white transition disabled:opacity-50">
Check for updates
</button>
</div>
</div>
<p id="app-update-status" class="text-xs text-gray-500 mt-3">Loading updater status…</p>
<p id="app-update-linux-note" class="hidden text-xs text-yellow-300 mt-2">
Auto-update is not available on Linux —
<a href="https://github.com/got-feedback/feedBack-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
</p>
</div>
<!-- ── Core FeedBack settings ─────────────────────────────── -->
<section>
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">FeedBack</h3>
<div class="space-y-6">
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Library Folder Path</label>
<div class="flex gap-3">
<input type="text" id="dlc-path" placeholder="/path/to/your/library"
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
<button onclick="pickDlcFolder()" id="btn-pick-dlc" class="hidden bg-dark-600 hover:bg-dark-500 px-4 py-2.5 rounded-xl text-sm text-gray-300 transition whitespace-nowrap">📂 Browse</button>
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
</div>
</div>
<div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" id="setting-lefty" onchange="highway.setLefty(this.checked)"
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
<span class="text-sm text-gray-300">Left-handed <span class="text-gray-500">(invert frets on the note highway)</span></span>
</label>
</div>
<div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" id="setting-autoplay-exit" checked onchange="setAutoplayExit(this.checked)"
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
<span class="text-sm text-gray-300">Autoplay &amp; auto-exit <span class="text-gray-500">(start songs/lessons automatically and return to the menu when the score screen closes)</span></span>
</label>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Default Arrangement</label>
<select id="default-arrangement"
onchange="persistSetting('default_arrangement', this.value)"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="">Most notes (auto)</option>
<option value="Lead">Lead</option>
<option value="Rhythm">Rhythm</option>
<option value="Bass">Bass</option>
</select>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Arrangement Names</label>
<select id="arrangement-naming-mode"
onchange="_onNamingModeChange(this.value)"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="smart">Smart (Lead, Alt. Lead, Rhythm, Bass…)</option>
<option value="legacy">Legacy (Combo, Bass)</option>
</select>
</div>
<div>
<label for="setting-av-offset" class="text-sm font-medium text-gray-400 mb-2 block">
A/V Sync Offset: <span id="setting-av-offset-val">0</span> ms
</label>
<input type="range" id="setting-av-offset" min="-1000" max="1000" step="1" value="0"
oninput="setAvOffsetMs(this.value)"
class="w-full slider-input">
<p class="text-xs text-gray-600 mt-1">Positive = audio plays ahead of visual notes; raise this value to catch the highway up. Adjust live with the [ and ] keys (Shift for ±50 ms). Auto-saves on every change.</p>
</div>
<div>
<label for="demucs-server-url" class="text-sm font-medium text-gray-400 mb-2 block">Demucs Server (for stem separation)</label>
<div class="flex gap-3">
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
</div>
<p class="text-xs text-gray-600 mt-1">Optional. Run <a href="https://github.com/got-feedBack/feedBack-demucs-server" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">feedBack-demucs-server</a> on a machine with a GPU to offload stem splitting and avoid resource exhaustion on the host running FeedBack.</p>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Library</label>
<div class="flex items-center gap-3">
<button onclick="rescanLibrary()" id="btn-rescan" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Rescan Library</button>
<button onclick="fullRescanLibrary()" id="btn-full-rescan" class="bg-dark-600 hover:bg-red-900/30 px-5 py-2.5 rounded-xl text-sm text-gray-400 transition">Full Rescan</button>
<span id="rescan-status" class="text-xs text-gray-500"></span>
</div>
<p class="text-xs text-gray-600 mt-1">Rescan checks for new songs. Full Rescan clears the cache and re-imports everything.</p>
</div>
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Backup</label>
<div class="flex items-center gap-3">
<button onclick="exportSettings()" id="btn-export-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Settings</button>
<button onclick="document.getElementById('import-settings-file').click()" id="btn-import-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Import Settings</button>
<input type="file" id="import-settings-file" accept="application/json,.json" class="hidden" onchange="importSettings(this.files[0]); this.value=''">
<span id="backup-status" class="text-xs text-gray-500"></span>
</div>
<p class="text-xs text-gray-600 mt-1">Export bundles server config, browser preferences, and opted-in plugin data into one JSON file. Import overwrites current settings and reloads.</p>
</div>
<!-- ── Diagnostics (feedBack#166) ────────────────────── -->
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Diagnostics</label>
<div class="grid grid-cols-2 gap-2 mb-3 text-xs text-gray-400">
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-system" checked class="rounded border-gray-600 bg-dark-700 text-accent"> System info</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-hardware" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Hardware (CPU/GPU/RAM)</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-logs" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Server logs (last 5 MB)</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-console" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Browser console + errors</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-plugins" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Plugin diagnostics</label>
<label class="flex items-center gap-2"><input type="checkbox" id="diag-redact" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Redact paths &amp; song names</label>
</div>
<div class="flex items-center gap-3">
<button onclick="previewDiagnostics()" id="btn-diag-preview" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Preview Bundle</button>
<button onclick="exportDiagnostics()" id="btn-diag-export" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Diagnostics</button>
<span id="diag-status" class="text-xs text-gray-500"></span>
</div>
<p class="text-xs text-gray-600 mt-1">Bundles server logs, hardware info, plugin inventory, and the browser console transcript into one zip for bug reports. Redaction strips DLC paths, song filenames, and IP addresses by default. Attach to GitHub issues; AI agents can parse the included <code>manifest.json</code>.</p>
<div id="diag-preview" class="hidden mt-3 bg-dark-700 border border-gray-800 rounded-xl p-3 text-xs text-gray-400 max-h-96 overflow-auto"></div>
</div>
<div id="settings-status" class="text-sm text-gray-500"></div>
</div>
</section>
<!-- ── Plugin settings ─────────────────────────────────────── -->
<section id="plugin-settings-area" class="hidden">
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">Plugins</h3>
<div class="space-y-6">
<div>
<label class="text-sm font-medium text-gray-400 mb-2 block">Plugin Updates</label>
<div class="flex items-center gap-3 mb-2">
<button onclick="checkPluginUpdates()" id="btn-check-updates" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Check for Updates</button>
<span id="updates-status" class="text-xs text-gray-500"></span>
</div>
<div id="plugin-updates-list" class="space-y-2"></div>
</div>
<!-- Per-plugin collapsible sections injected here -->
<div id="plugin-settings" class="space-y-3"></div>
</div>
</section>
<!-- ── About / Source / License (AGPL §13 disclosure) ──────── -->
<section>
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">About</h3>
<div class="space-y-2 text-sm text-gray-400">
<div>FeedBack <span id="app-version-about" class="text-gray-500"></span></div>
<div>Licensed under <a id="about-license-link" href="https://github.com/got-feedback/feedBack/blob/main/LICENSE" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">GNU AGPL v3.0</a>.</div>
<div><a id="about-source-link" href="https://github.com/got-feedback/feedBack" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">Source code repository</a></div>
<p class="text-xs text-gray-600 mt-2">FeedBack is free software. You can redistribute it and modify it under the terms of the AGPL. If you run a modified version that interacts with users over a network, you must make the modified source available to those users.</p>
</div>
</section>
</div>
</div>
</div>
<!-- Global audio element (outside player so it's always accessible) -->
<audio id="audio" preload="auto"></audio>
<script>
// Web Audio API fallback for iOS WKWebView which can't play WAV via <audio>
(function() {
var _waCtx = null, _waSource = null, _waStartTime = 0, _waBuffer = null, _waPlaying = false, _waLoading = false;
var audioEl = document.getElementById('audio');
window._webAudioFallback = {
load: function(url, cb) {
if (!url || _waLoading) return;
_waLoading = true;
if (!_waCtx) _waCtx = new (window.AudioContext || window.webkitAudioContext)();
console.log('[WebAudio] Loading: ' + url);
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'arraybuffer';
xhr.onload = function() {
_waCtx.decodeAudioData(xhr.response, function(decoded) {
_waBuffer = decoded;
_waLoading = false;
console.log('[WebAudio] Decoded: ' + decoded.duration.toFixed(1) + 's');
if (cb) cb();
}, function(e) {
_waLoading = false;
console.error('[WebAudio] Decode error:', e);
});
};
xhr.onerror = function() { _waLoading = false; };
xhr.send();
},
play: function() {
if (!_waBuffer || !_waCtx) return false;
this.stop();
if (_waCtx.state === 'suspended') _waCtx.resume();
_waSource = _waCtx.createBufferSource();
_waSource.buffer = _waBuffer;
// AudioBufferSourceNode has no preservesPitch equivalent so changing playbackRate here also changes pitch
_waSource.playbackRate.value = audioEl.playbackRate || 1;
_waSource.connect(_waCtx.destination);
_waStartTime = _waCtx.currentTime;
_waSource.start(0);
_waPlaying = true;
console.log('[WebAudio] Playing');
return true;
},
stop: function() {
if (_waSource) { try { _waSource.stop(); } catch(e){} _waSource = null; }
_waPlaying = false;
},
getTime: function() {
if (!_waPlaying || !_waCtx) return 0;
return _waCtx.currentTime - _waStartTime;
},
isActive: function() { return _waPlaying; },
isReady: function() { return !!_waBuffer; },
getDuration: function() { return _waBuffer ? _waBuffer.duration : 0; }
};
})();
</script>
<!-- ══ PLAYER ═════════════════════════════════════════════════════════ -->
<div id="player" class="screen">
<canvas id="highway"></canvas>
<div id="player-hud" class="absolute top-0 left-0 right-0 flex justify-between px-4 py-3 pointer-events-none z-10">
<div class="text-sm">
<span id="hud-artist" class="text-gray-300"></span><span id="hud-title" class="text-white font-semibold"></span>
<br><span id="hud-arrangement" class="text-gray-500 text-xs"></span>
<br><span id="hud-tuning" class="text-gray-500 text-xs"></span>
<br><span id="hud-tuning-targets" class="text-gray-500 text-xs"></span>
</div>
<div class="text-right">
<div id="hud-time" class="text-sm text-gray-400"></div>
<div id="hud-avoffset" class="text-xs text-gray-500 tabular-nums hidden" title="A/V offset — [ and ] to adjust, Shift for ±50 ms">A/V 0 ms</div>
</div>
</div>
<!-- #section-practice-bar lives OUTSIDE #player-controls on purpose: its
nested chip <button>s would otherwise be matched by plugins' legacy
`#player-controls`-scoped `button:last-child` injector anchor, making
insertBefore throw (the node isn't a direct child) and aborting the
shared playSong wrapper chain. The #player-footer wrapper keeps it
visually directly above the transport row; margin-top:auto moves here
from #player-controls so the whole footer still pins to the bottom. -->
<div id="player-footer">
<!-- Section Practice is collapsed behind a single pill; the multi-row bar
is a popover opened from the pill (toggleSectionPracticePopover). The
pill + popover live in #player-footer (NOT #player-controls) so the
popover's nested chip <button>s can't be matched by a plugin's
`#player-controls > button:last-child` injector anchor. -->
<div id="section-practice-control" class="section-practice-control section-practice-control--hidden">
<button type="button" id="section-practice-pill" class="section-practice-pill"
aria-haspopup="dialog" aria-expanded="false" aria-controls="section-practice-bar"
aria-label="Section practice"
onclick="toggleSectionPracticePopover()" title="Section practice">
<span class="section-practice-pill-icon" aria-hidden="true">🎯</span>
<span class="section-practice-pill-text">Practice</span>
<span class="section-practice-pill-caret" aria-hidden="true"></span>
</button>
<div id="section-practice-bar" class="section-practice-bar" role="dialog" aria-label="Section practice">
<div class="section-practice-row">
<label class="section-practice-mode-wrap" title="Loop the selected section until turned off">
<input type="checkbox" id="section-practice-mode" onchange="onSectionPracticeModeChange()">
<span class="section-practice-mode-text">Practice Section</span>
</label>
<span class="section-practice-label">Sections:</span>
<div id="section-practice-scroll" class="section-practice-scroll" role="toolbar" aria-label="Section selection"></div>
</div>
</div>
</div>
<div id="player-controls" class="flex items-center gap-2 px-4 py-2.5 bg-dark-800 border-t border-gray-800/50 flex-wrap">
<button onclick="seekBy(-5)" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Seek Back 5s" aria-label="Seek Back 5s"><img src="/static/svg/rw.svg" class="button-icon-svg" alt="" aria-hidden="true" /> 5s</button>
<button type="button" onclick="restartCurrentSong()" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Restart song" aria-label="Restart song"></button>
<button onclick="togglePlay()" id="btn-play" class="px-4 py-1.5 bg-accent hover:bg-accent-light rounded-lg text-xs font-semibold text-white transition" aria-label="Play" title="Play" aria-pressed="false"><img src="/static/svg/play.svg" class="button-icon-svg" alt="" aria-hidden="true" /></button>
<button onclick="seekBy(5)" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Seek Forward 5s" aria-label="Seek Forward 5s">5s <img src="/static/svg/ff.svg" class="button-icon-svg" alt="" aria-hidden="true" /></button>
<select id="arr-select" onchange="changeArrangement(this.value)" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none max-w-[130px]"></select>
<button id="arr-default-pin" type="button" onclick="pinCurrentArrangementDefault()" aria-pressed="false" aria-label="Select an arrangement to make it the default" class="w-8 h-8 inline-flex items-center justify-center bg-dark-600 border border-gray-700 hover:bg-dark-500 rounded-lg text-xs text-gray-400 transition" title="Select an arrangement to make it the default"></button>
<input type="range" id="speed-slider" min="15" max="150" value="100" step="5" oninput="setSpeed(this.value/100)" class="w-20 accent-accent slider-input">
<span id="speed-label" class="text-xs text-gray-500 w-10">1.0x</span>
<span id="mastery-slider-label" class="text-xs text-gray-500 ml-1">Difficulty</span>
<input type="range" id="mastery-slider" min="0" max="100" value="100" step="5" oninput="setMastery(this.value)" class="w-20 accent-accent slider-input" title="Master difficulty — low = simpler chart, high = full" aria-labelledby="mastery-slider-label">
<span id="mastery-label" class="text-xs text-gray-500 w-10">100%</span>
<span id="player-av-offset-slider-label" class="text-xs text-gray-500 ml-1">A/V sync offset (ms)</span>
<input type="range" id="player-av-offset-slider" min="-1000" max="1000" value="0" step="1" oninput="setAvOffsetMs(this.value)" class="w-20 accent-accent slider-input" title="A/V sync offset (ms) — positive = audio plays ahead of visuals. [ and ] adjust ±10 ms (Shift = ±50). Double-click to reset." ondblclick="setAvOffsetMs(0)" aria-labelledby="player-av-offset-slider-label">
<span id="player-av-offset-label" class="text-xs text-gray-500 w-12 tabular-nums">+0ms</span>
<div id="mixer-control">
<div id="mixer-anchor" class="relative">
<button id="btn-mixer" type="button" onclick="window.feedBack.audio.toggleMixer()" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" aria-haspopup="true" aria-expanded="false" aria-controls="mixer-popover" title="Audio mixer">Mixer ▾</button>
<div id="mixer-popover" class="hidden absolute right-0 bottom-full mb-2 z-50 bg-dark-700 border border-gray-800 rounded-xl shadow-xl" role="group" aria-label="Audio mixer"></div>
</div>
</div>
<button onclick="highway.toggleLyrics()" id="btn-lyrics" class="px-3 py-1.5 bg-purple-900/40 hover:bg-purple-900/60 rounded-lg text-xs text-purple-300 transition">Lyrics ✓</button>
<select id="quality-select" onchange="highway.setRenderScale(parseFloat(this.value))" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none">
<option value="1">HD</option>
<option value="0.75">Medium</option>
<option value="0.5">Low</option>
</select>
<select id="min-scale-select" aria-label="Minimum auto resolution" onchange="highway.setMinRenderScale && highway.setMinRenderScale(parseFloat(this.value))" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none" title="Minimum auto resolution — how far the highway may lower its resolution to hold the frame rate on heavy scenes. 'Full' disables auto-downscaling, but the Quality selector still caps the maximum (so it's only full resolution at Quality = HD).">
<option value="0.25">Min res: 25%</option>
<option value="0.5">Min res: 50%</option>
<option value="0.75">Min res: 75%</option>
<option value="1">Min res: Full</option>
</select>
<span id="viz-picker-label" class="text-xs text-gray-500 ml-1 sr-only">Visualization</span>
<select id="viz-picker" onchange="setViz(this.value)" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none" aria-labelledby="viz-picker-label" title="Visualization">
<option value="auto">Auto (match arrangement)</option>
<option value="default">Classic 2D Highway</option>
<!-- Additional entries populated on load from /api/plugins (feedBack#36).
The bundled 3D Highway plugin (plugins/highway_3d/) registers as
`highway_3d` and is the default selection on fresh installs — see
_populateVizPicker() in app.js. -->
</select>
<span class="text-gray-700 mx-1">|</span>
<button onclick="setLoopStart()" id="btn-loop-a" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Set loop start at current time">A</button>
<button onclick="setLoopEnd()" id="btn-loop-b" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Set loop end at current time">B</button>
<button onclick="saveCurrentLoop()" id="btn-loop-save" class="px-3 py-1.5 bg-dark-600 hover:bg-green-900/50 rounded-lg text-xs text-gray-300 transition hidden" title="Save this loop">Save</button>
<button onclick="clearLoop()" id="btn-loop-clear" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-500 transition hidden" title="Clear loop"></button>
<span id="loop-label" class="text-xs text-gray-600"></span>
<select id="saved-loops" onchange="loadSavedLoop(this.value)" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none max-w-[160px] hidden">
<option value="">Saved Loops</option>
</select>
<button onclick="deleteSelectedLoop()" id="btn-loop-delete" class="px-2 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-500 hover:text-red-400 transition hidden" title="Delete selected loop"></button>
<!-- Editor ⇄ 3D Highway round-trip. "Edit region" opens the Song Editor
scrolled to the active loop (or the section at the playhead).
"↩ Editor" returns to the editing position you came from; it only
appears after a Loop-in-3D handoff. Both are hidden when the editor
plugin isn't loaded (state managed by _updateEditRegionBtn). -->
<button onclick="editRegionInEditor()" id="btn-edit-region" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Edit this region in the Song Editor">✎ Edit region</button>
<button onclick="returnToEditorFromHighway()" id="btn-return-editor" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Return to the editor where you left off">↩ Editor</button>
<button onclick="showScreen('home')" class="ml-auto px-3 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-400 hover:text-red-400 transition">✕ Close</button>
</div>
</div>
</div>
<script src="/static/highway.js"></script>
<script src="/static/vendor/lottie.min.js"></script>
<script src="/static/lottie-api.js"></script>
<script src="/static/app.js"></script>
<script src="/static/audio-mixer.js"></script>
<script src="/static/vendor/shepherd.min.js"></script>
<script src="/static/tour-engine.js"></script>
<script>
// Navbar scroll effect
window.addEventListener('scroll', () => {
const nav = document.getElementById('navbar');
if (window.scrollY > 50) {
nav.classList.add('bg-dark-900/80', 'backdrop-blur-lg', 'border-b', 'border-gray-800/50');
} else {
nav.classList.remove('bg-dark-900/80', 'backdrop-blur-lg', 'border-b', 'border-gray-800/50');
}
});
</script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
// The one <audio> element the whole app plays through.
//
// This exists so that code carved out of app.js can reach the player without
// importing app.js back — which would close a cycle and fail the import-x/no-cycle
// gate. It is the same handle app.js has always held (`document.getElementById`
// on the element in the shell), just given a home of its own.
//
// It is deliberately a `const`, and it is never reassigned anywhere in core — so a
// read-only import binding is exactly right, and no state container is needed.
// (Contrast the reassigned scalars — isPlaying, _avOffsetMs, … — which cannot be
// shared this way, because an imported binding cannot be written to.)
//
// Module scripts evaluate after the HTML is parsed, so the element is already in
// the document by the time this runs. app.js is loaded as <script type="module">,
// and its imports evaluate before its body — the same point at which app.js used
// to run this exact lookup itself.
export const audio = document.getElementById('audio');
+280
View File
@@ -0,0 +1,280 @@
// The diagnostics-bundle export — the Settings "Export diagnostics" flow.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
// It snapshots the browser-only state (console ring buffer, hardware probe,
// localStorage, ua) via window.feedBack.diagnostics, POSTs it to
// /api/diagnostics/export with the user's include/redact toggles, and streams the
// returned zip to disk. Bundle layout + schemas: docs/diagnostics-bundle-spec.md.
//
// Everything except the two entry points is module-private — the preview
// renderer, the file-label table, and the byte/HTML formatters are used nowhere
// else in core.
//
// Companion to Settings export but for troubleshooting bug reports.
// Bundle layout + schemas: docs/diagnostics-bundle-spec.md.
//
// Frontend's job is to:
// 1. Snapshot the browser-only state (console ring buffer, hardware
// probe, localStorage, ua) via window.feedBack.diagnostics.
// 2. POST it to /api/diagnostics/export with the user's include /
// redact toggles.
// 3. Stream the returned zip to disk.
function _diagIncludeFromUI() {
const v = (id) => document.getElementById(id)?.checked !== false;
return {
system: v('diag-incl-system'),
hardware: v('diag-incl-hardware'),
logs: v('diag-incl-logs'),
console: v('diag-incl-console'),
plugins: v('diag-incl-plugins'),
};
}
function _diagRedactFromUI() {
const el = document.getElementById('diag-redact');
return el ? !!el.checked : true;
}
// Map raw file paths inside the bundle to plain-English labels +
// descriptions for the preview UI. Only paths that show up in
// previews need entries — unknown paths fall back to the path itself.
const _DIAG_FILE_LABELS = {
'system/version.json': { label: 'App version', desc: 'FeedBack version, Python, OS' },
'system/env.json': { label: 'Environment', desc: 'Allowlisted env vars (LOG_LEVEL, etc.). No secrets.' },
'system/hardware.json': { label: 'Hardware (server-side)', desc: 'CPU, RAM, GPU. In Docker this reflects the container, not the host.' },
'system/plugins.json': { label: 'Plugins', desc: 'Loaded plugins + git commit + orphan detection.' },
'logs/server.log': { label: 'Server log', desc: 'Tail of LOG_FILE (last ~5 MB).' },
'logs/server.log.meta.json': { label: 'Log metadata', desc: 'Log file path, size, rotation info.' },
'client/console.json': { label: 'Browser console', desc: 'console.log/warn/error transcript + window errors.' },
'client/hardware.json': { label: 'Hardware (browser)', desc: 'WebGL/WebGPU adapter, host OS via userAgent.' },
'client/local_storage.json': { label: 'Browser storage', desc: 'localStorage contents (preferences).' },
'client/ua.json': { label: 'User agent', desc: 'Browser, screen, page URL.' },
};
function _formatBytes(n) {
if (!n || n < 1024) return (n || 0) + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / (1024 * 1024)).toFixed(1) + ' MB';
}
function _escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
function _renderDiagPreview(data) {
const m = data.manifest || {};
const files = m.files || [];
const groups = { system: [], logs: [], client: [], plugins: [], other: [] };
for (const f of files) {
const top = (f.path || '').split('/')[0];
(groups[top] || groups.other).push(f);
}
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
const include = _diagIncludeFromUI();
const redact = _diagRedactFromUI();
const sections = [];
// Per-file `summary` (server-derived) → human one-liner.
function _summaryLine(path, summary) {
if (!summary || typeof summary !== 'object') return '';
if (path === 'system/plugins.json') {
const loaded = summary.loaded_count || 0;
const orphans = summary.orphan_count || 0;
const orphPart = orphans ? ` · <span class="text-amber-400">${orphans} orphan${orphans === 1 ? '' : 's'}</span>` : '';
return `${loaded} plugin${loaded === 1 ? '' : 's'} loaded${orphPart}`;
}
if (path === 'client/console.json') {
const total = summary.entry_count || 0;
const lvl = summary.by_level || {};
const parts = [];
for (const k of ['error','warn','info','log','debug']) {
if (lvl[k]) parts.push(`${lvl[k]} ${k}`);
}
return `${total} entries${parts.length ? ' (' + parts.join(', ') + ')' : ''}`;
}
if (path === 'system/hardware.json') {
const bits = [];
if (summary.cpu_brand) bits.push(summary.cpu_brand);
if (summary.cores_logical) bits.push(`${summary.cores_logical} cores`);
if (summary.gpu_count) bits.push(`${summary.gpu_count} GPU`);
if (summary.runtime) bits.push(`runtime: ${summary.runtime}`);
return bits.join(' · ');
}
if (path === 'client/hardware.json') {
const bits = [];
if (summary.runtime) bits.push(summary.runtime);
if (summary.webgl_renderer) bits.push(summary.webgl_renderer);
return bits.join(' · ');
}
if (path === 'client/local_storage.json') {
return `${summary.key_count || 0} keys`;
}
if (path === 'system/version.json') {
const bits = [];
if (summary.feedBack) bits.push(`feedBack ${summary.feedBack}`);
if (summary.python) bits.push(`python ${summary.python}`);
if (summary.os) bits.push(summary.os);
return bits.join(' · ');
}
return '';
}
function pushSection(title, list, emptyHint) {
if (!list.length) {
if (emptyHint) {
sections.push(`<div class="mb-3"><div class="text-gray-300 font-semibold mb-1">${_escapeHtml(title)}</div><div class="text-gray-500">${_escapeHtml(emptyHint)}</div></div>`);
}
return;
}
const rows = list.map(f => {
const meta = _DIAG_FILE_LABELS[f.path] || { label: f.path, desc: '' };
const summary = _summaryLine(f.path, f.summary);
const summaryHtml = summary
? `<div class="text-accent-light text-[10px] mt-0.5">${summary}</div>`
: '';
return `<div class="flex justify-between gap-4 py-1 border-b border-dark-600 last:border-0">
<div class="min-w-0">
<div class="text-gray-200">${_escapeHtml(meta.label)}</div>
<div class="text-gray-500 text-[10px]">${_escapeHtml(meta.desc)}</div>
${summaryHtml}
</div>
<div class="text-gray-400 text-right whitespace-nowrap">${_escapeHtml(_formatBytes(f.size))}</div>
</div>`;
}).join('');
sections.push(`<div class="mb-3"><div class="text-gray-300 font-semibold mb-1">${_escapeHtml(title)}</div>${rows}</div>`);
}
pushSection('System', groups.system, include.system ? '' : 'Skipped (toggle off)');
pushSection('Server logs', groups.logs, include.logs
? 'No log file configured — set LOG_FILE env var to include server logs.'
: 'Skipped (toggle off)');
pushSection('Plugin diagnostics', groups.plugins, include.plugins
? 'No plugins have opted in to diagnostics.'
: 'Skipped (toggle off)');
// Client section preview is a server-side estimate only — actual
// client/* payloads are added at Export time after the browser
// snapshots. Show what WILL be added, not file sizes.
const clientLines = [];
if (include.console) clientLines.push({ label: 'Browser console', desc: 'console.log/warn/error transcript + window errors.' });
if (include.hardware) clientLines.push({ label: 'Hardware (browser)', desc: 'WebGL/WebGPU adapter, host OS via userAgent.' });
clientLines.push({ label: 'Browser storage', desc: 'localStorage contents (preferences).' });
clientLines.push({ label: 'User agent', desc: 'Browser, screen, page URL.' });
const clientHtml = clientLines.map(c => `<div class="flex justify-between gap-4 py-1 border-b border-dark-600 last:border-0">
<div><div class="text-gray-200">${_escapeHtml(c.label)}</div><div class="text-gray-500 text-[10px]">${_escapeHtml(c.desc)}</div></div>
<div class="text-gray-500 text-right whitespace-nowrap">added on export</div>
</div>`).join('');
sections.push(`<div class="mb-3"><div class="text-gray-300 font-semibold mb-1">Browser data</div>${clientHtml}</div>`);
const notesHtml = (m.notes || []).length
? `<div class="mb-3 bg-dark-600 border border-amber-500/30 rounded-lg p-2">
<div class="text-amber-400 text-[10px] font-semibold uppercase mb-1">Notes</div>
${(m.notes).map(n => `<div class="text-gray-300 text-[11px]">• ${_escapeHtml(n)}</div>`).join('')}
</div>`
: '';
const privacyHtml = redact
? `<div class="text-emerald-400 text-[11px]">🔒 Redaction enabled — paths, song names, IPs, and secrets will be replaced with stable hash tokens.</div>`
: `<div class="text-amber-400 text-[11px]">⚠ Redaction OFF — bundle will contain raw paths, song names, and IPs. Only share with people you trust.</div>`;
return `
<div class="text-[11px]">
<div class="flex justify-between items-baseline mb-2">
<div class="text-gray-200 font-semibold">${_escapeHtml(data.filename)}</div>
<div class="text-gray-400">${_escapeHtml(_formatBytes(totalBytes))}<span class="text-gray-600"> server-side</span></div>
</div>
<div class="text-gray-500 text-[10px] mb-3">runtime: ${_escapeHtml(m.runtime || 'unknown')} · exported_at: ${_escapeHtml(m.exported_at || '')}</div>
${notesHtml}
${sections.join('')}
${privacyHtml}
</div>`;
}
export async function previewDiagnostics() {
const status = document.getElementById('diag-status');
const preview = document.getElementById('diag-preview');
if (!status || !preview) return;
status.textContent = 'Building preview…';
preview.classList.add('hidden');
const include = _diagIncludeFromUI();
const params = new URLSearchParams({
redact: String(_diagRedactFromUI()),
system: String(include.system),
hardware: String(include.hardware),
logs: String(include.logs),
console: String(include.console),
plugins: String(include.plugins),
});
try {
const resp = await fetch(`/api/diagnostics/preview?${params.toString()}`);
if (!resp.ok) {
status.textContent = `Preview failed (HTTP ${resp.status})`;
return;
}
const data = await resp.json();
preview.innerHTML = _renderDiagPreview(data);
preview.classList.remove('hidden');
status.textContent = 'Preview ready.';
} catch (e) {
status.textContent = `Preview failed: ${e.message}`;
}
}
export async function exportDiagnostics() {
const status = document.getElementById('diag-status');
if (!status) return;
status.textContent = 'Building bundle…';
const include = _diagIncludeFromUI();
const redact = _diagRedactFromUI();
const diag = window.feedBack && window.feedBack.diagnostics;
const body = {
redact,
include,
client_console: include.console && diag ? diag.snapshotConsole() : null,
client_hardware: include.hardware && diag ? await diag.snapshotHardware() : null,
client_ua: diag ? diag.snapshotUa() : null,
local_storage: diag ? diag.snapshotLocalStorage() : null,
client_contributions: diag ? diag.snapshotContributions() : null,
};
let resp;
try {
resp = await fetch('/api/diagnostics/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch (e) {
status.textContent = `Export failed: ${e.message}`;
return;
}
if (!resp.ok) {
status.textContent = `Export failed (HTTP ${resp.status})`;
return;
}
let filename = 'feedBack-diag.zip';
const disp = resp.headers.get('Content-Disposition');
if (disp) {
const m = /filename="([^"]+)"/.exec(disp);
if (m) filename = m[1];
}
try {
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
status.textContent = `Exported ${filename}`;
} catch (e) {
status.textContent = `Export failed during download: ${e.message}`;
}
}
+203
View File
@@ -0,0 +1,203 @@
// DOM + HTML-escaping primitives, and the modal dialogs built on them.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
//
// This one is a GATHER, not a slice — the six lived in six different places in
// app.js. They belong together because they are the bottom of the UI stack:
// `esc` / `_escAttr` alone have ~48 call sites, and every later carve that
// renders HTML will need them. Giving them a home NOW means those carves can
// import them instead of inventing a host seam to reach back into app.js —
// which is exactly the trap the plugin-loader carve had to work around before
// the viz layer became a module.
export function _isElementVisible(el) {
// Walk ancestors looking for display:none. Handles collapsed
// `.album-body` / `.artist-body` subtrees (hidden via CSS class
// rules). Using a DOM walk rather than `offsetParent` avoids the
// false-negative for `position:fixed` elements whose offsetParent
// is null even when they are perfectly visible.
if (!el) return false;
let node = el;
while (node && node !== document.body) {
if (getComputedStyle(node).display === 'none') return false;
node = node.parentElement;
}
return true;
}
// Focus trap: keep Tab / Shift+Tab cycling inside `modal` so focus
// can't escape to the content underneath while the overlay is open.
// Call this once after the modal is in the DOM and initial focus is set.
export function _trapFocusInModal(modal) {
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
modal.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
const els = Array.from(modal.querySelectorAll(FOCUSABLE)).filter(el => {
if (!_isElementVisible(el)) return false;
if (getComputedStyle(el).visibility === 'hidden') return false;
if (el.disabled) return false;
return true;
});
if (!els.length) return;
const first = els[0];
const last = els[els.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
});
}
// Styled async confirm dialog. Returns a Promise<boolean>. For destructive
// prompts pass `danger: true` — confirm button turns red and Cancel gets
// initial focus so an accidental Enter won't fire the action. `body` is
// inserted as HTML so callers can use formatting; callers are responsible
// for escaping any user-supplied content in it (use _escAttr).
export function _confirmDialog({ title, body = '', confirmText = 'Confirm', cancelText = 'Cancel', danger = false } = {}) {
return new Promise((resolve) => {
const previouslyFocused = document.activeElement;
const modal = document.createElement('div');
modal.className = 'feedBack-modal fixed inset-0 z-[250] flex items-center justify-center bg-black/70 backdrop-blur-sm';
modal.setAttribute('role', 'alertdialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-label', title || 'Confirm');
const confirmClass = danger
? 'flex-1 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-xl text-sm font-semibold text-white transition focus:outline-none focus:ring-2 focus:ring-red-400/60'
: 'flex-1 bg-accent hover:bg-accent-light px-4 py-2 rounded-xl text-sm font-semibold text-white transition focus:outline-none focus:ring-2 focus:ring-accent/60';
modal.innerHTML = `
<div class="bg-dark-700 border border-gray-700 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
<h3 class="text-lg font-bold text-white mb-3">${_escAttr(title || '')}</h3>
<div class="mb-5">${body}</div>
<div class="flex gap-3">
<button type="button" data-confirm class="${confirmClass}">${_escAttr(confirmText)}</button>
<button type="button" data-cancel class="px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-xl text-sm text-gray-300 transition focus:outline-none focus:ring-2 focus:ring-gray-500/40">${_escAttr(cancelText)}</button>
</div>
</div>`;
document.body.appendChild(modal);
function finish(result) {
modal.remove();
document.removeEventListener('keydown', onKey, true);
if (previouslyFocused && document.body.contains(previouslyFocused)) {
try { previouslyFocused.focus({ preventScroll: true }); } catch {}
}
resolve(result);
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); finish(false); }
else if (e.key === 'Enter' && document.activeElement === modal.querySelector('[data-confirm]')) {
e.preventDefault(); finish(true);
}
}
modal.addEventListener('click', (e) => {
if (e.target === modal) finish(false);
else if (e.target.closest('[data-confirm]')) finish(true);
else if (e.target.closest('[data-cancel]')) finish(false);
});
document.addEventListener('keydown', onKey, true);
_trapFocusInModal(modal);
// Focus Cancel by default for destructive prompts so an accidental
// Enter / Space won't fire the dangerous action; otherwise focus
// the confirm button so Enter accepts.
const focusTarget = modal.querySelector(danger ? '[data-cancel]' : '[data-confirm]');
if (focusTarget) focusTarget.focus({ preventScroll: true });
});
}
export function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// `esc()` escapes the HTML-content metacharacters (<, >, &) but not
// quotes — fine for text-node interpolation but unsafe when the
// result is used as an attribute value, where a literal `"` ends the
// attribute early. Use `_escAttr` for any `attr="${...}"` site.
export function _escAttr(s) {
return esc(s == null ? '' : String(s))
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// In-app text prompt — replaces window.prompt(), which Electron does NOT
// implement (it logs "prompt() is and will not be supported" and returns null),
// so any prompt()-based flow is a silent no-op on desktop. Returns the entered
// string, or null if cancelled (Esc / Cancel / backdrop). Styled to match the
// edit modal; role=dialog so the global keyboard shortcuts ignore typing here.
// Injection-safe: all caller text is set via textContent / value, never innerHTML.
export function uiPrompt({ title = '', label = '', value = '', okLabel = 'Save', placeholder = '' } = {}) {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'feedBack-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
if (title) modal.setAttribute('aria-label', title);
modal.innerHTML = `
<form class="bg-dark-700 border border-gray-700 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
<h3 class="text-lg font-bold text-white mb-4" data-ui-prompt-title hidden></h3>
<label class="text-xs text-gray-400 mb-1 block" data-ui-prompt-label hidden></label>
<input type="text" data-ui-prompt-input autocomplete="off"
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
<div class="flex gap-3 mt-5">
<button type="submit"
class="flex-1 bg-accent hover:bg-accent-light px-4 py-2 rounded-xl text-sm font-semibold text-white transition" data-ui-prompt-ok></button>
<button type="button" data-ui-prompt-cancel
class="px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-xl text-sm text-gray-300 transition">Cancel</button>
</div>
</form>`;
const titleEl = modal.querySelector('[data-ui-prompt-title]');
const labelEl = modal.querySelector('[data-ui-prompt-label]');
const input = modal.querySelector('[data-ui-prompt-input]');
const okEl = modal.querySelector('[data-ui-prompt-ok]');
if (title) { titleEl.textContent = title; titleEl.hidden = false; }
if (label) { labelEl.textContent = label; labelEl.hidden = false; }
okEl.textContent = okLabel;
input.value = value;
if (placeholder) input.placeholder = placeholder;
// Restore focus to wherever it was when we're done (matches the edit
// modal's behavior so keyboard users aren't dumped at the page top).
const previousActiveElement = document.activeElement;
const focusables = () => Array.from(
modal.querySelectorAll('input, button, [tabindex]:not([tabindex="-1"])'),
).filter((el) => !el.disabled && el.offsetParent !== null);
let settled = false;
const close = (result) => {
if (settled) return;
settled = true;
document.removeEventListener('keydown', onKey, true);
modal.remove();
if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
previousActiveElement.focus();
}
resolve(result);
};
const onKey = (e) => {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(null); return; }
// Trap Tab inside the modal so focus can't wander to the page behind it.
if (e.key === 'Tab') {
const items = focusables();
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || !modal.contains(active))) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && (active === last || !modal.contains(active))) {
e.preventDefault(); first.focus();
}
}
};
modal.querySelector('form').addEventListener('submit', (e) => { e.preventDefault(); close(input.value); });
modal.querySelector('[data-ui-prompt-cancel]').addEventListener('click', () => close(null));
// Backdrop (overlay itself, not the panel) cancels.
modal.addEventListener('mousedown', (e) => { if (e.target === modal) close(null); });
document.addEventListener('keydown', onKey, true);
document.body.appendChild(modal);
input.focus();
input.select();
});
}
+601
View File
@@ -0,0 +1,601 @@
// Highway string colours — user theming for the 2D + bundled 3D highways.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
//
// Slot→hex colours (per named string slot, so a 6-string map survives a 4-string
// bass and a 7-string's Low B), named themes in localStorage, a copy/paste share
// code, and the Settings-screen picker UI. The highways colour by raw string
// INDEX, so a translation table maps named slots → per-index colours for the
// current arrangement, recomputed whenever a song loads.
//
// Exports exactly two entry points; the other 43 symbols (the HWC_* tables, the
// theme store, the picker handlers, the window.feedBack facade) are used nowhere
// else in core and stay private. The Settings buttons are wired by
// addEventListener inside hwcInitSettingsUI — there are no inline on*= handlers
// here, so nothing needs re-exposing on window.
//
// It does import uiPrompt from ./dom.js (the "name this theme" prompt) — which is
// precisely why dom.js was carved out first: without it this module would have
// needed a host seam back into app.js.
import { uiPrompt } from './dom.js';
// Colors are assigned per NAMED string (Low E, A, D, G, B, High E, plus the
// extended low strings of 7/8-string guitars), so a string keeps its color
// when the string count changes (e.g. Low E stays the same from a 6-string
// guitar to a 4-string bass, and on a 7-string the extra Low B takes the
// 7-string slot rather than bumping every color over). The highways color by
// raw string INDEX, so a small translation table maps named slots → per-index
// colors for the current arrangement; this is recomputed whenever a song loads
// (its string count / bass-vs-guitar may differ). Applies to BOTH the 2D and
// bundled 3D highway; stored client-side; shared via a copy/paste code.
const HWC_KEY_ACTIVE = 'highwayStringColors'; // JSON slot→hex map (active)
const HWC_KEY_THEMES = 'highwayColorThemes'; // { "<name>": {slot:hex} }
const HWC_KEY_NAME = 'highwayColorActiveName'; // selected saved theme name, or ''
const HWC_HEX_RE = /^#[0-9a-fA-F]{6}$/;
// Named color slots, in display order (high → low, then extended low strings).
const HWC_SLOTS = [
{ key: 'highE', label: 'High E', sub: '1st' },
{ key: 'B', label: 'B', sub: '2nd' },
{ key: 'G', label: 'G', sub: '3rd' },
{ key: 'D', label: 'D', sub: '4th' },
{ key: 'A', label: 'A', sub: '5th' },
{ key: 'lowE', label: 'Low E', sub: '6th / lowest' },
{ key: 'low7', label: 'Low B', sub: '7-string' },
{ key: 'low8', label: 'Low F#', sub: '8-string' },
];
const HWC_SLOT_KEYS = HWC_SLOTS.map((s) => s.key);
// Hardcoded fallback (matches the highway defaults) for before the 2D highway
// is queryable.
const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '#cc6600', B: '#00cc66', highE: '#9900cc', low7: '#cc00aa', low8: '#00cccc' };
// One-click string-color presets. Each is a full named-slot → hex map (every
// slot, so 7/8-string charts get a sensible color too) keyed by the same slot
// names as HWC_SLOTS, so "Low E" always lands on the lowE slot regardless of
// string count. Hues are chosen for the dark scene (~#080810): each color is
// bright enough to read on black and distinct from its neighbours.
// - warmcool: an ordered low→high spectrum (warm reds at the bass end →
// cool blues/violet at the treble end) so pitch reads as color temperature.
// - vivid: punchier, higher-saturation take on the classic mapping for a
// stage-bright look.
// - colorblind: the OkabeIto accessible qualitative palette (vermillion,
// orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most
// distinguishable option for deuteranopia/protanopia.
// - colorblind_deuteranope: a deuteranope-tuned variant of the OkabeIto set
// above, contributed by a deuteranopic player who still found that set hard
// to separate. Retunes the six main strings (red / yellow-green / blue /
// orange / teal / deep-purple) and keeps its 7/8-string colors unchanged.
// - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags
// between neighbours (bright→bright→brightest→dark blue→bright green→dark
// violet) so adjacent strings separate harder than vivid — a stage/stream
// "pop" set, not a vivid duplicate.
// - accessible: a CVD-safe set ORDERED by ascending lightness low→high (deep
// blue → vermilion → azure → orange → yellow → cream). Unlike the unordered
// OkabeIto 'colorblind' set, the value ramp teaches pitch low→high AND
// survives grayscale/colorblindness; no red/green pair carries meaning.
// - ember: a warm, lower-intensity family for long sessions, luminance-stepped
// from rust/ember at the bass through warm gold to cream at the treble. The
// bass embers stay light enough to clear the near-black scene.
// - tapedeck: a vintage-print, slightly desaturated ochre-tinted family
// (rust-red → mustard → avocado → teal → faded denim → dusty plum). Muted
// hues collapse, so neighbour LIGHTNESS deliberately zig-zags to keep the
// dusty mid-strings (avocado/teal/denim) distinct on the dark board.
// - crtgreen / crtamber: monochrome CRT-phosphor families (green / amber)
// stepped by STRICT ASCENDING LIGHTNESS low→high. Mono sets collapse on hue,
// so lightness alone carries the ordering. Verified to stay legible even on
// the matching phosphor scene board (green-on-green / amber-on-amber).
// - pitchramp: a smooth low→high hue sweep (violet → blue → teal → green →
// yellow → warm-white) with rising lightness — memorable + teaches order.
// - sunrise: a soft dawn gradient (plum → rose → coral → amber → gold → cream),
// warm and lower-intensity, lightness-stepped low→high.
const HWC_PRESETS = [
{
id: 'warmcool', label: 'Warm → Cool',
colors: { lowE: '#ff3b30', A: '#ff7a18', D: '#ffc400', G: '#36c46a', B: '#2196f3', highE: '#9b5cff', low7: '#ff2d78', low8: '#00c2c7' },
},
{
id: 'vivid', label: 'Vivid',
colors: { lowE: '#ff2222', A: '#ffd000', D: '#1e8bff', G: '#ff7a00', B: '#16d65a', highE: '#b24bff', low7: '#ff3cc0', low8: '#15d8d8' },
},
{
id: 'colorblind', label: 'Colorblind-friendly',
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
},
{
id: 'colorblind_deuteranope', label: 'Colorblind (deuteranope)',
colors: { lowE: '#aa1414', A: '#88de00', D: '#1889e3', G: '#c6601c', B: '#00f5b2', highE: '#4d2173', low7: '#0072b2', low8: '#999999' },
},
{
id: 'neon', label: 'Neon',
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
},
{
id: 'accessible', label: 'Accessible (ordered)',
colors: { lowE: '#2453c0', A: '#c44a00', D: '#3f93cf', G: '#ec9a1e', B: '#f2d43c', highE: '#f5eecb', low7: '#173f96', low8: '#0f2c6b' },
},
{
id: 'ember', label: 'Warm Ember',
colors: { lowE: '#c0392b', A: '#e0552a', D: '#ef7d2e', G: '#f6a13a', B: '#f4c95d', highE: '#f7e3a8', low7: '#9e2f23', low8: '#7d2418' },
},
{
id: 'tapedeck', label: 'Tape Deck',
colors: { lowE: '#b04632', A: '#d8ad42', D: '#5f7a34', G: '#54b3a6', B: '#5e83ad', highE: '#b98abb', low7: '#8f3526', low8: '#6f2a1e' },
},
{
id: 'crtgreen', label: 'CRT Green',
colors: { lowE: '#0a5a23', A: '#108a30', D: '#1fb53f', G: '#3ad94f', B: '#74f06a', highE: '#c7ffb0', low7: '#08491c', low8: '#063514' },
},
{
id: 'crtamber', label: 'CRT Amber',
colors: { lowE: '#7a3a02', A: '#a85f06', D: '#cf8410', G: '#e8a82a', B: '#f4cf5e', highE: '#ffeeb8', low7: '#5f2d01', low8: '#471f00' },
},
{
id: 'pitchramp', label: 'Pitch Ramp',
colors: { lowE: '#7a2390', A: '#2f5ad8', D: '#1f9bc4', G: '#2fb84a', B: '#cfd22a', highE: '#f3e0c0', low7: '#5e1a78', low8: '#440f5e' },
},
{
id: 'sunrise', label: 'Sunrise',
colors: { lowE: '#8a3a6e', A: '#bf4a5e', D: '#e0664f', G: '#f29a55', B: '#f7c873', highE: '#fce8b8', low7: '#6e2c5c', low8: '#54214a' },
},
];
// Translation table: chart string index → named slot, for a given string count
// and bass/guitar family. Mirrors the 3D highway's _baseOpenStringMidis: bass
// shares the low strings (E A D G), 7/8-string guitars prepend lower strings,
// and sub-6 guitars truncate from the high end. Index 0 is always the lowest.
function _hwcSlotKeysForChart(sc, isBass) {
sc = Math.max(1, Math.min(8, (sc | 0) || 6));
if (isBass) {
if (sc <= 4) return ['lowE', 'A', 'D', 'G'].slice(0, sc);
if (sc === 5) return ['low7', 'lowE', 'A', 'D', 'G'];
return ['low8', 'low7', 'lowE', 'A', 'D', 'G'].slice(0, sc);
}
if (sc <= 6) return ['lowE', 'A', 'D', 'G', 'B', 'highE'].slice(0, sc);
if (sc === 7) return ['low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
return ['low8', 'low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
}
// Current arrangement shape (string count + bass-vs-guitar) from the 2D highway.
function _hwcChartShape() {
let sc = 6, arr = '';
try { sc = window.highway?.getStringCount?.() || 6; } catch (_) {}
try { arr = window.highway?.getSongInfo?.()?.arrangement || window.feedBack?.currentSong?.arrangement || ''; } catch (_) {}
return { sc: Math.max(1, Math.min(8, sc)), isBass: /bass/i.test(String(arr)) };
}
// Normalize an arbitrary value to a slot→hex map of validated lowercase colors
// (absent / invalid slots are omitted).
function _hwcNormalize(slotMap) {
const out = {};
if (slotMap && typeof slotMap === 'object' && !Array.isArray(slotMap)) {
for (const k of HWC_SLOT_KEYS) {
const v = (typeof slotMap[k] === 'string') ? slotMap[k].trim().toLowerCase() : '';
if (HWC_HEX_RE.test(v)) out[k] = v;
}
}
return out;
}
// Canonical default color per named slot (the classic highway mapping).
// Fixed, not read back from the highway (which may already be name-remapped for
// a 7/8-string chart), so the pickers always preview the true per-name default.
function getHighwayDefaultSlotColors() {
return { ...HWC_DEFAULT_FALLBACK };
}
// Active (user-customized) slot→hex map from storage ({} when none set).
function getHighwayStringColors() {
try {
const raw = localStorage.getItem(HWC_KEY_ACTIVE);
if (raw) return _hwcNormalize(JSON.parse(raw));
} catch (_) { /* corrupt / blocked */ }
return {};
}
// Defaults overlaid with the user's custom slots (custom wins). Always a full
// 8-slot map, so name-mapping has a color for every string of any arrangement.
function _hwcMergedSlotColors() {
return { ...getHighwayDefaultSlotColors(), ...getHighwayStringColors() };
}
// True when the slot→index mapping is the identity (index 0 = lowest = Low E):
// guitar ≤6 strings and 4-string bass. For these the name mapping equals the
// stock index order, so we leave the highways on their hand-tuned defaults
// (byte-identical) unless the user set custom colors. Extended-range charts —
// 7/8-string guitar and 5/6-string bass — prepend lower strings (Low B/F#),
// shifting Low E up an index, so their defaults must be name-remapped too.
function _hwcMappingIsIdentity(sc, isBass) {
return isBass ? sc <= 4 : sc <= 6;
}
// Translate a full slot map into the index-keyed array the highways consume.
function _hwcEffectiveIndexColors(slotMap, sc, isBass) {
const keys = _hwcSlotKeysForChart(sc, isBass);
return keys.map((k) => slotMap[k] || null);
}
// Persist the user's custom slot map (or clear it), then apply. Only slots that
// actually DIFFER from the default are stored — so reverting every picker to its
// stock color persists as empty and the identity/stock path is restored (rather
// than pinning the highways on an all-default "custom" theme).
function applyHighwayStringColors(slotMap, opts) {
const persist = !opts || opts.persist !== false;
const colors = _hwcNormalize(slotMap);
const defaults = getHighwayDefaultSlotColors();
const overrides = {};
for (const k of Object.keys(colors)) {
if (colors[k] !== defaults[k]) overrides[k] = colors[k];
}
if (persist) {
try {
if (Object.keys(overrides).length) localStorage.setItem(HWC_KEY_ACTIVE, JSON.stringify(overrides));
else localStorage.removeItem(HWC_KEY_ACTIVE);
} catch (_) {}
}
reapplyHighwayStringColors();
}
// Apply a named one-click string-color preset (see HWC_PRESETS) to all strings.
// Persists + applies to both highways (via applyHighwayStringColors), then —
// when the Settings UI is mounted — refreshes the per-string pickers so their
// swatches show the preset's colors. Unknown id is a no-op.
function applyHighwayStringPreset(id) {
const preset = HWC_PRESETS.find((p) => p.id === id);
if (!preset) return false;
applyHighwayStringColors(preset.colors);
try { if (typeof hwcRenderPickers === 'function') hwcRenderPickers(); } catch (_) {}
return true;
}
// Apply colors by NAMED string to both highways for the current arrangement.
// Colors follow the string name regardless of count: Low E stays Low E's color
// on a 6-, 7-, or 8-string. Defaults map identically to the stock order for
// 6-string/bass (so those stay byte-identical); 7/8-string remaps the defaults
// too so Low E keeps its color. The String Colors UI replaces the 3D highway's
// old palette picker, so core always drives the 3D string colors here.
function reapplyHighwayStringColors() {
const { sc, isBass } = _hwcChartShape();
const custom = getHighwayStringColors();
const hasCustom = Object.keys(custom).length > 0;
if (!hasCustom && _hwcMappingIsIdentity(sc, isBass)) {
// Pure stock defaults in natural order — leave the hand-tuned highway
// defaults intact, and make sure the 3D is on its plain default palette
// (clears any stale 'custom' / leftover palette selection).
try { window.highway?.setStringColors?.(null); } catch (_) {}
try {
if (localStorage.getItem('h3d_bg_palette') !== 'default') window.h3dBgSetPalette?.('default');
} catch (_) {}
try { window.feedBack?.emit?.('highway:stringColors', {}); } catch (_) {}
return;
}
const eff = _hwcEffectiveIndexColors(_hwcMergedSlotColors(), sc, isBass);
try { window.highway?.setStringColors?.(eff); } catch (_) {}
try { window.h3dBgSetStringColors?.(eff); } catch (_) {}
try { window.feedBack?.emit?.('highway:stringColors', custom); } catch (_) {}
}
function _hwcReadThemes() {
// Null-prototype store: theme names come from user input / share codes, so
// names like `constructor`/`toString`/`__proto__` must not collide with
// inherited Object properties or mutate the prototype.
try {
const parsed = JSON.parse(localStorage.getItem(HWC_KEY_THEMES) || '{}');
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return Object.create(null);
const out = Object.create(null);
for (const [name, colors] of Object.entries(parsed)) out[name] = _hwcNormalize(colors);
return out;
} catch (_) { return Object.create(null); }
}
function _hwcWriteThemes(o) { try { localStorage.setItem(HWC_KEY_THEMES, JSON.stringify(o)); } catch (_) {} }
function listHighwayColorThemes() { return Object.keys(_hwcReadThemes()); }
function getActiveHighwayColorThemeName() { try { return localStorage.getItem(HWC_KEY_NAME) || ''; } catch (_) { return ''; } }
function saveHighwayColorTheme(name, slotMap) {
name = String(name || '').trim();
if (!name) return false;
const o = _hwcReadThemes();
o[name] = _hwcNormalize(slotMap);
_hwcWriteThemes(o);
try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {}
return true;
}
function deleteHighwayColorTheme(name) {
const o = _hwcReadThemes();
if (Object.prototype.hasOwnProperty.call(o, name)) { delete o[name]; _hwcWriteThemes(o); }
if (getActiveHighwayColorThemeName() === name) { try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {} }
}
// Select a saved theme by name, or pass '' to revert to defaults.
function selectHighwayColorTheme(name) {
if (!name) {
try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {}
applyHighwayStringColors(null);
return;
}
const o = _hwcReadThemes();
if (!Object.prototype.hasOwnProperty.call(o, name)) return;
try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {}
applyHighwayStringColors(o[name]);
}
// Compact, paste-friendly share code: "SLOPHWY2." + base64url(JSON{n,c}) where
// c is the named slot→hex map.
function encodeHighwayColorShare(name, slotMap) {
const payload = { n: String(name || '').slice(0, 60), c: _hwcNormalize(slotMap) };
const json = JSON.stringify(payload);
let b64;
try { b64 = btoa(unescape(encodeURIComponent(json))); } catch (_) { b64 = btoa(json); }
return 'SLOPHWY2.' + b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function decodeHighwayColorShare(code) {
if (typeof code !== 'string') return null;
let s = code.trim();
// Require the exact versioned prefix. Anything else (a future/legacy
// SLOPHWY*, or unprefixed text) is rejected so the version boundary is real.
const PREFIX = 'SLOPHWY2.';
if (s.slice(0, PREFIX.length).toUpperCase() !== PREFIX) return null;
s = s.slice(PREFIX.length);
s = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
let json;
try { json = decodeURIComponent(escape(atob(s))); } catch (_) { try { json = atob(s); } catch (_) { return null; } }
let obj;
try { obj = JSON.parse(json); } catch (_) { return null; }
if (!obj || typeof obj.c !== 'object' || Array.isArray(obj.c)) return null;
return { name: String(obj.n || '').slice(0, 60), colors: _hwcNormalize(obj.c) };
}
// Import a share code: store it as a (uniquely named) saved theme and apply.
function importHighwayColorShare(code) {
const parsed = decodeHighwayColorShare(code);
if (!parsed) return null;
let name = parsed.name || 'Imported';
const existing = _hwcReadThemes();
if (Object.prototype.hasOwnProperty.call(existing, name)) {
let i = 2;
while (Object.prototype.hasOwnProperty.call(existing, name + ' ' + i)) i++;
name = name + ' ' + i;
}
saveHighwayColorTheme(name, parsed.colors);
applyHighwayStringColors(parsed.colors);
return { name, colors: parsed.colors };
}
// Startup: apply persisted colors to the 2D highway immediately and re-apply on
// every song load (string count / bass-vs-guitar can change the slot→index
// mapping) and whenever a viz renderer (re)initializes (the 3D loads async +
// rebuilds per song, so a one-shot apply could land before it exists).
let _hwcWired = false;
export function initHighwayColors() {
reapplyHighwayStringColors();
if (!_hwcWired && window.feedBack && typeof window.feedBack.on === 'function') {
_hwcWired = true;
window.feedBack.on('viz:renderer:ready', reapplyHighwayStringColors);
window.feedBack.on('song:loaded', reapplyHighwayStringColors);
window.feedBack.on('song:ready', reapplyHighwayStringColors);
}
_hwcInstallFacade();
}
// ── Public plugin API: window.feedBack.highwayColors ─────────────────────
// A stable, documented facade over the (otherwise private) string-color
// manager so plugins can read / react to / set the user's per-string colors
// without reaching into internals. This is a synchronous data-plane API, not a
// capability domain — consistent with the constitution keeping highway/viz
// surfaces off the capability graph until a dedicated render-facade slice
// lands. Colors are keyed by NAMED string slot (see `slots`); use
// `keysForChart`/`toEffective` to map names → per-string-index for a given
// arrangement. See docs/plugin-capability-inventory.md.
const _hwcChangeWrappers = new WeakMap();
function _hwcInstallFacade() {
if (!window.feedBack || window.feedBack.highwayColors) return;
const api = {
version: 1,
// Ordered named slots: [{ key, label, sub }]. `key` is the stable id.
slots: HWC_SLOTS.map((s) => ({ key: s.key, label: s.label, sub: s.sub })),
// User-set overrides only (named slot → hex); empty object = defaults.
get() { return getHighwayStringColors(); },
// Canonical default color per named slot.
getDefaults() { return getHighwayDefaultSlotColors(); },
// Defaults overlaid with overrides — the colors in effect, by name.
getResolved() { return _hwcMergedSlotColors(); },
// Which named slot each chart string index maps to, for an arrangement
// (index 0 = lowest string). e.g. (7,false) → ['low7','lowE','A',...].
keysForChart(stringCount, isBass) { return _hwcSlotKeysForChart(stringCount, !!isBass); },
// Per-string-INDEX hex array (resolved colors) for an arrangement.
// Omit args to use the currently-loaded chart's shape.
toEffective(stringCount, isBass) {
const shape = (typeof stringCount === 'number')
? { sc: stringCount, isBass: !!isBass }
: _hwcChartShape();
return _hwcEffectiveIndexColors(_hwcMergedSlotColors(), shape.sc, shape.isBass);
},
// The per-index colors actually applied to the live 2D highway now.
getCurrent() {
try { return (window.highway && window.highway.getStringColors) ? window.highway.getStringColors() : []; }
catch (_) { return []; }
},
// Set colors programmatically (persists + applies to both highways).
// Pass a named slot map, or null/{} to revert to defaults.
apply(slotMap) { return applyHighwayStringColors(slotMap); },
// One-click presets: [{ id, label, colors }] (full named-slot maps).
presets: HWC_PRESETS.map((p) => ({ id: p.id, label: p.label, colors: { ...p.colors } })),
// Apply a preset by id (persists + applies to both highways).
applyPreset(id) { return applyHighwayStringPreset(id); },
// Share-code interop (the "SLOPHWY2." copy/paste format).
encodeShare(name, slotMap) { return encodeHighwayColorShare(name, slotMap); },
decodeShare(code) { return decodeHighwayColorShare(code); },
// Subscribe to color changes; handler receives the resolved slot map.
// Returns an unsubscribe fn that removes exactly THIS subscription;
// offChange(fn) removes every subscription registered with that fn.
// (Each fn maps to a Set of wrappers so repeated mount/init paths that
// subscribe the same handler don't clobber each other or leak.)
onChange(fn) {
if (typeof fn !== 'function' || !window.feedBack) return () => {};
const wrapper = () => {
try { fn(api.getResolved()); } catch (e) { console.error('[highwayColors] onChange handler threw', e); }
};
let set = _hwcChangeWrappers.get(fn);
if (!set) { set = new Set(); _hwcChangeWrappers.set(fn, set); }
set.add(wrapper);
window.feedBack.on('highway:stringColors', wrapper);
return () => {
if (window.feedBack) window.feedBack.off('highway:stringColors', wrapper);
const s = _hwcChangeWrappers.get(fn);
if (s) { s.delete(wrapper); if (!s.size) _hwcChangeWrappers.delete(fn); }
};
},
offChange(fn) {
const set = _hwcChangeWrappers.get(fn);
if (set && window.feedBack) {
for (const wrapper of set) window.feedBack.off('highway:stringColors', wrapper);
_hwcChangeWrappers.delete(fn);
}
},
};
window.feedBack.highwayColors = api;
}
// ── Highway String Colors — Settings UI wiring ───────────────────────────
// Pickers are per NAMED string (see HWC_SLOTS). Assigning "Low E" a color
// keeps Low E that color regardless of string count — the translation table
// (_hwcSlotKeysForChart) handles the index remapping per arrangement.
function _hwcStatus(msg) {
const el = document.getElementById('hwc-status');
if (!el) return;
el.textContent = msg || '';
if (msg) {
clearTimeout(_hwcStatus._t);
_hwcStatus._t = setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 2500);
}
}
// Render one color input per named slot, seeded from active colors (falling
// back to the highway defaults for that slot).
function hwcRenderPickers() {
const host = document.getElementById('hwc-pickers');
if (!host) return;
const defaults = getHighwayDefaultSlotColors();
const active = getHighwayStringColors();
host.innerHTML = '';
for (const slot of HWC_SLOTS) {
const val = active[slot.key] || defaults[slot.key] || '#888888';
const wrap = document.createElement('label');
wrap.className = 'flex items-center gap-2 text-xs text-gray-400';
const input = document.createElement('input');
input.type = 'color';
input.id = 'hwc-color-' + slot.key;
input.dataset.slot = slot.key;
input.value = val;
input.style.width = '2.5rem';
input.style.height = '1.75rem';
input.style.padding = '2px';
input.style.cursor = 'pointer';
input.className = 'rounded border border-gray-800 bg-dark-700';
input.addEventListener('input', () => hwcOnColorInput());
wrap.appendChild(input);
const span = document.createElement('span');
span.textContent = slot.label;
wrap.appendChild(span);
const sub = document.createElement('span');
sub.className = 'text-gray-600';
sub.textContent = slot.sub;
wrap.appendChild(sub);
host.appendChild(wrap);
}
}
function hwcReadPickers() {
const out = {};
for (const slot of HWC_SLOTS) {
const el = document.getElementById('hwc-color-' + slot.key);
if (el) out[slot.key] = el.value;
}
return out;
}
// Live apply on any picker change. Leaves the saved-theme select alone so a
// tweaked-but-unsaved state is allowed; "Save as…" captures it.
function hwcOnColorInput() {
applyHighwayStringColors(hwcReadPickers());
}
function hwcPopulateThemeSelect() {
const sel = document.getElementById('hwc-theme-select');
if (!sel) return;
const names = listHighwayColorThemes().sort((a, b) => a.localeCompare(b));
const current = getActiveHighwayColorThemeName();
sel.innerHTML = '';
const def = document.createElement('option');
def.value = '';
def.textContent = 'Default colors';
sel.appendChild(def);
for (const n of names) {
const opt = document.createElement('option');
opt.value = n;
opt.textContent = n;
sel.appendChild(opt);
}
sel.value = (current && names.includes(current)) ? current : '';
}
function hwcOnSelectTheme(name) {
selectHighwayColorTheme(name);
hwcRenderPickers();
}
async function hwcSaveTheme() {
const name = await uiPrompt({ title: 'Save Highway Colors', label: 'Theme name', value: getActiveHighwayColorThemeName() || 'My Colors', okLabel: 'Save' });
if (!name) return;
saveHighwayColorTheme(name, hwcReadPickers());
hwcPopulateThemeSelect();
_hwcStatus('Saved “' + name + '”');
}
function hwcDeleteTheme() {
const name = getActiveHighwayColorThemeName();
if (!name) { _hwcStatus('No saved theme selected'); return; }
deleteHighwayColorTheme(name);
applyHighwayStringColors(null);
hwcPopulateThemeSelect();
hwcRenderPickers();
_hwcStatus('Deleted “' + name + '”');
}
function hwcReset() {
try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {}
applyHighwayStringColors(null);
hwcPopulateThemeSelect();
hwcRenderPickers();
_hwcStatus('Reset to defaults');
}
async function hwcCopyShare() {
const name = getActiveHighwayColorThemeName() || 'Highway Colors';
const code = encodeHighwayColorShare(name, hwcReadPickers());
let copied = false;
try { await navigator.clipboard.writeText(code); copied = true; } catch (_) {}
if (!copied) {
// Fallback: drop the code into the import field so it can be copied manually.
const inp = document.getElementById('hwc-import-code');
if (inp) { inp.value = code; inp.select(); }
}
_hwcStatus(copied ? 'Share code copied' : 'Copy failed — code shown below');
}
function hwcImport() {
const inp = document.getElementById('hwc-import-code');
const code = inp ? inp.value : '';
const res = importHighwayColorShare(code);
if (!res) { _hwcStatus('Invalid share code'); return; }
if (inp) inp.value = '';
hwcPopulateThemeSelect();
hwcRenderPickers();
_hwcStatus('Imported “' + res.name + '”');
}
export function hwcInitSettingsUI() {
hwcPopulateThemeSelect();
hwcRenderPickers();
}
+99
View File
@@ -0,0 +1,99 @@
// The host seam — how a carved-out module calls back into app.js.
//
// WHY THIS EXISTS. What is left in app.js is not a tree, it is a cycle: seeding a
// dependency closure from count-in, from loops, from section-practice, or from the
// JUCE seek shim all return the SAME 178-function set, and setLoop() and
// practiceSection() call each other directly. So a module carved out of that
// component will always need to call back into app.js — and it cannot `import`
// app.js to do it, because app.js imports the module, and that closes a cycle the
// import-x/no-cycle gate (rightly) rejects.
//
// So app.js hands its functions DOWN, once, at boot: `configureHost({ playSong, … })`.
//
// ─── THE FAILURE MODE THIS IS BUILT TO PREVENT ───────────────────────────────
//
// The obvious way to write this is a plain object with no-op defaults. That is a
// TRAP, and we walked into it once already: the plugin loader's host seam defaulted
// `populateVizPicker` to `() => {}`, which means that if the wiring call in app.js
// is ever dropped, renamed, or drifts, the loader keeps running, the viz picker
// silently stops refreshing, and NOTHING — no test, no boot check, no bot — says a
// word. A feature just quietly stops existing.
//
// Two layers stop that here, and the second is the one that actually closes it:
//
// 1. RUNTIME — reading an unwired hook THROWS. There are no defaults and no
// stubs. `host.playSong` either is the real function or it is a loud error.
// An unwired hook cannot degrade into a no-op, because there is nothing for
// it to degrade INTO.
//
// 2. STATIC — tests/js/host_contract.test.js asserts that the set of hooks the
// modules USE is exactly the set app.js WIRES. This is the important one:
// layer 1 only fires if the broken path actually executes, and the whole
// danger of this seam is paths that don't run in a smoke test. The static
// check catches a drifted or misspelled hook in CI, on a path nobody ran.
//
// Consequence for anyone adding a hook: add it to the configureHost({…}) call in
// app.js *and* use it as `host.<name>`. The contract test fails on either alone —
// deliberately. A hook wired but never used is dead weight; a hook used but never
// wired is a bug that would otherwise hide.
const _hooks = Object.create(null);
let _configured = false;
/**
* Called ONCE by app.js at boot, before any carved module runs. Every value must
* be a function a hook that is accidentally `undefined` (a typo, a renamed
* export, a dropped line) fails HERE, at startup, rather than silently much later.
*/
export function configureHost(hooks) {
if (_configured) {
throw new Error('[host] configureHost() called twice — it must be wired exactly once, at boot.');
}
const bad = Object.entries(hooks || {})
.filter(([, v]) => typeof v !== 'function')
.map(([k]) => k);
if (bad.length) {
throw new Error(
`[host] these hooks are not functions: ${bad.join(', ')}. `
+ 'A hook is usually undefined because it was renamed or its line was dropped.',
);
}
Object.assign(_hooks, hooks);
_configured = true;
}
/**
* The seam itself. Reading a hook that was never wired THROWS it never returns
* undefined and never returns a silent no-op. See the note at the top: a no-op
* default is precisely the bug this module exists to make impossible.
*/
export const host = new Proxy(Object.create(null), {
get(_target, name) {
if (typeof name === 'symbol') return undefined; // let JS probe it freely
if (!_configured) {
throw new Error(
`[host] host.${name} was read before configureHost() ran. `
+ 'app.js must call configureHost() at boot, before any carved module executes.',
);
}
const fn = _hooks[name];
if (typeof fn !== 'function') {
throw new Error(
`[host] host.${name} is not wired. Add it to the configureHost({ … }) `
+ 'call in app.js. (tests/js/host_contract.test.js should have caught this in CI.)',
);
}
return fn;
},
// Keep the object honest for anything that introspects it.
has(_target, name) { return name in _hooks; },
ownKeys() { return Object.keys(_hooks); },
getOwnPropertyDescriptor(_target, name) {
return name in _hooks
? { value: _hooks[name], enumerable: true, configurable: true, writable: false }
: undefined;
},
set(_target, name) {
throw new Error(`[host] host.${String(name)} is read-only — hooks are wired only via configureHost().`);
},
});
+863
View File
@@ -0,0 +1,863 @@
// The plugin loader — the R0 host rails.
//
// Carved verbatim out of static/app.js (R3a). This is the highest-risk module in
// core: it fetches /api/plugins, injects each plugin's screen.js (as
// <script type="module"> when its manifest says scriptType:"module"), mounts nav
// entries and screens, and wires plugin capability + UI contributions. If it
// breaks, every plugin breaks — so every change here ends with a real plugin
// booted against a local uvicorn, not just a green test run.
//
// The one thing it still needs from app.js is `window.showScreen` — already the
// public host contract (constitution II), so it is called through `window` rather
// than re-coupled as an import.
//
// `_populateVizPicker` used to arrive through a configurePluginLoader() host seam:
// it lived in app.js, and importing app.js from here would have closed a cycle.
// The viz layer is now its own leaf module, so the seam is GONE — this imports it
// directly, and the graph stays acyclic without any injection.
import { _populateVizPicker } from './viz.js';
let _loadPluginsInFlight = false;
const _pluginUiContributions = new Map();
const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu';
function _capabilityInspectorNavEnabled() {
try { return localStorage.getItem(CAPABILITY_INSPECTOR_NAV_SETTING) === '1'; }
catch (_) { return false; }
}
// Derive a display label from a (possibly string) nav value. `/api/plugins`
// can return `nav` as a plain string (manifest `"nav": "Declared"`) or an
// object with a `.label`, and _pluginNav() may synthesize an object (e.g. the
// Capability Inspector). Handle all three so string labels and the synthesized
// label aren't dropped in favour of the plugin name.
function _navLabel(nav, plugin) {
if (typeof nav === 'string' && nav.trim()) return nav;
if (nav && typeof nav === 'object' && nav.label) return nav.label;
return (plugin && (plugin.name || plugin.id)) || '';
}
function _pluginNav(plugin) {
if (!plugin || !plugin.id) return null;
if (plugin.id === 'capability_inspector') {
if (!_capabilityInspectorNavEnabled()) return null;
return plugin.nav || { label: 'Capabilities', screen: 'plugin-capability_inspector' };
}
return plugin.nav || null;
}
async function _commandUiDomain(domain, command, plugin, payload) {
try {
if (!window.feedBack?.capabilities?.command) return;
await window.feedBack.capabilities.command(domain, command, {
requester: plugin.id || 'plugin',
target: { id: payload.id, pluginId: plugin.id, region: payload.region },
payload: { ...payload, pluginId: plugin.id },
});
} catch (e) {
console.warn(`ui contribution ${command} failed for ${plugin.id}:`, e);
}
}
async function _registerLegacyPluginUiContributions(plugin) {
const previous = _pluginUiContributions.get(plugin.id) || [];
for (const contribution of previous) {
await _commandUiDomain(contribution.domain, 'unmount', plugin, contribution);
}
const contributions = [];
const nav = _pluginNav(plugin);
if (nav) {
contributions.push({ domain: 'ui.navigation', id: `${plugin.id}:nav`, region: 'plugins', label: _navLabel(nav, plugin), mounted: true });
}
if (plugin.has_screen) {
contributions.push({ domain: 'ui.plugin-screens', id: `${plugin.id}:screen`, region: 'plugin-screens', label: plugin.name || plugin.id, mounted: true });
}
if (plugin.has_settings) {
contributions.push({ domain: 'settings', id: `${plugin.id}:settings`, region: 'plugin-settings', label: plugin.name || plugin.id, mounted: true });
}
if (plugin.type === 'visualization') {
contributions.push({ domain: 'ui.player-overlays', id: `${plugin.id}:visualization`, region: 'visualization-picker', label: plugin.name || plugin.id, mounted: true });
}
contributions.sort((a, b) => `${a.domain}:${a.id}`.localeCompare(`${b.domain}:${b.id}`));
_pluginUiContributions.set(plugin.id, contributions);
for (const contribution of contributions) {
await _commandUiDomain(contribution.domain, 'register-contribution', plugin, contribution);
await _commandUiDomain(contribution.domain, 'mount', plugin, contribution);
}
}
// Settings-tab containers that can host plugin <details> panels on the v3
// tabbed settings page. '#plugin-settings' is the fallback bucket (and the
// only container in the classic v2 settings page); the per-tab containers map
// to a plugin manifest's settings.category. A plugin with no category, or one
// whose tab container is absent (v2, or render not yet run), falls back to
// '#plugin-settings'. Body divs injected per plugin use id
// `plugin-settings-<pluginId>` and live INSIDE a <details>, so they are never
// direct children of these containers — no id collision in the scans below.
const _PLUGIN_SETTINGS_CONTAINER_IDS = [
'plugin-settings', 'plugin-settings-graphics',
'plugin-settings-mic', 'plugin-settings-progression',
];
function _pluginSettingsContainers() {
const out = [];
for (const id of _PLUGIN_SETTINGS_CONTAINER_IDS) {
const el = document.getElementById(id);
if (el) out.push(el);
}
return out;
}
function _pluginSettingsTarget(plugin) {
const cat = plugin && plugin.settings_category;
if (cat) {
const el = document.getElementById('plugin-settings-' + cat);
if (el) return el;
}
return document.getElementById('plugin-settings');
}
export async function loadPlugins() {
if (_loadPluginsInFlight) { console.log('[feedBack] loadPlugins: in-flight, skipping'); return null; }
_loadPluginsInFlight = true;
console.log('[feedBack] loadPlugins: start');
let plugins;
const navContainer = document.getElementById('nav-plugins');
const mobileNavContainer = document.getElementById('mobile-nav-plugins');
// Snapshot current nav so we can restore it if the fetch fails.
const _savedNav = navContainer ? navContainer.innerHTML : null;
const _savedMobileNav = mobileNavContainer ? mobileNavContainer.innerHTML : null;
try {
const resp = await fetch('/api/plugins');
const fetchedPlugins = await resp.json();
const capabilityPlugins = fetchedPlugins.slice().sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')));
plugins = fetchedPlugins.slice().sort((a, b) => {
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
});
// 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 {
const capabilityApi = window.feedBack?.capabilities;
if (capabilityApi?.registerParticipants) {
capabilityApi.registerParticipants(capabilityPlugins);
if (capabilityApi.registerCompatibilityShim) {
for (const plugin of capabilityPlugins) {
for (const shim of Array.isArray(plugin.compatibility_shims) ? plugin.compatibility_shims : []) {
capabilityApi.registerCompatibilityShim(shim);
}
}
}
capabilityApi.validateRuntime?.({ phase: 'plugin-manifest-load' });
}
} catch (e) {
console.warn('[feedBack] capability manifest registration failed:', e);
}
// Plugin settings panels mount into one of several tab containers —
// see _pluginSettingsContainers()/_pluginSettingsTarget() above.
// Plugins whose screen.js has already been evaluated this session
// at the current version AND whose DOM is still in the document.
// Their listeners were bound to the existing settings / screen DOM,
// so we must preserve that DOM — the script load guard below skips
// re-evaluating screen.js, and a fresh empty DOM with no listeners
// would leave the plugin half-hydrated on subsequent loadPlugins()
// calls (e.g. the streamed refetches in _streamPluginStartup).
//
// The DOM-existence check is the safety net for plugins that
// disappeared and reappeared between calls (uninstall + reinstall,
// or a backend snapshot churn that drops a plugin then restores
// it). In that case the loadedScripts key would still be set, but
// any listeners are bound to elements that have since been removed
// — drop the stale key so screen.js re-runs against the fresh DOM
// we're about to inject.
// Map<pluginId, version> — one entry per plugin. Storing only the
// currently-loaded version (rather than a Set of all (id, version)
// pairs ever loaded) means upgrade → downgrade → upgrade cycles
// within one session don't leave stale keys that could mistakenly
// mark an old version as already-hydrated. Coerce a legacy Set, if
// present, to an empty Map — the previous shape never shipped.
let loadedScripts = window.feedBack._loadedPluginScripts;
if (!(loadedScripts instanceof Map)) {
loadedScripts = new Map();
window.feedBack._loadedPluginScripts = loadedScripts;
}
const _removePluginScriptTags = (pluginId) => {
// Filter via dataset rather than a CSS attribute selector —
// CSS.escape is not universally available, and plugin IDs
// aren't constrained server-side.
document.querySelectorAll('script[data-plugin-id]').forEach((s) => {
if (s.dataset.pluginId === pluginId) s.remove();
});
};
// Mirror of loadedScripts for the plugin `styles` capability: a single
// versioned <link rel=stylesheet> per plugin lives in <head>, deduped by
// id → version so an upgrade swaps it and re-activation doesn't pile up
// duplicate tags. The <link> covers both the plugin's screen and its
// settings panel. Plugins ship preflight-off (utilities only) CSS, so a
// stylesheet that lingers after deactivation can't bleed a base reset.
let loadedStyles = window.feedBack._loadedPluginStyles;
if (!(loadedStyles instanceof Map)) {
loadedStyles = new Map();
window.feedBack._loadedPluginStyles = loadedStyles;
}
const _removePluginStyleTags = (pluginId) => {
// Same dataset-filter rationale as _removePluginScriptTags.
document.querySelectorAll('link[data-plugin-id]').forEach((l) => {
if (l.dataset.pluginId === pluginId) l.remove();
});
};
const _injectPluginStyles = (plugin) => {
// Tear down a <link> we injected earlier this session when the plugin
// no longer ships a usable stylesheet — upgraded to drop `styles`, or
// to an invalid path — so stale CSS can't keep applying after the
// plugin disabled its styling.
const teardownStale = () => {
if (loadedStyles.has(plugin.id)) {
_removePluginStyleTags(plugin.id);
loadedStyles.delete(plugin.id);
}
};
if (!plugin.has_styles || !plugin.styles) { teardownStale(); return; }
// `styles` is a plugin-root-relative path (like screen/script/routes)
// and must live under assets/ so it serves through the sandboxed
// asset route — e.g. "assets/plugin.css". Reject anything that can't
// reach a served file or would build a malformed URL: not under
// assets/, a `..` traversal segment, a backslash, or a `?`/`#` that
// would collide with the cache-busting query we append. The server
// also enforces containment via safe_join — this just avoids the
// wasted 404 and matches the documented contract.
const path = String(plugin.styles).replace(/^\/+/, '');
const unsafe = !path.startsWith('assets/')
|| /(^|\/)\.\.(\/|$)/.test(path)
|| /[\\?#]/.test(path);
if (unsafe) {
console.warn(`Plugin ${plugin.id}: styles must be a path under assets/ with no "..", backslash, or query/fragment (got "${plugin.styles}") — skipping`);
teardownStale();
return;
}
const wantedVersion = plugin.version || '';
// Idempotent: same id+version already injected → nothing to do.
if (loadedStyles.get(plugin.id) === wantedVersion) return;
// A different version (or none) was loaded — drop the prior <link>
// so we never accumulate stale stylesheets across upgrades.
_removePluginStyleTags(plugin.id);
const link = document.createElement('link');
link.rel = 'stylesheet';
link.dataset.pluginId = plugin.id;
link.dataset.pluginVersion = wantedVersion;
// Version in the URL (the plugin `version`, mirroring the screen.js
// loader's ?v= convention) so a plugin upgrade within one session
// fetches fresh CSS instead of a copy cached by path alone.
const v = encodeURIComponent(wantedVersion);
link.href = `/api/plugins/${plugin.id}/${path}${v ? `?v=${v}` : ''}`;
// Cascade ordering: insert this <link> BEFORE core's prebuilt
// Tailwind (/static/tailwind.min.css) instead of appending at the
// end of <head>. A plugin that ships a full utility build — the
// default output of running the Tailwind CLI without a scoped
// content config — re-defines core utilities like .grid /
// .xl:grid-cols-4; appended last, those equal-specificity rules
// would win on source order and clobber core's responsive layout
// (e.g. the library grid collapses to 2 columns, the nav bar
// breaks). Loading the plugin sheet first means core wins any
// EQUAL-specificity collision, while the plugin's own namespaced
// classes still apply. A plugin can still deliberately override core
// via higher-specificity selectors or !important — this only removes
// the accidental source-order clobber.
const coreSheet =
document.head.querySelector('link[rel="stylesheet"][href*="tailwind.min.css"]')
|| document.head.querySelector('link[rel="stylesheet"]');
if (coreSheet) {
document.head.insertBefore(link, coreSheet);
} else {
document.head.appendChild(link);
}
loadedStyles.set(plugin.id, wantedVersion);
};
const _reconcilePluginStyles = (currentPlugins) => {
// 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 (responded.has(id) && !styled.has(id)) {
_removePluginStyleTags(id);
loadedStyles.delete(id);
}
}
};
const existingSettingsByPluginId = new Map();
for (const container of _pluginSettingsContainers()) {
for (const child of container.children) {
const pid = child.dataset ? child.dataset.pluginId : null;
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;
// Version must match exactly — an upgrade / downgrade has to
// re-run the new script against fresh DOM.
if (loadedScripts.get(p.id) !== (p.version || '')) continue;
const screenOk = !p.has_screen || !!document.getElementById(`plugin-${p.id}`);
const settingsOk = !p.has_settings || existingSettingsByPluginId.has(p.id);
if (screenOk && settingsOk) {
alreadyHydrated.add(p.id);
} else {
// DOM was wiped externally (uninstall + reinstall, snapshot
// churn) — drop the entry and remove the orphaned <script>
// so screen.js re-runs against fresh DOM below.
loadedScripts.delete(p.id);
_removePluginScriptTags(p.id);
}
}
// Clear plugin-owned containers, but keep already-hydrated plugins'
// settings / screen DOM. Nav links carry no per-plugin script state,
// so always rebuild them.
navContainer.innerHTML = '';
mobileNavContainer.innerHTML = '<span class="text-xs text-gray-600 uppercase tracking-wider">Plugins</span>';
for (const container of _pluginSettingsContainers()) {
[...container.children].forEach((el) => {
const pid = el.dataset ? el.dataset.pluginId : null;
// 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) => {
// dataset.pluginId is the source of truth (set on injection);
// the id-prefix fallback covers screens injected before this
// change shipped — both forms strip a single leading "plugin-".
const pid = (el.dataset && el.dataset.pluginId)
|| el.id.replace(/^plugin-/, '');
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
});
// Plugin settings area hosts both "Plugin Updates" and per-plugin
// collapsibles. Reveal it whenever any plugins are installed —
// updates are relevant even for plugins that contribute no settings.
if (plugins.length > 0) {
const area = document.getElementById('plugin-settings-area');
if (area) area.classList.remove('hidden');
}
// Build plugin dropdown for desktop nav
const navPlugins = plugins.map(plugin => ({ plugin, nav: _pluginNav(plugin) })).filter(entry => entry.nav);
if (navPlugins.length > 0) {
const dropdown = document.createElement('div');
dropdown.className = 'relative';
dropdown.innerHTML = `
<button class="text-sm text-gray-400 hover:text-white transition flex items-center gap-1" onclick="this.nextElementSibling.classList.toggle('hidden')">
Plugins
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div class="hidden absolute top-full left-0 mt-2 bg-dark-800 border border-gray-700 rounded-xl shadow-xl py-2 min-w-[180px] max-h-[80vh] overflow-y-auto z-50" id="plugin-dropdown"></div>`;
navContainer.appendChild(dropdown);
const ddMenu = dropdown.querySelector('#plugin-dropdown');
// Close the plugin dropdown when clicking outside it. Bind ONCE:
// loadPlugins() re-runs on every plugin status change during
// startup (SSE-driven refetches), and each run rebuilds `dropdown`
// / `ddMenu`. A per-run addEventListener would leak a new global
// click listener on every refetch, each closing over a now-detached
// dropdown. The one-time handler instead resolves the LIVE dropdown
// from the DOM at click time, so it always targets the current one.
if (!window.feedBack._pluginDropdownOutsideClickBound) {
window.feedBack._pluginDropdownOutsideClickBound = true;
document.addEventListener('click', (e) => {
const menu = document.getElementById('plugin-dropdown');
if (!menu) return;
const container = menu.parentElement;
if (container && !container.contains(e.target)) menu.classList.add('hidden');
});
}
for (const { plugin, nav } of navPlugins) {
const screenId = `plugin-${plugin.id}`;
// A plugin is navigable only once it's ready. While its deps
// install (status "installing") or after a failed load
// (status "failed") we still render the nav slot — disabled,
// with an "installing…" suffix or the error as a tooltip — so
// the nav is stable and the user sees the plugin is coming
// (#421). Entries without a status (legacy / stub) are ready.
const status = plugin.status || 'ready';
const isReady = status === 'ready';
// nav is truthy here (navPlugins is filtered on entry.nav), and
// is the computed value from _pluginNav() — which may be a
// string, an object that omits `label`, or a synthesized object
// (e.g. the Capability Inspector). _navLabel() normalizes all
// three and falls back to name/id so a missing label never
// renders "undefined" or throws. Use the loop's `nav`, not the
// raw `plugin.nav`, so string and synthesized labels survive.
const label = _navLabel(nav, plugin);
const item = document.createElement('a');
item.href = '#';
ddMenu.appendChild(item);
// Mobile nav — flat list
const ma = document.createElement('a');
ma.href = '#';
mobileNavContainer.appendChild(ma);
if (isReady) {
item.className = 'block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-dark-700 transition';
item.textContent = label;
item.onclick = (e) => { e.preventDefault(); ddMenu.classList.add('hidden'); window.showScreen(screenId); window.feedBackDemoTrack?.('event/plugin-open/' + plugin.id); };
ma.className = 'text-gray-400 hover:text-white pl-4 text-sm';
ma.textContent = label;
ma.onclick = (e) => { e.preventDefault(); window.showScreen(screenId); ma.closest('#mobile-menu').classList.add('hidden'); window.feedBackDemoTrack?.('event/plugin-open/' + plugin.id); };
} else {
const installing = status === 'installing';
const suffix = installing ? ' (installing…)' : ' (failed)';
const tip = installing
? 'This plugin is installing its dependencies and will become available shortly.'
: (plugin.error || 'This plugin failed to load. Check the server startup log for details.');
// Disabled appearance: dimmed, default cursor, no nav handler.
const cls = 'block px-4 py-2 text-sm text-gray-600 cursor-default select-none'
+ (installing ? ' animate-pulse' : '');
item.className = cls;
item.setAttribute('aria-disabled', 'true');
item.title = tip;
item.textContent = label + suffix;
// Drop disabled entries out of the tab order and strip the
// href so keyboard/screen-reader users don't land on a
// non-actionable "link" (a11y). Swallow clicks too, in case
// it's still reached via mouse.
item.removeAttribute('href');
item.setAttribute('tabindex', '-1');
item.onclick = (e) => { e.preventDefault(); };
ma.className = 'pl-4 text-sm text-gray-600 cursor-default select-none' + (installing ? ' animate-pulse' : '');
ma.setAttribute('aria-disabled', 'true');
ma.title = tip;
ma.textContent = label + suffix;
ma.removeAttribute('href');
ma.setAttribute('tabindex', '-1');
ma.onclick = (e) => { e.preventDefault(); };
}
}
}
// Tear down stylesheets for plugins that are gone / no longer styled
// before (re)injecting for the current set.
_reconcilePluginStyles(plugins);
for (const plugin of plugins) {
try {
// Only ready plugins have their assets available (the backend
// guards screen.html/screen.js/settings.html on status=="ready").
// Installing/failed plugins contribute only the disabled nav slot
// built above — skip screen/settings/script injection for them.
if (plugin.status && plugin.status !== 'ready') continue;
await _registerLegacyPluginUiContributions(plugin);
const screenId = `plugin-${plugin.id}`;
// Inject the plugin's stylesheet FIRST (before screen HTML/JS) so
// its utilities are present on first paint. Idempotent + version-
// deduped, so it's safe to call for already-hydrated plugins too.
_injectPluginStyles(plugin);
// Inject screen container. Skip for already-hydrated plugins —
// their existing screen DOM still has the listeners that
// screen.js bound on first load (rebuilding here would orphan
// them, since the script load guard further down won't re-run
// screen.js to re-bind).
if (plugin.has_screen && !alreadyHydrated.has(plugin.id)) {
const screenDiv = document.createElement('div');
screenDiv.id = screenId;
screenDiv.className = 'screen';
screenDiv.dataset.pluginId = plugin.id;
screenDiv.dataset.pluginVersion = plugin.version || '';
// Insert before the player screen
const player = document.getElementById('player');
player.parentNode.insertBefore(screenDiv, player);
const htmlResp = await fetch(`/api/plugins/${plugin.id}/screen.html`);
screenDiv.innerHTML = await htmlResp.text();
}
// Inject settings section — wrapped in a collapsible <details>
// per plugin so the page stays scannable as plugins accumulate.
// Collapsed by default; <details>/<summary> handles state natively.
// Skip for already-hydrated plugins — preserved details element
// still carries listeners wired by its inline settings script
// and by screen.js on first load.
// Resolve which settings tab this plugin's panel mounts under
// (manifest settings.category), falling back to '#plugin-settings'.
const settingsTarget = plugin.has_settings ? _pluginSettingsTarget(plugin) : null;
if (plugin.has_settings && settingsTarget && !alreadyHydrated.has(plugin.id)) {
const details = document.createElement('details');
details.className = 'bg-dark-700/40 border border-gray-800 rounded-xl overflow-hidden group';
details.dataset.pluginId = plugin.id;
details.dataset.pluginVersion = plugin.version || '';
const summary = document.createElement('summary');
// .plugin-settings-summary class hides the browser's native
// disclosure triangle (see style.css) so only our chevron shows.
// flex-col allows the fallback explanation note to appear below
// the name/badges row when plugin.fallback is set.
summary.className = 'plugin-settings-summary cursor-pointer select-none px-4 py-3 text-sm font-medium text-gray-300 hover:bg-dark-700/70 transition flex flex-col';
// Inner row: plugin name/badges (left) + chevron (right).
const headerRow = document.createElement('span');
headerRow.className = 'flex items-center justify-between';
const labelWrap = document.createElement('span');
labelWrap.className = 'flex items-center gap-2';
const labelSpan = document.createElement('span');
labelSpan.textContent = plugin.name || plugin.id;
labelWrap.appendChild(labelSpan);
// "Bundled" marker (feedBack#160). Visually distinguishes
// plugins that ship with the default container image from
// user-installed ones so users don't try to remove a core
// plugin via the manage-plugin flow and brick a feature
// that's expected to "just work".
if (plugin.bundled) {
const bundledDesc = 'This plugin ships with FeedBack core and is expected to be present.';
const badge = document.createElement('span');
badge.className = 'inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border border-purple-400/30 bg-purple-500/10 text-purple-300';
badge.title = bundledDesc;
badge.setAttribute('aria-label', 'Bundled — ' + bundledDesc);
badge.setAttribute('role', 'img');
badge.innerHTML = `
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 11c1.657 0 3-1.343 3-3V6a3 3 0 10-6 0v2c0 1.657 1.343 3 3 3zM6 11h12a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2v-6a2 2 0 012-2z"/>
</svg>
Bundled
`;
labelWrap.appendChild(badge);
}
// "Fallback" warning badge: the bundled copy failed to load its
// routes, so the server fell back to this older user-installed
// copy. Warn users so they know the bundled build is broken and
// can check the server startup log for the root cause.
if (plugin.fallback) {
const fbBadge = document.createElement('span');
fbBadge.className = 'inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border border-yellow-400/40 bg-yellow-500/10 text-yellow-300';
fbBadge.setAttribute('aria-hidden', 'true');
fbBadge.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg> Fallback';
labelWrap.appendChild(fbBadge);
}
// Assemble inner header row: [name/badges (left)] [chevron (right)].
// Both are placed in headerRow so the fallback note (if any)
// can sit below the entire row as a second flex-col child of
// summary, rather than being squeezed inline beside the chevron.
headerRow.appendChild(labelWrap);
// Chevron icon — built via setAttributeNS so the SVG sits in
// the SVG namespace and renders correctly. Plugin label is
// appended as text above so manifest values can't inject HTML.
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('class', 'w-4 h-4 text-gray-500 transition-transform group-open:rotate-180');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('viewBox', '0 0 24 24');
const svgPath = document.createElementNS(svgNS, 'path');
svgPath.setAttribute('stroke-linecap', 'round');
svgPath.setAttribute('stroke-linejoin', 'round');
svgPath.setAttribute('stroke-width', '2');
svgPath.setAttribute('d', 'M19 9l-7 7-7-7');
svg.appendChild(svgPath);
headerRow.appendChild(svg);
summary.appendChild(headerRow);
// Fallback explanation note: a visible <p> below the header row,
// accessible to touch/keyboard users (browser tooltip via title/
// aria-label alone is hover-only and insufficient). Appended to
// summary (not labelWrap) so it renders as the second child in
// summary's flex-col layout, appearing below the name+badges row.
if (plugin.fallback) {
const fbNote = document.createElement('span');
fbNote.className = 'block text-xs text-yellow-300/80 mt-1';
fbNote.textContent = 'The bundled version failed to start. This user-installed copy is serving as a fallback. Check the server startup log for details.';
summary.appendChild(fbNote);
}
details.appendChild(summary);
const body = document.createElement('div');
body.id = `plugin-settings-${plugin.id}`;
body.className = 'px-4 py-4 border-t border-gray-800 space-y-4';
details.appendChild(body);
settingsTarget.appendChild(details);
const settingsResp = await fetch(`/api/plugins/${plugin.id}/settings.html`);
body.innerHTML = await settingsResp.text();
// <script> tags inserted via innerHTML are intentionally
// inert per the HTML5 spec — the browser parses them as
// DOM nodes but never runs the body. That silently breaks
// any plugin settings.html that wires event handlers via
// addEventListener (e.g. file pickers, anything that
// can't be expressed as an inline onclick=… attribute),
// and any inline IIFE that hydrates form values from
// localStorage. Re-create each script node — script
// elements created via document.createElement DO execute
// when appended — so plugins get the script behavior
// they'd expect from a normal HTML document.
body.querySelectorAll('script').forEach(oldScript => {
const newScript = document.createElement('script');
for (const attr of oldScript.attributes) {
newScript.setAttribute(attr.name, attr.value);
}
newScript.textContent = oldScript.textContent;
oldScript.parentNode.replaceChild(newScript, oldScript);
});
}
// Load plugin JS
if (plugin.has_script) {
const wantedVersion = plugin.version || '';
if (loadedScripts.get(plugin.id) !== wantedVersion) {
// A different version (or none) was loaded previously —
// remove the prior <script> tag for this plugin id so we
// don't accumulate stale versions on upgrade/downgrade.
_removePluginScriptTags(plugin.id);
await new Promise((resolve, reject) => {
const script = document.createElement('script');
// Include version in URL so a plugin upgrade within the
// same browser session fetches the new screen.js instead
// of a cached copy keyed only by path (matches the art
// URL ?v=mtime convention elsewhere in this file).
const v = encodeURIComponent(wantedVersion);
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
// Module-migration (R0): a migrated plugin declares
// scriptType:"module" and its screen.js is `import
// './src/main.js'`. A <script type="module"> fires load
// only after its whole static-import graph evaluates, so
// the await-onload completion + _loadingPluginId contract
// below is preserved (a classic-IIFE dynamic import()
// would not). Classic plugins are unaffected.
if (plugin.script_type === 'module') script.type = 'module';
script.dataset.pluginId = plugin.id;
script.dataset.pluginVersion = wantedVersion;
window.feedBack._loadingPluginId = plugin.id;
script.onload = () => {
if (window.feedBack._loadingPluginId === plugin.id) delete window.feedBack._loadingPluginId;
loadedScripts.set(plugin.id, wantedVersion);
resolve();
};
script.onerror = (err) => {
if (window.feedBack._loadingPluginId === plugin.id) delete window.feedBack._loadingPluginId;
loadedScripts.delete(plugin.id);
reject(err);
};
document.body.appendChild(script);
});
}
}
} catch (e) {
console.warn(`Plugin '${plugin.id}' failed to load, skipping:`, e);
}
}
} catch (e) {
console.error('Failed to load plugins:', e);
// Restore nav so a failed re-hydration call doesn't leave it blank.
if (_savedNav !== null && navContainer) navContainer.innerHTML = _savedNav;
if (_savedMobileNav !== null && mobileNavContainer) mobileNavContainer.innerHTML = _savedMobileNav;
_loadPluginsInFlight = false;
return null;
}
_loadPluginsInFlight = false;
return plugins;
}
// Re-run loadPlugins (and the viz picker, since a newly-ready plugin may
// register a window.feedBackViz_<id> factory) when plugin status changes.
// Debounced so a burst of plugin-registered/plugin-error events during
// startup collapses into a single refetch.
let _pluginRefreshTimer = null;
function _refreshPluginsSoon() {
clearTimeout(_pluginRefreshTimer);
_pluginRefreshTimer = setTimeout(async () => {
const plugins = await loadPlugins();
if (plugins) {
_populateVizPicker(plugins);
} else {
// loadPlugins() returned null because a refetch was already in
// flight, so this status change would otherwise be dropped. Re-arm
// the debounce so the newer state is still applied once the
// in-flight load finishes. Reuses the 250ms delay (and the
// in-flight guard clears quickly), so this can't tight-loop.
_refreshPluginsSoon();
}
}, 250);
}
let _pluginStreamStarted = false;
function _streamPluginStartup() {
// Watch the SAME /api/startup-status/stream the splash used to gate on.
// Instead of blocking, we let the nav render immediately (loadPlugins ran
// already) and refetch whenever a plugin graduates to ready or fails — so
// its nav slot flips from "installing…" to active/failed without a reload
// (#421). loadPlugins is idempotent (in-flight guard + version map), so
// extra refetches are cheap and safe.
if (_pluginStreamStarted) return;
_pluginStreamStarted = true;
if (typeof EventSource === 'undefined') { _pollPluginStartup(); return; }
const es = new EventSource('/api/startup-status/stream');
es.onmessage = (event) => {
let status;
try { status = JSON.parse(event.data); } catch { return; }
if (!status || status.type === 'keepalive') return;
const phase = (status.phase || '').trim();
if (phase === 'plugin-registered' || phase === 'plugin-error') {
_refreshPluginsSoon();
}
// Terminal: one last refetch to catch anything missed, then stop.
if (!status.running && (phase === 'complete' || phase === 'error')) {
_refreshPluginsSoon();
es.close();
}
};
es.onerror = () => {
// Stream dropped (proxy buffering, backend hiccup). Stop retrying the
// stream and fall back to a bounded poll so late installs still surface.
es.close();
_pollPluginStartup();
};
}
let _pollStartupStarted = false;
async function _pollPluginStartup() {
// SSE-unavailable fallback: poll /api/startup-status until the backend
// finishes its plugin loader, refetching whenever the ready count changes
// or it goes terminal. Bounded so a backend that never finishes doesn't
// poll forever.
if (_pollStartupStarted) return;
_pollStartupStarted = true;
// Generous headroom over the documented worst case (whisperx → torch et al.
// can take 20-30 min): a 30-min ceiling would stop polling right as a
// slipping install — slow mirror, pip retry — actually finishes. 60 min
// leaves margin so the late graduation still surfaces. (#421)
const DEADLINE_MS = 60 * 60 * 1000;
const start = Date.now();
// Track a composite signature, not just the ready count: a plugin can fail
// (phase → "plugin-error", current_plugin/error change) without changing
// `loaded`, e.g. the next plugin breaks after all prior ones succeeded.
// Watching only `loaded` would miss that transition until some later
// ready-count change or terminal completion, so the failed/error nav state
// wouldn't surface. Refetch whenever any of these move.
let lastSig = null;
while (Date.now() - start < DEADLINE_MS) {
await new Promise((r) => setTimeout(r, 3000));
try {
const resp = await fetch('/api/startup-status');
if (!resp.ok) continue;
const status = await resp.json();
const sig = JSON.stringify([
Number(status.loaded || 0),
status.phase || '',
status.current_plugin || '',
status.error || '',
]);
if (sig !== lastSig) { lastSig = sig; _refreshPluginsSoon(); }
if (!status.running) { _refreshPluginsSoon(); return; }
} catch (_e) { /* network error — keep trying */ }
}
}
export async function bootstrapPluginsAndUi() {
// #421: never gate the nav on full plugin startup. Render it immediately
// from /api/plugins (ready plugins active; installing/failed disabled),
// then stream plugin status so each entry resolves in place as its
// dependencies finish installing or its load fails.
const plugins = await loadPlugins();
_streamPluginStartup();
return plugins;
}
// ── Plugin updates ──────────────────────────────────────────────────────
// The Settings-screen "Check for updates" / "Update" buttons. Carved out of
// app.js (R3a) into the loader rather than a module of their own: this is plugin
// MANAGEMENT, it belongs with the code that loads them. Both are inline handlers,
// so app.js re-exposes them on window.
export async function checkPluginUpdates() {
const btn = document.getElementById('btn-check-updates');
const status = document.getElementById('updates-status');
const list = document.getElementById('plugin-updates-list');
btn.disabled = true;
btn.textContent = 'Checking...';
status.textContent = '';
list.innerHTML = '';
try {
const resp = await fetch('/api/plugins/updates');
const data = await resp.json();
const updates = data.updates || {};
const keys = Object.keys(updates);
if (keys.length === 0) {
status.textContent = 'All plugins are up to date.';
} else {
status.textContent = `${keys.length} update${keys.length > 1 ? 's' : ''} available`;
for (const id of keys) {
const u = updates[id];
const row = document.createElement('div');
row.className = 'flex items-center gap-3 bg-dark-700 rounded-lg px-4 py-2';
row.innerHTML = `
<span class="text-sm text-gray-300 flex-1">${u.name} <span class="text-xs text-gray-500">(${u.behind} commit${u.behind > 1 ? 's' : ''} behind ${u.local} ${u.remote})</span></span>
<button onclick="updatePlugin('${id}', this)" class="bg-accent/20 hover:bg-accent/30 text-accent-light px-3 py-1 rounded-lg text-xs transition">Update</button>`;
list.appendChild(row);
}
}
} catch (e) {
status.textContent = 'Failed to check for updates.';
}
btn.disabled = false;
btn.textContent = 'Check for Updates';
}
export async function updatePlugin(pluginId, btn) {
btn.disabled = true;
btn.textContent = 'Updating...';
try {
const resp = await fetch(`/api/plugins/${pluginId}/update`, { method: 'POST' });
const data = await resp.json();
if (data.ok) {
btn.textContent = 'Updated — restart to apply';
btn.className = 'bg-green-900/30 text-green-400 px-3 py-1 rounded-lg text-xs';
} else {
btn.textContent = 'Failed';
btn.title = data.error || '';
}
} catch (e) {
btn.textContent = 'Error';
}
}
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
// Settings backup — the export / import bundle.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
//
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
// re-exposing them on window. The import is two-phase (server first, atomic; then
// a best-effort localStorage merge) — the rationale comment below is the contract
// and moved with the code.
//
// Bundles server config + every localStorage key + opted-in plugin server
// files into a single JSON file.
//
// Apply semantics — phased, NOT all-or-nothing across the two stores:
// 1. Server first (/api/settings/import). Phase-1 validation guards
// the whole bundle; phase-2 disk commit is per-file but ordered
// so a mid-apply failure surfaces a `partial` field. A server
// failure short-circuits before any localStorage write, so the
// browser side stays untouched on validation refusals.
// 2. localStorage second, only after the server returns ok. Applied
// as a MERGE (no clear): bundled keys overwrite, locally-present
// keys absent from the bundle are preserved (so a plugin
// installed after the export keeps its first-run defaults).
// A localStorage exception here (quota / private mode) is
// surfaced verbatim — server state is already committed and we
// don't pretend the import was clean.
//
// In short: the server side is atomic in phase 1 and surface-partial in
// phase 2; the localStorage side is best-effort merge after server
// success. Failures are reported, never silenced.
export async function exportSettings() {
const status = document.getElementById('backup-status');
status.textContent = 'Exporting...';
try {
const resp = await fetch('/api/settings/export');
if (!resp.ok) {
status.textContent = `Export failed (HTTP ${resp.status})`;
return;
}
const bundle = await resp.json();
// Layer in the browser's localStorage. Use the standard Storage
// iteration API (length + key(i)) rather than Object.keys —
// Object.keys on a Storage instance is not deterministic across
// browsers and can both miss entries and include non-entry
// properties depending on the implementation. Keys are preserved
// verbatim as strings; that's how localStorage stores them, and
// round-trip fidelity matters more than re-typing values that
// were never typed in the first place.
const localStorageData = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key === null) continue;
const value = localStorage.getItem(key);
if (value !== null) localStorageData[key] = value;
}
bundle.local_storage = localStorageData;
// Trigger download via blob + temporary <a download>. We honor the
// server's Content-Disposition filename when present, otherwise
// fall back to a date-stamped default.
let filename = 'feedBack-settings.json';
const disposition = resp.headers.get('Content-Disposition');
if (disposition) {
const match = /filename="([^"]+)"/.exec(disposition);
if (match) filename = match[1];
}
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
status.textContent = `Exported ${filename}`;
} catch (e) {
status.textContent = `Export failed: ${e.message}`;
}
}
export async function importSettings(file) {
if (!file) return;
const status = document.getElementById('backup-status');
if (!confirm('Import will overwrite settings present in the bundle (server config, browser preferences, and opted-in plugin data) and reload the page. Settings not in the bundle (e.g. from plugins installed after the export) are preserved. Continue?')) {
status.textContent = 'Import cancelled';
return;
}
let bundle;
try {
bundle = JSON.parse(await file.text());
} catch (e) {
status.textContent = `Import failed: not valid JSON (${e.message})`;
return;
}
status.textContent = 'Importing...';
let resp, data;
try {
resp = await fetch('/api/settings/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bundle),
});
data = await resp.json();
} catch (e) {
status.textContent = `Import failed: ${e.message}`;
return;
}
// Two failure shapes to surface: our own validation handler
// returns `{ok: false, error: "..."}`, but if the body fails
// FastAPI's request-level validation (e.g. top-level value is
// an array, not an object), the response is the framework's
// `{detail: ...}` shape with no `ok` key. `resp.ok` distinguishes
// both from success without depending on which path produced
// the failure.
if (!resp.ok || data.ok === false) {
let msg = data.error;
if (!msg && data.detail) {
msg = typeof data.detail === 'string'
? data.detail
: JSON.stringify(data.detail);
}
status.textContent = `Import failed: ${msg || `HTTP ${resp.status}`}`;
return;
}
// Server applied successfully. Now apply the localStorage portion as
// a MERGE (not clear+restore): keys in the bundle overwrite, keys
// present locally but absent from the bundle are preserved. This
// matters when a plugin was installed *after* the export — wiping
// its localStorage would erase first-run defaults the plugin set on
// load, leaving it in a worse state than before the import. The
// tradeoff is that orphan keys from removed plugins or renamed key
// schemes also linger; cleaning those up is the user's job.
const ls = bundle.local_storage;
if (ls && typeof ls === 'object') {
try {
for (const [key, value] of Object.entries(ls)) {
if (typeof value === 'string') localStorage.setItem(key, value);
}
} catch (e) {
// Quota exceeded / private mode etc. Server side already
// committed, so we surface the partial state rather than
// pretending it succeeded.
status.textContent = `Server applied, but localStorage write failed: ${e.message}`;
return;
}
}
const warnings = (data.warnings || []).join('; ');
status.textContent = warnings ? `Imported with warnings: ${warnings}. Reloading...` : 'Imported. Reloading...';
setTimeout(() => location.reload(), 800);
}
+228
View File
@@ -0,0 +1,228 @@
// Tuning display — naming, string counts, and target frequencies.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
//
// Turns raw per-string semitone offsets into things a human reads: a tuning NAME
// ("Drop D", "Eb Standard", or a raw-offsets fallback), whether an arrangement is
// bass, its effective string count, and the target FREQUENCIES + note names the
// tuner checks against. Pure functions over a small MIDI/note-name table.
//
// The window / window.feedBack assignments for these stay in app.js — they are the
// public contract (constitution II names window.feedBack), and app.js re-exposes
// the imported bindings from exactly where it always did, so nothing about the
// surface or its ordering changes.
// Display-only tuning label helpers — never mutate offsets or affect playback.
function _looksLikeRawTuningOffsets(str) {
if (!str || typeof str !== 'string') return false;
const s = str.trim();
if (!s) return false;
if (/^-?\d+$/.test(s)) return true;
if (/^-?\d+(?: -?\d+)+$/.test(s)) return true;
if (/^-?\d+(?:,-?\d+)+$/.test(s)) return true;
if (/^-?\d+(-?\d+){2,}$/.test(s)) return true;
return false;
}
function _tuningNameFromOffsets(offsets) {
if (!offsets || !offsets.length) return '';
const standard = {
0: 'E Standard', '-1': 'Eb Standard', '-2': 'D Standard',
'-3': 'C# Standard', '-4': 'C Standard', '-5': 'B Standard',
'-6': 'Bb Standard', '-7': 'A Standard',
1: 'F Standard', 2: 'F# Standard',
};
// Uniform offsets across 4 (bass) / 5 / 6 strings name the same Standard;
// a 4-string bass [0,0,0,0] must read "E Standard", not "Custom Tuning".
if (offsets.length >= 4 && offsets.every((o) => o === offsets[0])) {
const name = standard[offsets[0]];
if (name) return name;
}
if (offsets.length >= 4 && offsets[0] === offsets[1] - 2
&& offsets.slice(1).every((o) => o === offsets[1])) {
const noteNames = ['E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'C#', 'D', 'Eb'];
return 'Drop ' + noteNames[((offsets[0] % 12) + 12) % 12];
}
const named = {
'-2,0,0,0,0,0': 'Drop D',
'-4,-2,-2,-2,-2,-2': 'Drop C',
'-2,-2,0,0,0,0': 'Double Drop D',
'0,0,0,-1,0,0': 'Open G',
'-2,-2,0,0,-2,-2': 'Open D',
'-2,0,0,0,-2,0': 'DADGAD',
'0,2,2,1,0,0': 'Open E',
'-2,0,0,2,3,2': 'Open D (alt)',
};
if (offsets.length === 6) {
const key = offsets.join(',');
if (named[key]) return named[key];
}
return 'Custom Tuning';
}
export function displayTuningName(value, offsets) {
// Explicit offsets win — always name them.
if (Array.isArray(offsets) && offsets.length > 0) {
return _tuningNameFromOffsets(offsets);
}
if (value && typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed || trimmed === 'Unknown') return '';
if (!_looksLikeRawTuningOffsets(trimmed)) {
return trimmed;
}
// A raw offset string (now served by the API) — parse and name it so a
// known tuning like "-1 -1 -1 -1 -1 -1" reads "Eb Standard" rather than
// collapsing to "Custom Tuning".
const parsed = (typeof parseRawTuningOffsets === 'function')
? parseRawTuningOffsets(trimmed) : null;
if (parsed && parsed.length) return _tuningNameFromOffsets(parsed);
return 'Custom Tuning';
}
return '';
}
export function isBassArrangement(context) {
const ctx = context && typeof context === 'object' ? context : {};
if (typeof ctx.isBass === 'boolean') return ctx.isBass;
const label = ((ctx.arrangement || '') + ' ' + (ctx.arrangement_smart_name || '')).toLowerCase();
if (/\bbass\b/.test(label)) return true;
if (/\b(lead|rhythm|combo|guitar)\b/.test(label)) return false;
return false;
}
export function effectiveStringCount(offsets, context) {
if (!Array.isArray(offsets) || !offsets.length) return 0;
const ctx = context && typeof context === 'object' ? context : {};
const isBass = isBassArrangement(ctx);
let sc = ctx.stringCount > 0 ? Number(ctx.stringCount) : 0;
if (!isBass) {
if (sc > 0 && sc <= 5 && offsets.length >= 6) sc = 6;
if (!sc) sc = offsets.length >= 6 ? offsets.length : 6;
} else if (!sc) {
sc = offsets.length >= 5 ? offsets.length : 4;
}
return Math.min(sc, offsets.length);
}
export function songTuningContext(songInfo) {
if (!songInfo || typeof songInfo !== 'object') return {};
return {
stringCount: songInfo.stringCount,
arrangement: songInfo.arrangement,
arrangement_smart_name: songInfo.arrangement_smart_name,
};
}
// Open-string target notes (display only) — mirrors plugins/tuner/utils/tuning-utils.js.
const _TUNING_BASE_MIDI = {
4: [28, 33, 38, 43],
5: [23, 28, 33, 38, 43],
6: [40, 45, 50, 55, 59, 64],
7: [35, 40, 45, 50, 55, 59, 64],
8: [30, 35, 40, 45, 50, 55, 59, 64],
};
const _TUNING_NOTE_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const _TUNING_NOTE_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
function _tuningMidiToFreq(m) {
return Math.pow(2, (m - 69) / 12) * 440;
}
function _tuningOffsetsToFreqs(offsets, isBass) {
const len = offsets.length;
let base;
if (len === 4 || len === 5) {
base = isBass ? _TUNING_BASE_MIDI[len] : _TUNING_BASE_MIDI[6];
} else {
base = _TUNING_BASE_MIDI[len] || _TUNING_BASE_MIDI[6];
}
return offsets.map((offset, i) => {
const root = i < base.length ? base[i] : base[base.length - 1];
return _tuningMidiToFreq(root + offset);
});
}
function _noteNameFromFreq(freq, useFlats) {
const midi = 69 + 12 * Math.log2(freq / 440);
const rounded = Math.round(midi);
const names = useFlats ? _TUNING_NOTE_FLAT : _TUNING_NOTE_SHARP;
return names[((rounded % 12) + 12) % 12];
}
function _octaveNoteFromFreq(freq, useFlats) {
const midi = 69 + 12 * Math.log2(freq / 440);
const rounded = Math.round(midi);
const octave = Math.floor(rounded / 12) - 1;
return _noteNameFromFreq(freq, useFlats) + octave;
}
function _stringOrdinalLabel(n) {
const v = n % 100;
if (v >= 11 && v <= 13) return n + 'th';
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th';
return n + suffix;
}
function _tuningTargetFreqs(offsets, context) {
if (!Array.isArray(offsets) || !offsets.length) return [];
const ctx = context && typeof context === 'object' ? context : {};
const stringCount = effectiveStringCount(offsets, ctx);
const trimmed = offsets.slice(0, stringCount);
if (!trimmed.length) return [];
const isBass = isBassArrangement(ctx);
try {
return _tuningOffsetsToFreqs(trimmed, isBass);
} catch (_) {
return [];
}
}
// Flat vs sharp spelling. A caller that knows the preference can pass
// ctx.useFlats; otherwise we infer from a flat-keyed tuning name. The v3
// card/HUD pass "Custom Tuning" (raw offsets carry no key), so those default
// to sharps unless an explicit useFlats is supplied.
function _resolveTargetUseFlats(ctx) {
if (typeof ctx.useFlats === 'boolean') return ctx.useFlats;
return typeof ctx.tuningName === 'string' && /\b[A-G]b\b/.test(ctx.tuningName);
}
export function displayTuningTargetDetails(offsets, context) {
const ctx = context && typeof context === 'object' ? context : {};
const useFlats = _resolveTargetUseFlats(ctx);
const freqs = _tuningTargetFreqs(offsets, ctx);
return freqs.map((f, i) => {
const stringNumber = freqs.length - i;
const note = _noteNameFromFreq(f, useFlats);
const octaveNote = _octaveNoteFromFreq(f, useFlats);
return {
stringNumber,
note,
octaveNote,
title: _stringOrdinalLabel(stringNumber) + ' string: ' + octaveNote,
};
});
}
export function displayTuningTargets(offsets, context) {
const ctx = context && typeof context === 'object' ? context : {};
const useFlats = _resolveTargetUseFlats(ctx);
const freqs = _tuningTargetFreqs(offsets, ctx);
if (!freqs.length) return '';
return freqs.map((f) => _noteNameFromFreq(f, useFlats)).join(' ');
}
export function parseRawTuningOffsets(value) {
if (Array.isArray(value) && value.length) return value;
if (!value || typeof value !== 'string') return null;
const s = value.trim();
if (/^-?\d+(?: -?\d+)+$/.test(s)) {
return s.split(/\s+/).map((n) => Number(n));
}
if (/^-?\d+(?:,-?\d+)+$/.test(s)) {
return s.split(',').map((n) => Number(n));
}
return null;
}
+770
View File
@@ -0,0 +1,770 @@
// The visualization layer — the viz picker, renderer selection, and Auto-match.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: it imports NOTHING,
// which is what lets static/js/plugin-loader.js take _populateVizPicker straight
// from here and drop the configurePluginLoader() host seam it needed while this
// code still lived in app.js.
//
// It owns the state behind those decisions (the one-shot WebGL2 probe, the
// 3D-promotion flag, the Auto label, the notation-hint memo) — all
// module-private, because nothing outside reads them.
// ── Visualization picker (feedBack#36) ─────────────────────────────────
//
// Discovers viz plugins via /api/plugins and adds them to the #viz-picker
// dropdown. A viz plugin declares itself by setting `"type": "visualization"`
// in its plugin.json AND exposing a factory function on
// window.feedBackViz_<id> that returns an object matching the setRenderer
// contract ({init, draw, resize, destroy}).
//
// The "default" option in the dropdown is the built-in 2D highway that
// lives inside createHighway(); selecting it calls setRenderer(null) which
// restores the default renderer. The bundled 3D Highway plugin
// (plugins/highway_3d/) registers as id `highway_3d` and is the new
// fresh-install default per feedBack#160 PR 3.
// ── WebGL2 detection (one-shot probe) ────────────────────────────────────
// 3D Highway requires WebGL2. On environments where it's unavailable
// (older browsers, some embedded webviews, software-only contexts), we
// silently fall back to the Classic 2D Highway and flash a single toast
// so the user knows why their highway looks different. Cached so we don't
// thrash the GPU with repeat throwaway-canvas creations.
let _webgl2Probe = null;
function _canRun3D() {
if (_webgl2Probe !== null) return _webgl2Probe;
try {
const c = document.createElement('canvas');
const gl = c.getContext('webgl2');
_webgl2Probe = !!gl;
// Lose the context immediately — the probe canvas is never reused.
if (gl && gl.getExtension) {
const ext = gl.getExtension('WEBGL_lose_context');
if (ext && ext.loseContext) ext.loseContext();
}
} catch (_) { _webgl2Probe = false; }
return _webgl2Probe;
}
// ── Migration / nag flags ────────────────────────────────────────────────
// `feedBack_3d_promoted_v1` is set the first time we auto-flip an existing
// `vizSelection='default'` user to `'highway_3d'`. Persistence ensures we
// don't re-nag on every reload — and ensures the WebGL2 fallback path
// doesn't ping-pong (one fallback toast, not one per page load).
const _3D_PROMOTED_FLAG_KEY = 'feedBack_3d_promoted_v1';
function _markPromoted() {
try { localStorage.setItem(_3D_PROMOTED_FLAG_KEY, '1'); } catch (_) {}
}
function _hasPromotedFlag() {
try { return localStorage.getItem(_3D_PROMOTED_FLAG_KEY) === '1'; }
catch (_) { return false; }
}
// Pending nag: queued during _populateVizPicker, fired on the first
// `song:ready` (so the toast lands when the user actually opens the
// player, not at page load when they're still in the library).
// `song:ready` is emitted by highway.js via window.feedBack.emit(), so
// subscribe through the same EventTarget. window.feedBack is created in
// this same file before _populateVizPicker is reachable, so the global
// is guaranteed to exist by the time this listener registers — but guard
// anyway in case this module is ever loaded standalone for tests.
let _pendingPromotionNag = false;
if (window.feedBack && typeof window.feedBack.on === 'function') {
window.feedBack.on('song:ready', () => {
if (!_pendingPromotionNag) return;
_pendingPromotionNag = false;
_showPromotionNag();
});
}
function _showPromotionNag() {
// Lightweight toast — no dependency on a generic toast helper, since
// app.js doesn't currently have one. Fixed bottom-center, dismissed
// by clicking either action button or the × close.
const existing = document.getElementById('feedBack-3d-nag');
if (existing) existing.remove();
const wrap = document.createElement('div');
wrap.id = 'feedBack-3d-nag';
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'false');
wrap.setAttribute('aria-label', '3D Highway upgrade notification');
wrap.style.cssText = `
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
background: linear-gradient(145deg, #1a1a30 0%, #0d0d18 100%);
border: 1px solid rgba(64,128,224,0.4);
border-radius: 12px; padding: 12px 16px;
box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(64,128,224,0.15);
font-size: 13px; color: #e2e8f0; z-index: 10000;
max-width: 480px; display: flex; align-items: center; gap: 12px;
`;
wrap.innerHTML = `
<span aria-live="polite" style="flex:1;">Your highway was upgraded to <strong>3D</strong>.</span>
<button type="button" data-act="tour" style="background:rgba(64,128,224,0.25);color:#e2e8f0;border:1px solid rgba(64,128,224,0.5);padding:6px 12px;border-radius:8px;font-size:12px;cursor:pointer;">Try the tour</button>
<button type="button" data-act="back" style="background:transparent;color:#cbd5e1;border:1px solid rgba(255,255,255,0.1);padding:6px 12px;border-radius:8px;font-size:12px;cursor:pointer;">Switch back to 2D</button>
<button type="button" data-act="dismiss" aria-label="Dismiss" style="background:transparent;color:#6b7280;border:none;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">×</button>
`;
wrap.addEventListener('click', (ev) => {
const btn = ev.target.closest('button[data-act]');
if (!btn) return;
const act = btn.dataset.act;
if (act === 'tour') {
try {
if (window.feedBackTour && typeof window.feedBackTour.start === 'function') {
window.feedBackTour.start('highway_3d');
}
} catch (_) {}
} else if (act === 'back') {
setViz('default');
}
wrap.remove();
});
document.body.appendChild(wrap);
}
function _showWebGL2FallbackToast() {
// One-time fallback notice. Same lightweight DOM as the nag, simpler
// copy and only a dismiss button.
if (document.getElementById('feedBack-3d-fallback')) return;
const wrap = document.createElement('div');
wrap.id = 'feedBack-3d-fallback';
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'false');
wrap.setAttribute('aria-label', 'WebGL2 not available');
wrap.style.cssText = `
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
background: #181830; border: 1px solid rgba(255,180,80,0.4);
border-radius: 12px; padding: 10px 14px;
font-size: 12px; color: #e2e8f0; z-index: 10000;
display: flex; align-items: center; gap: 10px;
`;
wrap.innerHTML = `
<span aria-live="polite">3D Highway needs WebGL2 falling back to Classic 2D.</span>
<button type="button" data-act="dismiss" aria-label="Dismiss" style="background:transparent;color:#6b7280;border:none;font-size:16px;cursor:pointer;padding:0 4px;line-height:1;">×</button>
`;
wrap.addEventListener('click', (ev) => {
if (ev.target.closest('button[data-act]')) wrap.remove();
});
document.body.appendChild(wrap);
setTimeout(() => { try { wrap.remove(); } catch (_) {} }, 8000);
}
// The "default" option in the dropdown is the built-in 2D highway that
// lives inside createHighway(); selecting it calls setRenderer(null) which
// restores the default renderer.
function _ensureVenueVizOption(sel) {
if (!sel) return;
if (Array.from(sel.options).some(opt => opt.value === 'venue')) return;
if (!Array.from(sel.options).some(opt => opt.value === 'highway_3d')) return;
const h3dOpt = Array.from(sel.options).find(opt => opt.value === 'highway_3d');
const opt = document.createElement('option');
opt.value = 'venue';
opt.textContent = 'Venue';
if (h3dOpt && h3dOpt.nextSibling) sel.insertBefore(opt, h3dOpt.nextSibling);
else sel.appendChild(opt);
}
function _syncVenueVizPlayerClass(vizId) {
if (window.v3VenueViz && typeof window.v3VenueViz.setSelectedVizId === 'function') {
window.v3VenueViz.setSelectedVizId(vizId);
return;
}
if (window.v3VenueViz && typeof window.v3VenueViz.syncPlayerVizClass === 'function') {
window.v3VenueViz.syncPlayerVizClass(vizId);
return;
}
const player = document.getElementById('player');
if (player) player.classList.toggle('is-venue-visualization', vizId === 'venue');
}
export async function _populateVizPicker(plugins) {
const sel = document.getElementById('viz-picker');
if (!sel) return;
// Clear any previously-appended plugin options so calling this
// function more than once (e.g. from DevTools, or a hot-reloaded
// plugin) doesn't produce duplicates. The built-in "auto" and
// "default" options are static markup — preserve them.
const BUILTIN_OPT_VALUES = new Set(['auto', 'default', 'venue']);
Array.from(sel.options).forEach(opt => {
if (!BUILTIN_OPT_VALUES.has(opt.value)) sel.removeChild(opt);
});
// Accept a pre-fetched plugins array (normal startup path reuses
// loadPlugins' fetch). Fall back to our own fetch if called
// standalone — e.g. from the DevTools console for debugging.
if (!Array.isArray(plugins)) {
plugins = [];
try {
const resp = await fetch('/api/plugins');
if (resp.ok) plugins = await resp.json();
} catch (e) {
console.warn('viz picker: /api/plugins fetch failed', e);
}
}
const vizPlugins = plugins.filter(p => p && p.type === 'visualization');
// "default" is reserved for the built-in 2D renderer option and
// "auto" is reserved for the Auto-mode entry — both already in the
// <select>. A plugin with either id would collide: the
// restore-from-localStorage lookup would find the built-in entry,
// dragging the plugin into never-selected land silently. Fail
// loudly instead.
const RESERVED_IDS = new Set(['default', 'auto']);
for (const p of vizPlugins) {
if (RESERVED_IDS.has(p.id)) {
console.error(`viz picker: plugin id '${p.id}' collides with a reserved built-in picker entry ('auto' = Auto mode, 'default' = built-in 2D highway); rename the plugin's id in plugin.json to include it in the picker.`);
continue;
}
// Skip entries where the plugin script hasn't exposed a factory —
// likely means the script failed to load, or the plugin declared
// itself as a viz without shipping the factory yet.
const factoryName = 'feedBackViz_' + p.id;
if (typeof window[factoryName] !== 'function') {
console.warn(`viz picker: plugin '${p.id}' has type=visualization but ${factoryName} is not a function; skipping`);
continue;
}
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name || p.id;
sel.appendChild(opt);
}
_ensureVenueVizOption(sel);
// Refresh the visualization capability domain's provider registry from
// the picker entries just built (the domain host introspects each
// factory global for contextType / predicate metadata).
if (window.feedBack.vizDomain && typeof window.feedBack.vizDomain.refreshProviders === 'function') {
try {
// The host reads manifest-declared per-instance settings
// (capabilities.visualization.settings, feedBack#849) from the
// registered capability participant by id — no need to pass them
// through the picker here.
window.feedBack.vizDomain.refreshProviders(
Array.from(sel.options)
.filter(opt => !BUILTIN_OPT_VALUES.has(opt.value))
.map(opt => ({ id: opt.value, label: opt.text }))
);
} catch (e) { console.warn('viz picker: capability provider refresh failed', e); }
}
// Restore previous selection if still available. Direct option
// scan instead of a CSS-selector lookup so we don't depend on
// CSS.escape (missing in some test environments / older runtimes)
// and so a weird saved string (e.g. with a quote) can't throw.
// localStorage.getItem can itself throw when storage is blocked
// (private mode, sandboxed iframes, some strict test runners);
// fall back to null so the startup chain doesn't abort.
let saved = null;
try { saved = localStorage.getItem('vizSelection'); }
catch (e) { console.warn('viz picker: unable to read vizSelection', e); }
// ── 3D promotion migration (feedBack#160 PR 3) ──────────────────────
// Existing users with `vizSelection='default'` (the old built-in 2D
// highway) are auto-flipped to the bundled 3D Highway exactly once,
// and a non-modal nag toast offers them "Try the tour" / "Switch
// back to 2D" the first time they open the player. Users on `auto`
// are left alone (auto-pick semantics unchanged). Users on a custom
// viz plugin are left alone. WebGL2 absence falls back via setViz.
if (saved === 'default' && !_hasPromotedFlag()) {
const has3D = Array.from(sel.options).some(o => o.value === 'highway_3d');
if (has3D && _canRun3D()) {
saved = 'highway_3d';
try { localStorage.setItem('vizSelection', 'highway_3d'); } catch (_) {}
_markPromoted();
_pendingPromotionNag = true;
// Race guard: if song:ready already fired before _populateVizPicker
// ran (e.g. a deeplink or a fast-loading song), getSongInfo() will
// already be non-empty and we'll never receive another song:ready
// in this session. Show the nag immediately in that case.
const _si = window.highway && window.highway.getSongInfo();
if (_si && _si.title) {
_pendingPromotionNag = false;
_showPromotionNag();
}
} else if (has3D && !_canRun3D()) {
// 3D registered but WebGL2 absent — promote in name but
// immediately fall back so we don't ping-pong on every load.
// Set the flag so we don't try again next reload.
_markPromoted();
_showWebGL2FallbackToast();
}
// No `highway_3d` option (plugin unloaded?) → leave saved as
// 'default'. We'll retry the migration once the plugin is back.
}
const savedMatches = saved && Array.from(sel.options).some(opt => opt.value === saved);
if (savedMatches) {
sel.value = saved;
// 'default' needs no setViz — the highway already starts with
// the built-in renderer. 'auto' runs setViz so _autoMatchViz
// fires, though it's a no-op before the first song_info frame.
if (saved !== 'default') setViz(saved);
} else if (saved) {
// Saved selection references an option that no longer exists —
// plugin uninstalled since last session, renamed, or the plugin
// script failed to register its factory this time. Clear the
// stale value so we don't keep trying the same missing viz on
// every reload, and fall through to the fresh-install default
// below.
try { localStorage.removeItem('vizSelection'); }
catch (_) { /* storage blocked; ignore */ }
saved = null;
}
if (!saved) {
// Fresh install (or post-cleanup fallthrough): default to the
// bundled 3D Highway when available + WebGL2-capable, falling
// back to Auto otherwise so the arrangement-matching plugins
// (piano on Keys songs, drums on Drums songs, ...) still take
// over for non-3D arrangements.
const has3D = Array.from(sel.options).some(o => o.value === 'highway_3d');
if (has3D && _canRun3D()) {
sel.value = 'highway_3d';
try { localStorage.setItem('vizSelection', 'highway_3d'); } catch (_) {}
setViz('highway_3d');
} else {
sel.value = 'auto';
try { localStorage.setItem('vizSelection', 'auto'); } catch (_) {}
if (has3D && !_canRun3D()) { _markPromoted(); _showWebGL2FallbackToast(); }
}
}
// Close a startup race: if playback began before loadPlugins
// finished, song:ready already fired while the picker had no
// plugin options — _autoMatchViz saw no candidates and left the
// default active. Now that plugins are registered, re-evaluate
// against whatever song is currently loaded (a no-op when no song
// has been loaded yet, since highway.getSongInfo() returns {}).
if (sel.value === 'auto') _autoMatchViz();
}
function _tagVizRenderer(renderer, id) {
if (!renderer || !id) return renderer;
try {
if (!renderer.pluginId) renderer.pluginId = id;
if (!renderer.source) renderer.source = id;
} catch (_) {}
return renderer;
}
// Attribution hooks into the visualization capability domain (cap:6).
// Guarded no-ops when the domain host isn't loaded (minimal/test pages).
function _notifyVizDomain(id, source) {
const domain = window.feedBack && window.feedBack.vizDomain;
if (domain && typeof domain.notifyRendererChanged === 'function') {
try { domain.notifyRendererChanged(id, source); } catch (_) {}
}
}
function _noteVizAutoMatch(id, matched) {
const domain = window.feedBack && window.feedBack.vizDomain;
if (domain && typeof domain.noteAutoMatch === 'function') {
try { domain.noteAutoMatch(id, matched); } catch (_) {}
}
}
function _installVizRenderer(renderer, id, source = 'user-select') {
highway.setRenderer(_tagVizRenderer(renderer, id));
// Drop any stale notation-view hint now that we have a resolved renderer id.
// This is also the path used by _autoMatchViz() after it resolves 'auto' to
// a real plugin id, so the null passed at evaluation start is corrected here.
_dropStaleNotationHint(id);
_notifyVizDomain(id, source);
if (window.v3VenueViz && typeof window.v3VenueViz.notifyRendererInstalled === 'function') {
window.v3VenueViz.notifyRendererInstalled(id);
}
}
export function setViz(id) {
// Helper: reset the UI and persisted selection to the built-in
// "default" entry. Called whenever the requested viz can't be
// applied (missing factory, factory threw, factory returned a
// non-conforming renderer) so the picker, localStorage, and the
// highway's active renderer stay in sync.
const fallbackToDefault = () => {
try { localStorage.setItem('vizSelection', 'default'); } catch (_) {}
const sel = document.getElementById('viz-picker');
if (sel) sel.value = 'default';
highway.setRenderer(null);
_syncVenueVizPlayerClass('default');
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz('default');
}
_notifyVizDomain('default', 'fallback');
_maybeShowNotationViewHint('default');
};
// When switching away from Auto, reset the closed-state label so the
// Auto option shows base text the next time the user opens the dropdown.
// Also cancel any pending viz:renderer:ready listener from the previous
// Auto match cycle so it can't set a stale label after we've moved on.
if (id !== 'auto') {
if (_cancelPendingAutoLabel) { _cancelPendingAutoLabel(); _cancelPendingAutoLabel = null; }
_setAutoVizLabel(null);
}
if (id === 'default' || !id) {
try { localStorage.setItem('vizSelection', id || 'default'); } catch (_) {}
const _sel = document.getElementById('viz-picker');
if (_sel) _sel.value = 'default';
highway.setRenderer(null);
_syncVenueVizPlayerClass('default');
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz('default');
}
_notifyVizDomain('default', 'user-select');
_maybeShowNotationViewHint('default');
return;
}
if (id === 'auto') {
try { localStorage.setItem('vizSelection', 'auto'); } catch (_) {}
_syncVenueVizPlayerClass('auto');
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz('auto');
}
_autoMatchViz();
return;
}
if (id === 'venue') {
if (!_canRun3D()) {
console.warn('viz picker: WebGL2 unavailable, falling back to Classic 2D Highway');
_markPromoted();
_showWebGL2FallbackToast();
fallbackToDefault();
return;
}
const venueFactory = window['feedBackViz_highway_3d'];
if (typeof venueFactory !== 'function') {
console.error('viz picker: venue requires feedBackViz_highway_3d');
fallbackToDefault();
return;
}
let venueRenderer;
try { venueRenderer = venueFactory(); }
catch (e) {
console.error('viz picker: feedBackViz_highway_3d threw for venue mode', e);
fallbackToDefault();
return;
}
if (!venueRenderer || typeof venueRenderer.draw !== 'function') {
console.error('viz picker: feedBackViz_highway_3d returned an invalid renderer for venue mode');
fallbackToDefault();
return;
}
try { localStorage.setItem('vizSelection', 'venue'); } catch (_) {}
const _venueSel = document.getElementById('viz-picker');
if (_venueSel) _venueSel.value = 'venue';
_installVizRenderer(venueRenderer, 'highway_3d');
_syncVenueVizPlayerClass('venue');
console.info('[venue-viz] selected venue -> renderer highway_3d, venueClass=true');
if (window.v3VenueMoodFx && typeof window.v3VenueMoodFx.onVenueVisualizationSelected === 'function') {
window.v3VenueMoodFx.onVenueVisualizationSelected();
}
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz('venue');
}
_maybeShowNotationViewHint('highway_3d');
return;
}
// 3D Highway specifically gates on WebGL2. Any future WebGL viz
// plugin should declare its own probe — for now the bundled 3D
// Highway is the only viz with this requirement, so the gate is
// hardcoded. Falling back to 'default' (Classic 2D) keeps the
// picker in sync; toast informs the user.
if (id === 'highway_3d' && !_canRun3D()) {
console.warn('viz picker: WebGL2 unavailable, falling back to Classic 2D Highway');
_markPromoted();
_showWebGL2FallbackToast();
fallbackToDefault();
return;
}
const factory = window['feedBackViz_' + id];
if (typeof factory !== 'function') {
console.error(`viz picker: factory feedBackViz_${id} not available`);
fallbackToDefault();
return;
}
let renderer;
try { renderer = factory(); }
catch (e) {
console.error(`viz picker: factory feedBackViz_${id} threw`, e);
fallbackToDefault();
return;
}
// Validate shape — highway.setRenderer will itself fall back to
// default on a bad renderer, but without this check the UI and
// localStorage would still advertise the broken selection.
if (!renderer || typeof renderer.draw !== 'function') {
console.error(`viz picker: factory feedBackViz_${id} returned an invalid renderer (missing draw)`);
fallbackToDefault();
return;
}
// Persist only once we know the renderer is valid.
try { localStorage.setItem('vizSelection', id); } catch (_) {}
_installVizRenderer(renderer, id);
_syncVenueVizPlayerClass(id);
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
window.v3VenueScene3d.syncViz(id);
}
_maybeShowNotationViewHint(id);
}
// Auto mode: evaluate each registered viz factory's static
// `matchesArrangement(songInfo)` predicate and install the first
// matching renderer. No match → fall back to the built-in 2D highway.
//
// vizSelection stays 'auto' across invocations so the next song:ready
// re-evaluates. An explicit picker choice overrides Auto by persisting
// a different vizSelection.
//
// Enumerates viz plugins by walking the picker's own <option> list —
// that's the canonical set built by _populateVizPicker above and keeps
// us from needing a second module-level registry.
// Helper: update the closed-state label of the Auto option to show what was resolved.
// Resets to the base label when called with no argument (at evaluation start).
// _autoVizBaseLabel is captured from the DOM on first call so the reset text
// always matches the initial markup rather than a hardcoded duplicate.
let _autoVizBaseLabel = null;
function _setAutoVizLabel(resolvedText) {
const opt = document.querySelector('#viz-picker option[value="auto"]');
if (!opt) return;
if (_autoVizBaseLabel === null) _autoVizBaseLabel = opt.text;
opt.text = resolvedText != null ? `Auto \u2192 ${resolvedText}` : _autoVizBaseLabel;
}
// Holds a cleanup function for the pending viz:renderer:ready listener
// registered by _autoMatchViz(). Called at the start of each new evaluation
// to remove any listener left over from the previous match cycle.
let _cancelPendingAutoLabel = null;
// One-shot (per song) hint shown when a notation-only arrangement falls back
// to the built-in 2D highway. Such arrangements carry no wire notes
// (sloppak-spec §5.3: `file:` may be omitted when `notation:` is present), so
// the default renderer draws an empty board — without this the user is left
// staring at a silently blank highway. Core ships no notation view; point at
// the viz picker instead.
let _notationHintShownFor = null;
function _showNotationViewHint(arrangementIndex, activeVizId) {
const filename = (window.feedBack && window.feedBack.currentSong
&& window.feedBack.currentSong.filename) || '';
if (_notationHintShownFor === filename) return;
_notationHintShownFor = filename;
const player = document.getElementById('player');
if (!player) return;
const prev = document.getElementById('notation-view-hint');
if (prev) prev.remove();
const el = document.createElement('div');
el.id = 'notation-view-hint';
el.className = 'notation-view-hint';
el.dataset.filename = filename;
if (arrangementIndex != null) el.dataset.arrangementIndex = String(arrangementIndex);
if (activeVizId) el.dataset.vizId = String(activeVizId);
el.textContent = 'This arrangement is notation-only — the built-in highway has nothing to draw. '
+ 'Install a notation view plugin (e.g. Staff View or Keys Highway 3D) and select it in the visualization picker.';
const close = document.createElement('button');
close.className = 'notation-view-hint-close';
close.setAttribute('aria-label', 'Dismiss');
close.textContent = '×';
close.addEventListener('click', () => el.remove());
el.appendChild(close);
player.appendChild(el);
setTimeout(() => { el.remove(); }, 15000);
}
// Decide whether the active song needs the notation-view hint: the song is
// notation-only (has_notation + zero wire notes on the active arrangement)
// AND the given viz doesn't claim it via matchesArrangement. Covers both the
// Auto fallthrough (activeVizId='default') and explicit selections, where the
// renderer persists across songs — e.g. the fresh-install default highway_3d
// would otherwise show a silently empty 3D board on a notation-only song.
// Returns true when the hint was shown.
// A hint left over from a previous song refers to the wrong arrangement —
// drop it whenever the viz evaluation runs for a different filename, a
// different arrangement index, or a different active viz.
function _dropStaleNotationHint(activeVizId) {
const stale = document.getElementById('notation-view-hint');
if (!stale) return;
const curFilename = (window.feedBack && window.feedBack.currentSong
&& window.feedBack.currentSong.filename) || '';
if (stale.dataset.filename !== curFilename) { stale.remove(); return; }
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {};
const curArrIdx = songInfo.arrangement_index != null ? String(songInfo.arrangement_index) : null;
if (curArrIdx !== null && stale.dataset.arrangementIndex !== undefined
&& stale.dataset.arrangementIndex !== curArrIdx) {
stale.remove(); return;
}
if (activeVizId && stale.dataset.vizId !== undefined && stale.dataset.vizId !== String(activeVizId)) {
stale.remove();
}
}
export function _maybeShowNotationViewHint(activeVizId) {
_dropStaleNotationHint(activeVizId);
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {};
const activeArr = Array.isArray(songInfo.arrangements)
? songInfo.arrangements.find(a => a.index === songInfo.arrangement_index)
: null;
if (!(songInfo.has_notation && activeArr && activeArr.notes === 0)) {
// Condition no longer holds (arrangement switched to one with notes, or
// notation flag cleared) — remove any residual hint so it doesn't
// linger and contradict current state.
const existing = document.getElementById('notation-view-hint');
if (existing) existing.remove();
return false;
}
if (activeVizId && activeVizId !== 'default' && activeVizId !== 'auto') {
const factory = window['feedBackViz_' + activeVizId];
let claimed = false;
try {
claimed = typeof factory === 'function'
&& typeof factory.matchesArrangement === 'function'
&& !!factory.matchesArrangement(songInfo);
} catch (_) { /* predicate threw — treat as unclaimed */ }
if (claimed) {
// Renderer now claims notation — drop any existing hint.
const existing = document.getElementById('notation-view-hint');
if (existing) existing.remove();
return false;
}
}
_showNotationViewHint(songInfo.arrangement_index, activeVizId);
return true;
}
export function _autoMatchViz() {
const sel = document.getElementById('viz-picker');
if (!sel) return;
// Pass null here: sel.value is 'auto', which is never a valid viz-id hint
// key. Passing 'auto' would incorrectly drop hints whose data-viz-id is
// 'default' (the resolved renderer after a no-match pass), making the
// hint unshowable for the rest of the song. Drop using the resolved id
// happens later inside _installVizRenderer once the id is known.
_dropStaleNotationHint(null);
// Cancel any pending viz:renderer:ready listener from a previous match
// cycle. The song may change before the previous renderer's async init
// settles; we don't want that stale listener to clobber the new label.
if (_cancelPendingAutoLabel) { _cancelPendingAutoLabel(); _cancelPendingAutoLabel = null; }
// Reset label at evaluation start so a stale resolved label never persists
// if the song changes or the picker re-evaluates with a different outcome.
_setAutoVizLabel(null);
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
? (highway.getSongInfo() || {}) : {};
// Only update the label when a real song is loaded. Before the first
// song_info frame, getSongInfo() returns {} — leaving the reset state
// ("Auto (match arrangement)") is correct; we haven't evaluated yet.
const hasSong = Object.keys(songInfo).length > 0;
// Options are stable in DOM order, which matches what users see in
// the picker. The underlying order comes from /api/plugins →
// _populateVizPicker, and /api/plugins reflects the order the
// plugin loader discovered plugins in — plugins/__init__.py walks
// `sorted(plugins_base_dir.iterdir())`, i.e. sorted by the on-disk
// PLUGIN DIRECTORY name (e.g. "feedBack-plugin-drums" sorts
// before "feedBack-plugin-piano"), not by the plugin id declared
// in plugin.json. Two consequences worth noting:
// 1. First match wins among registered viz plugins — keep each
// plugin's matchesArrangement predicate narrow to avoid
// stealing songs from more specialized viz.
// 2. If you need a strict priority when multiple plugins match
// the same song, name the higher-priority plugin's directory
// earlier alphabetically. The picker dropdown reveals the
// actual tiebreaker at a glance.
const candidateIds = Array.from(sel.options)
.map(o => o.value)
.filter(v => v !== 'auto' && v !== 'default');
for (const id of candidateIds) {
const factory = window['feedBackViz_' + id];
if (typeof factory !== 'function') continue;
// If the factory statically declares contextType='webgl2', gate on
// WebGL2 availability so a match never installs a renderer that'll
// fail at init. This is the generic version of the old hard-coded
// highway_3d check — any future WebGL2 viz gets the same protection
// for free without needing a special-case here.
const factoryCtxType = typeof factory.contextType === 'string' ? factory.contextType : '2d';
if (factoryCtxType === 'webgl2' && !_canRun3D()) continue;
const predicate = factory.matchesArrangement;
if (typeof predicate !== 'function') continue;
let matched = false;
try { matched = !!predicate(songInfo); }
catch (err) {
console.error(`viz auto: matchesArrangement for ${id} threw`, err);
continue;
}
if (!matched) continue;
let renderer;
try { renderer = factory(); }
catch (err) {
console.error(`viz auto: factory feedBackViz_${id} threw`, err);
continue;
}
if (!renderer || typeof renderer.draw !== 'function') {
console.error(`viz auto: factory feedBackViz_${id} returned an invalid renderer (missing draw)`);
continue;
}
// Deliberately NOT persisting id — vizSelection stays 'auto' so
// the next song:ready re-evaluates against the new arrangement.
//
// Register the viz:renderer:ready listener BEFORE setRenderer() so we
// don't miss the event for sync renderers (no readyPromise), which emit
// it immediately inside setRenderer(). The _onReady guard still checks
// sel.value so a sync init failure (viz:reverted → sel.value='default')
// that fires during setRenderer() is handled correctly — the listener
// fires but finds sel.value !== 'auto' and skips the label update.
if (hasSong) {
const matchedOpt = Array.from(sel.options).find(o => o.value === id);
const labelText = matchedOpt ? matchedOpt.text : id;
function _onReady() { if (sel.value === 'auto') _setAutoVizLabel(labelText); }
window.feedBack.on('viz:renderer:ready', _onReady, { once: true });
_cancelPendingAutoLabel = () => window.feedBack.off('viz:renderer:ready', _onReady);
}
_installVizRenderer(renderer, id, 'auto-match');
_noteVizAutoMatch(id, true);
return;
}
// No match — restore the built-in 2D highway. setRenderer(null) is
// a no-op when the default is already active. If the previous Auto
// pick was a WebGL renderer, highway.setRenderer() handles the
// context-type change by replacing the canvas element (cloneNode +
// replaceWith) so the default 2D renderer's getContext('2d') always
// succeeds — no canvas-lock limitation here.
highway.setRenderer(null);
_notifyVizDomain('default', 'auto-match');
_noteVizAutoMatch('default', false);
// Update the label so the user can see Auto resolved to the built-in
// highway. Read from the DOM rather than hard-coding the name so a
// future rename of the default entry is automatically reflected.
if (hasSong) {
const defaultOpt = Array.from(sel.options).find(o => o.value === 'default');
// Notation-only arrangement falling through to the default renderer:
// there are no wire notes, so the board would be silently empty.
// Flag it in the Auto label and show the one-shot install hint.
if (_maybeShowNotationViewHint('default')) {
_setAutoVizLabel('no notation view installed');
} else {
_setAutoVizLabel(defaultOpt ? defaultOpt.text : null);
}
}
}
// ── viz:reverted ────────────────────────────────────────────────────────
// Lifted out of a top-level listener block in app.js that it shared with the
// non-viz song:loaded / arrangement:changed / song:ready handlers (those stay).
//
// It has to move WITH the state: it REASSIGNS `_cancelPendingAutoLabel`, and an
// imported binding is read-only — `_cancelPendingAutoLabel = null` would throw if
// this listener stayed behind in app.js. Same guard as the block it came from.
if (window.feedBack && typeof window.feedBack.on === 'function') {
// Highway signals when it's auto-reverted to the default renderer
// after a broken plugin (init failure or repeated draw failures).
// Sync the picker + persisted selection so the UI stops advertising
// the broken choice and the user doesn't hit the same failure on
// next reload.
window.feedBack.on('viz:reverted', (e) => {
const sel = document.getElementById('viz-picker');
if (sel) sel.value = 'default';
// Cancel any pending viz:renderer:ready label listener — the renderer
// that was queued never became (or stayed) active.
if (_cancelPendingAutoLabel) { _cancelPendingAutoLabel(); _cancelPendingAutoLabel = null; }
// Clear any Auto-resolved label — the renderer that was advertised
// never became (or stayed) active.
_setAutoVizLabel(null);
try { localStorage.setItem('vizSelection', 'default'); } catch (_) {}
console.warn(
`viz picker: reverted to default renderer (${e.detail?.reason || 'unknown'}).`
);
});
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -592,6 +592,8 @@
sm.on('working-tuning-changed', () => renderInstrument());
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();
+3 -1
View File
@@ -271,6 +271,8 @@
sm.on('v3:profile-updated', () => render());
}
function boot() { render(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();
+3 -1
View File
@@ -273,7 +273,9 @@
// the stage observer attaches.
window.addEventListener('feedBack-minigames-ready', () => { ensureStageObserver(); refresh(); });
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
+79 -62
View File
@@ -1,14 +1,15 @@
<!DOCTYPE html>
<!--
fee[dB]ack v0.3.0 shell (FEEDBACK_UI=v3 / GET /v3).
fee[dB]ack v0.3.0 shell — the app's only UI, served at `/` (and `/v3`, a
back-compat alias). The classic v2 shell it was forked from is deleted.
This is a re-chromed copy of the legacy static/index.html: the v0.3.0
sidebar + topbar replace the (hidden) legacy navbar, new #v3-* screens are
added, and all the legacy screens (#home library, #favorites, #settings,
#player, #audio, plugin nav containers) are kept verbatim so static/app.js
boots UNMODIFIED and the whole engine — player/highway, plugin loader,
capabilities, audio, library, settings — is reused as-is. Navigation is the
shared window.showScreen across both #v3-* and legacy/#plugin-* screens.
Originally a re-chromed copy of that shell: the v0.3.0 sidebar + topbar
replace the (hidden) legacy navbar, new #v3-* screens are added, and all the
legacy screens (#home library, #favorites, #settings, #player, #audio, plugin
nav containers) are kept verbatim so static/app.js boots UNMODIFIED and the
whole engine — player/highway, plugin loader, capabilities, audio, library,
settings — is reused as-is. Navigation is the shared window.showScreen across
both #v3-* and legacy/#plugin-* screens.
See ~/Repositories/feedBack-feedback-v030/prompts/12-app-shell.md.
-->
<html lang="en" class="dark scroll-smooth">
@@ -98,23 +99,39 @@
<link rel="stylesheet" href="/static/tour-engine.css">
<!-- v0.3.0 shell styles (radial-gradient bg, custom scrollbars). -->
<link rel="stylesheet" href="/static/v3/v3.css">
<!-- EVERY external script below is `defer`. Do not add a plain one.
`defer` and `type="module"` scripts share a single "execute after
parsing" list and run in DOCUMENT ORDER; a plain classic script runs
DURING parse, ahead of all of them. So one plain tag would jump the
queue — and once the capabilities become modules (they defer), a
still-plain app.js would run BEFORE the bus exists and die on its
top-level `window.feedBack.on(...)` calls. Keeping every tag deferred
is what preserves this file's order through the ES-module migration.
Enforced by test_every_external_script_defers_so_document_order_is_execution_order.
The scripts themselves boot on DOMContentLoaded, which fires only after
all of the above have evaluated — that is what lets a script's boot()
use a global another script defines further down this list (there are
~43 such forward references). Their readyState guards therefore treat
'interactive' as not-ready; see the note at each one. -->
<!-- Diagnostics console capture must wrap console.* before any other
script logs anything; load it as early as possible. See
docs/diagnostics-bundle-spec.md (feedBack#166). -->
<script src="/static/diagnostics.js"></script>
<script src="/static/capabilities.js"></script>
<script src="/static/capabilities/library.js"></script>
<script src="/static/capabilities/tuning.js"></script>
<script src="/static/capabilities/working-tuning.js"></script>
<script src="/static/capabilities/audio-session.js"></script>
<script src="/static/capabilities/audio-effects.js"></script>
<script src="/static/capabilities/playback.js"></script>
<script defer src="/static/diagnostics.js"></script>
<script type="module" src="/static/capabilities.js"></script>
<script type="module" src="/static/capabilities/library.js"></script>
<script type="module" src="/static/capabilities/tuning.js"></script>
<script type="module" src="/static/capabilities/working-tuning.js"></script>
<script type="module" src="/static/capabilities/audio-session.js"></script>
<script type="module" src="/static/capabilities/audio-effects.js"></script>
<script type="module" src="/static/capabilities/playback.js"></script>
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
<script src="/static/capabilities/library-card-actions.js"></script>
<script src="/static/capabilities/visualization.js"></script>
<script src="/static/capabilities/note-detection.js"></script>
<script src="/static/capabilities/midi-input.js"></script>
<script src="/static/capabilities/interface-scale.js"></script>
<script type="module" src="/static/capabilities/library-card-actions.js"></script>
<script type="module" src="/static/capabilities/visualization.js"></script>
<script type="module" src="/static/capabilities/note-detection.js"></script>
<script type="module" src="/static/capabilities/midi-input.js"></script>
<script type="module" src="/static/capabilities/interface-scale.js"></script>
</head>
<body class="h-screen flex overflow-hidden bg-fb-sidebar text-fb-text font-display">
@@ -1224,64 +1241,64 @@
</main>
<!-- /#v3-main -->
<script src="/static/highway.js"></script>
<script src="/static/vendor/lottie.min.js"></script>
<script src="/static/lottie-api.js"></script>
<script src="/static/app.js"></script>
<script src="/static/audio-mixer.js"></script>
<script src="/static/vendor/shepherd.min.js"></script>
<script src="/static/tour-engine.js"></script>
<script defer src="/static/highway.js"></script>
<script defer src="/static/vendor/lottie.min.js"></script>
<script defer src="/static/lottie-api.js"></script>
<script type="module" src="/static/app.js"></script>
<script defer src="/static/audio-mixer.js"></script>
<script defer src="/static/vendor/shepherd.min.js"></script>
<script defer src="/static/tour-engine.js"></script>
<!-- fee[dB]ack v0.3.0 shell: brand helper, then the shell (sidebar/topbar/
routing). Loaded after app.js/audio-mixer so window.showScreen and
window.feedBack(.audio) exist; dashboard.js is filled in prompt 13. -->
<script src="/static/v3/brand.js"></script>
<script src="/static/v3/shell.js"></script>
<script defer src="/static/v3/brand.js"></script>
<script defer src="/static/v3/shell.js"></script>
<!-- Progression (spec 010): theme-core before profile.js so the equipped
theme/avatar frame apply with the first badge render; progression-core
registers the `progression` capability owner + window.v3Progression. -->
<script src="/static/v3/theme-core.js"></script>
<script src="/static/v3/progression-core.js"></script>
<script src="/static/v3/notifications.js"></script>
<script src="/static/v3/profile.js"></script>
<script src="/static/v3/progress.js"></script>
<script src="/static/v3/shop.js"></script>
<script src="/static/v3/tuner-core.js"></script>
<script src="/static/v3/badges.js"></script>
<script src="/static/v3/stats-recorder.js"></script>
<script src="/static/v3/live-performance-hud.js"></script>
<script src="/static/v3/scoreboard-pref.js"></script>
<script src="/static/v3/venue-viz.js"></script>
<script src="/static/v3/venue-instrument-pov.js"></script>
<script defer src="/static/v3/theme-core.js"></script>
<script defer src="/static/v3/progression-core.js"></script>
<script defer src="/static/v3/notifications.js"></script>
<script defer src="/static/v3/profile.js"></script>
<script defer src="/static/v3/progress.js"></script>
<script defer src="/static/v3/shop.js"></script>
<script defer src="/static/v3/tuner-core.js"></script>
<script defer src="/static/v3/badges.js"></script>
<script defer src="/static/v3/stats-recorder.js"></script>
<script defer src="/static/v3/live-performance-hud.js"></script>
<script defer src="/static/v3/scoreboard-pref.js"></script>
<script defer src="/static/v3/venue-viz.js"></script>
<script defer src="/static/v3/venue-instrument-pov.js"></script>
<!-- venue-mood-fx must load before venue-scene-3d: the scene bridge reads
window.v3VenueMoodFx.getMotion() synchronously at boot when the saved
viz is 'venue'; loading it after falls back to 'subtle' and ignores a
saved 'off'/'full' motion preference on first paint. -->
<script src="/static/v3/venue-mood-fx.js"></script>
<script src="/static/v3/venue-scene-3d.js"></script>
<script src="/static/v3/playlists.js"></script>
<script src="/static/v3/audio-routing.js"></script>
<script src="/static/v3/live-guitar-tone-source.js"></script>
<script src="/static/v3/pedal-cables.js"></script>
<script src="/static/v3/plugins-page.js"></script>
<script src="/static/v3/card-actions-core.js"></script>
<script defer src="/static/v3/venue-mood-fx.js"></script>
<script defer src="/static/v3/venue-scene-3d.js"></script>
<script defer src="/static/v3/playlists.js"></script>
<script defer src="/static/v3/audio-routing.js"></script>
<script defer src="/static/v3/live-guitar-tone-source.js"></script>
<script defer src="/static/v3/pedal-cables.js"></script>
<script defer src="/static/v3/plugins-page.js"></script>
<script defer src="/static/v3/card-actions-core.js"></script>
<!-- Before songs.js: the songs toolbar calls the match-review chip hook
on build, so the module must already be registered. -->
<script src="/static/v3/match-review.js"></script>
<script defer 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>
<script src="/static/v3/settings.js"></script>
<script src="/static/v3/interface-size-ui.js"></script>
<script defer src="/static/v3/image-picker.js"></script>
<script defer src="/static/v3/songs.js"></script>
<script defer src="/static/v3/lessons.js"></script>
<script defer src="/static/v3/dashboard.js"></script>
<script defer src="/static/v3/settings.js"></script>
<script defer src="/static/v3/interface-size-ui.js"></script>
<!-- First-run home tour: spotlights the home cards via the shared tour
engine (tour-engine.js, loaded above). Auto-runs once after onboarding
(triggered from profile.js finish()); replayable from the "?" menu. -->
<script src="/static/v3/onboarding-tour.js"></script>
<script src="/static/v3/interface-size-nudge.js"></script>
<script src="/static/v3/feedbarcade.js"></script>
<script src="/static/v3/player-chrome.js"></script>
<script defer src="/static/v3/onboarding-tour.js"></script>
<script defer src="/static/v3/interface-size-nudge.js"></script>
<script defer src="/static/v3/feedbarcade.js"></script>
<script defer src="/static/v3/player-chrome.js"></script>
<script>
// Navbar scroll effect
window.addEventListener('scroll', () => {
+3 -1
View File
@@ -71,7 +71,9 @@
setTimeout(maybeNudge, 4000);
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
+3 -1
View File
@@ -45,7 +45,9 @@
// Settings markup is static, but re-sync when settings.js signals it wired.
document.addEventListener('v3:settings-rendered', function () { sync(); });
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', function () { sync(); }, { once: true });
} else {
sync();
+3 -1
View File
@@ -94,7 +94,9 @@
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
+3 -1
View File
@@ -303,7 +303,9 @@
const sm = root && root.feedBack;
if (sm) bindRuntime(sm);
};
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
+3 -1
View File
@@ -913,7 +913,9 @@
});
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', () => {
wireSettingsCard();
wireScreenTeardown();
+3 -1
View File
@@ -362,6 +362,8 @@
syncActivation();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', init);
else init();
})();
+3 -1
View File
@@ -440,6 +440,8 @@
});
}
function boot() { renderPlaylists(); renderSaved(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();
+3 -1
View File
@@ -579,6 +579,8 @@
}, { passive: true });
function boot() { render(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();
+3 -1
View File
@@ -792,7 +792,9 @@
});
}
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
+3 -1
View File
@@ -320,7 +320,9 @@
});
}
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
+3 -1
View File
@@ -241,7 +241,9 @@
};
_registerOwner();
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', () => { refresh(); }, { once: true });
} else {
refresh();
+3 -1
View File
@@ -200,7 +200,9 @@
});
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
+3 -1
View File
@@ -399,7 +399,9 @@
setTimeout(refreshHomeTitle, 700);
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
+3 -1
View File
@@ -203,7 +203,9 @@
});
}
}
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
+3 -1
View File
@@ -511,7 +511,9 @@
const sm = root && root.feedBack;
if (sm) bindRuntime(sm);
};
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
+3 -1
View File
@@ -242,7 +242,9 @@
if (typeof document !== 'undefined') {
const boot = () => bindRuntime();
if (document.readyState === 'loading') {
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
+3 -3
View File
@@ -13,9 +13,9 @@
module.exports = {
content: [
'./static/**/*.{html,js}',
// fee[dB]ack v0.3.0 shell + screens (additive, behind FEEDBACK_UI=v3).
// Subsumed by the recursive ./static/** glob above, but listed
// explicitly so the v3 tree's Tailwind coverage is obvious.
// fee[dB]ack v0.3.0 shell + screens — the only UI since the classic v2
// shell was removed. Subsumed by the recursive ./static/** glob above,
// but listed explicitly so the v3 tree's Tailwind coverage is obvious.
'./static/v3/**/*.{html,js}',
// One recursive plugin glob subsumes the previous four narrow ones
// (static/**, screen.js, settings.html, *.html) and additionally
+30
View File
@@ -9,6 +9,36 @@ import structlog
_LOGGING_NAMES = ("feedBack", "uvicorn", "uvicorn.error", "uvicorn.access")
@pytest.fixture(autouse=True)
def _reset_enrichment_state():
"""Reset the enrichment worker's process-global state between tests.
The `server` fixtures pop-and-reimport `server`, but `lib/enrichment.py`
(which now owns the worker) stays imported for the whole session, so its
module globals the cancel Event, the status dict, the caches would
otherwise leak across tests. A test that set `_enrich_cancel` (or a stale
`running` status) could silently short-circuit a later direct
`_background_enrich()` call. Clear it up front so each test starts clean.
"""
try:
import enrichment
except ImportError:
yield
return
enrichment._enrich_cancel.clear()
enrichment._enrich_pending_pass = False
enrichment._enrich_status.update(
{"running": False, "processed": 0, "last_pass_at": None,
"total": 0, "matched": 0, "current": None})
enrichment._enrich_last_fetch = 0.0
enrichment._artist_alias_cache.clear()
# _caa_index_locks is deliberately left alone: it's guarded by
# _caa_index_locks_guard, so clearing it here (unlocked) would race a
# still-alive worker thread, and its entries are stateless per-release
# mutexes that don't leak test state anyway.
yield
@pytest.fixture()
def isolate_logging():
"""Restore feedBack / uvicorn logger state after each test.
+2 -2
View File
@@ -1,5 +1,5 @@
// Verify the alpha-build heads-up banner: markup is present in
// static/index.html and `_updateAlphaWarningBanner(version)` in
// static/v3/index.html and `_updateAlphaWarningBanner(version)` in
// static/app.js toggles its visibility correctly per the version string.
const { test } = require('node:test');
@@ -8,7 +8,7 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
test('index.html ships the alpha-warning banner inside the library section', () => {
+4 -4
View File
@@ -4,7 +4,7 @@ const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const APP_JS = path.join(ROOT, 'static', 'app.js');
const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js');
const MANIFEST = path.join(ROOT, 'plugins', 'capability_inspector', 'plugin.json');
const SCREEN_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.html');
const SETTINGS_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'settings.html');
@@ -28,7 +28,7 @@ test('capability inspector manifest ships settings but no default nav entry', ()
});
test('capability inspector plugins menu entry is localStorage opt-in', () => {
const src = source(APP_JS);
const src = source(PLUGIN_LOADER_JS);
const helper = region(src, "const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu'", 1400);
const menu = region(src, 'const navPlugins = plugins.map', 1000);
const contributions = region(src, 'async function _registerLegacyPluginUiContributions(plugin)', 1400);
@@ -106,7 +106,7 @@ test('capability inspector screen ships scoped graph lane CSS', () => {
assert.match(html, /left: -1\.75rem/);
});
test('_navLabel resolves string, object, synthesized, and empty nav values', () => {
const src = source(APP_JS);
const src = source(PLUGIN_LOADER_JS);
const m = src.match(/function _navLabel\(nav, plugin\) \{[\s\S]*?\n\}/);
assert.ok(m, 'could not extract _navLabel from app.js');
const _navLabel = new Function(`${m[0]}; return _navLabel;`)();
@@ -123,7 +123,7 @@ test('_navLabel resolves string, object, synthesized, and empty nav values', ()
});
test('plugin nav dropdown label uses the computed nav, not the raw plugin.nav', () => {
const src = source(APP_JS);
const src = source(PLUGIN_LOADER_JS);
// Regression guard for the string/synthesized-nav label fix: the dropdown
// label must derive from the loop's computed nav via _navLabel, not from
// plugin.nav?.label (which drops string and synthesized labels).
+3 -1
View File
@@ -8,7 +8,9 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
// The highway string-colour manager was carved out of app.js into its own
// module (R3a).
const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js');
function extractBlock(src, signature) {
const start = src.indexOf(signature);
+4 -2
View File
@@ -13,7 +13,9 @@ const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
// The highway string-colour manager was carved out of app.js into its own
// module (R3a).
const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js');
// Brace-balanced extraction (same helper shape as highway_note_state.test.js).
function extractBlock(src, signature) {
@@ -86,7 +88,7 @@ test('3D gem-body gradients follow the active palette (not hardcoded)', () => {
assert.match(apply, /_recolorGemGradients\(\)/, '_applyPaletteToMaterials must recolor gems on palette change');
});
// ── Core color manager (static/app.js) ────────────────────────────────────
// ── Core color manager (static/js/highway-colors.js) ──────────────────────
test('app.js color manager name-maps to both highways, with identity no-op + builtin guard', () => {
const src = fs.readFileSync(appJs, 'utf8');
+113
View File
@@ -0,0 +1,113 @@
// The host-seam contract: the hooks the modules USE must be exactly the hooks
// app.js WIRES.
//
// This is the test that makes the seam safe. static/js/host.js already throws at
// runtime when an unwired hook is read — but a runtime throw only fires if the
// broken path actually executes, and the entire danger of a host seam is the paths
// that DON'T run in a smoke test. That is not hypothetical: the plugin loader's
// seam defaulted a hook to `() => {}`, and a dropped wiring line would have left
// the viz picker silently not refreshing with no test, boot check, or bot noticing.
//
// So this closes it statically. Rename a hook in app.js, drop a line from the
// configureHost({…}) call, or typo a `host.foo` in a module, and CI fails — on a
// path nobody ever ran.
//
// It is deliberately symmetric:
// * used but not wired -> a latent crash (host.js would throw at runtime)
// * wired but not used -> dead weight, and usually the fossil of a rename
// Both fail.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const APP_JS = path.join(ROOT, 'static', 'app.js');
const JS_DIR = path.join(ROOT, 'static', 'js');
// Strip comments, so prose about `host.foo` in a header block is not read as a call
// site.
//
// NOTHING ELSE. An earlier version also tried to strip import statements (to stop
// `from './host.js'` reading as a hook called `js`) and its `[\s\S]*?` spanned lines
// and silently ate 14,000 characters of the file — including, in the bite test, the
// very drift it was supposed to catch. A guard with a hole in it is worse than no
// guard, because you trust it. The `host.js` path is excluded far more cheaply,
// below, by refusing a match followed by a quote.
function scrub(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/[^\n]*$/gm, '');
}
// `host.<name>` — but not `host.js'` from the `from './host.js'` import path, which is
// the one string in these files that looks like a hook and isn't.
//
// The trailing class must forbid a WORD character as well as a quote. With only
// `(?!['"])`, `host.js'` fails on `js` (a quote follows), then BACKTRACKS to `j` —
// where the next char is `s`, not a quote — and happily reports a hook called `j`.
// Forbidding `[\w$]` too leaves it nowhere to backtrack to.
const HOOK_RE = /(?<![\w$.])host\.([A-Za-z_$][\w$]*)(?![\w$'"])/g;
/** Every `host.<name>` referenced by a carved module. */
function hooksUsed() {
const used = new Map(); // name -> [files]
for (const file of fs.readdirSync(JS_DIR)) {
if (!file.endsWith('.js') || file === 'host.js') continue;
const raw = fs.readFileSync(path.join(JS_DIR, file), 'utf8');
if (!/from\s+'\.\/host\.js'/.test(raw)) continue;
for (const m of scrub(raw).matchAll(HOOK_RE)) {
if (!used.has(m[1])) used.set(m[1], []);
used.get(m[1]).push(file);
}
}
return used;
}
/** Every hook app.js passes to configureHost({ … }). */
function hooksWired() {
const src = scrub(fs.readFileSync(APP_JS, 'utf8'));
// NB the closing brace is INDENTED (the call sits inside the boot function), so
// anchoring on `\n});` at column 0 runs straight past it and swallows the next
// object literal in the file — which is how this first read 77 "hooks", most of
// them app.js's window contract.
const call = src.match(/configureHost\(\{([\s\S]*?)\n\s*\}\);/);
if (!call) return null; // no seam wired yet — fine until there is one
const wired = new Set();
for (const m of call[1].matchAll(/(?:^|,)\s*([A-Za-z_$][\w$]*)\s*(?=[,:}]|$)/gm)) {
wired.add(m[1]);
}
return wired;
}
test('every host.<hook> a module uses is wired by app.js', () => {
const used = hooksUsed();
if (used.size === 0) return; // no consumers yet
const wired = hooksWired();
assert.ok(wired, 'modules import ./host.js but app.js never calls configureHost({ … })');
const missing = [...used.keys()]
.filter((h) => !wired.has(h))
.map((h) => `${h} (used in ${used.get(h).join(', ')})`);
assert.deepEqual(
missing, [],
'these hooks are read by a module but never wired by app.js — they would throw at runtime, '
+ 'on whatever path happens to reach them',
);
});
test('every hook app.js wires is actually used by a module', () => {
const wired = hooksWired();
if (!wired || wired.size === 0) return;
const used = hooksUsed();
const unused = [...wired].filter((h) => !used.has(h));
assert.deepEqual(
unused, [],
'these hooks are wired by app.js but no module reads them — dead weight, and usually '
+ 'the fossil of a rename that left the other half behind',
);
});
+7 -2
View File
@@ -71,6 +71,11 @@ test('native audio-mix participant suppresses matching legacy fader and records
const ROOT = path.join(__dirname, '..', '..');
const APP_JS = path.join(ROOT, 'static', 'app.js');
// The plugin loader was carved out of app.js into its own module (R3a); the
// library-provider code below still lives in app.js.
const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js');
// The viz layer was carved out of app.js too (R3a).
const VIZ_JS = path.join(ROOT, 'static', 'js', 'viz.js');
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
function source(file) {
@@ -87,7 +92,7 @@ function region(src, needle, length = 1200) {
}
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
const src = source(APP_JS);
const src = source(PLUGIN_LOADER_JS);
const block = region(src, 'script.src = `/api/plugins/${plugin.id}/screen.js');
assert.match(block, /window\.feedBack\._loadingPluginId\s*=\s*plugin\.id/);
assert.match(block, /delete\s+window\.feedBack\._loadingPluginId/);
@@ -113,7 +118,7 @@ test('library providers route through native library capability', () => {
});
test('visualization renderer installs preserve plugin attribution', () => {
const src = source(APP_JS);
const src = source(VIZ_JS);
const tagger = region(src, 'function _tagVizRenderer(renderer, id)', 700);
const setViz = region(src, 'function setViz(id)', 3600);
const autoViz = region(src, 'function _autoMatchViz()', 5200);
+14 -1
View File
@@ -44,10 +44,18 @@ function buildSandbox() {
const seekCalls = [];
const sectionPracticeModeCalls = [];
const transportEvents = [];
// clearLoop() used to zero section-practice's three selection scalars by hand.
// They now live in static/js/section-practice.js, which owns them, so clearLoop
// calls its exported resetSelection() instead. This is a SPY, not a stub — the
// test below still asserts the reset happens, it just asserts it through the
// seam rather than by reaching into someone else's state.
const resetSelectionCalls = [];
const sandbox = {
seekCalls,
sectionPracticeModeCalls,
transportEvents,
resetSelectionCalls,
resetSelection: () => resetSelectionCalls.push(true),
// Mutable state (declared as `var` in eval prelude so it lives on
// the sandbox global and the extracted functions can read/write).
// The actual values are set below.
@@ -201,7 +209,7 @@ test('setLoop rejects b <= a', async () => {
await assert.rejects(() => sandbox.__setLoop(10, 5), /b > a/);
});
test('clearLoop resets loopA/loopB to null', async () => {
test('clearLoop resets loopA/loopB to null (and asks section-practice to drop its selection)', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
@@ -211,6 +219,11 @@ test('clearLoop resets loopA/loopB to null', async () => {
const { loopA, loopB } = sandbox.__getLoop();
assert.equal(loopA, null);
assert.equal(loopB, null);
assert.equal(
sandbox.resetSelectionCalls.length, 1,
'clearLoop must ask section-practice to drop its selection (it used to zero the '
+ 'scalars by hand; the module owns them now)',
);
assert.equal(sandbox.sectionPracticeModeCalls.length, 1);
assert.equal(sandbox.sectionPracticeModeCalls[0].on, false);
// Field-wise: vm-context objects break deepStrictEqual across realms.
+3 -3
View File
@@ -1,4 +1,4 @@
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
// Verify loadPlugins' plugin-DOM wipe loops in static/js/plugin-loader.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
@@ -14,7 +14,7 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.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.
@@ -40,7 +40,7 @@ function makeEl(pluginId, id) {
}
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
const block = extractWipeBlock(src);
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
const container = { children: settingsChildren };
+3 -3
View File
@@ -1,4 +1,4 @@
// Guards the R0 module-migration loader change in static/app.js: a migrated
// Guards the R0 module-migration loader change in static/js/plugin-loader.js: a migrated
// plugin (manifest scriptType:"module", surfaced as plugin.script_type) must be
// injected as <script type="module"> so its screen.js `import './src/main.js'`
// graph loads, while classic plugins stay untouched.
@@ -16,8 +16,8 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const src = fs.readFileSync(APP_JS, 'utf8');
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
// Isolate the screen.js <script> injection block: from where its src is built
// to where the element is appended.
+3 -3
View File
@@ -1,4 +1,4 @@
// Verify the plugin `styles` capability in static/app.js: _injectPluginStyles
// Verify the plugin `styles` capability in static/js/plugin-loader.js: _injectPluginStyles
// adds exactly one versioned <link rel="stylesheet"> per plugin, swaps it on a
// version upgrade (no duplicates, no stale tags), injects nothing for a plugin
// without `styles`, and routes the URL through the sandboxed asset endpoint.
@@ -9,7 +9,7 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
// Brace-balanced extraction of a `const NAME = (...) => { ... }` arrow, so a
// nested object/template literal can't make a naive regex stop early.
@@ -84,7 +84,7 @@ function setupSandbox() {
},
};
vm.createContext(sandbox);
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
const removeSrc = extractConstArrow(src, '_removePluginStyleTags');
const injectSrc = extractConstArrow(src, '_injectPluginStyles');
const reconcileSrc = extractConstArrow(src, '_reconcilePluginStyles');
+146 -11
View File
@@ -50,17 +50,42 @@ function makeFakeContext(sampleRate = 48000) {
this.mediaSourceEl = el;
return { connect() {}, disconnect() {} };
},
createMediaStreamSource(stream) {
this.mediaStreamSource = stream;
return { connect() {}, disconnect() {} };
},
close() { this.closed = true; return Promise.resolve(); },
};
return ctx;
}
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
const calls = { setRendererBus: [], pushRendererAudio: [] };
// Fake getDisplayMedia stream for the loopback-capture path.
function makeLoopbackStream({ suppressed = true } = {}) {
const stopped = [];
const audioTrack = {
kind: 'audio',
stop() { stopped.push('audio'); },
getSettings: () => (suppressed ? { suppressLocalAudioPlayback: true } : {}),
};
const videoTrack = { kind: 'video', stop() { stopped.push('video'); } };
return {
__stopped: stopped,
getAudioTracks: () => [audioTrack],
getVideoTracks: () => [videoTrack],
getTracks: () => [videoTrack, audioTrack],
};
}
// `displayMedia`: undefined → loopback capture unavailable (Docker sphere /
// old desktop main); a function → used as navigator.mediaDevices.getDisplayMedia.
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true, displayMedia } = {}) {
const calls = { setRendererBus: [], pushRendererAudio: [], setPageMuted: [] };
const api = {
isAudioRunning: () => Promise.resolve(isAudioRunning()),
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
setPageMuted: (m) => { calls.setPageMuted.push(m); return Promise.resolve(m); },
};
class FakeWorkletNode {
@@ -85,6 +110,7 @@ function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {
__createdContexts: [],
__audioEl: { id: 'audio' },
__calls: calls,
navigator: { mediaDevices: displayMedia ? { getDisplayMedia: displayMedia } : {} },
window: null,
};
sandbox.window = {
@@ -111,12 +137,21 @@ function makeStemsGraph() {
};
}
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
// Surface-mode (stems/element) tests run WITHOUT getDisplayMedia: the first
// tick probes loopback, fails, and latches _loopbackUnavailable; the second
// tick exercises the fallback surface mode. This mirrors an old desktop main
// without the display-media handler.
async function reevaluateWithFallback(sb) {
await sb.window._reevaluateRendererBus(); // loopback probe → unavailable
await sb.window._reevaluateRendererBus(); // surface fallback
}
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked (loopback unavailable)', async () => {
const sb = makeSandbox({ exclusive: () => true });
const graph = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = graph;
await sb.window._reevaluateRendererBus();
await reevaluateWithFallback(sb);
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
@@ -128,7 +163,7 @@ test('output returns to shared → bus disabled, sink restored', async () => {
const graph = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = graph;
await sb.window._reevaluateRendererBus();
await reevaluateWithFallback(sb);
excl = false;
await sb.window._reevaluateRendererBus();
@@ -145,26 +180,27 @@ test('stems graph + shared output → feeder stays off (no double audio)', async
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
});
test('element song + exclusive → element captured into bus', async () => {
test('element song + exclusive → element captured into bus (loopback unavailable)', async () => {
const sb = makeSandbox({ exclusive: () => true });
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
sb.window._juceMode = false;
await sb.window._reevaluateRendererBus();
await reevaluateWithFallback(sb);
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
});
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
test('native-transport song, loopback unavailable → surface modes stay off', async () => {
const sb = makeSandbox({ exclusive: () => true });
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
sb.window._juceMode = true;
await sb.window._reevaluateRendererBus();
await reevaluateWithFallback(sb);
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true),
'bus never ENABLED (failed-probe cleanup may disable it)');
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
});
@@ -172,7 +208,7 @@ test('stems graph replaced mid-engagement → re-engages on the new graph', asyn
const sb = makeSandbox({ exclusive: () => true });
const g1 = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = g1;
await sb.window._reevaluateRendererBus();
await reevaluateWithFallback(sb);
const g2 = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = g2;
@@ -182,6 +218,105 @@ test('stems graph replaced mid-engagement → re-engages on the new graph', asyn
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
});
// ── Loopback mode (whole-app capture) ────────────────────────────────────────
test('exclusive output + loopback available → engages without any song loaded', async () => {
const stream = makeLoopbackStream();
const sb = makeSandbox({ exclusive: () => true, displayMedia: () => Promise.resolve(stream) });
await sb.window._reevaluateRendererBus();
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled for whole session');
assert.ok(stream.__stopped.includes('video'), 'unused video track stopped');
assert.equal(sb.__createdContexts.at(-1)?.mediaStreamSource, stream, 'loopback stream captured');
assert.equal(sb.__calls.setPageMuted.length, 0, 'suppress constraint honoured — no page mute');
});
test('loopback context is closed on disengage (no orphaned tap worklet)', async () => {
let excl = true;
const stream = makeLoopbackStream();
const sb = makeSandbox({ exclusive: () => excl, displayMedia: () => Promise.resolve(stream) });
await sb.window._reevaluateRendererBus(); // engage loopback
const lbCtx = sb.__createdContexts.at(-1);
assert.equal(lbCtx?.mediaStreamSource, stream, 'loopback engaged');
assert.notEqual(lbCtx.closed, true, 'context live while engaged');
excl = false;
await sb.window._reevaluateRendererBus(); // disengage
assert.equal(lbCtx.closed, true, 'loopback context closed on disengage');
assert.ok(stream.__stopped.includes('audio'), 'capture stream stopped');
});
test('loopback preferred over stems when both available', async () => {
const stream = makeLoopbackStream();
const sb = makeSandbox({ exclusive: () => true, displayMedia: () => Promise.resolve(stream) });
const graph = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = graph;
await sb.window._reevaluateRendererBus();
assert.equal(graph.context.sinkIdCalls.length, 0, 'stems ctx untouched — loopback owns capture');
assert.equal(sb.__createdContexts.at(-1)?.mediaStreamSource, stream, 'loopback engaged');
});
test('suppressLocalAudioPlayback unsupported → page-mute fallback, unmuted on disengage', async () => {
let excl = true;
const stream = makeLoopbackStream({ suppressed: false });
const sb = makeSandbox({ exclusive: () => excl, displayMedia: () => Promise.resolve(stream) });
await sb.window._reevaluateRendererBus();
assert.deepEqual(sb.__calls.setPageMuted, [true], 'page muted as fallback');
excl = false;
await sb.window._reevaluateRendererBus();
assert.deepEqual(sb.__calls.setPageMuted, [true, false], 'page unmuted on disengage');
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled');
});
test('getDisplayMedia rejected → sticky fallback to surface modes', async () => {
const sb = makeSandbox({
exclusive: () => true,
displayMedia: () => Promise.reject(new DOMException('denied', 'NotAllowedError')),
});
const graph = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = graph;
await sb.window._reevaluateRendererBus(); // probe fails, latches unavailable
await sb.window._reevaluateRendererBus(); // falls back to stems
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems fallback engaged');
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled via fallback');
});
test('element capture collision (createMediaElementSource throws) → no poisoned state, clean retry', async () => {
const sb = makeSandbox({ exclusive: () => true }); // loopback unavailable
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
// First capture attempt collides (highway analyser owns the element).
let collide = true;
const origFactory = sb.AudioContext;
sb.__createdContexts.length = 0;
// Patch contexts so createMediaElementSource throws while colliding.
sb.AudioContext = function () {
const c = origFactory();
const orig = c.createMediaElementSource.bind(c);
c.createMediaElementSource = (el) => {
if (collide) throw new DOMException('already connected', 'InvalidStateError');
return orig(el);
};
c.close = () => Promise.resolve();
return c;
};
await reevaluateWithFallback(sb); // element engage fails (collision)
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true), 'bus never left enabled');
collide = false;
await sb.window._reevaluateRendererBus(); // retry succeeds — no TypeError, fresh ctx
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'element engaged after collision cleared');
});
test('engine stops → bus disabled', async () => {
let running = true;
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
+3 -2
View File
@@ -15,9 +15,10 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
// _installSectionPracticeDismiss was carved out of app.js into its own module (R3a).
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'js', 'section-practice.js'), 'utf8');
const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/);
assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js');
assert.ok(m, '_installSectionPracticeDismiss() not found in static/js/section-practice.js');
const body = m[0];
test('the outside-click dismiss binds in the CAPTURE phase', () => {
-8
View File
@@ -14,7 +14,6 @@ const { extractFunction } = require('./test_utils');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
const V2_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
const sandbox = {
@@ -138,10 +137,3 @@ test('V3 transport restart button exists with correct attributes', () => {
assert.match(html, /title="Restart song"/);
assert.match(html, /aria-label="Restart song"/);
});
test('V2 transport restart button exists with correct attributes', () => {
const html = fs.readFileSync(V2_HTML, 'utf8');
assert.match(html, /#player-controls|player-controls[\s\S]*onclick="restartCurrentSong\(\)"/);
assert.match(html, /title="Restart song"/);
assert.match(html, /aria-label="Restart song"/);
});
+9 -6
View File
@@ -7,20 +7,23 @@ const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The tuning-display helpers were carved out of app.js into their own module (R3a);
// the autoplay-gate test below still reads app.js.
const TUNING_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
const TUNING_UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'tuning-utils.js');
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
function loadTuningHelpers() {
const src = fs.readFileSync(APP_JS, 'utf8');
const start = src.indexOf('function isBassArrangement(');
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
const end = src.indexOf(endMarker);
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
const src = fs.readFileSync(TUNING_JS, 'utf8');
// The module is nothing BUT the tuning helpers now, so there is no block to
// slice out — take it whole. `export` is stripped so the vm sandbox can still
// evaluate it as a plain script (the window.* contract lives in app.js).
const body = src.replace(/^export /gm, '');
const sandbox = { window: { feedBack: {} }, exports: {} };
vm.createContext(sandbox);
vm.runInContext(
src.slice(start, end + endMarker.length),
body,
sandbox
);
return sandbox.window.feedBack;
+2 -7
View File
@@ -6,10 +6,10 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The tuning-display helpers were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
const V2_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
function extractBlock(src, startMarker) {
const start = src.indexOf(startMarker);
@@ -97,11 +97,6 @@ test('V3 index.html defines hud-tuning', () => {
assert.match(html, /id="hud-tuning"/);
});
test('V2 index.html defines hud-tuning', () => {
const html = fs.readFileSync(V2_HTML, 'utf8');
assert.match(html, /id="hud-tuning"/);
});
test('highway.js updates hud-tuning from song_info tuning offsets', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
assert.match(src, /getElementById\('hud-tuning'\)/);
+7 -7
View File
@@ -6,23 +6,23 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The tuning-display helpers were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
const V2_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
function loadTuningHelpers() {
const src = fs.readFileSync(APP_JS, 'utf8');
const start = src.indexOf('function isBassArrangement(');
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
const end = src.indexOf(endMarker);
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
// The module is nothing BUT the tuning helpers now, so there is no block to
// slice out — take it whole. `export` is stripped so the vm sandbox can still
// evaluate it as a plain script (the window.* contract lives in app.js).
const body = src.replace(/^export /gm, '');
const sandbox = { window: { feedBack: {} }, exports: {} };
vm.createContext(sandbox);
vm.runInContext(
src.slice(start, end + endMarker.length) + '\n'
body + '\n'
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
+ 'exports.displayTuningTargetDetails = displayTuningTargetDetails;\n'
+ 'exports.isBassArrangement = isBassArrangement;\n'
+7 -6
View File
@@ -7,7 +7,8 @@ const path = require('node:path');
const vm = require('node:vm');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The tuning-display helpers were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
function extractBlock(src, startMarker) {
const start = src.indexOf(startMarker);
@@ -27,14 +28,14 @@ function extractBlock(src, startMarker) {
function loadTuningHelpers() {
const src = fs.readFileSync(APP_JS, 'utf8');
const start = src.indexOf('function _looksLikeRawTuningOffsets(');
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
const end = src.indexOf(endMarker);
if (start === -1 || end === -1) throw new Error('tuning helpers not found');
// The module is nothing BUT the tuning helpers now, so there is no block to
// slice out — take it whole. `export` is stripped so the vm sandbox can still
// evaluate it as a plain script (the window.* contract lives in app.js).
const body = src.replace(/^export /gm, '');
const sandbox = { window: { feedBack: {} }, exports: {} };
vm.createContext(sandbox);
vm.runInContext(
src.slice(start, end + endMarker.length) + '\n'
body + '\n'
+ 'exports.displayTuningName = displayTuningName;\n'
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
+ 'exports.parseRawTuningOffsets = parseRawTuningOffsets;',
+5 -2
View File
@@ -9,6 +9,9 @@ const venueScene = require('../../static/v3/venue-scene-3d.js');
const venueViz = require('../../static/v3/venue-viz.js');
const pov = require('../../static/v3/venue-instrument-pov.js');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The viz layer (setViz / the venue option / the picker) was carved out of
// app.js into its own module (R3a).
const VIZ_JS = path.join(__dirname, '..', '..', 'static', 'js', 'viz.js');
const H3D_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
const ASSET_DIR = path.join(__dirname, '..', '..', 'static', 'assets', 'venue', 'themes', 'small-club');
@@ -185,8 +188,8 @@ test('venue-scene-3d exports bg plate asset ids', () => {
assert.equal(venueScene.ASSET_BASE, '/static/assets/venue/themes/small-club/');
});
test('app.js syncs venue 3D scene on viz changes', () => {
const src = fs.readFileSync(APP_JS, 'utf8');
test('viz.js syncs venue 3D scene on viz changes', () => {
const src = fs.readFileSync(VIZ_JS, 'utf8');
assert.match(src, /v3VenueScene3d\.syncViz\('venue'\)/);
assert.match(src, /v3VenueScene3d\.syncViz\(id\)/);
});
+8 -6
View File
@@ -8,6 +8,8 @@ const path = require('node:path');
const venueViz = require('../../static/v3/venue-viz.js');
const venue = require('../../static/v3/venue-mood-fx.js');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The viz layer was carved out of app.js into its own module (R3a).
const VIZ_JS = path.join(__dirname, '..', '..', 'static', 'js', 'viz.js');
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
const V3_CSS = path.join(__dirname, '..', '..', 'static', 'v3', 'v3.css');
@@ -139,8 +141,8 @@ test('index.html contains in-player venue placeholder markup', () => {
assert.match(html, /id="v3-venue-scene-wash"/);
});
test('app.js adds Venue visualization option and adapter', () => {
const src = fs.readFileSync(APP_JS, 'utf8');
test('viz.js adds Venue visualization option and adapter', () => {
const src = fs.readFileSync(VIZ_JS, 'utf8');
assert.match(src, /function _ensureVenueVizOption/);
assert.match(src, /opt\.value = 'venue'/);
assert.match(src, /opt\.textContent = 'Venue'/);
@@ -210,15 +212,15 @@ test('venue mood source documents strip overlay disabled', () => {
assert.match(source, /v3-venue-mode-badge/);
});
test('app.js preserves plugin viz population for drum/tab/piano highways', () => {
const src = fs.readFileSync(APP_JS, 'utf8');
test('viz.js preserves plugin viz population for drum/tab/piano highways', () => {
const src = fs.readFileSync(VIZ_JS, 'utf8');
assert.match(src, /p\.type === 'visualization'/);
assert.match(src, /feedBackViz_/);
assert.match(src, /BUILTIN_OPT_VALUES/);
});
test('venue option remains distinct from highway_3d in app adapter', () => {
const src = fs.readFileSync(APP_JS, 'utf8');
test('venue option remains distinct from highway_3d in viz adapter', () => {
const src = fs.readFileSync(VIZ_JS, 'utf8');
assert.match(src, /if \(id === 'venue'\)/);
assert.doesNotMatch(src, /if \(id === 'venue'\)[\s\S]{0,400}sel\.value = 'highway_3d'/);
});
+95
View File
@@ -0,0 +1,95 @@
// Guards app.js's `window` contract ahead of the R3a ES-module flip.
//
// app.js is a classic script, so every top-level `function foo()` is implicitly
// a property of `window`. As an ES module it will not be — module scope is not
// global scope. Any name reached from OUTSIDE app.js must therefore be an
// explicit `window.foo = …` before the flip, or it vanishes silently.
//
// "Silently" is the whole problem. A missing inline handler is a ReferenceError
// only when someone clicks the button; a `typeof window.setViz !== 'function'`
// guard (capabilities/visualization.js) just degrades and says nothing. Neither
// shows up in a test run, so this file is the thing standing between a dropped
// name and a dead button in production.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const APP_JS = fs.readFileSync(path.join(ROOT, 'static', 'app.js'), 'utf8');
const V3_HTML = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'index.html'), 'utf8');
// Every name app.js publishes: the scattered `window.foo = …` assignments plus
// the consolidated `Object.assign(window, { … })` contract block at the bottom.
function exposedNames() {
const names = new Set(
[...APP_JS.matchAll(/^window\.([A-Za-z_$][\w$]*)\s*=/gm)].map((m) => m[1]),
);
const block = APP_JS.match(/Object\.assign\(window, \{([\s\S]*?)\n\}\);/);
assert.ok(block, 'the Object.assign(window, …) contract block is missing from app.js');
// Strip the comments first — the prose inside them is full of words that
// would otherwise scrape as identifiers.
const body = block[1].replace(/\/\/[^\n]*/g, '');
for (const m of body.matchAll(/([A-Za-z_$][\w$]*)\s*(?=,|$)/gm)) names.add(m[1]);
return names;
}
// app.js's own top-level `function foo()` declarations — the names that stop
// being global under `type="module"`.
function topLevelFunctions() {
return new Set(
[...APP_JS.matchAll(/^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm)].map((m) => m[1]),
);
}
const HANDLER = /on(?:click|change|input|submit|keyup|keydown|mousedown|error|focus|blur)\s*=\s*"([A-Za-z_$][\w$]*)/g;
test('every inline on*= handler in the v3 shell is on window', () => {
const exposed = exposedNames();
const owned = topLevelFunctions();
const missing = [...V3_HTML.matchAll(HANDLER)]
.map((m) => m[1])
.filter((n) => owned.has(n) && !exposed.has(n));
assert.deepEqual([...new Set(missing)], [], 'inline handlers that would break under type="module"');
});
test('every on*= handler app.js builds in a template literal is on window', () => {
// e.g. `<button onclick="goFavPage(${p})">` — these resolve against window at
// CLICK time, exactly like the ones written into the HTML, but they live in a
// JS string so scanning index.html alone never finds them.
const exposed = exposedNames();
const owned = topLevelFunctions();
const missing = [...APP_JS.matchAll(HANDLER)]
.map((m) => m[1])
.filter((n) => owned.has(n) && !exposed.has(n));
assert.deepEqual([...new Set(missing)], [], 'generated handlers that would break under type="module"');
});
test('the runtime-composed handler names are on window', () => {
// app.js:2156-2157 chooses the handler NAME at runtime:
// const letterFn = favoritesOnly ? 'filterFavTreeLetter' : 'filterTreeLetter';
// const pageFn = favoritesOnly ? 'goFavTreePage' : 'goTreePage';
// then interpolates it: `onclick="${letterFn}('A')"`.
//
// ponytail: hardcoded on purpose. These names exist only inside string
// literals, so the two scans above cannot see them, and neither can ESLint,
// no-undef, or a grep for `onclick="fn`. They are the library AZ rail and
// its pagination — drop one and those buttons throw on click and nowhere
// else. If that ternary ever gains a branch, add the new name here too.
const exposed = exposedNames();
for (const name of ['filterTreeLetter', 'filterFavTreeLetter', 'goTreePage', 'goFavTreePage']) {
assert.ok(exposed.has(name), `window.${name} is required by the runtime-composed AZ rail / pagination handlers`);
}
});
test('cross-file window.* readers still resolve', () => {
// Names other core scripts read off window. capabilities/visualization.js is
// the cautionary one: it reads window.setViz behind a `typeof` guard, so
// losing it degrades the visualization capability in SILENCE rather than
// throwing.
const exposed = exposedNames();
for (const name of ['setViz', 'showScreen', 'playSong', 'uiPrompt', '_confirmDialog', 'loadPlugins']) {
assert.ok(exposed.has(name), `window.${name} is read by another file`);
}
});
+18 -16
View File
@@ -12,6 +12,8 @@ tests/test_art_layer.py.
"""
import importlib
import enrichment
from routers import art
import io as _io
import sys
@@ -119,8 +121,8 @@ def caa_index(server, monkeypatch):
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)
monkeypatch.setattr(enrichment, "_caa_release_index", fake)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
return fake
@@ -251,7 +253,7 @@ def test_caa_candidates_capped_at_12(server, client, caa_index):
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
assert len(_caa(_get(client))) == art._ART_PICKER_MAX_CAA == 12
def test_demo_mode_blocks_candidates(server, client, monkeypatch):
@@ -274,10 +276,10 @@ 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()
art_dir = enrichment._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 not enrichment._CAA_ID_RE.match("../../etc/x")
assert enrichment._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.
@@ -340,10 +342,10 @@ def test_fetch_art_url_follows_redirects_validating_each_hop(server, monkeypatch
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",
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(art, "_url_host_is_internal",
lambda u: (checked.append(u), False)[1])
data = server._fetch_art_url("https://coverartarchive.example/release/x/front-500")
data = art._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"]
@@ -354,18 +356,18 @@ 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",
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(art, "_url_host_is_internal",
lambda u: "internal" in u)
with pytest.raises(ValueError):
server._fetch_art_url("https://public.example/x.png")
art._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")
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(art, "_url_host_is_internal", lambda u: False)
with pytest.raises(enrichment.EnrichTransportError):
art._fetch_art_url("https://public.example/x.png")
+30 -28
View File
@@ -7,6 +7,8 @@ here opens a socket, and the offline default is itself asserted.
"""
import importlib
import enrichment
from routers import art
import io as _io
import sys
@@ -121,7 +123,7 @@ def test_bad_upload_rejected(server, client):
def test_art_url_fetches_and_overrides(server, client, monkeypatch):
make_sloppak(server, "a.sloppak", with_cover=True)
monkeypatch.setattr(server, "_fetch_art_url", lambda url: png_bytes((9, 9, 9)))
monkeypatch.setattr(art, "_fetch_art_url", lambda url: png_bytes((9, 9, 9)))
body = client.post("/api/song/a.sloppak/art/url",
json={"url": "https://example.com/cover.png"}).json()
assert body == {"ok": True, "kind": "png"}
@@ -138,7 +140,7 @@ def test_art_url_validation(server, client, monkeypatch):
# Oversize → 400 (the seam raises ValueError at the cap).
def _huge(url):
raise ValueError("image larger than 10 MB")
monkeypatch.setattr(server, "_fetch_art_url", _huge)
monkeypatch.setattr(art, "_fetch_art_url", _huge)
assert client.post("/api/song/a.sloppak/art/url",
json={"url": "https://example.com/x.png"}).status_code == 400
@@ -176,15 +178,15 @@ def caa(server, monkeypatch):
calls.append(release_id)
return art.get(release_id)
fake.calls, fake.art = calls, art
monkeypatch.setattr(server, "_caa_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(enrichment, "_caa_http_get", fake)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
return fake
def test_caa_fetch_fills_missing_art(server, client, caa):
make_sloppak(server, "a.sloppak") # no pack art
_match_row(server, "a.sloppak")
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["art_state"] == "caa"
assert row["art_cache_path"] and row["art_cache_path"].endswith("caa_rel-1.jpg")
@@ -193,7 +195,7 @@ def test_caa_fetch_fills_missing_art(server, client, caa):
assert r.headers["content-type"] == "image/jpeg"
# Settled: the next pass never re-fetches.
n = len(caa.calls)
server._background_enrich()
enrichment._background_enrich()
assert len(caa.calls) == n
@@ -204,7 +206,7 @@ def test_caa_skips_pack_art_and_dedupes_by_release(server, caa):
_match_row(server, "haspack.sloppak")
_match_row(server, "b.sloppak") # same release as c
_match_row(server, "c.sloppak")
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("haspack.sloppak")["art_state"] == "pack"
assert server.meta_db.get_enrichment("b.sloppak")["art_state"] == "caa"
assert server.meta_db.get_enrichment("c.sloppak")["art_state"] == "caa"
@@ -214,10 +216,10 @@ def test_caa_skips_pack_art_and_dedupes_by_release(server, caa):
def test_caa_404_marks_none(server, caa):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-missing")
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "none"
n = len(caa.calls)
server._background_enrich()
enrichment._background_enrich()
assert len(caa.calls) == n # never re-hammered
@@ -226,13 +228,13 @@ def test_caa_transport_error_leaves_row_unevaluated(server, caa, monkeypatch):
_match_row(server, "a.sloppak")
def _down(release_id):
raise server.EnrichTransportError("down")
monkeypatch.setattr(server, "_caa_http_get", _down)
server._background_enrich()
raise enrichment.EnrichTransportError("down")
monkeypatch.setattr(enrichment, "_caa_http_get", _down)
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
# Network back → next pass completes it.
monkeypatch.setattr(server, "_caa_http_get", caa)
server._background_enrich()
monkeypatch.setattr(enrichment, "_caa_http_get", caa)
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
@@ -240,22 +242,22 @@ def test_offline_default_skips_art_worker(server, monkeypatch):
"""Under the plain test env the whole art phase is skipped with the rest
of the network work."""
calls = []
monkeypatch.setattr(server, "_caa_http_get", lambda rid: calls.append(rid))
monkeypatch.setattr(enrichment, "_caa_http_get", lambda rid: calls.append(rid))
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak")
server._background_enrich()
enrichment._background_enrich()
assert calls == []
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
def test_lru_prune_evicts_oldest_and_resets_rows(server, caa, monkeypatch):
monkeypatch.setattr(server, "_CAA_CACHE_CAP_BYTES", 1) # everything over cap
monkeypatch.setattr(enrichment, "_CAA_CACHE_CAP_BYTES", 1) # everything over cap
make_sloppak(server, "a.sloppak", title="One")
make_sloppak(server, "b.sloppak", title="Two")
caa.art["rel-2"] = png_bytes((1, 1, 1))
_match_row(server, "a.sloppak", release_id="rel-1")
_match_row(server, "b.sloppak", release_id="rel-2")
server._background_enrich()
enrichment._background_enrich()
# With a 1-byte cap every fetch immediately evicts — the rows that pointed
# at evicted files were reset to unevaluated.
caa_files = list(server.ART_CACHE_DIR.glob("caa_*.jpg"))
@@ -282,13 +284,13 @@ def test_delete_override_restores_caa_fallback(server, client, caa):
_match_row(server, "a.sloppak")
# Pin an override BEFORE the art worker runs → the pass stamps art_state='user'.
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "user"
# Remove it → the row resets to unevaluated…
assert client.delete("/api/art/a.sloppak/override").json()["removed"]
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
# …and the next pass fetches + serves the release's front cover.
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
r = client.get("/api/song/a.sloppak/art")
assert r.status_code == 200
@@ -302,7 +304,7 @@ def test_upload_rejects_unknown_song_and_oversize(server, client):
assert server._art_override_paths("ghost.sloppak") == []
# Oversize decoded payload → 400 (bounds the base64 upload path).
make_sloppak(server, "a.sloppak")
huge = b64(b"\x00" * (server._ART_URL_MAX_BYTES + 1))
huge = b64(b"\x00" * (art._ART_URL_MAX_BYTES + 1))
assert client.post("/api/song/a.sloppak/art/upload",
json={"image": huge}).status_code == 400
@@ -310,10 +312,10 @@ def test_upload_rejects_unknown_song_and_oversize(server, client):
def test_fetch_art_url_blocks_internal_hosts(server):
"""The SSRF guard refuses loopback / link-local / private targets before
any request is made (the real seam, not the faked one)."""
assert server._url_host_is_internal("http://127.0.0.1/x.png")
assert server._url_host_is_internal("http://localhost/x.png")
assert server._url_host_is_internal("http://169.254.169.254/latest/meta-data")
assert server._url_host_is_internal("http://10.0.0.5/x.png")
assert server._url_host_is_internal("http://[::1]/x.png")
assert server._url_host_is_internal("http://nonexistent.invalid/x.png") # unresolvable → closed
assert not server._url_host_is_internal("http://93.184.216.34/x.png") # public literal
assert art._url_host_is_internal("http://127.0.0.1/x.png")
assert art._url_host_is_internal("http://localhost/x.png")
assert art._url_host_is_internal("http://169.254.169.254/latest/meta-data")
assert art._url_host_is_internal("http://10.0.0.5/x.png")
assert art._url_host_is_internal("http://[::1]/x.png")
assert art._url_host_is_internal("http://nonexistent.invalid/x.png") # unresolvable → closed
assert not art._url_host_is_internal("http://93.184.216.34/x.png") # public literal
+5 -4
View File
@@ -10,7 +10,7 @@ Two halves, mirroring the design's split:
* 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
`enrichment._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
@@ -19,6 +19,7 @@ Two halves, mirroring the design's split:
"""
import importlib
import enrichment
import json
import sys
from urllib.parse import quote
@@ -88,7 +89,7 @@ class FakeMBArtist:
def __call__(self, path, params):
if self.raise_transport:
raise self._srv.EnrichTransportError("fake network down")
raise enrichment.EnrichTransportError("fake network down")
self.calls.append((path, dict(params)))
if path == f"artist/{MBID}":
return self.doc
@@ -100,8 +101,8 @@ 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)
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
return fake
+2
View File
@@ -25,6 +25,7 @@ def client_and_server(tmp_path, monkeypatch):
static_tmp = tmp_path / "static"
static_tmp.mkdir()
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
monkeypatch.setattr(server.appstate, "static_dir", static_tmp)
# Pass client=("127.0.0.1", 50000) so request.client.host is a loopback address
test_client = TestClient(server.app, client=("127.0.0.1", 50000))
try:
@@ -123,6 +124,7 @@ def dlc_client(tmp_path, monkeypatch):
static_tmp = tmp_path / "static"
static_tmp.mkdir()
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
monkeypatch.setattr(server.appstate, "static_dir", static_tmp)
tc = TestClient(server.app, client=("127.0.0.1", 50000))
try:
yield tc, server, dlc
+2 -1
View File
@@ -7,6 +7,7 @@ result, not stored songs.
"""
import importlib
import library_registry
import sys
import pytest
@@ -133,7 +134,7 @@ def test_collection_tolerates_corrupt_persisted_rules(client, server_mod):
('{"artist": [], "sort": [], "tunings": ["Drop D"]}', cid),
)
server_mod.meta_db.conn.commit()
server_mod._sync_collection_provider(server_mod.meta_db.get_collection(cid))
library_registry._sync_collection_provider(server_mod.meta_db.get_collection(cid))
r = client.get("/api/library", params={"provider": f"collection:{cid}"})
assert r.status_code == 200 # no 500/503 from bad rules
+4 -3
View File
@@ -4,6 +4,7 @@ contents). The refresh flow reuses the P8 fake-transport pattern — nothing
here opens a socket."""
import importlib
import enrichment
import sys
import pytest
@@ -85,9 +86,9 @@ def test_refresh_resets_even_a_manual_pin_and_rematches(server, client, monkeypa
"status": "Official", "date": "1990-09-24",
"release-group": {"primary-type": "Album"}}],
}]}
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
server._background_enrich()
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["mb_recording_id"] == "rec-new"
+33 -32
View File
@@ -5,6 +5,7 @@ contracts it will inherit: rename-survivable idempotent hashing, manual rows
never auto-reset, never purged on rescan, purged on explicit delete."""
import importlib
import enrichment
import sys
import pytest
@@ -58,7 +59,7 @@ def test_pending_covers_new_unscanned_and_changed(server):
_put(server, "a.archive")
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
# stubbed → still unscanned → still pending (the matcher hasn't run)
server._background_enrich()
enrichment._background_enrich()
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
# a matched row with the CURRENT hash is settled…
h = server.meta_db.enrichment_content_hash("Artist", "Song", "", 100)
@@ -76,7 +77,7 @@ def test_pending_covers_new_unscanned_and_changed(server):
def test_hash_change_resets_matched_but_never_manual(server):
_put(server, "a.archive")
_put(server, "b.archive", title="Other")
server._background_enrich()
enrichment._background_enrich()
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state = 'matched' WHERE filename = 'a.archive'")
@@ -86,7 +87,7 @@ def test_hash_change_resets_matched_but_never_manual(server):
# identity edits…
_put(server, "a.archive", title="Song v2")
_put(server, "b.archive", title="Other v2")
server._background_enrich()
enrichment._background_enrich()
a = server.meta_db.get_enrichment("a.archive")
b = server.meta_db.get_enrichment("b.archive")
# …drop a stale MATCH back to unscanned with the fresh hash
@@ -99,7 +100,7 @@ def test_hash_change_resets_matched_but_never_manual(server):
def test_failed_rows_not_requeued_by_pending(server):
_put(server, "a.archive")
server._background_enrich()
enrichment._background_enrich()
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state = 'failed' WHERE filename = 'a.archive'")
@@ -113,7 +114,7 @@ def test_failed_rows_not_requeued_by_pending(server):
def test_enrich_pass_stamps_every_song(server):
for i in range(5):
_put(server, f"s{i}.archive", title=f"Song {i}")
server._background_enrich()
enrichment._background_enrich()
for i in range(5):
row = server.meta_db.get_enrichment(f"s{i}.archive")
assert row is not None
@@ -126,7 +127,7 @@ def test_enrich_pass_stamps_every_song(server):
def test_rescan_never_purges_enrichment(server):
_put(server, "a.archive")
server._background_enrich()
enrichment._background_enrich()
server.meta_db.delete_missing(set()) # file vanished from a scan snapshot
assert server.meta_db.get_enrichment("a.archive") is not None # row survives
# …and is invisible in the read-time-filtered counts
@@ -138,7 +139,7 @@ def test_rescan_never_purges_enrichment(server):
def test_status_endpoint_counts(client, server):
_put(server, "a.archive")
_put(server, "b.archive", title="Other")
server._background_enrich()
enrichment._background_enrich()
body = client.get("/api/enrichment/status").json()
assert body["states"] == {"unscanned": 2}
assert body["total_songs"] == 2
@@ -147,7 +148,7 @@ def test_status_endpoint_counts(client, server):
def test_art_cache_dir_created(server):
d = server._enrichment_art_dir()
d = enrichment._enrichment_art_dir()
assert d.is_dir()
assert d.name == "art_cache"
@@ -156,7 +157,7 @@ def test_art_cache_dir_created(server):
def test_states_for_returns_only_known_filenames(server):
_put(server, "a.archive")
server._background_enrich()
enrichment._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([]) == {}
@@ -165,7 +166,7 @@ def test_states_for_returns_only_known_filenames(server):
def test_states_endpoint(client, server):
_put(server, "a.archive")
_put(server, "b.archive", title="Other")
server._background_enrich()
enrichment._background_enrich()
body = client.post("/api/enrichment/states",
json={"filenames": ["a.archive", "zzz.missing"]}).json()
assert body["states"] == {"a.archive": "unscanned"}
@@ -175,7 +176,7 @@ def test_states_endpoint(client, server):
def test_status_exposes_progress_fields(client, server):
_put(server, "a.archive")
server._background_enrich()
enrichment._background_enrich()
body = client.get("/api/enrichment/status").json()
for k in ("total", "matched", "current", "cancelling"):
assert k in body
@@ -186,7 +187,7 @@ 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
assert enrichment._enrich_cancel.is_set() is False
def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
@@ -195,28 +196,28 @@ def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
# 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)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
calls = []
def fake_enrich_one(row, **_kw):
calls.append(row["filename"])
server._enrich_cancel.set()
enrichment._enrich_cancel.set()
monkeypatch.setattr(server, "_enrich_one", fake_enrich_one)
server._enrich_cancel.clear()
server._background_enrich()
monkeypatch.setattr(enrichment, "_enrich_one", fake_enrich_one)
enrichment._enrich_cancel.clear()
enrichment._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
assert enrichment._enrich_status["total"] == 4
assert enrichment._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()
enrichment._background_enrich()
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state='matched' WHERE filename='a.archive'")
@@ -240,7 +241,7 @@ def test_rematch_requeues_visible_but_skips_manual(server, client):
# ── filename-derived artist/title fallback (blank-artist packs) ───────────────
def test_filename_artist_title_parse(server):
f = server._artist_title_from_filename
f = enrichment._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"}
@@ -255,18 +256,18 @@ 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: {})
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(enrichment, "_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)
monkeypatch.setattr(enrichment, "_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)
enrichment._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"}
@@ -276,18 +277,18 @@ 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: {})
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(enrichment, "_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)
monkeypatch.setattr(enrichment, "_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)
enrichment._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"}
@@ -295,7 +296,7 @@ def test_present_artist_is_not_overridden_by_filename(server, monkeypatch):
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()
enrichment._enrich_cancel.set()
enrichment._kick_enrich()
server._join_background_db_threads()
assert server._enrich_cancel.is_set() is False
assert enrichment._enrich_cancel.is_set() is False
+13 -13
View File
@@ -4,22 +4,22 @@ from env_compat import getenv_compat, env_flag_compat
def test_canonical_wins_over_legacy(monkeypatch):
monkeypatch.setenv("FEEDBACK_UI", "v3")
monkeypatch.setenv("SLOPSMITH_UI", "v2")
assert getenv_compat("FEEDBACK_UI") == "v3"
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "canonical")
monkeypatch.setenv("SLOPSMITH_DEMO_MODE", "legacy")
assert getenv_compat("FEEDBACK_DEMO_MODE") == "canonical"
def test_legacy_fallback_when_canonical_unset(monkeypatch):
monkeypatch.delenv("FEEDBACK_UI", raising=False)
monkeypatch.setenv("SLOPSMITH_UI", "v2")
assert getenv_compat("FEEDBACK_UI") == "v2"
monkeypatch.delenv("FEEDBACK_DEMO_MODE", raising=False)
monkeypatch.setenv("SLOPSMITH_DEMO_MODE", "legacy")
assert getenv_compat("FEEDBACK_DEMO_MODE") == "legacy"
def test_default_when_neither_set(monkeypatch):
monkeypatch.delenv("FEEDBACK_UI", raising=False)
monkeypatch.delenv("SLOPSMITH_UI", raising=False)
assert getenv_compat("FEEDBACK_UI", "default") == "default"
assert getenv_compat("FEEDBACK_UI") is None
monkeypatch.delenv("FEEDBACK_DEMO_MODE", raising=False)
monkeypatch.delenv("SLOPSMITH_DEMO_MODE", raising=False)
assert getenv_compat("FEEDBACK_DEMO_MODE", "default") == "default"
assert getenv_compat("FEEDBACK_DEMO_MODE") is None
def test_non_feedback_names_have_no_fallback(monkeypatch):
@@ -31,9 +31,9 @@ def test_non_feedback_names_have_no_fallback(monkeypatch):
def test_empty_canonical_is_respected_not_overridden(monkeypatch):
# An explicitly-empty canonical value is still "set" and wins.
monkeypatch.setenv("FEEDBACK_UI", "")
monkeypatch.setenv("SLOPSMITH_UI", "v2")
assert getenv_compat("FEEDBACK_UI") == ""
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "")
monkeypatch.setenv("SLOPSMITH_DEMO_MODE", "legacy")
assert getenv_compat("FEEDBACK_DEMO_MODE") == ""
def test_flag_parses_true_values(monkeypatch):
+2 -1
View File
@@ -14,6 +14,7 @@ back-compat for `.sloppak` libraries or stop accepting the new `.feedpak`:
from __future__ import annotations
import importlib
from routers import settings as settings_router
import io
import sys
import zipfile
@@ -242,7 +243,7 @@ def test_settings_dlc_count_includes_both_suffixes(tmp_path, settings_server):
(dlc / "c.FEEDPAK").write_bytes(b"") # case-insensitive (suffix.lower())
(dlc / "notes.txt").write_bytes(b"") # ignored
result = settings_server.save_settings({"dlc_dir": str(dlc)})
result = settings_router.save_settings({"dlc_dir": str(dlc)})
assert "error" not in result, result
# save_settings joins its notices into a single ``message`` string.
+3 -2
View File
@@ -6,6 +6,7 @@ song (delete_song). Locks pin a field against a later auto-match.
"""
import importlib
import enrichment
import sys
import pytest
@@ -149,7 +150,7 @@ def test_locked_fields_reader(server):
def test_compose_lock_filter_strips_locked_cand_keys(server):
f = server._compose_lock_filter(None, {"artist", "year"})
f = enrichment._compose_lock_filter(None, {"artist", "year"})
cand = {"recording_id": "r", "artist": "X", "artist_sort": "X", "title": "T",
"year": "1990", "album": "A", "genres": ["rock"]}
out = f(cand)
@@ -158,7 +159,7 @@ def test_compose_lock_filter_strips_locked_cand_keys(server):
# …identity + unlocked display fields survive
assert out["recording_id"] == "r" and out["title"] == "T" and out["album"] == "A"
# no locks → base filter returned unchanged (zero-copy common path)
assert server._compose_lock_filter(None, set()) is None
assert enrichment._compose_lock_filter(None, set()) is None
# ── display overlay in the grid (slice 3) ─────────────────────────────────────
+53 -52
View File
@@ -1,6 +1,6 @@
"""Server-level tests for the P8 MusicBrainz matcher + Match-Review flow.
The HTTP transport is a fake installed over `server._mb_http_get` the ONE
The HTTP transport is a fake installed over `enrichment._mb_http_get` the ONE
seam enrichment uses to reach the network so nothing here ever opens a
socket. The offline default is itself under test: without explicitly
enabling the network flag, a pass must skip matching entirely (pytest can
@@ -8,6 +8,7 @@ never hit MusicBrainz, whatever a test triggers).
"""
import importlib
import enrichment
import sys
import pytest
@@ -50,7 +51,7 @@ class FakeMB:
def __call__(self, path, params):
if self.raise_transport:
raise self._srv.EnrichTransportError("fake network down")
raise enrichment.EnrichTransportError("fake network down")
self.calls.append((path, dict(params)))
if path == "recording":
return self.search_response
@@ -71,8 +72,8 @@ def mb(server, monkeypatch):
disables it by default see test_offline_default_skips_matching)."""
fake = FakeMB()
fake._srv = server
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
return fake
@@ -111,8 +112,8 @@ def test_search_falls_back_to_loose_when_strict_is_empty(server, monkeypatch):
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")
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
cands = enrichment._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
@@ -128,8 +129,8 @@ def test_search_does_not_retry_when_strict_hits(server, monkeypatch):
calls.append(params.get("query", ""))
return {"recordings": [mb_doc()]}
monkeypatch.setattr(server, "_mb_http_get", _routed)
cands = server._mb_search_recordings("AC/DC", "Thunderstruck")
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
cands = enrichment._mb_search_recordings("AC/DC", "Thunderstruck")
assert len(cands) == 1
assert len(calls) == 1
@@ -147,10 +148,10 @@ def test_artist_aliases_fetched_and_cached(server, monkeypatch):
return {"sort-name": "Ohashi, Junko",
"aliases": [{"name": "Junko Ohashi"}, {"name": "大橋 純子"}]}
monkeypatch.setattr(server, "_mb_http_get", fake)
names = server._mb_artist_aliases(_AID)
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
names = enrichment._mb_artist_aliases(_AID)
assert "Junko Ohashi" in names and "Ohashi, Junko" in names
server._mb_artist_aliases(_AID) # cached → no second request
enrichment._mb_artist_aliases(_AID) # cached → no second request
assert len(calls) == 1
@@ -158,8 +159,8 @@ 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") == []
monkeypatch.setattr(enrichment, "_mb_http_get", boom)
assert enrichment._mb_artist_aliases("not-a-uuid") == []
def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
@@ -176,9 +177,9 @@ def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
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()
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
enrichment._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"
@@ -190,10 +191,10 @@ def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
def test_locked_field_not_canonicalized_by_auto_match(server, monkeypatch):
_put(server, "x.sloppak") # title "Thunderstruck (v2)", artist "ACDC"
server.meta_db.set_song_override("x.sloppak", "artist", locked=True)
monkeypatch.setattr(server, "_mb_http_get",
monkeypatch.setattr(enrichment, "_mb_http_get",
lambda path, params: {"recordings": [mb_doc()]})
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
server._background_enrich()
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
enrichment._background_enrich()
row = server.meta_db.get_enrichment("x.sloppak")
assert row["match_state"] == "matched" # still matches (identity applies)…
assert row["canon_artist"] is None # …but the LOCKED artist isn't canonicalized
@@ -208,9 +209,9 @@ def test_offline_default_skips_matching(server, monkeypatch):
but never matches even with a transport installed."""
fake = FakeMB()
fake._srv = server
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
_put(server, "a.sloppak")
server._background_enrich()
enrichment._background_enrich()
assert fake.calls == []
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "unscanned"
@@ -218,15 +219,15 @@ def test_offline_default_skips_matching(server, monkeypatch):
def test_real_transport_refuses_when_offline(server):
"""_mb_http_get itself raises (before any socket) when the network is
disabled defence in depth under pytest."""
with pytest.raises(server.EnrichTransportError):
server._mb_http_get("recording", {"query": "x"})
with pytest.raises(enrichment.EnrichTransportError):
enrichment._mb_http_get("recording", {"query": "x"})
def test_transport_error_pauses_pass_without_burning_attempts(server, mb):
_put(server, "a.sloppak")
_put(server, "b.sloppak", title="Other Song")
mb.raise_transport = True
server._background_enrich()
enrichment._background_enrich()
for fn in ("a.sloppak", "b.sloppak"):
row = server.meta_db.get_enrichment(fn)
assert row["match_state"] == "unscanned"
@@ -234,7 +235,7 @@ def test_transport_error_pauses_pass_without_burning_attempts(server, mb):
# Network comes back → the next kick matches both.
mb.raise_transport = False
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
@@ -243,7 +244,7 @@ def test_transport_error_pauses_pass_without_burning_attempts(server, mb):
def test_high_confidence_auto_matches_and_settles(server, mb):
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "text"
@@ -256,13 +257,13 @@ def test_high_confidence_auto_matches_and_settles(server, mb):
assert row["genres"] == ["hard rock"]
# Settled: another pass makes NO further network calls…
n = len(mb.calls)
server._background_enrich()
enrichment._background_enrich()
assert len(mb.calls) == n
# …until the identity changes, which re-matches.
_put(server, "a.sloppak", title="Back in Black")
mb.search_response = {"recordings": [mb_doc(rid="rec-2", title="Back in Black",
album="Back in Black", date="1980-07-25")]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["mb_recording_id"] == "rec-2"
@@ -271,7 +272,7 @@ def test_medium_confidence_goes_to_review_not_canonical(server, mb):
# Partial artist agreement → medium confidence.
_put(server, "a.sloppak", artist="AC/DC ft Nobody")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "review"
assert row["match_source"] == "text"
@@ -281,7 +282,7 @@ def test_medium_confidence_goes_to_review_not_canonical(server, mb):
assert row["candidates"] and row["candidates"][0]["recording_id"] == "rec-1"
# A review row is settled while its identity is unchanged — no re-query.
n = len(mb.calls)
server._background_enrich()
enrichment._background_enrich()
assert len(mb.calls) == n
@@ -289,14 +290,14 @@ def test_low_confidence_fails_with_backoff(server, mb):
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc(rid="rec-x", title="Sunrise",
artist="Norah Jones")]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "failed"
assert row["attempts"] == 1
assert row["last_attempt_at"] is not None
# Immediately after, the backoff hasn't elapsed → no retry, no network.
n = len(mb.calls)
server._background_enrich()
enrichment._background_enrich()
assert len(mb.calls) == n
assert server.meta_db.get_enrichment("a.sloppak")["attempts"] == 1
# Rewind the clock two hours → eligible again, attempts increments.
@@ -304,7 +305,7 @@ def test_low_confidence_fails_with_backoff(server, mb):
server.meta_db.conn.execute(
"UPDATE song_enrichment SET last_attempt_at = last_attempt_at - 7200")
server.meta_db.conn.commit()
server._background_enrich()
enrichment._background_enrich()
assert len(mb.calls) == n + 1
assert server.meta_db.get_enrichment("a.sloppak")["attempts"] == 2
@@ -312,7 +313,7 @@ def test_low_confidence_fails_with_backoff(server, mb):
def test_no_results_fails(server, mb):
_put(server, "a.sloppak")
mb.search_response = {"recordings": []}
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "failed"
@@ -322,7 +323,7 @@ def test_cache_hit_copies_match_without_network(server, mb):
_put(server, "a.sloppak")
_put(server, "b.sloppak") # identical identity → same content_hash
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
assert len(mb.search_calls) == 1 # ONE search covered both charts
a = server.meta_db.get_enrichment("a.sloppak")
b = server.meta_db.get_enrichment("b.sloppak")
@@ -347,7 +348,7 @@ def test_manifest_mbid_tier0(server, mb):
_write_sloppak_manifest(server, "a.sloppak", f"mbid: {mbid}\n")
_put(server, "a.sloppak")
mb.recording_lookups[mbid] = mb_doc(rid=mbid)
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "mbid"
@@ -360,7 +361,7 @@ def test_manifest_isrc_tier1(server, mb):
_write_sloppak_manifest(server, "a.sloppak", "isrc: AUAP09000045\n")
_put(server, "a.sloppak")
mb.isrc_lookups["AUAP09000045"] = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "isrc"
@@ -374,7 +375,7 @@ def test_manifest_isrc_display_hyphens_stripped(server, mb):
_write_sloppak_manifest(server, "a.sloppak", "isrc: AU-AP0-90-00045\n")
_put(server, "a.sloppak")
mb.isrc_lookups["AUAP09000045"] = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "isrc"
@@ -387,7 +388,7 @@ def test_bad_manifest_mbid_falls_through_to_text(server, mb):
_put(server, "a.sloppak")
mb.recording_lookups.clear() # lookup 404s (typo'd manifest)
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "text"
@@ -401,7 +402,7 @@ def test_manual_never_overwritten_by_matcher(server, mb):
"a.sloppak", {"recording_id": "user-pick", "title": "Thunderstruck",
"artist": "AC/DC"}, source="search")
mb.search_response = {"recordings": [mb_doc(rid="machine-pick")]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "manual"
assert row["mb_recording_id"] == "user-pick"
@@ -419,7 +420,7 @@ def _seed_review(server, mb, fn="a.sloppak", title="Thunderstruck (v2)"):
# legitimately copies an earlier row instead of running the text tiers).
_put(server, fn, title=title, artist="AC/DC ft Nobody")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment(fn)["match_state"] == "review"
@@ -457,11 +458,11 @@ def test_review_reject_route_never_retries(server, mb, client):
assert row["match_source"] == "rejected"
# Rejected rows are excluded from the retry backoff forever…
n = len(mb.calls)
server._background_enrich()
enrichment._background_enrich()
assert len(mb.calls) == n
# …but an identity edit re-queues (the user fixed the metadata).
_put(server, "a.sloppak", artist="AC/DC")
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
# Rejecting a manual row is refused.
r = client.post("/api/enrichment/review/a.sloppak/reject")
@@ -502,8 +503,8 @@ def test_search_proxy(server, mb, client, monkeypatch):
assert body["candidates"][0]["score"] > 0.9
# Transport failure surfaces as 503, not a 500.
def _down(path, params):
raise server.EnrichTransportError("down")
monkeypatch.setattr(server, "_mb_http_get", _down)
raise enrichment.EnrichTransportError("down")
monkeypatch.setattr(enrichment, "_mb_http_get", _down)
r = client.get("/api/enrichment/search", params={"title": "x"})
assert r.status_code == 503
@@ -523,8 +524,8 @@ def test_match_facet_filters_grid_and_stats(server, mb, client, monkeypatch):
if "revsong" in q:
return {"recordings": [mb_doc(rid="rec-r", title="Revsong")]}
return {"recordings": []}
monkeypatch.setattr(server, "_mb_http_get", _routed)
server._background_enrich()
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
enrichment._background_enrich()
# Pendsong got failed by the pass (no results); reset it to unscanned to
# represent the not-yet-scanned band.
with server.meta_db._lock:
@@ -566,14 +567,14 @@ def test_auto_threshold_setting_moves_the_auto_review_boundary(server, mb, clien
client.post("/api/settings", json={"enrich_auto_threshold": 0.95})
_put(server, "a.sloppak", title="Highway to Hell", artist="AC/DC",
year="", duration=0)
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "review"
# Lower the bar to the default 0.90 → an identity edit re-queues, and the
# same 0.90-scored candidate now auto-applies.
client.post("/api/settings", json={"enrich_auto_threshold": 0.9})
_put(server, "a.sloppak", title="Highway to Hell", artist="AC/DC",
year="", duration=0, album="Different Album")
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert abs(row["match_score"] - 0.9) < 1e-6
@@ -583,7 +584,7 @@ def test_enrich_enabled_setting_gates_background_matching(server, mb, client):
client.post("/api/settings", json={"enrich_enabled": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
assert mb.calls == []
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "unscanned"
# Manual search/fix stays available while the background matcher is off.
@@ -591,7 +592,7 @@ def test_enrich_enabled_setting_gates_background_matching(server, mb, client):
assert r.status_code == 200
# Re-enable → the next pass matches.
client.post("/api/settings", json={"enrich_enabled": True})
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
@@ -625,7 +626,7 @@ def test_review_queue_orders_missing_data_first(server, mb, client):
_seed_review(server, mb, fn="aa.sloppak", title="Thunderstruck (v2)")
_put(server, "zz.sloppak", title="Thunderstruck (Live)",
artist="AC/DC ft Nobody", album="", year="")
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("zz.sloppak")["match_state"] == "review"
songs = client.get("/api/enrichment/review").json()["songs"]
assert [s["filename"] for s in songs] == ["zz.sloppak", "aa.sloppak"]
+45 -8
View File
@@ -1,3 +1,4 @@
import re
from pathlib import Path
import pytest
@@ -6,6 +7,10 @@ import pytest
ROOT = Path(__file__).resolve().parents[1]
WORKSPACE_ROOT = ROOT.parent
# The plugin loader was carved out of static/app.js into its own module (R3a).
# These tests assert on its source text, so they read it from its new home.
PLUGIN_LOADER = ROOT / "static" / "js" / "plugin-loader.js"
def _sibling_file(plugin_dir: str, filename: str) -> Path:
path = WORKSPACE_ROOT / plugin_dir / filename
@@ -22,7 +27,7 @@ def _sibling_text(plugin_dir: str, filename: str, required_token: str | None = N
def test_plugin_loader_guards_duplicate_hydration_and_scripts():
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
source = PLUGIN_LOADER.read_text(encoding="utf-8")
assert "let _loadPluginsInFlight = false" in source
assert "window.feedBack._loadedPluginScripts" in source
@@ -30,7 +35,7 @@ def test_plugin_loader_guards_duplicate_hydration_and_scripts():
def test_plugin_loader_unmounts_previous_ui_contributions_before_reregistering():
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
source = PLUGIN_LOADER.read_text(encoding="utf-8")
assert "const _pluginUiContributions = new Map()" in source
assert "await _commandUiDomain(contribution.domain, 'unmount', plugin, contribution)" in source
@@ -47,7 +52,7 @@ def test_plugin_loader_does_not_treat_response_absence_as_uninstall():
# (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")
source = PLUGIN_LOADER.read_text(encoding="utf-8")
# The absence-triggered sweep is gone (rationale comment in its place)...
assert "const livePluginIds" not in source
@@ -69,15 +74,41 @@ def test_capability_visualizer_waits_for_registry_instead_of_hard_error():
def test_app_shell_loads_capability_registry_before_app_runtime():
source = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
source = (ROOT / "static" / "v3" / "index.html").read_text(encoding="utf-8")
assert '<script src="/static/capabilities.js"></script>' in source
assert '<script src="/static/capabilities/library.js"></script>' in source
assert re.search(r'<script[^>]+src="/static/capabilities\.js"', source)
assert re.search(r'<script[^>]+src="/static/capabilities/library\.js"', source)
assert source.index('/static/diagnostics.js') < source.index('/static/capabilities.js')
assert source.index('/static/capabilities.js') < source.index('/static/capabilities/library.js')
assert source.index('/static/capabilities/library.js') < source.index('/static/app.js')
def test_every_external_script_defers_so_document_order_is_execution_order():
"""The shell's scripts must all execute in document order.
`capabilities.js` builds the `window.feedBack` bus and must run before
`app.js`, which calls `window.feedBack.on(...)` at top level. Today document
order gives that for free, because every script is a parse-time classic one.
That guarantee survives the ES-module migration ONLY while no script is a
*plain* classic script: `defer` and `type="module"` scripts share one
"execute after parsing" list and run in document order, but a plain classic
script runs DURING parse ahead of every deferred one. So the moment
capabilities.js becomes a module while app.js is still plain, app.js runs
first and `window.feedBack.on` is undefined.
Pinning "no plain external scripts" is what keeps that from silently
regressing as tags flip to type="module" one at a time.
"""
source = (ROOT / "static" / "v3" / "index.html").read_text(encoding="utf-8")
plain = [
tag for tag in re.findall(r'<script\b[^>]*\bsrc=[^>]*>', source)
if 'defer' not in tag and 'async' not in tag and 'type="module"' not in tag
]
assert not plain, f"external scripts that would jump the deferred queue: {plain}"
def test_capability_registry_exposes_claim_dispatch_and_ready_contracts():
source = (ROOT / "static" / "capabilities.js").read_text(encoding="utf-8")
@@ -127,7 +158,13 @@ def test_deferred_runtime_domains_remain_reserved_not_bridged():
def test_capability_events_do_not_bridge_deferred_surfaces():
app_source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
# These are NEGATIVE assertions, so they must span every file the code could
# have moved to — otherwise carving a function out of app.js turns the guard
# vacuous instead of failing.
app_source = (
(ROOT / "static" / "app.js").read_text(encoding="utf-8")
+ PLUGIN_LOADER.read_text(encoding="utf-8")
)
capability_source = (ROOT / "static" / "capabilities.js").read_text(encoding="utf-8")
for token in ["return 'ui.navigation'", "return 'note-detection'", "eventName.startsWith('viz:') || eventName.startsWith('highway:')"]:
@@ -137,7 +174,7 @@ def test_capability_events_do_not_bridge_deferred_surfaces():
def test_plugin_loader_registers_manifest_capability_declarations():
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
source = PLUGIN_LOADER.read_text(encoding="utf-8")
assert "const capabilityPlugins = fetchedPlugins.slice().sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))" in source
assert "capabilityApi.registerParticipants(capabilityPlugins)" in source
+16 -15
View File
@@ -8,6 +8,7 @@ flag is only force-enabled where a test needs the pipeline to run.
"""
import importlib
import enrichment
import io as _io
import sys
@@ -57,8 +58,8 @@ class FakeMB:
@pytest.fixture()
def mb(server, monkeypatch):
fake = FakeMB()
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
return fake
@@ -116,13 +117,13 @@ def test_musicbrainz_source_off_stamps_without_matching(server, mb, client):
client.post("/api/settings", json={"enrich_src_musicbrainz": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
assert mb.calls == []
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "unscanned" # hash stamped, no match
# Re-enabling picks the same row up on the next pass.
client.post("/api/settings", json={"enrich_src_musicbrainz": True})
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
@@ -133,7 +134,7 @@ def test_field_toggles_strip_auto_applied_fields(server, mb, client):
"enrich_apply_genres": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_artist"] == "AC/DC"
@@ -150,7 +151,7 @@ def test_names_toggle_keeps_ids_and_other_fields(server, mb, client):
client.post("/api/settings", json={"enrich_apply_names": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_artist"] is None
@@ -170,7 +171,7 @@ def test_review_accept_applies_all_fields_despite_toggles(server, mb, client):
# Partial artist agreement → review tier (candidates stored unfiltered).
_put(server, "a.sloppak", artist="AC/DC ft Nobody")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "review"
r = client.post("/api/enrichment/review/a.sloppak/accept",
json={"recording_id": "rec-1"})
@@ -192,21 +193,21 @@ def test_reenabling_field_backfills_matched_row(server, mb, client):
client.post("/api/settings", json={"enrich_apply_year": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_year"] is None # suppressed
assert row["apply_mask"] == "enrich_apply_year" # …and remembered
# Re-enable → next pass re-queues and backfills the year (hash unchanged).
client.post("/api/settings", json={"enrich_apply_year": True})
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_year"] == "1990" # backfilled
assert row["apply_mask"] in (None, "") # fully applied now
# Converged: a fully-applied row is not re-queued again.
assert server.meta_db.enrichment_pending(
allowed_keys=frozenset(server._ENRICH_APPLY_FIELDS)) == []
allowed_keys=frozenset(enrichment._ENRICH_APPLY_FIELDS)) == []
def test_partial_match_is_not_a_cache_donor(server):
@@ -271,8 +272,8 @@ def caa(server, monkeypatch):
calls.append(release_id)
return art.get(release_id)
fake.calls = calls
monkeypatch.setattr(server, "_caa_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(enrichment, "_caa_http_get", fake)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
return fake
@@ -280,13 +281,13 @@ def test_caa_source_toggle_gates_art_fetch(server, client, caa):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak")
client.post("/api/settings", json={"enrich_src_caa": False})
server._background_enrich()
enrichment._background_enrich()
assert caa.calls == []
row = server.meta_db.get_enrichment("a.sloppak")
assert row["art_state"] is None # not forfeited, just skipped
# Re-enable → the same row is picked up.
client.post("/api/settings", json={"enrich_src_caa": True})
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
@@ -294,7 +295,7 @@ def test_apply_art_toggle_gates_art_fetch(server, client, caa):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak")
client.post("/api/settings", json={"enrich_apply_art": False})
server._background_enrich()
enrichment._background_enrich()
assert caa.calls == []
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
+5 -4
View File
@@ -10,6 +10,7 @@ shadow the config.json dlc_dir fallback.
"""
import importlib
from routers import settings as settings_router
import json
import sys
@@ -32,13 +33,13 @@ class _DirectSettingsClient:
def get(self, path):
if path != "/api/settings":
raise ValueError(f"unsupported path: {path}")
return _DirectResponse(self._server.get_settings())
return _DirectResponse(settings_router.get_settings())
def post(self, path, json):
if path == "/api/settings":
return _DirectResponse(self._server.save_settings(json))
return _DirectResponse(settings_router.save_settings(json))
if path == "/api/settings/reset":
return _DirectResponse(self._server.reset_settings(json))
return _DirectResponse(settings_router.reset_settings(json))
raise ValueError(f"unsupported path: {path}")
def close(self):
@@ -643,7 +644,7 @@ def test_achievements_enabled_persists_and_validates(api_client, tmp_path):
def test_achievements_enabled_is_resettable(server_module):
"""The flag is in the resettable allow-list so a Reset clears it to default."""
assert "achievements_enabled" in server_module._RESETTABLE_SETTINGS_KEYS
assert "achievements_enabled" in settings_router._RESETTABLE_SETTINGS_KEYS
def test_skip_startup_tasks_drives_startup_to_complete(api_client):
+5 -4
View File
@@ -11,6 +11,7 @@ is exercised separately in `test_plugins.py`.
import base64
import importlib
from routers import settings as settings_router
import json
import os
import sys
@@ -478,7 +479,7 @@ def test_normalize_export_paths_consistency(server_mod, tmp_path):
# Wraps `_validate_relpath` to assert it doesn't raise the
# hard-failure ValueErrors. _UndeclaredFile would mean the
# allowlist is wrong, not that the relpath shape is bad.
server_mod._validate_relpath(rel, cleaned, tmp_path)
settings_router._validate_relpath(rel, cleaned, tmp_path)
# ── Atomic write: unique tmp + cleanup on failure ───────────────────────────
@@ -502,7 +503,7 @@ def test_atomic_write_cleans_up_tmp_on_failure(server_mod, tmp_path, monkeypatch
for _ in range(2):
with pytest.raises(OSError):
server_mod._atomic_write_file(target, b"payload")
settings_router._atomic_write_file(target, b"payload")
# Both attempts cleaned up. No .tmp.import residue means the
# mkstemp + finally-unlink pattern held even across failures.
@@ -512,7 +513,7 @@ def test_atomic_write_cleans_up_tmp_on_failure(server_mod, tmp_path, monkeypatch
# Restoring real replace, the function should still work end-to-end.
monkeypatch.setattr(server_mod.os, "replace", real_replace)
server_mod._atomic_write_file(target, b"payload")
settings_router._atomic_write_file(target, b"payload")
assert target.read_bytes() == b"payload"
assert list(tmp_path.glob("*.tmp.import")) == []
@@ -764,7 +765,7 @@ def test_atomic_write_closes_fd_when_fdopen_fails(server_mod, tmp_path, monkeypa
monkeypatch.setattr(server_mod.os, "fdopen", boom_fdopen)
with pytest.raises(OSError, match="simulated EMFILE"):
server_mod._atomic_write_file(target, b"payload")
settings_router._atomic_write_file(target, b"payload")
# fd was closed (so it didn't leak), and the temp file mkstemp
# created was removed (so it doesn't litter / lock on Windows).
+11 -10
View File
@@ -15,6 +15,7 @@ this file pins the additive `core_server_files` section:
import base64
import importlib
from routers import settings as settings_router
import sqlite3
import sys
from pathlib import Path
@@ -118,7 +119,7 @@ def test_import_stages_db_restore_without_touching_live_db(client, server_mod, t
payload = _valid_db_bytes(tmp_path, name="incoming.db", marker="restored")
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"web_library.db": {"encoding": "base64",
@@ -143,7 +144,7 @@ def test_import_rejects_corrupt_db_with_valid_magic_header(client, server_mod, t
# fail to open the bad restore.
corrupt = b"SQLite format 3\x00" + b"\xff" * 200
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"web_library.db": {"encoding": "base64",
@@ -158,7 +159,7 @@ def test_import_rejects_non_sqlite_db_payload(client, server_mod, tmp_path):
# A truncated / wrong file staged as the restore would brick startup —
# reject anything lacking the SQLite magic header, before touching disk.
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"web_library.db": {"encoding": "base64",
@@ -171,7 +172,7 @@ def test_import_rejects_non_sqlite_db_payload(client, server_mod, tmp_path):
def test_import_writes_custom_art_immediately(client, server_mod, tmp_path):
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"playlist_covers/7.png": {"encoding": "base64",
@@ -186,7 +187,7 @@ def test_import_writes_custom_art_immediately(client, server_mod, tmp_path):
def test_import_core_path_traversal_rejected(client, server_mod, tmp_path):
secret = tmp_path.parent / "escape.txt"
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"../escape.txt": {"encoding": "base64",
@@ -201,7 +202,7 @@ def test_import_core_undeclared_path_skipped_not_fatal(client, server_mod, tmp_p
# A relpath outside the core allowlist is a warn-and-skip, not a refusal —
# the rest of the bundle still applies.
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"audio_cache/x.ogg": {"encoding": "base64",
@@ -289,7 +290,7 @@ def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
def test_export_fails_hard_when_db_snapshot_unavailable(client, server_mod, monkeypatch):
# A backup that silently omits the library DB is a data-loss trap — the
# export must error rather than hand back an incomplete-looking bundle.
monkeypatch.setattr(server_mod, "_snapshot_library_db", lambda: None)
monkeypatch.setattr(settings_router, "_snapshot_library_db", lambda: None)
r = client.get("/api/settings/export")
assert r.status_code == 500
assert "library database" in r.json()["error"].lower()
@@ -299,16 +300,16 @@ def test_failed_import_disarms_staged_db_restore(client, server_mod, tmp_path, m
# If a later write in phase 2 fails, the request 500s — but a staged DB
# restore must NOT survive to swap in on the next restart.
payload = _valid_db_bytes(tmp_path, name="incoming.db")
real_write = server_mod._atomic_write_file
real_write = settings_router._atomic_write_file
def boom(target, data):
if target.name == "config.json": # last write of the commit
raise OSError("disk full")
return real_write(target, data)
monkeypatch.setattr(server_mod, "_atomic_write_file", boom)
monkeypatch.setattr(settings_router, "_atomic_write_file", boom)
r = client.post("/api/settings/import", json={
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
"server_config": {},
"core_server_files": {
"web_library.db": {"encoding": "base64",
+1
View File
@@ -37,6 +37,7 @@ def dlc_client(tmp_path, monkeypatch):
static_tmp = tmp_path / "static"
static_tmp.mkdir()
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
monkeypatch.setattr(server.appstate, "static_dir", static_tmp)
tc = TestClient(server.app, client=("127.0.0.1", 50000))
try:
yield tc, server, dlc