mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 13:04:29 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bf4ec064a | ||
|
|
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 |
@@ -34,7 +34,7 @@ but not the primary supported path.
|
||||
|
||||
### II. Vanilla Frontend — No Frameworks
|
||||
|
||||
The frontend (`static/app.js`, `static/highway.js`, `static/index.html`,
|
||||
The frontend (`static/app.js`, `static/highway.js`, `static/v3/index.html`,
|
||||
`static/style.css`) is plain JavaScript with the `fetch` API, direct DOM
|
||||
manipulation, and the Canvas 2D / WebGL2 APIs. The only style framework
|
||||
is Tailwind CSS, served as a prebuilt static stylesheet
|
||||
@@ -284,4 +284,4 @@ no `..`, no absolute paths).
|
||||
higher-numbered principle's escape hatch is to live in a plugin
|
||||
with its own bundled assets.
|
||||
|
||||
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-08
|
||||
**Version**: 1.3.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-11
|
||||
|
||||
+21
-1
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Removed
|
||||
- **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the
|
||||
`/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve
|
||||
`static/v3/index.html`, which has been the default since 0.3.0. This is the first step of
|
||||
the core-frontend ES-module migration (R3a): both shells load the same `static/app.js`, so
|
||||
every subsequent step of that migration would otherwise have to be made, and verified,
|
||||
twice. Removing the fallback now halves that surface before any of it is touched.
|
||||
Incidentally fixes a latent bug in the old `index()` route — its guard read
|
||||
`if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy")`,
|
||||
whose left operand is truthy for *any* non-empty value, so `FEEDBACK_UI=v3` actually served
|
||||
the **v2** shell. `static/tailwind.min.css` is regenerated (the content globs scanned the
|
||||
deleted file, so v2-only utility classes are now purged). Constitution amended to 1.3.0:
|
||||
Principle II's frontend file list now names `static/v3/index.html`.
|
||||
**Migration notes:** if you set `FEEDBACK_UI=v2` (or `=legacy`), or bookmarked `/v2`, there
|
||||
is no longer a classic shell to fall back to — unset the variable and use `/`. The env var
|
||||
itself is no longer read; the `SLOPSMITH_*`→`FEEDBACK_*` compat shim is unaffected. No
|
||||
chart, settings, or plugin data changes, and no plugin API changes: v3 reuses the same
|
||||
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
||||
|
||||
### Fixed
|
||||
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
|
||||
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
|
||||
@@ -27,7 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`.
|
||||
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
|
||||
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `media` (the file-serving routes — song audio `/audio/{f}`, the local-audio-path resolver `/api/audio-local-path`, and raw sloppak-member serving `/api/sloppak/{f}/file/{rel}` → `lib/routers/media.py`; the cache/static path seams were already in `appstate`), and `artist` (the artist page + external-links payload `/api/artist/{name}/page|links|links/refresh` → `lib/routers/artist.py`; MB link enrichment reached as `enrichment.X`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
|
||||
- **`routers/` — the first extracted route module (R3).** The five audio-effects mapping
|
||||
endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a
|
||||
`fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file
|
||||
@@ -40,6 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
the second slot. The `_demo_mode_guard` middleware still blocks all four moved write
|
||||
routes with 403, and `Query(...)` validation still 422s — both checked against a running
|
||||
server. `server.py`: **9,445 → 9,386 lines**.
|
||||
- **`lib/enrichment.py` — the metadata-enrichment subsystem leaves `server.py` (R3, move-only).** MusicBrainz / Cover-Art-Archive / AcoustID transport, the match-scorer glue, and the background enrichment worker (~930 lines, 61 defs) move out as one cohesive unit. Bodies are verbatim; the only changes are seam reads — `meta_db`/`config_dir`/`sloppak_cache_dir`/`art_cache_dir` and the two shared art helpers (`song_pack_art_exists`, `art_override_paths`, which stay in `server.py` for the art/delete routes) are reached through `appstate` at call time, and the User-Agent VERSION lookup is corrected for the module's new depth. `server.py` drives the worker through the module (`import enrichment`; the routes + scan lifecycle call `enrichment.X`); tests that faked the network on `server` now patch the same names on `enrichment` (module attribute resolved at call time, so one `setattr` reaches both the routes and the worker's internal callers). Acyclic — `enrichment` imports no `server`. Route table byte-identical; full suite green. `server.py`: 6,917 → 5,988.
|
||||
- **`appstate.py` — the router seam (R3).** Route modules moving out of `server.py`
|
||||
need `meta_db` and friends but must not `import server`, or the import graph goes
|
||||
circular the moment `server` imports them back. So `server.py` keeps *constructing*
|
||||
|
||||
@@ -125,13 +125,13 @@ Notes:
|
||||
|
||||
### v3 UI (fee[dB]ack v0.3.0) — player-chrome contract
|
||||
|
||||
v0.3.0 ships a redesigned UI behind a flag (`FEEDBACK_UI=v3` or the `/v3` route);
|
||||
the classic UI (v2) stays the default until 0.3.0 ships, so **plugins must work in
|
||||
both**. v3 reuses the same engine (`server.py`, `app.js`, `highway.js`, `playSong`,
|
||||
v0.3.0's redesigned UI is **the only UI** — the classic v2 shell and its
|
||||
`FEEDBACK_UI` / `/v2` opt-outs are gone, so there is no second shell to support.
|
||||
v3 reuses the same engine (`server.py`, `app.js`, `highway.js`, `playSong`,
|
||||
`showScreen`, capabilities, library providers, the `window.feedBackViz_<id>` /
|
||||
`setRenderer` contract), so a plugin's **backend, capabilities, `nav`/`screen`,
|
||||
visualization renderers, diagnostics, and settings export work unchanged** — v3
|
||||
surfaces `nav` in its sidebar and mounts screens exactly as v2 does.
|
||||
surfaces `nav` in its sidebar and mounts screens as before.
|
||||
|
||||
**The only thing that changed is the player chrome.** If your plugin injects a
|
||||
control into it, you must adapt:
|
||||
@@ -157,7 +157,7 @@ control into it, you must adapt:
|
||||
popovers 40).
|
||||
|
||||
Full guide + the canonical snippet: **[docs/plugin-v3-ui.md](docs/plugin-v3-ui.md)**.
|
||||
Verify any player-injecting plugin in **both** `/` (v2) and `/v3`.
|
||||
Verify any player-injecting plugin at `/` — it and `/v3` serve the same v3 shell.
|
||||
|
||||
### Performance — never run DOM queries on a per-frame path
|
||||
|
||||
@@ -566,7 +566,7 @@ a local pointer + code map.
|
||||
- **Storage** — `localStorage` for all user preferences
|
||||
- **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (feedBack-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II.
|
||||
- **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs
|
||||
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable. (v2 is unchanged.)
|
||||
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable.
|
||||
- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it.
|
||||
|
||||
## Backend Conventions
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable.
|
||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
||||
|
||||
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
|
||||
(7,880 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and seven `routers/` modules) ·
|
||||
(2,413 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and twenty-two `routers/` modules, plus lib/library_registry.py for the provider-registry classes (album-art in `lib/routers/art.py`, the settings + export/import bundle in `lib/routers/settings.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) ·
|
||||
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
|
||||
and is a monolith in its own right, to be split per-table once the router train
|
||||
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
|
||||
+12
-5
@@ -43,12 +43,19 @@ module.exports = [
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
|
||||
rules: { 'max-lines': sizeRule(1500) },
|
||||
},
|
||||
// ES-module graphs (a plugin's src/ tree, .mjs tests): module parsing + the
|
||||
// acyclic-imports hard gate + the size norm. A migrated bundled plugin's
|
||||
// entry `import './src/main.js'` screen.js must parse as a module — add its
|
||||
// glob here in that plugin's migration PR (classic screen.js stays a script).
|
||||
// ES-module graphs (a plugin's src/ tree, .mjs tests, core's own static/js/
|
||||
// tree): module parsing + the acyclic-imports hard gate + the size norm. A
|
||||
// migrated bundled plugin's entry `import './src/main.js'` screen.js must
|
||||
// parse as a module — add its glob here in that plugin's migration PR
|
||||
// (classic screen.js stays a script).
|
||||
//
|
||||
// `static/app.js` is listed explicitly: it is served as
|
||||
// <script type="module"> (R3a) and now `import`s its carved-out modules, so
|
||||
// parsing it as a script would be a syntax error. It is the ENTRY of core's
|
||||
// module graph, which is what makes no-cycle meaningful here — a carved
|
||||
// module that imports app.js back would close a cycle and fail this gate.
|
||||
{
|
||||
files: ['**/src/**/*.js', '**/*.mjs'],
|
||||
files: ['**/src/**/*.js', '**/*.mjs', 'static/app.js', 'static/js/**/*.js'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: { 'import-x': importX },
|
||||
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
||||
|
||||
@@ -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
|
||||
+42
-1
@@ -61,6 +61,16 @@ copies a hardcoded file list — that regression is what moved this file here.
|
||||
# The singletons routers may read. Every name here must also be a `_SLOTS` key.
|
||||
meta_db = None
|
||||
audio_effect_mappings = None
|
||||
# The tuning-provider registry instance (built-ins + plugin-contributed). A
|
||||
# stable object mutated in place via register()/unregister() — injected here by
|
||||
# reference so routers read the same registry plugins populate.
|
||||
tuning_providers = None
|
||||
# The library-provider registry instance + the local provider, constructed in
|
||||
# server.py (LocalLibraryProvider needs meta_db) and injected by reference. The
|
||||
# classes live in lib/library_registry.py; plugins register their own providers
|
||||
# through the registry via plugin_context.
|
||||
library_providers = None
|
||||
local_library_provider = None
|
||||
|
||||
# Config paths. server.py derives these from the environment (fresh on every
|
||||
# import, so the ~49 pop-and-reimport fixtures keep working) and injects them
|
||||
@@ -82,10 +92,41 @@ 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
|
||||
|
||||
_SLOTS = frozenset({
|
||||
"meta_db", "audio_effect_mappings",
|
||||
"meta_db", "audio_effect_mappings", "tuning_providers",
|
||||
"library_providers", "local_library_provider",
|
||||
"config_dir", "dlc_dir", "dlc_dir_env",
|
||||
"static_dir", "sloppak_cache_dir", "audio_cache_dir",
|
||||
"get_progression_content", "builtin_diagnostic_filename",
|
||||
"running_version",
|
||||
"art_cache_dir", "song_pack_art_exists", "art_override_paths", "art_safe_name",
|
||||
"default_settings",
|
||||
"kick_scan", "invalidate_song_caches", "stat_for_cache", "scan_status",
|
||||
})
|
||||
|
||||
|
||||
|
||||
+1107
File diff suppressed because it is too large
Load Diff
@@ -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}")
|
||||
@@ -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,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,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,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,233 @@
|
||||
"""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)
|
||||
|
||||
# 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)
|
||||
# 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}
|
||||
|
||||
# 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)
|
||||
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,
|
||||
}
|
||||
+319
-2981
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
+895
-892
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,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,601 @@
|
||||
// Highway string colours — user theming for the 2D + bundled 3D highways.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
//
|
||||
// Slot→hex colours (per named string slot, so a 6-string map survives a 4-string
|
||||
// bass and a 7-string's Low B), named themes in localStorage, a copy/paste share
|
||||
// code, and the Settings-screen picker UI. The highways colour by raw string
|
||||
// INDEX, so a translation table maps named slots → per-index colours for the
|
||||
// current arrangement, recomputed whenever a song loads.
|
||||
//
|
||||
// Exports exactly two entry points; the other 43 symbols (the HWC_* tables, the
|
||||
// theme store, the picker handlers, the window.feedBack facade) are used nowhere
|
||||
// else in core and stay private. The Settings buttons are wired by
|
||||
// addEventListener inside hwcInitSettingsUI — there are no inline on*= handlers
|
||||
// here, so nothing needs re-exposing on window.
|
||||
//
|
||||
// It does import uiPrompt from ./dom.js (the "name this theme" prompt) — which is
|
||||
// precisely why dom.js was carved out first: without it this module would have
|
||||
// needed a host seam back into app.js.
|
||||
import { uiPrompt } from './dom.js';
|
||||
|
||||
// Colors are assigned per NAMED string (Low E, A, D, G, B, High E, plus the
|
||||
// extended low strings of 7/8-string guitars), so a string keeps its color
|
||||
// when the string count changes (e.g. Low E stays the same from a 6-string
|
||||
// guitar to a 4-string bass, and on a 7-string the extra Low B takes the
|
||||
// 7-string slot rather than bumping every color over). The highways color by
|
||||
// raw string INDEX, so a small translation table maps named slots → per-index
|
||||
// colors for the current arrangement; this is recomputed whenever a song loads
|
||||
// (its string count / bass-vs-guitar may differ). Applies to BOTH the 2D and
|
||||
// bundled 3D highway; stored client-side; shared via a copy/paste code.
|
||||
const HWC_KEY_ACTIVE = 'highwayStringColors'; // JSON slot→hex map (active)
|
||||
const HWC_KEY_THEMES = 'highwayColorThemes'; // { "<name>": {slot:hex} }
|
||||
const HWC_KEY_NAME = 'highwayColorActiveName'; // selected saved theme name, or ''
|
||||
const HWC_HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
// Named color slots, in display order (high → low, then extended low strings).
|
||||
const HWC_SLOTS = [
|
||||
{ key: 'highE', label: 'High E', sub: '1st' },
|
||||
{ key: 'B', label: 'B', sub: '2nd' },
|
||||
{ key: 'G', label: 'G', sub: '3rd' },
|
||||
{ key: 'D', label: 'D', sub: '4th' },
|
||||
{ key: 'A', label: 'A', sub: '5th' },
|
||||
{ key: 'lowE', label: 'Low E', sub: '6th / lowest' },
|
||||
{ key: 'low7', label: 'Low B', sub: '7-string' },
|
||||
{ key: 'low8', label: 'Low F#', sub: '8-string' },
|
||||
];
|
||||
const HWC_SLOT_KEYS = HWC_SLOTS.map((s) => s.key);
|
||||
// Hardcoded fallback (matches the highway defaults) for before the 2D highway
|
||||
// is queryable.
|
||||
const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '#cc6600', B: '#00cc66', highE: '#9900cc', low7: '#cc00aa', low8: '#00cccc' };
|
||||
|
||||
// One-click string-color presets. Each is a full named-slot → hex map (every
|
||||
// slot, so 7/8-string charts get a sensible color too) keyed by the same slot
|
||||
// names as HWC_SLOTS, so "Low E" always lands on the lowE slot regardless of
|
||||
// string count. Hues are chosen for the dark scene (~#080810): each color is
|
||||
// bright enough to read on black and distinct from its neighbours.
|
||||
// - warmcool: an ordered low→high spectrum (warm reds at the bass end →
|
||||
// cool blues/violet at the treble end) so pitch reads as color temperature.
|
||||
// - vivid: punchier, higher-saturation take on the classic mapping for a
|
||||
// stage-bright look.
|
||||
// - colorblind: the Okabe–Ito accessible qualitative palette (vermillion,
|
||||
// orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most
|
||||
// distinguishable option for deuteranopia/protanopia.
|
||||
// - colorblind_deuteranope: a deuteranope-tuned variant of the Okabe–Ito set
|
||||
// above, contributed by a deuteranopic player who still found that set hard
|
||||
// to separate. Retunes the six main strings (red / yellow-green / blue /
|
||||
// orange / teal / deep-purple) and keeps its 7/8-string colors unchanged.
|
||||
// - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags
|
||||
// between neighbours (bright→bright→brightest→dark blue→bright green→dark
|
||||
// violet) so adjacent strings separate harder than vivid — a stage/stream
|
||||
// "pop" set, not a vivid duplicate.
|
||||
// - accessible: a CVD-safe set ORDERED by ascending lightness low→high (deep
|
||||
// blue → vermilion → azure → orange → yellow → cream). Unlike the unordered
|
||||
// Okabe–Ito 'colorblind' set, the value ramp teaches pitch low→high AND
|
||||
// survives grayscale/colorblindness; no red/green pair carries meaning.
|
||||
// - ember: a warm, lower-intensity family for long sessions, luminance-stepped
|
||||
// from rust/ember at the bass through warm gold to cream at the treble. The
|
||||
// bass embers stay light enough to clear the near-black scene.
|
||||
// - tapedeck: a vintage-print, slightly desaturated ochre-tinted family
|
||||
// (rust-red → mustard → avocado → teal → faded denim → dusty plum). Muted
|
||||
// hues collapse, so neighbour LIGHTNESS deliberately zig-zags to keep the
|
||||
// dusty mid-strings (avocado/teal/denim) distinct on the dark board.
|
||||
// - crtgreen / crtamber: monochrome CRT-phosphor families (green / amber)
|
||||
// stepped by STRICT ASCENDING LIGHTNESS low→high. Mono sets collapse on hue,
|
||||
// so lightness alone carries the ordering. Verified to stay legible even on
|
||||
// the matching phosphor scene board (green-on-green / amber-on-amber).
|
||||
// - pitchramp: a smooth low→high hue sweep (violet → blue → teal → green →
|
||||
// yellow → warm-white) with rising lightness — memorable + teaches order.
|
||||
// - sunrise: a soft dawn gradient (plum → rose → coral → amber → gold → cream),
|
||||
// warm and lower-intensity, lightness-stepped low→high.
|
||||
const HWC_PRESETS = [
|
||||
{
|
||||
id: 'warmcool', label: 'Warm → Cool',
|
||||
colors: { lowE: '#ff3b30', A: '#ff7a18', D: '#ffc400', G: '#36c46a', B: '#2196f3', highE: '#9b5cff', low7: '#ff2d78', low8: '#00c2c7' },
|
||||
},
|
||||
{
|
||||
id: 'vivid', label: 'Vivid',
|
||||
colors: { lowE: '#ff2222', A: '#ffd000', D: '#1e8bff', G: '#ff7a00', B: '#16d65a', highE: '#b24bff', low7: '#ff3cc0', low8: '#15d8d8' },
|
||||
},
|
||||
{
|
||||
id: 'colorblind', label: 'Colorblind-friendly',
|
||||
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'colorblind_deuteranope', label: 'Colorblind (deuteranope)',
|
||||
colors: { lowE: '#aa1414', A: '#88de00', D: '#1889e3', G: '#c6601c', B: '#00f5b2', highE: '#4d2173', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'neon', label: 'Neon',
|
||||
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
|
||||
},
|
||||
{
|
||||
id: 'accessible', label: 'Accessible (ordered)',
|
||||
colors: { lowE: '#2453c0', A: '#c44a00', D: '#3f93cf', G: '#ec9a1e', B: '#f2d43c', highE: '#f5eecb', low7: '#173f96', low8: '#0f2c6b' },
|
||||
},
|
||||
{
|
||||
id: 'ember', label: 'Warm Ember',
|
||||
colors: { lowE: '#c0392b', A: '#e0552a', D: '#ef7d2e', G: '#f6a13a', B: '#f4c95d', highE: '#f7e3a8', low7: '#9e2f23', low8: '#7d2418' },
|
||||
},
|
||||
{
|
||||
id: 'tapedeck', label: 'Tape Deck',
|
||||
colors: { lowE: '#b04632', A: '#d8ad42', D: '#5f7a34', G: '#54b3a6', B: '#5e83ad', highE: '#b98abb', low7: '#8f3526', low8: '#6f2a1e' },
|
||||
},
|
||||
{
|
||||
id: 'crtgreen', label: 'CRT Green',
|
||||
colors: { lowE: '#0a5a23', A: '#108a30', D: '#1fb53f', G: '#3ad94f', B: '#74f06a', highE: '#c7ffb0', low7: '#08491c', low8: '#063514' },
|
||||
},
|
||||
{
|
||||
id: 'crtamber', label: 'CRT Amber',
|
||||
colors: { lowE: '#7a3a02', A: '#a85f06', D: '#cf8410', G: '#e8a82a', B: '#f4cf5e', highE: '#ffeeb8', low7: '#5f2d01', low8: '#471f00' },
|
||||
},
|
||||
{
|
||||
id: 'pitchramp', label: 'Pitch Ramp',
|
||||
colors: { lowE: '#7a2390', A: '#2f5ad8', D: '#1f9bc4', G: '#2fb84a', B: '#cfd22a', highE: '#f3e0c0', low7: '#5e1a78', low8: '#440f5e' },
|
||||
},
|
||||
{
|
||||
id: 'sunrise', label: 'Sunrise',
|
||||
colors: { lowE: '#8a3a6e', A: '#bf4a5e', D: '#e0664f', G: '#f29a55', B: '#f7c873', highE: '#fce8b8', low7: '#6e2c5c', low8: '#54214a' },
|
||||
},
|
||||
];
|
||||
|
||||
// Translation table: chart string index → named slot, for a given string count
|
||||
// and bass/guitar family. Mirrors the 3D highway's _baseOpenStringMidis: bass
|
||||
// shares the low strings (E A D G), 7/8-string guitars prepend lower strings,
|
||||
// and sub-6 guitars truncate from the high end. Index 0 is always the lowest.
|
||||
function _hwcSlotKeysForChart(sc, isBass) {
|
||||
sc = Math.max(1, Math.min(8, (sc | 0) || 6));
|
||||
if (isBass) {
|
||||
if (sc <= 4) return ['lowE', 'A', 'D', 'G'].slice(0, sc);
|
||||
if (sc === 5) return ['low7', 'lowE', 'A', 'D', 'G'];
|
||||
return ['low8', 'low7', 'lowE', 'A', 'D', 'G'].slice(0, sc);
|
||||
}
|
||||
if (sc <= 6) return ['lowE', 'A', 'D', 'G', 'B', 'highE'].slice(0, sc);
|
||||
if (sc === 7) return ['low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
|
||||
return ['low8', 'low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
|
||||
}
|
||||
|
||||
// Current arrangement shape (string count + bass-vs-guitar) from the 2D highway.
|
||||
function _hwcChartShape() {
|
||||
let sc = 6, arr = '';
|
||||
try { sc = window.highway?.getStringCount?.() || 6; } catch (_) {}
|
||||
try { arr = window.highway?.getSongInfo?.()?.arrangement || window.feedBack?.currentSong?.arrangement || ''; } catch (_) {}
|
||||
return { sc: Math.max(1, Math.min(8, sc)), isBass: /bass/i.test(String(arr)) };
|
||||
}
|
||||
|
||||
// Normalize an arbitrary value to a slot→hex map of validated lowercase colors
|
||||
// (absent / invalid slots are omitted).
|
||||
function _hwcNormalize(slotMap) {
|
||||
const out = {};
|
||||
if (slotMap && typeof slotMap === 'object' && !Array.isArray(slotMap)) {
|
||||
for (const k of HWC_SLOT_KEYS) {
|
||||
const v = (typeof slotMap[k] === 'string') ? slotMap[k].trim().toLowerCase() : '';
|
||||
if (HWC_HEX_RE.test(v)) out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Canonical default color per named slot (the classic highway mapping).
|
||||
// Fixed, not read back from the highway (which may already be name-remapped for
|
||||
// a 7/8-string chart), so the pickers always preview the true per-name default.
|
||||
function getHighwayDefaultSlotColors() {
|
||||
return { ...HWC_DEFAULT_FALLBACK };
|
||||
}
|
||||
|
||||
// Active (user-customized) slot→hex map from storage ({} when none set).
|
||||
function getHighwayStringColors() {
|
||||
try {
|
||||
const raw = localStorage.getItem(HWC_KEY_ACTIVE);
|
||||
if (raw) return _hwcNormalize(JSON.parse(raw));
|
||||
} catch (_) { /* corrupt / blocked */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
// Defaults overlaid with the user's custom slots (custom wins). Always a full
|
||||
// 8-slot map, so name-mapping has a color for every string of any arrangement.
|
||||
function _hwcMergedSlotColors() {
|
||||
return { ...getHighwayDefaultSlotColors(), ...getHighwayStringColors() };
|
||||
}
|
||||
|
||||
// True when the slot→index mapping is the identity (index 0 = lowest = Low E):
|
||||
// guitar ≤6 strings and 4-string bass. For these the name mapping equals the
|
||||
// stock index order, so we leave the highways on their hand-tuned defaults
|
||||
// (byte-identical) unless the user set custom colors. Extended-range charts —
|
||||
// 7/8-string guitar and 5/6-string bass — prepend lower strings (Low B/F#),
|
||||
// shifting Low E up an index, so their defaults must be name-remapped too.
|
||||
function _hwcMappingIsIdentity(sc, isBass) {
|
||||
return isBass ? sc <= 4 : sc <= 6;
|
||||
}
|
||||
|
||||
// Translate a full slot map into the index-keyed array the highways consume.
|
||||
function _hwcEffectiveIndexColors(slotMap, sc, isBass) {
|
||||
const keys = _hwcSlotKeysForChart(sc, isBass);
|
||||
return keys.map((k) => slotMap[k] || null);
|
||||
}
|
||||
|
||||
// Persist the user's custom slot map (or clear it), then apply. Only slots that
|
||||
// actually DIFFER from the default are stored — so reverting every picker to its
|
||||
// stock color persists as empty and the identity/stock path is restored (rather
|
||||
// than pinning the highways on an all-default "custom" theme).
|
||||
function applyHighwayStringColors(slotMap, opts) {
|
||||
const persist = !opts || opts.persist !== false;
|
||||
const colors = _hwcNormalize(slotMap);
|
||||
const defaults = getHighwayDefaultSlotColors();
|
||||
const overrides = {};
|
||||
for (const k of Object.keys(colors)) {
|
||||
if (colors[k] !== defaults[k]) overrides[k] = colors[k];
|
||||
}
|
||||
if (persist) {
|
||||
try {
|
||||
if (Object.keys(overrides).length) localStorage.setItem(HWC_KEY_ACTIVE, JSON.stringify(overrides));
|
||||
else localStorage.removeItem(HWC_KEY_ACTIVE);
|
||||
} catch (_) {}
|
||||
}
|
||||
reapplyHighwayStringColors();
|
||||
}
|
||||
|
||||
// Apply a named one-click string-color preset (see HWC_PRESETS) to all strings.
|
||||
// Persists + applies to both highways (via applyHighwayStringColors), then —
|
||||
// when the Settings UI is mounted — refreshes the per-string pickers so their
|
||||
// swatches show the preset's colors. Unknown id is a no-op.
|
||||
function applyHighwayStringPreset(id) {
|
||||
const preset = HWC_PRESETS.find((p) => p.id === id);
|
||||
if (!preset) return false;
|
||||
applyHighwayStringColors(preset.colors);
|
||||
try { if (typeof hwcRenderPickers === 'function') hwcRenderPickers(); } catch (_) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Apply colors by NAMED string to both highways for the current arrangement.
|
||||
// Colors follow the string name regardless of count: Low E stays Low E's color
|
||||
// on a 6-, 7-, or 8-string. Defaults map identically to the stock order for
|
||||
// 6-string/bass (so those stay byte-identical); 7/8-string remaps the defaults
|
||||
// too so Low E keeps its color. The String Colors UI replaces the 3D highway's
|
||||
// old palette picker, so core always drives the 3D string colors here.
|
||||
function reapplyHighwayStringColors() {
|
||||
const { sc, isBass } = _hwcChartShape();
|
||||
const custom = getHighwayStringColors();
|
||||
const hasCustom = Object.keys(custom).length > 0;
|
||||
|
||||
if (!hasCustom && _hwcMappingIsIdentity(sc, isBass)) {
|
||||
// Pure stock defaults in natural order — leave the hand-tuned highway
|
||||
// defaults intact, and make sure the 3D is on its plain default palette
|
||||
// (clears any stale 'custom' / leftover palette selection).
|
||||
try { window.highway?.setStringColors?.(null); } catch (_) {}
|
||||
try {
|
||||
if (localStorage.getItem('h3d_bg_palette') !== 'default') window.h3dBgSetPalette?.('default');
|
||||
} catch (_) {}
|
||||
try { window.feedBack?.emit?.('highway:stringColors', {}); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
const eff = _hwcEffectiveIndexColors(_hwcMergedSlotColors(), sc, isBass);
|
||||
try { window.highway?.setStringColors?.(eff); } catch (_) {}
|
||||
try { window.h3dBgSetStringColors?.(eff); } catch (_) {}
|
||||
try { window.feedBack?.emit?.('highway:stringColors', custom); } catch (_) {}
|
||||
}
|
||||
|
||||
function _hwcReadThemes() {
|
||||
// Null-prototype store: theme names come from user input / share codes, so
|
||||
// names like `constructor`/`toString`/`__proto__` must not collide with
|
||||
// inherited Object properties or mutate the prototype.
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(HWC_KEY_THEMES) || '{}');
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return Object.create(null);
|
||||
const out = Object.create(null);
|
||||
for (const [name, colors] of Object.entries(parsed)) out[name] = _hwcNormalize(colors);
|
||||
return out;
|
||||
} catch (_) { return Object.create(null); }
|
||||
}
|
||||
function _hwcWriteThemes(o) { try { localStorage.setItem(HWC_KEY_THEMES, JSON.stringify(o)); } catch (_) {} }
|
||||
function listHighwayColorThemes() { return Object.keys(_hwcReadThemes()); }
|
||||
function getActiveHighwayColorThemeName() { try { return localStorage.getItem(HWC_KEY_NAME) || ''; } catch (_) { return ''; } }
|
||||
|
||||
function saveHighwayColorTheme(name, slotMap) {
|
||||
name = String(name || '').trim();
|
||||
if (!name) return false;
|
||||
const o = _hwcReadThemes();
|
||||
o[name] = _hwcNormalize(slotMap);
|
||||
_hwcWriteThemes(o);
|
||||
try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {}
|
||||
return true;
|
||||
}
|
||||
function deleteHighwayColorTheme(name) {
|
||||
const o = _hwcReadThemes();
|
||||
if (Object.prototype.hasOwnProperty.call(o, name)) { delete o[name]; _hwcWriteThemes(o); }
|
||||
if (getActiveHighwayColorThemeName() === name) { try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {} }
|
||||
}
|
||||
// Select a saved theme by name, or pass '' to revert to defaults.
|
||||
function selectHighwayColorTheme(name) {
|
||||
if (!name) {
|
||||
try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {}
|
||||
applyHighwayStringColors(null);
|
||||
return;
|
||||
}
|
||||
const o = _hwcReadThemes();
|
||||
if (!Object.prototype.hasOwnProperty.call(o, name)) return;
|
||||
try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {}
|
||||
applyHighwayStringColors(o[name]);
|
||||
}
|
||||
|
||||
// Compact, paste-friendly share code: "SLOPHWY2." + base64url(JSON{n,c}) where
|
||||
// c is the named slot→hex map.
|
||||
function encodeHighwayColorShare(name, slotMap) {
|
||||
const payload = { n: String(name || '').slice(0, 60), c: _hwcNormalize(slotMap) };
|
||||
const json = JSON.stringify(payload);
|
||||
let b64;
|
||||
try { b64 = btoa(unescape(encodeURIComponent(json))); } catch (_) { b64 = btoa(json); }
|
||||
return 'SLOPHWY2.' + b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
function decodeHighwayColorShare(code) {
|
||||
if (typeof code !== 'string') return null;
|
||||
let s = code.trim();
|
||||
// Require the exact versioned prefix. Anything else (a future/legacy
|
||||
// SLOPHWY*, or unprefixed text) is rejected so the version boundary is real.
|
||||
const PREFIX = 'SLOPHWY2.';
|
||||
if (s.slice(0, PREFIX.length).toUpperCase() !== PREFIX) return null;
|
||||
s = s.slice(PREFIX.length);
|
||||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (s.length % 4) s += '=';
|
||||
let json;
|
||||
try { json = decodeURIComponent(escape(atob(s))); } catch (_) { try { json = atob(s); } catch (_) { return null; } }
|
||||
let obj;
|
||||
try { obj = JSON.parse(json); } catch (_) { return null; }
|
||||
if (!obj || typeof obj.c !== 'object' || Array.isArray(obj.c)) return null;
|
||||
return { name: String(obj.n || '').slice(0, 60), colors: _hwcNormalize(obj.c) };
|
||||
}
|
||||
// Import a share code: store it as a (uniquely named) saved theme and apply.
|
||||
function importHighwayColorShare(code) {
|
||||
const parsed = decodeHighwayColorShare(code);
|
||||
if (!parsed) return null;
|
||||
let name = parsed.name || 'Imported';
|
||||
const existing = _hwcReadThemes();
|
||||
if (Object.prototype.hasOwnProperty.call(existing, name)) {
|
||||
let i = 2;
|
||||
while (Object.prototype.hasOwnProperty.call(existing, name + ' ' + i)) i++;
|
||||
name = name + ' ' + i;
|
||||
}
|
||||
saveHighwayColorTheme(name, parsed.colors);
|
||||
applyHighwayStringColors(parsed.colors);
|
||||
return { name, colors: parsed.colors };
|
||||
}
|
||||
|
||||
// Startup: apply persisted colors to the 2D highway immediately and re-apply on
|
||||
// every song load (string count / bass-vs-guitar can change the slot→index
|
||||
// mapping) and whenever a viz renderer (re)initializes (the 3D loads async +
|
||||
// rebuilds per song, so a one-shot apply could land before it exists).
|
||||
let _hwcWired = false;
|
||||
export function initHighwayColors() {
|
||||
reapplyHighwayStringColors();
|
||||
if (!_hwcWired && window.feedBack && typeof window.feedBack.on === 'function') {
|
||||
_hwcWired = true;
|
||||
window.feedBack.on('viz:renderer:ready', reapplyHighwayStringColors);
|
||||
window.feedBack.on('song:loaded', reapplyHighwayStringColors);
|
||||
window.feedBack.on('song:ready', reapplyHighwayStringColors);
|
||||
}
|
||||
_hwcInstallFacade();
|
||||
}
|
||||
|
||||
// ── Public plugin API: window.feedBack.highwayColors ─────────────────────
|
||||
// A stable, documented facade over the (otherwise private) string-color
|
||||
// manager so plugins can read / react to / set the user's per-string colors
|
||||
// without reaching into internals. This is a synchronous data-plane API, not a
|
||||
// capability domain — consistent with the constitution keeping highway/viz
|
||||
// surfaces off the capability graph until a dedicated render-facade slice
|
||||
// lands. Colors are keyed by NAMED string slot (see `slots`); use
|
||||
// `keysForChart`/`toEffective` to map names → per-string-index for a given
|
||||
// arrangement. See docs/plugin-capability-inventory.md.
|
||||
const _hwcChangeWrappers = new WeakMap();
|
||||
function _hwcInstallFacade() {
|
||||
if (!window.feedBack || window.feedBack.highwayColors) return;
|
||||
const api = {
|
||||
version: 1,
|
||||
// Ordered named slots: [{ key, label, sub }]. `key` is the stable id.
|
||||
slots: HWC_SLOTS.map((s) => ({ key: s.key, label: s.label, sub: s.sub })),
|
||||
// User-set overrides only (named slot → hex); empty object = defaults.
|
||||
get() { return getHighwayStringColors(); },
|
||||
// Canonical default color per named slot.
|
||||
getDefaults() { return getHighwayDefaultSlotColors(); },
|
||||
// Defaults overlaid with overrides — the colors in effect, by name.
|
||||
getResolved() { return _hwcMergedSlotColors(); },
|
||||
// Which named slot each chart string index maps to, for an arrangement
|
||||
// (index 0 = lowest string). e.g. (7,false) → ['low7','lowE','A',...].
|
||||
keysForChart(stringCount, isBass) { return _hwcSlotKeysForChart(stringCount, !!isBass); },
|
||||
// Per-string-INDEX hex array (resolved colors) for an arrangement.
|
||||
// Omit args to use the currently-loaded chart's shape.
|
||||
toEffective(stringCount, isBass) {
|
||||
const shape = (typeof stringCount === 'number')
|
||||
? { sc: stringCount, isBass: !!isBass }
|
||||
: _hwcChartShape();
|
||||
return _hwcEffectiveIndexColors(_hwcMergedSlotColors(), shape.sc, shape.isBass);
|
||||
},
|
||||
// The per-index colors actually applied to the live 2D highway now.
|
||||
getCurrent() {
|
||||
try { return (window.highway && window.highway.getStringColors) ? window.highway.getStringColors() : []; }
|
||||
catch (_) { return []; }
|
||||
},
|
||||
// Set colors programmatically (persists + applies to both highways).
|
||||
// Pass a named slot map, or null/{} to revert to defaults.
|
||||
apply(slotMap) { return applyHighwayStringColors(slotMap); },
|
||||
// One-click presets: [{ id, label, colors }] (full named-slot maps).
|
||||
presets: HWC_PRESETS.map((p) => ({ id: p.id, label: p.label, colors: { ...p.colors } })),
|
||||
// Apply a preset by id (persists + applies to both highways).
|
||||
applyPreset(id) { return applyHighwayStringPreset(id); },
|
||||
// Share-code interop (the "SLOPHWY2." copy/paste format).
|
||||
encodeShare(name, slotMap) { return encodeHighwayColorShare(name, slotMap); },
|
||||
decodeShare(code) { return decodeHighwayColorShare(code); },
|
||||
// Subscribe to color changes; handler receives the resolved slot map.
|
||||
// Returns an unsubscribe fn that removes exactly THIS subscription;
|
||||
// offChange(fn) removes every subscription registered with that fn.
|
||||
// (Each fn maps to a Set of wrappers so repeated mount/init paths that
|
||||
// subscribe the same handler don't clobber each other or leak.)
|
||||
onChange(fn) {
|
||||
if (typeof fn !== 'function' || !window.feedBack) return () => {};
|
||||
const wrapper = () => {
|
||||
try { fn(api.getResolved()); } catch (e) { console.error('[highwayColors] onChange handler threw', e); }
|
||||
};
|
||||
let set = _hwcChangeWrappers.get(fn);
|
||||
if (!set) { set = new Set(); _hwcChangeWrappers.set(fn, set); }
|
||||
set.add(wrapper);
|
||||
window.feedBack.on('highway:stringColors', wrapper);
|
||||
return () => {
|
||||
if (window.feedBack) window.feedBack.off('highway:stringColors', wrapper);
|
||||
const s = _hwcChangeWrappers.get(fn);
|
||||
if (s) { s.delete(wrapper); if (!s.size) _hwcChangeWrappers.delete(fn); }
|
||||
};
|
||||
},
|
||||
offChange(fn) {
|
||||
const set = _hwcChangeWrappers.get(fn);
|
||||
if (set && window.feedBack) {
|
||||
for (const wrapper of set) window.feedBack.off('highway:stringColors', wrapper);
|
||||
_hwcChangeWrappers.delete(fn);
|
||||
}
|
||||
},
|
||||
};
|
||||
window.feedBack.highwayColors = api;
|
||||
}
|
||||
|
||||
// ── Highway String Colors — Settings UI wiring ───────────────────────────
|
||||
// Pickers are per NAMED string (see HWC_SLOTS). Assigning "Low E" a color
|
||||
// keeps Low E that color regardless of string count — the translation table
|
||||
// (_hwcSlotKeysForChart) handles the index remapping per arrangement.
|
||||
|
||||
function _hwcStatus(msg) {
|
||||
const el = document.getElementById('hwc-status');
|
||||
if (!el) return;
|
||||
el.textContent = msg || '';
|
||||
if (msg) {
|
||||
clearTimeout(_hwcStatus._t);
|
||||
_hwcStatus._t = setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
// Render one color input per named slot, seeded from active colors (falling
|
||||
// back to the highway defaults for that slot).
|
||||
function hwcRenderPickers() {
|
||||
const host = document.getElementById('hwc-pickers');
|
||||
if (!host) return;
|
||||
const defaults = getHighwayDefaultSlotColors();
|
||||
const active = getHighwayStringColors();
|
||||
host.innerHTML = '';
|
||||
for (const slot of HWC_SLOTS) {
|
||||
const val = active[slot.key] || defaults[slot.key] || '#888888';
|
||||
const wrap = document.createElement('label');
|
||||
wrap.className = 'flex items-center gap-2 text-xs text-gray-400';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'color';
|
||||
input.id = 'hwc-color-' + slot.key;
|
||||
input.dataset.slot = slot.key;
|
||||
input.value = val;
|
||||
input.style.width = '2.5rem';
|
||||
input.style.height = '1.75rem';
|
||||
input.style.padding = '2px';
|
||||
input.style.cursor = 'pointer';
|
||||
input.className = 'rounded border border-gray-800 bg-dark-700';
|
||||
input.addEventListener('input', () => hwcOnColorInput());
|
||||
wrap.appendChild(input);
|
||||
const span = document.createElement('span');
|
||||
span.textContent = slot.label;
|
||||
wrap.appendChild(span);
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'text-gray-600';
|
||||
sub.textContent = slot.sub;
|
||||
wrap.appendChild(sub);
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
function hwcReadPickers() {
|
||||
const out = {};
|
||||
for (const slot of HWC_SLOTS) {
|
||||
const el = document.getElementById('hwc-color-' + slot.key);
|
||||
if (el) out[slot.key] = el.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Live apply on any picker change. Leaves the saved-theme select alone so a
|
||||
// tweaked-but-unsaved state is allowed; "Save as…" captures it.
|
||||
function hwcOnColorInput() {
|
||||
applyHighwayStringColors(hwcReadPickers());
|
||||
}
|
||||
|
||||
function hwcPopulateThemeSelect() {
|
||||
const sel = document.getElementById('hwc-theme-select');
|
||||
if (!sel) return;
|
||||
const names = listHighwayColorThemes().sort((a, b) => a.localeCompare(b));
|
||||
const current = getActiveHighwayColorThemeName();
|
||||
sel.innerHTML = '';
|
||||
const def = document.createElement('option');
|
||||
def.value = '';
|
||||
def.textContent = 'Default colors';
|
||||
sel.appendChild(def);
|
||||
for (const n of names) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = n;
|
||||
opt.textContent = n;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = (current && names.includes(current)) ? current : '';
|
||||
}
|
||||
|
||||
function hwcOnSelectTheme(name) {
|
||||
selectHighwayColorTheme(name);
|
||||
hwcRenderPickers();
|
||||
}
|
||||
|
||||
async function hwcSaveTheme() {
|
||||
const name = await uiPrompt({ title: 'Save Highway Colors', label: 'Theme name', value: getActiveHighwayColorThemeName() || 'My Colors', okLabel: 'Save' });
|
||||
if (!name) return;
|
||||
saveHighwayColorTheme(name, hwcReadPickers());
|
||||
hwcPopulateThemeSelect();
|
||||
_hwcStatus('Saved “' + name + '”');
|
||||
}
|
||||
|
||||
function hwcDeleteTheme() {
|
||||
const name = getActiveHighwayColorThemeName();
|
||||
if (!name) { _hwcStatus('No saved theme selected'); return; }
|
||||
deleteHighwayColorTheme(name);
|
||||
applyHighwayStringColors(null);
|
||||
hwcPopulateThemeSelect();
|
||||
hwcRenderPickers();
|
||||
_hwcStatus('Deleted “' + name + '”');
|
||||
}
|
||||
|
||||
function hwcReset() {
|
||||
try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {}
|
||||
applyHighwayStringColors(null);
|
||||
hwcPopulateThemeSelect();
|
||||
hwcRenderPickers();
|
||||
_hwcStatus('Reset to defaults');
|
||||
}
|
||||
|
||||
async function hwcCopyShare() {
|
||||
const name = getActiveHighwayColorThemeName() || 'Highway Colors';
|
||||
const code = encodeHighwayColorShare(name, hwcReadPickers());
|
||||
let copied = false;
|
||||
try { await navigator.clipboard.writeText(code); copied = true; } catch (_) {}
|
||||
if (!copied) {
|
||||
// Fallback: drop the code into the import field so it can be copied manually.
|
||||
const inp = document.getElementById('hwc-import-code');
|
||||
if (inp) { inp.value = code; inp.select(); }
|
||||
}
|
||||
_hwcStatus(copied ? 'Share code copied' : 'Copy failed — code shown below');
|
||||
}
|
||||
|
||||
function hwcImport() {
|
||||
const inp = document.getElementById('hwc-import-code');
|
||||
const code = inp ? inp.value : '';
|
||||
const res = importHighwayColorShare(code);
|
||||
if (!res) { _hwcStatus('Invalid share code'); return; }
|
||||
if (inp) inp.value = '';
|
||||
hwcPopulateThemeSelect();
|
||||
hwcRenderPickers();
|
||||
_hwcStatus('Imported “' + res.name + '”');
|
||||
}
|
||||
|
||||
export function hwcInitSettingsUI() {
|
||||
hwcPopulateThemeSelect();
|
||||
hwcRenderPickers();
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
// The plugin loader — the R0 host rails.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). This is the highest-risk module in
|
||||
// core: it fetches /api/plugins, injects each plugin's screen.js (as
|
||||
// <script type="module"> when its manifest says scriptType:"module"), mounts nav
|
||||
// entries and screens, and wires plugin capability + UI contributions. If it
|
||||
// breaks, every plugin breaks — so every change here ends with a real plugin
|
||||
// booted against a local uvicorn, not just a green test run.
|
||||
//
|
||||
// The one thing it still needs from app.js is `window.showScreen` — already the
|
||||
// public host contract (constitution II), so it is called through `window` rather
|
||||
// than re-coupled as an import.
|
||||
//
|
||||
// `_populateVizPicker` used to arrive through a configurePluginLoader() host seam:
|
||||
// it lived in app.js, and importing app.js from here would have closed a cycle.
|
||||
// The viz layer is now its own leaf module, so the seam is GONE — this imports it
|
||||
// directly, and the graph stays acyclic without any injection.
|
||||
import { _populateVizPicker } from './viz.js';
|
||||
|
||||
let _loadPluginsInFlight = false;
|
||||
const _pluginUiContributions = new Map();
|
||||
const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu';
|
||||
|
||||
function _capabilityInspectorNavEnabled() {
|
||||
try { return localStorage.getItem(CAPABILITY_INSPECTOR_NAV_SETTING) === '1'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
// Derive a display label from a (possibly string) nav value. `/api/plugins`
|
||||
// can return `nav` as a plain string (manifest `"nav": "Declared"`) or an
|
||||
// object with a `.label`, and _pluginNav() may synthesize an object (e.g. the
|
||||
// Capability Inspector). Handle all three so string labels and the synthesized
|
||||
// label aren't dropped in favour of the plugin name.
|
||||
function _navLabel(nav, plugin) {
|
||||
if (typeof nav === 'string' && nav.trim()) return nav;
|
||||
if (nav && typeof nav === 'object' && nav.label) return nav.label;
|
||||
return (plugin && (plugin.name || plugin.id)) || '';
|
||||
}
|
||||
|
||||
function _pluginNav(plugin) {
|
||||
if (!plugin || !plugin.id) return null;
|
||||
if (plugin.id === 'capability_inspector') {
|
||||
if (!_capabilityInspectorNavEnabled()) return null;
|
||||
return plugin.nav || { label: 'Capabilities', screen: 'plugin-capability_inspector' };
|
||||
}
|
||||
return plugin.nav || null;
|
||||
}
|
||||
|
||||
async function _commandUiDomain(domain, command, plugin, payload) {
|
||||
try {
|
||||
if (!window.feedBack?.capabilities?.command) return;
|
||||
await window.feedBack.capabilities.command(domain, command, {
|
||||
requester: plugin.id || 'plugin',
|
||||
target: { id: payload.id, pluginId: plugin.id, region: payload.region },
|
||||
payload: { ...payload, pluginId: plugin.id },
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`ui contribution ${command} failed for ${plugin.id}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
async function _registerLegacyPluginUiContributions(plugin) {
|
||||
const previous = _pluginUiContributions.get(plugin.id) || [];
|
||||
for (const contribution of previous) {
|
||||
await _commandUiDomain(contribution.domain, 'unmount', plugin, contribution);
|
||||
}
|
||||
const contributions = [];
|
||||
const nav = _pluginNav(plugin);
|
||||
if (nav) {
|
||||
contributions.push({ domain: 'ui.navigation', id: `${plugin.id}:nav`, region: 'plugins', label: _navLabel(nav, plugin), mounted: true });
|
||||
}
|
||||
if (plugin.has_screen) {
|
||||
contributions.push({ domain: 'ui.plugin-screens', id: `${plugin.id}:screen`, region: 'plugin-screens', label: plugin.name || plugin.id, mounted: true });
|
||||
}
|
||||
if (plugin.has_settings) {
|
||||
contributions.push({ domain: 'settings', id: `${plugin.id}:settings`, region: 'plugin-settings', label: plugin.name || plugin.id, mounted: true });
|
||||
}
|
||||
if (plugin.type === 'visualization') {
|
||||
contributions.push({ domain: 'ui.player-overlays', id: `${plugin.id}:visualization`, region: 'visualization-picker', label: plugin.name || plugin.id, mounted: true });
|
||||
}
|
||||
contributions.sort((a, b) => `${a.domain}:${a.id}`.localeCompare(`${b.domain}:${b.id}`));
|
||||
_pluginUiContributions.set(plugin.id, contributions);
|
||||
for (const contribution of contributions) {
|
||||
await _commandUiDomain(contribution.domain, 'register-contribution', plugin, contribution);
|
||||
await _commandUiDomain(contribution.domain, 'mount', plugin, contribution);
|
||||
}
|
||||
}
|
||||
|
||||
// Settings-tab containers that can host plugin <details> panels on the v3
|
||||
// tabbed settings page. '#plugin-settings' is the fallback bucket (and the
|
||||
// only container in the classic v2 settings page); the per-tab containers map
|
||||
// to a plugin manifest's settings.category. A plugin with no category, or one
|
||||
// whose tab container is absent (v2, or render not yet run), falls back to
|
||||
// '#plugin-settings'. Body divs injected per plugin use id
|
||||
// `plugin-settings-<pluginId>` and live INSIDE a <details>, so they are never
|
||||
// direct children of these containers — no id collision in the scans below.
|
||||
const _PLUGIN_SETTINGS_CONTAINER_IDS = [
|
||||
'plugin-settings', 'plugin-settings-graphics',
|
||||
'plugin-settings-mic', 'plugin-settings-progression',
|
||||
];
|
||||
function _pluginSettingsContainers() {
|
||||
const out = [];
|
||||
for (const id of _PLUGIN_SETTINGS_CONTAINER_IDS) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) out.push(el);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function _pluginSettingsTarget(plugin) {
|
||||
const cat = plugin && plugin.settings_category;
|
||||
if (cat) {
|
||||
const el = document.getElementById('plugin-settings-' + cat);
|
||||
if (el) return el;
|
||||
}
|
||||
return document.getElementById('plugin-settings');
|
||||
}
|
||||
|
||||
export async function loadPlugins() {
|
||||
if (_loadPluginsInFlight) { console.log('[feedBack] loadPlugins: in-flight, skipping'); return null; }
|
||||
_loadPluginsInFlight = true;
|
||||
console.log('[feedBack] loadPlugins: start');
|
||||
let plugins;
|
||||
const navContainer = document.getElementById('nav-plugins');
|
||||
const mobileNavContainer = document.getElementById('mobile-nav-plugins');
|
||||
// Snapshot current nav so we can restore it if the fetch fails.
|
||||
const _savedNav = navContainer ? navContainer.innerHTML : null;
|
||||
const _savedMobileNav = mobileNavContainer ? mobileNavContainer.innerHTML : null;
|
||||
try {
|
||||
const resp = await fetch('/api/plugins');
|
||||
const fetchedPlugins = await resp.json();
|
||||
const capabilityPlugins = fetchedPlugins.slice().sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')));
|
||||
plugins = fetchedPlugins.slice().sort((a, b) => {
|
||||
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
|
||||
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
|
||||
});
|
||||
// NOTE deliberately NO stale-contribution sweep for plugins absent
|
||||
// from this response. Absent ≠ uninstalled: the backend clears its
|
||||
// plugin registry at the start of load_plugins() and repopulates it
|
||||
// incrementally while HTTP stays up, so every backend restart serves a
|
||||
// window of partial (even empty) responses. The old sweep unmounted UI
|
||||
// contributions and unregistered capability participants on mere
|
||||
// absence, permanently breaking still-loaded plugins — their scripts
|
||||
// don't re-run (loadedScripts guard below), so nothing ever
|
||||
// re-registered. A genuine mid-session uninstall now leaves the
|
||||
// (already-evaluated, un-unloadable) script's contributions in place
|
||||
// until reload; its nav entry still disappears because nav is rebuilt
|
||||
// from the response each round. Same invariant as the settings/screen
|
||||
// DOM wipe and _reconcilePluginStyles below.
|
||||
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
|
||||
|
||||
try {
|
||||
const capabilityApi = window.feedBack?.capabilities;
|
||||
if (capabilityApi?.registerParticipants) {
|
||||
capabilityApi.registerParticipants(capabilityPlugins);
|
||||
if (capabilityApi.registerCompatibilityShim) {
|
||||
for (const plugin of capabilityPlugins) {
|
||||
for (const shim of Array.isArray(plugin.compatibility_shims) ? plugin.compatibility_shims : []) {
|
||||
capabilityApi.registerCompatibilityShim(shim);
|
||||
}
|
||||
}
|
||||
}
|
||||
capabilityApi.validateRuntime?.({ phase: 'plugin-manifest-load' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[feedBack] capability manifest registration failed:', e);
|
||||
}
|
||||
|
||||
// Plugin settings panels mount into one of several tab containers —
|
||||
// see _pluginSettingsContainers()/_pluginSettingsTarget() above.
|
||||
|
||||
// Plugins whose screen.js has already been evaluated this session
|
||||
// at the current version AND whose DOM is still in the document.
|
||||
// Their listeners were bound to the existing settings / screen DOM,
|
||||
// so we must preserve that DOM — the script load guard below skips
|
||||
// re-evaluating screen.js, and a fresh empty DOM with no listeners
|
||||
// would leave the plugin half-hydrated on subsequent loadPlugins()
|
||||
// calls (e.g. the streamed refetches in _streamPluginStartup).
|
||||
//
|
||||
// The DOM-existence check is the safety net for plugins that
|
||||
// disappeared and reappeared between calls (uninstall + reinstall,
|
||||
// or a backend snapshot churn that drops a plugin then restores
|
||||
// it). In that case the loadedScripts key would still be set, but
|
||||
// any listeners are bound to elements that have since been removed
|
||||
// — drop the stale key so screen.js re-runs against the fresh DOM
|
||||
// we're about to inject.
|
||||
// Map<pluginId, version> — one entry per plugin. Storing only the
|
||||
// currently-loaded version (rather than a Set of all (id, version)
|
||||
// pairs ever loaded) means upgrade → downgrade → upgrade cycles
|
||||
// within one session don't leave stale keys that could mistakenly
|
||||
// mark an old version as already-hydrated. Coerce a legacy Set, if
|
||||
// present, to an empty Map — the previous shape never shipped.
|
||||
let loadedScripts = window.feedBack._loadedPluginScripts;
|
||||
if (!(loadedScripts instanceof Map)) {
|
||||
loadedScripts = new Map();
|
||||
window.feedBack._loadedPluginScripts = loadedScripts;
|
||||
}
|
||||
const _removePluginScriptTags = (pluginId) => {
|
||||
// Filter via dataset rather than a CSS attribute selector —
|
||||
// CSS.escape is not universally available, and plugin IDs
|
||||
// aren't constrained server-side.
|
||||
document.querySelectorAll('script[data-plugin-id]').forEach((s) => {
|
||||
if (s.dataset.pluginId === pluginId) s.remove();
|
||||
});
|
||||
};
|
||||
// Mirror of loadedScripts for the plugin `styles` capability: a single
|
||||
// versioned <link rel=stylesheet> per plugin lives in <head>, deduped by
|
||||
// id → version so an upgrade swaps it and re-activation doesn't pile up
|
||||
// duplicate tags. The <link> covers both the plugin's screen and its
|
||||
// settings panel. Plugins ship preflight-off (utilities only) CSS, so a
|
||||
// stylesheet that lingers after deactivation can't bleed a base reset.
|
||||
let loadedStyles = window.feedBack._loadedPluginStyles;
|
||||
if (!(loadedStyles instanceof Map)) {
|
||||
loadedStyles = new Map();
|
||||
window.feedBack._loadedPluginStyles = loadedStyles;
|
||||
}
|
||||
const _removePluginStyleTags = (pluginId) => {
|
||||
// Same dataset-filter rationale as _removePluginScriptTags.
|
||||
document.querySelectorAll('link[data-plugin-id]').forEach((l) => {
|
||||
if (l.dataset.pluginId === pluginId) l.remove();
|
||||
});
|
||||
};
|
||||
const _injectPluginStyles = (plugin) => {
|
||||
// Tear down a <link> we injected earlier this session when the plugin
|
||||
// no longer ships a usable stylesheet — upgraded to drop `styles`, or
|
||||
// to an invalid path — so stale CSS can't keep applying after the
|
||||
// plugin disabled its styling.
|
||||
const teardownStale = () => {
|
||||
if (loadedStyles.has(plugin.id)) {
|
||||
_removePluginStyleTags(plugin.id);
|
||||
loadedStyles.delete(plugin.id);
|
||||
}
|
||||
};
|
||||
if (!plugin.has_styles || !plugin.styles) { teardownStale(); return; }
|
||||
// `styles` is a plugin-root-relative path (like screen/script/routes)
|
||||
// and must live under assets/ so it serves through the sandboxed
|
||||
// asset route — e.g. "assets/plugin.css". Reject anything that can't
|
||||
// reach a served file or would build a malformed URL: not under
|
||||
// assets/, a `..` traversal segment, a backslash, or a `?`/`#` that
|
||||
// would collide with the cache-busting query we append. The server
|
||||
// also enforces containment via safe_join — this just avoids the
|
||||
// wasted 404 and matches the documented contract.
|
||||
const path = String(plugin.styles).replace(/^\/+/, '');
|
||||
const unsafe = !path.startsWith('assets/')
|
||||
|| /(^|\/)\.\.(\/|$)/.test(path)
|
||||
|| /[\\?#]/.test(path);
|
||||
if (unsafe) {
|
||||
console.warn(`Plugin ${plugin.id}: styles must be a path under assets/ with no "..", backslash, or query/fragment (got "${plugin.styles}") — skipping`);
|
||||
teardownStale();
|
||||
return;
|
||||
}
|
||||
const wantedVersion = plugin.version || '';
|
||||
// Idempotent: same id+version already injected → nothing to do.
|
||||
if (loadedStyles.get(plugin.id) === wantedVersion) return;
|
||||
// A different version (or none) was loaded — drop the prior <link>
|
||||
// so we never accumulate stale stylesheets across upgrades.
|
||||
_removePluginStyleTags(plugin.id);
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.dataset.pluginId = plugin.id;
|
||||
link.dataset.pluginVersion = wantedVersion;
|
||||
// Version in the URL (the plugin `version`, mirroring the screen.js
|
||||
// loader's ?v= convention) so a plugin upgrade within one session
|
||||
// fetches fresh CSS instead of a copy cached by path alone.
|
||||
const v = encodeURIComponent(wantedVersion);
|
||||
link.href = `/api/plugins/${plugin.id}/${path}${v ? `?v=${v}` : ''}`;
|
||||
// Cascade ordering: insert this <link> BEFORE core's prebuilt
|
||||
// Tailwind (/static/tailwind.min.css) instead of appending at the
|
||||
// end of <head>. A plugin that ships a full utility build — the
|
||||
// default output of running the Tailwind CLI without a scoped
|
||||
// content config — re-defines core utilities like .grid /
|
||||
// .xl:grid-cols-4; appended last, those equal-specificity rules
|
||||
// would win on source order and clobber core's responsive layout
|
||||
// (e.g. the library grid collapses to 2 columns, the nav bar
|
||||
// breaks). Loading the plugin sheet first means core wins any
|
||||
// EQUAL-specificity collision, while the plugin's own namespaced
|
||||
// classes still apply. A plugin can still deliberately override core
|
||||
// via higher-specificity selectors or !important — this only removes
|
||||
// the accidental source-order clobber.
|
||||
const coreSheet =
|
||||
document.head.querySelector('link[rel="stylesheet"][href*="tailwind.min.css"]')
|
||||
|| document.head.querySelector('link[rel="stylesheet"]');
|
||||
if (coreSheet) {
|
||||
document.head.insertBefore(link, coreSheet);
|
||||
} else {
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
loadedStyles.set(plugin.id, wantedVersion);
|
||||
};
|
||||
const _reconcilePluginStyles = (currentPlugins) => {
|
||||
// Drop stylesheets for plugins the response KNOWS about but that
|
||||
// are no longer ready+styled this round. _injectPluginStyles below
|
||||
// only visits plugins still returned by the API, so a newly-not-
|
||||
// ready or unstyled plugin would otherwise keep its <link>
|
||||
// applying. Plugins merely ABSENT from the response keep their
|
||||
// stylesheet — a transient partial response during a backend
|
||||
// restart is not an uninstall (same invariant as the screen/
|
||||
// settings wipe below), and stripping the <link> would leave a
|
||||
// still-loaded plugin visible but unstyled.
|
||||
const responded = new Set(currentPlugins.map((p) => p.id));
|
||||
const styled = new Set(
|
||||
currentPlugins
|
||||
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
|
||||
.map((p) => p.id),
|
||||
);
|
||||
for (const id of Array.from(loadedStyles.keys())) {
|
||||
if (responded.has(id) && !styled.has(id)) {
|
||||
_removePluginStyleTags(id);
|
||||
loadedStyles.delete(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
const existingSettingsByPluginId = new Map();
|
||||
for (const container of _pluginSettingsContainers()) {
|
||||
for (const child of container.children) {
|
||||
const pid = child.dataset ? child.dataset.pluginId : null;
|
||||
if (pid) existingSettingsByPluginId.set(pid, child);
|
||||
}
|
||||
}
|
||||
// Plugins named in THIS response. A plugin can be transiently absent
|
||||
// from /api/plugins — the backend clears its registry at the start of
|
||||
// load_plugins() and repopulates it incrementally while HTTP stays up,
|
||||
// so every backend restart serves a window of partial (even empty)
|
||||
// responses. The wipe loops below must never treat that absence as an
|
||||
// uninstall: stripping a still-loaded plugin's DOM while keeping its
|
||||
// loadedScripts entry made the NEXT refetch fail the DOM check and
|
||||
// re-evaluate its screen.js mid-session — which duplicated the desktop
|
||||
// audio_engine's native signal chain (its init re-ran against the
|
||||
// surviving engine chain). Absent plugins keep their DOM and script;
|
||||
// they're re-reconciled when they reappear in a later response.
|
||||
const respondedIds = new Set(plugins.map((p) => p.id));
|
||||
const alreadyHydrated = new Set();
|
||||
for (const p of plugins) {
|
||||
if (!p.has_script) continue;
|
||||
// Version must match exactly — an upgrade / downgrade has to
|
||||
// re-run the new script against fresh DOM.
|
||||
if (loadedScripts.get(p.id) !== (p.version || '')) continue;
|
||||
const screenOk = !p.has_screen || !!document.getElementById(`plugin-${p.id}`);
|
||||
const settingsOk = !p.has_settings || existingSettingsByPluginId.has(p.id);
|
||||
if (screenOk && settingsOk) {
|
||||
alreadyHydrated.add(p.id);
|
||||
} else {
|
||||
// DOM was wiped externally (uninstall + reinstall, snapshot
|
||||
// churn) — drop the entry and remove the orphaned <script>
|
||||
// so screen.js re-runs against fresh DOM below.
|
||||
loadedScripts.delete(p.id);
|
||||
_removePluginScriptTags(p.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear plugin-owned containers, but keep already-hydrated plugins'
|
||||
// settings / screen DOM. Nav links carry no per-plugin script state,
|
||||
// so always rebuild them.
|
||||
navContainer.innerHTML = '';
|
||||
mobileNavContainer.innerHTML = '<span class="text-xs text-gray-600 uppercase tracking-wider">Plugins</span>';
|
||||
for (const container of _pluginSettingsContainers()) {
|
||||
[...container.children].forEach((el) => {
|
||||
const pid = el.dataset ? el.dataset.pluginId : null;
|
||||
// Remove junk (no plugin id) and plugins the response KNOWS
|
||||
// about but that failed hydration; leave plugins absent from
|
||||
// the response untouched (see respondedIds above).
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
|
||||
// dataset.pluginId is the source of truth (set on injection);
|
||||
// the id-prefix fallback covers screens injected before this
|
||||
// change shipped — both forms strip a single leading "plugin-".
|
||||
const pid = (el.dataset && el.dataset.pluginId)
|
||||
|| el.id.replace(/^plugin-/, '');
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
});
|
||||
|
||||
// Plugin settings area hosts both "Plugin Updates" and per-plugin
|
||||
// collapsibles. Reveal it whenever any plugins are installed —
|
||||
// updates are relevant even for plugins that contribute no settings.
|
||||
if (plugins.length > 0) {
|
||||
const area = document.getElementById('plugin-settings-area');
|
||||
if (area) area.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Build plugin dropdown for desktop nav
|
||||
const navPlugins = plugins.map(plugin => ({ plugin, nav: _pluginNav(plugin) })).filter(entry => entry.nav);
|
||||
if (navPlugins.length > 0) {
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'relative';
|
||||
dropdown.innerHTML = `
|
||||
<button class="text-sm text-gray-400 hover:text-white transition flex items-center gap-1" onclick="this.nextElementSibling.classList.toggle('hidden')">
|
||||
Plugins
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
</button>
|
||||
<div class="hidden absolute top-full left-0 mt-2 bg-dark-800 border border-gray-700 rounded-xl shadow-xl py-2 min-w-[180px] max-h-[80vh] overflow-y-auto z-50" id="plugin-dropdown"></div>`;
|
||||
navContainer.appendChild(dropdown);
|
||||
const ddMenu = dropdown.querySelector('#plugin-dropdown');
|
||||
|
||||
// Close the plugin dropdown when clicking outside it. Bind ONCE:
|
||||
// loadPlugins() re-runs on every plugin status change during
|
||||
// startup (SSE-driven refetches), and each run rebuilds `dropdown`
|
||||
// / `ddMenu`. A per-run addEventListener would leak a new global
|
||||
// click listener on every refetch, each closing over a now-detached
|
||||
// dropdown. The one-time handler instead resolves the LIVE dropdown
|
||||
// from the DOM at click time, so it always targets the current one.
|
||||
if (!window.feedBack._pluginDropdownOutsideClickBound) {
|
||||
window.feedBack._pluginDropdownOutsideClickBound = true;
|
||||
document.addEventListener('click', (e) => {
|
||||
const menu = document.getElementById('plugin-dropdown');
|
||||
if (!menu) return;
|
||||
const container = menu.parentElement;
|
||||
if (container && !container.contains(e.target)) menu.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
for (const { plugin, nav } of navPlugins) {
|
||||
const screenId = `plugin-${plugin.id}`;
|
||||
// A plugin is navigable only once it's ready. While its deps
|
||||
// install (status "installing") or after a failed load
|
||||
// (status "failed") we still render the nav slot — disabled,
|
||||
// with an "installing…" suffix or the error as a tooltip — so
|
||||
// the nav is stable and the user sees the plugin is coming
|
||||
// (#421). Entries without a status (legacy / stub) are ready.
|
||||
const status = plugin.status || 'ready';
|
||||
const isReady = status === 'ready';
|
||||
// nav is truthy here (navPlugins is filtered on entry.nav), and
|
||||
// is the computed value from _pluginNav() — which may be a
|
||||
// string, an object that omits `label`, or a synthesized object
|
||||
// (e.g. the Capability Inspector). _navLabel() normalizes all
|
||||
// three and falls back to name/id so a missing label never
|
||||
// renders "undefined" or throws. Use the loop's `nav`, not the
|
||||
// raw `plugin.nav`, so string and synthesized labels survive.
|
||||
const label = _navLabel(nav, plugin);
|
||||
|
||||
const item = document.createElement('a');
|
||||
item.href = '#';
|
||||
ddMenu.appendChild(item);
|
||||
// Mobile nav — flat list
|
||||
const ma = document.createElement('a');
|
||||
ma.href = '#';
|
||||
mobileNavContainer.appendChild(ma);
|
||||
|
||||
if (isReady) {
|
||||
item.className = 'block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-dark-700 transition';
|
||||
item.textContent = label;
|
||||
item.onclick = (e) => { e.preventDefault(); ddMenu.classList.add('hidden'); window.showScreen(screenId); window.feedBackDemoTrack?.('event/plugin-open/' + plugin.id); };
|
||||
ma.className = 'text-gray-400 hover:text-white pl-4 text-sm';
|
||||
ma.textContent = label;
|
||||
ma.onclick = (e) => { e.preventDefault(); window.showScreen(screenId); ma.closest('#mobile-menu').classList.add('hidden'); window.feedBackDemoTrack?.('event/plugin-open/' + plugin.id); };
|
||||
} else {
|
||||
const installing = status === 'installing';
|
||||
const suffix = installing ? ' (installing…)' : ' (failed)';
|
||||
const tip = installing
|
||||
? 'This plugin is installing its dependencies and will become available shortly.'
|
||||
: (plugin.error || 'This plugin failed to load. Check the server startup log for details.');
|
||||
// Disabled appearance: dimmed, default cursor, no nav handler.
|
||||
const cls = 'block px-4 py-2 text-sm text-gray-600 cursor-default select-none'
|
||||
+ (installing ? ' animate-pulse' : '');
|
||||
item.className = cls;
|
||||
item.setAttribute('aria-disabled', 'true');
|
||||
item.title = tip;
|
||||
item.textContent = label + suffix;
|
||||
// Drop disabled entries out of the tab order and strip the
|
||||
// href so keyboard/screen-reader users don't land on a
|
||||
// non-actionable "link" (a11y). Swallow clicks too, in case
|
||||
// it's still reached via mouse.
|
||||
item.removeAttribute('href');
|
||||
item.setAttribute('tabindex', '-1');
|
||||
item.onclick = (e) => { e.preventDefault(); };
|
||||
ma.className = 'pl-4 text-sm text-gray-600 cursor-default select-none' + (installing ? ' animate-pulse' : '');
|
||||
ma.setAttribute('aria-disabled', 'true');
|
||||
ma.title = tip;
|
||||
ma.textContent = label + suffix;
|
||||
ma.removeAttribute('href');
|
||||
ma.setAttribute('tabindex', '-1');
|
||||
ma.onclick = (e) => { e.preventDefault(); };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tear down stylesheets for plugins that are gone / no longer styled
|
||||
// before (re)injecting for the current set.
|
||||
_reconcilePluginStyles(plugins);
|
||||
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
// Only ready plugins have their assets available (the backend
|
||||
// guards screen.html/screen.js/settings.html on status=="ready").
|
||||
// Installing/failed plugins contribute only the disabled nav slot
|
||||
// built above — skip screen/settings/script injection for them.
|
||||
if (plugin.status && plugin.status !== 'ready') continue;
|
||||
await _registerLegacyPluginUiContributions(plugin);
|
||||
const screenId = `plugin-${plugin.id}`;
|
||||
|
||||
// Inject the plugin's stylesheet FIRST (before screen HTML/JS) so
|
||||
// its utilities are present on first paint. Idempotent + version-
|
||||
// deduped, so it's safe to call for already-hydrated plugins too.
|
||||
_injectPluginStyles(plugin);
|
||||
|
||||
// Inject screen container. Skip for already-hydrated plugins —
|
||||
// their existing screen DOM still has the listeners that
|
||||
// screen.js bound on first load (rebuilding here would orphan
|
||||
// them, since the script load guard further down won't re-run
|
||||
// screen.js to re-bind).
|
||||
if (plugin.has_screen && !alreadyHydrated.has(plugin.id)) {
|
||||
const screenDiv = document.createElement('div');
|
||||
screenDiv.id = screenId;
|
||||
screenDiv.className = 'screen';
|
||||
screenDiv.dataset.pluginId = plugin.id;
|
||||
screenDiv.dataset.pluginVersion = plugin.version || '';
|
||||
// Insert before the player screen
|
||||
const player = document.getElementById('player');
|
||||
player.parentNode.insertBefore(screenDiv, player);
|
||||
|
||||
const htmlResp = await fetch(`/api/plugins/${plugin.id}/screen.html`);
|
||||
screenDiv.innerHTML = await htmlResp.text();
|
||||
}
|
||||
|
||||
// Inject settings section — wrapped in a collapsible <details>
|
||||
// per plugin so the page stays scannable as plugins accumulate.
|
||||
// Collapsed by default; <details>/<summary> handles state natively.
|
||||
// Skip for already-hydrated plugins — preserved details element
|
||||
// still carries listeners wired by its inline settings script
|
||||
// and by screen.js on first load.
|
||||
// Resolve which settings tab this plugin's panel mounts under
|
||||
// (manifest settings.category), falling back to '#plugin-settings'.
|
||||
const settingsTarget = plugin.has_settings ? _pluginSettingsTarget(plugin) : null;
|
||||
if (plugin.has_settings && settingsTarget && !alreadyHydrated.has(plugin.id)) {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'bg-dark-700/40 border border-gray-800 rounded-xl overflow-hidden group';
|
||||
details.dataset.pluginId = plugin.id;
|
||||
details.dataset.pluginVersion = plugin.version || '';
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
// .plugin-settings-summary class hides the browser's native
|
||||
// disclosure triangle (see style.css) so only our chevron shows.
|
||||
// flex-col allows the fallback explanation note to appear below
|
||||
// the name/badges row when plugin.fallback is set.
|
||||
summary.className = 'plugin-settings-summary cursor-pointer select-none px-4 py-3 text-sm font-medium text-gray-300 hover:bg-dark-700/70 transition flex flex-col';
|
||||
// Inner row: plugin name/badges (left) + chevron (right).
|
||||
const headerRow = document.createElement('span');
|
||||
headerRow.className = 'flex items-center justify-between';
|
||||
const labelWrap = document.createElement('span');
|
||||
labelWrap.className = 'flex items-center gap-2';
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.textContent = plugin.name || plugin.id;
|
||||
labelWrap.appendChild(labelSpan);
|
||||
// "Bundled" marker (feedBack#160). Visually distinguishes
|
||||
// plugins that ship with the default container image from
|
||||
// user-installed ones so users don't try to remove a core
|
||||
// plugin via the manage-plugin flow and brick a feature
|
||||
// that's expected to "just work".
|
||||
if (plugin.bundled) {
|
||||
const bundledDesc = 'This plugin ships with FeedBack core and is expected to be present.';
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border border-purple-400/30 bg-purple-500/10 text-purple-300';
|
||||
badge.title = bundledDesc;
|
||||
badge.setAttribute('aria-label', 'Bundled — ' + bundledDesc);
|
||||
badge.setAttribute('role', 'img');
|
||||
badge.innerHTML = `
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 11c1.657 0 3-1.343 3-3V6a3 3 0 10-6 0v2c0 1.657 1.343 3 3 3zM6 11h12a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2v-6a2 2 0 012-2z"/>
|
||||
</svg>
|
||||
Bundled
|
||||
`;
|
||||
labelWrap.appendChild(badge);
|
||||
}
|
||||
// "Fallback" warning badge: the bundled copy failed to load its
|
||||
// routes, so the server fell back to this older user-installed
|
||||
// copy. Warn users so they know the bundled build is broken and
|
||||
// can check the server startup log for the root cause.
|
||||
if (plugin.fallback) {
|
||||
const fbBadge = document.createElement('span');
|
||||
fbBadge.className = 'inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded border border-yellow-400/40 bg-yellow-500/10 text-yellow-300';
|
||||
fbBadge.setAttribute('aria-hidden', 'true');
|
||||
fbBadge.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg> Fallback';
|
||||
labelWrap.appendChild(fbBadge);
|
||||
}
|
||||
// Assemble inner header row: [name/badges (left)] [chevron (right)].
|
||||
// Both are placed in headerRow so the fallback note (if any)
|
||||
// can sit below the entire row as a second flex-col child of
|
||||
// summary, rather than being squeezed inline beside the chevron.
|
||||
headerRow.appendChild(labelWrap);
|
||||
// Chevron icon — built via setAttributeNS so the SVG sits in
|
||||
// the SVG namespace and renders correctly. Plugin label is
|
||||
// appended as text above so manifest values can't inject HTML.
|
||||
const svgNS = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(svgNS, 'svg');
|
||||
svg.setAttribute('class', 'w-4 h-4 text-gray-500 transition-transform group-open:rotate-180');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
const svgPath = document.createElementNS(svgNS, 'path');
|
||||
svgPath.setAttribute('stroke-linecap', 'round');
|
||||
svgPath.setAttribute('stroke-linejoin', 'round');
|
||||
svgPath.setAttribute('stroke-width', '2');
|
||||
svgPath.setAttribute('d', 'M19 9l-7 7-7-7');
|
||||
svg.appendChild(svgPath);
|
||||
headerRow.appendChild(svg);
|
||||
summary.appendChild(headerRow);
|
||||
// Fallback explanation note: a visible <p> below the header row,
|
||||
// accessible to touch/keyboard users (browser tooltip via title/
|
||||
// aria-label alone is hover-only and insufficient). Appended to
|
||||
// summary (not labelWrap) so it renders as the second child in
|
||||
// summary's flex-col layout, appearing below the name+badges row.
|
||||
if (plugin.fallback) {
|
||||
const fbNote = document.createElement('span');
|
||||
fbNote.className = 'block text-xs text-yellow-300/80 mt-1';
|
||||
fbNote.textContent = 'The bundled version failed to start. This user-installed copy is serving as a fallback. Check the server startup log for details.';
|
||||
summary.appendChild(fbNote);
|
||||
}
|
||||
details.appendChild(summary);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.id = `plugin-settings-${plugin.id}`;
|
||||
body.className = 'px-4 py-4 border-t border-gray-800 space-y-4';
|
||||
details.appendChild(body);
|
||||
|
||||
settingsTarget.appendChild(details);
|
||||
|
||||
const settingsResp = await fetch(`/api/plugins/${plugin.id}/settings.html`);
|
||||
body.innerHTML = await settingsResp.text();
|
||||
// <script> tags inserted via innerHTML are intentionally
|
||||
// inert per the HTML5 spec — the browser parses them as
|
||||
// DOM nodes but never runs the body. That silently breaks
|
||||
// any plugin settings.html that wires event handlers via
|
||||
// addEventListener (e.g. file pickers, anything that
|
||||
// can't be expressed as an inline onclick=… attribute),
|
||||
// and any inline IIFE that hydrates form values from
|
||||
// localStorage. Re-create each script node — script
|
||||
// elements created via document.createElement DO execute
|
||||
// when appended — so plugins get the script behavior
|
||||
// they'd expect from a normal HTML document.
|
||||
body.querySelectorAll('script').forEach(oldScript => {
|
||||
const newScript = document.createElement('script');
|
||||
for (const attr of oldScript.attributes) {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
newScript.textContent = oldScript.textContent;
|
||||
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Load plugin JS
|
||||
if (plugin.has_script) {
|
||||
const wantedVersion = plugin.version || '';
|
||||
if (loadedScripts.get(plugin.id) !== wantedVersion) {
|
||||
// A different version (or none) was loaded previously —
|
||||
// remove the prior <script> tag for this plugin id so we
|
||||
// don't accumulate stale versions on upgrade/downgrade.
|
||||
_removePluginScriptTags(plugin.id);
|
||||
await new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
// Include version in URL so a plugin upgrade within the
|
||||
// same browser session fetches the new screen.js instead
|
||||
// of a cached copy keyed only by path (matches the art
|
||||
// URL ?v=mtime convention elsewhere in this file).
|
||||
const v = encodeURIComponent(wantedVersion);
|
||||
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
|
||||
// Module-migration (R0): a migrated plugin declares
|
||||
// scriptType:"module" and its screen.js is `import
|
||||
// './src/main.js'`. A <script type="module"> fires load
|
||||
// only after its whole static-import graph evaluates, so
|
||||
// the await-onload completion + _loadingPluginId contract
|
||||
// below is preserved (a classic-IIFE dynamic import()
|
||||
// would not). Classic plugins are unaffected.
|
||||
if (plugin.script_type === 'module') script.type = 'module';
|
||||
script.dataset.pluginId = plugin.id;
|
||||
script.dataset.pluginVersion = wantedVersion;
|
||||
window.feedBack._loadingPluginId = plugin.id;
|
||||
script.onload = () => {
|
||||
if (window.feedBack._loadingPluginId === plugin.id) delete window.feedBack._loadingPluginId;
|
||||
loadedScripts.set(plugin.id, wantedVersion);
|
||||
resolve();
|
||||
};
|
||||
script.onerror = (err) => {
|
||||
if (window.feedBack._loadingPluginId === plugin.id) delete window.feedBack._loadingPluginId;
|
||||
loadedScripts.delete(plugin.id);
|
||||
reject(err);
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Plugin '${plugin.id}' failed to load, skipping:`, e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load plugins:', e);
|
||||
// Restore nav so a failed re-hydration call doesn't leave it blank.
|
||||
if (_savedNav !== null && navContainer) navContainer.innerHTML = _savedNav;
|
||||
if (_savedMobileNav !== null && mobileNavContainer) mobileNavContainer.innerHTML = _savedMobileNav;
|
||||
_loadPluginsInFlight = false;
|
||||
return null;
|
||||
}
|
||||
_loadPluginsInFlight = false;
|
||||
return plugins;
|
||||
}
|
||||
|
||||
// Re-run loadPlugins (and the viz picker, since a newly-ready plugin may
|
||||
// register a window.feedBackViz_<id> factory) when plugin status changes.
|
||||
// Debounced so a burst of plugin-registered/plugin-error events during
|
||||
// startup collapses into a single refetch.
|
||||
let _pluginRefreshTimer = null;
|
||||
function _refreshPluginsSoon() {
|
||||
clearTimeout(_pluginRefreshTimer);
|
||||
_pluginRefreshTimer = setTimeout(async () => {
|
||||
const plugins = await loadPlugins();
|
||||
if (plugins) {
|
||||
_populateVizPicker(plugins);
|
||||
} else {
|
||||
// loadPlugins() returned null because a refetch was already in
|
||||
// flight, so this status change would otherwise be dropped. Re-arm
|
||||
// the debounce so the newer state is still applied once the
|
||||
// in-flight load finishes. Reuses the 250ms delay (and the
|
||||
// in-flight guard clears quickly), so this can't tight-loop.
|
||||
_refreshPluginsSoon();
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
let _pluginStreamStarted = false;
|
||||
function _streamPluginStartup() {
|
||||
// Watch the SAME /api/startup-status/stream the splash used to gate on.
|
||||
// Instead of blocking, we let the nav render immediately (loadPlugins ran
|
||||
// already) and refetch whenever a plugin graduates to ready or fails — so
|
||||
// its nav slot flips from "installing…" to active/failed without a reload
|
||||
// (#421). loadPlugins is idempotent (in-flight guard + version map), so
|
||||
// extra refetches are cheap and safe.
|
||||
if (_pluginStreamStarted) return;
|
||||
_pluginStreamStarted = true;
|
||||
|
||||
if (typeof EventSource === 'undefined') { _pollPluginStartup(); return; }
|
||||
|
||||
const es = new EventSource('/api/startup-status/stream');
|
||||
es.onmessage = (event) => {
|
||||
let status;
|
||||
try { status = JSON.parse(event.data); } catch { return; }
|
||||
if (!status || status.type === 'keepalive') return;
|
||||
const phase = (status.phase || '').trim();
|
||||
if (phase === 'plugin-registered' || phase === 'plugin-error') {
|
||||
_refreshPluginsSoon();
|
||||
}
|
||||
// Terminal: one last refetch to catch anything missed, then stop.
|
||||
if (!status.running && (phase === 'complete' || phase === 'error')) {
|
||||
_refreshPluginsSoon();
|
||||
es.close();
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
// Stream dropped (proxy buffering, backend hiccup). Stop retrying the
|
||||
// stream and fall back to a bounded poll so late installs still surface.
|
||||
es.close();
|
||||
_pollPluginStartup();
|
||||
};
|
||||
}
|
||||
|
||||
let _pollStartupStarted = false;
|
||||
async function _pollPluginStartup() {
|
||||
// SSE-unavailable fallback: poll /api/startup-status until the backend
|
||||
// finishes its plugin loader, refetching whenever the ready count changes
|
||||
// or it goes terminal. Bounded so a backend that never finishes doesn't
|
||||
// poll forever.
|
||||
if (_pollStartupStarted) return;
|
||||
_pollStartupStarted = true;
|
||||
// Generous headroom over the documented worst case (whisperx → torch et al.
|
||||
// can take 20-30 min): a 30-min ceiling would stop polling right as a
|
||||
// slipping install — slow mirror, pip retry — actually finishes. 60 min
|
||||
// leaves margin so the late graduation still surfaces. (#421)
|
||||
const DEADLINE_MS = 60 * 60 * 1000;
|
||||
const start = Date.now();
|
||||
// Track a composite signature, not just the ready count: a plugin can fail
|
||||
// (phase → "plugin-error", current_plugin/error change) without changing
|
||||
// `loaded`, e.g. the next plugin breaks after all prior ones succeeded.
|
||||
// Watching only `loaded` would miss that transition until some later
|
||||
// ready-count change or terminal completion, so the failed/error nav state
|
||||
// wouldn't surface. Refetch whenever any of these move.
|
||||
let lastSig = null;
|
||||
while (Date.now() - start < DEADLINE_MS) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
try {
|
||||
const resp = await fetch('/api/startup-status');
|
||||
if (!resp.ok) continue;
|
||||
const status = await resp.json();
|
||||
const sig = JSON.stringify([
|
||||
Number(status.loaded || 0),
|
||||
status.phase || '',
|
||||
status.current_plugin || '',
|
||||
status.error || '',
|
||||
]);
|
||||
if (sig !== lastSig) { lastSig = sig; _refreshPluginsSoon(); }
|
||||
if (!status.running) { _refreshPluginsSoon(); return; }
|
||||
} catch (_e) { /* network error — keep trying */ }
|
||||
}
|
||||
}
|
||||
|
||||
export async function bootstrapPluginsAndUi() {
|
||||
// #421: never gate the nav on full plugin startup. Render it immediately
|
||||
// from /api/plugins (ready plugins active; installing/failed disabled),
|
||||
// then stream plugin status so each entry resolves in place as its
|
||||
// dependencies finish installing or its load fails.
|
||||
const plugins = await loadPlugins();
|
||||
_streamPluginStartup();
|
||||
return plugins;
|
||||
}
|
||||
|
||||
|
||||
// ── Plugin updates ──────────────────────────────────────────────────────
|
||||
// The Settings-screen "Check for updates" / "Update" buttons. Carved out of
|
||||
// app.js (R3a) into the loader rather than a module of their own: this is plugin
|
||||
// MANAGEMENT, it belongs with the code that loads them. Both are inline handlers,
|
||||
// so app.js re-exposes them on window.
|
||||
|
||||
export async function checkPluginUpdates() {
|
||||
const btn = document.getElementById('btn-check-updates');
|
||||
const status = document.getElementById('updates-status');
|
||||
const list = document.getElementById('plugin-updates-list');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Checking...';
|
||||
status.textContent = '';
|
||||
list.innerHTML = '';
|
||||
try {
|
||||
const resp = await fetch('/api/plugins/updates');
|
||||
const data = await resp.json();
|
||||
const updates = data.updates || {};
|
||||
const keys = Object.keys(updates);
|
||||
if (keys.length === 0) {
|
||||
status.textContent = 'All plugins are up to date.';
|
||||
} else {
|
||||
status.textContent = `${keys.length} update${keys.length > 1 ? 's' : ''} available`;
|
||||
for (const id of keys) {
|
||||
const u = updates[id];
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center gap-3 bg-dark-700 rounded-lg px-4 py-2';
|
||||
row.innerHTML = `
|
||||
<span class="text-sm text-gray-300 flex-1">${u.name} <span class="text-xs text-gray-500">(${u.behind} commit${u.behind > 1 ? 's' : ''} behind — ${u.local} → ${u.remote})</span></span>
|
||||
<button onclick="updatePlugin('${id}', this)" class="bg-accent/20 hover:bg-accent/30 text-accent-light px-3 py-1 rounded-lg text-xs transition">Update</button>`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = 'Failed to check for updates.';
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Check for Updates';
|
||||
}
|
||||
|
||||
export async function updatePlugin(pluginId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Updating...';
|
||||
try {
|
||||
const resp = await fetch(`/api/plugins/${pluginId}/update`, { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
btn.textContent = 'Updated — restart to apply';
|
||||
btn.className = 'bg-green-900/30 text-green-400 px-3 py-1 rounded-lg text-xs';
|
||||
} else {
|
||||
btn.textContent = 'Failed';
|
||||
btn.title = data.error || '';
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = 'Error';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Settings backup — the export / import bundle.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
//
|
||||
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
|
||||
// re-exposing them on window. The import is two-phase (server first, atomic; then
|
||||
// a best-effort localStorage merge) — the rationale comment below is the contract
|
||||
// and moved with the code.
|
||||
|
||||
//
|
||||
// Bundles server config + every localStorage key + opted-in plugin server
|
||||
// files into a single JSON file.
|
||||
//
|
||||
// Apply semantics — phased, NOT all-or-nothing across the two stores:
|
||||
// 1. Server first (/api/settings/import). Phase-1 validation guards
|
||||
// the whole bundle; phase-2 disk commit is per-file but ordered
|
||||
// so a mid-apply failure surfaces a `partial` field. A server
|
||||
// failure short-circuits before any localStorage write, so the
|
||||
// browser side stays untouched on validation refusals.
|
||||
// 2. localStorage second, only after the server returns ok. Applied
|
||||
// as a MERGE (no clear): bundled keys overwrite, locally-present
|
||||
// keys absent from the bundle are preserved (so a plugin
|
||||
// installed after the export keeps its first-run defaults).
|
||||
// A localStorage exception here (quota / private mode) is
|
||||
// surfaced verbatim — server state is already committed and we
|
||||
// don't pretend the import was clean.
|
||||
//
|
||||
// In short: the server side is atomic in phase 1 and surface-partial in
|
||||
// phase 2; the localStorage side is best-effort merge after server
|
||||
// success. Failures are reported, never silenced.
|
||||
|
||||
export async function exportSettings() {
|
||||
const status = document.getElementById('backup-status');
|
||||
status.textContent = 'Exporting...';
|
||||
try {
|
||||
const resp = await fetch('/api/settings/export');
|
||||
if (!resp.ok) {
|
||||
status.textContent = `Export failed (HTTP ${resp.status})`;
|
||||
return;
|
||||
}
|
||||
const bundle = await resp.json();
|
||||
// Layer in the browser's localStorage. Use the standard Storage
|
||||
// iteration API (length + key(i)) rather than Object.keys —
|
||||
// Object.keys on a Storage instance is not deterministic across
|
||||
// browsers and can both miss entries and include non-entry
|
||||
// properties depending on the implementation. Keys are preserved
|
||||
// verbatim as strings; that's how localStorage stores them, and
|
||||
// round-trip fidelity matters more than re-typing values that
|
||||
// were never typed in the first place.
|
||||
const localStorageData = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key === null) continue;
|
||||
const value = localStorage.getItem(key);
|
||||
if (value !== null) localStorageData[key] = value;
|
||||
}
|
||||
bundle.local_storage = localStorageData;
|
||||
|
||||
// Trigger download via blob + temporary <a download>. We honor the
|
||||
// server's Content-Disposition filename when present, otherwise
|
||||
// fall back to a date-stamped default.
|
||||
let filename = 'feedBack-settings.json';
|
||||
const disposition = resp.headers.get('Content-Disposition');
|
||||
if (disposition) {
|
||||
const match = /filename="([^"]+)"/.exec(disposition);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function importSettings(file) {
|
||||
if (!file) return;
|
||||
const status = document.getElementById('backup-status');
|
||||
if (!confirm('Import will overwrite settings present in the bundle (server config, browser preferences, and opted-in plugin data) and reload the page. Settings not in the bundle (e.g. from plugins installed after the export) are preserved. Continue?')) {
|
||||
status.textContent = 'Import cancelled';
|
||||
return;
|
||||
}
|
||||
let bundle;
|
||||
try {
|
||||
bundle = JSON.parse(await file.text());
|
||||
} catch (e) {
|
||||
status.textContent = `Import failed: not valid JSON (${e.message})`;
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = 'Importing...';
|
||||
let resp, data;
|
||||
try {
|
||||
resp = await fetch('/api/settings/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(bundle),
|
||||
});
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
status.textContent = `Import failed: ${e.message}`;
|
||||
return;
|
||||
}
|
||||
// Two failure shapes to surface: our own validation handler
|
||||
// returns `{ok: false, error: "..."}`, but if the body fails
|
||||
// FastAPI's request-level validation (e.g. top-level value is
|
||||
// an array, not an object), the response is the framework's
|
||||
// `{detail: ...}` shape with no `ok` key. `resp.ok` distinguishes
|
||||
// both from success without depending on which path produced
|
||||
// the failure.
|
||||
if (!resp.ok || data.ok === false) {
|
||||
let msg = data.error;
|
||||
if (!msg && data.detail) {
|
||||
msg = typeof data.detail === 'string'
|
||||
? data.detail
|
||||
: JSON.stringify(data.detail);
|
||||
}
|
||||
status.textContent = `Import failed: ${msg || `HTTP ${resp.status}`}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Server applied successfully. Now apply the localStorage portion as
|
||||
// a MERGE (not clear+restore): keys in the bundle overwrite, keys
|
||||
// present locally but absent from the bundle are preserved. This
|
||||
// matters when a plugin was installed *after* the export — wiping
|
||||
// its localStorage would erase first-run defaults the plugin set on
|
||||
// load, leaving it in a worse state than before the import. The
|
||||
// tradeoff is that orphan keys from removed plugins or renamed key
|
||||
// schemes also linger; cleaning those up is the user's job.
|
||||
const ls = bundle.local_storage;
|
||||
if (ls && typeof ls === 'object') {
|
||||
try {
|
||||
for (const [key, value] of Object.entries(ls)) {
|
||||
if (typeof value === 'string') localStorage.setItem(key, value);
|
||||
}
|
||||
} catch (e) {
|
||||
// Quota exceeded / private mode etc. Server side already
|
||||
// committed, so we surface the partial state rather than
|
||||
// pretending it succeeded.
|
||||
status.textContent = `Server applied, but localStorage write failed: ${e.message}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const warnings = (data.warnings || []).join('; ');
|
||||
status.textContent = warnings ? `Imported with warnings: ${warnings}. Reloading...` : 'Imported. Reloading...';
|
||||
setTimeout(() => location.reload(), 800);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Tuning display — naming, string counts, and target frequencies.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
//
|
||||
// Turns raw per-string semitone offsets into things a human reads: a tuning NAME
|
||||
// ("Drop D", "Eb Standard", or a raw-offsets fallback), whether an arrangement is
|
||||
// bass, its effective string count, and the target FREQUENCIES + note names the
|
||||
// tuner checks against. Pure functions over a small MIDI/note-name table.
|
||||
//
|
||||
// The window / window.feedBack assignments for these stay in app.js — they are the
|
||||
// public contract (constitution II names window.feedBack), and app.js re-exposes
|
||||
// the imported bindings from exactly where it always did, so nothing about the
|
||||
// surface or its ordering changes.
|
||||
|
||||
// Display-only tuning label helpers — never mutate offsets or affect playback.
|
||||
function _looksLikeRawTuningOffsets(str) {
|
||||
if (!str || typeof str !== 'string') return false;
|
||||
const s = str.trim();
|
||||
if (!s) return false;
|
||||
if (/^-?\d+$/.test(s)) return true;
|
||||
if (/^-?\d+(?: -?\d+)+$/.test(s)) return true;
|
||||
if (/^-?\d+(?:,-?\d+)+$/.test(s)) return true;
|
||||
if (/^-?\d+(-?\d+){2,}$/.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function _tuningNameFromOffsets(offsets) {
|
||||
if (!offsets || !offsets.length) return '';
|
||||
const standard = {
|
||||
0: 'E Standard', '-1': 'Eb Standard', '-2': 'D Standard',
|
||||
'-3': 'C# Standard', '-4': 'C Standard', '-5': 'B Standard',
|
||||
'-6': 'Bb Standard', '-7': 'A Standard',
|
||||
1: 'F Standard', 2: 'F# Standard',
|
||||
};
|
||||
// Uniform offsets across 4 (bass) / 5 / 6 strings name the same Standard;
|
||||
// a 4-string bass [0,0,0,0] must read "E Standard", not "Custom Tuning".
|
||||
if (offsets.length >= 4 && offsets.every((o) => o === offsets[0])) {
|
||||
const name = standard[offsets[0]];
|
||||
if (name) return name;
|
||||
}
|
||||
if (offsets.length >= 4 && offsets[0] === offsets[1] - 2
|
||||
&& offsets.slice(1).every((o) => o === offsets[1])) {
|
||||
const noteNames = ['E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'C#', 'D', 'Eb'];
|
||||
return 'Drop ' + noteNames[((offsets[0] % 12) + 12) % 12];
|
||||
}
|
||||
const named = {
|
||||
'-2,0,0,0,0,0': 'Drop D',
|
||||
'-4,-2,-2,-2,-2,-2': 'Drop C',
|
||||
'-2,-2,0,0,0,0': 'Double Drop D',
|
||||
'0,0,0,-1,0,0': 'Open G',
|
||||
'-2,-2,0,0,-2,-2': 'Open D',
|
||||
'-2,0,0,0,-2,0': 'DADGAD',
|
||||
'0,2,2,1,0,0': 'Open E',
|
||||
'-2,0,0,2,3,2': 'Open D (alt)',
|
||||
};
|
||||
if (offsets.length === 6) {
|
||||
const key = offsets.join(',');
|
||||
if (named[key]) return named[key];
|
||||
}
|
||||
return 'Custom Tuning';
|
||||
}
|
||||
|
||||
export function displayTuningName(value, offsets) {
|
||||
// Explicit offsets win — always name them.
|
||||
if (Array.isArray(offsets) && offsets.length > 0) {
|
||||
return _tuningNameFromOffsets(offsets);
|
||||
}
|
||||
if (value && typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === 'Unknown') return '';
|
||||
if (!_looksLikeRawTuningOffsets(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
// A raw offset string (now served by the API) — parse and name it so a
|
||||
// known tuning like "-1 -1 -1 -1 -1 -1" reads "Eb Standard" rather than
|
||||
// collapsing to "Custom Tuning".
|
||||
const parsed = (typeof parseRawTuningOffsets === 'function')
|
||||
? parseRawTuningOffsets(trimmed) : null;
|
||||
if (parsed && parsed.length) return _tuningNameFromOffsets(parsed);
|
||||
return 'Custom Tuning';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function isBassArrangement(context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
if (typeof ctx.isBass === 'boolean') return ctx.isBass;
|
||||
const label = ((ctx.arrangement || '') + ' ' + (ctx.arrangement_smart_name || '')).toLowerCase();
|
||||
if (/\bbass\b/.test(label)) return true;
|
||||
if (/\b(lead|rhythm|combo|guitar)\b/.test(label)) return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function effectiveStringCount(offsets, context) {
|
||||
if (!Array.isArray(offsets) || !offsets.length) return 0;
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const isBass = isBassArrangement(ctx);
|
||||
let sc = ctx.stringCount > 0 ? Number(ctx.stringCount) : 0;
|
||||
if (!isBass) {
|
||||
if (sc > 0 && sc <= 5 && offsets.length >= 6) sc = 6;
|
||||
if (!sc) sc = offsets.length >= 6 ? offsets.length : 6;
|
||||
} else if (!sc) {
|
||||
sc = offsets.length >= 5 ? offsets.length : 4;
|
||||
}
|
||||
return Math.min(sc, offsets.length);
|
||||
}
|
||||
|
||||
export function songTuningContext(songInfo) {
|
||||
if (!songInfo || typeof songInfo !== 'object') return {};
|
||||
return {
|
||||
stringCount: songInfo.stringCount,
|
||||
arrangement: songInfo.arrangement,
|
||||
arrangement_smart_name: songInfo.arrangement_smart_name,
|
||||
};
|
||||
}
|
||||
|
||||
// Open-string target notes (display only) — mirrors plugins/tuner/utils/tuning-utils.js.
|
||||
const _TUNING_BASE_MIDI = {
|
||||
4: [28, 33, 38, 43],
|
||||
5: [23, 28, 33, 38, 43],
|
||||
6: [40, 45, 50, 55, 59, 64],
|
||||
7: [35, 40, 45, 50, 55, 59, 64],
|
||||
8: [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
};
|
||||
|
||||
const _TUNING_NOTE_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
|
||||
const _TUNING_NOTE_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
|
||||
|
||||
function _tuningMidiToFreq(m) {
|
||||
return Math.pow(2, (m - 69) / 12) * 440;
|
||||
}
|
||||
|
||||
function _tuningOffsetsToFreqs(offsets, isBass) {
|
||||
const len = offsets.length;
|
||||
let base;
|
||||
if (len === 4 || len === 5) {
|
||||
base = isBass ? _TUNING_BASE_MIDI[len] : _TUNING_BASE_MIDI[6];
|
||||
} else {
|
||||
base = _TUNING_BASE_MIDI[len] || _TUNING_BASE_MIDI[6];
|
||||
}
|
||||
return offsets.map((offset, i) => {
|
||||
const root = i < base.length ? base[i] : base[base.length - 1];
|
||||
return _tuningMidiToFreq(root + offset);
|
||||
});
|
||||
}
|
||||
|
||||
function _noteNameFromFreq(freq, useFlats) {
|
||||
const midi = 69 + 12 * Math.log2(freq / 440);
|
||||
const rounded = Math.round(midi);
|
||||
const names = useFlats ? _TUNING_NOTE_FLAT : _TUNING_NOTE_SHARP;
|
||||
return names[((rounded % 12) + 12) % 12];
|
||||
}
|
||||
|
||||
function _octaveNoteFromFreq(freq, useFlats) {
|
||||
const midi = 69 + 12 * Math.log2(freq / 440);
|
||||
const rounded = Math.round(midi);
|
||||
const octave = Math.floor(rounded / 12) - 1;
|
||||
return _noteNameFromFreq(freq, useFlats) + octave;
|
||||
}
|
||||
|
||||
function _stringOrdinalLabel(n) {
|
||||
const v = n % 100;
|
||||
if (v >= 11 && v <= 13) return n + 'th';
|
||||
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th';
|
||||
return n + suffix;
|
||||
}
|
||||
|
||||
function _tuningTargetFreqs(offsets, context) {
|
||||
if (!Array.isArray(offsets) || !offsets.length) return [];
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const stringCount = effectiveStringCount(offsets, ctx);
|
||||
const trimmed = offsets.slice(0, stringCount);
|
||||
if (!trimmed.length) return [];
|
||||
const isBass = isBassArrangement(ctx);
|
||||
try {
|
||||
return _tuningOffsetsToFreqs(trimmed, isBass);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Flat vs sharp spelling. A caller that knows the preference can pass
|
||||
// ctx.useFlats; otherwise we infer from a flat-keyed tuning name. The v3
|
||||
// card/HUD pass "Custom Tuning" (raw offsets carry no key), so those default
|
||||
// to sharps unless an explicit useFlats is supplied.
|
||||
function _resolveTargetUseFlats(ctx) {
|
||||
if (typeof ctx.useFlats === 'boolean') return ctx.useFlats;
|
||||
return typeof ctx.tuningName === 'string' && /\b[A-G]b\b/.test(ctx.tuningName);
|
||||
}
|
||||
|
||||
export function displayTuningTargetDetails(offsets, context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const useFlats = _resolveTargetUseFlats(ctx);
|
||||
const freqs = _tuningTargetFreqs(offsets, ctx);
|
||||
return freqs.map((f, i) => {
|
||||
const stringNumber = freqs.length - i;
|
||||
const note = _noteNameFromFreq(f, useFlats);
|
||||
const octaveNote = _octaveNoteFromFreq(f, useFlats);
|
||||
return {
|
||||
stringNumber,
|
||||
note,
|
||||
octaveNote,
|
||||
title: _stringOrdinalLabel(stringNumber) + ' string: ' + octaveNote,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function displayTuningTargets(offsets, context) {
|
||||
const ctx = context && typeof context === 'object' ? context : {};
|
||||
const useFlats = _resolveTargetUseFlats(ctx);
|
||||
const freqs = _tuningTargetFreqs(offsets, ctx);
|
||||
if (!freqs.length) return '';
|
||||
return freqs.map((f) => _noteNameFromFreq(f, useFlats)).join(' ');
|
||||
}
|
||||
|
||||
export function parseRawTuningOffsets(value) {
|
||||
if (Array.isArray(value) && value.length) return value;
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
const s = value.trim();
|
||||
if (/^-?\d+(?: -?\d+)+$/.test(s)) {
|
||||
return s.split(/\s+/).map((n) => Number(n));
|
||||
}
|
||||
if (/^-?\d+(?:,-?\d+)+$/.test(s)) {
|
||||
return s.split(',').map((n) => Number(n));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
// The visualization layer — the viz picker, renderer selection, and Auto-match.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: it imports NOTHING,
|
||||
// which is what lets static/js/plugin-loader.js take _populateVizPicker straight
|
||||
// from here and drop the configurePluginLoader() host seam it needed while this
|
||||
// code still lived in app.js.
|
||||
//
|
||||
// It owns the state behind those decisions (the one-shot WebGL2 probe, the
|
||||
// 3D-promotion flag, the Auto label, the notation-hint memo) — all
|
||||
// module-private, because nothing outside reads them.
|
||||
|
||||
// ── Visualization picker (feedBack#36) ─────────────────────────────────
|
||||
//
|
||||
// Discovers viz plugins via /api/plugins and adds them to the #viz-picker
|
||||
// dropdown. A viz plugin declares itself by setting `"type": "visualization"`
|
||||
// in its plugin.json AND exposing a factory function on
|
||||
// window.feedBackViz_<id> that returns an object matching the setRenderer
|
||||
// contract ({init, draw, resize, destroy}).
|
||||
//
|
||||
// The "default" option in the dropdown is the built-in 2D highway that
|
||||
// lives inside createHighway(); selecting it calls setRenderer(null) which
|
||||
// restores the default renderer. The bundled 3D Highway plugin
|
||||
// (plugins/highway_3d/) registers as id `highway_3d` and is the new
|
||||
// fresh-install default per feedBack#160 PR 3.
|
||||
|
||||
// ── WebGL2 detection (one-shot probe) ────────────────────────────────────
|
||||
// 3D Highway requires WebGL2. On environments where it's unavailable
|
||||
// (older browsers, some embedded webviews, software-only contexts), we
|
||||
// silently fall back to the Classic 2D Highway and flash a single toast
|
||||
// so the user knows why their highway looks different. Cached so we don't
|
||||
// thrash the GPU with repeat throwaway-canvas creations.
|
||||
let _webgl2Probe = null;
|
||||
function _canRun3D() {
|
||||
if (_webgl2Probe !== null) return _webgl2Probe;
|
||||
try {
|
||||
const c = document.createElement('canvas');
|
||||
const gl = c.getContext('webgl2');
|
||||
_webgl2Probe = !!gl;
|
||||
// Lose the context immediately — the probe canvas is never reused.
|
||||
if (gl && gl.getExtension) {
|
||||
const ext = gl.getExtension('WEBGL_lose_context');
|
||||
if (ext && ext.loseContext) ext.loseContext();
|
||||
}
|
||||
} catch (_) { _webgl2Probe = false; }
|
||||
return _webgl2Probe;
|
||||
}
|
||||
|
||||
// ── Migration / nag flags ────────────────────────────────────────────────
|
||||
// `feedBack_3d_promoted_v1` is set the first time we auto-flip an existing
|
||||
// `vizSelection='default'` user to `'highway_3d'`. Persistence ensures we
|
||||
// don't re-nag on every reload — and ensures the WebGL2 fallback path
|
||||
// doesn't ping-pong (one fallback toast, not one per page load).
|
||||
const _3D_PROMOTED_FLAG_KEY = 'feedBack_3d_promoted_v1';
|
||||
function _markPromoted() {
|
||||
try { localStorage.setItem(_3D_PROMOTED_FLAG_KEY, '1'); } catch (_) {}
|
||||
}
|
||||
function _hasPromotedFlag() {
|
||||
try { return localStorage.getItem(_3D_PROMOTED_FLAG_KEY) === '1'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
// Pending nag: queued during _populateVizPicker, fired on the first
|
||||
// `song:ready` (so the toast lands when the user actually opens the
|
||||
// player, not at page load when they're still in the library).
|
||||
// `song:ready` is emitted by highway.js via window.feedBack.emit(), so
|
||||
// subscribe through the same EventTarget. window.feedBack is created in
|
||||
// this same file before _populateVizPicker is reachable, so the global
|
||||
// is guaranteed to exist by the time this listener registers — but guard
|
||||
// anyway in case this module is ever loaded standalone for tests.
|
||||
let _pendingPromotionNag = false;
|
||||
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
||||
window.feedBack.on('song:ready', () => {
|
||||
if (!_pendingPromotionNag) return;
|
||||
_pendingPromotionNag = false;
|
||||
_showPromotionNag();
|
||||
});
|
||||
}
|
||||
|
||||
function _showPromotionNag() {
|
||||
// Lightweight toast — no dependency on a generic toast helper, since
|
||||
// app.js doesn't currently have one. Fixed bottom-center, dismissed
|
||||
// by clicking either action button or the × close.
|
||||
const existing = document.getElementById('feedBack-3d-nag');
|
||||
if (existing) existing.remove();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.id = 'feedBack-3d-nag';
|
||||
wrap.setAttribute('role', 'dialog');
|
||||
wrap.setAttribute('aria-modal', 'false');
|
||||
wrap.setAttribute('aria-label', '3D Highway upgrade notification');
|
||||
wrap.style.cssText = `
|
||||
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
|
||||
background: linear-gradient(145deg, #1a1a30 0%, #0d0d18 100%);
|
||||
border: 1px solid rgba(64,128,224,0.4);
|
||||
border-radius: 12px; padding: 12px 16px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(64,128,224,0.15);
|
||||
font-size: 13px; color: #e2e8f0; z-index: 10000;
|
||||
max-width: 480px; display: flex; align-items: center; gap: 12px;
|
||||
`;
|
||||
wrap.innerHTML = `
|
||||
<span aria-live="polite" style="flex:1;">Your highway was upgraded to <strong>3D</strong>.</span>
|
||||
<button type="button" data-act="tour" style="background:rgba(64,128,224,0.25);color:#e2e8f0;border:1px solid rgba(64,128,224,0.5);padding:6px 12px;border-radius:8px;font-size:12px;cursor:pointer;">Try the tour</button>
|
||||
<button type="button" data-act="back" style="background:transparent;color:#cbd5e1;border:1px solid rgba(255,255,255,0.1);padding:6px 12px;border-radius:8px;font-size:12px;cursor:pointer;">Switch back to 2D</button>
|
||||
<button type="button" data-act="dismiss" aria-label="Dismiss" style="background:transparent;color:#6b7280;border:none;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">×</button>
|
||||
`;
|
||||
wrap.addEventListener('click', (ev) => {
|
||||
const btn = ev.target.closest('button[data-act]');
|
||||
if (!btn) return;
|
||||
const act = btn.dataset.act;
|
||||
if (act === 'tour') {
|
||||
try {
|
||||
if (window.feedBackTour && typeof window.feedBackTour.start === 'function') {
|
||||
window.feedBackTour.start('highway_3d');
|
||||
}
|
||||
} catch (_) {}
|
||||
} else if (act === 'back') {
|
||||
setViz('default');
|
||||
}
|
||||
wrap.remove();
|
||||
});
|
||||
document.body.appendChild(wrap);
|
||||
}
|
||||
|
||||
function _showWebGL2FallbackToast() {
|
||||
// One-time fallback notice. Same lightweight DOM as the nag, simpler
|
||||
// copy and only a dismiss button.
|
||||
if (document.getElementById('feedBack-3d-fallback')) return;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.id = 'feedBack-3d-fallback';
|
||||
wrap.setAttribute('role', 'dialog');
|
||||
wrap.setAttribute('aria-modal', 'false');
|
||||
wrap.setAttribute('aria-label', 'WebGL2 not available');
|
||||
wrap.style.cssText = `
|
||||
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
|
||||
background: #181830; border: 1px solid rgba(255,180,80,0.4);
|
||||
border-radius: 12px; padding: 10px 14px;
|
||||
font-size: 12px; color: #e2e8f0; z-index: 10000;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
`;
|
||||
wrap.innerHTML = `
|
||||
<span aria-live="polite">3D Highway needs WebGL2 — falling back to Classic 2D.</span>
|
||||
<button type="button" data-act="dismiss" aria-label="Dismiss" style="background:transparent;color:#6b7280;border:none;font-size:16px;cursor:pointer;padding:0 4px;line-height:1;">×</button>
|
||||
`;
|
||||
wrap.addEventListener('click', (ev) => {
|
||||
if (ev.target.closest('button[data-act]')) wrap.remove();
|
||||
});
|
||||
document.body.appendChild(wrap);
|
||||
setTimeout(() => { try { wrap.remove(); } catch (_) {} }, 8000);
|
||||
}
|
||||
|
||||
// The "default" option in the dropdown is the built-in 2D highway that
|
||||
// lives inside createHighway(); selecting it calls setRenderer(null) which
|
||||
// restores the default renderer.
|
||||
function _ensureVenueVizOption(sel) {
|
||||
if (!sel) return;
|
||||
if (Array.from(sel.options).some(opt => opt.value === 'venue')) return;
|
||||
if (!Array.from(sel.options).some(opt => opt.value === 'highway_3d')) return;
|
||||
const h3dOpt = Array.from(sel.options).find(opt => opt.value === 'highway_3d');
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 'venue';
|
||||
opt.textContent = 'Venue';
|
||||
if (h3dOpt && h3dOpt.nextSibling) sel.insertBefore(opt, h3dOpt.nextSibling);
|
||||
else sel.appendChild(opt);
|
||||
}
|
||||
|
||||
function _syncVenueVizPlayerClass(vizId) {
|
||||
if (window.v3VenueViz && typeof window.v3VenueViz.setSelectedVizId === 'function') {
|
||||
window.v3VenueViz.setSelectedVizId(vizId);
|
||||
return;
|
||||
}
|
||||
if (window.v3VenueViz && typeof window.v3VenueViz.syncPlayerVizClass === 'function') {
|
||||
window.v3VenueViz.syncPlayerVizClass(vizId);
|
||||
return;
|
||||
}
|
||||
const player = document.getElementById('player');
|
||||
if (player) player.classList.toggle('is-venue-visualization', vizId === 'venue');
|
||||
}
|
||||
|
||||
export async function _populateVizPicker(plugins) {
|
||||
const sel = document.getElementById('viz-picker');
|
||||
if (!sel) return;
|
||||
// Clear any previously-appended plugin options so calling this
|
||||
// function more than once (e.g. from DevTools, or a hot-reloaded
|
||||
// plugin) doesn't produce duplicates. The built-in "auto" and
|
||||
// "default" options are static markup — preserve them.
|
||||
const BUILTIN_OPT_VALUES = new Set(['auto', 'default', 'venue']);
|
||||
Array.from(sel.options).forEach(opt => {
|
||||
if (!BUILTIN_OPT_VALUES.has(opt.value)) sel.removeChild(opt);
|
||||
});
|
||||
// Accept a pre-fetched plugins array (normal startup path reuses
|
||||
// loadPlugins' fetch). Fall back to our own fetch if called
|
||||
// standalone — e.g. from the DevTools console for debugging.
|
||||
if (!Array.isArray(plugins)) {
|
||||
plugins = [];
|
||||
try {
|
||||
const resp = await fetch('/api/plugins');
|
||||
if (resp.ok) plugins = await resp.json();
|
||||
} catch (e) {
|
||||
console.warn('viz picker: /api/plugins fetch failed', e);
|
||||
}
|
||||
}
|
||||
const vizPlugins = plugins.filter(p => p && p.type === 'visualization');
|
||||
// "default" is reserved for the built-in 2D renderer option and
|
||||
// "auto" is reserved for the Auto-mode entry — both already in the
|
||||
// <select>. A plugin with either id would collide: the
|
||||
// restore-from-localStorage lookup would find the built-in entry,
|
||||
// dragging the plugin into never-selected land silently. Fail
|
||||
// loudly instead.
|
||||
const RESERVED_IDS = new Set(['default', 'auto']);
|
||||
for (const p of vizPlugins) {
|
||||
if (RESERVED_IDS.has(p.id)) {
|
||||
console.error(`viz picker: plugin id '${p.id}' collides with a reserved built-in picker entry ('auto' = Auto mode, 'default' = built-in 2D highway); rename the plugin's id in plugin.json to include it in the picker.`);
|
||||
continue;
|
||||
}
|
||||
// Skip entries where the plugin script hasn't exposed a factory —
|
||||
// likely means the script failed to load, or the plugin declared
|
||||
// itself as a viz without shipping the factory yet.
|
||||
const factoryName = 'feedBackViz_' + p.id;
|
||||
if (typeof window[factoryName] !== 'function') {
|
||||
console.warn(`viz picker: plugin '${p.id}' has type=visualization but ${factoryName} is not a function; skipping`);
|
||||
continue;
|
||||
}
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name || p.id;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
_ensureVenueVizOption(sel);
|
||||
// Refresh the visualization capability domain's provider registry from
|
||||
// the picker entries just built (the domain host introspects each
|
||||
// factory global for contextType / predicate metadata).
|
||||
if (window.feedBack.vizDomain && typeof window.feedBack.vizDomain.refreshProviders === 'function') {
|
||||
try {
|
||||
// The host reads manifest-declared per-instance settings
|
||||
// (capabilities.visualization.settings, feedBack#849) from the
|
||||
// registered capability participant by id — no need to pass them
|
||||
// through the picker here.
|
||||
window.feedBack.vizDomain.refreshProviders(
|
||||
Array.from(sel.options)
|
||||
.filter(opt => !BUILTIN_OPT_VALUES.has(opt.value))
|
||||
.map(opt => ({ id: opt.value, label: opt.text }))
|
||||
);
|
||||
} catch (e) { console.warn('viz picker: capability provider refresh failed', e); }
|
||||
}
|
||||
// Restore previous selection if still available. Direct option
|
||||
// scan instead of a CSS-selector lookup so we don't depend on
|
||||
// CSS.escape (missing in some test environments / older runtimes)
|
||||
// and so a weird saved string (e.g. with a quote) can't throw.
|
||||
// localStorage.getItem can itself throw when storage is blocked
|
||||
// (private mode, sandboxed iframes, some strict test runners);
|
||||
// fall back to null so the startup chain doesn't abort.
|
||||
let saved = null;
|
||||
try { saved = localStorage.getItem('vizSelection'); }
|
||||
catch (e) { console.warn('viz picker: unable to read vizSelection', e); }
|
||||
|
||||
// ── 3D promotion migration (feedBack#160 PR 3) ──────────────────────
|
||||
// Existing users with `vizSelection='default'` (the old built-in 2D
|
||||
// highway) are auto-flipped to the bundled 3D Highway exactly once,
|
||||
// and a non-modal nag toast offers them "Try the tour" / "Switch
|
||||
// back to 2D" the first time they open the player. Users on `auto`
|
||||
// are left alone (auto-pick semantics unchanged). Users on a custom
|
||||
// viz plugin are left alone. WebGL2 absence falls back via setViz.
|
||||
if (saved === 'default' && !_hasPromotedFlag()) {
|
||||
const has3D = Array.from(sel.options).some(o => o.value === 'highway_3d');
|
||||
if (has3D && _canRun3D()) {
|
||||
saved = 'highway_3d';
|
||||
try { localStorage.setItem('vizSelection', 'highway_3d'); } catch (_) {}
|
||||
_markPromoted();
|
||||
_pendingPromotionNag = true;
|
||||
// Race guard: if song:ready already fired before _populateVizPicker
|
||||
// ran (e.g. a deeplink or a fast-loading song), getSongInfo() will
|
||||
// already be non-empty and we'll never receive another song:ready
|
||||
// in this session. Show the nag immediately in that case.
|
||||
const _si = window.highway && window.highway.getSongInfo();
|
||||
if (_si && _si.title) {
|
||||
_pendingPromotionNag = false;
|
||||
_showPromotionNag();
|
||||
}
|
||||
} else if (has3D && !_canRun3D()) {
|
||||
// 3D registered but WebGL2 absent — promote in name but
|
||||
// immediately fall back so we don't ping-pong on every load.
|
||||
// Set the flag so we don't try again next reload.
|
||||
_markPromoted();
|
||||
_showWebGL2FallbackToast();
|
||||
}
|
||||
// No `highway_3d` option (plugin unloaded?) → leave saved as
|
||||
// 'default'. We'll retry the migration once the plugin is back.
|
||||
}
|
||||
|
||||
const savedMatches = saved && Array.from(sel.options).some(opt => opt.value === saved);
|
||||
if (savedMatches) {
|
||||
sel.value = saved;
|
||||
// 'default' needs no setViz — the highway already starts with
|
||||
// the built-in renderer. 'auto' runs setViz so _autoMatchViz
|
||||
// fires, though it's a no-op before the first song_info frame.
|
||||
if (saved !== 'default') setViz(saved);
|
||||
} else if (saved) {
|
||||
// Saved selection references an option that no longer exists —
|
||||
// plugin uninstalled since last session, renamed, or the plugin
|
||||
// script failed to register its factory this time. Clear the
|
||||
// stale value so we don't keep trying the same missing viz on
|
||||
// every reload, and fall through to the fresh-install default
|
||||
// below.
|
||||
try { localStorage.removeItem('vizSelection'); }
|
||||
catch (_) { /* storage blocked; ignore */ }
|
||||
saved = null;
|
||||
}
|
||||
if (!saved) {
|
||||
// Fresh install (or post-cleanup fallthrough): default to the
|
||||
// bundled 3D Highway when available + WebGL2-capable, falling
|
||||
// back to Auto otherwise so the arrangement-matching plugins
|
||||
// (piano on Keys songs, drums on Drums songs, ...) still take
|
||||
// over for non-3D arrangements.
|
||||
const has3D = Array.from(sel.options).some(o => o.value === 'highway_3d');
|
||||
if (has3D && _canRun3D()) {
|
||||
sel.value = 'highway_3d';
|
||||
try { localStorage.setItem('vizSelection', 'highway_3d'); } catch (_) {}
|
||||
setViz('highway_3d');
|
||||
} else {
|
||||
sel.value = 'auto';
|
||||
try { localStorage.setItem('vizSelection', 'auto'); } catch (_) {}
|
||||
if (has3D && !_canRun3D()) { _markPromoted(); _showWebGL2FallbackToast(); }
|
||||
}
|
||||
}
|
||||
// Close a startup race: if playback began before loadPlugins
|
||||
// finished, song:ready already fired while the picker had no
|
||||
// plugin options — _autoMatchViz saw no candidates and left the
|
||||
// default active. Now that plugins are registered, re-evaluate
|
||||
// against whatever song is currently loaded (a no-op when no song
|
||||
// has been loaded yet, since highway.getSongInfo() returns {}).
|
||||
if (sel.value === 'auto') _autoMatchViz();
|
||||
}
|
||||
|
||||
function _tagVizRenderer(renderer, id) {
|
||||
if (!renderer || !id) return renderer;
|
||||
try {
|
||||
if (!renderer.pluginId) renderer.pluginId = id;
|
||||
if (!renderer.source) renderer.source = id;
|
||||
} catch (_) {}
|
||||
return renderer;
|
||||
}
|
||||
|
||||
// Attribution hooks into the visualization capability domain (cap:6).
|
||||
// Guarded no-ops when the domain host isn't loaded (minimal/test pages).
|
||||
function _notifyVizDomain(id, source) {
|
||||
const domain = window.feedBack && window.feedBack.vizDomain;
|
||||
if (domain && typeof domain.notifyRendererChanged === 'function') {
|
||||
try { domain.notifyRendererChanged(id, source); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function _noteVizAutoMatch(id, matched) {
|
||||
const domain = window.feedBack && window.feedBack.vizDomain;
|
||||
if (domain && typeof domain.noteAutoMatch === 'function') {
|
||||
try { domain.noteAutoMatch(id, matched); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function _installVizRenderer(renderer, id, source = 'user-select') {
|
||||
highway.setRenderer(_tagVizRenderer(renderer, id));
|
||||
// Drop any stale notation-view hint now that we have a resolved renderer id.
|
||||
// This is also the path used by _autoMatchViz() after it resolves 'auto' to
|
||||
// a real plugin id, so the null passed at evaluation start is corrected here.
|
||||
_dropStaleNotationHint(id);
|
||||
_notifyVizDomain(id, source);
|
||||
if (window.v3VenueViz && typeof window.v3VenueViz.notifyRendererInstalled === 'function') {
|
||||
window.v3VenueViz.notifyRendererInstalled(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function setViz(id) {
|
||||
// Helper: reset the UI and persisted selection to the built-in
|
||||
// "default" entry. Called whenever the requested viz can't be
|
||||
// applied (missing factory, factory threw, factory returned a
|
||||
// non-conforming renderer) so the picker, localStorage, and the
|
||||
// highway's active renderer stay in sync.
|
||||
const fallbackToDefault = () => {
|
||||
try { localStorage.setItem('vizSelection', 'default'); } catch (_) {}
|
||||
const sel = document.getElementById('viz-picker');
|
||||
if (sel) sel.value = 'default';
|
||||
highway.setRenderer(null);
|
||||
_syncVenueVizPlayerClass('default');
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz('default');
|
||||
}
|
||||
_notifyVizDomain('default', 'fallback');
|
||||
_maybeShowNotationViewHint('default');
|
||||
};
|
||||
|
||||
// When switching away from Auto, reset the closed-state label so the
|
||||
// Auto option shows base text the next time the user opens the dropdown.
|
||||
// Also cancel any pending viz:renderer:ready listener from the previous
|
||||
// Auto match cycle so it can't set a stale label after we've moved on.
|
||||
if (id !== 'auto') {
|
||||
if (_cancelPendingAutoLabel) { _cancelPendingAutoLabel(); _cancelPendingAutoLabel = null; }
|
||||
_setAutoVizLabel(null);
|
||||
}
|
||||
|
||||
if (id === 'default' || !id) {
|
||||
try { localStorage.setItem('vizSelection', id || 'default'); } catch (_) {}
|
||||
const _sel = document.getElementById('viz-picker');
|
||||
if (_sel) _sel.value = 'default';
|
||||
highway.setRenderer(null);
|
||||
_syncVenueVizPlayerClass('default');
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz('default');
|
||||
}
|
||||
_notifyVizDomain('default', 'user-select');
|
||||
_maybeShowNotationViewHint('default');
|
||||
return;
|
||||
}
|
||||
if (id === 'auto') {
|
||||
try { localStorage.setItem('vizSelection', 'auto'); } catch (_) {}
|
||||
_syncVenueVizPlayerClass('auto');
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz('auto');
|
||||
}
|
||||
_autoMatchViz();
|
||||
return;
|
||||
}
|
||||
if (id === 'venue') {
|
||||
if (!_canRun3D()) {
|
||||
console.warn('viz picker: WebGL2 unavailable, falling back to Classic 2D Highway');
|
||||
_markPromoted();
|
||||
_showWebGL2FallbackToast();
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
const venueFactory = window['feedBackViz_highway_3d'];
|
||||
if (typeof venueFactory !== 'function') {
|
||||
console.error('viz picker: venue requires feedBackViz_highway_3d');
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
let venueRenderer;
|
||||
try { venueRenderer = venueFactory(); }
|
||||
catch (e) {
|
||||
console.error('viz picker: feedBackViz_highway_3d threw for venue mode', e);
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
if (!venueRenderer || typeof venueRenderer.draw !== 'function') {
|
||||
console.error('viz picker: feedBackViz_highway_3d returned an invalid renderer for venue mode');
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
try { localStorage.setItem('vizSelection', 'venue'); } catch (_) {}
|
||||
const _venueSel = document.getElementById('viz-picker');
|
||||
if (_venueSel) _venueSel.value = 'venue';
|
||||
_installVizRenderer(venueRenderer, 'highway_3d');
|
||||
_syncVenueVizPlayerClass('venue');
|
||||
console.info('[venue-viz] selected venue -> renderer highway_3d, venueClass=true');
|
||||
if (window.v3VenueMoodFx && typeof window.v3VenueMoodFx.onVenueVisualizationSelected === 'function') {
|
||||
window.v3VenueMoodFx.onVenueVisualizationSelected();
|
||||
}
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz('venue');
|
||||
}
|
||||
_maybeShowNotationViewHint('highway_3d');
|
||||
return;
|
||||
}
|
||||
// 3D Highway specifically gates on WebGL2. Any future WebGL viz
|
||||
// plugin should declare its own probe — for now the bundled 3D
|
||||
// Highway is the only viz with this requirement, so the gate is
|
||||
// hardcoded. Falling back to 'default' (Classic 2D) keeps the
|
||||
// picker in sync; toast informs the user.
|
||||
if (id === 'highway_3d' && !_canRun3D()) {
|
||||
console.warn('viz picker: WebGL2 unavailable, falling back to Classic 2D Highway');
|
||||
_markPromoted();
|
||||
_showWebGL2FallbackToast();
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
const factory = window['feedBackViz_' + id];
|
||||
if (typeof factory !== 'function') {
|
||||
console.error(`viz picker: factory feedBackViz_${id} not available`);
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
let renderer;
|
||||
try { renderer = factory(); }
|
||||
catch (e) {
|
||||
console.error(`viz picker: factory feedBackViz_${id} threw`, e);
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
// Validate shape — highway.setRenderer will itself fall back to
|
||||
// default on a bad renderer, but without this check the UI and
|
||||
// localStorage would still advertise the broken selection.
|
||||
if (!renderer || typeof renderer.draw !== 'function') {
|
||||
console.error(`viz picker: factory feedBackViz_${id} returned an invalid renderer (missing draw)`);
|
||||
fallbackToDefault();
|
||||
return;
|
||||
}
|
||||
// Persist only once we know the renderer is valid.
|
||||
try { localStorage.setItem('vizSelection', id); } catch (_) {}
|
||||
_installVizRenderer(renderer, id);
|
||||
_syncVenueVizPlayerClass(id);
|
||||
if (window.v3VenueScene3d && typeof window.v3VenueScene3d.syncViz === 'function') {
|
||||
window.v3VenueScene3d.syncViz(id);
|
||||
}
|
||||
_maybeShowNotationViewHint(id);
|
||||
}
|
||||
|
||||
// Auto mode: evaluate each registered viz factory's static
|
||||
// `matchesArrangement(songInfo)` predicate and install the first
|
||||
// matching renderer. No match → fall back to the built-in 2D highway.
|
||||
//
|
||||
// vizSelection stays 'auto' across invocations so the next song:ready
|
||||
// re-evaluates. An explicit picker choice overrides Auto by persisting
|
||||
// a different vizSelection.
|
||||
//
|
||||
// Enumerates viz plugins by walking the picker's own <option> list —
|
||||
// that's the canonical set built by _populateVizPicker above and keeps
|
||||
// us from needing a second module-level registry.
|
||||
// Helper: update the closed-state label of the Auto option to show what was resolved.
|
||||
// Resets to the base label when called with no argument (at evaluation start).
|
||||
// _autoVizBaseLabel is captured from the DOM on first call so the reset text
|
||||
// always matches the initial markup rather than a hardcoded duplicate.
|
||||
let _autoVizBaseLabel = null;
|
||||
function _setAutoVizLabel(resolvedText) {
|
||||
const opt = document.querySelector('#viz-picker option[value="auto"]');
|
||||
if (!opt) return;
|
||||
if (_autoVizBaseLabel === null) _autoVizBaseLabel = opt.text;
|
||||
opt.text = resolvedText != null ? `Auto \u2192 ${resolvedText}` : _autoVizBaseLabel;
|
||||
}
|
||||
|
||||
// Holds a cleanup function for the pending viz:renderer:ready listener
|
||||
// registered by _autoMatchViz(). Called at the start of each new evaluation
|
||||
// to remove any listener left over from the previous match cycle.
|
||||
let _cancelPendingAutoLabel = null;
|
||||
|
||||
// One-shot (per song) hint shown when a notation-only arrangement falls back
|
||||
// to the built-in 2D highway. Such arrangements carry no wire notes
|
||||
// (sloppak-spec §5.3: `file:` may be omitted when `notation:` is present), so
|
||||
// the default renderer draws an empty board — without this the user is left
|
||||
// staring at a silently blank highway. Core ships no notation view; point at
|
||||
// the viz picker instead.
|
||||
let _notationHintShownFor = null;
|
||||
function _showNotationViewHint(arrangementIndex, activeVizId) {
|
||||
const filename = (window.feedBack && window.feedBack.currentSong
|
||||
&& window.feedBack.currentSong.filename) || '';
|
||||
if (_notationHintShownFor === filename) return;
|
||||
_notationHintShownFor = filename;
|
||||
const player = document.getElementById('player');
|
||||
if (!player) return;
|
||||
const prev = document.getElementById('notation-view-hint');
|
||||
if (prev) prev.remove();
|
||||
const el = document.createElement('div');
|
||||
el.id = 'notation-view-hint';
|
||||
el.className = 'notation-view-hint';
|
||||
el.dataset.filename = filename;
|
||||
if (arrangementIndex != null) el.dataset.arrangementIndex = String(arrangementIndex);
|
||||
if (activeVizId) el.dataset.vizId = String(activeVizId);
|
||||
el.textContent = 'This arrangement is notation-only — the built-in highway has nothing to draw. '
|
||||
+ 'Install a notation view plugin (e.g. Staff View or Keys Highway 3D) and select it in the visualization picker.';
|
||||
const close = document.createElement('button');
|
||||
close.className = 'notation-view-hint-close';
|
||||
close.setAttribute('aria-label', 'Dismiss');
|
||||
close.textContent = '×';
|
||||
close.addEventListener('click', () => el.remove());
|
||||
el.appendChild(close);
|
||||
player.appendChild(el);
|
||||
setTimeout(() => { el.remove(); }, 15000);
|
||||
}
|
||||
|
||||
// Decide whether the active song needs the notation-view hint: the song is
|
||||
// notation-only (has_notation + zero wire notes on the active arrangement)
|
||||
// AND the given viz doesn't claim it via matchesArrangement. Covers both the
|
||||
// Auto fallthrough (activeVizId='default') and explicit selections, where the
|
||||
// renderer persists across songs — e.g. the fresh-install default highway_3d
|
||||
// would otherwise show a silently empty 3D board on a notation-only song.
|
||||
// Returns true when the hint was shown.
|
||||
// A hint left over from a previous song refers to the wrong arrangement —
|
||||
// drop it whenever the viz evaluation runs for a different filename, a
|
||||
// different arrangement index, or a different active viz.
|
||||
function _dropStaleNotationHint(activeVizId) {
|
||||
const stale = document.getElementById('notation-view-hint');
|
||||
if (!stale) return;
|
||||
const curFilename = (window.feedBack && window.feedBack.currentSong
|
||||
&& window.feedBack.currentSong.filename) || '';
|
||||
if (stale.dataset.filename !== curFilename) { stale.remove(); return; }
|
||||
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const curArrIdx = songInfo.arrangement_index != null ? String(songInfo.arrangement_index) : null;
|
||||
if (curArrIdx !== null && stale.dataset.arrangementIndex !== undefined
|
||||
&& stale.dataset.arrangementIndex !== curArrIdx) {
|
||||
stale.remove(); return;
|
||||
}
|
||||
if (activeVizId && stale.dataset.vizId !== undefined && stale.dataset.vizId !== String(activeVizId)) {
|
||||
stale.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function _maybeShowNotationViewHint(activeVizId) {
|
||||
_dropStaleNotationHint(activeVizId);
|
||||
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const activeArr = Array.isArray(songInfo.arrangements)
|
||||
? songInfo.arrangements.find(a => a.index === songInfo.arrangement_index)
|
||||
: null;
|
||||
if (!(songInfo.has_notation && activeArr && activeArr.notes === 0)) {
|
||||
// Condition no longer holds (arrangement switched to one with notes, or
|
||||
// notation flag cleared) — remove any residual hint so it doesn't
|
||||
// linger and contradict current state.
|
||||
const existing = document.getElementById('notation-view-hint');
|
||||
if (existing) existing.remove();
|
||||
return false;
|
||||
}
|
||||
if (activeVizId && activeVizId !== 'default' && activeVizId !== 'auto') {
|
||||
const factory = window['feedBackViz_' + activeVizId];
|
||||
let claimed = false;
|
||||
try {
|
||||
claimed = typeof factory === 'function'
|
||||
&& typeof factory.matchesArrangement === 'function'
|
||||
&& !!factory.matchesArrangement(songInfo);
|
||||
} catch (_) { /* predicate threw — treat as unclaimed */ }
|
||||
if (claimed) {
|
||||
// Renderer now claims notation — drop any existing hint.
|
||||
const existing = document.getElementById('notation-view-hint');
|
||||
if (existing) existing.remove();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_showNotationViewHint(songInfo.arrangement_index, activeVizId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function _autoMatchViz() {
|
||||
const sel = document.getElementById('viz-picker');
|
||||
if (!sel) return;
|
||||
// Pass null here: sel.value is 'auto', which is never a valid viz-id hint
|
||||
// key. Passing 'auto' would incorrectly drop hints whose data-viz-id is
|
||||
// 'default' (the resolved renderer after a no-match pass), making the
|
||||
// hint unshowable for the rest of the song. Drop using the resolved id
|
||||
// happens later inside _installVizRenderer once the id is known.
|
||||
_dropStaleNotationHint(null);
|
||||
// Cancel any pending viz:renderer:ready listener from a previous match
|
||||
// cycle. The song may change before the previous renderer's async init
|
||||
// settles; we don't want that stale listener to clobber the new label.
|
||||
if (_cancelPendingAutoLabel) { _cancelPendingAutoLabel(); _cancelPendingAutoLabel = null; }
|
||||
// Reset label at evaluation start so a stale resolved label never persists
|
||||
// if the song changes or the picker re-evaluates with a different outcome.
|
||||
_setAutoVizLabel(null);
|
||||
const songInfo = (typeof highway !== 'undefined' && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
// Only update the label when a real song is loaded. Before the first
|
||||
// song_info frame, getSongInfo() returns {} — leaving the reset state
|
||||
// ("Auto (match arrangement)") is correct; we haven't evaluated yet.
|
||||
const hasSong = Object.keys(songInfo).length > 0;
|
||||
// Options are stable in DOM order, which matches what users see in
|
||||
// the picker. The underlying order comes from /api/plugins →
|
||||
// _populateVizPicker, and /api/plugins reflects the order the
|
||||
// plugin loader discovered plugins in — plugins/__init__.py walks
|
||||
// `sorted(plugins_base_dir.iterdir())`, i.e. sorted by the on-disk
|
||||
// PLUGIN DIRECTORY name (e.g. "feedBack-plugin-drums" sorts
|
||||
// before "feedBack-plugin-piano"), not by the plugin id declared
|
||||
// in plugin.json. Two consequences worth noting:
|
||||
// 1. First match wins among registered viz plugins — keep each
|
||||
// plugin's matchesArrangement predicate narrow to avoid
|
||||
// stealing songs from more specialized viz.
|
||||
// 2. If you need a strict priority when multiple plugins match
|
||||
// the same song, name the higher-priority plugin's directory
|
||||
// earlier alphabetically. The picker dropdown reveals the
|
||||
// actual tiebreaker at a glance.
|
||||
const candidateIds = Array.from(sel.options)
|
||||
.map(o => o.value)
|
||||
.filter(v => v !== 'auto' && v !== 'default');
|
||||
for (const id of candidateIds) {
|
||||
const factory = window['feedBackViz_' + id];
|
||||
if (typeof factory !== 'function') continue;
|
||||
// If the factory statically declares contextType='webgl2', gate on
|
||||
// WebGL2 availability so a match never installs a renderer that'll
|
||||
// fail at init. This is the generic version of the old hard-coded
|
||||
// highway_3d check — any future WebGL2 viz gets the same protection
|
||||
// for free without needing a special-case here.
|
||||
const factoryCtxType = typeof factory.contextType === 'string' ? factory.contextType : '2d';
|
||||
if (factoryCtxType === 'webgl2' && !_canRun3D()) continue;
|
||||
const predicate = factory.matchesArrangement;
|
||||
if (typeof predicate !== 'function') continue;
|
||||
let matched = false;
|
||||
try { matched = !!predicate(songInfo); }
|
||||
catch (err) {
|
||||
console.error(`viz auto: matchesArrangement for ${id} threw`, err);
|
||||
continue;
|
||||
}
|
||||
if (!matched) continue;
|
||||
let renderer;
|
||||
try { renderer = factory(); }
|
||||
catch (err) {
|
||||
console.error(`viz auto: factory feedBackViz_${id} threw`, err);
|
||||
continue;
|
||||
}
|
||||
if (!renderer || typeof renderer.draw !== 'function') {
|
||||
console.error(`viz auto: factory feedBackViz_${id} returned an invalid renderer (missing draw)`);
|
||||
continue;
|
||||
}
|
||||
// Deliberately NOT persisting id — vizSelection stays 'auto' so
|
||||
// the next song:ready re-evaluates against the new arrangement.
|
||||
//
|
||||
// Register the viz:renderer:ready listener BEFORE setRenderer() so we
|
||||
// don't miss the event for sync renderers (no readyPromise), which emit
|
||||
// it immediately inside setRenderer(). The _onReady guard still checks
|
||||
// sel.value so a sync init failure (viz:reverted → sel.value='default')
|
||||
// that fires during setRenderer() is handled correctly — the listener
|
||||
// fires but finds sel.value !== 'auto' and skips the label update.
|
||||
if (hasSong) {
|
||||
const matchedOpt = Array.from(sel.options).find(o => o.value === id);
|
||||
const labelText = matchedOpt ? matchedOpt.text : id;
|
||||
function _onReady() { if (sel.value === 'auto') _setAutoVizLabel(labelText); }
|
||||
window.feedBack.on('viz:renderer:ready', _onReady, { once: true });
|
||||
_cancelPendingAutoLabel = () => window.feedBack.off('viz:renderer:ready', _onReady);
|
||||
}
|
||||
_installVizRenderer(renderer, id, 'auto-match');
|
||||
_noteVizAutoMatch(id, true);
|
||||
return;
|
||||
}
|
||||
// No match — restore the built-in 2D highway. setRenderer(null) is
|
||||
// a no-op when the default is already active. If the previous Auto
|
||||
// pick was a WebGL renderer, highway.setRenderer() handles the
|
||||
// context-type change by replacing the canvas element (cloneNode +
|
||||
// replaceWith) so the default 2D renderer's getContext('2d') always
|
||||
// succeeds — no canvas-lock limitation here.
|
||||
highway.setRenderer(null);
|
||||
_notifyVizDomain('default', 'auto-match');
|
||||
_noteVizAutoMatch('default', false);
|
||||
// Update the label so the user can see Auto resolved to the built-in
|
||||
// highway. Read from the DOM rather than hard-coding the name so a
|
||||
// future rename of the default entry is automatically reflected.
|
||||
if (hasSong) {
|
||||
const defaultOpt = Array.from(sel.options).find(o => o.value === 'default');
|
||||
// Notation-only arrangement falling through to the default renderer:
|
||||
// there are no wire notes, so the board would be silently empty.
|
||||
// Flag it in the Auto label and show the one-shot install hint.
|
||||
if (_maybeShowNotationViewHint('default')) {
|
||||
_setAutoVizLabel('no notation view installed');
|
||||
} else {
|
||||
_setAutoVizLabel(defaultOpt ? defaultOpt.text : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── viz:reverted ────────────────────────────────────────────────────────
|
||||
// Lifted out of a top-level listener block in app.js that it shared with the
|
||||
// non-viz song:loaded / arrangement:changed / song:ready handlers (those stay).
|
||||
//
|
||||
// It has to move WITH the state: it REASSIGNS `_cancelPendingAutoLabel`, and an
|
||||
// imported binding is read-only — `_cancelPendingAutoLabel = null` would throw if
|
||||
// this listener stayed behind in app.js. Same guard as the block it came from.
|
||||
if (window.feedBack && typeof window.feedBack.on === 'function') {
|
||||
// Highway signals when it's auto-reverted to the default renderer
|
||||
// after a broken plugin (init failure or repeated draw failures).
|
||||
// Sync the picker + persisted selection so the UI stops advertising
|
||||
// the broken choice and the user doesn't hit the same failure on
|
||||
// next reload.
|
||||
window.feedBack.on('viz:reverted', (e) => {
|
||||
const sel = document.getElementById('viz-picker');
|
||||
if (sel) sel.value = 'default';
|
||||
// Cancel any pending viz:renderer:ready label listener — the renderer
|
||||
// that was queued never became (or stayed) active.
|
||||
if (_cancelPendingAutoLabel) { _cancelPendingAutoLabel(); _cancelPendingAutoLabel = null; }
|
||||
// Clear any Auto-resolved label — the renderer that was advertised
|
||||
// never became (or stayed) active.
|
||||
_setAutoVizLabel(null);
|
||||
try { localStorage.setItem('vizSelection', 'default'); } catch (_) {}
|
||||
console.warn(
|
||||
`viz picker: reverted to default renderer (${e.detail?.reason || 'unknown'}).`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+3
-1
@@ -592,6 +592,8 @@
|
||||
sm.on('working-tuning-changed', () => renderInstrument());
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
else boot();
|
||||
})();
|
||||
|
||||
@@ -271,6 +271,8 @@
|
||||
sm.on('v3:profile-updated', () => render());
|
||||
}
|
||||
function boot() { render(); }
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
else boot();
|
||||
})();
|
||||
|
||||
@@ -273,7 +273,9 @@
|
||||
// the stage observer attaches.
|
||||
window.addEventListener('feedBack-minigames-ready', () => { ensureStageObserver(); refresh(); });
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
} else {
|
||||
boot();
|
||||
|
||||
+79
-62
@@ -1,14 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
fee[dB]ack v0.3.0 shell (FEEDBACK_UI=v3 / GET /v3).
|
||||
fee[dB]ack v0.3.0 shell — the app's only UI, served at `/` (and `/v3`, a
|
||||
back-compat alias). The classic v2 shell it was forked from is deleted.
|
||||
|
||||
This is a re-chromed copy of the legacy static/index.html: the v0.3.0
|
||||
sidebar + topbar replace the (hidden) legacy navbar, new #v3-* screens are
|
||||
added, and all the legacy screens (#home library, #favorites, #settings,
|
||||
#player, #audio, plugin nav containers) are kept verbatim so static/app.js
|
||||
boots UNMODIFIED and the whole engine — player/highway, plugin loader,
|
||||
capabilities, audio, library, settings — is reused as-is. Navigation is the
|
||||
shared window.showScreen across both #v3-* and legacy/#plugin-* screens.
|
||||
Originally a re-chromed copy of that shell: the v0.3.0 sidebar + topbar
|
||||
replace the (hidden) legacy navbar, new #v3-* screens are added, and all the
|
||||
legacy screens (#home library, #favorites, #settings, #player, #audio, plugin
|
||||
nav containers) are kept verbatim so static/app.js boots UNMODIFIED and the
|
||||
whole engine — player/highway, plugin loader, capabilities, audio, library,
|
||||
settings — is reused as-is. Navigation is the shared window.showScreen across
|
||||
both #v3-* and legacy/#plugin-* screens.
|
||||
See ~/Repositories/feedBack-feedback-v030/prompts/12-app-shell.md.
|
||||
-->
|
||||
<html lang="en" class="dark scroll-smooth">
|
||||
@@ -98,23 +99,39 @@
|
||||
<link rel="stylesheet" href="/static/tour-engine.css">
|
||||
<!-- v0.3.0 shell styles (radial-gradient bg, custom scrollbars). -->
|
||||
<link rel="stylesheet" href="/static/v3/v3.css">
|
||||
<!-- EVERY external script below is `defer`. Do not add a plain one.
|
||||
`defer` and `type="module"` scripts share a single "execute after
|
||||
parsing" list and run in DOCUMENT ORDER; a plain classic script runs
|
||||
DURING parse, ahead of all of them. So one plain tag would jump the
|
||||
queue — and once the capabilities become modules (they defer), a
|
||||
still-plain app.js would run BEFORE the bus exists and die on its
|
||||
top-level `window.feedBack.on(...)` calls. Keeping every tag deferred
|
||||
is what preserves this file's order through the ES-module migration.
|
||||
Enforced by test_every_external_script_defers_so_document_order_is_execution_order.
|
||||
|
||||
The scripts themselves boot on DOMContentLoaded, which fires only after
|
||||
all of the above have evaluated — that is what lets a script's boot()
|
||||
use a global another script defines further down this list (there are
|
||||
~43 such forward references). Their readyState guards therefore treat
|
||||
'interactive' as not-ready; see the note at each one. -->
|
||||
|
||||
<!-- Diagnostics console capture must wrap console.* before any other
|
||||
script logs anything; load it as early as possible. See
|
||||
docs/diagnostics-bundle-spec.md (feedBack#166). -->
|
||||
<script src="/static/diagnostics.js"></script>
|
||||
<script src="/static/capabilities.js"></script>
|
||||
<script src="/static/capabilities/library.js"></script>
|
||||
<script src="/static/capabilities/tuning.js"></script>
|
||||
<script src="/static/capabilities/working-tuning.js"></script>
|
||||
<script src="/static/capabilities/audio-session.js"></script>
|
||||
<script src="/static/capabilities/audio-effects.js"></script>
|
||||
<script src="/static/capabilities/playback.js"></script>
|
||||
<script defer src="/static/diagnostics.js"></script>
|
||||
<script type="module" src="/static/capabilities.js"></script>
|
||||
<script type="module" src="/static/capabilities/library.js"></script>
|
||||
<script type="module" src="/static/capabilities/tuning.js"></script>
|
||||
<script type="module" src="/static/capabilities/working-tuning.js"></script>
|
||||
<script type="module" src="/static/capabilities/audio-session.js"></script>
|
||||
<script type="module" src="/static/capabilities/audio-effects.js"></script>
|
||||
<script type="module" src="/static/capabilities/playback.js"></script>
|
||||
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
|
||||
<script src="/static/capabilities/library-card-actions.js"></script>
|
||||
<script src="/static/capabilities/visualization.js"></script>
|
||||
<script src="/static/capabilities/note-detection.js"></script>
|
||||
<script src="/static/capabilities/midi-input.js"></script>
|
||||
<script src="/static/capabilities/interface-scale.js"></script>
|
||||
<script type="module" src="/static/capabilities/library-card-actions.js"></script>
|
||||
<script type="module" src="/static/capabilities/visualization.js"></script>
|
||||
<script type="module" src="/static/capabilities/note-detection.js"></script>
|
||||
<script type="module" src="/static/capabilities/midi-input.js"></script>
|
||||
<script type="module" src="/static/capabilities/interface-scale.js"></script>
|
||||
</head>
|
||||
<body class="h-screen flex overflow-hidden bg-fb-sidebar text-fb-text font-display">
|
||||
|
||||
@@ -1224,64 +1241,64 @@
|
||||
</main>
|
||||
<!-- /#v3-main -->
|
||||
|
||||
<script src="/static/highway.js"></script>
|
||||
<script src="/static/vendor/lottie.min.js"></script>
|
||||
<script src="/static/lottie-api.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/audio-mixer.js"></script>
|
||||
<script src="/static/vendor/shepherd.min.js"></script>
|
||||
<script src="/static/tour-engine.js"></script>
|
||||
<script defer src="/static/highway.js"></script>
|
||||
<script defer src="/static/vendor/lottie.min.js"></script>
|
||||
<script defer src="/static/lottie-api.js"></script>
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
<script defer src="/static/audio-mixer.js"></script>
|
||||
<script defer src="/static/vendor/shepherd.min.js"></script>
|
||||
<script defer src="/static/tour-engine.js"></script>
|
||||
<!-- fee[dB]ack v0.3.0 shell: brand helper, then the shell (sidebar/topbar/
|
||||
routing). Loaded after app.js/audio-mixer so window.showScreen and
|
||||
window.feedBack(.audio) exist; dashboard.js is filled in prompt 13. -->
|
||||
<script src="/static/v3/brand.js"></script>
|
||||
<script src="/static/v3/shell.js"></script>
|
||||
<script defer src="/static/v3/brand.js"></script>
|
||||
<script defer src="/static/v3/shell.js"></script>
|
||||
<!-- Progression (spec 010): theme-core before profile.js so the equipped
|
||||
theme/avatar frame apply with the first badge render; progression-core
|
||||
registers the `progression` capability owner + window.v3Progression. -->
|
||||
<script src="/static/v3/theme-core.js"></script>
|
||||
<script src="/static/v3/progression-core.js"></script>
|
||||
<script src="/static/v3/notifications.js"></script>
|
||||
<script src="/static/v3/profile.js"></script>
|
||||
<script src="/static/v3/progress.js"></script>
|
||||
<script src="/static/v3/shop.js"></script>
|
||||
<script src="/static/v3/tuner-core.js"></script>
|
||||
<script src="/static/v3/badges.js"></script>
|
||||
<script src="/static/v3/stats-recorder.js"></script>
|
||||
<script src="/static/v3/live-performance-hud.js"></script>
|
||||
<script src="/static/v3/scoreboard-pref.js"></script>
|
||||
<script src="/static/v3/venue-viz.js"></script>
|
||||
<script src="/static/v3/venue-instrument-pov.js"></script>
|
||||
<script defer src="/static/v3/theme-core.js"></script>
|
||||
<script defer src="/static/v3/progression-core.js"></script>
|
||||
<script defer src="/static/v3/notifications.js"></script>
|
||||
<script defer src="/static/v3/profile.js"></script>
|
||||
<script defer src="/static/v3/progress.js"></script>
|
||||
<script defer src="/static/v3/shop.js"></script>
|
||||
<script defer src="/static/v3/tuner-core.js"></script>
|
||||
<script defer src="/static/v3/badges.js"></script>
|
||||
<script defer src="/static/v3/stats-recorder.js"></script>
|
||||
<script defer src="/static/v3/live-performance-hud.js"></script>
|
||||
<script defer src="/static/v3/scoreboard-pref.js"></script>
|
||||
<script defer src="/static/v3/venue-viz.js"></script>
|
||||
<script defer src="/static/v3/venue-instrument-pov.js"></script>
|
||||
<!-- venue-mood-fx must load before venue-scene-3d: the scene bridge reads
|
||||
window.v3VenueMoodFx.getMotion() synchronously at boot when the saved
|
||||
viz is 'venue'; loading it after falls back to 'subtle' and ignores a
|
||||
saved 'off'/'full' motion preference on first paint. -->
|
||||
<script src="/static/v3/venue-mood-fx.js"></script>
|
||||
<script src="/static/v3/venue-scene-3d.js"></script>
|
||||
<script src="/static/v3/playlists.js"></script>
|
||||
<script src="/static/v3/audio-routing.js"></script>
|
||||
<script src="/static/v3/live-guitar-tone-source.js"></script>
|
||||
<script src="/static/v3/pedal-cables.js"></script>
|
||||
<script src="/static/v3/plugins-page.js"></script>
|
||||
<script src="/static/v3/card-actions-core.js"></script>
|
||||
<script defer src="/static/v3/venue-mood-fx.js"></script>
|
||||
<script defer src="/static/v3/venue-scene-3d.js"></script>
|
||||
<script defer src="/static/v3/playlists.js"></script>
|
||||
<script defer src="/static/v3/audio-routing.js"></script>
|
||||
<script defer src="/static/v3/live-guitar-tone-source.js"></script>
|
||||
<script defer src="/static/v3/pedal-cables.js"></script>
|
||||
<script defer src="/static/v3/plugins-page.js"></script>
|
||||
<script defer src="/static/v3/card-actions-core.js"></script>
|
||||
<!-- Before songs.js: the songs toolbar calls the match-review chip hook
|
||||
on build, so the module must already be registered. -->
|
||||
<script src="/static/v3/match-review.js"></script>
|
||||
<script defer src="/static/v3/match-review.js"></script>
|
||||
<!-- Before songs.js: the drawer art click + card ⋮ "Change cover…" open
|
||||
the cover picker (window.__fbOpenImagePicker). -->
|
||||
<script src="/static/v3/image-picker.js"></script>
|
||||
<script src="/static/v3/songs.js"></script>
|
||||
<script src="/static/v3/lessons.js"></script>
|
||||
<script src="/static/v3/dashboard.js"></script>
|
||||
<script src="/static/v3/settings.js"></script>
|
||||
<script src="/static/v3/interface-size-ui.js"></script>
|
||||
<script defer src="/static/v3/image-picker.js"></script>
|
||||
<script defer src="/static/v3/songs.js"></script>
|
||||
<script defer src="/static/v3/lessons.js"></script>
|
||||
<script defer src="/static/v3/dashboard.js"></script>
|
||||
<script defer src="/static/v3/settings.js"></script>
|
||||
<script defer src="/static/v3/interface-size-ui.js"></script>
|
||||
<!-- First-run home tour: spotlights the home cards via the shared tour
|
||||
engine (tour-engine.js, loaded above). Auto-runs once after onboarding
|
||||
(triggered from profile.js finish()); replayable from the "?" menu. -->
|
||||
<script src="/static/v3/onboarding-tour.js"></script>
|
||||
<script src="/static/v3/interface-size-nudge.js"></script>
|
||||
<script src="/static/v3/feedbarcade.js"></script>
|
||||
<script src="/static/v3/player-chrome.js"></script>
|
||||
<script defer src="/static/v3/onboarding-tour.js"></script>
|
||||
<script defer src="/static/v3/interface-size-nudge.js"></script>
|
||||
<script defer src="/static/v3/feedbarcade.js"></script>
|
||||
<script defer src="/static/v3/player-chrome.js"></script>
|
||||
<script>
|
||||
// Navbar scroll effect
|
||||
window.addEventListener('scroll', () => {
|
||||
|
||||
@@ -71,7 +71,9 @@
|
||||
setTimeout(maybeNudge, 4000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
// Settings markup is static, but re-sync when settings.js signals it wired.
|
||||
document.addEventListener('v3:settings-rendered', function () { sync(); });
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', function () { sync(); }, { once: true });
|
||||
} else {
|
||||
sync();
|
||||
|
||||
@@ -94,7 +94,9 @@
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
|
||||
@@ -303,7 +303,9 @@
|
||||
const sm = root && root.feedBack;
|
||||
if (sm) bindRuntime(sm);
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
|
||||
@@ -913,7 +913,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
wireSettingsCard();
|
||||
wireScreenTeardown();
|
||||
|
||||
@@ -362,6 +362,8 @@
|
||||
syncActivation();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', init);
|
||||
else init();
|
||||
})();
|
||||
|
||||
@@ -440,6 +440,8 @@
|
||||
});
|
||||
}
|
||||
function boot() { renderPlaylists(); renderSaved(); }
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
else boot();
|
||||
})();
|
||||
|
||||
@@ -579,6 +579,8 @@
|
||||
}, { passive: true });
|
||||
|
||||
function boot() { render(); }
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
else boot();
|
||||
})();
|
||||
|
||||
@@ -792,7 +792,9 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
} else {
|
||||
boot();
|
||||
|
||||
@@ -320,7 +320,9 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
} else {
|
||||
boot();
|
||||
|
||||
@@ -241,7 +241,9 @@
|
||||
};
|
||||
|
||||
_registerOwner();
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', () => { refresh(); }, { once: true });
|
||||
} else {
|
||||
refresh();
|
||||
|
||||
@@ -200,7 +200,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
|
||||
+3
-1
@@ -399,7 +399,9 @@
|
||||
setTimeout(refreshHomeTitle, 700);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
} else {
|
||||
boot();
|
||||
|
||||
+3
-1
@@ -203,7 +203,9 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||||
} else {
|
||||
boot();
|
||||
|
||||
@@ -511,7 +511,9 @@
|
||||
const sm = root && root.feedBack;
|
||||
if (sm) bindRuntime(sm);
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
|
||||
@@ -242,7 +242,9 @@
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const boot = () => bindRuntime();
|
||||
if (document.readyState === 'loading') {
|
||||
// `defer` runs this at readyState 'interactive' — later scripts have not
|
||||
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
|
||||
if (document.readyState !== 'complete') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
|
||||
+3
-3
@@ -13,9 +13,9 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
'./static/**/*.{html,js}',
|
||||
// fee[dB]ack v0.3.0 shell + screens (additive, behind FEEDBACK_UI=v3).
|
||||
// Subsumed by the recursive ./static/** glob above, but listed
|
||||
// explicitly so the v3 tree's Tailwind coverage is obvious.
|
||||
// fee[dB]ack v0.3.0 shell + screens — the only UI since the classic v2
|
||||
// shell was removed. Subsumed by the recursive ./static/** glob above,
|
||||
// but listed explicitly so the v3 tree's Tailwind coverage is obvious.
|
||||
'./static/v3/**/*.{html,js}',
|
||||
// One recursive plugin glob subsumes the previous four narrow ones
|
||||
// (static/**, screen.js, settings.html, *.html) and additionally
|
||||
|
||||
@@ -9,6 +9,36 @@ import structlog
|
||||
_LOGGING_NAMES = ("feedBack", "uvicorn", "uvicorn.error", "uvicorn.access")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_enrichment_state():
|
||||
"""Reset the enrichment worker's process-global state between tests.
|
||||
|
||||
The `server` fixtures pop-and-reimport `server`, but `lib/enrichment.py`
|
||||
(which now owns the worker) stays imported for the whole session, so its
|
||||
module globals — the cancel Event, the status dict, the caches — would
|
||||
otherwise leak across tests. A test that set `_enrich_cancel` (or a stale
|
||||
`running` status) could silently short-circuit a later direct
|
||||
`_background_enrich()` call. Clear it up front so each test starts clean.
|
||||
"""
|
||||
try:
|
||||
import enrichment
|
||||
except ImportError:
|
||||
yield
|
||||
return
|
||||
enrichment._enrich_cancel.clear()
|
||||
enrichment._enrich_pending_pass = False
|
||||
enrichment._enrich_status.update(
|
||||
{"running": False, "processed": 0, "last_pass_at": None,
|
||||
"total": 0, "matched": 0, "current": None})
|
||||
enrichment._enrich_last_fetch = 0.0
|
||||
enrichment._artist_alias_cache.clear()
|
||||
# _caa_index_locks is deliberately left alone: it's guarded by
|
||||
# _caa_index_locks_guard, so clearing it here (unlocked) would race a
|
||||
# still-alive worker thread, and its entries are stateless per-release
|
||||
# mutexes that don't leak test state anyway.
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def isolate_logging():
|
||||
"""Restore feedBack / uvicorn logger state after each test.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Verify the alpha-build heads-up banner: markup is present in
|
||||
// static/index.html and `_updateAlphaWarningBanner(version)` in
|
||||
// static/v3/index.html and `_updateAlphaWarningBanner(version)` in
|
||||
// static/app.js toggles its visibility correctly per the version string.
|
||||
|
||||
const { test } = require('node:test');
|
||||
@@ -8,7 +8,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
test('index.html ships the alpha-warning banner inside the library section', () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ test('beats:loaded emit is wired into the WS beats case', () => {
|
||||
);
|
||||
assert.match(
|
||||
block,
|
||||
/count:\s*beats\.length/,
|
||||
/count:\s*hwState\.beats\.length/,
|
||||
'beats:loaded payload must include count = beats.length',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||
const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js');
|
||||
const MANIFEST = path.join(ROOT, 'plugins', 'capability_inspector', 'plugin.json');
|
||||
const SCREEN_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'screen.html');
|
||||
const SETTINGS_HTML = path.join(ROOT, 'plugins', 'capability_inspector', 'settings.html');
|
||||
@@ -28,7 +28,7 @@ test('capability inspector manifest ships settings but no default nav entry', ()
|
||||
});
|
||||
|
||||
test('capability inspector plugins menu entry is localStorage opt-in', () => {
|
||||
const src = source(APP_JS);
|
||||
const src = source(PLUGIN_LOADER_JS);
|
||||
const helper = region(src, "const CAPABILITY_INSPECTOR_NAV_SETTING = 'capability_inspector.showInPluginsMenu'", 1400);
|
||||
const menu = region(src, 'const navPlugins = plugins.map', 1000);
|
||||
const contributions = region(src, 'async function _registerLegacyPluginUiContributions(plugin)', 1400);
|
||||
@@ -106,7 +106,7 @@ test('capability inspector screen ships scoped graph lane CSS', () => {
|
||||
assert.match(html, /left: -1\.75rem/);
|
||||
});
|
||||
test('_navLabel resolves string, object, synthesized, and empty nav values', () => {
|
||||
const src = source(APP_JS);
|
||||
const src = source(PLUGIN_LOADER_JS);
|
||||
const m = src.match(/function _navLabel\(nav, plugin\) \{[\s\S]*?\n\}/);
|
||||
assert.ok(m, 'could not extract _navLabel from app.js');
|
||||
const _navLabel = new Function(`${m[0]}; return _navLabel;`)();
|
||||
@@ -123,7 +123,7 @@ test('_navLabel resolves string, object, synthesized, and empty nav values', ()
|
||||
});
|
||||
|
||||
test('plugin nav dropdown label uses the computed nav, not the raw plugin.nav', () => {
|
||||
const src = source(APP_JS);
|
||||
const src = source(PLUGIN_LOADER_JS);
|
||||
// Regression guard for the string/synthesized-nav label fix: the dropdown
|
||||
// label must derive from the loop's computed nav via _navLabel, not from
|
||||
// plugin.nav?.label (which drops string and synthesized labels).
|
||||
|
||||
@@ -33,7 +33,7 @@ test('handshapes WS case accumulates incoming chunks into handShapes', () => {
|
||||
const block = getCaseBlock(src, 'handshapes');
|
||||
assert.match(
|
||||
block,
|
||||
/handShapes\s*=\s*handShapes\.concat\(\s*msg\.data\s*\)/,
|
||||
/hwState\.handShapes\s*=\s*hwState\.handShapes\.concat\(\s*msg\.data\s*\)/,
|
||||
'handshapes case must concat msg.data into the handShapes accumulator',
|
||||
);
|
||||
});
|
||||
@@ -62,7 +62,7 @@ test('bundle exposes handShapes to renderers with flat-list fallback', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/\bhandShapes\s*[:=]\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/,
|
||||
/\bhandShapes\s*[:=]\s*\([^)]*hwState\._filteredHandShapes[^)]*\)\s*\?\s*hwState\._filteredHandShapes\s*:\s*hwState\.handShapes\b/,
|
||||
'bundle must expose handShapes with the _filteredHandShapes-vs-handShapes ternary fallback',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ function src(file) {
|
||||
test('highway renderer bundles surface the core lefty flag', () => {
|
||||
assert.match(
|
||||
src(HIGHWAY_JS),
|
||||
/lefty\s*[:=]\s*_lefty/,
|
||||
/lefty\s*[:=]\s*hwState\._lefty/,
|
||||
'custom renderer bundles must include lefty: _lefty',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,12 +49,12 @@ test('core _makeBundle exposes isPlaying derived from the chart-clock anchor', (
|
||||
// within the interp cap.
|
||||
assert.match(
|
||||
fn,
|
||||
/isPlaying\s*[:=]\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/,
|
||||
/isPlaying\s*[:=]\s*!Number\.isNaN\(\s*hwState\._chartAnchorPerfNow\s*\)/,
|
||||
'isPlaying must gate on a live anchor (_chartAnchorPerfNow not NaN)',
|
||||
);
|
||||
assert.match(
|
||||
fn,
|
||||
/_chartLastAdvanceAt\s*\)\s*<=\s*_CHART_MAX_INTERP_MS/,
|
||||
/hwState\._chartLastAdvanceAt\s*\)\s*<=\s*_CHART_MAX_INTERP_MS/,
|
||||
'isPlaying must require the clock advanced within _CHART_MAX_INTERP_MS',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ function extractBlock(src, signature) {
|
||||
|
||||
test('highway declares adaptive-scale state with a floor', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /let\s+_autoScale\s*=\s*1/, 'missing _autoScale multiplier');
|
||||
assert.match(src, /hwState\._autoScale\s*=\s*1/, 'missing _autoScale multiplier');
|
||||
assert.match(src, /const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget');
|
||||
@@ -49,18 +49,18 @@ test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () =
|
||||
test('min render scale floor is user-configurable + exposed on the api (#654)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
// Hard floor constant kept; configurable floor read from localStorage.
|
||||
assert.match(src, /let\s+_autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
|
||||
assert.match(src, /hwState\._autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
|
||||
assert.match(src, /localStorage\.getItem\('highwayMinRenderScale'\)/,
|
||||
'configurable floor must load from localStorage.highwayMinRenderScale');
|
||||
assert.match(src, /setMinRenderScale\(/, 'api.setMinRenderScale missing');
|
||||
assert.match(src, /getMinRenderScale\(\)\s*\{\s*return\s+_autoScaleMin/, 'api.getMinRenderScale missing');
|
||||
assert.match(src, /getMinRenderScale\(\)\s*\{\s*return\s+hwState\._autoScaleMin/, 'api.getMinRenderScale missing');
|
||||
// Floor is clamped to the user ceiling so it can never exceed the manual cap.
|
||||
const eff = extractBlock(src, 'function _effectiveRenderScale()');
|
||||
assert.match(eff, /Math\.min\(\s*_autoScaleMin\s*,\s*user\s*\)/,
|
||||
assert.match(eff, /Math\.min\(\s*hwState\._autoScaleMin\s*,\s*user\s*\)/,
|
||||
'effective scale must clamp the floor to the user ceiling');
|
||||
// _adaptRenderScale must cap the lo bound at 1 so _autoScale stays in [_,1].
|
||||
const adapt = extractBlock(src, 'function _adaptRenderScale(');
|
||||
assert.match(adapt, /Math\.min\(\s*1\s*,\s*_autoScaleMin\s*\/\s*_renderScale\s*\)/,
|
||||
assert.match(adapt, /Math\.min\(\s*1\s*,\s*hwState\._autoScaleMin\s*\/\s*hwState\._renderScale\s*\)/,
|
||||
'lo bound must be capped at 1 to keep _autoScale a [0,1] multiplier');
|
||||
});
|
||||
|
||||
|
||||
@@ -34,9 +34,9 @@ test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () =
|
||||
const neqEither = (a, b) => new RegExp(
|
||||
`\\b${a}\\b\\s*!==\\s*\\b${b}\\b|\\b${b}\\b\\s*!==\\s*\\b${a}\\b`
|
||||
);
|
||||
assert.match(src, eqEither('_chordRenderCacheSrc', 'src'), 'cache must key on src');
|
||||
assert.match(src, eqEither('_chordRenderCacheInverted', '_inverted'), 'cache must key on _inverted');
|
||||
assert.match(src, neqEither('_chordRenderCacheTemplates', 'chordTemplates'),
|
||||
assert.match(src, eqEither('hwState\\._chordRenderCacheSrc', 'src'), 'cache must key on src');
|
||||
assert.match(src, eqEither('hwState\\._chordRenderCacheInverted', 'hwState\\._inverted'), 'cache must key on _inverted');
|
||||
assert.match(src, neqEither('hwState\\._chordRenderCacheTemplates', 'hwState\\.chordTemplates'),
|
||||
'cache must key on chordTemplates (detected via !== for change-flag)');
|
||||
});
|
||||
|
||||
@@ -50,9 +50,9 @@ test('chordTemplates change resets fretline preview and frame-mismatch warner',
|
||||
// block inside the `if (templatesChanged) { … }` branch (e.g. an
|
||||
// inner conditional reset) doesn't break the match by introducing
|
||||
// a `}` before the symbol we're checking for.
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_chordFretLineNotes\s*=\s*\[\][\s\S]*?\}/,
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?hwState\._chordFretLineNotes\s*=\s*\[\][\s\S]*?\}/,
|
||||
'templatesChanged branch must reset _chordFretLineNotes');
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_lastChordOnFretLine\s*=\s*null[\s\S]*?\}/,
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?hwState\._lastChordOnFretLine\s*=\s*null[\s\S]*?\}/,
|
||||
'templatesChanged branch must null _lastChordOnFretLine');
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_frameMismatchWarned\.clear\(\)[\s\S]*?\}/,
|
||||
'templatesChanged branch must clear _frameMismatchWarned');
|
||||
|
||||
@@ -8,7 +8,9 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The highway string-colour manager was carved out of app.js into its own
|
||||
// module (R3a).
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js');
|
||||
|
||||
function extractBlock(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
|
||||
@@ -23,7 +23,7 @@ test('getFilteredNotes falls through to notes when _filteredNotes is null', () =
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes[^}]*:\s*notes/,
|
||||
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes[^}]*:\s*hwState\.notes/,
|
||||
'getFilteredNotes must return notes as fallback',
|
||||
);
|
||||
});
|
||||
@@ -41,7 +41,7 @@ test('getFilteredChords falls through to chords when _filteredChords is null', (
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords[^}]*:\s*chords/,
|
||||
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords[^}]*:\s*hwState\.chords/,
|
||||
'getFilteredChords must return chords as fallback',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -35,7 +35,10 @@ function extractBlock(src, signature) {
|
||||
// + getTime methods so behavioral tests can exercise the real
|
||||
// implementation in isolation.
|
||||
function buildClockSandbox(perfNowImpl) {
|
||||
const sandbox = {
|
||||
// The lifted per-instance state now lives on `hwState` (the R3c H lift);
|
||||
// the extracted setTime/getTime bodies reference hwState.<slot>. The const
|
||||
// _CHART_MAX_INTERP_MS was NOT lifted, so it stays a top-level global here.
|
||||
const hwState = {
|
||||
chartTime: 0,
|
||||
currentTime: 0,
|
||||
avOffsetSec: 0,
|
||||
@@ -50,6 +53,9 @@ function buildClockSandbox(perfNowImpl) {
|
||||
_chartAnchorPerfNow: NaN,
|
||||
_chartLastAdvanceAt: 0,
|
||||
_chartObservedRate: 1,
|
||||
};
|
||||
const sandbox = {
|
||||
hwState,
|
||||
_CHART_MAX_INTERP_MS: 100,
|
||||
performance: { now: perfNowImpl },
|
||||
};
|
||||
@@ -72,10 +78,10 @@ test('highway declares chart anchor + stall-detect + rate state', () => {
|
||||
// particular MUST start as NaN, not 0, otherwise setTime(0) on the
|
||||
// very first 60 Hz tick fails the `t !== _chartAnchorAudioT` check
|
||||
// and never re-anchors, leaving the clock uninitialized.
|
||||
assert.match(src, /let\s+_chartAnchorAudioT\s*=\s*NaN/, 'missing _chartAnchorAudioT (NaN sentinel)');
|
||||
assert.match(src, /let\s+_chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
|
||||
assert.match(src, /let\s+_chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
|
||||
assert.match(src, /let\s+_chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
|
||||
assert.match(src, /hwState\._chartAnchorAudioT\s*=\s*NaN/, 'missing _chartAnchorAudioT (NaN sentinel)');
|
||||
assert.match(src, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
|
||||
assert.match(src, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
|
||||
assert.match(src, /hwState\._chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
|
||||
assert.match(src, /const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
|
||||
});
|
||||
|
||||
@@ -86,7 +92,7 @@ test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', (
|
||||
const slice = m[0];
|
||||
assert.match(
|
||||
slice,
|
||||
/_chartObservedRate\s*\*\s*elapsedMs/,
|
||||
/hwState\._chartObservedRate\s*\*\s*elapsedMs/,
|
||||
'getTime must scale interpolation by observed rate so audio.playbackRate != 1 stays accurate',
|
||||
);
|
||||
});
|
||||
@@ -100,12 +106,12 @@ test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually ch
|
||||
// The implementation may capture performance.now() into a local
|
||||
// (e.g. newPerfNow) and assign that to both fields; accept either
|
||||
// direct or via-local writes.
|
||||
const m = src.match(/if\s*\(\s*t\s*!==\s*_chartAnchorAudioT\s*\)\s*\{[\s\S]+?\}\s*\},/);
|
||||
const m = src.match(/if\s*\(\s*t\s*!==\s*hwState\._chartAnchorAudioT\s*\)\s*\{[\s\S]+?\}\s*\},/);
|
||||
assert.ok(m, 'if (t !== _chartAnchorAudioT) block not found inside setTime');
|
||||
const block = m[0];
|
||||
assert.match(block, /_chartAnchorAudioT\s*=\s*t/, 'must assign _chartAnchorAudioT = t');
|
||||
assert.match(block, /_chartAnchorPerfNow\s*=/, 'must assign _chartAnchorPerfNow');
|
||||
assert.match(block, /_chartLastAdvanceAt\s*=/, 'must assign _chartLastAdvanceAt');
|
||||
assert.match(block, /hwState\._chartAnchorAudioT\s*=\s*t/, 'must assign _chartAnchorAudioT = t');
|
||||
assert.match(block, /hwState\._chartAnchorPerfNow\s*=/, 'must assign _chartAnchorPerfNow');
|
||||
assert.match(block, /hwState\._chartLastAdvanceAt\s*=/, 'must assign _chartLastAdvanceAt');
|
||||
});
|
||||
|
||||
test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
@@ -119,7 +125,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
// Must check stall-since-last-advance against the cap.
|
||||
assert.match(
|
||||
slice,
|
||||
/nowP\s*-\s*_chartLastAdvanceAt\s*>\s*_CHART_MAX_INTERP_MS/,
|
||||
/nowP\s*-\s*hwState\._chartLastAdvanceAt\s*>\s*_CHART_MAX_INTERP_MS/,
|
||||
'getTime must short-circuit when audio has stalled past the cap',
|
||||
);
|
||||
// Must interpolate when active.
|
||||
@@ -127,7 +133,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
// Rate-scaled formula: _chartAnchorAudioT + (_chartObservedRate * elapsedMs) / 1000
|
||||
assert.match(
|
||||
slice,
|
||||
/_chartAnchorAudioT\s*\+\s*\(\s*_chartObservedRate\s*\*\s*elapsedMs\s*\)\s*\/\s*1000/,
|
||||
/_chartAnchorAudioT\s*\+\s*\(\s*hwState\._chartObservedRate\s*\*\s*elapsedMs\s*\)\s*\/\s*1000/,
|
||||
'getTime must compute anchor + rate-scaled elapsed during play',
|
||||
);
|
||||
});
|
||||
@@ -138,10 +144,10 @@ test('api.stop() clears the chart anchor state so re-init starts fresh', () => {
|
||||
// the actual stop() body — a fixed-size slice would falsely match
|
||||
// resets that landed in an adjacent method.
|
||||
const stopBlock = extractBlock(src, 'stop() {');
|
||||
assert.match(stopBlock, /_chartAnchorAudioT\s*=\s*NaN/, 'stop() must reset _chartAnchorAudioT to the NaN sentinel');
|
||||
assert.match(stopBlock, /_chartAnchorPerfNow\s*=\s*NaN/, 'stop() must reset _chartAnchorPerfNow to the NaN sentinel');
|
||||
assert.match(stopBlock, /_chartLastAdvanceAt\s*=\s*0/, 'stop() must reset _chartLastAdvanceAt');
|
||||
assert.match(stopBlock, /_chartObservedRate\s*=\s*1/, 'stop() must reset _chartObservedRate to 1x');
|
||||
assert.match(stopBlock, /hwState\._chartAnchorAudioT\s*=\s*NaN/, 'stop() must reset _chartAnchorAudioT to the NaN sentinel');
|
||||
assert.match(stopBlock, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'stop() must reset _chartAnchorPerfNow to the NaN sentinel');
|
||||
assert.match(stopBlock, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'stop() must reset _chartLastAdvanceAt');
|
||||
assert.match(stopBlock, /hwState\._chartObservedRate\s*=\s*1/, 'stop() must reset _chartObservedRate to 1x');
|
||||
});
|
||||
|
||||
// ── Behavioral tests (run extracted setTime/getTime in vm sandbox) ──────
|
||||
@@ -195,12 +201,12 @@ test('behavior: seek discontinuity resets observed rate to 1x', () => {
|
||||
sb.setTime(10);
|
||||
now = 50;
|
||||
sb.setTime(10.025); // observed rate ≈ 0.5
|
||||
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, `prior segment must measure ≈0.5, got ${sb._chartObservedRate}`);
|
||||
assert.ok(Math.abs(sb.hwState._chartObservedRate - 0.5) < 0.001, `prior segment must measure ≈0.5, got ${sb.hwState._chartObservedRate}`);
|
||||
// Seek: large t jump in same perf delta — observed-rate clamp
|
||||
// rejects this segment, resets to 1.
|
||||
now = 70;
|
||||
sb.setTime(120); // dPerf=20ms, dT=110s → observed=5500 (out of clamp)
|
||||
assert.equal(sb._chartObservedRate, 1, 'seek must reset rate to 1x');
|
||||
assert.equal(sb.hwState._chartObservedRate, 1, 'seek must reset rate to 1x');
|
||||
});
|
||||
|
||||
test('behavior: getTime caps interpolation at _CHART_MAX_INTERP_MS', () => {
|
||||
@@ -225,8 +231,8 @@ test('behavior: setTime(0) on first tick anchors correctly (boot edge case)', ()
|
||||
const sb = buildClockSandbox(() => now);
|
||||
sb.setTime(0);
|
||||
// Anchor must now be initialized.
|
||||
assert.equal(sb._chartAnchorAudioT, 0, 'setTime(0) on first tick must set anchor.audioT');
|
||||
assert.equal(sb._chartAnchorPerfNow, 16, 'setTime(0) on first tick must set anchor.perfNow');
|
||||
assert.equal(sb.hwState._chartAnchorAudioT, 0, 'setTime(0) on first tick must set anchor.audioT');
|
||||
assert.equal(sb.hwState._chartAnchorPerfNow, 16, 'setTime(0) on first tick must set anchor.perfNow');
|
||||
// getTime should return a finite value, not NaN.
|
||||
const t = sb.getTime();
|
||||
assert.ok(!Number.isNaN(t), `getTime must not return NaN after setTime(0); got ${t}`);
|
||||
@@ -252,10 +258,10 @@ test('behavior: long anchor gap resets observed rate to 1x', () => {
|
||||
sb.setTime(10);
|
||||
now = 50;
|
||||
sb.setTime(10.025); // observed rate ≈ 0.5
|
||||
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, 'first segment measured 0.5x');
|
||||
assert.ok(Math.abs(sb.hwState._chartObservedRate - 0.5) < 0.001, 'first segment measured 0.5x');
|
||||
// Long gap (1 second) before next setTime — out of the dPerf < 0.5
|
||||
// window, so the rate must reset to 1.
|
||||
now = 1100;
|
||||
sb.setTime(10.5);
|
||||
assert.equal(sb._chartObservedRate, 1, 'long anchor gap must reset rate to 1x');
|
||||
assert.equal(sb.hwState._chartObservedRate, 1, 'long anchor gap must reset rate to 1x');
|
||||
});
|
||||
|
||||
@@ -34,16 +34,16 @@ function extractBlock(src, signature) {
|
||||
|
||||
test('highway declares the note-state provider slot', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /let\s+_noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
|
||||
assert.match(src, /hwState\._noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
|
||||
});
|
||||
|
||||
test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteState / isDefaultRenderer', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*_noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*hwState\._noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*typeof\s+fn\s*===\s*['"]function['"][^}]*:\s*null/, 'setNoteStateProvider must coerce non-functions (incl. null) to null');
|
||||
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider/, 'getNoteStateProvider must return the slot');
|
||||
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider/, 'getNoteStateProvider must return the slot');
|
||||
assert.match(src, /getNoteState\s*\(\s*note\s*,\s*chartTime\s*\)\s*\{\s*return\s+_noteState\s*\(/, 'getNoteState must delegate to _noteState');
|
||||
assert.match(src, /isDefaultRenderer\s*\(\s*\)\s*\{\s*return\s+_renderer\s*===\s*_defaultRenderer\s*\|\|\s*_renderer\s*==\s*null/, 'isDefaultRenderer must be (_renderer === _defaultRenderer || _renderer == null)');
|
||||
assert.match(src, /isDefaultRenderer\s*\(\s*\)\s*\{\s*return\s+hwState\._renderer\s*===\s*_defaultRenderer\s*\|\|\s*hwState\._renderer\s*==\s*null/, 'isDefaultRenderer must be (_renderer === _defaultRenderer || _renderer == null)');
|
||||
});
|
||||
|
||||
test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)', () => {
|
||||
@@ -72,7 +72,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
|
||||
// slot, so renderers see a live "is a provider registered?" view.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider\s*;?\s*\}/,
|
||||
/function\s+_getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider\s*;?\s*\}/,
|
||||
'_getNoteStateProvider must be defined as a stable named function returning _noteStateProvider'
|
||||
);
|
||||
});
|
||||
@@ -80,7 +80,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
|
||||
test('_noteState normalizes provider output as documented', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _noteState(note, chartTime)');
|
||||
assert.match(fn, /if\s*\(\s*!_noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
|
||||
assert.match(fn, /if\s*\(\s*!hwState\._noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
|
||||
assert.match(fn, /try\s*\{[\s\S]*_noteStateProvider\s*\([\s\S]*catch[\s\S]*return\s+null/, 'must call the provider inside try/catch and return null on throw');
|
||||
assert.match(fn, /state\s*!==\s*['"]hit['"]\s*&&\s*state\s*!==\s*['"]active['"]\s*&&\s*state\s*!==\s*['"]miss['"]/, 'must reject states other than hit/active/miss');
|
||||
assert.match(fn, /Math\.max\(\s*0\s*,\s*Math\.min\(\s*1\s*,\s*raw\.alpha\s*\)\s*\)/, 'must clamp alpha to [0,1]');
|
||||
|
||||
@@ -32,7 +32,7 @@ function extractBlock(src, signature) {
|
||||
test('highway declares the paused-render throttle state', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /const\s+_PAUSED_FRAME_INTERVAL_MS\s*=\s*\d+/, 'missing _PAUSED_FRAME_INTERVAL_MS cap');
|
||||
assert.match(src, /let\s+_lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
|
||||
assert.match(src, /hwState\._lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
|
||||
});
|
||||
|
||||
test('draw() throttles full renders while the audio clock is stalled', () => {
|
||||
@@ -42,7 +42,7 @@ test('draw() throttles full renders while the audio clock is stalled', () => {
|
||||
assert.match(fn, /_chartLastAdvanceAt/, 'throttle must key off _chartLastAdvanceAt (the advance timestamp)');
|
||||
assert.match(fn, /_CHART_MAX_INTERP_MS/, 'throttle must reuse the _CHART_MAX_INTERP_MS pause threshold');
|
||||
assert.match(fn, /_PAUSED_FRAME_INTERVAL_MS/, 'throttle must cap paused draws to _PAUSED_FRAME_INTERVAL_MS');
|
||||
assert.match(fn, /_lastPausedDrawAt\s*=\s*_nowP/, 'throttle must record the last paused draw time');
|
||||
assert.match(fn, /hwState\._lastPausedDrawAt\s*=\s*_nowP/, 'throttle must record the last paused draw time');
|
||||
});
|
||||
|
||||
test('throttle runs after the ready gate, before bundle/draw', () => {
|
||||
@@ -51,7 +51,7 @@ test('throttle runs after the ready gate, before bundle/draw', () => {
|
||||
// Regex landmarks (not exact-string indexOf) so harmless spacing /
|
||||
// semicolon changes don't break the ordering guard — matches the
|
||||
// search-based style of the other highway source-guard tests.
|
||||
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return;/);
|
||||
const readyIdx = fn.search(/if\s*\(\s*!hwState\.ready\s*\)\s*return;/);
|
||||
const throttleIdx = fn.search(/_PAUSED_FRAME_INTERVAL_MS/);
|
||||
const drawIdx = fn.search(/_renderer\.draw\s*\(/);
|
||||
assert.ok(readyIdx !== -1, 'ready gate not found');
|
||||
|
||||
@@ -22,7 +22,7 @@ test('getPhrases returns null when _phrases is falsy or empty', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getPhrases\s*\(\s*\)\s*\{[^}]*!\s*_phrases[^}]*return null/,
|
||||
/getPhrases\s*\(\s*\)\s*\{[^}]*!\s*hwState\._phrases[^}]*return null/,
|
||||
'getPhrases must return null when no phrase data is available',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,9 +44,9 @@ test('_setRenderer captures the outgoing renderer before overwriting it', () =>
|
||||
// prev must be captured BEFORE _destroyCurrentIfInited and the
|
||||
// `_renderer = next` assignment, otherwise the swap detection below
|
||||
// would always compare next against itself.
|
||||
const prevIdx = fn.search(/const\s+prev\s*=\s*_renderer/);
|
||||
const prevIdx = fn.search(/const\s+prev\s*=\s*hwState\._renderer/);
|
||||
const destroyIdx = fn.search(/_destroyCurrentIfInited\(\)/);
|
||||
const assignIdx = fn.search(/^\s*_renderer\s*=\s*next\s*;/m);
|
||||
const assignIdx = fn.search(/^\s*hwState\._renderer\s*=\s*next\s*;/m);
|
||||
assert.ok(prevIdx !== -1, 'must capture `const prev = _renderer`');
|
||||
assert.ok(destroyIdx !== -1, 'must call _destroyCurrentIfInited');
|
||||
assert.ok(assignIdx !== -1, 'must assign `_renderer = next`');
|
||||
@@ -67,7 +67,7 @@ test('_setRenderer replaces the canvas on a context-type change OR a viz change'
|
||||
);
|
||||
assert.match(
|
||||
fn,
|
||||
/if\s*\(\s*nextType\s*!==\s*_currentCanvasContextType\s*\|\|\s*_vizChanged\s*\)\s*\{\s*_replaceCanvas\(nextType\)/,
|
||||
/if\s*\(\s*nextType\s*!==\s*hwState\._currentCanvasContextType\s*\|\|\s*_vizChanged\s*\)\s*\{\s*_replaceCanvas\(nextType\)/,
|
||||
'replace guard must be `nextType !== _currentCanvasContextType || _vizChanged`',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,9 @@ const path = require('node:path');
|
||||
|
||||
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The highway string-colour manager was carved out of app.js into its own
|
||||
// module (R3a).
|
||||
const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js');
|
||||
|
||||
// Brace-balanced extraction (same helper shape as highway_note_state.test.js).
|
||||
function extractBlock(src, signature) {
|
||||
@@ -40,20 +42,20 @@ test('2D palette arrays are mutable (let) with frozen DEFAULT_* originals', () =
|
||||
assert.match(src, /const\s+DEFAULT_STRING_COLORS\s*=/, 'DEFAULT_STRING_COLORS must exist for reset');
|
||||
assert.match(src, /const\s+DEFAULT_STRING_DIM\s*=/, 'DEFAULT_STRING_DIM must exist for reset');
|
||||
assert.match(src, /const\s+DEFAULT_STRING_BRIGHT\s*=/, 'DEFAULT_STRING_BRIGHT must exist for reset');
|
||||
assert.match(src, /let\s+STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults');
|
||||
assert.match(src, /let\s+STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults');
|
||||
assert.match(src, /let\s+STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults');
|
||||
assert.match(src, /hwState\.STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults');
|
||||
assert.match(src, /hwState\.STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults');
|
||||
assert.match(src, /hwState\.STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults');
|
||||
});
|
||||
|
||||
test('2D public API exposes getStringColors / setStringColors', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy');
|
||||
assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+hwState\.STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy');
|
||||
const fn = extractBlock(src, 'setStringColors(arr)');
|
||||
// Each provided index sets base + derived dim/bright; missing → default.
|
||||
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*base/, 'setStringColors must set the base color');
|
||||
assert.match(fn, /STRING_DIM\[i\]\s*=\s*_darken\(/, 'setStringColors must derive the dim variant');
|
||||
assert.match(fn, /STRING_BRIGHT\[i\]\s*=\s*_lighten\(/, 'setStringColors must derive the bright variant');
|
||||
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*DEFAULT_STRING_COLORS\[i\]/, 'setStringColors must restore defaults for missing/invalid indices');
|
||||
assert.match(fn, /hwState\.STRING_COLORS\[i\]\s*=\s*base/, 'setStringColors must set the base color');
|
||||
assert.match(fn, /hwState\.STRING_DIM\[i\]\s*=\s*_darken\(/, 'setStringColors must derive the dim variant');
|
||||
assert.match(fn, /hwState\.STRING_BRIGHT\[i\]\s*=\s*_lighten\(/, 'setStringColors must derive the bright variant');
|
||||
assert.match(fn, /hwState\.STRING_COLORS\[i\]\s*=\s*DEFAULT_STRING_COLORS\[i\]/, 'setStringColors must restore defaults for missing/invalid indices');
|
||||
});
|
||||
|
||||
// ── 3D highway (plugins/highway_3d/screen.js) ─────────────────────────────
|
||||
@@ -86,7 +88,7 @@ test('3D gem-body gradients follow the active palette (not hardcoded)', () => {
|
||||
assert.match(apply, /_recolorGemGradients\(\)/, '_applyPaletteToMaterials must recolor gems on palette change');
|
||||
});
|
||||
|
||||
// ── Core color manager (static/app.js) ────────────────────────────────────
|
||||
// ── Core color manager (static/js/highway-colors.js) ──────────────────────
|
||||
|
||||
test('app.js color manager name-maps to both highways, with identity no-op + builtin guard', () => {
|
||||
const src = fs.readFileSync(appJs, 'utf8');
|
||||
|
||||
@@ -32,14 +32,14 @@ function extractBlock(src, signature) {
|
||||
|
||||
test('highway declares visibility state (_visibleOverride + _lastVisible)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /let\s+_visibleOverride\s*=\s*null/, 'missing _visibleOverride (override sentinel)');
|
||||
assert.match(src, /let\s+_lastVisible\s*=\s*null/, 'missing _lastVisible (last-emitted state)');
|
||||
assert.match(src, /hwState\._visibleOverride\s*=\s*null/, 'missing _visibleOverride (override sentinel)');
|
||||
assert.match(src, /hwState\._lastVisible\s*=\s*null/, 'missing _lastVisible (last-emitted state)');
|
||||
});
|
||||
|
||||
test('_isHighwayVisible respects _visibleOverride and falls back to offsetParent', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _isHighwayVisible()');
|
||||
assert.match(fn, /_visibleOverride\s*!==\s*null/, 'must check the override before the DOM');
|
||||
assert.match(fn, /hwState\._visibleOverride\s*!==\s*null/, 'must check the override before the DOM');
|
||||
assert.match(fn, /canvas\.offsetParent\s*!==\s*null/, 'DOM fallback must use offsetParent !== null');
|
||||
});
|
||||
|
||||
@@ -47,9 +47,9 @@ test('_emitVisibilityIfChanged is transition-only (no per-frame spam)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _emitVisibilityIfChanged()');
|
||||
// Must short-circuit when the current state equals the cached one.
|
||||
assert.match(fn, /v\s*===\s*_lastVisible/, 'must compare current vs _lastVisible and bail when equal');
|
||||
assert.match(fn, /v\s*===\s*hwState\._lastVisible/, 'must compare current vs _lastVisible and bail when equal');
|
||||
// Must update the cache and emit the event with the documented payload shape.
|
||||
assert.match(fn, /_lastVisible\s*=\s*v/, 'must update _lastVisible after a transition');
|
||||
assert.match(fn, /hwState\._lastVisible\s*=\s*v/, 'must update _lastVisible after a transition');
|
||||
assert.match(
|
||||
fn,
|
||||
/window\.feedBack\.emit\(\s*['"]highway:visibility['"][\s\S]*?visible:\s*v[\s\S]*?canvas/,
|
||||
@@ -66,7 +66,7 @@ test('rAF draw() loop calls _emitVisibilityIfChanged and skips when hidden', ()
|
||||
// transitions during loading/reconnect windows still propagate.
|
||||
const emitIdx = fn.search(/_emitVisibilityIfChanged\(\)/);
|
||||
const skipIdx = fn.search(/if\s*\(\s*!_rendering\s*\)\s*return/);
|
||||
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return/);
|
||||
const readyIdx = fn.search(/if\s*\(\s*!hwState\.ready\s*\)\s*return/);
|
||||
const drawIdx = fn.search(/_renderer\.draw\(/);
|
||||
assert.ok(emitIdx !== -1 && skipIdx !== -1 && readyIdx !== -1 && drawIdx !== -1, 'all four landmarks must be present');
|
||||
assert.ok(emitIdx < readyIdx, 'emit must run BEFORE the !ready gate (transitions during loading must still fire)');
|
||||
@@ -84,19 +84,19 @@ test('draw() keeps an active custom renderer painting through an override-hide (
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
// Single render decision drives both the perf-HUD reset and the gate.
|
||||
assert.match(fn, /let\s+_rendering\s*=\s*_lastVisible/, 'must derive a single _rendering decision from _lastVisible');
|
||||
assert.match(fn, /let\s+_rendering\s*=\s*hwState\._lastVisible/, 'must derive a single _rendering decision from _lastVisible');
|
||||
// Assert the exact boolean RELATIONSHIP, not just the tokens (CodeRabbit):
|
||||
// the exemption must AND together override-hide, an active custom renderer,
|
||||
// and the canvas still in layout. A weakened guard (e.g. `||`, or a dropped
|
||||
// offsetParent clause) must fail this — that's the regression being fixed.
|
||||
assert.match(
|
||||
fn,
|
||||
/!_rendering\s*&&\s*_visibleOverride\s*===\s*false\s*&&\s*_renderer\s*!==\s*_defaultRenderer\s*&&\s*canvas\s*&&\s*canvas\.offsetParent\s*!==\s*null/,
|
||||
/!_rendering\s*&&\s*hwState\._visibleOverride\s*===\s*false\s*&&\s*hwState\._renderer\s*!==\s*_defaultRenderer\s*&&\s*hwState\.canvas\s*&&\s*hwState\.canvas\.offsetParent\s*!==\s*null/,
|
||||
'exemption must AND override-hide + active custom renderer + canvas-in-layout (genuine off-screen still pauses, #246)',
|
||||
);
|
||||
// Both the HUD reset and the gate key off _rendering, not _lastVisible,
|
||||
// so the HUD doesn\'t churn while the custom renderer is actually drawing.
|
||||
assert.match(fn, /_perfHud\s*&&\s*\(\s*!_rendering/, 'perf-HUD reset must key off _rendering, not _lastVisible');
|
||||
assert.match(fn, /hwState\._perfHud\s*&&\s*\(\s*!_rendering/, 'perf-HUD reset must key off _rendering, not _lastVisible');
|
||||
assert.match(fn, /if\s*\(\s*!_rendering\s*\)\s*return/, 'the draw gate must bail on !_rendering');
|
||||
});
|
||||
|
||||
|
||||
@@ -71,6 +71,11 @@ test('native audio-mix participant suppresses matching legacy fader and records
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||
// The plugin loader was carved out of app.js into its own module (R3a); the
|
||||
// library-provider code below still lives in app.js.
|
||||
const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js');
|
||||
// The viz layer was carved out of app.js too (R3a).
|
||||
const VIZ_JS = path.join(ROOT, 'static', 'js', 'viz.js');
|
||||
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||
|
||||
function source(file) {
|
||||
@@ -87,7 +92,7 @@ function region(src, needle, length = 1200) {
|
||||
}
|
||||
|
||||
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
|
||||
const src = source(APP_JS);
|
||||
const src = source(PLUGIN_LOADER_JS);
|
||||
const block = region(src, 'script.src = `/api/plugins/${plugin.id}/screen.js');
|
||||
assert.match(block, /window\.feedBack\._loadingPluginId\s*=\s*plugin\.id/);
|
||||
assert.match(block, /delete\s+window\.feedBack\._loadingPluginId/);
|
||||
@@ -113,7 +118,7 @@ test('library providers route through native library capability', () => {
|
||||
});
|
||||
|
||||
test('visualization renderer installs preserve plugin attribution', () => {
|
||||
const src = source(APP_JS);
|
||||
const src = source(VIZ_JS);
|
||||
const tagger = region(src, 'function _tagVizRenderer(renderer, id)', 700);
|
||||
const setViz = region(src, 'function setViz(id)', 3600);
|
||||
const autoViz = region(src, 'function _autoMatchViz()', 5200);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
|
||||
// Verify loadPlugins' plugin-DOM wipe loops in static/js/plugin-loader.js: a plugin that is
|
||||
// merely ABSENT from the current /api/plugins response (transient partial
|
||||
// response while the backend's plugin registry is repopulating after a
|
||||
// restart) must keep its settings panel and screen DOM. Wiping it while its
|
||||
@@ -14,7 +14,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||
|
||||
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
|
||||
// nav reset that opens it to the comment introducing the next section.
|
||||
@@ -40,7 +40,7 @@ function makeEl(pluginId, id) {
|
||||
}
|
||||
|
||||
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||
const block = extractWipeBlock(src);
|
||||
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
|
||||
const container = { children: settingsChildren };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Guards the R0 module-migration loader change in static/app.js: a migrated
|
||||
// Guards the R0 module-migration loader change in static/js/plugin-loader.js: a migrated
|
||||
// plugin (manifest scriptType:"module", surfaced as plugin.script_type) must be
|
||||
// injected as <script type="module"> so its screen.js `import './src/main.js'`
|
||||
// graph loads, while classic plugins stay untouched.
|
||||
@@ -16,8 +16,8 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||
|
||||
// Isolate the screen.js <script> injection block: from where its src is built
|
||||
// to where the element is appended.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Verify the plugin `styles` capability in static/app.js: _injectPluginStyles
|
||||
// Verify the plugin `styles` capability in static/js/plugin-loader.js: _injectPluginStyles
|
||||
// adds exactly one versioned <link rel="stylesheet"> per plugin, swaps it on a
|
||||
// version upgrade (no duplicates, no stale tags), injects nothing for a plugin
|
||||
// without `styles`, and routes the URL through the sandboxed asset endpoint.
|
||||
@@ -9,7 +9,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||
|
||||
// Brace-balanced extraction of a `const NAME = (...) => { ... }` arrow, so a
|
||||
// nested object/template literal can't make a naive regex stop early.
|
||||
@@ -84,7 +84,7 @@ function setupSandbox() {
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||
const removeSrc = extractConstArrow(src, '_removePluginStyleTags');
|
||||
const injectSrc = extractConstArrow(src, '_injectPluginStyles');
|
||||
const reconcileSrc = extractConstArrow(src, '_reconcilePluginStyles');
|
||||
|
||||
@@ -50,17 +50,42 @@ function makeFakeContext(sampleRate = 48000) {
|
||||
this.mediaSourceEl = el;
|
||||
return { connect() {}, disconnect() {} };
|
||||
},
|
||||
createMediaStreamSource(stream) {
|
||||
this.mediaStreamSource = stream;
|
||||
return { connect() {}, disconnect() {} };
|
||||
},
|
||||
close() { this.closed = true; return Promise.resolve(); },
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
|
||||
const calls = { setRendererBus: [], pushRendererAudio: [] };
|
||||
// Fake getDisplayMedia stream for the loopback-capture path.
|
||||
function makeLoopbackStream({ suppressed = true } = {}) {
|
||||
const stopped = [];
|
||||
const audioTrack = {
|
||||
kind: 'audio',
|
||||
stop() { stopped.push('audio'); },
|
||||
getSettings: () => (suppressed ? { suppressLocalAudioPlayback: true } : {}),
|
||||
};
|
||||
const videoTrack = { kind: 'video', stop() { stopped.push('video'); } };
|
||||
return {
|
||||
__stopped: stopped,
|
||||
getAudioTracks: () => [audioTrack],
|
||||
getVideoTracks: () => [videoTrack],
|
||||
getTracks: () => [videoTrack, audioTrack],
|
||||
};
|
||||
}
|
||||
|
||||
// `displayMedia`: undefined → loopback capture unavailable (Docker sphere /
|
||||
// old desktop main); a function → used as navigator.mediaDevices.getDisplayMedia.
|
||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true, displayMedia } = {}) {
|
||||
const calls = { setRendererBus: [], pushRendererAudio: [], setPageMuted: [] };
|
||||
|
||||
const api = {
|
||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
|
||||
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
|
||||
setPageMuted: (m) => { calls.setPageMuted.push(m); return Promise.resolve(m); },
|
||||
};
|
||||
|
||||
class FakeWorkletNode {
|
||||
@@ -85,6 +110,7 @@ function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {
|
||||
__createdContexts: [],
|
||||
__audioEl: { id: 'audio' },
|
||||
__calls: calls,
|
||||
navigator: { mediaDevices: displayMedia ? { getDisplayMedia: displayMedia } : {} },
|
||||
window: null,
|
||||
};
|
||||
sandbox.window = {
|
||||
@@ -111,12 +137,21 @@ function makeStemsGraph() {
|
||||
};
|
||||
}
|
||||
|
||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
|
||||
// Surface-mode (stems/element) tests run WITHOUT getDisplayMedia: the first
|
||||
// tick probes loopback, fails, and latches _loopbackUnavailable; the second
|
||||
// tick exercises the fallback surface mode. This mirrors an old desktop main
|
||||
// without the display-media handler.
|
||||
async function reevaluateWithFallback(sb) {
|
||||
await sb.window._reevaluateRendererBus(); // loopback probe → unavailable
|
||||
await sb.window._reevaluateRendererBus(); // surface fallback
|
||||
}
|
||||
|
||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked (loopback unavailable)', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
|
||||
@@ -128,7 +163,7 @@ test('output returns to shared → bus disabled, sink restored', async () => {
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
@@ -145,26 +180,27 @@ test('stems graph + shared output → feeder stays off (no double audio)', async
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
|
||||
});
|
||||
|
||||
test('element song + exclusive → element captured into bus', async () => {
|
||||
test('element song + exclusive → element captured into bus (loopback unavailable)', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||
sb.window._juceMode = false;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
|
||||
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
});
|
||||
|
||||
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
|
||||
test('native-transport song, loopback unavailable → surface modes stay off', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
|
||||
sb.window._juceMode = true;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
|
||||
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true),
|
||||
'bus never ENABLED (failed-probe cleanup may disable it)');
|
||||
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
|
||||
});
|
||||
|
||||
@@ -172,7 +208,7 @@ test('stems graph replaced mid-engagement → re-engages on the new graph', asyn
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const g1 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g1;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
const g2 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g2;
|
||||
@@ -182,6 +218,105 @@ test('stems graph replaced mid-engagement → re-engages on the new graph', asyn
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
|
||||
});
|
||||
|
||||
// ── Loopback mode (whole-app capture) ────────────────────────────────────────
|
||||
|
||||
test('exclusive output + loopback available → engages without any song loaded', async () => {
|
||||
const stream = makeLoopbackStream();
|
||||
const sb = makeSandbox({ exclusive: () => true, displayMedia: () => Promise.resolve(stream) });
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled for whole session');
|
||||
assert.ok(stream.__stopped.includes('video'), 'unused video track stopped');
|
||||
assert.equal(sb.__createdContexts.at(-1)?.mediaStreamSource, stream, 'loopback stream captured');
|
||||
assert.equal(sb.__calls.setPageMuted.length, 0, 'suppress constraint honoured — no page mute');
|
||||
});
|
||||
|
||||
test('loopback context is closed on disengage (no orphaned tap worklet)', async () => {
|
||||
let excl = true;
|
||||
const stream = makeLoopbackStream();
|
||||
const sb = makeSandbox({ exclusive: () => excl, displayMedia: () => Promise.resolve(stream) });
|
||||
|
||||
await sb.window._reevaluateRendererBus(); // engage loopback
|
||||
const lbCtx = sb.__createdContexts.at(-1);
|
||||
assert.equal(lbCtx?.mediaStreamSource, stream, 'loopback engaged');
|
||||
assert.notEqual(lbCtx.closed, true, 'context live while engaged');
|
||||
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus(); // disengage
|
||||
assert.equal(lbCtx.closed, true, 'loopback context closed on disengage');
|
||||
assert.ok(stream.__stopped.includes('audio'), 'capture stream stopped');
|
||||
});
|
||||
|
||||
test('loopback preferred over stems when both available', async () => {
|
||||
const stream = makeLoopbackStream();
|
||||
const sb = makeSandbox({ exclusive: () => true, displayMedia: () => Promise.resolve(stream) });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(graph.context.sinkIdCalls.length, 0, 'stems ctx untouched — loopback owns capture');
|
||||
assert.equal(sb.__createdContexts.at(-1)?.mediaStreamSource, stream, 'loopback engaged');
|
||||
});
|
||||
|
||||
test('suppressLocalAudioPlayback unsupported → page-mute fallback, unmuted on disengage', async () => {
|
||||
let excl = true;
|
||||
const stream = makeLoopbackStream({ suppressed: false });
|
||||
const sb = makeSandbox({ exclusive: () => excl, displayMedia: () => Promise.resolve(stream) });
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
assert.deepEqual(sb.__calls.setPageMuted, [true], 'page muted as fallback');
|
||||
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
assert.deepEqual(sb.__calls.setPageMuted, [true, false], 'page unmuted on disengage');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled');
|
||||
});
|
||||
|
||||
test('getDisplayMedia rejected → sticky fallback to surface modes', async () => {
|
||||
const sb = makeSandbox({
|
||||
exclusive: () => true,
|
||||
displayMedia: () => Promise.reject(new DOMException('denied', 'NotAllowedError')),
|
||||
});
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus(); // probe fails, latches unavailable
|
||||
await sb.window._reevaluateRendererBus(); // falls back to stems
|
||||
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems fallback engaged');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled via fallback');
|
||||
});
|
||||
|
||||
test('element capture collision (createMediaElementSource throws) → no poisoned state, clean retry', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true }); // loopback unavailable
|
||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||
// First capture attempt collides (highway analyser owns the element).
|
||||
let collide = true;
|
||||
const origFactory = sb.AudioContext;
|
||||
sb.__createdContexts.length = 0;
|
||||
// Patch contexts so createMediaElementSource throws while colliding.
|
||||
sb.AudioContext = function () {
|
||||
const c = origFactory();
|
||||
const orig = c.createMediaElementSource.bind(c);
|
||||
c.createMediaElementSource = (el) => {
|
||||
if (collide) throw new DOMException('already connected', 'InvalidStateError');
|
||||
return orig(el);
|
||||
};
|
||||
c.close = () => Promise.resolve();
|
||||
return c;
|
||||
};
|
||||
|
||||
await reevaluateWithFallback(sb); // element engage fails (collision)
|
||||
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true), 'bus never left enabled');
|
||||
|
||||
collide = false;
|
||||
await sb.window._reevaluateRendererBus(); // retry succeeds — no TypeError, fresh ctx
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'element engaged after collision cleared');
|
||||
});
|
||||
|
||||
test('engine stops → bus disabled', async () => {
|
||||
let running = true;
|
||||
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
|
||||
|
||||
@@ -14,7 +14,6 @@ const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const V2_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
|
||||
|
||||
function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
|
||||
const sandbox = {
|
||||
@@ -138,10 +137,3 @@ test('V3 transport restart button exists with correct attributes', () => {
|
||||
assert.match(html, /title="Restart song"/);
|
||||
assert.match(html, /aria-label="Restart song"/);
|
||||
});
|
||||
|
||||
test('V2 transport restart button exists with correct attributes', () => {
|
||||
const html = fs.readFileSync(V2_HTML, 'utf8');
|
||||
assert.match(html, /#player-controls|player-controls[\s\S]*onclick="restartCurrentSong\(\)"/);
|
||||
assert.match(html, /title="Restart song"/);
|
||||
assert.match(html, /aria-label="Restart song"/);
|
||||
});
|
||||
|
||||
@@ -7,20 +7,23 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a);
|
||||
// the autoplay-gate test below still reads app.js.
|
||||
const TUNING_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||
const TUNING_UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'tuning-utils.js');
|
||||
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function isBassArrangement(');
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
|
||||
const src = fs.readFileSync(TUNING_JS, 'utf8');
|
||||
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||
const body = src.replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length),
|
||||
body,
|
||||
sandbox
|
||||
);
|
||||
return sandbox.window.feedBack;
|
||||
|
||||
@@ -6,10 +6,10 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const V2_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
|
||||
|
||||
function extractBlock(src, startMarker) {
|
||||
const start = src.indexOf(startMarker);
|
||||
@@ -97,11 +97,6 @@ test('V3 index.html defines hud-tuning', () => {
|
||||
assert.match(html, /id="hud-tuning"/);
|
||||
});
|
||||
|
||||
test('V2 index.html defines hud-tuning', () => {
|
||||
const html = fs.readFileSync(V2_HTML, 'utf8');
|
||||
assert.match(html, /id="hud-tuning"/);
|
||||
});
|
||||
|
||||
test('highway.js updates hud-tuning from song_info tuning offsets', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
assert.match(src, /getElementById\('hud-tuning'\)/);
|
||||
|
||||
@@ -6,23 +6,23 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
|
||||
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const V2_HTML = path.join(__dirname, '..', '..', 'static', 'index.html');
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function isBassArrangement(');
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
|
||||
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||
const body = src.replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length) + '\n'
|
||||
body + '\n'
|
||||
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
||||
+ 'exports.displayTuningTargetDetails = displayTuningTargetDetails;\n'
|
||||
+ 'exports.isBassArrangement = isBassArrangement;\n'
|
||||
|
||||
@@ -7,7 +7,8 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The tuning-display helpers were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
|
||||
function extractBlock(src, startMarker) {
|
||||
const start = src.indexOf(startMarker);
|
||||
@@ -27,14 +28,14 @@ function extractBlock(src, startMarker) {
|
||||
|
||||
function loadTuningHelpers() {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const start = src.indexOf('function _looksLikeRawTuningOffsets(');
|
||||
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
|
||||
const end = src.indexOf(endMarker);
|
||||
if (start === -1 || end === -1) throw new Error('tuning helpers not found');
|
||||
// The module is nothing BUT the tuning helpers now, so there is no block to
|
||||
// slice out — take it whole. `export` is stripped so the vm sandbox can still
|
||||
// evaluate it as a plain script (the window.* contract lives in app.js).
|
||||
const body = src.replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
src.slice(start, end + endMarker.length) + '\n'
|
||||
body + '\n'
|
||||
+ 'exports.displayTuningName = displayTuningName;\n'
|
||||
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
|
||||
+ 'exports.parseRawTuningOffsets = parseRawTuningOffsets;',
|
||||
|
||||
@@ -9,6 +9,9 @@ const venueScene = require('../../static/v3/venue-scene-3d.js');
|
||||
const venueViz = require('../../static/v3/venue-viz.js');
|
||||
const pov = require('../../static/v3/venue-instrument-pov.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The viz layer (setViz / the venue option / the picker) was carved out of
|
||||
// app.js into its own module (R3a).
|
||||
const VIZ_JS = path.join(__dirname, '..', '..', 'static', 'js', 'viz.js');
|
||||
const H3D_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const ASSET_DIR = path.join(__dirname, '..', '..', 'static', 'assets', 'venue', 'themes', 'small-club');
|
||||
@@ -185,8 +188,8 @@ test('venue-scene-3d exports bg plate asset ids', () => {
|
||||
assert.equal(venueScene.ASSET_BASE, '/static/assets/venue/themes/small-club/');
|
||||
});
|
||||
|
||||
test('app.js syncs venue 3D scene on viz changes', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
test('viz.js syncs venue 3D scene on viz changes', () => {
|
||||
const src = fs.readFileSync(VIZ_JS, 'utf8');
|
||||
assert.match(src, /v3VenueScene3d\.syncViz\('venue'\)/);
|
||||
assert.match(src, /v3VenueScene3d\.syncViz\(id\)/);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ const path = require('node:path');
|
||||
const venueViz = require('../../static/v3/venue-viz.js');
|
||||
const venue = require('../../static/v3/venue-mood-fx.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The viz layer was carved out of app.js into its own module (R3a).
|
||||
const VIZ_JS = path.join(__dirname, '..', '..', 'static', 'js', 'viz.js');
|
||||
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
|
||||
const V3_CSS = path.join(__dirname, '..', '..', 'static', 'v3', 'v3.css');
|
||||
|
||||
@@ -139,8 +141,8 @@ test('index.html contains in-player venue placeholder markup', () => {
|
||||
assert.match(html, /id="v3-venue-scene-wash"/);
|
||||
});
|
||||
|
||||
test('app.js adds Venue visualization option and adapter', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
test('viz.js adds Venue visualization option and adapter', () => {
|
||||
const src = fs.readFileSync(VIZ_JS, 'utf8');
|
||||
assert.match(src, /function _ensureVenueVizOption/);
|
||||
assert.match(src, /opt\.value = 'venue'/);
|
||||
assert.match(src, /opt\.textContent = 'Venue'/);
|
||||
@@ -210,15 +212,15 @@ test('venue mood source documents strip overlay disabled', () => {
|
||||
assert.match(source, /v3-venue-mode-badge/);
|
||||
});
|
||||
|
||||
test('app.js preserves plugin viz population for drum/tab/piano highways', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
test('viz.js preserves plugin viz population for drum/tab/piano highways', () => {
|
||||
const src = fs.readFileSync(VIZ_JS, 'utf8');
|
||||
assert.match(src, /p\.type === 'visualization'/);
|
||||
assert.match(src, /feedBackViz_/);
|
||||
assert.match(src, /BUILTIN_OPT_VALUES/);
|
||||
});
|
||||
|
||||
test('venue option remains distinct from highway_3d in app adapter', () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
test('venue option remains distinct from highway_3d in viz adapter', () => {
|
||||
const src = fs.readFileSync(VIZ_JS, 'utf8');
|
||||
assert.match(src, /if \(id === 'venue'\)/);
|
||||
assert.doesNotMatch(src, /if \(id === 'venue'\)[\s\S]{0,400}sel\.value = 'highway_3d'/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Guards app.js's `window` contract ahead of the R3a ES-module flip.
|
||||
//
|
||||
// app.js is a classic script, so every top-level `function foo()` is implicitly
|
||||
// a property of `window`. As an ES module it will not be — module scope is not
|
||||
// global scope. Any name reached from OUTSIDE app.js must therefore be an
|
||||
// explicit `window.foo = …` before the flip, or it vanishes silently.
|
||||
//
|
||||
// "Silently" is the whole problem. A missing inline handler is a ReferenceError
|
||||
// only when someone clicks the button; a `typeof window.setViz !== 'function'`
|
||||
// guard (capabilities/visualization.js) just degrades and says nothing. Neither
|
||||
// shows up in a test run, so this file is the thing standing between a dropped
|
||||
// name and a dead button in production.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const APP_JS = fs.readFileSync(path.join(ROOT, 'static', 'app.js'), 'utf8');
|
||||
const V3_HTML = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'index.html'), 'utf8');
|
||||
|
||||
// Every name app.js publishes: the scattered `window.foo = …` assignments plus
|
||||
// the consolidated `Object.assign(window, { … })` contract block at the bottom.
|
||||
function exposedNames() {
|
||||
const names = new Set(
|
||||
[...APP_JS.matchAll(/^window\.([A-Za-z_$][\w$]*)\s*=/gm)].map((m) => m[1]),
|
||||
);
|
||||
const block = APP_JS.match(/Object\.assign\(window, \{([\s\S]*?)\n\}\);/);
|
||||
assert.ok(block, 'the Object.assign(window, …) contract block is missing from app.js');
|
||||
// Strip the comments first — the prose inside them is full of words that
|
||||
// would otherwise scrape as identifiers.
|
||||
const body = block[1].replace(/\/\/[^\n]*/g, '');
|
||||
for (const m of body.matchAll(/([A-Za-z_$][\w$]*)\s*(?=,|$)/gm)) names.add(m[1]);
|
||||
return names;
|
||||
}
|
||||
|
||||
// app.js's own top-level `function foo()` declarations — the names that stop
|
||||
// being global under `type="module"`.
|
||||
function topLevelFunctions() {
|
||||
return new Set(
|
||||
[...APP_JS.matchAll(/^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm)].map((m) => m[1]),
|
||||
);
|
||||
}
|
||||
|
||||
const HANDLER = /on(?:click|change|input|submit|keyup|keydown|mousedown|error|focus|blur)\s*=\s*"([A-Za-z_$][\w$]*)/g;
|
||||
|
||||
test('every inline on*= handler in the v3 shell is on window', () => {
|
||||
const exposed = exposedNames();
|
||||
const owned = topLevelFunctions();
|
||||
const missing = [...V3_HTML.matchAll(HANDLER)]
|
||||
.map((m) => m[1])
|
||||
.filter((n) => owned.has(n) && !exposed.has(n));
|
||||
assert.deepEqual([...new Set(missing)], [], 'inline handlers that would break under type="module"');
|
||||
});
|
||||
|
||||
test('every on*= handler app.js builds in a template literal is on window', () => {
|
||||
// e.g. `<button onclick="goFavPage(${p})">` — these resolve against window at
|
||||
// CLICK time, exactly like the ones written into the HTML, but they live in a
|
||||
// JS string so scanning index.html alone never finds them.
|
||||
const exposed = exposedNames();
|
||||
const owned = topLevelFunctions();
|
||||
const missing = [...APP_JS.matchAll(HANDLER)]
|
||||
.map((m) => m[1])
|
||||
.filter((n) => owned.has(n) && !exposed.has(n));
|
||||
assert.deepEqual([...new Set(missing)], [], 'generated handlers that would break under type="module"');
|
||||
});
|
||||
|
||||
test('the runtime-composed handler names are on window', () => {
|
||||
// app.js:2156-2157 chooses the handler NAME at runtime:
|
||||
// const letterFn = favoritesOnly ? 'filterFavTreeLetter' : 'filterTreeLetter';
|
||||
// const pageFn = favoritesOnly ? 'goFavTreePage' : 'goTreePage';
|
||||
// then interpolates it: `onclick="${letterFn}('A')"`.
|
||||
//
|
||||
// ponytail: hardcoded on purpose. These names exist only inside string
|
||||
// literals, so the two scans above cannot see them, and neither can ESLint,
|
||||
// no-undef, or a grep for `onclick="fn`. They are the library A–Z rail and
|
||||
// its pagination — drop one and those buttons throw on click and nowhere
|
||||
// else. If that ternary ever gains a branch, add the new name here too.
|
||||
const exposed = exposedNames();
|
||||
for (const name of ['filterTreeLetter', 'filterFavTreeLetter', 'goTreePage', 'goFavTreePage']) {
|
||||
assert.ok(exposed.has(name), `window.${name} is required by the runtime-composed A–Z rail / pagination handlers`);
|
||||
}
|
||||
});
|
||||
|
||||
test('cross-file window.* readers still resolve', () => {
|
||||
// Names other core scripts read off window. capabilities/visualization.js is
|
||||
// the cautionary one: it reads window.setViz behind a `typeof` guard, so
|
||||
// losing it degrades the visualization capability in SILENCE rather than
|
||||
// throwing.
|
||||
const exposed = exposedNames();
|
||||
for (const name of ['setViz', 'showScreen', 'playSong', 'uiPrompt', '_confirmDialog', 'loadPlugins']) {
|
||||
assert.ok(exposed.has(name), `window.${name} is read by another file`);
|
||||
}
|
||||
});
|
||||
@@ -12,6 +12,8 @@ tests/test_art_layer.py.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import enrichment
|
||||
from routers import art
|
||||
import io as _io
|
||||
import sys
|
||||
|
||||
@@ -119,8 +121,8 @@ def caa_index(server, monkeypatch):
|
||||
calls.append(release_id)
|
||||
return indexes.get(release_id) # unknown release → None (a CAA 404)
|
||||
fake.calls, fake.indexes = calls, indexes
|
||||
monkeypatch.setattr(server, "_caa_release_index", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_caa_release_index", fake)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
@@ -251,7 +253,7 @@ def test_caa_candidates_capped_at_12(server, client, caa_index):
|
||||
caa_index.indexes["rel-big"] = {
|
||||
"images": [_img(300 + i, front=(i == 0)) for i in range(20)]}
|
||||
_match_row(server, "a.sloppak", release_id="rel-big")
|
||||
assert len(_caa(_get(client))) == server._ART_PICKER_MAX_CAA == 12
|
||||
assert len(_caa(_get(client))) == art._ART_PICKER_MAX_CAA == 12
|
||||
|
||||
|
||||
def test_demo_mode_blocks_candidates(server, client, monkeypatch):
|
||||
@@ -274,10 +276,10 @@ def test_malicious_release_id_rejected_no_fetch_no_write(server, caa_index):
|
||||
"""A crafted release id (path traversal) never matches _CAA_ID_RE, so it
|
||||
yields no images, opens no socket, and writes no cache file — inside the
|
||||
art dir or anywhere else."""
|
||||
art_dir = server._enrichment_art_dir()
|
||||
art_dir = enrichment._enrichment_art_dir()
|
||||
before = set(art_dir.glob("*"))
|
||||
assert not server._CAA_ID_RE.match("../../etc/x")
|
||||
assert server._caa_index_cached("../../etc/x") == []
|
||||
assert not enrichment._CAA_ID_RE.match("../../etc/x")
|
||||
assert enrichment._caa_index_cached("../../etc/x") == []
|
||||
assert caa_index.calls == [] # the seam was never asked
|
||||
assert set(art_dir.glob("*")) == before # nothing written
|
||||
# And nothing landed at the traversal target beside the cache dir either.
|
||||
@@ -340,10 +342,10 @@ def test_fetch_art_url_follows_redirects_validating_each_hop(server, monkeypatch
|
||||
return _FakeResp(200, chunks=[b"IMGDATA"])
|
||||
|
||||
monkeypatch.setattr(requests, "get", fake_get)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_url_host_is_internal",
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(art, "_url_host_is_internal",
|
||||
lambda u: (checked.append(u), False)[1])
|
||||
data = server._fetch_art_url("https://coverartarchive.example/release/x/front-500")
|
||||
data = art._fetch_art_url("https://coverartarchive.example/release/x/front-500")
|
||||
assert data == b"IMGDATA"
|
||||
assert fetched == ["https://coverartarchive.example/release/x/front-500",
|
||||
"https://archive.example/img.png"]
|
||||
@@ -354,18 +356,18 @@ def test_fetch_art_url_blocks_redirect_to_internal(server, monkeypatch):
|
||||
import requests
|
||||
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
|
||||
302, {"Location": "http://internal.example/x.png"}))
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_url_host_is_internal",
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(art, "_url_host_is_internal",
|
||||
lambda u: "internal" in u)
|
||||
with pytest.raises(ValueError):
|
||||
server._fetch_art_url("https://public.example/x.png")
|
||||
art._fetch_art_url("https://public.example/x.png")
|
||||
|
||||
|
||||
def test_fetch_art_url_redirect_budget(server, monkeypatch):
|
||||
import requests
|
||||
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
|
||||
307, {"Location": "https://public.example/next.png"}))
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_url_host_is_internal", lambda u: False)
|
||||
with pytest.raises(server.EnrichTransportError):
|
||||
server._fetch_art_url("https://public.example/x.png")
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(art, "_url_host_is_internal", lambda u: False)
|
||||
with pytest.raises(enrichment.EnrichTransportError):
|
||||
art._fetch_art_url("https://public.example/x.png")
|
||||
|
||||
+30
-28
@@ -7,6 +7,8 @@ here opens a socket, and the offline default is itself asserted.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import enrichment
|
||||
from routers import art
|
||||
import io as _io
|
||||
import sys
|
||||
|
||||
@@ -121,7 +123,7 @@ def test_bad_upload_rejected(server, client):
|
||||
|
||||
def test_art_url_fetches_and_overrides(server, client, monkeypatch):
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
monkeypatch.setattr(server, "_fetch_art_url", lambda url: png_bytes((9, 9, 9)))
|
||||
monkeypatch.setattr(art, "_fetch_art_url", lambda url: png_bytes((9, 9, 9)))
|
||||
body = client.post("/api/song/a.sloppak/art/url",
|
||||
json={"url": "https://example.com/cover.png"}).json()
|
||||
assert body == {"ok": True, "kind": "png"}
|
||||
@@ -138,7 +140,7 @@ def test_art_url_validation(server, client, monkeypatch):
|
||||
# Oversize → 400 (the seam raises ValueError at the cap).
|
||||
def _huge(url):
|
||||
raise ValueError("image larger than 10 MB")
|
||||
monkeypatch.setattr(server, "_fetch_art_url", _huge)
|
||||
monkeypatch.setattr(art, "_fetch_art_url", _huge)
|
||||
assert client.post("/api/song/a.sloppak/art/url",
|
||||
json={"url": "https://example.com/x.png"}).status_code == 400
|
||||
|
||||
@@ -176,15 +178,15 @@ def caa(server, monkeypatch):
|
||||
calls.append(release_id)
|
||||
return art.get(release_id)
|
||||
fake.calls, fake.art = calls, art
|
||||
monkeypatch.setattr(server, "_caa_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_caa_http_get", fake)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
def test_caa_fetch_fills_missing_art(server, client, caa):
|
||||
make_sloppak(server, "a.sloppak") # no pack art
|
||||
_match_row(server, "a.sloppak")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["art_state"] == "caa"
|
||||
assert row["art_cache_path"] and row["art_cache_path"].endswith("caa_rel-1.jpg")
|
||||
@@ -193,7 +195,7 @@ def test_caa_fetch_fills_missing_art(server, client, caa):
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
# Settled: the next pass never re-fetches.
|
||||
n = len(caa.calls)
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert len(caa.calls) == n
|
||||
|
||||
|
||||
@@ -204,7 +206,7 @@ def test_caa_skips_pack_art_and_dedupes_by_release(server, caa):
|
||||
_match_row(server, "haspack.sloppak")
|
||||
_match_row(server, "b.sloppak") # same release as c
|
||||
_match_row(server, "c.sloppak")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("haspack.sloppak")["art_state"] == "pack"
|
||||
assert server.meta_db.get_enrichment("b.sloppak")["art_state"] == "caa"
|
||||
assert server.meta_db.get_enrichment("c.sloppak")["art_state"] == "caa"
|
||||
@@ -214,10 +216,10 @@ def test_caa_skips_pack_art_and_dedupes_by_release(server, caa):
|
||||
def test_caa_404_marks_none(server, caa):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak", release_id="rel-missing")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "none"
|
||||
n = len(caa.calls)
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert len(caa.calls) == n # never re-hammered
|
||||
|
||||
|
||||
@@ -226,13 +228,13 @@ def test_caa_transport_error_leaves_row_unevaluated(server, caa, monkeypatch):
|
||||
_match_row(server, "a.sloppak")
|
||||
|
||||
def _down(release_id):
|
||||
raise server.EnrichTransportError("down")
|
||||
monkeypatch.setattr(server, "_caa_http_get", _down)
|
||||
server._background_enrich()
|
||||
raise enrichment.EnrichTransportError("down")
|
||||
monkeypatch.setattr(enrichment, "_caa_http_get", _down)
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
||||
# Network back → next pass completes it.
|
||||
monkeypatch.setattr(server, "_caa_http_get", caa)
|
||||
server._background_enrich()
|
||||
monkeypatch.setattr(enrichment, "_caa_http_get", caa)
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
|
||||
|
||||
|
||||
@@ -240,22 +242,22 @@ def test_offline_default_skips_art_worker(server, monkeypatch):
|
||||
"""Under the plain test env the whole art phase is skipped with the rest
|
||||
of the network work."""
|
||||
calls = []
|
||||
monkeypatch.setattr(server, "_caa_http_get", lambda rid: calls.append(rid))
|
||||
monkeypatch.setattr(enrichment, "_caa_http_get", lambda rid: calls.append(rid))
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert calls == []
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
||||
|
||||
|
||||
def test_lru_prune_evicts_oldest_and_resets_rows(server, caa, monkeypatch):
|
||||
monkeypatch.setattr(server, "_CAA_CACHE_CAP_BYTES", 1) # everything over cap
|
||||
monkeypatch.setattr(enrichment, "_CAA_CACHE_CAP_BYTES", 1) # everything over cap
|
||||
make_sloppak(server, "a.sloppak", title="One")
|
||||
make_sloppak(server, "b.sloppak", title="Two")
|
||||
caa.art["rel-2"] = png_bytes((1, 1, 1))
|
||||
_match_row(server, "a.sloppak", release_id="rel-1")
|
||||
_match_row(server, "b.sloppak", release_id="rel-2")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
# With a 1-byte cap every fetch immediately evicts — the rows that pointed
|
||||
# at evicted files were reset to unevaluated.
|
||||
caa_files = list(server.ART_CACHE_DIR.glob("caa_*.jpg"))
|
||||
@@ -282,13 +284,13 @@ def test_delete_override_restores_caa_fallback(server, client, caa):
|
||||
_match_row(server, "a.sloppak")
|
||||
# Pin an override BEFORE the art worker runs → the pass stamps art_state='user'.
|
||||
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "user"
|
||||
# Remove it → the row resets to unevaluated…
|
||||
assert client.delete("/api/art/a.sloppak/override").json()["removed"]
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
||||
# …and the next pass fetches + serves the release's front cover.
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.status_code == 200
|
||||
@@ -302,7 +304,7 @@ def test_upload_rejects_unknown_song_and_oversize(server, client):
|
||||
assert server._art_override_paths("ghost.sloppak") == []
|
||||
# Oversize decoded payload → 400 (bounds the base64 upload path).
|
||||
make_sloppak(server, "a.sloppak")
|
||||
huge = b64(b"\x00" * (server._ART_URL_MAX_BYTES + 1))
|
||||
huge = b64(b"\x00" * (art._ART_URL_MAX_BYTES + 1))
|
||||
assert client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": huge}).status_code == 400
|
||||
|
||||
@@ -310,10 +312,10 @@ def test_upload_rejects_unknown_song_and_oversize(server, client):
|
||||
def test_fetch_art_url_blocks_internal_hosts(server):
|
||||
"""The SSRF guard refuses loopback / link-local / private targets before
|
||||
any request is made (the real seam, not the faked one)."""
|
||||
assert server._url_host_is_internal("http://127.0.0.1/x.png")
|
||||
assert server._url_host_is_internal("http://localhost/x.png")
|
||||
assert server._url_host_is_internal("http://169.254.169.254/latest/meta-data")
|
||||
assert server._url_host_is_internal("http://10.0.0.5/x.png")
|
||||
assert server._url_host_is_internal("http://[::1]/x.png")
|
||||
assert server._url_host_is_internal("http://nonexistent.invalid/x.png") # unresolvable → closed
|
||||
assert not server._url_host_is_internal("http://93.184.216.34/x.png") # public literal
|
||||
assert art._url_host_is_internal("http://127.0.0.1/x.png")
|
||||
assert art._url_host_is_internal("http://localhost/x.png")
|
||||
assert art._url_host_is_internal("http://169.254.169.254/latest/meta-data")
|
||||
assert art._url_host_is_internal("http://10.0.0.5/x.png")
|
||||
assert art._url_host_is_internal("http://[::1]/x.png")
|
||||
assert art._url_host_is_internal("http://nonexistent.invalid/x.png") # unresolvable → closed
|
||||
assert not art._url_host_is_internal("http://93.184.216.34/x.png") # public literal
|
||||
|
||||
@@ -10,7 +10,7 @@ Two halves, mirroring the design's split:
|
||||
|
||||
* GET /api/artist/{name}/links + POST .../links/refresh — the lazy, cached,
|
||||
opt-in external-links layer. The HTTP transport is a fake over
|
||||
`server._mb_http_get` (the ONE network seam — same pattern as
|
||||
`enrichment._mb_http_get` (the ONE network seam — same pattern as
|
||||
tests/test_mb_enrichment.py), so nothing here opens a socket. Covers the
|
||||
url-rel whitelist mapping, the http(s) scheme gate (a hostile javascript:
|
||||
resource never reaches a link slot), cache-hit second calls making no
|
||||
@@ -19,6 +19,7 @@ Two halves, mirroring the design's split:
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import enrichment
|
||||
import json
|
||||
import sys
|
||||
from urllib.parse import quote
|
||||
@@ -88,7 +89,7 @@ class FakeMBArtist:
|
||||
|
||||
def __call__(self, path, params):
|
||||
if self.raise_transport:
|
||||
raise self._srv.EnrichTransportError("fake network down")
|
||||
raise enrichment.EnrichTransportError("fake network down")
|
||||
self.calls.append((path, dict(params)))
|
||||
if path == f"artist/{MBID}":
|
||||
return self.doc
|
||||
@@ -100,8 +101,8 @@ def mb_artist(server, monkeypatch):
|
||||
"""Install the fake transport AND enable the network flag (the test env
|
||||
disables it by default — see test_links_offline_returns_empty)."""
|
||||
fake = FakeMBArtist(server)
|
||||
monkeypatch.setattr(server, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ def client_and_server(tmp_path, monkeypatch):
|
||||
static_tmp = tmp_path / "static"
|
||||
static_tmp.mkdir()
|
||||
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
||||
monkeypatch.setattr(server.appstate, "static_dir", static_tmp)
|
||||
# Pass client=("127.0.0.1", 50000) so request.client.host is a loopback address
|
||||
test_client = TestClient(server.app, client=("127.0.0.1", 50000))
|
||||
try:
|
||||
@@ -123,6 +124,7 @@ def dlc_client(tmp_path, monkeypatch):
|
||||
static_tmp = tmp_path / "static"
|
||||
static_tmp.mkdir()
|
||||
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
||||
monkeypatch.setattr(server.appstate, "static_dir", static_tmp)
|
||||
tc = TestClient(server.app, client=("127.0.0.1", 50000))
|
||||
try:
|
||||
yield tc, server, dlc
|
||||
|
||||
@@ -7,6 +7,7 @@ result, not stored songs.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import library_registry
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -133,7 +134,7 @@ def test_collection_tolerates_corrupt_persisted_rules(client, server_mod):
|
||||
('{"artist": [], "sort": [], "tunings": ["Drop D"]}', cid),
|
||||
)
|
||||
server_mod.meta_db.conn.commit()
|
||||
server_mod._sync_collection_provider(server_mod.meta_db.get_collection(cid))
|
||||
library_registry._sync_collection_provider(server_mod.meta_db.get_collection(cid))
|
||||
|
||||
r = client.get("/api/library", params={"provider": f"collection:{cid}"})
|
||||
assert r.status_code == 200 # no 500/503 from bad rules
|
||||
|
||||
@@ -4,6 +4,7 @@ contents). The refresh flow reuses the P8 fake-transport pattern — nothing
|
||||
here opens a socket."""
|
||||
|
||||
import importlib
|
||||
import enrichment
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -85,9 +86,9 @@ def test_refresh_resets_even_a_manual_pin_and_rematches(server, client, monkeypa
|
||||
"status": "Official", "date": "1990-09-24",
|
||||
"release-group": {"primary-type": "Album"}}],
|
||||
}]}
|
||||
monkeypatch.setattr(server, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
server._background_enrich()
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["mb_recording_id"] == "rec-new"
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ contracts it will inherit: rename-survivable idempotent hashing, manual rows
|
||||
never auto-reset, never purged on rescan, purged on explicit delete."""
|
||||
|
||||
import importlib
|
||||
import enrichment
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -58,7 +59,7 @@ def test_pending_covers_new_unscanned_and_changed(server):
|
||||
_put(server, "a.archive")
|
||||
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
|
||||
# stubbed → still unscanned → still pending (the matcher hasn't run)
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
|
||||
# a matched row with the CURRENT hash is settled…
|
||||
h = server.meta_db.enrichment_content_hash("Artist", "Song", "", 100)
|
||||
@@ -76,7 +77,7 @@ def test_pending_covers_new_unscanned_and_changed(server):
|
||||
def test_hash_change_resets_matched_but_never_manual(server):
|
||||
_put(server, "a.archive")
|
||||
_put(server, "b.archive", title="Other")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
with server.meta_db._lock:
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state = 'matched' WHERE filename = 'a.archive'")
|
||||
@@ -86,7 +87,7 @@ def test_hash_change_resets_matched_but_never_manual(server):
|
||||
# identity edits…
|
||||
_put(server, "a.archive", title="Song v2")
|
||||
_put(server, "b.archive", title="Other v2")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
a = server.meta_db.get_enrichment("a.archive")
|
||||
b = server.meta_db.get_enrichment("b.archive")
|
||||
# …drop a stale MATCH back to unscanned with the fresh hash
|
||||
@@ -99,7 +100,7 @@ def test_hash_change_resets_matched_but_never_manual(server):
|
||||
|
||||
def test_failed_rows_not_requeued_by_pending(server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
with server.meta_db._lock:
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state = 'failed' WHERE filename = 'a.archive'")
|
||||
@@ -113,7 +114,7 @@ def test_failed_rows_not_requeued_by_pending(server):
|
||||
def test_enrich_pass_stamps_every_song(server):
|
||||
for i in range(5):
|
||||
_put(server, f"s{i}.archive", title=f"Song {i}")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
for i in range(5):
|
||||
row = server.meta_db.get_enrichment(f"s{i}.archive")
|
||||
assert row is not None
|
||||
@@ -126,7 +127,7 @@ def test_enrich_pass_stamps_every_song(server):
|
||||
|
||||
def test_rescan_never_purges_enrichment(server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
server.meta_db.delete_missing(set()) # file vanished from a scan snapshot
|
||||
assert server.meta_db.get_enrichment("a.archive") is not None # row survives
|
||||
# …and is invisible in the read-time-filtered counts
|
||||
@@ -138,7 +139,7 @@ def test_rescan_never_purges_enrichment(server):
|
||||
def test_status_endpoint_counts(client, server):
|
||||
_put(server, "a.archive")
|
||||
_put(server, "b.archive", title="Other")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
body = client.get("/api/enrichment/status").json()
|
||||
assert body["states"] == {"unscanned": 2}
|
||||
assert body["total_songs"] == 2
|
||||
@@ -147,7 +148,7 @@ def test_status_endpoint_counts(client, server):
|
||||
|
||||
|
||||
def test_art_cache_dir_created(server):
|
||||
d = server._enrichment_art_dir()
|
||||
d = enrichment._enrichment_art_dir()
|
||||
assert d.is_dir()
|
||||
assert d.name == "art_cache"
|
||||
|
||||
@@ -156,7 +157,7 @@ def test_art_cache_dir_created(server):
|
||||
|
||||
def test_states_for_returns_only_known_filenames(server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
got = server.meta_db.enrichment_states_for(["a.archive", "nope.archive"])
|
||||
assert got == {"a.archive": "unscanned"} # unknown filename absent
|
||||
assert server.meta_db.enrichment_states_for([]) == {}
|
||||
@@ -165,7 +166,7 @@ def test_states_for_returns_only_known_filenames(server):
|
||||
def test_states_endpoint(client, server):
|
||||
_put(server, "a.archive")
|
||||
_put(server, "b.archive", title="Other")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
body = client.post("/api/enrichment/states",
|
||||
json={"filenames": ["a.archive", "zzz.missing"]}).json()
|
||||
assert body["states"] == {"a.archive": "unscanned"}
|
||||
@@ -175,7 +176,7 @@ def test_states_endpoint(client, server):
|
||||
|
||||
def test_status_exposes_progress_fields(client, server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
body = client.get("/api/enrichment/status").json()
|
||||
for k in ("total", "matched", "current", "cancelling"):
|
||||
assert k in body
|
||||
@@ -186,7 +187,7 @@ def test_cancel_is_noop_when_idle(client, server):
|
||||
body = client.post("/api/enrichment/cancel").json()
|
||||
assert body == {"ok": True, "was_running": False}
|
||||
# A no-op must not arm the flag (which would then poison the next pass).
|
||||
assert server._enrich_cancel.is_set() is False
|
||||
assert enrichment._enrich_cancel.is_set() is False
|
||||
|
||||
|
||||
def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
|
||||
@@ -195,28 +196,28 @@ def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
|
||||
# Force the matcher path on (the test env is offline by default) and stub the
|
||||
# per-song matcher so nothing touches the network — it just trips Stop after
|
||||
# the first song, exactly as the /cancel route would mid-pass.
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
calls = []
|
||||
|
||||
def fake_enrich_one(row, **_kw):
|
||||
calls.append(row["filename"])
|
||||
server._enrich_cancel.set()
|
||||
enrichment._enrich_cancel.set()
|
||||
|
||||
monkeypatch.setattr(server, "_enrich_one", fake_enrich_one)
|
||||
server._enrich_cancel.clear()
|
||||
server._background_enrich()
|
||||
monkeypatch.setattr(enrichment, "_enrich_one", fake_enrich_one)
|
||||
enrichment._enrich_cancel.clear()
|
||||
enrichment._background_enrich()
|
||||
# The loop checks cancel BEFORE each song, so exactly one is processed before
|
||||
# it breaks — not the whole 4-row queue.
|
||||
assert calls == ["s0.archive"]
|
||||
assert server._enrich_status["total"] == 4
|
||||
assert server._enrich_status["matched"] == 1
|
||||
assert enrichment._enrich_status["total"] == 4
|
||||
assert enrichment._enrich_status["matched"] == 1
|
||||
|
||||
|
||||
def test_rematch_requeues_visible_but_skips_manual(server, client):
|
||||
_put(server, "a.archive") # will be 'matched'
|
||||
_put(server, "b.archive", title="Other") # will be 'failed'
|
||||
_put(server, "c.archive", title="Pinned") # will be 'manual' — untouchable
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
with server.meta_db._lock:
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state='matched' WHERE filename='a.archive'")
|
||||
@@ -240,7 +241,7 @@ def test_rematch_requeues_visible_but_skips_manual(server, client):
|
||||
# ── filename-derived artist/title fallback (blank-artist packs) ───────────────
|
||||
|
||||
def test_filename_artist_title_parse(server):
|
||||
f = server._artist_title_from_filename
|
||||
f = enrichment._artist_title_from_filename
|
||||
assert f("CDLC/0 - City Pop/Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak") == \
|
||||
{"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
|
||||
assert f("Anri_Windy-Summer_v1_p.feedpak") == {"artist": "Anri", "title": "Windy Summer"}
|
||||
@@ -255,18 +256,18 @@ def test_blank_artist_seeds_match_from_filename(server, monkeypatch):
|
||||
server.meta_db.put("Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak", 0, 0, {
|
||||
"title": "Tatsuro-Yamashita_Ride-On-Time_v1_p", "artist": "", "album": "",
|
||||
"duration": 240, "arrangements": [{"name": "Bass", "index": 0}]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_manifest_exact_ids", lambda fn: {})
|
||||
seen = {}
|
||||
|
||||
def fake_search(artist, title, limit=8):
|
||||
seen["artist"], seen["title"] = artist, title
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
|
||||
monkeypatch.setattr(enrichment, "_mb_search_recordings", fake_search)
|
||||
row = next(r for r in server.meta_db.enrichment_pending()
|
||||
if r["filename"].startswith("Tatsuro"))
|
||||
server._enrich_one(row)
|
||||
enrichment._enrich_one(row)
|
||||
# the blank pack artist was replaced by the filename-derived identity for
|
||||
# the search (this is exactly what rescues the 'failed' pile)
|
||||
assert seen == {"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
|
||||
@@ -276,18 +277,18 @@ def test_present_artist_is_not_overridden_by_filename(server, monkeypatch):
|
||||
server.meta_db.put("Weird-Filename_x_y.feedpak", 0, 0, {
|
||||
"title": "Real Title", "artist": "Real Artist", "album": "", "duration": 100,
|
||||
"arrangements": [{"name": "Lead", "index": 0}]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_manifest_exact_ids", lambda fn: {})
|
||||
seen = {}
|
||||
|
||||
def fake_search(artist, title, limit=8):
|
||||
seen["artist"], seen["title"] = artist, title
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
|
||||
monkeypatch.setattr(enrichment, "_mb_search_recordings", fake_search)
|
||||
row = next(r for r in server.meta_db.enrichment_pending()
|
||||
if r["filename"].startswith("Weird"))
|
||||
server._enrich_one(row)
|
||||
enrichment._enrich_one(row)
|
||||
# a pack that DOES carry an artist keeps it — the filename is never consulted
|
||||
assert seen == {"artist": "Real Artist", "title": "Real Title"}
|
||||
|
||||
@@ -295,7 +296,7 @@ def test_present_artist_is_not_overridden_by_filename(server, monkeypatch):
|
||||
def test_kick_clears_a_stale_cancel(server):
|
||||
# A cancelled-then-rekicked pass must start clean: _kick_enrich clears the
|
||||
# flag so the fresh pass isn't aborted the instant it checks.
|
||||
server._enrich_cancel.set()
|
||||
server._kick_enrich()
|
||||
enrichment._enrich_cancel.set()
|
||||
enrichment._kick_enrich()
|
||||
server._join_background_db_threads()
|
||||
assert server._enrich_cancel.is_set() is False
|
||||
assert enrichment._enrich_cancel.is_set() is False
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user