mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 13:48:31 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dba5717eed | ||
|
|
f9359712bb | ||
|
|
5e30138c87 | ||
|
|
0547f55844 | ||
|
|
b7624b7e65 | ||
|
|
f00ba2217d |
@@ -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
|
||||
|
||||
+20
-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), `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 `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
|
||||
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `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
|
||||
|
||||
@@ -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`
|
||||
(2,925 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and nineteen `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`) ·
|
||||
(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`
|
||||
|
||||
@@ -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,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,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)
|
||||
@@ -18,7 +18,7 @@ configure_logging()
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
from fastapi import Body, FastAPI, UploadFile, File, HTTPException
|
||||
from fastapi import FastAPI, File
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
|
||||
@@ -37,7 +37,7 @@ from metadata_db import MetadataDB
|
||||
from audio_effects_db import AudioEffectsMappingDB
|
||||
from library_registry import ( # registry classes + collection lifecycle moved to lib (R3)
|
||||
LibraryProviderRegistry, LocalLibraryProvider,
|
||||
_safe_art_redirect_url, _sync_collection_provider,
|
||||
_sync_collection_provider,
|
||||
)
|
||||
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||
# The router seam. Imported as a module (never `from appstate import ...`) so
|
||||
@@ -52,12 +52,14 @@ from routers import art as art_router
|
||||
from routers import settings as settings_router
|
||||
from routers import song as song_router
|
||||
from routers import library as library_router
|
||||
from routers import enrichment as enrichment_routes
|
||||
from routers import media as media_router
|
||||
from routers import artist as artist_router
|
||||
import sloppak as sloppak_mod
|
||||
import loosefolder as loosefolder_mod
|
||||
# Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/
|
||||
# tier classification + response parsing. No network/DB in there — the
|
||||
# throttled transport and the song_enrichment writes live in this module.
|
||||
import mb_match
|
||||
# Metadata extraction lives in a side-effect-free module so ProcessPool
|
||||
# scan workers can import + unpickle _scan_one without re-running this
|
||||
# module's import-time side effects (see lib/scan_worker.py).
|
||||
@@ -65,7 +67,6 @@ from scan_worker import _extract_meta_for_file, _relpath, _scan_one
|
||||
|
||||
import concurrent.futures
|
||||
import inspect
|
||||
import ipaddress
|
||||
import multiprocessing
|
||||
import re
|
||||
import threading
|
||||
@@ -1802,325 +1803,38 @@ def scan_status():
|
||||
return _scan_status
|
||||
|
||||
|
||||
@app.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": meta_db.enrichment_state_counts(),
|
||||
"total_songs": 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(),
|
||||
}
|
||||
# ── Enrichment routes → routers/enrichment.py (R3) ──────────────────────────
|
||||
app.include_router(enrichment_routes.router)
|
||||
|
||||
|
||||
@app.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 = 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")}
|
||||
|
||||
|
||||
@app.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()}
|
||||
|
||||
|
||||
@app.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}
|
||||
|
||||
|
||||
@app.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 = meta_db.enrichment_song_row(fn)
|
||||
if not song:
|
||||
continue
|
||||
h = 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 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}
|
||||
|
||||
|
||||
@app.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": meta_db.enrichment_states_for(fns),
|
||||
"current": enrichment._enrich_status.get("current"),
|
||||
"running": enrichment._enrich_status["running"],
|
||||
}
|
||||
|
||||
|
||||
@app.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 = meta_db.enrichment_song_row(filename)
|
||||
if not song:
|
||||
raise HTTPException(status_code=404, detail="unknown song")
|
||||
h = meta_db.enrichment_content_hash(
|
||||
song["artist"], song["title"], song["album"], song["duration"])
|
||||
meta_db.apply_enrichment_match(filename, h, "unscanned",
|
||||
allow_manual_overwrite=True)
|
||||
return {"ok": True, "started": enrichment._kick_enrich()}
|
||||
|
||||
|
||||
@app.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(CONFIG_DIR / "config.json") or {}
|
||||
order = cfg.get("enrich_review_order", "missing_first")
|
||||
return {
|
||||
"songs": meta_db.enrichment_review_queue(limit=limit, order=order),
|
||||
"total_review": meta_db.enrichment_state_counts().get("review", 0),
|
||||
}
|
||||
|
||||
|
||||
@app.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 = 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 meta_db.set_enrichment_manual(filename, cand, source="review"):
|
||||
raise HTTPException(status_code=404, detail="unknown song")
|
||||
return {"ok": True, "enrichment": meta_db.get_enrichment(filename)}
|
||||
|
||||
|
||||
@app.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 meta_db.set_enrichment_rejected(filename):
|
||||
raise HTTPException(status_code=404, detail="no rejectable match for this song")
|
||||
return {"ok": True, "enrichment": 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
|
||||
|
||||
|
||||
@app.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 meta_db.set_enrichment_manual(filename, cand, source="search"):
|
||||
raise HTTPException(status_code=404, detail="unknown song")
|
||||
return {"ok": True, "enrichment": meta_db.get_enrichment(filename)}
|
||||
|
||||
|
||||
@app.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 = 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)}
|
||||
|
||||
|
||||
@app.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}
|
||||
|
||||
|
||||
@app.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}
|
||||
|
||||
|
||||
@app.get("/api/startup-status")
|
||||
@@ -2339,112 +2053,18 @@ app.include_router(artist_aliases.router)
|
||||
# re-fetched only by the explicit refresh. Both links routes are demo-blocked
|
||||
# (they store server state + spend the shared MB rate limit).
|
||||
|
||||
# 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",
|
||||
}
|
||||
# ── Artist routes → routers/artist.py (R3) ──────────────────────────────────
|
||||
app.include_router(artist_router.router)
|
||||
|
||||
|
||||
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(CONFIG_DIR / "config.json") or _default_settings()
|
||||
if cfg.get("artist_external_links") is not True:
|
||||
return {"links": {}, "matched": False, "disabled": True}
|
||||
canonical = meta_db._terminal_canonical((name or "").strip())
|
||||
mbid = meta_db.artist_known_mb_id(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 = 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 {})
|
||||
meta_db.put_artist_enrichment(mbid, links, genres)
|
||||
return {"links": links, "genres": genres, "matched": True, "cached": False,
|
||||
"mb_artist_id": mbid}
|
||||
|
||||
|
||||
@app.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 meta_db.artist_page(name)
|
||||
|
||||
|
||||
@app.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)
|
||||
|
||||
|
||||
@app.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)
|
||||
# ── Player profile (identity / avatars / progress) ───────────────────────────
|
||||
# Mounted here (registration order). Implementation in lib/routers/profile.py.
|
||||
app.include_router(profile.router)
|
||||
@@ -2747,67 +2367,10 @@ _extract_cache = {} # filename -> (tmp_dir, song, timestamp)
|
||||
_extract_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
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, 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
|
||||
# ── Media/file-serving routes → routers/media.py (R3) ───────────────────────
|
||||
app.include_router(media_router.router)
|
||||
|
||||
|
||||
@app.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))
|
||||
|
||||
|
||||
# ── Highway chart WebSocket ──────────────────────────────────────────────────
|
||||
@@ -2819,82 +2382,8 @@ app.include_router(ws_highway.router)
|
||||
# ── Audio serving ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.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 AUDIO_CACHE_DIR or 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 [AUDIO_CACHE_DIR, 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)
|
||||
|
||||
|
||||
@app.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 [AUDIO_CACHE_DIR, 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)
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
@@ -2902,24 +2391,10 @@ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
# fee[dB]ack v0.3.0: the v3 shell is now the DEFAULT at `/`. The classic v2
|
||||
# UI remains fully available as a fallback — opt back in with
|
||||
# FEEDBACK_UI=v2 (or =legacy), or hit the dedicated /v2 route below (which
|
||||
# serves it regardless of the env var).
|
||||
if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy"):
|
||||
return FileResponse(str(STATIC_DIR / "index.html"))
|
||||
return FileResponse(str(STATIC_DIR / "v3" / "index.html"))
|
||||
|
||||
|
||||
@app.get("/v3")
|
||||
def index_v3():
|
||||
# Always serve the v0.3.0 shell, independent of the env var (kept for
|
||||
# explicit/back-compat links even though `/` now defaults to v3).
|
||||
# Retained as a back-compat alias for links minted while v3 was opt-in.
|
||||
return FileResponse(str(STATIC_DIR / "v3" / "index.html"))
|
||||
|
||||
|
||||
@app.get("/v2")
|
||||
def index_v2():
|
||||
# Always serve the classic v2 UI, independent of the env var, so the
|
||||
# fallback is reachable without flipping FEEDBACK_UI.
|
||||
return FileResponse(str(STATIC_DIR / "index.html"))
|
||||
|
||||
+11
-121
@@ -5375,94 +5375,15 @@ window.jucePlayer = jucePlayer;
|
||||
if (_elCtx) return;
|
||||
const el = document.getElementById('audio');
|
||||
if (!el) throw new Error('no core audio element');
|
||||
// Assign the module state ONLY after the whole chain succeeded.
|
||||
// createMediaElementSource throws InvalidStateError when another
|
||||
// consumer (highway_3d's analyser tap) already owns the element's
|
||||
// one-shot source — assigning _elCtx before that throw poisoned every
|
||||
// later tick into `_elTap.active` TypeErrors (tester log 2026-07-11)
|
||||
// while the song kept playing on the default device.
|
||||
const ctx = new AudioContext();
|
||||
let source, tap;
|
||||
try {
|
||||
source = ctx.createMediaElementSource(el);
|
||||
source.connect(ctx.destination);
|
||||
tap = _makeTap(ctx);
|
||||
await tap.attach(source);
|
||||
} catch (e) {
|
||||
try { await ctx.close(); } catch (_) { /* already closed */ }
|
||||
throw e;
|
||||
}
|
||||
_elCtx = ctx; _elSource = source; _elTap = tap;
|
||||
}
|
||||
|
||||
// ── Whole-app loopback capture ───────────────────────────────────────────
|
||||
// Preferred mode: one getDisplayMedia frame-audio capture covers EVERY
|
||||
// sound the app makes (song, previews, UI) — no per-surface taps, so
|
||||
// plugin-private AudioContexts (song-preview, future plugins) survive
|
||||
// exclusive/ASIO output too. The desktop main process answers the request
|
||||
// with this window's own frame (frame-scoped — no other apps' audio).
|
||||
// Local playback is silenced via the suppressLocalAudioPlayback track
|
||||
// constraint, with a page-mute IPC fallback (capture taps frame audio
|
||||
// before the output mute, so a muted page still feeds the stream).
|
||||
let _lbStream = null, _lbCtx = null, _lbTap = null, _lbPageMuted = false;
|
||||
let _loopbackUnavailable = false; // sticky: probe once, then fall back
|
||||
async function _engageLoopback() {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: { suppressLocalAudioPlayback: true },
|
||||
});
|
||||
for (const t of stream.getVideoTracks()) t.stop(); // required, unused
|
||||
const track = stream.getAudioTracks()[0];
|
||||
if (!track) {
|
||||
for (const t of stream.getTracks()) t.stop();
|
||||
throw new Error('no loopback audio track');
|
||||
}
|
||||
try {
|
||||
// Fresh context per session (not reused) so teardown's close()
|
||||
// fully releases the tap worklet node — see _teardownLoopback.
|
||||
_lbCtx = new AudioContext();
|
||||
if (_lbCtx.state !== 'running') await _lbCtx.resume().catch(() => {});
|
||||
const source = _lbCtx.createMediaStreamSource(stream);
|
||||
const tap = _makeTap(_lbCtx);
|
||||
await tap.attach(source);
|
||||
const suppressed = track.getSettings?.().suppressLocalAudioPlayback === true;
|
||||
if (!suppressed && typeof api.setPageMuted === 'function') {
|
||||
_lbPageMuted = (await api.setPageMuted(true)) === true;
|
||||
}
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
console.log('[asio-diag] loopback: suppressed=', suppressed,
|
||||
'pageMuted=', _lbPageMuted, 'rate=', _lbCtx.sampleRate);
|
||||
}
|
||||
await api.setRendererBus(true, 1.0);
|
||||
tap.active = true;
|
||||
_lbStream = stream; _lbTap = tap;
|
||||
_mode = 'loopback';
|
||||
console.log('[renderer-bus] engaged: app loopback → engine bus');
|
||||
} catch (e) {
|
||||
for (const t of stream.getTracks()) t.stop();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
async function _teardownLoopback() {
|
||||
if (_lbTap) _lbTap.active = false;
|
||||
if (_lbStream) for (const t of _lbStream.getTracks()) t.stop();
|
||||
_lbStream = null; _lbTap = null;
|
||||
// Close the capture context so its tap worklet node is released. The
|
||||
// context is per-session (not reused): without this, each exclusive⇄
|
||||
// shared switch orphaned a live worklet on a long-lived context.
|
||||
if (_lbCtx) {
|
||||
try { await _lbCtx.close(); } catch (_) { /* already closed */ }
|
||||
_lbCtx = null;
|
||||
}
|
||||
if (_lbPageMuted && typeof api.setPageMuted === 'function') {
|
||||
try { await api.setPageMuted(false); } catch (_) { /* engine gone */ }
|
||||
}
|
||||
_lbPageMuted = false;
|
||||
_elCtx = new AudioContext();
|
||||
_elSource = _elCtx.createMediaElementSource(el);
|
||||
_elSource.connect(_elCtx.destination);
|
||||
_elTap = _makeTap(_elCtx);
|
||||
await _elTap.attach(_elSource);
|
||||
}
|
||||
|
||||
// ── Engagement state machine ─────────────────────────────────────────────
|
||||
// 'off' | 'loopback' | 'element' | 'stems' (element/stems = fallback when
|
||||
// loopback capture is unavailable: old desktop main, denied capture)
|
||||
// 'off' | 'element' | 'stems'
|
||||
let _mode = 'off';
|
||||
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
|
||||
let _stemsTap = null;
|
||||
@@ -5487,9 +5408,7 @@ window.jucePlayer = jucePlayer;
|
||||
const prev = _mode;
|
||||
_mode = 'off';
|
||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||
if (prev === 'loopback') {
|
||||
await _teardownLoopback();
|
||||
} else if (prev === 'element' && _elCtx) {
|
||||
if (prev === 'element' && _elCtx) {
|
||||
_elTap.active = false;
|
||||
await _setSink(_elCtx, false).catch(() => {});
|
||||
} else if (prev === 'stems' && _stemsGraph) {
|
||||
@@ -5548,20 +5467,9 @@ window.jucePlayer = jucePlayer;
|
||||
|
||||
let want = 'off';
|
||||
if (running && exclusive) {
|
||||
// Loopback covers ALL app audio (song, previews, UI), so it
|
||||
// engages for the whole exclusive session — not just while a
|
||||
// song is loaded. Per-surface modes remain as fallback when
|
||||
// loopback capture is unavailable (old desktop main without
|
||||
// the display-media handler, capture denied).
|
||||
if (!_loopbackUnavailable) want = 'loopback';
|
||||
else if (stems) want = 'stems';
|
||||
if (stems) want = 'stems';
|
||||
else if (elementSong) want = 'element';
|
||||
}
|
||||
// Song audio riding the native transport must not ALSO ride the
|
||||
// loopback (double-carry into the same engine output). The native
|
||||
// transport plays from the engine, not the page, so page loopback
|
||||
// never hears it — no conflict; loopback stays engaged for
|
||||
// previews/UI while the transport owns the song.
|
||||
|
||||
// [asio-diag] full decision vector, change-gated (500ms poll —
|
||||
// steady state must not flood the buffer). This is the feeder-side
|
||||
@@ -5573,7 +5481,6 @@ window.jucePlayer = jucePlayer;
|
||||
+ ' stems=' + !!stems + ' songAudio=' + !!songAudio
|
||||
+ ' juceMode=' + !!window._juceMode
|
||||
+ ' elementSong=' + elementSong
|
||||
+ ' loopbackUnavailable=' + _loopbackUnavailable
|
||||
+ ' want=' + want + ' mode=' + _mode;
|
||||
if (d !== window._lastRendererBusDecision) {
|
||||
window._lastRendererBusDecision = d;
|
||||
@@ -5585,29 +5492,12 @@ window.jucePlayer = jucePlayer;
|
||||
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
|
||||
if (want !== _mode || stemsGraphChanged) {
|
||||
await _disengage();
|
||||
try {
|
||||
if (want === 'loopback') await _engageLoopback();
|
||||
else if (want === 'stems') await _engageStems(stems);
|
||||
else if (want === 'element') await _engageElement();
|
||||
} catch (e) {
|
||||
if (want === 'loopback') {
|
||||
// Capture unavailable (no handler in an old desktop
|
||||
// main, permission denied) — remember and fall back to
|
||||
// the per-surface modes on the next tick.
|
||||
_loopbackUnavailable = true;
|
||||
console.warn('[renderer-bus] loopback capture unavailable — falling back to surface taps:', e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (want === 'stems') await _engageStems(stems);
|
||||
else if (want === 'element') await _engageElement();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[renderer-bus] reevaluate failed (will retry):', e);
|
||||
_mode = 'off';
|
||||
// A partial engage may have left the bus enabled with no producer
|
||||
// and the page muted — undo both so a failed tick can't strand
|
||||
// audio in silence until the next successful engage.
|
||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||
await _teardownLoopback().catch(() => {});
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
@@ -9291,7 +9181,7 @@ function _ensureSectionPracticeDom() {
|
||||
ctrl.appendChild(bar);
|
||||
// Mount OUTSIDE #player-controls (in #player-footer, or as its sibling) so
|
||||
// the popover's chip <button>s can't be matched by a plugin injector that
|
||||
// anchors on `#player-controls > button:last-of-type` (see static/index.html).
|
||||
// anchors on `#player-controls > button:last-of-type` (see static/v3/index.html).
|
||||
_mountSectionPracticeControlSafe(ctrl);
|
||||
_placeSectionPracticeControlForChrome();
|
||||
return bar;
|
||||
|
||||
+1
-1
@@ -282,7 +282,7 @@ function createHighway() {
|
||||
// it's only "full resolution" when Quality is HD; otherwise it pins at the
|
||||
// chosen Quality. Read once here; live changes come through
|
||||
// api.setMinRenderScale(), surfaced as the "Min res" control next to Quality
|
||||
// in the player controls (static/index.html).
|
||||
// in the player controls (static/v3/index.html).
|
||||
hwState._autoScaleMin = (function () {
|
||||
const v = parseFloat(localStorage.getItem('highwayMinRenderScale'));
|
||||
return Number.isFinite(v) ? Math.max(_AUTO_SCALE_MIN, Math.min(1, v)) : _AUTO_SCALE_MIN;
|
||||
|
||||
@@ -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>
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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">
|
||||
|
||||
+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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -50,42 +50,17 @@ 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;
|
||||
}
|
||||
|
||||
// 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: [] };
|
||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
|
||||
const calls = { setRendererBus: [], pushRendererAudio: [] };
|
||||
|
||||
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 {
|
||||
@@ -110,7 +85,6 @@ function makeSandbox({ isAudioRunning = () => true, exclusive = () => true, disp
|
||||
__createdContexts: [],
|
||||
__audioEl: { id: 'audio' },
|
||||
__calls: calls,
|
||||
navigator: { mediaDevices: displayMedia ? { getDisplayMedia: displayMedia } : {} },
|
||||
window: null,
|
||||
};
|
||||
sandbox.window = {
|
||||
@@ -137,21 +111,12 @@ function makeStemsGraph() {
|
||||
};
|
||||
}
|
||||
|
||||
// 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 () => {
|
||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await reevaluateWithFallback(sb);
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
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');
|
||||
@@ -163,7 +128,7 @@ test('output returns to shared → bus disabled, sink restored', async () => {
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await reevaluateWithFallback(sb);
|
||||
await sb.window._reevaluateRendererBus();
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
@@ -180,27 +145,26 @@ 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 (loopback unavailable)', async () => {
|
||||
test('element song + exclusive → element captured into bus', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||
sb.window._juceMode = false;
|
||||
|
||||
await reevaluateWithFallback(sb);
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
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('native-transport song, loopback unavailable → surface modes stay off', async () => {
|
||||
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
|
||||
sb.window._juceMode = true;
|
||||
|
||||
await reevaluateWithFallback(sb);
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true),
|
||||
'bus never ENABLED (failed-probe cleanup may disable it)');
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
|
||||
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
|
||||
});
|
||||
|
||||
@@ -208,7 +172,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 reevaluateWithFallback(sb);
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
const g2 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g2;
|
||||
@@ -218,105 +182,6 @@ 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"/);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ const vm = require('node:vm');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.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 +96,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'\)/);
|
||||
|
||||
@@ -11,7 +11,6 @@ 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');
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-13
@@ -4,22 +4,22 @@ from env_compat import getenv_compat, env_flag_compat
|
||||
|
||||
|
||||
def test_canonical_wins_over_legacy(monkeypatch):
|
||||
monkeypatch.setenv("FEEDBACK_UI", "v3")
|
||||
monkeypatch.setenv("SLOPSMITH_UI", "v2")
|
||||
assert getenv_compat("FEEDBACK_UI") == "v3"
|
||||
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "canonical")
|
||||
monkeypatch.setenv("SLOPSMITH_DEMO_MODE", "legacy")
|
||||
assert getenv_compat("FEEDBACK_DEMO_MODE") == "canonical"
|
||||
|
||||
|
||||
def test_legacy_fallback_when_canonical_unset(monkeypatch):
|
||||
monkeypatch.delenv("FEEDBACK_UI", raising=False)
|
||||
monkeypatch.setenv("SLOPSMITH_UI", "v2")
|
||||
assert getenv_compat("FEEDBACK_UI") == "v2"
|
||||
monkeypatch.delenv("FEEDBACK_DEMO_MODE", raising=False)
|
||||
monkeypatch.setenv("SLOPSMITH_DEMO_MODE", "legacy")
|
||||
assert getenv_compat("FEEDBACK_DEMO_MODE") == "legacy"
|
||||
|
||||
|
||||
def test_default_when_neither_set(monkeypatch):
|
||||
monkeypatch.delenv("FEEDBACK_UI", raising=False)
|
||||
monkeypatch.delenv("SLOPSMITH_UI", raising=False)
|
||||
assert getenv_compat("FEEDBACK_UI", "default") == "default"
|
||||
assert getenv_compat("FEEDBACK_UI") is None
|
||||
monkeypatch.delenv("FEEDBACK_DEMO_MODE", raising=False)
|
||||
monkeypatch.delenv("SLOPSMITH_DEMO_MODE", raising=False)
|
||||
assert getenv_compat("FEEDBACK_DEMO_MODE", "default") == "default"
|
||||
assert getenv_compat("FEEDBACK_DEMO_MODE") is None
|
||||
|
||||
|
||||
def test_non_feedback_names_have_no_fallback(monkeypatch):
|
||||
@@ -31,9 +31,9 @@ def test_non_feedback_names_have_no_fallback(monkeypatch):
|
||||
|
||||
def test_empty_canonical_is_respected_not_overridden(monkeypatch):
|
||||
# An explicitly-empty canonical value is still "set" and wins.
|
||||
monkeypatch.setenv("FEEDBACK_UI", "")
|
||||
monkeypatch.setenv("SLOPSMITH_UI", "v2")
|
||||
assert getenv_compat("FEEDBACK_UI") == ""
|
||||
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "")
|
||||
monkeypatch.setenv("SLOPSMITH_DEMO_MODE", "legacy")
|
||||
assert getenv_compat("FEEDBACK_DEMO_MODE") == ""
|
||||
|
||||
|
||||
def test_flag_parses_true_values(monkeypatch):
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_capability_visualizer_waits_for_registry_instead_of_hard_error():
|
||||
|
||||
|
||||
def test_app_shell_loads_capability_registry_before_app_runtime():
|
||||
source = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
|
||||
source = (ROOT / "static" / "v3" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
assert '<script src="/static/capabilities.js"></script>' in source
|
||||
assert '<script src="/static/capabilities/library.js"></script>' in source
|
||||
|
||||
@@ -37,6 +37,7 @@ def dlc_client(tmp_path, monkeypatch):
|
||||
static_tmp = tmp_path / "static"
|
||||
static_tmp.mkdir()
|
||||
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
||||
monkeypatch.setattr(server.appstate, "static_dir", static_tmp)
|
||||
tc = TestClient(server.app, client=("127.0.0.1", 50000))
|
||||
try:
|
||||
yield tc, server, dlc
|
||||
|
||||
Reference in New Issue
Block a user