mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 17:24:30 +00:00
Compare commits
191
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27220ada3 | ||
|
|
955c8a12d0 | ||
|
|
4027c31a61 | ||
|
|
0fc6a4beed | ||
|
|
d26347981c | ||
|
|
45caa86ab8 | ||
|
|
8f1906a0c1 | ||
|
|
8b6829a946 | ||
|
|
3050c7b1d3 | ||
|
|
ba796b0f27 | ||
|
|
ddc06ff1e7 | ||
|
|
ac5c5ad20d | ||
|
|
f8012a8ce4 | ||
|
|
99b974a5a1 | ||
|
|
7ffa6e2c51 | ||
|
|
3832a5762b | ||
|
|
a60dcd10c2 | ||
|
|
5dcf39cd62 | ||
|
|
c485f02211 | ||
|
|
203f82b6fe | ||
|
|
0158286d06 | ||
|
|
1e2ce29cf6 | ||
|
|
b54b65d35c | ||
|
|
ab2e68a638 | ||
|
|
d806d12c22 | ||
|
|
32d723b774 | ||
|
|
ceb1e143cd | ||
|
|
d0626f5618 | ||
|
|
0dc9fd7ba8 | ||
|
|
22332bef22 | ||
|
|
342def3851 | ||
|
|
a0278bd3a7 | ||
|
|
67e6b25c43 | ||
|
|
503716acbf | ||
|
|
41bb4482fe | ||
|
|
0955f0b6f2 | ||
|
|
3a50e593bf | ||
|
|
5049be0523 | ||
|
|
de2a42bd35 | ||
|
|
671aba950c | ||
|
|
95d6d8a46e | ||
|
|
f43779c99e | ||
|
|
82aa8a757e | ||
|
|
cb425ed48d | ||
|
|
b74a364857 | ||
|
|
859b0036e5 | ||
|
|
a7348052ae | ||
|
|
1e5282e27e | ||
|
|
d364529919 | ||
|
|
81ef11d855 | ||
|
|
9e9f0fdac6 | ||
|
|
188bdaa837 | ||
|
|
330995588c | ||
|
|
254e26bb3a | ||
|
|
d508380532 | ||
|
|
fefb9051a4 | ||
|
|
e2215df753 | ||
|
|
e5cbea2e9f | ||
|
|
ea0ca94742 | ||
|
|
e779c72396 | ||
|
|
ffc52f13ce | ||
|
|
8d3db5f42c | ||
|
|
f27d4f623c | ||
|
|
57e7db5c2a | ||
|
|
545e569ad6 | ||
|
|
84fe29688c | ||
|
|
69aac32278 | ||
|
|
0a6e0309e5 | ||
|
|
36cf77dc44 | ||
|
|
12eb73aee9 | ||
|
|
1a386c272d | ||
|
|
8e89b39ad3 | ||
|
|
c6963fdf30 | ||
|
|
d9fa6d3f55 | ||
|
|
23ecddc721 | ||
|
|
79825af28e | ||
|
|
db3ca34fcb | ||
|
|
34215fbd32 | ||
|
|
f5d448af5c | ||
|
|
8f014e6a30 | ||
|
|
6b8f79dd9a | ||
|
|
70dbe45e27 | ||
|
|
1cef01d02c | ||
|
|
756588678b | ||
|
|
bd830328f0 | ||
|
|
09f7e450a5 | ||
|
|
8bec8d2466 | ||
|
|
8d0e270345 | ||
|
|
dc429ecd16 | ||
|
|
11f8c36b61 | ||
|
|
5fb28d5c5a | ||
|
|
cb236e6c04 | ||
|
|
64f04565e2 | ||
|
|
f53d566dbc | ||
|
|
b5dd585d25 | ||
|
|
d47883c5e5 | ||
|
|
ebbfc8da6f | ||
|
|
14b4058bc6 | ||
|
|
bfb31a8b89 | ||
|
|
a222b45c02 | ||
|
|
5b904706d0 | ||
|
|
38772f604a | ||
|
|
92c86f5393 | ||
|
|
c223ace419 | ||
|
|
ff7e855e35 | ||
|
|
4b4c156fce | ||
|
|
9d0bf95716 | ||
|
|
5e30138c87 | ||
|
|
0547f55844 | ||
|
|
b7624b7e65 | ||
|
|
f00ba2217d | ||
|
|
f09c4a217f | ||
|
|
9a58a55fe8 | ||
|
|
bbdff4e10f | ||
|
|
7258e1066a | ||
|
|
73127d5416 | ||
|
|
165475d115 | ||
|
|
508829c012 | ||
|
|
cce95cbd1e | ||
|
|
32ebc7671e | ||
|
|
46f3be7fd7 | ||
|
|
76159c16cd | ||
|
|
4cc8fa3b4d | ||
|
|
f9f33320ac | ||
|
|
5f58af4faa | ||
|
|
ea8834862d | ||
|
|
2281cac438 | ||
|
|
b6098e3695 | ||
|
|
cbc65458e3 | ||
|
|
7c87538d6b | ||
|
|
c8701991cb | ||
|
|
514461167e | ||
|
|
0dcc9136b6 | ||
|
|
6da01c55a4 | ||
|
|
a883f9213f | ||
|
|
4fd0cd49e7 | ||
|
|
b41361eb1b | ||
|
|
6c98aba433 | ||
|
|
b3215694e7 | ||
|
|
ebe59d3f97 | ||
|
|
d6f2df14f7 | ||
|
|
94a58b7a42 | ||
|
|
58120745bc | ||
|
|
e134f5c802 | ||
|
|
f1bae9774c | ||
|
|
751209b80e | ||
|
|
1c1a0e0268 | ||
|
|
0a16014698 | ||
|
|
aaf593bdd1 | ||
|
|
14d116d827 | ||
|
|
54b5d2e426 | ||
|
|
bcee2e8610 | ||
|
|
a6a5186180 | ||
|
|
1b3178037b | ||
|
|
845255e404 | ||
|
|
0d4d8229c7 | ||
|
|
ff8a638d28 | ||
|
|
5aa336961c | ||
|
|
950e348357 | ||
|
|
a18a818e8b | ||
|
|
5cb4ea0623 | ||
|
|
fadaa154e9 | ||
|
|
e446b05a99 | ||
|
|
115c3529e9 | ||
|
|
1bccb8a9e8 | ||
|
|
010edc239b | ||
|
|
9fb63fd3b5 | ||
|
|
cb72c5ab34 | ||
|
|
92e78be62d | ||
|
|
1840170e95 | ||
|
|
7cbf9824b1 | ||
|
|
33146cc7f6 | ||
|
|
69c8ad4e0c | ||
|
|
a20dca21bb | ||
|
|
021ee55f2a | ||
|
|
1f621e5fe5 | ||
|
|
612b1f2e0d | ||
|
|
4f6dc233f1 | ||
|
|
5be70939e4 | ||
|
|
6aaa2dcf47 | ||
|
|
1a8540935b | ||
|
|
d567fd5597 | ||
|
|
b914612f9d | ||
|
|
de002cdc24 | ||
|
|
3100d68a45 | ||
|
|
6397a959a4 | ||
|
|
5f499a8a3a | ||
|
|
af1170cec3 | ||
|
|
3e036e3db6 | ||
|
|
92dc321fdf | ||
|
|
74cff4e0d6 |
@@ -0,0 +1,17 @@
|
||||
## 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`)
|
||||
@@ -123,3 +123,116 @@ jobs:
|
||||
sys.exit(1)
|
||||
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
|
||||
# scripts/build-tailwind.sh). max-lines WARNS (the 1,500-line size ratchet;
|
||||
# non-blocking), while import-x/no-unresolved + no-cycle HARD-ERROR on the
|
||||
# ES-module graphs the refactor produces. Exemptions: docs/size-exemptions.md.
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# npm ci runs third-party postinstall scripts; don't leave the token in
|
||||
# git config for them (this job never pushes).
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: ESLint (size norm + module hygiene)
|
||||
run: npm run lint
|
||||
|
||||
@@ -6,7 +6,7 @@ name: Nightly
|
||||
# builds from release/** come from rc.yml instead.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
- cron: '0 23 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -24,6 +24,9 @@ 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__/
|
||||
|
||||
@@ -34,7 +34,7 @@ but not the primary supported path.
|
||||
|
||||
### II. Vanilla Frontend — No Frameworks
|
||||
|
||||
The frontend (`static/app.js`, `static/highway.js`, `static/index.html`,
|
||||
The frontend (`static/app.js`, `static/highway.js`, `static/v3/index.html`,
|
||||
`static/style.css`) is plain JavaScript with the `fetch` API, direct DOM
|
||||
manipulation, and the Canvas 2D / WebGL2 APIs. The only style framework
|
||||
is Tailwind CSS, served as a prebuilt static stylesheet
|
||||
@@ -48,11 +48,30 @@ output is committed, so Docker / desktop / end users never build. New
|
||||
features extend `app.js` and the existing globals (`window.playSong`,
|
||||
`window.showScreen`, `window.createHighway`, `window.feedBack`).
|
||||
|
||||
Native ES modules are a first-class, build-free extension mechanism.
|
||||
Because `<script type="module">` and `import` are browser features — not
|
||||
a bundler — a large source file MAY be split into an `import`-ed module
|
||||
graph of plain source files, with **no build step and no framework**. A
|
||||
plugin opts in with `"scriptType": "module"` in `plugin.json`: its
|
||||
`screen.js` becomes a one-line `import './src/main.js'`, and the host
|
||||
serves the `src/` subtree from the sandboxed `/api/plugins/<id>/src/…`
|
||||
route and injects the entry as `<script type="module">`. The classic
|
||||
global-scope `screen.js` path remains fully supported; both coexist, and
|
||||
module scripts are still source-served — the no-bundler, no-transpiler,
|
||||
build-free-at-serve rule is unchanged. Core's own `static/` tree may
|
||||
migrate to the same module-graph shape (`static/js/…`) over time under
|
||||
this rule.
|
||||
|
||||
**Non-negotiable rules**
|
||||
|
||||
- Do not introduce a frontend framework, JSX, or a JS build pipeline in
|
||||
core. Plugins MAY ship their own bundled assets but core MUST remain
|
||||
source-served.
|
||||
- ES-module plugins remain source-served: no bundler or transpiler, and
|
||||
their own asset URLs (worklets, WASM, images) resolve via
|
||||
`import.meta.url` — never `document.currentScript`, which is `null`
|
||||
inside a module. `scriptType:"module"` and the optional `minHost`
|
||||
version floor are the only new `plugin.json` keys the module path adds.
|
||||
- Because the core Tailwind stylesheet is prebuilt, it contains only the
|
||||
classes present in core source at build time. Core's committed
|
||||
`static/tailwind.min.css` MUST stay in sync with source — CI enforces
|
||||
@@ -214,6 +233,15 @@ no `..`, no absolute paths).
|
||||
runs first). Plugins MUST tolerate dependent globals being absent
|
||||
at load time and check at runtime
|
||||
(`typeof window.X === 'function'`).
|
||||
- **Module load contract**: a `scriptType:"module"` plugin is injected
|
||||
as `<script type="module">`, whose load event fires only after its
|
||||
whole static-import graph fetches and evaluates — so the loader's
|
||||
completion-by-`onload` guarantee (and the `playSong` wrapper-chain
|
||||
order above) is preserved exactly. The host loads `screen.js` once per
|
||||
version and `showScreen` re-injects nothing, so a plugin's per-visit
|
||||
re-initialization comes from its `screen:changed` handler, not from
|
||||
screen.js re-running; ES-module plugins inherit this unchanged (module
|
||||
top-level code does not re-execute on same-version re-mount).
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -256,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.1.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-06-01
|
||||
**Version**: 1.3.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-11
|
||||
|
||||
+222
@@ -8,12 +8,234 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **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/`,
|
||||
`static/`, `plugins/__init__.py`. The root-level `appstate.py` and `routers/` added in
|
||||
R3 shipped correctly in Docker and passed every test, and were then silently dropped
|
||||
from the packaged app, which died at startup. Both now live under **`lib/`** — the one
|
||||
core directory the Dockerfile (`COPY lib/`), `docker-compose.yml`, and the desktop
|
||||
bundler (`cp -r lib`) all copy wholesale, and that all three put on `sys.path` (on
|
||||
Windows via the embeddable-Python `._pth`, where `PYTHONPATH` is ignored). This needs no
|
||||
change in feedback-desktop and no new release to take effect. Placing them there is also
|
||||
correct under Principle V: with the injection seam, `appstate.py` constructs nothing and
|
||||
does no import-time IO, and a route module only builds an `APIRouter`. The
|
||||
`Dockerfile` / `.dockerignore` / `docker-compose.yml` entries added for the root layout
|
||||
are reverted. New `tests/test_packaging.py` walks `server.py`'s module-level imports and
|
||||
fails if any first-party module resolves outside a directory the packagers copy, so the
|
||||
next root-level module can't ship broken.
|
||||
|
||||
### 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/` — 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
|
||||
where they used to be defined** — FastAPI matches routes in registration order, so the
|
||||
mount site preserves it. Verified: the full 143-route table (paths, methods, *and*
|
||||
order) is byte-for-byte identical to `main`. Bodies are verbatim; the only edits are
|
||||
the decorator receiver (`@app.get` → `@router.get`) and the singleton read
|
||||
(`audio_effect_mappings` → `appstate.audio_effect_mappings`, a module attribute
|
||||
resolved at call time). This proves the seam from #833 under a real consumer, including
|
||||
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*
|
||||
its singletons and now **injects** them once — `appstate.configure(meta_db=…,
|
||||
audio_effect_mappings=…)` — and a router reads them back as module attributes at call
|
||||
time (`import appstate; appstate.meta_db.…`). This is the Python analogue of the
|
||||
frontend refactor's injected `configureX({…})` seams and of the plugin
|
||||
`setup(app, context)` contract: dependencies flow one way, `server → routers →
|
||||
appstate`. Two properties are load-bearing and pinned by `tests/test_appstate.py`:
|
||||
(1) `import appstate` constructs nothing and touches no disk, so the ~49 test fixtures
|
||||
that `sys.modules.pop("server")` + re-import (to rebuild `meta_db` under a patched
|
||||
`CONFIG_DIR`) keep working untouched — a singleton *owned* by `appstate` would survive
|
||||
that pop and go stale; (2) reads must be late-bound (`appstate.meta_db`, never
|
||||
`from appstate import meta_db`), since a `from` import freezes the binding and defeats
|
||||
both a later `configure()` and `monkeypatch.setattr` — the same read-only-binding trap
|
||||
as ES `import`. `configure()` rejects an unknown slot rather than silently creating a
|
||||
global nothing reads, and the suite asserts `server` actually calls it (a seam whose
|
||||
wiring can no-op undetected is worse than no seam). Lives at `lib/appstate.py`.
|
||||
|
||||
### Changed
|
||||
- **`AudioEffectsMappingDB` moved out of `server.py` into `lib/audio_effects_db.py`
|
||||
(R3, move-only).** The core-owned song/tone → provider routing index follows
|
||||
`MetadataDB` out of the host file, byte-identical apart from the same constructor
|
||||
seam (`__init__` takes `config_dir`; `audio_effect_mappings = AudioEffectsMappingDB(CONFIG_DIR)`),
|
||||
so the module does no IO at import. The singleton stays in `server.py`; no route,
|
||||
no test and no `monkeypatch.setattr(server, …)` target moves. `server.py`:
|
||||
**9,705 → 9,433 lines**.
|
||||
- **`MetadataDB` moved out of `server.py` into `lib/metadata_db.py` (R3, move-only).**
|
||||
The library metadata cache — the `MetadataDB` class (4,018 lines) plus the query
|
||||
helpers it owns (keyset paging cursors, the tuning grouping key, smart-arrangement
|
||||
naming, tag normalisation, the startup DB-restore swap) — now lives in its own flat
|
||||
`lib/` module. `server.py` drops from **14,037 → 9,705 lines** and keeps the
|
||||
`meta_db` singleton, so `server.meta_db` and `server.app` resolve exactly as before
|
||||
and every route is untouched. The only non-verbatim change is the seam that lets the
|
||||
class leave `server.py`: `MetadataDB.__init__` now takes `config_dir` explicitly
|
||||
(`meta_db = MetadataDB(CONFIG_DIR)`) instead of reading the module-level `CONFIG_DIR`,
|
||||
which also means `lib/metadata_db.py` performs no IO at import (Principle V). Logging
|
||||
still goes through the `feedBack.server` logger, so existing log filters and `caplog`
|
||||
assertions resolve to the same logger object. `tests/test_settings_export_library_db.py`
|
||||
now imports `_apply_pending_db_restore` from `metadata_db` (the test moved with its
|
||||
subject); no other test changed. Every moved block is byte-identical to its
|
||||
`server.py` original.
|
||||
|
||||
### Added
|
||||
- **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match`→`304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `<script type="module">` (its screen.js becomes `import './src/main.js'`). A `<script type=module>` fires its load event only after its whole static-import graph evaluates, preserving the loader's completion-by-`onload` + `_loadingPluginId` contract. Classic plugins are unaffected; `minHost` is passthrough-only for now (enforcement deferred). Tests: `tests/test_plugin_src_route.py` (serve/media-type/traversal/304/no-stale-304/screen.js+assets conditional), `tests/js/plugin_loader_script_type.test.js` (guarded module injection).
|
||||
- **Module-migration governance & rails (R0).** Constitution amended to **v1.2.0**: Principle II now names native ES modules as a first-class, *build-free* extension mechanism (the `scriptType:"module"` load path, both plugins and — over time — core's `static/js/`), keeping the no-bundler/no-transpiler/source-served rule intact; Operating Constraints gains a "Module load contract" clause (a `<script type=module>` load event awaits the whole static-import graph, so completion-by-`onload` is preserved; per-visit re-init comes from the `screen:changed` event, not screen.js re-execution). Mirrored into `CLAUDE.md`. New `docs/plugin-modules.md` (the migration playbook — layering, import-time purity, `import.meta.url` assets, the ETag live-edit loop) and `docs/size-exemptions.md` (the signed 1,500-line size-norm register; Byron signs core/bundled rows, Christian the authored virtuoso row). Adds a **maintainer/CI-only** ESLint gate (`eslint.config.js` + a `lint` CI job): `max-lines` warns at 1,500 as a non-blocking ratchet (ceilings for exempt files mirror the register), and `import-x/no-unresolved` + `import-x/no-cycle` hard-error on ES-module graphs — dormant until module code lands, never on the serve/Docker path.
|
||||
- **Perf-baseline harness (R0).** `scripts/perf-baseline.mjs` (maintainer-only, Playwright-driven) captures server p50/p95/p99 latency, cold boot-to-interactive, JS-heap after an idle soak, and the injected plugin-script shape (how many are `type=module`), so every refactor phase can be checked for "screen-entry and frame-time no worse." Methodology + the R0 baseline live in `docs/perf-baseline.md`; playback frame-time and chart-loaded screen-entry rows need a seeded library and are re-taken per environment.
|
||||
- **Sort and filter the library by your personal difficulty rating — now visible at a glance, not just in the edit drawer.** `song_user_meta.user_difficulty` (the 1–5 planning rating, settable manually or seeded by a plugin like the community `difficulty_tagger`) already existed but was only readable by opening a song's per-song details drawer. The library API gains `sort=difficulty` / `sort=difficulty-desc` — a correlated subquery over `song_user_meta`, following the same unrated-rows-sort-to-the-bottom-in-both-directions pattern as the existing `mastery` sort — and library cards now show the rating as a `◆N` badge (v2 grid/tree views and the v3 grid alike), next to the tuning and lyrics badges. The classic tree view's `query_artists` batch-attaches `user_difficulty` the same way `query_page` already did for the grid, so the badge actually renders there too instead of staying dark. Tests: `tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom`, `tests/test_library_filters.py::test_tree_view_songs_carry_user_difficulty`.
|
||||
- **`lib/midi_import.py`: `convert_midi_tempo_map` — MIDI imports can finally carry
|
||||
their bars.** The keys/drums note converters always computed a tempo-aware
|
||||
tick→seconds map internally (to bake note times to absolute seconds) and then threw
|
||||
it away — and never read `time_signature` meta at all — so every MIDI import landed
|
||||
with no measures and an implied 4/4 regardless of what the file said. The new helper
|
||||
extracts the whole grid: `tempos` (`{time, bpm}`), `time_signatures` (`{time,
|
||||
ts:[num,den]}`, the song-timeline sidecar shape), and a full `beats` grid on the
|
||||
editor's row shape (numbered downbeats with a `den` hint, `-1` interior beats,
|
||||
eighth-note rows in 6/8 etc.). Event scope mirrors the existing tick map — SMF
|
||||
type 0/1 merge meta across tracks, type 2 reads only the chosen track (independent
|
||||
timelines must never share a grid); mid-bar signature events apply at the next bar
|
||||
boundary; times are computed from absolute ticks through the cumulative tempo table
|
||||
and rounded once at emit, so rounding error never accumulates with song length.
|
||||
Consumed by the editor's upcoming multitrack MIDI import (tempo-seed dialog). Tests:
|
||||
`tests/test_midi_tempo_map.py`.
|
||||
|
||||
### Fixed
|
||||
- **Tuner: opening the player screen no longer throws `NotFoundError` and aborts the player render (feedBack#800).** `injectPlayerButton()` anchored the injected Tuner button with `controls.querySelector('button:last-child')`, which — unlike a `:scope`-scoped query — can match a **nested** button that is not a direct child of `#player-controls`. `controls.insertBefore(btn, nestedButton)` then throws `NotFoundError` (the reference node must be a direct child), and because the injection runs from the tuner's `screen:changed` → player handler, the throw propagated out of the player-screen transition and stalled its render (surfaced by a headless render of a notation arrangement; the v3 path was already safe via the plugin-control slot, only the classic path had the bad anchor). The anchor is now `:scope > button:last-of-type` (a direct child only) with a `parentNode === controls` guard before `insertBefore`, falling back to `appendChild`. `plugins/tuner` → 1.3.4. Tests: `tests/plugins/tuner/js/inject_player_button.test.js` (nested-last-button repro, direct-child insert, no-button append, idempotency, v3 slot path).
|
||||
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x–2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
|
||||
|
||||
### Added
|
||||
- **3D Keys Highway: key layout modes, lane-color opacity & octave lines.** A new **Highway layout** settings section rebuilds how sharps/flats and lanes draw on the 3D piano highway. **Sharps & flats layout** (`keys3d_bg_sharpMode`) picks between **floating** (the original raised-sharp look), **flat** (one plane, zero-overlap piano-shaped tiled lanes with the naturals evened out), and **realistic** (one plane, bars sized like the physical keys) — default **realistic**; the geometry lives in pure, unit-tested `laneSpanFlat()`/`laneSpanReal()` helpers. **Lane color opacity** (`keys3d_bg_laneOpacity`, 0–1, default 0) fades the pitch-class lane tint from full vivid color down to a dark floor with guide lines only at the key-block boundaries (E→F and each octave); the lane strips, per-lane separators and block lines crossfade with the value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) and **Octave line contrast** (`keys3d_bg_octaveContrast`, 0–1) control the B→C octave divider, which auto-shifts from a dark to a bright layer as lane opacity fades. Settings re-read on init and apply on the next chart build. `plugins/keys_highway_3d` → 0.2.0. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (new defaults, sharp-mode setting, lane-geometry tiling/evening for flat, uniform/overlap for realistic, and an active-range boundary case where a white key's edge stays untrimmed when its neighboring sharp falls outside the active range).
|
||||
- **Unmapped-percussion capture now records velocities alongside times.** Both drum converters' opt-in `out_unmapped` reporting (`lib/midi_import.py` `convert_drum_track_from_midi`, `lib/gp2rs.py` `convert_drum_track_to_drumtab`) gain an index-aligned `velocities` list next to `times`, carrying each dropped note's real dynamics (MIDI velocity verbatim; GP velocity with the same 1–127 gate as mapped hits, falling back to the 100 import default). This lets a hand-mapping UI (the editor's unmapped-notes dialog) restore mapped notes at their source dynamics instead of flattening everything to `v:100`. The GP path's chronological sort now reorders times and velocities in lockstep so multi-voice measures can't silently reassign dynamics. Additive — callers that ignore the new key are unaffected. Tests: `tests/test_midi_import_drums.py`, `tests/test_gp2rs_drums.py`.
|
||||
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
|
||||
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing Okabe–Ito "Colorblind-friendly" preset — contributed by a deuteranopic player who found the Okabe–Ito set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
|
||||
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117–123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
|
||||
- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
|
||||
|
||||
### Changed
|
||||
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
|
||||
|
||||
### Fixed
|
||||
- **`playback.loop-api` bridge no longer fires dozens of times per second.** Every `window.feedBack.getLoop()` call recorded a full bridge hit — compat-shim bookkeeping, a `playback:bridge-hit` event, and a diagnostics snapshot rebuild + stringify — so a plugin polling loop state from a HUD tick (note_detect at ~30 Hz) flooded the capability inspector and burned main-thread time even with no song playing. `_recordPlaybackBridge` now throttles per bridge/surface (5 s window): bridge hits are a "surface still in use" signal, not a call counter. The manual A/B loop buttons (`setLoopEnd`) also now emit the same `loop-set` transport event as `setLoop()`, so plugins can react to loop changes via `playback:loop-set` / `playback:loop-cleared` events instead of polling `getLoop()`.
|
||||
- **3D Highway: recover from a WebGL context loss instead of crashing on alt-tab.** Switching the active window / alt-tabbing away from the app (most often on Windows) can trigger a GPU context reset; the 3D highway's WebGL renderer had **no `webglcontextlost` handler**, so a lost context was left to escalate into a render-process crash — matching the intermittent "randomly crashes when I change windows" desktop reports. The renderer now binds `webglcontextlost`/`webglcontextrestored` on its own WebGL canvas (`ren.domElement`): the loss is `preventDefault()`'d so the browser keeps the context restorable, `draw()` bails while the context is down so no GL work runs on a dead context, and on restore the viewport is re-applied and rendering resumes (Three re-uploads scene resources on the next frame). Listeners are torn down with the renderer. `plugins/highway_3d` → 3.31.3. Tests: `tests/js/highway_3d_context_loss.test.js`. (The sibling `keys_highway_3d` / `drum_highway_3d` renderers share the same gap — tracked as a follow-up in their repos.)
|
||||
- **Guitar Pro 6 (`.gpx`) import no longer fails on every real file.** The GPX BCFS container reader (`lib/gp2rs_gpx.py`) rejected any file whose final sector wasn't a full `0x1000` block — but a real `.gpx`'s BCFZ-declared decompressed size isn't sector-aligned, so the last (small) container file always lands in a partial trailing sector. The bounds check *raised* `GPX BCFS sector pointer out of range (malformed file)` instead of clamping the tail read, so `_load_gpif` threw before `score.gpif` could be extracted and **no GP6 file could be imported into the song editor** (both real test files failed identically — this wasn't file-specific). GP7/GP8 `.gp` files were unaffected — they take the ZIP path, not BCFS, which is why prior GP-import work didn't surface it. The reader now **clamps the final sector read to the buffer end** (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro); a sector whose *start* is past the end still raises, preserving the malformed-file guard. Verified against two real GP6 files — both now unpack to valid GPIF with all tracks. Tests: `tests/test_gp2rs_gpx.py` (partial-final-sector round-trip, multi-file container, sector-aligned baseline, and the preserved out-of-range guard).
|
||||
- **v3 Songs grid: fixed the scroll stutter that "skips every so many scrolls," up or down.** The virtualized grid rebuilt its **entire** visible window (`grid.innerHTML = …` + a full `wireCards` pass) every time it slid by one row, so each row-boundary crossing was a heavy synchronous frame that stalled the main thread and buffered held-arrow key-repeats into a visible lurch (a tester's "super fast for a second then slowed down") at fixed scroll offsets — in **both directions and regardless of whether the page was already loaded** (the cost was DOM teardown, not fetching, which is why scrolling back up over cached songs hitched too). `renderWindow()` now **reconciles the window in place**: it reuses the card nodes that stay on-screen and builds only the row that enters/leaves (~6 nodes per slide instead of ~60), keyed by absolute index with a real-vs-skeleton + select-mode signature so hole-fills (after a page fetch) and select-mode toggles still rebuild exactly the nodes that changed. `wireCards`'s `data-wired` guard then wires only the freshly-built nodes, so per-slide listener churn drops with it. Follow-up to the stage-2 virtualized grid (got-feedback/feedBack#636 item 3). Frontend-only: `static/v3/songs.js`. Tests: `tests/js/v3_songs_window_recycle.test.js` (window stays `[start,end)` contiguous + in-window node identity reused across a down-then-up scroll; select-mode toggle and rail-seek jump rebuild correctly).
|
||||
- **Starter content seeds again (and now ships The Adicts' "Ode to Joy").** `_BUILTIN_STARTER_SOURCES` still listed `beethoven-ode_to_joy.feedpak` after that pack was deleted, and never wired up its replacement `the_adicts-ode-to-joy_vst_cover.feedpak` that landed on disk. The listed-but-missing file made the all-present gate never fire, so **no** starter songs seeded on first run. Synced the manifest to what's on disk (Für Elise, Star Spangled Banner, The Adicts' Ode to Joy). Tests: `tests/test_builtin_starter_seed.py` (the present/unlisted guards were red on `main`).
|
||||
|
||||
@@ -117,19 +117,21 @@ Notes:
|
||||
|
||||
**Frontend scripts** — `screen.js` runs in the global scope via a `<script>` tag. It can access `window.playSong`, `window.showScreen`, `window.createHighway`, the `<audio>` element, and the `window.feedBack` event emitter.
|
||||
|
||||
**ES-module plugins (`scriptType:"module"`)** — a plugin may instead ship a native ES-module graph with **no build step**: set `"scriptType": "module"` in `plugin.json`, make `screen.js` a one-line `import './src/main.js'`, and put the module tree under `src/` (served by the sandboxed `/api/plugins/<id>/src/{path}` route). The host injects it as `<script type="module">`, whose `onload` fires only after the whole static-import graph evaluates — so the loader's completion-by-`onload` + `_loadingPluginId` + `playSong` wrapper-chain ordering all hold. Resolve your own asset URLs (worklets, WASM) with `import.meta.url` — `document.currentScript` is `null` in a module. Module top-level code does **not** re-run when the user re-enters the screen at the same version (the host loads screen.js once and `showScreen` re-injects nothing), so keep per-visit re-init in a `screen:changed` handler, exactly as classic plugins do. Classic global-scope `screen.js` remains fully supported. See `docs/plugin-modules.md`.
|
||||
|
||||
**The playSong wrapper chain** — Plugins commonly wrap `window.playSong` to hook into song playback. Plugins load alphabetically, so the last-loaded (alphabetically later) wrapper runs first, while the alphabetically first plugin runs closest to the original. Be aware that `await` calls in inner wrappers yield to the event loop — WebSocket messages can arrive before outer wrappers finish setup.
|
||||
|
||||
## Plugin Best Practices
|
||||
|
||||
### v3 UI (fee[dB]ack v0.3.0) — player-chrome contract
|
||||
|
||||
v0.3.0 ships a redesigned UI behind a flag (`FEEDBACK_UI=v3` or the `/v3` route);
|
||||
the classic UI (v2) stays the default until 0.3.0 ships, so **plugins must work in
|
||||
both**. v3 reuses the same engine (`server.py`, `app.js`, `highway.js`, `playSong`,
|
||||
v0.3.0's redesigned UI is **the only UI** — the classic v2 shell and its
|
||||
`FEEDBACK_UI` / `/v2` opt-outs are gone, so there is no second shell to support.
|
||||
v3 reuses the same engine (`server.py`, `app.js`, `highway.js`, `playSong`,
|
||||
`showScreen`, capabilities, library providers, the `window.feedBackViz_<id>` /
|
||||
`setRenderer` contract), so a plugin's **backend, capabilities, `nav`/`screen`,
|
||||
visualization renderers, diagnostics, and settings export work unchanged** — v3
|
||||
surfaces `nav` in its sidebar and mounts screens exactly as v2 does.
|
||||
surfaces `nav` in its sidebar and mounts screens as before.
|
||||
|
||||
**The only thing that changed is the player chrome.** If your plugin injects a
|
||||
control into it, you must adapt:
|
||||
@@ -155,7 +157,7 @@ control into it, you must adapt:
|
||||
popovers 40).
|
||||
|
||||
Full guide + the canonical snippet: **[docs/plugin-v3-ui.md](docs/plugin-v3-ui.md)**.
|
||||
Verify any player-injecting plugin in **both** `/` (v2) and `/v3`.
|
||||
Verify any player-injecting plugin at `/` — it and `/v3` serve the same v3 shell.
|
||||
|
||||
### Performance — never run DOM queries on a per-frame path
|
||||
|
||||
@@ -463,6 +465,40 @@ 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.
|
||||
@@ -552,6 +588,21 @@ 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
|
||||
@@ -564,7 +615,7 @@ a local pointer + code map.
|
||||
- **Storage** — `localStorage` for all user preferences
|
||||
- **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (feedBack-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II.
|
||||
- **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs
|
||||
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable. (v2 is unchanged.)
|
||||
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable.
|
||||
- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it.
|
||||
|
||||
## Backend Conventions
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Perf baseline — module-migration refactor
|
||||
|
||||
The refactor promises "measured runtime wins, no hand-waved perf claims" and
|
||||
"screen-entry and frame-time no worse." This is the baseline to hold it to.
|
||||
Rerun the harness after every phase (R0 → R3c) and compare.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
# 1. start core against a library with real charts (see caveat below)
|
||||
CONFIG_DIR=… DLC_DIR=/path/to/songs PYTHONPATH=lib \
|
||||
python3 -m uvicorn server:app --host 127.0.0.1 --port 8000
|
||||
|
||||
# 2. capture (maintainer/CI-only; uses the committed Playwright chromium)
|
||||
node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 --n 60 --soak 30
|
||||
```
|
||||
|
||||
The script prints a markdown block; paste it under "Results" below with the date
|
||||
and the commit it was taken at.
|
||||
|
||||
## What it measures
|
||||
|
||||
- **Server latency** — p50/p95/p99 over N requests for `/api/version`,
|
||||
`/api/plugins`, `/api/library`, `/api/library/artists`.
|
||||
- **Cold boot → interactive** — full page load to `networkidle`.
|
||||
- **JS heap** — `performance.memory.usedJSHeapSize` after load and after an idle
|
||||
soak (a leak signal across a session).
|
||||
- **Plugin-script shape** — how many plugin `<script>`s the loader injected (a
|
||||
"the app booted with its plugins" sanity signal).
|
||||
|
||||
**Not yet captured — needs a seeded library with charts** (fill in when run
|
||||
against a real environment): playback **frame-time p95** on the 2D and 3D
|
||||
highway, and **screen-entry** (plugin inject → interactive) for
|
||||
editor / notedetect / highway_3d with a chart loaded. These are the
|
||||
perf-sensitive numbers that gate the `highway.js` split (R3c); the harness has
|
||||
the hooks, they just need real songs in `DLC_DIR`.
|
||||
|
||||
## Results
|
||||
|
||||
### R3c pre-lift baseline — 2026-07-10 (2D highway draw cost)
|
||||
|
||||
The gate for the `highway.js` split. Captured on a seeded library (the 33 MB
|
||||
Arcturus feedpak) with the new `--song` mode, which measures **per-frame draw
|
||||
cost** — rAF callbacks are tagged via `highway.addDrawHook`, so only frames the
|
||||
highway actually painted count (the other ~half are cheap no-op loops that would
|
||||
otherwise mask a regression). Any `highway.js` change must re-run this on the
|
||||
same machine and stay within noise of these numbers.
|
||||
|
||||
```bash
|
||||
node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 \
|
||||
--song "Arcturus - The Sham Mirrors - Kinetic.feedpak"
|
||||
```
|
||||
|
||||
| run | draw frames | p50 | p95 | p99 | max |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 53/106 | 2.2 | 3.2 | 3.6 | 3.6 |
|
||||
| 2 | 50/100 | 2.2 | 2.9 | 3.2 | 3.2 |
|
||||
| 3 | 53/106 | 2.1 | 2.7 | 3.5 | 3.5 |
|
||||
|
||||
**p50 ≈ 2.2 ms · p95 spread 2.7–3.2 ms** (3 runs × 10 s playback, headless
|
||||
chromium on the dev box). The `H`-container lift changes each closure-slot read
|
||||
to a `H.<slot>` property load; this is the number that proves it doesn't cost the
|
||||
hot loop.
|
||||
|
||||
### R0 baseline — 2026-07-08 (branch `feat/r0-plugin-module-rails`)
|
||||
|
||||
> ⚠️ A quick capture (`--n 50 --soak 8`) against an **empty** library (no charts
|
||||
> in `DLC_DIR`), so the `/api/library*` and boot numbers are floor values —
|
||||
> re-take on a seeded environment with the recommended `--n 60 --soak 30` for the
|
||||
> real R0 baseline before comparing R1+ against it. Recorded here to prove the
|
||||
> harness and lock the methodology.
|
||||
|
||||
Server latency (ms), n=50:
|
||||
|
||||
| Endpoint | status | p50 | p95 | p99 |
|
||||
|---|---|---|---|---|
|
||||
| `/api/version` | 200 | 0.9 | 1.8 | 22.3 |
|
||||
| `/api/plugins` | 200 | 1.6 | 2.1 | 3.4 |
|
||||
| `/api/library?limit=60` | 200 | 1.4 | 1.7 | 2.9 |
|
||||
| `/api/library/artists` | 200 | 1.3 | 1.8 | 2.7 |
|
||||
|
||||
Client:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Cold boot → networkidle | 1268 ms |
|
||||
| JS heap after load | 10.1 MB |
|
||||
| JS heap after idle soak | 10.1 MB (no idle growth) |
|
||||
| Plugin scripts injected | 12 |
|
||||
|
||||
No plugin has migrated yet, so all 12 are classic. When the R1 pilot (stems)
|
||||
lands, cold-boot / heap should not regress.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Plugin ES-module migration playbook
|
||||
|
||||
How to move a plugin off a single global-scope `screen.js` IIFE onto a native
|
||||
ES-module graph — **no build step, no framework, no bundler**. This is the
|
||||
mechanism the monolith-killing refactor uses; the host rails for it shipped in
|
||||
R0 (see `.specify/memory/constitution.md` Principle II + the "Module load
|
||||
contract" in Operating Constraints).
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
my-plugin/
|
||||
plugin.json + "scriptType": "module" ← opt in
|
||||
screen.js import './src/main.js'; ← the entire file
|
||||
src/
|
||||
state.js (0) module state + accessors
|
||||
util/… (1) pure helpers — real-import testable
|
||||
…/… (2..4) model → render/audio/io → input
|
||||
globals.js (5) THE ONLY file that writes window.*
|
||||
main.js (5) boot: wire modules, register screen:changed
|
||||
assets/… worklets / WASM / images (unchanged, served as today)
|
||||
```
|
||||
|
||||
`screen.js` becomes a one-line static `import`. The host injects it as
|
||||
`<script type="module">`, whose load event fires **only after the whole
|
||||
static-import graph fetches and evaluates** — so the loader's
|
||||
completion-by-`onload` + `_loadingPluginId` window + `playSong` wrapper-chain
|
||||
order are all preserved. (A classic IIFE that fired a fire-and-forget
|
||||
`import()` would break that contract — don't do that; use `scriptType:"module"`.)
|
||||
|
||||
## Non-negotiable rules
|
||||
|
||||
1. **Source-served, no build.** Modules are plain source files fetched from
|
||||
`/api/plugins/<id>/src/<path>`. No bundler, transpiler, or TypeScript.
|
||||
2. **Layering points downward** — `state → util → commands/model →
|
||||
render/audio/io → input → globals/main`. A lint check (`import-x/no-cycle`)
|
||||
enforces acyclicity; extract bottom-up so each move only imports
|
||||
already-extracted layers.
|
||||
3. **`globals.js` is the only writer of `window.*`.** The deliberate global
|
||||
surface shrinks to one auditable file; everything else is module-scoped.
|
||||
4. **Import-time purity.** `node --test` runs a module's top-level code on
|
||||
import, so a module you want to unit-test must be side-effect-free at import:
|
||||
no `document` / `window` / `localStorage` at module top level — lift init
|
||||
into an exported `init()` called by `main.js`. (Constitution Principle V's
|
||||
"no implicit IO at import time", applied to the frontend.) Tests are `.mjs`
|
||||
and use real `import`, retiring the regex/`extractFunction` harness.
|
||||
5. **Assets resolve via `import.meta.url`.** `document.currentScript` is `null`
|
||||
inside a module. `assets/` lives at the plugin root, so a `src/` module must
|
||||
climb out of `src/`: from `src/main.js`, `new URL('../assets/x.js',
|
||||
import.meta.url)` (deeper modules need more `../`). Simpler and
|
||||
depth-independent: the absolute route `/api/plugins/<id>/assets/x.js`.
|
||||
Worklets run in a *separate* module graph (`AudioWorkletGlobalScope`) and
|
||||
cannot share modules with `src/`.
|
||||
6. **Re-init comes from `screen:changed`, not re-execution.** The host loads
|
||||
`screen.js` once per version and `showScreen` re-injects nothing, so module
|
||||
top-level code does **not** re-run when the user re-enters the screen at the
|
||||
same version. Keep per-visit setup/teardown in a `window.feedBack.on(
|
||||
'screen:changed', …)` handler — exactly as classic plugins (tuner,
|
||||
minigames) already do. Do not rely on the IIFE re-running.
|
||||
7. **Inline `onclick=` keeps working** during migration via `globals.js` (which
|
||||
keeps every referenced symbol on `window`); retire inline handlers to
|
||||
module-side `addEventListener` opportunistically, never as a blocking step.
|
||||
|
||||
## The live-edit loop
|
||||
|
||||
The host serves `screen.js`, `src/**`, and `assets/**` with
|
||||
`Cache-Control: no-cache` + a weak `ETag` and honors `If-None-Match` → `304`.
|
||||
So: edit a `src/` file → **refresh the browser** → the edited module returns
|
||||
`200` and reloads while every unchanged module `304`s. There is no hot-reload;
|
||||
the loop is edit → refresh → see change, exactly as before. The `?v=<version>`
|
||||
query on `screen.js` is the legacy version buster; it does **not** propagate
|
||||
into the `src/` graph and does not need to — ETag/mtime is the correctness
|
||||
authority for the whole graph.
|
||||
|
||||
## Host-version floor (`minHost`)
|
||||
|
||||
A migrated plugin *requires* a host new enough to serve `src/` and inject
|
||||
`type=module`. Declare the floor with `"minHost": "X.Y.Z"` in `plugin.json`.
|
||||
(R0 plumbs the field through `/api/plugins`; enforcement — refuse-with-message
|
||||
on an older host — is deferred, so bundled plugins are unaffected. Community
|
||||
plugins should state the floor and not migrate below it.)
|
||||
|
||||
## Migration mechanics
|
||||
|
||||
- **Move-only PRs.** One slice extracts one module: cut code, add
|
||||
imports/exports, update `globals.js` — zero behavior change. Behavior fixes
|
||||
are separate PRs. (Init-lifts for import purity are the one non-pure move —
|
||||
budget them.)
|
||||
- **Bottom-up, layer by layer.** Within a layer, independent modules are
|
||||
independent PRs (a DAG, not a chain); use a git worktree per branch.
|
||||
- Tests move with their subject and convert to real `.mjs` imports in the same
|
||||
PR (assertions unchanged).
|
||||
- Size norm: no source file over **1,500 lines**; legitimate exceptions
|
||||
(hot renderers, etc.) go in the signed register at `docs/size-exemptions.md`.
|
||||
|
||||
## Verifying a migration
|
||||
|
||||
`node --test <plugin>/tests/*.mjs`; load the plugin on the `:8000` testbed and
|
||||
confirm it boots (`<script type=module>` in DevTools, the `src/` graph in
|
||||
Network); edit a `src/` file → refresh → change visible (`200` on the edited
|
||||
file, `304` on the rest); leave and re-enter the screen at the same version →
|
||||
it re-inits via `screen:changed`. The R1 pilots (stems, then studio) certify
|
||||
this end-to-end before the flagship repos migrate.
|
||||
@@ -0,0 +1,354 @@
|
||||
# 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.
|
||||
@@ -1,9 +1,9 @@
|
||||
# Plugin styling — the `styles` capability
|
||||
|
||||
> Building for the redesigned **v3 UI** (`FEEDBACK_UI=v3` / `/v3`)? v3 uses `fb-*`
|
||||
> design tokens and a restructured player chrome with a dedicated plugin-control
|
||||
> slot. See **[plugin-v3-ui.md](plugin-v3-ui.md)** for the player-chrome contract
|
||||
> plugins must follow in v3.
|
||||
> The **v3 UI** is the only UI — it uses `fb-*` design tokens and a restructured
|
||||
> player chrome with a dedicated plugin-control slot. See
|
||||
> **[plugin-v3-ui.md](plugin-v3-ui.md)** for the player-chrome contract plugins
|
||||
> must follow.
|
||||
|
||||
FeedBack serves Tailwind as a **prebuilt** stylesheet
|
||||
(`static/tailwind.min.css`), never the runtime Play CDN. The CDN's on-the-fly
|
||||
|
||||
+11
-11
@@ -1,16 +1,16 @@
|
||||
# Building plugins for the v3 UI (fee[dB]ack v0.3.0)
|
||||
|
||||
v0.3.0 ("fee[dB]ack") ships a redesigned UI **behind a flag** — `FEEDBACK_UI=v3`
|
||||
or the `/v3` route. The classic UI (v2) remains the default until 0.3.0 ships, so
|
||||
plugins must work in **both**.
|
||||
v0.3.0 ("fee[dB]ack") ships a redesigned UI. It is **the only UI** — the classic v2
|
||||
shell and its `FEEDBACK_UI` / `/v2` opt-outs have been removed, so there is no
|
||||
longer a second shell to support.
|
||||
|
||||
The good news: v3 **reuses the same engine** as v2 — same `server.py`, `app.js`,
|
||||
`highway.js`, `playSong`, `showScreen`, capability registry, library providers,
|
||||
and the `window.feedBackViz_<id>` / `setRenderer` visualization contract. So your
|
||||
plugin's **backend, capabilities, library providers, `nav`/`screen`, visualization
|
||||
renderers, diagnostics, and settings export all work unchanged in v3.** v3 surfaces
|
||||
your `nav` entry in the new sidebar (via `shell.js` `renderPluginNav`) and your
|
||||
screen mounts exactly as before.
|
||||
The good news: v3 **reuses the same engine** the classic UI did — same `server.py`,
|
||||
`app.js`, `highway.js`, `playSong`, `showScreen`, capability registry, library
|
||||
providers, and the `window.feedBackViz_<id>` / `setRenderer` visualization contract.
|
||||
So your plugin's **backend, capabilities, library providers, `nav`/`screen`,
|
||||
visualization renderers, diagnostics, and settings export all work unchanged.** v3
|
||||
surfaces your `nav` entry in the new sidebar (via `shell.js` `renderPluginNav`) and
|
||||
your screen mounts exactly as before.
|
||||
|
||||
**The one thing that changed is the player chrome** — and only if your plugin
|
||||
injects controls into it.
|
||||
@@ -188,4 +188,4 @@ out of the capability graph.
|
||||
- [ ] Dropdowns positioned via `getBoundingClientRect()`, not `#player-controls`.
|
||||
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
|
||||
rail 30, popovers 40).
|
||||
- [ ] Verify in **both** `/` (v2) and `/v3`.
|
||||
- [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Size-exemption register
|
||||
|
||||
The working norm (constitution Principle II; enforced by the `max-lines` lint
|
||||
gate) is **no source file over 1,500 lines**. A few files are allowed to exceed
|
||||
it because splitting them would do more harm than good — hot per-frame
|
||||
renderers, C++, offline generators, cohesive registries. This register is the
|
||||
list of those exceptions: each row is a **deliberate, signed** decision with a
|
||||
ceiling, a rationale, and a review trigger. Without it, "no file over 1,500
|
||||
without a *signed* exemption" is unenforceable.
|
||||
|
||||
**Rules**
|
||||
- One row per file: a ceiling, a rationale, a signer, a review trigger.
|
||||
- The `max-lines` per-file ceilings in `eslint.config.js` mirror this table —
|
||||
keep them in sync (this register is canonical).
|
||||
- Files with a scheduled split **plan** are *not* exempt — they live in
|
||||
"Planned, not exempt" at the bottom so nothing falls between the two states.
|
||||
- **Signers** (decided 2026-07-08): **Byron** signs core + bundled rows;
|
||||
**Christian** signs the authored-plugin row (virtuoso, its own repo/track).
|
||||
|
||||
## Permanent exemptions (structural rationale)
|
||||
|
||||
| Repo / file | Lines (7-07) | Ceiling | Rationale | Signer | Review |
|
||||
|---|---|---|---|---|---|
|
||||
| core `static/highway.js` → residual `renderer-2d.js` (post-split) | ~2,400–2,900 est. | **3,000** | 60 fps hot path; no module boundary inside the per-frame loop | Byron | after the highway.js split |
|
||||
| core `plugins/highway_3d/` → residual renderer | sized at split; likely **>3,000** | set at split, flagged now | same hot-path rule; the draw core can't be cut without behavior risk | Byron | after the highway_3d split |
|
||||
| core `static/capabilities.js` | 1,538 | 1,600 | cohesive registry + `window.feedBack` bus, 38 lines over; a split spends credibility for nothing | Byron | R4 |
|
||||
| tutorials `builtin/reading-the-highway/generate.py` | 1,818 | 2,000 | offline content generator, never imported at runtime, deps not in runtime requirements | Byron | if a 3rd builtin pack appears |
|
||||
| desktop `src/audio/NodeAddon.cpp` | 3,542 | as-is | C++, outside the ESM/routes playbooks; under active use-after-free crash work — do not churn | Byron | after crash-class work settles |
|
||||
| desktop `src/audio/AudioEngine.cpp` | 2,977 | as-is | same | Byron | same |
|
||||
| desktop `src/vst-host/main.cpp` | 1,928 | as-is | same | Byron | same |
|
||||
| virtuoso `screen.js` (authored, own track) | 25,741 | as-is until its own split | authored plugin on a separate roadmap; migrates on its own schedule | Christian | virtuoso split kickoff |
|
||||
|
||||
## Split-when-touched (no scheduled train; row retires when split)
|
||||
|
||||
| Repo / file | Lines | Ceiling | Rationale | Signer | Review |
|
||||
|---|---|---|---|---|---|
|
||||
| core `lib/gp2rs_gpx.py` | 2,540 | as-is | import converter, off the serve-path hot loop | Byron | when next touched |
|
||||
| core `lib/gp2rs.py` | 2,055 | as-is | same | Byron | when next touched |
|
||||
| core `lib/song.py` | 1,689 | as-is | data models + wire format; cohesive | Byron | when next touched |
|
||||
| core `lib/gp_autosync.py` | 1,572 | as-is | under active dev (#787/#791) — don't collide | Byron | after in-flight work lands |
|
||||
| core `plugins/capability_inspector/screen.js` | 1,752 | as-is | bundled diagnostics plugin, low churn | Byron | when next touched |
|
||||
| core `plugins/folder_library/screen.js` | 1,672 | as-is | bundled plugin, low churn | Byron | when next touched |
|
||||
|
||||
## Temporary rows (cleared by a scheduled PR)
|
||||
|
||||
| Repo / file | Lines | Cleared by |
|
||||
|---|---|---|
|
||||
| core `plugins/__init__.py` | ~2,470 (grew under R0) | the `plugins/_routes.py` + `plugins/_registry.py` split (rides the server.py router work) |
|
||||
|
||||
## Watch list (under the norm — no row needed, re-census each phase)
|
||||
|
||||
`musicxml-import/mxml2notation.py` (1,456) · core `static/capabilities/audio-effects.js`
|
||||
(1,436) · `studio routes.py` (1,399) · `update-manager screen.js` (1,492 — zero headroom).
|
||||
|
||||
## 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`) ·
|
||||
`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) — 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.
|
||||
@@ -0,0 +1,73 @@
|
||||
// Flat ESLint config — MAINTAINER / CI ONLY. Never runs on the serve or Docker
|
||||
// path (constitution Principle I: dev-only tooling is exempt, same category as
|
||||
// scripts/build-tailwind.sh). It enforces the module-migration guardrails:
|
||||
//
|
||||
// * max-lines — the 1,500-line size norm, as a WARNING ratchet. Legacy
|
||||
// monoliths warn (the "this is over the norm, split it" signal) and shrink
|
||||
// as the refactor lands; warnings do not fail CI. Genuinely-large files are
|
||||
// exempted below, mirroring the signed register in docs/size-exemptions.md.
|
||||
// * import-x/no-unresolved + no-cycle — module hygiene, scoped to the real
|
||||
// ES-module graphs the refactor produces (a plugin's src/ tree, .mjs
|
||||
// tests). no-unresolved (a HARD error) catches broken import paths;
|
||||
// no-cycle enforces the downward-only layering rule. Core's classic scripts
|
||||
// have no import graph, so both are dormant today and become live gates the
|
||||
// moment module code appears — validated against the first real module
|
||||
// plugin (R1 pilot).
|
||||
|
||||
const importX = require('eslint-plugin-import-x');
|
||||
|
||||
// Per-file size ceilings — a mirror of docs/size-exemptions.md (canonical).
|
||||
// Keep in sync; each entry corresponds to a signed row in the register.
|
||||
const SIZE_EXEMPTIONS = [
|
||||
{ files: ['**/static/capabilities.js'], max: 1600 },
|
||||
{ files: ['**/plugins/capability_inspector/screen.js'], max: 100000 },
|
||||
{ files: ['**/plugins/folder_library/screen.js'], max: 100000 },
|
||||
];
|
||||
|
||||
const sizeRule = (max) => ['warn', { max, skipBlankLines: false, skipComments: false }];
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'static/vendor/**',
|
||||
'plugins/**/assets/vendor/**',
|
||||
'**/*.min.js',
|
||||
'static/tailwind.min.css',
|
||||
],
|
||||
},
|
||||
// Size norm across all first-party JS. Classic scripts are parsed as
|
||||
// scripts (no import/export); module files get their own block below.
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs'],
|
||||
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.
|
||||
{
|
||||
files: ['**/src/**/*.js', '**/*.mjs', 'static/app.js', 'static/js/**/*.js', 'static/highway.js'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: { 'import-x': importX },
|
||||
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
||||
// it the import rules silently skip imports they can't resolve.
|
||||
settings: { 'import-x/resolver-next': [importX.createNodeResolver()] },
|
||||
rules: {
|
||||
'max-lines': sizeRule(1500),
|
||||
'import-x/no-unresolved': 'error',
|
||||
'import-x/no-cycle': 'error',
|
||||
},
|
||||
},
|
||||
// Signed size exemptions (docs/size-exemptions.md) — raise the ceiling so
|
||||
// registered files don't warn below it.
|
||||
...SIZE_EXEMPTIONS.map(({ files, max }) => ({ files, rules: { 'max-lines': sizeRule(max) } })),
|
||||
];
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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/933
|
||||
reason: >-
|
||||
Added by #583 (the full mix played while every stem fader sits at unity,
|
||||
since demucs recombination is lossy). Core, lib/enrichment.py, and the
|
||||
stems plugin all depend on it, but it never went through a FEP and the
|
||||
spec does not define it — the drift this gate exists to prevent.
|
||||
|
||||
The resolution is REMOVAL, not a FEP: the spec already carries the mixdown
|
||||
as a stem ({id: full, file: stems/full.ogg}), so this key added a second,
|
||||
redundant location for audio to a format that already had one. See #933.
|
||||
|
||||
Grandfathered so the gate can land green and start blocking the *next*
|
||||
instance immediately, rather than blocking on #933. This entry goes away
|
||||
when core no longer reads or writes the key.
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Reading the app's config.json — the one shared, pure helper (R3).
|
||||
|
||||
Extracted verbatim from server.py so route modules that need a config value
|
||||
(reference pitch, server_config, …) can read it without reaching back into the
|
||||
host file. server.py re-imports it, so its ~11 call sites and any
|
||||
`server._load_config` test reference keep resolving unchanged.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def _load_config(config_file):
|
||||
"""Read and parse config.json. Returns the parsed dict, or None if
|
||||
the file is missing, unreadable, invalid JSON, or parses to a
|
||||
non-dict (e.g. the file contains `[]` or `42`). Callers treat None
|
||||
as "fall back to defaults". Shared between GET and POST so both
|
||||
handle bad files the same way."""
|
||||
if not config_file.exists():
|
||||
return None
|
||||
try:
|
||||
# Explicit UTF-8: save_settings()/import write config.json as
|
||||
# UTF-8 bytes, so the read must not depend on the platform's
|
||||
# default text encoding (cp1252 on Windows would mojibake or
|
||||
# UnicodeDecodeError on a non-ASCII DLC path).
|
||||
parsed = json.loads(config_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
"""Shared application state — the seam that lets route modules reach core
|
||||
singletons without importing ``server``.
|
||||
|
||||
``server.py`` is the host: it owns the FastAPI ``app``, constructs the DB
|
||||
singletons, and runs the lifecycle. As routes move out into ``routers/`` (R3),
|
||||
those modules need ``meta_db`` and friends — but they must not ``import
|
||||
server``, or the import graph goes circular the moment ``server`` imports them
|
||||
back.
|
||||
|
||||
So ``server`` **injects** its singletons here once, at the point it builds them::
|
||||
|
||||
# server.py
|
||||
meta_db = MetadataDB(CONFIG_DIR)
|
||||
appstate.configure(meta_db=meta_db, ...)
|
||||
|
||||
and a router reads them back as **module attributes, at call time**::
|
||||
|
||||
# routers/artists.py
|
||||
import appstate
|
||||
|
||||
@router.get("/api/artist/{name}/page")
|
||||
def artist_page(name):
|
||||
return appstate.meta_db.artist_page(name)
|
||||
|
||||
This is the Python analogue of the injected `configureX({...})` seams the
|
||||
frontend refactor uses (stems' ``configureStreaming``, studio's
|
||||
``configureAudioGraph``, the editor's ``src/host.js``), and of the plugin
|
||||
``setup(app, context)`` contract in Principle III: dependencies flow one way,
|
||||
``server -> routers -> appstate``, and nothing imports back up.
|
||||
|
||||
Two properties this shape buys, both load-bearing:
|
||||
|
||||
* **``import appstate`` performs no IO and constructs nothing.** ``server``
|
||||
still owns construction, so the ~49 test fixtures that do
|
||||
``sys.modules.pop("server")`` + re-import (to rebuild ``meta_db`` under a
|
||||
patched ``CONFIG_DIR``) keep working untouched — a singleton *owned* here
|
||||
would survive that pop and go stale.
|
||||
* **Reads are late-bound.** Routers must use ``appstate.meta_db``, never
|
||||
``from appstate import meta_db`` — a ``from`` import freezes the binding at
|
||||
its current value, so a later ``configure()`` (or a
|
||||
``monkeypatch.setattr(appstate, "meta_db", fake)``) would not reach the
|
||||
router. This is the same read-only-binding trap as ES ``import``.
|
||||
|
||||
Defaults are ``None`` on purpose: they are inert but *type-honest*, so a router
|
||||
that runs before ``configure()`` fails loudly on ``NoneType`` instead of
|
||||
quietly operating on a stand-in.
|
||||
|
||||
Slots are added here only when a router actually needs one — this is a seam,
|
||||
not a grab-bag for everything in ``server.py``.
|
||||
|
||||
**Why this lives in ``lib/`` and not the repo root.** Because it constructs
|
||||
nothing and does no import-time IO, it satisfies Principle V's rule for ``lib/``
|
||||
modules — and ``lib/`` is the only core directory every packaging path already
|
||||
copies: the Dockerfile (``COPY lib/``), ``docker-compose.yml``, and
|
||||
feedback-desktop's ``bundle-slopsmith.sh`` (``cp -r lib``). All three also put
|
||||
both the bundle root and ``lib/`` on ``sys.path``. A root-level module ships in
|
||||
Docker but is silently dropped from the packaged desktop app, whose bundler
|
||||
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
|
||||
# here. Routers read them as `appstate.config_dir` etc. — a module attribute at
|
||||
# call time. NOTE: config_dir/dlc_dir are env-derived, so a `setenv`+reimport
|
||||
# test reconfigures them for free; STATIC_DIR/SLOPPAK_CACHE_DIR are patched via
|
||||
# `setattr(server, …)` in a few tests, so those slots (when added) need their
|
||||
# tests retargeted to appstate in the same PR.
|
||||
config_dir = None
|
||||
dlc_dir = None # the DLC_DIR env value as a Path (Path("") if unset)
|
||||
dlc_dir_env = None # the raw DLC_DIR env string, "" if unset — distinguishes
|
||||
# "unset" from Path("")→"." (see dlc_paths._get_dlc_dir)
|
||||
# Cache/asset dirs. static_dir + sloppak_cache_dir are patched via
|
||||
# `setattr(server, …)` in a few tests, so a router reading them here needs those
|
||||
# setattr sites retargeted to `setattr(appstate, …)` in the same PR (ws_highway
|
||||
# retargets the 3 test_highway_ws_* SLOPPAK sites). config_dir-derived dirs are
|
||||
# reconfigured for free on a setenv+reimport.
|
||||
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",
|
||||
"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",
|
||||
})
|
||||
|
||||
|
||||
def configure(**kwargs) -> None:
|
||||
"""Publish `server`'s singletons into this module. Called once per
|
||||
`server` import (and again on re-import), so it must be idempotent."""
|
||||
unknown = set(kwargs) - _SLOTS
|
||||
if unknown:
|
||||
raise TypeError(
|
||||
f"appstate.configure() got unknown slot(s): {sorted(unknown)}. "
|
||||
f"Known slots: {sorted(_SLOTS)}. Add the name to _SLOTS if a router "
|
||||
f"genuinely needs it."
|
||||
)
|
||||
globals().update(kwargs)
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Core-owned song/tone -> audio-effect-provider mapping index.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3). ``server.py`` still owns the
|
||||
``audio_effect_mappings`` singleton; this module only supplies the class, so
|
||||
nothing here touches config paths at import time — the caller passes
|
||||
``config_dir`` in.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class AudioEffectsMappingDB:
|
||||
"""Core-owned public song/tone -> provider mapping index.
|
||||
|
||||
Providers own the preset/chain rows addressed by provider_ref. Core owns
|
||||
the cross-provider routing index and the active mapping per song/tone.
|
||||
"""
|
||||
|
||||
def __init__(self, config_dir: Path):
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.db_path = str(config_dir / "audio_effects.db")
|
||||
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||
self.conn.execute("PRAGMA foreign_keys=ON")
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS audio_effect_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
song_key TEXT NOT NULL,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
tone_key TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
provider_ref TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(song_key, tone_key, provider_id)
|
||||
)
|
||||
""")
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS audio_effect_active_mappings (
|
||||
song_key TEXT NOT NULL,
|
||||
tone_key TEXT NOT NULL,
|
||||
mapping_id INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (song_key, tone_key),
|
||||
FOREIGN KEY (mapping_id) REFERENCES audio_effect_mappings(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
self.conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_audio_effect_mappings_provider "
|
||||
"ON audio_effect_mappings(provider_id)"
|
||||
)
|
||||
self.conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_audio_effect_mappings_filename "
|
||||
"ON audio_effect_mappings(filename)"
|
||||
)
|
||||
self.conn.commit()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _text(value, *, field: str, limit: int, allow_empty: bool = False) -> str:
|
||||
if value is None:
|
||||
text = ""
|
||||
elif not isinstance(value, str):
|
||||
raise ValueError(f"{field} must be a string")
|
||||
else:
|
||||
text = value.strip()
|
||||
if not text and not allow_empty:
|
||||
raise ValueError(f"{field} is required")
|
||||
if len(text) > limit:
|
||||
raise ValueError(f"{field} is too long")
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _mapping_id(value) -> int | None:
|
||||
# Bind only values SQLite can store as an INTEGER; an out-of-range id is a
|
||||
# clean miss (404), not a 500 at bind time.
|
||||
if isinstance(value, int) and not isinstance(value, bool) and -(2 ** 63) <= value < 2 ** 63:
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _field(data: dict, *keys):
|
||||
# Select the first present snake/camel alias by key, not by truthiness, so a
|
||||
# falsey non-string value (false/0) still reaches _text() and is rejected
|
||||
# instead of being silently swallowed by an `or` chain.
|
||||
for key in keys:
|
||||
if key in data:
|
||||
return data[key]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _metadata(value) -> str:
|
||||
if value is None:
|
||||
return "{}"
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("metadata must be an object")
|
||||
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True)
|
||||
if len(encoded) > 8192:
|
||||
raise ValueError("metadata is too large")
|
||||
return encoded
|
||||
|
||||
@staticmethod
|
||||
def _row(row) -> dict | None:
|
||||
if row is None:
|
||||
return None
|
||||
metadata = {}
|
||||
try:
|
||||
metadata = json.loads(row[8]) if row[8] else {}
|
||||
except Exception:
|
||||
metadata = {}
|
||||
return {
|
||||
"id": int(row[0]),
|
||||
"song_key": row[1],
|
||||
"filename": row[2] or "",
|
||||
"tone_key": row[3],
|
||||
"provider_id": row[4],
|
||||
"provider_ref": row[5],
|
||||
"label": row[6] or "",
|
||||
"source": row[7] or "manual",
|
||||
"metadata": metadata if isinstance(metadata, dict) else {},
|
||||
"created_at": row[9] or "",
|
||||
"updated_at": row[10] or "",
|
||||
"active": bool(row[11]),
|
||||
}
|
||||
|
||||
def _select_sql(self) -> str:
|
||||
return """
|
||||
SELECT m.id, m.song_key, m.filename, m.tone_key, m.provider_id,
|
||||
m.provider_ref, m.label, m.source, m.metadata_json,
|
||||
m.created_at, m.updated_at,
|
||||
CASE WHEN a.mapping_id IS NULL THEN 0 ELSE 1 END AS active
|
||||
FROM audio_effect_mappings m
|
||||
LEFT JOIN audio_effect_active_mappings a
|
||||
ON a.song_key = m.song_key AND a.tone_key = m.tone_key AND a.mapping_id = m.id
|
||||
"""
|
||||
|
||||
def list(self, *, song_key: str = "", filename: str = "", tone_key: str = "", provider_id: str = "") -> list[dict]:
|
||||
clauses: list[str] = []
|
||||
params: list[str] = []
|
||||
song_key = self._text(song_key, field="song_key", limit=240, allow_empty=True)
|
||||
filename = self._text(filename, field="filename", limit=500, allow_empty=True)
|
||||
tone_key = self._text(tone_key, field="tone_key", limit=160, allow_empty=True)
|
||||
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
|
||||
if song_key and filename:
|
||||
clauses.append("(m.song_key = ? OR m.filename = ?)")
|
||||
params.extend([song_key, filename])
|
||||
elif song_key:
|
||||
clauses.append("m.song_key = ?")
|
||||
params.append(song_key)
|
||||
elif filename:
|
||||
clauses.append("(m.song_key = ? OR m.filename = ?)")
|
||||
params.extend([filename, filename])
|
||||
if tone_key:
|
||||
clauses.append("m.tone_key = ?")
|
||||
params.append(tone_key)
|
||||
if provider_id:
|
||||
clauses.append("m.provider_id = ?")
|
||||
params.append(provider_id)
|
||||
sql = self._select_sql()
|
||||
if clauses:
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
sql += " ORDER BY m.song_key COLLATE NOCASE, m.tone_key COLLATE NOCASE, m.provider_id COLLATE NOCASE"
|
||||
with self._lock:
|
||||
rows = self.conn.execute(sql, params).fetchall()
|
||||
return [self._row(row) for row in rows]
|
||||
|
||||
def get(self, mapping_id: int) -> dict | None:
|
||||
mapping_id = self._mapping_id(mapping_id)
|
||||
if mapping_id is None:
|
||||
return None
|
||||
with self._lock:
|
||||
row = self.conn.execute(self._select_sql() + " WHERE m.id = ?", (mapping_id,)).fetchone()
|
||||
return self._row(row)
|
||||
|
||||
def upsert(self, data: dict) -> dict:
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("mapping body must be an object")
|
||||
filename = self._text(data.get("filename", ""), field="filename", limit=500, allow_empty=True)
|
||||
song_key_raw = self._field(data, "song_key", "songKey")
|
||||
if song_key_raw is None or song_key_raw == "":
|
||||
song_key_raw = filename
|
||||
song_key = self._text(song_key_raw, field="song_key", limit=240)
|
||||
tone_key = self._text(self._field(data, "tone_key", "toneKey"), field="tone_key", limit=160, allow_empty=True)
|
||||
provider_id = self._text(self._field(data, "provider_id", "providerId"), field="provider_id", limit=96)
|
||||
provider_ref = self._text(self._field(data, "provider_ref", "providerRef"), field="provider_ref", limit=240)
|
||||
label = self._text(data.get("label", ""), field="label", limit=160, allow_empty=True)
|
||||
source = self._text(data.get("source", "manual"), field="source", limit=40, allow_empty=True) or "manual"
|
||||
metadata_json = self._metadata(data.get("metadata", {}))
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO audio_effect_mappings
|
||||
(song_key, filename, tone_key, provider_id, provider_ref, label, source, metadata_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(song_key, tone_key, provider_id) DO UPDATE SET
|
||||
-- Only overwrite filename when a non-empty one was supplied; an
|
||||
-- omitted/empty filename must preserve the stored value (it's an
|
||||
-- alternate lookup key for list(..., filename=...)).
|
||||
filename=CASE WHEN excluded.filename <> '' THEN excluded.filename ELSE audio_effect_mappings.filename END,
|
||||
provider_ref=excluded.provider_ref,
|
||||
label=excluded.label,
|
||||
source=excluded.source,
|
||||
metadata_json=excluded.metadata_json,
|
||||
updated_at=datetime('now')
|
||||
""",
|
||||
(song_key, filename, tone_key, provider_id, provider_ref, label, source, metadata_json),
|
||||
)
|
||||
row = self.conn.execute(
|
||||
"SELECT id FROM audio_effect_mappings WHERE song_key = ? AND tone_key = ? AND provider_id = ?",
|
||||
(song_key, tone_key, provider_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("failed to create audio-effects mapping")
|
||||
mapping_id = int(row[0])
|
||||
if data.get("active") is True:
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO audio_effect_active_mappings (song_key, tone_key, mapping_id, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(song_key, tone_key) DO UPDATE SET
|
||||
mapping_id=excluded.mapping_id,
|
||||
updated_at=datetime('now')
|
||||
""",
|
||||
(song_key, tone_key, mapping_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get(mapping_id)
|
||||
|
||||
def delete(self, mapping_id: int, *, provider_id: str = "") -> bool:
|
||||
mapping_id = self._mapping_id(mapping_id)
|
||||
if mapping_id is None:
|
||||
return False
|
||||
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
|
||||
with self._lock:
|
||||
if provider_id:
|
||||
cur = self.conn.execute(
|
||||
"DELETE FROM audio_effect_mappings WHERE id = ? AND provider_id = ?",
|
||||
(mapping_id, provider_id),
|
||||
)
|
||||
else:
|
||||
cur = self.conn.execute("DELETE FROM audio_effect_mappings WHERE id = ?", (mapping_id,))
|
||||
self.conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
def activate(self, mapping_id: int, *, provider_id: str = "") -> dict | None:
|
||||
mapping_id = self._mapping_id(mapping_id)
|
||||
if mapping_id is None:
|
||||
return None
|
||||
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
|
||||
with self._lock:
|
||||
row = self.conn.execute(
|
||||
self._select_sql() + " WHERE m.id = ?",
|
||||
(mapping_id,),
|
||||
).fetchone()
|
||||
mapping = self._row(row)
|
||||
if not mapping or (provider_id and mapping["provider_id"] != provider_id):
|
||||
return None
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO audio_effect_active_mappings (song_key, tone_key, mapping_id, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(song_key, tone_key) DO UPDATE SET
|
||||
mapping_id=excluded.mapping_id,
|
||||
updated_at=datetime('now')
|
||||
""",
|
||||
(mapping["song_key"], mapping["tone_key"], mapping_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
selected = self.conn.execute(self._select_sql() + " WHERE m.id = ?", (mapping_id,)).fetchone()
|
||||
return self._row(selected)
|
||||
|
||||
def clear_active(self, *, song_key: str, tone_key: str) -> bool:
|
||||
song_key = self._text(song_key, field="song_key", limit=240)
|
||||
tone_key = self._text(tone_key, field="tone_key", limit=160, allow_empty=True)
|
||||
with self._lock:
|
||||
cur = self.conn.execute(
|
||||
"DELETE FROM audio_effect_active_mappings WHERE song_key = ? AND tone_key = ?",
|
||||
(song_key, tone_key),
|
||||
)
|
||||
self.conn.commit()
|
||||
return cur.rowcount > 0
|
||||
@@ -0,0 +1,378 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,380 @@
|
||||
"""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
|
||||
@@ -0,0 +1,99 @@
|
||||
"""DLC library path resolution — where the song files live, plus safe containment.
|
||||
|
||||
Extracted from ``server.py`` (R3). ``_resolve_dlc_path`` is pure and moved
|
||||
verbatim. ``_get_dlc_dir`` reads the env-derived paths through the ``appstate``
|
||||
seam (``server.py`` configures ``dlc_dir``/``dlc_dir_env``/``config_dir`` at
|
||||
import, fresh on every re-import), so this module does no import-time IO and the
|
||||
pop-and-reimport fixtures keep working. ``server.py`` re-exports both names, so
|
||||
existing ``server._get_dlc_dir`` / ``server._resolve_dlc_path`` references
|
||||
(tests, other handlers) resolve unchanged.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import appstate
|
||||
|
||||
|
||||
def _get_dlc_dir(cfg: dict | None = None) -> Path | None:
|
||||
# Only consider DLC_DIR if the env var was non-empty. `Path("")` collapses
|
||||
# to `.` and reports `.is_dir() == True`, which would silently shadow the
|
||||
# config.json fallback. Checking the raw env string preserves
|
||||
# `DLC_DIR=.` as a valid opt-in for cwd while keeping unset/empty out.
|
||||
if appstate.dlc_dir_env and appstate.dlc_dir.is_dir():
|
||||
return appstate.dlc_dir
|
||||
if cfg is None:
|
||||
config_file = appstate.config_dir / "config.json"
|
||||
if config_file.exists():
|
||||
try:
|
||||
cfg = json.loads(config_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(cfg, dict):
|
||||
raw = str(cfg.get("dlc_dir", "")).strip()
|
||||
if raw:
|
||||
p = Path(raw)
|
||||
if p.is_dir():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
|
||||
"""Resolve `filename` under DLC_DIR and refuse anything that escapes.
|
||||
|
||||
`filename` arrives from `:path` route params and can contain `..`
|
||||
segments. The Sloppak and archive paths happen to fail safely later
|
||||
because their loaders raise on missing/invalid files, but loose-
|
||||
folder format detection (`is_loose_song`) globs and parses XML on
|
||||
disk first, which lets a crafted path trigger filesystem reads
|
||||
outside DLC_DIR before any guard fires. Centralise the containment
|
||||
check so every filename-bound handler validates before touching the
|
||||
filesystem.
|
||||
|
||||
Containment here is LEXICAL (normalize `.`/`..` WITHOUT following
|
||||
symlinks), not `safe_join`'s `.resolve()`-based check — because users
|
||||
commonly mount their song library through a directory JUNCTION/symlink
|
||||
(a library shared across app installs; the desktop app's own mounts).
|
||||
`.resolve()` follows that junction to its real target, sees it sits
|
||||
outside DLC_DIR, and wrongly rejects every song reached through it — the
|
||||
scanner's `rglob` indexes those songs, but art/load then 403/404s (broken
|
||||
covers, unplayable songs). Lexical normalization still rejects the only
|
||||
escapes a `:path` filename can express — `..` traversal and absolute
|
||||
paths — which the traversal tests pin. `safe_join` stays strict (it is
|
||||
the zip-slip / plugin-asset guard, where following a symlink out IS the
|
||||
defense); the loose-folder art handler keeps its own per-file symlink
|
||||
re-check for defence-in-depth.
|
||||
|
||||
Returns the validated Path (not necessarily link-resolved), or None if
|
||||
the filename is empty, contains a NUL, or escapes the DLC root.
|
||||
"""
|
||||
if not filename:
|
||||
return None
|
||||
# Backslashes → forward slashes so a Windows-style `..\\x` traversal is
|
||||
# rejected identically on POSIX (mirrors safe_join's normalisation).
|
||||
safe = filename.replace("\\", "/")
|
||||
if "\x00" in safe:
|
||||
return None
|
||||
# Reject drive-letter / absolute paths in BOTH conventions. A POSIX "/x" is
|
||||
# caught by the containment check below (the `/` operator discards `root`),
|
||||
# but a Windows drive-absolute "C:/x" is treated as a relative "C:" dir on
|
||||
# POSIX and would otherwise slip in as `<root>/C:/x` — so the contract must
|
||||
# hold cross-platform (a shared library is reached from either OS).
|
||||
from pathlib import PurePosixPath, PureWindowsPath
|
||||
if (PurePosixPath(safe).is_absolute()
|
||||
or PureWindowsPath(safe).is_absolute()
|
||||
or PureWindowsPath(safe).drive):
|
||||
return None
|
||||
try:
|
||||
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
|
||||
# get caught by the containment check below.
|
||||
candidate = Path(os.path.normpath(root / safe))
|
||||
if not candidate.is_relative_to(root):
|
||||
return None
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
return candidate
|
||||
+1107
File diff suppressed because it is too large
Load Diff
+43
-12
@@ -1114,7 +1114,7 @@ def _build_xml(
|
||||
ET.SubElement(root, "arrangement").text = arrangement
|
||||
ET.SubElement(root, "offset").text = f"{audio_offset:.3f}"
|
||||
ET.SubElement(root, "songLength").text = f"{song_length:.3f}"
|
||||
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.3f}" if beats else "0.000"
|
||||
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.6f}" if beats else "0.000000"
|
||||
ET.SubElement(root, "averageTempo").text = str(tempo)
|
||||
ET.SubElement(root, "artistName").text = artist
|
||||
ET.SubElement(root, "albumName").text = album
|
||||
@@ -1139,10 +1139,17 @@ def _build_xml(
|
||||
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
|
||||
ET.SubElement(root, "capo").text = "0"
|
||||
|
||||
# Ebeats
|
||||
# Ebeats — write beat times at MICROSECOND (6-decimal) precision, not
|
||||
# millisecond (3-decimal). The editor/timeline DERIVES per-bar BPM from beat
|
||||
# spans (bpm = beats·60/span), which amplifies any rounding: at 3 decimals a
|
||||
# constant-tempo GP (e.g. 140) shows a spurious ±0.05–0.7 BPM per-bar drift
|
||||
# (worse for fast/odd meters) because most bar lengths don't land on a ms
|
||||
# boundary. gp2rs computes these times exactly from the GP tempo map, so the
|
||||
# only loss is this format string — 6 decimals makes the derived tempo match
|
||||
# GP's authored value. (Everything else stays at :.3f; only beats drive tempo.)
|
||||
ebeats = ET.SubElement(root, "ebeats", count=str(len(beats)))
|
||||
for b in beats:
|
||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.3f}", measure=str(b.measure))
|
||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.6f}", measure=str(b.measure))
|
||||
|
||||
# Sections
|
||||
sections_el = ET.SubElement(root, "sections", count=str(len(sections)))
|
||||
@@ -1836,9 +1843,10 @@ def convert_drum_track_to_drumtab(
|
||||
drum strings. Unknown percussion sounds (cowbell, tambourine etc.) are
|
||||
skipped — round-tripping them would require teaching `lib/drums.py` first.
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a per-MIDI
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...]}}``,
|
||||
times capped at 100 samples per note) so they can surface a warning or
|
||||
offer a manual mapping UI.
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...],
|
||||
"velocities": [...]}}``, times/velocities index-aligned and capped at
|
||||
100 samples per note — velocities carry the source notes' real dynamics)
|
||||
so they can surface a warning or offer a manual mapping UI.
|
||||
|
||||
Honours GP repeat brackets and D.S./D.C./Coda/Fine jumps when
|
||||
``expand_repeats`` is true — same `_build_playback_schedule` machinery
|
||||
@@ -1894,18 +1902,29 @@ def convert_drum_track_to_drumtab(
|
||||
# NB: do NOT shadow the outer `entry` loop
|
||||
# variable from `for entry in schedule:`.
|
||||
unmapped_rec = out_unmapped.setdefault(
|
||||
int(midi_note), {"count": 0, "times": []})
|
||||
int(midi_note),
|
||||
{"count": 0, "times": [], "velocities": []})
|
||||
unmapped_rec["count"] += 1
|
||||
if len(unmapped_rec["times"]) < 100:
|
||||
unmapped_rec["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real
|
||||
# dynamics (same 1-127 gate as mapped hits,
|
||||
# falling back to the 100 import default) so
|
||||
# a hand-mapping UI doesn't flatten them.
|
||||
_uv = int(getattr(note, "velocity", 0) or 0)
|
||||
unmapped_rec["velocities"].append(
|
||||
_uv if 1 <= _uv <= 127 else 100)
|
||||
continue
|
||||
|
||||
hit: dict = {"t": round(t, 3), "p": piece}
|
||||
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly; default
|
||||
# is 95 (Velocities.default). Pass through verbatim,
|
||||
# clamping defensively so a corrupt file can't poison the
|
||||
# wire format.
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly. Note
|
||||
# this is GP's *authoring* default (95, Velocities.default)
|
||||
# — unrelated to the drumtab render default of 100
|
||||
# (DEFAULT_VELOCITY, lib/drums.py:179), which only applies
|
||||
# when `v` is omitted from a hit. Pass the GP value through
|
||||
# verbatim, clamping defensively so a corrupt file can't
|
||||
# poison the wire format.
|
||||
vel = int(getattr(note, "velocity", 0) or 0)
|
||||
if 1 <= vel <= 127:
|
||||
hit["v"] = vel
|
||||
@@ -1946,9 +1965,21 @@ def convert_drum_track_to_drumtab(
|
||||
# Times for unmapped notes were collected in beat-iteration order;
|
||||
# multi-voice measures can produce out-of-order beats, so sort each
|
||||
# entry's `times` list chronologically before returning to the caller.
|
||||
# Velocities are index-aligned with times, so they must sort in
|
||||
# LOCKSTEP — sorting times alone would silently reassign dynamics.
|
||||
if out_unmapped is not None:
|
||||
for _rec in out_unmapped.values():
|
||||
_rec["times"].sort()
|
||||
_vels = _rec.get("velocities")
|
||||
if _vels and len(_vels) == len(_rec["times"]):
|
||||
_pairs = sorted(zip(_rec["times"], _vels))
|
||||
_rec["times"] = [p[0] for p in _pairs]
|
||||
_rec["velocities"] = [p[1] for p in _pairs]
|
||||
else:
|
||||
# Belt-and-suspenders: times & velocities are always appended
|
||||
# together under the same `len(times) < 100` guard above, so
|
||||
# in practice the lengths can't diverge. Kept as a defensive
|
||||
# fallback, not a real divergence case.
|
||||
_rec["times"].sort()
|
||||
|
||||
return {
|
||||
"version": drums_mod.SCHEMA_VERSION,
|
||||
|
||||
@@ -1506,6 +1506,10 @@ def convert_file(
|
||||
# Surface that to the caller rather than only the docstring: if the score
|
||||
# actually uses repeats, the produced bar count/timing will differ from the
|
||||
# equivalent .gp5. Warn once so plugin code/logs don't silently drift.
|
||||
# NB: lib/gp_autosync.gp_has_expandable_repeats() encodes this single-pass
|
||||
# behaviour (.gp/.gpx never expand). Implementing GPIF expansion here MUST
|
||||
# update that helper in the same change, or the editor's per-bar sync warp
|
||||
# would silently retime repeated sections onto the wrong bars.
|
||||
if expand_repeats and any(
|
||||
mb.find('Repeat') is not None or mb.find('AlternateEndings') is not None
|
||||
for mb in masterbars
|
||||
|
||||
+524
-29
@@ -18,8 +18,22 @@ plugin is installed; graceful ImportError otherwise with clear message).
|
||||
Public API:
|
||||
is_available() -> bool
|
||||
auto_sync(gp_path, audio_path, ...) -> GpSyncData
|
||||
refine_sync(sync, audio_path, ...) -> GpSyncData
|
||||
estimate_audio_offset(gp_path,
|
||||
audio_path) -> float
|
||||
bar_start_times(gp_path) -> list[float]
|
||||
gp_has_expandable_repeats(gp_path) -> bool
|
||||
build_warp_anchors(sync_points,
|
||||
bar_starts) -> list[tuple[float, float]]
|
||||
warp_time(t, anchors) -> float
|
||||
warp_song_times(song, warp) -> None
|
||||
|
||||
The warp helpers (bar_start_times / build_warp_anchors / warp_time /
|
||||
warp_song_times) are librosa-free: they turn a GpSyncData produced by
|
||||
auto_sync (or extracted from a GP8 file) into a piecewise-linear
|
||||
score-time -> audio-time mapping and apply it to a lib.song.Song, so
|
||||
converted charts follow the recording's actual tempo drift instead of a
|
||||
single scalar offset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -353,15 +367,22 @@ def _synthesise_score_chroma(
|
||||
return chroma
|
||||
|
||||
_GP345_TICKS_PER_QUARTER = 960
|
||||
# PyGuitarPro absolute ticks start at quarterTime (measure 1 begins at tick
|
||||
# 960, not 0). All tick math in this module runs on a 0-based axis (cumulative
|
||||
# measure starts), so raw beat.start values must be shifted by this origin —
|
||||
# mixing the two axes applied every mid-song tempo change a quarter note late
|
||||
# and skewed the synthesised chroma against the bar timeline.
|
||||
_GP345_TICK_ORIGIN = 960
|
||||
|
||||
|
||||
def _gp345_tempo_events(song) -> list[tuple[int, float]]:
|
||||
"""Sorted, tick-deduplicated ``[(tick, bpm)]`` tempo events for a GP3/4/5 song.
|
||||
|
||||
Seeds with the song's initial tempo at tick 0, then appends every
|
||||
``mixTableChange`` tempo. Shared by chroma synthesis and bar-time
|
||||
computation so both use one identical tempo model (mirrors
|
||||
``gp2rs._build_tempo_map``).
|
||||
``mixTableChange`` tempo. Ticks are normalised to the 0-based axis
|
||||
(raw ``beat.start`` minus ``_GP345_TICK_ORIGIN``). Shared by chroma
|
||||
synthesis and bar-time computation so both use one identical tempo
|
||||
model (mirrors ``gp2rs._build_tempo_map``).
|
||||
"""
|
||||
events: list[tuple[int, float]] = [(0, float(song.tempo))]
|
||||
for track in song.tracks:
|
||||
@@ -371,7 +392,10 @@ def _gp345_tempo_events(song) -> list[tuple[int, float]]:
|
||||
if beat.effect and beat.effect.mixTableChange:
|
||||
mtc = beat.effect.mixTableChange
|
||||
if mtc.tempo and mtc.tempo.value > 0:
|
||||
events.append((beat.start, float(mtc.tempo.value)))
|
||||
events.append((
|
||||
max(0, beat.start - _GP345_TICK_ORIGIN),
|
||||
float(mtc.tempo.value),
|
||||
))
|
||||
events.sort(key=lambda e: e[0])
|
||||
seen_ticks: set[int] = set()
|
||||
unique: list[tuple[int, float]] = []
|
||||
@@ -459,8 +483,9 @@ def _synthesise_score_chroma_gp345(
|
||||
for beat in voice.beats:
|
||||
if not beat.notes:
|
||||
continue
|
||||
beat_secs = tick_to_secs(beat.start)
|
||||
cur_tempo = tempo_at_tick(beat.start)
|
||||
beat_tick = max(0, beat.start - _GP345_TICK_ORIGIN)
|
||||
beat_secs = tick_to_secs(beat_tick)
|
||||
cur_tempo = tempo_at_tick(beat_tick)
|
||||
dur_secs = duration_to_secs(beat.duration, cur_tempo)
|
||||
|
||||
for note in beat.notes:
|
||||
@@ -513,13 +538,75 @@ def _dtw_align(
|
||||
Returns wp where wp[i] = [score_frame_index, audio_frame_index].
|
||||
"""
|
||||
import librosa
|
||||
import numpy as np
|
||||
cs = _safe_normalise(chroma_score)
|
||||
ca = _safe_normalise(chroma_audio)
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
# Slope-constrained step pattern ([[1,1],[1,2],[2,1]], Müller's standard
|
||||
# music-sync config): every step advances BOTH axes, bounding the local
|
||||
# tempo ratio to 0.5x-2x. librosa's default steps allow pure
|
||||
# horizontal/vertical runs, and on riff-based music (long self-similar
|
||||
# chroma stretches, e.g. stoner/doom) the flat cost surface let the path
|
||||
# collapse — whole minutes of score mapped onto a single audio frame,
|
||||
# producing garbage sync points. The constrained pattern makes that
|
||||
# degenerate path impossible.
|
||||
steps = np.array([[1, 1], [1, 2], [2, 1]])
|
||||
weights = np.array([1.0, 1.0, 1.0])
|
||||
try:
|
||||
_D, wp = librosa.sequence.dtw(
|
||||
cs, ca, metric='cosine',
|
||||
step_sizes_sigma=steps, weights_mul=weights,
|
||||
)
|
||||
except Exception as exc:
|
||||
# The constrained pattern needs the global length ratio within its
|
||||
# 0.5x-2x slope bounds; a pathological pairing (e.g. a 3-minute tab
|
||||
# against a 20-minute video) is infeasible and librosa raises. Fall
|
||||
# back to the unconstrained path rather than failing the whole sync.
|
||||
_log.warning("gp_autosync: constrained DTW infeasible (%s) — "
|
||||
"falling back to unconstrained steps", exc)
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
return wp[::-1] # reverse to forward order
|
||||
|
||||
# ── Sync point extraction from DTW path ──────────────────────────────────────
|
||||
|
||||
def _gpif_bar_starts(root: ET.Element) -> list[float]:
|
||||
"""Score-time (seconds) at the start of each masterbar in a GPIF score.
|
||||
|
||||
Integrates bar durations from the bar-resolution tempo map and each
|
||||
masterbar's time signature — the same time model _synthesise_score_chroma
|
||||
uses, so bar times land where the bars sit in the synthesised chroma.
|
||||
"""
|
||||
tempo_map = _get_tempo_map(root)
|
||||
masterbars = _children(root, 'MasterBars')
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
|
||||
ct = tempo_map[0][1]
|
||||
t_cur = 0.0
|
||||
bar_starts: list[float] = []
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tb:
|
||||
ct = next_bpm
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, ct))
|
||||
bar_starts.append(t_cur)
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
n_b, d_b = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
n_b, d_b = 4, 4
|
||||
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
|
||||
return bar_starts
|
||||
|
||||
|
||||
def _gp345_measure_start_ticks(song) -> list[int]:
|
||||
"""Cumulative start tick of each measure in a PyGuitarPro song."""
|
||||
starts: list[int] = []
|
||||
cum = 0
|
||||
for mh in song.measureHeaders:
|
||||
starts.append(cum)
|
||||
ts = mh.timeSignature
|
||||
cum += int(ts.numerator * (4.0 / ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
|
||||
return starts
|
||||
|
||||
|
||||
def _extract_sync_points(
|
||||
wp: 'np.ndarray',
|
||||
root: ET.Element,
|
||||
@@ -565,22 +652,7 @@ def _extract_sync_points(
|
||||
if bar_starts_override is not None:
|
||||
bar_starts_score = list(bar_starts_override)
|
||||
else:
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
|
||||
ct = tempo_map[0][1]
|
||||
t_cur = 0.0
|
||||
bar_starts_score = []
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tb:
|
||||
ct = next_bpm
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, ct))
|
||||
bar_starts_score.append(t_cur)
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
n_b, d_b = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
n_b, d_b = 4, 4
|
||||
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
|
||||
bar_starts_score = _gpif_bar_starts(root)
|
||||
|
||||
# Map each sampled bar to its audio time via the DTW path
|
||||
sync_points: list[SyncPoint] = []
|
||||
@@ -663,6 +735,224 @@ def _tempo_at_bar(tempo_map: list[tuple[int, float]], bar: int) -> float:
|
||||
|
||||
# ── Audio offset estimation ───────────────────────────────────────────────────
|
||||
|
||||
# ── Piecewise time warp (librosa-free) ───────────────────────────────────────
|
||||
#
|
||||
# auto_sync's per-bar sync points describe where each sampled bar of the tab
|
||||
# falls in the real recording. Applying only the scalar audio_offset (bar 1)
|
||||
# assumes the recording holds the authored tempo for the whole song — any
|
||||
# drift accumulates. These helpers build the full piecewise-linear
|
||||
# score-time -> audio-time mapping and apply it to a converted Song, so the
|
||||
# chart follows the recording bar by bar (Songsterr-style sync).
|
||||
|
||||
def bar_start_times(gp_path: str) -> list[float]:
|
||||
"""Score-time (seconds) at the start of every bar of a GP file.
|
||||
|
||||
Uses the same tempo models as auto_sync's chroma synthesis (GPIF
|
||||
bar-resolution map for .gp/.gpx, per-tick integration for .gp3/4/5), so
|
||||
the returned times share an axis with auto_sync's sync points.
|
||||
|
||||
Raises ValueError if the file cannot be parsed, ImportError if the file
|
||||
is GP3/4/5 and PyGuitarPro is not installed.
|
||||
"""
|
||||
try:
|
||||
root = _load_gpif(gp_path)
|
||||
except _Gp345FileError:
|
||||
import guitarpro
|
||||
try:
|
||||
song = guitarpro.parse(gp_path)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Cannot parse GP3/4/5 file {gp_path!r}: {exc}") from exc
|
||||
tempo_events = _gp345_tempo_events(song)
|
||||
return [
|
||||
_gp345_tick_to_secs(tempo_events, tick)
|
||||
for tick in _gp345_measure_start_ticks(song)
|
||||
]
|
||||
return _gpif_bar_starts(root)
|
||||
|
||||
|
||||
def gp_has_expandable_repeats(gp_path: str) -> bool:
|
||||
"""True when converting `gp_path` expands repeats into a longer timeline
|
||||
than the as-written score auto_sync aligned against.
|
||||
|
||||
gp2rs.convert_file walks the GP3/4/5 playback graph (repeat brackets,
|
||||
voltas, D.S./D.C. directions), so a file using any of those produces an
|
||||
as-performed timeline that auto_sync's as-written sync points cannot be
|
||||
mapped onto. GPIF (.gp/.gpx) conversion is single-pass as-written today,
|
||||
so those files always return False — both sides share one bar order.
|
||||
|
||||
Returns False when the file cannot be parsed (callers fall back to
|
||||
offset-only sync on parse failure anyway).
|
||||
"""
|
||||
if Path(gp_path).suffix.lower() in ('.gp', '.gpx'):
|
||||
return False
|
||||
try:
|
||||
import guitarpro
|
||||
song = guitarpro.parse(gp_path)
|
||||
except Exception:
|
||||
return False
|
||||
for mh in song.measureHeaders:
|
||||
if mh.isRepeatOpen or mh.repeatClose >= 0 or mh.repeatAlternative:
|
||||
return True
|
||||
# Both jump SOURCES (fromDirection: D.C., D.S., Da Coda) and jump
|
||||
# TARGETS (direction: Segno, Coda, Fine) count — a plain Da Capo
|
||||
# needs no target marker, so checking `direction` alone would miss
|
||||
# it while gp2rs's playback walker still expands the jump.
|
||||
if (getattr(mh, 'direction', None) is not None
|
||||
or getattr(mh, 'fromDirection', None) is not None):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_warp_anchors(
|
||||
sync_points: list[SyncPoint],
|
||||
bar_starts: list[float],
|
||||
) -> list[tuple[float, float]]:
|
||||
"""Turn sync points into (score_secs, audio_secs) anchor pairs.
|
||||
|
||||
Drops points whose bar index is out of range, points that would break
|
||||
strict monotonicity on either axis (DTW can locally fold on noisy audio;
|
||||
a non-monotonic anchor would make the warp non-invertible and reorder
|
||||
notes), and points whose segment slope implies a physically implausible
|
||||
tempo ratio (outside 0.2x-5x authored). Returns [] when fewer than 2
|
||||
usable anchors remain — callers should fall back to scalar-offset sync
|
||||
in that case.
|
||||
"""
|
||||
anchors: list[tuple[float, float]] = []
|
||||
for sp in sorted(sync_points, key=lambda p: p.bar):
|
||||
if not 0 <= sp.bar < len(bar_starts):
|
||||
continue
|
||||
score_t = bar_starts[sp.bar]
|
||||
audio_t = float(sp.time_secs)
|
||||
if anchors and (score_t <= anchors[-1][0] + 1e-6
|
||||
or audio_t <= anchors[-1][1] + 1e-3):
|
||||
continue
|
||||
if anchors:
|
||||
# Slope sanity gate: a segment whose audio/score tempo ratio is
|
||||
# outside [0.2, 5] is not a performance — it's a DTW fold onto a
|
||||
# repeated section, an abridged recording, or a run of
|
||||
# monotonicity-clamped refine points. Keeping it would crush (or
|
||||
# absurdly stretch) every bar in the span, which is far worse
|
||||
# than interpolating through from the neighbouring anchors.
|
||||
slope = (audio_t - anchors[-1][1]) / (score_t - anchors[-1][0])
|
||||
if not 0.2 <= slope <= 5.0:
|
||||
continue
|
||||
anchors.append((score_t, audio_t))
|
||||
return anchors if len(anchors) >= 2 else []
|
||||
|
||||
|
||||
def warp_time(t: float, anchors: list[tuple[float, float]]) -> float:
|
||||
"""Map a score-time (seconds) to audio-time via piecewise-linear anchors.
|
||||
|
||||
Between anchors: linear interpolation. Outside the anchor range: the
|
||||
nearest segment's slope is extended, so a count-in before bar 1 and the
|
||||
tail after the last sampled bar keep the local tempo ratio.
|
||||
|
||||
`anchors` must be the >=2-point strictly-monotonic list produced by
|
||||
build_warp_anchors.
|
||||
"""
|
||||
lo = 0
|
||||
hi = len(anchors) - 1
|
||||
if t <= anchors[0][0]:
|
||||
seg = (anchors[0], anchors[1])
|
||||
elif t >= anchors[hi][0]:
|
||||
seg = (anchors[hi - 1], anchors[hi])
|
||||
else:
|
||||
# Binary search for the segment containing t
|
||||
while hi - lo > 1:
|
||||
mid = (lo + hi) // 2
|
||||
if anchors[mid][0] <= t:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
seg = (anchors[lo], anchors[hi])
|
||||
(s0, a0), (s1, a1) = seg
|
||||
slope = (a1 - a0) / (s1 - s0)
|
||||
return a0 + (t - s0) * slope
|
||||
|
||||
|
||||
def warp_song_times(song, warp) -> None:
|
||||
"""Apply a monotonic time-mapping callable to every absolute time in a
|
||||
lib.song.Song, in place.
|
||||
|
||||
Covers beats, sections, song_length, and per-arrangement notes (onset +
|
||||
sustain), chords (incl. chord notes), anchors, hand shapes, per-phrase
|
||||
difficulty levels, tone changes, and tempo overrides. Durations (note
|
||||
sustain, handshape span) are warped as end-start so they stretch with the
|
||||
local tempo ratio; sub-second intra-note envelopes (bend curves, which are
|
||||
relative to the note onset) are left untouched.
|
||||
|
||||
Duck-typed: accepts any object with the lib.song.Song surface.
|
||||
|
||||
Identity-safe: parse_arrangement shares the SAME Note/Chord/Anchor/
|
||||
HandShape objects between the flat arrangement lists and the
|
||||
max-difficulty phrase level, so each object is warped at most once no
|
||||
matter how many containers reference it.
|
||||
"""
|
||||
seen: set[int] = set()
|
||||
|
||||
def _once(obj) -> bool:
|
||||
key = id(obj)
|
||||
if key in seen:
|
||||
return False
|
||||
seen.add(key)
|
||||
return True
|
||||
|
||||
def _warp_notes(notes):
|
||||
for n in notes or []:
|
||||
if not _once(n):
|
||||
continue
|
||||
end = warp(n.time + n.sustain)
|
||||
n.time = warp(n.time)
|
||||
n.sustain = max(0.0, end - n.time)
|
||||
|
||||
def _warp_chords(chords):
|
||||
for c in chords or []:
|
||||
if not _once(c):
|
||||
continue
|
||||
c.time = warp(c.time)
|
||||
_warp_notes(c.notes)
|
||||
|
||||
def _warp_anchors(anchors):
|
||||
for a in anchors or []:
|
||||
if _once(a):
|
||||
a.time = warp(a.time)
|
||||
|
||||
def _warp_handshapes(shapes):
|
||||
for h in shapes or []:
|
||||
if not _once(h):
|
||||
continue
|
||||
start = warp(h.start_time)
|
||||
end = warp(h.end_time)
|
||||
h.start_time = start
|
||||
h.end_time = max(start, end)
|
||||
|
||||
song.song_length = max(0.0, warp(song.song_length))
|
||||
for b in song.beats:
|
||||
b.time = warp(b.time)
|
||||
for s in song.sections:
|
||||
s.start_time = warp(s.start_time)
|
||||
for arr in song.arrangements:
|
||||
_warp_notes(arr.notes)
|
||||
_warp_chords(arr.chords)
|
||||
_warp_anchors(arr.anchors)
|
||||
_warp_handshapes(arr.hand_shapes)
|
||||
for ph in arr.phrases or []:
|
||||
ph.start_time = warp(ph.start_time)
|
||||
ph.end_time = warp(ph.end_time)
|
||||
for lvl in ph.levels or []:
|
||||
_warp_notes(lvl.notes)
|
||||
_warp_chords(lvl.chords)
|
||||
_warp_anchors(lvl.anchors)
|
||||
_warp_handshapes(lvl.hand_shapes)
|
||||
if arr.tones and isinstance(arr.tones, dict):
|
||||
for change in arr.tones.get('changes') or []:
|
||||
if isinstance(change, dict) and isinstance(change.get('t'), (int, float)):
|
||||
change['t'] = warp(float(change['t']))
|
||||
for tempo_ev in arr.tempos or []:
|
||||
if isinstance(tempo_ev, dict) and isinstance(tempo_ev.get('time'), (int, float)):
|
||||
tempo_ev['time'] = warp(float(tempo_ev['time']))
|
||||
|
||||
|
||||
def _estimate_audio_offset(
|
||||
root: ET.Element,
|
||||
audio_path: str,
|
||||
@@ -932,12 +1222,7 @@ def auto_sync(
|
||||
# below line up with the chroma timeline.
|
||||
_tempo_events_gp345 = _gp345_tempo_events(_gp345x_song)
|
||||
# Convert tick events to bar events using actual measure start ticks
|
||||
_measure_starts = [] # cumulative tick at start of each bar
|
||||
_cum = 0
|
||||
for _mh2 in _gp345x_song.measureHeaders:
|
||||
_measure_starts.append(_cum)
|
||||
_ts = _mh2.timeSignature
|
||||
_cum += int(_ts.numerator * (4.0 / _ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
|
||||
_measure_starts = _gp345_measure_start_ticks(_gp345x_song)
|
||||
|
||||
def _tick_to_bar(tick):
|
||||
"""Return 0-based bar index for a given tick position."""
|
||||
@@ -1024,6 +1309,216 @@ def auto_sync(
|
||||
sync_points=sync_points,
|
||||
)
|
||||
|
||||
def refine_sync(
|
||||
sync: GpSyncData,
|
||||
audio_path: str,
|
||||
bars_per_point: int = 8,
|
||||
gp_path: str | None = None,
|
||||
sr: int = _SR,
|
||||
search_radius: float = 0.35,
|
||||
phase_step: float = 0.005,
|
||||
onset_tolerance: float = 0.05,
|
||||
) -> GpSyncData:
|
||||
"""Refine coarse DTW sync points with a per-bar onset phase sweep.
|
||||
|
||||
auto_sync's mid-song points inherit the DTW frame granularity (~186ms at
|
||||
the default hop). This pass re-times a denser grid of bars — every
|
||||
`bars_per_point`-th bar plus the first and last — by sweeping a local
|
||||
beat grid (±`search_radius`s in `phase_step` steps) against detected
|
||||
onsets and keeping the phase that aligns best, narrowing each kept point
|
||||
to roughly the phase-step resolution on percussive material.
|
||||
|
||||
Args:
|
||||
sync: Coarse sync data from auto_sync (or a prior refine).
|
||||
audio_path: The same audio file auto_sync aligned against.
|
||||
bars_per_point: Refined-point density; every Nth bar gets a point.
|
||||
gp_path: Optional path to the GP file. When given, exact
|
||||
per-bar score times (bar_start_times) drive the
|
||||
densified grid; without it the grid is limited to
|
||||
a 4/4 approximation built from the points' authored
|
||||
tempos, and accuracy degrades on odd meters.
|
||||
sr: Analysis sample rate.
|
||||
search_radius: ±seconds around each coarse estimate to sweep.
|
||||
phase_step: Sweep resolution in seconds.
|
||||
onset_tolerance: Max onset-to-click distance that counts as aligned.
|
||||
|
||||
Returns:
|
||||
A new GpSyncData with the refined (and usually denser) points and a
|
||||
recomputed audio_offset. Returns `sync` unchanged when it has no
|
||||
usable points. Quiet bars (fewer than 4 onsets nearby) keep their
|
||||
coarse interpolated time rather than locking onto noise.
|
||||
"""
|
||||
if not sync.sync_points:
|
||||
return sync
|
||||
|
||||
pts = sorted(sync.sync_points, key=lambda p: p.bar)
|
||||
|
||||
bar_starts: list[float] | None = None
|
||||
if gp_path:
|
||||
try:
|
||||
bar_starts = bar_start_times(gp_path)
|
||||
except Exception as exc:
|
||||
_log.warning("refine_sync: bar_start_times(%s) failed (%s) — "
|
||||
"falling back to 4/4 tempo model", gp_path, exc)
|
||||
if bar_starts is None:
|
||||
# Approximate score bar starts from the points' authored tempos,
|
||||
# assuming 4 beats per bar (all GpSyncData carries without the file).
|
||||
max_bar = pts[-1].bar
|
||||
bar_starts = [0.0]
|
||||
ti = 0
|
||||
cur_bpm = pts[0].original_tempo or 120.0
|
||||
for b in range(1, max_bar + 1):
|
||||
while ti + 1 < len(pts) and pts[ti + 1].bar <= b - 1:
|
||||
ti += 1
|
||||
cur_bpm = pts[ti].original_tempo or cur_bpm
|
||||
bar_starts.append(bar_starts[-1] + 4 * 60.0 / max(cur_bpm, 1e-3))
|
||||
|
||||
anchors = build_warp_anchors(pts, bar_starts)
|
||||
if len(anchors) < 2:
|
||||
_log.warning("refine_sync: fewer than 2 usable anchors — returning "
|
||||
"input unchanged")
|
||||
return sync
|
||||
|
||||
# Authored-tempo lookup via the shared bar-map scan (_tempo_at_bar) so
|
||||
# boundary semantics can't drift from the rest of the module.
|
||||
_orig_map = [(p.bar, p.original_tempo or 120.0) for p in pts]
|
||||
|
||||
def _orig_bpm_at(bar: int) -> float:
|
||||
return max(_tempo_at_bar(_orig_map, bar), 1e-3)
|
||||
|
||||
n_bars = len(bar_starts)
|
||||
step = max(1, int(bars_per_point))
|
||||
targets = sorted(set(range(0, n_bars, step)) | {n_bars - 1})
|
||||
|
||||
# Deferred past the pure early-return paths above so degenerate inputs
|
||||
# (no points, <2 anchors) resolve without librosa installed.
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
y, _ = librosa.load(audio_path, sr=sr, mono=True)
|
||||
audio_dur = len(y) / sr
|
||||
hop = 512 # ~23ms at 22050Hz — fine enough for onset timing
|
||||
onset_frames = librosa.onset.onset_detect(
|
||||
y=y, sr=sr, hop_length=hop, backtrack=True
|
||||
)
|
||||
onset_times = np.asarray(
|
||||
librosa.frames_to_time(onset_frames, sr=sr, hop_length=hop)
|
||||
)
|
||||
|
||||
refined: list[tuple[int, float]] = []
|
||||
for b in targets:
|
||||
score_t = bar_starts[b]
|
||||
coarse = warp_time(score_t, anchors)
|
||||
if coarse > audio_dur + 1.0:
|
||||
break # bar falls past the end of the recording
|
||||
# Local beat period in AUDIO time: authored beat period scaled by the
|
||||
# local warp slope (recording tempo / authored tempo around this bar).
|
||||
slope = warp_time(score_t + 1.0, anchors) - coarse
|
||||
slope = min(max(slope, 0.25), 4.0)
|
||||
beat_period = (60.0 / _orig_bpm_at(b)) * slope
|
||||
|
||||
# Keep the scoring grid short: beat_period is estimated from the
|
||||
# coarse anchors (a few % off), and grid drift grows linearly with
|
||||
# distance — 16 beats at 2% error is already ~150ms of skew at the
|
||||
# far end, which drags the sweep. 8 beats bounds that to ~beat noise.
|
||||
grid_span = 8 * beat_period
|
||||
# Clamp the sweep window below half a beat so the neighbouring beat
|
||||
# is never a candidate — on periodic material (steady drums) a grid
|
||||
# shifted by one whole beat scores identically and the sweep could
|
||||
# lock a full beat off. DTW coarse error is ~1 analysis frame, which
|
||||
# this window still covers at all but extreme tempos.
|
||||
radius = min(search_radius, 0.45 * beat_period)
|
||||
w_lo = coarse - radius - onset_tolerance
|
||||
w_hi = coarse + radius + grid_span + onset_tolerance
|
||||
local = onset_times[(onset_times >= w_lo) & (onset_times <= w_hi)]
|
||||
if len(local) < 4:
|
||||
refined.append((b, coarse))
|
||||
continue
|
||||
|
||||
best_t, best_score, best_dist = coarse, -1, 0.0
|
||||
for phase in np.arange(coarse - radius, coarse + radius + 1e-9,
|
||||
phase_step):
|
||||
clicks = np.arange(phase, phase + grid_span, beat_period)
|
||||
score = int(sum(
|
||||
1 for t in local
|
||||
if float(np.min(np.abs(clicks - t))) < onset_tolerance
|
||||
))
|
||||
dist = abs(float(phase) - coarse)
|
||||
# Ties break toward the coarse estimate so a flat score surface
|
||||
# (sustained pads, sparse onsets) can't drag the point sideways.
|
||||
if score > best_score or (score == best_score and dist < best_dist):
|
||||
best_score, best_t, best_dist = score, float(phase), dist
|
||||
|
||||
# A sweep that matched almost nothing found a spurious edge
|
||||
# alignment, not the beat grid — this happens when the true phase
|
||||
# lies outside the (ambiguity-clamped) window, e.g. fast tempos
|
||||
# where the DTW coarse error exceeds half a beat. Keeping the
|
||||
# coarse estimate degrades gracefully instead of locking a
|
||||
# fraction of a beat off.
|
||||
if best_score < 3:
|
||||
refined.append((b, coarse))
|
||||
continue
|
||||
|
||||
# The onset-count score is flat within ±onset_tolerance of the true
|
||||
# phase, so the sweep alone can be off by up to the tolerance. Snap
|
||||
# inside that plateau: shift by the median residual between matched
|
||||
# onsets and their nearest grid click. Only the first few beats
|
||||
# count here — they are nearly insensitive to beat_period error,
|
||||
# while far clicks would leak that error into the residuals.
|
||||
if best_score > 0:
|
||||
clicks = np.arange(best_t, best_t + 4 * beat_period + 1e-9,
|
||||
beat_period)
|
||||
residuals = []
|
||||
for t in local:
|
||||
d = clicks - float(t)
|
||||
j = int(np.argmin(np.abs(d)))
|
||||
if abs(d[j]) < onset_tolerance:
|
||||
residuals.append(-float(d[j])) # onset minus click
|
||||
if residuals:
|
||||
best_t += float(np.median(residuals))
|
||||
refined.append((b, best_t))
|
||||
|
||||
if not refined:
|
||||
return sync
|
||||
|
||||
# Enforce monotonicity: a point refined earlier than its predecessor
|
||||
# would fold the warp. Clamp to a small positive gap.
|
||||
mono: list[tuple[int, float]] = []
|
||||
prev_t: float | None = None
|
||||
for b, t in refined:
|
||||
t = max(t, 0.0)
|
||||
if prev_t is not None and t <= prev_t + 0.02:
|
||||
t = prev_t + 0.02
|
||||
mono.append((b, t))
|
||||
prev_t = t
|
||||
|
||||
# Recompute per-segment modified tempos from the refined times (same
|
||||
# formula _extract_sync_points uses; the last point carries the previous
|
||||
# segment's tempo forward).
|
||||
new_points: list[SyncPoint] = []
|
||||
for i, (b, t) in enumerate(mono):
|
||||
obpm = _orig_bpm_at(b)
|
||||
if i + 1 < len(mono):
|
||||
b2, t2 = mono[i + 1]
|
||||
score_seg = bar_starts[b2] - bar_starts[b]
|
||||
audio_seg = t2 - t
|
||||
mod = obpm * (score_seg / audio_seg) if audio_seg > 1e-3 else obpm
|
||||
mod = max(20.0, min(300.0, mod))
|
||||
else:
|
||||
mod = new_points[-1].modified_tempo if new_points else obpm
|
||||
new_points.append(SyncPoint(
|
||||
bar=b, time_secs=t, modified_tempo=mod, original_tempo=obpm,
|
||||
))
|
||||
|
||||
_log.info("refine_sync: %d points (was %d), audio_offset=%.3fs",
|
||||
len(new_points), len(pts), -new_points[0].time_secs)
|
||||
return GpSyncData(
|
||||
audio_offset=-new_points[0].time_secs,
|
||||
audio_asset_id=sync.audio_asset_id,
|
||||
sync_points=new_points,
|
||||
)
|
||||
|
||||
|
||||
def estimate_audio_offset(gp_path: str, audio_path: str) -> float:
|
||||
"""
|
||||
Estimate the audio_offset for a GP file aligned to an audio file.
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
"""The library-provider registry — the plugin extension point for song sources.
|
||||
|
||||
`LocalLibraryProvider` wraps the local `MetadataDB`; third-party plugins register
|
||||
their own providers (duck-typed: any object with the advertised methods) through
|
||||
`LibraryProviderRegistry`, and smart collections are surfaced as
|
||||
`SmartCollectionProvider`s over the local one. server.py constructs the singleton
|
||||
(`library_providers`), injects it + the local provider into appstate, and exposes
|
||||
`register_library_provider`/`unregister_library_provider` to plugins via
|
||||
plugin_context (with per-plugin ownership scoping in plugins/__init__.py).
|
||||
|
||||
Moved verbatim out of server.py (R3). The shared query/collection helpers live
|
||||
here too so routers/library.py can import them without reaching into server.
|
||||
"""
|
||||
|
||||
import re
|
||||
import threading
|
||||
from typing import ClassVar
|
||||
|
||||
import appstate
|
||||
from metadata_db import MetadataDB, _tuning_group_key_sql
|
||||
from routers import art as art_router
|
||||
|
||||
import logging
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
def _safe_art_redirect_url(url: str) -> str | None:
|
||||
"""Return the URL if it is safe to redirect to (http/https only), else None."""
|
||||
from urllib.parse import urlparse
|
||||
if not url or not isinstance(url, str):
|
||||
return None
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme.lower() not in ("http", "https"):
|
||||
return None
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
return url
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
_TUNING_GROUP_KEY_SQL = _tuning_group_key_sql("songs")
|
||||
|
||||
|
||||
class LocalLibraryProvider:
|
||||
id = "local"
|
||||
label = "My Library"
|
||||
kind = "local"
|
||||
capabilities = (
|
||||
"library.read",
|
||||
"art.read",
|
||||
"song.play",
|
||||
"favorite.write",
|
||||
"metadata.write",
|
||||
)
|
||||
|
||||
def __init__(self, db: MetadataDB):
|
||||
self._db = db
|
||||
|
||||
def query_page(self, **kwargs) -> tuple[list[dict], int]:
|
||||
return self._db.query_page(**kwargs)
|
||||
|
||||
def query_artists(self, **kwargs) -> tuple[list[dict], int]:
|
||||
return self._db.query_artists(**kwargs)
|
||||
|
||||
def query_albums(self, **kwargs) -> tuple[list[dict], int]:
|
||||
return self._db.query_albums(**kwargs)
|
||||
|
||||
def query_stats(self, **kwargs) -> dict:
|
||||
return self._db.query_stats(**kwargs)
|
||||
|
||||
def tuning_names(self) -> dict:
|
||||
# Group custom tunings on their raw offsets so distinct ones stay
|
||||
# distinct (tuning_name collapses them all to "Custom Tuning"); named
|
||||
# tunings keep grouping by name (stable across the rescan boundary, no
|
||||
# offsets/name split). `key` is the value the client sends back as the
|
||||
# filter selector — equal to the name for named tunings, the offsets
|
||||
# string for customs; offsets also feed the client's custom-pill label.
|
||||
with self._db._lock:
|
||||
rows = self._db.conn.execute(
|
||||
f"SELECT tuning_name, {_TUNING_GROUP_KEY_SQL} AS gkey, "
|
||||
"MIN(tuning_sort_key), COUNT(*), MIN(tuning_offsets) "
|
||||
"FROM songs WHERE title != '' AND COALESCE(tuning_name, '') != '' "
|
||||
"GROUP BY gkey COLLATE NOCASE "
|
||||
"ORDER BY ABS(COALESCE(MIN(tuning_sort_key), 0)), "
|
||||
"COALESCE(MIN(tuning_sort_key), 0) ASC, "
|
||||
"tuning_name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return {
|
||||
"tunings": [
|
||||
{"name": name, "key": gkey, "offsets": offs or "",
|
||||
"sort_key": int(sk or 0), "count": count}
|
||||
for name, gkey, sk, count, offs in rows
|
||||
],
|
||||
}
|
||||
|
||||
async def get_art(self, song_id: str):
|
||||
return await art_router.get_song_art(song_id)
|
||||
|
||||
|
||||
class LibraryProviderRegistry:
|
||||
# Methods required per declared capability — only validated when the
|
||||
# provider advertises the corresponding capability so action-only providers
|
||||
# (e.g. art.read + song.sync without library.read) don't need to implement
|
||||
# unused stubs.
|
||||
_CAPABILITY_METHODS: ClassVar[dict[str, tuple[str, ...]]] = {
|
||||
"library.read": ("query_page", "query_artists", "query_stats", "tuning_names"),
|
||||
"art.read": ("get_art",),
|
||||
"song.sync": ("sync_song",),
|
||||
}
|
||||
_ID_RE: ClassVar[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
||||
|
||||
def __init__(self):
|
||||
self._providers: dict[str, object] = {}
|
||||
# Capabilities inferred at registration for legacy providers that omit
|
||||
# the `capabilities` field. Merged with provider_capabilities() so that
|
||||
# runtime capability checks see the complete effective capability set.
|
||||
self._inferred_caps: dict[str, set[str]] = {}
|
||||
self._owner_plugin_ids: dict[str, str] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def register(self, provider: object, *, replace: bool = False, owner_plugin_id: str | None = None) -> object:
|
||||
provider_id = self.provider_id(provider)
|
||||
if not self._ID_RE.match(provider_id):
|
||||
raise ValueError(
|
||||
"library provider id must start with an alphanumeric character "
|
||||
"and contain only letters, digits, _, ., :, or -"
|
||||
)
|
||||
if not self.provider_label(provider):
|
||||
raise ValueError("library provider label must be a non-empty string")
|
||||
# Use declared-only caps during validation — never include stale inferred
|
||||
# caps from a previous provider registered under the same id (replace=True).
|
||||
caps = self._declared_capabilities(provider)
|
||||
# Backward compatibility: providers that predate explicit capability
|
||||
# declarations may omit `capabilities` entirely. If the browse methods
|
||||
# are all present, infer `library.read` so they still work unchanged.
|
||||
# If capabilities are absent but the browse surface is also absent,
|
||||
# raise a clear error rather than letting the provider register and
|
||||
# then fail on every API call with a late 501.
|
||||
inferred: set[str] = set()
|
||||
if not caps:
|
||||
browse_methods = self._CAPABILITY_METHODS["library.read"]
|
||||
if all(callable(self.provider_method(provider, m)) for m in browse_methods):
|
||||
# Legacy provider without explicit capabilities — infer library.read
|
||||
# from the presence of all browse methods. Store in _inferred_caps
|
||||
# so that runtime capability checks see the full effective set.
|
||||
inferred = {"library.read"}
|
||||
caps = inferred
|
||||
else:
|
||||
raise TypeError(
|
||||
f"library provider {provider_id!r} must declare at least one capability "
|
||||
f"(or implement the {browse_methods!r} browse methods for backward compatibility)"
|
||||
)
|
||||
for cap, methods in self._CAPABILITY_METHODS.items():
|
||||
if cap not in caps:
|
||||
continue
|
||||
for method_name in methods:
|
||||
if not callable(self.provider_method(provider, method_name)):
|
||||
raise TypeError(f"library provider {provider_id!r} declares {cap!r} but is missing callable {method_name}()")
|
||||
with self._lock:
|
||||
if provider_id == "local" and provider_id in self._providers and self._providers[provider_id] is not provider:
|
||||
raise ValueError("the local library provider cannot be replaced")
|
||||
if provider_id in self._providers and not replace:
|
||||
raise ValueError(f"library provider {provider_id!r} is already registered")
|
||||
self._providers[provider_id] = provider
|
||||
# owner_plugin_id is attribution that flows into the browser
|
||||
# capability participant id. The scoped register_library_provider
|
||||
# wrappers force it to the trusted loading plugin id, so the spoof
|
||||
# vector is closed there. Here we only normalize: trim and require a
|
||||
# non-empty string. We deliberately do NOT apply the provider-id
|
||||
# grammar (_ID_RE) — plugin ids aren't constrained to it at load
|
||||
# time, so that would silently drop attribution for valid plugins.
|
||||
owner = owner_plugin_id.strip() if isinstance(owner_plugin_id, str) else ""
|
||||
owner = owner or None
|
||||
if owner:
|
||||
self._owner_plugin_ids[provider_id] = owner
|
||||
else:
|
||||
self._owner_plugin_ids.pop(provider_id, None)
|
||||
if inferred:
|
||||
self._inferred_caps[provider_id] = inferred
|
||||
else:
|
||||
self._inferred_caps.pop(provider_id, None)
|
||||
return provider
|
||||
|
||||
def unregister(self, provider_id: str) -> bool:
|
||||
if provider_id == "local":
|
||||
raise ValueError("the local library provider cannot be unregistered")
|
||||
with self._lock:
|
||||
self._inferred_caps.pop(provider_id, None)
|
||||
self._owner_plugin_ids.pop(provider_id, None)
|
||||
return self._providers.pop(provider_id, None) is not None
|
||||
|
||||
def get(self, provider_id: str = "local") -> object | None:
|
||||
with self._lock:
|
||||
return self._providers.get(provider_id or "local")
|
||||
|
||||
def list(self) -> list[dict]:
|
||||
with self._lock:
|
||||
providers = list(self._providers.values())
|
||||
return [self.describe(provider) for provider in providers]
|
||||
|
||||
def describe(self, provider: object) -> dict:
|
||||
provider_id = self.provider_id(provider)
|
||||
with self._lock:
|
||||
owner_plugin_id = self._owner_plugin_ids.get(provider_id)
|
||||
return {
|
||||
"id": provider_id,
|
||||
"label": self.provider_label(provider),
|
||||
"kind": self.provider_field(provider, "kind", "local" if provider_id == "local" else "remote"),
|
||||
"capabilities": sorted(self.provider_capabilities(provider)),
|
||||
"owner_plugin_id": owner_plugin_id,
|
||||
"default": provider_id == "local",
|
||||
}
|
||||
|
||||
def provider_field(self, provider: object, name: str, default=None):
|
||||
if isinstance(provider, dict):
|
||||
return provider.get(name, default)
|
||||
return getattr(provider, name, default)
|
||||
|
||||
def provider_id(self, provider: object) -> str:
|
||||
provider_id = self.provider_field(provider, "id", "")
|
||||
if not isinstance(provider_id, str) or not provider_id:
|
||||
raise ValueError("library provider id must be a non-empty string")
|
||||
return provider_id
|
||||
|
||||
def provider_label(self, provider: object) -> str:
|
||||
label = self.provider_field(provider, "label", self.provider_field(provider, "name", ""))
|
||||
if not isinstance(label, str):
|
||||
return ""
|
||||
return label.strip()
|
||||
|
||||
def _declared_capabilities(self, provider: object) -> set[str]:
|
||||
"""Return only the capabilities explicitly declared on the provider object."""
|
||||
raw = self.provider_field(provider, "capabilities", ())
|
||||
if raw is None:
|
||||
raw = ()
|
||||
if isinstance(raw, str):
|
||||
raw = (raw,) if raw else ()
|
||||
return {str(cap) for cap in raw if cap}
|
||||
|
||||
def provider_capabilities(self, provider: object) -> set[str]:
|
||||
# Guard against a common plugin authoring mistake: passing a single string
|
||||
# instead of a list/tuple. Iterating a string produces individual characters,
|
||||
# none of which would match a valid capability name.
|
||||
declared = self._declared_capabilities(provider)
|
||||
# Merge with any capabilities inferred at registration time for legacy
|
||||
# providers that omit the `capabilities` field but implement browse methods.
|
||||
provider_id = self.provider_id(provider)
|
||||
with self._lock:
|
||||
inferred = self._inferred_caps.get(provider_id, set())
|
||||
return declared | inferred
|
||||
|
||||
def provider_method(self, provider: object, name: str):
|
||||
if isinstance(provider, dict):
|
||||
return provider.get(name)
|
||||
return getattr(provider, name, None)
|
||||
|
||||
|
||||
# Keys `_library_filter_args` (and a smart collection's stored `rules`) accept.
|
||||
_LIBRARY_FILTER_PARAM_KEYS = frozenset((
|
||||
"q", "favorites", "format", "artist", "album",
|
||||
"arrangements_has", "arrangements_lacks", "stems_has", "stems_lacks",
|
||||
"has_lyrics", "tunings",
|
||||
))
|
||||
|
||||
|
||||
# Rules mirror the raw /api/library query params (so the provider can feed them
|
||||
# straight through `_library_filter_args`, and the frontend can build a rule from
|
||||
# the same query string it already constructs). Multi-value filters are CSV
|
||||
# strings; `favorites` is 0/1; the rest are plain strings.
|
||||
_RULE_CSV_KEYS = frozenset((
|
||||
"tunings", "arrangements_has", "arrangements_lacks", "stems_has", "stems_lacks",
|
||||
))
|
||||
|
||||
|
||||
_RULE_STR_KEYS = frozenset(("q", "format", "artist", "album", "has_lyrics", "sort"))
|
||||
|
||||
|
||||
def _sanitize_collection_rules(raw) -> dict:
|
||||
"""Normalize rules to the raw query-param format, keeping only known keys. A
|
||||
list for a multi-value filter is joined to CSV; `favorites` becomes 0/1.
|
||||
Unknown keys are dropped so a rule survives a filter-vocab change rather than
|
||||
500-ing. Applied at API ingress AND when a provider loads a persisted row, so
|
||||
a hand-edited / imported bad value (e.g. an int where a string is expected,
|
||||
or a list for `sort`) can never crash a query."""
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
out: dict = {}
|
||||
for k, v in raw.items():
|
||||
if k in _RULE_CSV_KEYS:
|
||||
if isinstance(v, list):
|
||||
vals = [str(x) for x in v if isinstance(x, (str, int)) and not isinstance(x, bool)]
|
||||
elif isinstance(v, str):
|
||||
vals = [s for s in (p.strip() for p in v.split(",")) if s]
|
||||
else:
|
||||
continue
|
||||
if vals:
|
||||
out[k] = ",".join(vals)
|
||||
elif k == "favorites":
|
||||
if v:
|
||||
out[k] = 1
|
||||
elif k in _RULE_STR_KEYS:
|
||||
if isinstance(v, (str, int)) and not isinstance(v, bool):
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
out[k] = s
|
||||
return out
|
||||
|
||||
|
||||
class SmartCollectionProvider:
|
||||
"""A saved library filter, surfaced as a source (#636 item 2). Browse/stats
|
||||
delegate to the local DB with the collection's stored `rules` applied — so
|
||||
selecting it in the v3 source picker shows exactly that filtered slice with
|
||||
the whole Songs UI (paging, stats, A–Z 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}")
|
||||
+4408
File diff suppressed because it is too large
Load Diff
+160
-7
@@ -203,7 +203,13 @@ def convert_midi_track_to_keys_wire(
|
||||
# a foreign track's tempo events do NOT apply to the chosen
|
||||
# track. Merging would mis-time the notes — restrict the tempo
|
||||
# scan to the selected track only.
|
||||
ticks_per_beat = midi.ticks_per_beat
|
||||
# ``ticks_per_beat`` is 0 for a malformed header and NEGATIVE for SMPTE
|
||||
# division (mido returns the signed short as-is). Both feed the two
|
||||
# divisions below (tempo-table build + tick_to_seconds), so guard here:
|
||||
# 0 would raise ZeroDivisionError and a negative value would yield
|
||||
# negative/garbage times. Use ``> 0`` (not ``or``) so the negative SMPTE
|
||||
# case also falls back to the SMF default.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
tempo_source = (
|
||||
@@ -352,7 +358,14 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
|
||||
- type 1: parallel tracks share the timeline; merge tempo events.
|
||||
- type 2: independent timelines; tempo only from the chosen track.
|
||||
"""
|
||||
ticks_per_beat = midi.ticks_per_beat
|
||||
# A metrical header carries positive ticks-per-beat. mido reads the SMF
|
||||
# division as a signed short, so an SMPTE-division file surfaces as a
|
||||
# negative value and a malformed header as 0 — both make the two division
|
||||
# sites below divide by a non-positive number (ZeroDivisionError, or
|
||||
# negative seconds that send the bar walk off the rails). Fall back to the
|
||||
# SMF default here, the single place every caller routes ticks through, so
|
||||
# each caller's own fallback is real rather than cosmetic.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
tempo_source = (
|
||||
@@ -393,6 +406,141 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
|
||||
return tick_to_seconds
|
||||
|
||||
|
||||
# Safety valve for the bar walk below: a malformed SMF (absurd tempo + long
|
||||
# trailing meta) could otherwise imply millions of bars. Real charts sit
|
||||
# orders of magnitude below this.
|
||||
_TEMPO_MAP_MAX_BARS = 20000
|
||||
|
||||
|
||||
def convert_midi_tempo_map(midi_path: str, track_index: int = 0) -> dict:
|
||||
"""Extract the song-timeline grid a `.mid` file carries: tempos, time
|
||||
signatures, and a full beat grid — the data the note converters here
|
||||
always computed internally (to bake note times) and then threw away,
|
||||
which left every MIDI import with no bars, no measures, and an implied
|
||||
4/4 no matter what the file said.
|
||||
|
||||
Returns ``{"tempos": [...], "time_signatures": [...], "beats": [...]}``:
|
||||
|
||||
- ``tempos``: ``{time, bpm}`` per tempo event (deduped per tick).
|
||||
- ``time_signatures``: ``{time, ts: [num, den]}`` per signature event —
|
||||
the song-timeline sidecar shape (feedpak-spec §7.4).
|
||||
- ``beats``: one row per beat on the editor grid shape — downbeats carry
|
||||
a running ``measure`` (1, 2, 3, …) plus a ``den`` hint (the signature
|
||||
denominator), interior beats carry ``measure: -1``. The beat unit
|
||||
follows the active signature (6/8 ⇒ six eighth-note rows per bar).
|
||||
|
||||
Event scope mirrors ``_build_tick_to_seconds``: SMF type 0/1 merge meta
|
||||
from all tracks (shared timeline); type 2 reads ONLY ``track_index``
|
||||
(independent timelines — callers must never share one grid across
|
||||
type-2 tracks). Signature changes apply at the NEXT bar boundary when a
|
||||
file places one mid-bar (ill-formed but seen in the wild). All times
|
||||
are computed from absolute ticks through the cumulative tempo table and
|
||||
rounded once at emit — rounding error never accumulates with song
|
||||
length. An SMF with no note events yields empty ``beats``.
|
||||
"""
|
||||
midi = mido.MidiFile(midi_path)
|
||||
# Positive for metrical files; 0 (malformed) or negative (SMPTE division,
|
||||
# read as a signed short) otherwise — fall back so beat_ticks below stays
|
||||
# sane, mirroring the guard inside _build_tick_to_seconds.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
# Same scope both converters use: type 2 reads only the chosen track
|
||||
# (independent timelines); type 0/1 merge all tracks (shared timeline).
|
||||
source_tracks = (
|
||||
[midi.tracks[track_index]] if midi_type == 2 else midi.tracks
|
||||
)
|
||||
tick_to_seconds = _build_tick_to_seconds(midi, track_index)
|
||||
|
||||
# ── collect meta + the end of musical content in one pass ────────────
|
||||
sig_events: list[tuple[int, int, int]] = []
|
||||
tempo_events: list[tuple[int, int]] = []
|
||||
end_tick = 0
|
||||
for tr in source_tracks:
|
||||
abs_tick = 0
|
||||
for msg in tr:
|
||||
abs_tick += msg.time
|
||||
if msg.type == "time_signature":
|
||||
num = int(getattr(msg, "numerator", 4) or 4)
|
||||
den = int(getattr(msg, "denominator", 4) or 4)
|
||||
if num > 0 and den > 0:
|
||||
sig_events.append((abs_tick, num, den))
|
||||
elif msg.type == "set_tempo":
|
||||
tempo_events.append((abs_tick, int(msg.tempo)))
|
||||
elif msg.type in ("note_on", "note_off"):
|
||||
end_tick = max(end_tick, abs_tick)
|
||||
|
||||
# Dedupe at equal ticks (last wins), matching the tempo-table rule.
|
||||
sig_events.sort(key=lambda e: e[0])
|
||||
sigs: list[tuple[int, int, int]] = []
|
||||
for ev in sig_events:
|
||||
if sigs and sigs[-1][0] == ev[0]:
|
||||
sigs[-1] = ev
|
||||
else:
|
||||
sigs.append(ev)
|
||||
if not sigs or sigs[0][0] > 0:
|
||||
sigs.insert(0, (0, 4, 4))
|
||||
|
||||
tempo_events.sort(key=lambda e: e[0])
|
||||
seen_tempo_ticks: dict[int, int] = {}
|
||||
for ev_tick, ev_tempo in tempo_events:
|
||||
seen_tempo_ticks[ev_tick] = ev_tempo
|
||||
sorted_tempo_ticks = sorted(seen_tempo_ticks)
|
||||
tempos_out: list[dict] = []
|
||||
# Seed the MIDI default (120 BPM) at time 0 when the first tempo event
|
||||
# lands after the start (or there are none). The beat grid already runs
|
||||
# at 120 for the head of the song, so the sidecar must say so too —
|
||||
# symmetric with the (0, 4, 4) default seeded into the signatures above.
|
||||
if not sorted_tempo_ticks or sorted_tempo_ticks[0] > 0:
|
||||
tempos_out.append({"time": 0.0, "bpm": 120.0})
|
||||
for ev_tick in sorted_tempo_ticks:
|
||||
tempos_out.append({
|
||||
"time": round(tick_to_seconds(ev_tick), 3),
|
||||
"bpm": round(60_000_000.0 / seen_tempo_ticks[ev_tick], 3),
|
||||
})
|
||||
|
||||
time_signatures_out = [
|
||||
{"time": round(tick_to_seconds(t), 3), "ts": [num, den]}
|
||||
for t, num, den in sigs
|
||||
]
|
||||
|
||||
# ── walk bars from tick 0 to the end of the notes ────────────────────
|
||||
beats: list[dict] = []
|
||||
if end_tick > 0:
|
||||
cur_tick = 0.0
|
||||
measure = 1
|
||||
sig_idx = 0
|
||||
while cur_tick < end_tick and measure <= _TEMPO_MAP_MAX_BARS:
|
||||
# Active signature: the latest event at or before this bar's
|
||||
# start. Mid-bar events wait for the next boundary by
|
||||
# construction (we only re-read between bars).
|
||||
while (sig_idx + 1 < len(sigs)
|
||||
and sigs[sig_idx + 1][0] <= cur_tick + 1e-6):
|
||||
sig_idx += 1
|
||||
_, num, den = sigs[sig_idx]
|
||||
beat_ticks = ticks_per_beat * 4.0 / den
|
||||
beats.append({
|
||||
"time": round(tick_to_seconds(int(round(cur_tick))), 3),
|
||||
"measure": measure,
|
||||
"den": den,
|
||||
})
|
||||
for k in range(1, num):
|
||||
sub_tick = cur_tick + k * beat_ticks
|
||||
if sub_tick >= end_tick:
|
||||
break
|
||||
beats.append({
|
||||
"time": round(tick_to_seconds(int(round(sub_tick))), 3),
|
||||
"measure": -1,
|
||||
})
|
||||
cur_tick += num * beat_ticks
|
||||
measure += 1
|
||||
|
||||
return {
|
||||
"tempos": tempos_out,
|
||||
"time_signatures": time_signatures_out,
|
||||
"beats": beats,
|
||||
}
|
||||
|
||||
|
||||
# ── Drum track listing (channel-9 only) ──────────────────────────────────────
|
||||
|
||||
# Velocity below this is treated as a ghost note. GM doesn't have an explicit
|
||||
@@ -486,10 +634,12 @@ def convert_drum_track_from_midi(
|
||||
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a
|
||||
per-MIDI record of every channel-9 note_on that didn't resolve to a
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...]}}``, times
|
||||
capped at 100 samples per note). The default path skips this
|
||||
capture entirely so MIDIs heavy with cowbell/tambourine/etc. take
|
||||
no extra work.
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...],
|
||||
"velocities": [int, ...]}}``, times/velocities index-aligned and
|
||||
capped at 100 samples per note — velocities carry the source notes'
|
||||
real dynamics so a hand-mapping UI doesn't have to flatten them to a
|
||||
default). The default path skips this capture entirely so MIDIs
|
||||
heavy with cowbell/tambourine/etc. take no extra work.
|
||||
"""
|
||||
offset = float(audio_offset)
|
||||
if not math.isfinite(offset):
|
||||
@@ -527,10 +677,13 @@ def convert_drum_track_from_midi(
|
||||
continue
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
entry = out_unmapped.setdefault(
|
||||
midi_note, {"count": 0, "times": []})
|
||||
midi_note, {"count": 0, "times": [], "velocities": []})
|
||||
entry["count"] += 1
|
||||
if len(entry["times"]) < 100:
|
||||
entry["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real dynamics,
|
||||
# so hand-mapping doesn't flatten everything to 100.
|
||||
entry["velocities"].append(int(msg.velocity))
|
||||
continue
|
||||
# Mapped note: compute t once for the raw entry.
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Request-field coercion helpers shared by the raw-`dict` POST handlers.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3). Pure — no IO, no globals — so it
|
||||
imports cleanly from both ``server`` and any ``routers/`` module.
|
||||
"""
|
||||
|
||||
|
||||
def _clean_str(value) -> str:
|
||||
"""Trim a request field to a string; non-strings (or missing) → ''.
|
||||
Lets the raw-`dict` POST handlers treat wrong-typed JSON (an int/list/etc.
|
||||
where a string was expected) as "empty" and answer 400, instead of raising
|
||||
AttributeError/TypeError → 500 on a later .strip()/`in`."""
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
@@ -0,0 +1,31 @@
|
||||
"""FastAPI route modules extracted from ``server.py`` (R3).
|
||||
|
||||
Each module here exposes a module-level ``router`` (a ``fastapi.APIRouter``)
|
||||
that ``server.py`` mounts with ``app.include_router(...)`` at the point in the
|
||||
file where those routes used to be defined — FastAPI matches routes in
|
||||
registration order, so keeping the mount site preserves it.
|
||||
|
||||
**Routers must never ``import server``.** They reach core singletons through
|
||||
the injected seam instead::
|
||||
|
||||
import appstate
|
||||
|
||||
@router.get("/api/thing")
|
||||
def get_thing():
|
||||
return appstate.meta_db.thing()
|
||||
|
||||
and always as a **module attribute, at call time** — never
|
||||
``from appstate import meta_db``, which freezes the binding and defeats both a
|
||||
later ``appstate.configure()`` and ``monkeypatch.setattr``. See ``appstate.py``.
|
||||
|
||||
Dependencies flow one way: ``server -> routers -> appstate``.
|
||||
|
||||
**Why this lives under ``lib/``.** ``lib/`` is the only core directory every
|
||||
packaging path already copies wholesale — the Dockerfile (``COPY lib/``),
|
||||
``docker-compose.yml``, and feedback-desktop's ``bundle-slopsmith.sh``
|
||||
(``cp -r lib``) — and all three put it on ``sys.path``. A root-level package
|
||||
ships in Docker but is silently dropped from the packaged desktop app, whose
|
||||
bundler copies a hardcoded file list. Route modules import nothing at module
|
||||
scope beyond FastAPI and ``appstate``, so they do no import-time IO and satisfy
|
||||
Principle V's rule for ``lib/``.
|
||||
"""
|
||||
@@ -0,0 +1,513 @@
|
||||
"""Album-art routes: serve / cover-search / candidates / upload / url / remove
|
||||
(/api/song/{filename}/art*, /api/art/{filename}/override).
|
||||
|
||||
Extracted verbatim from server.py (R3). Only the decorators (@app -> @router) and
|
||||
the seam reads change: meta_db -> appstate.meta_db, ART_CACHE_DIR ->
|
||||
appstate.art_cache_dir, and the three shared art helpers that stay in server.py
|
||||
(they are also used by the song/delete routes) -> appstate.<callable>
|
||||
(_song_pack_art_exists, _art_override_paths, _art_safe_name). The CAA / release
|
||||
search transport lives in lib/enrichment.py and is reached as enrichment.X.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
|
||||
import appstate
|
||||
import enrichment
|
||||
import loosefolder as loosefolder_mod
|
||||
import sloppak as sloppak_mod
|
||||
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||
|
||||
import logging
|
||||
log = logging.getLogger("feedBack.server")
|
||||
router = APIRouter()
|
||||
|
||||
def _if_none_match_hits(header: str | None, etag: str) -> bool:
|
||||
"""True if an If-None-Match header matches `etag` (weak comparison).
|
||||
|
||||
Handles the `*` wildcard and comma-separated lists, and ignores a weak
|
||||
`W/` prefix on either side — the standard semantics for a conditional GET.
|
||||
"""
|
||||
if not header:
|
||||
return False
|
||||
bare = etag.removeprefix("W/")
|
||||
for tok in header.split(","):
|
||||
t = tok.strip()
|
||||
if t == "*" or t.removeprefix("W/") == bare:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Album art is served with a strong validator (an ETag on the sloppak byte
|
||||
# path; FileResponse's own ETag/Last-Modified on the file paths) and revalidated
|
||||
# with `no-cache`. That keeps re-scroll cheap — a conditional GET returns a
|
||||
# bodyless 304 — without ever serving a stale cover. A long `immutable` max-age
|
||||
# was rejected: the frontend's `?v=<mtime>` buster is only second-resolution, so
|
||||
# a same-second cover rewrite would keep the URL and pin the old bytes for the
|
||||
# cache lifetime. Validation cost is negligible for a localhost backend.
|
||||
_ART_CACHE_HEADERS = {"Cache-Control": "no-cache"}
|
||||
|
||||
|
||||
def _art_etag(path: Path) -> str | None:
|
||||
"""Strong validator for an art file: nanosecond mtime + size (so a
|
||||
same-second rewrite still changes it). None if the file can't be stat'd."""
|
||||
try:
|
||||
st = path.stat()
|
||||
return f'"{st.st_mtime_ns}-{st.st_size}"'
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _art_conditional(etag: str | None, request: Request | None):
|
||||
"""Return (headers, not_modified) for an art response. `not_modified` is
|
||||
True when the client's If-None-Match already matches `etag` → caller should
|
||||
return a bodyless 304. Starlette's FileResponse emits an ETag but does NOT
|
||||
itself evaluate If-None-Match, so every art path routes through here to get
|
||||
real conditional handling."""
|
||||
headers = dict(_ART_CACHE_HEADERS)
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
inm = request.headers.get("if-none-match") if request is not None else None
|
||||
return headers, bool(etag) and _if_none_match_hits(inm, etag)
|
||||
|
||||
|
||||
def _file_art_response(path: Path, media_type: str, request: Request | None):
|
||||
"""FileResponse for an on-disk art file, with no-cache + ETag and a bodyless
|
||||
304 when the client's validator still matches."""
|
||||
headers, not_modified = _art_conditional(_art_etag(path), request)
|
||||
if not_modified:
|
||||
return Response(status_code=304, headers=headers)
|
||||
return FileResponse(str(path), media_type=media_type, headers=headers)
|
||||
|
||||
|
||||
@router.get("/api/song/{filename:path}/art")
|
||||
async def get_song_art(filename: str, request: Request = None, source: str = ""):
|
||||
"""Serve album art for a song, walking the R3 override chain:
|
||||
|
||||
1. USER OVERRIDE (upload / URL-fetch, {safe_name}.gif|.png in the art
|
||||
cache) — art the user explicitly pinned outranks everything, pack
|
||||
art included. GIF is allowed HERE only: an animated cover is a
|
||||
local-only bonus; packs stay jpg/png/webp and nothing ever writes
|
||||
art into a pack file.
|
||||
2. PACK ART — sloppak cover (single member read, no full unpack) or
|
||||
the loose folder's discovered image.
|
||||
3. COVER ART ARCHIVE cache — fetched by the enrichment art worker for
|
||||
matched songs that lack pack art, keyed by release MBID.
|
||||
|
||||
`?source=pack` narrows the chain to step 2 only (no override, no CAA):
|
||||
the cover picker's "Pack original" tile must show the pack's own art
|
||||
even while a user override is what the plain route serves. 404 when the
|
||||
song ships no art of its own.
|
||||
"""
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
return JSONResponse({"error": "not configured"}, 404)
|
||||
|
||||
song_path = _resolve_dlc_path(dlc, filename)
|
||||
if song_path is None:
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
if not song_path.exists():
|
||||
return JSONResponse({"error": "not found"}, 404)
|
||||
|
||||
pack_only = source == "pack"
|
||||
|
||||
# 1. User override — GIF first (it wins over a stale PNG override).
|
||||
if not pack_only:
|
||||
for cached in appstate.art_override_paths(filename):
|
||||
mt = "image/gif" if cached.suffix == ".gif" else "image/png"
|
||||
return _file_art_response(cached, mt, request)
|
||||
|
||||
# 2a. Sloppak: read the cover (manifest-declared or default) straight from
|
||||
# the package. For a zip-form sloppak this opens just the cover member —
|
||||
# NOT the whole archive — so the library grid never triggers a full unpack
|
||||
# of stems just to paint a thumbnail.
|
||||
if sloppak_mod.is_sloppak(song_path):
|
||||
# Read the cover (cheap — single member, no full unpack) and validate by
|
||||
# its CONTENT. A stat-based ETag would be wrong for directory-form
|
||||
# sloppaks: editing cover.jpg in place changes the file's mtime, not the
|
||||
# directory's, so a dir-stat ETag could emit a stale 304. Content hashing
|
||||
# is correct for both dir- and zip-form. Raw byte Response lacks
|
||||
# FileResponse's validators, so we attach the ETag + honor If-None-Match.
|
||||
try:
|
||||
art = await asyncio.to_thread(sloppak_mod.read_cover_bytes, song_path)
|
||||
except Exception:
|
||||
art = None
|
||||
if art is not None:
|
||||
data, mt = art
|
||||
etag = f'"{hashlib.sha1(data).hexdigest()}"'
|
||||
headers, not_modified = _art_conditional(etag, request)
|
||||
if not_modified:
|
||||
return Response(status_code=304, headers=headers)
|
||||
return Response(content=data, media_type=mt, headers=headers)
|
||||
|
||||
# 2b. Loose folder: serve the discovered art file directly.
|
||||
# song_path is already validated against DLC_DIR by _resolve_dlc_path.
|
||||
elif loosefolder_mod.is_loose_song(song_path):
|
||||
art_path = loosefolder_mod.find_art(song_path)
|
||||
if art_path:
|
||||
# Re-resolve in case the matched file is a symlink — a crafted
|
||||
# custom song could put `album_art.jpg` as a symlink to anywhere on
|
||||
# disk. Insist the final target stays inside the song folder.
|
||||
art_resolved = art_path.resolve()
|
||||
try:
|
||||
art_resolved.relative_to(song_path)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
if art_resolved.is_file():
|
||||
mt = {
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".png": "image/png", ".webp": "image/webp",
|
||||
}.get(art_resolved.suffix.lower(), "image/jpeg")
|
||||
return _file_art_response(art_resolved, mt, request)
|
||||
|
||||
# 3. Cover Art Archive cache (the enrichment art worker's fetch).
|
||||
if not pack_only:
|
||||
row = appstate.meta_db.get_enrichment(filename)
|
||||
if row and row.get("art_state") == "caa" and row.get("art_cache_path"):
|
||||
caa = Path(row["art_cache_path"])
|
||||
if caa.is_file():
|
||||
return _file_art_response(caa, "image/jpeg", request)
|
||||
|
||||
return JSONResponse({"error": "no art"}, 404)
|
||||
|
||||
|
||||
# ── Cover picker (PR-C): candidate assembly ───────────────────────────────────
|
||||
# Enumerated ON OPEN, never at scan time (charrette §8), and NO image bytes
|
||||
# are fetched here — Cover Art Archive release INDEX jsons only (1-3 throttled
|
||||
# calls on a cache miss); the tiles' thumbnails load straight from the archive
|
||||
# in the client. Applying a pick never grows a new write path: the client
|
||||
# POSTs the chosen thumb URL to the EXISTING …/art/url route (the override
|
||||
# lane — never evicted, survives a re-match), "Pack original" DELETEs the
|
||||
# override, uploads keep the existing upload route.
|
||||
_ART_PICKER_MAX_CAA = 12
|
||||
|
||||
|
||||
@router.get("/api/song/{filename:path}/art/cover-search")
|
||||
def api_art_cover_search(filename: str, q: str = ""):
|
||||
"""Search Cover Art Archive (via MusicBrainz release-groups) for album covers
|
||||
— powers the Change-cover picker's search box, so a cover can be found even
|
||||
for a song with no metadata match (the unmatched city-pop pile, where
|
||||
/art/candidates is empty). `q` defaults to the song's own artist + album/
|
||||
title (romaji fallback applied). Read-only; the picker renders the thumbs and
|
||||
applies a pick through the existing /art/url route."""
|
||||
query = (q or "").strip()
|
||||
if not query:
|
||||
pack = appstate.meta_db.pack_fields(appstate.meta_db._canonical_song_filename(filename))
|
||||
query = " ".join(x for x in (pack.get("artist"), pack.get("album") or pack.get("title")) if x).strip()
|
||||
if not query:
|
||||
return {"query": "", "covers": []}
|
||||
try:
|
||||
return {"query": query, "covers": enrichment._mb_search_release_groups(query, limit=8)}
|
||||
except enrichment.EnrichTransportError:
|
||||
return {"query": query, "covers": [], "error": "unavailable"}
|
||||
|
||||
|
||||
@router.get("/api/song/{filename:path}/art/candidates")
|
||||
def get_song_art_candidates(filename: str):
|
||||
"""Everything the cover picker can offer for one song, without fetching a
|
||||
single image: the current cover (with its provenance), the pack original
|
||||
when the song ships art, and CAA candidates for the matched/manual
|
||||
release plus any distinct releases among the stored review candidates.
|
||||
Sync route on purpose (the CAA index fetch sleeps in the shared
|
||||
throttle — FastAPI runs `def` routes in the threadpool). One response,
|
||||
`pending` always False — the client shows a spinner for the request's own
|
||||
latency; offline / CAA-down just means an empty caa tail (the instant
|
||||
tiles keep working), never an error."""
|
||||
from urllib.parse import quote
|
||||
dlc = _get_dlc_dir()
|
||||
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
|
||||
if song_path is None or not song_path.exists():
|
||||
raise HTTPException(status_code=404, detail="unknown song")
|
||||
|
||||
row = appstate.meta_db.get_enrichment(filename) or {}
|
||||
has_pack = appstate.song_pack_art_exists(filename)
|
||||
art_url = f"/api/song/{quote(filename)}/art"
|
||||
|
||||
# What the plain art route would serve right now — the serve chain's
|
||||
# order (override > pack > CAA cache) restated as provenance.
|
||||
if appstate.art_override_paths(filename):
|
||||
provenance = "yours"
|
||||
elif has_pack:
|
||||
provenance = "pack"
|
||||
elif row.get("art_state") == "caa" and row.get("art_cache_path"):
|
||||
provenance = "matched"
|
||||
else:
|
||||
provenance = "none"
|
||||
|
||||
candidates: list[dict] = [{
|
||||
"id": "current", "kind": "current", "label": "Current",
|
||||
"thumb_url": art_url, "provenance": provenance,
|
||||
}]
|
||||
if has_pack:
|
||||
candidates.append({
|
||||
"id": "pack", "kind": "pack", "label": "Pack original",
|
||||
"thumb_url": art_url + "?source=pack", "provenance": "pack",
|
||||
})
|
||||
|
||||
# Releases worth asking the archive about: the matched/manual release
|
||||
# first (it seeds the best candidates), then any distinct release among
|
||||
# the stored review candidates (a review row has no mb_release_id of its
|
||||
# own — its releases live in the candidates JSON).
|
||||
# Only spend the shared CAA rate budget on rows whose match warrants it:
|
||||
# a matched/manual release seeds the best candidates, and a review row's
|
||||
# stored candidates are still live proposals. A failed/rejected (or
|
||||
# unscanned) row has no accepted match — asking would burn the budget and
|
||||
# surface releases already rejected as non-matches. The Current + Pack
|
||||
# tiles above serve regardless, so those songs still get a picker.
|
||||
rids: list[str] = []
|
||||
if row.get("match_state") in ("matched", "manual", "review"):
|
||||
if row.get("match_state") in ("matched", "manual") and row.get("mb_release_id"):
|
||||
rids.append(str(row["mb_release_id"]))
|
||||
for cand in (row.get("candidates") or []):
|
||||
rid = str(cand.get("release_id") or "") if isinstance(cand, dict) else ""
|
||||
if rid and rid not in rids:
|
||||
rids.append(rid)
|
||||
|
||||
caa_entries: list[dict] = []
|
||||
for rid in rids:
|
||||
if len(caa_entries) >= _ART_PICKER_MAX_CAA:
|
||||
break
|
||||
try:
|
||||
imgs = enrichment._caa_index_cached(rid)
|
||||
except enrichment.EnrichTransportError:
|
||||
# Offline / archive down — stop asking (each further miss would
|
||||
# only burn a timeout). The instant tiles still serve; a later
|
||||
# picker-open retries naturally (failures are never cached).
|
||||
break
|
||||
# Front covers first, approved before pending, otherwise index order
|
||||
# (the picker grammar is a RANKED list — §7/§9).
|
||||
def _rank(img):
|
||||
types = img.get("types") or []
|
||||
is_front = bool(img.get("front")) or "Front" in types
|
||||
return (not is_front, not bool(img.get("approved")))
|
||||
for img in sorted((i for i in imgs if isinstance(i, dict)), key=_rank):
|
||||
if len(caa_entries) >= _ART_PICKER_MAX_CAA:
|
||||
break
|
||||
thumbs = img.get("thumbnails") or {}
|
||||
if not isinstance(thumbs, dict):
|
||||
continue
|
||||
thumb = (thumbs.get("500") or thumbs.get("large")
|
||||
or thumbs.get("250") or thumbs.get("small"))
|
||||
if not thumb:
|
||||
continue
|
||||
types = [str(t) for t in (img.get("types") or []) if isinstance(t, str)]
|
||||
caa_entries.append({
|
||||
"id": f"caa-{rid}-{img.get('id', '')}",
|
||||
"kind": "caa",
|
||||
"label": ", ".join(types) or "Cover",
|
||||
"thumb_url": str(thumb),
|
||||
"provenance": "matched",
|
||||
"types": types,
|
||||
"approved": bool(img.get("approved")),
|
||||
"release_id": rid,
|
||||
})
|
||||
|
||||
return {"candidates": candidates + caa_entries, "pending": False}
|
||||
|
||||
|
||||
def _save_art_override(filename: str, img_data: bytes) -> dict:
|
||||
"""Persist a user art override into the art cache (R3). One override per
|
||||
song: GIF input is validated and kept VERBATIM as .gif (animation intact —
|
||||
the local-only bonus; it is never written into the pack file), everything
|
||||
else is normalized to RGB PNG via PIL. Saving either kind removes the
|
||||
other so the serve chain has exactly one user file to find."""
|
||||
appstate.art_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = appstate.art_safe_name(filename)
|
||||
png_path = appstate.art_cache_dir / f"{stem}.png"
|
||||
gif_path = appstate.art_cache_dir / f"{stem}.gif"
|
||||
from PIL import Image
|
||||
import io as _io
|
||||
if img_data[:6] in (b"GIF87a", b"GIF89a"):
|
||||
try:
|
||||
probe = Image.open(_io.BytesIO(img_data))
|
||||
probe.verify() # decodes headers/frames without keeping the image
|
||||
if probe.format != "GIF":
|
||||
raise ValueError("not a GIF")
|
||||
except Exception as e:
|
||||
return {"error": f"Invalid image: {e}"}
|
||||
gif_path.write_bytes(img_data)
|
||||
png_path.unlink(missing_ok=True)
|
||||
return {"ok": True, "kind": "gif"}
|
||||
try:
|
||||
img = Image.open(_io.BytesIO(img_data)).convert("RGB")
|
||||
img.save(str(png_path), "PNG")
|
||||
except Exception as e:
|
||||
return {"error": f"Invalid image: {e}"}
|
||||
gif_path.unlink(missing_ok=True)
|
||||
return {"ok": True, "kind": "png"}
|
||||
|
||||
|
||||
@router.post("/api/song/{filename:path}/art/upload")
|
||||
async def upload_song_art_b64(filename: str, data: dict):
|
||||
"""Upload a custom cover as base64 (PNG/JPG/WebP → normalized PNG;
|
||||
GIF → kept animated, local-only). The override outranks pack art in the
|
||||
serve chain; remove it via DELETE …/art/override."""
|
||||
import base64
|
||||
# Reject art for a filename that doesn't resolve to a real song (mirrors the
|
||||
# url route's guard) — no writing stray override files for unknown keys.
|
||||
dlc = _get_dlc_dir()
|
||||
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
|
||||
if song_path is None or not song_path.exists():
|
||||
raise HTTPException(status_code=404, detail="unknown song")
|
||||
b64 = data.get("image", "")
|
||||
if not b64:
|
||||
return {"error": "No image data"}
|
||||
# Strip data URL prefix if present
|
||||
if "," in b64:
|
||||
b64 = b64.split(",", 1)[1]
|
||||
try:
|
||||
img_data = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return {"error": "Invalid base64"}
|
||||
if len(img_data) > _ART_URL_MAX_BYTES:
|
||||
raise HTTPException(status_code=400, detail="image larger than 10 MB")
|
||||
return _save_art_override(filename, img_data)
|
||||
|
||||
|
||||
# Art-by-URL fetch cap — a cover, not a wallpaper pack.
|
||||
_ART_URL_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _url_host_is_internal(url: str) -> bool:
|
||||
"""True when a user-supplied URL's host resolves to a loopback, private,
|
||||
link-local, reserved, multicast or unspecified address — an SSRF target we
|
||||
refuse to fetch on the user's behalf (e.g. 169.254.169.254 metadata, LAN
|
||||
services). Fails CLOSED: an unresolvable or unparseable host is treated as
|
||||
internal. Every resolved address must be public for the URL to pass."""
|
||||
from urllib.parse import urlparse
|
||||
import socket
|
||||
host = urlparse(url).hostname
|
||||
if not host:
|
||||
return True
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except OSError:
|
||||
return True
|
||||
if not infos:
|
||||
return True
|
||||
for info in infos:
|
||||
raw = info[4][0].split("%", 1)[0] # strip any zone id
|
||||
try:
|
||||
ip = ipaddress.ip_address(raw)
|
||||
except ValueError:
|
||||
return True
|
||||
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Art-by-URL redirect budget. Cover hosts commonly answer with a redirect —
|
||||
# the Cover Art Archive (whose thumbs the cover picker applies through this
|
||||
# very route) 307s every image to archive.org — so redirects must work; 5
|
||||
# hops is generous for any real CDN chain while still bounding the walk.
|
||||
_ART_URL_MAX_REDIRECTS = 5
|
||||
|
||||
|
||||
def _fetch_art_url(url: str) -> bytes:
|
||||
"""The one place art-by-URL touches the network (tests fake this seam).
|
||||
User-initiated, so not throttled like the background workers — but the
|
||||
same offline guard applies (pytest can never fetch), the host is checked
|
||||
against internal/reserved ranges (SSRF), redirects are followed MANUALLY
|
||||
with the scheme + internal-host guard re-applied to every hop (so a
|
||||
redirect can't smuggle the request to an internal target — a blanket
|
||||
no-redirect rule would break every Cover Art Archive pick, which always
|
||||
redirects to archive.org), and the size cap is enforced while streaming
|
||||
so a huge response never fully downloads.
|
||||
|
||||
Residual, accepted: each hop's host is resolved here and again by
|
||||
requests, so a rebinding DNS name is a theoretical TOCTOU. Not closed
|
||||
with an IP-pinned connection because (a) this is a single-user, no-auth
|
||||
app (constitution §I) and the route is demo-blocked, so there is no
|
||||
untrusted submission path, and (b) no other in-tree client (MusicBrainz,
|
||||
CAA) pins either — a bespoke pinned+SNI adapter here would be
|
||||
inconsistent and disproportionate. The cheap guards above still stop the
|
||||
realistic vectors (direct internal URL, redirect-to-internal)."""
|
||||
if not enrichment._enrich_network_enabled():
|
||||
raise enrichment.EnrichTransportError("art fetch disabled (offline)")
|
||||
import requests
|
||||
from urllib.parse import urljoin, urlparse
|
||||
for _hop in range(_ART_URL_MAX_REDIRECTS + 1):
|
||||
# Re-validate EVERY hop, not just the user's original URL: the whole
|
||||
# point of handling redirects ourselves is that each target gets the
|
||||
# same scheme + SSRF gate before any request is made.
|
||||
if urlparse(url).scheme not in ("http", "https"):
|
||||
raise ValueError("url must be http(s)")
|
||||
if _url_host_is_internal(url):
|
||||
raise ValueError("url host is not allowed")
|
||||
try:
|
||||
with requests.get(url, timeout=15, stream=True, allow_redirects=False,
|
||||
headers={"User-Agent": enrichment._enrich_user_agent()}) as resp:
|
||||
if resp.status_code in (301, 302, 303, 307, 308):
|
||||
loc = resp.headers.get("Location") or ""
|
||||
if not loc:
|
||||
raise enrichment.EnrichTransportError(
|
||||
f"HTTP {resp.status_code} without a Location")
|
||||
url = urljoin(url, loc)
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
raise enrichment.EnrichTransportError(f"HTTP {resp.status_code}")
|
||||
data = b""
|
||||
for chunk in resp.iter_content(65536):
|
||||
data += chunk
|
||||
if len(data) > _ART_URL_MAX_BYTES:
|
||||
raise ValueError("image larger than 10 MB")
|
||||
return data
|
||||
except requests.RequestException as e:
|
||||
raise enrichment.EnrichTransportError(str(e)) from e
|
||||
raise enrichment.EnrichTransportError("too many redirects")
|
||||
|
||||
|
||||
@router.post("/api/song/{filename:path}/art/url")
|
||||
def set_song_art_from_url(filename: str, data: dict):
|
||||
"""Paste-a-link cover art (the media-server idiom): the server fetches the
|
||||
image and stores it as this song's local override — identical result to an
|
||||
upload, including the GIF-stays-local rule. http(s) only."""
|
||||
url = str((data or {}).get("url") or "").strip()
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise HTTPException(status_code=400, detail="url must be http(s)")
|
||||
dlc = _get_dlc_dir()
|
||||
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
|
||||
if song_path is None or not song_path.exists():
|
||||
raise HTTPException(status_code=404, detail="unknown song")
|
||||
try:
|
||||
img_data = _fetch_art_url(url)
|
||||
except enrichment.EnrichTransportError as e:
|
||||
return JSONResponse({"error": "could not fetch image", "detail": str(e)},
|
||||
status_code=502)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return _save_art_override(filename, img_data)
|
||||
|
||||
|
||||
@router.delete("/api/art/{filename:path}/override")
|
||||
def remove_song_art_override(filename: str):
|
||||
"""Drop the user art override — the serve chain falls back to pack art,
|
||||
then the Cover Art Archive cache. Lives under /api/art (NOT /api/song) so
|
||||
the greedy DELETE /api/song/{path} catch-all can't shadow it — the same
|
||||
dodge the chart split/unsplit routes use."""
|
||||
removed = False
|
||||
for p in appstate.art_override_paths(filename):
|
||||
try:
|
||||
p.unlink()
|
||||
removed = True
|
||||
except OSError:
|
||||
pass
|
||||
if removed:
|
||||
# The art worker may have settled this row as 'user' (override present,
|
||||
# no pack art). Reset it so the next enrichment pass re-evaluates and the
|
||||
# CAA fallback resumes — otherwise a removed override strands the row
|
||||
# (enrichment_art_pending only re-queues art_state IS NULL) and the song
|
||||
# is left with no art at all.
|
||||
try:
|
||||
appstate.meta_db.set_enrichment_art(filename, None, None)
|
||||
except Exception:
|
||||
log.exception("art override delete: failed to reset enrichment state")
|
||||
return {"ok": True, "removed": removed}
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Artist routes: the artist page + external-links payload
|
||||
(/api/artist/{name}/page, /links, /links/refresh).
|
||||
|
||||
Extracted verbatim from server.py (R3) except @app->@router and the seam reads
|
||||
(meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir, _default_settings->
|
||||
appstate.default_settings). MusicBrainz link enrichment is reached as
|
||||
enrichment.X; the shared URL-safety validator lives in lib/library_registry.py.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
import appstate
|
||||
import enrichment
|
||||
from appconfig import _load_config
|
||||
from library_registry import _safe_art_redirect_url
|
||||
|
||||
import logging
|
||||
log = logging.getLogger("feedBack.server")
|
||||
router = APIRouter()
|
||||
|
||||
# MB artist url-relation types → the page's link slots (locked position 4:
|
||||
# whitelist only, links-only forever). Everything not listed is dropped.
|
||||
_ARTIST_URL_REL_SLOTS = {
|
||||
"official homepage": "official",
|
||||
"setlistfm": "tour",
|
||||
"concerts": "tour",
|
||||
"youtube": "video",
|
||||
"video channel": "video",
|
||||
"social network": "social",
|
||||
"bandcamp": "social",
|
||||
"soundcloud": "social",
|
||||
"wikipedia": "wikipedia",
|
||||
"wikidata": "wikipedia",
|
||||
}
|
||||
|
||||
|
||||
def _artist_links_from_mb(body: dict) -> tuple[dict, list]:
|
||||
"""Whitelist an MB artist doc's url-relations into the page's link slots:
|
||||
{official, tour, video, social: [...], wikipedia}. Every URL passes the
|
||||
same http(s)-scheme gate as art redirects (_safe_art_redirect_url) so a
|
||||
hostile javascript:/data:/file: resource can never reach an href. First
|
||||
URL wins per single slot; social collects up to 5; wikipedia is preferred
|
||||
over wikidata when both exist. Also returns MB's genre names (capped)."""
|
||||
links: dict = {}
|
||||
social: list = []
|
||||
wikidata_url = None
|
||||
for rel in (body or {}).get("relations") or []:
|
||||
if not isinstance(rel, dict):
|
||||
continue
|
||||
rtype = str(rel.get("type") or "").strip().lower()
|
||||
slot = _ARTIST_URL_REL_SLOTS.get(rtype)
|
||||
if not slot:
|
||||
continue
|
||||
url = rel.get("url")
|
||||
url = url.get("resource") if isinstance(url, dict) else url
|
||||
if _safe_art_redirect_url(url) is None:
|
||||
continue
|
||||
if slot == "social":
|
||||
if url not in social and len(social) < 5:
|
||||
social.append(url)
|
||||
elif rtype == "wikidata":
|
||||
wikidata_url = wikidata_url or url
|
||||
elif slot not in links:
|
||||
links[slot] = url
|
||||
if social:
|
||||
links["social"] = social
|
||||
if "wikipedia" not in links and wikidata_url:
|
||||
links["wikipedia"] = wikidata_url
|
||||
genres = [str(g.get("name")) for g in (body or {}).get("genres") or []
|
||||
if isinstance(g, dict) and g.get("name")]
|
||||
return links, genres[:8]
|
||||
|
||||
|
||||
def _artist_links_payload(name: str, force: bool = False) -> dict:
|
||||
"""Shared by GET links + POST refresh. Order of gates: the user's opt-in
|
||||
setting (external links are OFF by default — the dev-chat thread's call),
|
||||
then a known mb_artist_id (no id → nothing to look up), then the cache
|
||||
(unless force), then the offline guard, then ONE throttled fetch."""
|
||||
cfg = _load_config(appstate.config_dir / "config.json") or appstate.default_settings()
|
||||
if cfg.get("artist_external_links") is not True:
|
||||
return {"links": {}, "matched": False, "disabled": True}
|
||||
canonical = appstate.meta_db._terminal_canonical((name or "").strip())
|
||||
mbid = appstate.meta_db.artist_known_mb_id(appstate.meta_db._raw_variants_for(canonical))
|
||||
mbid = (mbid or "").strip().lower()
|
||||
# The id is interpolated into the MB request path — same strict-shape rule
|
||||
# as the manifest identity keys (_MBID_RE), so a junk/hostile value stored
|
||||
# via a hand-rolled /pick body can never reach the request line.
|
||||
if not mbid or not enrichment._MBID_RE.match(mbid):
|
||||
return {"links": {}, "matched": False}
|
||||
if not force:
|
||||
cached = appstate.meta_db.get_artist_enrichment(mbid)
|
||||
if cached:
|
||||
return {"links": cached["url_rels"], "genres": cached["genres"],
|
||||
"matched": True, "cached": True, "mb_artist_id": mbid}
|
||||
if not enrichment._enrich_network_enabled():
|
||||
return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid}
|
||||
try:
|
||||
body = enrichment._mb_http_get(f"artist/{mbid}", {"inc": "url-rels+genres+tags"})
|
||||
except enrichment.EnrichTransportError:
|
||||
return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid}
|
||||
links, genres = _artist_links_from_mb(body or {})
|
||||
appstate.meta_db.put_artist_enrichment(mbid, links, genres)
|
||||
return {"links": links, "genres": genres, "matched": True, "cached": False,
|
||||
"mb_artist_id": mbid}
|
||||
|
||||
|
||||
@router.get("/api/artist/{name:path}/page")
|
||||
def api_artist_page(name: str):
|
||||
"""The artist page's all-LOCAL payload — counts, albums, aliases, similar-
|
||||
in-library, mosaic art, play-all seed. Never touches the network; an
|
||||
unmatched or even unknown artist still returns a functional page."""
|
||||
return appstate.meta_db.artist_page(name)
|
||||
|
||||
|
||||
@router.get("/api/artist/{name:path}/links")
|
||||
def api_artist_links(name: str):
|
||||
"""External links for a matched artist — cached after the first call.
|
||||
Sync route on purpose (like /api/enrichment/search): FastAPI runs it in
|
||||
the threadpool so the MB throttle's sleep never blocks the event loop."""
|
||||
return _artist_links_payload(name)
|
||||
|
||||
|
||||
@router.post("/api/artist/{name:path}/links/refresh")
|
||||
def api_artist_links_refresh(name: str):
|
||||
"""Explicit re-fetch of the cached links (the page's manual Refresh)."""
|
||||
return _artist_links_payload(name, force=True)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Artist aliases / Tidy-up (P4) — canonicalize messy artist tags at DISPLAY
|
||||
("ACDC" -> "AC/DC") without touching feedpak files or the scanner-derived
|
||||
songs.artist. All DB-only.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); only the decorator receiver
|
||||
(``@app`` -> ``@router``) and the singleton read (``meta_db`` ->
|
||||
``appstate.meta_db``) changed. The read stays a module attribute so a re-imported
|
||||
``server`` re-publishes a fresh DB into the seam — see ``appstate.py``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/artist-aliases")
|
||||
def list_artist_aliases():
|
||||
"""Existing raw→canonical overrides (the Tidy-up 'current merges' list)."""
|
||||
return {"aliases": appstate.meta_db.list_artist_aliases()}
|
||||
|
||||
|
||||
@router.get("/api/artists/raw")
|
||||
def list_raw_artists(limit: int = 2000):
|
||||
"""Distinct RAW artist names + song counts + current canonical — the Tidy-up
|
||||
picker (you merge raw variants into one canonical)."""
|
||||
return {"artists": appstate.meta_db.raw_artists(limit)}
|
||||
|
||||
|
||||
@router.post("/api/artist-aliases")
|
||||
def set_artist_alias(data: dict):
|
||||
"""Upsert one override: {raw_name, canonical_name, mb_artist_id?}. A self-alias
|
||||
(raw == canonical) clears the row instead (un-merge)."""
|
||||
raw = (data.get("raw_name") or "").strip()
|
||||
canon = (data.get("canonical_name") or "").strip()
|
||||
if not raw or not canon:
|
||||
return JSONResponse({"error": "raw_name and canonical_name are required"}, 400)
|
||||
result = appstate.meta_db.set_artist_alias(raw, canon, (data.get("mb_artist_id") or None))
|
||||
if not result.get("ok"):
|
||||
# Would form a cycle (raw → … → raw) — refuse rather than corrupt the chain.
|
||||
return JSONResponse(
|
||||
{"error": "alias would create a cycle", "raw_name": raw, "canonical_name": canon},
|
||||
409)
|
||||
return {"ok": True, "raw_name": raw, "canonical_name": result.get("canonical_name", canon)}
|
||||
|
||||
|
||||
@router.post("/api/artist-aliases/merge")
|
||||
def merge_artist_aliases(data: dict):
|
||||
"""Merge several raw artist variants into one canonical:
|
||||
{raw_names: [...], canonical_name}. The canonical's own self-alias is skipped.
|
||||
Returns {merged: N}."""
|
||||
canon = (data.get("canonical_name") or "").strip()
|
||||
raws = data.get("raw_names")
|
||||
if not canon:
|
||||
return JSONResponse({"error": "canonical_name is required"}, 400)
|
||||
if not isinstance(raws, list) or not raws:
|
||||
return JSONResponse({"error": "raw_names must be a non-empty array"}, 400)
|
||||
n = appstate.meta_db.merge_artists(raws, canon)
|
||||
return {"merged": n, "canonical_name": canon}
|
||||
|
||||
|
||||
@router.delete("/api/artist-aliases/{raw_name:path}")
|
||||
def delete_artist_alias(raw_name: str):
|
||||
"""Remove one override so that raw artist stands on its own again."""
|
||||
appstate.meta_db.remove_artist_alias(raw_name)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Audio-effects mapping API — the core-owned song/tone -> provider routing index.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); only the decorator receiver
|
||||
(``@app`` -> ``@router``) and the singleton read (``audio_effect_mappings`` ->
|
||||
``appstate.audio_effect_mappings``) changed. The read must stay a module
|
||||
attribute so a re-imported ``server`` re-publishes a fresh DB into the seam and
|
||||
`monkeypatch.setattr` reaches this module — see ``appstate.py``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _audio_effects_error(exc: Exception):
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
|
||||
|
||||
@router.get("/api/audio-effects/mappings")
|
||||
def list_audio_effect_mappings(
|
||||
song_key: str = Query(""),
|
||||
filename: str = Query(""),
|
||||
tone_key: str = Query(""),
|
||||
provider_id: str = Query(""),
|
||||
):
|
||||
try:
|
||||
return {
|
||||
"mappings": appstate.audio_effect_mappings.list(
|
||||
song_key=song_key,
|
||||
filename=filename,
|
||||
tone_key=tone_key,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
}
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
|
||||
|
||||
@router.post("/api/audio-effects/mappings")
|
||||
def upsert_audio_effect_mapping(data: dict = Body(...)):
|
||||
try:
|
||||
mapping = appstate.audio_effect_mappings.upsert(data)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
return {"ok": True, "mapping": mapping}
|
||||
|
||||
|
||||
@router.delete("/api/audio-effects/mappings/{mapping_id}")
|
||||
def delete_audio_effect_mapping(mapping_id: int, provider_id: str = Query("")):
|
||||
try:
|
||||
deleted = appstate.audio_effect_mappings.delete(mapping_id, provider_id=provider_id)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
if not deleted:
|
||||
return JSONResponse({"error": "mapping not found"}, status_code=404)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/audio-effects/mappings/{mapping_id}/activate")
|
||||
def activate_audio_effect_mapping(mapping_id: int, data: dict = Body(default_factory=dict)):
|
||||
try:
|
||||
provider_id = data.get("provider_id") if "provider_id" in data else data.get("providerId")
|
||||
mapping = appstate.audio_effect_mappings.activate(mapping_id, provider_id="" if provider_id is None else provider_id)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
if not mapping:
|
||||
return JSONResponse({"error": "mapping not found"}, status_code=404)
|
||||
return {"ok": True, "mapping": mapping}
|
||||
|
||||
|
||||
@router.delete("/api/audio-effects/active-mapping")
|
||||
def clear_audio_effect_active_mapping(song_key: str = Query(...), tone_key: str = Query("")):
|
||||
try:
|
||||
cleared = appstate.audio_effect_mappings.clear_active(song_key=song_key, tone_key=tone_key)
|
||||
except ValueError as exc:
|
||||
return _audio_effects_error(exc)
|
||||
return {"ok": True, "cleared": cleared}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Chart-level endpoints — split/unsplit a chart from its work, resolve work
|
||||
membership, and the context-menu "Get info" file inspector.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``. DLC path resolution comes from
|
||||
``dlc_paths``; sloppak/loose detection from the shared lib modules.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
import appstate
|
||||
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||
import sloppak as sloppak_mod
|
||||
import loosefolder as loosefolder_mod
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/api/chart/{filename:path}/split")
|
||||
def api_split_chart(filename: str):
|
||||
"""'These aren't the same song' — split this chart out as its own singleton
|
||||
work. Under /api/chart (NOT /api/song) so the DELETE /api/song/{path}
|
||||
catch-all can't shadow it."""
|
||||
key = appstate.meta_db._canonical_song_filename(filename)
|
||||
appstate.meta_db.split_chart(key)
|
||||
return {"ok": True, "filename": key}
|
||||
|
||||
|
||||
@router.post("/api/chart/{filename:path}/unsplit")
|
||||
def api_unsplit_chart(filename: str):
|
||||
"""Undo a split — rejoin the chart to its work."""
|
||||
key = appstate.meta_db._canonical_song_filename(filename)
|
||||
appstate.meta_db.unsplit_chart(key)
|
||||
return {"ok": True, "filename": key}
|
||||
|
||||
|
||||
@router.get("/api/chart/{filename:path}/work")
|
||||
def api_get_chart_work(filename: str):
|
||||
"""Resolve a chart's work membership: {work_key, chart_count}. For openers
|
||||
on rows that came from an ungrouped query (the tree view) — grouped grid
|
||||
rows already carry both fields inline."""
|
||||
return appstate.meta_db.chart_work(filename)
|
||||
|
||||
|
||||
@router.get("/api/chart/{filename:path}/fileinfo")
|
||||
def api_chart_fileinfo(filename: str):
|
||||
"""The context menu's "Get info": where the file lives + what the pack
|
||||
contains. Under /api/chart — the GET /api/song/{path} catch-all would
|
||||
swallow a /api/song/…/fileinfo suffix. Read-only; demo-mode blocks it
|
||||
because it exposes filesystem paths."""
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
raise HTTPException(status_code=404, detail="not configured")
|
||||
p = _resolve_dlc_path(dlc, filename)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
if not p.exists():
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
# Restrict to actual charts — sloppak or loose song. Without this the route
|
||||
# would stat ANY file the user happens to keep under DLC_DIR (e.g. notes),
|
||||
# leaking its path/size; the app only recognises these two song formats.
|
||||
is_pak = sloppak_mod.is_sloppak(p)
|
||||
is_loose = loosefolder_mod.is_loose_song(p)
|
||||
if not (is_pak or is_loose):
|
||||
raise HTTPException(status_code=404, detail="not a chart")
|
||||
st = p.stat()
|
||||
info = {
|
||||
"filename": filename,
|
||||
"path": str(p),
|
||||
"folder": str(p.parent),
|
||||
"format": "sloppak" if is_pak else "loose",
|
||||
# Directory-form songs report the tree's total (covers loose folders
|
||||
# and dir-form paks); zip-form paks report the archive size. Symlinked
|
||||
# entries are skipped so a link inside the folder can't pull in — or
|
||||
# leak the size of — a file outside it.
|
||||
"size": (st.st_size if p.is_file()
|
||||
else sum(f.stat().st_size for f in p.rglob("*")
|
||||
if f.is_file() and not f.is_symlink())),
|
||||
"mtime": st.st_mtime,
|
||||
}
|
||||
if is_pak:
|
||||
try:
|
||||
m = sloppak_mod.load_manifest(p) or {}
|
||||
except Exception:
|
||||
m = {}
|
||||
arrs = [str(a.get("name", a.get("id", ""))) for a in (m.get("arrangements") or [])
|
||||
if isinstance(a, dict)]
|
||||
stems = [str(s.get("id", "")) for s in (m.get("stems") or []) if isinstance(s, dict)]
|
||||
try:
|
||||
has_cover = sloppak_mod.read_cover_bytes(p, m) is not None
|
||||
except Exception:
|
||||
has_cover = False
|
||||
# The optional identity/catalog keys, listed only when present — the
|
||||
# Get-info panel's "what this pack carries vs what's missing" readout.
|
||||
identity = {k: m.get(k) for k in
|
||||
("mbid", "isrc", "genres", "track", "disc", "album_artist",
|
||||
"feedpak_version", "language")
|
||||
if m.get(k) not in (None, "", [])}
|
||||
info["manifest"] = {
|
||||
"title": str(m.get("title", "")), "artist": str(m.get("artist", "")),
|
||||
"album": str(m.get("album", "")), "year": str(m.get("year", "") or ""),
|
||||
"arrangements": arrs, "stems": stems,
|
||||
"has_cover": has_cover, "has_lyrics": bool(m.get("lyrics")),
|
||||
"authors": [a.get("name", "") if isinstance(a, dict) else str(a)
|
||||
for a in (m.get("authors") or [])],
|
||||
"identity": identity,
|
||||
}
|
||||
# The enrichment verdict, so Get info can say "Matched (auto, 96%)" /
|
||||
# "Pinned by you" / "Not matched" alongside the file facts.
|
||||
row = appstate.meta_db.get_enrichment(filename)
|
||||
if row:
|
||||
info["match"] = {k: row.get(k) for k in
|
||||
("match_state", "match_source", "match_score",
|
||||
"canon_artist", "canon_title", "canon_album", "canon_year")}
|
||||
return info
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Diagnostic bundle export + hardware probe (/api/diagnostics/*).
|
||||
|
||||
One-click "Export Diagnostics" in Settings produces a redacted zip combining
|
||||
server logs, system info, hardware (CPU/GPU/RAM), plugin inventory, and the
|
||||
browser-side console transcript + hardware probe. Bundle format is specified in
|
||||
docs/diagnostics-bundle-spec.md.
|
||||
|
||||
Extracted verbatim from server.py (R3) except:
|
||||
- the decorators (@app -> @router),
|
||||
- CONFIG_DIR -> appstate.config_dir and _running_version() ->
|
||||
appstate.running_version() (both read through the appstate seam),
|
||||
- the builtin-plugins lookup in _diag_plugins_roots: Path(__file__).parent
|
||||
(the app root when this lived at the top level) ->
|
||||
Path(__file__).resolve().parents[2] (routers -> lib -> app root). The
|
||||
plugins/ dir ships at the app root in every packaging path.
|
||||
|
||||
The pure helpers + caps here are re-exported from server.py so the existing
|
||||
`server._diag_*` / `server._DIAG_*` tests keep resolving (none monkeypatch them).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Response
|
||||
|
||||
import appstate
|
||||
from dlc_paths import _get_dlc_dir
|
||||
from diagnostics_bundle import build_bundle as _diag_build, preview_bundle as _diag_preview
|
||||
from diagnostics_hardware import collect as _diag_hardware
|
||||
from env_compat import getenv_compat
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _diag_log_file() -> Path | None:
|
||||
raw = os.environ.get("LOG_FILE", "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
return Path(raw)
|
||||
|
||||
|
||||
def _diag_plugins_roots() -> list[Path]:
|
||||
"""Return all plugin root directories for orphan scanning.
|
||||
|
||||
Includes both the built-in ``plugins/`` directory and
|
||||
``FEEDBACK_PLUGINS_DIR`` when set, so user-installed plugins and
|
||||
orphans in the external dir are reflected in the bundle.
|
||||
"""
|
||||
roots: list[Path] = []
|
||||
user_dir = getenv_compat("FEEDBACK_PLUGINS_DIR", "").strip()
|
||||
if user_dir:
|
||||
p = Path(user_dir)
|
||||
if p.is_dir():
|
||||
roots.append(p)
|
||||
builtin = Path(__file__).resolve().parents[2] / "plugins" # R3: app root from lib/routers/
|
||||
if builtin not in roots:
|
||||
roots.append(builtin)
|
||||
return roots
|
||||
|
||||
|
||||
def _diag_coerce_bool(v, *, default: bool = True) -> bool:
|
||||
"""Coerce a request-side value to bool, accepting both JSON booleans and
|
||||
string representations.
|
||||
|
||||
- Falsy strings: ``"false"``, ``"0"``, ``"no"``, ``""`` → ``False``
|
||||
- ``None`` → *default*
|
||||
- Everything else (including ``"true"``, ``"1"``) → ``True``
|
||||
"""
|
||||
if v is None:
|
||||
return default
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
return v.strip().lower() not in ("false", "0", "no", "")
|
||||
return bool(v)
|
||||
|
||||
|
||||
def _diag_normalize_include(include: dict | None) -> dict:
|
||||
"""Coerce request-side flags to the booleans build_bundle expects.
|
||||
Missing keys default to True so a bare {} request still produces
|
||||
the full bundle.
|
||||
|
||||
Accepts both JSON booleans (``true``/``false``) and string
|
||||
representations so callers that serialize flags as strings behave
|
||||
consistently with the preview endpoint:
|
||||
- Falsy strings: ``"false"``, ``"0"``, ``"no"``, ``""`` → ``False``
|
||||
- Everything else (including ``"true"``, ``"1"``, ``"yes"``) → ``True``
|
||||
"""
|
||||
keys = ("system", "hardware", "logs", "console", "plugins")
|
||||
if not isinstance(include, dict):
|
||||
return {k: True for k in keys}
|
||||
|
||||
return {k: _diag_coerce_bool(include.get(k), default=True) for k in keys}
|
||||
|
||||
|
||||
# Server-side caps on client-supplied payload sections. diagnostics.js
|
||||
# enforces a 500-entry / ~250 KB ring buffer on the browser side; these
|
||||
# bounds give generous headroom while still preventing a crafted POST from
|
||||
# forcing the server to allocate arbitrarily large in-memory bundles.
|
||||
_DIAG_MAX_CONSOLE_ENTRIES = 1000 # hard cap: truncate silently
|
||||
_DIAG_MAX_CONSOLE_BYTES = 2 * 1024 * 1024 # 2 MB hard cap on total console list
|
||||
_DIAG_MAX_CLIENT_PAYLOAD_BYTES = 2 * 1024 * 1024 # 2 MB per dict section
|
||||
_DIAG_MAX_CONTRIBUTIONS_BYTES = 4 * 1024 * 1024 # 4 MB aggregate cap for contributions
|
||||
|
||||
|
||||
def _diag_cap_console(v) -> list | None:
|
||||
"""Return *v* if it is a list, truncated to _DIAG_MAX_CONSOLE_ENTRIES entries
|
||||
and _DIAG_MAX_CONSOLE_BYTES total. Entries are accumulated until either cap
|
||||
is reached; no partial-entry splitting occurs."""
|
||||
if not isinstance(v, list):
|
||||
return None
|
||||
result = v[:_DIAG_MAX_CONSOLE_ENTRIES]
|
||||
# Also enforce a byte cap — the count cap alone does not bound memory when
|
||||
# entries contain arbitrarily large strings.
|
||||
try:
|
||||
out = []
|
||||
total = 0
|
||||
for entry in result:
|
||||
encoded = json.dumps(entry, separators=(",", ":")).encode("utf-8", errors="replace")
|
||||
if total + len(encoded) > _DIAG_MAX_CONSOLE_BYTES:
|
||||
break
|
||||
out.append(entry)
|
||||
total += len(encoded)
|
||||
return out
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _diag_cap_dict(v) -> dict | None:
|
||||
"""Return *v* if it is a dict whose JSON serialisation fits within
|
||||
_DIAG_MAX_CLIENT_PAYLOAD_BYTES, otherwise return None."""
|
||||
if not isinstance(v, dict):
|
||||
return None
|
||||
try:
|
||||
encoded = json.dumps(v, separators=(",", ":")).encode("utf-8", errors="replace")
|
||||
except (TypeError, ValueError) as e:
|
||||
log.warning("diagnostics client payload is not JSON-serialisable, dropping: %s", e)
|
||||
return None
|
||||
if len(encoded) > _DIAG_MAX_CLIENT_PAYLOAD_BYTES:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def _diag_cap_contributions(v, known_ids=None) -> dict | None:
|
||||
"""Apply per-plugin and aggregate size caps on client_contributions.
|
||||
|
||||
Unlike _diag_cap_dict(), which drops the whole dict when any plugin
|
||||
exceeds the limit, this function caps each plugin independently so
|
||||
one noisy plugin does not silence every other plugin's contribution.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
v:
|
||||
The raw contributions dict from the POST payload.
|
||||
known_ids:
|
||||
When provided, contributions from plugins not in this set are
|
||||
skipped *before* serialisation, preventing a malicious caller
|
||||
from forcing the server to JSON-encode hundreds of near-limit
|
||||
payloads that ``build_bundle()`` would later discard anyway.
|
||||
``None`` means "accept all plugin ids" (used in tests / preview).
|
||||
"""
|
||||
if not isinstance(v, dict):
|
||||
return None
|
||||
result = {}
|
||||
total_bytes = 0
|
||||
for pid, contribution in v.items():
|
||||
if not isinstance(pid, str):
|
||||
continue
|
||||
# Filter unknown plugin ids early — before serialising — so a
|
||||
# crafted request cannot force large allocations for plugins that
|
||||
# build_bundle() would drop.
|
||||
if known_ids is not None and pid not in known_ids:
|
||||
continue
|
||||
try:
|
||||
encoded = json.dumps(contribution, separators=(",", ":")).encode("utf-8", errors="replace")
|
||||
except (TypeError, ValueError) as e:
|
||||
log.warning(
|
||||
"client_contributions[%r] is not JSON-serialisable, dropping: %s", pid, e
|
||||
)
|
||||
continue
|
||||
if len(encoded) > _DIAG_MAX_CLIENT_PAYLOAD_BYTES:
|
||||
log.warning(
|
||||
"client_contributions[%r] exceeds %d bytes, dropping",
|
||||
pid, _DIAG_MAX_CLIENT_PAYLOAD_BYTES,
|
||||
)
|
||||
continue
|
||||
if total_bytes + len(encoded) > _DIAG_MAX_CONTRIBUTIONS_BYTES:
|
||||
log.warning(
|
||||
"client_contributions aggregate size limit (%d bytes) reached, "
|
||||
"dropping remaining entries",
|
||||
_DIAG_MAX_CONTRIBUTIONS_BYTES,
|
||||
)
|
||||
break
|
||||
result[pid] = contribution
|
||||
total_bytes += len(encoded)
|
||||
return result or None
|
||||
|
||||
|
||||
@router.post("/api/diagnostics/export")
|
||||
def export_diagnostics(payload: dict = Body(default_factory=dict)):
|
||||
"""Build a diagnostic bundle and stream it back as a zip download.
|
||||
|
||||
The browser layers in `client_console`, `client_hardware`,
|
||||
`client_ua`, and `local_storage` before posting; the server adds
|
||||
server logs, hardware, plugin inventory, and packages everything
|
||||
into a single zip.
|
||||
|
||||
Errors during plugin diagnostics callables are caught and logged
|
||||
to the bundle's manifest `notes` rather than failing the export.
|
||||
"""
|
||||
from plugins import LOADED_PLUGINS, PLUGINS_LOCK
|
||||
|
||||
redact = _diag_coerce_bool(payload.get("redact", True), default=True)
|
||||
include = _diag_normalize_include(payload.get("include"))
|
||||
client_console = _diag_cap_console(payload.get("client_console"))
|
||||
client_hardware = _diag_cap_dict(payload.get("client_hardware"))
|
||||
client_ua = _diag_cap_dict(payload.get("client_ua"))
|
||||
local_storage = _diag_cap_dict(payload.get("local_storage"))
|
||||
# Fetch the plugin list first so we can filter contributions to known
|
||||
# plugin ids before serialising — prevents a crafted request from
|
||||
# forcing large allocations for plugins build_bundle() would drop.
|
||||
with PLUGINS_LOCK:
|
||||
plugins_snapshot = list(LOADED_PLUGINS)
|
||||
known_ids = {p.get("id") for p in plugins_snapshot if isinstance(p.get("id"), str)}
|
||||
client_contributions = _diag_cap_contributions(
|
||||
payload.get("client_contributions"), known_ids=known_ids
|
||||
)
|
||||
|
||||
zip_bytes, filename, _manifest = _diag_build(
|
||||
feedBack_version=appstate.running_version(),
|
||||
config_dir=appstate.config_dir,
|
||||
dlc_dir=_get_dlc_dir(),
|
||||
log_file=_diag_log_file(),
|
||||
loaded_plugins=plugins_snapshot,
|
||||
include=include,
|
||||
redact=redact,
|
||||
client_console=client_console,
|
||||
client_hardware=client_hardware,
|
||||
client_ua=client_ua,
|
||||
local_storage=local_storage,
|
||||
client_contributions=client_contributions,
|
||||
log=log,
|
||||
plugins_root=_diag_plugins_roots(),
|
||||
)
|
||||
return Response(
|
||||
content=zip_bytes,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/diagnostics/preview")
|
||||
def preview_diagnostics(
|
||||
redact: bool = True,
|
||||
system: bool = True,
|
||||
hardware: bool = True,
|
||||
logs: bool = True,
|
||||
console: bool = True,
|
||||
plugins: bool = True,
|
||||
):
|
||||
"""Return what `/api/diagnostics/export` would produce, minus the
|
||||
actual file contents — file tree, sizes, schemas, redaction counts.
|
||||
Lets the Settings UI show the user what's about to be sent."""
|
||||
from plugins import LOADED_PLUGINS, PLUGINS_LOCK
|
||||
|
||||
include = {
|
||||
"system": system,
|
||||
"hardware": hardware,
|
||||
"logs": logs,
|
||||
"console": console,
|
||||
"plugins": plugins,
|
||||
}
|
||||
with PLUGINS_LOCK:
|
||||
plugins_snapshot = list(LOADED_PLUGINS)
|
||||
return _diag_preview(
|
||||
feedBack_version=appstate.running_version(),
|
||||
config_dir=appstate.config_dir,
|
||||
dlc_dir=_get_dlc_dir(),
|
||||
log_file=_diag_log_file(),
|
||||
loaded_plugins=plugins_snapshot,
|
||||
include=include,
|
||||
redact=redact,
|
||||
log=log,
|
||||
plugins_root=_diag_plugins_roots(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/diagnostics/hardware")
|
||||
def diagnostics_hardware():
|
||||
"""Backend hardware probe (cross-platform). Reusable independently
|
||||
of the bundle export — handy for "what's my GPU" plugin queries."""
|
||||
return _diag_hardware()
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Metadata-enrichment route handlers (/api/enrichment/*): status, kick/cancel,
|
||||
per-song state, the Match-Review queue (accept/reject/pick/search), and AcoustID
|
||||
fingerprint identify.
|
||||
|
||||
Extracted verbatim from server.py (R3) except @app->@router and the seam reads
|
||||
(meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir). The enrichment
|
||||
engine itself — transport, matcher, the background worker, and the upload caps —
|
||||
lives in lib/enrichment.py and is reached here as enrichment.X.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
import enrichment
|
||||
import mb_match
|
||||
from appconfig import _load_config
|
||||
|
||||
import logging
|
||||
log = logging.getLogger("feedBack.server")
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/enrichment/status")
|
||||
def enrichment_status():
|
||||
"""Enrichment pipeline state: worker flags + row counts by match_state.
|
||||
Ambient tool-state for the match-review UI (never a home-screen score —
|
||||
design §11); also what tests poke."""
|
||||
return {
|
||||
"running": enrichment._enrich_status["running"],
|
||||
"processed": enrichment._enrich_status["processed"],
|
||||
"last_pass_at": enrichment._enrich_status["last_pass_at"],
|
||||
"states": appstate.meta_db.enrichment_state_counts(),
|
||||
"total_songs": appstate.meta_db.count(),
|
||||
# Per-pass matching progress for the "Refresh Metadata" batch bar +
|
||||
# per-tile badges (total = songs queued to match this pass, matched =
|
||||
# done so far, current = the one being matched now).
|
||||
"total": enrichment._enrich_status.get("total", 0),
|
||||
"matched": enrichment._enrich_status.get("matched", 0),
|
||||
"current": enrichment._enrich_status.get("current"),
|
||||
"cancelling": enrichment._enrich_cancel.is_set(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/enrichment/song/{filename:path}")
|
||||
def api_enrichment_song(filename: str):
|
||||
"""Read-only per-song match provenance for the Details drawer (launch
|
||||
polish): which canonical identity this chart matched and how. A tiny
|
||||
projection of the cache row — no candidates, no cache paths."""
|
||||
row = appstate.meta_db.get_enrichment(filename)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="no enrichment row")
|
||||
return {k: row.get(k) for k in
|
||||
("match_state", "canon_artist", "canon_title",
|
||||
"match_source", "match_score")}
|
||||
|
||||
|
||||
@router.post("/api/enrichment/kick")
|
||||
def api_enrichment_kick():
|
||||
"""The Settings "Match now" button AND the library's "Refresh Metadata"
|
||||
button: request an enrichment pass without waiting for a scan to complete.
|
||||
Processes the songs that still need it (unscanned/changed + retriable
|
||||
failures) — already-matched songs are left alone, so on a fully-matched
|
||||
library this is a fast no-op. Single-flight + coalescing like every other
|
||||
kick — spamming it queues at most one follow-up pass."""
|
||||
return {"started": enrichment._kick_enrich()}
|
||||
|
||||
|
||||
@router.post("/api/enrichment/cancel")
|
||||
def api_enrichment_cancel():
|
||||
"""Stop button on the "Refresh Metadata" batch: signal the running pass to
|
||||
halt after the current song (an in-flight ≤1/s lookup can't be interrupted,
|
||||
but no new one is started) and drop any coalesced follow-up. A no-op when
|
||||
nothing is running."""
|
||||
was_running = enrichment._enrich_status["running"]
|
||||
if was_running:
|
||||
enrichment._enrich_cancel.set()
|
||||
return {"ok": True, "was_running": was_running}
|
||||
|
||||
|
||||
@router.post("/api/enrichment/rematch")
|
||||
def api_enrichment_rematch(data: dict = Body(...)):
|
||||
"""The library "Refresh Metadata" button: force a fresh re-match of the
|
||||
songs the grid is SHOWING (its visible/filtered window). Resets each to
|
||||
`unscanned` so the next pass re-fetches it from scratch — EXCEPT user-pinned
|
||||
`manual` rows, which are never auto-overwritten (apply_enrichment_match
|
||||
guards that) — then kicks one pass. Scoped to the visible set on purpose:
|
||||
fast (dozens of songs), visible (tiles animate), and it can't blow the whole
|
||||
≤1/s rate budget on a 1000-song library the way a full re-sweep would.
|
||||
Returns the filenames actually queued so the UI badges exactly those."""
|
||||
raw = (data or {}).get("filenames") or []
|
||||
fns = [str(f) for f in raw if isinstance(f, str)][:500]
|
||||
queued: list[str] = []
|
||||
for fn in fns:
|
||||
song = appstate.meta_db.enrichment_song_row(fn)
|
||||
if not song:
|
||||
continue
|
||||
h = appstate.meta_db.enrichment_content_hash(
|
||||
song["artist"], song["title"], song["album"], song["duration"])
|
||||
# allow_manual_overwrite=False → a manual pin is left as-is (returns
|
||||
# False), everything else resets to unscanned (returns True).
|
||||
if appstate.meta_db.apply_enrichment_match(fn, h, "unscanned",
|
||||
allow_manual_overwrite=False):
|
||||
queued.append(fn)
|
||||
started = enrichment._kick_enrich() if queued else False
|
||||
return {"queued": queued, "count": len(queued), "started": started}
|
||||
|
||||
|
||||
@router.post("/api/enrichment/states")
|
||||
def api_enrichment_states(data: dict = Body(...)):
|
||||
"""Per-tile match states for the grid's VISIBLE window during a metadata
|
||||
refresh: the client posts the filenames it is showing and gets back each
|
||||
one's match_state (+ the song being matched right now, + whether a pass is
|
||||
running), so a card can animate queued→working→result without a per-song
|
||||
round-trip. Read-only — safe for demo visitors (no network, no mutation)."""
|
||||
raw = (data or {}).get("filenames") or []
|
||||
# Bound the batch: a visible grid window is dozens of cards; cap defensively.
|
||||
fns = [str(f) for f in raw if isinstance(f, str)][:500]
|
||||
return {
|
||||
"states": appstate.meta_db.enrichment_states_for(fns),
|
||||
"current": enrichment._enrich_status.get("current"),
|
||||
"running": enrichment._enrich_status["running"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/enrichment/refresh/{filename:path}")
|
||||
def api_enrichment_refresh(filename: str):
|
||||
"""The context menu's "Refresh metadata": reset THIS song's match to
|
||||
unscanned (canonical values + candidates cleared, backoff zeroed) and
|
||||
kick a pass so it re-matches immediately. An EXPLICIT user action, so it
|
||||
may discard a manual pin — the automation never does, but the user
|
||||
asking for a re-match is the one party who owns that pin."""
|
||||
song = appstate.meta_db.enrichment_song_row(filename)
|
||||
if not song:
|
||||
raise HTTPException(status_code=404, detail="unknown song")
|
||||
h = appstate.meta_db.enrichment_content_hash(
|
||||
song["artist"], song["title"], song["album"], song["duration"])
|
||||
appstate.meta_db.apply_enrichment_match(filename, h, "unscanned",
|
||||
allow_manual_overwrite=True)
|
||||
return {"ok": True, "started": enrichment._kick_enrich()}
|
||||
|
||||
|
||||
@router.get("/api/enrichment/review")
|
||||
def api_enrichment_review(limit: int = 200):
|
||||
"""The Match-Review queue: songs whose text match landed in the medium-
|
||||
confidence review tier, each with its stored candidate list — the drawer
|
||||
renders straight from this, no MusicBrainz round-trip. Ordered by the
|
||||
user's enrich_review_order setting."""
|
||||
limit = max(1, min(int(limit), 500))
|
||||
cfg = _load_config(appstate.config_dir / "config.json") or {}
|
||||
order = cfg.get("enrich_review_order", "missing_first")
|
||||
return {
|
||||
"songs": appstate.meta_db.enrichment_review_queue(limit=limit, order=order),
|
||||
"total_review": appstate.meta_db.enrichment_state_counts().get("review", 0),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/enrichment/review/{filename:path}/accept")
|
||||
def api_enrichment_accept(filename: str, data: dict = Body(...)):
|
||||
"""Accept one of the stored review candidates: the row becomes a
|
||||
user-pinned `manual` match (never auto-reset). Display-only, like every
|
||||
enrichment write — nothing touches the pack file."""
|
||||
recording_id = str((data or {}).get("recording_id") or "")
|
||||
row = appstate.meta_db.get_enrichment(filename)
|
||||
if not row or row["match_state"] != "review":
|
||||
raise HTTPException(status_code=404, detail="no review row for this song")
|
||||
cand = next((c for c in (row.get("candidates") or [])
|
||||
if c.get("recording_id") == recording_id), None)
|
||||
if not cand:
|
||||
raise HTTPException(status_code=404, detail="candidate not in the stored list")
|
||||
if not appstate.meta_db.set_enrichment_manual(filename, cand, source="review"):
|
||||
raise HTTPException(status_code=404, detail="unknown song")
|
||||
return {"ok": True, "enrichment": appstate.meta_db.get_enrichment(filename)}
|
||||
|
||||
|
||||
@router.post("/api/enrichment/review/{filename:path}/reject")
|
||||
def api_enrichment_reject(filename: str):
|
||||
""""None of these" — clears any canonical values and parks the row as
|
||||
failed/rejected (never auto-retried; editing the song's metadata
|
||||
re-queues it). Valid from `review` or `matched`, never from `manual`."""
|
||||
if not appstate.meta_db.set_enrichment_rejected(filename):
|
||||
raise HTTPException(status_code=404, detail="no rejectable match for this song")
|
||||
return {"ok": True, "enrichment": appstate.meta_db.get_enrichment(filename)}
|
||||
|
||||
|
||||
# The candidate fields a manual pick is allowed to carry — the payload comes
|
||||
# from our own /api/enrichment/search proxy, but the route re-sanitizes so a
|
||||
# hand-rolled client can't stuff arbitrary keys/types into the cache row.
|
||||
_CAND_STR_FIELDS = ("recording_id", "title", "artist", "artist_id",
|
||||
"artist_sort", "release_id", "album", "year", "isrc")
|
||||
|
||||
|
||||
def _sanitize_candidate(raw: dict) -> dict | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
out = {k: str(raw.get(k) or "") for k in _CAND_STR_FIELDS}
|
||||
if not out["recording_id"] or not out["title"]:
|
||||
return None
|
||||
genres = raw.get("genres") or []
|
||||
out["genres"] = [str(g) for g in genres if isinstance(g, str)][:5] \
|
||||
if isinstance(genres, list) else []
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/api/enrichment/review/{filename:path}/pick")
|
||||
def api_enrichment_pick(filename: str, data: dict = Body(...)):
|
||||
"""Fix-match / manual search-and-pick: pin a candidate the user found via
|
||||
/api/enrichment/search (not limited to the stored review list — this is
|
||||
the escape hatch for a wrong auto-match too). Sets `manual`, the
|
||||
highest-authority state."""
|
||||
cand = _sanitize_candidate((data or {}).get("candidate"))
|
||||
if not cand:
|
||||
raise HTTPException(status_code=400, detail="candidate needs recording_id + title")
|
||||
if not appstate.meta_db.set_enrichment_manual(filename, cand, source="search"):
|
||||
raise HTTPException(status_code=404, detail="unknown song")
|
||||
return {"ok": True, "enrichment": appstate.meta_db.get_enrichment(filename)}
|
||||
|
||||
|
||||
@router.get("/api/enrichment/search")
|
||||
def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
|
||||
filename: str = "", duration: float = 0.0):
|
||||
"""Manual-search proxy to MusicBrainz (throttled + identified like the
|
||||
background matcher — a user typing in the drawer must not sidestep the
|
||||
rate limit). `filename` optionally scores results against that song's
|
||||
stored identity (year/duration corroboration) instead of just the typed
|
||||
text. `duration` (seconds) lets a caller that HAS the audio but no library
|
||||
row — e.g. the editor's create modal, which holds the master track — pass
|
||||
its length so the studio take ranks above live/extended cuts. Sync route on
|
||||
purpose: FastAPI runs it in the threadpool, so the throttle's sleep never
|
||||
blocks the event loop."""
|
||||
if not (artist.strip() or title.strip()):
|
||||
raise HTTPException(status_code=400, detail="artist or title required")
|
||||
limit = max(1, min(int(limit), 25))
|
||||
try:
|
||||
cands = enrichment._mb_search_recordings(artist, title, limit=limit)
|
||||
except enrichment.EnrichTransportError as e:
|
||||
return JSONResponse({"error": "musicbrainz unavailable", "detail": str(e)},
|
||||
status_code=503)
|
||||
ref = None
|
||||
if filename:
|
||||
ref = appstate.meta_db.enrichment_song_row(filename)
|
||||
if ref is None:
|
||||
ref = {"artist": artist, "title": title}
|
||||
# A caller-supplied duration corroborates the take even without a library row.
|
||||
if duration and duration > 0 and not ref.get("duration"):
|
||||
ref = dict(ref)
|
||||
ref["duration"] = duration
|
||||
# Alias-enrich so a non-Latin-primary artist (大橋純子) ranks by its
|
||||
# romanized alias against the typed query ("Junko Ohashi") instead of
|
||||
# sinking to the bottom with a 0 artist score.
|
||||
try:
|
||||
enrichment._alias_enrich(ref, cands)
|
||||
except enrichment.EnrichTransportError:
|
||||
pass # aliases are a ranking nicety here; fall back to primary-name scoring
|
||||
return {"candidates": mb_match.rank_candidates(ref, cands)}
|
||||
|
||||
|
||||
@router.post("/api/enrichment/identify")
|
||||
async def api_enrichment_identify(request: Request):
|
||||
"""Identify a song by AUDIO FINGERPRINT (AcoustID) rather than text — the
|
||||
reliable way to get the EXACT recording/version (the studio take, not a live
|
||||
bootleg or an extended cut). Upload the master audio; returns candidates in
|
||||
the same shape as /search, so the review UI and the editor's Match popup can
|
||||
render fingerprint hits identically. 412 `needs_setup` when the user hasn't
|
||||
opted in / has no key (the UI nudges them to Settings); 503 when it's set up
|
||||
but the fpcalc Chromaprint binary is missing or the network is off. Async so
|
||||
the multipart is size-capped BEFORE spooling; the blocking fpcalc subprocess
|
||||
+ AcoustID HTTP run in the threadpool via run_in_executor."""
|
||||
gate = enrichment._acoustid_gate()
|
||||
if gate is not None:
|
||||
return gate
|
||||
# Pre-parse Content-Length guard — reject an oversized body before Starlette
|
||||
# spools the multipart to temp disk (mirrors the song-upload endpoint). The
|
||||
# per-part cap below is the authoritative limit; this is the fast up-front no.
|
||||
cl = request.headers.get("content-length")
|
||||
if cl is not None:
|
||||
try:
|
||||
cl_int = int(cl)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400)
|
||||
if cl_int > enrichment._ACOUSTID_MAX_UPLOAD_BYTES + enrichment._MULTIPART_OVERHEAD_SLACK:
|
||||
return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413)
|
||||
try:
|
||||
form = await request.form(max_part_size=enrichment._ACOUSTID_MAX_UPLOAD_BYTES)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413)
|
||||
file = form.get("file")
|
||||
if not isinstance(file, UploadFile):
|
||||
raise HTTPException(status_code=400, detail="missing file upload")
|
||||
import tempfile
|
||||
ext = (Path(file.filename or "").suffix or ".bin").lower()
|
||||
tmpdir = tempfile.mkdtemp(prefix="feedback_acoustid_")
|
||||
tmp = os.path.join(tmpdir, "audio" + ext)
|
||||
try:
|
||||
total = 0
|
||||
with open(tmp, "wb") as fh:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > enrichment._ACOUSTID_MAX_UPLOAD_BYTES:
|
||||
return JSONResponse(
|
||||
{"error": "audio upload too large (256 MB max)"}, status_code=413)
|
||||
fh.write(chunk)
|
||||
if total == 0:
|
||||
raise HTTPException(status_code=400, detail="empty upload")
|
||||
# fpcalc subprocess + AcoustID HTTP are blocking — off the event loop.
|
||||
cands = await asyncio.get_event_loop().run_in_executor(
|
||||
None, enrichment._identify_by_fingerprint, tmp)
|
||||
except enrichment.EnrichTransportError as e:
|
||||
return JSONResponse({"error": "acoustid unavailable", "detail": str(e)},
|
||||
status_code=503)
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
return {"candidates": cands}
|
||||
|
||||
|
||||
@router.post("/api/enrichment/identify/{filename:path}")
|
||||
def api_enrichment_identify_song(filename: str):
|
||||
"""Identify an EXISTING library song by AUDIO FINGERPRINT — the library-side
|
||||
counterpart to /api/enrichment/identify (which takes an upload). Fingerprints
|
||||
the song's own master audio on disk (the manual "Identify by audio" action in
|
||||
the Fix-metadata / match-review flow). Same candidate shape as /search, so the
|
||||
review UI renders fingerprint hits like text hits. Same 412/503 gating; 404
|
||||
when the song has no full-mix audio to fingerprint."""
|
||||
gate = enrichment._acoustid_gate()
|
||||
if gate is not None:
|
||||
return gate
|
||||
audio = enrichment._song_audio_file(filename)
|
||||
if not audio:
|
||||
return JSONResponse(
|
||||
{"error": "no audio",
|
||||
"detail": "couldn't find this song's master audio to fingerprint "
|
||||
"(a stems-only pack has no full mix to identify)."},
|
||||
status_code=404)
|
||||
try:
|
||||
cands = enrichment._identify_by_fingerprint(audio)
|
||||
except enrichment.EnrichTransportError as e:
|
||||
return JSONResponse({"error": "acoustid unavailable", "detail": str(e)},
|
||||
status_code=503)
|
||||
return {"candidates": cands}
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Library + smart-collection routes: the provider list/art/sync endpoints, the
|
||||
library query surface (songs, albums, artists, stats, genres, tuning-names,
|
||||
practice-suggestions), and collection CRUD.
|
||||
|
||||
Extracted verbatim from server.py (R3) except @app->@router and the seam reads:
|
||||
meta_db->appstate.meta_db, and the registry singletons ->
|
||||
appstate.library_providers / appstate.local_library_provider (constructed +
|
||||
owned by server.py; plugins register providers through plugin_context). The
|
||||
provider classes + shared query/collection helpers live in lib/library_registry.py.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
import appstate
|
||||
from library_registry import (
|
||||
_library_filter_args, _sanitize_collection_rules,
|
||||
_safe_art_redirect_url, _split_csv, _sync_collection_provider,
|
||||
_unregister_collection_provider,
|
||||
)
|
||||
from metadata_db import _effective_keyset_sort, next_library_cursor
|
||||
from reqfields import _clean_str
|
||||
|
||||
import logging
|
||||
log = logging.getLogger("feedBack.server")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _get_library_provider(provider: str = "local") -> object:
|
||||
library_provider = appstate.library_providers.get(provider or "local")
|
||||
if library_provider is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown library provider: {provider}")
|
||||
return library_provider
|
||||
|
||||
|
||||
def _require_library_provider_capability(provider: object, capability: str) -> None:
|
||||
if capability in appstate.library_providers.provider_capabilities(provider):
|
||||
return
|
||||
provider_id = appstate.library_providers.provider_id(provider)
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail=f"Library provider {provider_id!r} does not declare capability {capability!r}",
|
||||
)
|
||||
|
||||
|
||||
_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters", "after",
|
||||
"mastery", "match_states")
|
||||
|
||||
|
||||
def _filter_provider_kwargs(method: object, kwargs: dict) -> dict:
|
||||
"""Drop kwargs that the method's signature does not declare.
|
||||
|
||||
Provides backward-compat for third-party library providers whose
|
||||
query_page/query_artists/query_stats methods were written before
|
||||
naming_mode was added — calling them with the extra kwarg would
|
||||
raise TypeError and return a 500 to the client.
|
||||
|
||||
When ``inspect.signature`` cannot introspect the method (rare: C
|
||||
extensions / built-ins / exotic callables), fall back to stripping
|
||||
only the kwargs we know were added later — older providers won't
|
||||
accept them, anything else stays so the call still works.
|
||||
"""
|
||||
try:
|
||||
sig = inspect.signature(method) # type: ignore[arg-type]
|
||||
for p in sig.parameters.values():
|
||||
if p.kind == inspect.Parameter.VAR_KEYWORD:
|
||||
return kwargs # method accepts **kwargs, pass everything
|
||||
return {k: v for k, v in kwargs.items() if k in sig.parameters}
|
||||
except (ValueError, TypeError):
|
||||
return {k: v for k, v in kwargs.items() if k not in _OPTIONAL_NEW_PROVIDER_KWARGS}
|
||||
|
||||
|
||||
def _call_library_provider(provider: object, method_name: str, **kwargs) -> Any:
|
||||
method = appstate.library_providers.provider_method(provider, method_name)
|
||||
if not callable(method):
|
||||
provider_id = appstate.library_providers.provider_id(provider)
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail=f"Library provider {provider_id!r} does not support {method_name}",
|
||||
)
|
||||
try:
|
||||
return method(**_filter_provider_kwargs(method, kwargs))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
provider_id = appstate.library_providers.provider_id(provider)
|
||||
# A provider with an explicit kind="local" is treated as local even if
|
||||
# its id is not "local" (e.g. a kind="local" plugin variant). Otherwise
|
||||
# fall back to provider_id comparison so providers that omit `kind` are
|
||||
# still wrapped correctly — the safe default for unknown providers is to
|
||||
# surface an offline message rather than leaking raw exceptions.
|
||||
provider_kind = str(appstate.library_providers.provider_field(provider, "kind", "") or "")
|
||||
if provider_kind:
|
||||
is_remote = provider_kind not in ("", "local")
|
||||
else:
|
||||
is_remote = provider_id != "local"
|
||||
if is_remote:
|
||||
detail = f"This source appears to be offline ({provider_id})."
|
||||
message = str(exc).strip()
|
||||
if message:
|
||||
detail = f"{detail} {message}"
|
||||
raise HTTPException(status_code=503, detail=detail) from exc
|
||||
raise
|
||||
|
||||
|
||||
def _is_async_callable(obj: object) -> bool:
|
||||
"""Return True if obj is an async function or a callable object with an async __call__.
|
||||
|
||||
``inspect.iscoroutinefunction`` only recognises bare coroutine functions; it returns
|
||||
False for class instances whose ``__call__`` method is defined as ``async def``.
|
||||
Checking both handles the common plugin pattern of wrapping an async method in a
|
||||
callable object.
|
||||
"""
|
||||
if inspect.iscoroutinefunction(obj):
|
||||
return True
|
||||
_call = getattr(obj, "__call__", None)
|
||||
return _call is not None and inspect.iscoroutinefunction(_call)
|
||||
|
||||
|
||||
async def _call_library_provider_async(provider: object, method_name: str, **kwargs) -> Any:
|
||||
method = appstate.library_providers.provider_method(provider, method_name)
|
||||
if _is_async_callable(method):
|
||||
# Async provider method — call directly on the event loop.
|
||||
try:
|
||||
return await method(**_filter_provider_kwargs(method, kwargs))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
provider_id = appstate.library_providers.provider_id(provider)
|
||||
provider_kind = str(appstate.library_providers.provider_field(provider, "kind", "") or "")
|
||||
if provider_kind:
|
||||
is_remote = provider_kind not in ("", "local")
|
||||
else:
|
||||
is_remote = provider_id != "local"
|
||||
if is_remote:
|
||||
detail = f"This source appears to be offline ({provider_id})."
|
||||
message = str(exc).strip()
|
||||
if message:
|
||||
detail = f"{detail} {message}"
|
||||
raise HTTPException(status_code=503, detail=detail) from exc
|
||||
raise
|
||||
# Synchronous provider method — run in a threadpool so the event loop stays free.
|
||||
return await run_in_threadpool(_call_library_provider, provider, method_name, **kwargs)
|
||||
|
||||
|
||||
def _library_art_response(result: Any) -> Response:
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Library provider returned no art")
|
||||
if isinstance(result, Response):
|
||||
return result
|
||||
if isinstance(result, (bytes, bytearray, memoryview)):
|
||||
return Response(content=bytes(result), media_type="image/png")
|
||||
if isinstance(result, str):
|
||||
safe_url = _safe_art_redirect_url(result)
|
||||
if safe_url is not None:
|
||||
return RedirectResponse(safe_url)
|
||||
# If the string looks like a URL (contains a scheme separator) but
|
||||
# didn't pass the http/https check, refuse it rather than treating
|
||||
# it as a filesystem path — a provider returning ftp:// or file://
|
||||
# should get a 400, not a 500 from FileResponse failing on a URL.
|
||||
if "://" in result:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Library provider returned an unsupported URL scheme for art",
|
||||
)
|
||||
if not Path(result).is_file():
|
||||
raise HTTPException(status_code=404, detail="Library provider returned an unreadable art path")
|
||||
return FileResponse(result)
|
||||
if isinstance(result, Path):
|
||||
if not result.is_file():
|
||||
raise HTTPException(status_code=404, detail="Library provider returned an unreadable art path")
|
||||
return FileResponse(str(result))
|
||||
if isinstance(result, dict):
|
||||
url = result.get("url") or result.get("art_url") or result.get("artUrl")
|
||||
if isinstance(url, str) and url:
|
||||
safe_url = _safe_art_redirect_url(url)
|
||||
if safe_url is None:
|
||||
raise HTTPException(status_code=400, detail="Library provider returned an unsafe art URL")
|
||||
return RedirectResponse(safe_url)
|
||||
path = result.get("path") or result.get("file")
|
||||
if isinstance(path, (str, Path)):
|
||||
media_type = result.get("media_type") or result.get("content_type")
|
||||
if not Path(path).is_file():
|
||||
raise HTTPException(status_code=404, detail="Library provider returned an unreadable art path")
|
||||
return FileResponse(str(path), media_type=media_type)
|
||||
content = result.get("content") or result.get("bytes")
|
||||
if isinstance(content, (bytes, bytearray, memoryview)):
|
||||
media_type = result.get("media_type") or result.get("content_type") or "image/png"
|
||||
return Response(content=bytes(content), media_type=media_type)
|
||||
raise HTTPException(status_code=500, detail="Library provider returned unsupported art data")
|
||||
|
||||
|
||||
@router.get("/api/library/providers")
|
||||
def list_library_providers():
|
||||
"""List registered library providers."""
|
||||
return {"providers": appstate.library_providers.list()}
|
||||
|
||||
|
||||
@router.get("/api/library/providers/{provider_id}/songs/{song_id:path}/art")
|
||||
async def get_library_provider_song_art(provider_id: str, song_id: str):
|
||||
"""Return album art for a song owned by a library provider."""
|
||||
library_provider = _get_library_provider(provider_id)
|
||||
_require_library_provider_capability(library_provider, "art.read")
|
||||
result = await _call_library_provider_async(library_provider, "get_art", song_id=song_id)
|
||||
return _library_art_response(result)
|
||||
|
||||
|
||||
@router.post("/api/library/providers/{provider_id}/songs/{song_id:path}/sync")
|
||||
async def sync_library_provider_song(provider_id: str, song_id: str):
|
||||
"""Ask a provider to sync a remote song into the local library/cache."""
|
||||
library_provider = _get_library_provider(provider_id)
|
||||
_require_library_provider_capability(library_provider, "song.sync")
|
||||
result = await _call_library_provider_async(library_provider, "sync_song", song_id=song_id)
|
||||
if result is None:
|
||||
return {"ok": True}
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
return {"ok": True, "result": result}
|
||||
|
||||
|
||||
@router.get("/api/library")
|
||||
async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "artist",
|
||||
dir: str = "asc", favorites: int = 0, format: str = "",
|
||||
artist: str = "", album: str = "",
|
||||
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||
stems_has: str = "", stems_lacks: str = "",
|
||||
has_lyrics: str = "", tunings: str = "", provider: str = "local",
|
||||
mastery: str = "", tags: str = "", user_difficulty: str = "",
|
||||
match: str = "", genre: str = "", after: str = "", group: int = 0,
|
||||
naming_mode: str = "legacy"):
|
||||
"""Paginated library search through the selected library provider.
|
||||
|
||||
`after` is an opaque keyset cursor (feedBack#636 item 3): pass back the
|
||||
`next_cursor` from the previous response to fetch the next page with a
|
||||
WHERE-seek instead of OFFSET. Providers that don't support it ignore it and
|
||||
page by OFFSET, so the client can always fall back."""
|
||||
size = min(size, 100)
|
||||
library_provider = _get_library_provider(provider)
|
||||
_require_library_provider_capability(library_provider, "library.read")
|
||||
# Only the true local provider keysets: it's the one whose effective sort is
|
||||
# exactly the request `sort`. A smart collection may pin its own sort and
|
||||
# remote providers don't keyset — both must page by OFFSET, so never hand
|
||||
# them a cursor (a mismatched one would mis-seek).
|
||||
is_local = getattr(library_provider, "id", "") == "local"
|
||||
songs, total = await _call_library_provider_async(
|
||||
library_provider,
|
||||
"query_page",
|
||||
page=page,
|
||||
size=size,
|
||||
sort=sort,
|
||||
direction=dir,
|
||||
after=((after or None) if is_local else None),
|
||||
group=bool(group),
|
||||
naming_mode=naming_mode,
|
||||
mastery=_split_csv(mastery),
|
||||
tags_has=_split_csv(tags),
|
||||
user_difficulty_in=_split_csv(user_difficulty),
|
||||
match_states=_split_csv(match),
|
||||
genre=_split_csv(genre),
|
||||
**_library_filter_args(
|
||||
q=q, favorites=favorites, format=format,
|
||||
artist=artist, album=album,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
),
|
||||
)
|
||||
# The cursor to resume after this page (effective sort folds in dir=desc).
|
||||
next_cursor = (next_library_cursor(_effective_keyset_sort(sort, dir), songs[-1])
|
||||
if (is_local and songs) else None)
|
||||
# Drop the private raw-title stash query_page attached for the cursor — it's
|
||||
# an internal keyset detail, not part of the card payload.
|
||||
for s in songs:
|
||||
s.pop("_sort_title", None)
|
||||
return {"songs": songs, "total": total, "page": page, "size": size,
|
||||
"next_cursor": next_cursor}
|
||||
|
||||
|
||||
@router.get("/api/library/albums")
|
||||
async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
|
||||
favorites: int = 0, format: str = "",
|
||||
artist: str = "", album: str = "",
|
||||
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||
stems_has: str = "", stems_lacks: str = "",
|
||||
has_lyrics: str = "", tunings: str = "", mastery: str = "",
|
||||
match: str = "", genre: str = "",
|
||||
provider: str = "local"):
|
||||
"""Album-condensed browse: distinct (artist, album) groups with a track count
|
||||
and a representative cover song. Paged by album. Same filters as /api/library."""
|
||||
size = min(size, 500)
|
||||
library_provider = _get_library_provider(provider)
|
||||
_require_library_provider_capability(library_provider, "library.read")
|
||||
albums, total = await _call_library_provider_async(
|
||||
library_provider, "query_albums",
|
||||
page=page, size=size, mastery=_split_csv(mastery),
|
||||
match_states=_split_csv(match), genre=_split_csv(genre),
|
||||
**_library_filter_args(
|
||||
q=q, favorites=favorites, format=format, artist=artist, album=album,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
),
|
||||
)
|
||||
return {"albums": albums, "total": total, "page": page, "size": size}
|
||||
|
||||
|
||||
@router.get("/api/library/artists")
|
||||
async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page: int = 0,
|
||||
size: int = 50, format: str = "",
|
||||
artist: str = "", album: str = "",
|
||||
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||
stems_has: str = "", stems_lacks: str = "",
|
||||
has_lyrics: str = "", tunings: str = "", provider: str = "local",
|
||||
naming_mode: str = "legacy"):
|
||||
"""Get artists grouped by letter with albums and songs (for tree view)."""
|
||||
size = min(size, 100)
|
||||
library_provider = _get_library_provider(provider)
|
||||
_require_library_provider_capability(library_provider, "library.read")
|
||||
artists, total = await _call_library_provider_async(
|
||||
library_provider,
|
||||
"query_artists",
|
||||
letter=letter,
|
||||
page=page,
|
||||
size=size,
|
||||
naming_mode=naming_mode,
|
||||
**_library_filter_args(
|
||||
q=q, favorites=favorites, format=format,
|
||||
artist=artist, album=album,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
),
|
||||
)
|
||||
return {"artists": artists, "total_artists": total, "page": page, "size": size}
|
||||
|
||||
|
||||
@router.get("/api/library/stats")
|
||||
async def library_stats(favorites: int = 0, q: str = "", format: str = "",
|
||||
artist: str = "", album: str = "",
|
||||
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||
stems_has: str = "", stems_lacks: str = "",
|
||||
has_lyrics: str = "", tunings: str = "", provider: str = "local",
|
||||
match: str = "",
|
||||
sort: str = "artist", sort_letters: int = 0,
|
||||
group: int = 0, naming_mode: str = "legacy"):
|
||||
"""Aggregate stats for the UI. Accepts the same filter params as
|
||||
/api/library so the letter bar mirrors the active grid filter set.
|
||||
`sort` selects the column the jump rail's `sort_letters` keys on;
|
||||
`sort_letters=1` opts into that breakdown (the rail), so non-rail
|
||||
callers skip the extra per-letter aggregate. `group=1` counts works not
|
||||
charts (mirrors the grouped grid)."""
|
||||
library_provider = _get_library_provider(provider)
|
||||
_require_library_provider_capability(library_provider, "library.read")
|
||||
return await _call_library_provider_async(
|
||||
library_provider,
|
||||
"query_stats",
|
||||
naming_mode=naming_mode,
|
||||
sort=sort,
|
||||
want_sort_letters=bool(sort_letters),
|
||||
group=bool(group),
|
||||
# The match facet rides the stats call too — the A–Z 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}
|
||||
@@ -0,0 +1,75 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Practice loops — saved A/B regions per song.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); only the decorator receiver
|
||||
(``@app`` -> ``@router``) and the singleton reads (``meta_db`` ->
|
||||
``appstate.meta_db``) changed. See ``appstate.py`` for why the reads stay
|
||||
module attributes.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
import appstate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/loops")
|
||||
def list_loops(filename: str):
|
||||
# Hold the DB lock for the read: the shared single connection
|
||||
# (check_same_thread=False) is serialized through meta_db._lock by every
|
||||
# writer, so an unlocked SELECT here can overlap a POST/DELETE commit.
|
||||
db = appstate.meta_db
|
||||
with db._lock:
|
||||
rows = db.conn.execute(
|
||||
"SELECT id, name, start_time, end_time FROM loops WHERE filename = ? ORDER BY start_time",
|
||||
(filename,)
|
||||
).fetchall()
|
||||
return [{"id": r[0], "name": r[1], "start": r[2], "end": r[3]} for r in rows]
|
||||
|
||||
|
||||
@router.post("/api/loops")
|
||||
def save_loop(data: dict):
|
||||
filename = data.get("filename", "")
|
||||
name = data.get("name", "").strip()
|
||||
start = data.get("start")
|
||||
end = data.get("end")
|
||||
if not filename or start is None or end is None:
|
||||
return {"error": "Missing fields"}
|
||||
db = appstate.meta_db
|
||||
with db._lock:
|
||||
# COUNT + INSERT under one lock so two unnamed POSTs can't read the same
|
||||
# count and both mint "Loop N" (the count is only used to name the row).
|
||||
if not name:
|
||||
count = db.conn.execute(
|
||||
"SELECT COUNT(*) FROM loops WHERE filename = ?", (filename,)
|
||||
).fetchone()[0]
|
||||
name = f"Loop {count + 1}"
|
||||
db.conn.execute(
|
||||
"INSERT INTO loops (filename, name, start_time, end_time) VALUES (?, ?, ?, ?)",
|
||||
(filename, name, float(start), float(end))
|
||||
)
|
||||
db.conn.commit()
|
||||
return {"ok": True, "name": name}
|
||||
|
||||
|
||||
@router.delete("/api/loops/{loop_id}")
|
||||
def delete_loop(loop_id: int):
|
||||
with appstate.meta_db._lock:
|
||||
appstate.meta_db.conn.execute("DELETE FROM loops WHERE id = ?", (loop_id,))
|
||||
appstate.meta_db.conn.commit()
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Media/file-serving routes: song audio (/audio/{f}), the local-audio-path
|
||||
resolver (/api/audio-local-path), and raw sloppak member serving
|
||||
(/api/sloppak/{f}/file/{rel}).
|
||||
|
||||
Extracted verbatim from server.py (R3) except @app->@router and the cache/static
|
||||
path seams (AUDIO_CACHE_DIR->appstate.audio_cache_dir, STATIC_DIR->
|
||||
appstate.static_dir, SLOPPAK_CACHE_DIR->appstate.sloppak_cache_dir).
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
import appstate
|
||||
import sloppak as sloppak_mod
|
||||
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||
|
||||
import logging
|
||||
log = logging.getLogger("feedBack.server")
|
||||
router = APIRouter()
|
||||
|
||||
def _resolve_sloppak_local_file(filename: str, rel_path: str):
|
||||
"""Resolve a file inside a sloppak to its on-disk path.
|
||||
|
||||
Applies the same containment guards as ``serve_sloppak_file``. Returns the
|
||||
resolved ``Path`` on success, or an ``(error, status)`` tuple on failure so
|
||||
callers can produce their endpoint-appropriate response.
|
||||
"""
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
return ("not configured", 404)
|
||||
# `filename` is caller-controlled. Contain it under DLC_DIR before it
|
||||
# reaches the resolver (see serve_sloppak_file for the traversal rationale).
|
||||
resolved = _resolve_dlc_path(dlc, filename)
|
||||
if resolved is None:
|
||||
return ("forbidden", 403)
|
||||
# Confine to actual sloppak bundles — otherwise any plain subdirectory
|
||||
# would become a read-any-file-under-DLC_DIR source.
|
||||
if not sloppak_mod.is_sloppak(resolved):
|
||||
return ("not found", 404)
|
||||
# Canonicalise the cache key against the resolved path so equivalent URL
|
||||
# forms of the same sloppak converge on one _source_cache entry.
|
||||
try:
|
||||
filename = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
# safe_join already proved containment; fail closed regardless.
|
||||
return ("forbidden", 403)
|
||||
src = sloppak_mod.get_cached_source_dir(filename)
|
||||
if src is None:
|
||||
try:
|
||||
src = sloppak_mod.resolve_source_dir(filename, dlc, appstate.sloppak_cache_dir)
|
||||
except Exception:
|
||||
return ("not found", 404)
|
||||
# Prevent path traversal within the sloppak.
|
||||
target = (src / rel_path).resolve()
|
||||
try:
|
||||
target.relative_to(src.resolve())
|
||||
except ValueError:
|
||||
return ("forbidden", 403)
|
||||
if not target.exists() or not target.is_file():
|
||||
return ("not found", 404)
|
||||
return target
|
||||
|
||||
|
||||
@router.get("/api/sloppak/{filename:path}/file/{rel_path:path}")
|
||||
def serve_sloppak_file(filename: str, rel_path: str):
|
||||
"""Serve a file from inside a sloppak (stems, cover, etc.)."""
|
||||
result = _resolve_sloppak_local_file(filename, rel_path)
|
||||
if isinstance(result, tuple):
|
||||
error, status = result
|
||||
return JSONResponse({"error": error}, status)
|
||||
target = result
|
||||
ext = target.suffix.lower()
|
||||
mt = {
|
||||
".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg",
|
||||
".mp3": "audio/mpeg", ".wav": "audio/wav", ".flac": "audio/flac",
|
||||
".m4a": "audio/mp4",
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".png": "image/png", ".webp": "image/webp",
|
||||
".json": "application/json",
|
||||
}.get(ext)
|
||||
return FileResponse(str(target), media_type=mt) if mt else FileResponse(str(target))
|
||||
|
||||
|
||||
@router.get("/api/audio-local-path")
|
||||
def audio_local_path(url: str, request: Request):
|
||||
"""Return absolute local filesystem path for a song URL (Electron desktop only).
|
||||
|
||||
Accepts ``/audio/<path>`` where ``<path>`` may include subdirectory segments —
|
||||
no scheme, no host, no query string, no fragment. The resolved path must stay
|
||||
inside appstate.audio_cache_dir or appstate.static_dir; ``..`` traversal, backslashes, and
|
||||
absolute ``filename`` values are rejected.
|
||||
|
||||
Also accepts ``/api/sloppak/<filename>/file/<rel>`` (percent-encoded, as
|
||||
emitted by the highway song payload) and resolves it to the unpacked
|
||||
sloppak cache file via the same containment guards as
|
||||
``serve_sloppak_file`` — this lets the desktop engine play a feedpak
|
||||
full-mix natively under WASAPI-exclusive output.
|
||||
|
||||
This endpoint returns a raw filesystem path and is intended exclusively for
|
||||
the Electron desktop process (which runs on loopback). Requests from non-
|
||||
loopback clients are rejected with 403.
|
||||
"""
|
||||
# Loopback-only — only the local Electron process should call this
|
||||
client_host = request.client.host if request.client else None
|
||||
try:
|
||||
is_loopback = bool(client_host and ipaddress.ip_address(client_host).is_loopback)
|
||||
except ValueError:
|
||||
is_loopback = client_host == "localhost"
|
||||
if not is_loopback:
|
||||
return JSONResponse({"error": "forbidden"}, status_code=403)
|
||||
# Sloppak in-pack file (feedpak full-mix): /api/sloppak/<fn>/file/<rel>.
|
||||
# Both segments arrive percent-encoded (built with urllib quote() in the
|
||||
# highway payload); decode before handing to the shared resolver, which
|
||||
# re-applies all containment guards on the decoded values.
|
||||
slop_match = re.fullmatch(r"/api/sloppak/([^?#]+)/file/([^?#]+)", url)
|
||||
if slop_match:
|
||||
from urllib.parse import unquote
|
||||
|
||||
result = _resolve_sloppak_local_file(
|
||||
unquote(slop_match.group(1)), unquote(slop_match.group(2))
|
||||
)
|
||||
if isinstance(result, tuple):
|
||||
error, status = result
|
||||
return JSONResponse({"error": error}, status_code=status)
|
||||
return JSONResponse({"path": str(result)})
|
||||
# Accept only simple /audio/<filename> — no scheme, no host, no query/fragment
|
||||
if not re.fullmatch(r"/audio/[^?#]+", url):
|
||||
return JSONResponse({"error": "invalid url"}, status_code=400)
|
||||
filename = url[len("/audio/"):]
|
||||
# Reject traversal, absolute paths, and backslash separators
|
||||
if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename:
|
||||
return JSONResponse({"error": "invalid url"}, status_code=400)
|
||||
for d in [appstate.audio_cache_dir, appstate.static_dir]:
|
||||
candidate = (d / filename).resolve()
|
||||
# Ensure resolved path is inside the allowed directory
|
||||
try:
|
||||
candidate.relative_to(d.resolve())
|
||||
except ValueError:
|
||||
continue
|
||||
if candidate.is_file():
|
||||
return JSONResponse({"path": str(candidate)})
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
|
||||
@router.get("/audio/{filename:path}")
|
||||
def serve_audio(filename: str):
|
||||
"""Serve audio files from the writable audio cache directory."""
|
||||
# Reject traversal attempts and absolute-path components
|
||||
if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
for d in [appstate.audio_cache_dir, appstate.static_dir]:
|
||||
candidate = (d / filename).resolve()
|
||||
try:
|
||||
candidate.relative_to(d.resolve())
|
||||
except ValueError:
|
||||
continue
|
||||
if candidate.is_file():
|
||||
return FileResponse(str(candidate))
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Playlists + custom playlist covers (fee[dB]ack v0.3.0).
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3). Edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``CONFIG_DIR`` -> ``appstate.config_dir``
|
||||
(both read at call time through the seam), and ``_clean_str`` now imports from
|
||||
``reqfields``. See ``appstate.py``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Cache policy for the custom-cover file response: revalidate every time so a
|
||||
# replaced cover is never served stale (pairs with the mtime-ns URL token).
|
||||
_ART_CACHE_HEADERS = {"Cache-Control": "no-cache"}
|
||||
|
||||
|
||||
def _playlist_cover_path(pid) -> Path | None:
|
||||
"""Filesystem path of a playlist's optional custom cover image (PNG),
|
||||
stored under CONFIG_DIR. Returns None for a non-integer id."""
|
||||
try:
|
||||
pid = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return appstate.config_dir / "playlist_covers" / f"{pid}.png"
|
||||
|
||||
|
||||
def _playlist_cover_url(pid) -> str | None:
|
||||
cover = _playlist_cover_path(pid)
|
||||
if not cover or not cover.exists():
|
||||
return None
|
||||
try:
|
||||
# Nanosecond mtime so a same-second replace/remove/re-upload still
|
||||
# changes the cache-bust token (int seconds could collide → stale image).
|
||||
mt = cover.stat().st_mtime_ns
|
||||
except OSError:
|
||||
mt = 0
|
||||
return f"/api/playlists/{pid}/cover?v={mt}"
|
||||
|
||||
|
||||
@router.get("/api/playlists")
|
||||
def api_list_playlists():
|
||||
lists = appstate.meta_db.list_playlists()
|
||||
for pl in lists:
|
||||
pl["cover_url"] = _playlist_cover_url(pl["id"])
|
||||
return lists
|
||||
|
||||
|
||||
@router.post("/api/playlists")
|
||||
def api_create_playlist(data: dict):
|
||||
name = _clean_str(data.get("name"))
|
||||
if not (1 <= len(name) <= 100):
|
||||
return JSONResponse({"error": "Playlist name must be 1–100 characters."}, status_code=400)
|
||||
# kind='album' = a curated album (§7.2): hand-picked works, a chosen chart
|
||||
# per slot, played front-to-back on the queue. Absent/None = a regular mix.
|
||||
kind = _clean_str(data.get("kind")) or None
|
||||
if kind not in (None, "album"):
|
||||
return JSONResponse({"error": "kind must be 'album' or omitted"}, status_code=400)
|
||||
return appstate.meta_db.create_playlist(name, kind=kind)
|
||||
|
||||
|
||||
@router.get("/api/playlists/{pid}")
|
||||
def api_get_playlist(pid: int):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
pl["cover_url"] = _playlist_cover_url(pid)
|
||||
return pl
|
||||
|
||||
|
||||
@router.patch("/api/playlists/{pid}")
|
||||
def api_rename_playlist(pid: int, data: dict):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if pl["system_key"]:
|
||||
return JSONResponse({"error": "System playlists cannot be renamed."}, status_code=400)
|
||||
name = _clean_str(data.get("name"))
|
||||
if not (1 <= len(name) <= 100):
|
||||
return JSONResponse({"error": "Playlist name must be 1–100 characters."}, status_code=400)
|
||||
appstate.meta_db.rename_playlist(pid, name)
|
||||
return appstate.meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@router.delete("/api/playlists/{pid}")
|
||||
def api_delete_playlist(pid: int):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if pl["system_key"]:
|
||||
return JSONResponse({"error": "System playlists cannot be deleted."}, status_code=400)
|
||||
if not appstate.meta_db.delete_playlist(pid): # vanished under us (concurrent delete)
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
cover = _playlist_cover_path(pid) # drop any custom cover with the playlist
|
||||
if cover and cover.exists():
|
||||
try:
|
||||
cover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/playlists/{pid}/songs")
|
||||
def api_add_playlist_song(pid: int, data: dict):
|
||||
if appstate.meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
filename = _clean_str(data.get("filename"))
|
||||
if not filename:
|
||||
return JSONResponse({"error": "filename required"}, status_code=400)
|
||||
if appstate.meta_db.add_playlist_song(pid, filename) is None: # playlist vanished under us
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
|
||||
@router.patch("/api/playlists/{pid}/songs/{filename:path}")
|
||||
def api_update_playlist_slot(pid: int, filename: str, data: dict):
|
||||
"""Edit one curated-album slot: {"arrangement": name|null} pins/clears the
|
||||
slot's arrangement; {"chart_filename": fn} swaps the slot to another chart
|
||||
of the same work (position + pin kept). Albums only — a mix has no slots."""
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
if pl.get("kind") != "album":
|
||||
return JSONResponse({"error": "Slot editing is for albums."}, status_code=400)
|
||||
kwargs = {}
|
||||
if "chart_filename" in data:
|
||||
new_fn = _clean_str(data.get("chart_filename"))
|
||||
if not new_fn:
|
||||
return JSONResponse({"error": "chart_filename must be a filename"}, status_code=400)
|
||||
kwargs["new_filename"] = new_fn
|
||||
if "arrangement" in data:
|
||||
arr = data.get("arrangement")
|
||||
if arr is not None and not (isinstance(arr, str) and 1 <= len(arr.strip()) <= 100):
|
||||
return JSONResponse({"error": "arrangement must be a name or null"}, status_code=400)
|
||||
kwargs["arrangement"] = arr.strip() if isinstance(arr, str) else None
|
||||
if not kwargs:
|
||||
return JSONResponse({"error": "nothing to update"}, status_code=400)
|
||||
if appstate.meta_db.update_playlist_slot(pid, filename, **kwargs) is None:
|
||||
return JSONResponse(
|
||||
{"error": "no such slot, or the chart isn't a version of this song"},
|
||||
status_code=400)
|
||||
return appstate.meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@router.delete("/api/playlists/{pid}/songs/{filename:path}")
|
||||
def api_remove_playlist_song(pid: int, filename: str):
|
||||
if appstate.meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
appstate.meta_db.remove_playlist_song(pid, filename)
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
|
||||
@router.post("/api/playlists/{pid}/reorder")
|
||||
def api_reorder_playlist(pid: int, data: dict):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
if pl is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
order = data.get("order")
|
||||
if not isinstance(order, list) or not all(isinstance(f, str) for f in order):
|
||||
return JSONResponse({"error": "order must be a list of filenames"}, status_code=400)
|
||||
# Require an exact permutation of the playlist's current songs: a list with
|
||||
# duplicates, omissions, or extras would otherwise produce duplicate
|
||||
# positions / a partial reorder while still returning 200.
|
||||
current = [s["filename"] for s in pl["songs"]]
|
||||
if len(order) != len(current) or sorted(order) != sorted(current):
|
||||
return JSONResponse(
|
||||
{"error": "order must be a permutation of the playlist's current songs"},
|
||||
status_code=400,
|
||||
)
|
||||
appstate.meta_db.reorder_playlist(pid, order)
|
||||
return appstate.meta_db.get_playlist(pid)
|
||||
|
||||
|
||||
@router.post("/api/playlists/{pid}/cover")
|
||||
async def api_set_playlist_cover(pid: int, data: dict):
|
||||
"""Set a playlist's custom cover from a base64 / data-URL image (PNG/JPG).
|
||||
Overrides the content-dependent (song-art) cover. Stored as a small PNG
|
||||
thumbnail under CONFIG_DIR/playlist_covers/."""
|
||||
if appstate.meta_db.get_playlist(pid) is None:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
import base64
|
||||
import io
|
||||
b64 = data.get("image", "")
|
||||
# Guard the type before the `","` membership test — a non-string image
|
||||
# (e.g. {"image": 123} / null) would otherwise raise TypeError → 500.
|
||||
# Mirrors the avatar/song-art upload guard.
|
||||
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]
|
||||
if not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
try:
|
||||
img_data = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid base64"}, status_code=400)
|
||||
cover = _playlist_cover_path(pid)
|
||||
cover.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Decode/validate the image — a bad payload is a CLIENT error (400), and the
|
||||
# message stays generic so it can't echo internals.
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(img_data)).convert("RGB")
|
||||
img.thumbnail((640, 640)) # covers stay small
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid image"}, status_code=400)
|
||||
# Persist. A save/replace failure is a SERVER error (500, logged, no
|
||||
# filesystem detail leaked) — the pre-split handler mislabeled these as 400
|
||||
# and echoed the exception. A unique temp name in the cover dir (not a shared
|
||||
# `{pid}.png.tmp`) means two concurrent uploads can't clobber each other's
|
||||
# temp file; the atomic replace publishes. Re-check the playlist still exists
|
||||
# just before publishing so a delete that raced the decode above can't leave
|
||||
# an orphan cover — cheap belt-and-braces; FeedBack is single-user
|
||||
# (Principle I), so a full per-playlist lock would be for a race the
|
||||
# deployment model precludes.
|
||||
tmp = None
|
||||
try:
|
||||
# mkstemp is inside the try too: an unwritable dir / full disk raises
|
||||
# here, and that's the same class of persistence failure as save/replace.
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=f".{pid}.", suffix=".png.tmp", dir=str(cover.parent))
|
||||
tmp = Path(tmp_name)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
img.save(f, "PNG")
|
||||
if appstate.meta_db.get_playlist(pid) is None:
|
||||
tmp.unlink(missing_ok=True)
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
tmp.replace(cover)
|
||||
except Exception:
|
||||
if tmp is not None:
|
||||
tmp.unlink(missing_ok=True)
|
||||
log.exception("playlist cover save failed (pid=%s)", pid)
|
||||
return JSONResponse({"error": "could not save cover"}, status_code=500)
|
||||
return {"ok": True, "cover_url": _playlist_cover_url(pid)}
|
||||
|
||||
|
||||
@router.get("/api/playlists/{pid}/cover")
|
||||
def api_get_playlist_cover(pid: int):
|
||||
cover = _playlist_cover_path(pid)
|
||||
if not cover or not cover.exists():
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
# no-cache (revalidate) like song art, so a replaced cover is never served
|
||||
# stale — pairs with the mtime-ns cache-bust token on the URL.
|
||||
return FileResponse(str(cover), media_type="image/png", headers=_ART_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.delete("/api/playlists/{pid}/cover")
|
||||
def api_delete_playlist_cover(pid: int):
|
||||
cover = _playlist_cover_path(pid)
|
||||
if cover and cover.exists():
|
||||
try:
|
||||
cover.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,138 @@
|
||||
"""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 1–32 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()
|
||||
@@ -0,0 +1,230 @@
|
||||
"""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
@@ -0,0 +1,62 @@
|
||||
"""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)}
|
||||
@@ -0,0 +1,867 @@
|
||||
"""Song routes: upload / delete / metadata (user-meta, overrides, catalog meta
|
||||
write-back), gap-fill proposals, and the per-song info payload.
|
||||
|
||||
Extracted verbatim from server.py (R3) except @app->@router and the seam reads:
|
||||
meta_db->appstate.meta_db, and the scan/ingest helpers that stay in server.py
|
||||
(the scan lifecycle owns them) -> appstate.<callable>: kick_scan,
|
||||
invalidate_song_caches, stat_for_cache, scan_status() (a getter — the underlying
|
||||
dict is reassigned), plus art_override_paths. The gap-fill MBID/ISRC regexes live
|
||||
in lib/enrichment.py and are reached as enrichment.X.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
import appstate
|
||||
import enrichment
|
||||
import loosefolder as loosefolder_mod
|
||||
import sloppak as sloppak_mod
|
||||
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||
from scan_worker import _extract_meta_for_file
|
||||
|
||||
import logging
|
||||
log = logging.getLogger("feedBack.server")
|
||||
router = APIRouter()
|
||||
|
||||
_ALLOWED_SONG_EXTS = set(sloppak_mod.SONG_EXTS)
|
||||
|
||||
_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB — covers sloppaks bundled with stems
|
||||
|
||||
|
||||
# Per-request batch cap. Lets a user drop a whole album of sloppaks at once
|
||||
# without giving a hostile client a 1000-file DoS surface via Starlette's
|
||||
# default max_files=1000. The pre-parse Content-Length guard is sized as
|
||||
# _MAX_UPLOAD_FILES * _MAX_UPLOAD_BYTES + slack.
|
||||
_MAX_UPLOAD_FILES = 50
|
||||
|
||||
|
||||
# Serializes the mutating step of upload (os.replace into DLC_DIR) with
|
||||
# delete_song so the two endpoints can't interleave on the same path —
|
||||
# e.g. an upload finishing right after a concurrent delete shouldn't
|
||||
# resurrect a song the user just removed, and a delete arriving mid-
|
||||
# overwrite shouldn't strand a half-written file. threading.Lock (not
|
||||
# asyncio.Lock) because delete_song is sync (runs in the threadpool);
|
||||
# upload acquires it inside ``run_in_threadpool`` for the same reason.
|
||||
_song_io_lock = threading.Lock()
|
||||
|
||||
|
||||
def _commit_uploaded_song(tmp_path: Path, dest: Path, overwrite: bool, base: str):
|
||||
"""Atomically move a validated temp upload into ``dest`` under ``_song_io_lock``.
|
||||
|
||||
Returns ``None`` on success or an error result dict matching the upload
|
||||
endpoint's contract. Holds the lock across the directory re-check and
|
||||
the final ``os.replace`` so a concurrent delete or upload can't slip
|
||||
between them. Always cleans up the temp file on the error paths.
|
||||
"""
|
||||
with _song_io_lock:
|
||||
if dest.exists():
|
||||
if not overwrite:
|
||||
# Lost the race against a concurrent upload of the same name.
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"status": "exists", "filename": base,
|
||||
"error": "A file with this name already exists"}
|
||||
# Re-check directory state under the lock — the pre-check
|
||||
# may have raced an unrelated mkdir, and a sloppak directory
|
||||
# has to be removed before os.replace() can write over it.
|
||||
if dest.is_dir():
|
||||
if not sloppak_mod.is_sloppak(dest):
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return {"status": "exists", "filename": base,
|
||||
"error": "A directory with this name exists and is not "
|
||||
"a sloppak — refusing to overwrite"}
|
||||
shutil.rmtree(str(dest))
|
||||
os.replace(str(tmp_path), str(dest))
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/api/songs/upload")
|
||||
async def upload_song(request: Request):
|
||||
"""Upload one or more .sloppak files into the configured DLC folder.
|
||||
|
||||
Multipart body with one or more ``file`` fields (up to ``_MAX_UPLOAD_FILES``
|
||||
per request). Query string:
|
||||
``overwrite=1`` — replace existing files with the same name.
|
||||
|
||||
Response shape (always HTTP 200 once we've gotten past request-level guards
|
||||
like DLC-not-configured / payload-too-large):
|
||||
``{"results": [{"filename": "...", "status": "ok" | "exists" | "error",
|
||||
"error"?: "...", "size"?: N, "format"?: "sloppak"}, ...]}``
|
||||
Per-file conflicts surface as ``status: "exists"`` so a batch upload can
|
||||
surface ALL conflicts at once instead of bailing on the first one. The
|
||||
client re-POSTs just the conflicting files with ``overwrite=1`` if the
|
||||
user opts in.
|
||||
|
||||
The DLC directory is resolved via ``_get_dlc_dir()`` which honours the
|
||||
``DLC_DIR`` env var first and falls back to ``dlc_dir`` in
|
||||
``config.json`` — so uploads land in whichever folder the rest of the
|
||||
app already considers the library root, regardless of which mechanism
|
||||
configured it.
|
||||
"""
|
||||
dlc = _get_dlc_dir()
|
||||
if dlc is None:
|
||||
return JSONResponse(
|
||||
{"error": "DLC folder is not configured. Set DLC_DIR or configure it in Settings."},
|
||||
status_code=503,
|
||||
)
|
||||
if not os.access(str(dlc), os.W_OK):
|
||||
return JSONResponse(
|
||||
{"error": f"DLC folder {dlc} is not writable by the server process."},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Pre-parse Content-Length guard — fail fast before reading any body.
|
||||
# Multipart Content-Length is file bytes + boundary + per-part headers, so
|
||||
# we can't use _MAX_UPLOAD_BYTES as an exact cap here (a file right at the
|
||||
# advertised max would be rejected before _save_uploaded_song() can apply
|
||||
# the real per-file byte cap). For batch uploads we allow up to
|
||||
# _MAX_UPLOAD_FILES files at _MAX_UPLOAD_BYTES each; the parser still
|
||||
# enforces per-part size via max_part_size and per-batch count via
|
||||
# max_files. The streaming check inside _save_uploaded_song() is the
|
||||
# authoritative per-file size cap.
|
||||
max_total = _MAX_UPLOAD_FILES * _MAX_UPLOAD_BYTES + enrichment._MULTIPART_OVERHEAD_SLACK
|
||||
cl = request.headers.get("content-length")
|
||||
if cl is not None:
|
||||
try:
|
||||
cl_int = int(cl)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400)
|
||||
if cl_int < 0:
|
||||
return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400)
|
||||
if cl_int > max_total:
|
||||
return JSONResponse(
|
||||
{"error": f"Batch upload exceeds {_MAX_UPLOAD_FILES} files × "
|
||||
f"{_MAX_UPLOAD_BYTES // (1024 * 1024)} MB limit"},
|
||||
status_code=413,
|
||||
)
|
||||
|
||||
overwrite = request.query_params.get("overwrite") == "1"
|
||||
# Tighten the parser to the handler's contract: up to _MAX_UPLOAD_FILES
|
||||
# file parts, no text parts (overwrite comes from query params).
|
||||
# Starlette's defaults of max_files=1000 / max_fields=1000 would
|
||||
# otherwise let a client force the parser to spool far more parts than
|
||||
# the endpoint is willing to process.
|
||||
form = await request.form(
|
||||
max_files=_MAX_UPLOAD_FILES,
|
||||
max_fields=0,
|
||||
max_part_size=_MAX_UPLOAD_BYTES,
|
||||
)
|
||||
try:
|
||||
from starlette.datastructures import UploadFile as _StarletteUploadFile
|
||||
# form.getlist("file") returns all parts named "file" in submission
|
||||
# order. Filter to file parts only — Starlette would yield strings
|
||||
# for text parts, but we've capped max_fields=0 so any non-file part
|
||||
# is already a parser error before reaching here.
|
||||
uploads = [u for u in form.getlist("file") if isinstance(u, _StarletteUploadFile)]
|
||||
if not uploads:
|
||||
return JSONResponse(
|
||||
{"error": "Expected one or more files in multipart field 'file'"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
results = []
|
||||
any_saved = False
|
||||
for upload in uploads:
|
||||
try:
|
||||
result = await _save_uploaded_song(upload, dlc, overwrite)
|
||||
results.append(result)
|
||||
if result.get("status") == "ok":
|
||||
any_saved = True
|
||||
except Exception as e:
|
||||
# Per-file failure must not abort the batch — record and
|
||||
# continue so the client gets a complete report.
|
||||
log.exception("upload failed for %r", getattr(upload, "filename", "?"))
|
||||
results.append({
|
||||
"filename": Path(getattr(upload, "filename", "") or "").name or "?",
|
||||
"status": "error",
|
||||
"error": f"Upload failed: {e}",
|
||||
})
|
||||
finally:
|
||||
try:
|
||||
await upload.close()
|
||||
except Exception:
|
||||
log.debug("failed to close upload file handle", exc_info=True)
|
||||
|
||||
if any_saved:
|
||||
appstate.kick_scan()
|
||||
return {"results": results}
|
||||
finally:
|
||||
try:
|
||||
await form.close()
|
||||
except Exception:
|
||||
log.debug("failed to close form", exc_info=True)
|
||||
|
||||
|
||||
async def _save_uploaded_song(upload: UploadFile, dlc: Path, overwrite: bool) -> dict:
|
||||
"""Save one upload into ``dlc``. Returns a per-file result dict (never
|
||||
a JSONResponse) so batch uploads can aggregate.
|
||||
|
||||
Shape:
|
||||
ok: ``{"status": "ok", "filename": base, "size": N, "format": "sloppak"}``
|
||||
exists: ``{"status": "exists", "filename": base, "error": "..."}``
|
||||
error: ``{"status": "error", "filename": base, "error": "..."}``
|
||||
"""
|
||||
# Strip any path components a client may have included in the filename —
|
||||
# only the basename lands in the DLC root. Path traversal would otherwise
|
||||
# let a crafted upload escape the library directory.
|
||||
raw_name = upload.filename or ""
|
||||
base = Path(raw_name).name
|
||||
if not base or base in (".", "..") or "/" in base or "\\" in base:
|
||||
return {"status": "error", "filename": raw_name or "?", "error": "Invalid filename"}
|
||||
suffix = Path(base).suffix.lower()
|
||||
if suffix not in _ALLOWED_SONG_EXTS:
|
||||
return {"status": "error", "filename": base,
|
||||
"error": "Only .feedpak files are accepted"}
|
||||
|
||||
dest = dlc / base
|
||||
if dest.exists():
|
||||
if not overwrite:
|
||||
return {"status": "exists", "filename": base,
|
||||
"error": "A file with this name already exists"}
|
||||
# overwrite=1 must handle directory-form sloppaks (the scanner and
|
||||
# delete path both treat them as song entries). os.replace() can't
|
||||
# clobber a non-empty directory, so without the rmtree below the
|
||||
# whole upload would write to a temp file and then surface a late
|
||||
# 500 at the os.replace() call. Refuse other directories so an
|
||||
# unrelated folder isn't blown away by a same-named upload.
|
||||
if dest.is_dir() and not sloppak_mod.is_sloppak(dest):
|
||||
return {"status": "exists", "filename": base,
|
||||
"error": "A directory with this name exists and is not a sloppak — "
|
||||
"refusing to overwrite"}
|
||||
|
||||
# Temp file in the DLC dir itself so os.replace is atomic (same filesystem).
|
||||
# Dot-prefix keeps it out of the rglob("*.sloppak") scan glob.
|
||||
fd, tmp_name = await run_in_threadpool(
|
||||
tempfile.mkstemp, dir=str(dlc), prefix=".upload-", suffix=".part"
|
||||
)
|
||||
tmp_path = Path(tmp_name)
|
||||
bytes_read = 0
|
||||
head = b""
|
||||
error_result: dict | None = None
|
||||
try:
|
||||
try:
|
||||
tmpf = await run_in_threadpool(os.fdopen, fd, "wb")
|
||||
except BaseException:
|
||||
try:
|
||||
await run_in_threadpool(os.close, fd)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
try:
|
||||
while True:
|
||||
chunk = await upload.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
bytes_read += len(chunk)
|
||||
if bytes_read > _MAX_UPLOAD_BYTES:
|
||||
error_result = {
|
||||
"status": "error", "filename": base,
|
||||
"error": f"Upload exceeds {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB cap",
|
||||
}
|
||||
break
|
||||
if len(head) < 4:
|
||||
head += chunk[: 4 - len(head)]
|
||||
await run_in_threadpool(tmpf.write, chunk)
|
||||
finally:
|
||||
await run_in_threadpool(tmpf.close)
|
||||
|
||||
if error_result is None:
|
||||
if bytes_read == 0:
|
||||
error_result = {"status": "error", "filename": base,
|
||||
"error": "Empty upload — file is 0 bytes"}
|
||||
elif suffix in _ALLOWED_SONG_EXTS:
|
||||
if head[:2] != b"PK":
|
||||
error_result = {"status": "error", "filename": base,
|
||||
"error": "Not a valid feedpak file (expected zip archive)"}
|
||||
else:
|
||||
# ZIP magic alone admits any renamed zip — verify the sloppak
|
||||
# loader can actually parse a manifest.yaml inside. Without
|
||||
# this, /api/songs/upload returns "ok" for files the rest of
|
||||
# the backend would refuse to scan or load.
|
||||
try:
|
||||
await run_in_threadpool(sloppak_mod.load_manifest, tmp_path)
|
||||
except Exception as e:
|
||||
error_result = {"status": "error", "filename": base,
|
||||
"error": f"Not a valid sloppak file: {e}"}
|
||||
|
||||
if error_result is not None:
|
||||
try:
|
||||
await run_in_threadpool(tmp_path.unlink)
|
||||
except OSError:
|
||||
pass
|
||||
return error_result
|
||||
|
||||
# Single sync helper so the lock is held for the whole commit —
|
||||
# ``async with _upload_lock`` would have released between every
|
||||
# ``run_in_threadpool`` and let a concurrent delete or upload slip
|
||||
# in between the dir check and the final ``os.replace``.
|
||||
commit_result = await run_in_threadpool(
|
||||
_commit_uploaded_song, tmp_path, dest, overwrite, base
|
||||
)
|
||||
if commit_result is not None:
|
||||
return commit_result
|
||||
except BaseException:
|
||||
try:
|
||||
await run_in_threadpool(tmp_path.unlink)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
# Even on a fresh (non-overwrite) upload, evict any stale entries left
|
||||
# over from a previous delete+re-upload of the same name.
|
||||
await run_in_threadpool(appstate.invalidate_song_caches, base)
|
||||
|
||||
log.info("Uploaded %s (%d bytes) to %s", base, bytes_read, dlc)
|
||||
return {"status": "ok", "filename": base, "size": bytes_read,
|
||||
"format": suffix.lstrip(".")}
|
||||
|
||||
|
||||
@router.delete("/api/song/{filename:path}")
|
||||
def delete_song(filename: str):
|
||||
"""Remove a song from the DLC folder and clear its cache entries.
|
||||
|
||||
Works for both formats: ``.sloppak`` files OR directories, and
|
||||
loose-folder songs (the directory containing the chart). The path is
|
||||
resolved through ``_resolve_dlc_path`` so URL-encoded ``..`` segments
|
||||
cannot escape the library root.
|
||||
"""
|
||||
dlc = _get_dlc_dir()
|
||||
if dlc is None:
|
||||
return JSONResponse({"error": "DLC folder not configured"}, status_code=503)
|
||||
resolved = _resolve_dlc_path(dlc, filename)
|
||||
if resolved is None:
|
||||
return JSONResponse({"error": "forbidden"}, status_code=403)
|
||||
if not resolved.exists():
|
||||
return JSONResponse({"error": "File not found"}, status_code=404)
|
||||
if resolved == dlc.resolve():
|
||||
return JSONResponse({"error": "Refusing to delete the DLC root"}, status_code=400)
|
||||
|
||||
# Only delete actual song entries. Without this, DELETE /api/song/ArtistName
|
||||
# would recursively wipe a whole artist subfolder — far broader than the
|
||||
# UI's per-song contract. Sloppak detection wins over loose because a
|
||||
# sloppak dir can also contain WEM/XML (matches the scanner's precedence).
|
||||
is_sloppak = sloppak_mod.is_sloppak(resolved)
|
||||
is_loose = (
|
||||
resolved.is_dir()
|
||||
and not is_sloppak
|
||||
and loosefolder_mod.is_loose_song(resolved)
|
||||
)
|
||||
if not (is_sloppak or is_loose):
|
||||
return JSONResponse(
|
||||
{"error": "Not a song entry — only sloppaks "
|
||||
"or loose-folder songs can be deleted"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Hold ``_song_io_lock`` across the filesystem removal AND the DB/cache
|
||||
# eviction. Without it, an upload of the same filename could ``os.replace``
|
||||
# a new file into place between our removal and DB delete, leaving the
|
||||
# new generation stranded with no library row; or the reverse, where
|
||||
# delete runs between an upload's directory check and its replace and
|
||||
# the upload then resurrects the song we just removed.
|
||||
with _song_io_lock:
|
||||
try:
|
||||
if resolved.is_dir():
|
||||
shutil.rmtree(resolved)
|
||||
else:
|
||||
resolved.unlink()
|
||||
except OSError as e:
|
||||
log.error("Failed to delete %s: %s", resolved, e)
|
||||
return JSONResponse({"error": f"Delete failed: {e}"}, status_code=500)
|
||||
|
||||
# Canonicalise the cache key the same way update_song_meta does so we
|
||||
# hit the row the scanner indexed under.
|
||||
try:
|
||||
cache_key = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
cache_key = filename
|
||||
with appstate.meta_db._lock:
|
||||
appstate.meta_db.conn.execute("DELETE FROM songs WHERE filename = ?", (cache_key,))
|
||||
appstate.meta_db.conn.execute("DELETE FROM favorites WHERE filename = ?", (cache_key,))
|
||||
appstate.meta_db.conn.execute("DELETE FROM loops WHERE filename = ?", (cache_key,))
|
||||
# Purge the v3 filename-keyed state too, so the deleted song stops
|
||||
# surfacing in stats / recent / continue / playlists immediately.
|
||||
appstate.meta_db.conn.execute("DELETE FROM song_stats WHERE filename = ?", (cache_key,))
|
||||
appstate.meta_db.conn.execute("DELETE FROM playlist_songs WHERE filename = ?", (cache_key,))
|
||||
# Personal difficulty / notes / tags for this song (we hold the
|
||||
# lock, so purge is lock-free).
|
||||
appstate.meta_db.purge_song_user_data(cache_key)
|
||||
# Multi-chart grouping (P5a): drop this chart's split + read-model rows,
|
||||
# and any preferred-chart pointer that named it (the work re-auto-picks).
|
||||
# work_key-keyed prefs for OTHER charts survive. Mark the read-model
|
||||
# dirty so the affected work regroups on the next grouped query.
|
||||
appstate.meta_db.conn.execute("DELETE FROM chart_group_split WHERE filename = ?", (cache_key,))
|
||||
appstate.meta_db.conn.execute("DELETE FROM work_display WHERE filename = ?", (cache_key,))
|
||||
appstate.meta_db.conn.execute("DELETE FROM chart_group_pref WHERE preferred_filename = ?", (cache_key,))
|
||||
appstate.meta_db._work_display_dirty = True
|
||||
# Enrichment is never purged on rescan (delete_missing), only here
|
||||
# on the explicit per-song delete — the never-clobber contract.
|
||||
appstate.meta_db.conn.execute("DELETE FROM song_enrichment WHERE filename = ?", (cache_key,))
|
||||
appstate.meta_db.conn.commit()
|
||||
|
||||
# User art overrides go with the song (CAA cache files are keyed by
|
||||
# RELEASE and may be shared with other charts — the LRU owns those).
|
||||
for _p in appstate.art_override_paths(cache_key):
|
||||
try:
|
||||
_p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
appstate.invalidate_song_caches(cache_key)
|
||||
|
||||
log.info("Deleted song %s", cache_key)
|
||||
# If a scan was mid-flight when we removed the row, it may already have
|
||||
# listed (and not yet processed) the file and will call ``appstate.meta_db.put()``
|
||||
# for it after our DB delete — reinserting a ghost row. Coalesce a
|
||||
# follow-up pass via ``appstate.kick_scan`` so the next scan's ``delete_missing()``
|
||||
# purges that entry. Cheap no-op when no scan is running.
|
||||
if appstate.scan_status()["running"]:
|
||||
appstate.kick_scan()
|
||||
return {"ok": True, "filename": cache_key}
|
||||
|
||||
|
||||
@router.get("/api/song/{filename:path}/user-meta")
|
||||
def get_song_user_meta(filename: str):
|
||||
"""Read {user_difficulty, notes, tags} for one song."""
|
||||
return appstate.meta_db.get_song_user_meta(appstate.meta_db._canonical_song_filename(filename))
|
||||
|
||||
|
||||
@router.put("/api/song/{filename:path}/user-meta")
|
||||
def put_song_user_meta(filename: str, data: dict):
|
||||
"""Partial update. Send any of: `user_difficulty` (int 1–5, 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 1–5 or null"}, 400)
|
||||
try:
|
||||
iv = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return JSONResponse({"error": "user_difficulty must be an integer 1–5 or null"}, 400)
|
||||
if not (1 <= iv <= 5):
|
||||
return JSONResponse({"error": "user_difficulty must be 1–5 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 1–5 or null"}, 400)
|
||||
try:
|
||||
iv = int(v)
|
||||
except (TypeError, ValueError):
|
||||
return JSONResponse({"error": "set_difficulty must be an integer 1–5 or null"}, 400)
|
||||
if not (1 <= iv <= 5):
|
||||
return JSONResponse({"error": "set_difficulty must be 1–5 or null"}, 400)
|
||||
kwargs["set_difficulty"] = iv
|
||||
|
||||
add_tags = data.get("add_tags")
|
||||
remove_tags = data.get("remove_tags")
|
||||
for name, val in (("add_tags", add_tags), ("remove_tags", remove_tags)):
|
||||
if val is not None and not isinstance(val, list):
|
||||
return JSONResponse({"error": f"{name} must be an array of strings"}, 400)
|
||||
if "set_difficulty" not in data and not add_tags and not remove_tags:
|
||||
return JSONResponse({"error": "Nothing to apply"}, 400)
|
||||
|
||||
keys = [appstate.meta_db._canonical_song_filename(f) for f in fns]
|
||||
n = appstate.meta_db.batch_user_meta(keys, add_tags=add_tags, remove_tags=remove_tags, **kwargs)
|
||||
return {"updated": n, "tags": appstate.meta_db.all_tags()}
|
||||
|
||||
|
||||
@router.post("/api/song/{filename:path}/meta")
|
||||
def update_song_meta(filename: str, data: dict):
|
||||
"""Update song metadata, persisting it back into the underlying file.
|
||||
|
||||
The library scanner re-derives title/artist/album/year from the file
|
||||
(archive manifest Attributes / sloppak manifest.yaml) on every full rescan,
|
||||
so a DB-only edit reverts. We write the edit into the file first, then
|
||||
refresh the cache row (including mtime/size) to match. Loose-folder and
|
||||
unwritable songs fall back to a DB-only update (which still survives an
|
||||
incremental rescan via the mtime/size cache hit).
|
||||
"""
|
||||
# Canonicalise to the same key get_song_info uses so an update via
|
||||
# one URL form (e.g. with `..` segments) lands on the row that
|
||||
# later reads will see.
|
||||
dlc = _get_dlc_dir()
|
||||
cache_key = filename
|
||||
resolved = None
|
||||
if dlc:
|
||||
resolved = _resolve_dlc_path(dlc, filename)
|
||||
if resolved is None:
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
try:
|
||||
cache_key = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
fields = {k: data[k] for k in ("title", "artist", "album", "year") if k in data}
|
||||
if not fields:
|
||||
return {"error": "No fields to update"}
|
||||
# Normalise the year value so the DB and file stay in sync. The file
|
||||
# writer (songmeta) coerces empty/non-numeric years to 0, which the
|
||||
# scanner reads back as "". Store "" in the DB instead of a raw
|
||||
# non-numeric string so that if the mtime/size are updated (making the
|
||||
# row cache-fresh) the DB still matches what the scanner would derive.
|
||||
if "year" in fields:
|
||||
try:
|
||||
_yr_int = int(fields["year"])
|
||||
except (TypeError, ValueError):
|
||||
_yr_int = 0
|
||||
fields = {**fields, "year": str(_yr_int) if _yr_int else ""}
|
||||
|
||||
# Persist into the file so the edit survives a full rescan.
|
||||
# Hold _song_io_lock across the existence check and file write so a
|
||||
# concurrent delete cannot remove the file between our check and the
|
||||
# repack's atomic replace, and so a concurrent upload cannot be clobbered
|
||||
# by our atomic rename. archive repack is slow — the lock is held longer
|
||||
# than a simple upload/delete, but correctness requires serialisation.
|
||||
persisted = False
|
||||
with _song_io_lock:
|
||||
if resolved is not None and resolved.exists():
|
||||
try:
|
||||
import songmeta
|
||||
persisted = songmeta.write_song_metadata(resolved, fields)
|
||||
except Exception:
|
||||
log.warning("metadata file write failed for %s", cache_key, exc_info=True)
|
||||
|
||||
with appstate.meta_db._lock:
|
||||
updates = [f"{field} = ?" for field in fields]
|
||||
params = list(fields.values())
|
||||
if persisted:
|
||||
# The file changed — re-stat so an incremental rescan sees a
|
||||
# consistent cache row instead of re-reading the (now matching)
|
||||
# file.
|
||||
try:
|
||||
mtime, size = appstate.stat_for_cache(resolved)
|
||||
updates += ["mtime = ?", "size = ?"]
|
||||
params += [mtime, size]
|
||||
except OSError:
|
||||
pass
|
||||
params.append(cache_key)
|
||||
appstate.meta_db.conn.execute(
|
||||
f"UPDATE songs SET {', '.join(updates)} WHERE filename = ?", params
|
||||
)
|
||||
appstate.meta_db.conn.commit()
|
||||
|
||||
if persisted:
|
||||
appstate.invalidate_song_caches(cache_key)
|
||||
# Coalesce a follow-up scan so a mid-flight scan's stale appstate.meta_db.put()
|
||||
# for this file can't win: if a scan is running appstate.kick_scan() queues a
|
||||
# pending pass; if not it starts a fresh one. Unconditional to avoid a
|
||||
# race where the scan finishes between our DB commit and a guarded check.
|
||||
appstate.kick_scan()
|
||||
return {"ok": True, "persisted": persisted}
|
||||
|
||||
|
||||
# ── Gap-fill: write CONFIRMED missing metadata into the pack (R4a) ────────────
|
||||
# The agreed write-back contract (spec-alignment §7): opt-in + user-initiated
|
||||
# (nothing here runs in the background), adds ABSENT keys only (never replaces
|
||||
# an author-set value — the writer refuses, and existing manifest bytes are
|
||||
# preserved verbatim by appending), spec'd-keys allowlist, values only from a
|
||||
# CONFIRMED identity (an auto/exact match or a user pin — review-tier rows are
|
||||
# not eligible until a human confirms), atomic write + .bak. Single-song only;
|
||||
# batch write-back stays an open question with the spec chair.
|
||||
_GAP_FILL_KEYS = ("album", "year", "genres", "mbid", "isrc")
|
||||
|
||||
|
||||
def _gap_fill_manifest_absent(manifest: dict, key: str) -> bool:
|
||||
"""A key is a GAP only when it's genuinely MISSING from the manifest.
|
||||
|
||||
Gap-fill is append-only: the writer's never-clobber guard raises on ANY
|
||||
key already present, and appending a second `album:` line to a manifest
|
||||
that already carries `album: ''` would just create a duplicate YAML key.
|
||||
So a present-but-empty value (None / '' / [] / year 0) is NOT a gap the
|
||||
append-only writer can fill — offering it in the preview would only lead
|
||||
to a POST the writer refuses. Present-but-empty keys are therefore left
|
||||
to the metadata editor (which re-serializes and can replace in place)."""
|
||||
return key not in manifest
|
||||
|
||||
|
||||
def _gap_fill_proposals(cache_key: str, resolved) -> tuple[dict, str]:
|
||||
"""What gap-fill could add for this song: (proposals, reason). Empty
|
||||
proposals explain themselves via reason — 'not-sloppak', 'no-match'
|
||||
(nothing confirmed yet), 'review' (a human hasn't confirmed the match),
|
||||
or 'nothing-missing'."""
|
||||
if resolved is None or not resolved.exists() or not sloppak_mod.is_sloppak(resolved):
|
||||
return {}, "not-sloppak"
|
||||
row = appstate.meta_db.get_enrichment(cache_key)
|
||||
if not row or row.get("match_state") not in ("matched", "manual"):
|
||||
state = (row or {}).get("match_state")
|
||||
return {}, ("review" if state == "review" else "no-match")
|
||||
try:
|
||||
manifest = sloppak_mod.load_manifest(resolved) or {}
|
||||
except Exception:
|
||||
return {}, "not-sloppak"
|
||||
# A LOCKED field (Fix-metadata popup) is never gap-filled — the user pinned
|
||||
# it away from the matched value, so writing that value to the file would
|
||||
# be exactly the clobber the lock exists to prevent. (The lock field name is
|
||||
# `genre`; the manifest/gap-fill key is `genres`.)
|
||||
locked = appstate.meta_db.locked_fields(cache_key)
|
||||
out = {}
|
||||
album = (row.get("canon_album") or "").strip()
|
||||
if album and "album" not in locked and _gap_fill_manifest_absent(manifest, "album"):
|
||||
out["album"] = album
|
||||
year = (row.get("canon_year") or "").strip()
|
||||
if (year.isdigit() and int(year) and "year" not in locked
|
||||
and _gap_fill_manifest_absent(manifest, "year")):
|
||||
out["year"] = int(year)
|
||||
genres = [str(g) for g in (row.get("genres") or []) if isinstance(g, str) and g.strip()]
|
||||
if genres and "genre" not in locked and _gap_fill_manifest_absent(manifest, "genres"):
|
||||
out["genres"] = genres
|
||||
# Identity keys (feedpak spec 1.14.0) — written in canonical form only.
|
||||
mbid = (row.get("mb_recording_id") or "").strip().lower()
|
||||
if enrichment._MBID_RE.match(mbid) and _gap_fill_manifest_absent(manifest, "mbid"):
|
||||
out["mbid"] = mbid
|
||||
isrc = (row.get("isrc") or "").strip().upper().replace("-", "").replace(" ", "")
|
||||
if enrichment._ISRC_RE.match(isrc) and _gap_fill_manifest_absent(manifest, "isrc"):
|
||||
out["isrc"] = isrc
|
||||
return out, ("" if out else "nothing-missing")
|
||||
|
||||
|
||||
@router.get("/api/song/{filename:path}/gap-fill")
|
||||
def get_song_gap_fill(filename: str):
|
||||
"""Preview what "Write missing info to file" would add — the Details
|
||||
drawer renders its confirm list straight from this. Read-only."""
|
||||
dlc = _get_dlc_dir()
|
||||
cache_key, resolved = filename, None
|
||||
if dlc:
|
||||
resolved = _resolve_dlc_path(dlc, filename)
|
||||
if resolved is None:
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
try:
|
||||
cache_key = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
pass
|
||||
proposals, reason = _gap_fill_proposals(cache_key, resolved)
|
||||
row = appstate.meta_db.get_enrichment(cache_key) or {}
|
||||
return {
|
||||
"eligible": bool(proposals),
|
||||
"reason": reason,
|
||||
"match_state": row.get("match_state"),
|
||||
"missing": [{"key": k, "value": v} for k, v in proposals.items()],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/song/{filename:path}/gap-fill")
|
||||
def post_song_gap_fill(filename: str, data: dict):
|
||||
"""Write the user-confirmed subset of the preview into the pack file.
|
||||
Proposals are recomputed under the io lock, so a key that gained an
|
||||
author value between preview and confirm is skipped, never replaced."""
|
||||
keys = (data or {}).get("keys")
|
||||
if not isinstance(keys, list) or not keys:
|
||||
return JSONResponse({"error": "keys must be a non-empty list"}, 400)
|
||||
bad = [k for k in keys if k not in _GAP_FILL_KEYS]
|
||||
if bad:
|
||||
return JSONResponse(
|
||||
{"error": "unknown key(s): " + ", ".join(sorted(set(map(str, bad))))}, 400)
|
||||
|
||||
dlc = _get_dlc_dir()
|
||||
cache_key, resolved = filename, None
|
||||
if dlc:
|
||||
resolved = _resolve_dlc_path(dlc, filename)
|
||||
if resolved is None:
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
try:
|
||||
cache_key = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
with _song_io_lock:
|
||||
proposals, reason = _gap_fill_proposals(cache_key, resolved)
|
||||
additions = {k: proposals[k] for k in _GAP_FILL_KEYS if k in keys and k in proposals}
|
||||
skipped = sorted(set(keys) - set(additions))
|
||||
if not additions:
|
||||
return JSONResponse({"error": "nothing to write", "reason": reason,
|
||||
"skipped": skipped}, 409)
|
||||
try:
|
||||
import songmeta
|
||||
songmeta.gap_fill_sloppak(resolved, additions)
|
||||
except Exception:
|
||||
log.warning("gap-fill write failed for %s", cache_key, exc_info=True)
|
||||
return JSONResponse({"error": "write failed"}, 500)
|
||||
|
||||
# Keep the cache row consistent with what the scanner would now derive
|
||||
# (same contract as the metadata editor above): sync the columns the
|
||||
# scan reads from the keys we appended, then re-stat so the row stays
|
||||
# cache-fresh.
|
||||
fields = {}
|
||||
if "album" in additions:
|
||||
fields["album"] = additions["album"]
|
||||
if "year" in additions:
|
||||
fields["year"] = str(additions["year"])
|
||||
if "genres" in additions:
|
||||
fields["genre"] = additions["genres"][0]
|
||||
with appstate.meta_db._lock:
|
||||
updates = [f"{field} = ?" for field in fields]
|
||||
params = list(fields.values())
|
||||
try:
|
||||
mtime, size = appstate.stat_for_cache(resolved)
|
||||
updates += ["mtime = ?", "size = ?"]
|
||||
params += [mtime, size]
|
||||
except OSError:
|
||||
pass
|
||||
if updates:
|
||||
params.append(cache_key)
|
||||
appstate.meta_db.conn.execute(
|
||||
f"UPDATE songs SET {', '.join(updates)} WHERE filename = ?", params)
|
||||
appstate.meta_db.conn.commit()
|
||||
|
||||
appstate.invalidate_song_caches(cache_key)
|
||||
appstate.kick_scan()
|
||||
return {"ok": True, "written": additions, "skipped": skipped}
|
||||
|
||||
|
||||
@router.get("/api/song/{filename:path}")
|
||||
async def get_song_info(filename: str):
|
||||
"""Return song metadata, from cache or by extracting it from the song source."""
|
||||
import asyncio
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
return JSONResponse({"error": "DLC folder not configured"}, 404)
|
||||
|
||||
song_path = _resolve_dlc_path(dlc, filename)
|
||||
if song_path is None:
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
if not song_path.exists():
|
||||
return JSONResponse({"error": "File not found"}, 404)
|
||||
|
||||
# Canonicalise the cache key against the resolved path so two URL
|
||||
# forms of the same physical file (e.g. `Artist/song.sloppak` vs
|
||||
# `Artist/../Artist/song.sloppak`) converge on a single row instead
|
||||
# of fragmenting / shadowing each other in appstate.meta_db.
|
||||
try:
|
||||
cache_key = song_path.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
cache_key = filename
|
||||
|
||||
mtime, size = appstate.stat_for_cache(song_path)
|
||||
cached = appstate.meta_db.get(cache_key, mtime, size)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Extract in thread pool
|
||||
def _extract():
|
||||
meta = _extract_meta_for_file(song_path, dlc)
|
||||
appstate.meta_db.put(cache_key, mtime, size, meta)
|
||||
return meta
|
||||
|
||||
meta = await asyncio.get_event_loop().run_in_executor(None, _extract)
|
||||
return meta
|
||||
@@ -0,0 +1,265 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""The merged tuning catalog (/api/tunings).
|
||||
|
||||
Extracted verbatim from server.py (R3) except @app->@router, CONFIG_DIR->
|
||||
appstate.config_dir, _load_config imported from lib/appconfig, and the tuning
|
||||
registry read through the appstate seam (appstate.tuning_providers — the same
|
||||
instance plugins register into via the plugin_context in server.py).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
import appstate
|
||||
from appconfig import _load_config
|
||||
from tunings import DEFAULT_REFERENCE_PITCH, TUNING_PRESET_MIDIS, freqs_to_midis
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/tunings")
|
||||
def get_tunings():
|
||||
cfg = _load_config(appstate.config_dir / "config.json") or {}
|
||||
ref = cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH)
|
||||
try:
|
||||
ref = float(ref)
|
||||
if not (430.0 <= ref <= 450.0):
|
||||
ref = DEFAULT_REFERENCE_PITCH
|
||||
except (TypeError, ValueError):
|
||||
ref = DEFAULT_REFERENCE_PITCH
|
||||
merged = appstate.tuning_providers.get_merged(ref)
|
||||
# tuningMidis: the same catalog as exact integer MIDI notes (low → high).
|
||||
# Built-ins come straight from TUNING_PRESET_MIDIS (no float round-trip);
|
||||
# provider-contributed entries are recovered from their frequencies at the
|
||||
# served reference pitch. Every consumer today (the v3 badges, plugins)
|
||||
# reconstructs midis client-side via log2 — a rounding footgun at non-440
|
||||
# references — so serve the integers once, host-side. Additive: the
|
||||
# existing referencePitch/tunings shape is unchanged.
|
||||
tuning_midis: dict[str, dict[str, list[int]]] = {}
|
||||
for key, names in merged.items():
|
||||
builtin = TUNING_PRESET_MIDIS.get(key, {})
|
||||
resolved: dict[str, list[int]] = {}
|
||||
for name, freqs in names.items():
|
||||
midis = builtin.get(name) or freqs_to_midis(freqs, ref)
|
||||
if midis:
|
||||
resolved[name] = list(midis)
|
||||
if resolved:
|
||||
tuning_midis[key] = resolved
|
||||
return {"referencePitch": ref, "tunings": merged, "tuningMidis": tuning_midis}
|
||||
@@ -0,0 +1,81 @@
|
||||
"""App version + source/license URLs (/api/version).
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3) except the decorator (``@app`` ->
|
||||
``@router``) and the VERSION-file lookup: ``Path(__file__).parent`` (app root
|
||||
when this lived at the top level) -> ``Path(__file__).resolve().parents[2]``
|
||||
(routers -> lib -> app root). VERSION ships at the app root in every packaging
|
||||
path (Dockerfile COPY, desktop bundle).
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_http_url(raw):
|
||||
"""Return `raw` stripped + trailing-slash-stripped if it parses as an
|
||||
http(s) URL with a non-empty host; else None.
|
||||
|
||||
Used to validate operator-supplied `APP_SOURCE_URL` / `APP_LICENSE_URL`
|
||||
env vars before they reach `<a href>` in the UI. A bare prefix check
|
||||
like `startswith(("http://","https://"))` accepts malformed inputs
|
||||
such as `"https://"` (no host) or `"https:///foo"` (empty host) that
|
||||
still produce broken hrefs — and, when used as a base for the default
|
||||
`license_url`, garbage like `"https:///blob/main/LICENSE"`.
|
||||
"""
|
||||
from urllib.parse import urlsplit
|
||||
if not raw:
|
||||
return None
|
||||
s = raw.strip().rstrip("/")
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(s)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.scheme.lower() not in ("http", "https"):
|
||||
return None
|
||||
# `netloc` includes any `user:pass@` and `:port` — strings like
|
||||
# "http://:80/path" have non-empty netloc (":80") but no real
|
||||
# hostname. Validate `hostname` so only URLs with an actual host
|
||||
# are accepted.
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
@router.get("/api/version")
|
||||
def get_version():
|
||||
env_version = os.environ.get("APP_VERSION", "").strip()
|
||||
if env_version:
|
||||
version = env_version
|
||||
else:
|
||||
version_file = Path(__file__).resolve().parents[2] / "VERSION" # R3: app root from lib/routers/
|
||||
version = "unknown"
|
||||
if version_file.exists():
|
||||
try:
|
||||
version = version_file.read_text().strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass
|
||||
default_source_url = "https://github.com/got-feedback/feedBack"
|
||||
# APP_SOURCE_URL / APP_LICENSE_URL flow straight into <a href> in the UI,
|
||||
# so validate with urllib.parse rather than a bare prefix check — a prefix
|
||||
# check accepts malformed values like "https://" (no host) which produce
|
||||
# broken hrefs (and a constructed license_url like "https:///blob/main/LICENSE").
|
||||
# _safe_http_url requires scheme in {http,https} AND a non-empty hostname
|
||||
# (not just netloc — that would still accept port-only authorities like
|
||||
# "http://:80/path"); fall back to the safe default otherwise.
|
||||
source_url = _safe_http_url(os.environ.get("APP_SOURCE_URL")) or default_source_url
|
||||
# APP_LICENSE_URL: explicit override for the LICENSE link. The default
|
||||
# constructed value (source_url + "/blob/main/LICENSE") is GitHub-
|
||||
# specific and assumes the repo's default branch is `main`; non-GitHub
|
||||
# hosts (GitLab, Gitea, self-hosted) need an explicit value.
|
||||
license_url = _safe_http_url(os.environ.get("APP_LICENSE_URL")) or (source_url + "/blob/main/LICENSE")
|
||||
return {
|
||||
"version": version,
|
||||
"source_url": source_url,
|
||||
"license_url": license_url,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Wishlist / "wanted" API (feedBack#636) — songs the user wants but doesn't own.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/wanted")
|
||||
def api_list_wanted():
|
||||
"""The wishlist — songs the user wants but doesn't own yet (newest first)."""
|
||||
return {"wanted": appstate.meta_db.list_wanted()}
|
||||
|
||||
|
||||
@router.post("/api/wanted")
|
||||
def api_add_wanted(data: dict):
|
||||
"""Add a not-owned song to the wishlist. `artist`/`title` are required (at
|
||||
least one non-empty); `source`/`source_ref`/`note` are optional. Idempotent
|
||||
on identity so producers (find_more ownership-diff, manual add) can re-post."""
|
||||
if not isinstance(data, dict):
|
||||
return JSONResponse({"error": "body must be an object"}, status_code=400)
|
||||
artist = _clean_str(data.get("artist"))
|
||||
title = _clean_str(data.get("title"))
|
||||
if not artist and not title:
|
||||
return JSONResponse({"error": "artist or title required"}, status_code=400)
|
||||
row = appstate.meta_db.add_wanted(
|
||||
artist=artist, title=title,
|
||||
source=_clean_str(data.get("source")) or "manual",
|
||||
source_ref=_clean_str(data.get("source_ref")),
|
||||
note=_clean_str(data.get("note")),
|
||||
)
|
||||
return {"ok": True, "wanted": row}
|
||||
|
||||
|
||||
@router.delete("/api/wanted/{wanted_id}")
|
||||
def api_remove_wanted(wanted_id: int):
|
||||
"""Remove a wishlist entry by id."""
|
||||
return {"ok": appstate.meta_db.remove_wanted(wanted_id)}
|
||||
File diff suppressed because it is too large
Load Diff
+326
@@ -0,0 +1,326 @@
|
||||
"""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")
|
||||
|
||||
|
||||
_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():
|
||||
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
|
||||
|
||||
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)
|
||||
|
||||
# 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:
|
||||
_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
|
||||
|
||||
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
|
||||
|
||||
|
||||
# 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() -> bool:
|
||||
"""Request a library rescan, single-flight + coalescing.
|
||||
|
||||
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
|
||||
with _scan_kick_lock:
|
||||
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
|
||||
while True:
|
||||
try:
|
||||
background_scan()
|
||||
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
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
This module is deliberately kept apart from ``server.py`` so that
|
||||
``ProcessPoolExecutor`` workers can import and unpickle ``_scan_one``
|
||||
without dragging in ``server.py``'s import-time side effects
|
||||
(``configure_logging()``, ``meta_db = MetadataDB()`` opening/migrating
|
||||
(``configure_logging()``, ``meta_db = MetadataDB(CONFIG_DIR)`` opening/migrating
|
||||
SQLite, and ``register_plugin_api(app)`` registering routes).
|
||||
|
||||
The background scan spawns its pool with the ``spawn`` start method (see
|
||||
|
||||
+26
-14
@@ -703,20 +703,32 @@ def load_song(
|
||||
and isinstance(e.get("d"), (int, float))
|
||||
]
|
||||
if song.lyrics:
|
||||
# Provenance — populated by the converter (xml/notechart),
|
||||
# the WhisperX fallback (whisperx), or hand-edits
|
||||
# (user). Validate against the closed enum so a
|
||||
# hand-edited (or otherwise malformed) manifest can't
|
||||
# propagate a YAML dict / list / arbitrary string
|
||||
# into the highway WS `lyrics.source` field and out
|
||||
# to plugin badges. Anything outside the enum (or
|
||||
# the wrong type) falls back to "xml" — the spec's
|
||||
# back-compat default — instead of being stringified
|
||||
# and trusted.
|
||||
_ALLOWED_LYRICS_SOURCES = {"xml", "notechart", "whisperx", "user"}
|
||||
# Legacy alias: older manifests labelled note-chart-derived
|
||||
# lyrics with the source format's name; normalise it.
|
||||
_LYRICS_SOURCE_ALIASES = {"sng": "notechart"}
|
||||
# Provenance. The feedpak spec (§7.1) vocabulary is
|
||||
# {authored, transcribed, user}; older manifests + the
|
||||
# in-tree readers also use the source-format names
|
||||
# (xml/notechart) and the WhisperX engine name
|
||||
# (whisperx). Accept the union so both spec-compliant
|
||||
# writers (e.g. the stem_splitter plugin emitting
|
||||
# `transcribed`) and legacy packs validate. Validate
|
||||
# against the closed enum so a hand-edited (or otherwise
|
||||
# malformed) manifest can't propagate a YAML dict / list /
|
||||
# arbitrary string into the highway WS `lyrics.source`
|
||||
# field and out to plugin badges. Anything outside the
|
||||
# enum (or the wrong type) falls back to "xml" — the
|
||||
# back-compat default — instead of being stringified and
|
||||
# trusted.
|
||||
# Post-alias values only: `whisperx` is normalised to
|
||||
# `transcribed` before the membership check below, so (like
|
||||
# `sng`) it is intentionally absent from this set.
|
||||
_ALLOWED_LYRICS_SOURCES = {
|
||||
"xml", "notechart", "user",
|
||||
"authored", "transcribed",
|
||||
}
|
||||
# Legacy aliases: older manifests labelled note-chart-derived
|
||||
# lyrics with the source format's name, and the WhisperX
|
||||
# fallback with the engine name — normalise both to the
|
||||
# spec vocabulary the badges now expect.
|
||||
_LYRICS_SOURCE_ALIASES = {"sng": "notechart", "whisperx": "transcribed"}
|
||||
raw_source = manifest.get("lyrics_source")
|
||||
if isinstance(raw_source, str):
|
||||
raw_source = _LYRICS_SOURCE_ALIASES.get(raw_source, raw_source)
|
||||
|
||||
+89
-3
@@ -1,4 +1,4 @@
|
||||
"""Regenerate ``static/tailwind.min.css`` over the full installed-plugin set.
|
||||
"""Regenerate the runtime stylesheet 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,6 +15,7 @@ on a missing optional engine.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -40,12 +41,84 @@ _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:
|
||||
@@ -136,6 +209,14 @@ 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:]
|
||||
@@ -153,7 +234,7 @@ def _run_build(cmd_prefix: list[str], out: Path, src: Path) -> bool:
|
||||
|
||||
|
||||
def rebuild(reason: str = "") -> bool:
|
||||
"""Regenerate ``static/tailwind.min.css`` over baked-in + user plugins.
|
||||
"""Regenerate the RUNTIME stylesheet (see runtime_css_path) 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
|
||||
@@ -166,8 +247,13 @@ def rebuild(reason: str = "") -> bool:
|
||||
log.info("tailwind rebuild skipped — engine/inputs unavailable%s", tag)
|
||||
return False
|
||||
|
||||
out = APP_DIR / "static" / "tailwind.min.css"
|
||||
out = runtime_css_path()
|
||||
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.
|
||||
|
||||
@@ -98,6 +98,24 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER
|
||||
return [round(midi_to_freq(m, reference_pitch), 2) for m in midis]
|
||||
|
||||
|
||||
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)."""
|
||||
out: list[int] = []
|
||||
for f in freqs:
|
||||
try:
|
||||
f = float(f)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(f) or f <= 0:
|
||||
return None
|
||||
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
|
||||
return out
|
||||
|
||||
|
||||
def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None:
|
||||
"""Return semitone offsets from the instrument's standard open strings."""
|
||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||
|
||||
Generated
+1758
-1
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -8,9 +8,12 @@
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:debug": "playwright test --debug",
|
||||
"test:js": "node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js'",
|
||||
"install:playwright": "playwright install chromium"
|
||||
"install:playwright": "playwright install chromium",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1"
|
||||
"@playwright/test": "^1.59.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-import-x": "^4.17.1"
|
||||
}
|
||||
}
|
||||
|
||||
+147
-15
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -18,6 +19,54 @@ from safepath import safe_join
|
||||
log = logging.getLogger("feedBack.plugins")
|
||||
|
||||
|
||||
def _plugin_media_type(path: Path) -> str:
|
||||
"""Best-effort Content-Type for a served plugin file. `.js`/`.css` must come
|
||||
back as JavaScript/CSS so `<script type=module>` / `addModule()` / a `<link>`
|
||||
accept them; `mimetypes.guess_type` can miss these on a stripped platform
|
||||
registry, so fall back explicitly (mirrors the assets/ route)."""
|
||||
media_type = mimetypes.guess_type(path.name)[0]
|
||||
if media_type is None and path.suffix == ".js":
|
||||
return "application/javascript"
|
||||
if media_type is None and path.suffix == ".css":
|
||||
return "text/css"
|
||||
return media_type or "application/octet-stream"
|
||||
|
||||
|
||||
def _plugin_file_etag(path: Path) -> str | None:
|
||||
"""Weak ETag from mtime+size — cheap, stable across reads, changes on edit.
|
||||
This is what makes the live-edit loop work for module graphs: a conditional
|
||||
GET revalidates and 304s unchanged files on refresh instead of re-downloading
|
||||
the whole `src/` tree. Returns None if the file can't be stat'd."""
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
return f'W/"{st.st_mtime_ns:x}-{st.st_size:x}"'
|
||||
|
||||
|
||||
def _if_none_match(request: Request, etag: str) -> bool:
|
||||
"""True when the client's If-None-Match already holds `etag`."""
|
||||
# ponytail: we serve one weak ETag; the browser echoes it back verbatim, so
|
||||
# a direct compare is enough (comma-split tolerates a proxy concatenation).
|
||||
return etag in [t.strip() for t in request.headers.get("if-none-match", "").split(",")]
|
||||
|
||||
|
||||
def _plugin_file_response(request: Request, path: Path, media_type: str) -> Response:
|
||||
"""Serve a plugin source/asset file with the live-edit cache contract:
|
||||
`Cache-Control: no-cache` (browser may store but MUST revalidate) + a weak
|
||||
ETag, and a bodyless 304 when the client's If-None-Match already matches.
|
||||
Starlette's `FileResponse` emits an ETag but never evaluates If-None-Match
|
||||
itself, so the conditional handling has to live here."""
|
||||
headers = {"Cache-Control": "no-cache"}
|
||||
etag = _plugin_file_etag(path)
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if _if_none_match(request, etag):
|
||||
return Response(status_code=304, headers=headers)
|
||||
# FileResponse sets etag/last-modified via setdefault, so the ETag above wins.
|
||||
return FileResponse(path, media_type=media_type, headers=headers)
|
||||
|
||||
|
||||
PLUGINS_DIR = Path(__file__).parent
|
||||
# Holds only *ready* (loaded) plugins — those whose dependencies installed
|
||||
# and whose routes registered. A plugin GRADUATES from PENDING_PLUGINS into
|
||||
@@ -1373,6 +1422,12 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
||||
"version": manifest.get("version"),
|
||||
"has_screen": bool(manifest.get("screen")),
|
||||
"has_script": bool(manifest.get("script")),
|
||||
# Module-migration (R0): `scriptType:"module"` tells the loader to
|
||||
# inject screen.js as <script type="module">; `minHost` is the
|
||||
# min core version a migrated plugin needs (passthrough only in R0 —
|
||||
# enforcement is deferred to R4, master §4b). None when unset.
|
||||
"script_type": manifest.get("scriptType"),
|
||||
"min_host": manifest.get("minHost"),
|
||||
"has_settings": bool(manifest.get("settings")),
|
||||
"settings_category": _settings_category,
|
||||
# Drives the v3 shell's immersive (full-screen) mode for this
|
||||
@@ -2089,6 +2144,11 @@ def register_plugin_api(app: FastAPI):
|
||||
"fallback": p.get("fallback", False),
|
||||
"has_screen": p["has_screen"],
|
||||
"has_script": p["has_script"],
|
||||
# Module-migration passthrough (R0). Re-read from the manifest
|
||||
# like `version` above so stubbed test entries (built without
|
||||
# _nav_entry) don't need the key.
|
||||
"script_type": (p.get("_manifest") or {}).get("scriptType"),
|
||||
"min_host": (p.get("_manifest") or {}).get("minHost"),
|
||||
"has_settings": p["has_settings"],
|
||||
# v3 immersive screen opt-in (full-screen plugin UI).
|
||||
"fullscreen": p.get("fullscreen", False),
|
||||
@@ -2142,6 +2202,9 @@ def register_plugin_api(app: FastAPI):
|
||||
"fallback": False,
|
||||
"has_screen": e.get("has_screen", False),
|
||||
"has_script": e.get("has_script", False),
|
||||
# Pending entries come from _nav_entry, so they carry these.
|
||||
"script_type": e.get("script_type"),
|
||||
"min_host": e.get("min_host"),
|
||||
"has_settings": e.get("has_settings", False),
|
||||
"settings_category": e.get("settings_category"),
|
||||
"fullscreen": e.get("fullscreen", False),
|
||||
@@ -2307,7 +2370,7 @@ def register_plugin_api(app: FastAPI):
|
||||
return HTMLResponse("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/screen.js")
|
||||
def plugin_screen_js(plugin_id: str):
|
||||
def plugin_screen_js(request: Request, plugin_id: str):
|
||||
with PLUGINS_LOCK:
|
||||
snapshot = list(LOADED_PLUGINS)
|
||||
for p in snapshot:
|
||||
@@ -2315,10 +2378,60 @@ def register_plugin_api(app: FastAPI):
|
||||
if p.get("status", "ready") != "ready":
|
||||
break
|
||||
script_file = p["_dir"] / p["_manifest"].get("script", "screen.js")
|
||||
if script_file.exists():
|
||||
return Response(script_file.read_text(encoding="utf-8"), media_type="application/javascript")
|
||||
if script_file.is_file():
|
||||
# no-cache + ETag/304 so an edited screen.js reloads on
|
||||
# refresh while an unchanged one revalidates cheaply — the
|
||||
# same live-edit contract the src/ module graph relies on.
|
||||
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:
|
||||
@@ -2377,7 +2490,7 @@ def register_plugin_api(app: FastAPI):
|
||||
return Response("{}", status_code=404, media_type="application/json")
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/assets/{asset_path:path}")
|
||||
def plugin_asset(plugin_id: str, asset_path: str):
|
||||
def plugin_asset(request: Request, plugin_id: str, asset_path: str):
|
||||
"""Serve a static file a plugin bundles under its own ``assets/``
|
||||
directory (e.g. an AudioWorklet module, WASM, or image). Unlike the
|
||||
fixed screen.js/settings.html handlers above, this is a generic
|
||||
@@ -2399,16 +2512,35 @@ def register_plugin_api(app: FastAPI):
|
||||
log.warning("Plugin %r: asset path rejected: %r", plugin_id, asset_path)
|
||||
break
|
||||
if target.is_file():
|
||||
media_type = mimetypes.guess_type(target.name)[0]
|
||||
# .js must come back as JavaScript so addModule() / <script>
|
||||
# accept it; guess_type can miss this on some platforms.
|
||||
if media_type is None and target.suffix == ".js":
|
||||
media_type = "application/javascript"
|
||||
# .css must come back as text/css so a <link rel=stylesheet>
|
||||
# (the styles capability) is honoured; guess_type can miss it
|
||||
# on a stripped platform mimetypes registry, same as .js.
|
||||
elif media_type is None and target.suffix == ".css":
|
||||
media_type = "text/css"
|
||||
return FileResponse(target, media_type=media_type or "application/octet-stream")
|
||||
# no-cache + ETag/304 so a live-edited worklet/asset reloads
|
||||
# on refresh (bare FileResponse emits an ETag but never 304s).
|
||||
return _plugin_file_response(request, target, _plugin_media_type(target))
|
||||
break
|
||||
return Response("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/src/{src_path:path}")
|
||||
def plugin_src(request: Request, plugin_id: str, src_path: str):
|
||||
"""Serve a file from a plugin's ES-module source tree under ``src/``.
|
||||
|
||||
This is the R0 host capability that lets a migrated plugin's
|
||||
``screen.js`` (a one-line ``import './src/main.js'``) load its whole
|
||||
module graph. Containment mirrors the assets/ route exactly —
|
||||
``safe_join`` against ``<plugin>/src`` rejects ``..``, absolute paths,
|
||||
and NUL bytes — and the live-edit cache contract (no-cache + ETag/304)
|
||||
makes an edited module reload on refresh while unchanged ones 304.
|
||||
Read-only; the src/ tree is source files, never executed server-side.
|
||||
"""
|
||||
with PLUGINS_LOCK:
|
||||
snapshot = list(LOADED_PLUGINS)
|
||||
for p in snapshot:
|
||||
if p["id"] == plugin_id:
|
||||
if p.get("status", "ready") != "ready":
|
||||
break
|
||||
target = safe_join(p["_dir"] / "src", src_path)
|
||||
if target is None:
|
||||
log.warning("Plugin %r: src path rejected: %r", plugin_id, src_path)
|
||||
break
|
||||
if target.is_file():
|
||||
return _plugin_file_response(request, target, _plugin_media_type(target))
|
||||
break
|
||||
return Response("", status_code=404)
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
/* 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). */
|
||||
.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 foil preview — honest "coming", never earnable-looking. */
|
||||
.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 dashed #c8b273;
|
||||
color: #a8946d;
|
||||
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; }
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"badge_requirement": {
|
||||
"songs": 5,
|
||||
"min_stars": 2
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
"""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)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
_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 = sum(1 for t in thresholds if acc >= t)
|
||||
if stars:
|
||||
per_song[filename] = stars
|
||||
next_at = next((t for t in thresholds if acc < t), None)
|
||||
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"] = sum(1 for t in thresholds if acc >= t)
|
||||
return out, seconds
|
||||
|
||||
|
||||
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 _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)
|
||||
override = (cfg.get("genres") or {}).get(gkey)
|
||||
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 {}
|
||||
return doc.get("received_at"), by_node
|
||||
|
||||
|
||||
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 _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()
|
||||
played, played_seconds = _played_by_instrument_genre()
|
||||
received_at, by_node = _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):
|
||||
badge = "earned"
|
||||
else:
|
||||
badge = "in_progress"
|
||||
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,
|
||||
# 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,
|
||||
})
|
||||
instruments[inst] = {"committed_at": committed_at, "passports": passports}
|
||||
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 _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.")
|
||||
with _lock:
|
||||
_, existing = _drill_by_node()
|
||||
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
|
||||
"byNode": _merge_drill_nodes(existing, body["byNode"])}
|
||||
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}/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"},
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
<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-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>
|
||||
@@ -0,0 +1,897 @@
|
||||
/*
|
||||
* Career plugin — venue progression UI + crowd-manifest push.
|
||||
*
|
||||
* Reads /api/plugins/career/state (stars from song_stats, per-venue
|
||||
* unlock/install/download status), renders the career screen, and pushes the
|
||||
* active venue's pack manifest into the crowd video layer
|
||||
* (window.v3VenueCrowd, shipped with the venue crowd PR) whenever it changes.
|
||||
* Everything degrades: no crowd layer → screen still works; no packs → the
|
||||
* venue scene keeps its static plate.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const API = '/api/plugins/career';
|
||||
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
|
||||
const NO_VENUE = '__none__';
|
||||
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
|
||||
const POLL_MS = 2000;
|
||||
|
||||
// Passports (the badge-journey layer; see routes.py — badges are computed
|
||||
// server-side, this file only renders and relays).
|
||||
const PP_SEEN_KEY = 'feedBack-career-badges-seen';
|
||||
const PP_INST_KEY = 'feedBack-career-instrument';
|
||||
const PP_TAB_KEY = 'feedBack-career-tab';
|
||||
const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' };
|
||||
const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕'];
|
||||
|
||||
let _state = null;
|
||||
let _pollTimer = 0;
|
||||
let _appliedManifestVenue = null;
|
||||
let _manifestReqGen = 0; // invalidates in-flight manifest fetches
|
||||
let _prevUnlockedIds = null;
|
||||
let _pp = null; // last /passports view
|
||||
let _ppRelayTimer = 0;
|
||||
let _ppBook = null; // {inst, gkey} of the open spread
|
||||
let _ppReturnFocus = null; // element to refocus when the book closes
|
||||
let _ppCeremonyQueue = []; // badges awaiting their ceremony overlay
|
||||
let _ppCeremonyActive = false;
|
||||
let _ppBootstrapped = false;
|
||||
let _ppNotified = {}; // badges chimed this session (slam still pending)
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
async function fetchState() {
|
||||
const res = await fetch(API + '/state');
|
||||
if (!res.ok) throw new Error('career state ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function lastOf(arr) { return arr.length ? arr[arr.length - 1] : null; }
|
||||
|
||||
// Active pack = localStorage override when unlocked+installed, else the
|
||||
// highest unlocked+installed tier; none → clear the crowd manifest.
|
||||
async function pushCrowdManifest(state) {
|
||||
const crowd = window.v3VenueCrowd;
|
||||
if (!crowd || typeof crowd.setManifest !== 'function') return;
|
||||
// Any newer invocation (delete, venue switch, fresher state) must win
|
||||
// over a manifest fetch still in flight from this one.
|
||||
const gen = ++_manifestReqGen;
|
||||
const unlocked = state.venues.filter((v) => v.unlocked);
|
||||
let venue = null;
|
||||
let override = null;
|
||||
try { override = localStorage.getItem(VENUE_OVERRIDE_KEY); } catch (_) { /* ok */ }
|
||||
if (override !== NO_VENUE) {
|
||||
venue = unlocked.find((v) => v.id === override && v.installed) || null;
|
||||
if (!venue) venue = lastOf(unlocked.filter((v) => v.installed));
|
||||
}
|
||||
if (!venue) {
|
||||
if (_appliedManifestVenue !== null) {
|
||||
_appliedManifestVenue = null;
|
||||
crowd.setManifest(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (venue.id === _appliedManifestVenue) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/venues/${venue.id}/manifest.json`);
|
||||
if (gen !== _manifestReqGen || !res.ok) return;
|
||||
const manifest = await res.json();
|
||||
if (gen !== _manifestReqGen) return;
|
||||
manifest.base = `${API}/venues/${venue.id}/`;
|
||||
_appliedManifestVenue = venue.id;
|
||||
crowd.setManifest(manifest);
|
||||
} catch (_) { /* pack half-installed; next refresh retries */ }
|
||||
}
|
||||
|
||||
function venueCardHTML(v, state) {
|
||||
const locked = !v.unlocked;
|
||||
const dl = v.download || { status: 'idle' };
|
||||
const pct = dl.bytes_total > 0
|
||||
? Math.round((dl.bytes_done / dl.bytes_total) * 100) : 0;
|
||||
let action = '';
|
||||
if (locked) {
|
||||
action = `<div class="text-xs text-gray-500">Unlocks at ${v.star_threshold} ★ — ${Math.max(0, v.star_threshold - state.stars_total)} to go</div>`;
|
||||
} else if (dl.status === 'running') {
|
||||
action = `<div class="career-bar-track mb-1" style="height:0.375rem"><div class="career-bar-fill" style="width:${pct}%"></div></div>
|
||||
<div class="text-xs text-gray-400">Downloading… ${pct}%</div>`;
|
||||
} else if (v.installed) {
|
||||
const active = localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id;
|
||||
const main = active
|
||||
? `<button data-career-unselect="1" class="career-btn career-btn-ghost">Leave venue</button>`
|
||||
: `<button data-career-play="${esc(v.id)}" class="career-btn career-btn-primary">Play here</button>`;
|
||||
const remove = v.bundled
|
||||
? ''
|
||||
: `<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>`;
|
||||
action = `<div class="flex items-center gap-2">
|
||||
${main}
|
||||
${remove}
|
||||
</div>`;
|
||||
} else if (v.has_pack) {
|
||||
const err = dl.status === 'error'
|
||||
? `<div class="text-xs text-amber-400 mb-1">${esc(dl.error || 'Download failed')} — try again</div>` : '';
|
||||
action = `${err}<button data-career-download="${esc(v.id)}" class="career-btn career-btn-primary">Download venue pack</button>`;
|
||||
} else {
|
||||
action = '<div class="text-xs text-gray-500">Venue pack coming soon — plays with the standard stage for now</div>';
|
||||
}
|
||||
// Mirror pushCrowdManifest(): an override only counts while the pack
|
||||
// is installed — after a removal the badge must not claim a venue the
|
||||
// crowd layer can't use.
|
||||
const isActive = !locked && v.installed &&
|
||||
localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id;
|
||||
return `<div class="rounded-xl border ${locked ? 'border-gray-800 opacity-60' : 'border-gray-700'} bg-dark-700/40 p-4 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-white">${esc(v.name)}${isActive ? ' <span class="text-cyan-400 text-xs">● playing here</span>' : ''}</div>
|
||||
<div class="text-xs text-gray-400">${v.star_threshold} ★</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 flex-1">${esc(v.description)}</div>
|
||||
${action}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function starGlyphs(n) {
|
||||
let out = '';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
out += `<span class="${i < n ? 'on' : 'off'}">★</span>`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderStars(state) {
|
||||
const list = $('career-star-list');
|
||||
const summary = $('career-star-summary');
|
||||
if (!list || !summary) return;
|
||||
const detail = state.star_detail || [];
|
||||
const tiers = [0, 0, 0, 0];
|
||||
for (const r of detail) tiers[r.stars]++;
|
||||
summary.textContent =
|
||||
`${tiers[3]}× 3★ · ${tiers[2]}× 2★ · ${tiers[1]}× 1★ · ${tiers[0]} unstarred`;
|
||||
if (!detail.length) {
|
||||
list.innerHTML = '<div class="text-xs text-gray-500">Play songs to start collecting stars — 60% accuracy earns the first one.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = detail.map((r) => {
|
||||
let hint = 'maxed';
|
||||
let close = '';
|
||||
if (r.next_star_at != null) {
|
||||
const gap = Math.max(0, r.next_star_at - r.best_accuracy) * 100;
|
||||
hint = `${gap.toFixed(0)}% to next ★`;
|
||||
if (gap <= 5) close = ' close';
|
||||
}
|
||||
return `<div class="career-star-row">
|
||||
<span class="stars">${starGlyphs(r.stars)}</span>
|
||||
<span class="song">${esc(r.title)}${r.artist ? ` <span class="artist">— ${esc(r.artist)}</span>` : ''}</span>
|
||||
<span class="hint${close}">best ${(r.best_accuracy * 100).toFixed(0)}% · ${hint}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
const host = $('career-venues');
|
||||
if (!host) return;
|
||||
$('career-stars-summary').textContent = `★ ${state.stars_total} total`;
|
||||
const next = state.venues.find((v) => !v.unlocked);
|
||||
const bar = $('career-progress-bar');
|
||||
const label = $('career-progress-label');
|
||||
if (next) {
|
||||
const prevThreshold = state.venues
|
||||
.filter((v) => v.unlocked)
|
||||
.reduce((m, v) => Math.max(m, v.star_threshold), 0);
|
||||
const span = Math.max(1, next.star_threshold - prevThreshold);
|
||||
const into = Math.max(0, state.stars_total - prevThreshold);
|
||||
bar.style.width = Math.min(100, Math.round((into / span) * 100)) + '%';
|
||||
label.textContent = `${state.stars_total} / ${next.star_threshold} ★ to unlock ${next.name}`;
|
||||
} else {
|
||||
bar.style.width = '100%';
|
||||
label.textContent = 'All venues unlocked — enjoy the arena.';
|
||||
}
|
||||
host.innerHTML = state.venues.map((v) => venueCardHTML(v, state)).join('');
|
||||
renderStars(state);
|
||||
}
|
||||
|
||||
function schedulePoll(state) {
|
||||
clearTimeout(_pollTimer);
|
||||
if (state.venues.some((v) => (v.download || {}).status === 'running')) {
|
||||
_pollTimer = setTimeout(refresh, POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function announceUnlocks(state) {
|
||||
const unlocked = state.venues.filter((v) => v.unlocked).map((v) => v.id);
|
||||
if (_prevUnlockedIds) {
|
||||
for (const v of state.venues) {
|
||||
if (v.unlocked && !_prevUnlockedIds.includes(v.id)) {
|
||||
const sm = window.feedBack;
|
||||
if (sm && typeof sm.emit === 'function') {
|
||||
sm.emit('career:venue-unlocked', { id: v.id, name: v.name });
|
||||
}
|
||||
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||
window.fbNotify.show({
|
||||
big: true, icon: '🎤', accent: '#06B6D4',
|
||||
title: 'New venue unlocked!',
|
||||
message: `${v.name} — your crowd just got bigger.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_prevUnlockedIds = unlocked;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let state;
|
||||
try {
|
||||
state = await fetchState();
|
||||
} catch (_) {
|
||||
return; // server restarting; next trigger retries
|
||||
}
|
||||
_state = state;
|
||||
announceUnlocks(state);
|
||||
render(state);
|
||||
schedulePoll(state);
|
||||
pushCrowdManifest(state);
|
||||
refreshPassports(); // independent fetch; failures don't touch venues
|
||||
}
|
||||
|
||||
// ── Passports ─────────────────────────────────────────────────────────
|
||||
|
||||
function lsGet(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }
|
||||
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (_) { /* ok */ } }
|
||||
|
||||
function ppLabel(inst) {
|
||||
return PP_LABELS[inst] || (inst.charAt(0).toUpperCase() + inst.slice(1));
|
||||
}
|
||||
|
||||
function ppKey(genre) {
|
||||
return String(genre || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
function ppHash(seed) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
// Deterministic per-key jitter (sin-hash): stamps and stubs land slightly
|
||||
// askew, the same way on every visit.
|
||||
function ppJitter(seed, range) {
|
||||
return (Math.abs(Math.sin(ppHash(seed))) * 2 - 1) * range;
|
||||
}
|
||||
|
||||
function sfx(name) {
|
||||
try {
|
||||
const a = new Audio(`${API}/assets/sfx/${name}.mp3`);
|
||||
a.volume = 0.45;
|
||||
a.play().catch(() => { /* autoplay policy — silent is fine */ });
|
||||
} catch (_) { /* no Audio — fine */ }
|
||||
}
|
||||
|
||||
function showCareerTab(tab) {
|
||||
lsSet(PP_TAB_KEY, tab);
|
||||
const venues = $('career-tab-venues');
|
||||
const pp = $('career-tab-passports');
|
||||
if (!venues || !pp) return;
|
||||
venues.classList.toggle('hidden', tab !== 'venues');
|
||||
pp.classList.toggle('hidden', tab !== 'passports');
|
||||
document.querySelectorAll('#plugin-career .career-tab').forEach((b) => {
|
||||
const active = b.dataset.careerTab === tab;
|
||||
b.classList.toggle('active', active);
|
||||
b.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function activeInstrument() {
|
||||
const list = (_pp && _pp.config && _pp.config.instruments) || [];
|
||||
const saved = lsGet(PP_INST_KEY);
|
||||
if (saved && list.includes(saved)) return saved;
|
||||
const committed = list.find((i) => ((_pp.instruments || {})[i] || {}).committed_at);
|
||||
return committed || list[0] || 'guitar';
|
||||
}
|
||||
|
||||
function seenBadges() {
|
||||
try {
|
||||
const seen = JSON.parse(lsGet(PP_SEEN_KEY) || '{}');
|
||||
// Guard non-object JSON (a stray "null" or array) — a broken
|
||||
// stored value must not throw on every passport refresh.
|
||||
return seen && typeof seen === 'object' && !Array.isArray(seen) ? seen : {};
|
||||
} catch (_) { return {}; }
|
||||
}
|
||||
|
||||
function badgeId(inst, gkey) { return inst + '/' + gkey; }
|
||||
|
||||
function markBadgeSeen(inst, gkey) {
|
||||
const seen = seenBadges();
|
||||
seen[badgeId(inst, gkey)] = 1;
|
||||
lsSet(PP_SEEN_KEY, JSON.stringify(seen));
|
||||
}
|
||||
|
||||
// New badge → chime + notification + the venue ceremony, once per
|
||||
// session; the stamp SLAM plays when the passport is next opened (and
|
||||
// only then is the badge marked seen, so a pending slam survives a
|
||||
// reload).
|
||||
function detectNewBadges(view) {
|
||||
const seen = seenBadges();
|
||||
for (const inst of Object.keys(view.instruments || {})) {
|
||||
for (const p of (view.instruments[inst].passports || [])) {
|
||||
const id = badgeId(inst, p.genre_key);
|
||||
if (p.badge !== 'earned' || seen[id] || _ppNotified[id]) continue;
|
||||
_ppNotified[id] = true;
|
||||
sfx('chime');
|
||||
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||
window.fbNotify.show({
|
||||
big: true, icon: '🛂', accent: '#b45309',
|
||||
title: 'Badge earned!',
|
||||
message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
|
||||
});
|
||||
}
|
||||
badgeCeremony(inst, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reducedMotion() {
|
||||
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
// The badge moment: the crowd erupts first (if a venue pack is live —
|
||||
// badges land post-stats:recorded while the player is still on screen),
|
||||
// then a body-level overlay. It CANNOT live in #pp-overlay: #plugin-career
|
||||
// is display:none during playback.
|
||||
function badgeCeremony(inst, p) {
|
||||
// Reduced motion: the chime + fbNotify already delivered the news —
|
||||
// no overlay, and no app-initiated crowd eruption either.
|
||||
if (reducedMotion()) return;
|
||||
const crowd = window.v3VenueCrowd;
|
||||
if (crowd && typeof crowd.celebrate === 'function') {
|
||||
try { crowd.celebrate(); } catch (_) { /* crowd layer optional */ }
|
||||
}
|
||||
if (!document.body || typeof document.createElement !== 'function') return;
|
||||
// Several badges can land in one refresh (first load, drill-snapshot
|
||||
// bootstrap): queue the ceremonies and play them back to back.
|
||||
_ppCeremonyQueue.push({ inst, p });
|
||||
if (!_ppCeremonyActive) setTimeout(drainCeremonies, 300);
|
||||
}
|
||||
|
||||
function drainCeremonies() {
|
||||
if (_ppCeremonyActive) return;
|
||||
const queued = _ppCeremonyQueue.shift();
|
||||
if (!queued) return;
|
||||
_ppCeremonyActive = true;
|
||||
showCeremonyOverlay(queued.inst, queued.p, () => {
|
||||
_ppCeremonyActive = false;
|
||||
setTimeout(drainCeremonies, 250);
|
||||
});
|
||||
}
|
||||
|
||||
function showCeremonyOverlay(inst, p, done) {
|
||||
const el = document.createElement('div');
|
||||
el.id = 'pp-ceremony';
|
||||
el.className = 'pp-ceremony-overlay';
|
||||
el.innerHTML = `
|
||||
<canvas class="pp-confetti"></canvas>
|
||||
<div class="pp-ceremony-card">
|
||||
<div class="pp-stamp pp-stamp-page pp-ceremony-stamp" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-stamp-tier">BRONZE</span>
|
||||
</div>
|
||||
<div class="pp-ceremony-title">Badge earned</div>
|
||||
<div class="pp-ceremony-sub">${esc(p.genre)} — ${esc(ppLabel(inst))} passport</div>
|
||||
</div>`;
|
||||
let timer = 0;
|
||||
let closed = false;
|
||||
const dismiss = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
clearTimeout(timer);
|
||||
el.classList.add('pp-ceremony-out');
|
||||
setTimeout(() => { el.remove(); done(); }, 350);
|
||||
};
|
||||
el.addEventListener('click', dismiss);
|
||||
document.body.appendChild(el);
|
||||
timer = setTimeout(dismiss, 4200);
|
||||
confettiBurst(el.querySelector('.pp-confetti'));
|
||||
}
|
||||
|
||||
function confettiBurst(canvas) {
|
||||
if (!canvas || typeof canvas.getContext !== 'function') return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
canvas.width = canvas.clientWidth;
|
||||
canvas.height = canvas.clientHeight;
|
||||
const colors = ['#d9a253', '#b45309', '#facc15', '#06b6d4', '#e5e7eb'];
|
||||
const parts = Array.from({ length: 42 }, () => ({
|
||||
x: canvas.width / 2 + (Math.random() - 0.5) * 90,
|
||||
y: canvas.height * 0.42,
|
||||
vx: (Math.random() - 0.5) * 9,
|
||||
vy: -(4 + Math.random() * 7),
|
||||
rot: Math.random() * Math.PI,
|
||||
vr: (Math.random() - 0.5) * 0.3,
|
||||
w: 5 + Math.random() * 5,
|
||||
h: 3 + Math.random() * 4,
|
||||
c: colors[(Math.random() * colors.length) | 0],
|
||||
}));
|
||||
let frames = 0;
|
||||
(function tick() {
|
||||
if (!canvas.isConnected || frames++ > 240) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
for (const q of parts) {
|
||||
q.x += q.vx; q.y += q.vy; q.vy += 0.18; q.rot += q.vr;
|
||||
ctx.save();
|
||||
ctx.translate(q.x, q.y);
|
||||
ctx.rotate(q.rot);
|
||||
ctx.fillStyle = q.c;
|
||||
ctx.fillRect(-q.w / 2, -q.h / 2, q.w, q.h);
|
||||
ctx.restore();
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}());
|
||||
}
|
||||
|
||||
// Relay the Virtuoso drill snapshot (localStorage doc, not the thin bus
|
||||
// payload) to the server intake, debounced across event bursts.
|
||||
function relayDrillState() {
|
||||
clearTimeout(_ppRelayTimer);
|
||||
_ppRelayTimer = setTimeout(() => {
|
||||
let snap = null;
|
||||
try { snap = JSON.parse(lsGet('virtuoso.progress') || 'null'); } catch (_) { /* corrupt */ }
|
||||
if (!snap || typeof snap !== 'object' || !snap.byNode || typeof snap.byNode !== 'object') return;
|
||||
fetch(`${API}/drill-state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: snap.mode, xp: snap.xp, byNode: snap.byNode }),
|
||||
}).then(() => refreshPassports()).catch(() => { /* next event retries */ });
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function refreshPassports() {
|
||||
let view;
|
||||
try {
|
||||
const res = await fetch(`${API}/passports`);
|
||||
if (!res.ok) return;
|
||||
view = await res.json();
|
||||
} catch (_) { return; }
|
||||
_pp = view;
|
||||
detectNewBadges(view);
|
||||
renderPassports();
|
||||
if (!_ppBootstrapped) {
|
||||
_ppBootstrapped = true;
|
||||
// Sync the local drill snapshot once per session — drill progress
|
||||
// made before the career plugin existed (or a relay POST that
|
||||
// failed) must not deny a gated badge until the next virtuoso
|
||||
// event happens to fire. Tiny payload, single-user app.
|
||||
relayDrillState();
|
||||
}
|
||||
}
|
||||
|
||||
// Honest hours odometer (Stage 5 post-cap). Below a minute of history
|
||||
// there is nothing meaningful to show.
|
||||
function fmtHours(seconds) {
|
||||
const s = Number(seconds) || 0;
|
||||
if (s < 60) return '';
|
||||
if (s < 3600) return `${Math.round(s / 60)} min`;
|
||||
return `${(s / 3600).toFixed(1).replace(/\.0$/, '')} h`;
|
||||
}
|
||||
|
||||
function ppCoverHTML(inst, p) {
|
||||
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
|
||||
const earned = p.badge === 'earned';
|
||||
const stamp = earned
|
||||
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
|
||||
: '';
|
||||
const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
|
||||
const hours = fmtHours(p.seconds_total);
|
||||
// Earned covers are trading cards: rotation moves into a CSS var so
|
||||
// the pointer-tracked tilt transform can compose with it.
|
||||
const style = earned
|
||||
? `--pp-cover-rot:${rot}deg` : `transform:rotate(${rot}deg)`;
|
||||
return `<button class="pp-cover${earned ? ' pp-tilt' : ''} pp-leather-${esc(inst)}" data-pp-open="${esc(p.genre_key)}" style="${style}">
|
||||
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
||||
${stamp}
|
||||
<span class="pp-cover-sub">${stubs}${hours ? ` · ${hours}` : ''}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function renderShelf(inst, data) {
|
||||
const shelf = $('pp-shelf');
|
||||
if (!shelf) return;
|
||||
if (!data.committed_at) {
|
||||
shelf.innerHTML = `<div class="pp-commit-card">
|
||||
<div class="pp-commit-cover pp-leather-${esc(inst)}">
|
||||
<span class="pp-cover-title">${esc(ppLabel(inst).toUpperCase())}</span>
|
||||
<span class="pp-cover-inst">passport</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-200 font-medium mb-1">Pick up the ${esc(ppLabel(inst).toLowerCase())}.</div>
|
||||
<div class="text-xs text-gray-400 mb-2">Press your seal to commit — then choose a genre below and go deep.</div>
|
||||
<button class="career-btn career-btn-primary" data-pp-commit="${esc(inst)}">Press the seal</button>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
const books = (data.passports || []).map((p) => ppCoverHTML(inst, p)).join('');
|
||||
shelf.innerHTML = books ||
|
||||
'<div class="text-xs text-gray-500">Your shelf is ready — open your first genre passport below.</div>';
|
||||
}
|
||||
|
||||
function renderRack(inst, data) {
|
||||
const rack = $('pp-rack');
|
||||
if (!rack || !_pp) return;
|
||||
const openedKeys = new Set((data.passports || []).map((p) => p.genre_key));
|
||||
const genres = (_pp.genres || []).filter((g) => !openedKeys.has(g.genre_key));
|
||||
if (!genres.length) {
|
||||
rack.innerHTML = '<div class="text-xs text-gray-500">No further genres in your library yet — new songs bring new brochures.</div>';
|
||||
return;
|
||||
}
|
||||
rack.innerHTML = genres.map((g) => {
|
||||
const art = PP_BROCHURE_ART[Math.abs(ppHash(g.genre_key)) % PP_BROCHURE_ART.length];
|
||||
return `<button class="pp-brochure" data-pp-genre="${esc(g.genre)}">
|
||||
<span class="pp-brochure-art" aria-hidden="true">${art}</span>
|
||||
<span class="pp-brochure-name">${esc(g.genre)}</span>
|
||||
<span class="pp-brochure-sub">${g.songs_in_library === 1 ? '1 song' : `${g.songs_in_library} songs`} in your library</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderPassports() {
|
||||
const host = $('pp-instruments');
|
||||
if (!host || !_pp) return;
|
||||
const inst = activeInstrument();
|
||||
const data = (_pp.instruments || {})[inst] || { passports: [] };
|
||||
host.innerHTML = ((_pp.config || {}).instruments || []).map((i) => {
|
||||
const d = (_pp.instruments || {})[i] || {};
|
||||
const earned = (d.passports || []).filter((p) => p.badge === 'earned').length;
|
||||
const committed = !!d.committed_at;
|
||||
return `<button class="pp-inst${i === inst ? ' active' : ''}${committed ? '' : ' uncommitted'}" data-pp-inst="${esc(i)}">
|
||||
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
|
||||
</button>`;
|
||||
}).join('');
|
||||
renderShelf(inst, data);
|
||||
renderRack(inst, data);
|
||||
}
|
||||
|
||||
function ppStubHTML(s) {
|
||||
const date = (s.last_played_at || '').slice(0, 10);
|
||||
return `<div class="pp-stub" style="transform:rotate(${ppJitter(s.filename, 1.2).toFixed(2)}deg)">
|
||||
<span class="pp-stub-stars">${'★'.repeat(s.stars)}</span>
|
||||
<span class="pp-stub-title">${esc(s.title)}</span>
|
||||
${s.artist ? `<span class="pp-stub-artist">${esc(s.artist)}</span>` : ''}
|
||||
<span class="pp-stub-meta">${date ? `${esc(date)} · ` : ''}best ${(s.best_accuracy * 100).toFixed(0)}%</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Emerging-stamp ink: how much of the ghost stamp has "carved in".
|
||||
// Song progress toward the bar only — the invite line stays the words.
|
||||
function ppFillFraction(p) {
|
||||
if (!p || p.badge !== 'in_progress') return 0;
|
||||
const need = Number((p.requirement || {}).songs) || 0;
|
||||
if (need <= 0) return 0;
|
||||
return Math.max(0, Math.min(1, (p.qualifying_count || 0) / need));
|
||||
}
|
||||
|
||||
function ppBookHTML(inst, p, pendingSlam) {
|
||||
const req = p.requirement || {};
|
||||
const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
|
||||
const starGl = '★'.repeat(req.min_stars || 0);
|
||||
const reqNodes = (p.drills || {}).required || [];
|
||||
const clearedNodes = new Set((p.drills || {}).cleared || []);
|
||||
const labels = ((_pp && _pp.config) || {}).drill_labels || {};
|
||||
const pendingDrills = reqNodes.filter((n) => !clearedNodes.has(n));
|
||||
// The invite names what actually blocks the stamp: songs first, then
|
||||
// the genre drill once the song bar is met.
|
||||
let invite;
|
||||
if (need > 0) {
|
||||
invite = need === 1 ? `One more ${starGl} song mints this stamp.`
|
||||
: `${need} more ${starGl} songs mint this stamp.`;
|
||||
} else {
|
||||
const names = pendingDrills.map((n) => labels[n] || n).join(', ');
|
||||
invite = `Clear ${names || 'the genre drill'} in Virtuoso to mint this stamp.`;
|
||||
}
|
||||
let badgeArea = '';
|
||||
if (p.badge === 'shown_not_judged') {
|
||||
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
||||
} else if (p.badge === 'earned') {
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ' pp-tilt'}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-stamp-tier">BRONZE</span>
|
||||
</div>
|
||||
<div class="pp-gold-foil" aria-hidden="true">GOLD</div>
|
||||
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>`;
|
||||
} else {
|
||||
const fill = (ppFillFraction(p) * 100).toFixed(0);
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg; --pp-fill:${fill}%">
|
||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-stamp-tier">BRONZE</span>
|
||||
</div>
|
||||
<div class="pp-invite">${esc(invite)}</div>`;
|
||||
}
|
||||
const hours = fmtHours(p.seconds_total);
|
||||
const odometer = hours
|
||||
? `<div class="pp-hours">${hours} in ${esc(p.genre)}</div>` : '';
|
||||
let drills = '';
|
||||
if (reqNodes.length) {
|
||||
drills = `<div class="pp-drills">${reqNodes.map((n) =>
|
||||
`<div class="pp-drill${clearedNodes.has(n) ? ' cleared' : ''}">${clearedNodes.has(n) ? '✓' : '○'} ${esc(labels[n] || n)}</div>`).join('')}</div>`;
|
||||
}
|
||||
// Graded instruments collect stubs at the badge bar; shown-not-judged
|
||||
// instruments have no bar — every played genre song is repertoire.
|
||||
const stubs = p.badge === 'shown_not_judged'
|
||||
? (p.songs || [])
|
||||
: (p.songs || []).filter((s) => s.qualifies);
|
||||
const emptyLine = p.badge === 'shown_not_judged'
|
||||
? `Play ${esc(p.genre)} songs to fill this page.`
|
||||
: `Play ${esc(p.genre)} songs at ${starGl} to collect ticket stubs.`;
|
||||
const stubsHTML = stubs.length ? stubs.map(ppStubHTML).join('')
|
||||
: `<div class="pp-stub-empty">${emptyLine}</div>`;
|
||||
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="${esc(p.genre)} ${esc(ppLabel(inst))} passport">
|
||||
<div class="pp-book">
|
||||
<div class="pp-page pp-page-left">
|
||||
<div class="pp-page-head">${esc(p.genre)} — ${esc(ppLabel(inst))}</div>
|
||||
${badgeArea}${odometer}${drills}
|
||||
</div>
|
||||
<div class="pp-page pp-page-right">
|
||||
<div class="pp-page-head">Ticket stubs</div>
|
||||
<div class="pp-stubs">${stubsHTML}</div>
|
||||
</div>
|
||||
<div class="pp-book-cover pp-leather-${esc(inst)}">
|
||||
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-cover-inst">${esc(ppLabel(inst))} passport</span>
|
||||
</div>
|
||||
<button class="pp-book-close" data-pp-close="1" aria-label="Close">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openBook(inst, gkey) {
|
||||
if (!_pp) return;
|
||||
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
|
||||
.find((x) => x.genre_key === gkey);
|
||||
const overlay = $('pp-overlay');
|
||||
if (!p || !overlay) return;
|
||||
_ppBook = { inst, gkey };
|
||||
_ppReturnFocus = document.activeElement;
|
||||
const pending = p.badge === 'earned' && !seenBadges()[badgeId(inst, gkey)];
|
||||
overlay.innerHTML = ppBookHTML(inst, p, pending);
|
||||
overlay.classList.remove('hidden');
|
||||
const close = overlay.querySelector('.pp-book-close');
|
||||
if (close) close.focus();
|
||||
sfx('page');
|
||||
// Double rAF so the cover's closed state paints before the transition.
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
const book = overlay.querySelector('.pp-book');
|
||||
if (book) book.classList.add('open');
|
||||
}));
|
||||
if (pending) {
|
||||
setTimeout(() => {
|
||||
if (!_ppBook || _ppBook.gkey !== gkey || _ppBook.inst !== inst) return;
|
||||
const stamp = overlay.querySelector('.pp-stamp-page');
|
||||
const book = overlay.querySelector('.pp-book');
|
||||
if (!stamp) return;
|
||||
stamp.classList.remove('pp-stamp-hidden');
|
||||
stamp.classList.add('pp-slam');
|
||||
stamp.classList.add('pp-tilt'); // freshly slammed = trading card too
|
||||
if (book) book.classList.add('pp-shake');
|
||||
sfx('stamp');
|
||||
markBadgeSeen(inst, gkey);
|
||||
renderPassports(); // the shelf cover gains its mini-stamp
|
||||
}, 950);
|
||||
}
|
||||
}
|
||||
|
||||
function closeBook() {
|
||||
_ppBook = null;
|
||||
const overlay = $('pp-overlay');
|
||||
if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; }
|
||||
if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' &&
|
||||
document.contains(_ppReturnFocus)) {
|
||||
_ppReturnFocus.focus();
|
||||
}
|
||||
_ppReturnFocus = null;
|
||||
}
|
||||
|
||||
function commitInstrument(inst, after) {
|
||||
fetch(`${API}/passports/commit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ instrument: inst }),
|
||||
}).then(() => refreshPassports())
|
||||
.then(() => { if (after) after(); })
|
||||
.catch(() => { /* server restarting; user retries */ });
|
||||
}
|
||||
|
||||
// Stage 0 — the wax seal. Purely theatrical: the overlay plays the press,
|
||||
// the POST commits, the shelf re-renders committed.
|
||||
function sealCeremony(inst, after) {
|
||||
const overlay = $('pp-overlay');
|
||||
if (!overlay) { commitInstrument(inst, after); return; }
|
||||
overlay.innerHTML = `<div class="pp-book-wrap">
|
||||
<div class="pp-commit-cover pp-ceremony pp-leather-${esc(inst)}">
|
||||
<span class="pp-cover-title">${esc(ppLabel(inst).toUpperCase())}</span>
|
||||
<span class="pp-cover-inst">passport</span>
|
||||
<span class="pp-wax"><span>${esc(ppLabel(inst).charAt(0))}</span></span>
|
||||
</div>
|
||||
</div>`;
|
||||
overlay.classList.remove('hidden');
|
||||
setTimeout(() => sfx('seal'), 450);
|
||||
setTimeout(() => {
|
||||
overlay.classList.add('hidden');
|
||||
overlay.innerHTML = '';
|
||||
commitInstrument(inst, after);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// ── Trading-card tilt (earned artifacts only) ─────────────────────────
|
||||
let _tiltRaf = 0;
|
||||
let _tiltEl = null;
|
||||
|
||||
function tiltAllowed() {
|
||||
try {
|
||||
return window.matchMedia('(hover: hover)').matches &&
|
||||
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
function resetTilt(el) {
|
||||
if (!el) return;
|
||||
el.style.removeProperty('--pp-tilt-x');
|
||||
el.style.removeProperty('--pp-tilt-y');
|
||||
el.style.removeProperty('--pp-glint-x');
|
||||
}
|
||||
|
||||
function onTiltMove(e) {
|
||||
if (!tiltAllowed()) return;
|
||||
const card = e.target && e.target.closest ? e.target.closest('.pp-tilt') : null;
|
||||
if (_tiltEl && _tiltEl !== card) { resetTilt(_tiltEl); _tiltEl = null; }
|
||||
if (!card) return;
|
||||
_tiltEl = card;
|
||||
if (_tiltRaf) return;
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
_tiltRaf = requestAnimationFrame(() => {
|
||||
_tiltRaf = 0;
|
||||
const r = card.getBoundingClientRect();
|
||||
if (!r.width || !r.height) return;
|
||||
const px = (x - r.left) / r.width;
|
||||
const py = (y - r.top) / r.height;
|
||||
card.style.setProperty('--pp-tilt-x', `${((0.5 - py) * 10).toFixed(2)}deg`);
|
||||
card.style.setProperty('--pp-tilt-y', `${((px - 0.5) * 12).toFixed(2)}deg`);
|
||||
card.style.setProperty('--pp-glint-x', `${(px * 100).toFixed(1)}%`);
|
||||
});
|
||||
}
|
||||
|
||||
function onTiltLeave() {
|
||||
// Cancel any queued frame: it closes over the departed card and would
|
||||
// re-apply tilt vars after the pointer has left.
|
||||
if (_tiltRaf) { cancelAnimationFrame(_tiltRaf); _tiltRaf = 0; }
|
||||
if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; }
|
||||
}
|
||||
|
||||
function openGenre(inst, genre) {
|
||||
fetch(`${API}/passports/open`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ instrument: inst, genre }),
|
||||
}).then((res) => { if (!res.ok) throw new Error('open ' + res.status); })
|
||||
.then(() => refreshPassports())
|
||||
.then(() => openBook(inst, ppKey(genre)))
|
||||
.catch(() => { /* validation/restart; rack stays */ });
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
const tabBtn = e.target.closest('[data-career-tab]');
|
||||
const instBtn = e.target.closest('[data-pp-inst]');
|
||||
const commitBtn = e.target.closest('[data-pp-commit]');
|
||||
const coverBtn = e.target.closest('[data-pp-open]');
|
||||
const brochureBtn = e.target.closest('[data-pp-genre]');
|
||||
if (tabBtn) {
|
||||
showCareerTab(tabBtn.dataset.careerTab);
|
||||
return;
|
||||
}
|
||||
if (instBtn) {
|
||||
lsSet(PP_INST_KEY, instBtn.dataset.ppInst);
|
||||
renderPassports();
|
||||
return;
|
||||
}
|
||||
if (commitBtn) {
|
||||
sealCeremony(commitBtn.dataset.ppCommit);
|
||||
return;
|
||||
}
|
||||
if (coverBtn) {
|
||||
openBook(activeInstrument(), coverBtn.dataset.ppOpen);
|
||||
return;
|
||||
}
|
||||
if (brochureBtn) {
|
||||
const inst = activeInstrument();
|
||||
const genre = brochureBtn.dataset.ppGenre;
|
||||
const committed = _pp && ((_pp.instruments || {})[inst] || {}).committed_at;
|
||||
// Opening your first passport on an instrument IS the commitment —
|
||||
// the seal ceremony runs first, then the passport opens.
|
||||
if (committed) openGenre(inst, genre);
|
||||
else sealCeremony(inst, () => openGenre(inst, genre));
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('[data-pp-close]') ||
|
||||
(e.target.dataset && e.target.dataset.ppCloseBg)) {
|
||||
closeBook();
|
||||
return;
|
||||
}
|
||||
const dlBtn = e.target.closest('[data-career-download]');
|
||||
const delBtn = e.target.closest('[data-career-delete]');
|
||||
const playBtn = e.target.closest('[data-career-play]');
|
||||
if (dlBtn) {
|
||||
fetch(`${API}/packs/${dlBtn.dataset.careerDownload}/download`, { method: 'POST' })
|
||||
.then(refresh);
|
||||
} else if (delBtn) {
|
||||
// Do NOT null _appliedManifestVenue here: pushCrowdManifest()
|
||||
// clears/replaces the crowd manifest precisely by seeing that the
|
||||
// applied venue is no longer among the installed ones.
|
||||
fetch(`${API}/packs/${delBtn.dataset.careerDelete}`, { method: 'DELETE' })
|
||||
.then(refresh);
|
||||
} else if (playBtn) {
|
||||
try {
|
||||
localStorage.setItem(VENUE_OVERRIDE_KEY, playBtn.dataset.careerPlay);
|
||||
// Selecting a venue makes the Venue visualization the default;
|
||||
// remember what the user had so Leave venue can restore it.
|
||||
const cur = localStorage.getItem('vizSelection');
|
||||
if (cur && cur !== 'venue') localStorage.setItem(PREV_VIZ_KEY, cur);
|
||||
localStorage.setItem('vizSelection', 'venue');
|
||||
if (typeof window.setViz === 'function') window.setViz('venue');
|
||||
} catch (_) { /* ok */ }
|
||||
_appliedManifestVenue = null; // force manifest re-push
|
||||
refresh();
|
||||
} else if (e.target.closest('[data-career-unselect]')) {
|
||||
try {
|
||||
localStorage.setItem(VENUE_OVERRIDE_KEY, NO_VENUE);
|
||||
const prev = localStorage.getItem(PREV_VIZ_KEY);
|
||||
if (prev) {
|
||||
localStorage.setItem('vizSelection', prev);
|
||||
if (typeof window.setViz === 'function') window.setViz(prev);
|
||||
}
|
||||
} catch (_) { /* ok */ }
|
||||
// keep _appliedManifestVenue: pushCrowdManifest clears the crowd
|
||||
// manifest precisely by seeing it is still set with no venue left
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function boot() {
|
||||
const screen = document.getElementById('plugin-career');
|
||||
if (screen) {
|
||||
screen.addEventListener('click', onClick);
|
||||
screen.addEventListener('pointermove', onTiltMove);
|
||||
screen.addEventListener('pointerleave', onTiltLeave);
|
||||
}
|
||||
const sm = window.feedBack;
|
||||
if (sm && typeof sm.on === 'function') {
|
||||
// New song stats can add stars → thresholds may cross mid-session.
|
||||
sm.on('stats:recorded', () => refresh());
|
||||
// Virtuoso's progress emits are the drill-state relay trigger; the
|
||||
// payload is a thin delta, so the relay reads the full localStorage
|
||||
// snapshot instead (see relayDrillState).
|
||||
sm.on('virtuoso:progress', relayDrillState);
|
||||
}
|
||||
showCareerTab(lsGet(PP_TAB_KEY) === 'passports' ? 'passports' : 'venues');
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && _ppBook) closeBook();
|
||||
});
|
||||
refresh();
|
||||
}
|
||||
|
||||
// Test seam (bare-vm harness, see plugins/career/tests/): pure helpers +
|
||||
// the badge-diff logic; nothing here touches the DOM.
|
||||
window.__careerPassportTest = {
|
||||
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
||||
fmtHours, ppFillFraction,
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
}());
|
||||
@@ -0,0 +1,32 @@
|
||||
<!-- 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>
|
||||
@@ -0,0 +1,152 @@
|
||||
// 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);
|
||||
});
|
||||
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.
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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.
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "drum_highway_3d",
|
||||
"name": "3D Drum Highway",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -1361,6 +1361,41 @@
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
/* ======================================================================
|
||||
* Camera Director bridge resolver (pure — exported via createFactory.__test)
|
||||
* ====================================================================== */
|
||||
|
||||
/**
|
||||
* The active splitscreen API, defensive on the global-name rename in flight
|
||||
* (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias).
|
||||
* @returns {object|null} the splitscreen API, or null when not present
|
||||
*/
|
||||
function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; }
|
||||
|
||||
/**
|
||||
* Resolve the Camera Director camera for a canvas: this panel's camera under
|
||||
* splitscreen, else the global, else null (Camera Director absent → stock
|
||||
* framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build
|
||||
* can't break framing.
|
||||
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
|
||||
* @param {object|null} ss the splitscreen API (see _ssApi)
|
||||
* @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index)
|
||||
* @param {object|null} globalCam window.__h3dCamCtl (single global camera)
|
||||
* @returns {object|null} the resolved free-camera bridge, or null
|
||||
*/
|
||||
function _resolveFreeCam(canvas, ss, panelsMap, globalCam) {
|
||||
if (panelsMap && ss && typeof ss.panelIndexFor === 'function') {
|
||||
try {
|
||||
const i = ss.panelIndexFor(canvas);
|
||||
// Only a non-negative integer indexes the panel map — a non-int /
|
||||
// negative / string index (or a prototype key) must not resolve an
|
||||
// unintended/inherited property; fall through to the global then.
|
||||
if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i];
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
return globalCam || null;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* Renderer factory
|
||||
* ====================================================================== */
|
||||
@@ -1414,7 +1449,7 @@
|
||||
let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null;
|
||||
let _fxLastWall = 0; // wall clock for FX integration (sparks, pulse decay)
|
||||
let _kickPulse = 0; // kick-hit camera-dip + floor-wash envelope
|
||||
let _camBaseH = 0, _camBaseD = 0; // positionCamera's unpulsed pose
|
||||
let _camBaseH = null, _camBaseD = null; // positionCamera's unpulsed pose (null until it first runs; applyCamera's guard depends on this)
|
||||
let _gaussTex = null; // shared soft-falloff texture for flash quads
|
||||
let _laneFlashQuads = []; // pooled additive quad per hand lane (z=0)
|
||||
let _kickFlashQuad = null; // full-width flash quad for the kick bar
|
||||
@@ -2651,6 +2686,48 @@
|
||||
cam.lookAt(0, 0, -AHEAD * TS * 0.45);
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera Director bridge for THIS panel — delegates to the pure, unit-
|
||||
* tested _resolveFreeCam / _ssApi (resolver block above the factory).
|
||||
* Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's
|
||||
* camera, else the global __h3dCamCtl, else null (stock framing).
|
||||
* @param {HTMLCanvasElement} canvas this panel's highway canvas
|
||||
* @returns {object|null} the resolved free-camera bridge, or null
|
||||
*/
|
||||
function _freeCamFor(canvas) {
|
||||
return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl);
|
||||
}
|
||||
|
||||
// Per-frame camera write: static base pose (positionCamera) + kick-pulse Y
|
||||
// dip, then layer Camera Director free-cam offsets (dolly/height/orbit on
|
||||
// the camera-from-target vector; pan/pitch on the look target). Runs every
|
||||
// frame so a live free-cam drag is smooth; allocation-free; NaN-safe; a
|
||||
// null/disabled bridge reproduces the stock static+pulse pose exactly.
|
||||
function applyCamera() {
|
||||
if (_camBaseH == null) return; // before first positionCamera()
|
||||
const _dip = (_kickPulse > 0.001) ? (0.8 * K * _kickPulse * fx.hitFx) : 0;
|
||||
let _cx = 0, _cy = _camBaseH - _dip, _cz = _camBaseD;
|
||||
let _lx = 0, _ly = 0, _lz = -AHEAD * TS * 0.45;
|
||||
const _fc = _freeCamFor(highwayCanvas);
|
||||
if (_fc && _fc.enabled) {
|
||||
const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
|
||||
const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
|
||||
const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
|
||||
let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
|
||||
_vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom)
|
||||
_vy *= _hm; // height
|
||||
const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
|
||||
const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
|
||||
_cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
|
||||
const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
|
||||
const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
|
||||
const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
|
||||
_lx += _px * K; _ly += (_pt + _py) * K;
|
||||
}
|
||||
cam.position.set(_cx, _cy, _cz);
|
||||
cam.lookAt(_lx, _ly, _lz);
|
||||
}
|
||||
|
||||
function buildLanes(_floorW, floorD) {
|
||||
laneGroup = new T.Group();
|
||||
laneStripeMats = [];
|
||||
@@ -3431,15 +3508,18 @@
|
||||
BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000);
|
||||
} catch (_) { /* visual-only */ }
|
||||
}
|
||||
// Kick pulse decays each frame; it drives the floor flash and,
|
||||
// via applyCamera(), the camera Y dip.
|
||||
if (_kickPulse > 0.001) {
|
||||
_kickPulse *= Math.exp(-fdt * 7);
|
||||
cam.position.y = _camBaseH - 0.8 * K * _kickPulse * fx.hitFx;
|
||||
if (_floorFlash) _floorFlash.material.opacity = 0.25 * _kickPulse * fx.hitFx;
|
||||
} else if (_kickPulse !== 0) {
|
||||
_kickPulse = 0;
|
||||
cam.position.y = _camBaseH;
|
||||
if (_floorFlash) _floorFlash.material.opacity = 0;
|
||||
}
|
||||
// Write the camera every frame: static base pose + kick dip +
|
||||
// Camera Director free-cam offsets (per-panel-aware).
|
||||
applyCamera();
|
||||
}
|
||||
// Approach highlight: raise each lane stripe toward its next
|
||||
// note (accumulated by the rebuildNotes walk above).
|
||||
@@ -3565,6 +3645,8 @@
|
||||
// vm-loaded with no DOM/WebGL; everything here must stay side-effect
|
||||
// free to call).
|
||||
window.slopsmithViz_drum_highway_3d.__test = {
|
||||
_resolveFreeCam,
|
||||
_ssApi,
|
||||
_variantForHit,
|
||||
_classifyTiming,
|
||||
readFxSettings,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Camera Director bridge resolver tests: per-panel select, global fallback,
|
||||
// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads
|
||||
// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL).
|
||||
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,
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
slopsmith: {},
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const context = vm.createContext(window);
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'screen.js' });
|
||||
return { window, __test: window.slopsmithViz_drum_highway_3d.__test };
|
||||
}
|
||||
|
||||
test('_resolveFreeCam: per-panel camera under splitscreen', () => {
|
||||
const { __test } = load();
|
||||
const c0 = {}, c1 = {};
|
||||
const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) };
|
||||
const map = { 0: { id: 'p0' }, 1: { id: 'p1' } };
|
||||
assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0');
|
||||
assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1');
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when there is no panel map', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when the panel has no map entry', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => 3 }; // index 3 absent from map
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: null when Camera Director is absent (no global)', () => {
|
||||
const { __test } = load();
|
||||
assert.equal(__test._resolveFreeCam({}, null, null, null), null);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => { throw new Error('boom'); } };
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g);
|
||||
// A string/prototype key must not resolve an inherited property (e.g. toString).
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g);
|
||||
});
|
||||
|
||||
test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => {
|
||||
const { window, __test } = load();
|
||||
assert.equal(__test._ssApi(), null);
|
||||
const legacy = { panelIndexFor: () => 0 };
|
||||
window.slopsmithSplitscreen = legacy;
|
||||
assert.equal(__test._ssApi(), legacy); // legacy alias picked up
|
||||
const current = { panelIndexFor: () => 1 };
|
||||
window.feedBackSplitscreen = current;
|
||||
assert.equal(__test._ssApi(), current); // canonical name takes precedence
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.31.2",
|
||||
"version": "3.31.5",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
+254
-17
@@ -548,9 +548,20 @@
|
||||
ctrl.ownsActx = !(fogAudio && fogAudio.ctx);
|
||||
ctrl.actx = (fogAudio && fogAudio.ctx) || new Ctx();
|
||||
if (ctrl.actx.state === 'suspended' && ctrl.actx.resume) ctrl.actx.resume().catch(() => {});
|
||||
// Seed the DRAWING BUFFER (canvas.width/height) to the device-pixel
|
||||
// render size and report that SAME size to Butterchurn. Its on-screen
|
||||
// pass viewports to the reported size but never sizes the output canvas
|
||||
// itself — leaving the buffer at the 300x150 default blits the whole
|
||||
// visualizer into a corner that CSS then stretches across the highway.
|
||||
// pixelRatio:1 because DPR is now folded into the reported size, so
|
||||
// buffer == viewport == internal texsize (no double-counting).
|
||||
const _bcRatio0 = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
const _bcW0 = Math.max(1, Math.round((sz.w || 1280) * _bcRatio0));
|
||||
const _bcH0 = Math.max(1, Math.round((sz.h || 720) * _bcRatio0));
|
||||
canvas.width = _bcW0; canvas.height = _bcH0;
|
||||
ctrl.viz = bc.createVisualizer(ctrl.actx, canvas, {
|
||||
width: sz.w || 1280, height: sz.h || 720,
|
||||
pixelRatio: Math.min(window.devicePixelRatio || 1, 1.5), textureRatio: 1,
|
||||
width: _bcW0, height: _bcH0,
|
||||
pixelRatio: 1, textureRatio: 1,
|
||||
});
|
||||
if (_bcIsDesktop()) {
|
||||
try {
|
||||
@@ -584,6 +595,27 @@
|
||||
ctrl.actx = null; ctrl.viz = null; ctrl.dead = true;
|
||||
_bcControllers.delete(ctrl);
|
||||
});
|
||||
// Size the Butterchurn output: set the canvas DRAWING BUFFER to the
|
||||
// device-pixel render size AND report that same size, so buffer ==
|
||||
// on-screen viewport == full fill. Butterchurn never sizes the output
|
||||
// canvas itself; the previous code set only CSS size, leaving the buffer
|
||||
// at the 300x150 default -> the viz showed a stretched lower-left corner
|
||||
// (worse the larger the panel). Ratio reuses the highway's DPR budget.
|
||||
function _bcApplySize(cssW, cssH) {
|
||||
if (!(cssW > 0 && cssH > 0)) return;
|
||||
ctrl.lastW = cssW; ctrl.lastH = cssH;
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
const bw = Math.max(1, Math.round(cssW * ratio)), bh = Math.max(1, Math.round(cssH * ratio));
|
||||
if (canvas.width !== bw) canvas.width = bw;
|
||||
if (canvas.height !== bh) canvas.height = bh;
|
||||
const wpx = cssW + 'px', hpx = cssH + 'px';
|
||||
// Confine ALL layers to exactly the highway-canvas rect so the opaque
|
||||
// backdrop can't bleed over the transport bar above the highway.
|
||||
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
|
||||
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
|
||||
});
|
||||
if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(bw, bh); } catch (e) {} }
|
||||
}
|
||||
return {
|
||||
applySettings() { ctrl.applySettings(); },
|
||||
dead() { return ctrl.dead; },
|
||||
@@ -612,18 +644,11 @@
|
||||
if (!ctrl.viz || !s.enabled) return; // skip GPU work when the bg is off
|
||||
const sz = sizeProvider && sizeProvider();
|
||||
if (sz && sz.w > 0 && sz.h > 0 && (sz.w !== ctrl.lastW || sz.h !== ctrl.lastH)) {
|
||||
ctrl.lastW = sz.w; ctrl.lastH = sz.h;
|
||||
const wpx = sz.w + 'px', hpx = sz.h + 'px';
|
||||
// Confine ALL layers to exactly the highway-canvas rect so the opaque
|
||||
// backdrop can't bleed over the transport bar above the highway.
|
||||
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
|
||||
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
|
||||
});
|
||||
try { ctrl.viz.setRendererSize(sz.w, sz.h); } catch (e) {}
|
||||
_bcApplySize(sz.w, sz.h);
|
||||
}
|
||||
try { ctrl.viz.render(); } catch (e) {}
|
||||
},
|
||||
resize(w, h) { if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(w, h); } catch (e) {} ctrl.lastW = w; ctrl.lastH = h; } },
|
||||
resize(w, h) { _bcApplySize(w, h); },
|
||||
destroy() {
|
||||
ctrl.dead = true;
|
||||
_bcControllers.delete(ctrl);
|
||||
@@ -2393,6 +2418,13 @@
|
||||
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();
|
||||
@@ -2595,10 +2627,51 @@
|
||||
}
|
||||
const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all'];
|
||||
|
||||
/**
|
||||
* localStorage panel key for per-panel background settings ('main' or
|
||||
* 'panel<index>'). Defensive on the splitscreen global-name rename in flight,
|
||||
* and throw-safe on panelIndexFor — same as _freeCamFor — so a misbehaving
|
||||
* splitscreen build can't take down background-settings resolution. Only a
|
||||
* non-negative integer index yields a 'panel<N>' key; anything else (null,
|
||||
* NaN, negative, non-integer) falls back to 'main' so a bad index can never
|
||||
* mint a bogus "panelNaN"-style key.
|
||||
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
|
||||
* @returns {string} 'main' or 'panel<index>'
|
||||
*/
|
||||
function _bgPanelKey(canvas) {
|
||||
const ss = window.feedBackSplitscreen;
|
||||
const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null;
|
||||
return (idx == null) ? 'main' : 'panel' + idx;
|
||||
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
|
||||
let idx = null;
|
||||
if (ss && typeof ss.panelIndexFor === 'function') {
|
||||
try { idx = ss.panelIndexFor(canvas); } catch (e) { idx = null; }
|
||||
}
|
||||
return (Number.isInteger(idx) && idx >= 0) ? 'panel' + idx : 'main';
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera Director bridge resolver. Prefers THIS panel's per-panel camera under
|
||||
* splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back to the
|
||||
* single global (window.__h3dCamCtl); returns null when Camera Director is
|
||||
* absent → 100% stock framing. Defensive on the splitscreen global-name rename
|
||||
* in flight (feedBackSplitscreen vs slopsmithSplitscreen); throw-safe on
|
||||
* panelIndexFor. Mirrors the panel resolution in _bgPanelKey.
|
||||
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
|
||||
* @returns {object|null} the resolved free-camera bridge, or null
|
||||
*/
|
||||
function _freeCamFor(canvas) {
|
||||
const map = window.__h3dCamCtlPanels;
|
||||
if (map) {
|
||||
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
|
||||
if (ss && typeof ss.panelIndexFor === 'function') {
|
||||
try {
|
||||
const i = ss.panelIndexFor(canvas);
|
||||
// Only a non-negative integer indexes the map (same hardening
|
||||
// as _bgPanelKey) — a non-int / negative / string index must not
|
||||
// resolve an unintended/inherited property; fall through then.
|
||||
if (Number.isInteger(i) && i >= 0 && map[i]) return map[i];
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return window.__h3dCamCtl || null;
|
||||
}
|
||||
// In-memory fallback for when localStorage is blocked (private mode,
|
||||
// sandboxed iframes, some test runners). _bgWriteGlobal stages the
|
||||
@@ -2843,6 +2916,20 @@
|
||||
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;
|
||||
@@ -3305,6 +3392,40 @@
|
||||
() => _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,
|
||||
@@ -3336,6 +3457,64 @@
|
||||
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;
|
||||
@@ -3350,6 +3529,19 @@
|
||||
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).
|
||||
@@ -3747,6 +3939,15 @@
|
||||
// ── Per-instance Three.js state ───────────────────────────────────
|
||||
let scene = null, cam = null, ren = null;
|
||||
let wrap = null;
|
||||
// WebGL context-loss recovery. Switching the active window / alt-tabbing
|
||||
// (especially on Windows) can trigger a GPU context reset; with no
|
||||
// handler the lost context escalates into a render-process crash. The
|
||||
// listeners (bound in initScene on ren.domElement, removed in teardown)
|
||||
// preventDefault the loss so the browser keeps the context restorable,
|
||||
// _ctxLost gates draw() off the dead context, and on restore we reset the
|
||||
// viewport + resume (Three re-uploads scene resources on the next render).
|
||||
let _ctxLost = false;
|
||||
let _onCtxLost = null, _onCtxRestored = null;
|
||||
let bcCtrl = null; // Butterchurn audio-reactive background (the 'butterchurn' bg-style)
|
||||
let _chartEnv = 0, _chartPrevT = -1, _bcBeatIdx = 0, _bcNoteIdx = 0, _bcChordIdx = 0, _bcTintTarget = null;
|
||||
let _tintR = 20, _tintG = 24, _tintB = 40; // smoothed instrument-color tint for the bg
|
||||
@@ -6526,6 +6727,26 @@
|
||||
ren.setClearColor(0x101820, _bcActive() ? 0 : 1);
|
||||
wrap.appendChild(ren.domElement);
|
||||
|
||||
// WebGL context-loss recovery (see the _ctxLost declaration). Bound
|
||||
// on Three's own canvas — the context that actually resets on a GPU
|
||||
// reset / alt-tab. preventDefault() keeps the context restorable
|
||||
// instead of letting the loss escalate to a render-process crash;
|
||||
// _ctxLost then makes draw() bail so no GL work runs on the dead
|
||||
// context; on restore we reset the viewport and resume (Three
|
||||
// re-uploads geometry/materials/textures lazily on the next render).
|
||||
_onCtxLost = (e) => {
|
||||
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
||||
_ctxLost = true;
|
||||
console.warn('[3D-Hwy] WebGL context lost — pausing render until it is restored.');
|
||||
};
|
||||
_onCtxRestored = () => {
|
||||
_ctxLost = false;
|
||||
console.warn('[3D-Hwy] WebGL context restored — resuming render.');
|
||||
try { const s = canvasSize(highwayCanvas); if (s.w > 0 && s.h > 0) applySize(s.w, s.h); } catch (err) {}
|
||||
};
|
||||
ren.domElement.addEventListener('webglcontextlost', _onCtxLost, false);
|
||||
ren.domElement.addEventListener('webglcontextrestored', _onCtxRestored, false);
|
||||
|
||||
lyricsCanvas = document.createElement('canvas');
|
||||
lyricsCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:1;';
|
||||
lyricsCtx = lyricsCanvas.getContext('2d');
|
||||
@@ -14635,7 +14856,10 @@
|
||||
// suppressed while the Camera Director owns the view (it wins).
|
||||
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
|
||||
? _tune.startAspect : HORPLUS_START_ASPECT;
|
||||
const _dirActive = !!(window.__h3dCamCtl && window.__h3dCamCtl.enabled);
|
||||
// Resolve the Camera Director bridge once (per-panel under splitscreen,
|
||||
// else global). Used both for the wide-pane gate and the transforms below.
|
||||
const _freeCam = _freeCamFor(highwayCanvas);
|
||||
const _dirActive = !!(_freeCam && _freeCam.enabled);
|
||||
const _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive;
|
||||
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
|
||||
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
|
||||
@@ -14662,13 +14886,16 @@
|
||||
if (_poseHMul !== 1) _camY *= _poseHMul;
|
||||
if (_poseDMul !== 1) _camZ *= _poseDMul;
|
||||
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
|
||||
// Driven by the Camera Director plugin via window.__h3dCamCtl.
|
||||
// Driven by the Camera Director plugin via the camera bridge:
|
||||
// window.__h3dCamCtlPanels[panelIndexFor(canvas)] when split (this
|
||||
// panel's own camera), falling back to the global window.__h3dCamCtl.
|
||||
// Layered ON TOP of the auto-framing so note tracking still works.
|
||||
// The bridge is read once into _freeCam and reused for both the
|
||||
// position and the look-at transforms; every field is coerced to a
|
||||
// finite number before use so a malformed object can never feed NaN
|
||||
// into cam.position / cam.lookAt.
|
||||
const _freeCam = window.__h3dCamCtl;
|
||||
// _freeCam resolved above via _freeCamFor(highwayCanvas): the
|
||||
// per-panel __h3dCamCtlPanels entry, else global __h3dCamCtl, else null.
|
||||
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
|
||||
if (_freeCam && _freeCam.enabled) {
|
||||
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
|
||||
@@ -14829,6 +15056,15 @@
|
||||
// mid-teardown settings change doesn't try to rebuild a torn-
|
||||
// down scene; then dispose the active style's resources.
|
||||
if (_bgListener) { _bgUnsubscribe(_bgListener); _bgListener = null; }
|
||||
// WebGL context-loss listeners (bound in initScene on ren.domElement).
|
||||
// Remove before ren is disposed below so a torn-down instance can't
|
||||
// keep firing them; reset the flag so a reused instance starts clean.
|
||||
if (ren && ren.domElement) {
|
||||
if (_onCtxLost) { try { ren.domElement.removeEventListener('webglcontextlost', _onCtxLost, false); } catch (e) {} }
|
||||
if (_onCtxRestored) { try { ren.domElement.removeEventListener('webglcontextrestored', _onCtxRestored, false); } catch (e) {} }
|
||||
}
|
||||
_onCtxLost = _onCtxRestored = null;
|
||||
_ctxLost = false;
|
||||
// Notedetect listeners (issue #9). Remove on destroy so a
|
||||
// panel that stops doesn't keep accumulating marks. Marks
|
||||
// arrays are cleared too — they hold stale chart positions
|
||||
@@ -15154,6 +15390,7 @@
|
||||
|
||||
draw(bundle) {
|
||||
if (!_isReady) return;
|
||||
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
|
||||
if (!_chartPrewarmed) {
|
||||
_chartPrewarmed = true;
|
||||
_prewarmChart(bundle);
|
||||
|
||||
@@ -3,12 +3,34 @@
|
||||
RS+-style falling-note 3D piano highway for [Slopsmith](https://github.com/got-feedback/feedback), fed by the **Sloppak Notation Format** (sloppak-spec §5.3) — part of the piano/keys first-class epic (slopsmith#828, plugin workstream slopsmith#824).
|
||||
|
||||
- Consumes the `notation_info` / `notation_measures` highway-WS stream over a private per-instance socket and flattens measure → staff → voice → beat → note into `{midi, t, durSec, hand}` (durations derived from written `dur`/`dot`/`tu` at the running tempo; ties extend; overlap-clamped).
|
||||
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colours** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue.
|
||||
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colors** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue. Selectable **note-color palettes** (settings → Note colors, `keys3d_bg_palette`, default the per-octave scheme): a per-octave rainbow (each octave its own hue, darker sharps), the original per-pitch "Rainbow" table, vivid/pastel per-pitch variants, and single-hue two-tone palettes (uniform naturals, darker sharps) for players who want "black key coming" to read at a glance; notes, key glow, lane guides and hit flames all follow the pick live.
|
||||
- Full RS+ visual treatment: key **letter glyphs** printed on the active-range key tops (cached CanvasTextures), **bevelled gem-style note blocks** (ExtrudeGeometry, geometry/material caches keyed by size and pitch-class×hand), **floating bar numbers** scrolling with the notes, **active-range lane dimming** so the playable span pops, and a **glowing pulsing hit-line** (layered additive gradient planes — no postprocessing).
|
||||
- Performance discipline: no per-frame allocations or DOM queries in `draw()`. Chart-scoped resources — note geometries/materials, bar-number and glow textures — are cached and disposed on chart teardown; the key-letter glyph `CanvasTexture`s live in a shared module-level cache that survives teardown and is reused across instances.
|
||||
- Auto-selected for arrangements with notation via `matchesArrangement(songInfo.has_notation)`; capability-native `visualization` provider declaration.
|
||||
- **Camera settings**: camera-rig presets (`keys3d_bg_camera` — classic low rig / elevated / overhead; default overhead, applied live, adaptive pan-zoom preserved) with base-rig fine-tune sliders for height, distance and tilt (`keys3d_bg_camHeight` / `camDist` / `camTilt`) that nudge the vantage point the follow-motion orbits. Numeric FX keys clamp to per-key declared ranges (`FX_RANGES`, default 0–1).
|
||||
- **Highway-layout options** (settings → Highway layout). **Sharps & flats**
|
||||
(`keys3d_bg_sharpMode`, string; default `realistic`) picks the sharp layout:
|
||||
`floating` (original raised-plane sharps, white-only lanes); `flat` (one plane,
|
||||
zero-overlap piano-shaped tiled lanes — white lanes trimmed where a sharp adjoins
|
||||
them, and each sharp leaned toward the edge natural beside it so the naturals come
|
||||
out close to even: C/D/E/F/B equal, G/A a hair smaller since G# can't lean; pure
|
||||
`laneSpanFlat()`); `realistic` (one plane, bars sized like the physical keys — full
|
||||
naturals always rendered full, full black keys drawn on top and only occluding a
|
||||
natural where a sharp note actually coincides in time; pure `laneSpanReal()`).
|
||||
**Lane color opacity** (`keys3d_bg_laneOpacity`, 0–1, default 0) fades the
|
||||
pitch-class lane tint; at 0 (default) the strips are a dark floor with guide lines
|
||||
only at the key-block boundaries (E→F and each octave B→C), so each block is bounded
|
||||
rather than every lane — the notes keep their colors; toward 1 it fills in full,
|
||||
vivid colored lanes. The strips, per-lane separators and block lines crossfade with
|
||||
this value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) widens the
|
||||
gap a touch at each B→C octave boundary. **Octave line contrast**
|
||||
(`keys3d_bg_octaveContrast`, 0–1, default 0.5) scales how hard the B→C octave line
|
||||
reads; it is drawn as a dark layer (scaled by lane opacity) plus a bright layer
|
||||
(scaled by its inverse), so it auto-shifts dark→bright as the lanes fade — no mode
|
||||
switch needed. All are geometry-time — applied on the next chart build via
|
||||
`init()`'s re-read.
|
||||
- **Web MIDI input scoring**: module-level MIDI singleton (one access per tab, focused-instance routing) with device auto-connect by saved id+name, loopback blocklist, channel filter, transpose and CC64 sustain (`keys3d_` localStorage prefix; `window.keysH3d*` settings API). Hit detection matches played MIDI against the flattened chart notes within ±0.10 s with per-note dedupe and a missed-note sweep (only while a device is connected — never retroactive across a mid-song connect).
|
||||
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class colour, ~400 ms).
|
||||
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class color, ~400 ms).
|
||||
- **End-of-run stats**: POSTs `/api/stats` `{filename, arrangement, score, accuracy}` exactly once per run with the same formula as the guitar notedetect path (`accuracy = hits / max(1, hits+misses)`, `score = round(hits·100·accuracy)`), then notifies the progression core when present.
|
||||
- **Capability wiring** (all guarded for servers without the hosts): registers as a note-detection `midi` provider (`keys-midi`, `verify.target`), opens a per-song binding scoped to the chart's keys range, reports hit/miss observability events, and exposes Web MIDI inputs to the audio-input domain with pseudonymized labels (`midi-input-1`, …) via `source.enumerate/describe/open/close`.
|
||||
- Headless test hook: `window.__keysHwTest = { injectNoteOn(midi, when), getScore() }`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "keys_highway_3d",
|
||||
"name": "Keys Highway 3D",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.1",
|
||||
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
|
||||
+750
-104
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,22 @@
|
||||
<div class="mt-3">
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
|
||||
|
||||
<label for="keysh3d-fx-palette" class="text-xs font-medium text-gray-400 mb-1 block">Note colors</label>
|
||||
<select id="keysh3d-fx-palette"
|
||||
onchange="window.keys3dSetPalette && window.keys3dSetPalette(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="octaves" selected>Octaves (color per octave, darker sharps)</option>
|
||||
<option value="emerald">Emerald (green, darker sharps)</option>
|
||||
<option value="ice">Ice (blue, darker sharps)</option>
|
||||
<option value="classic">Rainbow (per-pitch)</option>
|
||||
<option value="vivid">Vivid (per-pitch, punchier)</option>
|
||||
<option value="pastel">Pastel (per-pitch, soft)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Choose the color scheme for the falling notes, key glow, lane
|
||||
guides and hit flames. Each option is described in its own label.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-theme" class="text-xs font-medium text-gray-400 mb-1 block">Scene theme</label>
|
||||
<select id="keysh3d-fx-theme"
|
||||
onchange="window.keys3dSetTheme && window.keys3dSetTheme(this.value)"
|
||||
@@ -30,7 +46,117 @@
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Background gradient, floor and lane rails — the same theme names
|
||||
as the guitar highway. Pitch-class note colours never change.
|
||||
as the guitar highway. Note colors come from the
|
||||
"Note colors" palette above.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camera" class="text-xs font-medium text-gray-400 mb-1 block">Camera angle</label>
|
||||
<select id="keysh3d-fx-camera"
|
||||
onchange="window.keys3dSetCamera && window.keys3dSetCamera(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="classic">Classic (low, deep runway)</option>
|
||||
<option value="elevated">Elevated (higher, more board)</option>
|
||||
<option value="overhead" selected>Overhead (top-down reading view)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Where the camera sits. Classic is the original low rig; Elevated
|
||||
lifts it for a fuller view of the keybed; Overhead looks down the
|
||||
lanes for a sheet-reading feel. Applies live, keeps the
|
||||
auto-pan/zoom that follows your hands.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camheight" class="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Camera height <span id="keysh3d-fx-camheight-val" class="text-gray-500 font-mono">1.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camheight"
|
||||
min="0.4" max="2.2" step="0.02" value="1"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camHeight', this.value); document.getElementById('keysh3d-fx-camheight-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Raise or lower the camera around the angle above (higher = more
|
||||
top-down). Fine-tunes the base view; the follow-motion stays.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camdist" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Camera distance <span id="keysh3d-fx-camdist-val" class="text-gray-500 font-mono">1.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camdist"
|
||||
min="0.4" max="2.2" step="0.02" value="1"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camDist', this.value); document.getElementById('keysh3d-fx-camdist-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Pull the camera back or push it in (larger = further away, smaller
|
||||
= closer).
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camtilt" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Camera tilt <span id="keysh3d-fx-camtilt-val" class="text-gray-500 font-mono">0.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camtilt"
|
||||
min="-1" max="1" step="0.02" value="0"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camTilt', this.value); document.getElementById('keysh3d-fx-camtilt-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Tilt the view up (+) or down (−) without moving the camera —
|
||||
aims higher up the runway or down toward the keys. 0 = neutral.
|
||||
</p>
|
||||
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2 mt-4">Highway layout</h4>
|
||||
|
||||
<label for="keysh3d-fx-sharpmode" class="text-xs font-medium text-gray-400 mb-1 block">Sharps & flats</label>
|
||||
<select id="keysh3d-fx-sharpmode"
|
||||
onchange="window.keys3dSetSharpMode && window.keys3dSetSharpMode(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="floating">Floating</option>
|
||||
<option value="flat">Non-floating</option>
|
||||
<option value="realistic" selected>Realistic key sizes (default — best with no colored lanes)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
How sharps and flats are drawn. <em>Floating</em>: they ride a raised
|
||||
plane above the naturals. <em>Non-floating</em>: everything on one
|
||||
plane, each key its own even piano-shaped lane. <em>Realistic key
|
||||
sizes</em>: one plane, bars sized like the real keys (full naturals,
|
||||
full black keys on top). Applies next time you open a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-laneopacity" class="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Lane color opacity <span id="keysh3d-fx-laneopacity-val" class="text-gray-500 font-mono">0.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-laneopacity"
|
||||
min="0" max="1" step="0.05" value="0"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('laneOpacity', this.value); document.getElementById('keysh3d-fx-laneopacity-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
How strongly each lane is tinted its note color. 0.00 (default) is a
|
||||
dark floor with plain guide lines only between the key blocks (at E–F
|
||||
and each octave); the notes keep their colors and pop off the floor.
|
||||
Raise toward 1.00 for full, vivid colored lanes. Applies next time you
|
||||
open a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-octavegaps" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-octavegaps" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('octaveGaps', this.checked)">
|
||||
Octave separators
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Widen the gap a little at each octave boundary (every B to the C
|
||||
above it) so octaves are easier to read. Applies next time you open
|
||||
a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-octavecontrast" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Octave line contrast <span id="keysh3d-fx-octavecontrast-val" class="text-gray-500 font-mono">0.50</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-octavecontrast"
|
||||
min="0" max="1" step="0.05" value="0.5"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('octaveContrast', this.value); document.getElementById('keysh3d-fx-octavecontrast-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
How strongly the octave line (every B to C) stands out. It adapts to
|
||||
the lane color opacity automatically — darkening the line against
|
||||
bright lanes and brightening it as you fade them toward the dark
|
||||
floor. Applies next time you open a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||||
@@ -116,7 +242,7 @@
|
||||
<label for="keysh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-timing" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('timingFx', this.checked)">
|
||||
Timing colours
|
||||
Timing colors
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Tint the sparks by timing — on-time green, early cyan, late
|
||||
@@ -179,6 +305,9 @@
|
||||
hydrateFxBool('cinematic', 'keysh3d-fx-cinematic');
|
||||
hydrateFxBool('bgReactive', 'keysh3d-fx-bgreactive');
|
||||
hydrateFxBool('scoreFx', 'keysh3d-fx-scorefx');
|
||||
// Highway-layout: octaveGaps defaults ON (bool); laneOpacity /
|
||||
// octaveContrast are 0-1 sliders hydrated with hydrateFxRange below.
|
||||
hydrateFxBool('octaveGaps', 'keysh3d-fx-octavegaps');
|
||||
const hydrateFxRange = (key, elId, valId) => {
|
||||
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
|
||||
if (!Number.isFinite(n)) return;
|
||||
@@ -190,6 +319,26 @@
|
||||
hydrateFxRange('vibrancy', 'keysh3d-fx-vibrancy', 'keysh3d-fx-vibrancy-val');
|
||||
hydrateFxRange('glow', 'keysh3d-fx-glow', 'keysh3d-fx-glow-val');
|
||||
hydrateFxRange('bgIntensity', 'keysh3d-fx-bgintensity', 'keysh3d-fx-bgintensity-val');
|
||||
hydrateFxRange('laneOpacity', 'keysh3d-fx-laneopacity', 'keysh3d-fx-laneopacity-val');
|
||||
hydrateFxRange('octaveContrast', 'keysh3d-fx-octavecontrast', 'keysh3d-fx-octavecontrast-val');
|
||||
// Camera fine-tune sliders live outside 0-1 — clamp to the
|
||||
// control's own min/max (mirrors screen.js FX_RANGES).
|
||||
const hydrateFxRangeIn = (key, elId, valId) => {
|
||||
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
|
||||
if (!Number.isFinite(n)) return;
|
||||
const el = document.getElementById(elId);
|
||||
const v = Math.min(parseFloat(el.max), Math.max(parseFloat(el.min), n));
|
||||
el.value = String(v);
|
||||
document.getElementById(valId).textContent = v.toFixed(2);
|
||||
};
|
||||
hydrateFxRangeIn('camHeight', 'keysh3d-fx-camheight', 'keysh3d-fx-camheight-val');
|
||||
hydrateFxRangeIn('camDist', 'keysh3d-fx-camdist', 'keysh3d-fx-camdist-val');
|
||||
hydrateFxRangeIn('camTilt', 'keysh3d-fx-camtilt', 'keysh3d-fx-camtilt-val');
|
||||
const storedCamera = localStorage.getItem('keys3d_bg_camera');
|
||||
const cameraSel = document.getElementById('keysh3d-fx-camera');
|
||||
if (storedCamera && Array.from(cameraSel.options).some(o => o.value === storedCamera)) {
|
||||
cameraSel.value = storedCamera;
|
||||
}
|
||||
const storedStyle = localStorage.getItem('keys3d_bg_style');
|
||||
const styleSel = document.getElementById('keysh3d-fx-bgstyle');
|
||||
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
|
||||
@@ -200,6 +349,16 @@
|
||||
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
|
||||
themeSel.value = storedTheme;
|
||||
}
|
||||
const storedPalette = localStorage.getItem('keys3d_bg_palette');
|
||||
const paletteSel = document.getElementById('keysh3d-fx-palette');
|
||||
if (storedPalette && Array.from(paletteSel.options).some(o => o.value === storedPalette)) {
|
||||
paletteSel.value = storedPalette;
|
||||
}
|
||||
const storedSharp = localStorage.getItem('keys3d_bg_sharpMode');
|
||||
const sharpSel = document.getElementById('keysh3d-fx-sharpmode');
|
||||
if (storedSharp && Array.from(sharpSel.options).some(o => o.value === storedSharp)) {
|
||||
sharpSel.value = storedSharp;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Camera Director bridge resolver tests: per-panel select, global fallback,
|
||||
// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads
|
||||
// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL).
|
||||
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,
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
slopsmith: {},
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const context = vm.createContext(window);
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'screen.js' });
|
||||
return { window, __test: window.slopsmithViz_keys_highway_3d.__test };
|
||||
}
|
||||
|
||||
test('_resolveFreeCam: per-panel camera under splitscreen', () => {
|
||||
const { __test } = load();
|
||||
const c0 = {}, c1 = {};
|
||||
const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) };
|
||||
const map = { 0: { id: 'p0' }, 1: { id: 'p1' } };
|
||||
assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0');
|
||||
assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1');
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when there is no panel map', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when the panel has no map entry', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => 3 }; // index 3 absent from map
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: null when Camera Director is absent (no global)', () => {
|
||||
const { __test } = load();
|
||||
assert.equal(__test._resolveFreeCam({}, null, null, null), null);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => { throw new Error('boom'); } };
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g);
|
||||
// A string/prototype key must not resolve an inherited property (e.g. toString).
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g);
|
||||
});
|
||||
|
||||
test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => {
|
||||
const { window, __test } = load();
|
||||
assert.equal(__test._ssApi(), null);
|
||||
const legacy = { panelIndexFor: () => 0 };
|
||||
window.slopsmithSplitscreen = legacy;
|
||||
assert.equal(__test._ssApi(), legacy); // legacy alias picked up
|
||||
const current = { panelIndexFor: () => 1 };
|
||||
window.feedBackSplitscreen = current;
|
||||
assert.equal(__test._ssApi(), current); // canonical name takes precedence
|
||||
});
|
||||
@@ -188,3 +188,99 @@ test('measureMarkers extracts idx/t pairs', () => {
|
||||
[{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }],
|
||||
);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: no plugin-local pick defers to the domain-wide selection, not "first device"', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
// Fresh install / never picked here — must use the Input Setup global,
|
||||
// NOT fall through to inputs[0].
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::b', true);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: the domain-wide selection is the source of truth — it wins over a stale plugin-local pick', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
// A stale local pick (e.g. left by a pre-fix build's auto-connect) must
|
||||
// NOT override the device the user configured in Settings → Input Setup.
|
||||
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, 'web-midi::b', true);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: local pick is used as a fallback when no global is configured', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, null, true);
|
||||
assert.equal(target.id, 'a');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: local pick name-recovers when its logicalSourceKey went stale (id regeneration)', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
// Same physical device, new id/key across a reload; the saved key/id miss
|
||||
// but the name still matches.
|
||||
const inputs = [{ id: 'a2', name: 'Device A', key: 'web-midi::a2' }];
|
||||
const target = _pickMidiTarget(inputs, { id: 'a1', name: 'Device A', key: 'web-midi::a1' }, null, true);
|
||||
assert.equal(target.id, 'a2');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: domain-wide selection is ignored if it names a blocklisted loopback port', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'thru', name: 'IAC Driver Bus 1', key: 'web-midi::thru' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::thru', true);
|
||||
assert.equal(target.id, 'b'); // falls through to the first non-loopback device
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: when every present device is a loopback, connect to nothing (never a dead port)', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'thru', name: 'MIDI Through Port-0', key: 'web-midi::thru' },
|
||||
{ id: 'iac', name: 'IAC Driver Bus 1', key: 'web-midi::iac' },
|
||||
];
|
||||
// No non-loopback device exists — must NOT fall back to inputs[0] (a port
|
||||
// that carries no input and would silently eat every note).
|
||||
const target = _pickMidiTarget(inputs, null, null, true);
|
||||
assert.equal(target, null);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: explicit "None" opt-out still wins over any global default', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'a', name: 'Device A', key: 'web-midi::a' }];
|
||||
const target = _pickMidiTarget(inputs, { id: '', name: '' }, 'web-midi::a', true);
|
||||
assert.equal(target, null);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: a present global wins even during hotplug recovery', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
|
||||
// The configured global device is present — reconnect to it, don't bail.
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::b', false);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: recovery (allowFallback=false) preserves an absent configured device instead of grabbing a random one', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
|
||||
// The configured device ('x', global) is currently unplugged; a transient
|
||||
// recovery must NOT switch to the unrelated device that is present.
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::x', false);
|
||||
assert.equal(target, null);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: recovery with no preference at all still allows a first-hotplug grab', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
|
||||
const target = _pickMidiTarget(inputs, null, null, false);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
@@ -148,3 +148,405 @@ test('FX defaults: ambience + score FX ship enabled', () => {
|
||||
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
|
||||
assert.equal(FX_DEFAULTS.bgReactive, true);
|
||||
});
|
||||
|
||||
/* ── Note-colour palettes (feat/keys3d-note-palettes) ────────────────── */
|
||||
|
||||
test('note palettes: 12 entries each, classic IS the stock table', () => {
|
||||
const { NOTE_PALETTES, PITCH_CLASS_COLORS } =
|
||||
load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(Object.keys(NOTE_PALETTES),
|
||||
['classic', 'emerald', 'vivid', 'pastel', 'ice']);
|
||||
for (const [id, colors] of Object.entries(NOTE_PALETTES)) {
|
||||
assert.equal(colors.length, 12, id + ' has one colour per pitch class');
|
||||
for (const c of colors) {
|
||||
assert.ok(Number.isInteger(c) && c >= 0 && c <= 0xffffff,
|
||||
id + ' colours are 24-bit ints');
|
||||
}
|
||||
}
|
||||
// 'classic' preserves the shipped look byte-identically — it is the
|
||||
// same array, not a copy that could drift.
|
||||
assert.equal(NOTE_PALETTES.classic, PITCH_CLASS_COLORS);
|
||||
assert.equal(PITCH_CLASS_COLORS[0], 0xff3030); // C stays red in classic
|
||||
});
|
||||
|
||||
test('note palettes: two-tone tables use darker sharps than naturals', () => {
|
||||
const { NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const luma = (c) =>
|
||||
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
|
||||
for (const id of ['emerald', 'ice']) {
|
||||
const p = NOTE_PALETTES[id];
|
||||
for (const sharp of [1, 3, 6, 8, 10]) {
|
||||
assert.ok(luma(p[sharp]) < luma(p[0]),
|
||||
id + ' sharp pc ' + sharp + ' darker than naturals');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('readPaletteSetting: octaves default, validated overrides only', () => {
|
||||
// No localStorage in the vm → the plug-and-play default.
|
||||
const bare = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(bare.readPaletteSetting(), 'octaves');
|
||||
// An explicit non-default value (classic) overrides.
|
||||
const store = { keys3d_bg_palette: 'classic' };
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
});
|
||||
const { readPaletteSetting } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(readPaletteSetting(), 'classic');
|
||||
// Corrupt/foreign value → the default rather than an undefined scheme.
|
||||
store.keys3d_bg_palette = 'banana';
|
||||
assert.equal(readPaletteSetting(), 'octaves');
|
||||
});
|
||||
|
||||
test('keys3dSetPalette: persists + dispatches valid ids, ignores unknown', () => {
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
win.keys3dSetPalette('emerald');
|
||||
assert.equal(store.keys3d_bg_palette, 'emerald');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].type, 'keys3d:settings');
|
||||
assert.equal(events[0].detail.palette, 'emerald');
|
||||
// Unknown id: no write, no event.
|
||||
win.keys3dSetPalette('banana');
|
||||
assert.equal(store.keys3d_bg_palette, 'emerald');
|
||||
assert.equal(events.length, 1);
|
||||
// 'octaves' (procedural, not a 12-array) is a valid selectable id.
|
||||
win.keys3dSetPalette('octaves');
|
||||
assert.equal(store.keys3d_bg_palette, 'octaves');
|
||||
assert.equal(events.length, 2);
|
||||
});
|
||||
|
||||
test('PALETTE_IDS: the array palettes plus the procedural octaves scheme', () => {
|
||||
const { PALETTE_IDS, NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual([...PALETTE_IDS],
|
||||
[...Object.keys(NOTE_PALETTES), 'octaves']);
|
||||
assert.ok(PALETTE_IDS.indexOf('octaves') !== -1);
|
||||
assert.ok(!('octaves' in NOTE_PALETTES)); // it is NOT a 12-entry table
|
||||
});
|
||||
|
||||
test('octaveNoteColor: hue steps per octave, loops, sharps darker, sub-C1 distinct', () => {
|
||||
const { octaveNoteColor, OCTAVE_HUES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const luma = (c) =>
|
||||
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
|
||||
// C1 (midi 24) = first hue; C2 (36) = second; C8 (108) = 8th (index 7).
|
||||
assert.equal(octaveNoteColor(24), OCTAVE_HUES[0]); // C1 red
|
||||
assert.equal(octaveNoteColor(35), OCTAVE_HUES[0]); // B1 still octave 1
|
||||
assert.equal(octaveNoteColor(36), OCTAVE_HUES[1]); // C2 orange
|
||||
assert.equal(octaveNoteColor(60), OCTAVE_HUES[3]); // C4 (middle C)
|
||||
assert.equal(octaveNoteColor(108), OCTAVE_HUES[7]); // C8 last hue
|
||||
// Naturals across one octave (C1..B1 whites) all share the octave hue.
|
||||
for (const nat of [24, 26, 28, 29, 31, 33, 35]) {
|
||||
assert.equal(octaveNoteColor(nat), OCTAVE_HUES[0], 'natural ' + nat);
|
||||
}
|
||||
// Sharps in an octave are a DARKER shade of that same hue.
|
||||
for (const sharp of [25, 27, 30, 32, 34]) { // C#1..A#1
|
||||
assert.ok(luma(octaveNoteColor(sharp)) < luma(OCTAVE_HUES[0]),
|
||||
'sharp ' + sharp + ' darker than the octave natural');
|
||||
}
|
||||
// The three keys below C1 (A0/A#0/B0) share a distinct sub-C1 colour,
|
||||
// different from the red octave-1 start.
|
||||
assert.equal(octaveNoteColor(21), octaveNoteColor(23)); // A0 == B0 hue
|
||||
assert.notEqual(octaveNoteColor(21), OCTAVE_HUES[0]);
|
||||
// Loop: an octave past the table wraps (safety for out-of-88 midi).
|
||||
assert.equal(octaveNoteColor(24 + 12 * OCTAVE_HUES.length), OCTAVE_HUES[0]);
|
||||
});
|
||||
|
||||
/* ── Camera presets + fine-tune (feat/keys3d-camera) ─────────────────── */
|
||||
|
||||
test('FX defaults: camera height/distance/tilt all neutral (preset carries the tuned aim)', () => {
|
||||
const { FX_DEFAULTS, FX_RANGES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.camHeight, 1.0);
|
||||
assert.equal(FX_DEFAULTS.camDist, 1.0);
|
||||
// Tilt ships NEUTRAL (0): the tuned plug-and-play aim now lives in
|
||||
// CAM_PRESETS.overhead.lookY, so the fine-tune only nudges from a preset
|
||||
// and 'classic' + this default reproduces the exact historical rig.
|
||||
assert.equal(FX_DEFAULTS.camTilt, 0.0);
|
||||
assert.ok(FX_DEFAULTS.camTilt >= FX_RANGES.camTilt[0] && FX_DEFAULTS.camTilt <= FX_RANGES.camTilt[1]);
|
||||
// Height/distance bracket 1 (can go lower AND higher); tilt spans 0.
|
||||
assert.ok(FX_RANGES.camHeight[0] < 1 && 1 < FX_RANGES.camHeight[1]);
|
||||
assert.ok(FX_RANGES.camDist[0] < 1 && 1 < FX_RANGES.camDist[1]);
|
||||
assert.ok(FX_RANGES.camTilt[0] < 0 && 0 < FX_RANGES.camTilt[1]);
|
||||
});
|
||||
|
||||
test('camTilt: negative values survive the clamp (down-tilt must be reachable)', () => {
|
||||
const store = {};
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
const { FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
win.keys3dSetFx('camTilt', -0.5);
|
||||
assert.equal(store.keys3d_bg_camTilt, '-0.5'); // NOT crushed to 0 by a 0-1 clamp
|
||||
win.keys3dSetFx('camTilt', -99);
|
||||
assert.equal(parseFloat(store.keys3d_bg_camTilt), FX_RANGES.camTilt[0]);
|
||||
});
|
||||
|
||||
test('FX ranges: reader + setter clamp to the declared range, not 0-1', () => {
|
||||
const store = { keys3d_bg_camHeight: '5', keys3d_bg_camDist: '0.01' };
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
const { readFxSettings, FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
// Reader: corrupt/out-of-range writes clamp to the declared bounds.
|
||||
assert.equal(readFxSettings().camHeight, FX_RANGES.camHeight[1]);
|
||||
assert.equal(readFxSettings().camDist, FX_RANGES.camDist[0]);
|
||||
// Setter: same clamp on the way in; a value above 1 must survive
|
||||
// (the historical 0-1 clamp would have crushed 1.3 to 1).
|
||||
win.keys3dSetFx('camHeight', 1.3);
|
||||
assert.equal(store.keys3d_bg_camHeight, '1.3');
|
||||
win.keys3dSetFx('camDist', 99);
|
||||
assert.equal(parseFloat(store.keys3d_bg_camDist), FX_RANGES.camDist[1]);
|
||||
// Un-ranged keys keep the historical 0-1 clamp.
|
||||
win.keys3dSetFx('vibrancy', 2);
|
||||
assert.equal(store.keys3d_bg_vibrancy, '1');
|
||||
});
|
||||
|
||||
test('scrollZ: distance-to-hitline scales linearly with the speed argument', () => {
|
||||
const { scrollZ } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const hitZ = 0;
|
||||
const d1 = scrollZ(2, 0, hitZ, 130) - hitZ; // 2s ahead at stock speed
|
||||
const d2 = scrollZ(2, 0, hitZ, 260) - hitZ; // same note at 2x speed
|
||||
assert.equal(d2, d1 * 2);
|
||||
// At the hit moment the note is at the hit-line regardless of speed.
|
||||
assert.equal(scrollZ(5, 5, hitZ, 130), hitZ);
|
||||
assert.equal(scrollZ(5, 5, hitZ, 260), hitZ);
|
||||
});
|
||||
|
||||
test('camera presets: classic preserves the stock rig, overhead is the default', () => {
|
||||
const { CAM_PRESETS, readCameraSetting } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(Object.keys(CAM_PRESETS), ['classic', 'elevated', 'overhead']);
|
||||
// 'classic' preserves the historical constants (pre-K units) even though
|
||||
// it is no longer the default — anyone who picks it gets the old rig back
|
||||
// EXACTLY, because camTilt now defaults to 0 (neutral): effective aim =
|
||||
// classic.lookY + 0*CAM_TILT_UNITS = 8, the historical LOOK_Y.
|
||||
assert.deepEqual({ ...CAM_PRESETS.classic },
|
||||
{ fov: 40, y: 46, z: 112, lookY: 8, lookZ: -165 });
|
||||
for (const [id, p] of Object.entries(CAM_PRESETS)) {
|
||||
for (const f of ['fov', 'y', 'z', 'lookY', 'lookZ']) {
|
||||
assert.ok(Number.isFinite(p[f]), id + '.' + f + ' is a number');
|
||||
}
|
||||
assert.ok(p.y > 0 && p.z > 0, id + ' sits above and behind the keys');
|
||||
}
|
||||
assert.equal(readCameraSetting(), 'overhead'); // no localStorage in the vm → tuned default
|
||||
});
|
||||
|
||||
test('camera default look is unchanged: overhead bakes the old tuned tilt, camTilt is neutral', () => {
|
||||
const { CAM_PRESETS, FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const CAM_TILT_UNITS = 55; // full-swing of the camTilt offset at ±1 (screen.js)
|
||||
// The shipped default look = overhead preset + the default camTilt. Before,
|
||||
// that was lookY 0 + (−0.6 × 55) = −33; the tuned aim now lives in the
|
||||
// preset (lookY −33) with a neutral camTilt (0), so the effective aim — and
|
||||
// thus the out-of-the-box framing — is byte-identical.
|
||||
const effOverhead = CAM_PRESETS.overhead.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
|
||||
assert.equal(effOverhead, -33);
|
||||
// 'classic' + the neutral default reproduces the historical LOOK_Y (8) —
|
||||
// the "pick Classic for the original look" promise, now actually true.
|
||||
const effClassic = CAM_PRESETS.classic.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
|
||||
assert.equal(effClassic, 8);
|
||||
});
|
||||
|
||||
test('keys3dSetCamera: persists + dispatches valid ids, ignores unknown', () => {
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
win.keys3dSetCamera('overhead');
|
||||
assert.equal(store.keys3d_bg_camera, 'overhead');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].type, 'keys3d:settings');
|
||||
assert.equal(events[0].detail.camera, 'overhead');
|
||||
win.keys3dSetCamera('helicopter');
|
||||
assert.equal(store.keys3d_bg_camera, 'overhead');
|
||||
assert.equal(events.length, 1);
|
||||
const { readCameraSetting } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(readCameraSetting(), 'overhead');
|
||||
store.keys3d_bg_camera = 'garbage';
|
||||
assert.equal(readCameraSetting(), 'overhead');
|
||||
});
|
||||
|
||||
/* ── Flat-sharps / piano-shaped lanes (feat/keys3d-flat-lanes) ───────── */
|
||||
|
||||
test('FX defaults: octave separators on, lanes off (minimal default look)', () => {
|
||||
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.octaveGaps, true); // octave separators ship on
|
||||
assert.equal(FX_DEFAULTS.laneOpacity, 0.0); // dark floor + guide lines by default
|
||||
assert.equal(FX_DEFAULTS.octaveContrast, 0.5);
|
||||
// Sharp LAYOUT is a string setting, not an FX bool.
|
||||
assert.equal('flatSharps' in FX_DEFAULTS, false);
|
||||
assert.equal('laneColors' in FX_DEFAULTS, false); // superseded by laneOpacity
|
||||
});
|
||||
|
||||
test('keys3dSetFx: highway-layout controls persist (bool + sliders)', () => {
|
||||
const store = {};
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
win.keys3dSetFx('octaveGaps', true);
|
||||
assert.equal(store.keys3d_bg_octaveGaps, '1');
|
||||
// laneOpacity / octaveContrast are 0-1 numbers, persisted verbatim + clamped.
|
||||
win.keys3dSetFx('laneOpacity', 0.35);
|
||||
assert.equal(store.keys3d_bg_laneOpacity, '0.35');
|
||||
win.keys3dSetFx('laneOpacity', 5); // clamps to the 0-1 range
|
||||
assert.equal(store.keys3d_bg_laneOpacity, '1');
|
||||
win.keys3dSetFx('octaveContrast', 0.8);
|
||||
assert.equal(store.keys3d_bg_octaveContrast, '0.8');
|
||||
});
|
||||
|
||||
test('sharpMode: realistic default, validated ids, persists + dispatches', () => {
|
||||
const bare = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual([...bare.SHARP_MODES], ['floating', 'flat', 'realistic']);
|
||||
assert.equal(bare.readSharpModeSetting(), 'realistic'); // no localStorage → default
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
win.keys3dSetSharpMode('flat'); // a non-default id, to exercise persistence
|
||||
assert.equal(store.keys3d_bg_sharpMode, 'flat');
|
||||
assert.equal(events[0].detail.sharpMode, 'flat');
|
||||
assert.equal(win.slopsmithViz_keys_highway_3d.__test.readSharpModeSetting(), 'flat');
|
||||
// Unknown id ignored (no write, no event).
|
||||
win.keys3dSetSharpMode('bogus');
|
||||
assert.equal(store.keys3d_bg_sharpMode, 'flat');
|
||||
assert.equal(events.length, 1);
|
||||
});
|
||||
|
||||
test('laneSpanFlat (V5): lanes tile with zero overlap and even the naturals', () => {
|
||||
const { laneSpanFlat, _isBlackPc } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const sh = 2.2, shift = 2.2 / 3;
|
||||
const dims = { whiteW: 12, sharpHalf: sh, shift, octGap: 0.9 }; // mirrors shipped LANE_DIMS_FLAT
|
||||
// cx for one octave: whites on integer slots, blacks on half-slots — the
|
||||
// same slot geometry keyLayout/keyX produce (cx = slot * whiteW=12).
|
||||
const CX = {
|
||||
60: 0, 61: 6, 62: 12, 63: 18, 64: 24, 65: 36, 66: 42,
|
||||
67: 48, 68: 54, 69: 60, 70: 66, 71: 72, 72: 84,
|
||||
};
|
||||
const midis = [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72];
|
||||
const spans = midis.map((m) => laneSpanFlat(m, _isBlackPc(m), CX[m], dims, false));
|
||||
const wOf = (s) => s.right - s.left;
|
||||
const w = (m) => wOf(spans[midis.indexOf(m)]);
|
||||
// Zero-overlap tiling: every lane abuts the previous one (no gap, no overlap).
|
||||
for (let i = 1; i < spans.length; i++) {
|
||||
assert.ok(Math.abs(spans[i].left - spans[i - 1].right) < 1e-9, 'lane ' + midis[i] + ' abuts');
|
||||
}
|
||||
// Sharps are all the same width.
|
||||
for (const m of [61, 63, 66, 68, 70]) {
|
||||
assert.ok(Math.abs(w(m) - 2 * sh) < 1e-9, 'sharp ' + m + ' width');
|
||||
}
|
||||
// The lean evens the naturals: C, D, E, F, B all come out equal.
|
||||
for (const m of [62, 64, 65, 71]) {
|
||||
assert.ok(Math.abs(w(m) - w(60)) < 1e-9, 'natural ' + m + ' == C (evened)');
|
||||
}
|
||||
// G and A are the only slightly-smaller naturals (G# can't lean) — still
|
||||
// clearly wider than a sharp, and MUCH closer to the rest than plain V2
|
||||
// (which would leave D at 12−2·sh, far below C's 12−sh).
|
||||
assert.ok(Math.abs(w(67) - w(69)) < 1e-9, 'G == A');
|
||||
assert.ok(w(67) < w(60) && w(67) > 2 * sh, 'G/A a touch smaller, still wider than a sharp');
|
||||
assert.ok(w(60) - w(67) < sh, 'natural spread is under one sharp-width');
|
||||
});
|
||||
|
||||
test('laneSpanReal (V4): naturals uniform, sharps full-width and overlapping', () => {
|
||||
const { laneSpanReal } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const dims = { natHalf: 5.64, sharpHalf: 3.2, octGap: 0.9 }; // mirrors LANE_DIMS_REAL
|
||||
const wOf = (s) => s.right - s.left;
|
||||
// Every natural is the same full width, whatever its neighbours.
|
||||
for (const [midi, slot] of [[60, 0], [62, 1], [64, 2], [67, 4], [71, 6]]) {
|
||||
assert.ok(Math.abs(wOf(laneSpanReal(midi, false, slot * 12, dims, false)) - 2 * 5.64) < 1e-9,
|
||||
'natural ' + midi + ' uniform');
|
||||
}
|
||||
// Sharps are the full (wider) black-key width and overlap their naturals.
|
||||
const C = laneSpanReal(60, false, 0, dims, false);
|
||||
const Cs = laneSpanReal(61, true, 6, dims, false);
|
||||
assert.ok(Math.abs(wOf(Cs) - 2 * 3.2) < 1e-9, 'sharp full width');
|
||||
assert.ok(Cs.left < C.right, 'sharp overlaps (tucks over) the natural');
|
||||
});
|
||||
|
||||
test('laneSpanFlat (V5): octaveGaps widens B→C by octGap, sharps unaffected', () => {
|
||||
const { laneSpanFlat } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const dims = { whiteW: 12, sharpHalf: 2.2, shift: 2.2 / 3, octGap: 0.9 };
|
||||
const gapOff = laneSpanFlat(72, false, 84, dims, false).left - laneSpanFlat(71, false, 72, dims, false).right;
|
||||
const gapOn = laneSpanFlat(72, false, 84, dims, true).left - laneSpanFlat(71, false, 72, dims, true).right;
|
||||
assert.ok(Math.abs((gapOn - gapOff) - dims.octGap) < 1e-9, 'B→C divider grows by octGap');
|
||||
// Sharps are unaffected by the octave-gap option.
|
||||
const s = laneSpanFlat(61, true, 6, dims, true);
|
||||
assert.ok(Math.abs((s.right - s.left) - 2 * dims.sharpHalf) < 1e-9, 'sharp width unchanged by gaps');
|
||||
});
|
||||
|
||||
test('laneSpanFlat (V5): active-range boundary key is NOT trimmed by an out-of-range neighbor sharp', () => {
|
||||
const { laneSpanFlat } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const dims = { whiteW: 12, sharpHalf: 2.2, shift: 2.2 / 3, octGap: 0.9 }; // mirrors LANE_DIMS_FLAT
|
||||
// F (midi 65, cx 36): its upper neighbor F# (66) is a sharp. When F sits
|
||||
// at range.activeHigh and F# is excluded from the active range, F# never
|
||||
// gets a lane drawn (see the activeLow/activeHigh skip around the
|
||||
// lane-strip loop) — trimming F's right edge for it would leave a dark,
|
||||
// unfilled sliver. The edge should stay full instead.
|
||||
const highBoundary = { activeLow: 60, activeHigh: 65 };
|
||||
const fAtBoundary = laneSpanFlat(65, false, 36, dims, false, highBoundary);
|
||||
assert.ok(Math.abs(fAtBoundary.right - (36 + dims.whiteW / 2)) < 1e-9,
|
||||
'F right edge stays full when F# is out of the active range');
|
||||
// Same key, but now F# IS in the active range: normal zero-overlap
|
||||
// tiling applies — the trim matches the ungated (no-range) call exactly,
|
||||
// so in-range geometry is unaffected by this fix.
|
||||
const highIncluded = { activeLow: 60, activeHigh: 66 };
|
||||
const fWithSharpInRange = laneSpanFlat(65, false, 36, dims, false, highIncluded);
|
||||
const fUngated = laneSpanFlat(65, false, 36, dims, false);
|
||||
assert.ok(Math.abs(fWithSharpInRange.right - fUngated.right) < 1e-9,
|
||||
'F trims normally once F# is back in range');
|
||||
assert.ok(fWithSharpInRange.right < fAtBoundary.right, 'in-range trim is narrower than the boundary full edge');
|
||||
|
||||
// Symmetric case on the low edge: D (midi 62, cx 12), lower neighbor C#
|
||||
// (61) excluded when D sits at range.activeLow.
|
||||
const lowBoundary = { activeLow: 62, activeHigh: 72 };
|
||||
const dAtBoundary = laneSpanFlat(62, false, 12, dims, false, lowBoundary);
|
||||
assert.ok(Math.abs(dAtBoundary.left - (12 - dims.whiteW / 2)) < 1e-9,
|
||||
'D left edge stays full when C# is out of the active range');
|
||||
const lowIncluded = { activeLow: 61, activeHigh: 72 };
|
||||
const dWithSharpInRange = laneSpanFlat(62, false, 12, dims, false, lowIncluded);
|
||||
const dUngated = laneSpanFlat(62, false, 12, dims, false);
|
||||
assert.ok(Math.abs(dWithSharpInRange.left - dUngated.left) < 1e-9,
|
||||
'D trims normally once C# is back in range');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "tuner",
|
||||
"name": "Guitar/Bass Tuner",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.4",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -869,8 +869,15 @@ window._tunerUI = function(state, actions) {
|
||||
btn.textContent = 'Tuner';
|
||||
btn.title = 'Open Tuner';
|
||||
btn.onclick = window.tuner.toggle;
|
||||
const closeBtn = isV3 ? null : controls.querySelector('button:last-child');
|
||||
if (closeBtn) controls.insertBefore(btn, closeBtn);
|
||||
// Anchor to the last DIRECT-child button of `controls` (the classic
|
||||
// transport's close/exit button). A bare `button:last-child` can match
|
||||
// a NESTED button that is not a direct child of `controls`, and
|
||||
// `insertBefore()` then throws NotFoundError — which propagated out of
|
||||
// the player-screen transition and aborted its render (feedBack#800).
|
||||
// `:scope > button:last-of-type` restricts the anchor to a direct child;
|
||||
// the parentNode check is a belt-and-suspenders guard before insertBefore.
|
||||
const closeBtn = isV3 ? null : controls.querySelector(':scope > button:last-of-type');
|
||||
if (closeBtn && closeBtn.parentNode === controls) controls.insertBefore(btn, closeBtn);
|
||||
else controls.appendChild(btn);
|
||||
updatePlayerButton();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Perf-baseline harness for the module-migration refactor (R0).
|
||||
//
|
||||
// Rerun this after every phase (R0 → R3c) to prove the split does not regress
|
||||
// screen-entry, frame-time, memory, or server latency. It writes a markdown
|
||||
// results block to stdout; paste it into docs/perf-baseline.md (or redirect).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 [--n 60] [--soak 30]
|
||||
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 --song "Arcturus ….feedpak"
|
||||
//
|
||||
// With --song, it additionally measures the 2D highway's PER-FRAME DRAW cost:
|
||||
// it wraps requestAnimationFrame before any page script runs, tags the frames
|
||||
// in which the highway actually painted (via highway.addDrawHook), starts
|
||||
// playback, and reports draw-frame p50/p95/p99 over --frames seconds. Tagging
|
||||
// matters — roughly half the rAF callbacks belong to other cheap loops, and
|
||||
// averaging them in hides a renderer regression behind ~0.1 ms no-op frames.
|
||||
// This is the metric that gates the highway.js split (R3c); run it before AND
|
||||
// after each highway change on the same machine.
|
||||
//
|
||||
// Maintainer/CI-only dev tooling (uses the committed @playwright/test browser);
|
||||
// never part of the serve or Docker path. Metrics that need a seeded library
|
||||
// with charts (playback frame-time, screen-entry into a live highway) are
|
||||
// clearly labelled — run those against an environment with real songs.
|
||||
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require('@playwright/test');
|
||||
|
||||
const args = new Map();
|
||||
for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i].replace(/^--/, ''), process.argv[i + 1]);
|
||||
const BASE = args.get('base') || 'http://127.0.0.1:8000';
|
||||
const N = parseInt(args.get('n') || '60', 10);
|
||||
const SOAK_S = parseInt(args.get('soak') || '30', 10);
|
||||
const SONG = args.get('song') || null;
|
||||
const FRAME_S = parseInt(args.get('frames') || '10', 10);
|
||||
const RUNS = parseInt(args.get('runs') || '3', 10); // repeat frame sampling to show spread
|
||||
|
||||
const pct = (xs, p) => {
|
||||
if (!xs.length) return null;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
||||
};
|
||||
const ms = (x) => (x == null ? '—' : `${x.toFixed(1)}`);
|
||||
|
||||
// ── Server latency: p50/p95/p99 over N requests per endpoint ──────────────────
|
||||
async function serverLatency(paths) {
|
||||
const rows = [];
|
||||
for (const path of paths) {
|
||||
const t = [];
|
||||
let status = 0;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const r = await fetch(BASE + path);
|
||||
status = r.status;
|
||||
await r.arrayBuffer();
|
||||
} catch { status = -1; }
|
||||
t.push(performance.now() - t0);
|
||||
}
|
||||
rows.push({ path, status, p50: pct(t, 50), p95: pct(t, 95), p99: pct(t, 99) });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Client: cold boot-to-interactive + idle memory after a soak ───────────────
|
||||
async function clientMetrics() {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const t0 = Date.now();
|
||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
const bootMs = Date.now() - t0;
|
||||
|
||||
// performance.memory is Chromium-only; JS heap after settle.
|
||||
const mem0 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
|
||||
await page.waitForTimeout(SOAK_S * 1000);
|
||||
const mem1 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
|
||||
|
||||
const scripts = await page.evaluate(() =>
|
||||
document.querySelectorAll('script[data-plugin-id]').length);
|
||||
|
||||
await browser.close();
|
||||
return { bootMs, memStartMB: mem0 && mem0 / 1048576, memSoakMB: mem1 && mem1 / 1048576, scripts };
|
||||
}
|
||||
|
||||
// ── 2D highway per-frame draw cost (needs --song + a seeded library) ──────────
|
||||
async function frameTimeOnce(browser) {
|
||||
const page = await browser.newPage();
|
||||
let f, t2, notes;
|
||||
try {
|
||||
// Wrap rAF before any page script; mark frames the highway actually drew.
|
||||
await page.addInitScript(() => {
|
||||
window.__f = [];
|
||||
window.__drew = false;
|
||||
const raf = window.requestAnimationFrame.bind(window);
|
||||
window.requestAnimationFrame = (cb) => raf((t) => {
|
||||
window.__drew = false;
|
||||
const t0 = performance.now();
|
||||
try { cb(t); } finally { window.__f.push({ ms: performance.now() - t0, drew: window.__drew }); }
|
||||
});
|
||||
});
|
||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
await page.evaluate(() => document.getElementById('v3-onboarding')?.remove());
|
||||
await page.evaluate((s) => window.playSong(s), SONG);
|
||||
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length >= 0 && window.highway?.getSongInfo?.(),
|
||||
null, { timeout: 45000 }).catch(() => {});
|
||||
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length > 0, null, { timeout: 45000 });
|
||||
await page.evaluate(() => window.highway.addDrawHook(() => { window.__drew = true; }));
|
||||
// Start playback so draw() leaves its paused-throttle path; confirm the clock advances.
|
||||
await page.evaluate(async () => { const a = window.highway.getAudioElement?.(); if (a) a.muted = true; await a?.play?.(); });
|
||||
await page.waitForTimeout(1500);
|
||||
const t1 = await page.evaluate(() => window.highway.getTime());
|
||||
await page.evaluate(() => { window.__f.length = 0; });
|
||||
await page.waitForTimeout(FRAME_S * 1000);
|
||||
({ f, t2, notes } = await page.evaluate(() => ({
|
||||
f: window.__f.slice(), t2: window.highway.getTime(), notes: window.highway.getNotes().length,
|
||||
})));
|
||||
if (!(t2 > t1 + 1)) throw new Error(`clock did not advance (${t1}→${t2}) — measured the paused path`);
|
||||
} finally {
|
||||
// Always close, even when an await above throws — otherwise a failing
|
||||
// run leaks its page until the final browser.close().
|
||||
await page.close();
|
||||
}
|
||||
const drew = f.filter((x) => x.drew).map((x) => x.ms);
|
||||
return { drew, total: f.length, notes };
|
||||
}
|
||||
|
||||
async function frameTime() {
|
||||
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
|
||||
const rows = [];
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
try {
|
||||
const r = await frameTimeOnce(browser);
|
||||
rows.push({
|
||||
p50: pct(r.drew, 50), p95: pct(r.drew, 95), p99: pct(r.drew, 99),
|
||||
max: Math.max(...r.drew), n: r.drew.length, total: r.total, notes: r.notes,
|
||||
});
|
||||
} catch (e) { rows.push({ error: String(e.message || e) }); }
|
||||
}
|
||||
await browser.close();
|
||||
return rows;
|
||||
}
|
||||
|
||||
const server = await serverLatency([
|
||||
'/api/version',
|
||||
'/api/plugins',
|
||||
'/api/library?limit=60',
|
||||
'/api/library/artists',
|
||||
]);
|
||||
const client = await clientMetrics();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let out = `\n<!-- generated by scripts/perf-baseline.mjs @ ${now} against ${BASE} (n=${N}, soak=${SOAK_S}s) -->\n\n`;
|
||||
out += `### Server latency (ms)\n\n| Endpoint | status | p50 | p95 | p99 |\n|---|---|---|---|---|\n`;
|
||||
for (const r of server) out += `| \`${r.path}\` | ${r.status} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} |\n`;
|
||||
out += `\n### Client\n\n| Metric | Value |\n|---|---|\n`;
|
||||
out += `| Cold boot → networkidle | ${client.bootMs} ms |\n`;
|
||||
out += `| JS heap after load | ${client.memStartMB ? client.memStartMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| JS heap after ${SOAK_S}s idle soak | ${client.memSoakMB ? client.memSoakMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| Plugin scripts injected | ${client.scripts} |\n`;
|
||||
if (SONG) {
|
||||
const frames = await frameTime();
|
||||
out += `\n### 2D highway per-frame draw cost — \`${SONG}\` (${RUNS} runs × ${FRAME_S}s)\n\n`;
|
||||
out += `| run | draw frames | p50 | p95 | p99 | max |\n|---|---|---|---|---|---|\n`;
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const r = frames[i];
|
||||
if (r.error) { out += `| ${i + 1} | — | \`${r.error}\` | | | |\n`; continue; }
|
||||
out += `| ${i + 1} | ${r.n}/${r.total} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} | ${ms(r.max)} |\n`;
|
||||
}
|
||||
const p95s = frames.filter((r) => !r.error).map((r) => r.p95);
|
||||
if (p95s.length) out += `\n**p95 spread across runs: ${ms(Math.min(...p95s))}–${ms(Math.max(...p95s))} ms**\n`;
|
||||
} else {
|
||||
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
|
||||
out += `> on the 2D highway — pass \`--song "<filename in DLC_DIR>"\` to capture it.\n`;
|
||||
out += `> (3D highway_3d + screen-entry timings are a separate R4 concern.)\n`;
|
||||
}
|
||||
|
||||
console.log(out);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user