Compare commits

..
Author SHA1 Message Date
byrongamatos 4b8ec441b6 perf(harness): close page on error + MD040 fence + CHANGELOG (CodeRabbit)
- frameTimeOnce now closes its page in a finally, so a failing run doesn't leak
  the page until the final browser.close() (CodeRabbit).
- fenced code block gets a bash language hint (MD040).
- CHANGELOG mentions the new --song frame-time mode.
2026-07-10 23:07:38 +02:00
byrongamatosandClaude Opus 4.8 3b862ba117 perf(harness): add 2D-highway frame-time measurement + capture the R3c gate (H0)
scripts/perf-baseline.mjs gains a `--song` mode: it wraps requestAnimationFrame
before any page script, TAGS the frames the highway actually painted (via
highway.addDrawHook), starts playback, and reports draw-frame p50/p95/p99 over
`--frames` seconds across `--runs` runs. Tagging is the point — ~half the rAF
callbacks are other cheap loops (~0.1 ms); averaging them in would hide a
renderer regression, so only draw frames are counted.

This is the metric the plan says gates the highway.js split (R3c) but the harness
never measured (it did server latency + boot + heap only, and the R0 numbers were
against an empty library). docs/perf-baseline.md now records the pre-lift baseline
on the Arcturus feedpak: p50 ~2.2 ms, p95 spread 2.7-3.2 ms across 3 runs. The
H-container lift (next) re-runs this on the same box and must stay within noise.

