mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 08:14:29 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1cd1b3bbb | ||
|
|
73127d5416 | ||
|
|
165475d115 | ||
|
|
508829c012 | ||
|
|
cce95cbd1e | ||
|
|
32ebc7671e | ||
|
|
46f3be7fd7 | ||
|
|
76159c16cd | ||
|
|
4cc8fa3b4d | ||
|
|
f9f33320ac | ||
|
|
5f58af4faa | ||
|
|
ea8834862d | ||
|
|
2281cac438 | ||
|
|
b6098e3695 | ||
|
|
cbc65458e3 | ||
|
|
7c87538d6b | ||
|
|
c8701991cb | ||
|
|
514461167e | ||
|
|
0dcc9136b6 |
+3
-1
@@ -26,7 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
next root-level module can't ship broken.
|
||||
|
||||
### Added
|
||||
- **`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 + custom covers). The `config_dir` path constant now rides 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.
|
||||
- **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 `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
|
||||
@@ -39,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
the second slot. The `_demo_mode_guard` middleware still blocks all four moved write
|
||||
routes with 403, and `Query(...)` validation still 422s — both checked against a running
|
||||
server. `server.py`: **9,445 → 9,386 lines**.
|
||||
- **`lib/enrichment.py` — the metadata-enrichment subsystem leaves `server.py` (R3, move-only).** MusicBrainz / Cover-Art-Archive / AcoustID transport, the match-scorer glue, and the background enrichment worker (~930 lines, 61 defs) move out as one cohesive unit. Bodies are verbatim; the only changes are seam reads — `meta_db`/`config_dir`/`sloppak_cache_dir`/`art_cache_dir` and the two shared art helpers (`song_pack_art_exists`, `art_override_paths`, which stay in `server.py` for the art/delete routes) are reached through `appstate` at call time, and the User-Agent VERSION lookup is corrected for the module's new depth. `server.py` drives the worker through the module (`import enrichment`; the routes + scan lifecycle call `enrichment.X`); tests that faked the network on `server` now patch the same names on `enrichment` (module attribute resolved at call time, so one `setattr` reaches both the routes and the worker's internal callers). Acyclic — `enrichment` imports no `server`. Route table byte-identical; full suite green. `server.py`: 6,917 → 5,988.
|
||||
- **`appstate.py` — the router seam (R3).** Route modules moving out of `server.py`
|
||||
need `meta_db` and friends but must not `import server`, or the import graph goes
|
||||
circular the moment `server` imports them back. So `server.py` keeps *constructing*
|
||||
|
||||
@@ -37,6 +37,31 @@ the hooks, they just need real songs in `DLC_DIR`.
|
||||
|
||||
## Results
|
||||
|
||||
### R3c pre-lift baseline — 2026-07-10 (2D highway draw cost)
|
||||
|
||||
The gate for the `highway.js` split. Captured on a seeded library (the 33 MB
|
||||
Arcturus feedpak) with the new `--song` mode, which measures **per-frame draw
|
||||
cost** — rAF callbacks are tagged via `highway.addDrawHook`, so only frames the
|
||||
highway actually painted count (the other ~half are cheap no-op loops that would
|
||||
otherwise mask a regression). Any `highway.js` change must re-run this on the
|
||||
same machine and stay within noise of these numbers.
|
||||
|
||||
```bash
|
||||
node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 \
|
||||
--song "Arcturus - The Sham Mirrors - Kinetic.feedpak"
|
||||
```
|
||||
|
||||
| run | draw frames | p50 | p95 | p99 | max |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 53/106 | 2.2 | 3.2 | 3.6 | 3.6 |
|
||||
| 2 | 50/100 | 2.2 | 2.9 | 3.2 | 3.2 |
|
||||
| 3 | 53/106 | 2.1 | 2.7 | 3.5 | 3.5 |
|
||||
|
||||
**p50 ≈ 2.2 ms · p95 spread 2.7–3.2 ms** (3 runs × 10 s playback, headless
|
||||
chromium on the dev box). The `H`-container lift changes each closure-slot read
|
||||
to a `H.<slot>` property load; this is the number that proves it doesn't cost the
|
||||
hot loop.
|
||||
|
||||
### R0 baseline — 2026-07-08 (branch `feat/r0-plugin-module-rails`)
|
||||
|
||||
> ⚠️ A quick capture (`--n 50 --soak 8`) against an **empty** library (no charts
|
||||
|
||||
@@ -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`
|
||||
(9,085 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and four `routers/` modules) ·
|
||||
(4,478 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and seventeen `routers/` modules (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,28 @@
|
||||
"""Reading the app's config.json — the one shared, pure helper (R3).
|
||||
|
||||
Extracted verbatim from server.py so route modules that need a config value
|
||||
(reference pitch, server_config, …) can read it without reaching back into the
|
||||
host file. server.py re-imports it, so its ~11 call sites and any
|
||||
`server._load_config` test reference keep resolving unchanged.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def _load_config(config_file):
|
||||
"""Read and parse config.json. Returns the parsed dict, or None if
|
||||
the file is missing, unreadable, invalid JSON, or parses to a
|
||||
non-dict (e.g. the file contains `[]` or `42`). Callers treat None
|
||||
as "fall back to defaults". Shared between GET and POST so both
|
||||
handle bad files the same way."""
|
||||
if not config_file.exists():
|
||||
return None
|
||||
try:
|
||||
# Explicit UTF-8: save_settings()/import write config.json as
|
||||
# UTF-8 bytes, so the read must not depend on the platform's
|
||||
# default text encoding (cp1252 on Windows would mojibake or
|
||||
# UnicodeDecodeError on a non-ASCII DLC path).
|
||||
parsed = json.loads(config_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
+41
-1
@@ -61,6 +61,10 @@ copies a hardcoded file list — that regression is what moved this file here.
|
||||
# The singletons routers may read. Every name here must also be a `_SLOTS` key.
|
||||
meta_db = None
|
||||
audio_effect_mappings = None
|
||||
# The tuning-provider registry instance (built-ins + plugin-contributed). A
|
||||
# stable object mutated in place via register()/unregister() — injected here by
|
||||
# reference so routers read the same registry plugins populate.
|
||||
tuning_providers = None
|
||||
|
||||
# Config paths. server.py derives these from the environment (fresh on every
|
||||
# import, so the ~49 pop-and-reimport fixtures keep working) and injects them
|
||||
@@ -70,8 +74,44 @@ audio_effect_mappings = None
|
||||
# `setattr(server, …)` in a few tests, so those slots (when added) need their
|
||||
# tests retargeted to appstate in the same PR.
|
||||
config_dir = None
|
||||
dlc_dir = None # the DLC_DIR env value as a Path (Path("") if unset)
|
||||
dlc_dir_env = None # the raw DLC_DIR env string, "" if unset — distinguishes
|
||||
# "unset" from Path("")→"." (see dlc_paths._get_dlc_dir)
|
||||
# Cache/asset dirs. static_dir + sloppak_cache_dir are patched via
|
||||
# `setattr(server, …)` in a few tests, so a router reading them here needs those
|
||||
# setattr sites retargeted to `setattr(appstate, …)` in the same PR (ws_highway
|
||||
# retargets the 3 test_highway_ws_* SLOPPAK sites). config_dir-derived dirs are
|
||||
# reconfigured for free on a setenv+reimport.
|
||||
static_dir = None
|
||||
sloppak_cache_dir = None
|
||||
audio_cache_dir = None
|
||||
|
||||
_SLOTS = frozenset({"meta_db", "audio_effect_mappings", "config_dir"})
|
||||
# Injected callables (not values): server owns the impl + its state, routers call
|
||||
# through the seam. get_progression_content wraps a lazy content cache that stays
|
||||
# in server.py (its `setattr(server, "_progression_content")` test is untouched).
|
||||
get_progression_content = None
|
||||
builtin_diagnostic_filename = None
|
||||
running_version = None
|
||||
# Art helpers that stay in server.py (shared with the art/delete routes) but are
|
||||
# also called by the enrichment worker in lib/enrichment.py — injected as
|
||||
# callables to keep enrichment acyclic. art_cache_dir is server's ART_CACHE_DIR.
|
||||
art_cache_dir = None
|
||||
song_pack_art_exists = None
|
||||
art_override_paths = None
|
||||
art_safe_name = None
|
||||
# The canonical settings-defaults builder — stays in server.py (shared with the
|
||||
# scan/artist-links code) but the settings router calls it through the seam.
|
||||
default_settings = None
|
||||
|
||||
_SLOTS = frozenset({
|
||||
"meta_db", "audio_effect_mappings", "tuning_providers",
|
||||
"config_dir", "dlc_dir", "dlc_dir_env",
|
||||
"static_dir", "sloppak_cache_dir", "audio_cache_dir",
|
||||
"get_progression_content", "builtin_diagnostic_filename",
|
||||
"running_version",
|
||||
"art_cache_dir", "song_pack_art_exists", "art_override_paths", "art_safe_name",
|
||||
"default_settings",
|
||||
})
|
||||
|
||||
|
||||
def configure(**kwargs) -> None:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""DLC library path resolution — where the song files live, plus safe containment.
|
||||
|
||||
Extracted from ``server.py`` (R3). ``_resolve_dlc_path`` is pure and moved
|
||||
verbatim. ``_get_dlc_dir`` reads the env-derived paths through the ``appstate``
|
||||
seam (``server.py`` configures ``dlc_dir``/``dlc_dir_env``/``config_dir`` at
|
||||
import, fresh on every re-import), so this module does no import-time IO and the
|
||||
pop-and-reimport fixtures keep working. ``server.py`` re-exports both names, so
|
||||
existing ``server._get_dlc_dir`` / ``server._resolve_dlc_path`` references
|
||||
(tests, other handlers) resolve unchanged.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import appstate
|
||||
|
||||
|
||||
def _get_dlc_dir(cfg: dict | None = None) -> Path | None:
|
||||
# Only consider DLC_DIR if the env var was non-empty. `Path("")` collapses
|
||||
# to `.` and reports `.is_dir() == True`, which would silently shadow the
|
||||
# config.json fallback. Checking the raw env string preserves
|
||||
# `DLC_DIR=.` as a valid opt-in for cwd while keeping unset/empty out.
|
||||
if appstate.dlc_dir_env and appstate.dlc_dir.is_dir():
|
||||
return appstate.dlc_dir
|
||||
if cfg is None:
|
||||
config_file = appstate.config_dir / "config.json"
|
||||
if config_file.exists():
|
||||
try:
|
||||
cfg = json.loads(config_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
if isinstance(cfg, dict):
|
||||
raw = str(cfg.get("dlc_dir", "")).strip()
|
||||
if raw:
|
||||
p = Path(raw)
|
||||
if p.is_dir():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
|
||||
"""Resolve `filename` under DLC_DIR and refuse anything that escapes.
|
||||
|
||||
`filename` arrives from `:path` route params and can contain `..`
|
||||
segments. The Sloppak and archive paths happen to fail safely later
|
||||
because their loaders raise on missing/invalid files, but loose-
|
||||
folder format detection (`is_loose_song`) globs and parses XML on
|
||||
disk first, which lets a crafted path trigger filesystem reads
|
||||
outside DLC_DIR before any guard fires. Centralise the containment
|
||||
check so every filename-bound handler validates before touching the
|
||||
filesystem.
|
||||
|
||||
Containment here is LEXICAL (normalize `.`/`..` WITHOUT following
|
||||
symlinks), not `safe_join`'s `.resolve()`-based check — because users
|
||||
commonly mount their song library through a directory JUNCTION/symlink
|
||||
(a library shared across app installs; the desktop app's own mounts).
|
||||
`.resolve()` follows that junction to its real target, sees it sits
|
||||
outside DLC_DIR, and wrongly rejects every song reached through it — the
|
||||
scanner's `rglob` indexes those songs, but art/load then 403/404s (broken
|
||||
covers, unplayable songs). Lexical normalization still rejects the only
|
||||
escapes a `:path` filename can express — `..` traversal and absolute
|
||||
paths — which the traversal tests pin. `safe_join` stays strict (it is
|
||||
the zip-slip / plugin-asset guard, where following a symlink out IS the
|
||||
defense); the loose-folder art handler keeps its own per-file symlink
|
||||
re-check for defence-in-depth.
|
||||
|
||||
Returns the validated Path (not necessarily link-resolved), or None if
|
||||
the filename is empty, contains a NUL, or escapes the DLC root.
|
||||
"""
|
||||
if not filename:
|
||||
return None
|
||||
# Backslashes → forward slashes so a Windows-style `..\\x` traversal is
|
||||
# rejected identically on POSIX (mirrors safe_join's normalisation).
|
||||
safe = filename.replace("\\", "/")
|
||||
if "\x00" in safe:
|
||||
return None
|
||||
# Reject drive-letter / absolute paths in BOTH conventions. A POSIX "/x" is
|
||||
# caught by the containment check below (the `/` operator discards `root`),
|
||||
# but a Windows drive-absolute "C:/x" is treated as a relative "C:" dir on
|
||||
# POSIX and would otherwise slip in as `<root>/C:/x` — so the contract must
|
||||
# hold cross-platform (a shared library is reached from either OS).
|
||||
from pathlib import PurePosixPath, PureWindowsPath
|
||||
if (PurePosixPath(safe).is_absolute()
|
||||
or PureWindowsPath(safe).is_absolute()
|
||||
or PureWindowsPath(safe).drive):
|
||||
return None
|
||||
try:
|
||||
root = dlc.resolve()
|
||||
# normpath collapses `.`/`..`/duplicate separators purely lexically —
|
||||
# it never touches the filesystem, so an in-library junction component
|
||||
# is preserved (allowed) while `..`/absolute segments still escape and
|
||||
# get caught by the containment check below.
|
||||
candidate = Path(os.path.normpath(root / safe))
|
||||
if not candidate.is_relative_to(root):
|
||||
return None
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
return candidate
|
||||
+1103
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,513 @@
|
||||
"""Album-art routes: serve / cover-search / candidates / upload / url / remove
|
||||
(/api/song/{filename}/art*, /api/art/{filename}/override).
|
||||
|
||||
Extracted verbatim from server.py (R3). Only the decorators (@app -> @router) and
|
||||
the seam reads change: meta_db -> appstate.meta_db, ART_CACHE_DIR ->
|
||||
appstate.art_cache_dir, and the three shared art helpers that stay in server.py
|
||||
(they are also used by the song/delete routes) -> appstate.<callable>
|
||||
(_song_pack_art_exists, _art_override_paths, _art_safe_name). The CAA / release
|
||||
search transport lives in lib/enrichment.py and is reached as enrichment.X.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
|
||||
import appstate
|
||||
import enrichment
|
||||
import loosefolder as loosefolder_mod
|
||||
import sloppak as sloppak_mod
|
||||
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||
|
||||
import logging
|
||||
log = logging.getLogger("feedBack.server")
|
||||
router = APIRouter()
|
||||
|
||||
def _if_none_match_hits(header: str | None, etag: str) -> bool:
|
||||
"""True if an If-None-Match header matches `etag` (weak comparison).
|
||||
|
||||
Handles the `*` wildcard and comma-separated lists, and ignores a weak
|
||||
`W/` prefix on either side — the standard semantics for a conditional GET.
|
||||
"""
|
||||
if not header:
|
||||
return False
|
||||
bare = etag.removeprefix("W/")
|
||||
for tok in header.split(","):
|
||||
t = tok.strip()
|
||||
if t == "*" or t.removeprefix("W/") == bare:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Album art is served with a strong validator (an ETag on the sloppak byte
|
||||
# path; FileResponse's own ETag/Last-Modified on the file paths) and revalidated
|
||||
# with `no-cache`. That keeps re-scroll cheap — a conditional GET returns a
|
||||
# bodyless 304 — without ever serving a stale cover. A long `immutable` max-age
|
||||
# was rejected: the frontend's `?v=<mtime>` buster is only second-resolution, so
|
||||
# a same-second cover rewrite would keep the URL and pin the old bytes for the
|
||||
# cache lifetime. Validation cost is negligible for a localhost backend.
|
||||
_ART_CACHE_HEADERS = {"Cache-Control": "no-cache"}
|
||||
|
||||
|
||||
def _art_etag(path: Path) -> str | None:
|
||||
"""Strong validator for an art file: nanosecond mtime + size (so a
|
||||
same-second rewrite still changes it). None if the file can't be stat'd."""
|
||||
try:
|
||||
st = path.stat()
|
||||
return f'"{st.st_mtime_ns}-{st.st_size}"'
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _art_conditional(etag: str | None, request: Request | None):
|
||||
"""Return (headers, not_modified) for an art response. `not_modified` is
|
||||
True when the client's If-None-Match already matches `etag` → caller should
|
||||
return a bodyless 304. Starlette's FileResponse emits an ETag but does NOT
|
||||
itself evaluate If-None-Match, so every art path routes through here to get
|
||||
real conditional handling."""
|
||||
headers = dict(_ART_CACHE_HEADERS)
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
inm = request.headers.get("if-none-match") if request is not None else None
|
||||
return headers, bool(etag) and _if_none_match_hits(inm, etag)
|
||||
|
||||
|
||||
def _file_art_response(path: Path, media_type: str, request: Request | None):
|
||||
"""FileResponse for an on-disk art file, with no-cache + ETag and a bodyless
|
||||
304 when the client's validator still matches."""
|
||||
headers, not_modified = _art_conditional(_art_etag(path), request)
|
||||
if not_modified:
|
||||
return Response(status_code=304, headers=headers)
|
||||
return FileResponse(str(path), media_type=media_type, headers=headers)
|
||||
|
||||
|
||||
@router.get("/api/song/{filename:path}/art")
|
||||
async def get_song_art(filename: str, request: Request = None, source: str = ""):
|
||||
"""Serve album art for a song, walking the R3 override chain:
|
||||
|
||||
1. USER OVERRIDE (upload / URL-fetch, {safe_name}.gif|.png in the art
|
||||
cache) — art the user explicitly pinned outranks everything, pack
|
||||
art included. GIF is allowed HERE only: an animated cover is a
|
||||
local-only bonus; packs stay jpg/png/webp and nothing ever writes
|
||||
art into a pack file.
|
||||
2. PACK ART — sloppak cover (single member read, no full unpack) or
|
||||
the loose folder's discovered image.
|
||||
3. COVER ART ARCHIVE cache — fetched by the enrichment art worker for
|
||||
matched songs that lack pack art, keyed by release MBID.
|
||||
|
||||
`?source=pack` narrows the chain to step 2 only (no override, no CAA):
|
||||
the cover picker's "Pack original" tile must show the pack's own art
|
||||
even while a user override is what the plain route serves. 404 when the
|
||||
song ships no art of its own.
|
||||
"""
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
return JSONResponse({"error": "not configured"}, 404)
|
||||
|
||||
song_path = _resolve_dlc_path(dlc, filename)
|
||||
if song_path is None:
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
if not song_path.exists():
|
||||
return JSONResponse({"error": "not found"}, 404)
|
||||
|
||||
pack_only = source == "pack"
|
||||
|
||||
# 1. User override — GIF first (it wins over a stale PNG override).
|
||||
if not pack_only:
|
||||
for cached in appstate.art_override_paths(filename):
|
||||
mt = "image/gif" if cached.suffix == ".gif" else "image/png"
|
||||
return _file_art_response(cached, mt, request)
|
||||
|
||||
# 2a. Sloppak: read the cover (manifest-declared or default) straight from
|
||||
# the package. For a zip-form sloppak this opens just the cover member —
|
||||
# NOT the whole archive — so the library grid never triggers a full unpack
|
||||
# of stems just to paint a thumbnail.
|
||||
if sloppak_mod.is_sloppak(song_path):
|
||||
# Read the cover (cheap — single member, no full unpack) and validate by
|
||||
# its CONTENT. A stat-based ETag would be wrong for directory-form
|
||||
# sloppaks: editing cover.jpg in place changes the file's mtime, not the
|
||||
# directory's, so a dir-stat ETag could emit a stale 304. Content hashing
|
||||
# is correct for both dir- and zip-form. Raw byte Response lacks
|
||||
# FileResponse's validators, so we attach the ETag + honor If-None-Match.
|
||||
try:
|
||||
art = await asyncio.to_thread(sloppak_mod.read_cover_bytes, song_path)
|
||||
except Exception:
|
||||
art = None
|
||||
if art is not None:
|
||||
data, mt = art
|
||||
etag = f'"{hashlib.sha1(data).hexdigest()}"'
|
||||
headers, not_modified = _art_conditional(etag, request)
|
||||
if not_modified:
|
||||
return Response(status_code=304, headers=headers)
|
||||
return Response(content=data, media_type=mt, headers=headers)
|
||||
|
||||
# 2b. Loose folder: serve the discovered art file directly.
|
||||
# song_path is already validated against DLC_DIR by _resolve_dlc_path.
|
||||
elif loosefolder_mod.is_loose_song(song_path):
|
||||
art_path = loosefolder_mod.find_art(song_path)
|
||||
if art_path:
|
||||
# Re-resolve in case the matched file is a symlink — a crafted
|
||||
# custom song could put `album_art.jpg` as a symlink to anywhere on
|
||||
# disk. Insist the final target stays inside the song folder.
|
||||
art_resolved = art_path.resolve()
|
||||
try:
|
||||
art_resolved.relative_to(song_path)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
if art_resolved.is_file():
|
||||
mt = {
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".png": "image/png", ".webp": "image/webp",
|
||||
}.get(art_resolved.suffix.lower(), "image/jpeg")
|
||||
return _file_art_response(art_resolved, mt, request)
|
||||
|
||||
# 3. Cover Art Archive cache (the enrichment art worker's fetch).
|
||||
if not pack_only:
|
||||
row = appstate.meta_db.get_enrichment(filename)
|
||||
if row and row.get("art_state") == "caa" and row.get("art_cache_path"):
|
||||
caa = Path(row["art_cache_path"])
|
||||
if caa.is_file():
|
||||
return _file_art_response(caa, "image/jpeg", request)
|
||||
|
||||
return JSONResponse({"error": "no art"}, 404)
|
||||
|
||||
|
||||
# ── Cover picker (PR-C): candidate assembly ───────────────────────────────────
|
||||
# Enumerated ON OPEN, never at scan time (charrette §8), and NO image bytes
|
||||
# are fetched here — Cover Art Archive release INDEX jsons only (1-3 throttled
|
||||
# calls on a cache miss); the tiles' thumbnails load straight from the archive
|
||||
# in the client. Applying a pick never grows a new write path: the client
|
||||
# POSTs the chosen thumb URL to the EXISTING …/art/url route (the override
|
||||
# lane — never evicted, survives a re-match), "Pack original" DELETEs the
|
||||
# override, uploads keep the existing upload route.
|
||||
_ART_PICKER_MAX_CAA = 12
|
||||
|
||||
|
||||
@router.get("/api/song/{filename:path}/art/cover-search")
|
||||
def api_art_cover_search(filename: str, q: str = ""):
|
||||
"""Search Cover Art Archive (via MusicBrainz release-groups) for album covers
|
||||
— powers the Change-cover picker's search box, so a cover can be found even
|
||||
for a song with no metadata match (the unmatched city-pop pile, where
|
||||
/art/candidates is empty). `q` defaults to the song's own artist + album/
|
||||
title (romaji fallback applied). Read-only; the picker renders the thumbs and
|
||||
applies a pick through the existing /art/url route."""
|
||||
query = (q or "").strip()
|
||||
if not query:
|
||||
pack = appstate.meta_db.pack_fields(appstate.meta_db._canonical_song_filename(filename))
|
||||
query = " ".join(x for x in (pack.get("artist"), pack.get("album") or pack.get("title")) if x).strip()
|
||||
if not query:
|
||||
return {"query": "", "covers": []}
|
||||
try:
|
||||
return {"query": query, "covers": enrichment._mb_search_release_groups(query, limit=8)}
|
||||
except enrichment.EnrichTransportError:
|
||||
return {"query": query, "covers": [], "error": "unavailable"}
|
||||
|
||||
|
||||
@router.get("/api/song/{filename:path}/art/candidates")
|
||||
def get_song_art_candidates(filename: str):
|
||||
"""Everything the cover picker can offer for one song, without fetching a
|
||||
single image: the current cover (with its provenance), the pack original
|
||||
when the song ships art, and CAA candidates for the matched/manual
|
||||
release plus any distinct releases among the stored review candidates.
|
||||
Sync route on purpose (the CAA index fetch sleeps in the shared
|
||||
throttle — FastAPI runs `def` routes in the threadpool). One response,
|
||||
`pending` always False — the client shows a spinner for the request's own
|
||||
latency; offline / CAA-down just means an empty caa tail (the instant
|
||||
tiles keep working), never an error."""
|
||||
from urllib.parse import quote
|
||||
dlc = _get_dlc_dir()
|
||||
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
|
||||
if song_path is None or not song_path.exists():
|
||||
raise HTTPException(status_code=404, detail="unknown song")
|
||||
|
||||
row = appstate.meta_db.get_enrichment(filename) or {}
|
||||
has_pack = appstate.song_pack_art_exists(filename)
|
||||
art_url = f"/api/song/{quote(filename)}/art"
|
||||
|
||||
# What the plain art route would serve right now — the serve chain's
|
||||
# order (override > pack > CAA cache) restated as provenance.
|
||||
if appstate.art_override_paths(filename):
|
||||
provenance = "yours"
|
||||
elif has_pack:
|
||||
provenance = "pack"
|
||||
elif row.get("art_state") == "caa" and row.get("art_cache_path"):
|
||||
provenance = "matched"
|
||||
else:
|
||||
provenance = "none"
|
||||
|
||||
candidates: list[dict] = [{
|
||||
"id": "current", "kind": "current", "label": "Current",
|
||||
"thumb_url": art_url, "provenance": provenance,
|
||||
}]
|
||||
if has_pack:
|
||||
candidates.append({
|
||||
"id": "pack", "kind": "pack", "label": "Pack original",
|
||||
"thumb_url": art_url + "?source=pack", "provenance": "pack",
|
||||
})
|
||||
|
||||
# Releases worth asking the archive about: the matched/manual release
|
||||
# first (it seeds the best candidates), then any distinct release among
|
||||
# the stored review candidates (a review row has no mb_release_id of its
|
||||
# own — its releases live in the candidates JSON).
|
||||
# Only spend the shared CAA rate budget on rows whose match warrants it:
|
||||
# a matched/manual release seeds the best candidates, and a review row's
|
||||
# stored candidates are still live proposals. A failed/rejected (or
|
||||
# unscanned) row has no accepted match — asking would burn the budget and
|
||||
# surface releases already rejected as non-matches. The Current + Pack
|
||||
# tiles above serve regardless, so those songs still get a picker.
|
||||
rids: list[str] = []
|
||||
if row.get("match_state") in ("matched", "manual", "review"):
|
||||
if row.get("match_state") in ("matched", "manual") and row.get("mb_release_id"):
|
||||
rids.append(str(row["mb_release_id"]))
|
||||
for cand in (row.get("candidates") or []):
|
||||
rid = str(cand.get("release_id") or "") if isinstance(cand, dict) else ""
|
||||
if rid and rid not in rids:
|
||||
rids.append(rid)
|
||||
|
||||
caa_entries: list[dict] = []
|
||||
for rid in rids:
|
||||
if len(caa_entries) >= _ART_PICKER_MAX_CAA:
|
||||
break
|
||||
try:
|
||||
imgs = enrichment._caa_index_cached(rid)
|
||||
except enrichment.EnrichTransportError:
|
||||
# Offline / archive down — stop asking (each further miss would
|
||||
# only burn a timeout). The instant tiles still serve; a later
|
||||
# picker-open retries naturally (failures are never cached).
|
||||
break
|
||||
# Front covers first, approved before pending, otherwise index order
|
||||
# (the picker grammar is a RANKED list — §7/§9).
|
||||
def _rank(img):
|
||||
types = img.get("types") or []
|
||||
is_front = bool(img.get("front")) or "Front" in types
|
||||
return (not is_front, not bool(img.get("approved")))
|
||||
for img in sorted((i for i in imgs if isinstance(i, dict)), key=_rank):
|
||||
if len(caa_entries) >= _ART_PICKER_MAX_CAA:
|
||||
break
|
||||
thumbs = img.get("thumbnails") or {}
|
||||
if not isinstance(thumbs, dict):
|
||||
continue
|
||||
thumb = (thumbs.get("500") or thumbs.get("large")
|
||||
or thumbs.get("250") or thumbs.get("small"))
|
||||
if not thumb:
|
||||
continue
|
||||
types = [str(t) for t in (img.get("types") or []) if isinstance(t, str)]
|
||||
caa_entries.append({
|
||||
"id": f"caa-{rid}-{img.get('id', '')}",
|
||||
"kind": "caa",
|
||||
"label": ", ".join(types) or "Cover",
|
||||
"thumb_url": str(thumb),
|
||||
"provenance": "matched",
|
||||
"types": types,
|
||||
"approved": bool(img.get("approved")),
|
||||
"release_id": rid,
|
||||
})
|
||||
|
||||
return {"candidates": candidates + caa_entries, "pending": False}
|
||||
|
||||
|
||||
def _save_art_override(filename: str, img_data: bytes) -> dict:
|
||||
"""Persist a user art override into the art cache (R3). One override per
|
||||
song: GIF input is validated and kept VERBATIM as .gif (animation intact —
|
||||
the local-only bonus; it is never written into the pack file), everything
|
||||
else is normalized to RGB PNG via PIL. Saving either kind removes the
|
||||
other so the serve chain has exactly one user file to find."""
|
||||
appstate.art_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = appstate.art_safe_name(filename)
|
||||
png_path = appstate.art_cache_dir / f"{stem}.png"
|
||||
gif_path = appstate.art_cache_dir / f"{stem}.gif"
|
||||
from PIL import Image
|
||||
import io as _io
|
||||
if img_data[:6] in (b"GIF87a", b"GIF89a"):
|
||||
try:
|
||||
probe = Image.open(_io.BytesIO(img_data))
|
||||
probe.verify() # decodes headers/frames without keeping the image
|
||||
if probe.format != "GIF":
|
||||
raise ValueError("not a GIF")
|
||||
except Exception as e:
|
||||
return {"error": f"Invalid image: {e}"}
|
||||
gif_path.write_bytes(img_data)
|
||||
png_path.unlink(missing_ok=True)
|
||||
return {"ok": True, "kind": "gif"}
|
||||
try:
|
||||
img = Image.open(_io.BytesIO(img_data)).convert("RGB")
|
||||
img.save(str(png_path), "PNG")
|
||||
except Exception as e:
|
||||
return {"error": f"Invalid image: {e}"}
|
||||
gif_path.unlink(missing_ok=True)
|
||||
return {"ok": True, "kind": "png"}
|
||||
|
||||
|
||||
@router.post("/api/song/{filename:path}/art/upload")
|
||||
async def upload_song_art_b64(filename: str, data: dict):
|
||||
"""Upload a custom cover as base64 (PNG/JPG/WebP → normalized PNG;
|
||||
GIF → kept animated, local-only). The override outranks pack art in the
|
||||
serve chain; remove it via DELETE …/art/override."""
|
||||
import base64
|
||||
# Reject art for a filename that doesn't resolve to a real song (mirrors the
|
||||
# url route's guard) — no writing stray override files for unknown keys.
|
||||
dlc = _get_dlc_dir()
|
||||
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
|
||||
if song_path is None or not song_path.exists():
|
||||
raise HTTPException(status_code=404, detail="unknown song")
|
||||
b64 = data.get("image", "")
|
||||
if not b64:
|
||||
return {"error": "No image data"}
|
||||
# Strip data URL prefix if present
|
||||
if "," in b64:
|
||||
b64 = b64.split(",", 1)[1]
|
||||
try:
|
||||
img_data = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return {"error": "Invalid base64"}
|
||||
if len(img_data) > _ART_URL_MAX_BYTES:
|
||||
raise HTTPException(status_code=400, detail="image larger than 10 MB")
|
||||
return _save_art_override(filename, img_data)
|
||||
|
||||
|
||||
# Art-by-URL fetch cap — a cover, not a wallpaper pack.
|
||||
_ART_URL_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _url_host_is_internal(url: str) -> bool:
|
||||
"""True when a user-supplied URL's host resolves to a loopback, private,
|
||||
link-local, reserved, multicast or unspecified address — an SSRF target we
|
||||
refuse to fetch on the user's behalf (e.g. 169.254.169.254 metadata, LAN
|
||||
services). Fails CLOSED: an unresolvable or unparseable host is treated as
|
||||
internal. Every resolved address must be public for the URL to pass."""
|
||||
from urllib.parse import urlparse
|
||||
import socket
|
||||
host = urlparse(url).hostname
|
||||
if not host:
|
||||
return True
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except OSError:
|
||||
return True
|
||||
if not infos:
|
||||
return True
|
||||
for info in infos:
|
||||
raw = info[4][0].split("%", 1)[0] # strip any zone id
|
||||
try:
|
||||
ip = ipaddress.ip_address(raw)
|
||||
except ValueError:
|
||||
return True
|
||||
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Art-by-URL redirect budget. Cover hosts commonly answer with a redirect —
|
||||
# the Cover Art Archive (whose thumbs the cover picker applies through this
|
||||
# very route) 307s every image to archive.org — so redirects must work; 5
|
||||
# hops is generous for any real CDN chain while still bounding the walk.
|
||||
_ART_URL_MAX_REDIRECTS = 5
|
||||
|
||||
|
||||
def _fetch_art_url(url: str) -> bytes:
|
||||
"""The one place art-by-URL touches the network (tests fake this seam).
|
||||
User-initiated, so not throttled like the background workers — but the
|
||||
same offline guard applies (pytest can never fetch), the host is checked
|
||||
against internal/reserved ranges (SSRF), redirects are followed MANUALLY
|
||||
with the scheme + internal-host guard re-applied to every hop (so a
|
||||
redirect can't smuggle the request to an internal target — a blanket
|
||||
no-redirect rule would break every Cover Art Archive pick, which always
|
||||
redirects to archive.org), and the size cap is enforced while streaming
|
||||
so a huge response never fully downloads.
|
||||
|
||||
Residual, accepted: each hop's host is resolved here and again by
|
||||
requests, so a rebinding DNS name is a theoretical TOCTOU. Not closed
|
||||
with an IP-pinned connection because (a) this is a single-user, no-auth
|
||||
app (constitution §I) and the route is demo-blocked, so there is no
|
||||
untrusted submission path, and (b) no other in-tree client (MusicBrainz,
|
||||
CAA) pins either — a bespoke pinned+SNI adapter here would be
|
||||
inconsistent and disproportionate. The cheap guards above still stop the
|
||||
realistic vectors (direct internal URL, redirect-to-internal)."""
|
||||
if not enrichment._enrich_network_enabled():
|
||||
raise enrichment.EnrichTransportError("art fetch disabled (offline)")
|
||||
import requests
|
||||
from urllib.parse import urljoin, urlparse
|
||||
for _hop in range(_ART_URL_MAX_REDIRECTS + 1):
|
||||
# Re-validate EVERY hop, not just the user's original URL: the whole
|
||||
# point of handling redirects ourselves is that each target gets the
|
||||
# same scheme + SSRF gate before any request is made.
|
||||
if urlparse(url).scheme not in ("http", "https"):
|
||||
raise ValueError("url must be http(s)")
|
||||
if _url_host_is_internal(url):
|
||||
raise ValueError("url host is not allowed")
|
||||
try:
|
||||
with requests.get(url, timeout=15, stream=True, allow_redirects=False,
|
||||
headers={"User-Agent": enrichment._enrich_user_agent()}) as resp:
|
||||
if resp.status_code in (301, 302, 303, 307, 308):
|
||||
loc = resp.headers.get("Location") or ""
|
||||
if not loc:
|
||||
raise enrichment.EnrichTransportError(
|
||||
f"HTTP {resp.status_code} without a Location")
|
||||
url = urljoin(url, loc)
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
raise enrichment.EnrichTransportError(f"HTTP {resp.status_code}")
|
||||
data = b""
|
||||
for chunk in resp.iter_content(65536):
|
||||
data += chunk
|
||||
if len(data) > _ART_URL_MAX_BYTES:
|
||||
raise ValueError("image larger than 10 MB")
|
||||
return data
|
||||
except requests.RequestException as e:
|
||||
raise enrichment.EnrichTransportError(str(e)) from e
|
||||
raise enrichment.EnrichTransportError("too many redirects")
|
||||
|
||||
|
||||
@router.post("/api/song/{filename:path}/art/url")
|
||||
def set_song_art_from_url(filename: str, data: dict):
|
||||
"""Paste-a-link cover art (the media-server idiom): the server fetches the
|
||||
image and stores it as this song's local override — identical result to an
|
||||
upload, including the GIF-stays-local rule. http(s) only."""
|
||||
url = str((data or {}).get("url") or "").strip()
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise HTTPException(status_code=400, detail="url must be http(s)")
|
||||
dlc = _get_dlc_dir()
|
||||
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
|
||||
if song_path is None or not song_path.exists():
|
||||
raise HTTPException(status_code=404, detail="unknown song")
|
||||
try:
|
||||
img_data = _fetch_art_url(url)
|
||||
except enrichment.EnrichTransportError as e:
|
||||
return JSONResponse({"error": "could not fetch image", "detail": str(e)},
|
||||
status_code=502)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return _save_art_override(filename, img_data)
|
||||
|
||||
|
||||
@router.delete("/api/art/{filename:path}/override")
|
||||
def remove_song_art_override(filename: str):
|
||||
"""Drop the user art override — the serve chain falls back to pack art,
|
||||
then the Cover Art Archive cache. Lives under /api/art (NOT /api/song) so
|
||||
the greedy DELETE /api/song/{path} catch-all can't shadow it — the same
|
||||
dodge the chart split/unsplit routes use."""
|
||||
removed = False
|
||||
for p in appstate.art_override_paths(filename):
|
||||
try:
|
||||
p.unlink()
|
||||
removed = True
|
||||
except OSError:
|
||||
pass
|
||||
if removed:
|
||||
# The art worker may have settled this row as 'user' (override present,
|
||||
# no pack art). Reset it so the next enrichment pass re-evaluates and the
|
||||
# CAA fallback resumes — otherwise a removed override strands the row
|
||||
# (enrichment_art_pending only re-queues art_state IS NULL) and the song
|
||||
# is left with no art at all.
|
||||
try:
|
||||
appstate.meta_db.set_enrichment_art(filename, None, None)
|
||||
except Exception:
|
||||
log.exception("art override delete: failed to reset enrichment state")
|
||||
return {"ok": True, "removed": removed}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Chart-level endpoints — split/unsplit a chart from its work, resolve work
|
||||
membership, and the context-menu "Get info" file inspector.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``. DLC path resolution comes from
|
||||
``dlc_paths``; sloppak/loose detection from the shared lib modules.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
import appstate
|
||||
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||
import sloppak as sloppak_mod
|
||||
import loosefolder as loosefolder_mod
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/api/chart/{filename:path}/split")
|
||||
def api_split_chart(filename: str):
|
||||
"""'These aren't the same song' — split this chart out as its own singleton
|
||||
work. Under /api/chart (NOT /api/song) so the DELETE /api/song/{path}
|
||||
catch-all can't shadow it."""
|
||||
key = appstate.meta_db._canonical_song_filename(filename)
|
||||
appstate.meta_db.split_chart(key)
|
||||
return {"ok": True, "filename": key}
|
||||
|
||||
|
||||
@router.post("/api/chart/{filename:path}/unsplit")
|
||||
def api_unsplit_chart(filename: str):
|
||||
"""Undo a split — rejoin the chart to its work."""
|
||||
key = appstate.meta_db._canonical_song_filename(filename)
|
||||
appstate.meta_db.unsplit_chart(key)
|
||||
return {"ok": True, "filename": key}
|
||||
|
||||
|
||||
@router.get("/api/chart/{filename:path}/work")
|
||||
def api_get_chart_work(filename: str):
|
||||
"""Resolve a chart's work membership: {work_key, chart_count}. For openers
|
||||
on rows that came from an ungrouped query (the tree view) — grouped grid
|
||||
rows already carry both fields inline."""
|
||||
return appstate.meta_db.chart_work(filename)
|
||||
|
||||
|
||||
@router.get("/api/chart/{filename:path}/fileinfo")
|
||||
def api_chart_fileinfo(filename: str):
|
||||
"""The context menu's "Get info": where the file lives + what the pack
|
||||
contains. Under /api/chart — the GET /api/song/{path} catch-all would
|
||||
swallow a /api/song/…/fileinfo suffix. Read-only; demo-mode blocks it
|
||||
because it exposes filesystem paths."""
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
raise HTTPException(status_code=404, detail="not configured")
|
||||
p = _resolve_dlc_path(dlc, filename)
|
||||
if p is None:
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
if not p.exists():
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
# Restrict to actual charts — sloppak or loose song. Without this the route
|
||||
# would stat ANY file the user happens to keep under DLC_DIR (e.g. notes),
|
||||
# leaking its path/size; the app only recognises these two song formats.
|
||||
is_pak = sloppak_mod.is_sloppak(p)
|
||||
is_loose = loosefolder_mod.is_loose_song(p)
|
||||
if not (is_pak or is_loose):
|
||||
raise HTTPException(status_code=404, detail="not a chart")
|
||||
st = p.stat()
|
||||
info = {
|
||||
"filename": filename,
|
||||
"path": str(p),
|
||||
"folder": str(p.parent),
|
||||
"format": "sloppak" if is_pak else "loose",
|
||||
# Directory-form songs report the tree's total (covers loose folders
|
||||
# and dir-form paks); zip-form paks report the archive size. Symlinked
|
||||
# entries are skipped so a link inside the folder can't pull in — or
|
||||
# leak the size of — a file outside it.
|
||||
"size": (st.st_size if p.is_file()
|
||||
else sum(f.stat().st_size for f in p.rglob("*")
|
||||
if f.is_file() and not f.is_symlink())),
|
||||
"mtime": st.st_mtime,
|
||||
}
|
||||
if is_pak:
|
||||
try:
|
||||
m = sloppak_mod.load_manifest(p) or {}
|
||||
except Exception:
|
||||
m = {}
|
||||
arrs = [str(a.get("name", a.get("id", ""))) for a in (m.get("arrangements") or [])
|
||||
if isinstance(a, dict)]
|
||||
stems = [str(s.get("id", "")) for s in (m.get("stems") or []) if isinstance(s, dict)]
|
||||
try:
|
||||
has_cover = sloppak_mod.read_cover_bytes(p, m) is not None
|
||||
except Exception:
|
||||
has_cover = False
|
||||
# The optional identity/catalog keys, listed only when present — the
|
||||
# Get-info panel's "what this pack carries vs what's missing" readout.
|
||||
identity = {k: m.get(k) for k in
|
||||
("mbid", "isrc", "genres", "track", "disc", "album_artist",
|
||||
"feedpak_version", "language")
|
||||
if m.get(k) not in (None, "", [])}
|
||||
info["manifest"] = {
|
||||
"title": str(m.get("title", "")), "artist": str(m.get("artist", "")),
|
||||
"album": str(m.get("album", "")), "year": str(m.get("year", "") or ""),
|
||||
"arrangements": arrs, "stems": stems,
|
||||
"has_cover": has_cover, "has_lyrics": bool(m.get("lyrics")),
|
||||
"authors": [a.get("name", "") if isinstance(a, dict) else str(a)
|
||||
for a in (m.get("authors") or [])],
|
||||
"identity": identity,
|
||||
}
|
||||
# The enrichment verdict, so Get info can say "Matched (auto, 96%)" /
|
||||
# "Pinned by you" / "Not matched" alongside the file facts.
|
||||
row = appstate.meta_db.get_enrichment(filename)
|
||||
if row:
|
||||
info["match"] = {k: row.get(k) for k in
|
||||
("match_state", "match_source", "match_score",
|
||||
"canon_artist", "canon_title", "canon_album", "canon_year")}
|
||||
return info
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Diagnostic bundle export + hardware probe (/api/diagnostics/*).
|
||||
|
||||
One-click "Export Diagnostics" in Settings produces a redacted zip combining
|
||||
server logs, system info, hardware (CPU/GPU/RAM), plugin inventory, and the
|
||||
browser-side console transcript + hardware probe. Bundle format is specified in
|
||||
docs/diagnostics-bundle-spec.md.
|
||||
|
||||
Extracted verbatim from server.py (R3) except:
|
||||
- the decorators (@app -> @router),
|
||||
- CONFIG_DIR -> appstate.config_dir and _running_version() ->
|
||||
appstate.running_version() (both read through the appstate seam),
|
||||
- the builtin-plugins lookup in _diag_plugins_roots: Path(__file__).parent
|
||||
(the app root when this lived at the top level) ->
|
||||
Path(__file__).resolve().parents[2] (routers -> lib -> app root). The
|
||||
plugins/ dir ships at the app root in every packaging path.
|
||||
|
||||
The pure helpers + caps here are re-exported from server.py so the existing
|
||||
`server._diag_*` / `server._DIAG_*` tests keep resolving (none monkeypatch them).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Response
|
||||
|
||||
import appstate
|
||||
from dlc_paths import _get_dlc_dir
|
||||
from diagnostics_bundle import build_bundle as _diag_build, preview_bundle as _diag_preview
|
||||
from diagnostics_hardware import collect as _diag_hardware
|
||||
from env_compat import getenv_compat
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _diag_log_file() -> Path | None:
|
||||
raw = os.environ.get("LOG_FILE", "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
return Path(raw)
|
||||
|
||||
|
||||
def _diag_plugins_roots() -> list[Path]:
|
||||
"""Return all plugin root directories for orphan scanning.
|
||||
|
||||
Includes both the built-in ``plugins/`` directory and
|
||||
``FEEDBACK_PLUGINS_DIR`` when set, so user-installed plugins and
|
||||
orphans in the external dir are reflected in the bundle.
|
||||
"""
|
||||
roots: list[Path] = []
|
||||
user_dir = getenv_compat("FEEDBACK_PLUGINS_DIR", "").strip()
|
||||
if user_dir:
|
||||
p = Path(user_dir)
|
||||
if p.is_dir():
|
||||
roots.append(p)
|
||||
builtin = Path(__file__).resolve().parents[2] / "plugins" # R3: app root from lib/routers/
|
||||
if builtin not in roots:
|
||||
roots.append(builtin)
|
||||
return roots
|
||||
|
||||
|
||||
def _diag_coerce_bool(v, *, default: bool = True) -> bool:
|
||||
"""Coerce a request-side value to bool, accepting both JSON booleans and
|
||||
string representations.
|
||||
|
||||
- Falsy strings: ``"false"``, ``"0"``, ``"no"``, ``""`` → ``False``
|
||||
- ``None`` → *default*
|
||||
- Everything else (including ``"true"``, ``"1"``) → ``True``
|
||||
"""
|
||||
if v is None:
|
||||
return default
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
return v.strip().lower() not in ("false", "0", "no", "")
|
||||
return bool(v)
|
||||
|
||||
|
||||
def _diag_normalize_include(include: dict | None) -> dict:
|
||||
"""Coerce request-side flags to the booleans build_bundle expects.
|
||||
Missing keys default to True so a bare {} request still produces
|
||||
the full bundle.
|
||||
|
||||
Accepts both JSON booleans (``true``/``false``) and string
|
||||
representations so callers that serialize flags as strings behave
|
||||
consistently with the preview endpoint:
|
||||
- Falsy strings: ``"false"``, ``"0"``, ``"no"``, ``""`` → ``False``
|
||||
- Everything else (including ``"true"``, ``"1"``, ``"yes"``) → ``True``
|
||||
"""
|
||||
keys = ("system", "hardware", "logs", "console", "plugins")
|
||||
if not isinstance(include, dict):
|
||||
return {k: True for k in keys}
|
||||
|
||||
return {k: _diag_coerce_bool(include.get(k), default=True) for k in keys}
|
||||
|
||||
|
||||
# Server-side caps on client-supplied payload sections. diagnostics.js
|
||||
# enforces a 500-entry / ~250 KB ring buffer on the browser side; these
|
||||
# bounds give generous headroom while still preventing a crafted POST from
|
||||
# forcing the server to allocate arbitrarily large in-memory bundles.
|
||||
_DIAG_MAX_CONSOLE_ENTRIES = 1000 # hard cap: truncate silently
|
||||
_DIAG_MAX_CONSOLE_BYTES = 2 * 1024 * 1024 # 2 MB hard cap on total console list
|
||||
_DIAG_MAX_CLIENT_PAYLOAD_BYTES = 2 * 1024 * 1024 # 2 MB per dict section
|
||||
_DIAG_MAX_CONTRIBUTIONS_BYTES = 4 * 1024 * 1024 # 4 MB aggregate cap for contributions
|
||||
|
||||
|
||||
def _diag_cap_console(v) -> list | None:
|
||||
"""Return *v* if it is a list, truncated to _DIAG_MAX_CONSOLE_ENTRIES entries
|
||||
and _DIAG_MAX_CONSOLE_BYTES total. Entries are accumulated until either cap
|
||||
is reached; no partial-entry splitting occurs."""
|
||||
if not isinstance(v, list):
|
||||
return None
|
||||
result = v[:_DIAG_MAX_CONSOLE_ENTRIES]
|
||||
# Also enforce a byte cap — the count cap alone does not bound memory when
|
||||
# entries contain arbitrarily large strings.
|
||||
try:
|
||||
out = []
|
||||
total = 0
|
||||
for entry in result:
|
||||
encoded = json.dumps(entry, separators=(",", ":")).encode("utf-8", errors="replace")
|
||||
if total + len(encoded) > _DIAG_MAX_CONSOLE_BYTES:
|
||||
break
|
||||
out.append(entry)
|
||||
total += len(encoded)
|
||||
return out
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _diag_cap_dict(v) -> dict | None:
|
||||
"""Return *v* if it is a dict whose JSON serialisation fits within
|
||||
_DIAG_MAX_CLIENT_PAYLOAD_BYTES, otherwise return None."""
|
||||
if not isinstance(v, dict):
|
||||
return None
|
||||
try:
|
||||
encoded = json.dumps(v, separators=(",", ":")).encode("utf-8", errors="replace")
|
||||
except (TypeError, ValueError) as e:
|
||||
log.warning("diagnostics client payload is not JSON-serialisable, dropping: %s", e)
|
||||
return None
|
||||
if len(encoded) > _DIAG_MAX_CLIENT_PAYLOAD_BYTES:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def _diag_cap_contributions(v, known_ids=None) -> dict | None:
|
||||
"""Apply per-plugin and aggregate size caps on client_contributions.
|
||||
|
||||
Unlike _diag_cap_dict(), which drops the whole dict when any plugin
|
||||
exceeds the limit, this function caps each plugin independently so
|
||||
one noisy plugin does not silence every other plugin's contribution.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
v:
|
||||
The raw contributions dict from the POST payload.
|
||||
known_ids:
|
||||
When provided, contributions from plugins not in this set are
|
||||
skipped *before* serialisation, preventing a malicious caller
|
||||
from forcing the server to JSON-encode hundreds of near-limit
|
||||
payloads that ``build_bundle()`` would later discard anyway.
|
||||
``None`` means "accept all plugin ids" (used in tests / preview).
|
||||
"""
|
||||
if not isinstance(v, dict):
|
||||
return None
|
||||
result = {}
|
||||
total_bytes = 0
|
||||
for pid, contribution in v.items():
|
||||
if not isinstance(pid, str):
|
||||
continue
|
||||
# Filter unknown plugin ids early — before serialising — so a
|
||||
# crafted request cannot force large allocations for plugins that
|
||||
# build_bundle() would drop.
|
||||
if known_ids is not None and pid not in known_ids:
|
||||
continue
|
||||
try:
|
||||
encoded = json.dumps(contribution, separators=(",", ":")).encode("utf-8", errors="replace")
|
||||
except (TypeError, ValueError) as e:
|
||||
log.warning(
|
||||
"client_contributions[%r] is not JSON-serialisable, dropping: %s", pid, e
|
||||
)
|
||||
continue
|
||||
if len(encoded) > _DIAG_MAX_CLIENT_PAYLOAD_BYTES:
|
||||
log.warning(
|
||||
"client_contributions[%r] exceeds %d bytes, dropping",
|
||||
pid, _DIAG_MAX_CLIENT_PAYLOAD_BYTES,
|
||||
)
|
||||
continue
|
||||
if total_bytes + len(encoded) > _DIAG_MAX_CONTRIBUTIONS_BYTES:
|
||||
log.warning(
|
||||
"client_contributions aggregate size limit (%d bytes) reached, "
|
||||
"dropping remaining entries",
|
||||
_DIAG_MAX_CONTRIBUTIONS_BYTES,
|
||||
)
|
||||
break
|
||||
result[pid] = contribution
|
||||
total_bytes += len(encoded)
|
||||
return result or None
|
||||
|
||||
|
||||
@router.post("/api/diagnostics/export")
|
||||
def export_diagnostics(payload: dict = Body(default_factory=dict)):
|
||||
"""Build a diagnostic bundle and stream it back as a zip download.
|
||||
|
||||
The browser layers in `client_console`, `client_hardware`,
|
||||
`client_ua`, and `local_storage` before posting; the server adds
|
||||
server logs, hardware, plugin inventory, and packages everything
|
||||
into a single zip.
|
||||
|
||||
Errors during plugin diagnostics callables are caught and logged
|
||||
to the bundle's manifest `notes` rather than failing the export.
|
||||
"""
|
||||
from plugins import LOADED_PLUGINS, PLUGINS_LOCK
|
||||
|
||||
redact = _diag_coerce_bool(payload.get("redact", True), default=True)
|
||||
include = _diag_normalize_include(payload.get("include"))
|
||||
client_console = _diag_cap_console(payload.get("client_console"))
|
||||
client_hardware = _diag_cap_dict(payload.get("client_hardware"))
|
||||
client_ua = _diag_cap_dict(payload.get("client_ua"))
|
||||
local_storage = _diag_cap_dict(payload.get("local_storage"))
|
||||
# Fetch the plugin list first so we can filter contributions to known
|
||||
# plugin ids before serialising — prevents a crafted request from
|
||||
# forcing large allocations for plugins build_bundle() would drop.
|
||||
with PLUGINS_LOCK:
|
||||
plugins_snapshot = list(LOADED_PLUGINS)
|
||||
known_ids = {p.get("id") for p in plugins_snapshot if isinstance(p.get("id"), str)}
|
||||
client_contributions = _diag_cap_contributions(
|
||||
payload.get("client_contributions"), known_ids=known_ids
|
||||
)
|
||||
|
||||
zip_bytes, filename, _manifest = _diag_build(
|
||||
feedBack_version=appstate.running_version(),
|
||||
config_dir=appstate.config_dir,
|
||||
dlc_dir=_get_dlc_dir(),
|
||||
log_file=_diag_log_file(),
|
||||
loaded_plugins=plugins_snapshot,
|
||||
include=include,
|
||||
redact=redact,
|
||||
client_console=client_console,
|
||||
client_hardware=client_hardware,
|
||||
client_ua=client_ua,
|
||||
local_storage=local_storage,
|
||||
client_contributions=client_contributions,
|
||||
log=log,
|
||||
plugins_root=_diag_plugins_roots(),
|
||||
)
|
||||
return Response(
|
||||
content=zip_bytes,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/diagnostics/preview")
|
||||
def preview_diagnostics(
|
||||
redact: bool = True,
|
||||
system: bool = True,
|
||||
hardware: bool = True,
|
||||
logs: bool = True,
|
||||
console: bool = True,
|
||||
plugins: bool = True,
|
||||
):
|
||||
"""Return what `/api/diagnostics/export` would produce, minus the
|
||||
actual file contents — file tree, sizes, schemas, redaction counts.
|
||||
Lets the Settings UI show the user what's about to be sent."""
|
||||
from plugins import LOADED_PLUGINS, PLUGINS_LOCK
|
||||
|
||||
include = {
|
||||
"system": system,
|
||||
"hardware": hardware,
|
||||
"logs": logs,
|
||||
"console": console,
|
||||
"plugins": plugins,
|
||||
}
|
||||
with PLUGINS_LOCK:
|
||||
plugins_snapshot = list(LOADED_PLUGINS)
|
||||
return _diag_preview(
|
||||
feedBack_version=appstate.running_version(),
|
||||
config_dir=appstate.config_dir,
|
||||
dlc_dir=_get_dlc_dir(),
|
||||
log_file=_diag_log_file(),
|
||||
loaded_plugins=plugins_snapshot,
|
||||
include=include,
|
||||
redact=redact,
|
||||
log=log,
|
||||
plugins_root=_diag_plugins_roots(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/diagnostics/hardware")
|
||||
def diagnostics_hardware():
|
||||
"""Backend hardware probe (cross-platform). Reusable independently
|
||||
of the bundle export — handy for "what's my GPU" plugin queries."""
|
||||
return _diag_hardware()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Small meta_db-backed library / user-state endpoints — work keeper-chart
|
||||
prefs, favorites, personal tags, saved-for-later, and continue-playing.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``. All paths
|
||||
are distinct and non-overlapping, so mounting them together (rather than at each
|
||||
original scattered site) does not change routing.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/work/{work_key:path}/charts")
|
||||
def api_get_work_charts(work_key: str):
|
||||
"""All charts in a work + which is the keeper (your pick vs auto-pick)."""
|
||||
return appstate.meta_db.work_charts(work_key)
|
||||
|
||||
|
||||
@router.put("/api/work/{work_key:path}/preferred")
|
||||
def api_set_work_preferred(work_key: str, data: dict):
|
||||
"""Set the keeper chart of a work: body {filename}. The filename must be a
|
||||
current member of the work. Returns the refreshed chart list."""
|
||||
fn = (data.get("filename") or "").strip()
|
||||
if not fn:
|
||||
return JSONResponse({"error": "filename is required"}, 400)
|
||||
members = {c["filename"] for c in appstate.meta_db.work_charts(work_key)["charts"]}
|
||||
if fn not in members:
|
||||
return JSONResponse({"error": "filename is not a chart of this work"}, 400)
|
||||
appstate.meta_db.set_chart_preferred(work_key, fn)
|
||||
return appstate.meta_db.work_charts(work_key)
|
||||
|
||||
|
||||
@router.delete("/api/work/{work_key:path}/preferred")
|
||||
def api_reset_work_preferred(work_key: str):
|
||||
"""Reset a work to auto-pick (drop the explicit preferred)."""
|
||||
appstate.meta_db.clear_chart_preferred(work_key)
|
||||
return appstate.meta_db.work_charts(work_key)
|
||||
|
||||
|
||||
@router.post("/api/favorites/toggle")
|
||||
def toggle_favorite(data: dict):
|
||||
"""Toggle a song's favorite status."""
|
||||
filename = data.get("filename", "")
|
||||
if not filename:
|
||||
return {"error": "No filename"}
|
||||
new_state = appstate.meta_db.toggle_favorite(filename)
|
||||
return {"favorite": new_state}
|
||||
|
||||
|
||||
@router.get("/api/tags")
|
||||
def list_tags():
|
||||
"""All personal tags in use (over still-present songs), most-used first —
|
||||
powers the tag filter UI."""
|
||||
return {"tags": appstate.meta_db.all_tags()}
|
||||
|
||||
|
||||
@router.post("/api/saved/toggle")
|
||||
def api_toggle_saved(data: dict):
|
||||
"""Add/remove a song on the reserved Saved-for-Later playlist."""
|
||||
filename = _clean_str(data.get("filename"))
|
||||
if not filename:
|
||||
return JSONResponse({"error": "filename required"}, status_code=400)
|
||||
return {"saved": appstate.meta_db.toggle_saved(filename)}
|
||||
|
||||
|
||||
@router.get("/api/session/continue")
|
||||
def api_session_continue():
|
||||
"""The Continue-Playing card's song (most recent play) or null."""
|
||||
return appstate.meta_db.continue_session()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Player profile — identity, avatars (bundled + custom uploads), and progress.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``CONFIG_DIR``/``STATIC_DIR`` ->
|
||||
``appstate.config_dir``/``appstate.static_dir`` (seam), ``_clean_str`` from
|
||||
``reqfields``, ``_get_progression_content()`` ->
|
||||
``appstate.get_progression_content()``. The bundled-avatar lister moves with it.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_bundled_avatars() -> list[str]:
|
||||
"""Bundled default avatar filenames under static/v3/avatars/."""
|
||||
d = appstate.static_dir / "v3" / "avatars"
|
||||
if not d.is_dir():
|
||||
return []
|
||||
exts = {".svg", ".png", ".webp"}
|
||||
return sorted(
|
||||
p.name for p in d.iterdir()
|
||||
if p.is_file() and p.suffix.lower() in exts and not p.name.startswith(".")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/profile")
|
||||
def api_get_profile():
|
||||
profile = appstate.meta_db.get_profile()
|
||||
# Equipped cosmetics ride along (resolved to their payloads) so the theme
|
||||
# and avatar frame apply at boot without an extra request. Never let a
|
||||
# cosmetics/content problem break the profile read.
|
||||
cosmetics = {}
|
||||
try:
|
||||
shop = appstate.get_progression_content()["shop"]
|
||||
for slot, item_id in appstate.meta_db.get_equipped().items():
|
||||
item = shop.get(item_id)
|
||||
if item:
|
||||
cosmetics[slot] = {"item_id": item_id, "payload": item["payload"]}
|
||||
except Exception:
|
||||
log.warning("profile cosmetics enrich failed", exc_info=True)
|
||||
profile["cosmetics"] = cosmetics
|
||||
return profile
|
||||
|
||||
|
||||
|
||||
@router.post("/api/profile")
|
||||
def api_set_profile(data: dict):
|
||||
"""Set/update the player profile. Body: {display_name, avatar:{type,value}}.
|
||||
avatar.type is 'default' (value = bundled filename) or 'upload' (value =
|
||||
the /api/profile/avatar/<name> URL returned by the upload endpoint); omit
|
||||
avatar to keep the existing one (name-only edit)."""
|
||||
name = _clean_str(data.get("display_name"))
|
||||
if not (1 <= len(name) <= 32):
|
||||
return JSONResponse({"error": "Display name must be 1–32 characters."}, status_code=400)
|
||||
avatar = data.get("avatar")
|
||||
if avatar is None:
|
||||
avatar = {} # omitted → keep the current avatar (name-only edit)
|
||||
elif not isinstance(avatar, dict):
|
||||
return JSONResponse({"error": "avatar must be an object."}, status_code=400)
|
||||
atype = avatar.get("type")
|
||||
aval = _clean_str(avatar.get("value"))
|
||||
avatar_url = None
|
||||
if atype == "default":
|
||||
if aval not in _list_bundled_avatars():
|
||||
return JSONResponse({"error": "Unknown default avatar."}, status_code=400)
|
||||
avatar_url = f"/static/v3/avatars/{aval}"
|
||||
elif atype == "upload":
|
||||
from safepath import safe_join
|
||||
fname = aval.rsplit("/", 1)[-1] if aval.startswith("/api/profile/avatar/") else ""
|
||||
target = safe_join(appstate.config_dir / "avatars", fname) if fname else None
|
||||
if target is None or not target.is_file():
|
||||
return JSONResponse({"error": "Uploaded avatar not found."}, status_code=400)
|
||||
avatar_url = f"/api/profile/avatar/{fname}"
|
||||
elif atype:
|
||||
return JSONResponse({"error": "Unknown avatar type."}, status_code=400)
|
||||
# atype None/missing → keep the current avatar (name-only edit).
|
||||
return appstate.meta_db.set_profile(name, avatar_url)
|
||||
|
||||
|
||||
@router.get("/api/profile/avatars")
|
||||
def api_list_avatars():
|
||||
return [{"name": n, "url": f"/static/v3/avatars/{n}"} for n in _list_bundled_avatars()]
|
||||
|
||||
|
||||
@router.post("/api/profile/avatar")
|
||||
def api_upload_avatar(data: dict):
|
||||
"""Upload a custom avatar as base64 (mirrors the album-art upload pattern).
|
||||
Re-encodes to a ≤512px PNG under appstate.config_dir/avatars/."""
|
||||
import base64
|
||||
import io
|
||||
b64 = data.get("image", "")
|
||||
if not isinstance(b64, str) or not b64:
|
||||
return JSONResponse({"error": "No image data"}, status_code=400)
|
||||
if "," in b64:
|
||||
b64 = b64.split(",", 1)[1]
|
||||
try:
|
||||
raw = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid base64"}, status_code=400)
|
||||
if len(raw) > 6 * 1024 * 1024:
|
||||
return JSONResponse({"error": "Image too large (max 6 MB)."}, status_code=400)
|
||||
avatars_dir = appstate.config_dir / "avatars"
|
||||
avatars_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
img.thumbnail((512, 512))
|
||||
fname = f"upload-{secrets.token_hex(4)}.png" # token busts caches on change
|
||||
img.save(str(avatars_dir / fname), "PNG")
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400)
|
||||
return {"url": f"/api/profile/avatar/{fname}"}
|
||||
|
||||
|
||||
@router.get("/api/profile/avatar/{name}")
|
||||
def api_get_avatar(name: str):
|
||||
from safepath import safe_join
|
||||
target = safe_join(appstate.config_dir / "avatars", name)
|
||||
if target is None or not target.is_file():
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
return FileResponse(str(target), media_type="image/png")
|
||||
|
||||
|
||||
@router.get("/api/profile/progress")
|
||||
def api_profile_progress():
|
||||
"""One call for the whole profile badge: {level, xp, xp_in_level,
|
||||
xp_to_next, current_streak, best_streak, last_active_date}."""
|
||||
return appstate.meta_db.get_progress()
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Progression (spec 010) — mastery rank, challenges, quests, onboarding paths.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``, and the
|
||||
two shared server accessors read through the seam:
|
||||
``_get_progression_content()`` -> ``appstate.get_progression_content()`` and
|
||||
``_builtin_diagnostic_filename()`` -> ``appstate.builtin_diagnostic_filename()``.
|
||||
The exclusive helpers (_goal_ui_progress, _progression_overview) + the
|
||||
_PROGRESSION_EVENT_TYPES whitelist move with it.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _goal_ui_progress(goal: dict, state: dict, streak: int, xp_total: int) -> tuple:
|
||||
"""(count, target) for a challenge/quest progress bar. Count goals show
|
||||
n/target; threshold goals show how far the live stat is along the line."""
|
||||
import progression as progression_mod
|
||||
gtype = goal.get("type")
|
||||
if gtype in progression_mod.COUNT_GOAL_TYPES:
|
||||
target = int(goal.get("target") or 1)
|
||||
count = target if state.get("completed") else min(int(state.get("count") or 0), target)
|
||||
return count, target
|
||||
if gtype == "streak_reached":
|
||||
target = int(goal.get("days") or 1)
|
||||
return (target if state.get("completed") else min(streak, target)), target
|
||||
if gtype == "db_earned":
|
||||
target = int(goal.get("amount") or 1)
|
||||
return (target if state.get("completed") else min(xp_total, target)), target
|
||||
return 0, 1
|
||||
|
||||
|
||||
def _progression_overview() -> dict:
|
||||
"""The full GET /api/progression payload (also the capability `inspect`
|
||||
result): rank, onboarding, per-path challenge checklists, quests, wallet."""
|
||||
import progression as progression_mod
|
||||
from datetime import datetime as _dt
|
||||
content = appstate.get_progression_content()
|
||||
now = _dt.now()
|
||||
appstate.meta_db.ensure_quest_period(content, now)
|
||||
|
||||
state = appstate.meta_db.get_progression_state()
|
||||
player_paths = appstate.meta_db.get_player_paths()
|
||||
challenge_state = appstate.meta_db.get_challenge_state()
|
||||
wallet = appstate.meta_db.get_wallet()
|
||||
streak_progress = appstate.meta_db.get_progress()
|
||||
streak = int(streak_progress.get("current_streak") or 0)
|
||||
xp_total = wallet["lifetime_db"]
|
||||
keys = progression_mod.period_keys(now)
|
||||
|
||||
def _path_order(pid):
|
||||
pdef = content["paths"].get(pid) or {}
|
||||
return (pdef.get("order") or 0, pid)
|
||||
|
||||
paths_payload = []
|
||||
for pid in sorted(player_paths, key=_path_order):
|
||||
pdef = content["paths"].get(pid)
|
||||
level = player_paths[pid]
|
||||
if not pdef:
|
||||
# Path selected under older content that no longer ships: keep its
|
||||
# rank contribution visible rather than silently dropping it.
|
||||
paths_payload.append({"id": pid, "name": pid, "icon": "", "level": level,
|
||||
"max_level": level, "next": None})
|
||||
continue
|
||||
next_block = None
|
||||
active = progression_mod.active_challenges(content, pid, level)
|
||||
if active:
|
||||
level_def = next(e for e in pdef["levels"] if e["level"] == level + 1)
|
||||
challenges = []
|
||||
completed_count = 0
|
||||
for ch in active:
|
||||
st = challenge_state.get(ch["id"]) or {}
|
||||
count, target = _goal_ui_progress(ch["goal"], st, streak, xp_total)
|
||||
if st.get("completed"):
|
||||
completed_count += 1
|
||||
challenges.append({
|
||||
"id": ch["id"],
|
||||
"title": ch["title"],
|
||||
"description": ch["description"],
|
||||
"count": count,
|
||||
"target": target,
|
||||
"completed": bool(st.get("completed")),
|
||||
"completed_at": st.get("completed_at"),
|
||||
})
|
||||
next_block = {
|
||||
"level": level + 1,
|
||||
"required": level_def["required"],
|
||||
"completed": completed_count,
|
||||
"challenges": challenges,
|
||||
}
|
||||
paths_payload.append({
|
||||
"id": pid,
|
||||
"name": pdef["name"],
|
||||
"icon": pdef["icon"],
|
||||
"level": level,
|
||||
"max_level": progression_mod.path_max_level(content, pid),
|
||||
"next": next_block,
|
||||
})
|
||||
|
||||
available = [
|
||||
{"id": pid, "name": pdef["name"], "icon": pdef["icon"]}
|
||||
for pid, pdef in sorted(content["paths"].items(), key=lambda kv: (kv[1].get("order") or 0, kv[0]))
|
||||
if pid not in player_paths
|
||||
]
|
||||
|
||||
quest_rows = appstate.meta_db.get_quest_rows(keys)
|
||||
quests_payload = {}
|
||||
for period_type in ("daily", "weekly"):
|
||||
pool = content["quests"][period_type]["pool"]
|
||||
quests = []
|
||||
for row in quest_rows:
|
||||
if row["period_type"] != period_type:
|
||||
continue
|
||||
qdef = pool.get(row["quest_id"])
|
||||
if not qdef:
|
||||
continue # removed from the pool mid-period: hide, keep the row
|
||||
count, target = _goal_ui_progress(qdef["goal"], row, streak, xp_total)
|
||||
quests.append({
|
||||
"id": row["quest_id"],
|
||||
"title": qdef["title"],
|
||||
"description": qdef["description"],
|
||||
"reward_db": row["reward_db"],
|
||||
"count": count,
|
||||
"target": target,
|
||||
"completed": row["completed"],
|
||||
"completed_at": row["completed_at"],
|
||||
})
|
||||
quests_payload[period_type] = {
|
||||
"period_key": keys[period_type],
|
||||
"resets_at": progression_mod.period_resets_at(period_type, now).isoformat(),
|
||||
"quests": quests,
|
||||
}
|
||||
|
||||
return {
|
||||
"mastery_rank": progression_mod.mastery_rank(state["calibration_status"], player_paths),
|
||||
"onboarding": {
|
||||
"calibration_status": state["calibration_status"],
|
||||
"calibration_completed_at": state["calibration_completed_at"],
|
||||
"diagnostic_filename": appstate.builtin_diagnostic_filename(),
|
||||
},
|
||||
"paths": paths_payload,
|
||||
"available_paths": available,
|
||||
"quests": quests_payload,
|
||||
"wallet": wallet,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/progression")
|
||||
def api_progression():
|
||||
return _progression_overview()
|
||||
|
||||
|
||||
@router.post("/api/progression/paths")
|
||||
def api_progression_add_paths(data: dict):
|
||||
"""Select instrument paths. Body: {add: [path_id, ...]}. Idempotent;
|
||||
removal is unsupported (Mastery Rank never decreases)."""
|
||||
add = data.get("add")
|
||||
if not isinstance(add, list) or not add:
|
||||
return JSONResponse({"error": "add must be a non-empty list of path ids"}, status_code=400)
|
||||
content = appstate.get_progression_content()
|
||||
for pid in add:
|
||||
if not isinstance(pid, str) or pid not in content["paths"]:
|
||||
return JSONResponse({"error": f"unknown path: {pid!r}"}, status_code=400)
|
||||
appstate.meta_db.add_player_paths(add)
|
||||
return _progression_overview()
|
||||
|
||||
|
||||
@router.post("/api/progression/onboarding")
|
||||
def api_progression_onboarding(data: dict):
|
||||
"""Onboarding calibration choice. Body: {action: "skip"} — completing the
|
||||
calibration needs no endpoint, it flows through the normal /api/stats path."""
|
||||
if _clean_str(data.get("action")) != "skip":
|
||||
return JSONResponse({"error": "action must be 'skip'"}, status_code=400)
|
||||
# Spec invariant: onboarding requires picking at least one instrument path
|
||||
# before finishing, so skipping straight to rank 1 with no paths would
|
||||
# leave a rank that can never grow. Only enforced when the content bundle
|
||||
# actually defines paths — broken/empty content must never brick onboarding.
|
||||
if appstate.get_progression_content()["paths"] and not appstate.meta_db.get_player_paths():
|
||||
return JSONResponse(
|
||||
{"error": "select at least one instrument path before skipping calibration"},
|
||||
status_code=400,
|
||||
)
|
||||
appstate.meta_db.skip_calibration()
|
||||
return _progression_overview()
|
||||
|
||||
|
||||
# Externally postable progression events. song_completed is deliberately NOT
|
||||
# here: it is server-derived inside /api/stats so the scored-session authority
|
||||
# stays in one place.
|
||||
_PROGRESSION_EVENT_TYPES = {"minigame_run"}
|
||||
|
||||
|
||||
@router.post("/api/progression/events")
|
||||
def api_progression_events(data: dict):
|
||||
"""Generic progression-event intake for plugins (capability `record-event`).
|
||||
Body: {type, payload}. Whitelisted types, scalar payload values only."""
|
||||
etype = _clean_str(data.get("type"))
|
||||
if etype not in _PROGRESSION_EVENT_TYPES:
|
||||
return JSONResponse(
|
||||
{"error": f"event type must be one of {sorted(_PROGRESSION_EVENT_TYPES)}"},
|
||||
status_code=400,
|
||||
)
|
||||
payload = data.get("payload")
|
||||
if payload is None:
|
||||
payload = {}
|
||||
if not isinstance(payload, dict) or len(payload) > 16:
|
||||
return JSONResponse({"error": "payload must be a small object"}, status_code=400)
|
||||
clean = {}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or len(key) > 64:
|
||||
return JSONResponse({"error": "payload keys must be short strings"}, status_code=400)
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, bool) or (
|
||||
not isinstance(value, (int, float, str))
|
||||
) or (isinstance(value, float) and not math.isfinite(value)) or (
|
||||
isinstance(value, str) and len(value) > 256
|
||||
):
|
||||
return JSONResponse({"error": "payload values must be short strings or finite numbers"}, status_code=400)
|
||||
clean[key] = value
|
||||
summary = appstate.meta_db.record_progression_event(etype, clean, appstate.get_progression_content())
|
||||
return {"ok": True, "progression": summary}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
"""Cosmetics shop (spec 010) — buy/equip avatars & themes with earned currency.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_get_progression_content()`` ->
|
||||
``appstate.get_progression_content()`` (the accessor is injected into the seam;
|
||||
its lazy content cache stays in server.py).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/shop")
|
||||
def api_shop():
|
||||
content = appstate.get_progression_content()
|
||||
owned = appstate.meta_db.get_owned_items()
|
||||
equipped = appstate.meta_db.get_equipped()
|
||||
items = [
|
||||
{**item, "owned": iid in owned, "equipped": equipped.get(item["slot"]) == iid}
|
||||
for iid, item in sorted(content["shop"].items())
|
||||
]
|
||||
return {"items": items, "wallet": appstate.meta_db.get_wallet()}
|
||||
|
||||
|
||||
@router.post("/api/shop/buy")
|
||||
def api_shop_buy(data: dict):
|
||||
"""Spend Decibels on a cosmetic. Atomic: balance check + spend + ownership
|
||||
in one transaction. Decibels are earned by playing only — never purchasable."""
|
||||
item_id = _clean_str(data.get("item_id"))
|
||||
item = appstate.get_progression_content()["shop"].get(item_id)
|
||||
if not item:
|
||||
return JSONResponse({"error": f"unknown item: {item_id!r}"}, status_code=400)
|
||||
status, wallet = appstate.meta_db.buy_shop_item(item)
|
||||
if status == "owned":
|
||||
return JSONResponse({"error": "already owned", "wallet": wallet}, status_code=409)
|
||||
if status == "insufficient":
|
||||
return JSONResponse({"error": "insufficient balance", "wallet": wallet}, status_code=402)
|
||||
return {"ok": True, "item_id": item_id, "wallet": wallet}
|
||||
|
||||
|
||||
@router.post("/api/shop/equip")
|
||||
def api_shop_equip(data: dict):
|
||||
"""Equip an owned cosmetic into its slot. Body: {slot, item_id|null}
|
||||
(null unequips, restoring the default look)."""
|
||||
import progression as progression_mod
|
||||
slot = _clean_str(data.get("slot"))
|
||||
if slot not in progression_mod.SHOP_SLOTS:
|
||||
return JSONResponse({"error": f"slot must be one of {sorted(progression_mod.SHOP_SLOTS)}"}, status_code=400)
|
||||
item_id = data.get("item_id")
|
||||
if item_id is not None:
|
||||
item_id = _clean_str(item_id)
|
||||
item = appstate.get_progression_content()["shop"].get(item_id)
|
||||
if not item or item["slot"] != slot:
|
||||
return JSONResponse({"error": f"unknown item for slot {slot}: {item_id!r}"}, status_code=400)
|
||||
if item_id not in appstate.meta_db.get_owned_items():
|
||||
return JSONResponse({"error": "item not owned"}, status_code=403)
|
||||
return {"ok": True, "equipped": appstate.meta_db.equip_item(slot, item_id)}
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Gameplay scoring — XP award + per-song practice stats (record / recent / best /
|
||||
top / per-song). The `/api/stats/{filename:path}` route is registered LAST so its
|
||||
catch-all doesn't shadow the fixed /recent /best /top paths.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_get_progression_content()`` /
|
||||
``_builtin_diagnostic_filename()`` read through the seam.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from metadata_db import _as_int
|
||||
from reqfields import _clean_str
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/api/xp/award")
|
||||
def api_award_xp(data: dict):
|
||||
"""Award XP into the unified store. Body: {source, amount}. Returns the
|
||||
new progress payload. The single XP authority — song-play, minigames, and
|
||||
tutorials all feed this (no second curve)."""
|
||||
try:
|
||||
amount = _as_int(data.get("amount", 0)) # rejects bool / non-integral / inf
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "amount must be an integer"}, status_code=400)
|
||||
# Upper-bound it: an unbounded value overflows SQLite's 64-bit INTEGER on
|
||||
# bind (→ 500) and no real run awards anywhere near this.
|
||||
if not (0 <= amount <= 10_000_000):
|
||||
return JSONResponse({"error": "amount must be between 0 and 10,000,000"}, status_code=400)
|
||||
appstate.meta_db.award_xp(amount)
|
||||
return appstate.meta_db.get_progress()
|
||||
|
||||
|
||||
@router.post("/api/stats")
|
||||
def api_record_stats(data: dict):
|
||||
"""Record a play. With `score`+`accuracy` → a scored session (plays += 1,
|
||||
best_* = max, last_* = new) plus unified-XP + streak side-effects. With
|
||||
only `lastPlayPosition`/`last_position` → a lightweight resume-position
|
||||
touch (no plays change) so Continue-Playing works for non-scored plays."""
|
||||
filename = _clean_str(data.get("filename"))
|
||||
if not filename:
|
||||
return JSONResponse({"error": "filename required"}, status_code=400)
|
||||
# The recorder hands us URL-encoded filenames; canonicalize to the library
|
||||
# key so stored rows line up with `songs` (and so the arrangement-count bound
|
||||
# below resolves the real song). See MetadataDB._canonical_song_filename.
|
||||
filename = appstate.meta_db._canonical_song_filename(filename)
|
||||
arr_raw = data.get("arrangement", 0)
|
||||
if arr_raw is None:
|
||||
arrangement = 0
|
||||
else:
|
||||
try:
|
||||
arrangement = _as_int(arr_raw) # rejects bool / non-integral (1.9) / inf
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400)
|
||||
# Reject (don't silently coerce to 0) so a malformed/out-of-range index
|
||||
# can't corrupt arrangement 0's stats; also keeps it bindable to INTEGER.
|
||||
if not (0 <= arrangement < 2**63):
|
||||
return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400)
|
||||
# Bound against the song's real arrangement count when it's a known library
|
||||
# song, so a bad index can't create fake arrangement buckets that poison the
|
||||
# per-song aggregate / Continue. Skipped when the song isn't in the library
|
||||
# yet (count unknown — dead-song reads are filtered anyway).
|
||||
_acount = appstate.meta_db.arrangement_count(filename)
|
||||
if _acount and arrangement >= _acount:
|
||||
return JSONResponse({"error": "arrangement out of range for this song"}, status_code=400)
|
||||
score = data.get("score")
|
||||
accuracy = data.get("accuracy")
|
||||
last_pos = data.get("lastPlayPosition", data.get("last_position"))
|
||||
if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
|
||||
# A scored session needs BOTH score and accuracy. Exactly one provided is
|
||||
# ambiguous — don't silently fall through to the position-only branch.
|
||||
if (score is None) != (accuracy is None):
|
||||
return JSONResponse({"error": "score and accuracy must be provided together"}, status_code=400)
|
||||
|
||||
if score is not None and accuracy is not None:
|
||||
# Reject booleans explicitly — float(True) would otherwise record a play.
|
||||
if isinstance(score, bool) or isinstance(accuracy, bool):
|
||||
return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400)
|
||||
# Reject NaN/Inf too: round(inf) raises OverflowError (→ 500), and a
|
||||
# stored Inf/NaN later breaks JSON serialization of /api/stats reads.
|
||||
try:
|
||||
score = float(score)
|
||||
accuracy = float(accuracy)
|
||||
if not (math.isfinite(score) and math.isfinite(accuracy)):
|
||||
raise ValueError("non-finite")
|
||||
score = int(round(score))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400)
|
||||
# A huge-but-finite score passes isfinite() yet overflows SQLite's
|
||||
# 64-bit INTEGER on bind (→ 500). Bound it to the int64 range.
|
||||
if not (0 <= score < 2**63):
|
||||
return JSONResponse({"error": "score out of range"}, status_code=400)
|
||||
# accuracy is a 0..1 fraction (the recorder's contract); reject
|
||||
# out-of-range values so they don't surface as >100% / negative in
|
||||
# /api/stats/best and the badge UI.
|
||||
if not (0 <= accuracy <= 1):
|
||||
return JSONResponse({"error": "accuracy must be between 0 and 1"}, status_code=400)
|
||||
# Validate the optional resume position in this branch too (the
|
||||
# position-only branch below already rejects non-finite).
|
||||
if last_pos is not None:
|
||||
try:
|
||||
last_pos = float(last_pos)
|
||||
if not math.isfinite(last_pos):
|
||||
raise ValueError("non-finite")
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
row = appstate.meta_db.record_session(filename, arrangement, score=score,
|
||||
accuracy=accuracy, last_position=last_pos)
|
||||
# Unified XP + streak side-effects — never let these drop the stat write.
|
||||
progress = None
|
||||
try:
|
||||
from xp import xp_for_run
|
||||
from datetime import date
|
||||
appstate.meta_db.award_xp(xp_for_run(score))
|
||||
appstate.meta_db.record_active_day(date.today().isoformat())
|
||||
progress = appstate.meta_db.get_progress()
|
||||
except Exception:
|
||||
log.warning("stats side-effects (xp/streak) failed", exc_info=True)
|
||||
# Progression engine (spec 010) — same never-drop-the-stat-write
|
||||
# contract. Scored sessions are the server-derived `song_completed`
|
||||
# authority (scored == note detection by construction); instrument is
|
||||
# resolved from library arrangement metadata, after the XP award so
|
||||
# db_earned goals see this run's Decibels.
|
||||
progression_summary = None
|
||||
try:
|
||||
import progression as progression_mod
|
||||
instrument = progression_mod.instrument_for_arrangement(
|
||||
appstate.meta_db.arrangement_entry(filename, arrangement)
|
||||
)
|
||||
progression_summary = appstate.meta_db.record_progression_event(
|
||||
"song_completed",
|
||||
{
|
||||
"filename": filename,
|
||||
"instrument": instrument,
|
||||
"accuracy": accuracy,
|
||||
"score": score,
|
||||
"is_diagnostic": filename == appstate.builtin_diagnostic_filename(),
|
||||
},
|
||||
appstate.get_progression_content(),
|
||||
)
|
||||
except Exception:
|
||||
log.warning("stats side-effects (progression) failed", exc_info=True)
|
||||
return {"stats": row, "progress": progress, "progression": progression_summary}
|
||||
|
||||
# Position-only touch.
|
||||
if last_pos is None:
|
||||
return JSONResponse(
|
||||
{"error": "provide score+accuracy (scored) or lastPlayPosition (resume)"},
|
||||
status_code=400,
|
||||
)
|
||||
try:
|
||||
pos = float(last_pos)
|
||||
if not math.isfinite(pos):
|
||||
raise ValueError("non-finite")
|
||||
row = appstate.meta_db.touch_position(filename, arrangement, pos)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
|
||||
# A resume session still counts as playing today: advance the streak (no XP —
|
||||
# that's scoring-only) so a non-scored practice day keeps the streak alive,
|
||||
# consistent with these sessions also surfacing in recent / continue.
|
||||
progress = None
|
||||
try:
|
||||
from datetime import date
|
||||
appstate.meta_db.record_active_day(date.today().isoformat())
|
||||
progress = appstate.meta_db.get_progress()
|
||||
except Exception:
|
||||
log.warning("stats side-effects (streak) failed", exc_info=True)
|
||||
return {"stats": row, "progress": progress}
|
||||
|
||||
|
||||
@router.get("/api/stats/recent")
|
||||
def api_recent_stats(limit: int = 12):
|
||||
"""Recently-played rows joined to song metadata for 'Jump back in'."""
|
||||
from urllib.parse import quote
|
||||
out = []
|
||||
for r in appstate.meta_db.recent_stats(limit):
|
||||
meta = appstate.meta_db.conn.execute(
|
||||
"SELECT title, artist, tuning_name FROM songs WHERE filename = ?",
|
||||
(r["filename"],),
|
||||
).fetchone()
|
||||
title, artist, tuning_name = meta if meta else (None, None, None)
|
||||
out.append({
|
||||
**r,
|
||||
"title": title or r["filename"],
|
||||
"artist": artist or "",
|
||||
"tuning_name": tuning_name or "",
|
||||
"art_url": f"/api/song/{quote(r['filename'])}/art",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/api/stats/best")
|
||||
def api_stats_best():
|
||||
"""{filename: best_accuracy} for all songs with a recorded best — one call
|
||||
to badge the library grid (defined before the {filename} catch-all)."""
|
||||
return appstate.meta_db.best_accuracy_map()
|
||||
|
||||
|
||||
@router.get("/api/stats/top")
|
||||
def api_top_stats(limit: int = 5):
|
||||
"""Top scored songs (best first), joined to song metadata, for the profile
|
||||
'Your best scores' panel (defined before the {filename} catch-all)."""
|
||||
from urllib.parse import quote
|
||||
out = []
|
||||
for r in appstate.meta_db.top_stats(limit):
|
||||
meta = appstate.meta_db.conn.execute(
|
||||
"SELECT title, artist, tuning_name FROM songs WHERE filename = ?",
|
||||
(r["filename"],),
|
||||
).fetchone()
|
||||
title, artist, tuning_name = meta if meta else (None, None, None)
|
||||
out.append({
|
||||
**r,
|
||||
"title": title or r["filename"],
|
||||
"artist": artist or "",
|
||||
"tuning_name": tuning_name or "",
|
||||
"art_url": f"/api/song/{quote(r['filename'])}/art",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/api/stats/{filename:path}")
|
||||
def api_song_stats(filename: str):
|
||||
return appstate.meta_db.get_song_stats(filename)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""The merged tuning catalog (/api/tunings).
|
||||
|
||||
Extracted verbatim from server.py (R3) except @app->@router, CONFIG_DIR->
|
||||
appstate.config_dir, _load_config imported from lib/appconfig, and the tuning
|
||||
registry read through the appstate seam (appstate.tuning_providers — the same
|
||||
instance plugins register into via the plugin_context in server.py).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
import appstate
|
||||
from appconfig import _load_config
|
||||
from tunings import DEFAULT_REFERENCE_PITCH, TUNING_PRESET_MIDIS, freqs_to_midis
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/tunings")
|
||||
def get_tunings():
|
||||
cfg = _load_config(appstate.config_dir / "config.json") or {}
|
||||
ref = cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH)
|
||||
try:
|
||||
ref = float(ref)
|
||||
if not (430.0 <= ref <= 450.0):
|
||||
ref = DEFAULT_REFERENCE_PITCH
|
||||
except (TypeError, ValueError):
|
||||
ref = DEFAULT_REFERENCE_PITCH
|
||||
merged = appstate.tuning_providers.get_merged(ref)
|
||||
# tuningMidis: the same catalog as exact integer MIDI notes (low → high).
|
||||
# Built-ins come straight from TUNING_PRESET_MIDIS (no float round-trip);
|
||||
# provider-contributed entries are recovered from their frequencies at the
|
||||
# served reference pitch. Every consumer today (the v3 badges, plugins)
|
||||
# reconstructs midis client-side via log2 — a rounding footgun at non-440
|
||||
# references — so serve the integers once, host-side. Additive: the
|
||||
# existing referencePitch/tunings shape is unchanged.
|
||||
tuning_midis: dict[str, dict[str, list[int]]] = {}
|
||||
for key, names in merged.items():
|
||||
builtin = TUNING_PRESET_MIDIS.get(key, {})
|
||||
resolved: dict[str, list[int]] = {}
|
||||
for name, freqs in names.items():
|
||||
midis = builtin.get(name) or freqs_to_midis(freqs, ref)
|
||||
if midis:
|
||||
resolved[name] = list(midis)
|
||||
if resolved:
|
||||
tuning_midis[key] = resolved
|
||||
return {"referencePitch": ref, "tunings": merged, "tuningMidis": tuning_midis}
|
||||
@@ -0,0 +1,81 @@
|
||||
"""App version + source/license URLs (/api/version).
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3) except the decorator (``@app`` ->
|
||||
``@router``) and the VERSION-file lookup: ``Path(__file__).parent`` (app root
|
||||
when this lived at the top level) -> ``Path(__file__).resolve().parents[2]``
|
||||
(routers -> lib -> app root). VERSION ships at the app root in every packaging
|
||||
path (Dockerfile COPY, desktop bundle).
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _safe_http_url(raw):
|
||||
"""Return `raw` stripped + trailing-slash-stripped if it parses as an
|
||||
http(s) URL with a non-empty host; else None.
|
||||
|
||||
Used to validate operator-supplied `APP_SOURCE_URL` / `APP_LICENSE_URL`
|
||||
env vars before they reach `<a href>` in the UI. A bare prefix check
|
||||
like `startswith(("http://","https://"))` accepts malformed inputs
|
||||
such as `"https://"` (no host) or `"https:///foo"` (empty host) that
|
||||
still produce broken hrefs — and, when used as a base for the default
|
||||
`license_url`, garbage like `"https:///blob/main/LICENSE"`.
|
||||
"""
|
||||
from urllib.parse import urlsplit
|
||||
if not raw:
|
||||
return None
|
||||
s = raw.strip().rstrip("/")
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(s)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.scheme.lower() not in ("http", "https"):
|
||||
return None
|
||||
# `netloc` includes any `user:pass@` and `:port` — strings like
|
||||
# "http://:80/path" have non-empty netloc (":80") but no real
|
||||
# hostname. Validate `hostname` so only URLs with an actual host
|
||||
# are accepted.
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
@router.get("/api/version")
|
||||
def get_version():
|
||||
env_version = os.environ.get("APP_VERSION", "").strip()
|
||||
if env_version:
|
||||
version = env_version
|
||||
else:
|
||||
version_file = Path(__file__).resolve().parents[2] / "VERSION" # R3: app root from lib/routers/
|
||||
version = "unknown"
|
||||
if version_file.exists():
|
||||
try:
|
||||
version = version_file.read_text().strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass
|
||||
default_source_url = "https://github.com/got-feedback/feedBack"
|
||||
# APP_SOURCE_URL / APP_LICENSE_URL flow straight into <a href> in the UI,
|
||||
# so validate with urllib.parse rather than a bare prefix check — a prefix
|
||||
# check accepts malformed values like "https://" (no host) which produce
|
||||
# broken hrefs (and a constructed license_url like "https:///blob/main/LICENSE").
|
||||
# _safe_http_url requires scheme in {http,https} AND a non-empty hostname
|
||||
# (not just netloc — that would still accept port-only authorities like
|
||||
# "http://:80/path"); fall back to the safe default otherwise.
|
||||
source_url = _safe_http_url(os.environ.get("APP_SOURCE_URL")) or default_source_url
|
||||
# APP_LICENSE_URL: explicit override for the LICENSE link. The default
|
||||
# constructed value (source_url + "/blob/main/LICENSE") is GitHub-
|
||||
# specific and assumes the repo's default branch is `main`; non-GitHub
|
||||
# hosts (GitLab, Gitea, self-hosted) need an explicit value.
|
||||
license_url = _safe_http_url(os.environ.get("APP_LICENSE_URL")) or (source_url + "/blob/main/LICENSE")
|
||||
return {
|
||||
"version": version,
|
||||
"source_url": source_url,
|
||||
"license_url": license_url,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Wishlist / "wanted" API (feedBack#636) — songs the user wants but doesn't own.
|
||||
|
||||
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
|
||||
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import appstate
|
||||
from reqfields import _clean_str
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/wanted")
|
||||
def api_list_wanted():
|
||||
"""The wishlist — songs the user wants but doesn't own yet (newest first)."""
|
||||
return {"wanted": appstate.meta_db.list_wanted()}
|
||||
|
||||
|
||||
@router.post("/api/wanted")
|
||||
def api_add_wanted(data: dict):
|
||||
"""Add a not-owned song to the wishlist. `artist`/`title` are required (at
|
||||
least one non-empty); `source`/`source_ref`/`note` are optional. Idempotent
|
||||
on identity so producers (find_more ownership-diff, manual add) can re-post."""
|
||||
if not isinstance(data, dict):
|
||||
return JSONResponse({"error": "body must be an object"}, status_code=400)
|
||||
artist = _clean_str(data.get("artist"))
|
||||
title = _clean_str(data.get("title"))
|
||||
if not artist and not title:
|
||||
return JSONResponse({"error": "artist or title required"}, status_code=400)
|
||||
row = appstate.meta_db.add_wanted(
|
||||
artist=artist, title=title,
|
||||
source=_clean_str(data.get("source")) or "manual",
|
||||
source_ref=_clean_str(data.get("source_ref")),
|
||||
note=_clean_str(data.get("note")),
|
||||
)
|
||||
return {"ok": True, "wanted": row}
|
||||
|
||||
|
||||
@router.delete("/api/wanted/{wanted_id}")
|
||||
def api_remove_wanted(wanted_id: int):
|
||||
"""Remove a wishlist entry by id."""
|
||||
return {"ok": appstate.meta_db.remove_wanted(wanted_id)}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,16 @@
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 [--n 60] [--soak 30]
|
||||
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 --song "Arcturus ….feedpak"
|
||||
//
|
||||
// With --song, it additionally measures the 2D highway's PER-FRAME DRAW cost:
|
||||
// it wraps requestAnimationFrame before any page script runs, tags the frames
|
||||
// in which the highway actually painted (via highway.addDrawHook), starts
|
||||
// playback, and reports draw-frame p50/p95/p99 over --frames seconds. Tagging
|
||||
// matters — roughly half the rAF callbacks belong to other cheap loops, and
|
||||
// averaging them in hides a renderer regression behind ~0.1 ms no-op frames.
|
||||
// This is the metric that gates the highway.js split (R3c); run it before AND
|
||||
// after each highway change on the same machine.
|
||||
//
|
||||
// Maintainer/CI-only dev tooling (uses the committed @playwright/test browser);
|
||||
// never part of the serve or Docker path. Metrics that need a seeded library
|
||||
@@ -21,6 +31,9 @@ for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i].replac
|
||||
const BASE = args.get('base') || 'http://127.0.0.1:8000';
|
||||
const N = parseInt(args.get('n') || '60', 10);
|
||||
const SOAK_S = parseInt(args.get('soak') || '30', 10);
|
||||
const SONG = args.get('song') || null;
|
||||
const FRAME_S = parseInt(args.get('frames') || '10', 10);
|
||||
const RUNS = parseInt(args.get('runs') || '3', 10); // repeat frame sampling to show spread
|
||||
|
||||
const pct = (xs, p) => {
|
||||
if (!xs.length) return null;
|
||||
@@ -69,6 +82,64 @@ async function clientMetrics() {
|
||||
return { bootMs, memStartMB: mem0 && mem0 / 1048576, memSoakMB: mem1 && mem1 / 1048576, scripts };
|
||||
}
|
||||
|
||||
// ── 2D highway per-frame draw cost (needs --song + a seeded library) ──────────
|
||||
async function frameTimeOnce(browser) {
|
||||
const page = await browser.newPage();
|
||||
let f, t2, notes;
|
||||
try {
|
||||
// Wrap rAF before any page script; mark frames the highway actually drew.
|
||||
await page.addInitScript(() => {
|
||||
window.__f = [];
|
||||
window.__drew = false;
|
||||
const raf = window.requestAnimationFrame.bind(window);
|
||||
window.requestAnimationFrame = (cb) => raf((t) => {
|
||||
window.__drew = false;
|
||||
const t0 = performance.now();
|
||||
try { cb(t); } finally { window.__f.push({ ms: performance.now() - t0, drew: window.__drew }); }
|
||||
});
|
||||
});
|
||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
await page.evaluate(() => document.getElementById('v3-onboarding')?.remove());
|
||||
await page.evaluate((s) => window.playSong(s), SONG);
|
||||
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length >= 0 && window.highway?.getSongInfo?.(),
|
||||
null, { timeout: 45000 }).catch(() => {});
|
||||
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length > 0, null, { timeout: 45000 });
|
||||
await page.evaluate(() => window.highway.addDrawHook(() => { window.__drew = true; }));
|
||||
// Start playback so draw() leaves its paused-throttle path; confirm the clock advances.
|
||||
await page.evaluate(async () => { const a = window.highway.getAudioElement?.(); if (a) a.muted = true; await a?.play?.(); });
|
||||
await page.waitForTimeout(1500);
|
||||
const t1 = await page.evaluate(() => window.highway.getTime());
|
||||
await page.evaluate(() => { window.__f.length = 0; });
|
||||
await page.waitForTimeout(FRAME_S * 1000);
|
||||
({ f, t2, notes } = await page.evaluate(() => ({
|
||||
f: window.__f.slice(), t2: window.highway.getTime(), notes: window.highway.getNotes().length,
|
||||
})));
|
||||
if (!(t2 > t1 + 1)) throw new Error(`clock did not advance (${t1}→${t2}) — measured the paused path`);
|
||||
} finally {
|
||||
// Always close, even when an await above throws — otherwise a failing
|
||||
// run leaks its page until the final browser.close().
|
||||
await page.close();
|
||||
}
|
||||
const drew = f.filter((x) => x.drew).map((x) => x.ms);
|
||||
return { drew, total: f.length, notes };
|
||||
}
|
||||
|
||||
async function frameTime() {
|
||||
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
|
||||
const rows = [];
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
try {
|
||||
const r = await frameTimeOnce(browser);
|
||||
rows.push({
|
||||
p50: pct(r.drew, 50), p95: pct(r.drew, 95), p99: pct(r.drew, 99),
|
||||
max: Math.max(...r.drew), n: r.drew.length, total: r.total, notes: r.notes,
|
||||
});
|
||||
} catch (e) { rows.push({ error: String(e.message || e) }); }
|
||||
}
|
||||
await browser.close();
|
||||
return rows;
|
||||
}
|
||||
|
||||
const server = await serverLatency([
|
||||
'/api/version',
|
||||
'/api/plugins',
|
||||
@@ -86,9 +157,21 @@ out += `| Cold boot → networkidle | ${client.bootMs} ms |\n`;
|
||||
out += `| JS heap after load | ${client.memStartMB ? client.memStartMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| JS heap after ${SOAK_S}s idle soak | ${client.memSoakMB ? client.memSoakMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| Plugin scripts injected | ${client.scripts} |\n`;
|
||||
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
|
||||
out += `> on the 2D + 3D highway, and screen-entry (plugin inject → interactive) for\n`;
|
||||
out += `> editor / notedetect / highway_3d with a real chart loaded. Run this harness\n`;
|
||||
out += `> against an environment with charts in \`DLC_DIR\` to fill those in.\n`;
|
||||
if (SONG) {
|
||||
const frames = await frameTime();
|
||||
out += `\n### 2D highway per-frame draw cost — \`${SONG}\` (${RUNS} runs × ${FRAME_S}s)\n\n`;
|
||||
out += `| run | draw frames | p50 | p95 | p99 | max |\n|---|---|---|---|---|---|\n`;
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const r = frames[i];
|
||||
if (r.error) { out += `| ${i + 1} | — | \`${r.error}\` | | | |\n`; continue; }
|
||||
out += `| ${i + 1} | ${r.n}/${r.total} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} | ${ms(r.max)} |\n`;
|
||||
}
|
||||
const p95s = frames.filter((r) => !r.error).map((r) => r.p95);
|
||||
if (p95s.length) out += `\n**p95 spread across runs: ${ms(Math.min(...p95s))}–${ms(Math.max(...p95s))} ms**\n`;
|
||||
} else {
|
||||
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
|
||||
out += `> on the 2D highway — pass \`--song "<filename in DLC_DIR>"\` to capture it.\n`;
|
||||
out += `> (3D highway_3d + screen-entry timings are a separate R4 concern.)\n`;
|
||||
}
|
||||
|
||||
console.log(out);
|
||||
|
||||
@@ -4877,6 +4877,15 @@ window.jucePlayer = jucePlayer;
|
||||
// value change (this runs on a 350ms poll — logging every tick would
|
||||
// flood the diagnostics buffer).
|
||||
let _loggedOutputType;
|
||||
// [asio-diag] verbose diagnostics, gated on --debug (preload exposes
|
||||
// audio.debugEnabled). Resolved once at install; until it resolves the
|
||||
// flag stays false and verbose lines are skipped. Shared with the
|
||||
// renderer-bus feeder below via window._asioDiagEnabled.
|
||||
let _asioDiag = false;
|
||||
if (typeof juceApi.debugEnabled === 'function') {
|
||||
juceApi.debugEnabled().then((v) => { _asioDiag = !!v; }).catch(() => {});
|
||||
}
|
||||
window._asioDiagEnabled = () => _asioDiag;
|
||||
async function _outputIsExclusive() {
|
||||
if (typeof juceApi.getCurrentDevice !== 'function') {
|
||||
if (_loggedOutputType !== '<no-getCurrentDevice>') {
|
||||
@@ -4892,6 +4901,15 @@ window.jucePlayer = jucePlayer;
|
||||
if (t !== _loggedOutputType) {
|
||||
_loggedOutputType = t;
|
||||
console.log('[feedpak-route] outputType=', JSON.stringify(t), '→ exclusive=', excl);
|
||||
// [asio-diag] full device object on every type change — shows
|
||||
// the exact strings the predicate saw (inputType vs outputType,
|
||||
// device names, duplex), so a driver reporting a non-'ASIO'
|
||||
// type name is visible in tester logs.
|
||||
if (_asioDiag) {
|
||||
try {
|
||||
console.log('[asio-diag] getCurrentDevice=', JSON.stringify(dev));
|
||||
} catch (_) { /* circular/hostile object — skip */ }
|
||||
}
|
||||
}
|
||||
return excl;
|
||||
} catch (e) {
|
||||
@@ -5376,6 +5394,13 @@ window.jucePlayer = jucePlayer;
|
||||
if (typeof ctx.setSinkId !== 'function') throw new Error('setSinkId unsupported');
|
||||
await ctx.setSinkId(exclusive ? { type: 'none' } : '');
|
||||
if (ctx.state !== 'running') await ctx.resume().catch(() => {});
|
||||
// [asio-diag] a context left on the default sink while the bus is
|
||||
// engaged is exactly the "song on the wrong device" symptom — record
|
||||
// every successful sink flip (failures throw and are logged upstream).
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
console.log('[asio-diag] setSink:', exclusive ? 'null-sink' : 'default',
|
||||
'state=', ctx.state, 'rate=', ctx.sampleRate);
|
||||
}
|
||||
}
|
||||
|
||||
async function _disengage() {
|
||||
@@ -5446,6 +5471,24 @@ window.jucePlayer = jucePlayer;
|
||||
else if (elementSong) want = 'element';
|
||||
}
|
||||
|
||||
// [asio-diag] full decision vector, change-gated (500ms poll —
|
||||
// steady state must not flood the buffer). This is the feeder-side
|
||||
// counterpart of the watcher's [feedpak-route] decision line: it
|
||||
// shows WHY the bus did or didn't engage (exclusive predicate,
|
||||
// stems graph presence, native transport ownership, element song).
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
const d = 'running=' + running + ' exclusive=' + exclusive
|
||||
+ ' stems=' + !!stems + ' songAudio=' + !!songAudio
|
||||
+ ' juceMode=' + !!window._juceMode
|
||||
+ ' elementSong=' + elementSong
|
||||
+ ' want=' + want + ' mode=' + _mode;
|
||||
if (d !== window._lastRendererBusDecision) {
|
||||
window._lastRendererBusDecision = d;
|
||||
console.log('[asio-diag] renderer-bus:', d);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
|
||||
if (want !== _mode || stemsGraphChanged) {
|
||||
await _disengage();
|
||||
|
||||
+894
-891
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,36 @@ import structlog
|
||||
_LOGGING_NAMES = ("feedBack", "uvicorn", "uvicorn.error", "uvicorn.access")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_enrichment_state():
|
||||
"""Reset the enrichment worker's process-global state between tests.
|
||||
|
||||
The `server` fixtures pop-and-reimport `server`, but `lib/enrichment.py`
|
||||
(which now owns the worker) stays imported for the whole session, so its
|
||||
module globals — the cancel Event, the status dict, the caches — would
|
||||
otherwise leak across tests. A test that set `_enrich_cancel` (or a stale
|
||||
`running` status) could silently short-circuit a later direct
|
||||
`_background_enrich()` call. Clear it up front so each test starts clean.
|
||||
"""
|
||||
try:
|
||||
import enrichment
|
||||
except ImportError:
|
||||
yield
|
||||
return
|
||||
enrichment._enrich_cancel.clear()
|
||||
enrichment._enrich_pending_pass = False
|
||||
enrichment._enrich_status.update(
|
||||
{"running": False, "processed": 0, "last_pass_at": None,
|
||||
"total": 0, "matched": 0, "current": None})
|
||||
enrichment._enrich_last_fetch = 0.0
|
||||
enrichment._artist_alias_cache.clear()
|
||||
# _caa_index_locks is deliberately left alone: it's guarded by
|
||||
# _caa_index_locks_guard, so clearing it here (unlocked) would race a
|
||||
# still-alive worker thread, and its entries are stateless per-release
|
||||
# mutexes that don't leak test state anyway.
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def isolate_logging():
|
||||
"""Restore feedBack / uvicorn logger state after each test.
|
||||
|
||||
@@ -42,7 +42,7 @@ test('beats:loaded emit is wired into the WS beats case', () => {
|
||||
);
|
||||
assert.match(
|
||||
block,
|
||||
/count:\s*beats\.length/,
|
||||
/count:\s*hwState\.beats\.length/,
|
||||
'beats:loaded payload must include count = beats.length',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ test('handshapes WS case accumulates incoming chunks into handShapes', () => {
|
||||
const block = getCaseBlock(src, 'handshapes');
|
||||
assert.match(
|
||||
block,
|
||||
/handShapes\s*=\s*handShapes\.concat\(\s*msg\.data\s*\)/,
|
||||
/hwState\.handShapes\s*=\s*hwState\.handShapes\.concat\(\s*msg\.data\s*\)/,
|
||||
'handshapes case must concat msg.data into the handShapes accumulator',
|
||||
);
|
||||
});
|
||||
@@ -62,7 +62,7 @@ test('bundle exposes handShapes to renderers with flat-list fallback', () => {
|
||||
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/\bhandShapes\s*[:=]\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/,
|
||||
/\bhandShapes\s*[:=]\s*\([^)]*hwState\._filteredHandShapes[^)]*\)\s*\?\s*hwState\._filteredHandShapes\s*:\s*hwState\.handShapes\b/,
|
||||
'bundle must expose handShapes with the _filteredHandShapes-vs-handShapes ternary fallback',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ function src(file) {
|
||||
test('highway renderer bundles surface the core lefty flag', () => {
|
||||
assert.match(
|
||||
src(HIGHWAY_JS),
|
||||
/lefty\s*[:=]\s*_lefty/,
|
||||
/lefty\s*[:=]\s*hwState\._lefty/,
|
||||
'custom renderer bundles must include lefty: _lefty',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,12 +49,12 @@ test('core _makeBundle exposes isPlaying derived from the chart-clock anchor', (
|
||||
// within the interp cap.
|
||||
assert.match(
|
||||
fn,
|
||||
/isPlaying\s*[:=]\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/,
|
||||
/isPlaying\s*[:=]\s*!Number\.isNaN\(\s*hwState\._chartAnchorPerfNow\s*\)/,
|
||||
'isPlaying must gate on a live anchor (_chartAnchorPerfNow not NaN)',
|
||||
);
|
||||
assert.match(
|
||||
fn,
|
||||
/_chartLastAdvanceAt\s*\)\s*<=\s*_CHART_MAX_INTERP_MS/,
|
||||
/hwState\._chartLastAdvanceAt\s*\)\s*<=\s*_CHART_MAX_INTERP_MS/,
|
||||
'isPlaying must require the clock advanced within _CHART_MAX_INTERP_MS',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ function extractBlock(src, signature) {
|
||||
|
||||
test('highway declares adaptive-scale state with a floor', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /let\s+_autoScale\s*=\s*1/, 'missing _autoScale multiplier');
|
||||
assert.match(src, /hwState\._autoScale\s*=\s*1/, 'missing _autoScale multiplier');
|
||||
assert.match(src, /const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget');
|
||||
assert.match(src, /const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget');
|
||||
@@ -49,18 +49,18 @@ test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () =
|
||||
test('min render scale floor is user-configurable + exposed on the api (#654)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
// Hard floor constant kept; configurable floor read from localStorage.
|
||||
assert.match(src, /let\s+_autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
|
||||
assert.match(src, /hwState\._autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
|
||||
assert.match(src, /localStorage\.getItem\('highwayMinRenderScale'\)/,
|
||||
'configurable floor must load from localStorage.highwayMinRenderScale');
|
||||
assert.match(src, /setMinRenderScale\(/, 'api.setMinRenderScale missing');
|
||||
assert.match(src, /getMinRenderScale\(\)\s*\{\s*return\s+_autoScaleMin/, 'api.getMinRenderScale missing');
|
||||
assert.match(src, /getMinRenderScale\(\)\s*\{\s*return\s+hwState\._autoScaleMin/, 'api.getMinRenderScale missing');
|
||||
// Floor is clamped to the user ceiling so it can never exceed the manual cap.
|
||||
const eff = extractBlock(src, 'function _effectiveRenderScale()');
|
||||
assert.match(eff, /Math\.min\(\s*_autoScaleMin\s*,\s*user\s*\)/,
|
||||
assert.match(eff, /Math\.min\(\s*hwState\._autoScaleMin\s*,\s*user\s*\)/,
|
||||
'effective scale must clamp the floor to the user ceiling');
|
||||
// _adaptRenderScale must cap the lo bound at 1 so _autoScale stays in [_,1].
|
||||
const adapt = extractBlock(src, 'function _adaptRenderScale(');
|
||||
assert.match(adapt, /Math\.min\(\s*1\s*,\s*_autoScaleMin\s*\/\s*_renderScale\s*\)/,
|
||||
assert.match(adapt, /Math\.min\(\s*1\s*,\s*hwState\._autoScaleMin\s*\/\s*hwState\._renderScale\s*\)/,
|
||||
'lo bound must be capped at 1 to keep _autoScale a [0,1] multiplier');
|
||||
});
|
||||
|
||||
|
||||
@@ -34,9 +34,9 @@ test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () =
|
||||
const neqEither = (a, b) => new RegExp(
|
||||
`\\b${a}\\b\\s*!==\\s*\\b${b}\\b|\\b${b}\\b\\s*!==\\s*\\b${a}\\b`
|
||||
);
|
||||
assert.match(src, eqEither('_chordRenderCacheSrc', 'src'), 'cache must key on src');
|
||||
assert.match(src, eqEither('_chordRenderCacheInverted', '_inverted'), 'cache must key on _inverted');
|
||||
assert.match(src, neqEither('_chordRenderCacheTemplates', 'chordTemplates'),
|
||||
assert.match(src, eqEither('hwState\\._chordRenderCacheSrc', 'src'), 'cache must key on src');
|
||||
assert.match(src, eqEither('hwState\\._chordRenderCacheInverted', 'hwState\\._inverted'), 'cache must key on _inverted');
|
||||
assert.match(src, neqEither('hwState\\._chordRenderCacheTemplates', 'hwState\\.chordTemplates'),
|
||||
'cache must key on chordTemplates (detected via !== for change-flag)');
|
||||
});
|
||||
|
||||
@@ -50,9 +50,9 @@ test('chordTemplates change resets fretline preview and frame-mismatch warner',
|
||||
// block inside the `if (templatesChanged) { … }` branch (e.g. an
|
||||
// inner conditional reset) doesn't break the match by introducing
|
||||
// a `}` before the symbol we're checking for.
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_chordFretLineNotes\s*=\s*\[\][\s\S]*?\}/,
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?hwState\._chordFretLineNotes\s*=\s*\[\][\s\S]*?\}/,
|
||||
'templatesChanged branch must reset _chordFretLineNotes');
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_lastChordOnFretLine\s*=\s*null[\s\S]*?\}/,
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?hwState\._lastChordOnFretLine\s*=\s*null[\s\S]*?\}/,
|
||||
'templatesChanged branch must null _lastChordOnFretLine');
|
||||
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_frameMismatchWarned\.clear\(\)[\s\S]*?\}/,
|
||||
'templatesChanged branch must clear _frameMismatchWarned');
|
||||
|
||||
@@ -23,7 +23,7 @@ test('getFilteredNotes falls through to notes when _filteredNotes is null', () =
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes[^}]*:\s*notes/,
|
||||
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes[^}]*:\s*hwState\.notes/,
|
||||
'getFilteredNotes must return notes as fallback',
|
||||
);
|
||||
});
|
||||
@@ -41,7 +41,7 @@ test('getFilteredChords falls through to chords when _filteredChords is null', (
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords[^}]*:\s*chords/,
|
||||
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords[^}]*:\s*hwState\.chords/,
|
||||
'getFilteredChords must return chords as fallback',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -35,7 +35,10 @@ function extractBlock(src, signature) {
|
||||
// + getTime methods so behavioral tests can exercise the real
|
||||
// implementation in isolation.
|
||||
function buildClockSandbox(perfNowImpl) {
|
||||
const sandbox = {
|
||||
// The lifted per-instance state now lives on `hwState` (the R3c H lift);
|
||||
// the extracted setTime/getTime bodies reference hwState.<slot>. The const
|
||||
// _CHART_MAX_INTERP_MS was NOT lifted, so it stays a top-level global here.
|
||||
const hwState = {
|
||||
chartTime: 0,
|
||||
currentTime: 0,
|
||||
avOffsetSec: 0,
|
||||
@@ -50,6 +53,9 @@ function buildClockSandbox(perfNowImpl) {
|
||||
_chartAnchorPerfNow: NaN,
|
||||
_chartLastAdvanceAt: 0,
|
||||
_chartObservedRate: 1,
|
||||
};
|
||||
const sandbox = {
|
||||
hwState,
|
||||
_CHART_MAX_INTERP_MS: 100,
|
||||
performance: { now: perfNowImpl },
|
||||
};
|
||||
@@ -72,10 +78,10 @@ test('highway declares chart anchor + stall-detect + rate state', () => {
|
||||
// particular MUST start as NaN, not 0, otherwise setTime(0) on the
|
||||
// very first 60 Hz tick fails the `t !== _chartAnchorAudioT` check
|
||||
// and never re-anchors, leaving the clock uninitialized.
|
||||
assert.match(src, /let\s+_chartAnchorAudioT\s*=\s*NaN/, 'missing _chartAnchorAudioT (NaN sentinel)');
|
||||
assert.match(src, /let\s+_chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
|
||||
assert.match(src, /let\s+_chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
|
||||
assert.match(src, /let\s+_chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
|
||||
assert.match(src, /hwState\._chartAnchorAudioT\s*=\s*NaN/, 'missing _chartAnchorAudioT (NaN sentinel)');
|
||||
assert.match(src, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
|
||||
assert.match(src, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
|
||||
assert.match(src, /hwState\._chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
|
||||
assert.match(src, /const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
|
||||
});
|
||||
|
||||
@@ -86,7 +92,7 @@ test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', (
|
||||
const slice = m[0];
|
||||
assert.match(
|
||||
slice,
|
||||
/_chartObservedRate\s*\*\s*elapsedMs/,
|
||||
/hwState\._chartObservedRate\s*\*\s*elapsedMs/,
|
||||
'getTime must scale interpolation by observed rate so audio.playbackRate != 1 stays accurate',
|
||||
);
|
||||
});
|
||||
@@ -100,12 +106,12 @@ test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually ch
|
||||
// The implementation may capture performance.now() into a local
|
||||
// (e.g. newPerfNow) and assign that to both fields; accept either
|
||||
// direct or via-local writes.
|
||||
const m = src.match(/if\s*\(\s*t\s*!==\s*_chartAnchorAudioT\s*\)\s*\{[\s\S]+?\}\s*\},/);
|
||||
const m = src.match(/if\s*\(\s*t\s*!==\s*hwState\._chartAnchorAudioT\s*\)\s*\{[\s\S]+?\}\s*\},/);
|
||||
assert.ok(m, 'if (t !== _chartAnchorAudioT) block not found inside setTime');
|
||||
const block = m[0];
|
||||
assert.match(block, /_chartAnchorAudioT\s*=\s*t/, 'must assign _chartAnchorAudioT = t');
|
||||
assert.match(block, /_chartAnchorPerfNow\s*=/, 'must assign _chartAnchorPerfNow');
|
||||
assert.match(block, /_chartLastAdvanceAt\s*=/, 'must assign _chartLastAdvanceAt');
|
||||
assert.match(block, /hwState\._chartAnchorAudioT\s*=\s*t/, 'must assign _chartAnchorAudioT = t');
|
||||
assert.match(block, /hwState\._chartAnchorPerfNow\s*=/, 'must assign _chartAnchorPerfNow');
|
||||
assert.match(block, /hwState\._chartLastAdvanceAt\s*=/, 'must assign _chartLastAdvanceAt');
|
||||
});
|
||||
|
||||
test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
@@ -119,7 +125,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
// Must check stall-since-last-advance against the cap.
|
||||
assert.match(
|
||||
slice,
|
||||
/nowP\s*-\s*_chartLastAdvanceAt\s*>\s*_CHART_MAX_INTERP_MS/,
|
||||
/nowP\s*-\s*hwState\._chartLastAdvanceAt\s*>\s*_CHART_MAX_INTERP_MS/,
|
||||
'getTime must short-circuit when audio has stalled past the cap',
|
||||
);
|
||||
// Must interpolate when active.
|
||||
@@ -127,7 +133,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => {
|
||||
// Rate-scaled formula: _chartAnchorAudioT + (_chartObservedRate * elapsedMs) / 1000
|
||||
assert.match(
|
||||
slice,
|
||||
/_chartAnchorAudioT\s*\+\s*\(\s*_chartObservedRate\s*\*\s*elapsedMs\s*\)\s*\/\s*1000/,
|
||||
/_chartAnchorAudioT\s*\+\s*\(\s*hwState\._chartObservedRate\s*\*\s*elapsedMs\s*\)\s*\/\s*1000/,
|
||||
'getTime must compute anchor + rate-scaled elapsed during play',
|
||||
);
|
||||
});
|
||||
@@ -138,10 +144,10 @@ test('api.stop() clears the chart anchor state so re-init starts fresh', () => {
|
||||
// the actual stop() body — a fixed-size slice would falsely match
|
||||
// resets that landed in an adjacent method.
|
||||
const stopBlock = extractBlock(src, 'stop() {');
|
||||
assert.match(stopBlock, /_chartAnchorAudioT\s*=\s*NaN/, 'stop() must reset _chartAnchorAudioT to the NaN sentinel');
|
||||
assert.match(stopBlock, /_chartAnchorPerfNow\s*=\s*NaN/, 'stop() must reset _chartAnchorPerfNow to the NaN sentinel');
|
||||
assert.match(stopBlock, /_chartLastAdvanceAt\s*=\s*0/, 'stop() must reset _chartLastAdvanceAt');
|
||||
assert.match(stopBlock, /_chartObservedRate\s*=\s*1/, 'stop() must reset _chartObservedRate to 1x');
|
||||
assert.match(stopBlock, /hwState\._chartAnchorAudioT\s*=\s*NaN/, 'stop() must reset _chartAnchorAudioT to the NaN sentinel');
|
||||
assert.match(stopBlock, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'stop() must reset _chartAnchorPerfNow to the NaN sentinel');
|
||||
assert.match(stopBlock, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'stop() must reset _chartLastAdvanceAt');
|
||||
assert.match(stopBlock, /hwState\._chartObservedRate\s*=\s*1/, 'stop() must reset _chartObservedRate to 1x');
|
||||
});
|
||||
|
||||
// ── Behavioral tests (run extracted setTime/getTime in vm sandbox) ──────
|
||||
@@ -195,12 +201,12 @@ test('behavior: seek discontinuity resets observed rate to 1x', () => {
|
||||
sb.setTime(10);
|
||||
now = 50;
|
||||
sb.setTime(10.025); // observed rate ≈ 0.5
|
||||
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, `prior segment must measure ≈0.5, got ${sb._chartObservedRate}`);
|
||||
assert.ok(Math.abs(sb.hwState._chartObservedRate - 0.5) < 0.001, `prior segment must measure ≈0.5, got ${sb.hwState._chartObservedRate}`);
|
||||
// Seek: large t jump in same perf delta — observed-rate clamp
|
||||
// rejects this segment, resets to 1.
|
||||
now = 70;
|
||||
sb.setTime(120); // dPerf=20ms, dT=110s → observed=5500 (out of clamp)
|
||||
assert.equal(sb._chartObservedRate, 1, 'seek must reset rate to 1x');
|
||||
assert.equal(sb.hwState._chartObservedRate, 1, 'seek must reset rate to 1x');
|
||||
});
|
||||
|
||||
test('behavior: getTime caps interpolation at _CHART_MAX_INTERP_MS', () => {
|
||||
@@ -225,8 +231,8 @@ test('behavior: setTime(0) on first tick anchors correctly (boot edge case)', ()
|
||||
const sb = buildClockSandbox(() => now);
|
||||
sb.setTime(0);
|
||||
// Anchor must now be initialized.
|
||||
assert.equal(sb._chartAnchorAudioT, 0, 'setTime(0) on first tick must set anchor.audioT');
|
||||
assert.equal(sb._chartAnchorPerfNow, 16, 'setTime(0) on first tick must set anchor.perfNow');
|
||||
assert.equal(sb.hwState._chartAnchorAudioT, 0, 'setTime(0) on first tick must set anchor.audioT');
|
||||
assert.equal(sb.hwState._chartAnchorPerfNow, 16, 'setTime(0) on first tick must set anchor.perfNow');
|
||||
// getTime should return a finite value, not NaN.
|
||||
const t = sb.getTime();
|
||||
assert.ok(!Number.isNaN(t), `getTime must not return NaN after setTime(0); got ${t}`);
|
||||
@@ -252,10 +258,10 @@ test('behavior: long anchor gap resets observed rate to 1x', () => {
|
||||
sb.setTime(10);
|
||||
now = 50;
|
||||
sb.setTime(10.025); // observed rate ≈ 0.5
|
||||
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, 'first segment measured 0.5x');
|
||||
assert.ok(Math.abs(sb.hwState._chartObservedRate - 0.5) < 0.001, 'first segment measured 0.5x');
|
||||
// Long gap (1 second) before next setTime — out of the dPerf < 0.5
|
||||
// window, so the rate must reset to 1.
|
||||
now = 1100;
|
||||
sb.setTime(10.5);
|
||||
assert.equal(sb._chartObservedRate, 1, 'long anchor gap must reset rate to 1x');
|
||||
assert.equal(sb.hwState._chartObservedRate, 1, 'long anchor gap must reset rate to 1x');
|
||||
});
|
||||
|
||||
@@ -34,16 +34,16 @@ function extractBlock(src, signature) {
|
||||
|
||||
test('highway declares the note-state provider slot', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /let\s+_noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
|
||||
assert.match(src, /hwState\._noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
|
||||
});
|
||||
|
||||
test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteState / isDefaultRenderer', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*_noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*hwState\._noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
|
||||
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*typeof\s+fn\s*===\s*['"]function['"][^}]*:\s*null/, 'setNoteStateProvider must coerce non-functions (incl. null) to null');
|
||||
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider/, 'getNoteStateProvider must return the slot');
|
||||
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider/, 'getNoteStateProvider must return the slot');
|
||||
assert.match(src, /getNoteState\s*\(\s*note\s*,\s*chartTime\s*\)\s*\{\s*return\s+_noteState\s*\(/, 'getNoteState must delegate to _noteState');
|
||||
assert.match(src, /isDefaultRenderer\s*\(\s*\)\s*\{\s*return\s+_renderer\s*===\s*_defaultRenderer\s*\|\|\s*_renderer\s*==\s*null/, 'isDefaultRenderer must be (_renderer === _defaultRenderer || _renderer == null)');
|
||||
assert.match(src, /isDefaultRenderer\s*\(\s*\)\s*\{\s*return\s+hwState\._renderer\s*===\s*_defaultRenderer\s*\|\|\s*hwState\._renderer\s*==\s*null/, 'isDefaultRenderer must be (_renderer === _defaultRenderer || _renderer == null)');
|
||||
});
|
||||
|
||||
test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)', () => {
|
||||
@@ -72,7 +72,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
|
||||
// slot, so renderers see a live "is a provider registered?" view.
|
||||
assert.match(
|
||||
src,
|
||||
/function\s+_getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider\s*;?\s*\}/,
|
||||
/function\s+_getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider\s*;?\s*\}/,
|
||||
'_getNoteStateProvider must be defined as a stable named function returning _noteStateProvider'
|
||||
);
|
||||
});
|
||||
@@ -80,7 +80,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
|
||||
test('_noteState normalizes provider output as documented', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _noteState(note, chartTime)');
|
||||
assert.match(fn, /if\s*\(\s*!_noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
|
||||
assert.match(fn, /if\s*\(\s*!hwState\._noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
|
||||
assert.match(fn, /try\s*\{[\s\S]*_noteStateProvider\s*\([\s\S]*catch[\s\S]*return\s+null/, 'must call the provider inside try/catch and return null on throw');
|
||||
assert.match(fn, /state\s*!==\s*['"]hit['"]\s*&&\s*state\s*!==\s*['"]active['"]\s*&&\s*state\s*!==\s*['"]miss['"]/, 'must reject states other than hit/active/miss');
|
||||
assert.match(fn, /Math\.max\(\s*0\s*,\s*Math\.min\(\s*1\s*,\s*raw\.alpha\s*\)\s*\)/, 'must clamp alpha to [0,1]');
|
||||
|
||||
@@ -32,7 +32,7 @@ function extractBlock(src, signature) {
|
||||
test('highway declares the paused-render throttle state', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /const\s+_PAUSED_FRAME_INTERVAL_MS\s*=\s*\d+/, 'missing _PAUSED_FRAME_INTERVAL_MS cap');
|
||||
assert.match(src, /let\s+_lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
|
||||
assert.match(src, /hwState\._lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
|
||||
});
|
||||
|
||||
test('draw() throttles full renders while the audio clock is stalled', () => {
|
||||
@@ -42,7 +42,7 @@ test('draw() throttles full renders while the audio clock is stalled', () => {
|
||||
assert.match(fn, /_chartLastAdvanceAt/, 'throttle must key off _chartLastAdvanceAt (the advance timestamp)');
|
||||
assert.match(fn, /_CHART_MAX_INTERP_MS/, 'throttle must reuse the _CHART_MAX_INTERP_MS pause threshold');
|
||||
assert.match(fn, /_PAUSED_FRAME_INTERVAL_MS/, 'throttle must cap paused draws to _PAUSED_FRAME_INTERVAL_MS');
|
||||
assert.match(fn, /_lastPausedDrawAt\s*=\s*_nowP/, 'throttle must record the last paused draw time');
|
||||
assert.match(fn, /hwState\._lastPausedDrawAt\s*=\s*_nowP/, 'throttle must record the last paused draw time');
|
||||
});
|
||||
|
||||
test('throttle runs after the ready gate, before bundle/draw', () => {
|
||||
@@ -51,7 +51,7 @@ test('throttle runs after the ready gate, before bundle/draw', () => {
|
||||
// Regex landmarks (not exact-string indexOf) so harmless spacing /
|
||||
// semicolon changes don't break the ordering guard — matches the
|
||||
// search-based style of the other highway source-guard tests.
|
||||
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return;/);
|
||||
const readyIdx = fn.search(/if\s*\(\s*!hwState\.ready\s*\)\s*return;/);
|
||||
const throttleIdx = fn.search(/_PAUSED_FRAME_INTERVAL_MS/);
|
||||
const drawIdx = fn.search(/_renderer\.draw\s*\(/);
|
||||
assert.ok(readyIdx !== -1, 'ready gate not found');
|
||||
|
||||
@@ -22,7 +22,7 @@ test('getPhrases returns null when _phrases is falsy or empty', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/getPhrases\s*\(\s*\)\s*\{[^}]*!\s*_phrases[^}]*return null/,
|
||||
/getPhrases\s*\(\s*\)\s*\{[^}]*!\s*hwState\._phrases[^}]*return null/,
|
||||
'getPhrases must return null when no phrase data is available',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,9 +44,9 @@ test('_setRenderer captures the outgoing renderer before overwriting it', () =>
|
||||
// prev must be captured BEFORE _destroyCurrentIfInited and the
|
||||
// `_renderer = next` assignment, otherwise the swap detection below
|
||||
// would always compare next against itself.
|
||||
const prevIdx = fn.search(/const\s+prev\s*=\s*_renderer/);
|
||||
const prevIdx = fn.search(/const\s+prev\s*=\s*hwState\._renderer/);
|
||||
const destroyIdx = fn.search(/_destroyCurrentIfInited\(\)/);
|
||||
const assignIdx = fn.search(/^\s*_renderer\s*=\s*next\s*;/m);
|
||||
const assignIdx = fn.search(/^\s*hwState\._renderer\s*=\s*next\s*;/m);
|
||||
assert.ok(prevIdx !== -1, 'must capture `const prev = _renderer`');
|
||||
assert.ok(destroyIdx !== -1, 'must call _destroyCurrentIfInited');
|
||||
assert.ok(assignIdx !== -1, 'must assign `_renderer = next`');
|
||||
@@ -67,7 +67,7 @@ test('_setRenderer replaces the canvas on a context-type change OR a viz change'
|
||||
);
|
||||
assert.match(
|
||||
fn,
|
||||
/if\s*\(\s*nextType\s*!==\s*_currentCanvasContextType\s*\|\|\s*_vizChanged\s*\)\s*\{\s*_replaceCanvas\(nextType\)/,
|
||||
/if\s*\(\s*nextType\s*!==\s*hwState\._currentCanvasContextType\s*\|\|\s*_vizChanged\s*\)\s*\{\s*_replaceCanvas\(nextType\)/,
|
||||
'replace guard must be `nextType !== _currentCanvasContextType || _vizChanged`',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,20 +40,20 @@ test('2D palette arrays are mutable (let) with frozen DEFAULT_* originals', () =
|
||||
assert.match(src, /const\s+DEFAULT_STRING_COLORS\s*=/, 'DEFAULT_STRING_COLORS must exist for reset');
|
||||
assert.match(src, /const\s+DEFAULT_STRING_DIM\s*=/, 'DEFAULT_STRING_DIM must exist for reset');
|
||||
assert.match(src, /const\s+DEFAULT_STRING_BRIGHT\s*=/, 'DEFAULT_STRING_BRIGHT must exist for reset');
|
||||
assert.match(src, /let\s+STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults');
|
||||
assert.match(src, /let\s+STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults');
|
||||
assert.match(src, /let\s+STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults');
|
||||
assert.match(src, /hwState\.STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults');
|
||||
assert.match(src, /hwState\.STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults');
|
||||
assert.match(src, /hwState\.STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults');
|
||||
});
|
||||
|
||||
test('2D public API exposes getStringColors / setStringColors', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy');
|
||||
assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+hwState\.STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy');
|
||||
const fn = extractBlock(src, 'setStringColors(arr)');
|
||||
// Each provided index sets base + derived dim/bright; missing → default.
|
||||
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*base/, 'setStringColors must set the base color');
|
||||
assert.match(fn, /STRING_DIM\[i\]\s*=\s*_darken\(/, 'setStringColors must derive the dim variant');
|
||||
assert.match(fn, /STRING_BRIGHT\[i\]\s*=\s*_lighten\(/, 'setStringColors must derive the bright variant');
|
||||
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*DEFAULT_STRING_COLORS\[i\]/, 'setStringColors must restore defaults for missing/invalid indices');
|
||||
assert.match(fn, /hwState\.STRING_COLORS\[i\]\s*=\s*base/, 'setStringColors must set the base color');
|
||||
assert.match(fn, /hwState\.STRING_DIM\[i\]\s*=\s*_darken\(/, 'setStringColors must derive the dim variant');
|
||||
assert.match(fn, /hwState\.STRING_BRIGHT\[i\]\s*=\s*_lighten\(/, 'setStringColors must derive the bright variant');
|
||||
assert.match(fn, /hwState\.STRING_COLORS\[i\]\s*=\s*DEFAULT_STRING_COLORS\[i\]/, 'setStringColors must restore defaults for missing/invalid indices');
|
||||
});
|
||||
|
||||
// ── 3D highway (plugins/highway_3d/screen.js) ─────────────────────────────
|
||||
|
||||
@@ -32,14 +32,14 @@ function extractBlock(src, signature) {
|
||||
|
||||
test('highway declares visibility state (_visibleOverride + _lastVisible)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
assert.match(src, /let\s+_visibleOverride\s*=\s*null/, 'missing _visibleOverride (override sentinel)');
|
||||
assert.match(src, /let\s+_lastVisible\s*=\s*null/, 'missing _lastVisible (last-emitted state)');
|
||||
assert.match(src, /hwState\._visibleOverride\s*=\s*null/, 'missing _visibleOverride (override sentinel)');
|
||||
assert.match(src, /hwState\._lastVisible\s*=\s*null/, 'missing _lastVisible (last-emitted state)');
|
||||
});
|
||||
|
||||
test('_isHighwayVisible respects _visibleOverride and falls back to offsetParent', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _isHighwayVisible()');
|
||||
assert.match(fn, /_visibleOverride\s*!==\s*null/, 'must check the override before the DOM');
|
||||
assert.match(fn, /hwState\._visibleOverride\s*!==\s*null/, 'must check the override before the DOM');
|
||||
assert.match(fn, /canvas\.offsetParent\s*!==\s*null/, 'DOM fallback must use offsetParent !== null');
|
||||
});
|
||||
|
||||
@@ -47,9 +47,9 @@ test('_emitVisibilityIfChanged is transition-only (no per-frame spam)', () => {
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function _emitVisibilityIfChanged()');
|
||||
// Must short-circuit when the current state equals the cached one.
|
||||
assert.match(fn, /v\s*===\s*_lastVisible/, 'must compare current vs _lastVisible and bail when equal');
|
||||
assert.match(fn, /v\s*===\s*hwState\._lastVisible/, 'must compare current vs _lastVisible and bail when equal');
|
||||
// Must update the cache and emit the event with the documented payload shape.
|
||||
assert.match(fn, /_lastVisible\s*=\s*v/, 'must update _lastVisible after a transition');
|
||||
assert.match(fn, /hwState\._lastVisible\s*=\s*v/, 'must update _lastVisible after a transition');
|
||||
assert.match(
|
||||
fn,
|
||||
/window\.feedBack\.emit\(\s*['"]highway:visibility['"][\s\S]*?visible:\s*v[\s\S]*?canvas/,
|
||||
@@ -66,7 +66,7 @@ test('rAF draw() loop calls _emitVisibilityIfChanged and skips when hidden', ()
|
||||
// transitions during loading/reconnect windows still propagate.
|
||||
const emitIdx = fn.search(/_emitVisibilityIfChanged\(\)/);
|
||||
const skipIdx = fn.search(/if\s*\(\s*!_rendering\s*\)\s*return/);
|
||||
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return/);
|
||||
const readyIdx = fn.search(/if\s*\(\s*!hwState\.ready\s*\)\s*return/);
|
||||
const drawIdx = fn.search(/_renderer\.draw\(/);
|
||||
assert.ok(emitIdx !== -1 && skipIdx !== -1 && readyIdx !== -1 && drawIdx !== -1, 'all four landmarks must be present');
|
||||
assert.ok(emitIdx < readyIdx, 'emit must run BEFORE the !ready gate (transitions during loading must still fire)');
|
||||
@@ -84,19 +84,19 @@ test('draw() keeps an active custom renderer painting through an override-hide (
|
||||
const src = fs.readFileSync(highwayJs, 'utf8');
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
// Single render decision drives both the perf-HUD reset and the gate.
|
||||
assert.match(fn, /let\s+_rendering\s*=\s*_lastVisible/, 'must derive a single _rendering decision from _lastVisible');
|
||||
assert.match(fn, /let\s+_rendering\s*=\s*hwState\._lastVisible/, 'must derive a single _rendering decision from _lastVisible');
|
||||
// Assert the exact boolean RELATIONSHIP, not just the tokens (CodeRabbit):
|
||||
// the exemption must AND together override-hide, an active custom renderer,
|
||||
// and the canvas still in layout. A weakened guard (e.g. `||`, or a dropped
|
||||
// offsetParent clause) must fail this — that's the regression being fixed.
|
||||
assert.match(
|
||||
fn,
|
||||
/!_rendering\s*&&\s*_visibleOverride\s*===\s*false\s*&&\s*_renderer\s*!==\s*_defaultRenderer\s*&&\s*canvas\s*&&\s*canvas\.offsetParent\s*!==\s*null/,
|
||||
/!_rendering\s*&&\s*hwState\._visibleOverride\s*===\s*false\s*&&\s*hwState\._renderer\s*!==\s*_defaultRenderer\s*&&\s*hwState\.canvas\s*&&\s*hwState\.canvas\.offsetParent\s*!==\s*null/,
|
||||
'exemption must AND override-hide + active custom renderer + canvas-in-layout (genuine off-screen still pauses, #246)',
|
||||
);
|
||||
// Both the HUD reset and the gate key off _rendering, not _lastVisible,
|
||||
// so the HUD doesn\'t churn while the custom renderer is actually drawing.
|
||||
assert.match(fn, /_perfHud\s*&&\s*\(\s*!_rendering/, 'perf-HUD reset must key off _rendering, not _lastVisible');
|
||||
assert.match(fn, /hwState\._perfHud\s*&&\s*\(\s*!_rendering/, 'perf-HUD reset must key off _rendering, not _lastVisible');
|
||||
assert.match(fn, /if\s*\(\s*!_rendering\s*\)\s*return/, 'the draw gate must bail on !_rendering');
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ tests/test_art_layer.py.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import enrichment
|
||||
from routers import art
|
||||
import io as _io
|
||||
import sys
|
||||
|
||||
@@ -119,8 +121,8 @@ def caa_index(server, monkeypatch):
|
||||
calls.append(release_id)
|
||||
return indexes.get(release_id) # unknown release → None (a CAA 404)
|
||||
fake.calls, fake.indexes = calls, indexes
|
||||
monkeypatch.setattr(server, "_caa_release_index", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_caa_release_index", fake)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
@@ -251,7 +253,7 @@ def test_caa_candidates_capped_at_12(server, client, caa_index):
|
||||
caa_index.indexes["rel-big"] = {
|
||||
"images": [_img(300 + i, front=(i == 0)) for i in range(20)]}
|
||||
_match_row(server, "a.sloppak", release_id="rel-big")
|
||||
assert len(_caa(_get(client))) == server._ART_PICKER_MAX_CAA == 12
|
||||
assert len(_caa(_get(client))) == art._ART_PICKER_MAX_CAA == 12
|
||||
|
||||
|
||||
def test_demo_mode_blocks_candidates(server, client, monkeypatch):
|
||||
@@ -274,10 +276,10 @@ def test_malicious_release_id_rejected_no_fetch_no_write(server, caa_index):
|
||||
"""A crafted release id (path traversal) never matches _CAA_ID_RE, so it
|
||||
yields no images, opens no socket, and writes no cache file — inside the
|
||||
art dir or anywhere else."""
|
||||
art_dir = server._enrichment_art_dir()
|
||||
art_dir = enrichment._enrichment_art_dir()
|
||||
before = set(art_dir.glob("*"))
|
||||
assert not server._CAA_ID_RE.match("../../etc/x")
|
||||
assert server._caa_index_cached("../../etc/x") == []
|
||||
assert not enrichment._CAA_ID_RE.match("../../etc/x")
|
||||
assert enrichment._caa_index_cached("../../etc/x") == []
|
||||
assert caa_index.calls == [] # the seam was never asked
|
||||
assert set(art_dir.glob("*")) == before # nothing written
|
||||
# And nothing landed at the traversal target beside the cache dir either.
|
||||
@@ -340,10 +342,10 @@ def test_fetch_art_url_follows_redirects_validating_each_hop(server, monkeypatch
|
||||
return _FakeResp(200, chunks=[b"IMGDATA"])
|
||||
|
||||
monkeypatch.setattr(requests, "get", fake_get)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_url_host_is_internal",
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(art, "_url_host_is_internal",
|
||||
lambda u: (checked.append(u), False)[1])
|
||||
data = server._fetch_art_url("https://coverartarchive.example/release/x/front-500")
|
||||
data = art._fetch_art_url("https://coverartarchive.example/release/x/front-500")
|
||||
assert data == b"IMGDATA"
|
||||
assert fetched == ["https://coverartarchive.example/release/x/front-500",
|
||||
"https://archive.example/img.png"]
|
||||
@@ -354,18 +356,18 @@ def test_fetch_art_url_blocks_redirect_to_internal(server, monkeypatch):
|
||||
import requests
|
||||
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
|
||||
302, {"Location": "http://internal.example/x.png"}))
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_url_host_is_internal",
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(art, "_url_host_is_internal",
|
||||
lambda u: "internal" in u)
|
||||
with pytest.raises(ValueError):
|
||||
server._fetch_art_url("https://public.example/x.png")
|
||||
art._fetch_art_url("https://public.example/x.png")
|
||||
|
||||
|
||||
def test_fetch_art_url_redirect_budget(server, monkeypatch):
|
||||
import requests
|
||||
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
|
||||
307, {"Location": "https://public.example/next.png"}))
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_url_host_is_internal", lambda u: False)
|
||||
with pytest.raises(server.EnrichTransportError):
|
||||
server._fetch_art_url("https://public.example/x.png")
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(art, "_url_host_is_internal", lambda u: False)
|
||||
with pytest.raises(enrichment.EnrichTransportError):
|
||||
art._fetch_art_url("https://public.example/x.png")
|
||||
|
||||
+30
-28
@@ -7,6 +7,8 @@ here opens a socket, and the offline default is itself asserted.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import enrichment
|
||||
from routers import art
|
||||
import io as _io
|
||||
import sys
|
||||
|
||||
@@ -121,7 +123,7 @@ def test_bad_upload_rejected(server, client):
|
||||
|
||||
def test_art_url_fetches_and_overrides(server, client, monkeypatch):
|
||||
make_sloppak(server, "a.sloppak", with_cover=True)
|
||||
monkeypatch.setattr(server, "_fetch_art_url", lambda url: png_bytes((9, 9, 9)))
|
||||
monkeypatch.setattr(art, "_fetch_art_url", lambda url: png_bytes((9, 9, 9)))
|
||||
body = client.post("/api/song/a.sloppak/art/url",
|
||||
json={"url": "https://example.com/cover.png"}).json()
|
||||
assert body == {"ok": True, "kind": "png"}
|
||||
@@ -138,7 +140,7 @@ def test_art_url_validation(server, client, monkeypatch):
|
||||
# Oversize → 400 (the seam raises ValueError at the cap).
|
||||
def _huge(url):
|
||||
raise ValueError("image larger than 10 MB")
|
||||
monkeypatch.setattr(server, "_fetch_art_url", _huge)
|
||||
monkeypatch.setattr(art, "_fetch_art_url", _huge)
|
||||
assert client.post("/api/song/a.sloppak/art/url",
|
||||
json={"url": "https://example.com/x.png"}).status_code == 400
|
||||
|
||||
@@ -176,15 +178,15 @@ def caa(server, monkeypatch):
|
||||
calls.append(release_id)
|
||||
return art.get(release_id)
|
||||
fake.calls, fake.art = calls, art
|
||||
monkeypatch.setattr(server, "_caa_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_caa_http_get", fake)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
def test_caa_fetch_fills_missing_art(server, client, caa):
|
||||
make_sloppak(server, "a.sloppak") # no pack art
|
||||
_match_row(server, "a.sloppak")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["art_state"] == "caa"
|
||||
assert row["art_cache_path"] and row["art_cache_path"].endswith("caa_rel-1.jpg")
|
||||
@@ -193,7 +195,7 @@ def test_caa_fetch_fills_missing_art(server, client, caa):
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
# Settled: the next pass never re-fetches.
|
||||
n = len(caa.calls)
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert len(caa.calls) == n
|
||||
|
||||
|
||||
@@ -204,7 +206,7 @@ def test_caa_skips_pack_art_and_dedupes_by_release(server, caa):
|
||||
_match_row(server, "haspack.sloppak")
|
||||
_match_row(server, "b.sloppak") # same release as c
|
||||
_match_row(server, "c.sloppak")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("haspack.sloppak")["art_state"] == "pack"
|
||||
assert server.meta_db.get_enrichment("b.sloppak")["art_state"] == "caa"
|
||||
assert server.meta_db.get_enrichment("c.sloppak")["art_state"] == "caa"
|
||||
@@ -214,10 +216,10 @@ def test_caa_skips_pack_art_and_dedupes_by_release(server, caa):
|
||||
def test_caa_404_marks_none(server, caa):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak", release_id="rel-missing")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "none"
|
||||
n = len(caa.calls)
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert len(caa.calls) == n # never re-hammered
|
||||
|
||||
|
||||
@@ -226,13 +228,13 @@ def test_caa_transport_error_leaves_row_unevaluated(server, caa, monkeypatch):
|
||||
_match_row(server, "a.sloppak")
|
||||
|
||||
def _down(release_id):
|
||||
raise server.EnrichTransportError("down")
|
||||
monkeypatch.setattr(server, "_caa_http_get", _down)
|
||||
server._background_enrich()
|
||||
raise enrichment.EnrichTransportError("down")
|
||||
monkeypatch.setattr(enrichment, "_caa_http_get", _down)
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
||||
# Network back → next pass completes it.
|
||||
monkeypatch.setattr(server, "_caa_http_get", caa)
|
||||
server._background_enrich()
|
||||
monkeypatch.setattr(enrichment, "_caa_http_get", caa)
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
|
||||
|
||||
|
||||
@@ -240,22 +242,22 @@ def test_offline_default_skips_art_worker(server, monkeypatch):
|
||||
"""Under the plain test env the whole art phase is skipped with the rest
|
||||
of the network work."""
|
||||
calls = []
|
||||
monkeypatch.setattr(server, "_caa_http_get", lambda rid: calls.append(rid))
|
||||
monkeypatch.setattr(enrichment, "_caa_http_get", lambda rid: calls.append(rid))
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert calls == []
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
||||
|
||||
|
||||
def test_lru_prune_evicts_oldest_and_resets_rows(server, caa, monkeypatch):
|
||||
monkeypatch.setattr(server, "_CAA_CACHE_CAP_BYTES", 1) # everything over cap
|
||||
monkeypatch.setattr(enrichment, "_CAA_CACHE_CAP_BYTES", 1) # everything over cap
|
||||
make_sloppak(server, "a.sloppak", title="One")
|
||||
make_sloppak(server, "b.sloppak", title="Two")
|
||||
caa.art["rel-2"] = png_bytes((1, 1, 1))
|
||||
_match_row(server, "a.sloppak", release_id="rel-1")
|
||||
_match_row(server, "b.sloppak", release_id="rel-2")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
# With a 1-byte cap every fetch immediately evicts — the rows that pointed
|
||||
# at evicted files were reset to unevaluated.
|
||||
caa_files = list(server.ART_CACHE_DIR.glob("caa_*.jpg"))
|
||||
@@ -282,13 +284,13 @@ def test_delete_override_restores_caa_fallback(server, client, caa):
|
||||
_match_row(server, "a.sloppak")
|
||||
# Pin an override BEFORE the art worker runs → the pass stamps art_state='user'.
|
||||
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "user"
|
||||
# Remove it → the row resets to unevaluated…
|
||||
assert client.delete("/api/art/a.sloppak/override").json()["removed"]
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
||||
# …and the next pass fetches + serves the release's front cover.
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
|
||||
r = client.get("/api/song/a.sloppak/art")
|
||||
assert r.status_code == 200
|
||||
@@ -302,7 +304,7 @@ def test_upload_rejects_unknown_song_and_oversize(server, client):
|
||||
assert server._art_override_paths("ghost.sloppak") == []
|
||||
# Oversize decoded payload → 400 (bounds the base64 upload path).
|
||||
make_sloppak(server, "a.sloppak")
|
||||
huge = b64(b"\x00" * (server._ART_URL_MAX_BYTES + 1))
|
||||
huge = b64(b"\x00" * (art._ART_URL_MAX_BYTES + 1))
|
||||
assert client.post("/api/song/a.sloppak/art/upload",
|
||||
json={"image": huge}).status_code == 400
|
||||
|
||||
@@ -310,10 +312,10 @@ def test_upload_rejects_unknown_song_and_oversize(server, client):
|
||||
def test_fetch_art_url_blocks_internal_hosts(server):
|
||||
"""The SSRF guard refuses loopback / link-local / private targets before
|
||||
any request is made (the real seam, not the faked one)."""
|
||||
assert server._url_host_is_internal("http://127.0.0.1/x.png")
|
||||
assert server._url_host_is_internal("http://localhost/x.png")
|
||||
assert server._url_host_is_internal("http://169.254.169.254/latest/meta-data")
|
||||
assert server._url_host_is_internal("http://10.0.0.5/x.png")
|
||||
assert server._url_host_is_internal("http://[::1]/x.png")
|
||||
assert server._url_host_is_internal("http://nonexistent.invalid/x.png") # unresolvable → closed
|
||||
assert not server._url_host_is_internal("http://93.184.216.34/x.png") # public literal
|
||||
assert art._url_host_is_internal("http://127.0.0.1/x.png")
|
||||
assert art._url_host_is_internal("http://localhost/x.png")
|
||||
assert art._url_host_is_internal("http://169.254.169.254/latest/meta-data")
|
||||
assert art._url_host_is_internal("http://10.0.0.5/x.png")
|
||||
assert art._url_host_is_internal("http://[::1]/x.png")
|
||||
assert art._url_host_is_internal("http://nonexistent.invalid/x.png") # unresolvable → closed
|
||||
assert not art._url_host_is_internal("http://93.184.216.34/x.png") # public literal
|
||||
|
||||
@@ -10,7 +10,7 @@ Two halves, mirroring the design's split:
|
||||
|
||||
* GET /api/artist/{name}/links + POST .../links/refresh — the lazy, cached,
|
||||
opt-in external-links layer. The HTTP transport is a fake over
|
||||
`server._mb_http_get` (the ONE network seam — same pattern as
|
||||
`enrichment._mb_http_get` (the ONE network seam — same pattern as
|
||||
tests/test_mb_enrichment.py), so nothing here opens a socket. Covers the
|
||||
url-rel whitelist mapping, the http(s) scheme gate (a hostile javascript:
|
||||
resource never reaches a link slot), cache-hit second calls making no
|
||||
@@ -19,6 +19,7 @@ Two halves, mirroring the design's split:
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import enrichment
|
||||
import json
|
||||
import sys
|
||||
from urllib.parse import quote
|
||||
@@ -88,7 +89,7 @@ class FakeMBArtist:
|
||||
|
||||
def __call__(self, path, params):
|
||||
if self.raise_transport:
|
||||
raise self._srv.EnrichTransportError("fake network down")
|
||||
raise enrichment.EnrichTransportError("fake network down")
|
||||
self.calls.append((path, dict(params)))
|
||||
if path == f"artist/{MBID}":
|
||||
return self.doc
|
||||
@@ -100,8 +101,8 @@ def mb_artist(server, monkeypatch):
|
||||
"""Install the fake transport AND enable the network flag (the test env
|
||||
disables it by default — see test_links_offline_returns_empty)."""
|
||||
fake = FakeMBArtist(server)
|
||||
monkeypatch.setattr(server, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ contents). The refresh flow reuses the P8 fake-transport pattern — nothing
|
||||
here opens a socket."""
|
||||
|
||||
import importlib
|
||||
import enrichment
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -85,9 +86,9 @@ def test_refresh_resets_even_a_manual_pin_and_rematches(server, client, monkeypa
|
||||
"status": "Official", "date": "1990-09-24",
|
||||
"release-group": {"primary-type": "Album"}}],
|
||||
}]}
|
||||
monkeypatch.setattr(server, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
server._background_enrich()
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["mb_recording_id"] == "rec-new"
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ contracts it will inherit: rename-survivable idempotent hashing, manual rows
|
||||
never auto-reset, never purged on rescan, purged on explicit delete."""
|
||||
|
||||
import importlib
|
||||
import enrichment
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -58,7 +59,7 @@ def test_pending_covers_new_unscanned_and_changed(server):
|
||||
_put(server, "a.archive")
|
||||
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
|
||||
# stubbed → still unscanned → still pending (the matcher hasn't run)
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
|
||||
# a matched row with the CURRENT hash is settled…
|
||||
h = server.meta_db.enrichment_content_hash("Artist", "Song", "", 100)
|
||||
@@ -76,7 +77,7 @@ def test_pending_covers_new_unscanned_and_changed(server):
|
||||
def test_hash_change_resets_matched_but_never_manual(server):
|
||||
_put(server, "a.archive")
|
||||
_put(server, "b.archive", title="Other")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
with server.meta_db._lock:
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state = 'matched' WHERE filename = 'a.archive'")
|
||||
@@ -86,7 +87,7 @@ def test_hash_change_resets_matched_but_never_manual(server):
|
||||
# identity edits…
|
||||
_put(server, "a.archive", title="Song v2")
|
||||
_put(server, "b.archive", title="Other v2")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
a = server.meta_db.get_enrichment("a.archive")
|
||||
b = server.meta_db.get_enrichment("b.archive")
|
||||
# …drop a stale MATCH back to unscanned with the fresh hash
|
||||
@@ -99,7 +100,7 @@ def test_hash_change_resets_matched_but_never_manual(server):
|
||||
|
||||
def test_failed_rows_not_requeued_by_pending(server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
with server.meta_db._lock:
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state = 'failed' WHERE filename = 'a.archive'")
|
||||
@@ -113,7 +114,7 @@ def test_failed_rows_not_requeued_by_pending(server):
|
||||
def test_enrich_pass_stamps_every_song(server):
|
||||
for i in range(5):
|
||||
_put(server, f"s{i}.archive", title=f"Song {i}")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
for i in range(5):
|
||||
row = server.meta_db.get_enrichment(f"s{i}.archive")
|
||||
assert row is not None
|
||||
@@ -126,7 +127,7 @@ def test_enrich_pass_stamps_every_song(server):
|
||||
|
||||
def test_rescan_never_purges_enrichment(server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
server.meta_db.delete_missing(set()) # file vanished from a scan snapshot
|
||||
assert server.meta_db.get_enrichment("a.archive") is not None # row survives
|
||||
# …and is invisible in the read-time-filtered counts
|
||||
@@ -138,7 +139,7 @@ def test_rescan_never_purges_enrichment(server):
|
||||
def test_status_endpoint_counts(client, server):
|
||||
_put(server, "a.archive")
|
||||
_put(server, "b.archive", title="Other")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
body = client.get("/api/enrichment/status").json()
|
||||
assert body["states"] == {"unscanned": 2}
|
||||
assert body["total_songs"] == 2
|
||||
@@ -147,7 +148,7 @@ def test_status_endpoint_counts(client, server):
|
||||
|
||||
|
||||
def test_art_cache_dir_created(server):
|
||||
d = server._enrichment_art_dir()
|
||||
d = enrichment._enrichment_art_dir()
|
||||
assert d.is_dir()
|
||||
assert d.name == "art_cache"
|
||||
|
||||
@@ -156,7 +157,7 @@ def test_art_cache_dir_created(server):
|
||||
|
||||
def test_states_for_returns_only_known_filenames(server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
got = server.meta_db.enrichment_states_for(["a.archive", "nope.archive"])
|
||||
assert got == {"a.archive": "unscanned"} # unknown filename absent
|
||||
assert server.meta_db.enrichment_states_for([]) == {}
|
||||
@@ -165,7 +166,7 @@ def test_states_for_returns_only_known_filenames(server):
|
||||
def test_states_endpoint(client, server):
|
||||
_put(server, "a.archive")
|
||||
_put(server, "b.archive", title="Other")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
body = client.post("/api/enrichment/states",
|
||||
json={"filenames": ["a.archive", "zzz.missing"]}).json()
|
||||
assert body["states"] == {"a.archive": "unscanned"}
|
||||
@@ -175,7 +176,7 @@ def test_states_endpoint(client, server):
|
||||
|
||||
def test_status_exposes_progress_fields(client, server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
body = client.get("/api/enrichment/status").json()
|
||||
for k in ("total", "matched", "current", "cancelling"):
|
||||
assert k in body
|
||||
@@ -186,7 +187,7 @@ def test_cancel_is_noop_when_idle(client, server):
|
||||
body = client.post("/api/enrichment/cancel").json()
|
||||
assert body == {"ok": True, "was_running": False}
|
||||
# A no-op must not arm the flag (which would then poison the next pass).
|
||||
assert server._enrich_cancel.is_set() is False
|
||||
assert enrichment._enrich_cancel.is_set() is False
|
||||
|
||||
|
||||
def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
|
||||
@@ -195,28 +196,28 @@ def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
|
||||
# Force the matcher path on (the test env is offline by default) and stub the
|
||||
# per-song matcher so nothing touches the network — it just trips Stop after
|
||||
# the first song, exactly as the /cancel route would mid-pass.
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
calls = []
|
||||
|
||||
def fake_enrich_one(row, **_kw):
|
||||
calls.append(row["filename"])
|
||||
server._enrich_cancel.set()
|
||||
enrichment._enrich_cancel.set()
|
||||
|
||||
monkeypatch.setattr(server, "_enrich_one", fake_enrich_one)
|
||||
server._enrich_cancel.clear()
|
||||
server._background_enrich()
|
||||
monkeypatch.setattr(enrichment, "_enrich_one", fake_enrich_one)
|
||||
enrichment._enrich_cancel.clear()
|
||||
enrichment._background_enrich()
|
||||
# The loop checks cancel BEFORE each song, so exactly one is processed before
|
||||
# it breaks — not the whole 4-row queue.
|
||||
assert calls == ["s0.archive"]
|
||||
assert server._enrich_status["total"] == 4
|
||||
assert server._enrich_status["matched"] == 1
|
||||
assert enrichment._enrich_status["total"] == 4
|
||||
assert enrichment._enrich_status["matched"] == 1
|
||||
|
||||
|
||||
def test_rematch_requeues_visible_but_skips_manual(server, client):
|
||||
_put(server, "a.archive") # will be 'matched'
|
||||
_put(server, "b.archive", title="Other") # will be 'failed'
|
||||
_put(server, "c.archive", title="Pinned") # will be 'manual' — untouchable
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
with server.meta_db._lock:
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state='matched' WHERE filename='a.archive'")
|
||||
@@ -240,7 +241,7 @@ def test_rematch_requeues_visible_but_skips_manual(server, client):
|
||||
# ── filename-derived artist/title fallback (blank-artist packs) ───────────────
|
||||
|
||||
def test_filename_artist_title_parse(server):
|
||||
f = server._artist_title_from_filename
|
||||
f = enrichment._artist_title_from_filename
|
||||
assert f("CDLC/0 - City Pop/Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak") == \
|
||||
{"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
|
||||
assert f("Anri_Windy-Summer_v1_p.feedpak") == {"artist": "Anri", "title": "Windy Summer"}
|
||||
@@ -255,18 +256,18 @@ def test_blank_artist_seeds_match_from_filename(server, monkeypatch):
|
||||
server.meta_db.put("Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak", 0, 0, {
|
||||
"title": "Tatsuro-Yamashita_Ride-On-Time_v1_p", "artist": "", "album": "",
|
||||
"duration": 240, "arrangements": [{"name": "Bass", "index": 0}]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_manifest_exact_ids", lambda fn: {})
|
||||
seen = {}
|
||||
|
||||
def fake_search(artist, title, limit=8):
|
||||
seen["artist"], seen["title"] = artist, title
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
|
||||
monkeypatch.setattr(enrichment, "_mb_search_recordings", fake_search)
|
||||
row = next(r for r in server.meta_db.enrichment_pending()
|
||||
if r["filename"].startswith("Tatsuro"))
|
||||
server._enrich_one(row)
|
||||
enrichment._enrich_one(row)
|
||||
# the blank pack artist was replaced by the filename-derived identity for
|
||||
# the search (this is exactly what rescues the 'failed' pile)
|
||||
assert seen == {"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
|
||||
@@ -276,18 +277,18 @@ def test_present_artist_is_not_overridden_by_filename(server, monkeypatch):
|
||||
server.meta_db.put("Weird-Filename_x_y.feedpak", 0, 0, {
|
||||
"title": "Real Title", "artist": "Real Artist", "album": "", "duration": 100,
|
||||
"arrangements": [{"name": "Lead", "index": 0}]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_manifest_exact_ids", lambda fn: {})
|
||||
seen = {}
|
||||
|
||||
def fake_search(artist, title, limit=8):
|
||||
seen["artist"], seen["title"] = artist, title
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
|
||||
monkeypatch.setattr(enrichment, "_mb_search_recordings", fake_search)
|
||||
row = next(r for r in server.meta_db.enrichment_pending()
|
||||
if r["filename"].startswith("Weird"))
|
||||
server._enrich_one(row)
|
||||
enrichment._enrich_one(row)
|
||||
# a pack that DOES carry an artist keeps it — the filename is never consulted
|
||||
assert seen == {"artist": "Real Artist", "title": "Real Title"}
|
||||
|
||||
@@ -295,7 +296,7 @@ def test_present_artist_is_not_overridden_by_filename(server, monkeypatch):
|
||||
def test_kick_clears_a_stale_cancel(server):
|
||||
# A cancelled-then-rekicked pass must start clean: _kick_enrich clears the
|
||||
# flag so the fresh pass isn't aborted the instant it checks.
|
||||
server._enrich_cancel.set()
|
||||
server._kick_enrich()
|
||||
enrichment._enrich_cancel.set()
|
||||
enrichment._kick_enrich()
|
||||
server._join_background_db_threads()
|
||||
assert server._enrich_cancel.is_set() is False
|
||||
assert enrichment._enrich_cancel.is_set() is False
|
||||
|
||||
@@ -14,6 +14,7 @@ back-compat for `.sloppak` libraries or stop accepting the new `.feedpak`:
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from routers import settings as settings_router
|
||||
import io
|
||||
import sys
|
||||
import zipfile
|
||||
@@ -242,7 +243,7 @@ def test_settings_dlc_count_includes_both_suffixes(tmp_path, settings_server):
|
||||
(dlc / "c.FEEDPAK").write_bytes(b"") # case-insensitive (suffix.lower())
|
||||
(dlc / "notes.txt").write_bytes(b"") # ignored
|
||||
|
||||
result = settings_server.save_settings({"dlc_dir": str(dlc)})
|
||||
result = settings_router.save_settings({"dlc_dir": str(dlc)})
|
||||
|
||||
assert "error" not in result, result
|
||||
# save_settings joins its notices into a single ``message`` string.
|
||||
|
||||
@@ -6,6 +6,7 @@ song (delete_song). Locks pin a field against a later auto-match.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import enrichment
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -149,7 +150,7 @@ def test_locked_fields_reader(server):
|
||||
|
||||
|
||||
def test_compose_lock_filter_strips_locked_cand_keys(server):
|
||||
f = server._compose_lock_filter(None, {"artist", "year"})
|
||||
f = enrichment._compose_lock_filter(None, {"artist", "year"})
|
||||
cand = {"recording_id": "r", "artist": "X", "artist_sort": "X", "title": "T",
|
||||
"year": "1990", "album": "A", "genres": ["rock"]}
|
||||
out = f(cand)
|
||||
@@ -158,7 +159,7 @@ def test_compose_lock_filter_strips_locked_cand_keys(server):
|
||||
# …identity + unlocked display fields survive
|
||||
assert out["recording_id"] == "r" and out["title"] == "T" and out["album"] == "A"
|
||||
# no locks → base filter returned unchanged (zero-copy common path)
|
||||
assert server._compose_lock_filter(None, set()) is None
|
||||
assert enrichment._compose_lock_filter(None, set()) is None
|
||||
|
||||
|
||||
# ── display overlay in the grid (slice 3) ─────────────────────────────────────
|
||||
|
||||
@@ -16,6 +16,8 @@ import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from routers.ws_highway import _sanitize_authors
|
||||
|
||||
|
||||
# ── _sanitize_authors unit tests ────────────────────────────────────────────
|
||||
|
||||
@@ -35,7 +37,7 @@ def server_mod(monkeypatch, tmp_path):
|
||||
|
||||
|
||||
def test_sanitize_authors_valid(server_mod):
|
||||
out = server_mod._sanitize_authors(
|
||||
out = _sanitize_authors(
|
||||
{
|
||||
"authors": [
|
||||
{"name": "Azure", "role": "charter", "email": "a@b.c", "url": "x"},
|
||||
@@ -53,7 +55,7 @@ def test_sanitize_authors_valid(server_mod):
|
||||
|
||||
|
||||
def test_sanitize_authors_skips_malformed(server_mod):
|
||||
out = server_mod._sanitize_authors(
|
||||
out = _sanitize_authors(
|
||||
{
|
||||
"authors": [
|
||||
{"name": ""}, # blank name → skipped
|
||||
@@ -69,7 +71,7 @@ def test_sanitize_authors_skips_malformed(server_mod):
|
||||
|
||||
@pytest.mark.parametrize("manifest", [None, {}, {"authors": None}, {"authors": "x"}, "nope"])
|
||||
def test_sanitize_authors_absent_or_nonlist(server_mod, manifest):
|
||||
assert server_mod._sanitize_authors(manifest) == []
|
||||
assert _sanitize_authors(manifest) == []
|
||||
|
||||
|
||||
# ── song_info WS integration ────────────────────────────────────────────────
|
||||
@@ -118,6 +120,9 @@ def make_client(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
||||
monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache")
|
||||
# ws_highway reads the cache dir through the appstate seam now.
|
||||
import appstate as _appstate
|
||||
monkeypatch.setattr(_appstate, "sloppak_cache_dir", tmp_path / "cache")
|
||||
return server
|
||||
|
||||
(tmp_path / "dlc").mkdir()
|
||||
|
||||
@@ -96,6 +96,9 @@ def make_client(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
||||
monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache")
|
||||
# ws_highway reads the cache dir through the appstate seam now.
|
||||
import appstate as _appstate
|
||||
monkeypatch.setattr(_appstate, "sloppak_cache_dir", tmp_path / "cache")
|
||||
return server
|
||||
|
||||
(tmp_path / "dlc").mkdir()
|
||||
|
||||
@@ -123,6 +123,9 @@ def make_client(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
||||
monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache")
|
||||
# ws_highway reads the cache dir through the appstate seam now.
|
||||
import appstate as _appstate
|
||||
monkeypatch.setattr(_appstate, "sloppak_cache_dir", tmp_path / "cache")
|
||||
return server
|
||||
|
||||
(tmp_path / "dlc").mkdir()
|
||||
|
||||
+53
-52
@@ -1,6 +1,6 @@
|
||||
"""Server-level tests for the P8 MusicBrainz matcher + Match-Review flow.
|
||||
|
||||
The HTTP transport is a fake installed over `server._mb_http_get` — the ONE
|
||||
The HTTP transport is a fake installed over `enrichment._mb_http_get` — the ONE
|
||||
seam enrichment uses to reach the network — so nothing here ever opens a
|
||||
socket. The offline default is itself under test: without explicitly
|
||||
enabling the network flag, a pass must skip matching entirely (pytest can
|
||||
@@ -8,6 +8,7 @@ never hit MusicBrainz, whatever a test triggers).
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import enrichment
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -50,7 +51,7 @@ class FakeMB:
|
||||
|
||||
def __call__(self, path, params):
|
||||
if self.raise_transport:
|
||||
raise self._srv.EnrichTransportError("fake network down")
|
||||
raise enrichment.EnrichTransportError("fake network down")
|
||||
self.calls.append((path, dict(params)))
|
||||
if path == "recording":
|
||||
return self.search_response
|
||||
@@ -71,8 +72,8 @@ def mb(server, monkeypatch):
|
||||
disables it by default — see test_offline_default_skips_matching)."""
|
||||
fake = FakeMB()
|
||||
fake._srv = server
|
||||
monkeypatch.setattr(server, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
@@ -111,8 +112,8 @@ def test_search_falls_back_to_loose_when_strict_is_empty(server, monkeypatch):
|
||||
return {"recordings": []}
|
||||
return {"recordings": [mb_doc(rid="rec-x", title="Telephone Number")]}
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", _routed)
|
||||
cands = server._mb_search_recordings("Junko Ohashi", "Telephone Number")
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
|
||||
cands = enrichment._mb_search_recordings("Junko Ohashi", "Telephone Number")
|
||||
assert len(cands) == 1
|
||||
assert len(calls) == 2 # strict first, then the loose retry
|
||||
assert calls[0].startswith("recording:") # strict is the field-phrase form
|
||||
@@ -128,8 +129,8 @@ def test_search_does_not_retry_when_strict_hits(server, monkeypatch):
|
||||
calls.append(params.get("query", ""))
|
||||
return {"recordings": [mb_doc()]}
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", _routed)
|
||||
cands = server._mb_search_recordings("AC/DC", "Thunderstruck")
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
|
||||
cands = enrichment._mb_search_recordings("AC/DC", "Thunderstruck")
|
||||
assert len(cands) == 1
|
||||
assert len(calls) == 1
|
||||
|
||||
@@ -147,10 +148,10 @@ def test_artist_aliases_fetched_and_cached(server, monkeypatch):
|
||||
return {"sort-name": "Ohashi, Junko",
|
||||
"aliases": [{"name": "Junko Ohashi"}, {"name": "大橋 純子"}]}
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", fake)
|
||||
names = server._mb_artist_aliases(_AID)
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
|
||||
names = enrichment._mb_artist_aliases(_AID)
|
||||
assert "Junko Ohashi" in names and "Ohashi, Junko" in names
|
||||
server._mb_artist_aliases(_AID) # cached → no second request
|
||||
enrichment._mb_artist_aliases(_AID) # cached → no second request
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@@ -158,8 +159,8 @@ def test_artist_aliases_rejects_bad_id(server, monkeypatch):
|
||||
def boom(path, params):
|
||||
raise AssertionError("must not fetch for a non-UUID id")
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", boom)
|
||||
assert server._mb_artist_aliases("not-a-uuid") == []
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", boom)
|
||||
assert enrichment._mb_artist_aliases("not-a-uuid") == []
|
||||
|
||||
|
||||
def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
|
||||
@@ -176,9 +177,9 @@ def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
|
||||
return {"recordings": [mb_doc(rid="rec-jp", title="Telephone Number",
|
||||
artist="大橋純子", artist_id=_AID)]} # loose hit
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", _routed)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
server._background_enrich()
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("x.sloppak")
|
||||
# The romanized alias lifts the artist over the auto floor → auto-confirmed.
|
||||
assert row["match_state"] == "matched"
|
||||
@@ -190,10 +191,10 @@ def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
|
||||
def test_locked_field_not_canonicalized_by_auto_match(server, monkeypatch):
|
||||
_put(server, "x.sloppak") # title "Thunderstruck (v2)", artist "ACDC"
|
||||
server.meta_db.set_song_override("x.sloppak", "artist", locked=True)
|
||||
monkeypatch.setattr(server, "_mb_http_get",
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get",
|
||||
lambda path, params: {"recordings": [mb_doc()]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
server._background_enrich()
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("x.sloppak")
|
||||
assert row["match_state"] == "matched" # still matches (identity applies)…
|
||||
assert row["canon_artist"] is None # …but the LOCKED artist isn't canonicalized
|
||||
@@ -208,9 +209,9 @@ def test_offline_default_skips_matching(server, monkeypatch):
|
||||
but never matches — even with a transport installed."""
|
||||
fake = FakeMB()
|
||||
fake._srv = server
|
||||
monkeypatch.setattr(server, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
|
||||
_put(server, "a.sloppak")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert fake.calls == []
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "unscanned"
|
||||
|
||||
@@ -218,15 +219,15 @@ def test_offline_default_skips_matching(server, monkeypatch):
|
||||
def test_real_transport_refuses_when_offline(server):
|
||||
"""_mb_http_get itself raises (before any socket) when the network is
|
||||
disabled — defence in depth under pytest."""
|
||||
with pytest.raises(server.EnrichTransportError):
|
||||
server._mb_http_get("recording", {"query": "x"})
|
||||
with pytest.raises(enrichment.EnrichTransportError):
|
||||
enrichment._mb_http_get("recording", {"query": "x"})
|
||||
|
||||
|
||||
def test_transport_error_pauses_pass_without_burning_attempts(server, mb):
|
||||
_put(server, "a.sloppak")
|
||||
_put(server, "b.sloppak", title="Other Song")
|
||||
mb.raise_transport = True
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
for fn in ("a.sloppak", "b.sloppak"):
|
||||
row = server.meta_db.get_enrichment(fn)
|
||||
assert row["match_state"] == "unscanned"
|
||||
@@ -234,7 +235,7 @@ def test_transport_error_pauses_pass_without_burning_attempts(server, mb):
|
||||
# Network comes back → the next kick matches both.
|
||||
mb.raise_transport = False
|
||||
mb.search_response = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
|
||||
|
||||
|
||||
@@ -243,7 +244,7 @@ def test_transport_error_pauses_pass_without_burning_attempts(server, mb):
|
||||
def test_high_confidence_auto_matches_and_settles(server, mb):
|
||||
_put(server, "a.sloppak")
|
||||
mb.search_response = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "matched"
|
||||
assert row["match_source"] == "text"
|
||||
@@ -256,13 +257,13 @@ def test_high_confidence_auto_matches_and_settles(server, mb):
|
||||
assert row["genres"] == ["hard rock"]
|
||||
# Settled: another pass makes NO further network calls…
|
||||
n = len(mb.calls)
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert len(mb.calls) == n
|
||||
# …until the identity changes, which re-matches.
|
||||
_put(server, "a.sloppak", title="Back in Black")
|
||||
mb.search_response = {"recordings": [mb_doc(rid="rec-2", title="Back in Black",
|
||||
album="Back in Black", date="1980-07-25")]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["mb_recording_id"] == "rec-2"
|
||||
|
||||
@@ -271,7 +272,7 @@ def test_medium_confidence_goes_to_review_not_canonical(server, mb):
|
||||
# Partial artist agreement → medium confidence.
|
||||
_put(server, "a.sloppak", artist="AC/DC ft Nobody")
|
||||
mb.search_response = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "review"
|
||||
assert row["match_source"] == "text"
|
||||
@@ -281,7 +282,7 @@ def test_medium_confidence_goes_to_review_not_canonical(server, mb):
|
||||
assert row["candidates"] and row["candidates"][0]["recording_id"] == "rec-1"
|
||||
# A review row is settled while its identity is unchanged — no re-query.
|
||||
n = len(mb.calls)
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert len(mb.calls) == n
|
||||
|
||||
|
||||
@@ -289,14 +290,14 @@ def test_low_confidence_fails_with_backoff(server, mb):
|
||||
_put(server, "a.sloppak")
|
||||
mb.search_response = {"recordings": [mb_doc(rid="rec-x", title="Sunrise",
|
||||
artist="Norah Jones")]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "failed"
|
||||
assert row["attempts"] == 1
|
||||
assert row["last_attempt_at"] is not None
|
||||
# Immediately after, the backoff hasn't elapsed → no retry, no network.
|
||||
n = len(mb.calls)
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert len(mb.calls) == n
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["attempts"] == 1
|
||||
# Rewind the clock two hours → eligible again, attempts increments.
|
||||
@@ -304,7 +305,7 @@ def test_low_confidence_fails_with_backoff(server, mb):
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET last_attempt_at = last_attempt_at - 7200")
|
||||
server.meta_db.conn.commit()
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert len(mb.calls) == n + 1
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["attempts"] == 2
|
||||
|
||||
@@ -312,7 +313,7 @@ def test_low_confidence_fails_with_backoff(server, mb):
|
||||
def test_no_results_fails(server, mb):
|
||||
_put(server, "a.sloppak")
|
||||
mb.search_response = {"recordings": []}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "failed"
|
||||
|
||||
|
||||
@@ -322,7 +323,7 @@ def test_cache_hit_copies_match_without_network(server, mb):
|
||||
_put(server, "a.sloppak")
|
||||
_put(server, "b.sloppak") # identical identity → same content_hash
|
||||
mb.search_response = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert len(mb.search_calls) == 1 # ONE search covered both charts
|
||||
a = server.meta_db.get_enrichment("a.sloppak")
|
||||
b = server.meta_db.get_enrichment("b.sloppak")
|
||||
@@ -347,7 +348,7 @@ def test_manifest_mbid_tier0(server, mb):
|
||||
_write_sloppak_manifest(server, "a.sloppak", f"mbid: {mbid}\n")
|
||||
_put(server, "a.sloppak")
|
||||
mb.recording_lookups[mbid] = mb_doc(rid=mbid)
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "matched"
|
||||
assert row["match_source"] == "mbid"
|
||||
@@ -360,7 +361,7 @@ def test_manifest_isrc_tier1(server, mb):
|
||||
_write_sloppak_manifest(server, "a.sloppak", "isrc: AUAP09000045\n")
|
||||
_put(server, "a.sloppak")
|
||||
mb.isrc_lookups["AUAP09000045"] = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "matched"
|
||||
assert row["match_source"] == "isrc"
|
||||
@@ -374,7 +375,7 @@ def test_manifest_isrc_display_hyphens_stripped(server, mb):
|
||||
_write_sloppak_manifest(server, "a.sloppak", "isrc: AU-AP0-90-00045\n")
|
||||
_put(server, "a.sloppak")
|
||||
mb.isrc_lookups["AUAP09000045"] = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "matched"
|
||||
assert row["match_source"] == "isrc"
|
||||
@@ -387,7 +388,7 @@ def test_bad_manifest_mbid_falls_through_to_text(server, mb):
|
||||
_put(server, "a.sloppak")
|
||||
mb.recording_lookups.clear() # lookup 404s (typo'd manifest)
|
||||
mb.search_response = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "matched"
|
||||
assert row["match_source"] == "text"
|
||||
@@ -401,7 +402,7 @@ def test_manual_never_overwritten_by_matcher(server, mb):
|
||||
"a.sloppak", {"recording_id": "user-pick", "title": "Thunderstruck",
|
||||
"artist": "AC/DC"}, source="search")
|
||||
mb.search_response = {"recordings": [mb_doc(rid="machine-pick")]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "manual"
|
||||
assert row["mb_recording_id"] == "user-pick"
|
||||
@@ -419,7 +420,7 @@ def _seed_review(server, mb, fn="a.sloppak", title="Thunderstruck (v2)"):
|
||||
# legitimately copies an earlier row instead of running the text tiers).
|
||||
_put(server, fn, title=title, artist="AC/DC ft Nobody")
|
||||
mb.search_response = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment(fn)["match_state"] == "review"
|
||||
|
||||
|
||||
@@ -457,11 +458,11 @@ def test_review_reject_route_never_retries(server, mb, client):
|
||||
assert row["match_source"] == "rejected"
|
||||
# Rejected rows are excluded from the retry backoff forever…
|
||||
n = len(mb.calls)
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert len(mb.calls) == n
|
||||
# …but an identity edit re-queues (the user fixed the metadata).
|
||||
_put(server, "a.sloppak", artist="AC/DC")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
|
||||
# Rejecting a manual row is refused.
|
||||
r = client.post("/api/enrichment/review/a.sloppak/reject")
|
||||
@@ -502,8 +503,8 @@ def test_search_proxy(server, mb, client, monkeypatch):
|
||||
assert body["candidates"][0]["score"] > 0.9
|
||||
# Transport failure surfaces as 503, not a 500.
|
||||
def _down(path, params):
|
||||
raise server.EnrichTransportError("down")
|
||||
monkeypatch.setattr(server, "_mb_http_get", _down)
|
||||
raise enrichment.EnrichTransportError("down")
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", _down)
|
||||
r = client.get("/api/enrichment/search", params={"title": "x"})
|
||||
assert r.status_code == 503
|
||||
|
||||
@@ -523,8 +524,8 @@ def test_match_facet_filters_grid_and_stats(server, mb, client, monkeypatch):
|
||||
if "revsong" in q:
|
||||
return {"recordings": [mb_doc(rid="rec-r", title="Revsong")]}
|
||||
return {"recordings": []}
|
||||
monkeypatch.setattr(server, "_mb_http_get", _routed)
|
||||
server._background_enrich()
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
|
||||
enrichment._background_enrich()
|
||||
# Pendsong got failed by the pass (no results); reset it to unscanned to
|
||||
# represent the not-yet-scanned band.
|
||||
with server.meta_db._lock:
|
||||
@@ -566,14 +567,14 @@ def test_auto_threshold_setting_moves_the_auto_review_boundary(server, mb, clien
|
||||
client.post("/api/settings", json={"enrich_auto_threshold": 0.95})
|
||||
_put(server, "a.sloppak", title="Highway to Hell", artist="AC/DC",
|
||||
year="", duration=0)
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "review"
|
||||
# Lower the bar to the default 0.90 → an identity edit re-queues, and the
|
||||
# same 0.90-scored candidate now auto-applies.
|
||||
client.post("/api/settings", json={"enrich_auto_threshold": 0.9})
|
||||
_put(server, "a.sloppak", title="Highway to Hell", artist="AC/DC",
|
||||
year="", duration=0, album="Different Album")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "matched"
|
||||
assert abs(row["match_score"] - 0.9) < 1e-6
|
||||
@@ -583,7 +584,7 @@ def test_enrich_enabled_setting_gates_background_matching(server, mb, client):
|
||||
client.post("/api/settings", json={"enrich_enabled": False})
|
||||
_put(server, "a.sloppak")
|
||||
mb.search_response = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert mb.calls == []
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "unscanned"
|
||||
# Manual search/fix stays available while the background matcher is off.
|
||||
@@ -591,7 +592,7 @@ def test_enrich_enabled_setting_gates_background_matching(server, mb, client):
|
||||
assert r.status_code == 200
|
||||
# Re-enable → the next pass matches.
|
||||
client.post("/api/settings", json={"enrich_enabled": True})
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
|
||||
|
||||
|
||||
@@ -625,7 +626,7 @@ def test_review_queue_orders_missing_data_first(server, mb, client):
|
||||
_seed_review(server, mb, fn="aa.sloppak", title="Thunderstruck (v2)")
|
||||
_put(server, "zz.sloppak", title="Thunderstruck (Live)",
|
||||
artist="AC/DC ft Nobody", album="", year="")
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("zz.sloppak")["match_state"] == "review"
|
||||
songs = client.get("/api/enrichment/review").json()["songs"]
|
||||
assert [s["filename"] for s in songs] == ["zz.sloppak", "aa.sloppak"]
|
||||
|
||||
@@ -8,6 +8,7 @@ flag is only force-enabled where a test needs the pipeline to run.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import enrichment
|
||||
import io as _io
|
||||
import sys
|
||||
|
||||
@@ -57,8 +58,8 @@ class FakeMB:
|
||||
@pytest.fixture()
|
||||
def mb(server, monkeypatch):
|
||||
fake = FakeMB()
|
||||
monkeypatch.setattr(server, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
@@ -116,13 +117,13 @@ def test_musicbrainz_source_off_stamps_without_matching(server, mb, client):
|
||||
client.post("/api/settings", json={"enrich_src_musicbrainz": False})
|
||||
_put(server, "a.sloppak")
|
||||
mb.search_response = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert mb.calls == []
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "unscanned" # hash stamped, no match
|
||||
# Re-enabling picks the same row up on the next pass.
|
||||
client.post("/api/settings", json={"enrich_src_musicbrainz": True})
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
|
||||
|
||||
|
||||
@@ -133,7 +134,7 @@ def test_field_toggles_strip_auto_applied_fields(server, mb, client):
|
||||
"enrich_apply_genres": False})
|
||||
_put(server, "a.sloppak")
|
||||
mb.search_response = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "matched"
|
||||
assert row["canon_artist"] == "AC/DC"
|
||||
@@ -150,7 +151,7 @@ def test_names_toggle_keeps_ids_and_other_fields(server, mb, client):
|
||||
client.post("/api/settings", json={"enrich_apply_names": False})
|
||||
_put(server, "a.sloppak")
|
||||
mb.search_response = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "matched"
|
||||
assert row["canon_artist"] is None
|
||||
@@ -170,7 +171,7 @@ def test_review_accept_applies_all_fields_despite_toggles(server, mb, client):
|
||||
# Partial artist agreement → review tier (candidates stored unfiltered).
|
||||
_put(server, "a.sloppak", artist="AC/DC ft Nobody")
|
||||
mb.search_response = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "review"
|
||||
r = client.post("/api/enrichment/review/a.sloppak/accept",
|
||||
json={"recording_id": "rec-1"})
|
||||
@@ -192,21 +193,21 @@ def test_reenabling_field_backfills_matched_row(server, mb, client):
|
||||
client.post("/api/settings", json={"enrich_apply_year": False})
|
||||
_put(server, "a.sloppak")
|
||||
mb.search_response = {"recordings": [mb_doc()]}
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "matched"
|
||||
assert row["canon_year"] is None # suppressed
|
||||
assert row["apply_mask"] == "enrich_apply_year" # …and remembered
|
||||
# Re-enable → next pass re-queues and backfills the year (hash unchanged).
|
||||
client.post("/api/settings", json={"enrich_apply_year": True})
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["match_state"] == "matched"
|
||||
assert row["canon_year"] == "1990" # backfilled
|
||||
assert row["apply_mask"] in (None, "") # fully applied now
|
||||
# Converged: a fully-applied row is not re-queued again.
|
||||
assert server.meta_db.enrichment_pending(
|
||||
allowed_keys=frozenset(server._ENRICH_APPLY_FIELDS)) == []
|
||||
allowed_keys=frozenset(enrichment._ENRICH_APPLY_FIELDS)) == []
|
||||
|
||||
|
||||
def test_partial_match_is_not_a_cache_donor(server):
|
||||
@@ -271,8 +272,8 @@ def caa(server, monkeypatch):
|
||||
calls.append(release_id)
|
||||
return art.get(release_id)
|
||||
fake.calls = calls
|
||||
monkeypatch.setattr(server, "_caa_http_get", fake)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(enrichment, "_caa_http_get", fake)
|
||||
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
||||
return fake
|
||||
|
||||
|
||||
@@ -280,13 +281,13 @@ def test_caa_source_toggle_gates_art_fetch(server, client, caa):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak")
|
||||
client.post("/api/settings", json={"enrich_src_caa": False})
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert caa.calls == []
|
||||
row = server.meta_db.get_enrichment("a.sloppak")
|
||||
assert row["art_state"] is None # not forfeited, just skipped
|
||||
# Re-enable → the same row is picked up.
|
||||
client.post("/api/settings", json={"enrich_src_caa": True})
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
|
||||
|
||||
|
||||
@@ -294,7 +295,7 @@ def test_apply_art_toggle_gates_art_fetch(server, client, caa):
|
||||
make_sloppak(server, "a.sloppak")
|
||||
_match_row(server, "a.sloppak")
|
||||
client.post("/api/settings", json={"enrich_apply_art": False})
|
||||
server._background_enrich()
|
||||
enrichment._background_enrich()
|
||||
assert caa.calls == []
|
||||
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ shadow the config.json dlc_dir fallback.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from routers import settings as settings_router
|
||||
import json
|
||||
import sys
|
||||
|
||||
@@ -32,13 +33,13 @@ class _DirectSettingsClient:
|
||||
def get(self, path):
|
||||
if path != "/api/settings":
|
||||
raise ValueError(f"unsupported path: {path}")
|
||||
return _DirectResponse(self._server.get_settings())
|
||||
return _DirectResponse(settings_router.get_settings())
|
||||
|
||||
def post(self, path, json):
|
||||
if path == "/api/settings":
|
||||
return _DirectResponse(self._server.save_settings(json))
|
||||
return _DirectResponse(settings_router.save_settings(json))
|
||||
if path == "/api/settings/reset":
|
||||
return _DirectResponse(self._server.reset_settings(json))
|
||||
return _DirectResponse(settings_router.reset_settings(json))
|
||||
raise ValueError(f"unsupported path: {path}")
|
||||
|
||||
def close(self):
|
||||
@@ -643,7 +644,7 @@ def test_achievements_enabled_persists_and_validates(api_client, tmp_path):
|
||||
|
||||
def test_achievements_enabled_is_resettable(server_module):
|
||||
"""The flag is in the resettable allow-list so a Reset clears it to default."""
|
||||
assert "achievements_enabled" in server_module._RESETTABLE_SETTINGS_KEYS
|
||||
assert "achievements_enabled" in settings_router._RESETTABLE_SETTINGS_KEYS
|
||||
|
||||
|
||||
def test_skip_startup_tasks_drives_startup_to_complete(api_client):
|
||||
|
||||
@@ -11,6 +11,7 @@ is exercised separately in `test_plugins.py`.
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
from routers import settings as settings_router
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -478,7 +479,7 @@ def test_normalize_export_paths_consistency(server_mod, tmp_path):
|
||||
# Wraps `_validate_relpath` to assert it doesn't raise the
|
||||
# hard-failure ValueErrors. _UndeclaredFile would mean the
|
||||
# allowlist is wrong, not that the relpath shape is bad.
|
||||
server_mod._validate_relpath(rel, cleaned, tmp_path)
|
||||
settings_router._validate_relpath(rel, cleaned, tmp_path)
|
||||
|
||||
|
||||
# ── Atomic write: unique tmp + cleanup on failure ───────────────────────────
|
||||
@@ -502,7 +503,7 @@ def test_atomic_write_cleans_up_tmp_on_failure(server_mod, tmp_path, monkeypatch
|
||||
|
||||
for _ in range(2):
|
||||
with pytest.raises(OSError):
|
||||
server_mod._atomic_write_file(target, b"payload")
|
||||
settings_router._atomic_write_file(target, b"payload")
|
||||
|
||||
# Both attempts cleaned up. No .tmp.import residue means the
|
||||
# mkstemp + finally-unlink pattern held even across failures.
|
||||
@@ -512,7 +513,7 @@ def test_atomic_write_cleans_up_tmp_on_failure(server_mod, tmp_path, monkeypatch
|
||||
|
||||
# Restoring real replace, the function should still work end-to-end.
|
||||
monkeypatch.setattr(server_mod.os, "replace", real_replace)
|
||||
server_mod._atomic_write_file(target, b"payload")
|
||||
settings_router._atomic_write_file(target, b"payload")
|
||||
assert target.read_bytes() == b"payload"
|
||||
assert list(tmp_path.glob("*.tmp.import")) == []
|
||||
|
||||
@@ -764,7 +765,7 @@ def test_atomic_write_closes_fd_when_fdopen_fails(server_mod, tmp_path, monkeypa
|
||||
monkeypatch.setattr(server_mod.os, "fdopen", boom_fdopen)
|
||||
|
||||
with pytest.raises(OSError, match="simulated EMFILE"):
|
||||
server_mod._atomic_write_file(target, b"payload")
|
||||
settings_router._atomic_write_file(target, b"payload")
|
||||
|
||||
# fd was closed (so it didn't leak), and the temp file mkstemp
|
||||
# created was removed (so it doesn't litter / lock on Windows).
|
||||
|
||||
@@ -15,6 +15,7 @@ this file pins the additive `core_server_files` section:
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
from routers import settings as settings_router
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -118,7 +119,7 @@ def test_import_stages_db_restore_without_touching_live_db(client, server_mod, t
|
||||
|
||||
payload = _valid_db_bytes(tmp_path, name="incoming.db", marker="restored")
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"web_library.db": {"encoding": "base64",
|
||||
@@ -143,7 +144,7 @@ def test_import_rejects_corrupt_db_with_valid_magic_header(client, server_mod, t
|
||||
# fail to open the bad restore.
|
||||
corrupt = b"SQLite format 3\x00" + b"\xff" * 200
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"web_library.db": {"encoding": "base64",
|
||||
@@ -158,7 +159,7 @@ def test_import_rejects_non_sqlite_db_payload(client, server_mod, tmp_path):
|
||||
# A truncated / wrong file staged as the restore would brick startup —
|
||||
# reject anything lacking the SQLite magic header, before touching disk.
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"web_library.db": {"encoding": "base64",
|
||||
@@ -171,7 +172,7 @@ def test_import_rejects_non_sqlite_db_payload(client, server_mod, tmp_path):
|
||||
|
||||
def test_import_writes_custom_art_immediately(client, server_mod, tmp_path):
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"playlist_covers/7.png": {"encoding": "base64",
|
||||
@@ -186,7 +187,7 @@ def test_import_writes_custom_art_immediately(client, server_mod, tmp_path):
|
||||
def test_import_core_path_traversal_rejected(client, server_mod, tmp_path):
|
||||
secret = tmp_path.parent / "escape.txt"
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"../escape.txt": {"encoding": "base64",
|
||||
@@ -201,7 +202,7 @@ def test_import_core_undeclared_path_skipped_not_fatal(client, server_mod, tmp_p
|
||||
# A relpath outside the core allowlist is a warn-and-skip, not a refusal —
|
||||
# the rest of the bundle still applies.
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"audio_cache/x.ogg": {"encoding": "base64",
|
||||
@@ -289,7 +290,7 @@ def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
|
||||
def test_export_fails_hard_when_db_snapshot_unavailable(client, server_mod, monkeypatch):
|
||||
# A backup that silently omits the library DB is a data-loss trap — the
|
||||
# export must error rather than hand back an incomplete-looking bundle.
|
||||
monkeypatch.setattr(server_mod, "_snapshot_library_db", lambda: None)
|
||||
monkeypatch.setattr(settings_router, "_snapshot_library_db", lambda: None)
|
||||
r = client.get("/api/settings/export")
|
||||
assert r.status_code == 500
|
||||
assert "library database" in r.json()["error"].lower()
|
||||
@@ -299,16 +300,16 @@ def test_failed_import_disarms_staged_db_restore(client, server_mod, tmp_path, m
|
||||
# If a later write in phase 2 fails, the request 500s — but a staged DB
|
||||
# restore must NOT survive to swap in on the next restart.
|
||||
payload = _valid_db_bytes(tmp_path, name="incoming.db")
|
||||
real_write = server_mod._atomic_write_file
|
||||
real_write = settings_router._atomic_write_file
|
||||
|
||||
def boom(target, data):
|
||||
if target.name == "config.json": # last write of the commit
|
||||
raise OSError("disk full")
|
||||
return real_write(target, data)
|
||||
|
||||
monkeypatch.setattr(server_mod, "_atomic_write_file", boom)
|
||||
monkeypatch.setattr(settings_router, "_atomic_write_file", boom)
|
||||
r = client.post("/api/settings/import", json={
|
||||
"schema": server_mod.SETTINGS_BUNDLE_SCHEMA,
|
||||
"schema": settings_router.SETTINGS_BUNDLE_SCHEMA,
|
||||
"server_config": {},
|
||||
"core_server_files": {
|
||||
"web_library.db": {"encoding": "base64",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""A client that disconnects mid-stream is routine, not a logged error.
|
||||
|
||||
The highway WS streams ~15 message batches before its keep-alive loop. A
|
||||
disconnect during that streaming used to fall through to the blanket
|
||||
`except Exception` and log `highway_ws unhandled error`. The dedicated
|
||||
`except WebSocketDisconnect: return` makes it quiet.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi import WebSocketDisconnect
|
||||
|
||||
|
||||
def _write_sloppak(dlc_root):
|
||||
pak = dlc_root / "disc.sloppak"
|
||||
pak.mkdir(parents=True)
|
||||
(pak / "arrangements").mkdir()
|
||||
(pak / "arrangements" / "lead.json").write_text(json.dumps({
|
||||
"notes": [], "chords": [], "anchors": [], "handshapes": [],
|
||||
"templates": [], "beats": [{"time": 0.0, "measure": 1}],
|
||||
"sections": [{"name": "intro", "number": 1, "time": 0.0}],
|
||||
}))
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump({
|
||||
"title": "Disc", "artist": "T", "album": "", "year": 2026, "duration": 10.0,
|
||||
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
|
||||
"stems": [],
|
||||
}, sort_keys=False))
|
||||
return pak
|
||||
|
||||
|
||||
class _DisconnectingWS:
|
||||
"""Accepts, then raises WebSocketDisconnect on the first streamed send —
|
||||
i.e. a client that drops mid-stream."""
|
||||
def __init__(self):
|
||||
self.sends = 0
|
||||
|
||||
async def accept(self):
|
||||
pass
|
||||
|
||||
async def send_json(self, data):
|
||||
self.sends += 1
|
||||
# Raise only on the FIRST send. If the handler wrongly kept streaming
|
||||
# after catching the disconnect, later sends would succeed and `sends`
|
||||
# would climb past 1 — the exactly-one assertion catches that.
|
||||
if self.sends == 1:
|
||||
raise WebSocketDisconnect(code=1001)
|
||||
|
||||
async def receive_text(self):
|
||||
raise WebSocketDisconnect(code=1001)
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch):
|
||||
(tmp_path / "dlc").mkdir()
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
|
||||
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod, tmp_path / "dlc"
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(mod, "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
sys.modules.pop("server", None)
|
||||
|
||||
|
||||
class _Capture(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.messages = []
|
||||
|
||||
def emit(self, record):
|
||||
self.messages.append(record.getMessage())
|
||||
|
||||
|
||||
def test_midstream_disconnect_is_not_logged_as_error(server):
|
||||
"""The first streamed send (`loading`) raises WebSocketDisconnect; the
|
||||
handler must return quietly, not log `highway_ws unhandled error`.
|
||||
|
||||
A raw handler on `feedBack.server` is used rather than pytest's `caplog`
|
||||
because `configure_logging()` (run at server import) reroutes that logger
|
||||
through the structlog pipeline, which caplog's fixture doesn't observe.
|
||||
"""
|
||||
_server, dlc = server
|
||||
_write_sloppak(dlc)
|
||||
from routers.ws_highway import highway_ws
|
||||
|
||||
cap = _Capture()
|
||||
cap.setLevel(logging.ERROR)
|
||||
lg = logging.getLogger("feedBack.server")
|
||||
lg.addHandler(cap)
|
||||
try:
|
||||
ws = _DisconnectingWS()
|
||||
asyncio.run(highway_ws(ws, "disc.sloppak", arrangement=0)) # must not raise
|
||||
finally:
|
||||
lg.removeHandler(cap)
|
||||
|
||||
assert ws.sends == 1, f"handler kept streaming after the disconnect ({ws.sends} sends)"
|
||||
unhandled = [m for m in cap.messages if "highway_ws unhandled error" in m]
|
||||
assert not unhandled, f"disconnect logged as unhandled error: {unhandled}"
|
||||
Reference in New Issue
Block a user