mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 05:44:30 +00:00
Compare commits
163
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6e7b3c6e8 | ||
|
|
329cc86315 | ||
|
|
d876ded00f | ||
|
|
18d77d2d41 | ||
|
|
5921157f35 | ||
|
|
3e57ba0345 | ||
|
|
b85496fe58 | ||
|
|
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 |
@@ -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`)
|
||||
@@ -124,6 +124,94 @@ jobs:
|
||||
print(f"Validated {len(manifests)} manifest(s) — OK")
|
||||
EOF
|
||||
|
||||
feedpak-spec:
|
||||
# Guard that core stays faithful to the feedpak format spec, which lives in
|
||||
# its own repo (got-feedback/feedpak-spec) and is the contract third-party
|
||||
# packers and players build against. Four surface checks: core reads/writes
|
||||
# only manifest keys the spec declares (and the scanned-module list can't
|
||||
# fall behind); the exception allowlist never grows, so the FEP process is
|
||||
# the only way a new key lands; core ingests the spec's example packs; packs
|
||||
# committed here pass the spec's reference validator. Motivated by
|
||||
# #933, where a manifest key (`original_audio`) shipped in core without ever
|
||||
# reaching the spec.
|
||||
#
|
||||
# The gate checks against the spec repo's HEAD, deliberately: the app must
|
||||
# conform to the LIVING spec, always. The dev flow is self-serve — a gated
|
||||
# PR opens a FEP, the spec PR merges, re-running this job goes green; no
|
||||
# pin file to bump, nothing to maintain. Accepted trade-off: a BREAKING
|
||||
# spec change (rare, deliberate, MAJOR per the spec's compatibility policy)
|
||||
# reddens every PR here until core conforms — which is the correct
|
||||
# org-wide signal that the app is out of conformance. The normal FEP is
|
||||
# additive and can never redden this job.
|
||||
name: feedpak-spec
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# This job runs repository code (tools/check_spec_conformance.py) and
|
||||
# never pushes; don't leave the token in git config for it.
|
||||
# fetch-depth: 0 so the base branch is available — the gate must prove
|
||||
# the exception allowlist didn't grow in this PR.
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Check out feedpak-spec at HEAD
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: got-feedback/feedpak-spec
|
||||
ref: main
|
||||
path: .feedpak-spec
|
||||
persist-credentials: false
|
||||
|
||||
- name: Record the spec commit this run verified against
|
||||
# HEAD-tracking means CI results can differ across time on the same
|
||||
# commit. Log the exact spec SHA so a red run is reproducible.
|
||||
run: git -C .feedpak-spec rev-parse HEAD
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
# CI-only: the spec's reference validator needs jsonschema. Not a
|
||||
# runtime dependency — this gate never runs on the serve/Docker path
|
||||
# (constitution Principle I). Pinned for the same reason the spec SHA
|
||||
# is: an upstream release must not turn this job red on a PR that
|
||||
# changed neither this repo nor the spec.
|
||||
pip install 'jsonschema==4.26.0'
|
||||
|
||||
- name: Fetch the base branch's exception allowlist
|
||||
id: baseline
|
||||
run: |
|
||||
# The allowlist is closed: it grandfathers keys that predate this gate
|
||||
# and may only shrink. Prove that by diffing against the base branch —
|
||||
# without this, anyone could append an entry and route around the FEP
|
||||
# process from inside this repo.
|
||||
#
|
||||
# Resolve the base rather than hardcoding `main`: ship-ci.yml also runs
|
||||
# this workflow for PRs into release/** and for pushes to release/**,
|
||||
# where a main baseline would diff against the wrong branch.
|
||||
# PR -> the branch it merges into
|
||||
# push -> the branch itself (its tip already contains the change, so
|
||||
# this is a no-op; enforcement happens at PR time)
|
||||
BASE="${{ github.event.pull_request.base.ref || github.ref_name }}"
|
||||
echo "diffing the allowlist against origin/$BASE"
|
||||
git fetch --no-tags --depth=1 origin "$BASE"
|
||||
if git cat-file -e FETCH_HEAD:feedpak-spec-exceptions.yml 2>/dev/null; then
|
||||
git show FETCH_HEAD:feedpak-spec-exceptions.yml > "$RUNNER_TEMP/baseline-exceptions.yml"
|
||||
echo "args=--baseline-exceptions $RUNNER_TEMP/baseline-exceptions.yml" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Only true until the PR that introduces this gate lands.
|
||||
echo "args=--bootstrap-allowlist" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check feedpak spec conformance
|
||||
run: python tools/check_spec_conformance.py --spec .feedpak-spec ${{ steps.baseline.outputs.args }}
|
||||
|
||||
lint:
|
||||
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
|
||||
# dev tooling, never on the serve/Docker path — same category as
|
||||
|
||||
@@ -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
|
||||
@@ -284,4 +284,4 @@ no `..`, no absolute paths).
|
||||
higher-numbered principle's escape hatch is to live in a plugin
|
||||
with its own bundled assets.
|
||||
|
||||
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-08
|
||||
**Version**: 1.3.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-11
|
||||
|
||||
+230
@@ -7,6 +7,236 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
||||
top-level manifest key this repo invented (#583) that the feedpak spec never had.
|
||||
The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0
|
||||
(feedpak-spec#53) reserves the id **`full`** for it, so that is where core reads it
|
||||
from now.
|
||||
|
||||
`full` is a mixdown, not a layer — it already contains every instrument — so
|
||||
`load_song()` lifts it OUT of `LoadedSloppak.stems` onto `LoadedSloppak.full_mix`.
|
||||
Nothing that sums stems or renders one fader per stem can see it, which is what
|
||||
makes retaining it safe; leaving it in the list would double the whole song and
|
||||
leave "guitar" audible with the guitar fader muted. That trap is exactly why the
|
||||
packer invented the key instead of putting the mixdown where the format says it
|
||||
goes — the bug was in the reader, and this fixes the reader.
|
||||
|
||||
Consequences worth knowing:
|
||||
- The highway WS `song_info` frame gains `full_mix_url` / `has_full_mix`.
|
||||
`original_audio_url` / `has_original_audio` remain as **deprecated aliases**
|
||||
(same values) for one release so a client built against the old frame keeps
|
||||
working; they go with the fallback below (#945).
|
||||
- `stems` on `song_info`, and `stem_ids` / `stem_count` in the library index, now
|
||||
describe *instrument* stems only — a separated pack that retains its mixdown no
|
||||
longer advertises a bogus "full" stem chip or an inflated stem count.
|
||||
- Audio fingerprinting (`lib/enrichment.py`) now resolves the mixdown the same
|
||||
way, which **widens** its coverage: it previously returned `None` for any pack
|
||||
without the invented key, so fingerprinting silently did nothing for the
|
||||
overwhelming majority of packs.
|
||||
- Core still **reads** `original_audio:` as a deprecated fallback, because every
|
||||
pack written before the spec caught up carries it and would otherwise lose its
|
||||
pristine mix. `tools/migrate_full_mix_stem.py` rewrites those packs into the
|
||||
spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem
|
||||
at `default: off`, drops the key); the fallback and the aliases are removed once
|
||||
they are migrated (#945).
|
||||
|
||||
### Added
|
||||
- **Genres fall back to MusicBrainz enrichment** — the effective genre now
|
||||
resolves override → pack genre → the enrichment match's primary genre
|
||||
(matched or user-pinned manual rows only). Converted packs rarely carry a `genres` manifest key,
|
||||
which starved the library genre facet and career passports on real
|
||||
libraries; with the fallback, every enriched song's genre is browsable and
|
||||
passport-able immediately, and coverage grows as enrichment runs.
|
||||
- **Badge ceremony in the venue** — earning a genre badge now stages a moment:
|
||||
the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant
|
||||
ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger;
|
||||
a no-op without a venue pack) and a full-screen overlay drops the bronze
|
||||
stamp with a shine sweep and a confetti burst over whatever screen is active
|
||||
(badges land right after `stats:recorded`, while the player is still up).
|
||||
Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing
|
||||
chime + notification only. The stamp still slams into the passport book on
|
||||
next open, unchanged.
|
||||
- **Hours-per-genre odometer (career passports)** — the app now measures real
|
||||
play time: the stats recorder accrues **wall-clock** seconds across
|
||||
play/resume ↔ pause/stop/end spans (wall time, not song position — position
|
||||
deltas double-count A-B loops and mis-read seeks; single spans clamp at 2h
|
||||
against suspend/sleep inflation) and piggybacks them as `seconds` on the
|
||||
`POST /api/stats` calls it already makes. New additive
|
||||
`song_stats.seconds_total` column; a seconds-only POST banks time for
|
||||
unscored plays that run to the song's natural end without touching the
|
||||
resume position (and still counts as playing today for the streak).
|
||||
Passports surface it honestly: "14.2 h in Blues" under the badge and on the
|
||||
shelf cover — a true fact that only grows, never a target or a meter.
|
||||
- **Career passport drills, curated** — Bronze in blues/rock/metal/funk/jazz
|
||||
now also asks for that genre's signature Virtuoso drill (Blues Shuffle,
|
||||
Power Chords & Backbeat, Gallop Picking, 16th Pocket, Shell Voicings — one
|
||||
per genre, data-driven in `passports.json` with display labels). Drill
|
||||
lists are per-instrument (`virtuoso_nodes: {instrument: [nodes]}`; a flat
|
||||
list still means guitar), so a keys passport never demands a guitar drill.
|
||||
A drill counts as cleared on the first real completion artifact — a
|
||||
top-tier clean pass in one key (`keysCleared`), any depth rung, or
|
||||
mastery — rather than only the maxed-speed depth flips. Genres without a
|
||||
curated drill stay songs-only.
|
||||
- **Career passport visuals pack** — earned covers and badge stamps become
|
||||
trading cards (pointer-tracked tilt + light glint, hover-capable devices
|
||||
only); the ghost stamp visibly "carves in" as qualifying songs land (a
|
||||
conic ink fill, no numbers added); the Gold rung preview is a small foil
|
||||
chip with a shimmer sweep, still honestly labeled coming. All theatrics
|
||||
disabled under `prefers-reduced-motion`.
|
||||
- **Career passports (backend)** — the badge-journey layer on top of career stars.
|
||||
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
|
||||
passport walls: genre badges computed on read from `song_stats` × the library's
|
||||
effective genre — Bronze = N genre songs at K★, data-driven in
|
||||
`plugins/career/passports.json`, default 5 songs at 2★ — plus qualifying-song
|
||||
"ticket stubs", the library genre list, and drill status), `POST /passports/commit`
|
||||
(instrument commitment), `POST /passports/open` (open a genre
|
||||
passport), and `POST /drill-state` (intake for the relayed Virtuoso
|
||||
`virtuoso.progress` snapshot, so drill requirements can gate badges
|
||||
server-side). Badges are never stored; the only persisted state (commitments,
|
||||
opened passports, drill snapshot) lives under `CONFIG_DIR/career/` and rides the
|
||||
settings export/import bundle via `settings.server_files`. Instruments are
|
||||
attributed via the existing progression arrangement→instrument mapping;
|
||||
non-graded instruments (bass, drums) render shown-not-judged — repertoire
|
||||
without a pass bar, never a false badge denial.
|
||||
- **Career passports (UI)** — the Career screen gains a Passports tab beside
|
||||
Venues: a physical per-instrument passport book (embossed leather cover, 3D
|
||||
page-turn) with a wax-seal commitment ceremony (Stage 0), rubber-stamp badge
|
||||
slam with ink bleed and deterministic per-genre jitter, qualifying songs as
|
||||
collected ticket stubs, and unopened genres as an "Explore next"
|
||||
travel-brochure rack (invitations, never greyed-out slots or completion
|
||||
meters). Badge earns chime + notify immediately; the stamp slam plays when
|
||||
the passport is next opened. Four small synthesized sound effects ship as
|
||||
plugin assets. The career screen also relays the Virtuoso `virtuoso.progress`
|
||||
localStorage snapshot to the drill-state intake on `virtuoso:progress` bus
|
||||
events (debounced, plus a one-time bootstrap), closing the
|
||||
fires-into-a-void seam without touching the virtuoso plugin.
|
||||
- **CI gate: core must stay faithful to the feedpak spec (`feedpak-spec` job).** feedpak is published as
|
||||
an open format with its own repo, normative spec, JSON Schemas, and reference validator — but nothing
|
||||
stopped core from reading a manifest key the spec never defined, which is exactly what happened with
|
||||
`original_audio` (#583 → #933). `tools/check_spec_conformance.py` now enforces four surface properties
|
||||
in CI: (1) **key-coverage** — every manifest key core reads *or writes* is declared in the spec's
|
||||
`manifest.schema.json`, found by walking the AST of `lib/sloppak.py`, `lib/enrichment.py`, and
|
||||
`lib/songmeta.py`, `lib/gp2notation.py`, and `lib/routers/ws_highway.py` (writes are gated too — including
|
||||
`setdefault()` — and reported separately: a key core writes lands in every pack we emit, so an undeclared
|
||||
one seeds the ecosystem with non-spec data; a **readers-complete** guard fails the build if that module
|
||||
list falls behind the codebase); (2) **allowlist-closed** — `feedpak-spec-exceptions.yml` never grows;
|
||||
(3) **forward** — core's `load_song()` ingests every example pack the spec ships;
|
||||
(4) **reverse** — every pack committed here passes the spec's own `tools/validate.py` (7/7 pass today).
|
||||
The gate verifies against the spec repo's **HEAD** — the app must conform to the living spec, and the
|
||||
flow is self-serve: a gated PR opens a FEP, the spec PR merges, re-running checks goes green. Nothing to
|
||||
pin, nothing to bump. Each run logs the spec SHA it verified against so results are reproducible.
|
||||
**There is no in-repo escape hatch, by design.** A blocked PR has exactly one route: land the key in the
|
||||
spec via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md), then
|
||||
re-run the PR's checks — the gate verifies against the spec's HEAD, so it goes green once the key is real. `feedpak-spec-exceptions.yml` is a **closed
|
||||
grandfather list** for keys that predate the gate, not a bypass: a fourth check (**allowlist-closed**)
|
||||
diffs it against the base branch and fails any PR that *adds* an entry, so it may only shrink.
|
||||
`original_audio` is grandfathered there against #933 so the gate lands green and starts blocking the
|
||||
*next* instance immediately; the gate takes no position on how #933 resolves (the expected outcome is
|
||||
removing the key, since the spec already carries the mixdown as a stem — not adopting it). Docs:
|
||||
[docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md).
|
||||
|
||||
### Removed
|
||||
- **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the
|
||||
`/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve
|
||||
`static/v3/index.html`, which has been the default since 0.3.0. This is the first step of
|
||||
the core-frontend ES-module migration (R3a): both shells load the same `static/app.js`, so
|
||||
every subsequent step of that migration would otherwise have to be made, and verified,
|
||||
twice. Removing the fallback now halves that surface before any of it is touched.
|
||||
Incidentally fixes a latent bug in the old `index()` route — its guard read
|
||||
`if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy")`,
|
||||
whose left operand is truthy for *any* non-empty value, so `FEEDBACK_UI=v3` actually served
|
||||
the **v2** shell. `static/tailwind.min.css` is regenerated (the content globs scanned the
|
||||
deleted file, so v2-only utility classes are now purged). Constitution amended to 1.3.0:
|
||||
Principle II's frontend file list now names `static/v3/index.html`.
|
||||
**Migration notes:** if you set `FEEDBACK_UI=v2` (or `=legacy`), or bookmarked `/v2`, there
|
||||
is no longer a classic shell to fall back to — unset the variable and use `/`. The env var
|
||||
itself is no longer read; the `SLOPSMITH_*`→`FEEDBACK_*` compat shim is unaffected. No
|
||||
chart, settings, or plugin data changes, and no plugin API changes: v3 reuses the same
|
||||
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
||||
|
||||
### Fixed
|
||||
- **Career passports review polish** — the passport tabs and book overlay carry
|
||||
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
|
||||
`role="dialog"` + `aria-modal` with focus moved to the close button on open
|
||||
and restored on close), and a corrupt stored seen-badges value (e.g. a stray
|
||||
`"null"`) can no longer throw on every passport refresh.
|
||||
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
|
||||
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
|
||||
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
|
||||
`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.
|
||||
|
||||
@@ -125,13 +125,13 @@ Notes:
|
||||
|
||||
### v3 UI (fee[dB]ack v0.3.0) — player-chrome contract
|
||||
|
||||
v0.3.0 ships a redesigned UI behind a flag (`FEEDBACK_UI=v3` or the `/v3` route);
|
||||
the classic UI (v2) stays the default until 0.3.0 ships, so **plugins must work in
|
||||
both**. v3 reuses the same engine (`server.py`, `app.js`, `highway.js`, `playSong`,
|
||||
v0.3.0's redesigned UI is **the only UI** — the classic v2 shell and its
|
||||
`FEEDBACK_UI` / `/v2` opt-outs are gone, so there is no second shell to support.
|
||||
v3 reuses the same engine (`server.py`, `app.js`, `highway.js`, `playSong`,
|
||||
`showScreen`, capabilities, library providers, the `window.feedBackViz_<id>` /
|
||||
`setRenderer` contract), so a plugin's **backend, capabilities, `nav`/`screen`,
|
||||
visualization renderers, diagnostics, and settings export work unchanged** — v3
|
||||
surfaces `nav` in its sidebar and mounts screens exactly as v2 does.
|
||||
surfaces `nav` in its sidebar and mounts screens as before.
|
||||
|
||||
**The only thing that changed is the player chrome.** If your plugin injects a
|
||||
control into it, you must adapt:
|
||||
@@ -157,7 +157,7 @@ control into it, you must adapt:
|
||||
popovers 40).
|
||||
|
||||
Full guide + the canonical snippet: **[docs/plugin-v3-ui.md](docs/plugin-v3-ui.md)**.
|
||||
Verify any player-injecting plugin in **both** `/` (v2) and `/v3`.
|
||||
Verify any player-injecting plugin at `/` — it and `/v3` serve the same v3 shell.
|
||||
|
||||
### Performance — never run DOM queries on a per-frame path
|
||||
|
||||
@@ -465,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.
|
||||
@@ -554,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
|
||||
@@ -566,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`.
|
||||
@@ -37,6 +37,31 @@ 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -54,8 +54,12 @@ without a *signed* exemption" is unenforceable.
|
||||
|
||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
||||
|
||||
core `static/app.js` (11,821) · `static/highway.js` (4,154, whole file) · `server.py`
|
||||
(13,948) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
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
|
||||
|
||||
+12
-5
@@ -43,12 +43,19 @@ module.exports = [
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
|
||||
rules: { 'max-lines': sizeRule(1500) },
|
||||
},
|
||||
// ES-module graphs (a plugin's src/ tree, .mjs tests): module parsing + the
|
||||
// acyclic-imports hard gate + the size norm. A migrated bundled plugin's
|
||||
// entry `import './src/main.js'` screen.js must parse as a module — add its
|
||||
// glob here in that plugin's migration PR (classic screen.js stays a script).
|
||||
// ES-module graphs (a plugin's src/ tree, .mjs tests, core's own static/js/
|
||||
// tree): module parsing + the acyclic-imports hard gate + the size norm. A
|
||||
// migrated bundled plugin's entry `import './src/main.js'` screen.js must
|
||||
// parse as a module — add its glob here in that plugin's migration PR
|
||||
// (classic screen.js stays a script).
|
||||
//
|
||||
// `static/app.js` is listed explicitly: it is served as
|
||||
// <script type="module"> (R3a) and now `import`s its carved-out modules, so
|
||||
// parsing it as a script would be a syntax error. It is the ENTRY of core's
|
||||
// module graph, which is what makes no-cycle meaningful here — a carved
|
||||
// module that imports app.js back would close a cycle and fail this gate.
|
||||
{
|
||||
files: ['**/src/**/*.js', '**/*.mjs'],
|
||||
files: ['**/src/**/*.js', '**/*.mjs', 'static/app.js', 'static/js/**/*.js', 'static/highway.js'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: { 'import-x': importX },
|
||||
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# CLOSED grandfather list — manifest keys core reads or writes that predate the
|
||||
# spec-conformance gate and that the feedpak spec does not define.
|
||||
#
|
||||
# Please don't add entries here — CI will flag any PR that grows this list, so
|
||||
# it can only shrink over time. That's by design, not distrust: the moment the
|
||||
# app touches a key the spec doesn't define, every teammate's PR starts failing
|
||||
# the conformance gate too, and whoever added the key is the only person who
|
||||
# can fix it. The FEP process below avoids putting anyone in that spot. The
|
||||
# feedpak spec's own governance is explicit:
|
||||
#
|
||||
# "This repository defines the format only. Applications that read or write
|
||||
# feedpak ... track this spec as a dependency; they do not drive it.
|
||||
# A change is not part of the format until it lands here."
|
||||
# — got-feedback/feedpak-spec, GOVERNANCE.md
|
||||
#
|
||||
# So a new manifest key goes through the feedpak Enhancement Proposal (FEP)
|
||||
# process — see feedpak-spec/CONTRIBUTING.md:
|
||||
#
|
||||
# 1. Open a FEP issue on got-feedback/feedpak-spec describing the problem, the
|
||||
# on-disk shape, backward compatibility, and the version bump implied.
|
||||
# 2. Land one PR there updating the normative spec, the JSON Schemas, an
|
||||
# example that exercises it, and the changelog — together.
|
||||
# 3. Back here, re-run this PR's checks. The gate verifies against the spec's
|
||||
# HEAD, so once your key is in the spec, the gate goes green.
|
||||
#
|
||||
# That's the supported route — and usually a quick one for additive keys. If
|
||||
# your PR is blocked by this gate, a FEP will get you unblocked properly; an
|
||||
# entry here won't (CI rejects it).
|
||||
#
|
||||
# Entries below exist ONLY because they predate the gate. Each is debt with a
|
||||
# tracking issue, and each disappears when its issue is fixed. The gate also
|
||||
# fails if an entry goes stale — the spec caught up, or core no longer reads or
|
||||
# writes the key — so this file cannot quietly become a place drift hides.
|
||||
|
||||
exceptions:
|
||||
- key: original_audio
|
||||
issue: https://github.com/got-feedback/feedback/issues/945
|
||||
reason: >-
|
||||
Added by #583 (the full mix played while every stem fader sits at unity,
|
||||
since demucs recombination is lossy). It never went through a FEP and the
|
||||
spec does not define it — the drift this gate exists to prevent.
|
||||
|
||||
#933 fixed the drift: feedpak 1.15.0 RESERVES the stem id `full` for the
|
||||
complete mixdown (feedpak-spec#53), and core now reads the full mix from
|
||||
that stem. Nothing depends on this key any more — not the loader, not
|
||||
lib/enrichment.py, not the stems plugin, and the packer no longer writes it.
|
||||
|
||||
What remains is a READ-ONLY deprecated fallback in lib/sloppak.py
|
||||
(_legacy_full_mix), kept for one release because every pack produced before
|
||||
the spec caught up carries `original_audio: original/full.ogg` and would
|
||||
otherwise silently lose its pristine mix. tools/migrate_full_mix_stem.py
|
||||
rewrites those packs into the spec shape.
|
||||
|
||||
This entry disappears with that fallback — tracked by #945, which cannot be
|
||||
forgotten: the gate fails if the entry goes stale, and deleting the read is
|
||||
what makes it stale.
|
||||
@@ -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
|
||||
+1122
File diff suppressed because it is too large
Load Diff
+10
-3
@@ -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)))
|
||||
|
||||
@@ -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}")
|
||||
+4448
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
+409
-61
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import zipfile
|
||||
@@ -34,6 +35,21 @@ FEEDPAK_EXT = ".feedpak"
|
||||
SLOPPAK_EXT = ".sloppak"
|
||||
SONG_EXTS = (FEEDPAK_EXT, SLOPPAK_EXT) # accepted on read/discovery
|
||||
|
||||
# ── The full mix ──────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Spec §5.3 RESERVES the stem id `full` for the song's complete mixdown: the
|
||||
# whole song in one file, as heard before source separation. It is a stem — it
|
||||
# lives in `stems` like every other audio file in a pack — but it is a *mixdown,
|
||||
# not a layer*. A reader that sums stems must never include it in the sum: it
|
||||
# already contains every instrument, so summing it doubles the whole song and
|
||||
# muting `guitar` still leaves guitar audible inside it.
|
||||
#
|
||||
# Keeping it matters because separation is lossy: re-summing guitar+bass+drums+
|
||||
# vocals does NOT reproduce the file they came from. The mixdown is the only
|
||||
# faithful rendering of the song a pack can carry, so we play it whenever every
|
||||
# stem sits at unity and nothing is muted.
|
||||
FULL_MIX_STEM_ID = "full"
|
||||
|
||||
import yaml
|
||||
|
||||
from jsonc import load_json
|
||||
@@ -51,6 +67,97 @@ import drums as drums_mod
|
||||
import notation as notation_mod
|
||||
|
||||
|
||||
def find_full_mix(stems: list[dict]) -> dict | None:
|
||||
"""The RESERVED `full` stem (spec §5.3) — the pack's complete mixdown — or None.
|
||||
|
||||
Answers "what is this pack's master audio", which is what fingerprinting
|
||||
wants. For playback use partition_stems() instead: a pack whose *only* stem
|
||||
is `full` has no mixdown to play *separately from* its stems, and this
|
||||
function still returns it.
|
||||
"""
|
||||
return next(
|
||||
(s for s in stems if str(s.get("id", "")) == FULL_MIX_STEM_ID), None
|
||||
)
|
||||
|
||||
|
||||
def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
|
||||
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
|
||||
|
||||
The mixdown is lifted OUT of the stem list because every consumer of `stems`
|
||||
treats that list as layers to sum or to show as mixer channels, and `full` is
|
||||
neither (spec §5.3). Leaving it in is precisely the bug that made the packer
|
||||
invent `original_audio` in the first place: a listed full mix plays on top of
|
||||
the stems.
|
||||
|
||||
A pack whose only stem is `full` is a single-mix pack, not a separated one:
|
||||
there are no instruments to be pristine *against*, so `full` stays the sole
|
||||
playable stem and no mixdown is surfaced. That keeps the freshly-converted
|
||||
single-stem pack — much the most common shape — behaving exactly as before.
|
||||
|
||||
EVERY entry with the reserved id is removed, not just the one we surface. A
|
||||
malformed pack that lists `full` twice would otherwise leave a copy of the
|
||||
whole song behind in the stem list, to be summed with the instruments — the
|
||||
precise failure this function exists to prevent, reintroduced by a duplicate.
|
||||
"""
|
||||
if len(stems) < 2:
|
||||
return None, stems
|
||||
full = find_full_mix(stems)
|
||||
if full is None:
|
||||
return None, stems
|
||||
return full, [s for s in stems if str(s.get("id", "")) != FULL_MIX_STEM_ID]
|
||||
|
||||
|
||||
def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
|
||||
"""Full mix from the DEPRECATED `original_audio:` manifest key, or None.
|
||||
|
||||
Before feedpak 1.15.0 reserved `full`, §5.3 said the mixdown was "commonly
|
||||
replaced" by the per-instrument stems on splitting — so it had nowhere to
|
||||
live, and this repo invented a top-level key pointing at a parallel
|
||||
`original/` directory (#583) to hold it. That key was never in the spec, and
|
||||
#933 removed our dependence on it: the mixdown is a stem.
|
||||
|
||||
We still READ it, because every pack written before the spec caught up
|
||||
carries `original_audio: original/full.ogg` and would otherwise lose its full
|
||||
mix. We never write it. Delete this once those packs are migrated (#945);
|
||||
`tools/migrate_full_mix_stem.py` is the migration.
|
||||
|
||||
NOTE the string literal below. tools/check_spec_conformance.py AST-scans for
|
||||
`manifest.get("<literal>")` to prove every manifest key core reads is one the
|
||||
spec declares. Hoisting "original_audio" into a named constant would hide
|
||||
this read from that scan — the gate would conclude core no longer touches the
|
||||
key, and the grandfather entry that documents this debt would go stale. The
|
||||
literal is what keeps the deprecation honest and visible to CI. Leave it.
|
||||
|
||||
Same permissive, path-traversal-guarded posture as the optional side-files: a
|
||||
missing / escaping / unreadable file leaves the pack without a full mix (the
|
||||
player falls back to the separated stems) rather than aborting the load.
|
||||
Returns the manifest-relative string, so callers build its URL exactly as
|
||||
they build a stem's.
|
||||
"""
|
||||
rel_raw = manifest.get("original_audio")
|
||||
if not isinstance(rel_raw, str) or not rel_raw.strip():
|
||||
return None
|
||||
rel = rel_raw.strip()
|
||||
try:
|
||||
target = (source_dir / rel).resolve()
|
||||
target.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
|
||||
return None
|
||||
if not target.is_file():
|
||||
return None
|
||||
log.info(
|
||||
"sloppak: pack uses the deprecated `original_audio:` key (%r) — the full mix "
|
||||
"is a stem (id `full`, feedpak spec §5.3). Re-pack with "
|
||||
"tools/migrate_full_mix_stem.py; support for this key will be removed.",
|
||||
rel,
|
||||
)
|
||||
return rel
|
||||
|
||||
|
||||
# ── Format detection ──────────────────────────────────────────────────────────
|
||||
|
||||
def is_sloppak(path: Path) -> bool:
|
||||
@@ -81,6 +188,116 @@ _unpack_semaphore = threading.BoundedSemaphore(_UNPACK_MAX_CONCURRENCY)
|
||||
_unpack_locks: dict[str, threading.Lock] = {}
|
||||
_unpack_locks_guard = threading.Lock()
|
||||
|
||||
# Destinations with an unpack in flight right now. Eviction MUST skip these: two
|
||||
# unpacks run concurrently, so one finishing could otherwise rmtree the other's
|
||||
# half-written directory and leave that resolver caching an incomplete song.
|
||||
_unpacking: set[Path] = set()
|
||||
_unpacking_guard = threading.Lock()
|
||||
|
||||
# Cap the unpack cache. Stems are already-compressed audio, so an unpacked song
|
||||
# is ~1.1x its zip — the cache is effectively a second, DECOMPRESSED copy of
|
||||
# every song it holds, and it used to grow without any bound at all. A tester
|
||||
# reached 60 GB from a 1800-song library: their whole library, unpacked, because
|
||||
# one caller looped the library calling load_song(). Nothing ever deleted any of
|
||||
# it — not even when the song itself was deleted.
|
||||
#
|
||||
# Default 4 GB ≈ 130 average songs of recency, which is far more than the "the
|
||||
# song I'm playing, and the last few I played" that this cache actually exists
|
||||
# to serve. Override with FEEDBACK_SLOPPAK_CACHE_MAX_MB (0 disables eviction).
|
||||
def _unpack_cache_cap_bytes() -> int:
|
||||
raw = os.environ.get("FEEDBACK_SLOPPAK_CACHE_MAX_MB", "").strip()
|
||||
try:
|
||||
mb = int(raw) if raw else 4096
|
||||
except ValueError:
|
||||
mb = 4096
|
||||
return max(0, mb) * 1024 * 1024
|
||||
|
||||
|
||||
def _dir_size(path: Path) -> int:
|
||||
total = 0
|
||||
for f in path.rglob("*"):
|
||||
try:
|
||||
if f.is_file():
|
||||
total += f.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def _touch(path: Path) -> None:
|
||||
"""Bump mtime so the LRU sweep below treats this song as recently used.
|
||||
|
||||
Reading files out of an unpacked dir doesn't change the DIRECTORY's mtime,
|
||||
so without this the song you are actively playing looks as stale as one you
|
||||
unpacked days ago — and a burst of unpacks could evict it mid-song.
|
||||
"""
|
||||
try:
|
||||
os.utime(path, None)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _evict_unpack_cache(root: Path, keep: Path | None = None) -> None:
|
||||
"""Bound the unpack cache: drop least-recently-used songs until under the cap.
|
||||
|
||||
`keep` is never evicted — it's the song the caller just resolved, i.e. almost
|
||||
certainly the one about to be played.
|
||||
|
||||
Evicting a directory MUST also drop its `_source_cache` entry. Otherwise
|
||||
get_cached_source_dir() keeps handing out a path that no longer exists and
|
||||
the media route 404s on every stem instead of re-unpacking (it only falls
|
||||
back to resolve_source_dir when the cache returns None).
|
||||
"""
|
||||
cap = _unpack_cache_cap_bytes()
|
||||
if cap <= 0:
|
||||
return
|
||||
try:
|
||||
entries = []
|
||||
total = 0
|
||||
for d in root.iterdir():
|
||||
if not d.is_dir():
|
||||
continue
|
||||
try:
|
||||
size = _dir_size(d)
|
||||
mtime = d.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
entries.append((mtime, size, d))
|
||||
total += size
|
||||
if total <= cap:
|
||||
return
|
||||
|
||||
keep_resolved = keep.resolve() if keep else None
|
||||
entries.sort(key=lambda e: e[0]) # oldest first
|
||||
for _mtime, size, d in entries:
|
||||
if total <= cap:
|
||||
break
|
||||
try:
|
||||
if keep_resolved and d.resolve() == keep_resolved:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
# Check-and-delete under ONE hold of the guard. Releasing between the
|
||||
# two would let a resolver mark this dest in-flight and start writing
|
||||
# into it in the gap, and we'd rmtree a song mid-unpack. A resolver
|
||||
# that blocks here simply proceeds afterwards — _unpack_zip recreates
|
||||
# the directory anyway.
|
||||
with _unpacking_guard:
|
||||
if d in _unpacking:
|
||||
continue # another thread is writing this
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
if d.exists():
|
||||
continue # couldn't remove — don't claim the bytes back
|
||||
total -= size
|
||||
with _source_lock:
|
||||
for fn, (cached_dir, _m, _s) in list(_source_cache.items()):
|
||||
if cached_dir == d:
|
||||
_source_cache.pop(fn, None)
|
||||
log.info("sloppak: evicted %s from the unpack cache (%.0f MB)",
|
||||
d.name, size / 1e6)
|
||||
except OSError:
|
||||
log.warning("sloppak: unpack-cache eviction failed", exc_info=True)
|
||||
|
||||
|
||||
def _unpack_lock_for(filename: str) -> threading.Lock:
|
||||
"""Return a stable per-file lock so concurrent unpacks of the same sloppak
|
||||
@@ -145,10 +362,17 @@ def resolve_source_dir(
|
||||
re-unpacks if mtime/size changed, then returns that dir.
|
||||
|
||||
Caches the resolution so subsequent calls are ~free.
|
||||
|
||||
NOTE: this writes the WHOLE pack — every stem — to disk. Only call it for a
|
||||
song you are about to play. To read a *part* of a song (an arrangement, the
|
||||
lyrics, a tone blob), use read_member_bytes(): unpacking a pack to read a few
|
||||
KB of JSON is ~45x write amplification, and doing it in a loop over the
|
||||
library fills the disk with a decompressed copy of every song.
|
||||
"""
|
||||
path = dlc_root / filename
|
||||
stat = path.stat()
|
||||
mtime, size = stat.st_mtime, stat.st_size
|
||||
guarded: Path | None = None # a dir WE unpacked, shielded from eviction
|
||||
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
@@ -159,42 +383,76 @@ def resolve_source_dir(
|
||||
and cached_size == size
|
||||
and cached_dir.exists()
|
||||
):
|
||||
# Mark it recently-used before returning — see _touch().
|
||||
if cached_dir != path:
|
||||
_touch(cached_dir)
|
||||
return cached_dir
|
||||
|
||||
if path.is_dir():
|
||||
resolved = path
|
||||
else:
|
||||
# Zip form — unpack to the cache. Serialize per-file (so concurrent
|
||||
# callers don't rmtree + re-extract the same dest at once) and cap
|
||||
# global unpack concurrency (so a burst can't saturate disk/CPU).
|
||||
dest = unpack_cache_root / _safe_id(filename)
|
||||
with _unpack_lock_for(filename):
|
||||
# Re-check the cache inside the per-file lock — a prior holder may
|
||||
# have just finished unpacking this exact (mtime, size).
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
if (
|
||||
cached
|
||||
and cached[1] == mtime
|
||||
and cached[2] == size
|
||||
and cached[0].exists()
|
||||
):
|
||||
resolved = cached[0]
|
||||
else:
|
||||
with _unpack_semaphore:
|
||||
_unpack_zip(path, dest)
|
||||
resolved = dest
|
||||
try:
|
||||
if path.is_dir():
|
||||
resolved = path
|
||||
else:
|
||||
# Zip form — unpack to the cache. Serialize per-file (so concurrent
|
||||
# callers don't rmtree + re-extract the same dest at once) and cap
|
||||
# global unpack concurrency (so a burst can't saturate disk/CPU).
|
||||
dest = unpack_cache_root / _safe_id(filename)
|
||||
with _unpack_lock_for(filename):
|
||||
# Re-check the cache inside the per-file lock — a prior holder may
|
||||
# have just finished unpacking this exact (mtime, size).
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
if (
|
||||
cached
|
||||
and cached[1] == mtime
|
||||
and cached[2] == size
|
||||
and cached[0].exists()
|
||||
):
|
||||
resolved = cached[0]
|
||||
else:
|
||||
# Shield `dest` from eviction from the moment we start writing
|
||||
# until it is safely in _source_cache. `keep` only shields it
|
||||
# from OUR OWN sweep — a concurrent resolver sweeping with a
|
||||
# different `keep` would delete it, and we would then cache and
|
||||
# return a path that no longer exists. The `finally` below
|
||||
# releases it on EVERY exit, including a failed unpack: leaving
|
||||
# a dest marked in-flight would make it un-evictable forever.
|
||||
with _unpacking_guard:
|
||||
_unpacking.add(dest)
|
||||
guarded = dest
|
||||
with _unpack_semaphore:
|
||||
_unpack_zip(path, dest)
|
||||
resolved = dest
|
||||
# The only moment this cache grows. Sweep here rather than on a
|
||||
# timer so it can never drift far past the cap.
|
||||
_evict_unpack_cache(unpack_cache_root, keep=dest)
|
||||
|
||||
with _source_lock:
|
||||
_source_cache[filename] = (resolved, mtime, size)
|
||||
return resolved
|
||||
with _source_lock:
|
||||
_source_cache[filename] = (resolved, mtime, size)
|
||||
return resolved
|
||||
finally:
|
||||
if guarded is not None:
|
||||
with _unpacking_guard:
|
||||
_unpacking.discard(guarded)
|
||||
|
||||
|
||||
def get_cached_source_dir(filename: str) -> Path | None:
|
||||
"""Return the cached source dir for a sloppak if one is known."""
|
||||
"""Return the cached source dir for a sloppak if one is known AND still there.
|
||||
|
||||
The existence check is load-bearing: callers (media.py) only fall back to
|
||||
resolve_source_dir() when this returns None, so handing back a path that has
|
||||
been evicted — or that the user deleted by hand to reclaim disk — would 404
|
||||
every stem for the rest of the process instead of re-unpacking.
|
||||
"""
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
return cached[0] if cached else None
|
||||
if not cached:
|
||||
return None
|
||||
src = cached[0]
|
||||
if not src.is_dir():
|
||||
_source_cache.pop(filename, None)
|
||||
return None
|
||||
_touch(src)
|
||||
return src
|
||||
|
||||
|
||||
# ── Manifest + song loading ───────────────────────────────────────────────────
|
||||
@@ -233,6 +491,82 @@ def load_manifest(path: Path) -> dict:
|
||||
return _read_manifest_from_zip(path)
|
||||
|
||||
|
||||
_ZIP_ROOT = Path("/_root").resolve()
|
||||
|
||||
|
||||
def _zip_member_key(name: str) -> str | None:
|
||||
"""Canonical lookup key for a zip member name, or None if it escapes the root.
|
||||
|
||||
Collapses './', 'a/../b' and backslash separators — the same normalization
|
||||
_unpack_zip()/safe_join() apply when extracting. Both the name the caller asks
|
||||
for AND the names the archive actually stores must go through this, or a pack
|
||||
that stores './arrangements/lead.json' unpacks fine but reads back as missing.
|
||||
"""
|
||||
safe = safe_join(_ZIP_ROOT, name or "")
|
||||
# None → escapes the root; == root → a degenerate name like "." or "a/..".
|
||||
if safe is None or safe == _ZIP_ROOT:
|
||||
return None
|
||||
return safe.relative_to(_ZIP_ROOT).as_posix()
|
||||
|
||||
|
||||
def read_member_bytes(path: Path, rel: str) -> bytes | None:
|
||||
"""Return the bytes of ONE file inside a sloppak, or None if it isn't there.
|
||||
|
||||
For a zipped sloppak this opens that single member instead of unpacking the
|
||||
archive — the same trick read_cover_bytes() uses to keep the library grid
|
||||
from exploding every pack just to show a cover.
|
||||
|
||||
Reach for this whenever you want a *part* of a song (an arrangement's JSON,
|
||||
the lyrics, a tone blob) rather than a song you're about to play. The
|
||||
alternative, load_song(), calls resolve_source_dir() and writes the WHOLE
|
||||
pack — every stem — into the unpack cache. That is a ~45x write amplification
|
||||
when all you wanted was a few KB of JSON, and looping the library on it
|
||||
unpacks the entire library (got-feedBack/feedBack: a tester hit 60 GB that
|
||||
way). Stems are already-compressed audio, so an unpacked song is ~1.1x its
|
||||
zip: the cache becomes a second, decompressed copy of everything it touches.
|
||||
"""
|
||||
rel = (rel or "").strip()
|
||||
if not rel:
|
||||
return None
|
||||
|
||||
if path.is_dir():
|
||||
target = safe_join(path.resolve(), rel)
|
||||
if target is None or not target.is_file():
|
||||
return None
|
||||
try:
|
||||
return target.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# Zip form — read just that member, no unpack. Zip-slip is rejected before we
|
||||
# open anything, and both sides of the comparison are normalized, so a
|
||||
# non-canonical-but-valid name ('./arrangements/lead.json') resolves the same
|
||||
# way it did when we unpacked first.
|
||||
member = _zip_member_key(rel)
|
||||
if member is None:
|
||||
log.warning("sloppak: rejected unsafe member name %r in %r", rel, path)
|
||||
return None
|
||||
try:
|
||||
with zipfile.ZipFile(str(path), "r") as zf:
|
||||
# Match on the NORMALIZED stored name, and take the LAST match — the
|
||||
# archive may store './x' or a backslash path (Windows tooling), and
|
||||
# if it stores two names that normalize to the same file, _unpack_zip
|
||||
# writes them in order so the last one wins. Reading the raw member by
|
||||
# exact name would miss the first case and return the wrong bytes in
|
||||
# the second. A pack has a handful of members; the scan is free.
|
||||
info = None
|
||||
for cand in zf.infolist():
|
||||
if _zip_member_key(cand.filename) == member:
|
||||
info = cand
|
||||
if info is None or info.is_dir():
|
||||
return None
|
||||
with zf.open(info) as f:
|
||||
return f.read()
|
||||
except (zipfile.BadZipFile, OSError, RuntimeError) as e:
|
||||
log.warning("sloppak: failed to read %r from %s: %s", rel, path.name, e)
|
||||
return None
|
||||
|
||||
|
||||
_COVER_MEDIA_TYPES = {
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".png": "image/png", ".webp": "image/webp",
|
||||
@@ -367,14 +701,21 @@ class LoadedSloppak:
|
||||
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
|
||||
# absent so indexing by song.arrangements index is safe.
|
||||
arrangement_ids: list[str | None] = field(default_factory=list)
|
||||
# Manifest-relative path to the single full-mix audio file, taken from the
|
||||
# manifest `original_audio:` key (e.g. "original/full.ogg"). This is the
|
||||
# pre-separation mixdown that exists alongside the per-instrument `stems`.
|
||||
# None when the key is absent, points outside source_dir, or the file is
|
||||
# missing on disk. Served to the front-end via the highway WS as
|
||||
# `original_audio_url`; the stems plugin uses it to play the untouched mix
|
||||
# when every stem slider is at unity (and the separate stems otherwise).
|
||||
original_audio: str | None = None
|
||||
# Manifest-relative path to the pack's complete mixdown — the whole song in
|
||||
# one file, as heard before source separation. This is the RESERVED `full`
|
||||
# stem (spec §5.3), lifted out of `stems` above precisely because it is NOT
|
||||
# an instrument layer: summing it with the per-instrument stems it was split
|
||||
# into would double the entire song. See partition_stems().
|
||||
#
|
||||
# None when the pack has no mixdown to offer *separately* from its stems —
|
||||
# which includes the common single-mix pack, whose only stem IS the mixdown
|
||||
# (there is nothing to be pristine against, so it stays in `stems`).
|
||||
#
|
||||
# Served to the front-end via the highway WS as `full_mix_url`; the stems
|
||||
# plugin plays it while every stem slider sits at unity and crosses to the
|
||||
# separated stems the moment one drops below 100% — demucs recombination is
|
||||
# lossy, so the mixdown is strictly the better audio when nothing is muted.
|
||||
full_mix: str | None = None
|
||||
|
||||
|
||||
def load_song(
|
||||
@@ -766,6 +1107,13 @@ def load_song(
|
||||
default_on = bool(default_val)
|
||||
stems.append({"id": sid, "file": sfile, "default": default_on})
|
||||
|
||||
# The complete mixdown is a stem (spec §5.3), but it is not a *layer*: lift
|
||||
# it out so that no consumer of `stems` — the mixer, the library's stem
|
||||
# chips, the WS payload — sums it with, or lists it beside, the instruments
|
||||
# it was separated into. `full_mix_stem` is None for a single-mix pack,
|
||||
# whose only stem IS the mixdown and stays in the list.
|
||||
full_mix_stem, stems = partition_stems(stems)
|
||||
|
||||
# Optional keys.json — song-level, instrument-independent key/scale track
|
||||
# (manifest `keys:` key, spec §7.7). Permissive like the other side-files:
|
||||
# missing / unreadable / malformed -> None, never fatal. Stored as a
|
||||
@@ -828,28 +1176,22 @@ def load_song(
|
||||
}
|
||||
|
||||
_fpv = manifest.get("feedpak_version")
|
||||
# Optional full-mix audio — manifest `original_audio:` key. The single
|
||||
# pre-separation mixdown that ships alongside the per-instrument stems.
|
||||
# Same permissive, path-traversal-guarded posture as drum_tab above: a
|
||||
# missing/escaping/absent file simply leaves the full mix unavailable (the
|
||||
# player falls back to the separate stems) rather than aborting the load.
|
||||
# We store the manifest-relative string so server.py can build its URL the
|
||||
# same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint).
|
||||
original_audio_data: str | None = None
|
||||
original_audio_rel = manifest.get("original_audio")
|
||||
if isinstance(original_audio_rel, str) and original_audio_rel.strip():
|
||||
rel = original_audio_rel.strip()
|
||||
try:
|
||||
oa_path = (source_dir / rel).resolve()
|
||||
oa_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
|
||||
oa_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
|
||||
oa_path = None
|
||||
if oa_path is not None and oa_path.is_file():
|
||||
original_audio_data = rel
|
||||
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
|
||||
# above (spec §5.3) — no path work needed, it was validated with the other
|
||||
# stems and its URL is built the same way. Only when the pack has no `full`
|
||||
# stem do we fall back to the DEPRECATED `original_audio:` key, which is the
|
||||
# shape every pack written before feedpak 1.15.0 uses.
|
||||
if full_mix_stem is not None:
|
||||
full_mix_data: str | None = full_mix_stem["file"]
|
||||
elif find_full_mix(stems) is not None:
|
||||
# Single-mix pack: its ONE stem is the mixdown, so there is no mixdown to
|
||||
# offer *apart from* the stems. Never fall through to the legacy key here
|
||||
# — a pack that both carries a `full` stem and names the old key would
|
||||
# otherwise surface the mixdown twice (once as the stem the player is
|
||||
# already playing, once as a "pristine" track to cross to).
|
||||
full_mix_data = None
|
||||
else:
|
||||
full_mix_data = _legacy_full_mix(manifest, source_dir)
|
||||
|
||||
return LoadedSloppak(
|
||||
song=song,
|
||||
@@ -864,7 +1206,7 @@ def load_song(
|
||||
keys=keys_data,
|
||||
notation_by_id=notation_by_id_data,
|
||||
arrangement_ids=arrangement_ids_acc,
|
||||
original_audio=original_audio_data,
|
||||
full_mix=full_mix_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -909,7 +1251,7 @@ def extract_meta(path: Path) -> dict:
|
||||
tuning_offsets = _tuning_for_meta(arr_list)
|
||||
|
||||
stems_list = manifest.get("stems", []) or []
|
||||
stem_ids: list[str] = []
|
||||
valid_stems: list[dict] = []
|
||||
for s in stems_list:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
@@ -923,7 +1265,13 @@ def extract_meta(path: Path) -> dict:
|
||||
isinstance(sid, str) and sid
|
||||
and isinstance(sfile, str) and sfile
|
||||
):
|
||||
stem_ids.append(sid)
|
||||
valid_stems.append({"id": sid, "file": sfile})
|
||||
# Partition exactly as load_song() does, for the same reason the library
|
||||
# filter must not lie: `full` is the mixdown, not an instrument (spec §5.3).
|
||||
# A separated pack that retains it would otherwise offer the user a "full"
|
||||
# stem chip alongside guitar/bass/drums and count it as a seventh stem.
|
||||
_full, instrument_stems = partition_stems(valid_stems)
|
||||
stem_ids = [s["id"] for s in instrument_stems]
|
||||
stem_count = len(stem_ids)
|
||||
|
||||
return {
|
||||
|
||||
+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)
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -2384,6 +2385,53 @@ def register_plugin_api(app: FastAPI):
|
||||
return _plugin_file_response(request, script_file, "application/javascript")
|
||||
return Response("", status_code=404)
|
||||
|
||||
# ── Module-graph cache busting (#879) ────────────────────────────────
|
||||
#
|
||||
# ES modules are evaluated ONCE PER URL PER DOCUMENT. Re-inserting a
|
||||
# <script type="module"> whose src the module map has already seen fires
|
||||
# `load` but does NOT re-run the body. So re-loading a plugin — a rollback,
|
||||
# and (see below) an upgrade too — silently kept the OLD module live while
|
||||
# the loader recorded success: a no-op that reported it worked.
|
||||
#
|
||||
# Busting the ENTRY url does not help. A module plugin's screen.js is a
|
||||
# one-line `import './src/main.js'`, and a relative specifier resolves
|
||||
# against the base URL WITH THE QUERY DROPPED — so a ?v= token never reaches
|
||||
# the graph. Driving a real browser through install -> upgrade -> rollback and
|
||||
# counting evaluations of src/main.js gives ONE. The upgrade re-runs the shim
|
||||
# at its new ?v= URL; the shim imports './src/main.js'; that resolves to the
|
||||
# same URL; the module map returns the already-evaluated old module.
|
||||
#
|
||||
# So the token goes in the PATH: /api/plugins/<id>/g/<n>/screen.js. Every
|
||||
# relative import inherits it at every depth — for free, with no
|
||||
# import-specifier rewriting (which could never see `import(expr)` anyway).
|
||||
#
|
||||
# WHY A PATH REWRITE AND NOT TWO MIRRORED ROUTES. The token shifts the base
|
||||
# URL, so EVERYTHING a module resolves relatively moves with it — not just
|
||||
# imports. `new URL('../assets/worklet.js', import.meta.url)` from
|
||||
# /api/plugins/x/g/1/src/main.js resolves to /api/plugins/x/g/1/assets/... .
|
||||
# Mirroring only screen.js and src/ would fix imports and 404 every asset,
|
||||
# worklet and wasm file the graph reaches — and would silently break again the
|
||||
# next time someone adds a plugin route. Stripping the segment before routing
|
||||
# makes every plugin route, present and future, work under the prefix.
|
||||
#
|
||||
# The token is opaque: it is never joined into a filesystem path (and is gone
|
||||
# by the time any handler runs), so containment still rests entirely on the
|
||||
# same safe_join the un-prefixed routes use.
|
||||
_GEN_PREFIX = re.compile(r"^(/api/plugins/[^/]+)/g/[^/]+(/.+)$")
|
||||
|
||||
@app.middleware("http")
|
||||
async def _strip_plugin_generation_prefix(request: Request, call_next):
|
||||
m = _GEN_PREFIX.match(request.scope.get("path", ""))
|
||||
if m:
|
||||
# Starlette routes on scope["path"] alone. raw_path is deliberately left
|
||||
# ALONE: it is informational, and re-encoding the rewritten str back to
|
||||
# bytes would have to guess a codec — `.encode("latin-1")` raises
|
||||
# UnicodeEncodeError on a perfectly valid plugin file like src/工具.js,
|
||||
# 500ing a request the un-prefixed route serves fine. Leaving raw_path as
|
||||
# the client actually sent it is also simply more truthful for logs.
|
||||
request.scope["path"] = m.group(1) + m.group(2)
|
||||
return await call_next(request)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/settings.html")
|
||||
def plugin_settings_html(plugin_id: str):
|
||||
with PLUGINS_LOCK:
|
||||
|
||||
@@ -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,37 @@
|
||||
{
|
||||
"badge_requirement": {
|
||||
"songs": 5,
|
||||
"min_stars": 2
|
||||
},
|
||||
"families": [
|
||||
{ "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] },
|
||||
{ "key": "blues", "match": ["blues"] },
|
||||
{ "key": "jazz", "match": ["jazz", "bebop", "swing", "bossa"] },
|
||||
{ "key": "funk", "match": ["funk", "disco"] },
|
||||
{ "key": "rock", "match": ["rock", "punk", "grunge", "shoegaze"] }
|
||||
],
|
||||
"genres": {
|
||||
"blues": { "virtuoso_nodes": { "guitar": ["blues_shuffle"] } },
|
||||
"rock": { "virtuoso_nodes": { "guitar": ["rock_power_backbeat"] } },
|
||||
"metal": { "virtuoso_nodes": { "guitar": ["melodic_metal_gallop"] } },
|
||||
"funk": { "virtuoso_nodes": { "guitar": ["sixteenth_pocket"] } },
|
||||
"jazz": { "virtuoso_nodes": { "guitar": ["vl_shells"] } }
|
||||
},
|
||||
"drill_labels": {
|
||||
"blues_shuffle": "Blues Shuffle",
|
||||
"rock_power_backbeat": "Power Chords & Backbeat",
|
||||
"melodic_metal_gallop": "Gallop Picking",
|
||||
"sixteenth_pocket": "16th Pocket",
|
||||
"vl_shells": "Shell Voicings"
|
||||
},
|
||||
"graded_instruments": [
|
||||
"guitar",
|
||||
"keys"
|
||||
],
|
||||
"instruments": [
|
||||
"guitar",
|
||||
"bass",
|
||||
"keys",
|
||||
"drums"
|
||||
]
|
||||
}
|
||||
@@ -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,678 @@
|
||||
"""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 _genre_family(gkey):
|
||||
"""First family whose keyword appears in the genre key (substring — MB's
|
||||
vocabulary is open: 'metalcore' must hit the 'metal' family without an
|
||||
exact alias). List order decides ambiguity: families are checked top to
|
||||
bottom, so 'blues rock' lands on whichever of blues/rock is listed first."""
|
||||
for fam in _state["passports_content"].get("families") or []:
|
||||
if not isinstance(fam, dict):
|
||||
continue
|
||||
for kw in fam.get("match") or []:
|
||||
if isinstance(kw, str) and kw and kw in gkey:
|
||||
return fam.get("key")
|
||||
return None
|
||||
|
||||
|
||||
def _badge_requirement(gkey, instrument="guitar"):
|
||||
cfg = _state["passports_content"]
|
||||
req = dict(cfg.get("badge_requirement") or {})
|
||||
req.setdefault("songs", 5)
|
||||
req.setdefault("min_stars", 2)
|
||||
# Exact per-genre override wins; otherwise the genre inherits its FAMILY's
|
||||
# requirement — so 'death metal' / 'metalcore' passports carry the metal
|
||||
# drill without curating every MB sub-genre by hand.
|
||||
genres_cfg = cfg.get("genres") or {}
|
||||
override = genres_cfg.get(gkey)
|
||||
if not isinstance(override, dict):
|
||||
family = _genre_family(gkey)
|
||||
override = genres_cfg.get(family) if family else None
|
||||
if isinstance(override, dict):
|
||||
req.update(override)
|
||||
# virtuoso_nodes: {instrument: [node_ids]} — a passport only carries its
|
||||
# own instrument's drills. A flat list keeps meaning guitar (back-compat;
|
||||
# virtuoso's drill content is guitar-first).
|
||||
nodes = req.get("virtuoso_nodes") or []
|
||||
if isinstance(nodes, dict):
|
||||
nodes = nodes.get(instrument) or []
|
||||
elif instrument != "guitar":
|
||||
nodes = []
|
||||
req["virtuoso_nodes"] = [n for n in nodes if isinstance(n, str)]
|
||||
return req
|
||||
|
||||
|
||||
def _drill_by_node():
|
||||
doc = _load_json(_drill_file(), {})
|
||||
if not isinstance(doc, dict):
|
||||
return None, {}
|
||||
snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {}
|
||||
by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {}
|
||||
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.3",
|
||||
"version": "3.31.5",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
+215
-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).
|
||||
@@ -14664,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;
|
||||
@@ -14691,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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "keys_highway_3d",
|
||||
"name": "Keys Highway 3D",
|
||||
"version": "0.2.0",
|
||||
"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,
|
||||
|
||||
@@ -813,20 +813,36 @@
|
||||
_writeStore(STORE_KEYS.midiPick, JSON.stringify({ id: id || '', name: name || '', key: key || '' }));
|
||||
}
|
||||
|
||||
function _midiAutoConnect(allowFallback) {
|
||||
// Recovery (sources-changed after unplug) passes false: never switch to a
|
||||
// fallback input, because _midiConnect persists the pick and that would
|
||||
// overwrite the user's saved device on a transient multi-device unplug
|
||||
// (the original returns on replug and reconnects then).
|
||||
if (allowFallback === undefined) allowFallback = true;
|
||||
const inputs = _midiSources();
|
||||
if (!inputs.length) return;
|
||||
const saved = _readSavedPick();
|
||||
// Explicit "None" opt-out.
|
||||
if (saved && saved.id === '' && saved.name === '') return;
|
||||
// Prefer the globally-unique logicalSourceKey, then the legacy bare
|
||||
// sourceId, then case-insensitive name (Chrome on Linux regenerates ids
|
||||
// per page load), then first non-loopback.
|
||||
// Pure decision logic (exported via __test): pick which device to
|
||||
// auto-connect to from the current source list, the domain-wide selection
|
||||
// (`globalKey`, from Settings → Input Setup), and this plugin's own legacy
|
||||
// saved pick. Returns null for "connect to nothing" (explicit None opt-out,
|
||||
// or the configured device currently absent during hotplug recovery).
|
||||
//
|
||||
// The domain-wide selection is the SOURCE OF TRUTH (checked first): a device
|
||||
// configured globally must never be overridden by a stale plugin-local pick
|
||||
// or an arbitrary first-device fallback — that override was the bug. The
|
||||
// local pick is retained only as a fallback BELOW the global (and for
|
||||
// name-recovery when the global's logicalSourceKey went stale, e.g. a
|
||||
// browser that regenerates MIDI port ids across reloads). Auto-connect no
|
||||
// longer writes the local pick, so it only ever holds a value an explicit
|
||||
// selection put there (or a stale one from a pre-fix build — the global
|
||||
// still wins over it).
|
||||
function _pickMidiTarget(inputs, saved, globalKey, allowFallback) {
|
||||
if (!inputs.length) return null;
|
||||
const notBlocked = (i) => !!i && !_MIDI_BLOCKLIST_RE.test(i.name || '');
|
||||
// Explicit "None" opt-out (set only via the device-select API).
|
||||
if (saved && saved.id === '' && saved.name === '') return null;
|
||||
|
||||
// 1. Domain-wide selection (Settings → Input Setup) — source of truth.
|
||||
if (globalKey) {
|
||||
const g = inputs.find(i => i.key === globalKey);
|
||||
if (notBlocked(g)) return g;
|
||||
}
|
||||
|
||||
// 2. Legacy plugin-local pick, as a fallback below the global. Prefer the
|
||||
// globally-unique logicalSourceKey, then the legacy bare sourceId, then
|
||||
// case-insensitive name (Chrome on Linux regenerates ids per page load).
|
||||
let target = null;
|
||||
if (saved && saved.key) target = inputs.find(i => i.key === saved.key) || null;
|
||||
if (!target && saved && saved.id) target = inputs.find(i => i.id === saved.id) || null;
|
||||
@@ -834,23 +850,50 @@
|
||||
const n = saved.name.toLowerCase();
|
||||
target = inputs.find(i => (i.name || '').toLowerCase() === n) || null;
|
||||
}
|
||||
// Never honour a saved pick that's a loopback / "Midi Through" port — it
|
||||
// carries no device input, so a stale pick silently eats every note. The
|
||||
// saved-pick lookups above bypass the block-list; re-apply it here.
|
||||
if (target && _MIDI_BLOCKLIST_RE.test(target.name || '')) target = null;
|
||||
if (!target) {
|
||||
// Skip the substitute ONLY when a saved pick exists but is currently
|
||||
// absent (recovery: preserve it, don't clobber on a transient unplug).
|
||||
// With no saved pick at all, a fallback is the intended first-hotplug
|
||||
// auto-connect — allow it even in recovery.
|
||||
const hasSavedPick = !!(saved && (saved.key || saved.id || saved.name));
|
||||
if (!allowFallback && hasSavedPick) return;
|
||||
target = inputs.find(i => !_MIDI_BLOCKLIST_RE.test(i.name || '')) || inputs[0];
|
||||
}
|
||||
// Never honour a saved pick that resolves to a loopback / "Midi Through"
|
||||
// port — it carries no device input, so it silently eats every note.
|
||||
if (target && !notBlocked(target)) target = null;
|
||||
if (target) return target;
|
||||
|
||||
// 3. Nothing configured resolved to a present device. In recovery
|
||||
// (allowFallback=false) with a configured preference — a global pick or a
|
||||
// saved pick — that's currently absent, preserve it rather than switching
|
||||
// to an arbitrary device on a transient multi-device unplug. With no
|
||||
// preference at all, a first-device grab is the intended first-hotplug
|
||||
// auto-connect, allowed even in recovery.
|
||||
const hasPreference = !!(globalKey || (saved && (saved.key || saved.id || saved.name)));
|
||||
if (!allowFallback && hasPreference) return null;
|
||||
// Connect to nothing rather than a loopback: if every present device is
|
||||
// blocklisted, a first-device grab would attach to a "Midi Through"/IAC
|
||||
// port that carries no input and silently eats every note.
|
||||
return inputs.find(notBlocked) || null;
|
||||
}
|
||||
|
||||
function _midiAutoConnect(allowFallback) {
|
||||
// Recovery (sources-changed after unplug) passes false: never switch to a
|
||||
// fallback input on a transient multi-device unplug (the configured
|
||||
// device returns on replug and reconnects then). Auto-connect is
|
||||
// non-persisting (persist omitted → false): it opens the resolved device
|
||||
// for this session WITHOUT writing the plugin-local pick or the shared
|
||||
// domain selection, so opening this highway can't clobber the user's
|
||||
// globally-configured device.
|
||||
if (allowFallback === undefined) allowFallback = true;
|
||||
const inputs = _midiSources();
|
||||
const saved = _readSavedPick();
|
||||
const mi = _mi();
|
||||
const globalKey = mi && typeof mi.getSelected === 'function' ? mi.getSelected() : null;
|
||||
const target = _pickMidiTarget(inputs, saved, globalKey, allowFallback);
|
||||
if (!target) return;
|
||||
_midiConnect(target.id, target.name, target.key);
|
||||
}
|
||||
|
||||
async function _midiConnect(id, name, key) {
|
||||
// `persist` gates the two preference writes. Only an EXPLICIT device
|
||||
// selection (the device-select API) persists: it writes the plugin-local
|
||||
// pick AND the shared domain selection (`mi.select`, so the user's choice
|
||||
// becomes the global default). Auto-connect and programmatic opens pass
|
||||
// falsy — they open the resolved device for this session only, never
|
||||
// touching either store, so they can't clobber a globally-configured device.
|
||||
async function _midiConnect(id, name, key, persist) {
|
||||
// Capture our generation AFTER _midiDetach()'s own bump, so a later
|
||||
// detach (device removal / new connect / opt-out) reliably supersedes us.
|
||||
_midiDetach();
|
||||
@@ -861,7 +904,7 @@
|
||||
for (const inst of _instances) {
|
||||
if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld();
|
||||
}
|
||||
_writeSavedPick(id || '', name || '', key || '');
|
||||
if (persist) _writeSavedPick(id || '', name || '', key || '');
|
||||
const mi = _mi();
|
||||
if ((id || key) && mi) {
|
||||
// Prefer the globally-unique logicalSourceKey so two providers that
|
||||
@@ -874,13 +917,19 @@
|
||||
const lkey = src.key || ('web-midi::' + src.id);
|
||||
_midiInput = { id: src.id, name: src.name, key: lkey };
|
||||
_midiJustConnected = true;
|
||||
// Only an explicit selection writes the shared global default;
|
||||
// open takes the logicalSourceKey directly, so select() is not
|
||||
// needed to open — it exists purely to set the global. Persist it
|
||||
// BEFORE the no-instance early return so a settings-panel pick with
|
||||
// no live renderer still updates the shared default (best-effort:
|
||||
// a select hiccup must not abort the connect).
|
||||
if (persist) { try { await mi.select(lkey); } catch (_) { /* best-effort */ } }
|
||||
// No live renderer to consume OR release a session — don't hold one
|
||||
// open (settings-only ensure-init, or the last instance was torn
|
||||
// down during async discovery). The pick is saved; a later renderer
|
||||
// mount re-runs auto-connect and opens for real, releasing on destroy.
|
||||
// down during async discovery). A later renderer mount re-runs
|
||||
// auto-connect and opens for real, releasing on destroy.
|
||||
if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; }
|
||||
try {
|
||||
await mi.select(lkey);
|
||||
const res = await mi.open({ requester: PLUGIN_ID, logicalSourceKey: lkey });
|
||||
// A newer _midiConnect (device switch / None / replug) ran while
|
||||
// we awaited open — discard this stale session so we don't wire a
|
||||
@@ -1039,10 +1088,11 @@
|
||||
window.keysH3dGetMidiInputId = function () { return _midiInput ? _midiInput.id : ''; };
|
||||
window.keysH3dSetMidiInput = function (id) {
|
||||
// `id` may be a logicalSourceKey (new host calls) or a legacy sourceId.
|
||||
// Explicit user selection → persist (local pick + shared global default).
|
||||
const src = id
|
||||
? (_midiSources().find(s => s.key === id) || _midiSources().find(s => s.id === id))
|
||||
: null;
|
||||
_midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '');
|
||||
_midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '', true);
|
||||
return true;
|
||||
};
|
||||
window.keysH3dGetMidiChannel = function () { return _cfg.midiChannel; };
|
||||
@@ -1547,6 +1597,8 @@
|
||||
|
||||
function _aiOpen(req) {
|
||||
// Opening a MIDI source connects the corresponding Web MIDI input.
|
||||
// Programmatic open (audio-input source.open) — non-persisting: it must
|
||||
// not rewrite the user's saved pick or the shared global default.
|
||||
const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey));
|
||||
const inputs = _midiSources(); // carries .key (logicalSourceKey), unlike _midiListInputs()
|
||||
if (idx == null || idx >= inputs.length) {
|
||||
@@ -1600,6 +1652,41 @@
|
||||
_aiRegisteredCount = 0;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* 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 → 100% 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
|
||||
* ====================================================================== */
|
||||
@@ -1849,6 +1936,18 @@
|
||||
_rigOut.lookZ = _camPreset.lookZ;
|
||||
return _rigOut;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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-key approach glow: a key lights in its pitch-class color ONLY while a
|
||||
// note is heading for it, ramping up the closer that note gets to the hit-line.
|
||||
const KEY_GLOW_AHEAD = 2.0; // seconds before the hit-line a key starts to light
|
||||
@@ -3259,7 +3358,33 @@
|
||||
}
|
||||
_camX += (_camTargetX - _camX) * CAM_PAN_LERP;
|
||||
_camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP;
|
||||
{ const r = _rig(); cam.position.set(_camX, r.y * K * _camZoom, r.z * K * _camZoom); cam.lookAt(_camX, r.lookY * K * _camZoom, r.lookZ * K * _camZoom); }
|
||||
{
|
||||
const r = _rig();
|
||||
let _cx = _camX, _cy = r.y * K * _camZoom, _cz = r.z * K * _camZoom;
|
||||
let _lx = _camX, _ly = r.lookY * K * _camZoom, _lz = r.lookZ * K * _camZoom;
|
||||
// Camera Director free-cam offsets (per-panel-aware), layered on top
|
||||
// of the auto-framing so pan/zoom-follow still works. Dolly/height/
|
||||
// orbit act on the camera-from-target vector; pan/pitch shift the
|
||||
// look target. NaN-safe; null/disabled bridge → stock.
|
||||
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);
|
||||
}
|
||||
|
||||
for (const km of keyMeshes.values()) km.userData.glow = 0;
|
||||
for (const { mesh, note, len, label } of noteMeshes) {
|
||||
@@ -3960,6 +4085,8 @@
|
||||
};
|
||||
// Pure data-layer + scoring hooks for headless tests.
|
||||
window.slopsmithViz_keys_highway_3d.__test = {
|
||||
_resolveFreeCam,
|
||||
_ssApi,
|
||||
beatDurSec,
|
||||
flattenNotation,
|
||||
keyRange,
|
||||
@@ -3993,6 +4120,7 @@
|
||||
FX_DEFAULTS,
|
||||
FX_RANGES,
|
||||
_classifyTiming,
|
||||
_pickMidiTarget,
|
||||
};
|
||||
|
||||
// Headless verification hook: lets Playwright drive synthetic note-ons
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
//
|
||||
// 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
|
||||
@@ -21,6 +31,9 @@ for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i].replac
|
||||
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;
|
||||
@@ -69,6 +82,64 @@ async function clientMetrics() {
|
||||
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',
|
||||
@@ -86,9 +157,21 @@ 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`;
|
||||
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
|
||||
out += `> on the 2D + 3D highway, and screen-entry (plugin inject → interactive) for\n`;
|
||||
out += `> editor / notedetect / highway_3d with a real chart loaded. Run this harness\n`;
|
||||
out += `> against an environment with charts in \`DLC_DIR\` to fill those in.\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);
|
||||
|
||||
+456
-9982
File diff suppressed because it is too large
Load Diff
@@ -519,7 +519,9 @@ window.feedBack.audio = Object.assign(window.feedBack.audio || {}, {
|
||||
readSongVolume: _readSongVolume,
|
||||
});
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', _init);
|
||||
} else {
|
||||
_init();
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
const SCHEMA = 'feedBack.audio_effects.diagnostics.v1';
|
||||
const PLAN_SCHEMA = 'feedBack.audio_effects.chain_plan.v1';
|
||||
// Pre-rebrand plugins (rig_builder <= 2.9.x) still send the old schema id — accept it as an alias.
|
||||
const LEGACY_PLAN_SCHEMA = 'slopsmith.audio_effects.chain_plan.v1';
|
||||
const OWNER_ID = 'core.audio.effects';
|
||||
const DEFAULT_ROUTE_KEY = 'desktop-main';
|
||||
const DEFAULT_TIMEOUT_MS = 2000;
|
||||
@@ -734,7 +736,7 @@
|
||||
const errors = [];
|
||||
const source = _plainObject(rawPlan);
|
||||
const schema = _string(source.schema || source.version, PLAN_SCHEMA);
|
||||
if (schema !== PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
|
||||
if (schema !== PLAN_SCHEMA && schema !== LEGACY_PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
|
||||
const planRoute = _safeRoute(source.routeKey || source.route || routeKey);
|
||||
if (planRoute !== routeKey) errors.push('Chain plan route does not match selected route');
|
||||
const providerId = _safeId(source.providerId || provider.providerId, provider.providerId);
|
||||
|
||||
@@ -111,7 +111,9 @@
|
||||
|
||||
// Announce once after the document parses, so any listener wired during page
|
||||
// load can sync without special-casing (consumers may also just call get()).
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', announce, { once: true });
|
||||
} else {
|
||||
announce();
|
||||
|
||||
+723
-2161
File diff suppressed because it is too large
Load Diff
@@ -1,621 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FeedBack</title>
|
||||
<!-- Placeholder favicon — emoji SVG data URI. Swap for a real logo later. See #55. -->
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='14' font-size='14'%3E%F0%9F%8E%B8%3C/text%3E%3C/svg%3E">
|
||||
<!-- Tailwind utility classes are served from a prebuilt static
|
||||
stylesheet (regenerated by scripts/build-tailwind.sh). The old
|
||||
Play CDN (cdn.tailwindcss.com) JIT scanned the DOM ~1.8x/sec
|
||||
on the main thread, dropping ~26% of frames with the 3D
|
||||
highway running — see feedBack-desktop#110. Theme extensions
|
||||
(dark/accent/gold colors, Inter font) live in tailwind.config.js. -->
|
||||
<link rel="stylesheet" href="/static/tailwind.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/vendor/shepherd.css">
|
||||
<link rel="stylesheet" href="/static/tour-engine.css">
|
||||
<!-- Diagnostics console capture must wrap console.* before any other
|
||||
script logs anything; load it as early as possible. See
|
||||
docs/diagnostics-bundle-spec.md (feedBack#166). -->
|
||||
<script src="/static/diagnostics.js"></script>
|
||||
<script src="/static/capabilities.js"></script>
|
||||
<script src="/static/capabilities/library.js"></script>
|
||||
<script src="/static/capabilities/tuning.js"></script>
|
||||
<script src="/static/capabilities/working-tuning.js"></script>
|
||||
<script src="/static/capabilities/audio-session.js"></script>
|
||||
<script src="/static/capabilities/audio-effects.js"></script>
|
||||
<script src="/static/capabilities/playback.js"></script>
|
||||
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
|
||||
<script src="/static/capabilities/library-card-actions.js"></script>
|
||||
<script src="/static/capabilities/visualization.js"></script>
|
||||
<script src="/static/capabilities/note-detection.js"></script>
|
||||
<script src="/static/capabilities/midi-input.js"></script>
|
||||
</head>
|
||||
<body class="bg-dark-900 text-gray-200 font-display">
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav id="navbar" class="fixed top-0 w-full z-50 transition-all duration-300">
|
||||
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<div class="flex items-end gap-1.5">
|
||||
<a href="#" onclick="showScreen('home');return false" class="text-xl font-bold bg-gradient-to-r from-accent-light to-purple-400 bg-clip-text text-transparent">
|
||||
FeedBack
|
||||
</a>
|
||||
<span id="app-version" class="text-xs text-gray-600 mb-0.5"></span>
|
||||
</div>
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
<a href="#" onclick="showScreen('home');return false" class="text-sm text-gray-400 hover:text-white transition">Library</a>
|
||||
<a href="#" onclick="showScreen('favorites');return false" class="text-sm text-gray-400 hover:text-white transition">Favorites</a>
|
||||
<a href="#" onclick="document.getElementById('upload-songs-file').click();return false" class="text-sm text-gray-400 hover:text-white transition">Upload</a>
|
||||
<span id="nav-plugins" class="contents"></span>
|
||||
<a href="#" onclick="showScreen('settings');return false" class="text-sm text-gray-400 hover:text-white transition">Settings</a>
|
||||
</div>
|
||||
<!-- Mobile menu -->
|
||||
<button onclick="document.getElementById('mobile-menu').classList.toggle('hidden')" class="md:hidden text-gray-400">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="mobile-menu" class="hidden md:hidden bg-dark-800/95 backdrop-blur border-t border-gray-800">
|
||||
<div class="px-6 py-4 flex flex-col gap-3">
|
||||
<a href="#" onclick="showScreen('home');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Library</a>
|
||||
<a href="#" onclick="showScreen('favorites');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Favorites</a>
|
||||
<a href="#" onclick="document.getElementById('upload-songs-file').click();this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Upload</a>
|
||||
<span id="mobile-nav-plugins" class="flex flex-col gap-2 border-t border-b border-gray-800 py-2 my-1">
|
||||
<span class="text-xs text-gray-600 uppercase tracking-wider">Plugins</span>
|
||||
</span>
|
||||
<a href="#" onclick="showScreen('settings');this.parentElement.parentElement.classList.add('hidden');return false" class="text-gray-400 hover:text-white">Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
|
||||
level so it stays reachable regardless of which screen is active. -->
|
||||
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
|
||||
<!-- ══ HOME (Hero + Library) ══════════════════════════════════════════ -->
|
||||
<div id="home" class="screen active">
|
||||
<!-- Library -->
|
||||
<section id="library-section" class="max-w-7xl mx-auto px-6 pt-24 pb-16">
|
||||
<div id="alpha-warning-banner" class="hidden mb-6 px-4 py-3 bg-amber-900/30 border border-amber-500/30 rounded-xl flex items-start gap-3" role="status">
|
||||
<span class="text-amber-400 text-lg leading-none mt-0.5" aria-hidden="true">⚠</span>
|
||||
<div class="text-sm text-amber-100">
|
||||
<strong class="text-amber-300">Heads up — this is an alpha build.</strong>
|
||||
Some things may be broken or change without warning. If you hit a bug, please file an issue. Thanks for trying it out!
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-10">
|
||||
<div>
|
||||
<h2 id="lib-title" class="text-3xl font-bold text-white">Your Library</h2>
|
||||
<p class="text-gray-500 mt-1" id="lib-count"></p>
|
||||
</div>
|
||||
<div class="flex gap-3 w-full md:w-auto flex-wrap">
|
||||
<select id="lib-provider" onchange="setLibraryProvider(this.value)"
|
||||
aria-label="Library source"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none" title="Library source">
|
||||
<option value="local">My Library</option>
|
||||
</select>
|
||||
<!-- View toggle -->
|
||||
<div class="flex bg-dark-700 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<button id="view-grid-btn" onclick="setLibView('grid')" class="px-3 py-2.5 text-sm transition" title="Grid view">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
|
||||
</button>
|
||||
<button id="view-tree-btn" onclick="setLibView('tree')" class="px-3 py-2.5 text-sm transition" title="Artist/Album view">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>
|
||||
</button>
|
||||
<button id="view-folder-btn" onclick="setLibView('folder')" class="px-3 py-2.5 text-sm transition" title="Folder view">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Grid controls -->
|
||||
<select id="lib-sort" onchange="sortLibrary()"
|
||||
class="lib-nontree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="artist">Artist A-Z</option>
|
||||
<option value="artist-desc">Artist Z-A</option>
|
||||
<option value="title">Title A-Z</option>
|
||||
<option value="title-desc">Title Z-A</option>
|
||||
<option value="recent">Recently Added</option>
|
||||
<option value="year-desc">Year (newest)</option>
|
||||
<option value="year">Year (oldest)</option>
|
||||
<option value="tuning">Tuning</option>
|
||||
<option value="difficulty">Difficulty (easiest first)</option>
|
||||
<option value="difficulty-desc">Difficulty (hardest first)</option>
|
||||
</select>
|
||||
<!-- Format filter (shared) -->
|
||||
<select id="lib-format" onchange="sortLibrary()"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none" title="Filter by format">
|
||||
<option value="">All formats</option>
|
||||
<option value="sloppak">Feedpak</option>
|
||||
<option value="loose">Folder</option>
|
||||
</select>
|
||||
<!-- Tree controls -->
|
||||
<button onclick="toggleAllArtists(true)" class="lib-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Expand All</button>
|
||||
<button onclick="toggleAllArtists(false)" class="lib-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Collapse All</button>
|
||||
<!-- Filters drawer toggle (feedBack#129) -->
|
||||
<button onclick="toggleLibFilters()" id="btn-lib-filters"
|
||||
class="bg-dark-700 border border-gray-800 hover:border-accent/40 rounded-xl px-4 py-2.5 text-sm text-gray-300 transition flex items-center gap-2"
|
||||
title="Filter by parts, tuning, lyrics">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h18M6 12h12M10 20h4"/></svg>
|
||||
<span>Filters</span>
|
||||
<span id="lib-filters-count" class="hidden bg-accent/30 text-accent-light text-xs font-semibold rounded-full px-1.5 py-0.5 min-w-[1.25rem] text-center">0</span>
|
||||
</button>
|
||||
<!-- Shared -->
|
||||
<input type="text" id="lib-filter" placeholder="Search songs..." oninput="filterLibrary()"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 focus:ring-1 focus:ring-accent/30 outline-none flex-1 md:w-60 transition">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Active-filter chip row (only visible when filters are set, feedBack#129) -->
|
||||
<div id="lib-filter-chips" class="hidden flex flex-wrap gap-2 mb-5"></div>
|
||||
<div id="lib-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
<!-- Cards populated by JS -->
|
||||
</div>
|
||||
<div id="lib-tree" class="space-y-2 hidden">
|
||||
<!-- Tree populated by JS -->
|
||||
</div>
|
||||
<div id="lib-folder-tree" class="space-y-1 hidden">
|
||||
<!-- Folder tree populated by JS when Folders source is active -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Filters drawer (feedBack#129/#69/#22) ═════════════════════ -->
|
||||
<div id="lib-filter-overlay" class="fixed inset-0 bg-black/40 z-40 hidden"
|
||||
onclick="toggleLibFilters(false)"></div>
|
||||
<aside id="lib-filter-drawer"
|
||||
class="fixed top-0 right-0 h-full w-full sm:w-96 bg-dark-800 border-l border-gray-800 z-50 transform translate-x-full transition-transform duration-200 overflow-y-auto">
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-white">Filters</h3>
|
||||
<button onclick="toggleLibFilters(false)" class="text-gray-500 hover:text-white" title="Close">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Arrangements</div>
|
||||
<p class="text-xs text-gray-600 mb-3">Click cycles: any → require → exclude</p>
|
||||
<div id="filter-arrangements" class="flex flex-wrap gap-2"></div>
|
||||
</section>
|
||||
|
||||
<section id="filter-stems-section">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Stems <span class="text-gray-600 normal-case font-normal">(sloppak)</span></div>
|
||||
<p class="text-xs text-gray-600 mb-3">Click cycles: any → require → exclude</p>
|
||||
<div id="filter-stems" class="flex flex-wrap gap-2"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">Lyrics</div>
|
||||
<div id="filter-lyrics" class="flex flex-wrap gap-2"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<details>
|
||||
<summary class="cursor-pointer flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">
|
||||
<span>Tuning</span>
|
||||
<span id="filter-tunings-summary" class="text-gray-600 normal-case font-normal text-xs">All tunings</span>
|
||||
</summary>
|
||||
<div id="filter-tunings" class="mt-3 space-y-1 max-h-64 overflow-y-auto pr-1"></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-800">
|
||||
<button onclick="clearLibFilters()" class="text-sm text-gray-400 hover:text-white transition">Clear all</button>
|
||||
<button onclick="toggleLibFilters(false)" class="bg-accent hover:bg-accent-light px-4 py-2 rounded-lg text-sm font-medium text-white transition">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- ══ FAVORITES ════════════════════════════════════════════════════ -->
|
||||
<div id="favorites" class="screen">
|
||||
<section class="max-w-7xl mx-auto px-6 pt-24 pb-16">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-10">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-white">Favorites</h2>
|
||||
<p class="text-gray-500 mt-1" id="fav-count"></p>
|
||||
</div>
|
||||
<div class="flex gap-3 w-full md:w-auto flex-wrap">
|
||||
<div class="flex bg-dark-700 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<button id="fav-view-grid-btn" onclick="setFavView('grid')" class="px-3 py-2.5 text-sm transition" title="Grid view">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
|
||||
</button>
|
||||
<button id="fav-view-tree-btn" onclick="setFavView('tree')" class="px-3 py-2.5 text-sm transition" title="Artist/Album view">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<select id="fav-sort" onchange="sortFavorites()"
|
||||
class="fav-grid-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="artist">Artist A-Z</option>
|
||||
<option value="artist-desc">Artist Z-A</option>
|
||||
<option value="title">Title A-Z</option>
|
||||
<option value="title-desc">Title Z-A</option>
|
||||
<option value="recent">Recently Added</option>
|
||||
<option value="tuning">Tuning</option>
|
||||
</select>
|
||||
<button onclick="toggleAllFavoriteArtists(true)" class="fav-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Expand All</button>
|
||||
<button onclick="toggleAllFavoriteArtists(false)" class="fav-tree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-400 hover:text-white transition">Collapse All</button>
|
||||
<input type="text" id="fav-filter" placeholder="Search favorites..." oninput="filterFavorites()"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 focus:ring-1 focus:ring-accent/30 outline-none flex-1 md:w-60 transition">
|
||||
</div>
|
||||
</div>
|
||||
<div id="fav-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
</div>
|
||||
<div id="fav-tree" class="space-y-2 hidden">
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ══ Plugin screens injected dynamically by loadPlugins() ══════════ -->
|
||||
|
||||
<!-- ══ SETTINGS ═══════════════════════════════════════════════════════ -->
|
||||
<div id="settings" class="screen">
|
||||
<div class="max-w-2xl mx-auto px-6 pt-24 pb-16">
|
||||
<button onclick="showScreen('home')" class="text-gray-500 hover:text-white text-sm mb-6 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Back
|
||||
</button>
|
||||
<h2 class="text-3xl font-bold text-white mb-8">Settings</h2>
|
||||
|
||||
<div class="space-y-10">
|
||||
<!-- App Updates — Velopack auto-update, desktop only. Stays
|
||||
hidden in the plain web app; setupAppUpdates() unhides
|
||||
this block when window.feedBackDesktop.update exists,
|
||||
and shows a disabled "not available on Linux" fallback
|
||||
when running on Linux. -->
|
||||
<div id="app-updates-block" class="hidden border border-gray-800 rounded-xl bg-dark-800/40 p-5">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">App Updates</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block" for="app-update-channel">Update channel</label>
|
||||
<select id="app-update-channel"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="stable">Stable</option>
|
||||
<option value="rc">Release candidate</option>
|
||||
<option value="beta">Beta</option>
|
||||
<option value="alpha">Alpha</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button id="app-update-check-now"
|
||||
class="bg-accent hover:bg-accent-light px-4 py-2.5 rounded-xl text-sm font-medium text-white transition disabled:opacity-50">
|
||||
Check for updates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="app-update-status" class="text-xs text-gray-500 mt-3">Loading updater status…</p>
|
||||
<p id="app-update-linux-note" class="hidden text-xs text-yellow-300 mt-2">
|
||||
Auto-update is not available on Linux —
|
||||
<a href="https://github.com/got-feedback/feedBack-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Core FeedBack settings ─────────────────────────────── -->
|
||||
<section>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">FeedBack</h3>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Library Folder Path</label>
|
||||
<div class="flex gap-3">
|
||||
<input type="text" id="dlc-path" placeholder="/path/to/your/library"
|
||||
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
|
||||
<button onclick="pickDlcFolder()" id="btn-pick-dlc" class="hidden bg-dark-600 hover:bg-dark-500 px-4 py-2.5 rounded-xl text-sm text-gray-300 transition whitespace-nowrap">📂 Browse</button>
|
||||
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" id="setting-lefty" onchange="highway.setLefty(this.checked)"
|
||||
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
|
||||
<span class="text-sm text-gray-300">Left-handed <span class="text-gray-500">(invert frets on the note highway)</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" id="setting-autoplay-exit" checked onchange="setAutoplayExit(this.checked)"
|
||||
class="rounded border-gray-600 bg-dark-700 text-accent focus:ring-accent/40">
|
||||
<span class="text-sm text-gray-300">Autoplay & auto-exit <span class="text-gray-500">(start songs/lessons automatically and return to the menu when the score screen closes)</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Default Arrangement</label>
|
||||
<select id="default-arrangement"
|
||||
onchange="persistSetting('default_arrangement', this.value)"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="">Most notes (auto)</option>
|
||||
<option value="Lead">Lead</option>
|
||||
<option value="Rhythm">Rhythm</option>
|
||||
<option value="Bass">Bass</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Arrangement Names</label>
|
||||
<select id="arrangement-naming-mode"
|
||||
onchange="_onNamingModeChange(this.value)"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="smart">Smart (Lead, Alt. Lead, Rhythm, Bass…)</option>
|
||||
<option value="legacy">Legacy (Combo, Bass)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="setting-av-offset" class="text-sm font-medium text-gray-400 mb-2 block">
|
||||
A/V Sync Offset: <span id="setting-av-offset-val">0</span> ms
|
||||
</label>
|
||||
<input type="range" id="setting-av-offset" min="-1000" max="1000" step="1" value="0"
|
||||
oninput="setAvOffsetMs(this.value)"
|
||||
class="w-full slider-input">
|
||||
<p class="text-xs text-gray-600 mt-1">Positive = audio plays ahead of visual notes; raise this value to catch the highway up. Adjust live with the [ and ] keys (Shift for ±50 ms). Auto-saves on every change.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="demucs-server-url" class="text-sm font-medium text-gray-400 mb-2 block">Demucs Server (for stem separation)</label>
|
||||
<div class="flex gap-3">
|
||||
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
|
||||
class="flex-1 bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
|
||||
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1">Optional. Run <a href="https://github.com/got-feedBack/feedBack-demucs-server" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">feedBack-demucs-server</a> on a machine with a GPU to offload stem splitting and avoid resource exhaustion on the host running FeedBack.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Library</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="rescanLibrary()" id="btn-rescan" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Rescan Library</button>
|
||||
<button onclick="fullRescanLibrary()" id="btn-full-rescan" class="bg-dark-600 hover:bg-red-900/30 px-5 py-2.5 rounded-xl text-sm text-gray-400 transition">Full Rescan</button>
|
||||
<span id="rescan-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1">Rescan checks for new songs. Full Rescan clears the cache and re-imports everything.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Backup</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="exportSettings()" id="btn-export-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Settings</button>
|
||||
<button onclick="document.getElementById('import-settings-file').click()" id="btn-import-settings" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Import Settings</button>
|
||||
<input type="file" id="import-settings-file" accept="application/json,.json" class="hidden" onchange="importSettings(this.files[0]); this.value=''">
|
||||
<span id="backup-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1">Export bundles server config, browser preferences, and opted-in plugin data into one JSON file. Import overwrites current settings and reloads.</p>
|
||||
</div>
|
||||
<!-- ── Diagnostics (feedBack#166) ────────────────────── -->
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Diagnostics</label>
|
||||
<div class="grid grid-cols-2 gap-2 mb-3 text-xs text-gray-400">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-system" checked class="rounded border-gray-600 bg-dark-700 text-accent"> System info</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-hardware" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Hardware (CPU/GPU/RAM)</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-logs" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Server logs (last 5 MB)</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-console" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Browser console + errors</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-incl-plugins" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Plugin diagnostics</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" id="diag-redact" checked class="rounded border-gray-600 bg-dark-700 text-accent"> Redact paths & song names</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick="previewDiagnostics()" id="btn-diag-preview" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Preview Bundle</button>
|
||||
<button onclick="exportDiagnostics()" id="btn-diag-export" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Export Diagnostics</button>
|
||||
<span id="diag-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1">Bundles server logs, hardware info, plugin inventory, and the browser console transcript into one zip for bug reports. Redaction strips DLC paths, song filenames, and IP addresses by default. Attach to GitHub issues; AI agents can parse the included <code>manifest.json</code>.</p>
|
||||
<div id="diag-preview" class="hidden mt-3 bg-dark-700 border border-gray-800 rounded-xl p-3 text-xs text-gray-400 max-h-96 overflow-auto"></div>
|
||||
</div>
|
||||
<div id="settings-status" class="text-sm text-gray-500"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Plugin settings ─────────────────────────────────────── -->
|
||||
<section id="plugin-settings-area" class="hidden">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">Plugins</h3>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-400 mb-2 block">Plugin Updates</label>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<button onclick="checkPluginUpdates()" id="btn-check-updates" class="bg-dark-600 hover:bg-dark-500 px-5 py-2.5 rounded-xl text-sm text-gray-300 transition">Check for Updates</button>
|
||||
<span id="updates-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
<div id="plugin-updates-list" class="space-y-2"></div>
|
||||
</div>
|
||||
<!-- Per-plugin collapsible sections injected here -->
|
||||
<div id="plugin-settings" class="space-y-3"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── About / Source / License (AGPL §13 disclosure) ──────── -->
|
||||
<section>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-4">About</h3>
|
||||
<div class="space-y-2 text-sm text-gray-400">
|
||||
<div>FeedBack <span id="app-version-about" class="text-gray-500"></span></div>
|
||||
<div>Licensed under <a id="about-license-link" href="https://github.com/got-feedback/feedBack/blob/main/LICENSE" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">GNU AGPL v3.0</a>.</div>
|
||||
<div><a id="about-source-link" href="https://github.com/got-feedback/feedBack" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">Source code repository</a></div>
|
||||
<p class="text-xs text-gray-600 mt-2">FeedBack is free software. You can redistribute it and modify it under the terms of the AGPL. If you run a modified version that interacts with users over a network, you must make the modified source available to those users.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global audio element (outside player so it's always accessible) -->
|
||||
<audio id="audio" preload="auto"></audio>
|
||||
<script>
|
||||
// Web Audio API fallback for iOS WKWebView which can't play WAV via <audio>
|
||||
(function() {
|
||||
var _waCtx = null, _waSource = null, _waStartTime = 0, _waBuffer = null, _waPlaying = false, _waLoading = false;
|
||||
var audioEl = document.getElementById('audio');
|
||||
|
||||
window._webAudioFallback = {
|
||||
load: function(url, cb) {
|
||||
if (!url || _waLoading) return;
|
||||
_waLoading = true;
|
||||
if (!_waCtx) _waCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
console.log('[WebAudio] Loading: ' + url);
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, true);
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.onload = function() {
|
||||
_waCtx.decodeAudioData(xhr.response, function(decoded) {
|
||||
_waBuffer = decoded;
|
||||
_waLoading = false;
|
||||
console.log('[WebAudio] Decoded: ' + decoded.duration.toFixed(1) + 's');
|
||||
if (cb) cb();
|
||||
}, function(e) {
|
||||
_waLoading = false;
|
||||
console.error('[WebAudio] Decode error:', e);
|
||||
});
|
||||
};
|
||||
xhr.onerror = function() { _waLoading = false; };
|
||||
xhr.send();
|
||||
},
|
||||
play: function() {
|
||||
if (!_waBuffer || !_waCtx) return false;
|
||||
this.stop();
|
||||
if (_waCtx.state === 'suspended') _waCtx.resume();
|
||||
_waSource = _waCtx.createBufferSource();
|
||||
_waSource.buffer = _waBuffer;
|
||||
// AudioBufferSourceNode has no preservesPitch equivalent so changing playbackRate here also changes pitch
|
||||
_waSource.playbackRate.value = audioEl.playbackRate || 1;
|
||||
_waSource.connect(_waCtx.destination);
|
||||
_waStartTime = _waCtx.currentTime;
|
||||
_waSource.start(0);
|
||||
_waPlaying = true;
|
||||
console.log('[WebAudio] Playing');
|
||||
return true;
|
||||
},
|
||||
stop: function() {
|
||||
if (_waSource) { try { _waSource.stop(); } catch(e){} _waSource = null; }
|
||||
_waPlaying = false;
|
||||
},
|
||||
getTime: function() {
|
||||
if (!_waPlaying || !_waCtx) return 0;
|
||||
return _waCtx.currentTime - _waStartTime;
|
||||
},
|
||||
isActive: function() { return _waPlaying; },
|
||||
isReady: function() { return !!_waBuffer; },
|
||||
getDuration: function() { return _waBuffer ? _waBuffer.duration : 0; }
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ══ PLAYER ═════════════════════════════════════════════════════════ -->
|
||||
<div id="player" class="screen">
|
||||
<canvas id="highway"></canvas>
|
||||
<div id="player-hud" class="absolute top-0 left-0 right-0 flex justify-between px-4 py-3 pointer-events-none z-10">
|
||||
<div class="text-sm">
|
||||
<span id="hud-artist" class="text-gray-300"></span> — <span id="hud-title" class="text-white font-semibold"></span>
|
||||
<br><span id="hud-arrangement" class="text-gray-500 text-xs"></span>
|
||||
<br><span id="hud-tuning" class="text-gray-500 text-xs"></span>
|
||||
<br><span id="hud-tuning-targets" class="text-gray-500 text-xs"></span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div id="hud-time" class="text-sm text-gray-400"></div>
|
||||
<div id="hud-avoffset" class="text-xs text-gray-500 tabular-nums hidden" title="A/V offset — [ and ] to adjust, Shift for ±50 ms">A/V 0 ms</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #section-practice-bar lives OUTSIDE #player-controls on purpose: its
|
||||
nested chip <button>s would otherwise be matched by plugins' legacy
|
||||
`#player-controls`-scoped `button:last-child` injector anchor, making
|
||||
insertBefore throw (the node isn't a direct child) and aborting the
|
||||
shared playSong wrapper chain. The #player-footer wrapper keeps it
|
||||
visually directly above the transport row; margin-top:auto moves here
|
||||
from #player-controls so the whole footer still pins to the bottom. -->
|
||||
<div id="player-footer">
|
||||
<!-- Section Practice is collapsed behind a single pill; the multi-row bar
|
||||
is a popover opened from the pill (toggleSectionPracticePopover). The
|
||||
pill + popover live in #player-footer (NOT #player-controls) so the
|
||||
popover's nested chip <button>s can't be matched by a plugin's
|
||||
`#player-controls > button:last-child` injector anchor. -->
|
||||
<div id="section-practice-control" class="section-practice-control section-practice-control--hidden">
|
||||
<button type="button" id="section-practice-pill" class="section-practice-pill"
|
||||
aria-haspopup="dialog" aria-expanded="false" aria-controls="section-practice-bar"
|
||||
aria-label="Section practice"
|
||||
onclick="toggleSectionPracticePopover()" title="Section practice">
|
||||
<span class="section-practice-pill-icon" aria-hidden="true">🎯</span>
|
||||
<span class="section-practice-pill-text">Practice</span>
|
||||
<span class="section-practice-pill-caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<div id="section-practice-bar" class="section-practice-bar" role="dialog" aria-label="Section practice">
|
||||
<div class="section-practice-row">
|
||||
<label class="section-practice-mode-wrap" title="Loop the selected section until turned off">
|
||||
<input type="checkbox" id="section-practice-mode" onchange="onSectionPracticeModeChange()">
|
||||
<span class="section-practice-mode-text">Practice Section</span>
|
||||
</label>
|
||||
<span class="section-practice-label">Sections:</span>
|
||||
<div id="section-practice-scroll" class="section-practice-scroll" role="toolbar" aria-label="Section selection"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="player-controls" class="flex items-center gap-2 px-4 py-2.5 bg-dark-800 border-t border-gray-800/50 flex-wrap">
|
||||
<button onclick="seekBy(-5)" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Seek Back 5s" aria-label="Seek Back 5s"><img src="/static/svg/rw.svg" class="button-icon-svg" alt="" aria-hidden="true" /> 5s</button>
|
||||
<button type="button" onclick="restartCurrentSong()" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Restart song" aria-label="Restart song">↺</button>
|
||||
<button onclick="togglePlay()" id="btn-play" class="px-4 py-1.5 bg-accent hover:bg-accent-light rounded-lg text-xs font-semibold text-white transition" aria-label="Play" title="Play" aria-pressed="false"><img src="/static/svg/play.svg" class="button-icon-svg" alt="" aria-hidden="true" /></button>
|
||||
<button onclick="seekBy(5)" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Seek Forward 5s" aria-label="Seek Forward 5s">5s <img src="/static/svg/ff.svg" class="button-icon-svg" alt="" aria-hidden="true" /></button>
|
||||
<select id="arr-select" onchange="changeArrangement(this.value)" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none max-w-[130px]"></select>
|
||||
<button id="arr-default-pin" type="button" onclick="pinCurrentArrangementDefault()" aria-pressed="false" aria-label="Select an arrangement to make it the default" class="w-8 h-8 inline-flex items-center justify-center bg-dark-600 border border-gray-700 hover:bg-dark-500 rounded-lg text-xs text-gray-400 transition" title="Select an arrangement to make it the default">☆</button>
|
||||
<input type="range" id="speed-slider" min="15" max="150" value="100" step="5" oninput="setSpeed(this.value/100)" class="w-20 accent-accent slider-input">
|
||||
<span id="speed-label" class="text-xs text-gray-500 w-10">1.0x</span>
|
||||
<span id="mastery-slider-label" class="text-xs text-gray-500 ml-1">Difficulty</span>
|
||||
<input type="range" id="mastery-slider" min="0" max="100" value="100" step="5" oninput="setMastery(this.value)" class="w-20 accent-accent slider-input" title="Master difficulty — low = simpler chart, high = full" aria-labelledby="mastery-slider-label">
|
||||
<span id="mastery-label" class="text-xs text-gray-500 w-10">100%</span>
|
||||
<span id="player-av-offset-slider-label" class="text-xs text-gray-500 ml-1">A/V sync offset (ms)</span>
|
||||
<input type="range" id="player-av-offset-slider" min="-1000" max="1000" value="0" step="1" oninput="setAvOffsetMs(this.value)" class="w-20 accent-accent slider-input" title="A/V sync offset (ms) — positive = audio plays ahead of visuals. [ and ] adjust ±10 ms (Shift = ±50). Double-click to reset." ondblclick="setAvOffsetMs(0)" aria-labelledby="player-av-offset-slider-label">
|
||||
<span id="player-av-offset-label" class="text-xs text-gray-500 w-12 tabular-nums">+0ms</span>
|
||||
<div id="mixer-control">
|
||||
<div id="mixer-anchor" class="relative">
|
||||
<button id="btn-mixer" type="button" onclick="window.feedBack.audio.toggleMixer()" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" aria-haspopup="true" aria-expanded="false" aria-controls="mixer-popover" title="Audio mixer">Mixer ▾</button>
|
||||
<div id="mixer-popover" class="hidden absolute right-0 bottom-full mb-2 z-50 bg-dark-700 border border-gray-800 rounded-xl shadow-xl" role="group" aria-label="Audio mixer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="highway.toggleLyrics()" id="btn-lyrics" class="px-3 py-1.5 bg-purple-900/40 hover:bg-purple-900/60 rounded-lg text-xs text-purple-300 transition">Lyrics ✓</button>
|
||||
<select id="quality-select" onchange="highway.setRenderScale(parseFloat(this.value))" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<option value="1">HD</option>
|
||||
<option value="0.75">Medium</option>
|
||||
<option value="0.5">Low</option>
|
||||
</select>
|
||||
<select id="min-scale-select" aria-label="Minimum auto resolution" onchange="highway.setMinRenderScale && highway.setMinRenderScale(parseFloat(this.value))" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none" title="Minimum auto resolution — how far the highway may lower its resolution to hold the frame rate on heavy scenes. 'Full' disables auto-downscaling, but the Quality selector still caps the maximum (so it's only full resolution at Quality = HD).">
|
||||
<option value="0.25">Min res: 25%</option>
|
||||
<option value="0.5">Min res: 50%</option>
|
||||
<option value="0.75">Min res: 75%</option>
|
||||
<option value="1">Min res: Full</option>
|
||||
</select>
|
||||
<span id="viz-picker-label" class="text-xs text-gray-500 ml-1 sr-only">Visualization</span>
|
||||
<select id="viz-picker" onchange="setViz(this.value)" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none" aria-labelledby="viz-picker-label" title="Visualization">
|
||||
<option value="auto">Auto (match arrangement)</option>
|
||||
<option value="default">Classic 2D Highway</option>
|
||||
<!-- Additional entries populated on load from /api/plugins (feedBack#36).
|
||||
The bundled 3D Highway plugin (plugins/highway_3d/) registers as
|
||||
`highway_3d` and is the default selection on fresh installs — see
|
||||
_populateVizPicker() in app.js. -->
|
||||
</select>
|
||||
<span class="text-gray-700 mx-1">|</span>
|
||||
<button onclick="setLoopStart()" id="btn-loop-a" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Set loop start at current time">A</button>
|
||||
<button onclick="setLoopEnd()" id="btn-loop-b" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition" title="Set loop end at current time">B</button>
|
||||
<button onclick="saveCurrentLoop()" id="btn-loop-save" class="px-3 py-1.5 bg-dark-600 hover:bg-green-900/50 rounded-lg text-xs text-gray-300 transition hidden" title="Save this loop">Save</button>
|
||||
<button onclick="clearLoop()" id="btn-loop-clear" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-500 transition hidden" title="Clear loop">✕</button>
|
||||
<span id="loop-label" class="text-xs text-gray-600"></span>
|
||||
<select id="saved-loops" onchange="loadSavedLoop(this.value)" class="bg-dark-600 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-gray-300 outline-none max-w-[160px] hidden">
|
||||
<option value="">Saved Loops</option>
|
||||
</select>
|
||||
<button onclick="deleteSelectedLoop()" id="btn-loop-delete" class="px-2 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-500 hover:text-red-400 transition hidden" title="Delete selected loop">✕</button>
|
||||
<!-- Editor ⇄ 3D Highway round-trip. "Edit region" opens the Song Editor
|
||||
scrolled to the active loop (or the section at the playhead).
|
||||
"↩ Editor" returns to the editing position you came from; it only
|
||||
appears after a Loop-in-3D handoff. Both are hidden when the editor
|
||||
plugin isn't loaded (state managed by _updateEditRegionBtn). -->
|
||||
<button onclick="editRegionInEditor()" id="btn-edit-region" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Edit this region in the Song Editor">✎ Edit region</button>
|
||||
<button onclick="returnToEditorFromHighway()" id="btn-return-editor" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Return to the editor where you left off">↩ Editor</button>
|
||||
<button onclick="showScreen('home')" class="ml-auto px-3 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-400 hover:text-red-400 transition">✕ Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/highway.js"></script>
|
||||
<script src="/static/vendor/lottie.min.js"></script>
|
||||
<script src="/static/lottie-api.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/audio-mixer.js"></script>
|
||||
<script src="/static/vendor/shepherd.min.js"></script>
|
||||
<script src="/static/tour-engine.js"></script>
|
||||
<script>
|
||||
// Navbar scroll effect
|
||||
window.addEventListener('scroll', () => {
|
||||
const nav = document.getElementById('navbar');
|
||||
if (window.scrollY > 50) {
|
||||
nav.classList.add('bg-dark-900/80', 'backdrop-blur-lg', 'border-b', 'border-gray-800/50');
|
||||
} else {
|
||||
nav.classList.remove('bg-dark-900/80', 'backdrop-blur-lg', 'border-b', 'border-gray-800/50');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
// The one <audio> element the whole app plays through.
|
||||
//
|
||||
// This exists so that code carved out of app.js can reach the player without
|
||||
// importing app.js back — which would close a cycle and fail the import-x/no-cycle
|
||||
// gate. It is the same handle app.js has always held (`document.getElementById`
|
||||
// on the element in the shell), just given a home of its own.
|
||||
//
|
||||
// It is deliberately a `const`, and it is never reassigned anywhere in core — so a
|
||||
// read-only import binding is exactly right, and no state container is needed.
|
||||
// (Contrast the reassigned scalars — isPlaying, _avOffsetMs, … — which cannot be
|
||||
// shared this way, because an imported binding cannot be written to.)
|
||||
//
|
||||
// Module scripts evaluate after the HTML is parsed, so the element is already in
|
||||
// the document by the time this runs. app.js is loaded as <script type="module">,
|
||||
// and its imports evaluate before its body — the same point at which app.js used
|
||||
// to run this exact lookup itself.
|
||||
export const audio = document.getElementById('audio');
|
||||
@@ -0,0 +1,389 @@
|
||||
// Count-in — the 1-2-3-4 click before playback, plus the song-credits overlay that
|
||||
// shares its lifecycle and timers.
|
||||
//
|
||||
// The third slice out of app.js's strongly-connected core, and the first that had to
|
||||
// WRITE shared state rather than just read it. It starts and stops playback, so it sets
|
||||
// `isPlaying` and `lastAudioTime`. An imported binding is read-only — `isPlaying = true`
|
||||
// throws — which is exactly why those two scalars were lifted onto the container in
|
||||
// ./player-state.js. Every earlier slice only READ what it shared, so a getter hook
|
||||
// sufficed; this one could not.
|
||||
//
|
||||
// It imports the loop module directly (setLoop / loopA / loopB — a count-in that starts
|
||||
// inside an A-B loop must begin at A). Nothing imports count-in back: app.js and
|
||||
// section-practice both reach it through the host seam, so the graph stays acyclic.
|
||||
//
|
||||
// app.js's autoplay path used to reach IN and set the credits timers itself. It cannot
|
||||
// now, and it should not have to — so the module exports the OPERATIONS instead
|
||||
// (armCreditsHideOnPlay, scheduleCreditsHide, holdCreditsThen, isCountingIn) and owns
|
||||
// its own timer invariants. Same reason section-practice grew resetSelection().
|
||||
//
|
||||
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
|
||||
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
|
||||
import { audio } from './audio-el.js';
|
||||
import { _audioSeek, _songEventPayload, jucePlayer, setPlayButtonState, togglePlay } from './transport.js';
|
||||
import { loopA, loopB, setLoop } from './loops.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
// ── Count-in click sound (Web Audio API) ────────────────────────────────
|
||||
let _audioCtx = null;
|
||||
export function playClick(high = false) {
|
||||
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = _audioCtx.createOscillator();
|
||||
const gain = _audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(_audioCtx.destination);
|
||||
osc.frequency.value = high ? 1200 : 800;
|
||||
osc.type = 'sine';
|
||||
gain.gain.setValueAtTime(0.5, _audioCtx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, _audioCtx.currentTime + 0.08);
|
||||
osc.start(_audioCtx.currentTime);
|
||||
osc.stop(_audioCtx.currentTime + 0.08);
|
||||
}
|
||||
|
||||
let _countingIn = false;
|
||||
let _countOverlay = null;
|
||||
// Generation token so teardown can cancel an in-progress count-in. Each
|
||||
// startCountIn() captures the gen at entry; rewindStep, the loop-wrap
|
||||
// then-callback, and beginCount's tick all bail when their captured gen
|
||||
// no longer matches. Bumped by _cancelCountIn().
|
||||
let _countInGen = 0;
|
||||
let _countInTimer = null;
|
||||
let _countInRaf = 0;
|
||||
// Feedpak credits overlay (manifest `authors:`, spec §5.4): shown on the
|
||||
// highway when a song is loaded, alongside the count-in. Torn down together
|
||||
// with the count-in via _cancelCountIn().
|
||||
let _creditsOverlay = null;
|
||||
let _creditsTimer = null;
|
||||
let _creditsHideOnPlay = null;
|
||||
let _creditsMaxTimer = null;
|
||||
const _CREDITS_HOLD_MS = 3000;
|
||||
// Backstop: the overlay's primary dismiss is song:play, but playback can fail
|
||||
// to start without emitting it (HTML5 autoplay rejection, JUCE start failure,
|
||||
// a count-in handoff that never plays). This hard cap guarantees the credits
|
||||
// never linger over the window.highway. Generous enough to outlast a normal count-in.
|
||||
const _CREDITS_MAX_MS = 12000;
|
||||
export function _cancelCountIn() {
|
||||
_countInGen++;
|
||||
_countingIn = false;
|
||||
hideCountOverlay();
|
||||
// The credits overlay rides the count-in lifecycle (and its no-count-in
|
||||
// hold timer), so a teardown — leaving the player, loading another song —
|
||||
// must clear it too, or it lingers on the next screen.
|
||||
hideSongCreditsOverlay();
|
||||
if (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; }
|
||||
if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; }
|
||||
}
|
||||
|
||||
export function showCountOverlay(n) {
|
||||
if (!_countOverlay) {
|
||||
_countOverlay = document.createElement('div');
|
||||
_countOverlay.className = 'fixed inset-0 z-[100] flex items-center justify-center pointer-events-none';
|
||||
document.body.appendChild(_countOverlay);
|
||||
}
|
||||
_countOverlay.innerHTML = `<span class="text-9xl font-black text-white/30">${n}</span>`;
|
||||
}
|
||||
|
||||
export function hideCountOverlay() {
|
||||
if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; }
|
||||
}
|
||||
|
||||
// Map a feedpak author `role` to a friendly "<verb> by" credit line. The
|
||||
// recommended vocabulary is from feedpak spec §5.4; unknown roles are
|
||||
// title-cased ("foo" → "Foo by"); a missing role shows the bare name.
|
||||
const _CREDIT_ROLE_VERBS = {
|
||||
charter: 'Charted by',
|
||||
transcriber: 'Transcribed by',
|
||||
arranger: 'Arranged by',
|
||||
editor: 'Edited by',
|
||||
mixer: 'Mixed by',
|
||||
engineer: 'Engineered by',
|
||||
proofreader: 'Proofread by',
|
||||
};
|
||||
|
||||
function _creditLineLabel(role) {
|
||||
if (!role) return '';
|
||||
const key = String(role).trim().toLowerCase();
|
||||
if (_CREDIT_ROLE_VERBS[key]) return _CREDIT_ROLE_VERBS[key];
|
||||
return key.charAt(0).toUpperCase() + key.slice(1) + ' by';
|
||||
}
|
||||
|
||||
// Show the feedpak contributor credits over the window.highway. `authors` is the
|
||||
// sanitized [{name, role}] list from window.feedBack.currentSong.authors.
|
||||
// Anchored to the lower third (bottom-center) so it never collides with the
|
||||
// vertically-centered count-in number, and pointer-events-none so it never
|
||||
// intercepts clicks. No-op when there are no contributors to show.
|
||||
export function showSongCreditsOverlay(authors) {
|
||||
if (!Array.isArray(authors) || authors.length === 0) return;
|
||||
if (!_creditsOverlay) {
|
||||
_creditsOverlay = document.createElement('div');
|
||||
_creditsOverlay.className = 'song-credits-overlay';
|
||||
document.body.appendChild(_creditsOverlay);
|
||||
}
|
||||
// Build via DOM + textContent — author names are untrusted pack data and
|
||||
// must never be interpolated as HTML.
|
||||
_creditsOverlay.replaceChildren();
|
||||
const card = document.createElement('div');
|
||||
card.className = 'song-credits-card';
|
||||
|
||||
const eyebrow = document.createElement('div');
|
||||
eyebrow.className = 'song-credits-eyebrow';
|
||||
eyebrow.textContent = 'Credits';
|
||||
card.appendChild(eyebrow);
|
||||
|
||||
const title = (window.feedBack && window.feedBack.currentSong
|
||||
&& window.feedBack.currentSong.title) || '';
|
||||
if (title) {
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'song-credits-heading';
|
||||
heading.textContent = title;
|
||||
card.appendChild(heading);
|
||||
}
|
||||
|
||||
for (const a of authors) {
|
||||
if (!a || !a.name) continue;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'song-credits-line';
|
||||
const label = _creditLineLabel(a.role);
|
||||
if (label) {
|
||||
const lab = document.createElement('span');
|
||||
lab.className = 'song-credits-role';
|
||||
lab.textContent = label + ' ';
|
||||
row.appendChild(lab);
|
||||
}
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'song-credits-name';
|
||||
nm.textContent = a.name;
|
||||
row.appendChild(nm);
|
||||
card.appendChild(row);
|
||||
}
|
||||
_creditsOverlay.appendChild(card);
|
||||
// Arm the backstop so the overlay self-clears even if playback never starts
|
||||
// / never emits song:play. song:play (or any teardown) clears it earlier.
|
||||
if (_creditsMaxTimer) clearTimeout(_creditsMaxTimer);
|
||||
_creditsMaxTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_MAX_MS);
|
||||
}
|
||||
|
||||
export function hideSongCreditsOverlay() {
|
||||
if (_creditsTimer) { clearTimeout(_creditsTimer); _creditsTimer = null; }
|
||||
if (_creditsMaxTimer) { clearTimeout(_creditsMaxTimer); _creditsMaxTimer = null; }
|
||||
if (_creditsHideOnPlay) {
|
||||
window.feedBack.off('song:play', _creditsHideOnPlay);
|
||||
_creditsHideOnPlay = null;
|
||||
}
|
||||
if (_creditsOverlay) { _creditsOverlay.remove(); _creditsOverlay = null; }
|
||||
}
|
||||
|
||||
export async function startCountIn(opts = {}) {
|
||||
if (_countingIn) return;
|
||||
_countingIn = true;
|
||||
// Snapshot the current gen so every delayed callback (rewind frames,
|
||||
// post-seek then, count-in ticks, post-count play) can bail if a
|
||||
// teardown bumped the gen mid-flight via _cancelCountIn().
|
||||
const gen = _countInGen;
|
||||
const immediate = !!opts.immediate;
|
||||
if (window._juceMode) {
|
||||
await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in count-in:', err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
if (gen !== _countInGen) return; // teardown during pause
|
||||
|
||||
// Section-practice entry: already at loop A after setLoop(); skip the
|
||||
// B→A rewind animation used on loop wrap and go straight to clicks.
|
||||
if (immediate) {
|
||||
if (loopA === null || loopB === null) {
|
||||
_countingIn = false;
|
||||
return;
|
||||
}
|
||||
S.lastAudioTime = loopA;
|
||||
window.highway.setTime(loopA);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
|
||||
}
|
||||
beginCount();
|
||||
return;
|
||||
}
|
||||
|
||||
// Rewind animation: sweep highway time from B to A
|
||||
const rewindDuration = 400; // ms
|
||||
const rewindStart = performance.now();
|
||||
const fromTime = loopB;
|
||||
const toTime = loopA;
|
||||
|
||||
function rewindStep(now) {
|
||||
if (gen !== _countInGen) return; // teardown mid-rewind
|
||||
const elapsed = now - rewindStart;
|
||||
const t = Math.min(elapsed / rewindDuration, 1);
|
||||
// Ease out quad
|
||||
const eased = 1 - (1 - t) * (1 - t);
|
||||
const currentT = fromTime + (toTime - fromTime) * eased;
|
||||
window.highway.setTime(currentT);
|
||||
if (t < 1) {
|
||||
_countInRaf = requestAnimationFrame(rewindStep);
|
||||
} else {
|
||||
_countInRaf = 0;
|
||||
// Rewind done — set final position and start count.
|
||||
// Await the JUCE seek so the engine has repositioned before
|
||||
// we start the click track (HTML5 path is synchronous).
|
||||
_audioSeek(loopA, 'loop-wrap').then((r) => {
|
||||
if (gen !== _countInGen) return; // teardown during seek
|
||||
// Abort the loop restart in two cases:
|
||||
// 1. Cancelled (player torn down): don't beginCount on a
|
||||
// new session.
|
||||
// 2. Off-target landing (JUCE rollback / clamp far from
|
||||
// loopA): proceeding would emit loop:restart and start
|
||||
// a count-in from the wrong position. Audio is at
|
||||
// r.from / r.to, which is not where the loop wants to
|
||||
// resume — better to drop this iteration than play out
|
||||
// of sync.
|
||||
// 50 ms tolerance: well within JUCE's normal seek precision
|
||||
// but tight enough to catch a real rollback or no-op.
|
||||
if (!r.completed || Math.abs(r.to - loopA) > 0.05) {
|
||||
// startCountIn paused audio at entry but left isPlaying
|
||||
// alone — beginCount would have set it on resume. On
|
||||
// abort, sync the transport: audio is paused, so
|
||||
// isPlaying must reflect that and the button + plugin
|
||||
// host must agree.
|
||||
_countingIn = false;
|
||||
if (S.isPlaying) {
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', _songEventPayload());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Use the verified post-seek clock for the chart so audio
|
||||
// and chart stay in sync if JUCE clamped to slightly
|
||||
// before/after loopA. The loop:restart event keeps `time:
|
||||
// loopA` because subscribers treat that as the semantic
|
||||
// marker for "new iteration starts at A", not the actual
|
||||
// audio position.
|
||||
S.lastAudioTime = r.to;
|
||||
window.highway.setTime(r.to);
|
||||
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
|
||||
beginCount();
|
||||
});
|
||||
}
|
||||
}
|
||||
_countInRaf = requestAnimationFrame(rewindStep);
|
||||
|
||||
function beginCount() {
|
||||
const bpm = window.highway.getBPM(loopA);
|
||||
const beatInterval = 60 / bpm;
|
||||
let count = 0;
|
||||
|
||||
function tick() {
|
||||
if (gen !== _countInGen) return; // teardown mid-count
|
||||
count++;
|
||||
if (count > 4) {
|
||||
hideCountOverlay();
|
||||
_countingIn = false;
|
||||
if (window._juceMode) {
|
||||
jucePlayer.play().then((started) => {
|
||||
if (gen !== _countInGen) return; // teardown during play start
|
||||
if (!started) return;
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
window.feedBack.isPlaying = true;
|
||||
const payload = _songEventPayload();
|
||||
window.feedBack.emit('song:play', payload);
|
||||
window.feedBack.emit('song:resume', payload);
|
||||
}).catch((err) => console.error('[app] jucePlayer.play error:', err));
|
||||
} else {
|
||||
audio.play().then(() => {
|
||||
if (gen !== _countInGen) return;
|
||||
S.isPlaying = true;
|
||||
setPlayButtonState(true);
|
||||
}).catch((err) => {
|
||||
if (gen !== _countInGen) return;
|
||||
// An engine reroute's deliberate pause aborts this play()
|
||||
// while playback continues on JUCE — don't reset the
|
||||
// button (mirrors the togglePlay guard).
|
||||
if (window._juceRerouteInProgress) return;
|
||||
// Same rationale as togglePlay: don't claim playback
|
||||
// started if the Promise rejected.
|
||||
console.error('[app] audio.play() rejected after count-in:', err);
|
||||
S.isPlaying = false;
|
||||
setPlayButtonState(false);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
showCountOverlay(count);
|
||||
playClick(count === 1);
|
||||
_countInTimer = setTimeout(tick, beatInterval * 1000);
|
||||
}
|
||||
_countInTimer = setTimeout(tick, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Start-of-song count-in: a 4-beat click before playback begins, gated by the
|
||||
// "Countdown before song" setting (Gameplay tab). Mirrors the loop count-in's
|
||||
// overlay + click + gen-token cancellation, but counts from the song's current
|
||||
// position (0 at song start) with no loop A/B rewind. startCountIn() is loop-
|
||||
// coupled (early-returns when loopA/loopB are null), so this is a sibling
|
||||
// rather than an overload. Hands off to togglePlay() once the count completes.
|
||||
export async function startSongCountIn() {
|
||||
if (_countingIn) return;
|
||||
_countingIn = true;
|
||||
// Snapshot the gen so a teardown (showScreen/playSong calls _cancelCountIn)
|
||||
// bumps it and every delayed callback below bails.
|
||||
const gen = _countInGen;
|
||||
if (window._juceMode) {
|
||||
await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in song count-in:', err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
if (gen !== _countInGen) return; // teardown during pause
|
||||
const startT = S.lastAudioTime || 0;
|
||||
let bpm = window.highway.getBPM(startT);
|
||||
// Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each).
|
||||
if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120;
|
||||
const beatInterval = 60 / bpm;
|
||||
let count = 0;
|
||||
function tick() {
|
||||
if (gen !== _countInGen) return; // teardown mid-count
|
||||
count++;
|
||||
if (count > 4) {
|
||||
hideCountOverlay();
|
||||
_countingIn = false;
|
||||
// Hand off to the normal play path — togglePlay() flips isPlaying,
|
||||
// updates the button, and emits song:play/resume for plugins.
|
||||
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] play after count-in failed:', err));
|
||||
return;
|
||||
}
|
||||
showCountOverlay(count);
|
||||
playClick(count === 1);
|
||||
_countInTimer = setTimeout(tick, beatInterval * 1000);
|
||||
}
|
||||
// First beat after a short lead-in, matching the loop count-in's 500 ms.
|
||||
_countInTimer = setTimeout(tick, 500);
|
||||
}
|
||||
|
||||
// ── Operations app.js's autoplay path used to perform by reaching in ────────
|
||||
// It used to assign _creditsTimer / _creditsHideOnPlay directly. Imported bindings are
|
||||
// read-only, and the module should own its own timer invariants anyway.
|
||||
|
||||
/** Is a count-in running? app.js's timeupdate handler suppresses highway sync during one. */
|
||||
export function isCountingIn() {
|
||||
return _countingIn;
|
||||
}
|
||||
|
||||
/** Dismiss the credits the moment real playback begins. Fires once. */
|
||||
export function armCreditsHideOnPlay() {
|
||||
_creditsHideOnPlay = () => { _creditsHideOnPlay = null; hideSongCreditsOverlay(); };
|
||||
window.feedBack.on('song:play', _creditsHideOnPlay, { once: true });
|
||||
}
|
||||
|
||||
/** Let the credits dwell, then clear them. Used when autoplay-exit is disabled. */
|
||||
export function scheduleCreditsHide() {
|
||||
_creditsTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_HOLD_MS);
|
||||
}
|
||||
|
||||
/** Let the credits dwell, then run `then` (the autoplay start). */
|
||||
export function holdCreditsThen(then) {
|
||||
_creditsTimer = setTimeout(() => { _creditsTimer = null; then(); }, _CREDITS_HOLD_MS);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// The diagnostics-bundle export — the Settings "Export diagnostics" flow.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
// It snapshots the browser-only state (console ring buffer, hardware probe,
|
||||
// localStorage, ua) via window.feedBack.diagnostics, POSTs it to
|
||||
// /api/diagnostics/export with the user's include/redact toggles, and streams the
|
||||
// returned zip to disk. Bundle layout + schemas: docs/diagnostics-bundle-spec.md.
|
||||
//
|
||||
// Everything except the two entry points is module-private — the preview
|
||||
// renderer, the file-label table, and the byte/HTML formatters are used nowhere
|
||||
// else in core.
|
||||
|
||||
//
|
||||
// Companion to Settings export but for troubleshooting bug reports.
|
||||
// Bundle layout + schemas: docs/diagnostics-bundle-spec.md.
|
||||
//
|
||||
// Frontend's job is to:
|
||||
// 1. Snapshot the browser-only state (console ring buffer, hardware
|
||||
// probe, localStorage, ua) via window.feedBack.diagnostics.
|
||||
// 2. POST it to /api/diagnostics/export with the user's include /
|
||||
// redact toggles.
|
||||
// 3. Stream the returned zip to disk.
|
||||
|
||||
function _diagIncludeFromUI() {
|
||||
const v = (id) => document.getElementById(id)?.checked !== false;
|
||||
return {
|
||||
system: v('diag-incl-system'),
|
||||
hardware: v('diag-incl-hardware'),
|
||||
logs: v('diag-incl-logs'),
|
||||
console: v('diag-incl-console'),
|
||||
plugins: v('diag-incl-plugins'),
|
||||
};
|
||||
}
|
||||
|
||||
function _diagRedactFromUI() {
|
||||
const el = document.getElementById('diag-redact');
|
||||
return el ? !!el.checked : true;
|
||||
}
|
||||
|
||||
// Map raw file paths inside the bundle to plain-English labels +
|
||||
// descriptions for the preview UI. Only paths that show up in
|
||||
// previews need entries — unknown paths fall back to the path itself.
|
||||
const _DIAG_FILE_LABELS = {
|
||||
'system/version.json': { label: 'App version', desc: 'FeedBack version, Python, OS' },
|
||||
'system/env.json': { label: 'Environment', desc: 'Allowlisted env vars (LOG_LEVEL, etc.). No secrets.' },
|
||||
'system/hardware.json': { label: 'Hardware (server-side)', desc: 'CPU, RAM, GPU. In Docker this reflects the container, not the host.' },
|
||||
'system/plugins.json': { label: 'Plugins', desc: 'Loaded plugins + git commit + orphan detection.' },
|
||||
'logs/server.log': { label: 'Server log', desc: 'Tail of LOG_FILE (last ~5 MB).' },
|
||||
'logs/server.log.meta.json': { label: 'Log metadata', desc: 'Log file path, size, rotation info.' },
|
||||
'client/console.json': { label: 'Browser console', desc: 'console.log/warn/error transcript + window errors.' },
|
||||
'client/hardware.json': { label: 'Hardware (browser)', desc: 'WebGL/WebGPU adapter, host OS via userAgent.' },
|
||||
'client/local_storage.json': { label: 'Browser storage', desc: 'localStorage contents (preferences).' },
|
||||
'client/ua.json': { label: 'User agent', desc: 'Browser, screen, page URL.' },
|
||||
};
|
||||
|
||||
function _formatBytes(n) {
|
||||
if (!n || n < 1024) return (n || 0) + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function _escapeHtml(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function _renderDiagPreview(data) {
|
||||
const m = data.manifest || {};
|
||||
const files = m.files || [];
|
||||
const groups = { system: [], logs: [], client: [], plugins: [], other: [] };
|
||||
for (const f of files) {
|
||||
const top = (f.path || '').split('/')[0];
|
||||
(groups[top] || groups.other).push(f);
|
||||
}
|
||||
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
|
||||
const include = _diagIncludeFromUI();
|
||||
const redact = _diagRedactFromUI();
|
||||
|
||||
const sections = [];
|
||||
// Per-file `summary` (server-derived) → human one-liner.
|
||||
function _summaryLine(path, summary) {
|
||||
if (!summary || typeof summary !== 'object') return '';
|
||||
if (path === 'system/plugins.json') {
|
||||
const loaded = summary.loaded_count || 0;
|
||||
const orphans = summary.orphan_count || 0;
|
||||
const orphPart = orphans ? ` · <span class="text-amber-400">${orphans} orphan${orphans === 1 ? '' : 's'}</span>` : '';
|
||||
return `${loaded} plugin${loaded === 1 ? '' : 's'} loaded${orphPart}`;
|
||||
}
|
||||
if (path === 'client/console.json') {
|
||||
const total = summary.entry_count || 0;
|
||||
const lvl = summary.by_level || {};
|
||||
const parts = [];
|
||||
for (const k of ['error','warn','info','log','debug']) {
|
||||
if (lvl[k]) parts.push(`${lvl[k]} ${k}`);
|
||||
}
|
||||
return `${total} entries${parts.length ? ' (' + parts.join(', ') + ')' : ''}`;
|
||||
}
|
||||
if (path === 'system/hardware.json') {
|
||||
const bits = [];
|
||||
if (summary.cpu_brand) bits.push(summary.cpu_brand);
|
||||
if (summary.cores_logical) bits.push(`${summary.cores_logical} cores`);
|
||||
if (summary.gpu_count) bits.push(`${summary.gpu_count} GPU`);
|
||||
if (summary.runtime) bits.push(`runtime: ${summary.runtime}`);
|
||||
return bits.join(' · ');
|
||||
}
|
||||
if (path === 'client/hardware.json') {
|
||||
const bits = [];
|
||||
if (summary.runtime) bits.push(summary.runtime);
|
||||
if (summary.webgl_renderer) bits.push(summary.webgl_renderer);
|
||||
return bits.join(' · ');
|
||||
}
|
||||
if (path === 'client/local_storage.json') {
|
||||
return `${summary.key_count || 0} keys`;
|
||||
}
|
||||
if (path === 'system/version.json') {
|
||||
const bits = [];
|
||||
if (summary.feedBack) bits.push(`feedBack ${summary.feedBack}`);
|
||||
if (summary.python) bits.push(`python ${summary.python}`);
|
||||
if (summary.os) bits.push(summary.os);
|
||||
return bits.join(' · ');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function pushSection(title, list, emptyHint) {
|
||||
if (!list.length) {
|
||||
if (emptyHint) {
|
||||
sections.push(`<div class="mb-3"><div class="text-gray-300 font-semibold mb-1">${_escapeHtml(title)}</div><div class="text-gray-500">${_escapeHtml(emptyHint)}</div></div>`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const rows = list.map(f => {
|
||||
const meta = _DIAG_FILE_LABELS[f.path] || { label: f.path, desc: '' };
|
||||
const summary = _summaryLine(f.path, f.summary);
|
||||
const summaryHtml = summary
|
||||
? `<div class="text-accent-light text-[10px] mt-0.5">${summary}</div>`
|
||||
: '';
|
||||
return `<div class="flex justify-between gap-4 py-1 border-b border-dark-600 last:border-0">
|
||||
<div class="min-w-0">
|
||||
<div class="text-gray-200">${_escapeHtml(meta.label)}</div>
|
||||
<div class="text-gray-500 text-[10px]">${_escapeHtml(meta.desc)}</div>
|
||||
${summaryHtml}
|
||||
</div>
|
||||
<div class="text-gray-400 text-right whitespace-nowrap">${_escapeHtml(_formatBytes(f.size))}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
sections.push(`<div class="mb-3"><div class="text-gray-300 font-semibold mb-1">${_escapeHtml(title)}</div>${rows}</div>`);
|
||||
}
|
||||
|
||||
pushSection('System', groups.system, include.system ? '' : 'Skipped (toggle off)');
|
||||
pushSection('Server logs', groups.logs, include.logs
|
||||
? 'No log file configured — set LOG_FILE env var to include server logs.'
|
||||
: 'Skipped (toggle off)');
|
||||
pushSection('Plugin diagnostics', groups.plugins, include.plugins
|
||||
? 'No plugins have opted in to diagnostics.'
|
||||
: 'Skipped (toggle off)');
|
||||
|
||||
// Client section preview is a server-side estimate only — actual
|
||||
// client/* payloads are added at Export time after the browser
|
||||
// snapshots. Show what WILL be added, not file sizes.
|
||||
const clientLines = [];
|
||||
if (include.console) clientLines.push({ label: 'Browser console', desc: 'console.log/warn/error transcript + window errors.' });
|
||||
if (include.hardware) clientLines.push({ label: 'Hardware (browser)', desc: 'WebGL/WebGPU adapter, host OS via userAgent.' });
|
||||
clientLines.push({ label: 'Browser storage', desc: 'localStorage contents (preferences).' });
|
||||
clientLines.push({ label: 'User agent', desc: 'Browser, screen, page URL.' });
|
||||
const clientHtml = clientLines.map(c => `<div class="flex justify-between gap-4 py-1 border-b border-dark-600 last:border-0">
|
||||
<div><div class="text-gray-200">${_escapeHtml(c.label)}</div><div class="text-gray-500 text-[10px]">${_escapeHtml(c.desc)}</div></div>
|
||||
<div class="text-gray-500 text-right whitespace-nowrap">added on export</div>
|
||||
</div>`).join('');
|
||||
sections.push(`<div class="mb-3"><div class="text-gray-300 font-semibold mb-1">Browser data</div>${clientHtml}</div>`);
|
||||
|
||||
const notesHtml = (m.notes || []).length
|
||||
? `<div class="mb-3 bg-dark-600 border border-amber-500/30 rounded-lg p-2">
|
||||
<div class="text-amber-400 text-[10px] font-semibold uppercase mb-1">Notes</div>
|
||||
${(m.notes).map(n => `<div class="text-gray-300 text-[11px]">• ${_escapeHtml(n)}</div>`).join('')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const privacyHtml = redact
|
||||
? `<div class="text-emerald-400 text-[11px]">🔒 Redaction enabled — paths, song names, IPs, and secrets will be replaced with stable hash tokens.</div>`
|
||||
: `<div class="text-amber-400 text-[11px]">⚠ Redaction OFF — bundle will contain raw paths, song names, and IPs. Only share with people you trust.</div>`;
|
||||
|
||||
return `
|
||||
<div class="text-[11px]">
|
||||
<div class="flex justify-between items-baseline mb-2">
|
||||
<div class="text-gray-200 font-semibold">${_escapeHtml(data.filename)}</div>
|
||||
<div class="text-gray-400">${_escapeHtml(_formatBytes(totalBytes))}<span class="text-gray-600"> server-side</span></div>
|
||||
</div>
|
||||
<div class="text-gray-500 text-[10px] mb-3">runtime: ${_escapeHtml(m.runtime || 'unknown')} · exported_at: ${_escapeHtml(m.exported_at || '')}</div>
|
||||
${notesHtml}
|
||||
${sections.join('')}
|
||||
${privacyHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export async function previewDiagnostics() {
|
||||
const status = document.getElementById('diag-status');
|
||||
const preview = document.getElementById('diag-preview');
|
||||
if (!status || !preview) return;
|
||||
status.textContent = 'Building preview…';
|
||||
preview.classList.add('hidden');
|
||||
const include = _diagIncludeFromUI();
|
||||
const params = new URLSearchParams({
|
||||
redact: String(_diagRedactFromUI()),
|
||||
system: String(include.system),
|
||||
hardware: String(include.hardware),
|
||||
logs: String(include.logs),
|
||||
console: String(include.console),
|
||||
plugins: String(include.plugins),
|
||||
});
|
||||
try {
|
||||
const resp = await fetch(`/api/diagnostics/preview?${params.toString()}`);
|
||||
if (!resp.ok) {
|
||||
status.textContent = `Preview failed (HTTP ${resp.status})`;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
preview.innerHTML = _renderDiagPreview(data);
|
||||
preview.classList.remove('hidden');
|
||||
status.textContent = 'Preview ready.';
|
||||
} catch (e) {
|
||||
status.textContent = `Preview failed: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportDiagnostics() {
|
||||
const status = document.getElementById('diag-status');
|
||||
if (!status) return;
|
||||
status.textContent = 'Building bundle…';
|
||||
const include = _diagIncludeFromUI();
|
||||
const redact = _diagRedactFromUI();
|
||||
|
||||
const diag = window.feedBack && window.feedBack.diagnostics;
|
||||
const body = {
|
||||
redact,
|
||||
include,
|
||||
client_console: include.console && diag ? diag.snapshotConsole() : null,
|
||||
client_hardware: include.hardware && diag ? await diag.snapshotHardware() : null,
|
||||
client_ua: diag ? diag.snapshotUa() : null,
|
||||
local_storage: diag ? diag.snapshotLocalStorage() : null,
|
||||
client_contributions: diag ? diag.snapshotContributions() : null,
|
||||
};
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch('/api/diagnostics/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed: ${e.message}`;
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
status.textContent = `Export failed (HTTP ${resp.status})`;
|
||||
return;
|
||||
}
|
||||
let filename = 'feedBack-diag.zip';
|
||||
const disp = resp.headers.get('Content-Disposition');
|
||||
if (disp) {
|
||||
const m = /filename="([^"]+)"/.exec(disp);
|
||||
if (m) filename = m[1];
|
||||
}
|
||||
try {
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed during download: ${e.message}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// DOM + HTML-escaping primitives, and the modal dialogs built on them.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
//
|
||||
// This one is a GATHER, not a slice — the six lived in six different places in
|
||||
// app.js. They belong together because they are the bottom of the UI stack:
|
||||
// `esc` / `_escAttr` alone have ~48 call sites, and every later carve that
|
||||
// renders HTML will need them. Giving them a home NOW means those carves can
|
||||
// import them instead of inventing a host seam to reach back into app.js —
|
||||
// which is exactly the trap the plugin-loader carve had to work around before
|
||||
// the viz layer became a module.
|
||||
|
||||
export function _isElementVisible(el) {
|
||||
// Walk ancestors looking for display:none. Handles collapsed
|
||||
// `.album-body` / `.artist-body` subtrees (hidden via CSS class
|
||||
// rules). Using a DOM walk rather than `offsetParent` avoids the
|
||||
// false-negative for `position:fixed` elements whose offsetParent
|
||||
// is null even when they are perfectly visible.
|
||||
if (!el) return false;
|
||||
let node = el;
|
||||
while (node && node !== document.body) {
|
||||
if (getComputedStyle(node).display === 'none') return false;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Focus trap: keep Tab / Shift+Tab cycling inside `modal` so focus
|
||||
// can't escape to the content underneath while the overlay is open.
|
||||
// Call this once after the modal is in the DOM and initial focus is set.
|
||||
export function _trapFocusInModal(modal) {
|
||||
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
modal.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
const els = Array.from(modal.querySelectorAll(FOCUSABLE)).filter(el => {
|
||||
if (!_isElementVisible(el)) return false;
|
||||
if (getComputedStyle(el).visibility === 'hidden') return false;
|
||||
if (el.disabled) return false;
|
||||
return true;
|
||||
});
|
||||
if (!els.length) return;
|
||||
const first = els[0];
|
||||
const last = els[els.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
} else {
|
||||
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Styled async confirm dialog. Returns a Promise<boolean>. For destructive
|
||||
// prompts pass `danger: true` — confirm button turns red and Cancel gets
|
||||
// initial focus so an accidental Enter won't fire the action. `body` is
|
||||
// inserted as HTML so callers can use formatting; callers are responsible
|
||||
// for escaping any user-supplied content in it (use _escAttr).
|
||||
export function _confirmDialog({ title, body = '', confirmText = 'Confirm', cancelText = 'Cancel', danger = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const previouslyFocused = document.activeElement;
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'feedBack-modal fixed inset-0 z-[250] flex items-center justify-center bg-black/70 backdrop-blur-sm';
|
||||
modal.setAttribute('role', 'alertdialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-label', title || 'Confirm');
|
||||
const confirmClass = danger
|
||||
? 'flex-1 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-xl text-sm font-semibold text-white transition focus:outline-none focus:ring-2 focus:ring-red-400/60'
|
||||
: 'flex-1 bg-accent hover:bg-accent-light px-4 py-2 rounded-xl text-sm font-semibold text-white transition focus:outline-none focus:ring-2 focus:ring-accent/60';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-dark-700 border border-gray-700 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-white mb-3">${_escAttr(title || '')}</h3>
|
||||
<div class="mb-5">${body}</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="button" data-confirm class="${confirmClass}">${_escAttr(confirmText)}</button>
|
||||
<button type="button" data-cancel class="px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-xl text-sm text-gray-300 transition focus:outline-none focus:ring-2 focus:ring-gray-500/40">${_escAttr(cancelText)}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
function finish(result) {
|
||||
modal.remove();
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
if (previouslyFocused && document.body.contains(previouslyFocused)) {
|
||||
try { previouslyFocused.focus({ preventScroll: true }); } catch {}
|
||||
}
|
||||
resolve(result);
|
||||
}
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); finish(false); }
|
||||
else if (e.key === 'Enter' && document.activeElement === modal.querySelector('[data-confirm]')) {
|
||||
e.preventDefault(); finish(true);
|
||||
}
|
||||
}
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) finish(false);
|
||||
else if (e.target.closest('[data-confirm]')) finish(true);
|
||||
else if (e.target.closest('[data-cancel]')) finish(false);
|
||||
});
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
_trapFocusInModal(modal);
|
||||
// Focus Cancel by default for destructive prompts so an accidental
|
||||
// Enter / Space won't fire the dangerous action; otherwise focus
|
||||
// the confirm button so Enter accepts.
|
||||
const focusTarget = modal.querySelector(danger ? '[data-cancel]' : '[data-confirm]');
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// `esc()` escapes the HTML-content metacharacters (<, >, &) but not
|
||||
// quotes — fine for text-node interpolation but unsafe when the
|
||||
// result is used as an attribute value, where a literal `"` ends the
|
||||
// attribute early. Use `_escAttr` for any `attr="${...}"` site.
|
||||
export function _escAttr(s) {
|
||||
return esc(s == null ? '' : String(s))
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// In-app text prompt — replaces window.prompt(), which Electron does NOT
|
||||
// implement (it logs "prompt() is and will not be supported" and returns null),
|
||||
// so any prompt()-based flow is a silent no-op on desktop. Returns the entered
|
||||
// string, or null if cancelled (Esc / Cancel / backdrop). Styled to match the
|
||||
// edit modal; role=dialog so the global keyboard shortcuts ignore typing here.
|
||||
// Injection-safe: all caller text is set via textContent / value, never innerHTML.
|
||||
export function uiPrompt({ title = '', label = '', value = '', okLabel = 'Save', placeholder = '' } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'feedBack-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
if (title) modal.setAttribute('aria-label', title);
|
||||
modal.innerHTML = `
|
||||
<form class="bg-dark-700 border border-gray-700 rounded-2xl p-6 w-full max-w-sm mx-4 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-white mb-4" data-ui-prompt-title hidden></h3>
|
||||
<label class="text-xs text-gray-400 mb-1 block" data-ui-prompt-label hidden></label>
|
||||
<input type="text" data-ui-prompt-input autocomplete="off"
|
||||
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button type="submit"
|
||||
class="flex-1 bg-accent hover:bg-accent-light px-4 py-2 rounded-xl text-sm font-semibold text-white transition" data-ui-prompt-ok></button>
|
||||
<button type="button" data-ui-prompt-cancel
|
||||
class="px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-xl text-sm text-gray-300 transition">Cancel</button>
|
||||
</div>
|
||||
</form>`;
|
||||
const titleEl = modal.querySelector('[data-ui-prompt-title]');
|
||||
const labelEl = modal.querySelector('[data-ui-prompt-label]');
|
||||
const input = modal.querySelector('[data-ui-prompt-input]');
|
||||
const okEl = modal.querySelector('[data-ui-prompt-ok]');
|
||||
if (title) { titleEl.textContent = title; titleEl.hidden = false; }
|
||||
if (label) { labelEl.textContent = label; labelEl.hidden = false; }
|
||||
okEl.textContent = okLabel;
|
||||
input.value = value;
|
||||
if (placeholder) input.placeholder = placeholder;
|
||||
|
||||
// Restore focus to wherever it was when we're done (matches the edit
|
||||
// modal's behavior so keyboard users aren't dumped at the page top).
|
||||
const previousActiveElement = document.activeElement;
|
||||
const focusables = () => Array.from(
|
||||
modal.querySelectorAll('input, button, [tabindex]:not([tabindex="-1"])'),
|
||||
).filter((el) => !el.disabled && el.offsetParent !== null);
|
||||
|
||||
let settled = false;
|
||||
const close = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
modal.remove();
|
||||
if (previousActiveElement && typeof previousActiveElement.focus === 'function') {
|
||||
previousActiveElement.focus();
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(null); return; }
|
||||
// Trap Tab inside the modal so focus can't wander to the page behind it.
|
||||
if (e.key === 'Tab') {
|
||||
const items = focusables();
|
||||
if (!items.length) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && (active === first || !modal.contains(active))) {
|
||||
e.preventDefault(); last.focus();
|
||||
} else if (!e.shiftKey && (active === last || !modal.contains(active))) {
|
||||
e.preventDefault(); first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
modal.querySelector('form').addEventListener('submit', (e) => { e.preventDefault(); close(input.value); });
|
||||
modal.querySelector('[data-ui-prompt-cancel]').addEventListener('click', () => close(null));
|
||||
// Backdrop (overlay itself, not the panel) cancels.
|
||||
modal.addEventListener('mousedown', (e) => { if (e.target === modal) close(null); });
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
document.body.appendChild(modal);
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// The library's edit-song modal: open, validate, save, delete.
|
||||
//
|
||||
// Interface width ZERO — nothing in app.js calls into this cluster; app.js only needs the four
|
||||
// names on the window contract so the markup's onclick= handlers resolve. That is what makes it
|
||||
// the cleanest slice left, and it only became clean because the LIBRARY came out first (#896):
|
||||
// every dependency this modal has is now a module.
|
||||
//
|
||||
// It reads six bindings out of ./library.js (loadLibrary, loadFavorites, loadTreeView,
|
||||
// _removeLibCardsForFilename, libView, _lastLibSelected) and never writes one — checked, which
|
||||
// matters: an imported binding is READ-ONLY, so a single write would have forced a setter or a
|
||||
// container. Every use is a read, so plain imports suffice.
|
||||
//
|
||||
// Acyclic: edit-modal -> { dom, library-state, library }, and library imports none of them back.
|
||||
import { _confirmDialog, _escAttr, _trapFocusInModal } from './dom.js';
|
||||
import { L } from './library-state.js';
|
||||
import {
|
||||
_lastLibSelected, _removeLibCardsForFilename, libView, loadFavorites, loadLibrary, loadTreeView,
|
||||
} from './library.js';
|
||||
|
||||
// ── Edit metadata modal ─────────────────────────────────────────────────
|
||||
export function openEditModal(songData, openerEl) {
|
||||
const artUrl = `/api/song/${encodeURIComponent(songData.f)}/art?t=${Date.now()}`;
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'edit-modal';
|
||||
modal.className = 'feedBack-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm';
|
||||
// role=dialog: assistive tech announces it as a modal; also lets
|
||||
// the global keyboard listener's `_isInsideInteractiveControl`
|
||||
// bail when typing inside the modal so Library shortcuts don't
|
||||
// hijack keys from the edit form.
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-label', 'Edit song metadata');
|
||||
// Record the element that triggered the modal so Esc / Cancel can
|
||||
// return focus to the exact entry the user was on, even if
|
||||
// _lastLibSelected changes before the modal closes.
|
||||
// Prefer the explicitly-passed openerEl (from the edit-btn click
|
||||
// handler, which has the exact [data-play] parent) over
|
||||
// _lastLibSelected, which may not have been updated when the
|
||||
// click's stopPropagation() prevented the card-click handler.
|
||||
const _emActive = document.querySelector('.screen.active');
|
||||
const _emLast = (_lastLibSelected && document.body.contains(_lastLibSelected)
|
||||
&& _emActive && _emActive.contains(_lastLibSelected)) ? _lastLibSelected : null;
|
||||
modal._opener = (openerEl && document.body.contains(openerEl)) ? openerEl : _emLast;
|
||||
modal.innerHTML = `
|
||||
<div class="bg-dark-700 border border-gray-700 rounded-2xl p-6 w-full max-w-md mx-4 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Edit Song</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-4 mb-2">
|
||||
<div class="relative group cursor-pointer" id="edit-art-wrapper">
|
||||
<img src="${artUrl}" alt="" class="w-20 h-20 rounded-lg object-cover bg-dark-600" id="edit-art-preview">
|
||||
<div class="absolute inset-0 bg-black/50 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 transition">
|
||||
<span class="text-white text-xs">Change</span>
|
||||
</div>
|
||||
<input type="file" accept="image/*" id="edit-art-file" class="hidden" onchange="previewEditArt(this)">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 flex-1">Click image to change album art</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Title</label>
|
||||
<input type="text" id="edit-title" value="${_escAttr(songData.t)}"
|
||||
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Artist</label>
|
||||
<input type="text" id="edit-artist" value="${_escAttr(songData.a)}"
|
||||
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Album</label>
|
||||
<input type="text" id="edit-album" value="${_escAttr(songData.al)}"
|
||||
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-400 mb-1 block">Year</label>
|
||||
<input type="text" inputmode="numeric" id="edit-year" value="${_escAttr(songData.y)}" placeholder="e.g. 2024"
|
||||
class="w-full bg-dark-600 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-accent/50">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button data-edit-save
|
||||
class="flex-1 bg-accent hover:bg-accent-light px-4 py-2 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
<button data-edit-close
|
||||
class="px-4 py-2 bg-dark-600 hover:bg-dark-500 rounded-xl text-sm text-gray-300 transition">Cancel</button>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-800">
|
||||
<button data-delete-filename="${_escAttr(songData.f)}"
|
||||
class="w-full px-4 py-2 bg-red-900/30 hover:bg-red-900/60 border border-red-900/50 hover:border-red-700 rounded-xl text-sm text-red-300 hover:text-red-100 transition">Remove from library</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Move focus into the dialog's first text input so background
|
||||
// shortcuts (and arrow nav) can't fire on the underlying library
|
||||
// entry while the edit form is open. Title is the natural primary
|
||||
// field — most edits are correcting spelling there. Caret-end
|
||||
// selection so the user can keep typing rather than overtype the
|
||||
// current value.
|
||||
const titleInput = document.getElementById('edit-title');
|
||||
if (titleInput) {
|
||||
titleInput.focus({ preventScroll: true });
|
||||
try {
|
||||
const len = titleInput.value.length;
|
||||
titleInput.setSelectionRange(len, len);
|
||||
} catch { /* some browsers reject selection on certain input types */ }
|
||||
}
|
||||
|
||||
// Trap Tab / Shift+Tab inside the modal so focus can't escape to
|
||||
// the library content underneath while the edit form is open.
|
||||
_trapFocusInModal(modal);
|
||||
|
||||
// Click on art triggers file input
|
||||
document.getElementById('edit-art-wrapper').addEventListener('click', () => {
|
||||
document.getElementById('edit-art-file').click();
|
||||
});
|
||||
|
||||
// Save — wired in JS (not an inline onclick) so the filename never has to
|
||||
// survive embedding in a single-quoted attribute string. encodeURIComponent
|
||||
// does NOT escape `'`, so a filename like `Bob's Song.sloppak` used to break
|
||||
// the inline `saveEditModal('…')` handler and silently fail the save. The
|
||||
// raw filename lives in the closure; encode it here for saveEditModal.
|
||||
const saveBtn = modal.querySelector('[data-edit-save]');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', () => saveEditModal(encodeURIComponent(songData.f)));
|
||||
}
|
||||
|
||||
const deleteBtn = modal.querySelector('[data-delete-filename]');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', () => {
|
||||
deleteSongFromModal(deleteBtn.dataset.deleteFilename);
|
||||
});
|
||||
}
|
||||
|
||||
// Close on backdrop click or Cancel button; restore focus to opener.
|
||||
// Backdrop dismissal requires the gesture's mousedown to have STARTED on
|
||||
// the backdrop — not just the click/mouseup to land there. Otherwise a
|
||||
// click-drag that begins inside a field (e.g. selecting text) and is
|
||||
// released past the modal edge resolves its `click` target to the backdrop
|
||||
// and silently discards the edit. Cancel / ✕ (data-edit-close) always close.
|
||||
let _downOnBackdrop = false;
|
||||
modal.addEventListener('mousedown', (e) => { _downOnBackdrop = (e.target === modal); });
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (!_editModalShouldClose(e.target, modal, _downOnBackdrop)) return;
|
||||
const opener = modal._opener;
|
||||
modal.remove();
|
||||
const focusTarget = (opener && document.body.contains(opener)) ? opener
|
||||
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Whether a click on the edit-metadata modal should dismiss it. The Cancel / ✕
|
||||
// control (data-edit-close) always dismisses. A backdrop dismissal needs BOTH
|
||||
// the click target to be the backdrop element itself AND the gesture to have
|
||||
// started there (downOnBackdrop) — so a click-drag begun inside a field and
|
||||
// released on the backdrop does not discard the form. Pure + top-level so it's
|
||||
// unit-testable in isolation.
|
||||
export function _editModalShouldClose(clickTarget, modalEl, downOnBackdrop) {
|
||||
if (clickTarget && clickTarget.closest && clickTarget.closest('[data-edit-close]')) return true;
|
||||
return clickTarget === modalEl && downOnBackdrop === true;
|
||||
}
|
||||
|
||||
export async function saveEditModal(encodedFilename) {
|
||||
const filename = decodeURIComponent(encodedFilename);
|
||||
|
||||
// Save metadata
|
||||
await fetch(`/api/song/${encodeURIComponent(filename)}/meta`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: document.getElementById('edit-title').value.trim(),
|
||||
artist: document.getElementById('edit-artist').value.trim(),
|
||||
album: document.getElementById('edit-album').value.trim(),
|
||||
// Year is normalised server-side (non-numeric/empty → ""), so a
|
||||
// blank or cleared field round-trips safely.
|
||||
year: document.getElementById('edit-year').value.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Upload art if changed
|
||||
const fileInput = document.getElementById('edit-art-file');
|
||||
if (fileInput.files && fileInput.files[0]) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
await fetch(`/api/song/${encodeURIComponent(filename)}/art/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: e.target.result }),
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(fileInput.files[0]);
|
||||
}
|
||||
|
||||
const modal = document.getElementById('edit-modal');
|
||||
const opener = modal ? modal._opener : null;
|
||||
if (modal) modal.remove();
|
||||
// Restore focus to the entry the modal was opened from so subsequent
|
||||
// keyboard navigation resumes correctly (same as Esc / Cancel paths).
|
||||
const focusTarget = (opener && document.body.contains(opener)) ? opener
|
||||
: (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null);
|
||||
if (focusTarget) focusTarget.focus({ preventScroll: true });
|
||||
// Refresh current view
|
||||
const activeScreen = document.querySelector('.screen.active');
|
||||
if (activeScreen?.id === 'favorites') loadFavorites();
|
||||
else loadLibrary();
|
||||
}
|
||||
|
||||
export async function deleteSongFromModal(filename) {
|
||||
const title = (document.getElementById('edit-title')?.value || filename).trim();
|
||||
const ok = await _confirmDialog({
|
||||
title: 'Remove from library?',
|
||||
body: `<p class="text-sm text-gray-300">Remove <span class="font-semibold text-white">${_escAttr(title)}</span> from your library?</p>
|
||||
<p class="text-xs text-red-400/90 mt-2">This permanently deletes the file from disk. This cannot be undone.</p>`,
|
||||
confirmText: 'Remove',
|
||||
cancelText: 'Cancel',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(`/api/song/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
} catch (e) {
|
||||
alert(`Delete failed: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
let msg = resp.statusText;
|
||||
try { msg = (await resp.json()).error || msg; } catch (_) {}
|
||||
alert(`Delete failed: ${msg}`);
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById('edit-modal');
|
||||
if (modal) modal.remove();
|
||||
L.treeStats = null;
|
||||
L.favTreeStats = null;
|
||||
L.tuningNames = null;
|
||||
|
||||
// Remove the deleted song's card from any currently-rendered grid/tree
|
||||
// so the user sees it disappear without waiting for a refetch. A full
|
||||
// loadLibrary() here would re-call loadGridPage(currentPage), which
|
||||
// uses 'append' mode when currentPage > 0 and re-appends the same
|
||||
// (now-shortened) page on top of what's already rendered — leaving
|
||||
// the deleted card visible. Direct DOM removal also preserves scroll
|
||||
// position, which a refetch from page 0 would lose.
|
||||
_removeLibCardsForFilename(filename);
|
||||
|
||||
// Tree views group by artist with song counts; a single card removal
|
||||
// leaves stale counts, so refresh the tree for whichever screen we're
|
||||
// looking at (each tree-view renderer replaces innerHTML cleanly).
|
||||
const activeScreen = document.querySelector('.screen.active');
|
||||
if (activeScreen?.id === 'favorites') {
|
||||
// loadFavorites() routes to either loadFavGridPage (always
|
||||
// 'replace') or loadFavTreeView — both safe for a single delete.
|
||||
loadFavorites();
|
||||
} else if (libView === 'tree') {
|
||||
loadTreeView();
|
||||
}
|
||||
// Main library grid view: DOM removal above is sufficient.
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user