Maintainer/CI-only tooling; the existing no-`--song` run is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:36:08 +02:00
266 changed files with 21958 additions and 39690 deletions
-17
View File
@@ -1,17 +0,0 @@
## What
<!-- What does this PR do, and why? Link the issue it addresses. -->
## feedpak surface
<!-- The feedpak spec is sacrosanct: the spec defines the format, this app implements it.
Delete this section ONLY if your change doesn't touch how the app reads or writes packs. -->
- [ ] This PR does **not** change how the app reads/writes feedpaks (manifest keys, pack files, folder layout)
- [ ] …or it does, and the spec change landed first via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md) — FEP / spec PR: `got-feedback/feedpak-spec#___` (once it merges, re-run this PR's checks and the gate goes green)
## Checklist
- [ ] `CHANGELOG.md` `[Unreleased]` updated (user-visible changes)
- [ ] Tests added/updated for new behaviour
- [ ] Commits are DCO signed off (`git commit -s`)
-88
View File
@@ -124,94 +124,6 @@ jobs:
print(f"Validated {len(manifests)} manifest(s) — OK")
EOF
feedpak-spec:
# Guard that core stays faithful to the feedpak format spec, which lives in
# its own repo (got-feedback/feedpak-spec) and is the contract third-party
# packers and players build against. Four surface checks: core reads/writes
# only manifest keys the spec declares (and the scanned-module list can't
# fall behind); the exception allowlist never grows, so the FEP process is
# the only way a new key lands; core ingests the spec's example packs; packs
# committed here pass the spec's reference validator. Motivated by
# #933, where a manifest key (`original_audio`) shipped in core without ever
# reaching the spec.
#
# The gate checks against the spec repo's HEAD, deliberately: the app must
# conform to the LIVING spec, always. The dev flow is self-serve — a gated
# PR opens a FEP, the spec PR merges, re-running this job goes green; no
# pin file to bump, nothing to maintain. Accepted trade-off: a BREAKING
# spec change (rare, deliberate, MAJOR per the spec's compatibility policy)
# reddens every PR here until core conforms — which is the correct
# org-wide signal that the app is out of conformance. The normal FEP is
# additive and can never redden this job.
name: feedpak-spec
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# This job runs repository code (tools/check_spec_conformance.py) and
# never pushes; don't leave the token in git config for it.
# fetch-depth: 0 so the base branch is available — the gate must prove
# the exception allowlist didn't grow in this PR.
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Check out feedpak-spec at HEAD
uses: actions/checkout@v4
with:
repository: got-feedback/feedpak-spec
ref: main
path: .feedpak-spec
persist-credentials: false
- name: Record the spec commit this run verified against
# HEAD-tracking means CI results can differ across time on the same
# commit. Log the exact spec SHA so a red run is reproducible.
run: git -C .feedpak-spec rev-parse HEAD
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
# CI-only: the spec's reference validator needs jsonschema. Not a
# runtime dependency — this gate never runs on the serve/Docker path
# (constitution Principle I). Pinned for the same reason the spec SHA
# is: an upstream release must not turn this job red on a PR that
# changed neither this repo nor the spec.
pip install 'jsonschema==4.26.0'
- name: Fetch the base branch's exception allowlist
id: baseline
run: |
# The allowlist is closed: it grandfathers keys that predate this gate
# and may only shrink. Prove that by diffing against the base branch —
# without this, anyone could append an entry and route around the FEP
# process from inside this repo.
#
# Resolve the base rather than hardcoding `main`: ship-ci.yml also runs
# this workflow for PRs into release/** and for pushes to release/**,
# where a main baseline would diff against the wrong branch.
# PR -> the branch it merges into
# push -> the branch itself (its tip already contains the change, so
# this is a no-op; enforcement happens at PR time)
BASE="${{ github.event.pull_request.base.ref || github.ref_name }}"
echo "diffing the allowlist against origin/$BASE"
git fetch --no-tags --depth=1 origin "$BASE"
if git cat-file -e FETCH_HEAD:feedpak-spec-exceptions.yml 2>/dev/null; then
git show FETCH_HEAD:feedpak-spec-exceptions.yml > "$RUNNER_TEMP/baseline-exceptions.yml"
echo "args=--baseline-exceptions $RUNNER_TEMP/baseline-exceptions.yml" >> "$GITHUB_OUTPUT"
else
# Only true until the PR that introduces this gate lands.
echo "args=--bootstrap-allowlist" >> "$GITHUB_OUTPUT"
fi
- name: Check feedpak spec conformance
run: python tools/check_spec_conformance.py --spec .feedpak-spec ${{ steps.baseline.outputs.args }}
lint:
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
# dev tooling, never on the serve/Docker path — same category as
-3
View File
@@ -24,9 +24,6 @@ plugins/*/
!plugins/achievements/
!plugins/achievements/**
plugins/achievements/__pycache__/
!plugins/career/
!plugins/career/**
plugins/career/__pycache__/
!plugins/highway_3d/
!plugins/highway_3d/**
plugins/highway_3d/__pycache__/
+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/v3/index.html`,
The frontend (`static/app.js`, `static/highway.js`, `static/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.3.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-11
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-08
+1 -211
View File
@@ -7,216 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Gold tier (career passports)** — an earned badge turns **gold** when
Virtuoso verifies an improvised jam in the passport's style (the
`gold_improv` artifact relays with the drill snapshot; a genre inherits its
family's style, gained-only, and gold never substitutes for the badge bar
itself). Gold gets its own ceremony, stamp slam, foil chip, and gold ink on
the shelf cover, profile wall, and passport card; the bronze page's "Gold
rung coming" preview becomes a live invitation to jam it.
- **Gigs (the career verb, frontend)** — book a gig from any opened passport:
a gig poster proposes the setlist (re-roll for a different bill; save or
copy the poster as a PNG), "Play the gig" hands the set to the play queue
with the venue on stage, a floating strip tracks the set, and finishing it
logs dated entries with per-song accuracies in the passport book — with an
encore celebration (crowd eruption + confetti) when the whole set clears
the bar, and a summary poster to share. Quitting mid-set simply abandons
it: no log, no fail state.
- **Career on the Profile and Home pages** — the Profile gains a passport
wall (earned-badge covers per instrument, hours, gig count; absent until a
passport exists), injected through the same mount-point + rendered-event
seam the achievements plugin uses (now documented in docs/plugin-v3-ui.md).
The home page's plugin-count stat tile becomes a career trading card
(badges, hours, the closest stamp ask, foil shine) with the old stat as the
built-in fallback when career has no state. Earned passports gain **Save
card / Copy card** — a natively-drawn PNG passport card, downloadable or
copied straight to the clipboard for pasting outside the app (shared
`blob-io` helpers replace the download idiom previously duplicated in
settings-io and diagnostics-export).
- **Gigs (backend)** — career mode gains its verb: `POST
/api/plugins/career/gigs/propose` builds a playable setlist for an
instrument+genre (your qualifying songs plus a couple of stakes songs near
the bar; a young passport fills from unplayed genre songs — the first gig
is how stubs start; re-roll by calling again), naming the room your stars
can book. `POST /gigs` logs a **completed** set — per-song accuracies read
from the set's own freshly-recorded stats, an encore flag at the
data-driven bar (avg ≥ 75%) — into the career state; abandoned sets never
log (no fail state: the gig you finished is the gig you played). Passports
carry their gig log; instruments their gig count.
### Changed
- **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem
list (`[{id, url, default}]` + `full_mix_url`), the same list the highway's WS
`ready` sends. The stems plugin could only learn it from that WS message, which
arrives once the highway is already on screen — so it decoded and then copied the
whole song's PCM to its audio worklet with the player visible: over half a gigabyte
of memcpy in one frame for a 6-stem pack, a measured 698 ms freeze right as the
song-credits card appeared. With the list available at `song:loading` the plugin
does all of it before the highway is drawn. Built by calling `load_song` itself, so
it cannot drift from what the WS sends. Opt-in, so the library's metadata calls pay
nothing.
- **Folder library renders only the songs on screen** (#965) — a song list used to
render *every* song it held. On a flat 50,944-song library that was one `<div>`
with 50,938 children and ~1.3 **million** DOM nodes (~4.2 GB of renderer memory),
built even while another screen was showing. A document that size also punishes
unrelated code: any `document.querySelector` that misses has to walk the whole
tree — which is how the song-preview menu check ended up eating ~50% of the
renderer and dropping the app to 2.7 fps. Lists longer than 200 songs are now
windowed (2531 rows in the DOM instead of 50,000); shorter lists are unchanged.
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
top-level manifest key this repo invented (#583) that the feedpak spec never had.
The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0
(feedpak-spec#53) reserves the id **`full`** for it, so that is where core reads it
from now.
`full` is a mixdown, not a layer — it already contains every instrument — so
`load_song()` lifts it OUT of `LoadedSloppak.stems` onto `LoadedSloppak.full_mix`.
Nothing that sums stems or renders one fader per stem can see it, which is what
makes retaining it safe; leaving it in the list would double the whole song and
leave "guitar" audible with the guitar fader muted. That trap is exactly why the
packer invented the key instead of putting the mixdown where the format says it
goes — the bug was in the reader, and this fixes the reader.
Consequences worth knowing:
- The highway WS `song_info` frame gains `full_mix_url` / `has_full_mix`.
`original_audio_url` / `has_original_audio` remain as **deprecated aliases**
(same values) for one release so a client built against the old frame keeps
working; they go with the fallback below (#945).
- `stems` on `song_info`, and `stem_ids` / `stem_count` in the library index, now
describe *instrument* stems only — a separated pack that retains its mixdown no
longer advertises a bogus "full" stem chip or an inflated stem count.
- Audio fingerprinting (`lib/enrichment.py`) now resolves the mixdown the same
way, which **widens** its coverage: it previously returned `None` for any pack
without the invented key, so fingerprinting silently did nothing for the
overwhelming majority of packs.
- Core still **reads** `original_audio:` as a deprecated fallback, because every
pack written before the spec caught up carries it and would otherwise lose its
pristine mix. `tools/migrate_full_mix_stem.py` rewrites those packs into the
spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem
at `default: off`, drops the key); the fallback and the aliases are removed once
they are migrated (#945).
### Added
- **Genres fall back to MusicBrainz enrichment** — the effective genre now
resolves override → pack genre → the enrichment match's primary genre
(matched or user-pinned manual rows only). Converted packs rarely carry a `genres` manifest key,
which starved the library genre facet and career passports on real
libraries; with the fallback, every enriched song's genre is browsable and
passport-able immediately, and coverage grows as enrichment runs.
- **Badge ceremony in the venue** — earning a genre badge now stages a moment:
the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant
ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger;
a no-op without a venue pack) and a full-screen overlay drops the bronze
stamp with a shine sweep and a confetti burst over whatever screen is active
(badges land right after `stats:recorded`, while the player is still up).
Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing
chime + notification only. The stamp still slams into the passport book on
next open, unchanged.
- **Hours-per-genre odometer (career passports)** — the app now measures real
play time: the stats recorder accrues **wall-clock** seconds across
play/resume ↔ pause/stop/end spans (wall time, not song position — position
deltas double-count A-B loops and mis-read seeks; single spans clamp at 2h
against suspend/sleep inflation) and piggybacks them as `seconds` on the
`POST /api/stats` calls it already makes. New additive
`song_stats.seconds_total` column; a seconds-only POST banks time for
unscored plays that run to the song's natural end without touching the
resume position (and still counts as playing today for the streak).
Passports surface it honestly: "14.2 h in Blues" under the badge and on the
shelf cover — a true fact that only grows, never a target or a meter.
- **Career passport drills, curated** — Bronze in blues/rock/metal/funk/jazz
now also asks for that genre's signature Virtuoso drill (Blues Shuffle,
Power Chords & Backbeat, Gallop Picking, 16th Pocket, Shell Voicings — one
per genre, data-driven in `passports.json` with display labels). Drill
lists are per-instrument (`virtuoso_nodes: {instrument: [nodes]}`; a flat
list still means guitar), so a keys passport never demands a guitar drill.
A drill counts as cleared on the first real completion artifact — a
top-tier clean pass in one key (`keysCleared`), any depth rung, or
mastery — rather than only the maxed-speed depth flips. Genres without a
curated drill stay songs-only.
- **Career passport visuals pack** — earned covers and badge stamps become
trading cards (pointer-tracked tilt + light glint, hover-capable devices
only); the ghost stamp visibly "carves in" as qualifying songs land (a
conic ink fill, no numbers added); the Gold rung preview is a small foil
chip with a shimmer sweep, still honestly labeled coming. All theatrics
disabled under `prefers-reduced-motion`.
- **Career passports (backend)** — the badge-journey layer on top of career stars.
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
passport walls: genre badges computed on read from `song_stats` × the library's
effective genre — Bronze = N genre songs at K★, data-driven in
`plugins/career/passports.json`, default 5 songs at 2★ — plus qualifying-song
"ticket stubs", the library genre list, and drill status), `POST /passports/commit`
(instrument commitment), `POST /passports/open` (open a genre
passport), and `POST /drill-state` (intake for the relayed Virtuoso
`virtuoso.progress` snapshot, so drill requirements can gate badges
server-side). Badges are never stored; the only persisted state (commitments,
opened passports, drill snapshot) lives under `CONFIG_DIR/career/` and rides the
settings export/import bundle via `settings.server_files`. Instruments are
attributed via the existing progression arrangement→instrument mapping;
non-graded instruments (bass, drums) render shown-not-judged — repertoire
without a pass bar, never a false badge denial.
- **Career passports (UI)** — the Career screen gains a Passports tab beside
Venues: a physical per-instrument passport book (embossed leather cover, 3D
page-turn) with a wax-seal commitment ceremony (Stage 0), rubber-stamp badge
slam with ink bleed and deterministic per-genre jitter, qualifying songs as
collected ticket stubs, and unopened genres as an "Explore next"
travel-brochure rack (invitations, never greyed-out slots or completion
meters). Badge earns chime + notify immediately; the stamp slam plays when
the passport is next opened. Four small synthesized sound effects ship as
plugin assets. The career screen also relays the Virtuoso `virtuoso.progress`
localStorage snapshot to the drill-state intake on `virtuoso:progress` bus
events (debounced, plus a one-time bootstrap), closing the
fires-into-a-void seam without touching the virtuoso plugin.
- **CI gate: core must stay faithful to the feedpak spec (`feedpak-spec` job).** feedpak is published as
an open format with its own repo, normative spec, JSON Schemas, and reference validator — but nothing
stopped core from reading a manifest key the spec never defined, which is exactly what happened with
`original_audio` (#583 → #933). `tools/check_spec_conformance.py` now enforces four surface properties
in CI: (1) **key-coverage** — every manifest key core reads *or writes* is declared in the spec's
`manifest.schema.json`, found by walking the AST of `lib/sloppak.py`, `lib/enrichment.py`, and
`lib/songmeta.py`, `lib/gp2notation.py`, and `lib/routers/ws_highway.py` (writes are gated too — including
`setdefault()` — and reported separately: a key core writes lands in every pack we emit, so an undeclared
one seeds the ecosystem with non-spec data; a **readers-complete** guard fails the build if that module
list falls behind the codebase); (2) **allowlist-closed** — `feedpak-spec-exceptions.yml` never grows;
(3) **forward** — core's `load_song()` ingests every example pack the spec ships;
(4) **reverse** — every pack committed here passes the spec's own `tools/validate.py` (7/7 pass today).
The gate verifies against the spec repo's **HEAD** — the app must conform to the living spec, and the
flow is self-serve: a gated PR opens a FEP, the spec PR merges, re-running checks goes green. Nothing to
pin, nothing to bump. Each run logs the spec SHA it verified against so results are reproducible.
**There is no in-repo escape hatch, by design.** A blocked PR has exactly one route: land the key in the
spec via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md), then
re-run the PR's checks — the gate verifies against the spec's HEAD, so it goes green once the key is real. `feedpak-spec-exceptions.yml` is a **closed
grandfather list** for keys that predate the gate, not a bypass: a fourth check (**allowlist-closed**)
diffs it against the base branch and fails any PR that *adds* an entry, so it may only shrink.
`original_audio` is grandfathered there against #933 so the gate lands green and starts blocking the
*next* instance immediately; the gate takes no position on how #933 resolves (the expected outcome is
removing the key, since the spec already carries the mixdown as a stem — not adopting it). Docs:
[docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md).
### 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
- **Career passports review polish** — the passport tabs and book overlay carry
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
`role="dialog"` + `aria-modal` with focus moved to the close button on open
and restored on close), and a corrupt stored seen-badges value (e.g. a stray
`"null"`) can no longer throw on every passport refresh.
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
@@ -236,7 +27,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), `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/` — 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/` — 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
@@ -249,7 +40,6 @@ 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 -55
View File
@@ -125,13 +125,13 @@ Notes:
### v3 UI (fee[dB]ack v0.3.0) — player-chrome contract
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`,
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`,
`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 as before.
surfaces `nav` in its sidebar and mounts screens exactly as v2 does.
**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 at `/` — it and `/v3` serve the same v3 shell.
Verify any player-injecting plugin in **both** `/` (v2) and `/v3`.
### Performance — never run DOM queries on a per-frame path
@@ -465,40 +465,6 @@ window.feedBack.diagnostics.contribute('my_plugin', {
Loaded from `static/diagnostics.js` ASAP in `<head>` so the console-wrap is in place before any other script runs. Available on the `window.feedBack.diagnostics` namespace alongside `snapshotConsole()`, `snapshotHardware()`, `snapshotUa()`, `snapshotLocalStorage()`, `snapshotContributions()`. Keep your payload small (< 100 KB) and don't include secrets — bundles are shared with maintainers.
### Detachable panes — pop your panel out into its own window
If your plugin has a floating panel that sits over the player — a mixer, a camera rig, a settings board — you can let the user pop it out into its own OS window and leave it there: while they play, across song switches, on a second monitor, minimized to the tray. Two calls:
```js
feedBack.panes.register({
id: 'camera_director',
title: 'Camera Director',
icon: '🎥',
element: () => panelEl, // your existing panel, exactly as it is
});
feedBack.panes.attachChip(panelEl, 'camera_director');
```
**The host moves your real element.** Not a copy, not a re-render — the actual DOM node, adopted into the pop-out window, keeping its listeners and its closures. Your panel goes on running *your* code against *your* state. It looks and behaves like what was popped out because it **is** what was popped out. Nothing to mirror, nothing to keep in sync.
The rules below are all things that have already gone wrong. Full contract: **[docs/plugin-panes.md](docs/plugin-panes.md)**.
- **Your code still runs in the main window.** The element is *displayed* elsewhere; its closures, timers and `document` references still belong to the main realm. That is exactly why everything keeps working — and exactly why `document.body.appendChild(myPopover)` lands in the **main window, not the pane**. Anchor tooltips, popovers and menus to your panel, not to `document.body`. Measure with `el.ownerDocument.defaultView`, never a cached `window`.
- **Don't hide your panel yourself when it pops out.** Core hides it and leaves a "bring it back" stub. If you also hide it, you will hide the node that just moved — and blank the pane window.
- **Prefer `hidden` or a class over inline `display` for show/hide.** While popped out, core neutralises *placement* with `.fb-paned` (`position`, `inset`, `width`, `z-index`, `box-shadow`). An inline `display:none` on your panel reasserts itself the moment the pane docks back and the class is removed, so your panel returns invisible.
- **`element` is a function so it can be resolved late.** Return the *live* node. If you rebuild your panel (Camera Director rebuilds on every mode change), re-run `attachChip` — it returns a `detach()`; call it before re-attaching, and again in your teardown.
- **`isConnected` does not mean "docked".** A panel sitting in a pane window is very much connected — just not to *this* document. Test `el.ownerDocument === document`, or take the `onHost(hostId, el)` callback.
- **rAF is throttled while the main window is backgrounded** — and it will be, whenever the user is looking at your pane. Event-driven panels (sliders, buttons) are unaffected. A panel that *animates continuously* may run slowly while it is the only thing on screen.
- **Don't reach for BroadcastChannel, `postMessage`, or a second copy of your state.** There is one realm and one panel. If you find yourself synchronising, you have misunderstood the model.
- **Nothing is required.** No panes API on the host → skip both calls, and your panel behaves exactly as it does today.
### Keyboard Shortcuts
Plugins can register keyboard shortcuts via the global `window.registerShortcut()` function. Shortcuts appear in the `?` help panel.
@@ -588,21 +554,6 @@ tab, key/scale annotations, etc.). Published as **feedpak**; this codebase still
**sloppak** name internally — same on-disk format. [docs/sloppak-spec.md](docs/sloppak-spec.md) is
a local pointer + code map.
**The spec is sacrosanct — read it BEFORE changing how this app reads or writes packs.** The
spec repo defines the format; this app merely implements it ("a change is not part of the format
until it lands here" — feedpak-spec/GOVERNANCE.md). Any new manifest key, file, or directory the
app touches must land in the spec **first**, via the
[FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md) (proposal
issue → one spec PR updating spec + schemas + example + changelog → then re-run your PR's checks
here; the gate verifies against the spec's HEAD, so it goes green the moment your key is real).
CI enforces this: the `feedpak-spec` job
([docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md)) fails any PR whose code touches a
manifest key the spec doesn't declare, and there is **no in-repo bypass** — the exceptions
file is a closed grandfather list that only shrinks. If the format seems to be missing something
you need, that's a FEP conversation, not a workaround. (Cautionary tale: `original_audio`, #933 —
shipped without a spec entry, and third-party packers reverse-engineered a folder convention out
of a code comment.)
**Key code:**
- `lib/sloppak.py` — format detection, zip/directory resolution, metadata extraction, song loading
- `lib/sloppak_convert.py` — sloppak assembly pipeline, Demucs stem splitting
@@ -615,7 +566,7 @@ of a code comment.)
- **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.
- **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.)
- **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
-132
View File
@@ -1,132 +0,0 @@
# The feedpak spec-conformance gate
`tools/check_spec_conformance.py`, run in CI as the `feedpak-spec` job.
## Why
feedpak is published as an **open format**: its own repo
([got-feedback/feedpak-spec](https://github.com/got-feedback/feedpak-spec)), a normative spec, JSON
Schemas, and a reference validator. That is a promise to everyone outside this codebase — third-party
packers, converters, and players build against the spec, and the spec is meant to be the complete and
authoritative description of a pack.
The moment core reads a manifest key the spec doesn't define, that promise breaks silently:
- A spec-compliant pack is no longer guaranteed to be a fully-working pack.
- The reference validator can't warn authors about a key it has never heard of — it will happily green-light
the key, and every misspelling of it.
- The format's real definition drifts into our source tree. In the case that motivated this gate
([#933](https://github.com/got-feedback/feedback/issues/933)), third-party tooling started emitting an
`original/` directory that no code anywhere requires — the convention was reverse-engineered from an
example in a *code comment*.
The rule this gate enforces: **any manifest key core reads _or writes_ must be in the spec before core
ships code that depends on it.** Spec first, implementation second. Writes are not exempt — a key core
writes lands in every pack we emit, so an undeclared one seeds the ecosystem with non-spec data.
Note that "get it into the spec" is not automatically the right fix for an existing violation — for
`original_audio` it isn't. The spec already carries the pre-separation mixdown as a stem
(`{id: full, file: stems/full.ogg}`), so that key added a *second, redundant* location for audio to a format
that already had one, and the resolution is to remove it rather than bless it. The gate takes no position on
which way a violation resolves; it only insists that one of the two happens deliberately, in the open,
before the code merges.
## What it checks
We can't mechanically prove core *interprets* a key the way the spec means. We can prove four surface
properties, and they cover the drift that actually occurs.
| Layer | Check | Catches |
|---|---|---|
| 1. key-coverage | Every manifest key core reads **or writes** is declared in the spec's `manifest.schema.json`. | Core growing a key the spec never defined — the #933 class. |
| 2. allowlist-closed | `feedpak-spec-exceptions.yml` has not **grown** relative to the base branch. | Someone routing around the FEP process by allowlisting their own new key. |
| 3. forward | Core's `load_song()` ingests every example pack the spec ships. | The spec adding or tightening something core ignores or breaks on. |
| 4. reverse | Every pack committed to this repo passes the spec's `tools/validate.py`. | Core (or a contributor) committing a pack the spec would reject. |
Layer 1 works by walking the AST of the modules listed in `READERS` and collecting every literal key touched
on a manifest dict (`manifest.get("x")`, `manifest["x"]`, and the wrapped
`(load_manifest(p) or {}).get("x")` form used in `lib/enrichment.py`).
**Reads and writes are both checked, and reported differently.** A key core *writes*
(`manifest["x"] = v`, as `lib/songmeta.py` does) is spec surface pointed outward: it puts a key into every
pack we emit, so an undeclared one seeds the ecosystem with non-spec data. Subscripts are classified by AST
context — `Store` is a write, `Load` is a read — so `manifest["year"] = ...` is not miscounted as a read.
## When it fails
You added a manifest key the spec doesn't define. **There is exactly one way forward, and it is not in this
repo.**
Land the key in the spec through the **feedpak Enhancement Proposal (FEP)** process
([feedpak-spec/CONTRIBUTING.md](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md)):
1. **Open a FEP issue** on `got-feedback/feedpak-spec` — the problem, the proposed on-disk shape (manifest
key and/or side-file), backward compatibility, and the version bump it implies.
2. **Discuss**, until it has a clear shape and rough consensus.
3. **Land one PR there** that updates the normative spec (`spec/feedpak-v1.md`), the relevant JSON
Schema(s), an example in `examples/` that exercises it, and the changelog — *together*. A PR touching
only one of those is incomplete.
4. **Back here**, just re-run your PR's checks. The gate verifies against the spec's HEAD, so the moment
your key is genuinely part of the format, your PR goes green — nothing to bump, nothing to maintain.
That's deliberately the only route — no experimental prefix, no self-serve allowlist — and it's usually a
quick one for additive keys. The reason it's worth the round-trip: the gate checks the whole repo against
the living spec, so if non-conformance ever lands, it shows up as red CI on *every* teammate's open PR, and
only the person who introduced it can clear it. Going through the FEP keeps your change clean and keeps
everyone else unblocked.
The spec's own governance says the same thing:
> This repository defines the format only. Applications that read or write feedpak ... track this spec as a
> dependency; they do not drive it. **A change is not part of the format until it lands here.**
> — [feedpak-spec/GOVERNANCE.md](https://github.com/got-feedback/feedpak-spec/blob/main/GOVERNANCE.md)
### `feedpak-spec-exceptions.yml` is a closed grandfather list, not a hatch
It exists solely because `original_audio` predates the gate. **CI fails any PR that adds an entry** (layer 2
diffs it against the base branch), so the list can only ever shrink. Entries are debt, each carries a
tracking issue, and each disappears when the underlying key is removed from core. The gate also fails on a
*stale* entry — the spec caught up, or core stopped touching the key — so the file cannot quietly become
somewhere drift accumulates.
Deleting an entry does not, by itself, get you past the gate: layer 1 still fails while core reads the key.
The entry goes when the **code** goes.
## Tracking the spec's HEAD
The gate checks out `feedpak-spec` at **HEAD**, on purpose: the app must conform to the *living* spec, and
nobody should have to maintain a pin. The dev flow is fully self-serve — gated PR → FEP → spec merge →
re-run checks → green.
Two properties to know about:
- **The normal FEP is additive** (a new optional key), which only ever makes the gate *looser* — it cannot
redden anyone's PR. Only a **breaking** spec change (removing/renaming a key the app uses, tightening the
validator against committed packs) turns PRs red repo-wide — and per the spec's compatibility policy that
is a rare, deliberate MAJOR event, exactly when an org-wide "the app is out of conformance" signal is the
right outcome. The CI job logs the exact spec SHA each run verified against, so a red run is reproducible.
- **CI results can change over time on the same commit** — that is inherent to tracking a living contract,
and it is the point: green means "conformant *now*", not "conformant when written".
## Limitations
Known, and worth fixing in follow-ups rather than blocking on:
- **Layer 1's receiver detection is heuristic.** Locals *assigned from* `load_manifest(...)` are discovered
flow-aware whatever they're called (chart.py's `m` taught us that), and the inline
`(load_manifest(p) or {}).get(...)` form is recognised — but a manifest that arrives as a **function
parameter** is only recognised by name (`MANIFEST_VARS`: `manifest`, `mf`). A parameter called something
else would slip. The hardening step is to route all manifest access through a single declared
`KNOWN_MANIFEST_KEYS` registry in `lib/sloppak.py`; the gate then compares registry against schema exactly
instead of inferring.
- **Layer 1 covers top-level keys only.** Nested structure (`arrangements[].file`, `.id`, `.notation`) isn't
checked. Extending to it means walking the schema's `$ref` subschemas.
- **Layer 1 recognises `get`, `setdefault`, subscripts, and the known gap-fill helper** as key access.
`update()` and `pop()` aren't used against a feedpak manifest anywhere in the tree, so they're deliberately
not special-cased rather than speculatively handled. `readers-complete` reuses the same scanner
(`keys_touched()`), so this blind spot is shared, not doubled: a module using only unrecognised access forms
would evade both.
- **Layer 4 can't catch unknown keys**, because `manifest.schema.json` sets `additionalProperties: true` and
the reference validator deliberately "treats unknown keys/files as forward-compatible". Fixing this
properly belongs in the spec (tighten the schema, or give the validator a `--strict` mode). Until then,
layer 1 is the only thing standing between us and the next `original_audio`.
-354
View File
@@ -1,354 +0,0 @@
# Detachable panes (`window.feedBack.panes`)
Pop a panel out of the app into its own OS window, and leave it there: while you
play, across song switches, on a second monitor, minimized to the system tray.
Panes exist because the player's rail popovers are **exclusive** — opening one
closes the last. You cannot watch the mixer while riding the camera, and both
vanish the moment you want to look at the highway.
---
## The whole idea, in one sentence
**We move the real element.**
Not a copy of your panel. Not a re-implementation of it in the pop-out window.
The actual DOM node. Same-origin windows can adopt each other's nodes, and an
adopted node keeps its event listeners and its closures — so your panel goes on
running *your* code, against *your* state, in *your* realm. The app's stylesheets
are copied into the pane window, so it looks identical too.
What you popped out is what you get. That is the promise, and it is the reason
there is no `ctx`, no state mirroring, no cross-window RPC and no second copy of
your UI to keep in step with the first. Those are all solutions to a problem we
simply do not have.
---
## Adding a pane to your plugin
Two lines.
```js
// Guard: the panes API is optional. On a host without it, skip both calls and
// your panel behaves exactly as it does today.
const panes = window.feedBack && window.feedBack.panes;
if (panes && typeof panes.register === 'function') {
panes.register({
id: 'camera_director',
title: 'Camera Director',
icon: '🎥',
element: () => panelEl, // your existing panel, as it is
});
panes.attachChip(panelEl, 'camera_director');
}
```
`attachChip()` injects **the** standard pop-out chip (`⇱`) — same glyph, same
place, same behaviour in every plugin. Clicking it moves your panel to whichever
**host** the router picks — usually a pop-out window, but the dock when a window
can't be had (a blocked pop-up, or `defaultHost: 'dock'`) — and leaves a
"⇲ … is popped out" stub in its place. Clicking the stub brings the panel back, to
exactly the spot it left. Core owns the chip, the hiding and the stub, so you write
no show/hide logic.
That's it. Your sliders, your presets, your tabs, your CSS, your event handlers,
your state — all of it comes along, because none of it moved anywhere except into
a different window's document.
### `element` is a function for a reason
It is resolved at open time, not at registration. Plugins commonly build their
panel lazily on first use, or rebuild it wholesale when something changes (Camera
Director rebuilds its panel on every mode change). Asking for it when we need it
means we always move the live one.
**If you rebuild your panel, re-attach the chip.** Rebuilding takes the chip with
it. `attachChip()` returns a `detach()`; call it before re-attaching, and again in
your teardown — otherwise you leave a stub pointing at DOM that no longer exists.
```js
if (chipDetach) chipDetach();
chipDetach = panes.attachChip(panel, PANE_ID, { header: toolsEl });
```
Re-attaching is safe while the pane is popped out: the chip reconciles against the
pane's real state, so a panel rebuilt mid-pop-out stays correctly stubbed.
### The two things core changes about your element
**1. Placement.** `.fb-paned` is added while the pane is out:
```css
position: static; inset: auto; margin: 0; width: 100%;
max-width: none; max-height: none; z-index: auto; box-shadow: none;
```
Your panel was almost certainly a fixed overlay pinned to a corner of the app
(`position:fixed; top:72px; right:18px; width:288px`). Alone in its own window,
every one of those is wrong — it would float 72px down from the top of a 380px
window, still 288px wide, still casting a shadow over nothing.
Note there is deliberately **no `display` override**: a panel that is
`display:flex` or `grid` stays that way. Colours, borders, radius, padding, fonts
and your panel's own internal layout are untouched.
**2. Visibility.** A panel is usually hidden until its launcher is clicked, and a
pane can be opened from the tray or the rail without that ever happening — so core
un-hides it, in the two ways a panel is actually hidden:
```js
el.hidden = false;
if (el.style.display === 'none') el.style.display = '';
```
**Both are restored exactly as they were when the pane docks**, along with the
`.fb-paned` class. A panel that was closed when you opened its pane from the tray
goes back to being closed; one that was open stays open.
---
## Spec
```js
feedBack.panes.register({
id, // required, unique
element, // required — an Element, or a function returning one
title, // shown in the pane window's title bar, the dock card, the tray
icon, // one glyph, for the dock/tray/launcher lists
width, height, // the pane window's initial size (it remembers yours after that)
defaultHost, // 'window' (default) or 'dock'
onHost, // optional (hostId | null, el) => void — re-measure/re-anchor
});
```
```js
feedBack.panes.attachChip(el, paneId, { header }) // → detach()
feedBack.panes.open(id, { host }) / close(id) / detach(id) / dock(id) / focus(id)
feedBack.panes.isOpen(id) / hostOf(id) / get(id) / list()
```
`attachChip` puts the chip in the `header` element you pass, else in
`el.querySelector('[data-pane-header]')` if it finds one, else at the top of `el`.
An explicit `header` always wins.
---
## Hosts
`detach(id)` puts a pane in the best host available:
| host | | |
|---|---|---|
| `window` | 10 | A real OS window. In the desktop app: remembered bounds, always-on-top, system tray. |
| `dock` | 0 | A card in the in-window stack. **The floor** — always available, so opening a pane can never fail. |
You don't pick; you declare `defaultHost` and the router does the rest.
In the **desktop app** a pane you left popped out comes back popped out on next
launch. In a **browser** it comes back **docked** — a browser blocks
`window.open()` without a user gesture, so restoring it would only ever produce a
"pop-up blocked" toast. The chip pops it out again on your next click.
---
## Best practices
Every item below is something that has already gone wrong, in this codebase, on
this feature. They are cheap to get right up front and confusing to diagnose later
— a broken pane usually *looks* perfect.
### 1. Your code still runs in the main window
The element is *displayed* in the pane window, but its closures, its timers and its
`document` references all still belong to the main realm. **That is precisely why
everything keeps working** — and it has one sharp consequence:
```js
// WRONG — lands in the MAIN window, not the pane the user is looking at.
document.body.appendChild(myTooltip);
// RIGHT — anchored to the panel, so it travels with it.
panelEl.appendChild(myTooltip);
```
**And every lookup for something inside your panel.** Once the panel has moved,
`document.getElementById('my-panel-thing')` returns `null` — so every update it
guards silently stops happening, precisely while the user is looking at the panel.
No error. Just a UI that quietly goes dead.
```js
// WRONG — null once the panel is popped out.
document.getElementById('my-panel-hint').textContent = msg;
// RIGHT — search FROM the panel; works in either document.
panelEl.querySelector('#my-panel-hint').textContent = msg;
```
Elements that live outside your panel (your plugin's *screen*, host chrome) never
move, and should keep using `document.getElementById`. Audit which is which — in
the stem mixer, four ids were inside the panel and a dozen were not.
Same for measuring and popovers. `window.innerWidth` is the *main* window's, and a
dismiss listener on `window` watches a window the user isn't clicking in. Use
`el.ownerDocument` / `el.ownerDocument.defaultView` when you need the window your
panel is actually in.
### 2. Don't hide your panel yourself
Core hides it and leaves a "bring it back" stub. If your plugin *also* hides it,
you are hiding the node that just moved — and the pane window renders nothing.
(This is not hypothetical: core's own chip did exactly this, and the first
pop-out shipped blank because of it.)
### 3. Prefer `hidden` or a class for show/hide
Core makes your panel visible while it's hosted — it clears `hidden`, and clears an
inline `display: none` if that's how you hide — and **restores both on dock**. So
either style works.
`hidden` is still the better choice: it composes with everything, and it leaves
your panel's `display` mode (`flex`, `grid`, whatever it is) entirely alone. Core
deliberately does not override `display` for exactly that reason.
```js
panel.hidden = true; // best
panel.style.display = 'none'; // works — core saves and restores it
```
### 4. `element` is a function — return the *live* node
It is resolved when the pane opens, not when you register. Plugins build panels
lazily, and rebuild them wholesale (Camera Director rebuilds on every mode
change). If you rebuild yours, **re-attach the chip**:
```js
if (chipDetach) chipDetach(); // attachChip returns a detach()
chipDetach = feedBack.panes.attachChip(panel, PANE_ID, { header: toolsEl });
```
Call `chipDetach()` in your teardown too, or you leave a stub pointing at DOM that
no longer exists.
### 5. `isConnected` lies about a panel that is a pane
This one has cost more debugging than everything else on this page combined, and
it lies in **both directions**.
**It says `true` when your panel is not here.** A panel sitting in a pane window is
`isConnected` — just not to *this* document. Code asking "am I still mounted?" gets
`true` and then acts on a panel that is somewhere else entirely.
**It says `false` when your panel is perfectly fine.** The host *detaches* the
element the moment a pop-out starts, before the new window has even loaded. In that
gap `isConnected` is `false` — and any code that rebuilds on that basis builds a
**second panel**, while the host is still holding the first.
That second panel is the one your module variables now point at. The one the user
can *see* is the original, owned by nobody. So:
- its close button closes the *other*, invisible panel — "the X doesn't work"
- your chip gets re-attached to the impostor — "the pop-out icon vanished"
Two baffling symptoms, one duplicate, and nothing in the stack trace to suggest it.
**Ask the pane system, not the DOM.** It knows where your element is:
```js
function paneOwnsPanel() {
const panes = window.feedBack && window.feedBack.panes;
return !!(panes && panes.isOpen && panes.isOpen(MY_PANE_ID));
}
// "Is my panel gone?" — not "is it in this document?"
if (panel && (panel.isConnected || paneOwnsPanel())) return panel; // alive; possibly elsewhere
```
Every `isConnected` check on a panel that can be a pane needs this. In the stem
mixer that was `ensureMixerPanel()` (which rebuilt) *and* the MutationObserver's
fast path (which decided the UI was unmounted and swept on every mutation).
For "which document is it in right now", use `el.ownerDocument === document`, or
take the optional `onHost(hostId, el)` callback, which fires on both moves.
### 6. If your plugin can be re-injected, it must be able to remove itself
The host may run your script more than once — a screen re-entry, a version change.
Without a teardown, the second run builds a second panel while the first one is
still on screen, and every module variable in the new instance points at the new,
invisible one. The user clicks the panel they can see; nothing happens.
Everything stateful duplicates: observers, timers, listeners. And one thing is
worse than duplicated — **your pane registration**:
```js
panes.register({ id, element: () => panel }); // resolved LAZILY, at open time
```
First registration wins, so a stale one hands the host `panel` from a **dead
instance**. Popping out then moves a panel nobody owns.
So publish a teardown handle and call it at the top of your script:
```js
if (window.__myPluginInstance?.destroy) {
try { window.__myPluginInstance.destroy(); } catch (e) { /* tear down what we can */ }
}
window.__myPluginInstance = {
destroy() {
observer?.disconnect();
clearTimeout(myTimer);
chipDetach?.(); // attachChip() returned this
panes?.unregister?.(MY_PANE_ID); // ← the one people forget
document.querySelectorAll('#my-panel').forEach((n) => n.remove());
},
};
```
Belt and braces: when you build your panel, remove any node carrying its id that
isn't yours. A zombie panel is worse than no panel — it looks alive and does
nothing.
### 7. Expect rAF to be throttled while your pane has focus
Chromium throttles a **backgrounded** window's `requestAnimationFrame` — and the
main window is exactly what's backgrounded while the user is looking at your pane.
Your rAF lives in the main window.
Event-driven panels (sliders, buttons, presets) don't care. A panel that
*animates continuously* may run slowly precisely when it's the only thing on
screen. Drive such animation from data you already have, or accept the stutter.
### 8. Don't synchronise anything
No `BroadcastChannel`, no `postMessage`, no second copy of your state, no mirrored
UI. There is **one** realm and **one** panel. If you find yourself writing sync
code, you have misunderstood the model — the whole point is that there is nothing
to sync.
### 9. Nothing here is required
On a host without the panes API, `feedBack.panes` is `undefined`. Skip both calls
and your panel behaves exactly as it does today. Guard, don't depend:
```js
const panes = window.feedBack && window.feedBack.panes;
if (!panes || typeof panes.register !== 'function') return;
```
---
## Things core guarantees
- **The element goes home exactly where it came from** — same parent, same position
among its siblings. Don't move it yourself while it's popped out.
- **It comes home alive.** Core evacuates the element *before* the pane window's
document is destroyed. (Get this wrong — dock after the window dies — and the
node returns looking perfect with every listener in its subtree silently gone.
That bug is why this section exists.)
- **A pane window the user closes, or that crashes, is reaped** and the element
docked back. Your panel is never stranded in a dead document.
- **The app's stylesheets are copied into the pane window**, so your panel looks
identical — including your plugin's own `styles` sheet.
+4 -4
View File
@@ -1,9 +1,9 @@
# Plugin styling — the `styles` capability
> 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.
> 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.
FeedBack serves Tailwind as a **prebuilt** stylesheet
(`static/tailwind.min.css`), never the runtime Play CDN. The CDN's on-the-fly
+11 -31
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. 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.
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**.
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 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 one thing that changed is the player chrome** — and only if your plugin
injects controls into it.
@@ -188,24 +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 at `/` — it and `/v3` serve the same (and only) v3 shell.
## Injecting into core shells (profile, dashboard)
Core screens that accept plugin sections render **mount points** — usually
empty, sometimes holding core's own **fallback content** (the Dashboard's
career slot ships the plugin-count stat) — and announce each (re)build with a
DOM event, because their `innerHTML` swap wipes anything previously injected.
A plugin listens for the event and **replaces the mount's content** (never
append — a fallback may be present) by id — the same seam every time:
| Shell | Event | Mounts |
| --- | --- | --- |
| Profile | `v3:profile-rendered` | `#v3-profile-passports-mount` (career wall), `#v3-profile-feats-slot`, `#v3-profile-achievements-mount` |
| Dashboard | `v3:dashboard-rendered` | `#v3-dash-career-slot` (career card; core's plugin-count stat is the fallback content a plugin may replace) |
| Settings | `v3:settings-rendered` | per-plugin `settings.html` panels |
Rules: inject on every event (the mount is fresh), keep the section
**absent-not-empty** (no state → leave the mount alone / empty), and guard
re-wired listeners with a `dataset` flag when your own refresh path can run
against an unwiped mount.
- [ ] Verify in **both** `/` (v2) and `/v3`.
+3 -5
View File
@@ -55,14 +55,12 @@ 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`
(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`) ·
(7,880 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
extractions and seven `routers/` modules) ·
`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`
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) · `plugins/career/screen.js`
(1,530 — career v3 gigs + gold pushed it over; split plan: carve the gig block into a
`scriptType: module` file when career work next touches it) — and every monolith with a PR
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
by policy — the norm governs source files.
+5 -12
View File
@@ -43,19 +43,12 @@ module.exports = [
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
rules: { 'max-lines': sizeRule(1500) },
},
// 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.
// 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).
{
files: ['**/src/**/*.js', '**/*.mjs', 'static/app.js', 'static/js/**/*.js', 'static/highway.js'],
files: ['**/src/**/*.js', '**/*.mjs'],
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: { 'import-x': importX },
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
-56
View File
@@ -1,56 +0,0 @@
# CLOSED grandfather list — manifest keys core reads or writes that predate the
# spec-conformance gate and that the feedpak spec does not define.
#
# Please don't add entries here — CI will flag any PR that grows this list, so
# it can only shrink over time. That's by design, not distrust: the moment the
# app touches a key the spec doesn't define, every teammate's PR starts failing
# the conformance gate too, and whoever added the key is the only person who
# can fix it. The FEP process below avoids putting anyone in that spot. The
# feedpak spec's own governance is explicit:
#
# "This repository defines the format only. Applications that read or write
# feedpak ... track this spec as a dependency; they do not drive it.
# A change is not part of the format until it lands here."
# — got-feedback/feedpak-spec, GOVERNANCE.md
#
# So a new manifest key goes through the feedpak Enhancement Proposal (FEP)
# process — see feedpak-spec/CONTRIBUTING.md:
#
# 1. Open a FEP issue on got-feedback/feedpak-spec describing the problem, the
# on-disk shape, backward compatibility, and the version bump implied.
# 2. Land one PR there updating the normative spec, the JSON Schemas, an
# example that exercises it, and the changelog — together.
# 3. Back here, re-run this PR's checks. The gate verifies against the spec's
# HEAD, so once your key is in the spec, the gate goes green.
#
# That's the supported route — and usually a quick one for additive keys. If
# your PR is blocked by this gate, a FEP will get you unblocked properly; an
# entry here won't (CI rejects it).
#
# Entries below exist ONLY because they predate the gate. Each is debt with a
# tracking issue, and each disappears when its issue is fixed. The gate also
# fails if an entry goes stale — the spec caught up, or core no longer reads or
# writes the key — so this file cannot quietly become a place drift hides.
exceptions:
- key: original_audio
issue: https://github.com/got-feedback/feedback/issues/945
reason: >-
Added by #583 (the full mix played while every stem fader sits at unity,
since demucs recombination is lossy). It never went through a FEP and the
spec does not define it — the drift this gate exists to prevent.
#933 fixed the drift: feedpak 1.15.0 RESERVES the stem id `full` for the
complete mixdown (feedpak-spec#53), and core now reads the full mix from
that stem. Nothing depends on this key any more — not the loader, not
lib/enrichment.py, not the stems plugin, and the packer no longer writes it.
What remains is a READ-ONLY deprecated fallback in lib/sloppak.py
(_legacy_full_mix), kept for one release because every pack produced before
the spec caught up carries `original_audio: original/full.ogg` and would
otherwise silently lose its pristine mix. tools/migrate_full_mix_stem.py
rewrites those packs into the spec shape.
This entry disappears with that fallback — tracked by #945, which cannot be
forgotten: the gate fails if the entry goes stale, and deleting the read is
what makes it stale.
-28
View File
@@ -1,28 +0,0 @@
"""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
+1 -53
View File
@@ -61,16 +61,6 @@ 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
@@ -92,52 +82,10 @@ static_dir = None
sloppak_cache_dir = None
audio_cache_dir = None
# Injected callables (not values): server owns the impl + its state, routers call
# through the seam. get_progression_content wraps a lazy content cache that stays
# 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
# The directory containing server.py: the repo root in dev, resources/feedBack when
# bundled — the tree that actually holds docs/ and data/.
#
# It is published HERE, by server.py, precisely so no module under lib/ ever computes it.
# `Path(__file__).resolve().parent` is correct in server.py and silently WRONG anywhere in
# lib/ (it yields lib/, which has no docs/ or data/), and it fails by finding nothing
# rather than by raising — the builtin-content seeds would just quietly never run. See
# lib/builtin_content.py's header. Read it; never re-derive it.
server_root = None
_SLOTS = frozenset({
"meta_db", "audio_effect_mappings", "tuning_providers",
"library_providers", "local_library_provider",
"meta_db", "audio_effect_mappings",
"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",
"server_root",
})
-378
View File
@@ -1,378 +0,0 @@
"""Builtin content seeding: the calibration/diagnostic sloppaks and the starter library.
Carved VERBATIM out of server.py (R3b) — with ONE deliberate signature change, and it is
the whole reason this module is safe.
━━━ WHY THE ROOT IS A PARAMETER ━━━
server.py had `_feedBack_server_root()` = `Path(__file__).resolve().parent`. That is
correct *in server.py*: the repo root in dev, resources/feedBack when bundled — the tree
that actually holds docs/ and data/.
Move that body here unchanged and it keeps working, silently, and returns `lib/`. There is
no docs/diagnostics under lib/, so every seed would quietly find nothing and log "source
missing" — a verbatim move whose meaning changed because `__file__` did. Nothing would
fail; the starter library would just never appear.
So this module CANNOT compute a root: it takes `server_root` as a parameter, and server.py
— the only place that legitimately knows where it lives — passes it in. The trap is now
structurally impossible rather than merely avoided. (_copy_builtin_packs already took the
root this way; the two seed helpers now do too.)
Everything else is byte-identical. `log` is this module's own logger under the same
`feedBack.` hierarchy, and CONFIG_DIR is read late as `appstate.config_dir` — see appstate.py
for why those reads must be late-bound (tests monkeypatch it).
"""
import logging
import os
import secrets
import shutil
import stat
import tempfile
from pathlib import Path
import appstate
from dlc_paths import _get_dlc_dir
log = logging.getLogger("feedBack.builtin_content")
BUILTIN_DIAGNOSTIC_SUBDIR = "diagnostics-builtin"
BUILTIN_DIAGNOSTIC_SOURCES: list[tuple[str, str]] = [
(
"feedBack-diagnostic-basic-guitar.sloppak",
"docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak",
),
]
def builtin_diagnostic_filename() -> str:
"""Library filename (DLC-relative POSIX path) of the calibration sloppak —
the onboarding challenge target (spec 010)."""
return f"{BUILTIN_DIAGNOSTIC_SUBDIR}/{BUILTIN_DIAGNOSTIC_SOURCES[0][0]}"
def _copy_builtin_packs(
root: Path,
dest_dir: Path,
sources: list[tuple[str, str]],
label: str,
update_existing: bool = True,
) -> int:
"""Symlink-safe, mtime-aware copy of bundled packs into ``dest_dir``.
``sources`` is a list of ``(dest_name, rel_source)`` pairs; each source is
resolved under ``root`` (the repo root in dev, ``resources/feedBack`` when
bundled). A pack is copied when its destination is missing. Never deletes
user files; refuses to follow a symlinked seed directory or destination and
refuses to clobber a non-regular destination (any would let a copy escape
``dest_dir`` or destroy user data). Logs and continues on error. ``label``
prefixes every log line.
``update_existing`` controls what happens when a *regular* destination file
already exists: when True (diagnostic seed) a bundle copy newer than the
destination refreshes it; when False (one-time starter content) an existing
file is always left as-is so the user's copy is never overwritten.
Returns the number of ``sources`` that are present at their destination
afterwards (freshly seeded, refreshed, or already current) — so callers can
tell whether every pack made it. A skip (missing source, symlink/non-regular
refusal, copy error) does not count.
"""
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept it
# and copies would land at the link target, outside the DLC tree. The
# per-file symlink guard below cannot catch this.
if dest_dir.is_symlink():
log.warning("%s: %s is a symlink, skipping all seeding", label, dest_dir.name)
return 0
dest_dir.mkdir(parents=True, exist_ok=True)
# Pin the seed directory by an O_NOFOLLOW fd so a symlink swapped in for
# dest_dir *after* the check above cannot redirect the per-file stat /
# temp-create / replace outside the DLC tree (parent-directory TOCTOU).
# os.replace accepts dir_fd on POSIX even though it isn't listed in
# os.supports_dir_fd, so gate on os.rename (the reliable proxy); platforms
# without dir_fd/O_NOFOLLOW (e.g. Windows) fall back to path-based ops.
dir_fd = None
if (
hasattr(os, "O_NOFOLLOW")
and hasattr(os, "O_DIRECTORY")
and os.open in os.supports_dir_fd
and os.rename in os.supports_dir_fd
):
try:
dir_fd = os.open(dest_dir, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
except OSError as exc:
log.warning("%s: cannot open seed dir %s: %s", label, dest_dir, exc)
return 0
try:
present = 0
for dest_name, rel_source in sources:
source = root / rel_source
if not source.is_file():
log.warning("%s: source missing, skipping %s (%s)", label, dest_name, source)
continue
# lstat the destination without following symlinks. Pinned by dir_fd
# this resolves within the real seed dir, immune to a parent swap.
try:
if dir_fd is not None:
dstat = os.lstat(dest_name, dir_fd=dir_fd)
else:
dstat = os.lstat(dest_dir / dest_name)
dest_exists = True
dest_islink = stat.S_ISLNK(dstat.st_mode)
except FileNotFoundError:
dest_exists = False
dest_islink = False
except OSError as exc:
log.warning("%s: cannot stat %s: %s", label, dest_name, exc)
continue
# Refuse to seed through a symlink at the destination name.
if dest_islink:
log.warning("%s: destination is a symlink, skipping %s", label, dest_name)
continue
# A non-regular destination (directory, fifo, …) the user placed
# there: never clobber it, and never count it as present — otherwise
# a one-time seed would mark itself done without a real pack on disk.
if dest_exists and not stat.S_ISREG(dstat.st_mode):
log.warning("%s: destination is not a regular file, skipping %s", label, dest_name)
continue
if dest_exists:
# A regular file is already there. One-time seeds (starter
# content) must never overwrite the user's copy; refreshing
# seeds (diagnostics) replace it only when the bundle is newer.
if not update_existing:
log.info("%s: already present %s", label, dest_name)
present += 1
continue
try:
src_mtime = source.stat().st_mtime
except OSError as exc:
log.warning("%s: cannot stat source %s: %s", label, source, exc)
continue
if src_mtime <= dstat.st_mtime:
log.info("%s: already present %s", label, dest_name)
present += 1
continue
action = "updated"
else:
action = "seeded"
if _write_builtin_pack(source, dest_dir, dest_name, dir_fd):
present += 1
log.info("%s: %s %s -> %s", label, action, source.name, dest_name)
else:
log.warning("%s: failed to copy %s -> %s/%s", label, source, dest_dir.name, dest_name)
return present
finally:
if dir_fd is not None:
os.close(dir_fd)
def _write_builtin_pack(
source: Path,
dest_dir: Path,
dest_name: str,
dir_fd: int | None,
) -> bool:
"""Atomically write ``source`` to ``dest_name`` inside ``dest_dir``.
Writes to a temp file then ``os.replace()``s onto the final name so a
symlink raced in at the destination is overwritten (rename semantics), not
followed, and a crash never leaves a half-written pack. When ``dir_fd`` is
given, every step is anchored to that fd (O_NOFOLLOW temp create + dir_fd
replace), closing the parent-directory TOCTOU; otherwise falls back to
path-based temp+replace. Returns True on success. Never raises.
"""
# Unique per-attempt name (O_EXCL create) so a crash that orphans a temp
# can't permanently block later seeds via an EEXIST collision.
tmp_name = f".seed-{dest_name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
try:
src_stat = source.stat()
except OSError as exc:
log.debug("builtin pack: cannot stat source %s: %s", source, exc)
return False
if dir_fd is not None:
tmp_fd = None
try:
tmp_fd = os.open(
tmp_name,
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW,
0o644,
dir_fd=dir_fd,
)
with open(source, "rb") as sf, os.fdopen(tmp_fd, "wb") as tf:
tmp_fd = None # fdopen now owns the descriptor
shutil.copyfileobj(sf, tf)
os.replace(tmp_name, dest_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
# Preserve the bundle mtime (copyfileobj doesn't) so the mtime-based
# refresh check matches the shutil.copy2 fallback path. Best-effort.
try:
os.utime(
dest_name,
ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns),
dir_fd=dir_fd,
follow_symlinks=False,
)
except OSError as exc:
log.debug("builtin pack: could not set mtime on %s: %s", dest_name, exc)
return True
except OSError as exc:
log.debug("builtin pack write (dir_fd) failed for %s: %s", dest_name, exc)
if tmp_fd is not None:
try:
os.close(tmp_fd)
except OSError:
pass
try:
os.unlink(tmp_name, dir_fd=dir_fd)
except OSError:
pass
return False
tmp = None
try:
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".seed-", suffix=".tmp")
os.close(fd)
shutil.copy2(source, tmp)
os.replace(tmp, dest_dir / dest_name)
tmp = None
return True
except OSError as exc:
log.debug("builtin pack write failed for %s: %s", dest_name, exc)
return False
finally:
if tmp is not None:
try:
os.unlink(tmp)
except OSError:
pass
def seed_builtin_diagnostic_sloppaks(server_root: Path, dlc: Path | None = None) -> None:
"""Copy bundled diagnostic sloppaks into DLC before library scan.
Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak
when the destination is missing or older than the repo/bundle source.
Never deletes user files or touches manually copied paths (e.g.
``diagnostics-test/``). Re-seeds whenever the destination is missing so the
diagnostic target is always available. Logs and continues on errors.
"""
try:
if dlc is None:
dlc = _get_dlc_dir()
if dlc is None:
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
return
_copy_builtin_packs(
server_root,
dlc / BUILTIN_DIAGNOSTIC_SUBDIR,
BUILTIN_DIAGNOSTIC_SOURCES,
"Builtin diagnostic seed",
)
except Exception:
log.warning("Builtin diagnostic seed: unexpected error", exc_info=True)
# Starter content: bundled songs copied into ``DLC_DIR/starter/`` exactly ONCE,
# on first run, as a welcome library so a fresh install isn't empty. Unlike the
# diagnostic seed this is one-time — guarded by a marker in CONFIG_DIR — so if
# the user deletes the starter song it stays gone. ``starter/`` is NOT in the
# library scan carve-out (unlike diagnostics-builtin/ / tutorials-builtin/), so
# seeded packs surface as ordinary library songs.
BUILTIN_STARTER_SUBDIR = "starter"
BUILTIN_STARTER_SOURCES: list[tuple[str, str]] = [
(
"beethoven-fur_elise.feedpak",
"content/starter/beethoven-fur_elise.feedpak",
),
(
"star_spangled_banner.feedpak",
"content/starter/star_spangled_banner.feedpak",
),
(
"the_adicts-ode-to-joy_vst_cover.feedpak",
"content/starter/the_adicts-ode-to-joy_vst_cover.feedpak",
),
]
STARTER_SEED_MARKER = ".starter-content-seeded"
def seed_builtin_starter_content(server_root: Path, dlc: Path | None = None) -> None:
"""Copy bundled starter songs into ``DLC_DIR/starter/`` exactly once.
Guarded by ``CONFIG_DIR/.starter-content-seeded``: the first run with a DLC
folder configured seeds the packs and writes the marker; subsequent runs are
no-ops, so a user who deletes the starter song does not get it back on the
next launch. Symlink-safe; never deletes user files. Logs, never raises.
"""
try:
marker = appstate.config_dir / STARTER_SEED_MARKER
# Already seeded? The marker is a sentinel: any existing path there
# (regular file, or a symlink/dir a user deliberately planted to opt
# out) means "done" — lstat so we detect it without following a symlink.
# Worst case of a planted marker is simply no starter content, never a
# data write; the O_EXCL|O_NOFOLLOW create below refuses to write
# *through* a symlink regardless.
try:
os.lstat(marker)
return
except FileNotFoundError:
pass
except OSError as exc:
log.warning("Starter content seed: cannot stat marker %s: %s", marker, exc)
return
if dlc is None:
dlc = _get_dlc_dir()
if dlc is None:
# No DLC yet — leave the marker unwritten so we retry once a
# library folder is configured.
log.debug("Starter content seed: no DLC folder configured, skipping")
return
present = _copy_builtin_packs(
server_root,
dlc / BUILTIN_STARTER_SUBDIR,
BUILTIN_STARTER_SOURCES,
"Starter content seed",
update_existing=False,
)
# Only mark seeding complete once every starter pack is actually in
# place. If a source was missing or a copy failed, leave the marker
# unwritten so the next launch retries rather than permanently skipping.
if present < len(BUILTIN_STARTER_SOURCES):
log.info(
"Starter content seed: %d/%d packs present, will retry next launch",
present,
len(BUILTIN_STARTER_SOURCES),
)
return
# Record completion with an exclusive, no-follow create so a planted or
# raced symlink at the marker path can't redirect the write outside
# CONFIG_DIR. O_EXCL fails (EEXIST) on any existing path including a
# symlink, so we never write through one.
try:
appstate.config_dir.mkdir(parents=True, exist_ok=True)
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(marker, flags, 0o644)
try:
os.write(fd, b"1\n")
finally:
os.close(fd)
except FileExistsError:
pass # already marked (or a non-regular path is squatting) — fine
except OSError as exc:
log.warning("Starter content seed: could not write marker %s: %s", marker, exc)
except Exception:
log.warning("Starter content seed: unexpected error", exc_info=True)
-380
View File
@@ -1,380 +0,0 @@
"""Demo mode: the read-only request guard and the hourly session janitor.
Carved VERBATIM out of server.py (R3b). Bodies are byte-identical — including a bug, see
below.
━━━ THE MIDDLEWARE NEEDS `app`, SO THIS MODULE TAKES IT ━━━
`_demo_mode_guard` is an @app.middleware("http"), and a middleware has to be attached to an
app object. Rather than reach for a global, this module exposes install(app): server.py
owns the app and hands it over. Same direction as every other seam here — server.py knows
things lib/ must not have to guess.
The janitor is symmetrical: start_janitor() / stop_janitor(), called from server.py's
startup and shutdown hooks, which is where the process lifecycle actually lives.
━━━ register_demo_janitor_hook IS PART OF THE PLUGIN CONTRACT ━━━
It is a key in plugin_context, so plugins hold it as a LIVE REFERENCE from setup(). Moving
the function is fine; wrapping or renaming it is not. server.py imports this exact object
and puts it in the dict unchanged, so callable identity is preserved —
tests/test_plugin_context_contract.py (#898) fails if that ever stops being true.
━━━ A BUG MOVED VERBATIM, ON PURPOSE ━━━
The janitor start guard in server.py reads:
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1" \
and not _DEMO_JANITOR_STARTED:
`and` binds tighter than `or`, so that is `A or (B and C)` — the `not _DEMO_JANITOR_STARTED`
re-entry guard is DEAD whenever the env var is truthy, which is the only case that runs. A
second startup leaks a janitor thread (the handle is overwritten, so shutdown joins only
the last). Preserved exactly as-is here and filed as issue #902: a carve whose value is
being provably behaviour-neutral is not the place to change behaviour.
"""
import inspect
import logging
import re
import threading
import uuid
import warnings
from fastapi import Request
from fastapi.responses import JSONResponse
from env_compat import getenv_compat
log = logging.getLogger("feedBack.demo_mode")
# Plugins that maintain session stores can register a cleanup callback here.
# The demo-mode janitor calls every registered hook once per hour so stale
# sessions are swept without the core needing to know plugin internals.
_DEMO_JANITOR_HOOKS: list = []
_DEMO_JANITOR_HOOKS_LOCK = threading.Lock()
_DEMO_JANITOR_STARTED = False
_DEMO_JANITOR_STOP = threading.Event()
_DEMO_JANITOR_THREAD: threading.Thread | None = None
def register_demo_janitor_hook(fn) -> None:
"""Register a zero-argument callable to be invoked hourly by the demo
janitor. Plugins call this from their ``setup(app, context)`` when they
want to participate in session cleanup under demo mode.
The callable must accept no required arguments. Async (coroutine)
functions are rejected: the janitor runs in a plain thread and cannot
await coroutines.
"""
if not callable(fn):
raise TypeError(
f"register_demo_janitor_hook expects a callable, got {type(fn).__name__!r}"
)
# Reject coroutine functions — check both the callable itself and its
# __call__ method so objects with an async __call__ (e.g. class instances,
# functools.partial wrappers around async functions) are also caught.
_call = getattr(fn, "__call__", None)
if inspect.iscoroutinefunction(fn) or (
_call is not None and inspect.iscoroutinefunction(_call)
):
raise TypeError(
"register_demo_janitor_hook does not accept async functions; "
"the janitor runs in a plain thread and cannot await coroutines"
)
# Validate that the callable accepts zero required arguments so it won't
# crash at sweep time (hourly, far from the registration site).
try:
sig = inspect.signature(fn)
except ValueError:
# inspect.signature() raises ValueError for built-in C callables whose
# signature cannot be determined. Accept them as-is; if they fail at
# runtime the janitor will catch and log the exception.
pass
else:
required = [
p for p in sig.parameters.values()
if p.default is inspect.Parameter.empty
and p.kind not in (
inspect.Parameter.VAR_POSITIONAL,
inspect.Parameter.VAR_KEYWORD,
)
]
if required:
raise TypeError(
f"register_demo_janitor_hook expects a zero-argument callable; "
f"{fn!r} has {len(required)} required parameter(s): "
+ ", ".join(p.name for p in required)
)
with _DEMO_JANITOR_HOOKS_LOCK:
_DEMO_JANITOR_HOOKS.append(fn)
def _run_janitor_hook(hook) -> None:
"""Run a single janitor hook inline, swallowing and logging any exception.
If the hook returns an awaitable (e.g. a coroutine slipped through the
async-function guard), the coroutine is closed immediately to avoid
``RuntimeWarning: coroutine was never awaited`` noise, and a warning is
emitted so the plugin author knows to fix their hook.
"""
try:
result = hook()
except Exception:
log.exception("janitor hook %r raised", hook)
return
if inspect.iscoroutine(result):
# A coroutine slipped through the async-function guard (e.g. via a
# wrapper/partial). Close it to suppress "coroutine never awaited",
# then warn so the plugin author knows to fix their hook.
try:
result.close()
except Exception:
log.exception("error closing coroutine from janitor hook %r", hook)
warnings.warn(
f"janitor hook {hook!r} returned a coroutine; "
"hooks must be plain synchronous callables — "
"register_demo_janitor_hook does not accept async functions",
RuntimeWarning,
stacklevel=1,
)
elif inspect.isawaitable(result):
# Future/Task: no .close() method; just warn and leave it alone.
warnings.warn(
f"janitor hook {hook!r} returned an awaitable (Future/Task); "
"hooks must be plain synchronous callables",
RuntimeWarning,
stacklevel=1,
)
_DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
("POST", re.compile(r"^/api/settings$")),
("POST", re.compile(r"^/api/settings/import$")),
("POST", re.compile(r"^/api/settings/reset$")),
("POST", re.compile(r"^/api/rescan$")),
("POST", re.compile(r"^/api/rescan/full$")),
("POST", re.compile(r"^/api/songs/upload$")),
("DELETE", re.compile(r"^/api/song/.+$")),
("POST", re.compile(r"^/api/favorites/toggle$")),
("POST", re.compile(r"^/api/loops$")),
("DELETE", re.compile(r"^/api/loops/[^/]+$")),
("POST", re.compile(r"^/api/audio-effects/mappings$")),
("DELETE", re.compile(r"^/api/audio-effects/mappings/[^/]+$")),
("POST", re.compile(r"^/api/audio-effects/mappings/[^/]+/activate$")),
("DELETE", re.compile(r"^/api/audio-effects/active-mapping$")),
("POST", re.compile(r"^/api/song/.*/meta$")),
("POST", re.compile(r"^/api/song/.*/art/upload$")),
("PUT", re.compile(r"^/api/song/.+/overrides$")),
("GET", re.compile(r"^/api/plugins/updates$")),
("POST", re.compile(r"^/api/plugins/[^/]+/update$")),
("POST", re.compile(r"^/api/plugins/editor/save$")),
("POST", re.compile(r"^/api/plugins/editor/build$")),
("POST", re.compile(r"^/api/plugins/editor/upload-art$")),
("POST", re.compile(r"^/api/plugins/editor/upload-audio$")),
("POST", re.compile(r"^/api/plugins/editor/youtube-audio$")),
("POST", re.compile(r"^/api/plugins/editor/import-gp$")),
("POST", re.compile(r"^/api/plugins/editor/import-midi$")),
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/align$")),
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/generate-pitch$")),
("POST", re.compile(r"^/api/plugins/lyrics_karaoke/save-lyrics$")),
("POST", re.compile(r"^/api/plugins/lyrics_sync/align$")),
("POST", re.compile(r"^/api/plugins/lyrics_sync/save$")),
("POST", re.compile(r"^/api/plugins/studio/sessions/[^/]+/extract-drums$")),
("POST", re.compile(r"^/api/diagnostics/export$")),
("GET", re.compile(r"^/api/diagnostics/preview$")),
("GET", re.compile(r"^/api/diagnostics/hardware$")),
# Bundled core plugin — video background upload/delete
("POST", re.compile(r"^/api/plugins/highway_3d/files$")),
("DELETE", re.compile(r"^/api/plugins/highway_3d/files$")),
# fee[dB]ack v0.3.0 write endpoints — demo mode is read-only, so block the
# new profile / XP / stats / playlists / saved mutators too.
("POST", re.compile(r"^/api/profile$")),
("POST", re.compile(r"^/api/profile/avatar$")),
("POST", re.compile(r"^/api/xp/award$")),
("POST", re.compile(r"^/api/stats$")),
("POST", re.compile(r"^/api/playlists$")),
("PATCH", re.compile(r"^/api/playlists/[^/]+$")),
("DELETE", re.compile(r"^/api/playlists/[^/]+$")),
("POST", re.compile(r"^/api/playlists/[^/]+/songs$")),
("DELETE", re.compile(r"^/api/playlists/[^/]+/songs/.+$")),
("POST", re.compile(r"^/api/playlists/[^/]+/reorder$")),
("POST", re.compile(r"^/api/playlists/[^/]+/cover$")),
("DELETE", re.compile(r"^/api/playlists/[^/]+/cover$")),
("POST", re.compile(r"^/api/saved/toggle$")),
# Progression (spec 010) write endpoints — demo mode stays read-only.
("POST", re.compile(r"^/api/progression/paths$")),
("POST", re.compile(r"^/api/progression/onboarding$")),
("POST", re.compile(r"^/api/progression/events$")),
("POST", re.compile(r"^/api/shop/buy$")),
("POST", re.compile(r"^/api/shop/equip$")),
# Enrichment (P8): review writes mutate the local match cache, and the
# search proxy / manual kick relay to MusicBrainz — none of it belongs to
# anonymous demo visitors (they'd spend the shared rate limit).
("POST", re.compile(r"^/api/enrichment/review/.+$")),
("POST", re.compile(r"^/api/enrichment/kick$")),
("POST", re.compile(r"^/api/enrichment/cancel$")),
("POST", re.compile(r"^/api/enrichment/rematch$")),
("GET", re.compile(r"^/api/enrichment/search$")),
# AcoustID audio fingerprinting: both identify endpoints run fpcalc (CPU)
# and spend the shared AcoustID rate budget on the caller's behalf — same
# rule as the search/kick relays above; not for anonymous demo visitors.
("POST", re.compile(r"^/api/enrichment/identify$")),
("POST", re.compile(r"^/api/enrichment/identify/.+$")),
# Context menus (R2): the per-song re-match mutates the cache + spends
# rate limit; Get-info exposes filesystem paths.
("POST", re.compile(r"^/api/enrichment/refresh/.+$")),
("GET", re.compile(r"^/api/chart/.+/fileinfo$")),
# Gap-fill (R4a) rewrites pack files on disk — never for demo visitors.
("POST", re.compile(r"^/api/song/.+/gap-fill$")),
# Art layer (R3): all three mutate server state / touch the network on a
# visitor's behalf — the base64 upload writes files, the URL fetch makes the
# server request arbitrary images, and the override delete removes files.
("POST", re.compile(r"^/api/song/.+/art/upload$")),
("POST", re.compile(r"^/api/song/.+/art/url$")),
("DELETE", re.compile(r"^/api/art/.+/override$")),
# Cover picker (PR-C): read-only, but a cache-miss open spends 1-3
# throttled Cover Art Archive calls — anonymous demo visitors don't get
# to spend the shared rate budget (same rule as enrichment search/kick).
("GET", re.compile(r"^/api/song/.+/art/candidates$")),
# Artist pages (PR-B): the links GET lazily fetches from MusicBrainz on a
# visitor's behalf AND writes the artist_enrichment cache; refresh
# re-spends the shared rate limit. The /page route stays open (all-local
# read). Same rationale as /api/enrichment/search above.
("GET", re.compile(r"^/api/artist/.+/links$")),
("POST", re.compile(r"^/api/artist/.+/links/refresh$")),
]
async def _demo_mode_guard(request: Request, call_next):
if getenv_compat("FEEDBACK_DEMO_MODE") or getenv_compat("FEEDBACK_DEMO_MODE") == "1":
path = request.url.path
for method, pattern in _DEMO_BLOCKED:
if request.method == method and pattern.match(path):
return JSONResponse({"error": "demo mode: read-only"}, status_code=403)
response = await call_next(request)
if request.method == "GET" and path == "/" and "feedBack_demo_session" not in request.cookies:
forwarded_proto = (request.headers.get("x-forwarded-proto") or "").split(",")[0].strip()
is_secure = request.url.scheme == "https" or forwarded_proto.lower() == "https"
response.set_cookie(
"feedBack_demo_session", str(uuid.uuid4()),
max_age=86400, httponly=True, samesite="lax",
secure=is_secure,
)
return response
return await call_next(request)
def install(app) -> None:
"""Attach the demo-mode request guard to `app`.
Called by server.py, which owns the app. A middleware cannot exist without one, and a
module under lib/ should not be reaching for a global to find it.
"""
app.middleware("http")(_demo_mode_guard)
def demo_mode_enabled() -> bool:
"""True when demo mode is on. Read at CALL time, never captured — tests set and unset
FEEDBACK_DEMO_MODE with monkeypatch, so a value cached at import pins the wrong one."""
return bool(getenv_compat("FEEDBACK_DEMO_MODE"))
def start_janitor() -> None:
"""Start the hourly session janitor, at most one at a time. server.py's startup hook.
━━━ THE GUARD ASKS "IS A HEALTHY JANITOR RUNNING?", AND NOTHING ELSE ━━━
Three ways to get this wrong, and #902 plus two Codex passes found all three:
1. NO GUARD (the original #902 bug). The re-entry check lived at the call site as
`A or (B and C)`, so it never ran, and a second startup started a SECOND thread,
overwrote the handle, and left the first to fire hooks forever, unjoinable.
2. GUARD ON THE FLAG (`if _DEMO_JANITOR_STARTED: return`). stop_janitor() deliberately
leaves that flag True when a hook outruns its join timeout — so once that hook
finishes and the thread exits, the flag is stale and a later startup would refuse to
start a replacement. Demo cleanup silently dead for the rest of the process.
3. GUARD ON LIVENESS ALONE (`if thread.is_alive(): return`). A timed-out stop leaves the
old thread ALIVE BUT DOOMED — its stop event is set, and it exits the moment its
current hook returns. Treating it as a running janitor means the replacement is never
started, and we are back at (2) a second later.
So a janitor counts as running only if its thread is alive AND it has not been told to
stop.
━━━ AND WHY EACH JANITOR OWNS ITS STOP EVENT ━━━
This used to `_DEMO_JANITOR_STOP.clear()` a single shared Event. If a replacement were
started while a doomed thread was still finishing a hook, clearing the shared event would
RESURRECT it — it loops back to `stop.wait()`, sees the flag cleared, and carries on.
Two janitors, which is the exact bug we started from.
A fresh Event per janitor makes that impossible: the old thread waits on its OWN event,
which stays set forever, so it can only exit.
"""
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD, _DEMO_JANITOR_STOP
thread = _DEMO_JANITOR_THREAD
if thread is not None and thread.is_alive() and not _DEMO_JANITOR_STOP.is_set():
return # a healthy janitor is already running
# Either there is no janitor, or the previous one is dead / dying. Give the new one its
# OWN stop event so the old one stays stopped no matter what we do to ours.
stop = threading.Event()
_DEMO_JANITOR_STOP = stop
_DEMO_JANITOR_STARTED = True
def _janitor():
# Closes over `stop`, NOT the module global — a later start_janitor() rebinds
# _DEMO_JANITOR_STOP, and this thread must keep watching the event it was born with.
while not stop.wait(timeout=3600):
with _DEMO_JANITOR_HOOKS_LOCK:
hooks = list(_DEMO_JANITOR_HOOKS)
for hook in hooks:
_run_janitor_hook(hook)
_DEMO_JANITOR_THREAD = threading.Thread(target=_janitor, daemon=True, name="demo-janitor")
_DEMO_JANITOR_THREAD.start()
def janitor_started() -> bool:
return _DEMO_JANITOR_STARTED
def stop_janitor(timeout: float = 5) -> bool:
"""Signal the janitor to stop, join it, and drop the registered hooks.
Returns True if it stopped, False if it outlived the join (the caller warns).
THE ORDER HERE IS LOAD-BEARING and preserved exactly from server.py. When the thread
does NOT die within the timeout we return WITHOUT clearing _DEMO_JANITOR_STARTED and
WITHOUT dropping the thread handle — deliberately — so a subsequent startup does not
spawn a SECOND janitor alongside the one still running. Clearing the flag first (the
obvious way to write this) would quietly reintroduce exactly the double-janitor leak
the flag exists to prevent.
"""
global _DEMO_JANITOR_STARTED, _DEMO_JANITOR_THREAD
if not _DEMO_JANITOR_STARTED:
return True
_DEMO_JANITOR_STOP.set()
thread = _DEMO_JANITOR_THREAD
if thread is not None:
thread.join(timeout=timeout)
if thread.is_alive():
# Leave _DEMO_JANITOR_STARTED True so a new janitor is not spawned by a
# subsequent startup while the old one is alive.
return False
_DEMO_JANITOR_THREAD = None
_DEMO_JANITOR_STARTED = False
with _DEMO_JANITOR_HOOKS_LOCK:
_DEMO_JANITOR_HOOKS.clear()
return True
+1 -9
View File
@@ -14,7 +14,6 @@ import os
from pathlib import Path
import appstate
from safepath import resolved_root
def _get_dlc_dir(cfg: dict | None = None) -> Path | None:
@@ -87,14 +86,7 @@ def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
or PureWindowsPath(safe).drive):
return None
try:
# The library root is fixed for the life of the process, but this
# function runs once per song / art fetch / scanned row — and
# `.resolve()` lstats every path component. Re-resolving here was
# ~23,500 stat calls/sec on a 50,944-song library, which pins a core
# when the library sits on a FUSE mount (NTFS-3G, SMB, sshfs) where each
# stat is a userspace round trip. Resolve the root once; see
# safepath.resolved_root for the caching contract.
root = resolved_root(dlc)
root = dlc.resolve()
# normpath collapses `.`/`..`/duplicate separators purely lexically —
# it never touches the filesystem, so an in-library junction component
# is preserved (allowed) while `..`/absolute segments still escape and
-1122
View File
File diff suppressed because it is too large Load Diff
-417
View File
@@ -1,417 +0,0 @@
"""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}")
+15 -98
View File
@@ -23,18 +23,9 @@ Engine selection
Two transcription paths share a common output:
* `transcribe_vocals_remote(path, server_url, ...)` — POST the vocal
stem to the `/transcribe` endpoint on a feedBack-demucs-server
(got-feedBack's reference server already hosts WhisperX alongside
Demucs at the same URL).
It used to POST to `/align`, which is *forced alignment* — "here are
the lyrics, tell me when each word is sung". Its `text` field is
required and we have no lyrics (transcribing them is the point), so
the server answered 422 from FastAPI's validation layer before its
handler ran, and remote transcription never worked for anyone
(feedBack-plugin-stem-splitter#17). `/transcribe` takes only audio.
Requires feedBack-demucs-server ≥ the revision adding that endpoint;
an older server answers 404 and the error says so.
stem to the `/align` endpoint on a feedBack-demucs-server (got-feedBack's
reference server already hosts WhisperX alongside Demucs at the same
URL).
* `transcribe_vocals_local(path, ...)` — load WhisperX in-process. Heavy
(~3 GB of model weights for `large-v2` + the wav2vec2 aligner) and
@@ -425,38 +416,6 @@ def transcribe_vocals_local(
# ── Remote transcription ────────────────────────────────────────────────────
_MAX_ERR_BODY = 4000
def _err_body(resp) -> str:
"""The server's error body, whole if it plausibly is one, and marked when it isn't.
This was capped at 300 chars, which is enough for "Internal Server Error" and not much else.
The bodies carrying the most diagnosis are the long ones — a FastAPI validation body naming
the field it rejected, a 500 whose traceback answers on its LAST line — and those are exactly
the ones a 300-char cap decapitates. The cap survives so a server answering with a 2 MB HTML
error page can't dump a novel into a log line.
"""
# Strip FIRST, then measure: a body that is 300 chars of JSON and 3900 of trailing whitespace
# is not a long body, and truncating it would cut real content to make room for blanks.
text = (getattr(resp, "text", "") or "").strip()
if len(text) <= _MAX_ERR_BODY:
return text
# Keep the HEAD **and the TAIL**. Head-only truncation throws away the exception line — and
# on a traceback the exception line is the answer. This docstring said as much while the code
# did the opposite: it cut off precisely the part it exists to preserve, which is the same
# mistake, one level up, as the 300-char cap it replaced.
#
# The marker sits inside the bound, not past it: otherwise _MAX_ERR_BODY is a suggestion, and
# the callers who trust it (a log line, a job record persisted to disk) are the ones surprised.
marker = f"\n… [truncated, {len(text)} chars total] …\n"
budget = max(0, _MAX_ERR_BODY - len(marker))
head = budget * 2 // 3 # context: what was being attempted
tail = budget - head # verdict: what actually went wrong
return text[:head].rstrip() + marker + text[len(text) - tail:].lstrip()
def transcribe_vocals_remote(
vocals_path: Path,
server_url: str,
@@ -467,17 +426,7 @@ def transcribe_vocals_remote(
min_word_score: float = 0.35,
progress_cb: ProgressCB = None,
) -> list[dict]:
"""POST the vocal stem to `{server_url}/transcribe` and parse the response.
NOT `/align` — that endpoint is forced alignment ("here are the lyrics,
tell me when each word is sung") and its `text` field is required. We
have no lyrics; producing them is the point. Posting there returned a
422 from FastAPI's validation layer before the server's handler ran, so
remote transcription never worked at all
(feedBack-plugin-stem-splitter#17).
Requires a feedBack-demucs-server carrying `/transcribe`; an older one
answers 404 and the raised error says so.
"""POST the vocal stem to `{server_url}/align` and parse the response.
Expects the server to respond with a JSON object carrying a `words` (or
`segments`) field in WhisperX's native shape; `_whisperx_to_sloppak`
@@ -505,53 +454,21 @@ def transcribe_vocals_remote(
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# POST to /transcribe, not /align.
#
# /align is FORCED ALIGNMENT: "here are the lyrics, tell me when each word is sung". Its
# `text` field is required, and we have no lyrics — transcription is the whole point. So the
# server rejected every request with a 422 in FastAPI's validation layer, before its handler
# ever ran, and remote transcription has never worked for anyone. /transcribe answers the
# question we are actually asking and takes only the audio.
# (feedBack-plugin-stem-splitter#17; endpoint added in feedBack-demucs-server#14.)
#
# `language` goes in the FORM BODY, not the query string: the server reads it with
# Form(""), and a query param would be silently ignored — so an explicit language hint would
# do nothing and Whisper's auto-detection would quietly decide instead, which is exactly the
# kind of "it works but it's wrong" that hides for months.
form: dict[str, str] = {}
params: dict[str, str] = {}
if language:
form["language"] = language
params["language"] = language
# Everything that can go wrong out here comes back as RuntimeError, which is what the
# docstring promises and what the caller catches. A DNS failure, a timeout, a reset
# connection or an unreadable stem file would otherwise surface as requests.RequestException
# or OSError and escape the one handler written to log-and-continue — turning "this song's
# lyrics failed" into "the whole batch died".
try:
with open(vocals_path, "rb") as f:
resp = requests.post(
f"{server_url}/transcribe",
files={"file": (vocals_path.name, f, "audio/ogg")},
data=form or None,
headers=headers or None,
timeout=timeout,
)
except requests.RequestException as e:
raise RuntimeError(f"could not reach the WhisperX server at {server_url}: {e}") from e
except OSError as e:
raise RuntimeError(f"could not read the vocal stem {vocals_path.name}: {e}") from e
if resp.status_code == 404:
# The endpoint isn't there. Say what that means, because "404" on its own sends someone
# hunting for a typo in their URL when the real answer is that their server predates the
# feature. (feedBack-demucs-server#14 added /transcribe.)
raise RuntimeError(
f"the WhisperX server at {server_url} has no /transcribe endpoint (404) — it "
f"predates remote transcription support. Update the server, or use 'Check for "
f"update' if it is the plugin-managed one."
with open(vocals_path, "rb") as f:
resp = requests.post(
f"{server_url}/align",
files={"file": (vocals_path.name, f, "audio/ogg")},
params=params,
headers=headers or None,
timeout=timeout,
)
if resp.status_code != 200:
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {_err_body(resp)}")
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {resp.text[:300]}")
data = resp.json()
+19 -94
View File
@@ -614,14 +614,6 @@ class MetadataDB:
)
""")
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_stats_recent ON song_stats(last_played_at DESC)")
# Cumulative wall-clock play time (career "hours in genre" odometer).
# Fed by the same POST /api/stats the recorder already sends; additive
# + idempotent like every other song_stats change.
try:
self.conn.execute(
"ALTER TABLE song_stats ADD COLUMN seconds_total REAL NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Playlists + the reserved "Saved for Later" system playlist. Additive.
self.conn.execute("""
CREATE TABLE IF NOT EXISTS playlists (
@@ -909,9 +901,6 @@ class MetadataDB:
"best_accuracy": max(cur["best_accuracy"] or 0.0, r["best_accuracy"] or 0.0),
"last_score": newer["last_score"], "last_accuracy": newer["last_accuracy"],
"last_position": newer["last_position"],
# Play time is additive: both encodings' hours belong to
# the one canonical song.
"seconds_total": (cur.get("seconds_total") or 0.0) + (r.get("seconds_total") or 0.0),
"last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"],
}
# Atomic swap: clear and reinsert the canonicalized set in one txn.
@@ -1085,31 +1074,16 @@ class MetadataDB:
vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
return vals
# Effective genre precedence: per-song OVERRIDE (Fix-metadata popup)
# scanned pack genre → MusicBrainz enrichment primary genre (matched/manual rows
# only — a 'review'/'failed' candidate's genres could belong to the wrong
# recording). Applied at FILTER/FACET time (like the P4 artist alias) so a
# corrected or enriched genre is browsable. The vast majority of converted
# packs carry no `genres` manifest key, so without the enrichment leg the
# genre facet (and career passports) starve on real libraries. The
# correlated subqueries are used ONLY when overrides/enrichment genres
# actually exist; the common case stays on the plain indexed `genre`
# column. Genre stays a library-only overlay (it isn't a write-to-file
# field), so it never touches the pack.
_EFFECTIVE_GENRE_OVERRIDE_SQL = (
"COALESCE((SELECT o.value FROM song_field_override o "
"WHERE o.filename = songs.filename AND o.field = 'genre' "
"AND o.value IS NOT NULL AND o.value != ''), genre)"
)
# Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the
# scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias)
# so a corrected genre is browsable — the correlated subquery is used ONLY
# when genre overrides actually exist; the common case stays on the plain
# indexed `genre` column. Genre stays a library-only overlay (it isn't a
# write-to-file field), so it never touches the pack.
_EFFECTIVE_GENRE_SQL = (
"COALESCE((SELECT o.value FROM song_field_override o "
"WHERE o.filename = songs.filename AND o.field = 'genre' "
"AND o.value IS NOT NULL AND o.value != ''), "
"NULLIF(genre, ''), "
"(SELECT json_extract(e.genres, '$[0]') FROM song_enrichment e "
"WHERE e.filename = songs.filename AND e.match_state IN ('matched', 'manual') "
"AND e.genres IS NOT NULL AND e.genres NOT IN ('', '[]')), "
"'')"
"AND o.value IS NOT NULL AND o.value != ''), genre)"
)
def _has_genre_overrides(self) -> bool:
@@ -1117,25 +1091,9 @@ class MetadataDB:
"SELECT 1 FROM song_field_override WHERE field = 'genre' "
"AND value IS NOT NULL AND value != '' LIMIT 1").fetchone() is not None
def _has_enrichment_genres(self) -> bool:
try:
return self.conn.execute(
"SELECT 1 FROM song_enrichment WHERE match_state IN ('matched', 'manual') "
"AND genres IS NOT NULL AND genres NOT IN ('', '[]') "
"LIMIT 1").fetchone() is not None
except sqlite3.OperationalError:
return False # stand-ins / DBs without the enrichment table
def _effective_genre_expr(self) -> str:
"""`genre` normally; the enrichment-aware COALESCE only when trusted
enrichment genres exist (which also proves the table exists — a
stand-in DB without song_enrichment must never receive SQL that
references it); the override-only form when just overrides exist."""
if self._has_enrichment_genres():
return self._EFFECTIVE_GENRE_SQL
if self._has_genre_overrides():
return self._EFFECTIVE_GENRE_OVERRIDE_SQL
return "genre"
"""`genre` normally; the override-aware COALESCE only when overrides exist."""
return self._EFFECTIVE_GENRE_SQL if self._has_genre_overrides() else "genre"
def set_song_tags(self, filename: str, tags) -> list:
"""Replace ALL of a song's tags with the given set (each normalized;
@@ -1735,8 +1693,7 @@ class MetadataDB:
# ── Per-song practice stats ───────────────────────────────────────────---
_STATS_COLS = (
"filename", "arrangement", "plays", "best_score", "best_accuracy",
"last_score", "last_accuracy", "last_position", "seconds_total",
"last_played_at", "updated_at",
"last_score", "last_accuracy", "last_position", "last_played_at", "updated_at",
)
def _stats_row(self, filename: str, arrangement: int) -> dict | None:
@@ -2103,9 +2060,8 @@ class MetadataDB:
self.conn.commit()
def record_session(self, filename: str, arrangement: int, *, score: int,
accuracy: float, last_position=None, seconds: float = 0) -> dict:
"""Record a scored play: plays += 1, best_* = max, last_* = new.
`seconds` (wall-clock play time from the recorder) accrues."""
accuracy: float, last_position=None) -> dict:
"""Record a scored play: plays += 1, best_* = max, last_* = new."""
from song_score import merge_stats
with self._lock:
existing = self._stats_row(filename, int(arrangement))
@@ -2115,9 +2071,8 @@ class MetadataDB:
self.conn.execute(
"""INSERT INTO song_stats
(filename, arrangement, plays, best_score, best_accuracy,
last_score, last_accuracy, last_position, seconds_total,
last_played_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
last_score, last_accuracy, last_position, last_played_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
plays = excluded.plays,
@@ -2126,62 +2081,32 @@ class MetadataDB:
last_score = excluded.last_score,
last_accuracy = excluded.last_accuracy,
last_position = excluded.last_position,
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""",
(filename, int(arrangement), merged["plays"], merged["best_score"],
merged["best_accuracy"], merged["last_score"], merged["last_accuracy"],
merged["last_position"], float(seconds or 0)),
merged["last_position"]),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
def touch_position(self, filename: str, arrangement: int, last_position: float,
seconds: float = 0) -> dict:
def touch_position(self, filename: str, arrangement: int, last_position: float) -> dict:
"""Persist just the resume position (no plays/score change), so
Continue-Playing works for non-scored plays. Also stamps
last_played_at — both /api/stats/recent and /api/session/continue
filter/order on it, so a position-only touch must set it or the song
never surfaces as 'recent' / 'continue playing'. `seconds` accrues
wall-clock play time (career hours odometer)."""
never surfaces as 'recent' / 'continue playing'."""
with self._lock:
self.conn.execute(
"""INSERT INTO song_stats (filename, arrangement, last_position,
seconds_total, last_played_at, updated_at)
VALUES (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
last_position = excluded.last_position,
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""",
(filename, int(arrangement), float(last_position), float(seconds or 0)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
def add_play_seconds(self, filename: str, arrangement: int, seconds: float) -> dict:
"""Accrue wall-clock play time (no plays/score/position change) —
the recorder's seconds-only flush for unscored plays that ran to the
song's natural end (no resume position to touch there: `song:ended`
must not overwrite Continue with the end-of-song offset). Stamps
last_played_at like touch_position does: the song WAS played, so
/api/stats/recent and Continue ordering must see it. Accepted skew:
the recorder retries FAILED flushes later, which stamps recency at
retry time — rare (offline corner), self-healing on the next play,
and preferable to the alternative (keep-existing would leave repeat
plays looking stale, the common case)."""
with self._lock:
self.conn.execute(
"""INSERT INTO song_stats (filename, arrangement, seconds_total,
last_played_at, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_position = excluded.last_position,
last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""",
(filename, int(arrangement), float(seconds)),
(filename, int(arrangement), float(last_position)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
-513
View File
@@ -1,513 +0,0 @@
"""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
@@ -1,126 +0,0 @@
"""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
@@ -1,295 +0,0 @@
"""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
@@ -1,346 +0,0 @@
"""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 queuedworkingresult 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
@@ -1,485 +0,0 @@
"""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}
-75
View File
@@ -1,75 +0,0 @@
"""Small meta_db-backed library / user-state endpoints — work keeper-chart
prefs, favorites, personal tags, saved-for-later, and continue-playing.
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``. All paths
are distinct and non-overlapping, so mounting them together (rather than at each
original scattered site) does not change routing.
"""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import appstate
from reqfields import _clean_str
router = APIRouter()
@router.get("/api/work/{work_key:path}/charts")
def api_get_work_charts(work_key: str):
"""All charts in a work + which is the keeper (your pick vs auto-pick)."""
return appstate.meta_db.work_charts(work_key)
@router.put("/api/work/{work_key:path}/preferred")
def api_set_work_preferred(work_key: str, data: dict):
"""Set the keeper chart of a work: body {filename}. The filename must be a
current member of the work. Returns the refreshed chart list."""
fn = (data.get("filename") or "").strip()
if not fn:
return JSONResponse({"error": "filename is required"}, 400)
members = {c["filename"] for c in appstate.meta_db.work_charts(work_key)["charts"]}
if fn not in members:
return JSONResponse({"error": "filename is not a chart of this work"}, 400)
appstate.meta_db.set_chart_preferred(work_key, fn)
return appstate.meta_db.work_charts(work_key)
@router.delete("/api/work/{work_key:path}/preferred")
def api_reset_work_preferred(work_key: str):
"""Reset a work to auto-pick (drop the explicit preferred)."""
appstate.meta_db.clear_chart_preferred(work_key)
return appstate.meta_db.work_charts(work_key)
@router.post("/api/favorites/toggle")
def toggle_favorite(data: dict):
"""Toggle a song's favorite status."""
filename = data.get("filename", "")
if not filename:
return {"error": "No filename"}
new_state = appstate.meta_db.toggle_favorite(filename)
return {"favorite": new_state}
@router.get("/api/tags")
def list_tags():
"""All personal tags in use (over still-present songs), most-used first —
powers the tag filter UI."""
return {"tags": appstate.meta_db.all_tags()}
@router.post("/api/saved/toggle")
def api_toggle_saved(data: dict):
"""Add/remove a song on the reserved Saved-for-Later playlist."""
filename = _clean_str(data.get("filename"))
if not filename:
return JSONResponse({"error": "filename required"}, status_code=400)
return {"saved": appstate.meta_db.toggle_saved(filename)}
@router.get("/api/session/continue")
def api_session_continue():
"""The Continue-Playing card's song (most recent play) or null."""
return appstate.meta_db.continue_session()
-162
View File
@@ -1,162 +0,0 @@
"""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)
-138
View File
@@ -1,138 +0,0 @@
"""Player profile — identity, avatars (bundled + custom uploads), and progress.
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
``meta_db`` -> ``appstate.meta_db``, ``CONFIG_DIR``/``STATIC_DIR`` ->
``appstate.config_dir``/``appstate.static_dir`` (seam), ``_clean_str`` from
``reqfields``, ``_get_progression_content()`` ->
``appstate.get_progression_content()``. The bundled-avatar lister moves with it.
"""
import logging
import secrets
from fastapi import APIRouter
from fastapi.responses import FileResponse, JSONResponse
import appstate
from reqfields import _clean_str
log = logging.getLogger("feedBack.server")
router = APIRouter()
def _list_bundled_avatars() -> list[str]:
"""Bundled default avatar filenames under static/v3/avatars/."""
d = appstate.static_dir / "v3" / "avatars"
if not d.is_dir():
return []
exts = {".svg", ".png", ".webp"}
return sorted(
p.name for p in d.iterdir()
if p.is_file() and p.suffix.lower() in exts and not p.name.startswith(".")
)
@router.get("/api/profile")
def api_get_profile():
profile = appstate.meta_db.get_profile()
# Equipped cosmetics ride along (resolved to their payloads) so the theme
# and avatar frame apply at boot without an extra request. Never let a
# cosmetics/content problem break the profile read.
cosmetics = {}
try:
shop = appstate.get_progression_content()["shop"]
for slot, item_id in appstate.meta_db.get_equipped().items():
item = shop.get(item_id)
if item:
cosmetics[slot] = {"item_id": item_id, "payload": item["payload"]}
except Exception:
log.warning("profile cosmetics enrich failed", exc_info=True)
profile["cosmetics"] = cosmetics
return profile
@router.post("/api/profile")
def api_set_profile(data: dict):
"""Set/update the player profile. Body: {display_name, avatar:{type,value}}.
avatar.type is 'default' (value = bundled filename) or 'upload' (value =
the /api/profile/avatar/<name> URL returned by the upload endpoint); omit
avatar to keep the existing one (name-only edit)."""
name = _clean_str(data.get("display_name"))
if not (1 <= len(name) <= 32):
return JSONResponse({"error": "Display name must be 132 characters."}, status_code=400)
avatar = data.get("avatar")
if avatar is None:
avatar = {} # omitted → keep the current avatar (name-only edit)
elif not isinstance(avatar, dict):
return JSONResponse({"error": "avatar must be an object."}, status_code=400)
atype = avatar.get("type")
aval = _clean_str(avatar.get("value"))
avatar_url = None
if atype == "default":
if aval not in _list_bundled_avatars():
return JSONResponse({"error": "Unknown default avatar."}, status_code=400)
avatar_url = f"/static/v3/avatars/{aval}"
elif atype == "upload":
from safepath import safe_join
fname = aval.rsplit("/", 1)[-1] if aval.startswith("/api/profile/avatar/") else ""
target = safe_join(appstate.config_dir / "avatars", fname) if fname else None
if target is None or not target.is_file():
return JSONResponse({"error": "Uploaded avatar not found."}, status_code=400)
avatar_url = f"/api/profile/avatar/{fname}"
elif atype:
return JSONResponse({"error": "Unknown avatar type."}, status_code=400)
# atype None/missing → keep the current avatar (name-only edit).
return appstate.meta_db.set_profile(name, avatar_url)
@router.get("/api/profile/avatars")
def api_list_avatars():
return [{"name": n, "url": f"/static/v3/avatars/{n}"} for n in _list_bundled_avatars()]
@router.post("/api/profile/avatar")
def api_upload_avatar(data: dict):
"""Upload a custom avatar as base64 (mirrors the album-art upload pattern).
Re-encodes to a 512px PNG under appstate.config_dir/avatars/."""
import base64
import io
b64 = data.get("image", "")
if not isinstance(b64, str) or not b64:
return JSONResponse({"error": "No image data"}, status_code=400)
if "," in b64:
b64 = b64.split(",", 1)[1]
try:
raw = base64.b64decode(b64)
except Exception:
return JSONResponse({"error": "Invalid base64"}, status_code=400)
if len(raw) > 6 * 1024 * 1024:
return JSONResponse({"error": "Image too large (max 6 MB)."}, status_code=400)
avatars_dir = appstate.config_dir / "avatars"
avatars_dir.mkdir(parents=True, exist_ok=True)
try:
from PIL import Image
img = Image.open(io.BytesIO(raw)).convert("RGB")
img.thumbnail((512, 512))
fname = f"upload-{secrets.token_hex(4)}.png" # token busts caches on change
img.save(str(avatars_dir / fname), "PNG")
except Exception as e:
return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400)
return {"url": f"/api/profile/avatar/{fname}"}
@router.get("/api/profile/avatar/{name}")
def api_get_avatar(name: str):
from safepath import safe_join
target = safe_join(appstate.config_dir / "avatars", name)
if target is None or not target.is_file():
return JSONResponse({"error": "not found"}, status_code=404)
return FileResponse(str(target), media_type="image/png")
@router.get("/api/profile/progress")
def api_profile_progress():
"""One call for the whole profile badge: {level, xp, xp_in_level,
xp_to_next, current_streak, best_streak, last_active_date}."""
return appstate.meta_db.get_progress()
-230
View File
@@ -1,230 +0,0 @@
"""Progression (spec 010) — mastery rank, challenges, quests, onboarding paths.
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``, and the
two shared server accessors read through the seam:
``_get_progression_content()`` -> ``appstate.get_progression_content()`` and
``_builtin_diagnostic_filename()`` -> ``appstate.builtin_diagnostic_filename()``.
The exclusive helpers (_goal_ui_progress, _progression_overview) + the
_PROGRESSION_EVENT_TYPES whitelist move with it.
"""
import math
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import appstate
from reqfields import _clean_str
router = APIRouter()
def _goal_ui_progress(goal: dict, state: dict, streak: int, xp_total: int) -> tuple:
"""(count, target) for a challenge/quest progress bar. Count goals show
n/target; threshold goals show how far the live stat is along the line."""
import progression as progression_mod
gtype = goal.get("type")
if gtype in progression_mod.COUNT_GOAL_TYPES:
target = int(goal.get("target") or 1)
count = target if state.get("completed") else min(int(state.get("count") or 0), target)
return count, target
if gtype == "streak_reached":
target = int(goal.get("days") or 1)
return (target if state.get("completed") else min(streak, target)), target
if gtype == "db_earned":
target = int(goal.get("amount") or 1)
return (target if state.get("completed") else min(xp_total, target)), target
return 0, 1
def _progression_overview() -> dict:
"""The full GET /api/progression payload (also the capability `inspect`
result): rank, onboarding, per-path challenge checklists, quests, wallet."""
import progression as progression_mod
from datetime import datetime as _dt
content = appstate.get_progression_content()
now = _dt.now()
appstate.meta_db.ensure_quest_period(content, now)
state = appstate.meta_db.get_progression_state()
player_paths = appstate.meta_db.get_player_paths()
challenge_state = appstate.meta_db.get_challenge_state()
wallet = appstate.meta_db.get_wallet()
streak_progress = appstate.meta_db.get_progress()
streak = int(streak_progress.get("current_streak") or 0)
xp_total = wallet["lifetime_db"]
keys = progression_mod.period_keys(now)
def _path_order(pid):
pdef = content["paths"].get(pid) or {}
return (pdef.get("order") or 0, pid)
paths_payload = []
for pid in sorted(player_paths, key=_path_order):
pdef = content["paths"].get(pid)
level = player_paths[pid]
if not pdef:
# Path selected under older content that no longer ships: keep its
# rank contribution visible rather than silently dropping it.
paths_payload.append({"id": pid, "name": pid, "icon": "", "level": level,
"max_level": level, "next": None})
continue
next_block = None
active = progression_mod.active_challenges(content, pid, level)
if active:
level_def = next(e for e in pdef["levels"] if e["level"] == level + 1)
challenges = []
completed_count = 0
for ch in active:
st = challenge_state.get(ch["id"]) or {}
count, target = _goal_ui_progress(ch["goal"], st, streak, xp_total)
if st.get("completed"):
completed_count += 1
challenges.append({
"id": ch["id"],
"title": ch["title"],
"description": ch["description"],
"count": count,
"target": target,
"completed": bool(st.get("completed")),
"completed_at": st.get("completed_at"),
})
next_block = {
"level": level + 1,
"required": level_def["required"],
"completed": completed_count,
"challenges": challenges,
}
paths_payload.append({
"id": pid,
"name": pdef["name"],
"icon": pdef["icon"],
"level": level,
"max_level": progression_mod.path_max_level(content, pid),
"next": next_block,
})
available = [
{"id": pid, "name": pdef["name"], "icon": pdef["icon"]}
for pid, pdef in sorted(content["paths"].items(), key=lambda kv: (kv[1].get("order") or 0, kv[0]))
if pid not in player_paths
]
quest_rows = appstate.meta_db.get_quest_rows(keys)
quests_payload = {}
for period_type in ("daily", "weekly"):
pool = content["quests"][period_type]["pool"]
quests = []
for row in quest_rows:
if row["period_type"] != period_type:
continue
qdef = pool.get(row["quest_id"])
if not qdef:
continue # removed from the pool mid-period: hide, keep the row
count, target = _goal_ui_progress(qdef["goal"], row, streak, xp_total)
quests.append({
"id": row["quest_id"],
"title": qdef["title"],
"description": qdef["description"],
"reward_db": row["reward_db"],
"count": count,
"target": target,
"completed": row["completed"],
"completed_at": row["completed_at"],
})
quests_payload[period_type] = {
"period_key": keys[period_type],
"resets_at": progression_mod.period_resets_at(period_type, now).isoformat(),
"quests": quests,
}
return {
"mastery_rank": progression_mod.mastery_rank(state["calibration_status"], player_paths),
"onboarding": {
"calibration_status": state["calibration_status"],
"calibration_completed_at": state["calibration_completed_at"],
"diagnostic_filename": appstate.builtin_diagnostic_filename(),
},
"paths": paths_payload,
"available_paths": available,
"quests": quests_payload,
"wallet": wallet,
}
@router.get("/api/progression")
def api_progression():
return _progression_overview()
@router.post("/api/progression/paths")
def api_progression_add_paths(data: dict):
"""Select instrument paths. Body: {add: [path_id, ...]}. Idempotent;
removal is unsupported (Mastery Rank never decreases)."""
add = data.get("add")
if not isinstance(add, list) or not add:
return JSONResponse({"error": "add must be a non-empty list of path ids"}, status_code=400)
content = appstate.get_progression_content()
for pid in add:
if not isinstance(pid, str) or pid not in content["paths"]:
return JSONResponse({"error": f"unknown path: {pid!r}"}, status_code=400)
appstate.meta_db.add_player_paths(add)
return _progression_overview()
@router.post("/api/progression/onboarding")
def api_progression_onboarding(data: dict):
"""Onboarding calibration choice. Body: {action: "skip"} — completing the
calibration needs no endpoint, it flows through the normal /api/stats path."""
if _clean_str(data.get("action")) != "skip":
return JSONResponse({"error": "action must be 'skip'"}, status_code=400)
# Spec invariant: onboarding requires picking at least one instrument path
# before finishing, so skipping straight to rank 1 with no paths would
# leave a rank that can never grow. Only enforced when the content bundle
# actually defines paths — broken/empty content must never brick onboarding.
if appstate.get_progression_content()["paths"] and not appstate.meta_db.get_player_paths():
return JSONResponse(
{"error": "select at least one instrument path before skipping calibration"},
status_code=400,
)
appstate.meta_db.skip_calibration()
return _progression_overview()
# Externally postable progression events. song_completed is deliberately NOT
# here: it is server-derived inside /api/stats so the scored-session authority
# stays in one place.
_PROGRESSION_EVENT_TYPES = {"minigame_run"}
@router.post("/api/progression/events")
def api_progression_events(data: dict):
"""Generic progression-event intake for plugins (capability `record-event`).
Body: {type, payload}. Whitelisted types, scalar payload values only."""
etype = _clean_str(data.get("type"))
if etype not in _PROGRESSION_EVENT_TYPES:
return JSONResponse(
{"error": f"event type must be one of {sorted(_PROGRESSION_EVENT_TYPES)}"},
status_code=400,
)
payload = data.get("payload")
if payload is None:
payload = {}
if not isinstance(payload, dict) or len(payload) > 16:
return JSONResponse({"error": "payload must be a small object"}, status_code=400)
clean = {}
for key, value in payload.items():
if not isinstance(key, str) or len(key) > 64:
return JSONResponse({"error": "payload keys must be short strings"}, status_code=400)
if value is None:
continue
if isinstance(value, bool) or (
not isinstance(value, (int, float, str))
) or (isinstance(value, float) and not math.isfinite(value)) or (
isinstance(value, str) and len(value) > 256
):
return JSONResponse({"error": "payload values must be short strings or finite numbers"}, status_code=400)
clean[key] = value
summary = appstate.meta_db.record_progression_event(etype, clean, appstate.get_progression_content())
return {"ok": True, "progression": summary}
File diff suppressed because it is too large Load Diff
-62
View File
@@ -1,62 +0,0 @@
"""Cosmetics shop (spec 010) — buy/equip avatars & themes with earned currency.
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
``meta_db`` -> ``appstate.meta_db``, ``_get_progression_content()`` ->
``appstate.get_progression_content()`` (the accessor is injected into the seam;
its lazy content cache stays in server.py).
"""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import appstate
from reqfields import _clean_str
router = APIRouter()
@router.get("/api/shop")
def api_shop():
content = appstate.get_progression_content()
owned = appstate.meta_db.get_owned_items()
equipped = appstate.meta_db.get_equipped()
items = [
{**item, "owned": iid in owned, "equipped": equipped.get(item["slot"]) == iid}
for iid, item in sorted(content["shop"].items())
]
return {"items": items, "wallet": appstate.meta_db.get_wallet()}
@router.post("/api/shop/buy")
def api_shop_buy(data: dict):
"""Spend Decibels on a cosmetic. Atomic: balance check + spend + ownership
in one transaction. Decibels are earned by playing only never purchasable."""
item_id = _clean_str(data.get("item_id"))
item = appstate.get_progression_content()["shop"].get(item_id)
if not item:
return JSONResponse({"error": f"unknown item: {item_id!r}"}, status_code=400)
status, wallet = appstate.meta_db.buy_shop_item(item)
if status == "owned":
return JSONResponse({"error": "already owned", "wallet": wallet}, status_code=409)
if status == "insufficient":
return JSONResponse({"error": "insufficient balance", "wallet": wallet}, status_code=402)
return {"ok": True, "item_id": item_id, "wallet": wallet}
@router.post("/api/shop/equip")
def api_shop_equip(data: dict):
"""Equip an owned cosmetic into its slot. Body: {slot, item_id|null}
(null unequips, restoring the default look)."""
import progression as progression_mod
slot = _clean_str(data.get("slot"))
if slot not in progression_mod.SHOP_SLOTS:
return JSONResponse({"error": f"slot must be one of {sorted(progression_mod.SHOP_SLOTS)}"}, status_code=400)
item_id = data.get("item_id")
if item_id is not None:
item_id = _clean_str(item_id)
item = appstate.get_progression_content()["shop"].get(item_id)
if not item or item["slot"] != slot:
return JSONResponse({"error": f"unknown item for slot {slot}: {item_id!r}"}, status_code=400)
if item_id not in appstate.meta_db.get_owned_items():
return JSONResponse({"error": "item not owned"}, status_code=403)
return {"ok": True, "equipped": appstate.meta_db.equip_item(slot, item_id)}
-931
View File
@@ -1,931 +0,0 @@
"""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}
def _playable_stems_payload(filename: str, dlc) -> dict:
"""The playable stems (id/url/default) + full-mix URL for a sloppak.
Why it exists: the stems plugin could only learn its stem list from the
highway's WS `ready`, which arrives once the highway is already up. So it
decoded, and then copied the whole song's PCM to its worklet, with the player
on screen half a gigabyte of memcpy in one frame, ~700 ms, freezing the
venue video. Given the list at `song:loading` it can do all of that BEFORE the
highway appears, behind the loading overlay where a stall costs nothing.
The list MUST be the same one the WS sends a moment later. If it is not, the
plugin preloads a graph and then throws it away and rebuilds strictly worse
than not preloading. So this does not reimplement the WS's construction, it
calls THE SAME FUNCTION: load_song, whose LoadedSloppak already carries the
partitioned stems and the resolved full mix, and then builds the URLs exactly
as ws_highway does. Drift is impossible by construction rather than by
agreement which matters, because `full_mix` in particular is not simply the
`full` stem: load_song falls back to the deprecated `original_audio:` key for
every pack written before feedpak 1.15.0, and reimplementing that (I did, at
first) silently dropped the pristine full mix for most real libraries.
Opt-in (`?stems=1`) so the library's own metadata calls — the hot path — pay
nothing for it. Non-sloppak sources (archives, loose folders) have no stems
to preload: load_song raises and we return the empty list.
"""
from urllib.parse import quote
try:
loaded = sloppak_mod.load_song(filename, dlc, appstate.sloppak_cache_dir)
except Exception:
return {"stems": [], "full_mix_url": None}
q_fn = quote(filename, safe="")
def _url(rel: str) -> str:
return f"/api/sloppak/{q_fn}/file/{quote(rel)}"
return {
"stems": [
{"id": s["id"], "url": _url(s["file"]), "default": s["default"]}
for s in loaded.stems
],
"full_mix_url": _url(loaded.full_mix) if loaded.full_mix else None,
}
@router.get("/api/song/{filename:path}")
async def get_song_info(filename: str, stems: int = 0):
"""Return song metadata, from cache or by extracting it from the song source.
`?stems=1` additionally returns the playable stem list with URLs, so the
stems plugin can start fetching/decoding on `song:loading` instead of waiting
for the highway's WS `ready` (see _playable_stems_payload).
"""
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)
loop = asyncio.get_event_loop()
# The stem list is NOT stored in the metadata cache: that is a fixed-column
# table, and widening it would mean a migration plus a stale row for every
# song already scanned. It is cheap to read on demand (the pack is unpacked
# by then, so this is a plain manifest read), and only the opt-in caller pays.
async def _with_stems(meta: dict) -> dict:
if not stems:
return meta
extra = await loop.run_in_executor(
None, _playable_stems_payload, filename, dlc)
return {**meta, **extra}
if cached:
return await _with_stems(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 loop.run_in_executor(None, _extract)
return await _with_stems(meta)
-265
View File
@@ -1,265 +0,0 @@
"""Gameplay scoring — XP award + per-song practice stats (record / recent / best /
top / per-song). The `/api/stats/{filename:path}` route is registered LAST so its
catch-all doesn't shadow the fixed /recent /best /top paths.
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
``meta_db`` -> ``appstate.meta_db``, ``_get_progression_content()`` /
``_builtin_diagnostic_filename()`` read through the seam.
"""
import logging
import math
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import appstate
from metadata_db import _as_int
from reqfields import _clean_str
log = logging.getLogger("feedBack.server")
router = APIRouter()
@router.post("/api/xp/award")
def api_award_xp(data: dict):
"""Award XP into the unified store. Body: {source, amount}. Returns the
new progress payload. The single XP authority song-play, minigames, and
tutorials all feed this (no second curve)."""
try:
amount = _as_int(data.get("amount", 0)) # rejects bool / non-integral / inf
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "amount must be an integer"}, status_code=400)
# Upper-bound it: an unbounded value overflows SQLite's 64-bit INTEGER on
# bind (→ 500) and no real run awards anywhere near this.
if not (0 <= amount <= 10_000_000):
return JSONResponse({"error": "amount must be between 0 and 10,000,000"}, status_code=400)
appstate.meta_db.award_xp(amount)
return appstate.meta_db.get_progress()
@router.post("/api/stats")
def api_record_stats(data: dict):
"""Record a play. With `score`+`accuracy` → a scored session (plays += 1,
best_* = max, last_* = new) plus unified-XP + streak side-effects. With
only `lastPlayPosition`/`last_position` a lightweight resume-position
touch (no plays change) so Continue-Playing works for non-scored plays."""
filename = _clean_str(data.get("filename"))
if not filename:
return JSONResponse({"error": "filename required"}, status_code=400)
# The recorder hands us URL-encoded filenames; canonicalize to the library
# key so stored rows line up with `songs` (and so the arrangement-count bound
# below resolves the real song). See MetadataDB._canonical_song_filename.
filename = appstate.meta_db._canonical_song_filename(filename)
arr_raw = data.get("arrangement", 0)
if arr_raw is None:
arrangement = 0
else:
try:
arrangement = _as_int(arr_raw) # rejects bool / non-integral (1.9) / inf
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400)
# Reject (don't silently coerce to 0) so a malformed/out-of-range index
# can't corrupt arrangement 0's stats; also keeps it bindable to INTEGER.
if not (0 <= arrangement < 2**63):
return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400)
# Bound against the song's real arrangement count when it's a known library
# song, so a bad index can't create fake arrangement buckets that poison the
# per-song aggregate / Continue. Skipped when the song isn't in the library
# yet (count unknown — dead-song reads are filtered anyway).
_acount = appstate.meta_db.arrangement_count(filename)
if _acount and arrangement >= _acount:
return JSONResponse({"error": "arrangement out of range for this song"}, status_code=400)
score = data.get("score")
accuracy = data.get("accuracy")
last_pos = data.get("lastPlayPosition", data.get("last_position"))
if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
# Optional wall-clock play time (career hours odometer). Bounded per POST:
# the recorder flushes on pause/stop/end, so a single delta beyond 6h is a
# clock artifact (suspend/sleep), not practice.
seconds = data.get("seconds")
if seconds is not None:
if isinstance(seconds, bool):
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
try:
seconds = float(seconds)
if not math.isfinite(seconds):
raise ValueError("non-finite")
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
if not (0 < seconds <= 6 * 3600):
return JSONResponse({"error": "seconds must be between 0 and 21600"}, status_code=400)
seconds = seconds or 0.0
# A scored session needs BOTH score and accuracy. Exactly one provided is
# ambiguous — don't silently fall through to the position-only branch.
if (score is None) != (accuracy is None):
return JSONResponse({"error": "score and accuracy must be provided together"}, status_code=400)
if score is not None and accuracy is not None:
# Reject booleans explicitly — float(True) would otherwise record a play.
if isinstance(score, bool) or isinstance(accuracy, bool):
return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400)
# Reject NaN/Inf too: round(inf) raises OverflowError (→ 500), and a
# stored Inf/NaN later breaks JSON serialization of /api/stats reads.
try:
score = float(score)
accuracy = float(accuracy)
if not (math.isfinite(score) and math.isfinite(accuracy)):
raise ValueError("non-finite")
score = int(round(score))
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400)
# A huge-but-finite score passes isfinite() yet overflows SQLite's
# 64-bit INTEGER on bind (→ 500). Bound it to the int64 range.
if not (0 <= score < 2**63):
return JSONResponse({"error": "score out of range"}, status_code=400)
# accuracy is a 0..1 fraction (the recorder's contract); reject
# out-of-range values so they don't surface as >100% / negative in
# /api/stats/best and the badge UI.
if not (0 <= accuracy <= 1):
return JSONResponse({"error": "accuracy must be between 0 and 1"}, status_code=400)
# Validate the optional resume position in this branch too (the
# position-only branch below already rejects non-finite).
if last_pos is not None:
try:
last_pos = float(last_pos)
if not math.isfinite(last_pos):
raise ValueError("non-finite")
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
row = appstate.meta_db.record_session(filename, arrangement, score=score,
accuracy=accuracy, last_position=last_pos,
seconds=seconds)
# Unified XP + streak side-effects — never let these drop the stat write.
progress = None
try:
from xp import xp_for_run
from datetime import date
appstate.meta_db.award_xp(xp_for_run(score))
appstate.meta_db.record_active_day(date.today().isoformat())
progress = appstate.meta_db.get_progress()
except Exception:
log.warning("stats side-effects (xp/streak) failed", exc_info=True)
# Progression engine (spec 010) — same never-drop-the-stat-write
# contract. Scored sessions are the server-derived `song_completed`
# authority (scored == note detection by construction); instrument is
# resolved from library arrangement metadata, after the XP award so
# db_earned goals see this run's Decibels.
progression_summary = None
try:
import progression as progression_mod
instrument = progression_mod.instrument_for_arrangement(
appstate.meta_db.arrangement_entry(filename, arrangement)
)
progression_summary = appstate.meta_db.record_progression_event(
"song_completed",
{
"filename": filename,
"instrument": instrument,
"accuracy": accuracy,
"score": score,
"is_diagnostic": filename == appstate.builtin_diagnostic_filename(),
},
appstate.get_progression_content(),
)
except Exception:
log.warning("stats side-effects (progression) failed", exc_info=True)
return {"stats": row, "progress": progress, "progression": progression_summary}
# Seconds-only accrual: an unscored play that ran to the song's natural
# end has play time to bank but no resume position to touch (song:ended
# must not overwrite Continue with the end-of-song offset). Still counts
# as playing today for the streak below.
if last_pos is None and seconds:
row = appstate.meta_db.add_play_seconds(filename, arrangement, seconds)
progress = None
try:
from datetime import date
appstate.meta_db.record_active_day(date.today().isoformat())
progress = appstate.meta_db.get_progress()
except Exception:
log.warning("stats side-effects (streak) failed", exc_info=True)
return {"stats": row, "progress": progress}
# Position-only touch.
if last_pos is None:
return JSONResponse(
{"error": "provide score+accuracy (scored) or lastPlayPosition (resume)"},
status_code=400,
)
try:
pos = float(last_pos)
if not math.isfinite(pos):
raise ValueError("non-finite")
row = appstate.meta_db.touch_position(filename, arrangement, pos, seconds=seconds)
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
# A resume session still counts as playing today: advance the streak (no XP —
# that's scoring-only) so a non-scored practice day keeps the streak alive,
# consistent with these sessions also surfacing in recent / continue.
progress = None
try:
from datetime import date
appstate.meta_db.record_active_day(date.today().isoformat())
progress = appstate.meta_db.get_progress()
except Exception:
log.warning("stats side-effects (streak) failed", exc_info=True)
return {"stats": row, "progress": progress}
@router.get("/api/stats/recent")
def api_recent_stats(limit: int = 12):
"""Recently-played rows joined to song metadata for 'Jump back in'."""
from urllib.parse import quote
out = []
for r in appstate.meta_db.recent_stats(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/stats/best")
def api_stats_best():
"""{filename: best_accuracy} for all songs with a recorded best — one call
to badge the library grid (defined before the {filename} catch-all)."""
return appstate.meta_db.best_accuracy_map()
@router.get("/api/stats/top")
def api_top_stats(limit: int = 5):
"""Top scored songs (best first), joined to song metadata, for the profile
'Your best scores' panel (defined before the {filename} catch-all)."""
from urllib.parse import quote
out = []
for r in appstate.meta_db.top_stats(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/stats/{filename:path}")
def api_song_stats(filename: str):
return appstate.meta_db.get_song_stats(filename)
-46
View File
@@ -1,46 +0,0 @@
"""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
@@ -1,81 +0,0 @@
"""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,
}
+19 -43
View File
@@ -321,16 +321,11 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
audio_url = None
audio_error: str | None = None # Surfaced in song_info when audio_url is None
stems_payload: list[dict] = []
# URL of the pack's complete mixdown — the RESERVED `full` stem (spec
# §5.3), which sloppak.load_song() lifts out of `stems` because it is a
# mixdown, not a layer. The stems plugin plays it while every stem slider
# is at unity (separation is lossy, so it beats re-summing the stems) and
# crosses to the separated stems as soon as one is attenuated.
#
# None when the pack has no mixdown to offer separately from its stems:
# a single-mix pack (its one stem IS the mixdown), a loose folder, or an
# archive.
full_mix_url: str | None = None
# URL of the single full-mix audio (sloppak `original_audio:`), when the
# pack ships one. The stems plugin uses this to play the untouched mix
# while every stem slider is at unity; None otherwise (separate stems
# only, loose folder, or archive).
original_audio_url: str | None = None
if is_loose:
# Loose folder filenames are relative paths (artist/album/song).
# Hash the *canonical* dlc-relative path (so two URL spellings
@@ -370,25 +365,21 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
url = f"/api/sloppak/{q_fn}/file/{quote(s['file'])}"
stems_payload.append({"id": s["id"], "url": url, "default": s["default"]})
# Full-mix URL (served by the same /api/sloppak/.../file/ endpoint).
if loaded_slop is not None and loaded_slop.full_mix:
full_mix_url = (
f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.full_mix)}"
if loaded_slop is not None and loaded_slop.original_audio:
original_audio_url = (
f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.original_audio)}"
)
if stems_payload:
# Stems present: keep the core <audio> pointed at stem[0]. This
# URL is only ever heard in the degraded path (stems plugin
# refuses takeover / decode fails); the full-mix↔stems switch is
# driven client-side by `full_mix_url`, not `audio_url`.
# driven client-side by `original_audio_url`, not `audio_url`.
audio_url = stems_payload[0]["url"]
elif full_mix_url:
elif original_audio_url:
# Stem-less full-mix pack: nothing to separate, so play the full
# mix natively through the core <audio>. The stems plugin's
# onSongReady returns early on an empty stems list (no graph).
# Reachable only via the deprecated `original_audio:` key, whose
# packs put the mixdown outside `stems` — a pack that carries its
# mixdown as the `full` stem has it IN `stems`, so it lands in the
# branch above with stems_payload == [full].
audio_url = full_mix_url
audio_url = original_audio_url
else:
audio_error = "This sloppak has no playable stems."
else:
@@ -530,31 +521,16 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# for the credits overlay, so minigames / synthetic highway uses
# (no manifest) never trigger it.
"authors": _sanitize_authors(loaded_slop.manifest) if (is_slop and loaded_slop is not None) else [],
# Instrument stems ONLY. The pack's complete mixdown (the RESERVED
# `full` stem, spec §5.3) is deliberately NOT in this list: consumers
# sum `stems` into one mix and render one fader per entry, and the
# mixdown is neither a layer nor an instrument — summing it would
# double the whole song. It is surfaced separately, below.
"stems": stems_payload,
# The complete mixdown, served by the same /api/sloppak/.../file/
# endpoint as the stems. The stems plugin plays this single file
# while every stem slider is at unity and crosses to the separated
# stems the moment one drops below 100% — separation is lossy, so the
# mixdown is strictly better audio when nothing is muted. None when
# the pack has no mixdown apart from its stems. The `has_*` flags
# mirror the has_drum_tab/has_keys convention so a client can branch
# without re-deriving from the URLs.
"full_mix_url": full_mix_url,
"has_full_mix": bool(full_mix_url),
# Full-mix audio (sloppak `original_audio:`) served alongside the
# separate `stems`. The stems plugin plays this single file while
# every stem slider is at unity and switches to the separate stems
# the moment one drops below 100%. None when the pack ships stems
# only. `has_*` flags mirror the has_drum_tab/has_keys convention so
# a client can branch without re-deriving from the URLs.
"original_audio_url": original_audio_url,
"has_original_audio": bool(original_audio_url),
"has_stems": bool(stems_payload),
# DEPRECATED aliases of the two keys above, kept so a client built
# against the old frame keeps working across one release. They were
# named after `original_audio:` — a manifest key this repo invented
# and the feedpak spec never had (#933). The key is gone; the mixdown
# is a stem. Remove these once the shipped stems plugin reads
# `full_mix_url` (#945).
"original_audio_url": full_mix_url,
"has_original_audio": bool(full_mix_url),
# Surface a drum_tab presence flag so the visualization picker
# can auto-activate the drums plugin even when the chosen
# arrangement isn't named "Drums" (drum_tab.json lives next
+3 -31
View File
@@ -4,34 +4,9 @@ under a server-owned root.
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
@lru_cache(maxsize=16)
def resolved_root(root: Path) -> Path:
"""Canonical (link-resolved) form of a server-owned root directory.
``Path.resolve()`` is a filesystem call: it lstats every component of the
path. The roots we join against the DLC library, a plugin's asset dir —
are fixed for the life of the process, but the containment helpers below
(and ``dlc_paths._resolve_dlc_path``) were re-resolving them on EVERY call,
and those are called once per song, per art fetch, per scanned row.
On a real 50,944-song library that cost ~23,500 stat/lstat calls per second,
pinning a core. It is brutal when the library lives on a FUSE mount
(NTFS-3G, SMB, sshfs), where every stat is a userspace round trip: the same
three parent directories were being walked over and over.
Cached because a root is a constant here, not because resolution is cheap.
Consequence: if a root's symlink/junction is re-pointed at a NEW target
while the server is running, the old target stays in effect until restart.
That is fine for a library path fixed at startup, and the cache is keyed on
the Path, so switching to a different library dir is a different key.
"""
return root.resolve()
def safe_join(root: Path, name: str) -> Path | None:
"""Resolve ``name`` under ``root`` and return the resolved Path, or
``None`` if it would escape ``root`` or is unrepresentable.
@@ -60,12 +35,9 @@ def safe_join(root: Path, name: str) -> Path | None:
return None
safe = name.replace("\\", "/")
try:
# The ROOT is a constant — resolve it once (see resolved_root). The
# CANDIDATE must still be resolved on every call: following its symlinks
# is exactly the zip-slip / traversal defence, so it is never cached.
root_res = resolved_root(root)
candidate = (root_res / safe).resolve()
if not candidate.is_relative_to(root_res):
root_resolved = root.resolve()
candidate = (root_resolved / safe).resolve()
if not candidate.is_relative_to(root_resolved):
return None
except (ValueError, OSError):
return None
-458
View File
@@ -1,458 +0,0 @@
"""The library scanner: the background scan, its process pool, and the kick/runner
plumbing that serialises passes.
Carved VERBATIM out of server.py (R3b) except the seam reads. Everything shared is read
LATE off appstate the same contract every module in lib/routers/ uses, and it is not
cosmetic: tests monkeypatch CONFIG_DIR and swap meta_db, so a value captured at import
time would pin the wrong one for the life of the process.
CONFIG_DIR -> appstate.config_dir
meta_db -> appstate.meta_db
_default_settings -> appstate.default_settings()
_stat_for_cache -> appstate.stat_for_cache()
_feedBack_server_root() -> appstate.server_root <- see below
THE SCAN STATUS IS REBOUND, NOT MUTATED
`_background_scan` does `global _scan_status; _scan_status = {**INIT, ...}` at every stage
transition. It REPLACES the dict; it does not update it in place. So nothing may hold the
dict by value a reference captured once goes permanently stale at the first stage change,
and would report "listing" forever while the scan ran to completion.
That is why this module exports `status()`, a getter, and why appstate publishes
`scan_status` as a CALLABLE rather than a dict. appstate.py already says so in a comment;
this is the code that makes it true.
AND WHY THE SERVER ROOT IS READ, NEVER DERIVED
`_background_scan` seeds the builtin content, which needs the directory holding server.py.
`Path(__file__).resolve().parent` is correct in server.py and silently WRONG here (it
yields lib/, which has no docs/ or data/) and it fails by finding nothing rather than by
raising, so the seeds would just quietly never run. server.py publishes the root once, as
appstate.server_root. Read it; never re-derive it.
"""
import concurrent.futures
import logging
import multiprocessing
import os
import sys
import threading
from pathlib import Path
import appstate
import builtin_content
import enrichment
import loosefolder as loosefolder_mod
import sloppak as sloppak_mod
from appconfig import _load_config
from dlc_paths import _get_dlc_dir
from env_compat import getenv_compat
from scan_worker import _relpath, _scan_one
log = logging.getLogger("feedBack.scan")
import json
# ── Directory-signature fast path ─────────────────────────────────────────────
#
# A startup scan globs the whole library twice (*.feedpak, *.wem) and stats every
# file to detect what changed. On a 50k-song library that lives on a slow mount
# (an NTFS-3G FUSE volume here) it is ~100k filesystem round trips every launch —
# the "big drive churns on every startup" report.
#
# But adds / removes / renames of songs all bump the mtime of the DIRECTORY that
# holds them (verified on the target NTFS-3G mount), and so does the addition of
# a subdirectory (a new entry in its parent). So after a scan we record every
# library directory and its mtime; on the next scan we re-stat ONLY those
# directories (a handful, vs 100k file ops). If none changed, the file set is
# unchanged and the whole listing/stat pass is skipped.
#
# The one thing this cannot see is a file edited IN PLACE under the same name —
# that bumps the file's mtime but not its directory's. That is rare for a song
# library (you add and remove packs, you don't rewrite them under the same name),
# and the manual Refresh forces a full scan (force=True) for exactly that case.
def _dir_signature_file() -> Path:
return appstate.config_dir / "scan_dir_signature.json"
def _load_dir_signature() -> dict | None:
try:
data = json.loads(_dir_signature_file().read_text(encoding="utf-8"))
if isinstance(data, dict) and isinstance(data.get("dirs"), dict):
return data
except (OSError, ValueError):
pass
return None
def _save_dir_signature(dlc: Path, dirs: dict[str, int]) -> None:
# Keyed by the DLC path so switching libraries never matches a stale
# signature. Best-effort: a failed write just means the next scan is a full
# one, never a wrong one.
try:
_dir_signature_file().write_text(
json.dumps({"dlc": str(dlc), "dirs": dirs}), encoding="utf-8")
except OSError as e:
log.debug("scan: could not persist dir signature: %s", e)
def _library_dirs(all_songs, dlc: Path) -> set[str]:
"""Every directory whose mtime reflects an add/remove of a library song:
each song's containing directory and all of its ancestors up to the DLC
root (the root itself always included, as "."). Derived from the already-
listed songs no extra filesystem walk. The builtin carve-outs
(tutorials-builtin / minigames-builtin) are absent because the caller
already excluded them from `all_songs`, so a minigame writing a drill there
never invalidates the fast path.
Directory-form songs (loose-song folders, directory sloppak bundles) also
record their OWN directory: a file added/removed/replaced INSIDE the folder
bumps that folder's mtime but not its parent's, so tracking only the parent
would miss an in-place change to such a song. File-form sloppaks (a single
.feedpak zip) aren't dirs, so they add nothing here — the flat file library
stays at a handful of dir stats."""
rels = {"."}
for f in all_songs:
rel = Path(_relpath(f, dlc))
if f.is_dir():
rels.add(rel.as_posix())
parent = rel.parent
rels.add(parent.as_posix())
for anc in parent.parents:
rels.add(anc.as_posix())
return rels
def _record_dir_signature(all_songs, dlc: Path) -> None:
sig = _stat_dirs(dlc, _library_dirs(all_songs, dlc))
if sig is not None: # a dir vanished mid-scan → skip; next scan is full
_save_dir_signature(dlc, sig)
def _stat_dirs(dlc: Path, rels) -> dict[str, int] | None:
"""{reldir: mtime_ns} for the given library dirs, or None if any is gone or
unreadable a vanished recorded dir means the tree changed, so fail to a
full scan rather than a false match."""
out: dict[str, int] = {}
for rel in rels:
try:
out[rel] = (dlc if rel == "." else dlc / rel).stat().st_mtime_ns
except OSError:
return None
return out
_SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False, "added": 0, "removed": 0}
_scan_status = dict(_SCAN_STATUS_INIT)
def _make_scan_executor():
"""Build the executor for the background metadata scan.
A `spawn` ProcessPoolExecutor in production. `spawn` (not the platform
default) is mandatory: _background_scan runs on a non-main daemon
thread, and forking a multithreaded process from a non-main thread can
deadlock on locks held by other threads at fork time (the default on
Linux). `spawn` boots a clean interpreter that imports only scan_worker
(+ its pure lib deps) to unpickle the worker never this module so
workers don't re-run server.py's import-time side effects (reopening
SQLite, attaching a second RotatingFileHandler, re-registering routes).
Tests monkeypatch this to a ThreadPoolExecutor so the scan runs
in-process and metadata extraction can be mocked.
"""
mp_ctx = multiprocessing.get_context("spawn")
# Default to one worker per core so CPU-bound metadata parsing uses the
# whole machine (the point of moving to processes).
# FEEDBACK_MAX_SCAN_WORKERS (set by the Desktop launcher to cap memory
# usage on low-RAM machines — e.g. 8 GB M2 MacBook Air) takes priority;
# SCAN_MAX_WORKERS is a legacy override for Docker/bare installs.
# A malformed override falls back to the core count rather than crashing.
try:
max_workers = int(
getenv_compat("FEEDBACK_MAX_SCAN_WORKERS")
or os.environ.get("SCAN_MAX_WORKERS")
or (os.cpu_count() or 1)
)
except ValueError:
max_workers = os.cpu_count() or 1
# ProcessPoolExecutor raises ValueError on Windows when max_workers > 61
# (the WaitForMultipleObjects handle limit), so clamp there — otherwise
# a high-core Windows host can't construct the pool and the scan never
# starts.
if sys.platform == "win32":
max_workers = min(max_workers, 61)
return concurrent.futures.ProcessPoolExecutor(
max_workers=max(1, max_workers), mp_context=mp_ctx,
)
def background_scan(force: bool = False):
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
`force` skips the directory-signature fast path and always does the full
listing/stat pass the manual Refresh sets it (see _dir_signature_file).
Never sets `_scan_status["running"] = False` ownership of that flag
lives in `_scan_runner` so a `kick_scan()` racing this function's
terminal write cannot observe a stale False and start a second runner.
"""
global _scan_status
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "listing"}
# Load config once so both the DLC-dir lookup and the platform filter
# read from the same snapshot, avoiding a redundant parse of config.json.
_cfg = _load_config(appstate.config_dir / "config.json") or appstate.default_settings()
dlc = _get_dlc_dir(_cfg)
if not dlc:
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "idle", "error": "DLC folder not configured"}
log.warning("Scan: no DLC folder configured")
return
builtin_content.seed_builtin_diagnostic_sloppaks(appstate.server_root, dlc)
builtin_content.seed_builtin_starter_content(appstate.server_root, dlc)
# Fast path: if every library directory recorded by the last scan still has
# the same mtime, nothing was added, removed, or renamed, so the whole
# glob-and-stat pass below can be skipped (see the signature comment above).
# `force` (manual Refresh) always does the full pass. Seeding above is
# idempotent — it only writes when a builtin is missing — so it does not
# perturb the mtimes on a settled library.
if not force:
stored = _load_dir_signature()
if stored is not None and stored.get("dlc") == str(dlc):
current = _stat_dirs(dlc, stored["dirs"].keys())
if current is not None and current == stored["dirs"]:
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete"}
log.info("Scan: library tree unchanged (%d dirs) — skipped the full listing/stat pass",
len(current))
return
# Listing can fail on macOS without Full Disk Access, or on Docker if the
# path isn't shared. Report the failure explicitly rather than silently
# appearing to scan nothing.
try:
# Generated-content sloppaks that the highway WS must resolve by path
# but that are NOT library songs. Two conventions share this carve-out:
# - tutorials-builtin/ — lesson drills seeded by the tutorials plugin
# (see plugins/tutorials/routes.py::_seed_builtin_packs).
# - minigames-builtin/ — exercise charts generated on demand by
# minigame plugins (e.g. Chord Sprint writes alternating-chord
# drills here). Cached/reused per exercise, never browsed.
# Both are kept out of the scan; _resolve_dlc_path still loads them by
# path for playback.
def _is_excluded_from_library(p: Path) -> bool:
return "tutorials-builtin" in p.parts or "minigames-builtin" in p.parts
# Sloppaks: match both file (zip) and directory form, across both the
# `.feedpak` and legacy `.sloppak` suffixes.
_cands = sorted(p for ext in sloppak_mod.SONG_EXTS for p in dlc.rglob(f"*{ext}"))
sloppaks = [f for f in _cands
if sloppak_mod.is_sloppak(f)
and not _is_excluded_from_library(f)]
# Loose song folders: any directory containing a non-preview *.wem + *.xml.
# Skip directories that are actually sloppak bundles — those are
# already in `sloppaks`; the dispatcher's sloppak-first precedence
# would route them to the sloppak path anyway, but adding them
# here would inflate the scan queue and over-count the total.
loose_songs = []
seen_loose = set()
sloppak_dirs = {p for p in sloppaks if p.is_dir()}
for wem in sorted(dlc.rglob("*.wem")):
if "preview" in wem.stem.lower():
continue
if _is_excluded_from_library(wem):
continue
d = wem.parent
if d in sloppak_dirs or d.name.lower().endswith(sloppak_mod.SONG_EXTS):
continue
if d not in seen_loose and loosefolder_mod.is_loose_song(d):
loose_songs.append(d)
seen_loose.add(d)
except PermissionError as e:
msg = (f"Permission denied reading {dlc}. "
"On macOS: grant Full Disk Access to the app in System Settings → Privacy & Security. "
"With Docker: share this path in Docker Desktop → Settings → Resources → File Sharing.")
log.error("Scan failed: %s (%s)", msg, e)
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "error", "error": msg}
return
except OSError as e:
log.error("Scan failed listing %s: %s", dlc, e)
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "error", "error": f"Unable to list {dlc}: {e}"}
return
all_songs = sloppaks + loose_songs
log.info("Scan: listed %d sloppaks and %d loose folders in %s",
len(sloppaks), len(loose_songs), dlc)
current_files = {_relpath(f, dlc) for f in all_songs}
# Clean up stale DB entries. delete_missing reports both deltas (rows pruned
# + genuinely-new files) so the scan can surface an added/removed summary.
_delta = appstate.meta_db.delete_missing(current_files)
removed, added = _delta["removed"], _delta["added"]
if removed:
log.info("Removed %d stale DB entries", removed)
# Figure out which need scanning
to_scan = []
for f in all_songs:
# Skip entries that vanish or become unreadable between listing
# and stat. Without this, one concurrent move/delete in DLC_DIR
# would crash the scan thread and leave `_scan_status["running"]`
# stuck true with no path to recover.
try:
mtime, size = appstate.stat_for_cache(f)
except OSError as e:
log.debug("scan: skipping %s (%s)", f, e)
continue
cache_key = _relpath(f, dlc)
try:
cached = appstate.meta_db.get(cache_key, mtime, size)
except Exception as e:
# Keep scanning even if a single metadata lookup fails.
# The file will be re-scanned and cache repaired by put().
log.warning("scan cache lookup failed for %s: %s", cache_key, e)
cached = None
if not cached:
to_scan.append((f, mtime, size, dlc))
elif cached.get("arrangements") and any(
"smart_name" not in a for a in cached["arrangements"]
):
# Row was scanned before smart naming was introduced — force a
# rescan so the DB picks up authoritative path flags from the
# manifest JSON and stores correct smart_name values. Don't
# re-queue rows where smart_name is explicitly null: the writer
# only emits that when compute_smart_names truly can't classify
# the arrangement (e.g. a name outside the recognised set with
# zero path flags), so rescanning would produce the same null
# forever and never converge.
to_scan.append((f, mtime, size, dlc))
if not to_scan:
# Full pass completed with the DB already up to date — record the tree
# signature so the next startup can take the fast path.
_record_dir_signature(all_songs, dlc)
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs))
return
# Refine: all discovered songs need scanning → treat as first-time import
# (covers moved DLC folder / fully-stale DB as well as a genuinely empty DB).
is_first_scan = bool(all_songs) and len(to_scan) == len(all_songs)
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "scanning", "total": len(to_scan),
"is_first_scan": is_first_scan}
log.info("Library: %d sloppaks + %d loose folders, %d cached, %d to scan",
len(sloppaks), len(loose_songs), len(all_songs) - len(to_scan), len(to_scan))
with _make_scan_executor() as executor:
futures = {executor.submit(_scan_one, item): item[0].name for item in to_scan}
for future in concurrent.futures.as_completed(futures):
fname = futures[future]
try:
name, mtime, size, meta = future.result()
appstate.meta_db.put(name, mtime, size, meta)
except Exception as e:
log.warning("scan failed for %s: %s", fname, e)
_scan_status["done"] += 1
_scan_status["current"] = fname
# Record the tree signature after a completed full pass so the next startup
# can skip it when nothing has changed.
_record_dir_signature(all_songs, dlc)
log.info("Scan complete: %d songs cached", len(to_scan))
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
_scan_kick_lock = threading.Lock()
_scan_rescan_pending = False
# Set by kick_scan(force=True); consumed by _scan_runner for the next pass so a
# manual Refresh bypasses the directory-signature fast path.
_scan_force_next = False
# Handles to the running scan / enrichment worker threads. Both use the shared
# MetadataDB connection, so teardown/shutdown MUST join them before closing that
# connection — a daemon thread mid-query on a closed SQLite conn is a native
# use-after-free that segfaults the process (seen flaky in CI). Set by
# _kick_scan / _kick_enrich; joined by _join_background_db_threads().
_scan_thread: threading.Thread | None = None
def kick_scan(force: bool = False) -> bool:
"""Request a library rescan, single-flight + coalescing.
`force` skips the directory-signature fast path for the resulting pass (the
manual Refresh uses it so an in-place same-name edit the one thing the
fast path can't see — is always picked up). A forced request that coalesces
onto a running or queued scan keeps the force intent: the pass is forced if
ANY pending request asked for it.
Returns True if a new scan thread was started, False if one was already
running. In the latter case a follow-up pass is queued and runs as soon
as the current scan finishes so files landing mid-scan (e.g. an upload
that finalizes after the scan has already listed DLC_DIR) are not lost
until the next periodic pass. Multiple late-arriving requests coalesce
into a single follow-up.
"""
global _scan_rescan_pending, _scan_thread, _scan_force_next
with _scan_kick_lock:
if force:
_scan_force_next = True
if _scan_status["running"]:
_scan_rescan_pending = True
return False
# Mark running synchronously so a parallel kick_scan() observes it
# before the worker thread has a chance to reassign _scan_status.
_scan_status["running"] = True
_scan_thread = threading.Thread(target=_scan_runner, daemon=True)
_scan_thread.start()
return True
def _scan_runner():
"""Run _background_scan, then re-run if requests arrived mid-scan."""
global _scan_rescan_pending, _scan_force_next
while True:
# Consume the force flag for THIS pass; a forced request queued mid-scan
# sets it again for the follow-up.
with _scan_kick_lock:
forced = _scan_force_next
_scan_force_next = False
try:
background_scan(force=forced)
except Exception:
log.exception("background scan failed unexpectedly")
with _scan_kick_lock:
if not _scan_rescan_pending:
_scan_status["running"] = False
break
_scan_rescan_pending = False
_scan_status["running"] = True
# Enrichment rides scan completion (library-metadata design §6): the scan
# pool is a side-effect-free, no-network process pool by design, so
# enrichment is a SEPARATE post-scan pass — non-blocking, the library is
# usable immediately. The 5-minute periodic rescan re-kicks it, which is
# the natural low-priority retry hook.
enrichment._kick_enrich()
def status() -> dict:
"""The live scan status.
A GETTER, deliberately. `_scan_status` is REBOUND on every stage transition, so a
caller holding the dict would be reading a snapshot frozen at whatever stage it
happened to grab see the module header.
"""
return _scan_status
def scan_thread():
"""The background scan thread, or None. Read by shutdown to join it."""
return _scan_thread
+67 -428
View File
@@ -15,7 +15,6 @@ from __future__ import annotations
import logging
import math
import os
import shutil
import threading
import zipfile
@@ -35,21 +34,6 @@ FEEDPAK_EXT = ".feedpak"
SLOPPAK_EXT = ".sloppak"
SONG_EXTS = (FEEDPAK_EXT, SLOPPAK_EXT) # accepted on read/discovery
# ── The full mix ──────────────────────────────────────────────────────────────
#
# Spec §5.3 RESERVES the stem id `full` for the song's complete mixdown: the
# whole song in one file, as heard before source separation. It is a stem — it
# lives in `stems` like every other audio file in a pack — but it is a *mixdown,
# not a layer*. A reader that sums stems must never include it in the sum: it
# already contains every instrument, so summing it doubles the whole song and
# muting `guitar` still leaves guitar audible inside it.
#
# Keeping it matters because separation is lossy: re-summing guitar+bass+drums+
# vocals does NOT reproduce the file they came from. The mixdown is the only
# faithful rendering of the song a pack can carry, so we play it whenever every
# stem sits at unity and nothing is muted.
FULL_MIX_STEM_ID = "full"
import yaml
from jsonc import load_json
@@ -67,111 +51,6 @@ import drums as drums_mod
import notation as notation_mod
def find_full_mix(stems: list[dict]) -> dict | None:
"""The RESERVED `full` stem (spec §5.3) — the pack's complete mixdown — or None.
Answers "what is this pack's master audio", which is what fingerprinting
wants. For playback use partition_stems() instead: a pack whose *only* stem
is `full` has no mixdown to play *separately from* its stems, and this
function still returns it.
"""
return next(
(s for s in stems if str(s.get("id", "")) == FULL_MIX_STEM_ID), None
)
def stem_default_on(raw) -> bool:
"""Whether a manifest stem entry plays by default.
Absent means on. A string is honoured so a hand-written manifest can say
`default: off`. Extracted so the WS `ready` payload and the REST song-info
payload cannot drift: the stems plugin now preloads from REST and then has
to agree with what the WS says a moment later, or it would rebuild the whole
graph for nothing.
"""
if isinstance(raw, str):
return raw.lower() not in ("off", "false", "0", "no")
return bool(raw)
def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
The mixdown is lifted OUT of the stem list because every consumer of `stems`
treats that list as layers to sum or to show as mixer channels, and `full` is
neither (spec §5.3). Leaving it in is precisely the bug that made the packer
invent `original_audio` in the first place: a listed full mix plays on top of
the stems.
A pack whose only stem is `full` is a single-mix pack, not a separated one:
there are no instruments to be pristine *against*, so `full` stays the sole
playable stem and no mixdown is surfaced. That keeps the freshly-converted
single-stem pack much the most common shape behaving exactly as before.
EVERY entry with the reserved id is removed, not just the one we surface. A
malformed pack that lists `full` twice would otherwise leave a copy of the
whole song behind in the stem list, to be summed with the instruments the
precise failure this function exists to prevent, reintroduced by a duplicate.
"""
if len(stems) < 2:
return None, stems
full = find_full_mix(stems)
if full is None:
return None, stems
return full, [s for s in stems if str(s.get("id", "")) != FULL_MIX_STEM_ID]
def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
"""Full mix from the DEPRECATED `original_audio:` manifest key, or None.
Before feedpak 1.15.0 reserved `full`, §5.3 said the mixdown was "commonly
replaced" by the per-instrument stems on splitting — so it had nowhere to
live, and this repo invented a top-level key pointing at a parallel
`original/` directory (#583) to hold it. That key was never in the spec, and
#933 removed our dependence on it: the mixdown is a stem.
We still READ it, because every pack written before the spec caught up
carries `original_audio: original/full.ogg` and would otherwise lose its full
mix. We never write it. Delete this once those packs are migrated (#945);
`tools/migrate_full_mix_stem.py` is the migration.
NOTE the string literal below. tools/check_spec_conformance.py AST-scans for
`manifest.get("<literal>")` to prove every manifest key core reads is one the
spec declares. Hoisting "original_audio" into a named constant would hide
this read from that scan the gate would conclude core no longer touches the
key, and the grandfather entry that documents this debt would go stale. The
literal is what keeps the deprecation honest and visible to CI. Leave it.
Same permissive, path-traversal-guarded posture as the optional side-files: a
missing / escaping / unreadable file leaves the pack without a full mix (the
player falls back to the separated stems) rather than aborting the load.
Returns the manifest-relative string, so callers build its URL exactly as
they build a stem's.
"""
rel_raw = manifest.get("original_audio")
if not isinstance(rel_raw, str) or not rel_raw.strip():
return None
rel = rel_raw.strip()
try:
target = (source_dir / rel).resolve()
target.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
return None
except OSError as e:
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
return None
if not target.is_file():
return None
log.info(
"sloppak: pack uses the deprecated `original_audio:` key (%r) — the full mix "
"is a stem (id `full`, feedpak spec §5.3). Re-pack with "
"tools/migrate_full_mix_stem.py; support for this key will be removed.",
rel,
)
return rel
# ── Format detection ──────────────────────────────────────────────────────────
def is_sloppak(path: Path) -> bool:
@@ -202,116 +81,6 @@ _unpack_semaphore = threading.BoundedSemaphore(_UNPACK_MAX_CONCURRENCY)
_unpack_locks: dict[str, threading.Lock] = {}
_unpack_locks_guard = threading.Lock()
# Destinations with an unpack in flight right now. Eviction MUST skip these: two
# unpacks run concurrently, so one finishing could otherwise rmtree the other's
# half-written directory and leave that resolver caching an incomplete song.
_unpacking: set[Path] = set()
_unpacking_guard = threading.Lock()
# Cap the unpack cache. Stems are already-compressed audio, so an unpacked song
# is ~1.1x its zip — the cache is effectively a second, DECOMPRESSED copy of
# every song it holds, and it used to grow without any bound at all. A tester
# reached 60 GB from a 1800-song library: their whole library, unpacked, because
# one caller looped the library calling load_song(). Nothing ever deleted any of
# it — not even when the song itself was deleted.
#
# Default 4 GB ≈ 130 average songs of recency, which is far more than the "the
# song I'm playing, and the last few I played" that this cache actually exists
# to serve. Override with FEEDBACK_SLOPPAK_CACHE_MAX_MB (0 disables eviction).
def _unpack_cache_cap_bytes() -> int:
raw = os.environ.get("FEEDBACK_SLOPPAK_CACHE_MAX_MB", "").strip()
try:
mb = int(raw) if raw else 4096
except ValueError:
mb = 4096
return max(0, mb) * 1024 * 1024
def _dir_size(path: Path) -> int:
total = 0
for f in path.rglob("*"):
try:
if f.is_file():
total += f.stat().st_size
except OSError:
continue
return total
def _touch(path: Path) -> None:
"""Bump mtime so the LRU sweep below treats this song as recently used.
Reading files out of an unpacked dir doesn't change the DIRECTORY's mtime,
so without this the song you are actively playing looks as stale as one you
unpacked days ago and a burst of unpacks could evict it mid-song.
"""
try:
os.utime(path, None)
except OSError:
pass
def _evict_unpack_cache(root: Path, keep: Path | None = None) -> None:
"""Bound the unpack cache: drop least-recently-used songs until under the cap.
`keep` is never evicted it's the song the caller just resolved, i.e. almost
certainly the one about to be played.
Evicting a directory MUST also drop its `_source_cache` entry. Otherwise
get_cached_source_dir() keeps handing out a path that no longer exists and
the media route 404s on every stem instead of re-unpacking (it only falls
back to resolve_source_dir when the cache returns None).
"""
cap = _unpack_cache_cap_bytes()
if cap <= 0:
return
try:
entries = []
total = 0
for d in root.iterdir():
if not d.is_dir():
continue
try:
size = _dir_size(d)
mtime = d.stat().st_mtime
except OSError:
continue
entries.append((mtime, size, d))
total += size
if total <= cap:
return
keep_resolved = keep.resolve() if keep else None
entries.sort(key=lambda e: e[0]) # oldest first
for _mtime, size, d in entries:
if total <= cap:
break
try:
if keep_resolved and d.resolve() == keep_resolved:
continue
except OSError:
continue
# Check-and-delete under ONE hold of the guard. Releasing between the
# two would let a resolver mark this dest in-flight and start writing
# into it in the gap, and we'd rmtree a song mid-unpack. A resolver
# that blocks here simply proceeds afterwards — _unpack_zip recreates
# the directory anyway.
with _unpacking_guard:
if d in _unpacking:
continue # another thread is writing this
shutil.rmtree(d, ignore_errors=True)
if d.exists():
continue # couldn't remove — don't claim the bytes back
total -= size
with _source_lock:
for fn, (cached_dir, _m, _s) in list(_source_cache.items()):
if cached_dir == d:
_source_cache.pop(fn, None)
log.info("sloppak: evicted %s from the unpack cache (%.0f MB)",
d.name, size / 1e6)
except OSError:
log.warning("sloppak: unpack-cache eviction failed", exc_info=True)
def _unpack_lock_for(filename: str) -> threading.Lock:
"""Return a stable per-file lock so concurrent unpacks of the same sloppak
@@ -376,17 +145,10 @@ def resolve_source_dir(
re-unpacks if mtime/size changed, then returns that dir.
Caches the resolution so subsequent calls are ~free.
NOTE: this writes the WHOLE pack every stem to disk. Only call it for a
song you are about to play. To read a *part* of a song (an arrangement, the
lyrics, a tone blob), use read_member_bytes(): unpacking a pack to read a few
KB of JSON is ~45x write amplification, and doing it in a loop over the
library fills the disk with a decompressed copy of every song.
"""
path = dlc_root / filename
stat = path.stat()
mtime, size = stat.st_mtime, stat.st_size
guarded: Path | None = None # a dir WE unpacked, shielded from eviction
with _source_lock:
cached = _source_cache.get(filename)
@@ -397,76 +159,42 @@ def resolve_source_dir(
and cached_size == size
and cached_dir.exists()
):
# Mark it recently-used before returning — see _touch().
if cached_dir != path:
_touch(cached_dir)
return cached_dir
try:
if path.is_dir():
resolved = path
else:
# Zip form — unpack to the cache. Serialize per-file (so concurrent
# callers don't rmtree + re-extract the same dest at once) and cap
# global unpack concurrency (so a burst can't saturate disk/CPU).
dest = unpack_cache_root / _safe_id(filename)
with _unpack_lock_for(filename):
# Re-check the cache inside the per-file lock — a prior holder may
# have just finished unpacking this exact (mtime, size).
with _source_lock:
cached = _source_cache.get(filename)
if (
cached
and cached[1] == mtime
and cached[2] == size
and cached[0].exists()
):
resolved = cached[0]
else:
# Shield `dest` from eviction from the moment we start writing
# until it is safely in _source_cache. `keep` only shields it
# from OUR OWN sweep — a concurrent resolver sweeping with a
# different `keep` would delete it, and we would then cache and
# return a path that no longer exists. The `finally` below
# releases it on EVERY exit, including a failed unpack: leaving
# a dest marked in-flight would make it un-evictable forever.
with _unpacking_guard:
_unpacking.add(dest)
guarded = dest
with _unpack_semaphore:
_unpack_zip(path, dest)
resolved = dest
# The only moment this cache grows. Sweep here rather than on a
# timer so it can never drift far past the cap.
_evict_unpack_cache(unpack_cache_root, keep=dest)
if path.is_dir():
resolved = path
else:
# Zip form — unpack to the cache. Serialize per-file (so concurrent
# callers don't rmtree + re-extract the same dest at once) and cap
# global unpack concurrency (so a burst can't saturate disk/CPU).
dest = unpack_cache_root / _safe_id(filename)
with _unpack_lock_for(filename):
# Re-check the cache inside the per-file lock — a prior holder may
# have just finished unpacking this exact (mtime, size).
with _source_lock:
cached = _source_cache.get(filename)
if (
cached
and cached[1] == mtime
and cached[2] == size
and cached[0].exists()
):
resolved = cached[0]
else:
with _unpack_semaphore:
_unpack_zip(path, dest)
resolved = dest
with _source_lock:
_source_cache[filename] = (resolved, mtime, size)
return resolved
finally:
if guarded is not None:
with _unpacking_guard:
_unpacking.discard(guarded)
with _source_lock:
_source_cache[filename] = (resolved, mtime, size)
return resolved
def get_cached_source_dir(filename: str) -> Path | None:
"""Return the cached source dir for a sloppak if one is known AND still there.
The existence check is load-bearing: callers (media.py) only fall back to
resolve_source_dir() when this returns None, so handing back a path that has
been evicted or that the user deleted by hand to reclaim disk would 404
every stem for the rest of the process instead of re-unpacking.
"""
"""Return the cached source dir for a sloppak if one is known."""
with _source_lock:
cached = _source_cache.get(filename)
if not cached:
return None
src = cached[0]
if not src.is_dir():
_source_cache.pop(filename, None)
return None
_touch(src)
return src
return cached[0] if cached else None
# ── Manifest + song loading ───────────────────────────────────────────────────
@@ -505,82 +233,6 @@ def load_manifest(path: Path) -> dict:
return _read_manifest_from_zip(path)
_ZIP_ROOT = Path("/_root").resolve()
def _zip_member_key(name: str) -> str | None:
"""Canonical lookup key for a zip member name, or None if it escapes the root.
Collapses './', 'a/../b' and backslash separators the same normalization
_unpack_zip()/safe_join() apply when extracting. Both the name the caller asks
for AND the names the archive actually stores must go through this, or a pack
that stores './arrangements/lead.json' unpacks fine but reads back as missing.
"""
safe = safe_join(_ZIP_ROOT, name or "")
# None → escapes the root; == root → a degenerate name like "." or "a/..".
if safe is None or safe == _ZIP_ROOT:
return None
return safe.relative_to(_ZIP_ROOT).as_posix()
def read_member_bytes(path: Path, rel: str) -> bytes | None:
"""Return the bytes of ONE file inside a sloppak, or None if it isn't there.
For a zipped sloppak this opens that single member instead of unpacking the
archive the same trick read_cover_bytes() uses to keep the library grid
from exploding every pack just to show a cover.
Reach for this whenever you want a *part* of a song (an arrangement's JSON,
the lyrics, a tone blob) rather than a song you're about to play. The
alternative, load_song(), calls resolve_source_dir() and writes the WHOLE
pack every stem into the unpack cache. That is a ~45x write amplification
when all you wanted was a few KB of JSON, and looping the library on it
unpacks the entire library (got-feedBack/feedBack: a tester hit 60 GB that
way). Stems are already-compressed audio, so an unpacked song is ~1.1x its
zip: the cache becomes a second, decompressed copy of everything it touches.
"""
rel = (rel or "").strip()
if not rel:
return None
if path.is_dir():
target = safe_join(path.resolve(), rel)
if target is None or not target.is_file():
return None
try:
return target.read_bytes()
except OSError:
return None
# Zip form — read just that member, no unpack. Zip-slip is rejected before we
# open anything, and both sides of the comparison are normalized, so a
# non-canonical-but-valid name ('./arrangements/lead.json') resolves the same
# way it did when we unpacked first.
member = _zip_member_key(rel)
if member is None:
log.warning("sloppak: rejected unsafe member name %r in %r", rel, path)
return None
try:
with zipfile.ZipFile(str(path), "r") as zf:
# Match on the NORMALIZED stored name, and take the LAST match — the
# archive may store './x' or a backslash path (Windows tooling), and
# if it stores two names that normalize to the same file, _unpack_zip
# writes them in order so the last one wins. Reading the raw member by
# exact name would miss the first case and return the wrong bytes in
# the second. A pack has a handful of members; the scan is free.
info = None
for cand in zf.infolist():
if _zip_member_key(cand.filename) == member:
info = cand
if info is None or info.is_dir():
return None
with zf.open(info) as f:
return f.read()
except (zipfile.BadZipFile, OSError, RuntimeError) as e:
log.warning("sloppak: failed to read %r from %s: %s", rel, path.name, e)
return None
_COVER_MEDIA_TYPES = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".webp": "image/webp",
@@ -715,21 +367,14 @@ class LoadedSloppak:
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
# absent so indexing by song.arrangements index is safe.
arrangement_ids: list[str | None] = field(default_factory=list)
# Manifest-relative path to the pack's complete mixdown — the whole song in
# one file, as heard before source separation. This is the RESERVED `full`
# stem (spec §5.3), lifted out of `stems` above precisely because it is NOT
# an instrument layer: summing it with the per-instrument stems it was split
# into would double the entire song. See partition_stems().
#
# None when the pack has no mixdown to offer *separately* from its stems —
# which includes the common single-mix pack, whose only stem IS the mixdown
# (there is nothing to be pristine against, so it stays in `stems`).
#
# Served to the front-end via the highway WS as `full_mix_url`; the stems
# plugin plays it while every stem slider sits at unity and crosses to the
# separated stems the moment one drops below 100% — demucs recombination is
# lossy, so the mixdown is strictly the better audio when nothing is muted.
full_mix: str | None = None
# Manifest-relative path to the single full-mix audio file, taken from the
# manifest `original_audio:` key (e.g. "original/full.ogg"). This is the
# pre-separation mixdown that exists alongside the per-instrument `stems`.
# None when the key is absent, points outside source_dir, or the file is
# missing on disk. Served to the front-end via the highway WS as
# `original_audio_url`; the stems plugin uses it to play the untouched mix
# when every stem slider is at unity (and the separate stems otherwise).
original_audio: str | None = None
def load_song(
@@ -1114,18 +759,12 @@ def load_song(
sfile = str(s.get("file", ""))
if not sid or not sfile:
continue
stems.append({
"id": sid,
"file": sfile,
"default": stem_default_on(s.get("default", True)),
})
# The complete mixdown is a stem (spec §5.3), but it is not a *layer*: lift
# it out so that no consumer of `stems` — the mixer, the library's stem
# chips, the WS payload — sums it with, or lists it beside, the instruments
# it was separated into. `full_mix_stem` is None for a single-mix pack,
# whose only stem IS the mixdown and stays in the list.
full_mix_stem, stems = partition_stems(stems)
default_val = s.get("default", True)
if isinstance(default_val, str):
default_on = default_val.lower() not in ("off", "false", "0", "no")
else:
default_on = bool(default_val)
stems.append({"id": sid, "file": sfile, "default": default_on})
# Optional keys.json — song-level, instrument-independent key/scale track
# (manifest `keys:` key, spec §7.7). Permissive like the other side-files:
@@ -1189,22 +828,28 @@ def load_song(
}
_fpv = manifest.get("feedpak_version")
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
# above (spec §5.3) — no path work needed, it was validated with the other
# stems and its URL is built the same way. Only when the pack has no `full`
# stem do we fall back to the DEPRECATED `original_audio:` key, which is the
# shape every pack written before feedpak 1.15.0 uses.
if full_mix_stem is not None:
full_mix_data: str | None = full_mix_stem["file"]
elif find_full_mix(stems) is not None:
# Single-mix pack: its ONE stem is the mixdown, so there is no mixdown to
# offer *apart from* the stems. Never fall through to the legacy key here
# — a pack that both carries a `full` stem and names the old key would
# otherwise surface the mixdown twice (once as the stem the player is
# already playing, once as a "pristine" track to cross to).
full_mix_data = None
else:
full_mix_data = _legacy_full_mix(manifest, source_dir)
# Optional full-mix audio — manifest `original_audio:` key. The single
# pre-separation mixdown that ships alongside the per-instrument stems.
# Same permissive, path-traversal-guarded posture as drum_tab above: a
# missing/escaping/absent file simply leaves the full mix unavailable (the
# player falls back to the separate stems) rather than aborting the load.
# We store the manifest-relative string so server.py can build its URL the
# same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint).
original_audio_data: str | None = None
original_audio_rel = manifest.get("original_audio")
if isinstance(original_audio_rel, str) and original_audio_rel.strip():
rel = original_audio_rel.strip()
try:
oa_path = (source_dir / rel).resolve()
oa_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
oa_path = None
except OSError as e:
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
oa_path = None
if oa_path is not None and oa_path.is_file():
original_audio_data = rel
return LoadedSloppak(
song=song,
@@ -1219,7 +864,7 @@ def load_song(
keys=keys_data,
notation_by_id=notation_by_id_data,
arrangement_ids=arrangement_ids_acc,
full_mix=full_mix_data,
original_audio=original_audio_data,
)
@@ -1264,7 +909,7 @@ def extract_meta(path: Path) -> dict:
tuning_offsets = _tuning_for_meta(arr_list)
stems_list = manifest.get("stems", []) or []
valid_stems: list[dict] = []
stem_ids: list[str] = []
for s in stems_list:
if not isinstance(s, dict):
continue
@@ -1278,13 +923,7 @@ def extract_meta(path: Path) -> dict:
isinstance(sid, str) and sid
and isinstance(sfile, str) and sfile
):
valid_stems.append({"id": sid, "file": sfile})
# Partition exactly as load_song() does, for the same reason the library
# filter must not lie: `full` is the mixdown, not an instrument (spec §5.3).
# A separated pack that retains it would otherwise offer the user a "full"
# stem chip alongside guitar/bass/drums and count it as a seventh stem.
_full, instrument_stems = partition_stems(valid_stems)
stem_ids = [s["id"] for s in instrument_stems]
stem_ids.append(sid)
stem_count = len(stem_ids)
return {
+3 -89
View File
@@ -1,4 +1,4 @@
"""Regenerate the runtime stylesheet over the full installed-plugin set.
"""Regenerate ``static/tailwind.min.css`` over the full installed-plugin set.
Core's committed (and image-baked) stylesheet is built scanning only the
in-tree plugins. A plugin installed at runtime into ``FEEDBACK_PLUGINS_DIR``
@@ -15,7 +15,6 @@ on a missing optional engine.
from __future__ import annotations
import hashlib
import json
import logging
import os
@@ -41,84 +40,12 @@ _lock = threading.Lock()
# in-flight build re-runs once more to pick up the newer plugin set instead of
# every concurrent trigger stacking its own redundant build.
_rerun = threading.Event()
_fingerprint_cache: dict = {}
# lib/ lives at ``<app>/lib``; the app root (static/, tailwind.config.js) is its
# grandparent.
APP_DIR = Path(__file__).resolve().parent.parent
def _committed_css_fingerprint() -> str:
"""Content hash of the SHIPPED stylesheet, cached on (mtime, size).
This is the marker that says WHICH CORE the runtime sheet was built against. Any change to
core's CSS regenerates static/tailwind.min.css, which changes this hash.
"""
committed = APP_DIR / "static" / "tailwind.min.css"
try:
st = committed.stat()
except OSError:
return ""
key = (st.st_mtime_ns, st.st_size)
cached = _fingerprint_cache.get("k")
if cached == key:
return _fingerprint_cache["v"]
h = hashlib.sha256(committed.read_bytes()).hexdigest()
_fingerprint_cache["k"] = key
_fingerprint_cache["v"] = h
return h
def runtime_meta_path() -> Path:
"""Sidecar recording which core the runtime sheet was built against."""
return runtime_css_path().with_suffix(".meta.json")
def runtime_css_is_current() -> bool:
"""True when the runtime sheet was built against the core we are running NOW.
WHY NOT mtime. Codex [P2] on the second cut of #911, and it was right: filesystem
timestamps are not a freshness signal across install methods. Archives and container images
routinely PRESERVE SOURCE MTIMES, so a just-shipped stylesheet can carry an OLDER mtime than
a runtime sheet a user built days ago. The mtime comparison then reports the stale sheet as
fresh and it masks the new core CSS indefinitely permanently, if no Tailwind toolchain is
present to trigger a rebuild.
Content answers the question timestamps only gesture at: the sidecar records the hash of the
committed sheet this runtime build was made from. Core ships new CSS -> that file changes ->
the hash changes -> the runtime sheet is correctly judged stale.
"""
try:
meta = json.loads(runtime_meta_path().read_text())
except (OSError, ValueError):
return False
return bool(meta.get("committed_sha256")) and meta["committed_sha256"] == _committed_css_fingerprint()
def runtime_css_path() -> Path:
"""Where the RUNTIME-augmented stylesheet is written.
NOT ``static/tailwind.min.css``. That file is a BUILD ARTEFACT: committed, image-baked,
and generated by scanning only the in-tree plugins. This one is PER-INSTALL STATE it
additionally scans whatever the user has installed into FEEDBACK_PLUGINS_DIR, so it differs
from machine to machine. They are different things and must not share a path.
Writing the runtime sheet over the committed one had two costs:
* IN A GIT CHECKOUT it silently modifies a TRACKED file. `git add -A` then sweeps a
100KB reshuffle of minified CSS into the commit and `ci/tailwind-fresh` goes red with a
diff that explains nothing. That is issue #911, and it cost a red run on a PR whose
real diff touched no Tailwind classes at all.
* IN A DEPLOY the app directory may be read-only. Writing app state into it is wrong on
principle and fatal in practice.
CONFIG_DIR is where per-install state already lives.
"""
cfg = (getenv_compat("CONFIG_DIR", "") or "").strip()
base = Path(cfg) if cfg else (Path.home() / ".local" / "share" / "feedback")
return base / "tailwind.min.css"
def _user_plugins_dir() -> Path | None:
raw = (getenv_compat("FEEDBACK_PLUGINS_DIR", "") or "").strip()
if not raw:
@@ -209,14 +136,6 @@ def _run_build(cmd_prefix: list[str], out: Path, src: Path) -> bool:
cwd=str(APP_DIR), timeout=120,
)
os.replace(staged, out)
# Stamp WHICH CORE this was built against. Without it, an upgraded app cannot tell a
# current runtime sheet from one that predates its new CSS.
try:
runtime_meta_path().write_text(json.dumps({
"committed_sha256": _committed_css_fingerprint(),
}))
except OSError:
log.warning("tailwind: could not write the runtime sheet's meta sidecar")
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
stderr = (getattr(e, "stderr", "") or "")[-500:]
@@ -234,7 +153,7 @@ def _run_build(cmd_prefix: list[str], out: Path, src: Path) -> bool:
def rebuild(reason: str = "") -> bool:
"""Regenerate the RUNTIME stylesheet (see runtime_css_path) over baked-in + user plugins.
"""Regenerate ``static/tailwind.min.css`` over baked-in + user plugins.
Returns ``True`` on a successful rebuild, ``False`` on any skip/failure.
Never raises callers treat CSS freshness as best-effort. Concurrent
@@ -247,13 +166,8 @@ def rebuild(reason: str = "") -> bool:
log.info("tailwind rebuild skipped — engine/inputs unavailable%s", tag)
return False
out = runtime_css_path()
out = APP_DIR / "static" / "tailwind.min.css"
src = APP_DIR / "static" / "_tailwind.src.css"
try:
out.parent.mkdir(parents=True, exist_ok=True)
except OSError:
log.warning("tailwind rebuild skipped — cannot create %s%s", out.parent, tag)
return False
# If a rebuild is already running, flag a rerun and return instead of
# queueing a redundant build behind it.
+2 -4
View File
@@ -101,16 +101,14 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER
def freqs_to_midis(freqs: list[float], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[int] | None:
"""Return absolute open-string MIDI notes for frequencies at the supplied
A4 reference the inverse of open_midis_to_freqs. None if any entry is
non-numeric, non-finite, or non-positive (a provider could hand us
anything; NaN/Infinity would otherwise raise inside int(round(...)) and
500 the /api/tunings endpoint)."""
non-numeric or non-positive (a provider could hand us anything)."""
out: list[int] = []
for f in freqs:
try:
f = float(f)
except (TypeError, ValueError):
return None
if not math.isfinite(f) or f <= 0:
if f <= 0:
return None
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
return out
-48
View File
@@ -6,7 +6,6 @@ import json
import logging
import mimetypes
import os
import re
import subprocess
import sys
import threading
@@ -2385,53 +2384,6 @@ def register_plugin_api(app: FastAPI):
return _plugin_file_response(request, script_file, "application/javascript")
return Response("", status_code=404)
# ── Module-graph cache busting (#879) ────────────────────────────────
#
# ES modules are evaluated ONCE PER URL PER DOCUMENT. Re-inserting a
# <script type="module"> whose src the module map has already seen fires
# `load` but does NOT re-run the body. So re-loading a plugin — a rollback,
# and (see below) an upgrade too — silently kept the OLD module live while
# the loader recorded success: a no-op that reported it worked.
#
# Busting the ENTRY url does not help. A module plugin's screen.js is a
# one-line `import './src/main.js'`, and a relative specifier resolves
# against the base URL WITH THE QUERY DROPPED — so a ?v= token never reaches
# the graph. Driving a real browser through install -> upgrade -> rollback and
# counting evaluations of src/main.js gives ONE. The upgrade re-runs the shim
# at its new ?v= URL; the shim imports './src/main.js'; that resolves to the
# same URL; the module map returns the already-evaluated old module.
#
# So the token goes in the PATH: /api/plugins/<id>/g/<n>/screen.js. Every
# relative import inherits it at every depth — for free, with no
# import-specifier rewriting (which could never see `import(expr)` anyway).
#
# WHY A PATH REWRITE AND NOT TWO MIRRORED ROUTES. The token shifts the base
# URL, so EVERYTHING a module resolves relatively moves with it — not just
# imports. `new URL('../assets/worklet.js', import.meta.url)` from
# /api/plugins/x/g/1/src/main.js resolves to /api/plugins/x/g/1/assets/... .
# Mirroring only screen.js and src/ would fix imports and 404 every asset,
# worklet and wasm file the graph reaches — and would silently break again the
# next time someone adds a plugin route. Stripping the segment before routing
# makes every plugin route, present and future, work under the prefix.
#
# The token is opaque: it is never joined into a filesystem path (and is gone
# by the time any handler runs), so containment still rests entirely on the
# same safe_join the un-prefixed routes use.
_GEN_PREFIX = re.compile(r"^(/api/plugins/[^/]+)/g/[^/]+(/.+)$")
@app.middleware("http")
async def _strip_plugin_generation_prefix(request: Request, call_next):
m = _GEN_PREFIX.match(request.scope.get("path", ""))
if m:
# Starlette routes on scope["path"] alone. raw_path is deliberately left
# ALONE: it is informational, and re-encoding the rewritten str back to
# bytes would have to guess a codec — `.encode("latin-1")` raises
# UnicodeEncodeError on a perfectly valid plugin file like src/工具.js,
# 500ing a request the un-prefixed route serves fine. Leaving raw_path as
# the client actually sent it is also simply more truthful for logs.
request.scope["path"] = m.group(1) + m.group(2)
return await call_next(request)
@app.get("/api/plugins/{plugin_id}/settings.html")
def plugin_settings_html(plugin_id: str):
with PLUGINS_LOCK:
-791
View File
@@ -1,791 +0,0 @@
/* Career plugin only what the prebuilt core Tailwind doesn't ship
(plugin files are outside the core content glob, so responsive grid
variants and cyan button shades live here under plugin-prefixed names). */
.career-venues {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.career-venues { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.career-btn {
font-size: 0.75rem;
line-height: 1rem;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
transition: background-color 0.15s ease;
}
.career-btn-primary { background-color: #0891b2; color: #fff; }
.career-btn-primary:hover { background-color: #06b6d4; }
.career-btn-ghost { background-color: rgba(31, 41, 55, 0.7); color: #d1d5db; }
.career-btn-ghost:hover { background-color: rgba(55, 65, 81, 0.9); }
.career-bar-track {
height: 0.5rem;
border-radius: 0.25rem;
background-color: rgba(31, 41, 55, 0.9);
overflow: hidden;
}
.career-bar-fill {
height: 100%;
background-color: #06b6d4;
transition: width 0.3s ease;
}
.career-star-list {
display: grid;
gap: 0.375rem;
}
.career-star-row {
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
background-color: rgba(31, 41, 55, 0.4);
font-size: 0.8rem;
}
.career-star-row .stars {
color: #facc15;
letter-spacing: 0.1em;
min-width: 3.2em;
}
.career-star-row .stars .off { color: rgba(250, 204, 21, 0.25); }
.career-star-row .song {
color: #e5e7eb;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.career-star-row .song .artist { color: #9ca3af; }
.career-star-row .hint { color: #6b7280; white-space: nowrap; }
.career-star-row .hint.close { color: #22d3ee; }
/* ── Passports (badge journey) ─────────────────────────────────────────── */
.career-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid rgba(55, 65, 81, 0.6);
}
.career-tab {
padding: 0.375rem 0.875rem;
font-size: 0.85rem;
color: #9ca3af;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.career-tab:hover { color: #e5e7eb; }
.career-tab.active { color: #fff; border-bottom-color: #06b6d4; }
.pp-instruments { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pp-inst {
padding: 0.3rem 0.8rem;
border-radius: 999px;
font-size: 0.8rem;
color: #d1d5db;
background-color: rgba(31, 41, 55, 0.7);
border: 1px solid transparent;
}
.pp-inst:hover { background-color: rgba(55, 65, 81, 0.9); }
.pp-inst.active { border-color: #06b6d4; color: #fff; }
.pp-inst.uncommitted { color: #6b7280; border-style: dashed; border-color: rgba(107, 114, 128, 0.5); }
.pp-inst-badges { color: #fbbf24; font-size: 0.7rem; }
.pp-inst-plus { color: #6b7280; }
/* Leather covers per-instrument hue, embossed with layered shadows and a
subtle grain gradient (no image assets). Keep the hex pairs in sync with
PP_LEATHER_HEX in screen.js (the canvas card draws the same leather). */
.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); }
.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); }
.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); }
.pp-leather-drums { background: linear-gradient(160deg, #3f3f46, #26262b); }
.pp-shelf { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end; }
.pp-cover, .pp-commit-cover {
position: relative;
width: 9.5rem;
height: 13rem;
border-radius: 0.5rem 0.75rem 0.75rem 0.5rem;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
inset 0.5rem 0 0.75rem -0.5rem rgba(0, 0, 0, 0.8),
0 6px 16px rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.75rem;
text-align: center;
}
.pp-cover { transition: transform 0.15s ease, box-shadow 0.15s ease; }
.pp-cover:not(.pp-tilt):hover { transform: translateY(-4px) !important; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.55); }
.pp-cover-title {
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.14em;
color: rgba(240, 226, 195, 0.92);
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7), 0 -1px 0 rgba(255, 255, 255, 0.12);
overflow-wrap: anywhere;
}
.pp-cover-inst {
font-size: 0.6rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(240, 226, 195, 0.55);
}
.pp-cover-sub {
position: absolute;
bottom: 0.6rem;
font-size: 0.6rem;
color: rgba(240, 226, 195, 0.5);
}
.pp-commit-card {
display: flex;
gap: 1.25rem;
align-items: center;
padding: 1rem;
border-radius: 0.75rem;
border: 1px solid rgba(55, 65, 81, 0.6);
background-color: rgba(31, 41, 55, 0.35);
}
.pp-commit-card .pp-commit-cover { width: 7rem; height: 9.5rem; flex: none; }
.pp-rack { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); }
.pp-brochure {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
padding: 0.75rem 0.875rem;
border-radius: 0.5rem;
text-align: left;
background: linear-gradient(165deg, rgba(45, 55, 72, 0.55), rgba(31, 41, 55, 0.55));
border: 1px solid rgba(75, 85, 99, 0.5);
transition: transform 0.15s ease, border-color 0.15s ease;
}
.pp-brochure:hover { transform: translateY(-2px); border-color: #06b6d4; }
.pp-brochure-art { font-size: 1.4rem; }
.pp-brochure-name { color: #e5e7eb; font-size: 0.85rem; font-weight: 600; }
.pp-brochure-sub { color: #6b7280; font-size: 0.65rem; }
/* The open book */
.pp-overlay { position: fixed; inset: 0; z-index: 60; }
.pp-book-wrap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(3, 7, 18, 0.72);
backdrop-filter: blur(2px);
}
.pp-book {
position: relative;
width: min(92vw, 720px);
height: min(72vh, 470px);
perspective: 1800px;
}
.pp-page {
position: absolute;
top: 0;
bottom: 0;
width: 50%;
background:
linear-gradient(105deg, rgba(0, 0, 0, 0.08), transparent 12%),
#efe6d0;
color: #3f3428;
padding: 1.1rem 1.2rem;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pp-page-left { left: 0; border-radius: 0.6rem 0 0 0.6rem; opacity: 0; transition: opacity 0.35s ease 0.3s; align-items: center; }
.pp-page-right { right: 0; border-radius: 0 0.6rem 0.6rem 0; box-shadow: inset 0.4rem 0 0.6rem -0.4rem rgba(0, 0, 0, 0.35); }
.pp-book.open .pp-page-left { opacity: 1; }
.pp-book-cover {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 50%;
border-radius: 0 0.6rem 0.6rem 0;
transform-origin: left center;
transform: rotateY(0deg);
backface-visibility: hidden;
transition: transform 0.8s cubic-bezier(0.4, 0.1, 0.2, 1);
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 20px rgba(0, 0, 0, 0.5);
}
.pp-book.open .pp-book-cover { transform: rotateY(-180deg); }
.pp-book-close {
position: absolute;
top: -0.75rem;
right: -0.75rem;
z-index: 8;
width: 2rem;
height: 2rem;
border-radius: 999px;
background: rgba(17, 24, 39, 0.95);
color: #d1d5db;
border: 1px solid rgba(107, 114, 128, 0.5);
}
.pp-book-close:hover { color: #fff; border-color: #06b6d4; }
.pp-page-head {
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #8a7a5e;
border-bottom: 1px solid rgba(138, 122, 94, 0.35);
padding-bottom: 0.4rem;
width: 100%;
text-align: center;
}
/* The rubber stamp */
.pp-stamp {
--pp-rot: 0deg;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
width: 9rem;
height: 9rem;
border-radius: 999px;
border: 3px solid #9a5b16;
box-shadow: inset 0 0 0 3px #efe6d0, inset 0 0 0 4px #9a5b16;
color: #9a5b16;
transform: rotate(var(--pp-rot));
margin-top: 1.25rem;
text-align: center;
padding: 0.75rem;
opacity: 0.92;
}
.pp-stamp-genre { font-size: 0.72rem; font-weight: 800; letter-spacing: 0.16em; overflow-wrap: anywhere; }
.pp-stamp-tier { font-size: 0.58rem; letter-spacing: 0.3em; }
.pp-stamp-ghost {
border-style: dashed;
box-shadow: none;
border-color: #b3a68b;
color: #b3a68b;
opacity: 0.8;
}
.pp-stamp-hidden { opacity: 0; }
.pp-stamp-mini {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: auto;
height: auto;
border-width: 2px;
box-shadow: none;
border-radius: 999px;
font-size: 0.5rem;
font-weight: 800;
letter-spacing: 0.2em;
color: #d9a253;
border-color: #d9a253;
padding: 0.2rem 0.4rem;
margin: 0;
display: inline-block;
transform: rotate(var(--pp-rot));
opacity: 0.95;
}
.pp-stamp-page::after {
content: '';
position: absolute;
inset: -10%;
border-radius: 999px;
background: radial-gradient(closest-side, rgba(154, 91, 22, 0.25), transparent 72%);
filter: blur(5px);
opacity: 0;
pointer-events: none;
}
.pp-slam { animation: pp-slam 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) forwards; }
.pp-slam::after { animation: pp-ink 0.45s ease-out 0.12s forwards; }
@keyframes pp-slam {
0% { transform: rotate(calc(var(--pp-rot) - 15deg)) scale(2.5); opacity: 0; }
55% { transform: rotate(var(--pp-rot)) scale(0.92); opacity: 1; }
75% { transform: rotate(var(--pp-rot)) scale(1.05); }
100% { transform: rotate(var(--pp-rot)) scale(1); opacity: 0.92; }
}
@keyframes pp-ink {
from { opacity: 0; transform: scale(0.6); }
to { opacity: 1; transform: scale(1); }
}
.pp-shake { animation: pp-shake 0.4s ease-out 0.28s; }
@keyframes pp-shake {
0%, 100% { transform: translate(0, 0) rotate(0); }
25% { transform: translate(2px, 1px) rotate(0.3deg); }
50% { transform: translate(-2px, 2px) rotate(-0.25deg); }
75% { transform: translate(1px, -1px) rotate(0.15deg); }
}
.pp-invite, .pp-snj, .pp-gold-note { font-size: 0.75rem; text-align: center; }
.pp-invite { color: #6d5d40; }
.pp-snj { color: #6d5d40; margin-top: 2rem; font-style: italic; max-width: 15rem; }
.pp-gold-note { color: #a8946d; font-size: 0.62rem; margin-top: 0.5rem; }
.pp-drills { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.7rem; color: #6d5d40; }
.pp-drill.cleared { color: #4d7c0f; }
/* Ticket stubs */
.pp-stubs { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; padding-right: 0.25rem; }
.pp-stub {
background: #f7f1e3;
border: 1px solid #d8cbaa;
border-left: 2px dashed #b6a98c;
border-radius: 0.25rem 0.4rem 0.4rem 0.25rem;
padding: 0.4rem 0.6rem 0.4rem 0.75rem;
display: grid;
grid-template-columns: auto 1fr;
column-gap: 0.6rem;
align-items: baseline;
box-shadow: 0 1px 2px rgba(63, 52, 40, 0.15);
}
.pp-stub-stars { color: #b8860b; font-size: 0.7rem; letter-spacing: 0.08em; grid-row: span 2; }
.pp-stub-title { font-size: 0.78rem; font-weight: 600; color: #3f3428; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pp-stub-artist { grid-column: 2; font-size: 0.65rem; color: #6d5d40; }
.pp-stub-meta { grid-column: 2; font-size: 0.6rem; color: #8a7a5e; }
.pp-stub-empty { font-size: 0.72rem; color: #8a7a5e; font-style: italic; padding: 0.75rem 0.25rem; }
/* Wax-seal commitment ceremony */
.pp-ceremony { width: 11rem; height: 15rem; }
.pp-wax {
position: absolute;
bottom: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
width: 3.4rem;
height: 3.4rem;
border-radius: 999px;
background:
radial-gradient(circle at 32% 30%, #d24545 0%, #a41f1f 42%, #7c1414 100%);
box-shadow:
inset 0 0 0 4px rgba(124, 20, 20, 0.9),
inset 0 2px 4px rgba(255, 255, 255, 0.25),
0 3px 8px rgba(0, 0, 0, 0.55);
color: rgba(255, 235, 235, 0.9);
font-weight: 700;
font-size: 1.15rem;
animation: pp-seal-drop 0.9s cubic-bezier(0.25, 0.9, 0.3, 1.15) 0.35s backwards;
}
@keyframes pp-seal-drop {
0% { transform: translateY(-120px) scale(2.1); opacity: 0; }
60% { transform: translateY(0) scale(0.9); opacity: 1; }
80% { transform: translateY(0) scale(1.05); }
100% { transform: translateY(0) scale(1); }
}
/* Small screens: the spread stacks; the flip cover would straddle both
pages, so the book simply opens. */
@media (max-width: 640px) {
.pp-book { height: min(80vh, 620px); }
.pp-page { position: static; width: 100%; height: 50%; border-radius: 0; }
.pp-page-left { border-radius: 0.6rem 0.6rem 0 0; opacity: 1; }
.pp-page-right { border-radius: 0 0 0.6rem 0.6rem; }
.pp-book-cover { display: none; }
.pp-book { display: flex; flex-direction: column; }
}
@media (prefers-reduced-motion: reduce) {
.pp-book-cover, .pp-page-left, .pp-cover { transition: none; }
.pp-slam, .pp-slam::after, .pp-shake, .pp-wax { animation: none; }
.pp-slam, .pp-stamp-page::after { opacity: 1; }
.pp-stamp-hidden { opacity: 0.92; }
}
/* Badge ceremony (body-level overlay — shows over the player) */
.pp-ceremony-overlay {
position: fixed;
inset: 0;
z-index: 220;
display: flex;
align-items: center;
justify-content: center;
background: rgba(3, 7, 18, 0.55);
backdrop-filter: blur(1.5px);
animation: pp-ceremony-in 0.3s ease-out;
cursor: pointer;
}
.pp-ceremony-out { opacity: 0; transition: opacity 0.3s ease-out; }
.pp-confetti { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.pp-ceremony-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
}
.pp-ceremony-stamp {
position: relative;
overflow: hidden;
background: rgba(239, 230, 208, 0.97);
transform: rotate(var(--pp-rot)) scale(1.25);
animation: pp-slam 0.55s cubic-bezier(0.2, 0.8, 0.3, 1) 0.15s backwards;
margin-top: 0;
}
.pp-ceremony-stamp::before {
content: '';
position: absolute;
inset: -40%;
background: linear-gradient(115deg, transparent 42%, rgba(255, 255, 255, 0.55) 50%, transparent 58%);
transform: translateX(-120%);
animation: pp-shine 1.1s ease-out 0.75s forwards;
pointer-events: none;
}
@keyframes pp-shine {
to { transform: translateX(120%); }
}
@keyframes pp-ceremony-in {
from { opacity: 0; }
to { opacity: 1; }
}
.pp-ceremony-title {
margin-top: 1rem;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #f0e2c3;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
}
.pp-ceremony-sub { font-size: 0.8rem; color: #d1d5db; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); }
/* Hours odometer (Stage 5 post-cap — a true fact, never a meter) */
.pp-hours {
font-size: 0.72rem;
letter-spacing: 0.08em;
color: #8a7a5e;
margin-top: 0.75rem;
font-variant-numeric: tabular-nums;
}
/* ── Visuals pack: trading-card tilt, emerging ink, gold foil ──────────── */
/* Trading-card tilt (earned artifacts; JS feeds --pp-tilt-* on hover-capable
pointers only). */
.pp-tilt {
position: relative;
overflow: hidden;
will-change: transform;
}
.pp-cover.pp-tilt {
transform: perspective(700px)
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
rotate(var(--pp-cover-rot, 0deg));
transition: transform 0.12s ease, box-shadow 0.15s ease;
}
.pp-cover.pp-tilt:hover { box-shadow: 0 12px 26px rgba(0, 0, 0, 0.6); }
.pp-stamp-page.pp-tilt {
overflow: visible;
transform: perspective(600px)
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
rotate(var(--pp-rot));
transition: transform 0.12s ease;
}
.pp-tilt::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(105deg,
transparent calc(var(--pp-glint-x, 50%) - 14%),
rgba(255, 255, 255, 0.16) var(--pp-glint-x, 50%),
transparent calc(var(--pp-glint-x, 50%) + 14%));
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.pp-tilt:hover::after { opacity: 1; }
/* Emerging-stamp ink: the ghost fills as qualifying songs land. */
.pp-stamp-ghost::before {
content: '';
position: absolute;
inset: 7%;
border-radius: 999px;
background: conic-gradient(rgba(154, 91, 22, 0.16) var(--pp-fill, 0%), transparent 0);
pointer-events: none;
}
/* Gold ink — a REAL gold badge (comb-verified improv). */
.pp-stamp-gold {
border-color: #b8860b;
color: #a97b1b;
box-shadow: inset 0 0 0 3px #f3e8c8, inset 0 0 0 4px #b8860b;
}
.pp-stamp-mini.pp-stamp-gold {
color: #f0c75e;
border-color: #f0c75e;
box-shadow: none;
}
/* Gold foil chip — rendered only alongside an earned gold stamp. */
.pp-gold-foil {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 0.9rem;
padding: 0.28rem 0.85rem;
border-radius: 999px;
border: 2px solid #d9a253;
color: #c89040;
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.32em;
}
.pp-gold-foil::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 40%, rgba(255, 223, 128, 0.35) 50%, transparent 60%);
transform: translateX(-120%);
animation: pp-foil 3.4s ease-in-out infinite;
}
@keyframes pp-foil {
0%, 55% { transform: translateX(-120%); }
100% { transform: translateX(120%); }
}
@media (prefers-reduced-motion: reduce) {
.pp-gold-foil::after { animation: none; }
.pp-cover.pp-tilt, .pp-stamp-page.pp-tilt { transition: none; }
/* The hover glint is motion theatrics too — not just the JS tilt. */
.pp-tilt::after { display: none; }
}
/* Practice invitations — closest stamps + bring-these-up */
.pp-closest {
border: 1px solid rgba(75, 85, 99, 0.45);
border-radius: 0.6rem;
background: linear-gradient(165deg, rgba(45, 55, 72, 0.4), rgba(31, 41, 55, 0.4));
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.pp-closest-head {
font-size: 0.62rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #9ca3af;
}
.pp-closest-row {
display: flex;
align-items: baseline;
gap: 0.75rem;
text-align: left;
font-size: 0.8rem;
padding: 0.15rem 0.25rem;
border-radius: 0.35rem;
}
.pp-closest-row:hover { background: rgba(55, 65, 81, 0.5); }
.pp-closest-genre { color: #e5e7eb; font-weight: 600; white-space: nowrap; }
.pp-closest-ask { color: #9ca3af; font-size: 0.72rem; }
.pp-closest-ask em { color: #cbd5e1; font-style: italic; }
.pp-nearest { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
.pp-nearest-head {
font-size: 0.58rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #8a7a5e;
margin-bottom: 0.25rem;
}
.pp-nearest-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
.pp-nearest-row em { color: #3f3428; }
/* ── Career surfaces outside the plugin: profile wall + home card ───────── */
.pp-wall { display: flex; flex-direction: column; gap: 0.6rem; }
.pp-wall-head {
display: flex;
align-items: baseline;
justify-content: space-between;
font-weight: 600;
color: #e5e7eb;
font-size: 0.9rem;
}
.pp-wall-meta { color: #9ca3af; font-size: 0.7rem; font-weight: 400; }
.pp-wall-shelf {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.35rem 0;
border-bottom: 1px solid rgba(75, 85, 99, 0.25);
}
.pp-wall-inst {
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6b7280;
min-width: 3.6rem;
}
.pp-wall-cover {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
width: 4.2rem;
height: 5.6rem;
border-radius: 0.3rem 0.45rem 0.45rem 0.3rem;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07),
inset 0.25rem 0 0.4rem -0.25rem rgba(0, 0, 0, 0.8),
0 3px 8px rgba(0, 0, 0, 0.4);
padding: 0.3rem;
transition: transform 0.15s ease;
}
.pp-wall-cover:hover { transform: translateY(-3px); }
.pp-wall-cover span {
font-size: 0.5rem;
font-weight: 700;
letter-spacing: 0.1em;
color: rgba(240, 226, 195, 0.9);
overflow-wrap: anywhere;
text-align: center;
}
.pp-wall-cover em {
font-size: 0.42rem;
letter-spacing: 0.22em;
font-style: normal;
color: #d9a253;
}
.pp-wall-none { font-size: 0.7rem; color: #6b7280; font-style: italic; }
.pp-wall-link {
align-self: flex-end;
font-size: 0.72rem;
color: #22d3ee;
padding: 0.15rem 0.3rem;
}
.pp-wall-link:hover { text-decoration: underline; }
/* The home-page career card — a trading card among stat tiles. */
.pp-dash-card {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.2rem;
text-align: left;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid rgba(217, 162, 83, 0.35);
background:
linear-gradient(135deg, rgba(92, 35, 33, 0.85), rgba(30, 27, 34, 0.92)),
linear-gradient(160deg, #2b1414, #17111c);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 4px 14px rgba(0, 0, 0, 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.pp-dash-card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5); }
.pp-dash-shine {
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 42%, rgba(255, 223, 128, 0.18) 50%, transparent 58%);
transform: translateX(-130%);
pointer-events: none;
}
.pp-dash-card:hover .pp-dash-shine { animation: pp-foil 1.4s ease-out; }
.pp-dash-head {
font-size: 0.58rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: #d9a253;
}
.pp-dash-badges { color: #f3ead2; font-size: 1.05rem; }
.pp-dash-badges b { font-weight: 700; margin: 0 0.25rem 0 0.35rem; }
.pp-dash-meta { color: #b5a488; font-size: 0.72rem; }
.pp-dash-ask { color: #8d9aa8; font-size: 0.66rem; }
.pp-dash-ask em { color: #cbd5e1; }
.pp-card-actions { display: flex; gap: 0.5rem; margin-top: 0.9rem; }
@media (prefers-reduced-motion: reduce) {
.pp-dash-card:hover .pp-dash-shine { animation: none; }
.pp-wall-cover, .pp-dash-card { transition: none; }
}
/* ── Gigs: poster, runner strip, summary, log ───────────────────────────── */
.pp-poster {
position: relative;
width: min(92vw, 420px);
padding: 2rem 1.6rem 1.4rem;
border-radius: 0.5rem;
background: linear-gradient(180deg, #141019, #241318);
border: 2px solid rgba(217, 162, 83, 0.45);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
text-align: center;
}
.pp-poster-venue { color: rgba(240, 226, 195, 0.7); font-size: 0.95rem; letter-spacing: 0.08em; }
.pp-poster-presents { color: rgba(240, 226, 195, 0.4); font-size: 0.58rem; letter-spacing: 0.4em; text-transform: uppercase; }
.pp-poster-title {
color: #d9a253;
font-size: 1.7rem;
font-weight: 800;
letter-spacing: 0.1em;
line-height: 1.15;
overflow-wrap: anywhere;
}
.pp-poster-inst { color: rgba(240, 226, 195, 0.5); font-size: 0.68rem; letter-spacing: 0.2em; text-transform: uppercase; }
.pp-poster-bill { margin: 0.9rem 0 0.5rem; display: flex; flex-direction: column; gap: 0.35rem; width: 100%; }
.pp-poster-line { color: rgba(240, 226, 195, 0.85); font-size: 0.85rem; }
.pp-poster-line span { color: rgba(217, 162, 83, 0.7); margin-right: 0.35rem; }
.pp-poster-line em { color: rgba(240, 226, 195, 0.5); font-style: italic; font-size: 0.72rem; }
.pp-poster-line b { color: #f3d179; margin-left: 0.3rem; }
.pp-poster-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; margin-top: 0.6rem; }
.pp-poster-summary { cursor: default; }
.pp-gig-strip {
position: fixed;
top: 0.5rem;
left: 50%;
transform: translateX(-50%);
z-index: 35; /* above the rail (30), under popovers (40) — the chrome invariant */
background: rgba(10, 8, 14, 0.85);
border: 1px solid rgba(217, 162, 83, 0.4);
border-radius: 999px;
color: rgba(240, 226, 195, 0.85);
font-size: 0.72rem;
padding: 0.3rem 0.9rem;
pointer-events: none;
backdrop-filter: blur(2px);
}
.pp-gig-strip b { color: #d9a253; letter-spacing: 0.2em; }
.pp-gig-strip em { color: #f3ead2; font-style: italic; }
.pp-giglog { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
.pp-giglog-head {
font-size: 0.58rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #8a7a5e;
margin-bottom: 0.25rem;
}
.pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
.pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-43
View File
@@ -1,43 +0,0 @@
{
"badge_requirement": {
"songs": 5,
"min_stars": 2
},
"gig": {
"min_songs": 3,
"max_songs": 5,
"stakes_songs": 2,
"encore_accuracy": 0.75
},
"families": [
{ "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] },
{ "key": "blues", "match": ["blues"] },
{ "key": "jazz", "match": ["jazz", "bebop", "swing", "bossa"] },
{ "key": "funk", "match": ["funk", "disco"] },
{ "key": "rock", "match": ["rock", "punk", "grunge", "shoegaze"] }
],
"genres": {
"blues": { "virtuoso_nodes": { "guitar": ["blues_shuffle"] } },
"rock": { "virtuoso_nodes": { "guitar": ["rock_power_backbeat"] } },
"metal": { "virtuoso_nodes": { "guitar": ["melodic_metal_gallop"] } },
"funk": { "virtuoso_nodes": { "guitar": ["sixteenth_pocket"] } },
"jazz": { "virtuoso_nodes": { "guitar": ["vl_shells"] } }
},
"drill_labels": {
"blues_shuffle": "Blues Shuffle",
"rock_power_backbeat": "Power Chords & Backbeat",
"melodic_metal_gallop": "Gallop Picking",
"sixteenth_pocket": "16th Pocket",
"vl_shells": "Shell Voicings"
},
"graded_instruments": [
"guitar",
"keys"
],
"instruments": [
"guitar",
"bass",
"keys",
"drums"
]
}
-18
View File
@@ -1,18 +0,0 @@
{
"id": "career",
"name": "Career",
"version": "0.2.0",
"bundled": true,
"private": false,
"description": "Career mode — gig your way from a local bar to the arena, and build a passport wall of genre badges per instrument. Earn stars per song; the crowd reacts to how you play.",
"screen": "screen.html",
"script": "screen.js",
"styles": "assets/career.css",
"settings": {
"html": "settings.html",
"server_files": [
"career/"
]
},
"routes": "routes.py"
}
-990
View File
@@ -1,990 +0,0 @@
"""Career mode — venue progression driven by per-song stars.
Stars come straight from ``song_stats`` (meta.db): per song, the best
accuracy across arrangements crosses 0/1/2/3 of the thresholds in
``venues.json`` (data-driven so tuning never touches code). Cumulative
stars unlock venue tiers (bar club arena).
Venue packs (crowd-loop videos rendered offline in UE) may be bundled with
the plugin under ``venue-packs/<id>/`` or downloaded on demand into
``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. Downloaded packs override
bundled packs so release assets can replace a built-in starter venue.
Passports (badge journey per instrument × genre the identity layer on top
of the same stars): badges are COMPUTED on read from ``song_stats`` × the
library's effective genre, never stored. The only persisted career state is
what cannot be derived instrument commitment, opened passports, and the
relayed virtuoso drill snapshot as JSON under ``CONFIG_DIR/career/``
(exported via ``settings.server_files``).
Endpoints (all under /api/plugins/career/):
GET /state stars + per-venue unlock/install/download status
POST /packs/{venue_id}/download start background pack download (409 if running)
DELETE /packs/{venue_id} remove an installed pack
GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers)
GET /passports passport walls: badges, stubs, genres, drill status
POST /passports/commit commit to an instrument (the wax seal, Stage 0)
POST /passports/open open a genre passport for an instrument
POST /drill-state relayed virtuoso.progress snapshot (drill intake)
POST /gigs/propose build a playable setlist for a genre gig
POST /gigs log a COMPLETED gig (abandoned sets never log)
"""
import hashlib
import json
import logging
import random
import re
import shutil
import tempfile
import threading
import urllib.request
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from fastapi import Body, HTTPException
from fastapi.responses import FileResponse
import sloppak
from dlc_paths import _resolve_dlc_path
from progression import instrument_for_arrangement
PLUGIN_ID = "career"
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
DOWNLOAD_CHUNK = 1024 * 256
# A setlist is a handful of songs; this endpoint unpacks zips, so cap the work an
# arbitrary caller can ask for.
MAX_GIG_SONGS = 32
_lock = threading.Lock()
_state = {
"content": None, # parsed venues.json
"plugin_dir": None, # plugin root; bundled packs live below it
"venues_dir": None, # CONFIG_DIR/plugin_uploads/career/venues
"meta_db": None, # MetadataDB (song_stats reads are lock-free / WAL)
"log": logging.getLogger("feedBack.plugin.career"),
"downloads": {}, # venue_id -> {status, bytes_done, bytes_total, error}
}
def _venue(venue_id):
for v in _state["content"]["venues"]:
if v["id"] == venue_id:
return v
return None
def _venue_dir(venue_id) -> Path:
return _state["venues_dir"] / venue_id
def _bundled_venue_dir(venue_id) -> Path:
return _state["plugin_dir"] / "venue-packs" / venue_id
def _pack_dir(venue_id):
"""Runtime pack location: downloaded override first, bundled fallback."""
local = _venue_dir(venue_id)
if (local / "manifest.json").is_file():
return local
bundled = _bundled_venue_dir(venue_id)
if (bundled / "manifest.json").is_file():
return bundled
return local
def _installed(venue_id):
return (_pack_dir(venue_id) / "manifest.json").is_file()
def _bundled(venue_id):
return (_bundled_venue_dir(venue_id) / "manifest.json").is_file()
def _stars():
"""(total, per-song dict, detail rows). Accuracy is a 0..1 fraction."""
db = _state["meta_db"]
if db is None:
return 0, {}, []
thresholds = _state["content"]["star_accuracy_thresholds"]
# Existing-song filter: a scan hides (not deletes) stats of songs removed
# from the library, so orphaned rows must not keep counting toward stars.
rows = db.conn.execute(
"SELECT s.filename, MAX(s.best_accuracy), "
" COALESCE(MAX(sg.title), ''), COALESCE(MAX(sg.artist), '') "
"FROM song_stats s JOIN songs sg ON sg.filename = s.filename "
"GROUP BY s.filename"
).fetchall()
per_song = {}
detail = []
for filename, acc, title, artist in rows:
acc = acc or 0.0
stars, next_at = _star_progress(acc, thresholds)
if stars:
per_song[filename] = stars
detail.append({
"filename": filename,
"title": title or filename,
"artist": artist,
"stars": stars,
"best_accuracy": round(acc, 4),
"next_star_at": next_at,
})
# closest-to-next-star first (a practice worklist), maxed songs last
detail.sort(key=lambda r: (r["next_star_at"] is None,
(r["next_star_at"] or 1.0) - r["best_accuracy"]))
return sum(per_song.values()), per_song, detail
# ── Passports ─────────────────────────────────────────────────────────────────
GENRE_MAX_LEN = 64
DRILL_SNAPSHOT_MAX_BYTES = 256 * 1024
def _now_iso():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _genre_display(genre):
return " ".join(str(genre or "").strip().split())
def _genre_key(genre):
return _genre_display(genre).lower()
def _state_file() -> Path:
return _state["state_dir"] / "passports-state.json"
def _drill_file() -> Path:
return _state["state_dir"] / "drill-state.json"
def _load_json(path: Path, default):
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return default
def _save_json(path: Path, obj):
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(obj, indent=2), encoding="utf-8")
tmp.replace(path)
def _career_state():
st = _load_json(_state_file(), {})
if not isinstance(st, dict):
st = {}
if not isinstance(st.get("instruments"), dict):
st["instruments"] = {}
if not isinstance(st.get("passports"), dict):
st["passports"] = {}
return st
def _genre_expr(db):
# Reuse the host's override-aware effective-genre SQL (Fix-metadata popup
# overrides); plain `genre` on stand-ins that don't implement it.
fn = getattr(db, "_effective_genre_expr", None)
return fn() if callable(fn) else "genre"
def _instrument_of(arrangements, arrangement):
"""Progression's arrangement→instrument mapping, via the song_stats
arrangement index into the song's arrangements JSON."""
entry = None
try:
idx = int(arrangement)
if isinstance(arrangements, list) and 0 <= idx < len(arrangements):
entry = arrangements[idx]
except (TypeError, ValueError):
entry = None
return instrument_for_arrangement(entry)
def _played_by_instrument_genre():
"""((instrument, genre_key) → {filename: stub dict},
(instrument, genre_key) total played seconds).
Best accuracy per (instrument, song); seconds sum across every
arrangement row; the JOIN keeps the same dead-song filter as _stars()."""
db = _state["meta_db"]
if db is None:
return {}, {}
thresholds = _state["content"]["star_accuracy_thresholds"]
rows = db.conn.execute(
"SELECT s.filename, s.arrangement, s.best_accuracy, s.last_played_at, "
" s.seconds_total, songs.title, songs.artist, songs.arrangements, "
f" {_genre_expr(db)} "
"FROM song_stats s JOIN songs ON songs.filename = s.filename"
).fetchall()
arrs_cache = {}
out = {}
seconds = {}
for filename, arrangement, acc, played_at, secs, title, artist, arrs_json, genre in rows:
gkey = _genre_key(genre)
if not gkey:
continue
if filename not in arrs_cache:
try:
arrs_cache[filename] = json.loads(arrs_json) if arrs_json else None
except (TypeError, ValueError):
arrs_cache[filename] = None
instrument = _instrument_of(arrs_cache[filename], arrangement)
key = (instrument, gkey)
seconds[key] = seconds.get(key, 0.0) + (secs or 0.0)
acc = acc or 0.0
stub = out.setdefault(key, {}).get(filename)
if stub is None:
out[key][filename] = {
"filename": filename,
"title": title or filename,
"artist": artist or "",
"best_accuracy": acc,
"last_played_at": played_at,
}
else:
stub["best_accuracy"] = max(stub["best_accuracy"], acc)
stub["last_played_at"] = max(stub["last_played_at"] or "", played_at or "") or None
for stubs in out.values():
for stub in stubs.values():
acc = stub["best_accuracy"]
stub["best_accuracy"] = round(acc, 4)
stub["stars"], stub["next_star_at"] = _star_progress(acc, thresholds)
return out, seconds
def _star_progress(acc, thresholds):
"""(stars, next_star_at) — the one place the ascending-thresholds
assumption lives; _stars() and the passport stubs both use it."""
stars = sum(1 for t in thresholds if acc >= t)
next_at = next((t for t in thresholds if acc < t), None)
return stars, next_at
def _library_genres():
"""Distinct effective genres across the live library (the brochure rack)."""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT {_genre_expr(db)} AS g, COUNT(*) FROM songs GROUP BY g").fetchall()
by_key = {}
for genre, count in rows:
display = _genre_display(genre)
key = display.lower()
if not key:
continue
cur = by_key.get(key)
if cur: # case-variant duplicates collapse onto the first-seen casing
cur["songs_in_library"] += count
else:
by_key[key] = {"genre_key": key, "genre": display,
"songs_in_library": count}
return sorted(by_key.values(),
key=lambda r: (-r["songs_in_library"], r["genre_key"]))
def _genre_family(gkey):
"""First family whose keyword appears in the genre key (substring — MB's
vocabulary is open: 'metalcore' must hit the 'metal' family without an
exact alias). List order decides ambiguity: families are checked top to
bottom, so 'blues rock' lands on whichever of blues/rock is listed first."""
for fam in _state["passports_content"].get("families") or []:
if not isinstance(fam, dict):
continue
for kw in fam.get("match") or []:
if isinstance(kw, str) and kw and kw in gkey:
return fam.get("key")
return None
def _badge_requirement(gkey, instrument="guitar"):
cfg = _state["passports_content"]
req = dict(cfg.get("badge_requirement") or {})
req.setdefault("songs", 5)
req.setdefault("min_stars", 2)
# Exact per-genre override wins; otherwise the genre inherits its FAMILY's
# requirement — so 'death metal' / 'metalcore' passports carry the metal
# drill without curating every MB sub-genre by hand.
genres_cfg = cfg.get("genres") or {}
override = genres_cfg.get(gkey)
if not isinstance(override, dict):
family = _genre_family(gkey)
override = genres_cfg.get(family) if family else None
if isinstance(override, dict):
req.update(override)
# virtuoso_nodes: {instrument: [node_ids]} — a passport only carries its
# own instrument's drills. A flat list keeps meaning guitar (back-compat;
# virtuoso's drill content is guitar-first).
nodes = req.get("virtuoso_nodes") or []
if isinstance(nodes, dict):
nodes = nodes.get(instrument) or []
elif instrument != "guitar":
nodes = []
req["virtuoso_nodes"] = [n for n in nodes if isinstance(n, str)]
return req
def _drill_by_node():
doc = _load_json(_drill_file(), {})
if not isinstance(doc, dict):
return None, {}, {}
snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {}
by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {}
gold = snapshot.get("goldImprov") if isinstance(snapshot.get("goldImprov"), dict) else {}
return doc.get("received_at"), by_node, gold
def _merge_drill_nodes(old, new):
"""Gained-only merge of virtuoso byNode snapshots: a completion artifact
once relayed never un-earns via a stale snapshot (multi-browser races,
settings import, the once-per-session boot relay). Incoming wins the
descriptive fields; masteredAt / depth flips / keysCleared only grow."""
out = dict(old)
for node_id, incoming in new.items():
if not isinstance(incoming, dict):
continue
cur = out.get(node_id)
if not isinstance(cur, dict):
out[node_id] = incoming
continue
merged = dict(cur)
merged.update(incoming)
merged["masteredAt"] = cur.get("masteredAt") or incoming.get("masteredAt")
d_old = cur.get("depth") if isinstance(cur.get("depth"), dict) else {}
d_new = incoming.get("depth") if isinstance(incoming.get("depth"), dict) else {}
depth = dict(d_new)
for axis, val in d_old.items():
if val and not depth.get(axis):
depth[axis] = val
if depth:
merged["depth"] = depth
keys_old = cur.get("keysCleared") if isinstance(cur.get("keysCleared"), list) else []
keys_new = incoming.get("keysCleared") if isinstance(incoming.get("keysCleared"), list) else []
merged["keysCleared"] = keys_old + [k for k in keys_new if k not in keys_old]
out[node_id] = merged
return out
def _merge_gold(old, new):
"""Gained-only merge of goldImprov artifacts: a minted style never
un-mints via a stale relay; the FIRST artifact per style is kept."""
out = dict(old)
for style_id, art in (new or {}).items():
if isinstance(art, dict) and style_id not in out:
out[style_id] = art
return out
def _node_cleared(by_node, node_id):
"""A drill counts as cleared on real completion evidence: mastered, any
depth rung flipped true, or a key cleared (a top-tier clean pass in one
key virtuoso's first gained-only artifact, and an achievable Bronze
bar; the depth rungs additionally require a maxed speed tier)."""
entry = by_node.get(node_id)
if not isinstance(entry, dict):
return False
depth = entry.get("depth") if isinstance(entry.get("depth"), dict) else {}
keys = entry.get("keysCleared")
return (bool(entry.get("masteredAt"))
or any(bool(v) for v in depth.values())
or bool(isinstance(keys, list) and keys))
def _passports_view():
cfg = _state["passports_content"]
graded = set(cfg.get("graded_instruments") or [])
st = _career_state()
all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else []
played, played_seconds = _played_by_instrument_genre()
received_at, by_node, gold_improv = _drill_by_node()
instruments = {}
for inst in cfg.get("instruments") or []:
committed_at = (st["instruments"].get(inst) or {}).get("committed_at")
opened = st["passports"].get(inst)
opened = opened if isinstance(opened, dict) else {}
passports = []
for gkey, meta in sorted(opened.items(),
key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])):
meta = meta if isinstance(meta, dict) else {}
req = _badge_requirement(gkey, inst)
songs = list(played.get((inst, gkey), {}).values())
for s in songs:
s["qualifies"] = s["stars"] >= req["min_stars"]
songs.sort(key=lambda s: (not s["qualifies"], -s["stars"],
s["title"].lower()))
qualifying = sum(1 for s in songs if s["qualifies"])
required = req["virtuoso_nodes"]
cleared = [n for n in required if _node_cleared(by_node, n)]
is_graded = inst in graded
if not is_graded:
# Where the engine can't fairly grade the instrument's job
# (bass pocket, feel) the passport shows repertoire, never a
# false badge denial — the doc's shown-not-judged rule.
badge = "shown_not_judged"
elif qualifying >= req["songs"] and len(cleared) == len(required):
# Bronze is earned; GOLD upgrades it when a verified improv
# artifact exists for this genre's jam style. Virtuoso mints
# under raw STYLE_PALETTES ids ('punk', 'djent', 'disco', ...),
# which are mostly NOT family keys — so match in family space:
# the same keyword bucketing genres get ('punk' and 'punk
# rock' both bucket to 'rock'), with the exact key as a direct
# hit. Bronze remains a standalone win; gold never becomes an
# obligation.
fam = _genre_family(gkey)
gold = any(
s == gkey or (fam is not None and _genre_family(s) == fam)
for s in gold_improv
)
badge = "gold" if gold else "earned"
else:
badge = "in_progress"
# Practice invitation: the non-qualifying songs closest to the
# QUALIFYING bar (the badge ask), nearest first — invitation
# data, the UI voices it without meters.
thresholds = _state["content"]["star_accuracy_thresholds"]
bar = (thresholds[req["min_stars"] - 1]
if 0 < req["min_stars"] <= len(thresholds) else None)
nearest = [] if bar is None else sorted(
(s for s in songs if not s["qualifies"]),
key=lambda s: bar - s["best_accuracy"])[:3]
for s in nearest:
s["bar_at"] = bar
passports.append({
"genre_key": gkey,
"genre": meta.get("genre") or gkey,
"opened_at": meta.get("opened_at"),
"requirement": req,
"graded": is_graded,
"songs": songs,
"qualifying_count": qualifying,
"nearest": nearest,
# Honest hours odometer (Stage 5 post-cap): a true fact that
# only grows — never a target, never a meter.
"seconds_total": round(played_seconds.get((inst, gkey), 0.0), 1),
"drills": {"required": required, "cleared": cleared},
"badge": badge,
})
inst_gigs = [g for g in all_gigs if g.get("instrument") == inst]
for p in passports:
p["gigs"] = [g for g in inst_gigs if g.get("genre_key") == p["genre_key"]][-20:][::-1]
instruments[inst] = {"committed_at": committed_at, "passports": passports,
"gig_count": len(inst_gigs)}
return {
"config": {
"badge_requirement": cfg.get("badge_requirement") or {},
"graded_instruments": sorted(graded),
"instruments": list(cfg.get("instruments") or []),
# Career-side display names for virtuoso drill node ids.
"drill_labels": dict(cfg.get("drill_labels") or {}),
},
"instruments": instruments,
"genres": _library_genres(),
"drill_state": {"received_at": received_at},
}
def _gig_config():
cfg = _state["passports_content"].get("gig")
cfg = cfg if isinstance(cfg, dict) else {}
def _num(key, default, cast):
# Tuning data, not code: junk falls back instead of 500ing both gig
# endpoints, and a legitimate 0 (stakes_songs: 0) is respected.
val = cfg.get(key)
if isinstance(val, bool) or not isinstance(val, (int, float)):
return default
return cast(val)
return {
"min_songs": max(1, _num("min_songs", 3, int)),
"max_songs": max(1, _num("max_songs", 5, int)),
"stakes_songs": max(0, _num("stakes_songs", 2, int)),
"encore_accuracy": _num("encore_accuracy", 0.75, float),
}
def _current_venue():
"""Highest unlocked venue (the room you can book today)."""
stars_total, _, _ = _stars()
best = None
for v in _state["content"]["venues"]:
if stars_total >= v["star_threshold"]:
if best is None or v["star_threshold"] >= best["star_threshold"]:
best = v
return best
def _fill_genre_songs(gkey, exclude, limit):
"""Library songs of a genre to round out a gig — ANY song of the genre the
set hasn't already picked.
Was `_unplayed_genre_songs`, restricted to `filename NOT IN song_stats`.
That restriction created a hole: a song you'd played on a DIFFERENT
instrument's arrangement has a stats row, so it was excluded here — and it
lives in the played bucket for THAT instrument, not this passport's, so it
was excluded there too. It could never be gigged. A player with 137 metalcore
songs, all played on another instrument, got a 404 (reproduced). The player's
library is the pool; whether a song has stats on some other instrument has no
bearing on whether it can be in THIS gig.
Shuffled, so re-roll actually changes the set. The old version returned the
library's first N in table order every time, so re-roll was a no-op for any
set drawn from the filler (reproduced).
ponytail: full genre scan + python-side match + shuffle (a few ms at 7k
songs, single-user); push into SQL if propose ever feels slow.
"""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs"
).fetchall()
pool = [
{"filename": filename, "title": title or filename, "artist": artist or ""}
for filename, title, artist, genre in rows
if _genre_key(genre) == gkey and filename not in exclude
]
random.shuffle(pool) # re-roll must vary; free per call
return pool[:limit]
def _validate_pack_dir(pack_dir: Path):
"""Raise ValueError unless pack_dir holds a complete venue pack."""
manifest_path = pack_dir / "manifest.json"
if not manifest_path.is_file():
raise ValueError("pack has no manifest.json")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
loops = manifest.get("loops") or {}
for state in REQUIRED_LOOPS:
name = loops.get(state)
if not name or not PACK_FILENAME_RE.fullmatch(name):
raise ValueError(f"manifest is missing the '{state}' loop")
if not (pack_dir / name).is_file():
raise ValueError(f"loop file '{name}' missing from pack")
for name in (manifest.get("stingers") or {}).values():
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
raise ValueError(f"stinger file '{name}' invalid or missing")
for name in (manifest.get("intro") or {}).values():
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
raise ValueError(f"intro file '{name}' invalid or missing")
def _download_pack(venue_id, pack, progress):
"""Worker thread: stream → sha256 verify → extract → validate → swap in."""
log = _state["log"]
final_dir = _venue_dir(venue_id)
staging = Path(tempfile.mkdtemp(prefix=f"career-{venue_id}-",
dir=str(_state["venues_dir"])))
zip_path = staging / "pack.zip"
try:
digest = hashlib.sha256()
req = urllib.request.Request(pack["url"], headers={"User-Agent": "feedBack-career"})
with urllib.request.urlopen(req, timeout=60) as resp, open(zip_path, "wb") as out:
total = int(resp.headers.get("Content-Length") or pack.get("bytes") or 0)
progress["bytes_total"] = total
while True:
chunk = resp.read(DOWNLOAD_CHUNK)
if not chunk:
break
digest.update(chunk)
out.write(chunk)
progress["bytes_done"] += len(chunk)
if digest.hexdigest() != pack["sha256"]:
raise ValueError("sha256 mismatch — corrupt or tampered download")
extract_dir = staging / "pack"
extract_dir.mkdir()
with zipfile.ZipFile(zip_path) as zf:
for info in zf.infolist():
# Zip-slip guard: only flat, whitelisted names get extracted.
if info.is_dir():
continue
name = Path(info.filename).name
if name != info.filename or not PACK_FILENAME_RE.fullmatch(name):
raise ValueError(f"unexpected file in pack: {info.filename!r}")
with zf.open(info) as src, open(extract_dir / name, "wb") as dst:
shutil.copyfileobj(src, dst)
zip_path.unlink()
_validate_pack_dir(extract_dir)
if final_dir.exists():
shutil.rmtree(final_dir)
extract_dir.rename(final_dir)
progress["status"] = "done"
log.info("career: venue pack '%s' installed", venue_id)
except Exception as exc: # noqa: BLE001 — surface any failure to the UI
progress["status"] = "error"
progress["error"] = str(exc)
log.warning("career: venue pack '%s' download failed: %s", venue_id, exc)
finally:
shutil.rmtree(staging, ignore_errors=True)
def setup(app, context):
plugin_dir = Path(__file__).resolve().parent
_state["plugin_dir"] = plugin_dir
_state["content"] = json.loads((plugin_dir / "venues.json").read_text(encoding="utf-8"))
_state["venues_dir"] = (
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
_state["passports_content"] = json.loads(
(plugin_dir / "passports.json").read_text(encoding="utf-8"))
# Persisted career state (commitment / opened passports / drill snapshot)
# lives under CONFIG_DIR/career/ — declared in settings.server_files so it
# rides the settings export/import bundle. Packs stay out (they're media).
_state["state_dir"] = Path(context["config_dir"]) / PLUGIN_ID
_state["state_dir"].mkdir(parents=True, exist_ok=True)
_state["meta_db"] = context.get("meta_db")
_state["log"] = context.get("log") or _state["log"]
for v in _state["content"]["venues"]:
if _bundled(v["id"]):
_validate_pack_dir(_bundled_venue_dir(v["id"]))
@app.get(f"/api/plugins/{PLUGIN_ID}/state")
def get_state():
stars_total, per_song, star_detail = _stars()
venues = []
for v in _state["content"]["venues"]:
with _lock:
dl = dict(_state["downloads"].get(v["id"]) or {"status": "idle"})
venues.append({
"id": v["id"],
"name": v["name"],
"description": v.get("description", ""),
"star_threshold": v["star_threshold"],
"unlocked": stars_total >= v["star_threshold"],
"installed": _installed(v["id"]),
"bundled": _bundled(v["id"]),
"has_pack": _bundled(v["id"]) or bool(v.get("pack")),
"download": dl,
})
return {
"stars_total": stars_total,
"stars_per_song": per_song,
"star_detail": star_detail,
"star_accuracy_thresholds": _state["content"]["star_accuracy_thresholds"],
"venues": venues,
}
@app.get(f"/api/plugins/{PLUGIN_ID}/passports")
def get_passports():
with _lock:
return _passports_view()
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/commit")
def commit_instrument(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
with _lock:
st = _career_state()
entry = st["instruments"].setdefault(inst, {})
# Idempotent: the wax seal is pressed once; re-commits keep the
# original date (only-gained-never-lost).
if not entry.get("committed_at"):
entry["committed_at"] = _now_iso()
_save_json(_state_file(), st)
return {"ok": True, "instrument": inst,
"committed_at": entry["committed_at"]}
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/open")
def open_passport(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
with _lock:
st = _career_state()
# Opening a passport implies the instrument commitment (permissive
# server, ceremony ordering is the UI's job).
st["instruments"].setdefault(inst, {}).setdefault(
"committed_at", _now_iso())
genres = st["passports"].setdefault(inst, {})
if gkey not in genres:
genres[gkey] = {"genre": genre, "opened_at": _now_iso()}
_save_json(_state_file(), st)
return {"ok": True, "instrument": inst, "passport": genres[gkey]}
@app.post(f"/api/plugins/{PLUGIN_ID}/drill-state")
def post_drill_state(body: dict = Body(...)):
# The relayed virtuoso.progress snapshot (career's screen.js listens to
# the virtuoso:progress bus event and forwards the localStorage doc).
# Only the fields the badge check reads are kept.
if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict):
raise HTTPException(400, "Expected a progress snapshot with byNode.")
# Bound the INCOMING snapshot before the merge — the gained-only merge
# drops junk entries, which must not become a size-guard bypass.
if len(json.dumps(body["byNode"])) > DRILL_SNAPSHOT_MAX_BYTES:
raise HTTPException(413, "Snapshot too large.")
gold_in = body.get("goldImprov", {})
if not isinstance(gold_in, dict):
# A relay bug must be LOUD, not a silent 200 that drops gold.
raise HTTPException(400, "goldImprov must be an object keyed by style id.")
# Keep only plausible artifacts: a dict that names its verifier —
# an empty {} must not mint an evidence-free gold.
gold_in = {k: v for k, v in gold_in.items()
if isinstance(v, dict) and v.get("verifier")}
# Same pre-merge bound byNode gets: the gained-only merge dropping
# junk must not become a size-guard bypass (nor lock-held CPU burn).
if len(json.dumps(gold_in)) > DRILL_SNAPSHOT_MAX_BYTES:
raise HTTPException(413, "Snapshot too large.")
with _lock:
_, existing, existing_gold = _drill_by_node()
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
"byNode": _merge_drill_nodes(existing, body["byNode"]),
"goldImprov": _merge_gold(existing_gold, gold_in)}
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES:
raise HTTPException(413, "Snapshot too large.")
_save_json(_drill_file(), {"received_at": _now_iso(),
"snapshot": snapshot})
return {"ok": True}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/prepare")
def prepare_gig(body: dict = Body(...)):
"""Unpack every song of the set BEFORE the gig starts.
A feedpak is a zip: the first play of one pays for its extraction into
sloppak_cache. Inside a set that cost landed BETWEEN songs the player
finished a number and then sat waiting for the next one to unpack, mid-
gig. A set is a known list up front, so extract it all while the player
is still looking at the poster.
Idempotent and cheap on a warm cache: resolve_source_dir() returns the
already-unpacked dir without rewriting it. Best-effort per song one
bad feedpak must not block the set from starting (the play itself will
surface the error, exactly as it does outside a gig).
"""
raw = (body or {}).get("songs")
# A str is iterable: without the list check, "abc" would prepare three
# one-character "songs". Cap the count too — this endpoint unpacks zips,
# so an oversized list is real work, and a setlist is a handful of songs.
if not isinstance(raw, list):
return {"ok": True, "prepared": 0, "failed": []}
files = [f for f in raw if isinstance(f, str) and f.strip()][:MAX_GIG_SONGS]
if not files:
return {"ok": True, "prepared": 0, "failed": []}
# .get, not []: a host that doesn't hand us the resolvers (or has no
# library configured) must degrade to "extract lazily, as before" — this
# is an optimisation, and it is never allowed to be the thing that stops
# a gig from starting.
get_dlc = context.get("get_dlc_dir")
get_cache = context.get("get_sloppak_cache_dir")
dlc_root = get_dlc() if callable(get_dlc) else None
cache_root = get_cache() if callable(get_cache) else None
if dlc_root is None or cache_root is None:
return {"ok": False, "prepared": 0, "failed": files, "error": "no library"}
root = Path(dlc_root)
prepared, failed = 0, []
for fn in files:
# CONTAINMENT FIRST. resolve_source_dir() does a bare
# `dlc_root / filename` with no guard, so a crafted `../..` would
# walk straight out of the library. Every other filename-bound
# handler validates through _resolve_dlc_path; so does this one.
safe = _resolve_dlc_path(root, fn)
if safe is None:
_state["log"].warning("career: gig pre-extract rejected unsafe path %r", fn)
failed.append(fn)
continue
try:
sloppak.resolve_source_dir(fn, root, Path(cache_root))
prepared += 1
except Exception as exc: # noqa: BLE001 — one bad pak can't sink the set
_state["log"].warning("career: gig pre-extract failed for %s: %s", fn, exc)
failed.append(fn)
return {"ok": True, "prepared": prepared, "failed": failed}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
def propose_gig(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
cfg = _gig_config()
try:
size = int((body or {}).get("size") or 4)
except (TypeError, ValueError):
raise HTTPException(400, "size must be a number.")
size = max(cfg["min_songs"], min(cfg["max_songs"], size))
played, _seconds = _played_by_instrument_genre()
stubs = list(played.get((inst, gkey), {}).values())
req = _badge_requirement(gkey, inst)
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
rest = [s for s in stubs if s["stars"] < req["min_stars"]]
# The set: mostly songs you own, plus a couple of stakes songs near
# the bar; a young passport fills from unplayed genre songs so the
# first gig is how stubs start. random per call = free re-roll.
random.shuffle(qualifying)
rest.sort(key=lambda s: -s["best_accuracy"])
qtaken = max(1, size - cfg["stakes_songs"])
picks = qualifying[:qtaken]
for s in rest:
if len(picks) >= size:
break
picks.append(s)
# Surplus qualifying songs backfill a short set — a mature passport
# with no near-bar songs left must still fill the bill. Offset by how
# many QUALIFYING songs were taken, not len(picks): rest's stakes
# additions would otherwise skip eligible qualifying songs entirely.
for s in qualifying[qtaken:]:
if len(picks) >= size:
break
picks.append(s)
if len(picks) < size:
exclude = {s["filename"] for s in picks}
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks)))
if not picks:
raise HTTPException(404, "No songs of this genre in the library.")
venue = _current_venue()
return {
"instrument": inst,
"genre": genre,
"genre_key": gkey,
"venue_id": venue["id"] if venue else None,
"venue_name": venue["name"] if venue else "",
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
"artist": s.get("artist") or ""} for s in picks[:size]],
}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
def log_gig(body: dict = Body(...)):
# Called by the runner ONLY when the set completed — an abandoned set
# never logs (no fail state; the gig you finished is the gig you
# played). Accuracies come from song_stats, freshly written by the
# set's own plays.
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
venue_id = str((body or {}).get("venue_id") or "")
songs = (body or {}).get("songs")
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
if venue_id and (not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None):
raise HTTPException(400, "Unknown venue.")
if (not isinstance(songs, list) or not songs or len(songs) > 8
or not all(isinstance(f, str) and f.strip() for f in songs)):
raise HTTPException(400, "songs must be 1-8 filenames.")
db = _state["meta_db"]
entries = []
accuracies = []
for filename in songs:
title = filename
accuracy = None
if db is not None:
# The NEWEST row is the set's own just-recorded play — a
# MAX(last_accuracy) across arrangements would happily log a
# stale higher score from another instrument's old session.
row = db.conn.execute(
"SELECT last_accuracy FROM song_stats WHERE filename = ? "
"ORDER BY last_played_at DESC LIMIT 1",
(filename,)).fetchone()
if row and row[0] is not None:
accuracy = round(float(row[0]), 4)
accuracies.append(accuracy)
trow = db.conn.execute(
"SELECT title FROM songs WHERE filename = ?", (filename,)).fetchone()
if trow and trow[0]:
title = trow[0]
entries.append({"filename": filename, "title": title, "accuracy": accuracy})
# Encore needs the WHOLE set scored at the bar — one scored song must
# not earn an encore for a set that was 4/5 unheard.
encore = (len(accuracies) == len(songs) and
sum(accuracies) / len(accuracies) >= _gig_config()["encore_accuracy"])
gig = {
"at": _now_iso(),
"venue_id": venue_id or None,
"instrument": inst,
"genre": genre,
"genre_key": gkey,
"songs": entries,
"encore": encore,
}
with _lock:
st = _career_state()
if not isinstance(st.get("gigs"), list):
st["gigs"] = []
st["gigs"].append(gig)
# ponytail: hard cap — nothing reads past the last 20 per
# passport; the state file must not grow (and export) forever.
st["gigs"] = st["gigs"][-500:]
_save_json(_state_file(), st)
return {"ok": True, "gig": gig}
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
def start_download(venue_id: str):
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
if venue is None:
raise HTTPException(404, "Unknown venue.")
pack = venue.get("pack")
if not pack:
raise HTTPException(404, "No pack published for this venue yet.")
stars_total, _, _ = _stars()
if stars_total < venue["star_threshold"]:
raise HTTPException(403, "Venue not unlocked yet.")
with _lock:
running = _state["downloads"].get(venue_id)
if running and running["status"] == "running":
raise HTTPException(409, "Download already running.")
progress = {"status": "running", "bytes_done": 0,
"bytes_total": pack.get("bytes") or 0, "error": None}
_state["downloads"][venue_id] = progress
threading.Thread(target=_download_pack, args=(venue_id, pack, progress),
name=f"career-pack-{venue_id}", daemon=True).start()
return {"ok": True}
@app.delete(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}")
def delete_pack(venue_id: str):
if not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None:
raise HTTPException(404, "Unknown venue.")
with _lock:
running = _state["downloads"].get(venue_id)
if running and running["status"] == "running":
raise HTTPException(409, "Download in progress.")
_state["downloads"].pop(venue_id, None)
shutil.rmtree(_venue_dir(venue_id), ignore_errors=True)
return {"ok": True}
@app.get(f"/api/plugins/{PLUGIN_ID}/venues/{{venue_id}}/{{filename}}")
async def get_pack_file(venue_id: str, filename: str):
if not VENUE_ID_RE.fullmatch(venue_id) or not PACK_FILENAME_RE.fullmatch(filename):
raise HTTPException(404, "Not found.")
pack_dir = _pack_dir(venue_id)
path = pack_dir / filename
# Defense-in-depth beyond the regexes (same recipe as highway_3d):
# the resolved path must stay inside the selected pack dir.
try:
resolved = path.resolve()
resolved.relative_to(pack_dir.resolve())
except (OSError, ValueError):
raise HTTPException(404, "Not found.")
if not resolved.is_file():
raise HTTPException(404, "Not found.")
media = {"mp4": "video/mp4", "webm": "video/webm", "mp3": "audio/mpeg",
"json": "application/json"}[resolved.suffix.lstrip(".").lower()]
return FileResponse(
resolved,
media_type=media,
# Pack files are immutable per version, but a re-download after a
# pack update overwrites in place — no-cache + ETag revalidation
# keeps browsers honest for the price of a 304.
headers={"Cache-Control": "no-cache",
"X-Content-Type-Options": "nosniff"},
)
-43
View File
@@ -1,43 +0,0 @@
<div class="max-w-5xl mx-auto px-4 py-6">
<div class="flex items-end justify-between flex-wrap gap-3 mb-1">
<h1 class="text-2xl font-bold text-white">Career</h1>
<div id="career-stars-summary" class="text-sm text-gray-400"></div>
</div>
<div class="career-tabs" role="tablist">
<button class="career-tab" data-career-tab="venues" role="tab" id="career-tab-btn-venues" aria-controls="career-tab-venues">Venues</button>
<button class="career-tab" data-career-tab="passports" role="tab" id="career-tab-btn-passports" aria-controls="career-tab-passports">Passports</button>
</div>
<div id="career-tab-venues" role="tabpanel" aria-labelledby="career-tab-btn-venues">
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
<div id="career-progress-wrap" class="mb-6">
<div class="career-bar-track">
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
</div>
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
</div>
<div id="career-venues" class="career-venues"></div>
<div class="mt-8">
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
<div id="career-star-summary" class="text-xs text-gray-400"></div>
</div>
<div id="career-star-list" class="career-star-list"></div>
</div>
</div>
<div id="career-tab-passports" class="hidden" role="tabpanel" aria-labelledby="career-tab-btn-passports">
<p class="text-sm text-gray-400 mb-4">Commit to an instrument, pick a genre, and stamp your way to its badge — five ★★ songs mint a Bronze. Your passport wall is who you are as a musician.</p>
<div id="pp-instruments" class="pp-instruments"></div>
<div id="pp-closest" class="mt-4"></div>
<div id="pp-shelf-wrap" class="mt-5">
<div id="pp-shelf" class="pp-shelf"></div>
</div>
<div id="pp-rack-wrap" class="mt-8">
<h2 class="text-lg font-semibold text-white mb-1">Explore next</h2>
<p class="text-xs text-gray-500 mb-3">More genres, whenever you want them — your wall is complete as it is.</p>
<div id="pp-rack" class="pp-rack"></div>
</div>
</div>
</div>
<div id="pp-overlay" class="pp-overlay hidden"></div>
File diff suppressed because it is too large Load Diff
-32
View File
@@ -1,32 +0,0 @@
<!-- Career plugin — data panel. Exists so the passport/drill state declared in
settings.server_files has a visible home in Settings; nothing to configure. -->
<div class="text-sm text-gray-300 space-y-2">
<p><strong>Career</strong> computes stars and genre badges from your play
stats — they are never stored, so there is nothing to back up or reset.</p>
<p class="text-gray-400">What <em>is</em> saved server-side: your instrument
commitments, opened genre passports, and the practice-drill snapshot the
Virtuoso plugin reports. These ride along in
<em>Settings → Export</em> automatically.</p>
</div>
<hr class="border-gray-800 my-3">
<div class="space-y-3 text-sm">
<label class="flex items-center justify-between gap-4">
<span>
<span class="text-gray-200 font-medium">Crowd sound reactions</span>
<span class="block text-xs text-gray-500">Cheers when the crowd's mood rises, boos when it drops. Uses each venue's own recordings.</span>
</span>
<input type="checkbox" id="career-sfx-toggle" class="accent-cyan-500 w-4 h-4">
</label>
</div>
<script>
(function () {
'use strict';
var KEY = 'feedBack-venue-crowd-sfx';
var box = document.getElementById('career-sfx-toggle');
if (!box) return;
try { box.checked = localStorage.getItem(KEY) === 'on'; } catch (e) { /* ok */ }
box.addEventListener('change', function () {
try { localStorage.setItem(KEY, box.checked ? 'on' : 'off'); } catch (e) { /* ok */ }
});
}());
</script>
-249
View File
@@ -1,249 +0,0 @@
// Passport UI pure-logic tests: load screen.js in a bare vm window and
// exercise the __careerPassportTest seam (no DOM beyond stubs, no network).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load(seed) {
const store = Object.assign({}, seed);
const window = {
console,
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
},
document: {
readyState: 'complete',
getElementById: () => null,
querySelectorAll: () => [],
addEventListener: () => {},
},
notifications: [],
};
window.window = window;
window.globalThis = window;
window.fbNotify = { show: (n) => window.notifications.push(n) };
const context = vm.createContext(window);
// `document` and `localStorage` resolve as bare names inside the IIFE.
context.document = window.document;
context.localStorage = window.localStorage;
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'career/screen.js' });
return window;
}
test('module loads (and boots) in a bare vm window', () => {
const w = load();
assert.equal(typeof w.__careerPassportTest.ppKey, 'function');
});
test('ppKey normalizes case and whitespace', () => {
const { ppKey } = load().__careerPassportTest;
assert.equal(ppKey(' Blues Rock '), 'blues rock');
assert.equal(ppKey('FUNK'), 'funk');
assert.equal(ppKey(''), '');
assert.equal(ppKey(null), '');
});
test('ppJitter is deterministic and bounded', () => {
const { ppJitter } = load().__careerPassportTest;
assert.equal(ppJitter('blues', 8), ppJitter('blues', 8));
for (const seed of ['blues', 'funk', 'jazz', 'metal']) {
const j = ppJitter(seed, 8);
assert.ok(j >= -8 && j <= 8, `${seed}${j}`);
}
assert.notEqual(ppJitter('blues', 8), ppJitter('funk', 8));
});
test('detectNewBadges notifies once per badge, never after it is seen', () => {
const w = load();
const t = w.__careerPassportTest;
const view = {
instruments: {
guitar: {
passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' },
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress' },
],
},
},
};
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
assert.match(w.notifications[0].message, /Blues/);
// Same view again in the same session: no duplicate notification.
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
// Seen (slam played) → a fresh session stays quiet too.
t.markBadgeSeen('guitar', 'blues');
// JSON-compare: vm objects carry a foreign Object prototype.
assert.equal(JSON.stringify(t.seenBadges()), '{"guitar/blues":1}');
// Fresh session (new vm, empty notify cache) with the badge already seen:
// detection must stay silent.
const w2 = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 0);
});
test('a new badge triggers the crowd celebrate() exactly once', () => {
const w = load();
let calls = 0;
w.v3VenueCrowd = { celebrate: () => { calls += 1; } };
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
w.__careerPassportTest.detectNewBadges(view);
assert.equal(calls, 1);
// Same session, same view: no re-celebration.
w.__careerPassportTest.detectNewBadges(view);
assert.equal(calls, 1);
});
test('ceremony degrades when the crowd layer is absent or throws', () => {
const w = load();
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
// No v3VenueCrowd at all (already exercised elsewhere, explicit here).
w.__careerPassportTest.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
// celebrate() throwing must not break detection.
const w2 = load();
w2.v3VenueCrowd = { celebrate: () => { throw new Error('no pack'); } };
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 1);
});
test('seenBadges tolerates corrupt stored values', () => {
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
const w = load({ 'feedBack-career-badges-seen': bad });
const t = w.__careerPassportTest;
assert.equal(JSON.stringify(t.seenBadges()), '{}', `stored ${bad}`);
// And detection still works on top of the recovered empty state.
t.detectNewBadges({ instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } });
assert.equal(w.notifications.length, 1, `stored ${bad}`);
}
});
test('fmtHours: silent under a minute, minutes under an hour, tenths after', () => {
const { fmtHours } = load().__careerPassportTest;
assert.equal(fmtHours(0), '');
assert.equal(fmtHours(59), '');
assert.equal(fmtHours(60), '1 min');
assert.equal(fmtHours(1800), '30 min');
assert.equal(fmtHours(3600), '1 h');
assert.equal(fmtHours(51120), '14.2 h');
assert.equal(fmtHours(null), '');
assert.equal(fmtHours('junk'), '');
});
test('ppFillFraction: song progress toward the bar, in-progress only', () => {
const { ppFillFraction } = load().__careerPassportTest;
const p = (badge, q, songs) => ({ badge, qualifying_count: q, requirement: { songs } });
assert.equal(ppFillFraction(p('in_progress', 3, 5)), 0.6);
assert.equal(ppFillFraction(p('in_progress', 0, 5)), 0);
assert.equal(ppFillFraction(p('in_progress', 7, 5)), 1); // clamped
assert.equal(ppFillFraction(p('earned', 5, 5)), 0); // no fill once earned
assert.equal(ppFillFraction(p('shown_not_judged', 3, 5)), 0);
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
assert.equal(ppFillFraction(null), 0);
});
test('careerTotals / wall + dash card stay absent without commitment', () => {
const w = load();
const t = w.__careerPassportTest;
// No _pp at all → null; committed-less view → null (absent-not-empty).
assert.equal(t.careerTotals(), null);
t.setView({ config: { instruments: ['guitar'] },
instruments: { guitar: { committed_at: null, passports: [] } } });
assert.equal(t.careerTotals(), null);
// Committed but zero passports opened: still absent (no zero-wall).
t.setView({ config: { instruments: ['guitar'] },
instruments: { guitar: { committed_at: 'x', passports: [] } } });
assert.equal(t.careerTotals(), null);
// Committed with an earned badge + hours → totals aggregate.
t.setView({ config: { instruments: ['guitar', 'bass'] },
instruments: {
guitar: { committed_at: 'x', passports: [
{ badge: 'earned', seconds_total: 3600, genre: 'Blues', genre_key: 'blues' },
{ badge: 'in_progress', seconds_total: 120, genre: 'Funk', genre_key: 'funk',
qualifying_count: 4, requirement: { songs: 5, min_stars: 2 } }] },
bass: { committed_at: null, passports: [] },
} });
const totals = t.careerTotals();
assert.equal(totals.badges, 1);
assert.equal(totals.seconds, 3720);
assert.equal(totals.walls.length, 1);
});
test('gig runner lifecycle: advance on ended, abandon on dead-queue stop', () => {
const w = load();
const t = w.__careerPassportTest;
let remaining = 1;
w.feedBack = { playQueue: { remaining: () => remaining, active: () => remaining > 0 } };
t.setGigRun({
songs: [{ filename: 'a', title: 'A' }, { filename: 'b', title: 'B' }],
venue_id: null, genre: 'Soul', genre_key: 'soul', instrument: 'guitar', idx: 0,
});
// First song ends, one remains → the strip advances, no completion.
t.onGigSongEnded();
assert.equal(t.getGigRun().idx, 1);
// Stop while the queue is still active (end-of-song teardown) → run survives.
t.onGigSongStop();
assert.notEqual(t.getGigRun(), null);
// User quits: queue cleared → stop with a dead queue abandons (no log).
remaining = 0;
t.onGigSongStop();
assert.equal(t.getGigRun(), null);
});
test('a gold upgrade notifies even when the bronze moment was already seen', () => {
// Bronze seen under the legacy un-suffixed id; the badge then turns gold.
const w = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
const t = w.__careerPassportTest;
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'gold' }] } } };
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
assert.match(w.notifications[0].title, /Gold/);
// Same session: no duplicate.
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
// Gold slam seen → fresh session stays silent.
t.markBadgeSeen('guitar', 'blues', 'gold');
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(t.seenBadges()) });
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 0);
});
test('a gold slam marks the bronze moment seen too — never both ceremonies', () => {
const w = load();
const t = w.__careerPassportTest;
t.markBadgeSeen('guitar', 'blues', 'gold');
const seen = JSON.parse(JSON.stringify(t.seenBadges()));
assert.equal(seen['guitar/blues@gold'], 1);
assert.equal(seen['guitar/blues'], 1);
// A later view where the badge reads 'earned' (e.g. gold state lost
// server-side) must not replay the bronze ceremony.
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(seen) });
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 0);
});
test('careerTotals counts gold badges on the wall', () => {
const t = load().__careerPassportTest;
t.setView({
config: { instruments: ['guitar'] },
instruments: { guitar: { committed_at: 1, gig_count: 0, passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'gold', seconds_total: 60 },
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress', seconds_total: 0 },
] } },
});
const totals = t.careerTotals();
assert.equal(totals.badges, 1);
assert.equal(totals.walls[0].earned[0].badge, 'gold');
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,8 +0,0 @@
{
"venue": "arena",
"version": 1,
"loops": {"bored": "bored.mp4", "neutral": "neutral.mp4", "engaged": "engaged.mp4", "ecstatic": "ecstatic.mp4"},
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"},
"intro": {"video": "intro.mp4", "audio": "arena-ambience.mp3"},
"sfx": {"up": "sfx-up.mp3", "down": "sfx-down.mp3"}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,22 +0,0 @@
{
"venue": "bar",
"version": 1,
"loops": {
"bored": "bored.mp4",
"neutral": "neutral.mp4",
"engaged": "engaged.mp4",
"ecstatic": "ecstatic.mp4"
},
"stingers": {
"clap": "clap.mp4",
"cheer": "cheer.mp4"
},
"intro": {
"video": "intro.mp4",
"audio": "bar-ambience.mp3"
},
"sfx": {
"up": "sfx-up.mp3",
"down": "sfx-down.mp3"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,8 +0,0 @@
{
"venue": "club",
"version": 1,
"loops": {"bored": "bored.mp4", "neutral": "neutral.mp4", "engaged": "engaged.mp4", "ecstatic": "ecstatic.mp4"},
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"},
"intro": {"video": "intro.mp4", "audio": "club-ambience.mp3"},
"sfx": {"up": "sfx-up.mp3", "down": "sfx-down.mp3"}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
-30
View File
@@ -1,30 +0,0 @@
{
"star_accuracy_thresholds": [
0.6,
0.75,
0.85
],
"venues": [
{
"id": "bar",
"name": "The Dive Bar",
"description": "Sticky floors, a dozen regulars, and a PA that has seen better decades.",
"star_threshold": 0,
"pack": null
},
{
"id": "club",
"name": "Velvet Room",
"description": "A proper club stage. People actually came to hear you.",
"star_threshold": 50,
"pack": null
},
{
"id": "arena",
"name": "Feedback Arena",
"description": "Ten thousand seats. Try not to think about it.",
"star_threshold": 150,
"pack": null
}
]
}
+6 -162
View File
@@ -878,146 +878,6 @@ function createFolderSurface(cfg) {
var _dragRafId = null;
var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50;
// ── Windowed song lists ─────────────────────────────────────────────
// A song list used to render EVERY song it held. On a flat 50,944-song
// library that is one <div> with 50,938 children and ~1.3 MILLION DOM nodes
// (~25 per row) — ~4.2 GB of renderer RSS, for a screen the user may not
// even be looking at. It also poisons unrelated code: any
// `document.querySelector` miss anywhere in the app must walk that whole
// tree, which is how song_preview's per-frame menu check ended up eating
// ~50% of the renderer and dropping the app to 2.7 fps (feedBack#965).
//
// So render only what is on screen. Rows are uniform height (and grid cards
// uniform size), so the window is pure arithmetic — no per-row observers.
// Off-window rows are represented by padding on the list itself rather than
// spacer elements: a spacer <div> would become a grid ITEM in grid view and
// shift the columns, whereas padding works identically for both layouts.
var VIRTUAL_MIN = 200; // below this, render everything — no behaviour change
var VIRTUAL_BUFFER = 6; // rows kept rendered above/below the viewport
var _virtualCleanups = [];
var _virtualLists = []; // repaint fns, one per live windowed list
// Which slice of the list is on screen. Pure arithmetic — kept separate from
// the DOM so it can be tested directly (see tests/virtual_list.test.js).
//
// top : list's offset relative to the scroller viewport's top. NEGATIVE
// once the user has scrolled the list's start above the fold.
// rows : total ROWS (grid packs `perRow` songs into one row; list view is 1)
//
// Returns the song index range [start, end) to render, plus how many ROWS of
// padding stand in for the songs above and below it.
function _visibleWindow(top, viewportH, itemH, perRow, rows, total) {
if (!(itemH > 0) || !(rows > 0)) return { start: 0, end: total, padRowsTop: 0, padRowsBottom: 0 };
var firstRow = Math.max(0, Math.floor(-top / itemH) - VIRTUAL_BUFFER);
var lastRow = Math.min(rows, Math.ceil((-top + viewportH) / itemH) + VIRTUAL_BUFFER);
// Scrolled entirely past the list (either direction): keep one row alive
// rather than emptying it, so the padding math stays anchored.
if (lastRow <= firstRow) {
firstRow = Math.min(firstRow, rows - 1);
lastRow = firstRow + 1;
}
return {
start: firstRow * perRow,
end: Math.min(total, lastRow * perRow),
padRowsTop: firstRow,
padRowsBottom: Math.max(0, rows - lastRow),
};
}
function _clearVirtualLists() {
_virtualCleanups.forEach(function (fn) { try { fn(); } catch (_) {} });
_virtualCleanups = [];
_virtualLists = [];
}
// Fill `list` with `songs`, windowed when the list is big enough to matter.
// `make(song)` builds one row/card.
function _fillSongList(list, songs, make) {
var sorted = _sortSongs(songs);
if (sorted.length <= VIRTUAL_MIN) {
sorted.forEach(function (s) { list.appendChild(make(s)); });
return;
}
var scroller = _getScrollEl();
var basePadTop = parseFloat(window.getComputedStyle(list).paddingTop) || 0;
var basePadBot = parseFloat(window.getComputedStyle(list).paddingBottom) || 0;
// Measure one real row once — no hardcoded row height to drift out of
// sync with the CSS. (The list is shown before it is populated, so this
// measures a laid-out row, not a zero-height one.)
var probe = make(sorted[0]);
probe.style.visibility = 'hidden';
list.appendChild(probe);
var probeRect = probe.getBoundingClientRect();
var rowH = probeRect.height || 44;
var cardW = probeRect.width || 150;
list.removeChild(probe);
var GRID_GAP = 12; // matches the grid's `gap:12px`
var raf = 0, lastStart = -1, lastEnd = -1;
// Recomputed on EVERY paint, not captured once: a window resize changes
// the grid's column count, and therefore the row count and the height of
// the padding standing in for off-window rows. paint() runs on resize, so
// stale metrics would slice the wrong songs and mis-size the list.
function metrics() {
var perRow = 1, itemH = rowH;
if (_view === 'grid') {
perRow = Math.max(1, Math.floor((list.clientWidth + GRID_GAP) / (cardW + GRID_GAP)));
itemH = rowH + GRID_GAP;
}
return { perRow: perRow, itemH: itemH, rows: Math.ceil(sorted.length / perRow) };
}
function paint() {
raf = 0;
// Collapsed (display:none) or detached: nothing to paint, and don't
// pay for layout on every scroll tick of a section nobody can see.
// Forget the last window so re-showing repaints from scratch against
// the new position rather than short-circuiting on a stale memo.
if (!list.isConnected || list.offsetParent === null) {
lastStart = -1; lastEnd = -1;
return;
}
var m = metrics();
// Where the list sits relative to the scroller's viewport.
var top = list.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
var vh = scroller.clientHeight || window.innerHeight;
var w = _visibleWindow(top, vh, m.itemH, m.perRow, m.rows, sorted.length);
if (w.start === lastStart && w.end === lastEnd) return; // nothing moved
lastStart = w.start; lastEnd = w.end;
var frag = document.createDocumentFragment();
for (var i = w.start; i < w.end; i++) frag.appendChild(make(sorted[i]));
list.textContent = '';
list.style.paddingTop = (basePadTop + w.padRowsTop * m.itemH) + 'px';
list.style.paddingBottom = (basePadBot + w.padRowsBottom * m.itemH) + 'px';
list.appendChild(frag);
}
function schedule() { if (!raf) raf = window.requestAnimationFrame(paint); }
scroller.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
// Expanding or collapsing ANY section moves every list below it. Those
// lists' windows are computed from their position, so they must repaint
// too — otherwise they keep the window from their old position and show
// blank padding where songs should be until the user happens to scroll.
_virtualLists.push(schedule);
_virtualCleanups.push(function () {
scroller.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
if (raf) window.cancelAnimationFrame(raf);
});
paint();
}
// Re-window every live list — call after anything that can move them
// vertically (a folder expanding/collapsing, a section being shown).
function _repaintVirtualLists() {
_virtualLists.forEach(function (fn) { try { fn(); } catch (_) {} });
}
function _getScrollEl() {
var el = _treeEl();
while (el && el !== document.documentElement) {
@@ -1299,8 +1159,8 @@ function createFolderSurface(cfg) {
var _listPopulated = open;
function _populateList() {
_fillSongList(list, folder.songs, function (s) {
return _view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path);
_sortSongs(folder.songs).forEach(function (s) {
list.appendChild(_view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path));
});
(folder.children || []).forEach(function (child) {
childrenWrap.appendChild(_folderSection(child, depth + 1));
@@ -1335,18 +1195,12 @@ function createFolderSurface(cfg) {
hdr.addEventListener('click', function () {
if (_query()) return;
var nowOpen = content.style.display === 'none';
// Show BEFORE populating: a windowed list measures a real row and the
// scroller viewport, and both are zero while display:none.
content.style.display = nowOpen ? '' : 'none';
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
content.style.display = nowOpen ? '' : 'none';
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
if (nowOpen) _openFolders.add(folder.path);
else _openFolders.delete(folder.path);
_storeJSON('open', [..._openFolders]);
// This toggle moved everything below it — re-window the other lists,
// and re-window THIS one if it was already populated (its saved
// window was computed at its old position).
_repaintVirtualLists();
});
wrap.appendChild(hdr); wrap.appendChild(content);
@@ -1391,8 +1245,8 @@ function createFolderSurface(cfg) {
}
var _populated = _unsortedOpen;
function _populate() {
_fillSongList(list, songs, function (s) {
return _view === 'grid' ? _songCard(s, '') : _songRow(s, '');
_sortSongs(songs).forEach(function (s) {
list.appendChild(_view === 'grid' ? _songCard(s, '') : _songRow(s, ''));
});
}
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
@@ -1401,12 +1255,10 @@ function createFolderSurface(cfg) {
hdr.addEventListener('click', function () {
if (_query()) return;
_unsortedOpen = list.style.display === 'none';
// Show BEFORE populating — see the folder toggle above.
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
_store(cfg.unsortedKey, String(_unsortedOpen));
_repaintVirtualLists(); // this toggle moved every list below it
});
wrap.appendChild(hdr); wrap.appendChild(list);
@@ -1488,10 +1340,6 @@ function createFolderSurface(cfg) {
// ── Render ──────────────────────────────────────────────────────────
function _render() {
_hoveredFolder = null; // DOM is rebuilt; discard any stale reference
// Drop the scroll listeners of the previous render's windowed lists —
// their `list` nodes are about to be detached, and a surviving listener
// would keep painting into orphaned DOM (and leak on every re-render).
_clearVirtualLists();
var treeEl = _treeEl();
if (!treeEl) return;
var data = _filtered();
@@ -1603,7 +1451,6 @@ function createFolderSurface(cfg) {
// ── Unload (lib surface) ────────────────────────────────────────────
function _unload() {
_clearVirtualLists(); // don't leave scroll listeners behind on teardown
if (!cfg.searchInputId) return;
var el = _el(cfg.searchInputId);
if (el) el.style.maxWidth = '';
@@ -1707,8 +1554,6 @@ function createFolderSurface(cfg) {
init: _init,
onScreenChanged: _onScreenChanged,
render: _render,
// Pure window arithmetic, exposed for tests (no DOM needed).
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
};
}
@@ -1811,7 +1656,6 @@ if (!window.__folderLibraryLib) {
window.folderLibrary = {
load: function (force) { return _lib.load(force); },
unload: function () { _lib.unload(); },
__test: _lib.__test,
};
// Auto-load if folder view was already active when this script was injected.
@@ -1,165 +0,0 @@
// Windowed song lists (feedBack#965).
//
// A song list used to render EVERY song. On a flat 50,944-song library that is
// one div with 50,938 children and ~1.3 MILLION DOM nodes (~25 per row) —
// ~4.2 GB of renderer RSS, for a screen the user may not even be looking at. It
// also poisoned unrelated code: any `document.querySelector` miss anywhere in
// the app had to walk that whole tree.
//
// _visibleWindow is the arithmetic that decides which slice is on screen. If it
// is wrong the list silently shows the wrong songs, or scrolls to the wrong
// place, so it is tested directly — the DOM glue around it is not the risky bit.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load() {
const window = {
console,
document: {
readyState: 'complete',
addEventListener() {},
getElementById() { return null; },
querySelector() { return null; },
querySelectorAll() { return []; },
createElement() { return { style: {}, classList: { add() {}, remove() {}, contains() { return false; } }, addEventListener() {}, appendChild() {} }; },
},
addEventListener() {},
localStorage: { getItem() { return null; }, setItem() {} },
performance: { now: () => 0 },
setInterval() { return 0; },
clearInterval() {},
requestAnimationFrame() { return 0; },
cancelAnimationFrame() {},
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
innerHeight: 800,
};
window.window = window;
window.globalThis = window;
const ctx = vm.createContext(window);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
return window.folderLibrary.__test;
}
const { visibleWindow, VIRTUAL_BUFFER, VIRTUAL_MIN } = load();
// A flat 50k library in list view: 1 song per row, 44px rows, 800px viewport.
const ROW = 44;
const VH = 800;
const TOTAL = 50938;
test('the whole point: a 50k list renders a bounded window, not 50k rows', () => {
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
const rendered = w.end - w.start;
assert.ok(rendered < 60, `expected a small window, got ${rendered} rows`);
// ~18 rows fit in 800px, plus buffer above and below.
assert.ok(rendered >= Math.ceil(VH / ROW), 'must at least fill the viewport');
});
test('at the top: starts at 0, all remaining rows are bottom padding', () => {
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
assert.equal(w.start, 0);
assert.equal(w.padRowsTop, 0);
assert.equal(w.padRowsBottom, TOTAL - w.end);
});
test('scrolled into the middle: window tracks the scroll, padding adds up', () => {
const scrolled = 10000 * ROW; // row 10,000 at the fold
const w = visibleWindow(-scrolled, VH, ROW, 1, TOTAL, TOTAL);
assert.equal(w.start, (10000 - VIRTUAL_BUFFER) * 1);
assert.ok(w.end > w.start);
// The invariant that keeps the scrollbar honest: padding rows + rendered
// rows must account for every song, or the list changes height as you scroll.
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
});
test('at the very bottom: no bottom padding, end lands on the last song', () => {
const rows = TOTAL;
const scrolled = rows * ROW - VH; // scrolled to the end
const w = visibleWindow(-scrolled, VH, ROW, 1, rows, TOTAL);
assert.equal(w.end, TOTAL);
assert.equal(w.padRowsBottom, 0);
assert.equal(w.padRowsTop + (w.end - w.start), TOTAL);
});
test('grid view: perRow songs collapse into one row', () => {
const perRow = 6;
const rows = Math.ceil(TOTAL / perRow);
const w = visibleWindow(0, VH, 190, perRow, rows, TOTAL);
assert.equal(w.start, 0);
assert.equal(w.start % perRow, 0, 'a window must start on a row boundary');
assert.ok(w.end <= TOTAL);
assert.ok((w.end - w.start) < 200, 'grid window must stay bounded');
});
test('scrolled far past the list: keeps one row, never a negative window', () => {
const w = visibleWindow(-99999999, VH, ROW, 1, TOTAL, TOTAL);
assert.ok(w.end > w.start, 'window must never invert');
assert.ok(w.start >= 0 && w.end <= TOTAL);
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
});
test('list not yet scrolled to (below the fold): still yields a valid window', () => {
const w = visibleWindow(5000, VH, ROW, 1, TOTAL, TOTAL); // list starts below viewport
assert.equal(w.start, 0);
assert.ok(w.end > 0);
});
test('degenerate inputs fall back to rendering everything, never to a broken window', () => {
// Measured height of 0 (e.g. list still display:none) must not divide by zero
// and must not silently render an empty list.
const w = visibleWindow(0, VH, 0, 1, TOTAL, TOTAL);
assert.equal(w.start, 0);
assert.equal(w.end, TOTAL);
assert.equal(w.padRowsTop, 0);
assert.equal(w.padRowsBottom, 0);
});
test('small lists are below the virtualization threshold', () => {
assert.ok(VIRTUAL_MIN >= 100, 'threshold must be high enough that normal folders are untouched');
});
// ── the grid must be re-measured when the window resizes (CodeRabbit, #967) ──
// perRow and rows were originally captured once at fill time. paint() also runs
// on resize, so a narrower/wider window changed the column count while the
// window maths still used the OLD one — slicing the wrong songs and mis-sizing
// the padding. These pin that the geometry is a function of perRow, so a stale
// perRow cannot silently survive.
test('resizing the grid to fewer columns re-windows against the new row count', () => {
const total = 10000;
const wide = visibleWindow(0, VH, 190, 6, Math.ceil(total / 6), total);
const narrow = visibleWindow(0, VH, 190, 3, Math.ceil(total / 3), total);
// Same viewport, half the columns -> about half as many songs on screen.
assert.ok(narrow.end < wide.end, 'fewer columns must render fewer songs per screen');
// ...and the total must still add up, or the scrollbar lies after a resize.
for (const [w, perRow] of [[wide, 6], [narrow, 3]]) {
const rows = Math.ceil(total / perRow);
assert.equal(w.padRowsTop + Math.ceil((w.end - w.start) / perRow) + w.padRowsBottom, rows,
`rows must account for every song at perRow=${perRow}`);
}
});
test('a stale perRow would break the total-height invariant (the bug)', () => {
const total = 10000;
// Grid re-laid out to 3 columns, but windowed with the OLD perRow of 6:
// the row count no longer matches the geometry, and the padding is wrong.
const stalePerRow = 6, actualRows = Math.ceil(total / 3);
const bad = visibleWindow(0, VH, 190, stalePerRow, actualRows, total);
const accounted = bad.padRowsTop + Math.ceil((bad.end - bad.start) / 3) + bad.padRowsBottom;
assert.notEqual(accounted, actualRows,
'this asserts the FAILURE mode: mismatched perRow/rows must not silently look correct — ' +
'metrics() recomputes both together on every paint so this cannot happen in practice');
});
test('scrolled grid window always starts on a row boundary', () => {
const total = 10000, perRow = 4;
const rows = Math.ceil(total / perRow);
const w = visibleWindow(-5000, VH, 190, perRow, rows, total);
assert.equal(w.start % perRow, 0, 'a partial row would shift every card in the grid');
});
-161
View File
@@ -2418,13 +2418,6 @@
let _venueSceneAssetsLoaded = false;
let _venueSceneLoadFailed = false;
const _venueTextureCache = new Map();
// Crowd video layers (career mode). venue-crowd.js owns the <video>
// elements and the crossfade timing; the renderer only maps them onto
// two planes in front of the static plate. _venueCrowdRev bumps on any
// element (re)assignment so update() knows to rebind textures.
const _venueCrowdVideos = [null, null];
let _venueCrowdMix = 0;
let _venueCrowdRev = 0;
function _bgVenueMoodCoeffs(state) {
const s = String(state || 'idle').toLowerCase();
@@ -2916,20 +2909,6 @@
window.h3dVenueSceneSetMood = (state) => {
_venueMoodState = String(state || 'idle').toLowerCase();
};
// Crowd video layers (career mode) — see venue-crowd.js. Layer 0/1 are
// two coplanar backdrop planes; mix selects between them (0 → layer 0,
// 1 → layer 1) so the caller can crossfade loop videos.
window.h3dVenueBackdropSetVideo = (layer, videoEl) => {
const i = layer ? 1 : 0;
const el = videoEl || null;
if (_venueCrowdVideos[i] === el) return;
_venueCrowdVideos[i] = el;
_venueCrowdRev++;
};
window.h3dVenueBackdropSetMix = (mix) => {
const v = Number(mix);
_venueCrowdMix = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0;
};
window.h3dVenueSceneSetInstrumentPov = (input) => {
const next = _venueResolvePovFromInput(input);
if (_venueInstrumentPov === next) return;
@@ -3392,40 +3371,6 @@
() => _venueMarkFailed('failed to load small-club bg plate'),
);
// Crowd video planes (career mode): two crossfading layers
// just in front of the static plate (which stays mounted as
// the no-pack / load-failure fallback). Textures bind lazily
// in update() when venue-crowd.js assigns video elements.
state.crowd = { layers: [], rev: -1 };
for (let i = 0; i < 2; i++) {
const geo = new T.PlaneGeometry(1, 1);
const mat = new T.MeshBasicMaterial({
color: 0xffffff, transparent: true, opacity: 0,
depthWrite: false, fog: false,
});
const mesh = new T.Mesh(geo, mat);
mesh.visible = false;
// Layer 1 sits nearest so three.js's back-to-front
// transparent sort draws it after layer 0.
const layer = {
mesh, geo, mat, tex: null, videoEl: null,
cam: settings.cam,
distance: BG_BACKDROP_DISTANCE * (i === 0 ? 1.04 : 1.03),
lastAspect: 0, lastVisibleHeight: 0,
};
layer.applyCoverCrop = function () {
if (!layer.videoEl || !layer.tex) return;
_bgCoverCrop(
layer.tex,
layer.videoEl.videoWidth || 0,
layer.videoEl.videoHeight || 0,
layer.cam.aspect,
);
};
scene.add(mesh);
state.crowd.layers.push(layer);
}
const hazeGeo = new T.PlaneGeometry(280 * K, 40 * K);
const hazeMat = new T.MeshBasicMaterial({
color: 0x101820, transparent: true, opacity: coeffs.haze,
@@ -3457,64 +3402,6 @@
s.haze.mat.opacity = (s.haze.baseOp || VENUE_HAZE_STEADY)
* (coeffs.haze / VENUE_HAZE_STEADY);
}
if (s.crowd) {
// Rebind VideoTextures when venue-crowd.js (re)assigns
// elements. VideoTexture samples the element every frame,
// so a src change on the same element needs no rebind.
if (s.crowd.rev !== _venueCrowdRev) {
s.crowd.rev = _venueCrowdRev;
s.crowd.layers.forEach((layer, i) => {
const el = _venueCrowdVideos[i];
if (layer.videoEl === el) return;
if (layer.tex) { layer.mat.map = null; layer.tex.dispose(); layer.tex = null; }
layer.videoEl = el;
layer.lastAspect = 0; // force refit + recrop
if (el) {
const tex = new T.VideoTexture(el);
tex.colorSpace = T.SRGBColorSpace;
tex.wrapS = T.ClampToEdgeWrapping;
tex.wrapT = T.ClampToEdgeWrapping;
tex.minFilter = T.LinearFilter;
tex.magFilter = T.LinearFilter;
tex.generateMipmaps = false;
layer.tex = tex;
layer.mat.map = tex;
}
layer.mat.needsUpdate = true;
});
}
const warm = coeffs.warmth;
s.crowd.layers.forEach((layer, i) => {
const el = layer.videoEl;
// videoWidth === 0 until metadata lands — showing the
// plane before that paints a black flash over the plate.
const ready = !!el && el.videoWidth > 0;
// venue-crowd.js swaps src on the same element (loop ↔
// stinger); a new intrinsic size needs a fresh
// cover-crop, which _bgFitBackdropPlane only reapplies
// on camera aspect changes.
if (ready && (layer.lastVidW !== el.videoWidth ||
layer.lastVidH !== el.videoHeight)) {
layer.lastVidW = el.videoWidth;
layer.lastVidH = el.videoHeight;
layer.applyCoverCrop();
}
// Layer 0 (rear) stays fully opaque whenever any of the
// fade involves it: two half-transparent layers would
// let the static plate behind bleed through (~25% at
// mid-fade). The crossfade is therefore layer 1 (front)
// fading over an opaque layer 0 — in both directions.
const opacity = i === 0
? (_venueCrowdMix < 0.999 ? 1 : 0)
: _venueCrowdMix;
layer.mat.opacity = opacity;
layer.mesh.visible = ready && opacity > 0.01;
if (layer.mesh.visible) {
layer.mat.color.setRGB(warm, warm * 0.98, warm * 0.95);
_bgFitBackdropPlane(layer);
}
});
}
},
teardown(s) {
if (!s) return;
@@ -3529,19 +3416,6 @@
p.mat.dispose?.();
}
}
// Crowd planes: this style owns the VideoTextures; the
// <video> elements belong to venue-crowd.js and survive.
if (s.crowd) {
for (const layer of s.crowd.layers) {
layer.mesh?.parent?.remove(layer.mesh);
layer.geo?.dispose?.();
if (layer.mat) {
layer.mat.map = null;
layer.mat.dispose?.();
}
layer.tex?.dispose?.();
}
}
// Dispose the cached plate textures too — the module-level cache
// otherwise keeps every loaded POV plate GPU-resident for the
// page lifetime (steady VRAM growth across POV/arrangement swaps).
@@ -15388,41 +15262,6 @@
});
},
// The host throttles paused frames to ~10 fps, on the assumption
// that a paused chart is a static picture and re-rendering it is
// pure waste (highway-constants._PAUSED_FRAME_INTERVAL_MS).
//
// That stopped being true when the venue landed. The venue backdrop
// is a PLAYING VIDEO and the crowd reacts on its own clock, and they
// are drawn into this same canvas as the highway — so throttling the
// highway throttled the whole room. Pausing the song dropped the
// venue, the crowd and the stage to 10 fps.
//
// Two independent sources of motion, and BOTH must keep their frames:
//
// • a crowd video rolling on its own clock (career venue pack), and
// • the venue scene's own fake-depth motion — the backdrop breathes,
// the haze drifts, warmth pulses, the shimmer moves. That is
// Math.sin(t) in the draw loop (see _venueApplyFakeDepthMotion),
// so it only moves while we are actually given frames, and it runs
// with NO pack at all.
//
// The throttle fires whenever the CHART CLOCK is stalled — which is
// not just a pause. A count-in and the credits/author overlay stall it
// exactly the same way, so the venue was stuttering there too.
//
// With no venue at all (plain 3D highway) the paused scene really is a
// still picture: motion mode reads 'off', we claim nothing, and the
// throttle still saves the GPU as #654 intended.
needsContinuousFrames() {
if (!_isReady || _ctxLost) return false;
for (const v of _venueCrowdVideos) {
if (v && !v.paused && !v.ended && v.readyState >= 2) return true;
}
// 'off' also covers prefers-reduced-motion and "no venue scene".
try { return _venueEffectiveMotionMode() !== 'off'; } catch (_) { return false; }
},
draw(bundle) {
if (!_isReady) return;
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
+7880 -1736
View File
File diff suppressed because it is too large Load Diff
+10253 -482
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -519,9 +519,7 @@ window.feedBack.audio = Object.assign(window.feedBack.audio || {}, {
readSongVolume: _readSongVolume,
});
// `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') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _init);
} else {
_init();
+1 -3
View File
@@ -111,9 +111,7 @@
// Announce once after the document parses, so any listener wired during page
// load can sync without special-casing (consumers may also just call get()).
// `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') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', announce, { once: true });
} else {
announce();
+2167 -711
View File
File diff suppressed because it is too large Load Diff
+621
View File
@@ -0,0 +1,621 @@
<!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
@@ -1,17 +0,0 @@
// 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');

Some files were not shown because too many files have changed in this diff Show More