mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 01:28:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45a646bbbb |
+1
-1
@@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`.
|
||||
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `media` (the file-serving routes — song audio `/audio/{f}`, the local-audio-path resolver `/api/audio-local-path`, and raw sloppak-member serving `/api/sloppak/{f}/file/{rel}` → `lib/routers/media.py`; the cache/static path seams were already in `appstate`), and `artist` (the artist page + external-links payload `/api/artist/{name}/page|links|links/refresh` → `lib/routers/artist.py`; MB link enrichment reached as `enrichment.X`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
|
||||
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `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 `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
|
||||
|
||||
@@ -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,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`) ·
|
||||
(2,638 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and twenty `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`
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"""Artist routes: the artist page + external-links payload
|
||||
(/api/artist/{name}/page, /links, /links/refresh).
|
||||
|
||||
Extracted verbatim from server.py (R3) except @app->@router and the seam reads
|
||||
(meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir, _default_settings->
|
||||
appstate.default_settings). MusicBrainz link enrichment is reached as
|
||||
enrichment.X; the shared URL-safety validator lives in lib/library_registry.py.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
import appstate
|
||||
import enrichment
|
||||
from appconfig import _load_config
|
||||
from library_registry import _safe_art_redirect_url
|
||||
|
||||
import logging
|
||||
log = logging.getLogger("feedBack.server")
|
||||
router = APIRouter()
|
||||
|
||||
# MB artist url-relation types → the page's link slots (locked position 4:
|
||||
# whitelist only, links-only forever). Everything not listed is dropped.
|
||||
_ARTIST_URL_REL_SLOTS = {
|
||||
"official homepage": "official",
|
||||
"setlistfm": "tour",
|
||||
"concerts": "tour",
|
||||
"youtube": "video",
|
||||
"video channel": "video",
|
||||
"social network": "social",
|
||||
"bandcamp": "social",
|
||||
"soundcloud": "social",
|
||||
"wikipedia": "wikipedia",
|
||||
"wikidata": "wikipedia",
|
||||
}
|
||||
|
||||
|
||||
def _artist_links_from_mb(body: dict) -> tuple[dict, list]:
|
||||
"""Whitelist an MB artist doc's url-relations into the page's link slots:
|
||||
{official, tour, video, social: [...], wikipedia}. Every URL passes the
|
||||
same http(s)-scheme gate as art redirects (_safe_art_redirect_url) so a
|
||||
hostile javascript:/data:/file: resource can never reach an href. First
|
||||
URL wins per single slot; social collects up to 5; wikipedia is preferred
|
||||
over wikidata when both exist. Also returns MB's genre names (capped)."""
|
||||
links: dict = {}
|
||||
social: list = []
|
||||
wikidata_url = None
|
||||
for rel in (body or {}).get("relations") or []:
|
||||
if not isinstance(rel, dict):
|
||||
continue
|
||||
rtype = str(rel.get("type") or "").strip().lower()
|
||||
slot = _ARTIST_URL_REL_SLOTS.get(rtype)
|
||||
if not slot:
|
||||
continue
|
||||
url = rel.get("url")
|
||||
url = url.get("resource") if isinstance(url, dict) else url
|
||||
if _safe_art_redirect_url(url) is None:
|
||||
continue
|
||||
if slot == "social":
|
||||
if url not in social and len(social) < 5:
|
||||
social.append(url)
|
||||
elif rtype == "wikidata":
|
||||
wikidata_url = wikidata_url or url
|
||||
elif slot not in links:
|
||||
links[slot] = url
|
||||
if social:
|
||||
links["social"] = social
|
||||
if "wikipedia" not in links and wikidata_url:
|
||||
links["wikipedia"] = wikidata_url
|
||||
genres = [str(g.get("name")) for g in (body or {}).get("genres") or []
|
||||
if isinstance(g, dict) and g.get("name")]
|
||||
return links, genres[:8]
|
||||
|
||||
|
||||
def _artist_links_payload(name: str, force: bool = False) -> dict:
|
||||
"""Shared by GET links + POST refresh. Order of gates: the user's opt-in
|
||||
setting (external links are OFF by default — the dev-chat thread's call),
|
||||
then a known mb_artist_id (no id → nothing to look up), then the cache
|
||||
(unless force), then the offline guard, then ONE throttled fetch."""
|
||||
cfg = _load_config(appstate.config_dir / "config.json") or appstate.default_settings()
|
||||
if cfg.get("artist_external_links") is not True:
|
||||
return {"links": {}, "matched": False, "disabled": True}
|
||||
canonical = appstate.meta_db._terminal_canonical((name or "").strip())
|
||||
mbid = appstate.meta_db.artist_known_mb_id(appstate.meta_db._raw_variants_for(canonical))
|
||||
mbid = (mbid or "").strip().lower()
|
||||
# The id is interpolated into the MB request path — same strict-shape rule
|
||||
# as the manifest identity keys (_MBID_RE), so a junk/hostile value stored
|
||||
# via a hand-rolled /pick body can never reach the request line.
|
||||
if not mbid or not enrichment._MBID_RE.match(mbid):
|
||||
return {"links": {}, "matched": False}
|
||||
if not force:
|
||||
cached = appstate.meta_db.get_artist_enrichment(mbid)
|
||||
if cached:
|
||||
return {"links": cached["url_rels"], "genres": cached["genres"],
|
||||
"matched": True, "cached": True, "mb_artist_id": mbid}
|
||||
if not enrichment._enrich_network_enabled():
|
||||
return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid}
|
||||
try:
|
||||
body = enrichment._mb_http_get(f"artist/{mbid}", {"inc": "url-rels+genres+tags"})
|
||||
except enrichment.EnrichTransportError:
|
||||
return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid}
|
||||
links, genres = _artist_links_from_mb(body or {})
|
||||
appstate.meta_db.put_artist_enrichment(mbid, links, genres)
|
||||
return {"links": links, "genres": genres, "matched": True, "cached": False,
|
||||
"mb_artist_id": mbid}
|
||||
|
||||
|
||||
@router.get("/api/artist/{name:path}/page")
|
||||
def api_artist_page(name: str):
|
||||
"""The artist page's all-LOCAL payload — counts, albums, aliases, similar-
|
||||
in-library, mosaic art, play-all seed. Never touches the network; an
|
||||
unmatched or even unknown artist still returns a functional page."""
|
||||
return appstate.meta_db.artist_page(name)
|
||||
|
||||
|
||||
@router.get("/api/artist/{name:path}/links")
|
||||
def api_artist_links(name: str):
|
||||
"""External links for a matched artist — cached after the first call.
|
||||
Sync route on purpose (like /api/enrichment/search): FastAPI runs it in
|
||||
the threadpool so the MB throttle's sleep never blocks the event loop."""
|
||||
return _artist_links_payload(name)
|
||||
|
||||
|
||||
@router.post("/api/artist/{name:path}/links/refresh")
|
||||
def api_artist_links_refresh(name: str):
|
||||
"""Explicit re-fetch of the cached links (the page's manual Refresh)."""
|
||||
return _artist_links_payload(name, force=True)
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Media/file-serving routes: song audio (/audio/{f}), the local-audio-path
|
||||
resolver (/api/audio-local-path), and raw sloppak member serving
|
||||
(/api/sloppak/{f}/file/{rel}).
|
||||
|
||||
Extracted verbatim from server.py (R3) except @app->@router and the cache/static
|
||||
path seams (AUDIO_CACHE_DIR->appstate.audio_cache_dir, STATIC_DIR->
|
||||
appstate.static_dir, SLOPPAK_CACHE_DIR->appstate.sloppak_cache_dir).
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
import appstate
|
||||
import sloppak as sloppak_mod
|
||||
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||
|
||||
import logging
|
||||
log = logging.getLogger("feedBack.server")
|
||||
router = APIRouter()
|
||||
|
||||
def _resolve_sloppak_local_file(filename: str, rel_path: str):
|
||||
"""Resolve a file inside a sloppak to its on-disk path.
|
||||
|
||||
Applies the same containment guards as ``serve_sloppak_file``. Returns the
|
||||
resolved ``Path`` on success, or an ``(error, status)`` tuple on failure so
|
||||
callers can produce their endpoint-appropriate response.
|
||||
"""
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
return ("not configured", 404)
|
||||
# `filename` is caller-controlled. Contain it under DLC_DIR before it
|
||||
# reaches the resolver (see serve_sloppak_file for the traversal rationale).
|
||||
resolved = _resolve_dlc_path(dlc, filename)
|
||||
if resolved is None:
|
||||
return ("forbidden", 403)
|
||||
# Confine to actual sloppak bundles — otherwise any plain subdirectory
|
||||
# would become a read-any-file-under-DLC_DIR source.
|
||||
if not sloppak_mod.is_sloppak(resolved):
|
||||
return ("not found", 404)
|
||||
# Canonicalise the cache key against the resolved path so equivalent URL
|
||||
# forms of the same sloppak converge on one _source_cache entry.
|
||||
try:
|
||||
filename = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
# safe_join already proved containment; fail closed regardless.
|
||||
return ("forbidden", 403)
|
||||
src = sloppak_mod.get_cached_source_dir(filename)
|
||||
if src is None:
|
||||
try:
|
||||
src = sloppak_mod.resolve_source_dir(filename, dlc, appstate.sloppak_cache_dir)
|
||||
except Exception:
|
||||
return ("not found", 404)
|
||||
# Prevent path traversal within the sloppak.
|
||||
target = (src / rel_path).resolve()
|
||||
try:
|
||||
target.relative_to(src.resolve())
|
||||
except ValueError:
|
||||
return ("forbidden", 403)
|
||||
if not target.exists() or not target.is_file():
|
||||
return ("not found", 404)
|
||||
return target
|
||||
|
||||
|
||||
@router.get("/api/sloppak/{filename:path}/file/{rel_path:path}")
|
||||
def serve_sloppak_file(filename: str, rel_path: str):
|
||||
"""Serve a file from inside a sloppak (stems, cover, etc.)."""
|
||||
result = _resolve_sloppak_local_file(filename, rel_path)
|
||||
if isinstance(result, tuple):
|
||||
error, status = result
|
||||
return JSONResponse({"error": error}, status)
|
||||
target = result
|
||||
ext = target.suffix.lower()
|
||||
mt = {
|
||||
".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg",
|
||||
".mp3": "audio/mpeg", ".wav": "audio/wav", ".flac": "audio/flac",
|
||||
".m4a": "audio/mp4",
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".png": "image/png", ".webp": "image/webp",
|
||||
".json": "application/json",
|
||||
}.get(ext)
|
||||
return FileResponse(str(target), media_type=mt) if mt else FileResponse(str(target))
|
||||
|
||||
|
||||
@router.get("/api/audio-local-path")
|
||||
def audio_local_path(url: str, request: Request):
|
||||
"""Return absolute local filesystem path for a song URL (Electron desktop only).
|
||||
|
||||
Accepts ``/audio/<path>`` where ``<path>`` may include subdirectory segments —
|
||||
no scheme, no host, no query string, no fragment. The resolved path must stay
|
||||
inside appstate.audio_cache_dir or appstate.static_dir; ``..`` traversal, backslashes, and
|
||||
absolute ``filename`` values are rejected.
|
||||
|
||||
Also accepts ``/api/sloppak/<filename>/file/<rel>`` (percent-encoded, as
|
||||
emitted by the highway song payload) and resolves it to the unpacked
|
||||
sloppak cache file via the same containment guards as
|
||||
``serve_sloppak_file`` — this lets the desktop engine play a feedpak
|
||||
full-mix natively under WASAPI-exclusive output.
|
||||
|
||||
This endpoint returns a raw filesystem path and is intended exclusively for
|
||||
the Electron desktop process (which runs on loopback). Requests from non-
|
||||
loopback clients are rejected with 403.
|
||||
"""
|
||||
# Loopback-only — only the local Electron process should call this
|
||||
client_host = request.client.host if request.client else None
|
||||
try:
|
||||
is_loopback = bool(client_host and ipaddress.ip_address(client_host).is_loopback)
|
||||
except ValueError:
|
||||
is_loopback = client_host == "localhost"
|
||||
if not is_loopback:
|
||||
return JSONResponse({"error": "forbidden"}, status_code=403)
|
||||
# Sloppak in-pack file (feedpak full-mix): /api/sloppak/<fn>/file/<rel>.
|
||||
# Both segments arrive percent-encoded (built with urllib quote() in the
|
||||
# highway payload); decode before handing to the shared resolver, which
|
||||
# re-applies all containment guards on the decoded values.
|
||||
slop_match = re.fullmatch(r"/api/sloppak/([^?#]+)/file/([^?#]+)", url)
|
||||
if slop_match:
|
||||
from urllib.parse import unquote
|
||||
|
||||
result = _resolve_sloppak_local_file(
|
||||
unquote(slop_match.group(1)), unquote(slop_match.group(2))
|
||||
)
|
||||
if isinstance(result, tuple):
|
||||
error, status = result
|
||||
return JSONResponse({"error": error}, status_code=status)
|
||||
return JSONResponse({"path": str(result)})
|
||||
# Accept only simple /audio/<filename> — no scheme, no host, no query/fragment
|
||||
if not re.fullmatch(r"/audio/[^?#]+", url):
|
||||
return JSONResponse({"error": "invalid url"}, status_code=400)
|
||||
filename = url[len("/audio/"):]
|
||||
# Reject traversal, absolute paths, and backslash separators
|
||||
if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename:
|
||||
return JSONResponse({"error": "invalid url"}, status_code=400)
|
||||
for d in [appstate.audio_cache_dir, appstate.static_dir]:
|
||||
candidate = (d / filename).resolve()
|
||||
# Ensure resolved path is inside the allowed directory
|
||||
try:
|
||||
candidate.relative_to(d.resolve())
|
||||
except ValueError:
|
||||
continue
|
||||
if candidate.is_file():
|
||||
return JSONResponse({"path": str(candidate)})
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
|
||||
@router.get("/audio/{filename:path}")
|
||||
def serve_audio(filename: str):
|
||||
"""Serve audio files from the writable audio cache directory."""
|
||||
# Reject traversal attempts and absolute-path components
|
||||
if ".." in filename.split("/") or filename.startswith("/") or "\\" in filename:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
for d in [appstate.audio_cache_dir, appstate.static_dir]:
|
||||
candidate = (d / filename).resolve()
|
||||
try:
|
||||
candidate.relative_to(d.resolve())
|
||||
except ValueError:
|
||||
continue
|
||||
if candidate.is_file():
|
||||
return FileResponse(str(candidate))
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
@@ -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,
|
||||
_sync_collection_provider,
|
||||
_safe_art_redirect_url, _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
|
||||
@@ -53,8 +53,6 @@ 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/
|
||||
@@ -67,6 +65,7 @@ from scan_worker import _extract_meta_for_file, _relpath, _scan_one
|
||||
|
||||
import concurrent.futures
|
||||
import inspect
|
||||
import ipaddress
|
||||
import multiprocessing
|
||||
import re
|
||||
import threading
|
||||
@@ -2053,18 +2052,112 @@ 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).
|
||||
|
||||
# ── Artist routes → routers/artist.py (R3) ──────────────────────────────────
|
||||
app.include_router(artist_router.router)
|
||||
# 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(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)
|
||||
@@ -2367,10 +2460,67 @@ _extract_cache = {} # filename -> (tmp_dir, song, timestamp)
|
||||
_extract_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
# ── Media/file-serving routes → routers/media.py (R3) ───────────────────────
|
||||
app.include_router(media_router.router)
|
||||
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
|
||||
|
||||
|
||||
@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 ──────────────────────────────────────────────────
|
||||
@@ -2382,8 +2532,82 @@ 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")
|
||||
|
||||
+120
-10
@@ -5375,15 +5375,94 @@ window.jucePlayer = jucePlayer;
|
||||
if (_elCtx) return;
|
||||
const el = document.getElementById('audio');
|
||||
if (!el) throw new Error('no core audio element');
|
||||
_elCtx = new AudioContext();
|
||||
_elSource = _elCtx.createMediaElementSource(el);
|
||||
_elSource.connect(_elCtx.destination);
|
||||
_elTap = _makeTap(_elCtx);
|
||||
await _elTap.attach(_elSource);
|
||||
// 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;
|
||||
}
|
||||
|
||||
// ── Engagement state machine ─────────────────────────────────────────────
|
||||
// 'off' | 'element' | 'stems'
|
||||
// 'off' | 'loopback' | 'element' | 'stems' (element/stems = fallback when
|
||||
// loopback capture is unavailable: old desktop main, denied capture)
|
||||
let _mode = 'off';
|
||||
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
|
||||
let _stemsTap = null;
|
||||
@@ -5408,7 +5487,9 @@ window.jucePlayer = jucePlayer;
|
||||
const prev = _mode;
|
||||
_mode = 'off';
|
||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||
if (prev === 'element' && _elCtx) {
|
||||
if (prev === 'loopback') {
|
||||
await _teardownLoopback();
|
||||
} else if (prev === 'element' && _elCtx) {
|
||||
_elTap.active = false;
|
||||
await _setSink(_elCtx, false).catch(() => {});
|
||||
} else if (prev === 'stems' && _stemsGraph) {
|
||||
@@ -5467,9 +5548,20 @@ window.jucePlayer = jucePlayer;
|
||||
|
||||
let want = 'off';
|
||||
if (running && exclusive) {
|
||||
if (stems) want = 'stems';
|
||||
// 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';
|
||||
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
|
||||
@@ -5481,6 +5573,7 @@ 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;
|
||||
@@ -5492,12 +5585,29 @@ window.jucePlayer = jucePlayer;
|
||||
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
|
||||
if (want !== _mode || stemsGraphChanged) {
|
||||
await _disengage();
|
||||
if (want === 'stems') await _engageStems(stems);
|
||||
else if (want === 'element') await _engageElement();
|
||||
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;
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
||||
@@ -50,17 +50,42 @@ function makeFakeContext(sampleRate = 48000) {
|
||||
this.mediaSourceEl = el;
|
||||
return { connect() {}, disconnect() {} };
|
||||
},
|
||||
createMediaStreamSource(stream) {
|
||||
this.mediaStreamSource = stream;
|
||||
return { connect() {}, disconnect() {} };
|
||||
},
|
||||
close() { this.closed = true; return Promise.resolve(); },
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
|
||||
const calls = { setRendererBus: [], pushRendererAudio: [] };
|
||||
// Fake getDisplayMedia stream for the loopback-capture path.
|
||||
function makeLoopbackStream({ suppressed = true } = {}) {
|
||||
const stopped = [];
|
||||
const audioTrack = {
|
||||
kind: 'audio',
|
||||
stop() { stopped.push('audio'); },
|
||||
getSettings: () => (suppressed ? { suppressLocalAudioPlayback: true } : {}),
|
||||
};
|
||||
const videoTrack = { kind: 'video', stop() { stopped.push('video'); } };
|
||||
return {
|
||||
__stopped: stopped,
|
||||
getAudioTracks: () => [audioTrack],
|
||||
getVideoTracks: () => [videoTrack],
|
||||
getTracks: () => [videoTrack, audioTrack],
|
||||
};
|
||||
}
|
||||
|
||||
// `displayMedia`: undefined → loopback capture unavailable (Docker sphere /
|
||||
// old desktop main); a function → used as navigator.mediaDevices.getDisplayMedia.
|
||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true, displayMedia } = {}) {
|
||||
const calls = { setRendererBus: [], pushRendererAudio: [], setPageMuted: [] };
|
||||
|
||||
const api = {
|
||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
|
||||
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
|
||||
setPageMuted: (m) => { calls.setPageMuted.push(m); return Promise.resolve(m); },
|
||||
};
|
||||
|
||||
class FakeWorkletNode {
|
||||
@@ -85,6 +110,7 @@ function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {
|
||||
__createdContexts: [],
|
||||
__audioEl: { id: 'audio' },
|
||||
__calls: calls,
|
||||
navigator: { mediaDevices: displayMedia ? { getDisplayMedia: displayMedia } : {} },
|
||||
window: null,
|
||||
};
|
||||
sandbox.window = {
|
||||
@@ -111,12 +137,21 @@ function makeStemsGraph() {
|
||||
};
|
||||
}
|
||||
|
||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
|
||||
// Surface-mode (stems/element) tests run WITHOUT getDisplayMedia: the first
|
||||
// tick probes loopback, fails, and latches _loopbackUnavailable; the second
|
||||
// tick exercises the fallback surface mode. This mirrors an old desktop main
|
||||
// without the display-media handler.
|
||||
async function reevaluateWithFallback(sb) {
|
||||
await sb.window._reevaluateRendererBus(); // loopback probe → unavailable
|
||||
await sb.window._reevaluateRendererBus(); // surface fallback
|
||||
}
|
||||
|
||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked (loopback unavailable)', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
|
||||
@@ -128,7 +163,7 @@ test('output returns to shared → bus disabled, sink restored', async () => {
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
@@ -145,26 +180,27 @@ test('stems graph + shared output → feeder stays off (no double audio)', async
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
|
||||
});
|
||||
|
||||
test('element song + exclusive → element captured into bus', async () => {
|
||||
test('element song + exclusive → element captured into bus (loopback unavailable)', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||
sb.window._juceMode = false;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
|
||||
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
});
|
||||
|
||||
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
|
||||
test('native-transport song, loopback unavailable → surface modes stay off', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
|
||||
sb.window._juceMode = true;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
|
||||
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true),
|
||||
'bus never ENABLED (failed-probe cleanup may disable it)');
|
||||
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
|
||||
});
|
||||
|
||||
@@ -172,7 +208,7 @@ test('stems graph replaced mid-engagement → re-engages on the new graph', asyn
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const g1 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g1;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
const g2 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g2;
|
||||
@@ -182,6 +218,105 @@ test('stems graph replaced mid-engagement → re-engages on the new graph', asyn
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
|
||||
});
|
||||
|
||||
// ── Loopback mode (whole-app capture) ────────────────────────────────────────
|
||||
|
||||
test('exclusive output + loopback available → engages without any song loaded', async () => {
|
||||
const stream = makeLoopbackStream();
|
||||
const sb = makeSandbox({ exclusive: () => true, displayMedia: () => Promise.resolve(stream) });
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled for whole session');
|
||||
assert.ok(stream.__stopped.includes('video'), 'unused video track stopped');
|
||||
assert.equal(sb.__createdContexts.at(-1)?.mediaStreamSource, stream, 'loopback stream captured');
|
||||
assert.equal(sb.__calls.setPageMuted.length, 0, 'suppress constraint honoured — no page mute');
|
||||
});
|
||||
|
||||
test('loopback context is closed on disengage (no orphaned tap worklet)', async () => {
|
||||
let excl = true;
|
||||
const stream = makeLoopbackStream();
|
||||
const sb = makeSandbox({ exclusive: () => excl, displayMedia: () => Promise.resolve(stream) });
|
||||
|
||||
await sb.window._reevaluateRendererBus(); // engage loopback
|
||||
const lbCtx = sb.__createdContexts.at(-1);
|
||||
assert.equal(lbCtx?.mediaStreamSource, stream, 'loopback engaged');
|
||||
assert.notEqual(lbCtx.closed, true, 'context live while engaged');
|
||||
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus(); // disengage
|
||||
assert.equal(lbCtx.closed, true, 'loopback context closed on disengage');
|
||||
assert.ok(stream.__stopped.includes('audio'), 'capture stream stopped');
|
||||
});
|
||||
|
||||
test('loopback preferred over stems when both available', async () => {
|
||||
const stream = makeLoopbackStream();
|
||||
const sb = makeSandbox({ exclusive: () => true, displayMedia: () => Promise.resolve(stream) });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(graph.context.sinkIdCalls.length, 0, 'stems ctx untouched — loopback owns capture');
|
||||
assert.equal(sb.__createdContexts.at(-1)?.mediaStreamSource, stream, 'loopback engaged');
|
||||
});
|
||||
|
||||
test('suppressLocalAudioPlayback unsupported → page-mute fallback, unmuted on disengage', async () => {
|
||||
let excl = true;
|
||||
const stream = makeLoopbackStream({ suppressed: false });
|
||||
const sb = makeSandbox({ exclusive: () => excl, displayMedia: () => Promise.resolve(stream) });
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
assert.deepEqual(sb.__calls.setPageMuted, [true], 'page muted as fallback');
|
||||
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
assert.deepEqual(sb.__calls.setPageMuted, [true, false], 'page unmuted on disengage');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled');
|
||||
});
|
||||
|
||||
test('getDisplayMedia rejected → sticky fallback to surface modes', async () => {
|
||||
const sb = makeSandbox({
|
||||
exclusive: () => true,
|
||||
displayMedia: () => Promise.reject(new DOMException('denied', 'NotAllowedError')),
|
||||
});
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus(); // probe fails, latches unavailable
|
||||
await sb.window._reevaluateRendererBus(); // falls back to stems
|
||||
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems fallback engaged');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled via fallback');
|
||||
});
|
||||
|
||||
test('element capture collision (createMediaElementSource throws) → no poisoned state, clean retry', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true }); // loopback unavailable
|
||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||
// First capture attempt collides (highway analyser owns the element).
|
||||
let collide = true;
|
||||
const origFactory = sb.AudioContext;
|
||||
sb.__createdContexts.length = 0;
|
||||
// Patch contexts so createMediaElementSource throws while colliding.
|
||||
sb.AudioContext = function () {
|
||||
const c = origFactory();
|
||||
const orig = c.createMediaElementSource.bind(c);
|
||||
c.createMediaElementSource = (el) => {
|
||||
if (collide) throw new DOMException('already connected', 'InvalidStateError');
|
||||
return orig(el);
|
||||
};
|
||||
c.close = () => Promise.resolve();
|
||||
return c;
|
||||
};
|
||||
|
||||
await reevaluateWithFallback(sb); // element engage fails (collision)
|
||||
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true), 'bus never left enabled');
|
||||
|
||||
collide = false;
|
||||
await sb.window._reevaluateRendererBus(); // retry succeeds — no TypeError, fresh ctx
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'element engaged after collision cleared');
|
||||
});
|
||||
|
||||
test('engine stops → bus disabled', async () => {
|
||||
let running = true;
|
||||
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
|
||||
|
||||
@@ -25,7 +25,6 @@ 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:
|
||||
@@ -124,7 +123,6 @@ 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
|
||||
|
||||
@@ -37,7 +37,6 @@ 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