mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 06:14:29 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b02868cbc1 | ||
|
|
3717e4338d | ||
|
|
0b4b174d33 | ||
|
|
2f2a095e4c | ||
|
|
e14ef64224 | ||
|
|
365cec1d29 | ||
|
|
1702afa379 | ||
|
|
917d81c2d2 | ||
|
|
939c98214b | ||
|
|
4e0e3c5417 | ||
|
|
8ef97708ef | ||
|
|
e729c44d5b | ||
|
|
2991612531 | ||
|
|
af611770aa | ||
|
|
ea9da0acde | ||
|
|
be473dc7af | ||
|
|
0d35228d56 | ||
|
|
dd1927e27b | ||
|
|
7c897e9f2b | ||
|
|
6272af8d33 |
@@ -8,6 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Gold tier (career passports)** — an earned badge turns **gold** when
|
||||
Virtuoso verifies an improvised jam in the passport's style (the
|
||||
`gold_improv` artifact relays with the drill snapshot; a genre inherits its
|
||||
family's style, gained-only, and gold never substitutes for the badge bar
|
||||
itself). Gold gets its own ceremony, stamp slam, foil chip, and gold ink on
|
||||
the shelf cover, profile wall, and passport card; the bronze page's "Gold
|
||||
rung coming" preview becomes a live invitation to jam it.
|
||||
- **Gigs (the career verb, frontend)** — book a gig from any opened passport:
|
||||
a gig poster proposes the setlist (re-roll for a different bill; save or
|
||||
copy the poster as a PNG), "Play the gig" hands the set to the play queue
|
||||
with the venue on stage, a floating strip tracks the set, and finishing it
|
||||
logs dated entries with per-song accuracies in the passport book — with an
|
||||
encore celebration (crowd eruption + confetti) when the whole set clears
|
||||
the bar, and a summary poster to share. Quitting mid-set simply abandons
|
||||
it: no log, no fail state.
|
||||
- **Career on the Profile and Home pages** — the Profile gains a passport
|
||||
wall (earned-badge covers per instrument, hours, gig count; absent until a
|
||||
passport exists), injected through the same mount-point + rendered-event
|
||||
@@ -19,8 +34,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
copied straight to the clipboard for pasting outside the app (shared
|
||||
`blob-io` helpers replace the download idiom previously duplicated in
|
||||
settings-io and diagnostics-export).
|
||||
- **Gigs (backend)** — career mode gains its verb: `POST
|
||||
/api/plugins/career/gigs/propose` builds a playable setlist for an
|
||||
instrument+genre (your qualifying songs plus a couple of stakes songs near
|
||||
the bar; a young passport fills from unplayed genre songs — the first gig
|
||||
is how stubs start; re-roll by calling again), naming the room your stars
|
||||
can book. `POST /gigs` logs a **completed** set — per-song accuracies read
|
||||
from the set's own freshly-recorded stats, an encore flag at the
|
||||
data-driven bar (avg ≥ 75%) — into the career state; abandoned sets never
|
||||
log (no fail state: the gig you finished is the gig you played). Passports
|
||||
carry their gig log; instruments their gig count.
|
||||
|
||||
### Changed
|
||||
- **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem
|
||||
list (`[{id, url, default}]` + `full_mix_url`), the same list the highway's WS
|
||||
`ready` sends. The stems plugin could only learn it from that WS message, which
|
||||
arrives once the highway is already on screen — so it decoded and then copied the
|
||||
whole song's PCM to its audio worklet with the player visible: over half a gigabyte
|
||||
of memcpy in one frame for a 6-stem pack, a measured 698 ms freeze right as the
|
||||
song-credits card appeared. With the list available at `song:loading` the plugin
|
||||
does all of it before the highway is drawn. Built by calling `load_song` itself, so
|
||||
it cannot drift from what the WS sends. Opt-in, so the library's metadata calls pay
|
||||
nothing.
|
||||
- **Folder library renders only the songs on screen** (#965) — a song list used to
|
||||
render *every* song it held. On a flat 50,944-song library that was one `<div>`
|
||||
with 50,938 children and ~1.3 **million** DOM nodes (~4.2 GB of renderer memory),
|
||||
built even while another screen was showing. A document that size also punishes
|
||||
unrelated code: any `document.querySelector` that misses has to walk the whole
|
||||
tree — which is how the song-preview menu check ended up eating ~50% of the
|
||||
renderer and dropping the app to 2.7 fps. Lists longer than 200 songs are now
|
||||
windowed (25–31 rows in the DOM instead of 50,000); shorter lists are unchanged.
|
||||
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
||||
top-level manifest key this repo invented (#583) that the feedpak spec never had.
|
||||
The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0
|
||||
@@ -169,6 +212,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
||||
|
||||
### Fixed
|
||||
- **3D Highway: the lane stops at the hit line** (#991) — the highway lane, its
|
||||
dividers, and the fret boundary extension lines ran `BEHIND` seconds *past* the
|
||||
hit line toward the player. Nothing is ever drawn in that strip (notes and chord
|
||||
frames clamp to `Math.min(0, dZ(dt))`), so it read as lane with no notes on it.
|
||||
The floor geometry now ends at the hit line; its far edge is unchanged, still
|
||||
`-AHEAD*TS` at the note horizon.
|
||||
- **Career passports review polish** — the passport tabs and book overlay carry
|
||||
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
|
||||
`role="dialog"` + `aria-modal` with focus moved to the close button on open
|
||||
|
||||
@@ -61,6 +61,8 @@ extractions and twenty-two `routers/` modules, plus lib/library_registry.py for
|
||||
and is a monolith in its own right, to be split per-table once the router train
|
||||
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
|
||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
|
||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) · `plugins/career/screen.js`
|
||||
(1,530 — career v3 gigs + gold pushed it over; split plan: carve the gig block into a
|
||||
`scriptType: module` file when career work next touches it) — and every monolith with a PR
|
||||
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
|
||||
by policy — the norm governs source files.
|
||||
|
||||
+9
-1
@@ -14,6 +14,7 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
import appstate
|
||||
from safepath import resolved_root
|
||||
|
||||
|
||||
def _get_dlc_dir(cfg: dict | None = None) -> Path | None:
|
||||
@@ -86,7 +87,14 @@ def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
|
||||
or PureWindowsPath(safe).drive):
|
||||
return None
|
||||
try:
|
||||
root = dlc.resolve()
|
||||
# The library root is fixed for the life of the process, but this
|
||||
# function runs once per song / art fetch / scanned row — and
|
||||
# `.resolve()` lstats every path component. Re-resolving here was
|
||||
# ~23,500 stat calls/sec on a 50,944-song library, which pins a core
|
||||
# when the library sits on a FUSE mount (NTFS-3G, SMB, sshfs) where each
|
||||
# stat is a userspace round trip. Resolve the root once; see
|
||||
# safepath.resolved_root for the caching contract.
|
||||
root = resolved_root(dlc)
|
||||
# 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
|
||||
|
||||
+98
-15
@@ -23,9 +23,18 @@ Engine selection
|
||||
Two transcription paths share a common output:
|
||||
|
||||
* `transcribe_vocals_remote(path, server_url, ...)` — POST the vocal
|
||||
stem to the `/align` endpoint on a feedBack-demucs-server (got-feedBack's
|
||||
reference server already hosts WhisperX alongside Demucs at the same
|
||||
URL).
|
||||
stem to the `/transcribe` endpoint on a feedBack-demucs-server
|
||||
(got-feedBack's reference server already hosts WhisperX alongside
|
||||
Demucs at the same URL).
|
||||
|
||||
It used to POST to `/align`, which is *forced alignment* — "here are
|
||||
the lyrics, tell me when each word is sung". Its `text` field is
|
||||
required and we have no lyrics (transcribing them is the point), so
|
||||
the server answered 422 from FastAPI's validation layer before its
|
||||
handler ran, and remote transcription never worked for anyone
|
||||
(feedBack-plugin-stem-splitter#17). `/transcribe` takes only audio.
|
||||
Requires feedBack-demucs-server ≥ the revision adding that endpoint;
|
||||
an older server answers 404 and the error says so.
|
||||
|
||||
* `transcribe_vocals_local(path, ...)` — load WhisperX in-process. Heavy
|
||||
(~3 GB of model weights for `large-v2` + the wav2vec2 aligner) and
|
||||
@@ -416,6 +425,38 @@ def transcribe_vocals_local(
|
||||
|
||||
# ── Remote transcription ────────────────────────────────────────────────────
|
||||
|
||||
_MAX_ERR_BODY = 4000
|
||||
|
||||
|
||||
def _err_body(resp) -> str:
|
||||
"""The server's error body, whole if it plausibly is one, and marked when it isn't.
|
||||
|
||||
This was capped at 300 chars, which is enough for "Internal Server Error" and not much else.
|
||||
The bodies carrying the most diagnosis are the long ones — a FastAPI validation body naming
|
||||
the field it rejected, a 500 whose traceback answers on its LAST line — and those are exactly
|
||||
the ones a 300-char cap decapitates. The cap survives so a server answering with a 2 MB HTML
|
||||
error page can't dump a novel into a log line.
|
||||
"""
|
||||
# Strip FIRST, then measure: a body that is 300 chars of JSON and 3900 of trailing whitespace
|
||||
# is not a long body, and truncating it would cut real content to make room for blanks.
|
||||
text = (getattr(resp, "text", "") or "").strip()
|
||||
if len(text) <= _MAX_ERR_BODY:
|
||||
return text
|
||||
|
||||
# Keep the HEAD **and the TAIL**. Head-only truncation throws away the exception line — and
|
||||
# on a traceback the exception line is the answer. This docstring said as much while the code
|
||||
# did the opposite: it cut off precisely the part it exists to preserve, which is the same
|
||||
# mistake, one level up, as the 300-char cap it replaced.
|
||||
#
|
||||
# The marker sits inside the bound, not past it: otherwise _MAX_ERR_BODY is a suggestion, and
|
||||
# the callers who trust it (a log line, a job record persisted to disk) are the ones surprised.
|
||||
marker = f"\n… [truncated, {len(text)} chars total] …\n"
|
||||
budget = max(0, _MAX_ERR_BODY - len(marker))
|
||||
head = budget * 2 // 3 # context: what was being attempted
|
||||
tail = budget - head # verdict: what actually went wrong
|
||||
return text[:head].rstrip() + marker + text[len(text) - tail:].lstrip()
|
||||
|
||||
|
||||
def transcribe_vocals_remote(
|
||||
vocals_path: Path,
|
||||
server_url: str,
|
||||
@@ -426,7 +467,17 @@ def transcribe_vocals_remote(
|
||||
min_word_score: float = 0.35,
|
||||
progress_cb: ProgressCB = None,
|
||||
) -> list[dict]:
|
||||
"""POST the vocal stem to `{server_url}/align` and parse the response.
|
||||
"""POST the vocal stem to `{server_url}/transcribe` and parse the response.
|
||||
|
||||
NOT `/align` — that endpoint is forced alignment ("here are the lyrics,
|
||||
tell me when each word is sung") and its `text` field is required. We
|
||||
have no lyrics; producing them is the point. Posting there returned a
|
||||
422 from FastAPI's validation layer before the server's handler ran, so
|
||||
remote transcription never worked at all
|
||||
(feedBack-plugin-stem-splitter#17).
|
||||
|
||||
Requires a feedBack-demucs-server carrying `/transcribe`; an older one
|
||||
answers 404 and the raised error says so.
|
||||
|
||||
Expects the server to respond with a JSON object carrying a `words` (or
|
||||
`segments`) field in WhisperX's native shape; `_whisperx_to_sloppak`
|
||||
@@ -454,21 +505,53 @@ def transcribe_vocals_remote(
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
params: dict[str, str] = {}
|
||||
# POST to /transcribe, not /align.
|
||||
#
|
||||
# /align is FORCED ALIGNMENT: "here are the lyrics, tell me when each word is sung". Its
|
||||
# `text` field is required, and we have no lyrics — transcription is the whole point. So the
|
||||
# server rejected every request with a 422 in FastAPI's validation layer, before its handler
|
||||
# ever ran, and remote transcription has never worked for anyone. /transcribe answers the
|
||||
# question we are actually asking and takes only the audio.
|
||||
# (feedBack-plugin-stem-splitter#17; endpoint added in feedBack-demucs-server#14.)
|
||||
#
|
||||
# `language` goes in the FORM BODY, not the query string: the server reads it with
|
||||
# Form(""), and a query param would be silently ignored — so an explicit language hint would
|
||||
# do nothing and Whisper's auto-detection would quietly decide instead, which is exactly the
|
||||
# kind of "it works but it's wrong" that hides for months.
|
||||
form: dict[str, str] = {}
|
||||
if language:
|
||||
params["language"] = language
|
||||
form["language"] = language
|
||||
|
||||
with open(vocals_path, "rb") as f:
|
||||
resp = requests.post(
|
||||
f"{server_url}/align",
|
||||
files={"file": (vocals_path.name, f, "audio/ogg")},
|
||||
params=params,
|
||||
headers=headers or None,
|
||||
timeout=timeout,
|
||||
# Everything that can go wrong out here comes back as RuntimeError, which is what the
|
||||
# docstring promises and what the caller catches. A DNS failure, a timeout, a reset
|
||||
# connection or an unreadable stem file would otherwise surface as requests.RequestException
|
||||
# or OSError and escape the one handler written to log-and-continue — turning "this song's
|
||||
# lyrics failed" into "the whole batch died".
|
||||
try:
|
||||
with open(vocals_path, "rb") as f:
|
||||
resp = requests.post(
|
||||
f"{server_url}/transcribe",
|
||||
files={"file": (vocals_path.name, f, "audio/ogg")},
|
||||
data=form or None,
|
||||
headers=headers or None,
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise RuntimeError(f"could not reach the WhisperX server at {server_url}: {e}") from e
|
||||
except OSError as e:
|
||||
raise RuntimeError(f"could not read the vocal stem {vocals_path.name}: {e}") from e
|
||||
|
||||
if resp.status_code == 404:
|
||||
# The endpoint isn't there. Say what that means, because "404" on its own sends someone
|
||||
# hunting for a typo in their URL when the real answer is that their server predates the
|
||||
# feature. (feedBack-demucs-server#14 added /transcribe.)
|
||||
raise RuntimeError(
|
||||
f"the WhisperX server at {server_url} has no /transcribe endpoint (404) — it "
|
||||
f"predates remote transcription support. Update the server, or use 'Check for "
|
||||
f"update' if it is the plugin-managed one."
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {resp.text[:300]}")
|
||||
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {_err_body(resp)}")
|
||||
|
||||
data = resp.json()
|
||||
|
||||
|
||||
+69
-5
@@ -829,9 +829,60 @@ def post_song_gap_fill(filename: str, data: dict):
|
||||
return {"ok": True, "written": additions, "skipped": skipped}
|
||||
|
||||
|
||||
def _playable_stems_payload(filename: str, dlc) -> dict:
|
||||
"""The playable stems (id/url/default) + full-mix URL for a sloppak.
|
||||
|
||||
Why it exists: the stems plugin could only learn its stem list from the
|
||||
highway's WS `ready`, which arrives once the highway is already up. So it
|
||||
decoded, and then copied the whole song's PCM to its worklet, with the player
|
||||
on screen — half a gigabyte of memcpy in one frame, ~700 ms, freezing the
|
||||
venue video. Given the list at `song:loading` it can do all of that BEFORE the
|
||||
highway appears, behind the loading overlay where a stall costs nothing.
|
||||
|
||||
The list MUST be the same one the WS sends a moment later. If it is not, the
|
||||
plugin preloads a graph and then throws it away and rebuilds — strictly worse
|
||||
than not preloading. So this does not reimplement the WS's construction, it
|
||||
calls THE SAME FUNCTION: load_song, whose LoadedSloppak already carries the
|
||||
partitioned stems and the resolved full mix, and then builds the URLs exactly
|
||||
as ws_highway does. Drift is impossible by construction rather than by
|
||||
agreement — which matters, because `full_mix` in particular is not simply the
|
||||
`full` stem: load_song falls back to the deprecated `original_audio:` key for
|
||||
every pack written before feedpak 1.15.0, and reimplementing that (I did, at
|
||||
first) silently dropped the pristine full mix for most real libraries.
|
||||
|
||||
Opt-in (`?stems=1`) so the library's own metadata calls — the hot path — pay
|
||||
nothing for it. Non-sloppak sources (archives, loose folders) have no stems
|
||||
to preload: load_song raises and we return the empty list.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
try:
|
||||
loaded = sloppak_mod.load_song(filename, dlc, appstate.sloppak_cache_dir)
|
||||
except Exception:
|
||||
return {"stems": [], "full_mix_url": None}
|
||||
|
||||
q_fn = quote(filename, safe="")
|
||||
|
||||
def _url(rel: str) -> str:
|
||||
return f"/api/sloppak/{q_fn}/file/{quote(rel)}"
|
||||
|
||||
return {
|
||||
"stems": [
|
||||
{"id": s["id"], "url": _url(s["file"]), "default": s["default"]}
|
||||
for s in loaded.stems
|
||||
],
|
||||
"full_mix_url": _url(loaded.full_mix) if loaded.full_mix else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/song/{filename:path}")
|
||||
async def get_song_info(filename: str):
|
||||
"""Return song metadata, from cache or by extracting it from the song source."""
|
||||
async def get_song_info(filename: str, stems: int = 0):
|
||||
"""Return song metadata, from cache or by extracting it from the song source.
|
||||
|
||||
`?stems=1` additionally returns the playable stem list with URLs, so the
|
||||
stems plugin can start fetching/decoding on `song:loading` instead of waiting
|
||||
for the highway's WS `ready` (see _playable_stems_payload).
|
||||
"""
|
||||
import asyncio
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
@@ -854,8 +905,21 @@ async def get_song_info(filename: str):
|
||||
|
||||
mtime, size = appstate.stat_for_cache(song_path)
|
||||
cached = appstate.meta_db.get(cache_key, mtime, size)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# The stem list is NOT stored in the metadata cache: that is a fixed-column
|
||||
# table, and widening it would mean a migration plus a stale row for every
|
||||
# song already scanned. It is cheap to read on demand (the pack is unpacked
|
||||
# by then, so this is a plain manifest read), and only the opt-in caller pays.
|
||||
async def _with_stems(meta: dict) -> dict:
|
||||
if not stems:
|
||||
return meta
|
||||
extra = await loop.run_in_executor(
|
||||
None, _playable_stems_payload, filename, dlc)
|
||||
return {**meta, **extra}
|
||||
|
||||
if cached:
|
||||
return cached
|
||||
return await _with_stems(cached)
|
||||
|
||||
# Extract in thread pool
|
||||
def _extract():
|
||||
@@ -863,5 +927,5 @@ async def get_song_info(filename: str):
|
||||
appstate.meta_db.put(cache_key, mtime, size, meta)
|
||||
return meta
|
||||
|
||||
meta = await asyncio.get_event_loop().run_in_executor(None, _extract)
|
||||
return meta
|
||||
meta = await loop.run_in_executor(None, _extract)
|
||||
return await _with_stems(meta)
|
||||
|
||||
+31
-3
@@ -4,9 +4,34 @@ under a server-owned root.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def resolved_root(root: Path) -> Path:
|
||||
"""Canonical (link-resolved) form of a server-owned root directory.
|
||||
|
||||
``Path.resolve()`` is a filesystem call: it lstats every component of the
|
||||
path. The roots we join against — the DLC library, a plugin's asset dir —
|
||||
are fixed for the life of the process, but the containment helpers below
|
||||
(and ``dlc_paths._resolve_dlc_path``) were re-resolving them on EVERY call,
|
||||
and those are called once per song, per art fetch, per scanned row.
|
||||
|
||||
On a real 50,944-song library that cost ~23,500 stat/lstat calls per second,
|
||||
pinning a core. It is brutal when the library lives on a FUSE mount
|
||||
(NTFS-3G, SMB, sshfs), where every stat is a userspace round trip: the same
|
||||
three parent directories were being walked over and over.
|
||||
|
||||
Cached because a root is a constant here, not because resolution is cheap.
|
||||
Consequence: if a root's symlink/junction is re-pointed at a NEW target
|
||||
while the server is running, the old target stays in effect until restart.
|
||||
That is fine for a library path fixed at startup, and the cache is keyed on
|
||||
the Path, so switching to a different library dir is a different key.
|
||||
"""
|
||||
return root.resolve()
|
||||
|
||||
|
||||
def safe_join(root: Path, name: str) -> Path | None:
|
||||
"""Resolve ``name`` under ``root`` and return the resolved Path, or
|
||||
``None`` if it would escape ``root`` or is unrepresentable.
|
||||
@@ -35,9 +60,12 @@ def safe_join(root: Path, name: str) -> Path | None:
|
||||
return None
|
||||
safe = name.replace("\\", "/")
|
||||
try:
|
||||
root_resolved = root.resolve()
|
||||
candidate = (root_resolved / safe).resolve()
|
||||
if not candidate.is_relative_to(root_resolved):
|
||||
# The ROOT is a constant — resolve it once (see resolved_root). The
|
||||
# CANDIDATE must still be resolved on every call: following its symlinks
|
||||
# is exactly the zip-slip / traversal defence, so it is never cached.
|
||||
root_res = resolved_root(root)
|
||||
candidate = (root_res / safe).resolve()
|
||||
if not candidate.is_relative_to(root_res):
|
||||
return None
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
+137
-5
@@ -51,6 +51,97 @@ from scan_worker import _relpath, _scan_one
|
||||
|
||||
log = logging.getLogger("feedBack.scan")
|
||||
|
||||
import json
|
||||
|
||||
|
||||
# ── Directory-signature fast path ─────────────────────────────────────────────
|
||||
#
|
||||
# A startup scan globs the whole library twice (*.feedpak, *.wem) and stats every
|
||||
# file to detect what changed. On a 50k-song library that lives on a slow mount
|
||||
# (an NTFS-3G FUSE volume here) it is ~100k filesystem round trips every launch —
|
||||
# the "big drive churns on every startup" report.
|
||||
#
|
||||
# But adds / removes / renames of songs all bump the mtime of the DIRECTORY that
|
||||
# holds them (verified on the target NTFS-3G mount), and so does the addition of
|
||||
# a subdirectory (a new entry in its parent). So after a scan we record every
|
||||
# library directory and its mtime; on the next scan we re-stat ONLY those
|
||||
# directories (a handful, vs 100k file ops). If none changed, the file set is
|
||||
# unchanged and the whole listing/stat pass is skipped.
|
||||
#
|
||||
# The one thing this cannot see is a file edited IN PLACE under the same name —
|
||||
# that bumps the file's mtime but not its directory's. That is rare for a song
|
||||
# library (you add and remove packs, you don't rewrite them under the same name),
|
||||
# and the manual Refresh forces a full scan (force=True) for exactly that case.
|
||||
def _dir_signature_file() -> Path:
|
||||
return appstate.config_dir / "scan_dir_signature.json"
|
||||
|
||||
|
||||
def _load_dir_signature() -> dict | None:
|
||||
try:
|
||||
data = json.loads(_dir_signature_file().read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict) and isinstance(data.get("dirs"), dict):
|
||||
return data
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _save_dir_signature(dlc: Path, dirs: dict[str, int]) -> None:
|
||||
# Keyed by the DLC path so switching libraries never matches a stale
|
||||
# signature. Best-effort: a failed write just means the next scan is a full
|
||||
# one, never a wrong one.
|
||||
try:
|
||||
_dir_signature_file().write_text(
|
||||
json.dumps({"dlc": str(dlc), "dirs": dirs}), encoding="utf-8")
|
||||
except OSError as e:
|
||||
log.debug("scan: could not persist dir signature: %s", e)
|
||||
|
||||
|
||||
def _library_dirs(all_songs, dlc: Path) -> set[str]:
|
||||
"""Every directory whose mtime reflects an add/remove of a library song:
|
||||
each song's containing directory and all of its ancestors up to the DLC
|
||||
root (the root itself always included, as "."). Derived from the already-
|
||||
listed songs — no extra filesystem walk. The builtin carve-outs
|
||||
(tutorials-builtin / minigames-builtin) are absent because the caller
|
||||
already excluded them from `all_songs`, so a minigame writing a drill there
|
||||
never invalidates the fast path.
|
||||
|
||||
Directory-form songs (loose-song folders, directory sloppak bundles) also
|
||||
record their OWN directory: a file added/removed/replaced INSIDE the folder
|
||||
bumps that folder's mtime but not its parent's, so tracking only the parent
|
||||
would miss an in-place change to such a song. File-form sloppaks (a single
|
||||
.feedpak zip) aren't dirs, so they add nothing here — the flat file library
|
||||
stays at a handful of dir stats."""
|
||||
rels = {"."}
|
||||
for f in all_songs:
|
||||
rel = Path(_relpath(f, dlc))
|
||||
if f.is_dir():
|
||||
rels.add(rel.as_posix())
|
||||
parent = rel.parent
|
||||
rels.add(parent.as_posix())
|
||||
for anc in parent.parents:
|
||||
rels.add(anc.as_posix())
|
||||
return rels
|
||||
|
||||
|
||||
def _record_dir_signature(all_songs, dlc: Path) -> None:
|
||||
sig = _stat_dirs(dlc, _library_dirs(all_songs, dlc))
|
||||
if sig is not None: # a dir vanished mid-scan → skip; next scan is full
|
||||
_save_dir_signature(dlc, sig)
|
||||
|
||||
|
||||
def _stat_dirs(dlc: Path, rels) -> dict[str, int] | None:
|
||||
"""{reldir: mtime_ns} for the given library dirs, or None if any is gone or
|
||||
unreadable — a vanished recorded dir means the tree changed, so fail to a
|
||||
full scan rather than a false match."""
|
||||
out: dict[str, int] = {}
|
||||
for rel in rels:
|
||||
try:
|
||||
out[rel] = (dlc if rel == "." else dlc / rel).stat().st_mtime_ns
|
||||
except OSError:
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
_SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False, "added": 0, "removed": 0}
|
||||
|
||||
@@ -99,9 +190,12 @@ def _make_scan_executor():
|
||||
)
|
||||
|
||||
|
||||
def background_scan():
|
||||
def background_scan(force: bool = False):
|
||||
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
|
||||
|
||||
`force` skips the directory-signature fast path and always does the full
|
||||
listing/stat pass — the manual Refresh sets it (see _dir_signature_file).
|
||||
|
||||
Never sets `_scan_status["running"] = False` — ownership of that flag
|
||||
lives in `_scan_runner` so a `kick_scan()` racing this function's
|
||||
terminal write cannot observe a stale False and start a second runner.
|
||||
@@ -121,6 +215,22 @@ def background_scan():
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(appstate.server_root, dlc)
|
||||
builtin_content.seed_builtin_starter_content(appstate.server_root, dlc)
|
||||
|
||||
# Fast path: if every library directory recorded by the last scan still has
|
||||
# the same mtime, nothing was added, removed, or renamed, so the whole
|
||||
# glob-and-stat pass below can be skipped (see the signature comment above).
|
||||
# `force` (manual Refresh) always does the full pass. Seeding above is
|
||||
# idempotent — it only writes when a builtin is missing — so it does not
|
||||
# perturb the mtimes on a settled library.
|
||||
if not force:
|
||||
stored = _load_dir_signature()
|
||||
if stored is not None and stored.get("dlc") == str(dlc):
|
||||
current = _stat_dirs(dlc, stored["dirs"].keys())
|
||||
if current is not None and current == stored["dirs"]:
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete"}
|
||||
log.info("Scan: library tree unchanged (%d dirs) — skipped the full listing/stat pass",
|
||||
len(current))
|
||||
return
|
||||
|
||||
# Listing can fail on macOS without Full Disk Access, or on Docker if the
|
||||
# path isn't shared. Report the failure explicitly rather than silently
|
||||
# appearing to scan nothing.
|
||||
@@ -223,6 +333,9 @@ def background_scan():
|
||||
to_scan.append((f, mtime, size, dlc))
|
||||
|
||||
if not to_scan:
|
||||
# Full pass completed with the DB already up to date — record the tree
|
||||
# signature so the next startup can take the fast path.
|
||||
_record_dir_signature(all_songs, dlc)
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
|
||||
log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs))
|
||||
return
|
||||
@@ -247,6 +360,9 @@ def background_scan():
|
||||
_scan_status["done"] += 1
|
||||
_scan_status["current"] = fname
|
||||
|
||||
# Record the tree signature after a completed full pass so the next startup
|
||||
# can skip it when nothing has changed.
|
||||
_record_dir_signature(all_songs, dlc)
|
||||
log.info("Scan complete: %d songs cached", len(to_scan))
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
|
||||
|
||||
@@ -255,6 +371,9 @@ _scan_kick_lock = threading.Lock()
|
||||
|
||||
|
||||
_scan_rescan_pending = False
|
||||
# Set by kick_scan(force=True); consumed by _scan_runner for the next pass so a
|
||||
# manual Refresh bypasses the directory-signature fast path.
|
||||
_scan_force_next = False
|
||||
|
||||
|
||||
# Handles to the running scan / enrichment worker threads. Both use the shared
|
||||
@@ -265,9 +384,15 @@ _scan_rescan_pending = False
|
||||
_scan_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def kick_scan() -> bool:
|
||||
def kick_scan(force: bool = False) -> bool:
|
||||
"""Request a library rescan, single-flight + coalescing.
|
||||
|
||||
`force` skips the directory-signature fast path for the resulting pass (the
|
||||
manual Refresh uses it so an in-place same-name edit — the one thing the
|
||||
fast path can't see — is always picked up). A forced request that coalesces
|
||||
onto a running or queued scan keeps the force intent: the pass is forced if
|
||||
ANY pending request asked for it.
|
||||
|
||||
Returns True if a new scan thread was started, False if one was already
|
||||
running. In the latter case a follow-up pass is queued and runs as soon
|
||||
as the current scan finishes so files landing mid-scan (e.g. an upload
|
||||
@@ -275,8 +400,10 @@ def kick_scan() -> bool:
|
||||
until the next periodic pass. Multiple late-arriving requests coalesce
|
||||
into a single follow-up.
|
||||
"""
|
||||
global _scan_rescan_pending, _scan_thread
|
||||
global _scan_rescan_pending, _scan_thread, _scan_force_next
|
||||
with _scan_kick_lock:
|
||||
if force:
|
||||
_scan_force_next = True
|
||||
if _scan_status["running"]:
|
||||
_scan_rescan_pending = True
|
||||
return False
|
||||
@@ -290,10 +417,15 @@ def kick_scan() -> bool:
|
||||
|
||||
def _scan_runner():
|
||||
"""Run _background_scan, then re-run if requests arrived mid-scan."""
|
||||
global _scan_rescan_pending
|
||||
global _scan_rescan_pending, _scan_force_next
|
||||
while True:
|
||||
# Consume the force flag for THIS pass; a forced request queued mid-scan
|
||||
# sets it again for the follow-up.
|
||||
with _scan_kick_lock:
|
||||
forced = _scan_force_next
|
||||
_scan_force_next = False
|
||||
try:
|
||||
background_scan()
|
||||
background_scan(force=forced)
|
||||
except Exception:
|
||||
log.exception("background scan failed unexpectedly")
|
||||
|
||||
|
||||
+19
-6
@@ -80,6 +80,20 @@ def find_full_mix(stems: list[dict]) -> dict | None:
|
||||
)
|
||||
|
||||
|
||||
def stem_default_on(raw) -> bool:
|
||||
"""Whether a manifest stem entry plays by default.
|
||||
|
||||
Absent means on. A string is honoured so a hand-written manifest can say
|
||||
`default: off`. Extracted so the WS `ready` payload and the REST song-info
|
||||
payload cannot drift: the stems plugin now preloads from REST and then has
|
||||
to agree with what the WS says a moment later, or it would rebuild the whole
|
||||
graph for nothing.
|
||||
"""
|
||||
if isinstance(raw, str):
|
||||
return raw.lower() not in ("off", "false", "0", "no")
|
||||
return bool(raw)
|
||||
|
||||
|
||||
def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
|
||||
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
|
||||
|
||||
@@ -1100,12 +1114,11 @@ def load_song(
|
||||
sfile = str(s.get("file", ""))
|
||||
if not sid or not sfile:
|
||||
continue
|
||||
default_val = s.get("default", True)
|
||||
if isinstance(default_val, str):
|
||||
default_on = default_val.lower() not in ("off", "false", "0", "no")
|
||||
else:
|
||||
default_on = bool(default_val)
|
||||
stems.append({"id": sid, "file": sfile, "default": default_on})
|
||||
stems.append({
|
||||
"id": sid,
|
||||
"file": sfile,
|
||||
"default": stem_default_on(s.get("default", True)),
|
||||
})
|
||||
|
||||
# The complete mixdown is a stem (spec §5.3), but it is not a *layer*: lift
|
||||
# it out so that no consumer of `stems` — the mixer, the library's stem
|
||||
|
||||
@@ -525,7 +525,18 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Gold foil preview — honest "coming", never earnable-looking. */
|
||||
/* Gold ink — a REAL gold badge (comb-verified improv). */
|
||||
.pp-stamp-gold {
|
||||
border-color: #b8860b;
|
||||
color: #a97b1b;
|
||||
box-shadow: inset 0 0 0 3px #f3e8c8, inset 0 0 0 4px #b8860b;
|
||||
}
|
||||
.pp-stamp-mini.pp-stamp-gold {
|
||||
color: #f0c75e;
|
||||
border-color: #f0c75e;
|
||||
box-shadow: none;
|
||||
}
|
||||
/* Gold foil chip — rendered only alongside an earned gold stamp. */
|
||||
.pp-gold-foil {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -535,8 +546,8 @@
|
||||
margin-top: 0.9rem;
|
||||
padding: 0.28rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
border: 2px dashed #c8b273;
|
||||
color: #a8946d;
|
||||
border: 2px solid #d9a253;
|
||||
color: #c89040;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.32em;
|
||||
@@ -714,3 +725,67 @@
|
||||
.pp-dash-card:hover .pp-dash-shine { animation: none; }
|
||||
.pp-wall-cover, .pp-dash-card { transition: none; }
|
||||
}
|
||||
|
||||
/* ── Gigs: poster, runner strip, summary, log ───────────────────────────── */
|
||||
|
||||
.pp-poster {
|
||||
position: relative;
|
||||
width: min(92vw, 420px);
|
||||
padding: 2rem 1.6rem 1.4rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(180deg, #141019, #241318);
|
||||
border: 2px solid rgba(217, 162, 83, 0.45);
|
||||
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
text-align: center;
|
||||
}
|
||||
.pp-poster-venue { color: rgba(240, 226, 195, 0.7); font-size: 0.95rem; letter-spacing: 0.08em; }
|
||||
.pp-poster-presents { color: rgba(240, 226, 195, 0.4); font-size: 0.58rem; letter-spacing: 0.4em; text-transform: uppercase; }
|
||||
.pp-poster-title {
|
||||
color: #d9a253;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pp-poster-inst { color: rgba(240, 226, 195, 0.5); font-size: 0.68rem; letter-spacing: 0.2em; text-transform: uppercase; }
|
||||
.pp-poster-bill { margin: 0.9rem 0 0.5rem; display: flex; flex-direction: column; gap: 0.35rem; width: 100%; }
|
||||
.pp-poster-line { color: rgba(240, 226, 195, 0.85); font-size: 0.85rem; }
|
||||
.pp-poster-line span { color: rgba(217, 162, 83, 0.7); margin-right: 0.35rem; }
|
||||
.pp-poster-line em { color: rgba(240, 226, 195, 0.5); font-style: italic; font-size: 0.72rem; }
|
||||
.pp-poster-line b { color: #f3d179; margin-left: 0.3rem; }
|
||||
.pp-poster-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; margin-top: 0.6rem; }
|
||||
.pp-poster-summary { cursor: default; }
|
||||
|
||||
.pp-gig-strip {
|
||||
position: fixed;
|
||||
top: 0.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 35; /* above the rail (30), under popovers (40) — the chrome invariant */
|
||||
background: rgba(10, 8, 14, 0.85);
|
||||
border: 1px solid rgba(217, 162, 83, 0.4);
|
||||
border-radius: 999px;
|
||||
color: rgba(240, 226, 195, 0.85);
|
||||
font-size: 0.72rem;
|
||||
padding: 0.3rem 0.9rem;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.pp-gig-strip b { color: #d9a253; letter-spacing: 0.2em; }
|
||||
.pp-gig-strip em { color: #f3ead2; font-style: italic; }
|
||||
|
||||
.pp-giglog { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
|
||||
.pp-giglog-head {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #8a7a5e;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
|
||||
.pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; }
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
"songs": 5,
|
||||
"min_stars": 2
|
||||
},
|
||||
"gig": {
|
||||
"min_songs": 3,
|
||||
"max_songs": 5,
|
||||
"stakes_songs": 2,
|
||||
"encore_accuracy": 0.75
|
||||
},
|
||||
"families": [
|
||||
{ "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] },
|
||||
{ "key": "blues", "match": ["blues"] },
|
||||
|
||||
+300
-7
@@ -26,11 +26,14 @@ Endpoints (all under /api/plugins/career/):
|
||||
POST /passports/commit commit to an instrument (the wax seal, Stage 0)
|
||||
POST /passports/open open a genre passport for an instrument
|
||||
POST /drill-state relayed virtuoso.progress snapshot (drill intake)
|
||||
POST /gigs/propose build a playable setlist for a genre gig
|
||||
POST /gigs log a COMPLETED gig (abandoned sets never log)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -43,6 +46,8 @@ from pathlib import Path
|
||||
from fastapi import Body, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
import sloppak
|
||||
from dlc_paths import _resolve_dlc_path
|
||||
from progression import instrument_for_arrangement
|
||||
|
||||
PLUGIN_ID = "career"
|
||||
@@ -50,6 +55,9 @@ VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
|
||||
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
||||
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
|
||||
DOWNLOAD_CHUNK = 1024 * 256
|
||||
# A setlist is a handful of songs; this endpoint unpacks zips, so cap the work an
|
||||
# arbitrary caller can ask for.
|
||||
MAX_GIG_SONGS = 32
|
||||
|
||||
_lock = threading.Lock()
|
||||
_state = {
|
||||
@@ -327,10 +335,11 @@ def _badge_requirement(gkey, instrument="guitar"):
|
||||
def _drill_by_node():
|
||||
doc = _load_json(_drill_file(), {})
|
||||
if not isinstance(doc, dict):
|
||||
return None, {}
|
||||
return None, {}, {}
|
||||
snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {}
|
||||
by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {}
|
||||
return doc.get("received_at"), by_node
|
||||
gold = snapshot.get("goldImprov") if isinstance(snapshot.get("goldImprov"), dict) else {}
|
||||
return doc.get("received_at"), by_node, gold
|
||||
|
||||
|
||||
def _merge_drill_nodes(old, new):
|
||||
@@ -364,6 +373,16 @@ def _merge_drill_nodes(old, new):
|
||||
return out
|
||||
|
||||
|
||||
def _merge_gold(old, new):
|
||||
"""Gained-only merge of goldImprov artifacts: a minted style never
|
||||
un-mints via a stale relay; the FIRST artifact per style is kept."""
|
||||
out = dict(old)
|
||||
for style_id, art in (new or {}).items():
|
||||
if isinstance(art, dict) and style_id not in out:
|
||||
out[style_id] = art
|
||||
return out
|
||||
|
||||
|
||||
def _node_cleared(by_node, node_id):
|
||||
"""A drill counts as cleared on real completion evidence: mastered, any
|
||||
depth rung flipped true, or a key cleared (a top-tier clean pass in one
|
||||
@@ -383,8 +402,9 @@ def _passports_view():
|
||||
cfg = _state["passports_content"]
|
||||
graded = set(cfg.get("graded_instruments") or [])
|
||||
st = _career_state()
|
||||
all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else []
|
||||
played, played_seconds = _played_by_instrument_genre()
|
||||
received_at, by_node = _drill_by_node()
|
||||
received_at, by_node, gold_improv = _drill_by_node()
|
||||
instruments = {}
|
||||
for inst in cfg.get("instruments") or []:
|
||||
committed_at = (st["instruments"].get(inst) or {}).get("committed_at")
|
||||
@@ -410,7 +430,20 @@ def _passports_view():
|
||||
# false badge denial — the doc's shown-not-judged rule.
|
||||
badge = "shown_not_judged"
|
||||
elif qualifying >= req["songs"] and len(cleared) == len(required):
|
||||
badge = "earned"
|
||||
# Bronze is earned; GOLD upgrades it when a verified improv
|
||||
# artifact exists for this genre's jam style. Virtuoso mints
|
||||
# under raw STYLE_PALETTES ids ('punk', 'djent', 'disco', ...),
|
||||
# which are mostly NOT family keys — so match in family space:
|
||||
# the same keyword bucketing genres get ('punk' and 'punk
|
||||
# rock' both bucket to 'rock'), with the exact key as a direct
|
||||
# hit. Bronze remains a standalone win; gold never becomes an
|
||||
# obligation.
|
||||
fam = _genre_family(gkey)
|
||||
gold = any(
|
||||
s == gkey or (fam is not None and _genre_family(s) == fam)
|
||||
for s in gold_improv
|
||||
)
|
||||
badge = "gold" if gold else "earned"
|
||||
else:
|
||||
badge = "in_progress"
|
||||
# Practice invitation: the non-qualifying songs closest to the
|
||||
@@ -439,7 +472,11 @@ def _passports_view():
|
||||
"drills": {"required": required, "cleared": cleared},
|
||||
"badge": badge,
|
||||
})
|
||||
instruments[inst] = {"committed_at": committed_at, "passports": passports}
|
||||
inst_gigs = [g for g in all_gigs if g.get("instrument") == inst]
|
||||
for p in passports:
|
||||
p["gigs"] = [g for g in inst_gigs if g.get("genre_key") == p["genre_key"]][-20:][::-1]
|
||||
instruments[inst] = {"committed_at": committed_at, "passports": passports,
|
||||
"gig_count": len(inst_gigs)}
|
||||
return {
|
||||
"config": {
|
||||
"badge_requirement": cfg.get("badge_requirement") or {},
|
||||
@@ -454,6 +491,72 @@ def _passports_view():
|
||||
}
|
||||
|
||||
|
||||
def _gig_config():
|
||||
cfg = _state["passports_content"].get("gig")
|
||||
cfg = cfg if isinstance(cfg, dict) else {}
|
||||
|
||||
def _num(key, default, cast):
|
||||
# Tuning data, not code: junk falls back instead of 500ing both gig
|
||||
# endpoints, and a legitimate 0 (stakes_songs: 0) is respected.
|
||||
val = cfg.get(key)
|
||||
if isinstance(val, bool) or not isinstance(val, (int, float)):
|
||||
return default
|
||||
return cast(val)
|
||||
|
||||
return {
|
||||
"min_songs": max(1, _num("min_songs", 3, int)),
|
||||
"max_songs": max(1, _num("max_songs", 5, int)),
|
||||
"stakes_songs": max(0, _num("stakes_songs", 2, int)),
|
||||
"encore_accuracy": _num("encore_accuracy", 0.75, float),
|
||||
}
|
||||
|
||||
|
||||
def _current_venue():
|
||||
"""Highest unlocked venue (the room you can book today)."""
|
||||
stars_total, _, _ = _stars()
|
||||
best = None
|
||||
for v in _state["content"]["venues"]:
|
||||
if stars_total >= v["star_threshold"]:
|
||||
if best is None or v["star_threshold"] >= best["star_threshold"]:
|
||||
best = v
|
||||
return best
|
||||
|
||||
|
||||
def _fill_genre_songs(gkey, exclude, limit):
|
||||
"""Library songs of a genre to round out a gig — ANY song of the genre the
|
||||
set hasn't already picked.
|
||||
|
||||
Was `_unplayed_genre_songs`, restricted to `filename NOT IN song_stats`.
|
||||
That restriction created a hole: a song you'd played on a DIFFERENT
|
||||
instrument's arrangement has a stats row, so it was excluded here — and it
|
||||
lives in the played bucket for THAT instrument, not this passport's, so it
|
||||
was excluded there too. It could never be gigged. A player with 137 metalcore
|
||||
songs, all played on another instrument, got a 404 (reproduced). The player's
|
||||
library is the pool; whether a song has stats on some other instrument has no
|
||||
bearing on whether it can be in THIS gig.
|
||||
|
||||
Shuffled, so re-roll actually changes the set. The old version returned the
|
||||
library's first N in table order every time, so re-roll was a no-op for any
|
||||
set drawn from the filler (reproduced).
|
||||
|
||||
ponytail: full genre scan + python-side match + shuffle (a few ms at 7k
|
||||
songs, single-user); push into SQL if propose ever feels slow.
|
||||
"""
|
||||
db = _state["meta_db"]
|
||||
if db is None:
|
||||
return []
|
||||
rows = db.conn.execute(
|
||||
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs"
|
||||
).fetchall()
|
||||
pool = [
|
||||
{"filename": filename, "title": title or filename, "artist": artist or ""}
|
||||
for filename, title, artist, genre in rows
|
||||
if _genre_key(genre) == gkey and filename not in exclude
|
||||
]
|
||||
random.shuffle(pool) # re-roll must vary; free per call
|
||||
return pool[:limit]
|
||||
|
||||
|
||||
def _validate_pack_dir(pack_dir: Path):
|
||||
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
||||
manifest_path = pack_dir / "manifest.json"
|
||||
@@ -625,16 +728,206 @@ def setup(app, context):
|
||||
# drops junk entries, which must not become a size-guard bypass.
|
||||
if len(json.dumps(body["byNode"])) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||
raise HTTPException(413, "Snapshot too large.")
|
||||
gold_in = body.get("goldImprov", {})
|
||||
if not isinstance(gold_in, dict):
|
||||
# A relay bug must be LOUD, not a silent 200 that drops gold.
|
||||
raise HTTPException(400, "goldImprov must be an object keyed by style id.")
|
||||
# Keep only plausible artifacts: a dict that names its verifier —
|
||||
# an empty {} must not mint an evidence-free gold.
|
||||
gold_in = {k: v for k, v in gold_in.items()
|
||||
if isinstance(v, dict) and v.get("verifier")}
|
||||
# Same pre-merge bound byNode gets: the gained-only merge dropping
|
||||
# junk must not become a size-guard bypass (nor lock-held CPU burn).
|
||||
if len(json.dumps(gold_in)) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||
raise HTTPException(413, "Snapshot too large.")
|
||||
with _lock:
|
||||
_, existing = _drill_by_node()
|
||||
_, existing, existing_gold = _drill_by_node()
|
||||
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
|
||||
"byNode": _merge_drill_nodes(existing, body["byNode"])}
|
||||
"byNode": _merge_drill_nodes(existing, body["byNode"]),
|
||||
"goldImprov": _merge_gold(existing_gold, gold_in)}
|
||||
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES:
|
||||
raise HTTPException(413, "Snapshot too large.")
|
||||
_save_json(_drill_file(), {"received_at": _now_iso(),
|
||||
"snapshot": snapshot})
|
||||
return {"ok": True}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/prepare")
|
||||
def prepare_gig(body: dict = Body(...)):
|
||||
"""Unpack every song of the set BEFORE the gig starts.
|
||||
|
||||
A feedpak is a zip: the first play of one pays for its extraction into
|
||||
sloppak_cache. Inside a set that cost landed BETWEEN songs — the player
|
||||
finished a number and then sat waiting for the next one to unpack, mid-
|
||||
gig. A set is a known list up front, so extract it all while the player
|
||||
is still looking at the poster.
|
||||
|
||||
Idempotent and cheap on a warm cache: resolve_source_dir() returns the
|
||||
already-unpacked dir without rewriting it. Best-effort per song — one
|
||||
bad feedpak must not block the set from starting (the play itself will
|
||||
surface the error, exactly as it does outside a gig).
|
||||
"""
|
||||
raw = (body or {}).get("songs")
|
||||
# A str is iterable: without the list check, "abc" would prepare three
|
||||
# one-character "songs". Cap the count too — this endpoint unpacks zips,
|
||||
# so an oversized list is real work, and a setlist is a handful of songs.
|
||||
if not isinstance(raw, list):
|
||||
return {"ok": True, "prepared": 0, "failed": []}
|
||||
files = [f for f in raw if isinstance(f, str) and f.strip()][:MAX_GIG_SONGS]
|
||||
if not files:
|
||||
return {"ok": True, "prepared": 0, "failed": []}
|
||||
|
||||
# .get, not []: a host that doesn't hand us the resolvers (or has no
|
||||
# library configured) must degrade to "extract lazily, as before" — this
|
||||
# is an optimisation, and it is never allowed to be the thing that stops
|
||||
# a gig from starting.
|
||||
get_dlc = context.get("get_dlc_dir")
|
||||
get_cache = context.get("get_sloppak_cache_dir")
|
||||
dlc_root = get_dlc() if callable(get_dlc) else None
|
||||
cache_root = get_cache() if callable(get_cache) else None
|
||||
if dlc_root is None or cache_root is None:
|
||||
return {"ok": False, "prepared": 0, "failed": files, "error": "no library"}
|
||||
|
||||
root = Path(dlc_root)
|
||||
prepared, failed = 0, []
|
||||
for fn in files:
|
||||
# CONTAINMENT FIRST. resolve_source_dir() does a bare
|
||||
# `dlc_root / filename` with no guard, so a crafted `../..` would
|
||||
# walk straight out of the library. Every other filename-bound
|
||||
# handler validates through _resolve_dlc_path; so does this one.
|
||||
safe = _resolve_dlc_path(root, fn)
|
||||
if safe is None:
|
||||
_state["log"].warning("career: gig pre-extract rejected unsafe path %r", fn)
|
||||
failed.append(fn)
|
||||
continue
|
||||
try:
|
||||
sloppak.resolve_source_dir(fn, root, Path(cache_root))
|
||||
prepared += 1
|
||||
except Exception as exc: # noqa: BLE001 — one bad pak can't sink the set
|
||||
_state["log"].warning("career: gig pre-extract failed for %s: %s", fn, exc)
|
||||
failed.append(fn)
|
||||
return {"ok": True, "prepared": prepared, "failed": failed}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
|
||||
def propose_gig(body: dict = Body(...)):
|
||||
inst = str((body or {}).get("instrument") or "")
|
||||
genre = _genre_display((body or {}).get("genre"))
|
||||
gkey = genre.lower()
|
||||
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||
raise HTTPException(400, "Unknown instrument.")
|
||||
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||
raise HTTPException(400, "Provide a genre.")
|
||||
cfg = _gig_config()
|
||||
try:
|
||||
size = int((body or {}).get("size") or 4)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "size must be a number.")
|
||||
size = max(cfg["min_songs"], min(cfg["max_songs"], size))
|
||||
played, _seconds = _played_by_instrument_genre()
|
||||
stubs = list(played.get((inst, gkey), {}).values())
|
||||
req = _badge_requirement(gkey, inst)
|
||||
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
|
||||
rest = [s for s in stubs if s["stars"] < req["min_stars"]]
|
||||
# The set: mostly songs you own, plus a couple of stakes songs near
|
||||
# the bar; a young passport fills from unplayed genre songs so the
|
||||
# first gig is how stubs start. random per call = free re-roll.
|
||||
random.shuffle(qualifying)
|
||||
rest.sort(key=lambda s: -s["best_accuracy"])
|
||||
qtaken = max(1, size - cfg["stakes_songs"])
|
||||
picks = qualifying[:qtaken]
|
||||
for s in rest:
|
||||
if len(picks) >= size:
|
||||
break
|
||||
picks.append(s)
|
||||
# Surplus qualifying songs backfill a short set — a mature passport
|
||||
# with no near-bar songs left must still fill the bill. Offset by how
|
||||
# many QUALIFYING songs were taken, not len(picks): rest's stakes
|
||||
# additions would otherwise skip eligible qualifying songs entirely.
|
||||
for s in qualifying[qtaken:]:
|
||||
if len(picks) >= size:
|
||||
break
|
||||
picks.append(s)
|
||||
if len(picks) < size:
|
||||
exclude = {s["filename"] for s in picks}
|
||||
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks)))
|
||||
if not picks:
|
||||
raise HTTPException(404, "No songs of this genre in the library.")
|
||||
venue = _current_venue()
|
||||
return {
|
||||
"instrument": inst,
|
||||
"genre": genre,
|
||||
"genre_key": gkey,
|
||||
"venue_id": venue["id"] if venue else None,
|
||||
"venue_name": venue["name"] if venue else "",
|
||||
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
|
||||
"artist": s.get("artist") or ""} for s in picks[:size]],
|
||||
}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
|
||||
def log_gig(body: dict = Body(...)):
|
||||
# Called by the runner ONLY when the set completed — an abandoned set
|
||||
# never logs (no fail state; the gig you finished is the gig you
|
||||
# played). Accuracies come from song_stats, freshly written by the
|
||||
# set's own plays.
|
||||
inst = str((body or {}).get("instrument") or "")
|
||||
genre = _genre_display((body or {}).get("genre"))
|
||||
gkey = genre.lower()
|
||||
venue_id = str((body or {}).get("venue_id") or "")
|
||||
songs = (body or {}).get("songs")
|
||||
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||
raise HTTPException(400, "Unknown instrument.")
|
||||
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||
raise HTTPException(400, "Provide a genre.")
|
||||
if venue_id and (not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None):
|
||||
raise HTTPException(400, "Unknown venue.")
|
||||
if (not isinstance(songs, list) or not songs or len(songs) > 8
|
||||
or not all(isinstance(f, str) and f.strip() for f in songs)):
|
||||
raise HTTPException(400, "songs must be 1-8 filenames.")
|
||||
db = _state["meta_db"]
|
||||
entries = []
|
||||
accuracies = []
|
||||
for filename in songs:
|
||||
title = filename
|
||||
accuracy = None
|
||||
if db is not None:
|
||||
# The NEWEST row is the set's own just-recorded play — a
|
||||
# MAX(last_accuracy) across arrangements would happily log a
|
||||
# stale higher score from another instrument's old session.
|
||||
row = db.conn.execute(
|
||||
"SELECT last_accuracy FROM song_stats WHERE filename = ? "
|
||||
"ORDER BY last_played_at DESC LIMIT 1",
|
||||
(filename,)).fetchone()
|
||||
if row and row[0] is not None:
|
||||
accuracy = round(float(row[0]), 4)
|
||||
accuracies.append(accuracy)
|
||||
trow = db.conn.execute(
|
||||
"SELECT title FROM songs WHERE filename = ?", (filename,)).fetchone()
|
||||
if trow and trow[0]:
|
||||
title = trow[0]
|
||||
entries.append({"filename": filename, "title": title, "accuracy": accuracy})
|
||||
# Encore needs the WHOLE set scored at the bar — one scored song must
|
||||
# not earn an encore for a set that was 4/5 unheard.
|
||||
encore = (len(accuracies) == len(songs) and
|
||||
sum(accuracies) / len(accuracies) >= _gig_config()["encore_accuracy"])
|
||||
gig = {
|
||||
"at": _now_iso(),
|
||||
"venue_id": venue_id or None,
|
||||
"instrument": inst,
|
||||
"genre": genre,
|
||||
"genre_key": gkey,
|
||||
"songs": entries,
|
||||
"encore": encore,
|
||||
}
|
||||
with _lock:
|
||||
st = _career_state()
|
||||
if not isinstance(st.get("gigs"), list):
|
||||
st["gigs"] = []
|
||||
st["gigs"].append(gig)
|
||||
# ponytail: hard cap — nothing reads past the last 20 per
|
||||
# passport; the state file must not grow (and export) forever.
|
||||
st["gigs"] = st["gigs"][-500:]
|
||||
_save_json(_state_file(), st)
|
||||
return {"ok": True, "gig": gig}
|
||||
|
||||
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
||||
def start_download(venue_id: str):
|
||||
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
||||
|
||||
+457
-43
@@ -12,6 +12,10 @@
|
||||
'use strict';
|
||||
|
||||
const API = '/api/plugins/career';
|
||||
// Unpacking a setlist is real work (zips, possibly on a slow/network drive),
|
||||
// so this is generous — but it is a CEILING, not a wait. Past it we start the
|
||||
// gig and let the first play extract lazily, as it always did.
|
||||
const PREPARE_TIMEOUT_MS = 60000;
|
||||
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
|
||||
const NO_VENUE = '__none__';
|
||||
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
|
||||
@@ -38,6 +42,8 @@
|
||||
let _ppCeremonyActive = false;
|
||||
let _ppBootstrapped = false;
|
||||
let _ppNotified = {}; // badges chimed this session (slam still pending)
|
||||
let _ppGigProposal = null; // the booking poster's proposal, while open
|
||||
let _ppGigRun = null; // {songs, venue_id, genre, genre_key, instrument, idx} mid-set
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
@@ -302,11 +308,15 @@
|
||||
} catch (_) { return {}; }
|
||||
}
|
||||
|
||||
function badgeId(inst, gkey) { return inst + '/' + gkey; }
|
||||
// Bronze keeps the legacy un-suffixed id, so badges seen before the Gold
|
||||
// tier existed stay seen; gold is a distinct moment with its own id.
|
||||
function badgeId(inst, gkey, tier) { return inst + '/' + gkey + (tier === 'gold' ? '@gold' : ''); }
|
||||
|
||||
function markBadgeSeen(inst, gkey) {
|
||||
function markBadgeSeen(inst, gkey, tier) {
|
||||
const seen = seenBadges();
|
||||
seen[badgeId(inst, gkey)] = 1;
|
||||
seen[badgeId(inst, gkey, tier)] = 1;
|
||||
// A gold slam covers the bronze moment too — never queue both.
|
||||
if (tier === 'gold') seen[badgeId(inst, gkey)] = 1;
|
||||
lsSet(PP_SEEN_KEY, JSON.stringify(seen));
|
||||
}
|
||||
|
||||
@@ -318,15 +328,19 @@
|
||||
const seen = seenBadges();
|
||||
for (const inst of Object.keys(view.instruments || {})) {
|
||||
for (const p of (view.instruments[inst].passports || [])) {
|
||||
const id = badgeId(inst, p.genre_key);
|
||||
if (p.badge !== 'earned' || seen[id] || _ppNotified[id]) continue;
|
||||
if (p.badge !== 'earned' && p.badge !== 'gold') continue;
|
||||
const gold = p.badge === 'gold';
|
||||
const id = badgeId(inst, p.genre_key, p.badge);
|
||||
if (seen[id] || _ppNotified[id]) continue;
|
||||
_ppNotified[id] = true;
|
||||
sfx('chime');
|
||||
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
|
||||
window.fbNotify.show({
|
||||
big: true, icon: '🛂', accent: '#b45309',
|
||||
title: 'Badge earned!',
|
||||
message: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
|
||||
big: true, icon: gold ? '🏅' : '🛂', accent: gold ? '#d9a253' : '#b45309',
|
||||
title: gold ? 'Gold — a verified improv!' : 'Badge earned!',
|
||||
message: gold
|
||||
? `${p.genre} — your ${ppLabel(inst)} badge turns gold.`
|
||||
: `${p.genre} — Bronze, ready to stamp into your ${ppLabel(inst)} passport.`,
|
||||
});
|
||||
}
|
||||
badgeCeremony(inst, p);
|
||||
@@ -375,11 +389,11 @@
|
||||
el.innerHTML = `
|
||||
<canvas class="pp-confetti"></canvas>
|
||||
<div class="pp-ceremony-card">
|
||||
<div class="pp-stamp pp-stamp-page pp-ceremony-stamp" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
<div class="pp-stamp pp-stamp-page pp-ceremony-stamp${p.badge === 'gold' ? ' pp-stamp-gold' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-stamp-tier">BRONZE</span>
|
||||
<span class="pp-stamp-tier">${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</span>
|
||||
</div>
|
||||
<div class="pp-ceremony-title">Badge earned</div>
|
||||
<div class="pp-ceremony-title">${p.badge === 'gold' ? 'Gold — a verified improv' : 'Badge earned'}</div>
|
||||
<div class="pp-ceremony-sub">${esc(p.genre)} — ${esc(ppLabel(inst))} passport</div>
|
||||
</div>`;
|
||||
let timer = 0;
|
||||
@@ -443,7 +457,11 @@
|
||||
fetch(`${API}/drill-state`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: snap.mode, xp: snap.xp, byNode: snap.byNode }),
|
||||
body: JSON.stringify({
|
||||
mode: snap.mode, xp: snap.xp, byNode: snap.byNode,
|
||||
...(snap.goldImprov && typeof snap.goldImprov === 'object' && !Array.isArray(snap.goldImprov)
|
||||
? { goldImprov: snap.goldImprov } : {}),
|
||||
}),
|
||||
}).then(() => refreshPassports()).catch(() => { /* next event retries */ });
|
||||
}, 1500);
|
||||
}
|
||||
@@ -481,9 +499,9 @@
|
||||
|
||||
function ppCoverHTML(inst, p) {
|
||||
const rot = ppJitter(inst + p.genre_key, 1.6).toFixed(2);
|
||||
const earned = p.badge === 'earned';
|
||||
const earned = p.badge === 'earned' || p.badge === 'gold';
|
||||
const stamp = earned
|
||||
? `<span class="pp-stamp pp-stamp-mini" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">BRONZE</span>`
|
||||
? `<span class="pp-stamp pp-stamp-mini${p.badge === 'gold' ? ' pp-stamp-gold' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 8).toFixed(1)}deg">${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</span>`
|
||||
: '';
|
||||
const stubs = p.qualifying_count === 1 ? '1 stub' : `${p.qualifying_count} stubs`;
|
||||
const hours = fmtHours(p.seconds_total);
|
||||
@@ -597,7 +615,7 @@
|
||||
const data = (_pp.instruments || {})[inst] || { passports: [] };
|
||||
host.innerHTML = ((_pp.config || {}).instruments || []).map((i) => {
|
||||
const d = (_pp.instruments || {})[i] || {};
|
||||
const earned = (d.passports || []).filter((p) => p.badge === 'earned').length;
|
||||
const earned = (d.passports || []).filter((p) => p.badge === 'earned' || p.badge === 'gold').length;
|
||||
const committed = !!d.committed_at;
|
||||
return `<button class="pp-inst${i === inst ? ' active' : ''}${committed ? '' : ' uncommitted'}" data-pp-inst="${esc(i)}">
|
||||
${esc(ppLabel(i))}${earned ? ` <span class="pp-inst-badges">⚡${earned}</span>` : ''}${committed ? '' : ' <span class="pp-inst-plus">+</span>'}
|
||||
@@ -639,13 +657,15 @@
|
||||
let badgeArea = '';
|
||||
if (p.badge === 'shown_not_judged') {
|
||||
badgeArea = `<div class="pp-snj">Shown, not judged — your ${esc(ppLabel(inst).toLowerCase())} repertoire speaks for itself.</div>`;
|
||||
} else if (p.badge === 'earned') {
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ' pp-tilt'}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
} else if (p.badge === 'earned' || p.badge === 'gold') {
|
||||
const gold = p.badge === 'gold';
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page${pendingSlam ? ' pp-stamp-hidden' : ' pp-tilt'}${gold ? ' pp-stamp-gold' : ''}" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg">
|
||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-stamp-tier">BRONZE</span>
|
||||
<span class="pp-stamp-tier">${gold ? 'GOLD' : 'BRONZE'}</span>
|
||||
</div>
|
||||
<div class="pp-gold-foil" aria-hidden="true">GOLD</div>
|
||||
<div class="pp-gold-note">Gold rung coming — improvise it, verified.</div>
|
||||
${gold
|
||||
? '<div class="pp-gold-foil" aria-hidden="true">GOLD</div><div class="pp-gold-note">A verified improv — the comb heard it live.</div>'
|
||||
: '<div class="pp-gold-note">Gold rung: improvise over this style in a Virtuoso jam — verified, not self-reported.</div>'}
|
||||
<div class="pp-card-actions">
|
||||
<button class="career-btn career-btn-ghost" data-pp-card="save">Save card</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-card="copy">Copy card</button>
|
||||
@@ -686,15 +706,24 @@
|
||||
`<div class="pp-nearest-row"><em>${esc(s.title)}</em> — best ${pct(s.best_accuracy)}%, ${starGl} at ${pct(s.bar_at)}%</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
let gigLog = '';
|
||||
if ((p.gigs || []).length) {
|
||||
gigLog = `<div class="pp-giglog">
|
||||
<div class="pp-giglog-head">Gigs played</div>
|
||||
${p.gigs.slice(0, 6).map((g) =>
|
||||
`<div class="pp-giglog-row">${esc((g.at || '').slice(0, 10))} · ${esc(_venueName(g.venue_id))}${g.encore ? ' · <b>encore</b>' : ''}</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="${esc(p.genre)} ${esc(ppLabel(inst))} passport">
|
||||
<div class="pp-book">
|
||||
<div class="pp-page pp-page-left">
|
||||
<div class="pp-page-head">${esc(p.genre)} — ${esc(ppLabel(inst))}</div>
|
||||
${badgeArea}${odometer}${drills}
|
||||
<button class="career-btn career-btn-primary pp-gig-book" data-pp-gig="${esc(p.genre_key)}">Book a gig</button>
|
||||
</div>
|
||||
<div class="pp-page pp-page-right">
|
||||
<div class="pp-page-head">Ticket stubs</div>
|
||||
<div class="pp-stubs">${stubsHTML}${nearest}</div>
|
||||
<div class="pp-stubs">${stubsHTML}${nearest}${gigLog}</div>
|
||||
</div>
|
||||
<div class="pp-book-cover pp-leather-${esc(inst)}">
|
||||
<span class="pp-cover-title">${esc(p.genre.toUpperCase())}</span>
|
||||
@@ -713,7 +742,8 @@
|
||||
if (!p || !overlay) return;
|
||||
_ppBook = { inst, gkey };
|
||||
_ppReturnFocus = document.activeElement;
|
||||
const pending = p.badge === 'earned' && !seenBadges()[badgeId(inst, gkey)];
|
||||
const pending = (p.badge === 'earned' || p.badge === 'gold')
|
||||
&& !seenBadges()[badgeId(inst, gkey, p.badge)];
|
||||
overlay.innerHTML = ppBookHTML(inst, p, pending);
|
||||
overlay.classList.remove('hidden');
|
||||
const close = overlay.querySelector('.pp-book-close');
|
||||
@@ -735,7 +765,7 @@
|
||||
stamp.classList.add('pp-tilt'); // freshly slammed = trading card too
|
||||
if (book) book.classList.add('pp-shake');
|
||||
sfx('stamp');
|
||||
markBadgeSeen(inst, gkey);
|
||||
markBadgeSeen(inst, gkey, p.badge);
|
||||
renderPassports(); // the shelf cover gains its mini-stamp
|
||||
}, 950);
|
||||
}
|
||||
@@ -743,6 +773,7 @@
|
||||
|
||||
function closeBook() {
|
||||
_ppBook = null;
|
||||
_ppGigProposal = null; // a dismissed poster is a dismissed booking
|
||||
const overlay = $('pp-overlay');
|
||||
if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; }
|
||||
if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' &&
|
||||
@@ -894,6 +925,29 @@
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// One export path for every canvas artifact: copy (with download
|
||||
// fallback + notice) or save, failures audible.
|
||||
function exportCanvasPng(canvas, filename, mode, noun) {
|
||||
canvas.toBlob(async (blob) => {
|
||||
const fail = (why) => {
|
||||
if (window.fbNotify) window.fbNotify.show({ icon: '⚠️', title: `${noun} export failed`, message: why });
|
||||
};
|
||||
if (!blob) { fail('The canvas produced no image.'); return; }
|
||||
try {
|
||||
const io = await import('/static/js/blob-io.js');
|
||||
if (mode === 'copy') {
|
||||
const ok = await io.copyImageBlob(blob);
|
||||
if (ok) {
|
||||
if (window.fbNotify) window.fbNotify.show({ icon: '📋', title: `${noun} copied`, message: 'Paste it anywhere.' });
|
||||
return;
|
||||
}
|
||||
if (window.fbNotify) window.fbNotify.show({ icon: '💾', title: 'Clipboard unavailable', message: `Saved the ${noun.toLowerCase()} instead.` });
|
||||
}
|
||||
io.downloadBlob(blob, filename);
|
||||
} catch (e) { fail('Export helper unavailable.'); }
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
function exportPassportCard(mode) {
|
||||
if (!_ppBook || !_pp) return;
|
||||
const { inst, gkey } = _ppBook;
|
||||
@@ -901,25 +955,7 @@
|
||||
.find((x) => x.genre_key === gkey);
|
||||
if (!p) return;
|
||||
const canvas = drawPassportCard(inst, p);
|
||||
canvas.toBlob(async (blob) => {
|
||||
const fail = (why) => {
|
||||
if (window.fbNotify) window.fbNotify.show({ icon: '⚠️', title: 'Card export failed', message: why });
|
||||
};
|
||||
if (!blob) { fail('The canvas produced no image.'); return; }
|
||||
const filename = `passport-${inst}-${gkey.replace(/[^a-z0-9-]+/g, '-')}.png`;
|
||||
try {
|
||||
const io = await import('/static/js/blob-io.js');
|
||||
if (mode === 'copy') {
|
||||
const ok = await io.copyImageBlob(blob);
|
||||
if (ok) {
|
||||
if (window.fbNotify) window.fbNotify.show({ icon: '📋', title: 'Card copied', message: 'Paste it anywhere.' });
|
||||
return;
|
||||
}
|
||||
if (window.fbNotify) window.fbNotify.show({ icon: '💾', title: 'Clipboard unavailable', message: 'Saved the card instead.' });
|
||||
}
|
||||
io.downloadBlob(blob, filename);
|
||||
} catch (e) { fail('Export helper unavailable.'); }
|
||||
}, 'image/png');
|
||||
exportCanvasPng(canvas, `passport-${inst}-${gkey.replace(/[^a-z0-9-]+/g, '-')}.png`, mode, 'Card');
|
||||
}
|
||||
|
||||
// ── Career surfaces outside the plugin screen ─────────────────────────
|
||||
@@ -1040,6 +1076,363 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gigs: booking poster → set runner → summary ──────────────────────
|
||||
|
||||
function _venueName(venueId) {
|
||||
const v = (_state && _state.venues || []).find((x) => x.id === venueId);
|
||||
return v ? v.name : (venueId || 'the stage');
|
||||
}
|
||||
|
||||
function gigPosterHTML(prop) {
|
||||
const bill = prop.songs.map((s, i) =>
|
||||
`<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.artist ? ` <em>${esc(s.artist)}</em>` : ''}</div>`).join('');
|
||||
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="Gig poster">
|
||||
<div class="pp-poster">
|
||||
<div class="pp-poster-venue">${esc(prop.venue_name || 'The stage')}</div>
|
||||
<div class="pp-poster-presents">presents</div>
|
||||
<div class="pp-poster-title">${esc(prop.genre.toUpperCase())} NIGHT</div>
|
||||
<div class="pp-poster-inst">${esc(ppLabel(prop.instrument))} · tonight</div>
|
||||
<div class="pp-poster-bill">${bill}</div>
|
||||
<div class="pp-poster-actions">
|
||||
<button class="career-btn career-btn-primary" data-pp-gig-play="1">Play the gig</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-gig-reroll="1">Re-roll</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-poster="save">Save</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-poster="copy">Copy</button>
|
||||
</div>
|
||||
<button class="pp-book-close" data-pp-close="1" aria-label="Close">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function bookGig(gkey) {
|
||||
if (!_pp) return;
|
||||
const inst = activeInstrument();
|
||||
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
|
||||
.find((x) => x.genre_key === gkey);
|
||||
if (!p) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/gigs/propose`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ instrument: inst, genre: p.genre }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
_ppGigProposal = await res.json();
|
||||
} catch (_) { return; }
|
||||
const overlay = $('pp-overlay');
|
||||
if (!overlay) return;
|
||||
_ppBook = null; // the poster replaces the book in the overlay
|
||||
overlay.innerHTML = gigPosterHTML(_ppGigProposal);
|
||||
overlay.classList.remove('hidden');
|
||||
sfx('page');
|
||||
}
|
||||
|
||||
// Unpack the whole set before the first note.
|
||||
//
|
||||
// A feedpak is a zip, and the first play of one pays for its extraction. In
|
||||
// a set that cost landed BETWEEN songs: the player finished a number and
|
||||
// then sat there waiting for the next one to unpack, mid-gig. The setlist is
|
||||
// known up front, so warm it all while the poster is still on screen.
|
||||
//
|
||||
// Best-effort by design: a library that won't pre-extract must not stop the
|
||||
// gig from starting — the play itself surfaces the error the same way it
|
||||
// does outside a gig. Slow is better than blocked.
|
||||
async function prepareGigSongs(prop, btn) {
|
||||
const label = btn && btn.textContent;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Preparing set…'; }
|
||||
// A bare `await fetch(...)` only rejects on a network ERROR — a server
|
||||
// that accepts the connection and then never answers hangs forever, and
|
||||
// the gig would never start. That would make this optimisation the very
|
||||
// thing it promises never to be: the reason you cannot play. Give up
|
||||
// waiting and let the first play extract lazily, exactly as before.
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), PREPARE_TIMEOUT_MS);
|
||||
try {
|
||||
await fetch(`${API}/gigs/prepare`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ songs: prop.songs.map((s) => s.filename) }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} catch (_) {
|
||||
// abort, offline, non-2xx — all the same: start the gig anyway.
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
if (btn) { btn.disabled = false; if (label) btn.textContent = label; }
|
||||
}
|
||||
}
|
||||
|
||||
async function startGig(btn) {
|
||||
const prop = _ppGigProposal;
|
||||
const q = window.feedBack && window.feedBack.playQueue;
|
||||
if (!prop || !q || typeof q.start !== 'function' || typeof window.playSong !== 'function') return;
|
||||
|
||||
// Extract the setlist BEFORE the stage is borrowed and the queue starts,
|
||||
// so a failure here leaves nothing half-applied to unwind.
|
||||
await prepareGigSongs(prop, btn);
|
||||
// The poster's Play could have been cancelled while we were unpacking.
|
||||
if (_ppGigProposal !== prop) return;
|
||||
|
||||
// The gig BORROWS the stage: stash whatever venue/viz the user had so
|
||||
// the set ending gives it back (unlike "Play here", which is an
|
||||
// explicit persistent choice on the venue card).
|
||||
let restore = null;
|
||||
if (prop.venue_id) {
|
||||
// Capture the restore snapshot BEFORE any write: if a later write
|
||||
// (or setViz) throws, the stage must still be returnable.
|
||||
try {
|
||||
restore = {
|
||||
venue: localStorage.getItem(VENUE_OVERRIDE_KEY),
|
||||
viz: localStorage.getItem('vizSelection'),
|
||||
};
|
||||
} catch (_) { restore = null; }
|
||||
try {
|
||||
localStorage.setItem(VENUE_OVERRIDE_KEY, prop.venue_id);
|
||||
localStorage.setItem('vizSelection', 'venue');
|
||||
if (typeof window.setViz === 'function') window.setViz('venue');
|
||||
} catch (_) { /* viz optional — restore stays intact */ }
|
||||
}
|
||||
// Push the gig's venue pack to the crowd layer NOW.
|
||||
//
|
||||
// crowd.setManifest(venue) is reached only through pushCrowdManifest,
|
||||
// and pushCrowdManifest is called only from refresh() — the career
|
||||
// tab's own reload. A gig navigates AWAY from the career tab to the
|
||||
// player, so refresh() never runs during it, and setting the override
|
||||
// above does nothing on its own. The result the testers saw: the venue
|
||||
// visualization turns on (3D highway) but its crowd/stage pack never
|
||||
// loads, so the song plays over the bare highway backdrop ("standard
|
||||
// particles"), or over whatever venue a previous refresh() happened to
|
||||
// leave applied. We just changed the override to this gig's venue, so
|
||||
// re-push for it. _state is the career state the booking screen already
|
||||
// fetched; guard for the rare null.
|
||||
_appliedManifestVenue = null;
|
||||
if (_state) pushCrowdManifest(_state);
|
||||
_ppGigRun = {
|
||||
songs: prop.songs,
|
||||
venue_id: prop.venue_id,
|
||||
genre: prop.genre,
|
||||
genre_key: prop.genre_key,
|
||||
instrument: prop.instrument,
|
||||
idx: 0,
|
||||
restore,
|
||||
};
|
||||
closeBook();
|
||||
_ppGigProposal = null;
|
||||
// RAW filenames: the queue itself encodes for playSong — pre-encoding
|
||||
// double-encodes and breaks loading + the stats/gig filename join.
|
||||
if (!q.start(prop.songs.map((s) => s.filename), { source: 'gig' })) {
|
||||
_ppGigRun = null;
|
||||
return;
|
||||
}
|
||||
renderGigStrip();
|
||||
}
|
||||
|
||||
function restoreGigStage(run) {
|
||||
const r = run && run.restore;
|
||||
if (!r) return;
|
||||
try {
|
||||
if (r.venue == null) localStorage.removeItem(VENUE_OVERRIDE_KEY);
|
||||
else localStorage.setItem(VENUE_OVERRIDE_KEY, r.venue);
|
||||
if (r.viz && r.viz !== 'venue') {
|
||||
localStorage.setItem('vizSelection', r.viz);
|
||||
if (typeof window.setViz === 'function') window.setViz(r.viz);
|
||||
}
|
||||
} catch (_) { /* best effort */ }
|
||||
_appliedManifestVenue = null;
|
||||
}
|
||||
|
||||
function renderGigStrip() {
|
||||
if (!_ppGigRun || !document.body || typeof document.createElement !== 'function') return;
|
||||
let strip = document.getElementById('pp-gig-strip');
|
||||
if (!strip) {
|
||||
strip = document.createElement('div');
|
||||
strip.id = 'pp-gig-strip';
|
||||
strip.className = 'pp-gig-strip';
|
||||
document.body.appendChild(strip);
|
||||
}
|
||||
const run = _ppGigRun;
|
||||
const next = run.songs[run.idx + 1];
|
||||
strip.innerHTML = `<b>GIG</b> · ${esc(run.genre)} at ${esc(_venueName(run.venue_id))} · set ${Math.min(run.idx + 1, run.songs.length)}/${run.songs.length}${next ? ` — next: <em>${esc(next.title)}</em>` : ' — closer!'}`;
|
||||
}
|
||||
|
||||
function removeGigStrip() {
|
||||
const strip = document.getElementById('pp-gig-strip');
|
||||
if (strip) strip.remove();
|
||||
}
|
||||
|
||||
function abandonGig() {
|
||||
// No fail state: an abandoned set logs nothing and says nothing.
|
||||
const run = _ppGigRun;
|
||||
_ppGigRun = null;
|
||||
removeGigStrip();
|
||||
restoreGigStage(run);
|
||||
}
|
||||
|
||||
function completeGig() {
|
||||
const run = _ppGigRun;
|
||||
_ppGigRun = null;
|
||||
removeGigStrip();
|
||||
restoreGigStage(run);
|
||||
// The final song's own stats POST races this moment (both ride
|
||||
// song:ended): wait for its stats:recorded — or a short timeout, since
|
||||
// an UNSCORED play never emits one — so /gigs reads the set's real
|
||||
// accuracies, not last week's.
|
||||
const lastFile = run.songs[run.songs.length - 1].filename;
|
||||
const sm = window.feedBack;
|
||||
let done = false;
|
||||
const proceed = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (sm && typeof sm.off === 'function') { try { sm.off('stats:recorded', onRec); } catch (_) { /* ok */ } }
|
||||
postGig(run);
|
||||
};
|
||||
const onRec = (e) => {
|
||||
const d = (e && e.detail) || {};
|
||||
if (d.filename === lastFile) proceed();
|
||||
};
|
||||
if (sm && typeof sm.on === 'function') sm.on('stats:recorded', onRec);
|
||||
setTimeout(proceed, 3500);
|
||||
}
|
||||
|
||||
async function postGig(run) {
|
||||
let gig = null;
|
||||
try {
|
||||
const res = await fetch(`${API}/gigs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
instrument: run.instrument,
|
||||
genre: run.genre,
|
||||
venue_id: run.venue_id,
|
||||
songs: run.songs.map((s) => s.filename),
|
||||
}),
|
||||
});
|
||||
if (res.ok) gig = (await res.json()).gig;
|
||||
} catch (_) { /* summary still shows, unlogged */ }
|
||||
showGigSummary(run, gig);
|
||||
refreshPassports();
|
||||
}
|
||||
|
||||
function showGigSummary(run, gig) {
|
||||
if (!document.body || typeof document.createElement !== 'function') return;
|
||||
const entries = (gig && gig.songs) || run.songs.map((s) => ({ filename: s.filename, title: s.title, accuracy: null }));
|
||||
const encore = !!(gig && gig.encore);
|
||||
if (encore && !reducedMotion()) {
|
||||
const crowd = window.v3VenueCrowd;
|
||||
if (crowd && typeof crowd.celebrate === 'function') {
|
||||
try { crowd.celebrate(); } catch (_) { /* optional */ }
|
||||
}
|
||||
}
|
||||
const el = document.createElement('div');
|
||||
el.id = 'pp-gig-summary';
|
||||
el.className = 'pp-ceremony-overlay';
|
||||
el.innerHTML = `<canvas class="pp-confetti"></canvas>
|
||||
<div class="pp-poster pp-poster-summary">
|
||||
<div class="pp-poster-venue">${esc(_venueName(run.venue_id))}</div>
|
||||
<div class="pp-poster-title">${esc(run.genre.toUpperCase())} NIGHT</div>
|
||||
<div class="pp-poster-inst">${encore ? 'ENCORE! ' : ''}the set, as played</div>
|
||||
<div class="pp-poster-bill">${entries.map((s, i) =>
|
||||
`<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.accuracy != null ? ` <b>${Math.floor(s.accuracy * 100)}%</b>` : ''}</div>`).join('')}</div>
|
||||
<div class="pp-poster-actions">
|
||||
<button class="career-btn career-btn-ghost" data-pp-poster="save">Save poster</button>
|
||||
<button class="career-btn career-btn-ghost" data-pp-poster="copy">Copy poster</button>
|
||||
<button class="career-btn career-btn-primary" data-pp-gig-done="1">Done</button>
|
||||
</div>
|
||||
</div>`;
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target === el || e.target.closest('[data-pp-gig-done]')) {
|
||||
el.remove();
|
||||
} else if (e.target.closest('[data-pp-poster]')) {
|
||||
exportGigPoster(e.target.closest('[data-pp-poster]').dataset.ppPoster,
|
||||
{ ...run, encore, entries });
|
||||
}
|
||||
});
|
||||
document.body.appendChild(el);
|
||||
if (encore && !reducedMotion()) confettiBurst(el.querySelector('.pp-confetti'));
|
||||
}
|
||||
|
||||
function drawGigPoster(data) {
|
||||
const W = 480;
|
||||
const H = 640;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const bg = ctx.createLinearGradient(0, 0, 0, H);
|
||||
bg.addColorStop(0, '#141019');
|
||||
bg.addColorStop(1, '#241318');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.strokeStyle = 'rgba(217,162,83,0.5)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(16, 16, W - 32, H - 32);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.65)';
|
||||
ctx.font = '400 18px Georgia, serif';
|
||||
ctx.fillText(_venueName(data.venue_id), W / 2, 76, W - 80);
|
||||
ctx.font = '400 12px Georgia, serif';
|
||||
ctx.fillText('P R E S E N T S', W / 2, 102);
|
||||
ctx.fillStyle = '#d9a253';
|
||||
ctx.font = '800 40px Georgia, serif';
|
||||
ctx.fillText(`${data.genre.toUpperCase()}`, W / 2, 160, W - 60);
|
||||
ctx.font = '800 26px Georgia, serif';
|
||||
ctx.fillText('NIGHT', W / 2, 194);
|
||||
if (data.encore) {
|
||||
ctx.fillStyle = '#f3d179';
|
||||
ctx.font = '700 16px Georgia, serif';
|
||||
ctx.fillText('— E N C O R E —', W / 2, 226);
|
||||
}
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.85)';
|
||||
ctx.font = '400 18px Georgia, serif';
|
||||
const entries = data.entries || data.songs || [];
|
||||
entries.slice(0, 6).forEach((sng, i) => {
|
||||
const acc = sng.accuracy != null ? ` · ${Math.floor(sng.accuracy * 100)}%` : '';
|
||||
ctx.fillText(`${sng.title}${acc}`, W / 2, 290 + i * 44, W - 80);
|
||||
});
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.4)';
|
||||
ctx.font = '400 13px Georgia, serif';
|
||||
ctx.fillText(`${ppLabel(data.instrument || 'guitar')} · fee[dB]ack career`, W / 2, H - 42);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function exportGigPoster(mode, data) {
|
||||
exportCanvasPng(drawGigPoster(data),
|
||||
`gig-${(data.genre_key || 'set').replace(/[^a-z0-9-]+/g, '-')}.png`, mode, 'Poster');
|
||||
}
|
||||
|
||||
// Queue lifecycle: advance the strip per song; complete or abandon.
|
||||
function onGigSongLoading() {
|
||||
if (!_ppGigRun) return;
|
||||
renderGigStrip();
|
||||
}
|
||||
|
||||
function onGigSongEnded() {
|
||||
if (!_ppGigRun) return;
|
||||
const q = window.feedBack && window.feedBack.playQueue;
|
||||
if (!q || typeof q.remaining !== 'function') return;
|
||||
// Only OUR live queue counts: remaining()===0 is also true for a
|
||||
// cleared/foreign queue (a manual play silently clears the gig queue,
|
||||
// and that unrelated song's end must not log a gig).
|
||||
if (q.source && q.source() !== 'gig') { abandonGig(); return; }
|
||||
if (!q.remaining()) {
|
||||
if (q.active && q.active()) completeGig();
|
||||
else abandonGig();
|
||||
return;
|
||||
}
|
||||
_ppGigRun.idx = Math.min(_ppGigRun.idx + 1, _ppGigRun.songs.length - 1);
|
||||
renderGigStrip();
|
||||
}
|
||||
|
||||
function onGigSongStop() {
|
||||
// A deliberate quit mid-set (Escape clears the queue) abandons the
|
||||
// gig — but the LAST song's teardown also fires song:stop after
|
||||
// song:ended, so only abandon while songs genuinely remain.
|
||||
if (!_ppGigRun) return;
|
||||
const q = window.feedBack && window.feedBack.playQueue;
|
||||
const active = q && typeof q.active === 'function' ? q.active() : false;
|
||||
if (!active) abandonGig();
|
||||
}
|
||||
|
||||
function openGenre(inst, genre) {
|
||||
fetch(`${API}/passports/open`, {
|
||||
method: 'POST',
|
||||
@@ -1089,6 +1482,18 @@
|
||||
closeBook();
|
||||
return;
|
||||
}
|
||||
const gigBtn = e.target.closest('[data-pp-gig]');
|
||||
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
|
||||
if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; }
|
||||
if (e.target.closest('[data-pp-gig-reroll]')) {
|
||||
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
|
||||
return;
|
||||
}
|
||||
const posterBtn = e.target.closest('[data-pp-poster]');
|
||||
if (posterBtn && _ppGigProposal) {
|
||||
exportGigPoster(posterBtn.dataset.ppPoster, _ppGigProposal);
|
||||
return;
|
||||
}
|
||||
const cardBtn = e.target.closest('[data-pp-card]');
|
||||
if (cardBtn) {
|
||||
exportPassportCard(cardBtn.dataset.ppCard);
|
||||
@@ -1144,6 +1549,10 @@
|
||||
if (sm && typeof sm.on === 'function') {
|
||||
// New song stats can add stars → thresholds may cross mid-session.
|
||||
sm.on('stats:recorded', () => refresh());
|
||||
// Gig runner lifecycle (no-ops when no gig is live).
|
||||
sm.on('song:loading', onGigSongLoading);
|
||||
sm.on('song:ended', onGigSongEnded);
|
||||
sm.on('song:stop', onGigSongStop);
|
||||
// Virtuoso's progress emits are the drill-state relay trigger; the
|
||||
// payload is a thin delta, so the relay reads the full localStorage
|
||||
// snapshot instead (see relayDrillState).
|
||||
@@ -1151,7 +1560,9 @@
|
||||
}
|
||||
showCareerTab(lsGet(PP_TAB_KEY) === 'passports' ? 'passports' : 'venues');
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && _ppBook) closeBook();
|
||||
if (e.key !== 'Escape') return;
|
||||
const overlay = $('pp-overlay');
|
||||
if (_ppBook || (overlay && !overlay.classList.contains('hidden'))) closeBook();
|
||||
});
|
||||
// Core re-renders profile/dashboard shells (innerHTML wipe) and
|
||||
// announces the fresh mount points — same seam achievements uses.
|
||||
@@ -1165,6 +1576,9 @@
|
||||
window.__careerPassportTest = {
|
||||
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
||||
fmtHours, ppFillFraction, careerTotals, closestAskHTML,
|
||||
onGigSongEnded, onGigSongStop,
|
||||
setGigRun(r) { _ppGigRun = r; },
|
||||
getGigRun() { return _ppGigRun; },
|
||||
setView(v) { _pp = v; },
|
||||
};
|
||||
|
||||
|
||||
@@ -177,3 +177,73 @@ test('careerTotals / wall + dash card stay absent without commitment', () => {
|
||||
assert.equal(totals.seconds, 3720);
|
||||
assert.equal(totals.walls.length, 1);
|
||||
});
|
||||
|
||||
test('gig runner lifecycle: advance on ended, abandon on dead-queue stop', () => {
|
||||
const w = load();
|
||||
const t = w.__careerPassportTest;
|
||||
let remaining = 1;
|
||||
w.feedBack = { playQueue: { remaining: () => remaining, active: () => remaining > 0 } };
|
||||
t.setGigRun({
|
||||
songs: [{ filename: 'a', title: 'A' }, { filename: 'b', title: 'B' }],
|
||||
venue_id: null, genre: 'Soul', genre_key: 'soul', instrument: 'guitar', idx: 0,
|
||||
});
|
||||
// First song ends, one remains → the strip advances, no completion.
|
||||
t.onGigSongEnded();
|
||||
assert.equal(t.getGigRun().idx, 1);
|
||||
// Stop while the queue is still active (end-of-song teardown) → run survives.
|
||||
t.onGigSongStop();
|
||||
assert.notEqual(t.getGigRun(), null);
|
||||
// User quits: queue cleared → stop with a dead queue abandons (no log).
|
||||
remaining = 0;
|
||||
t.onGigSongStop();
|
||||
assert.equal(t.getGigRun(), null);
|
||||
});
|
||||
|
||||
test('a gold upgrade notifies even when the bronze moment was already seen', () => {
|
||||
// Bronze seen under the legacy un-suffixed id; the badge then turns gold.
|
||||
const w = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
|
||||
const t = w.__careerPassportTest;
|
||||
const view = { instruments: { guitar: { passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'gold' }] } } };
|
||||
t.detectNewBadges(view);
|
||||
assert.equal(w.notifications.length, 1);
|
||||
assert.match(w.notifications[0].title, /Gold/);
|
||||
// Same session: no duplicate.
|
||||
t.detectNewBadges(view);
|
||||
assert.equal(w.notifications.length, 1);
|
||||
// Gold slam seen → fresh session stays silent.
|
||||
t.markBadgeSeen('guitar', 'blues', 'gold');
|
||||
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(t.seenBadges()) });
|
||||
w2.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(w2.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('a gold slam marks the bronze moment seen too — never both ceremonies', () => {
|
||||
const w = load();
|
||||
const t = w.__careerPassportTest;
|
||||
t.markBadgeSeen('guitar', 'blues', 'gold');
|
||||
const seen = JSON.parse(JSON.stringify(t.seenBadges()));
|
||||
assert.equal(seen['guitar/blues@gold'], 1);
|
||||
assert.equal(seen['guitar/blues'], 1);
|
||||
// A later view where the badge reads 'earned' (e.g. gold state lost
|
||||
// server-side) must not replay the bronze ceremony.
|
||||
const view = { instruments: { guitar: { passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
|
||||
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(seen) });
|
||||
w2.__careerPassportTest.detectNewBadges(view);
|
||||
assert.equal(w2.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('careerTotals counts gold badges on the wall', () => {
|
||||
const t = load().__careerPassportTest;
|
||||
t.setView({
|
||||
config: { instruments: ['guitar'] },
|
||||
instruments: { guitar: { committed_at: 1, gig_count: 0, passports: [
|
||||
{ genre_key: 'blues', genre: 'Blues', badge: 'gold', seconds_total: 60 },
|
||||
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress', seconds_total: 0 },
|
||||
] } },
|
||||
});
|
||||
const totals = t.careerTotals();
|
||||
assert.equal(totals.badges, 1);
|
||||
assert.equal(totals.walls[0].earned[0].badge, 'gold');
|
||||
});
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"venue": "arena",
|
||||
"version": 1,
|
||||
"loops": {"bored": "bored.mp4", "neutral": "neutral.mp4", "engaged": "engaged.mp4", "ecstatic": "ecstatic.mp4"},
|
||||
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"},
|
||||
"intro": {"video": "intro.mp4", "audio": "arena-ambience.mp3"},
|
||||
"sfx": {"up": "sfx-up.mp3", "down": "sfx-down.mp3"}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"venue": "club",
|
||||
"version": 1,
|
||||
"loops": {"bored": "bored.mp4", "neutral": "neutral.mp4", "engaged": "engaged.mp4", "ecstatic": "ecstatic.mp4"},
|
||||
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"},
|
||||
"intro": {"video": "intro.mp4", "audio": "club-ambience.mp3"},
|
||||
"sfx": {"up": "sfx-up.mp3", "down": "sfx-down.mp3"}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -878,6 +878,146 @@ function createFolderSurface(cfg) {
|
||||
var _dragRafId = null;
|
||||
var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50;
|
||||
|
||||
// ── Windowed song lists ─────────────────────────────────────────────
|
||||
// A song list used to render EVERY song it held. On a flat 50,944-song
|
||||
// library that is one <div> with 50,938 children and ~1.3 MILLION DOM nodes
|
||||
// (~25 per row) — ~4.2 GB of renderer RSS, for a screen the user may not
|
||||
// even be looking at. It also poisons unrelated code: any
|
||||
// `document.querySelector` miss anywhere in the app must walk that whole
|
||||
// tree, which is how song_preview's per-frame menu check ended up eating
|
||||
// ~50% of the renderer and dropping the app to 2.7 fps (feedBack#965).
|
||||
//
|
||||
// So render only what is on screen. Rows are uniform height (and grid cards
|
||||
// uniform size), so the window is pure arithmetic — no per-row observers.
|
||||
// Off-window rows are represented by padding on the list itself rather than
|
||||
// spacer elements: a spacer <div> would become a grid ITEM in grid view and
|
||||
// shift the columns, whereas padding works identically for both layouts.
|
||||
var VIRTUAL_MIN = 200; // below this, render everything — no behaviour change
|
||||
var VIRTUAL_BUFFER = 6; // rows kept rendered above/below the viewport
|
||||
var _virtualCleanups = [];
|
||||
var _virtualLists = []; // repaint fns, one per live windowed list
|
||||
|
||||
// Which slice of the list is on screen. Pure arithmetic — kept separate from
|
||||
// the DOM so it can be tested directly (see tests/virtual_list.test.js).
|
||||
//
|
||||
// top : list's offset relative to the scroller viewport's top. NEGATIVE
|
||||
// once the user has scrolled the list's start above the fold.
|
||||
// rows : total ROWS (grid packs `perRow` songs into one row; list view is 1)
|
||||
//
|
||||
// Returns the song index range [start, end) to render, plus how many ROWS of
|
||||
// padding stand in for the songs above and below it.
|
||||
function _visibleWindow(top, viewportH, itemH, perRow, rows, total) {
|
||||
if (!(itemH > 0) || !(rows > 0)) return { start: 0, end: total, padRowsTop: 0, padRowsBottom: 0 };
|
||||
var firstRow = Math.max(0, Math.floor(-top / itemH) - VIRTUAL_BUFFER);
|
||||
var lastRow = Math.min(rows, Math.ceil((-top + viewportH) / itemH) + VIRTUAL_BUFFER);
|
||||
// Scrolled entirely past the list (either direction): keep one row alive
|
||||
// rather than emptying it, so the padding math stays anchored.
|
||||
if (lastRow <= firstRow) {
|
||||
firstRow = Math.min(firstRow, rows - 1);
|
||||
lastRow = firstRow + 1;
|
||||
}
|
||||
return {
|
||||
start: firstRow * perRow,
|
||||
end: Math.min(total, lastRow * perRow),
|
||||
padRowsTop: firstRow,
|
||||
padRowsBottom: Math.max(0, rows - lastRow),
|
||||
};
|
||||
}
|
||||
|
||||
function _clearVirtualLists() {
|
||||
_virtualCleanups.forEach(function (fn) { try { fn(); } catch (_) {} });
|
||||
_virtualCleanups = [];
|
||||
_virtualLists = [];
|
||||
}
|
||||
|
||||
// Fill `list` with `songs`, windowed when the list is big enough to matter.
|
||||
// `make(song)` builds one row/card.
|
||||
function _fillSongList(list, songs, make) {
|
||||
var sorted = _sortSongs(songs);
|
||||
if (sorted.length <= VIRTUAL_MIN) {
|
||||
sorted.forEach(function (s) { list.appendChild(make(s)); });
|
||||
return;
|
||||
}
|
||||
|
||||
var scroller = _getScrollEl();
|
||||
var basePadTop = parseFloat(window.getComputedStyle(list).paddingTop) || 0;
|
||||
var basePadBot = parseFloat(window.getComputedStyle(list).paddingBottom) || 0;
|
||||
|
||||
// Measure one real row once — no hardcoded row height to drift out of
|
||||
// sync with the CSS. (The list is shown before it is populated, so this
|
||||
// measures a laid-out row, not a zero-height one.)
|
||||
var probe = make(sorted[0]);
|
||||
probe.style.visibility = 'hidden';
|
||||
list.appendChild(probe);
|
||||
var probeRect = probe.getBoundingClientRect();
|
||||
var rowH = probeRect.height || 44;
|
||||
var cardW = probeRect.width || 150;
|
||||
list.removeChild(probe);
|
||||
|
||||
var GRID_GAP = 12; // matches the grid's `gap:12px`
|
||||
var raf = 0, lastStart = -1, lastEnd = -1;
|
||||
|
||||
// Recomputed on EVERY paint, not captured once: a window resize changes
|
||||
// the grid's column count, and therefore the row count and the height of
|
||||
// the padding standing in for off-window rows. paint() runs on resize, so
|
||||
// stale metrics would slice the wrong songs and mis-size the list.
|
||||
function metrics() {
|
||||
var perRow = 1, itemH = rowH;
|
||||
if (_view === 'grid') {
|
||||
perRow = Math.max(1, Math.floor((list.clientWidth + GRID_GAP) / (cardW + GRID_GAP)));
|
||||
itemH = rowH + GRID_GAP;
|
||||
}
|
||||
return { perRow: perRow, itemH: itemH, rows: Math.ceil(sorted.length / perRow) };
|
||||
}
|
||||
|
||||
function paint() {
|
||||
raf = 0;
|
||||
// Collapsed (display:none) or detached: nothing to paint, and don't
|
||||
// pay for layout on every scroll tick of a section nobody can see.
|
||||
// Forget the last window so re-showing repaints from scratch against
|
||||
// the new position rather than short-circuiting on a stale memo.
|
||||
if (!list.isConnected || list.offsetParent === null) {
|
||||
lastStart = -1; lastEnd = -1;
|
||||
return;
|
||||
}
|
||||
var m = metrics();
|
||||
// Where the list sits relative to the scroller's viewport.
|
||||
var top = list.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||
var vh = scroller.clientHeight || window.innerHeight;
|
||||
var w = _visibleWindow(top, vh, m.itemH, m.perRow, m.rows, sorted.length);
|
||||
if (w.start === lastStart && w.end === lastEnd) return; // nothing moved
|
||||
lastStart = w.start; lastEnd = w.end;
|
||||
|
||||
var frag = document.createDocumentFragment();
|
||||
for (var i = w.start; i < w.end; i++) frag.appendChild(make(sorted[i]));
|
||||
list.textContent = '';
|
||||
list.style.paddingTop = (basePadTop + w.padRowsTop * m.itemH) + 'px';
|
||||
list.style.paddingBottom = (basePadBot + w.padRowsBottom * m.itemH) + 'px';
|
||||
list.appendChild(frag);
|
||||
}
|
||||
function schedule() { if (!raf) raf = window.requestAnimationFrame(paint); }
|
||||
|
||||
scroller.addEventListener('scroll', schedule, { passive: true });
|
||||
window.addEventListener('resize', schedule);
|
||||
// Expanding or collapsing ANY section moves every list below it. Those
|
||||
// lists' windows are computed from their position, so they must repaint
|
||||
// too — otherwise they keep the window from their old position and show
|
||||
// blank padding where songs should be until the user happens to scroll.
|
||||
_virtualLists.push(schedule);
|
||||
_virtualCleanups.push(function () {
|
||||
scroller.removeEventListener('scroll', schedule);
|
||||
window.removeEventListener('resize', schedule);
|
||||
if (raf) window.cancelAnimationFrame(raf);
|
||||
});
|
||||
paint();
|
||||
}
|
||||
|
||||
// Re-window every live list — call after anything that can move them
|
||||
// vertically (a folder expanding/collapsing, a section being shown).
|
||||
function _repaintVirtualLists() {
|
||||
_virtualLists.forEach(function (fn) { try { fn(); } catch (_) {} });
|
||||
}
|
||||
|
||||
function _getScrollEl() {
|
||||
var el = _treeEl();
|
||||
while (el && el !== document.documentElement) {
|
||||
@@ -1159,8 +1299,8 @@ function createFolderSurface(cfg) {
|
||||
|
||||
var _listPopulated = open;
|
||||
function _populateList() {
|
||||
_sortSongs(folder.songs).forEach(function (s) {
|
||||
list.appendChild(_view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path));
|
||||
_fillSongList(list, folder.songs, function (s) {
|
||||
return _view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path);
|
||||
});
|
||||
(folder.children || []).forEach(function (child) {
|
||||
childrenWrap.appendChild(_folderSection(child, depth + 1));
|
||||
@@ -1195,12 +1335,18 @@ function createFolderSurface(cfg) {
|
||||
hdr.addEventListener('click', function () {
|
||||
if (_query()) return;
|
||||
var nowOpen = content.style.display === 'none';
|
||||
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
|
||||
// Show BEFORE populating: a windowed list measures a real row and the
|
||||
// scroller viewport, and both are zero while display:none.
|
||||
content.style.display = nowOpen ? '' : 'none';
|
||||
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
|
||||
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
|
||||
if (nowOpen) _openFolders.add(folder.path);
|
||||
else _openFolders.delete(folder.path);
|
||||
_storeJSON('open', [..._openFolders]);
|
||||
// This toggle moved everything below it — re-window the other lists,
|
||||
// and re-window THIS one if it was already populated (its saved
|
||||
// window was computed at its old position).
|
||||
_repaintVirtualLists();
|
||||
});
|
||||
|
||||
wrap.appendChild(hdr); wrap.appendChild(content);
|
||||
@@ -1245,8 +1391,8 @@ function createFolderSurface(cfg) {
|
||||
}
|
||||
var _populated = _unsortedOpen;
|
||||
function _populate() {
|
||||
_sortSongs(songs).forEach(function (s) {
|
||||
list.appendChild(_view === 'grid' ? _songCard(s, '') : _songRow(s, ''));
|
||||
_fillSongList(list, songs, function (s) {
|
||||
return _view === 'grid' ? _songCard(s, '') : _songRow(s, '');
|
||||
});
|
||||
}
|
||||
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
|
||||
@@ -1255,10 +1401,12 @@ function createFolderSurface(cfg) {
|
||||
hdr.addEventListener('click', function () {
|
||||
if (_query()) return;
|
||||
_unsortedOpen = list.style.display === 'none';
|
||||
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
|
||||
// Show BEFORE populating — see the folder toggle above.
|
||||
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
|
||||
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
|
||||
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
|
||||
_store(cfg.unsortedKey, String(_unsortedOpen));
|
||||
_repaintVirtualLists(); // this toggle moved every list below it
|
||||
});
|
||||
|
||||
wrap.appendChild(hdr); wrap.appendChild(list);
|
||||
@@ -1340,6 +1488,10 @@ function createFolderSurface(cfg) {
|
||||
// ── Render ──────────────────────────────────────────────────────────
|
||||
function _render() {
|
||||
_hoveredFolder = null; // DOM is rebuilt; discard any stale reference
|
||||
// Drop the scroll listeners of the previous render's windowed lists —
|
||||
// their `list` nodes are about to be detached, and a surviving listener
|
||||
// would keep painting into orphaned DOM (and leak on every re-render).
|
||||
_clearVirtualLists();
|
||||
var treeEl = _treeEl();
|
||||
if (!treeEl) return;
|
||||
var data = _filtered();
|
||||
@@ -1451,6 +1603,7 @@ function createFolderSurface(cfg) {
|
||||
|
||||
// ── Unload (lib surface) ────────────────────────────────────────────
|
||||
function _unload() {
|
||||
_clearVirtualLists(); // don't leave scroll listeners behind on teardown
|
||||
if (!cfg.searchInputId) return;
|
||||
var el = _el(cfg.searchInputId);
|
||||
if (el) el.style.maxWidth = '';
|
||||
@@ -1554,6 +1707,8 @@ function createFolderSurface(cfg) {
|
||||
init: _init,
|
||||
onScreenChanged: _onScreenChanged,
|
||||
render: _render,
|
||||
// Pure window arithmetic, exposed for tests (no DOM needed).
|
||||
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1656,6 +1811,7 @@ if (!window.__folderLibraryLib) {
|
||||
window.folderLibrary = {
|
||||
load: function (force) { return _lib.load(force); },
|
||||
unload: function () { _lib.unload(); },
|
||||
__test: _lib.__test,
|
||||
};
|
||||
|
||||
// Auto-load if folder view was already active when this script was injected.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Windowed song lists (feedBack#965).
|
||||
//
|
||||
// A song list used to render EVERY song. On a flat 50,944-song library that is
|
||||
// one div with 50,938 children and ~1.3 MILLION DOM nodes (~25 per row) —
|
||||
// ~4.2 GB of renderer RSS, for a screen the user may not even be looking at. It
|
||||
// also poisoned unrelated code: any `document.querySelector` miss anywhere in
|
||||
// the app had to walk that whole tree.
|
||||
//
|
||||
// _visibleWindow is the arithmetic that decides which slice is on screen. If it
|
||||
// is wrong the list silently shows the wrong songs, or scrolls to the wrong
|
||||
// place, so it is tested directly — the DOM glue around it is not the risky bit.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function load() {
|
||||
const window = {
|
||||
console,
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
addEventListener() {},
|
||||
getElementById() { return null; },
|
||||
querySelector() { return null; },
|
||||
querySelectorAll() { return []; },
|
||||
createElement() { return { style: {}, classList: { add() {}, remove() {}, contains() { return false; } }, addEventListener() {}, appendChild() {} }; },
|
||||
},
|
||||
addEventListener() {},
|
||||
localStorage: { getItem() { return null; }, setItem() {} },
|
||||
performance: { now: () => 0 },
|
||||
setInterval() { return 0; },
|
||||
clearInterval() {},
|
||||
requestAnimationFrame() { return 0; },
|
||||
cancelAnimationFrame() {},
|
||||
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
|
||||
innerHeight: 800,
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const ctx = vm.createContext(window);
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
|
||||
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
|
||||
return window.folderLibrary.__test;
|
||||
}
|
||||
|
||||
const { visibleWindow, VIRTUAL_BUFFER, VIRTUAL_MIN } = load();
|
||||
|
||||
// A flat 50k library in list view: 1 song per row, 44px rows, 800px viewport.
|
||||
const ROW = 44;
|
||||
const VH = 800;
|
||||
const TOTAL = 50938;
|
||||
|
||||
test('the whole point: a 50k list renders a bounded window, not 50k rows', () => {
|
||||
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
|
||||
const rendered = w.end - w.start;
|
||||
assert.ok(rendered < 60, `expected a small window, got ${rendered} rows`);
|
||||
// ~18 rows fit in 800px, plus buffer above and below.
|
||||
assert.ok(rendered >= Math.ceil(VH / ROW), 'must at least fill the viewport');
|
||||
});
|
||||
|
||||
test('at the top: starts at 0, all remaining rows are bottom padding', () => {
|
||||
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
|
||||
assert.equal(w.start, 0);
|
||||
assert.equal(w.padRowsTop, 0);
|
||||
assert.equal(w.padRowsBottom, TOTAL - w.end);
|
||||
});
|
||||
|
||||
test('scrolled into the middle: window tracks the scroll, padding adds up', () => {
|
||||
const scrolled = 10000 * ROW; // row 10,000 at the fold
|
||||
const w = visibleWindow(-scrolled, VH, ROW, 1, TOTAL, TOTAL);
|
||||
assert.equal(w.start, (10000 - VIRTUAL_BUFFER) * 1);
|
||||
assert.ok(w.end > w.start);
|
||||
// The invariant that keeps the scrollbar honest: padding rows + rendered
|
||||
// rows must account for every song, or the list changes height as you scroll.
|
||||
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
|
||||
});
|
||||
|
||||
test('at the very bottom: no bottom padding, end lands on the last song', () => {
|
||||
const rows = TOTAL;
|
||||
const scrolled = rows * ROW - VH; // scrolled to the end
|
||||
const w = visibleWindow(-scrolled, VH, ROW, 1, rows, TOTAL);
|
||||
assert.equal(w.end, TOTAL);
|
||||
assert.equal(w.padRowsBottom, 0);
|
||||
assert.equal(w.padRowsTop + (w.end - w.start), TOTAL);
|
||||
});
|
||||
|
||||
test('grid view: perRow songs collapse into one row', () => {
|
||||
const perRow = 6;
|
||||
const rows = Math.ceil(TOTAL / perRow);
|
||||
const w = visibleWindow(0, VH, 190, perRow, rows, TOTAL);
|
||||
assert.equal(w.start, 0);
|
||||
assert.equal(w.start % perRow, 0, 'a window must start on a row boundary');
|
||||
assert.ok(w.end <= TOTAL);
|
||||
assert.ok((w.end - w.start) < 200, 'grid window must stay bounded');
|
||||
});
|
||||
|
||||
test('scrolled far past the list: keeps one row, never a negative window', () => {
|
||||
const w = visibleWindow(-99999999, VH, ROW, 1, TOTAL, TOTAL);
|
||||
assert.ok(w.end > w.start, 'window must never invert');
|
||||
assert.ok(w.start >= 0 && w.end <= TOTAL);
|
||||
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
|
||||
});
|
||||
|
||||
test('list not yet scrolled to (below the fold): still yields a valid window', () => {
|
||||
const w = visibleWindow(5000, VH, ROW, 1, TOTAL, TOTAL); // list starts below viewport
|
||||
assert.equal(w.start, 0);
|
||||
assert.ok(w.end > 0);
|
||||
});
|
||||
|
||||
test('degenerate inputs fall back to rendering everything, never to a broken window', () => {
|
||||
// Measured height of 0 (e.g. list still display:none) must not divide by zero
|
||||
// and must not silently render an empty list.
|
||||
const w = visibleWindow(0, VH, 0, 1, TOTAL, TOTAL);
|
||||
assert.equal(w.start, 0);
|
||||
assert.equal(w.end, TOTAL);
|
||||
assert.equal(w.padRowsTop, 0);
|
||||
assert.equal(w.padRowsBottom, 0);
|
||||
});
|
||||
|
||||
test('small lists are below the virtualization threshold', () => {
|
||||
assert.ok(VIRTUAL_MIN >= 100, 'threshold must be high enough that normal folders are untouched');
|
||||
});
|
||||
|
||||
// ── the grid must be re-measured when the window resizes (CodeRabbit, #967) ──
|
||||
// perRow and rows were originally captured once at fill time. paint() also runs
|
||||
// on resize, so a narrower/wider window changed the column count while the
|
||||
// window maths still used the OLD one — slicing the wrong songs and mis-sizing
|
||||
// the padding. These pin that the geometry is a function of perRow, so a stale
|
||||
// perRow cannot silently survive.
|
||||
|
||||
test('resizing the grid to fewer columns re-windows against the new row count', () => {
|
||||
const total = 10000;
|
||||
const wide = visibleWindow(0, VH, 190, 6, Math.ceil(total / 6), total);
|
||||
const narrow = visibleWindow(0, VH, 190, 3, Math.ceil(total / 3), total);
|
||||
|
||||
// Same viewport, half the columns -> about half as many songs on screen.
|
||||
assert.ok(narrow.end < wide.end, 'fewer columns must render fewer songs per screen');
|
||||
// ...and the total must still add up, or the scrollbar lies after a resize.
|
||||
for (const [w, perRow] of [[wide, 6], [narrow, 3]]) {
|
||||
const rows = Math.ceil(total / perRow);
|
||||
assert.equal(w.padRowsTop + Math.ceil((w.end - w.start) / perRow) + w.padRowsBottom, rows,
|
||||
`rows must account for every song at perRow=${perRow}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a stale perRow would break the total-height invariant (the bug)', () => {
|
||||
const total = 10000;
|
||||
// Grid re-laid out to 3 columns, but windowed with the OLD perRow of 6:
|
||||
// the row count no longer matches the geometry, and the padding is wrong.
|
||||
const stalePerRow = 6, actualRows = Math.ceil(total / 3);
|
||||
const bad = visibleWindow(0, VH, 190, stalePerRow, actualRows, total);
|
||||
const accounted = bad.padRowsTop + Math.ceil((bad.end - bad.start) / 3) + bad.padRowsBottom;
|
||||
assert.notEqual(accounted, actualRows,
|
||||
'this asserts the FAILURE mode: mismatched perRow/rows must not silently look correct — ' +
|
||||
'metrics() recomputes both together on every paint so this cannot happen in practice');
|
||||
});
|
||||
|
||||
test('scrolled grid window always starts on a row boundary', () => {
|
||||
const total = 10000, perRow = 4;
|
||||
const rows = Math.ceil(total / perRow);
|
||||
const w = visibleWindow(-5000, VH, 190, perRow, rows, total);
|
||||
assert.equal(w.start % perRow, 0, 'a partial row would shift every card in the grid');
|
||||
});
|
||||
@@ -12614,8 +12614,19 @@
|
||||
const tC = now + (dt0 + dt1) * 0.5 - BEHIND;
|
||||
const b = laneBoundsFromAnchor(getChartAnchorAt(anchors, tC));
|
||||
if (!b) continue;
|
||||
const z0 = dZ(dt0) + TS * BEHIND;
|
||||
const z1 = dZ(dt1) + TS * BEHIND;
|
||||
// The lane STOPS AT THE HIT LINE (z = 0) — issue #991. The
|
||||
// slice window starts BEHIND seconds in the past, so the
|
||||
// first slices map to positive z, i.e. past the hit line
|
||||
// toward the player. Nothing is ever drawn there: notes and
|
||||
// chord frames clamp to Math.min(0, dZ(dt)), so that strip
|
||||
// is lane with nothing on it. Clamp the NEAR edge only —
|
||||
// the far edge stays at dZ(AHEAD+BEHIND)+TS*BEHIND = -AHEAD*TS,
|
||||
// aligned with the note horizon, exactly as before.
|
||||
const z0 = Math.min(0, dZ(dt0) + TS * BEHIND);
|
||||
const z1 = Math.min(0, dZ(dt1) + TS * BEHIND);
|
||||
// Slice lies entirely past the hit line -> zero length, nothing
|
||||
// to draw. Skip before the arp probe so it costs nothing.
|
||||
if (z0 === z1) continue;
|
||||
const arpSlice = (laneRailArpHsFlags && handShapesRails && handShapesRails.length)
|
||||
? arpeggioLaneOuterRailLaneSlice(
|
||||
dt0, dt1, now,
|
||||
@@ -12764,9 +12775,13 @@
|
||||
divMin = dMin;
|
||||
divMax = dMax;
|
||||
|
||||
// Same fix: extend to AHEAD+BEHIND so far edge = -AHEAD*TS.
|
||||
const laneLen = TS * (AHEAD + BEHIND);
|
||||
const zLane = -laneLen / 2 + TS * BEHIND;
|
||||
// Far edge at -AHEAD*TS (the note horizon), near edge at the
|
||||
// hit line (z = 0) — the lane does not run past it toward the
|
||||
// player, where nothing is ever drawn (#991). Spanning
|
||||
// AHEAD+BEHIND and shifting by +TS*BEHIND put the near edge at
|
||||
// +TS*BEHIND; spanning AHEAD alone keeps the same far edge.
|
||||
const laneLen = TS * AHEAD;
|
||||
const zLane = -laneLen / 2;
|
||||
const laneOp = (HWY_LANE_STRIPE_OP_BASE + highwayIntensity * HWY_LANE_STRIPE_OP_INT)
|
||||
* (_venueSceneOverride ? VENUE_LANE_OP_BOOST : 1);
|
||||
mLaneOdd.opacity = laneOp;
|
||||
@@ -12786,7 +12801,8 @@
|
||||
}
|
||||
|
||||
if (highwayIntensity > 0.05) {
|
||||
const divLen = TS * (AHEAD + BEHIND);
|
||||
// Matches the lane above: ends at the hit line (#991).
|
||||
const divLen = TS * AHEAD;
|
||||
const yPos = boardY + 0.03 * K;
|
||||
const divOp2 = 0.02 + highwayIntensity * 0.1;
|
||||
const divOpArp2 = Math.min(0.92, 0.16 + highwayIntensity * 0.42);
|
||||
@@ -12799,7 +12815,7 @@
|
||||
for (let f = fDivA; f <= fDivB; f++) {
|
||||
if (hwyLaneArpOuterDividers && (f === fDivA || f === fDivB)) continue;
|
||||
const div = pLaneDivider.get();
|
||||
div.position.set(xFret(f), yPos, dZ(0) - divLen * 0.5 + TS * BEHIND);
|
||||
div.position.set(xFret(f), yPos, -divLen * 0.5);
|
||||
div.material = mLaneDivider;
|
||||
div.scale.set(1, 1, divLen);
|
||||
div.renderOrder = 2;
|
||||
@@ -12818,8 +12834,10 @@
|
||||
|
||||
// ── Fret boundary extension lines ─────────────────────────
|
||||
if (mLaneDividerExt && fretDividersVisible) {
|
||||
const extLaneLen = TS * (AHEAD + BEHIND);
|
||||
const extZMid = -extLaneLen / 2 + TS * BEHIND;
|
||||
// Same hit-line stop as the lane (#991) — otherwise these lines
|
||||
// would be the only floor geometry still running past it.
|
||||
const extLaneLen = TS * AHEAD;
|
||||
const extZMid = -extLaneLen / 2;
|
||||
const extYPos = boardY + 0.03 * K;
|
||||
mLaneDividerExt.opacity = Math.max(0.3, 0.3 + highwayIntensity * 0.15);
|
||||
for (let f = 0; f <= NFRETS; f++) {
|
||||
@@ -15388,6 +15406,41 @@
|
||||
});
|
||||
},
|
||||
|
||||
// The host throttles paused frames to ~10 fps, on the assumption
|
||||
// that a paused chart is a static picture and re-rendering it is
|
||||
// pure waste (highway-constants._PAUSED_FRAME_INTERVAL_MS).
|
||||
//
|
||||
// That stopped being true when the venue landed. The venue backdrop
|
||||
// is a PLAYING VIDEO and the crowd reacts on its own clock, and they
|
||||
// are drawn into this same canvas as the highway — so throttling the
|
||||
// highway throttled the whole room. Pausing the song dropped the
|
||||
// venue, the crowd and the stage to 10 fps.
|
||||
//
|
||||
// Two independent sources of motion, and BOTH must keep their frames:
|
||||
//
|
||||
// • a crowd video rolling on its own clock (career venue pack), and
|
||||
// • the venue scene's own fake-depth motion — the backdrop breathes,
|
||||
// the haze drifts, warmth pulses, the shimmer moves. That is
|
||||
// Math.sin(t) in the draw loop (see _venueApplyFakeDepthMotion),
|
||||
// so it only moves while we are actually given frames, and it runs
|
||||
// with NO pack at all.
|
||||
//
|
||||
// The throttle fires whenever the CHART CLOCK is stalled — which is
|
||||
// not just a pause. A count-in and the credits/author overlay stall it
|
||||
// exactly the same way, so the venue was stuttering there too.
|
||||
//
|
||||
// With no venue at all (plain 3D highway) the paused scene really is a
|
||||
// still picture: motion mode reads 'off', we claim nothing, and the
|
||||
// throttle still saves the GPU as #654 intended.
|
||||
needsContinuousFrames() {
|
||||
if (!_isReady || _ctxLost) return false;
|
||||
for (const v of _venueCrowdVideos) {
|
||||
if (v && !v.paused && !v.ended && v.readyState >= 2) return true;
|
||||
}
|
||||
// 'off' also covers prefers-reduced-motion and "no venue scene".
|
||||
try { return _venueEffectiveMotionMode() !== 'off'; } catch (_) { return false; }
|
||||
},
|
||||
|
||||
draw(bundle) {
|
||||
if (!_isReady) return;
|
||||
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
|
||||
|
||||
@@ -1115,7 +1115,10 @@ async def startup_status_stream(request: Request):
|
||||
@app.post("/api/rescan")
|
||||
def trigger_rescan():
|
||||
"""Manually trigger a library rescan."""
|
||||
if not scan.kick_scan():
|
||||
# force=True: a manual Refresh must skip the directory-signature fast path —
|
||||
# it is the escape hatch for the one change dir mtimes can't see (a pack
|
||||
# rewritten in place under the same name).
|
||||
if not scan.kick_scan(force=True):
|
||||
return {"message": "Scan already in progress"}
|
||||
return {"message": "Rescan started"}
|
||||
|
||||
@@ -1133,7 +1136,7 @@ def trigger_full_rescan():
|
||||
# delete_missing() prunes anything genuinely gone at the end.
|
||||
meta_db.conn.execute("UPDATE songs SET mtime = -1")
|
||||
meta_db.conn.commit()
|
||||
if not scan.kick_scan():
|
||||
if not scan.kick_scan(force=True):
|
||||
return {"message": "Scan already in progress"}
|
||||
return {"message": "Full rescan started"}
|
||||
|
||||
|
||||
+27
-2
@@ -1334,12 +1334,25 @@ if (window.feedBack) window.feedBack.closeCurrentSong = closeCurrentSong;
|
||||
// leaving the player still leaves — and abandons the queue.
|
||||
window.feedBack.playQueue = (function () {
|
||||
let list = [], idx = -1, source = '', arrangements = null;
|
||||
// Set true by _play() right before it drives playSong, consumed once by
|
||||
// playSong's clear-guard. The primary "don't clear the queue I'm driving"
|
||||
// signal is options.fromQueue, but a chain of plugin playSong wrappers
|
||||
// (nam_tone, midi_amp, fretboard, invert_highway, tabview, ...) forward only
|
||||
// (filename, arrangement) and silently drop the options object — so the flag
|
||||
// never arrived and the queue cleared itself the instant its first song
|
||||
// started (a gig/album/playlist never advanced). This flag rides beside the
|
||||
// wrapper chain, not through it.
|
||||
let _internalPlay = false;
|
||||
const active = () => idx >= 0 && idx < list.length;
|
||||
const hasNext = () => active() && idx < list.length - 1;
|
||||
function clear() { list = []; idx = -1; source = ''; arrangements = null; }
|
||||
function _play(i) {
|
||||
const fn = list[i];
|
||||
// fromQueue keeps the queue from clearing itself; playSong decodeURIs.
|
||||
// fromQueue is the in-band signal; _internalPlay is the out-of-band one
|
||||
// that survives wrapper chains dropping the options arg. Both set; either
|
||||
// suffices. playSong runs its clear-guard synchronously at entry, and the
|
||||
// wrapper chain reaches it synchronously, so the flag is still set then.
|
||||
_internalPlay = true;
|
||||
window.playSong(encodeURIComponent(fn), arrangements ? arrangements[i] : undefined, { fromQueue: true });
|
||||
}
|
||||
function start(files, opts) {
|
||||
@@ -1371,6 +1384,15 @@ window.feedBack.playQueue = (function () {
|
||||
}
|
||||
return {
|
||||
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
|
||||
// True when the current song is a queue ADVANCE (song 2..N of a set),
|
||||
// false for its first song or a standalone play. The venue uses this to
|
||||
// fly in once on arrival at the set, then continue the room between
|
||||
// songs instead of replaying the arrival flyover every track.
|
||||
isContinuation: function () { return active() && idx > 0; },
|
||||
// One-shot: true iff _play just kicked off this playSong. Consumed on
|
||||
// read so a later MANUAL play still clears the queue. playSong calls this
|
||||
// instead of trusting options.fromQueue to survive the wrapper chain.
|
||||
_consumeInternalPlay: function () { const v = _internalPlay; _internalPlay = false; return v; },
|
||||
source: function () { return source; },
|
||||
remaining: function () { return active() ? list.length - idx - 1 : 0; },
|
||||
// What's coming, for consumers that RENDER the queue (a results
|
||||
@@ -2297,11 +2319,14 @@ configureHost({
|
||||
currentFilename: () => currentFilename,
|
||||
});
|
||||
|
||||
// `esc` is here for out-of-tree plugins only: their screen.js loads as a classic
|
||||
// script and called esc() back when app.js was one too and it was an implicit
|
||||
// global. Nothing in core reads window.esc — import it from ./js/dom.js instead.
|
||||
Object.assign(window, {
|
||||
_confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl,
|
||||
_librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal,
|
||||
changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop,
|
||||
deleteSelectedLoop, exportDiagnostics, exportSettings, filterFavorites,
|
||||
deleteSelectedLoop, esc, exportDiagnostics, exportSettings, filterFavorites,
|
||||
filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput,
|
||||
hideScanBanner, importSettings, loadPlugins, loadSavedLoop,
|
||||
loadSettings, onSectionPracticeModeChange, openEditModal, persistSetting,
|
||||
|
||||
+36
-1
@@ -986,6 +986,22 @@ function createHighway() {
|
||||
// inline arrow function.
|
||||
function _handleAsyncInitFailure(e) {
|
||||
if (hwState._renderer !== _installedRenderer) return;
|
||||
// ...and ignore a rejection from a SUPERSEDED init cycle.
|
||||
//
|
||||
// A renderer mints a fresh readyPromise on every init(), and
|
||||
// rejects the previous one ("superseded") when a newer init
|
||||
// starts. The renderer object is unchanged, so the identity
|
||||
// check above does not catch it — and we would tear down a
|
||||
// perfectly healthy renderer that is merely re-initialising.
|
||||
//
|
||||
// This is exactly what starting a gig did: setViz('venue')
|
||||
// installed the 3D renderer, then the queue's playSong()
|
||||
// re-initialised it a tick later; init #1's promise rejected,
|
||||
// and the gig dropped to the fallback 2D highway with the
|
||||
// venue gone. A superseded init is not a failed init — the
|
||||
// NEW cycle owns the outcome, and its own promise is what we
|
||||
// must judge.
|
||||
if (_installedRenderer.readyPromise !== rp) return;
|
||||
console.error('renderer async init failure:', e);
|
||||
_destroyCurrentIfInited();
|
||||
hwState._renderer = _defaultRenderer;
|
||||
@@ -1159,6 +1175,17 @@ function createHighway() {
|
||||
' (user ' + hwState._renderScale.toFixed(2) + ' / auto ' + hwState._autoScale.toFixed(2) + ')';
|
||||
}
|
||||
|
||||
// Optional renderer capability: "my picture keeps moving even when the chart
|
||||
// clock is stopped". Anything a renderer animates on its own clock (the 3D
|
||||
// highway's venue video + crowd) has to opt out of the paused-frame throttle
|
||||
// or it renders at 10 fps while the song is paused. Absent / throwing =
|
||||
// false, so every existing renderer keeps the throttle unchanged.
|
||||
function _rendererNeedsContinuousFrames() {
|
||||
const r = hwState._renderer;
|
||||
if (!r || typeof r.needsContinuousFrames !== 'function') return false;
|
||||
try { return r.needsContinuousFrames() === true; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
function draw() {
|
||||
hwState.animFrame = requestAnimationFrame(draw);
|
||||
if (!hwState.canvas || !hwState._renderer) return;
|
||||
@@ -1223,7 +1250,15 @@ function createHighway() {
|
||||
const _nowP = performance.now();
|
||||
if (_nowP - hwState._chartLastAdvanceAt > _CHART_MAX_INTERP_MS) {
|
||||
_paused = true;
|
||||
if (_nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
|
||||
// ...unless the renderer says its picture is NOT static while
|
||||
// paused. The throttle assumes a paused chart is a still frame,
|
||||
// but a renderer can own content on a clock of its own — the 3D
|
||||
// highway draws the venue's video backdrop and its reactive crowd
|
||||
// into this same canvas, so throttling the highway throttled the
|
||||
// whole room to 10 fps whenever the song was paused. Optional
|
||||
// method: renderers that don't implement it keep the throttle.
|
||||
if (!_rendererNeedsContinuousFrames()
|
||||
&& _nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
|
||||
hwState._lastPausedDrawAt = _nowP;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -638,9 +638,18 @@ export let artAbortController = null;
|
||||
export async function playSong(filename, arrangement, options) {
|
||||
console.log('playSong called:', filename);
|
||||
// A manual (non-queue) play abandons any active play-queue, so a stale queue
|
||||
// can't hijack the next song's end. The queue passes fromQueue to keep itself.
|
||||
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
|
||||
window.feedBack.playQueue.clear();
|
||||
// can't hijack the next song's end. The queue signals a play it is DRIVING
|
||||
// two ways: options.fromQueue (in-band) and _consumeInternalPlay() (out-of-
|
||||
// band). The out-of-band one exists because plugin playSong wrappers forward
|
||||
// only (filename, arrangement) and drop the options object — with just the
|
||||
// in-band flag, the queue cleared itself the instant its first song played
|
||||
// and a gig never advanced. Consume the flag whether or not we go on to clear,
|
||||
// so it can't leak into a later manual play.
|
||||
const _pq = window.feedBack && window.feedBack.playQueue;
|
||||
const _queueDriven = (options && options.fromQueue)
|
||||
|| (_pq && typeof _pq._consumeInternalPlay === 'function' && _pq._consumeInternalPlay());
|
||||
if (!_queueDriven && _pq) {
|
||||
_pq.clear();
|
||||
}
|
||||
if (!options || options.bridge !== false) {
|
||||
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
|
||||
|
||||
@@ -133,6 +133,11 @@
|
||||
let _lastStingerAt = -Infinity;
|
||||
let _prevStreak = 0;
|
||||
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
|
||||
// Filename of the song song:loaded last reported. An arrangement switch
|
||||
// re-emits song:loaded for the SAME file (changeArrangement reloads through
|
||||
// the normal load path), and that must not be mistaken for arriving at the
|
||||
// venue with a new song — see onSongLoaded.
|
||||
let _lastSongFile = '';
|
||||
let _bound = false;
|
||||
|
||||
function now() { return Date.now(); }
|
||||
@@ -478,10 +483,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onSongLoaded() {
|
||||
// song:loaded for the SAME file is an arrangement switch, not an arrival at
|
||||
// the venue. changeArrangement() reloads through the normal load path, so
|
||||
// the event is indistinguishable from a fresh load except by filename.
|
||||
function isArrangementSwitch(prevFile, nextFile) {
|
||||
return !!nextFile && nextFile === prevFile;
|
||||
}
|
||||
|
||||
function onSongLoaded(song) {
|
||||
const file = String((song && song.filename) || '');
|
||||
const sameSong = isArrangementSwitch(_lastSongFile, file);
|
||||
_lastSongFile = file;
|
||||
|
||||
machine.reset();
|
||||
_prevStreak = 0;
|
||||
_lastAccuracyPct = null;
|
||||
|
||||
// Switching arrangement is NOT arriving at the venue.
|
||||
//
|
||||
// changeArrangement() reloads the song through the same path as a fresh
|
||||
// load, so highway.js emits song:loaded again — same filename, new
|
||||
// arrangement. Treated as a new song, that replayed the arrival flyover:
|
||||
// the camera flew in from the back of the room again mid-set, every time
|
||||
// the player switched from lead to rhythm. The player is already on
|
||||
// stage; the room should just carry on.
|
||||
//
|
||||
// So keep the video pipeline running and only re-sync the mood: the
|
||||
// performance restarts, so the loop must follow the reset machine (a
|
||||
// quiet crossfade), never the intro.
|
||||
if (sameSong) {
|
||||
if (_venueActive && _manifest && !_introActive) showLoop(machine.current, FADE_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
// A genuinely different song — full teardown.
|
||||
// Abort any stinger/pending state from the previous song: its ended
|
||||
// handler must not fade back into the old song's layers.
|
||||
cancelFade();
|
||||
@@ -494,7 +529,27 @@
|
||||
_loadingLoop = null;
|
||||
_fadingLoop = null;
|
||||
if (_venueActive && _manifest) {
|
||||
if (!playIntro()) showLoop(machine.current, FADE_MS);
|
||||
// The flyover is ARRIVING at the venue, and you arrive once. Songs
|
||||
// 2..N of a set (a gig / album / playlist) are a NEW song but the
|
||||
// SAME arrival — the camera should not fly in from the back of the
|
||||
// room before every track (tester: "it showed the flyover intro
|
||||
// again" on a gig's second song). Continue the room to the new song's
|
||||
// loop; only a first-song / standalone arrival flies in.
|
||||
if (_isSetContinuation()) showLoop(machine.current, FADE_MS);
|
||||
else if (!playIntro()) showLoop(machine.current, FADE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// Is this song load a continuation of a play queue (a set already in
|
||||
// progress), rather than an arrival? True for song 2..N of a gig/album/
|
||||
// playlist. The queue owns the answer; treat any error / absent queue as
|
||||
// "not a continuation" so a standalone play still flies in.
|
||||
function _isSetContinuation() {
|
||||
try {
|
||||
const q = window.feedBack && window.feedBack.playQueue;
|
||||
return !!(q && typeof q.isContinuation === 'function' && q.isContinuation());
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,6 +706,7 @@
|
||||
bindRuntime,
|
||||
getState,
|
||||
celebrate,
|
||||
isArrangementSwitch,
|
||||
};
|
||||
|
||||
if (root) root.v3VenueCrowd = api;
|
||||
|
||||
@@ -18,6 +18,30 @@
|
||||
let _lastMood = 'idle';
|
||||
let _bound = false;
|
||||
|
||||
// The venue belongs to the SONG player and nowhere else.
|
||||
//
|
||||
// isVenueViz() only answers "is Venue the selected visualization" — a global
|
||||
// preference. It says nothing about what is on screen. Other surfaces borrow
|
||||
// the same highway_3d renderer (Virtuoso runs its practice charts on it), so
|
||||
// with Venue selected they inherited the venue backdrop: the crowd and the
|
||||
// stage showed up behind a chromatic exercise. The viz picker is a
|
||||
// preference for the player; it is not a licence to paint the venue over
|
||||
// whatever else happens to be using the renderer.
|
||||
//
|
||||
// So gate on both: Venue selected AND the player screen is the one showing.
|
||||
function isPlayerScreen() {
|
||||
try {
|
||||
const active = document.querySelector('.screen.active');
|
||||
return !!active && active.id === 'player';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldBeActive() {
|
||||
return isVenueViz() && isPlayerScreen();
|
||||
}
|
||||
|
||||
function isVenueViz() {
|
||||
if (root && root.v3VenueViz && typeof root.v3VenueViz.isVenueVisualization === 'function') {
|
||||
const sel = root.v3VenueViz.getSelectedVizId
|
||||
@@ -146,7 +170,8 @@
|
||||
|
||||
function syncViz(vizId) {
|
||||
const id = String(vizId || '');
|
||||
if (id === 'venue') {
|
||||
// Venue selected is necessary but not sufficient — see shouldBeActive.
|
||||
if (id === 'venue' && isPlayerScreen()) {
|
||||
activate();
|
||||
} else {
|
||||
deactivate();
|
||||
@@ -192,12 +217,19 @@
|
||||
if (_active) syncInstrumentPov();
|
||||
});
|
||||
sm.on('viz:renderer:ready', () => {
|
||||
if (isVenueViz()) activate();
|
||||
if (shouldBeActive()) activate();
|
||||
else deactivate();
|
||||
});
|
||||
sm.on('viz:reverted', () => deactivate());
|
||||
// Leaving the player tears the venue down; coming back rebuilds it.
|
||||
// Without this the backdrop followed the renderer onto every other
|
||||
// surface that borrows it (Virtuoso's practice highway).
|
||||
sm.on('screen:changed', () => {
|
||||
if (shouldBeActive()) activate();
|
||||
else deactivate();
|
||||
});
|
||||
}
|
||||
if (isVenueViz()) activate();
|
||||
if (shouldBeActive()) activate();
|
||||
}
|
||||
|
||||
function getState() {
|
||||
@@ -234,6 +266,8 @@
|
||||
activate,
|
||||
deactivate,
|
||||
syncViz,
|
||||
isPlayerScreen,
|
||||
shouldBeActive,
|
||||
onAssetsLoaded,
|
||||
onAssetsFailed,
|
||||
onPerformanceState,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// The window globals are a THIRD-PARTY CONTRACT. Pin them.
|
||||
//
|
||||
// Out-of-tree plugins load their screen.js as a CLASSIC script and call these
|
||||
// as bare globals. Nothing in core reads most of them, so a call-graph scan,
|
||||
// ESLint's no-undef, and a grep all come back clean while the plugin breaks in
|
||||
// the field. This is the frontend twin of tests/test_plugin_context_contract.py
|
||||
// — same reasoning, same literal-list rule.
|
||||
//
|
||||
// This guard is retroactive: `esc` was an implicit global back when app.js was
|
||||
// a classic script, went module-scoped in a9fce29, and got carved into
|
||||
// js/dom.js in 14b4058. The re-export list at the bottom of app.js was rebuilt
|
||||
// without it, and the MIDI plugin's device list threw "esc is not defined" for
|
||||
// testers — reported as "MIDI Access denied", because the ReferenceError landed
|
||||
// in a try/catch meant for permission failures.
|
||||
//
|
||||
// WHY A LITERAL LIST AND NOT A DERIVED ONE. Deriving the expected set from
|
||||
// app.js would assert the code equals itself. The point is that a human has to
|
||||
// look at a diff and consciously agree to change the contract.
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const PLUGIN_GLOBALS = [
|
||||
'_confirmDialog', '_getArrangementNamingMode', '_libraryLocalFilename', '_librarySongArtUrl',
|
||||
'_librarySongId', '_onHeaderClick', '_onNamingModeChange', '_trapFocusInModal',
|
||||
'changeArrangement', 'checkPluginUpdates', 'clearLibFilters', 'clearLoop',
|
||||
'deleteSelectedLoop', 'esc', 'exportDiagnostics', 'exportSettings', 'filterFavorites',
|
||||
'filterLibrary', 'fullRescanLibrary', 'goFavPage', 'handleSliderInput',
|
||||
'hideScanBanner', 'importSettings', 'loadPlugins', 'loadSavedLoop',
|
||||
'loadSettings', 'onSectionPracticeModeChange', 'openEditModal', 'persistSetting',
|
||||
'pickDlcFolder', 'pinCurrentArrangementDefault', 'playSong', 'previewDiagnostics',
|
||||
'previewEditArt', 'renderGridCards', 'renderTreeInto', 'rescanLibrary',
|
||||
'retuneSong', 'saveCurrentLoop', 'saveSettings', 'seekBy',
|
||||
'setAvOffsetMs', 'setFavView', 'setInstrumentPathway', 'setLibView',
|
||||
'setLibraryProvider', 'setLoopEnd', 'setLoopStart', 'setMastery',
|
||||
'setSpeed', 'setViz', 'showScreen', 'sortFavorites',
|
||||
'sortLibrary', 'syncLibrarySong', 'toggleAllArtists', 'toggleAllFavoriteArtists',
|
||||
'toggleLibFilters', 'togglePlay', 'toggleSectionPracticePopover', 'uiPrompt',
|
||||
'updatePlugin', 'uploadSongs',
|
||||
'filterFavTreeLetter', 'filterTreeLetter', 'goFavTreePage', 'goTreePage',
|
||||
];
|
||||
|
||||
test('plugin-facing window globals are all callable', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
|
||||
const missing = await page.evaluate(
|
||||
(names) => names.filter((n) => typeof (window as any)[n] !== 'function'),
|
||||
PLUGIN_GLOBALS,
|
||||
);
|
||||
|
||||
expect(missing, `window globals plugins depend on are missing or not functions: ${missing.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
// The plugin call site that actually broke: esc() interpolated into a template
|
||||
// string. A global that exists but doesn't escape is its own bug.
|
||||
test('window.esc escapes HTML metacharacters', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
|
||||
const escaped = await page.evaluate(() => (window as any).esc('<img src=x onerror=alert(1)>'));
|
||||
expect(escaped).not.toContain('<img');
|
||||
expect(escaped).toContain('<');
|
||||
});
|
||||
@@ -51,6 +51,56 @@ test('bar venue pack ships with intro media in the plugin checkout', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('arena venue pack ships with full media in the plugin checkout', () => {
|
||||
const packDir = path.join(PLUGIN_DIR, 'venue-packs', 'arena');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8'));
|
||||
assert.equal(manifest.venue, 'arena');
|
||||
assert.deepEqual(manifest.loops, {
|
||||
bored: 'bored.mp4', neutral: 'neutral.mp4',
|
||||
engaged: 'engaged.mp4', ecstatic: 'ecstatic.mp4',
|
||||
});
|
||||
assert.deepEqual(manifest.stingers, { clap: 'clap.mp4', cheer: 'cheer.mp4' });
|
||||
assert.deepEqual(manifest.sfx, { up: 'sfx-up.mp3', down: 'sfx-down.mp3' });
|
||||
assert.equal(manifest.intro.video, 'intro.mp4');
|
||||
assert.equal(manifest.intro.audio, 'arena-ambience.mp3');
|
||||
for (const f of [
|
||||
...Object.values(manifest.loops),
|
||||
...Object.values(manifest.stingers),
|
||||
manifest.intro.video,
|
||||
manifest.intro.audio,
|
||||
manifest.sfx.up,
|
||||
manifest.sfx.down,
|
||||
]) {
|
||||
const stat = fs.statSync(path.join(packDir, f));
|
||||
assert.ok(stat.size > 0, `${f} must be present`);
|
||||
}
|
||||
});
|
||||
|
||||
test('club venue pack ships with full media in the plugin checkout', () => {
|
||||
const packDir = path.join(PLUGIN_DIR, 'venue-packs', 'club');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8'));
|
||||
assert.equal(manifest.venue, 'club');
|
||||
assert.deepEqual(manifest.loops, {
|
||||
bored: 'bored.mp4', neutral: 'neutral.mp4',
|
||||
engaged: 'engaged.mp4', ecstatic: 'ecstatic.mp4',
|
||||
});
|
||||
assert.deepEqual(manifest.stingers, { clap: 'clap.mp4', cheer: 'cheer.mp4' });
|
||||
assert.deepEqual(manifest.sfx, { up: 'sfx-up.mp3', down: 'sfx-down.mp3' });
|
||||
assert.equal(manifest.intro.video, 'intro.mp4');
|
||||
assert.equal(manifest.intro.audio, 'club-ambience.mp3');
|
||||
for (const f of [
|
||||
...Object.values(manifest.loops),
|
||||
...Object.values(manifest.stingers),
|
||||
manifest.intro.video,
|
||||
manifest.intro.audio,
|
||||
manifest.sfx.up,
|
||||
manifest.sfx.down,
|
||||
]) {
|
||||
const stat = fs.statSync(path.join(packDir, f));
|
||||
assert.ok(stat.size > 0, `${f} must be present`);
|
||||
}
|
||||
});
|
||||
|
||||
test('shell promotes the career plugin into the sidebar', () => {
|
||||
const src = fs.readFileSync(SHELL_JS, 'utf8');
|
||||
assert.match(src, /key: 'career',\s*screen: 'plugin-career'/);
|
||||
@@ -66,3 +116,30 @@ test('career screen pushes the crowd manifest with a base URL', () => {
|
||||
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
||||
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
|
||||
});
|
||||
|
||||
// feedBack#… (tester): "Venue doesn't load when starting song from passport.
|
||||
// Loads standard particles." crowd.setManifest(venue) is reached ONLY through
|
||||
// pushCrowdManifest, and pushCrowdManifest is called ONLY from refresh() (the
|
||||
// career tab's own reload). A gig navigates away from that tab, so refresh()
|
||||
// never runs during it — the venue viz turns on but its crowd/stage pack never
|
||||
// loads. startGig must push the manifest itself after setting the override.
|
||||
test('startGig pushes the crowd manifest for the gig venue', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'plugins', 'career', 'screen.js'), 'utf8');
|
||||
const start = src.indexOf('async function startGig(');
|
||||
assert.ok(start !== -1, 'startGig not found');
|
||||
const open = src.indexOf('{', src.indexOf(')', start));
|
||||
let depth = 1, i = open + 1;
|
||||
while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; }
|
||||
const fn = src.slice(start, i);
|
||||
// The override is set, then the manifest must be (re)pushed for it.
|
||||
const overrideIdx = fn.search(/VENUE_OVERRIDE_KEY,\s*prop\.venue_id/);
|
||||
const pushIdx = fn.search(/pushCrowdManifest\s*\(/);
|
||||
assert.ok(overrideIdx !== -1, 'startGig must set the venue override');
|
||||
assert.ok(pushIdx !== -1,
|
||||
'startGig must push the crowd manifest — refresh() (its only other caller) ' +
|
||||
'never runs during a gig, so the venue pack would never load');
|
||||
assert.ok(overrideIdx < pushIdx, 'the manifest must be pushed AFTER the override is set to the gig venue');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// A gig is a SET, not a run of unrelated songs.
|
||||
//
|
||||
// Reported from a live gig: the player finished the first song and had to sit
|
||||
// through the per-song results popup before the next one would start, and then
|
||||
// wait again while that song was extracted from its feedpak zip.
|
||||
//
|
||||
// This file covers the CORE half — career pre-extracts the whole setlist before
|
||||
// the first note. The other half (note_detect must not show its per-song summary
|
||||
// inside a gig) lives in the note_detect plugin repo, which is not part of this
|
||||
// checkout: plugins/*/ is gitignored here and note_detect ships from
|
||||
// feedBack-plugin-notedetect. A test reading it from core would pass on a dev
|
||||
// box (where the plugin happens to be bundled) and fail in CI, which is worse
|
||||
// than no test.
|
||||
//
|
||||
// The pre-extraction is tested for REAL behaviour — actually unpacking zips — in
|
||||
// tests/plugins/career/test_routes.py. These are the wiring guards around it.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const CAREER = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'screen.js'), 'utf8');
|
||||
const CAREER_ROUTES = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'routes.py'), 'utf8');
|
||||
|
||||
function extractBlock(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
assert.ok(start !== -1, `signature '${signature}' not found`);
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
test('startGig extracts the whole setlist before starting the queue', () => {
|
||||
const fn = extractBlock(CAREER, 'async function startGig(');
|
||||
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
|
||||
const startIdx = fn.search(/q\.start\s*\(/);
|
||||
assert.ok(prepIdx !== -1, 'startGig must pre-extract the set');
|
||||
assert.ok(startIdx !== -1, 'q.start not found');
|
||||
assert.ok(prepIdx < startIdx,
|
||||
'the set must be unpacked BEFORE the queue starts — otherwise the player ' +
|
||||
'waits between songs, which is the bug');
|
||||
});
|
||||
|
||||
test('the stage is only borrowed once the set is ready', () => {
|
||||
const fn = extractBlock(CAREER, 'async function startGig(');
|
||||
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
|
||||
const stageIdx = fn.search(/VENUE_OVERRIDE_KEY/);
|
||||
assert.ok(prepIdx < stageIdx,
|
||||
'a gig cancelled while unpacking must not leave the venue/viz overwritten');
|
||||
assert.match(fn, /_ppGigProposal\s*!==\s*prop/,
|
||||
'a proposal dismissed while unpacking must not then start a gig');
|
||||
});
|
||||
|
||||
test('pre-extraction never blocks the gig from starting', () => {
|
||||
const fn = extractBlock(CAREER, 'async function prepareGigSongs(');
|
||||
assert.match(fn, /catch\s*\(/,
|
||||
'a failed prepare must fall through to the old lazy extraction, not abort the gig');
|
||||
});
|
||||
|
||||
test('the prepare route degrades instead of failing', () => {
|
||||
assert.match(CAREER_ROUTES, /def prepare_gig/, 'prepare route missing');
|
||||
assert.match(CAREER_ROUTES, /context\.get\(\s*["']get_dlc_dir["']\s*\)/,
|
||||
'a host without the library resolvers must degrade, not 500 — pre-extraction ' +
|
||||
'is an optimisation and can never be why a gig will not start');
|
||||
});
|
||||
|
||||
// ── the prepare must never be able to BLOCK the gig (CodeRabbit, #971) ──────
|
||||
//
|
||||
// A bare `await fetch(...)` only rejects on a network error. A server that
|
||||
// accepts the connection and then never answers hangs forever — and the gig
|
||||
// would never start. That would make this optimisation the exact thing it
|
||||
// promises never to be: the reason you cannot play.
|
||||
|
||||
test('the prepare fetch is bounded — a hung server cannot block the gig', () => {
|
||||
const fn = extractBlock(CAREER, 'async function prepareGigSongs(');
|
||||
assert.match(fn, /AbortController/, 'the request must be abortable');
|
||||
assert.match(fn, /setTimeout\([\s\S]{0,40}abort\s*\(\s*\)/,
|
||||
'a hung request must be aborted, not awaited forever');
|
||||
assert.match(fn, /signal:\s*ctrl\.signal/, 'the signal must actually be passed to fetch');
|
||||
assert.match(fn, /clearTimeout/, 'the timer must be cleared on the happy path');
|
||||
assert.match(CAREER, /const\s+PREPARE_TIMEOUT_MS\s*=\s*\d+/, 'the ceiling must be named');
|
||||
// The button must be restored however we leave — otherwise a timeout strands
|
||||
// the poster on "Preparing set…" with Play disabled: unplayable.
|
||||
assert.match(fn, /finally\s*\{[\s\S]{0,220}btn\.disabled\s*=\s*false/,
|
||||
'the Play button must be re-enabled on EVERY path, including the abort');
|
||||
});
|
||||
@@ -77,3 +77,89 @@ test('throttle runs after the ready gate, before bundle/draw', () => {
|
||||
assert.ok(readyIdx < throttleIdx, 'throttle must come after the ready gate');
|
||||
assert.ok(throttleIdx < drawIdx, 'throttle must come before the renderer draw');
|
||||
});
|
||||
|
||||
// ── The throttle must not starve a renderer that animates on its own clock ──
|
||||
//
|
||||
// The throttle assumes a paused chart is a still picture, so re-rendering it is
|
||||
// waste. That stopped being true when the venue landed: the 3D highway draws the
|
||||
// venue's VIDEO backdrop and its reactive crowd into the same canvas as the
|
||||
// notes, so capping paused frames capped the whole room — pausing the song
|
||||
// dropped the venue to ~10 fps ("everything around the highway drops fps").
|
||||
//
|
||||
// Renderers now opt out via an optional needsContinuousFrames(). Absent or
|
||||
// throwing must mean false, so every other renderer keeps the throttle.
|
||||
|
||||
test('paused throttle defers to a renderer that needs continuous frames', () => {
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function draw()');
|
||||
assert.match(fn, /_rendererNeedsContinuousFrames\s*\(\s*\)/,
|
||||
'the paused throttle must consult the renderer capability');
|
||||
// The capability must GATE the early-return, not merely be called near it:
|
||||
// the throttle only applies when the renderer does NOT need every frame.
|
||||
assert.match(
|
||||
fn,
|
||||
/!\s*_rendererNeedsContinuousFrames\s*\(\s*\)[\s\S]{0,160}_PAUSED_FRAME_INTERVAL_MS[\s\S]{0,40}return;/,
|
||||
'throttle must be skipped when the renderer needs continuous frames',
|
||||
);
|
||||
});
|
||||
|
||||
test('the capability probe fails closed (absent / non-function / throwing)', () => {
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _rendererNeedsContinuousFrames()');
|
||||
assert.match(fn, /typeof\s+r\.needsContinuousFrames\s*!==\s*'function'[\s\S]{0,40}return false/,
|
||||
'a renderer without the method must keep the throttle');
|
||||
assert.match(fn, /catch[\s\S]{0,40}return false/,
|
||||
'a throwing renderer must keep the throttle, not crash the draw loop');
|
||||
assert.match(fn, /===\s*true/,
|
||||
'only an explicit true opts out — a truthy accident must not disable the throttle');
|
||||
});
|
||||
|
||||
test('3D highway claims continuous frames for BOTH sources of venue motion', () => {
|
||||
const h3d = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8');
|
||||
const fn = extractBlock(h3d, 'needsContinuousFrames()');
|
||||
// (1) a crowd video rolling on its own clock (career venue pack)
|
||||
assert.match(fn, /_venueCrowdVideos/, 'must key off the actual crowd video elements');
|
||||
assert.match(fn, /\.paused/, 'a paused video is a still frame');
|
||||
// (2) the venue scene's OWN fake-depth motion — backdrop breathe, haze drift,
|
||||
// warmth pulse, shimmer. Math.sin(t) in the draw loop, so it only moves while
|
||||
// we get frames, and it runs with NO pack at all. Missing this meant the venue
|
||||
// still stuttered on pause / count-in / credits whenever no video was rolling.
|
||||
assert.match(fn, /_venueEffectiveMotionMode\s*\(\s*\)\s*!==\s*'off'/,
|
||||
'the venue scene animates without any video — it must claim frames too');
|
||||
// ...and with no venue at all the paused scene IS static: the #654 GPU saving
|
||||
// must survive, so the method has to be able to return false.
|
||||
assert.match(fn, /return false;/, 'must fall through to false on a plain 3D highway');
|
||||
});
|
||||
|
||||
// ── a SUPERSEDED init is not a FAILED init ──────────────────────────────────
|
||||
//
|
||||
// Starting a gig dropped the player onto the fallback 2D highway with no venue.
|
||||
//
|
||||
// setViz('venue') installs the 3D renderer, whose init is async; the gig then
|
||||
// immediately starts its play queue, and playSong() re-initialises that same
|
||||
// renderer a tick later. A renderer mints a fresh readyPromise per init() and
|
||||
// rejects the previous one with "superseded" — but highway.js only checked that
|
||||
// the RENDERER object was unchanged, which it is. So it treated a healthy
|
||||
// re-initialising renderer as a failed one, tore it down, and reverted to 2D:
|
||||
//
|
||||
// renderer async init failure: Error: superseded
|
||||
// viz picker: reverted to default renderer (async-init-failure)
|
||||
//
|
||||
// Reproduced and fixed against the real build (venue stays selected, scene
|
||||
// active, no viz:reverted).
|
||||
|
||||
test('a superseded readyPromise must not revert the viz to 2D', () => {
|
||||
const src = highwaySources();
|
||||
const fn = extractBlock(src, 'function _handleAsyncInitFailure(e)');
|
||||
assert.match(fn, /readyPromise\s*!==\s*rp[\s\S]{0,40}return/,
|
||||
'a rejection from a STALE readyPromise (the renderer has since re-init\'d) must be ' +
|
||||
'ignored — otherwise a re-initialising renderer is torn down as if it had failed');
|
||||
// The renderer-identity check must survive too: a rejection belonging to a
|
||||
// renderer that has since been REPLACED is also not our problem.
|
||||
assert.match(fn, /hwState\._renderer\s*!==\s*_installedRenderer[\s\S]{0,20}return/,
|
||||
'the renderer-identity guard must remain');
|
||||
// ...and a genuine failure of the CURRENT init cycle must still revert.
|
||||
assert.match(fn, /_emitVizReverted\s*\(\s*'async-init-failure'\s*\)/,
|
||||
'a real async-init failure must still fall back to the default renderer');
|
||||
});
|
||||
|
||||
@@ -49,3 +49,80 @@ test('peekNext is null after clear', () => {
|
||||
q.clear();
|
||||
assert.strictEqual(q.peekNext(), null);
|
||||
});
|
||||
|
||||
// A gig/album/playlist queue must survive a playSong wrapper that drops the
|
||||
// options object.
|
||||
//
|
||||
// The queue tells playSong "don't clear the queue I'm driving" via
|
||||
// options.fromQueue. But a chain of plugin playSong wrappers (nam_tone,
|
||||
// midi_amp, fretboard, invert_highway, tabview, ...) forward only
|
||||
// (filename, arrangement) and silently drop the 3rd arg. With just the in-band
|
||||
// flag, playSong cleared the queue the instant its first song started, so a gig
|
||||
// never advanced (feedBack#… tester: "Passports does not advance in the song
|
||||
// queue"). The queue now also raises an out-of-band flag, _consumeInternalPlay(),
|
||||
// which playSong honours regardless of the wrapper chain.
|
||||
|
||||
// The real clear-guard from session.js, driven against the queue.
|
||||
function clearGuard(win, options) {
|
||||
const pq = win.feedBack && win.feedBack.playQueue;
|
||||
const queueDriven = (options && options.fromQueue)
|
||||
|| (pq && typeof pq._consumeInternalPlay === 'function' && pq._consumeInternalPlay());
|
||||
if (!queueDriven && pq) pq.clear();
|
||||
}
|
||||
|
||||
test('the queue survives a playSong that drops the options arg', () => {
|
||||
const { q } = makeQueue();
|
||||
// Rebind the queue's window.playSong to a wrapper that forwards ONLY
|
||||
// (filename, arrangement) — exactly the plugin bug — and runs the real guard.
|
||||
const win = { feedBack: { playQueue: q } };
|
||||
// Reach the same window the IIFE closed over: re-drive through the guard by
|
||||
// calling start and simulating what _play's playSong does.
|
||||
// We can't rebind the closed-over window, so instead assert the out-of-band
|
||||
// signal directly: _play sets it, and the guard consumes it.
|
||||
q.start(['a.sloppak', 'b.sloppak', 'c.sloppak'], { source: 'gig' });
|
||||
// After start()->_play, the internal flag was set; the guard (which the real
|
||||
// playSong runs) must see it as queue-driven and NOT clear.
|
||||
win.feedBack.playQueue = q;
|
||||
clearGuard(win, undefined /* wrapper dropped options */);
|
||||
assert.strictEqual(q.active(), true, 'a dropped options arg must not clear the queue');
|
||||
assert.strictEqual(q.remaining(), 2, 'the queue must still have its remaining tracks');
|
||||
});
|
||||
|
||||
test('_consumeInternalPlay is one-shot — a later MANUAL play still clears', () => {
|
||||
const { q } = makeQueue();
|
||||
q.start(['a.sloppak', 'b.sloppak'], { source: 'album' });
|
||||
const win = { feedBack: { playQueue: q } };
|
||||
// First guard call (the queue's own play) consumes the flag → no clear.
|
||||
clearGuard(win, undefined);
|
||||
assert.strictEqual(q.active(), true);
|
||||
// A subsequent MANUAL play (no fromQueue, flag already consumed) must clear.
|
||||
clearGuard(win, undefined);
|
||||
assert.strictEqual(q.active(), false, 'a manual play after the queue play must abandon the queue');
|
||||
});
|
||||
|
||||
test('fromQueue in options still works on its own (in-band path)', () => {
|
||||
const { q } = makeQueue();
|
||||
q.start(['a.sloppak', 'b.sloppak'], { source: 'gig' });
|
||||
// consume the internal flag first so ONLY options.fromQueue is under test
|
||||
q._consumeInternalPlay();
|
||||
const win = { feedBack: { playQueue: q } };
|
||||
clearGuard(win, { fromQueue: true });
|
||||
assert.strictEqual(q.active(), true, 'options.fromQueue alone must still keep the queue');
|
||||
});
|
||||
|
||||
// isContinuation(): true for song 2..N of a set, false for the first song / a
|
||||
// standalone play. The venue uses it to fly in once on arrival, then carry the
|
||||
// room between songs instead of replaying the arrival flyover every track
|
||||
// (tester: "it showed the flyover intro again" on a gig's second song).
|
||||
test('isContinuation is false on the first song, true after advancing', () => {
|
||||
const { q } = makeQueue();
|
||||
assert.strictEqual(q.isContinuation(), false, 'idle queue is not a continuation');
|
||||
q.start(['a.sloppak', 'b.sloppak', 'c.sloppak'], { source: 'gig' });
|
||||
assert.strictEqual(q.isContinuation(), false, 'the FIRST song of a set is an arrival, not a continuation');
|
||||
q.advance();
|
||||
assert.strictEqual(q.isContinuation(), true, 'song 2 is a continuation — no re-flyover');
|
||||
q.advance();
|
||||
assert.strictEqual(q.isContinuation(), true, 'song 3 too');
|
||||
q.clear();
|
||||
assert.strictEqual(q.isContinuation(), false, 'a cleared queue is not a continuation');
|
||||
});
|
||||
|
||||
@@ -208,7 +208,7 @@ test('index.html loads venue deps before venue-scene-3d', () => {
|
||||
assert.ok(vizIdx < moodIdx && moodIdx < sceneIdx);
|
||||
});
|
||||
|
||||
test('syncViz activates only for venue visualization id', () => {
|
||||
test('syncViz activates only for venue visualization id, and only on the player', () => {
|
||||
global.h3dVenueSceneSetActive = (on) => { global._h3dActive = on; };
|
||||
global.h3dVenueSceneSetMood = (s) => { global._h3dMood = s; };
|
||||
global.h3dVenueSceneSetInstrumentPov = () => {};
|
||||
@@ -216,7 +216,14 @@ test('syncViz activates only for venue visualization id', () => {
|
||||
global.v3VenueViz = venueViz;
|
||||
global.v3VenueInstrumentPov = pov;
|
||||
global.feedBack = { on() {} };
|
||||
// The venue is scoped to the song player: selecting Venue is a preference
|
||||
// for THAT screen, not a licence to paint the venue over anything else that
|
||||
// borrows the highway_3d renderer (Virtuoso's practice charts did exactly
|
||||
// that). syncViz therefore needs to know which screen is showing.
|
||||
const onScreen = (id) => { global.document = { querySelector: (s) => (s === '.screen.active' && id ? { id } : null) }; };
|
||||
const prevDoc = global.document;
|
||||
try {
|
||||
onScreen('player');
|
||||
venueScene.deactivate();
|
||||
venueScene.syncViz('highway_3d');
|
||||
assert.equal(global._h3dActive, false);
|
||||
@@ -224,7 +231,16 @@ test('syncViz activates only for venue visualization id', () => {
|
||||
assert.equal(global._h3dActive, true);
|
||||
assert.equal(venueScene.getState().active, true);
|
||||
assert.equal(venueScene.getState().themeId, 'small-club');
|
||||
|
||||
// ...and the same call OFF the player must not activate it.
|
||||
venueScene.deactivate();
|
||||
onScreen('virtuoso');
|
||||
venueScene.syncViz('venue');
|
||||
assert.equal(global._h3dActive, false,
|
||||
'Venue selected must NOT paint the venue onto the Virtuoso highway');
|
||||
assert.equal(venueScene.getState().active, false);
|
||||
} finally {
|
||||
global.document = prevDoc;
|
||||
venueScene.deactivate();
|
||||
delete global.h3dVenueSceneSetActive;
|
||||
delete global.h3dVenueSceneSetMood;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Two venue bugs reported from a live career session.
|
||||
//
|
||||
// 1. Changing arrangement mid-song replayed the venue arrival flyover. The
|
||||
// camera flew in from the back of the room again, every time the player
|
||||
// switched lead -> rhythm. changeArrangement() reloads the song through the
|
||||
// normal load path, so highway.js re-emits `song:loaded` — same filename,
|
||||
// new arrangement — and the venue could not tell that from a fresh arrival.
|
||||
// The player is already on stage; the room should just carry on.
|
||||
//
|
||||
// 2. With Venue selected, the venue backdrop showed up on the VIRTUOSO highway.
|
||||
// The venue was gated purely on the viz selection, which is a global
|
||||
// preference and says nothing about what is on screen. Virtuoso borrows the
|
||||
// same highway_3d renderer for its practice charts, so it inherited the
|
||||
// crowd and the stage behind a chromatic exercise. The venue belongs to the
|
||||
// song player and nowhere else.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const crowd = require('../../static/v3/venue-crowd.js');
|
||||
|
||||
// ── 1. arrangement switch is not an arrival ────────────────────────────────
|
||||
|
||||
test('same filename = arrangement switch (no arrival flyover)', () => {
|
||||
// changeArrangement() re-emits song:loaded for the song already on stage.
|
||||
assert.equal(crowd.isArrangementSwitch('song.feedpak', 'song.feedpak'), true);
|
||||
});
|
||||
|
||||
test('different filename = a genuinely new song (flyover is correct)', () => {
|
||||
assert.equal(crowd.isArrangementSwitch('a.feedpak', 'b.feedpak'), false);
|
||||
});
|
||||
|
||||
test('first load of the session is an arrival, not a switch', () => {
|
||||
// No previous song -> the flyover must play.
|
||||
assert.equal(crowd.isArrangementSwitch('', 'a.feedpak'), false);
|
||||
});
|
||||
|
||||
test('a missing filename is never treated as a switch', () => {
|
||||
// Otherwise a malformed payload would silently suppress the flyover for the
|
||||
// rest of the session.
|
||||
assert.equal(crowd.isArrangementSwitch('a.feedpak', ''), false);
|
||||
assert.equal(crowd.isArrangementSwitch('a.feedpak', undefined), false);
|
||||
assert.equal(crowd.isArrangementSwitch('', ''), false);
|
||||
});
|
||||
|
||||
// ── 2. the venue belongs to the player screen ──────────────────────────────
|
||||
|
||||
const scene = require('../../static/v3/venue-scene-3d.js');
|
||||
|
||||
// Venue MUST be the selected visualization for these to mean anything: if the
|
||||
// viz were unset, shouldBeActive() would be false for the wrong reason and the
|
||||
// virtuoso assertion below would pass vacuously. Force the viz on, so the only
|
||||
// thing under test is the SCREEN gate.
|
||||
function withScreen(id, fn) {
|
||||
const prevDoc = global.document;
|
||||
const prevViz = global.v3VenueViz;
|
||||
global.v3VenueViz = {
|
||||
isVenueVisualization: (v) => String(v) === 'venue',
|
||||
getSelectedVizId: () => 'venue',
|
||||
};
|
||||
global.document = {
|
||||
querySelector(sel) {
|
||||
if (sel !== '.screen.active') return null;
|
||||
return id ? { id } : null;
|
||||
},
|
||||
};
|
||||
try { return fn(); } finally { global.document = prevDoc; global.v3VenueViz = prevViz; }
|
||||
}
|
||||
|
||||
test('guard: with Venue selected AND on the player, the venue IS active', () => {
|
||||
// If this ever fails, every "not active" test below is vacuous.
|
||||
withScreen('player', () => {
|
||||
assert.equal(scene.shouldBeActive(), true,
|
||||
'the screen gate must not break the normal case');
|
||||
});
|
||||
});
|
||||
|
||||
test('venue is active on the player screen', () => {
|
||||
withScreen('player', () => {
|
||||
assert.equal(scene.isPlayerScreen(), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('venue is NOT active on the virtuoso screen (the bug)', () => {
|
||||
withScreen('virtuoso', () => {
|
||||
assert.equal(scene.isPlayerScreen(), false,
|
||||
'Virtuoso borrows the same highway_3d renderer — the venue backdrop ' +
|
||||
'must not follow it there');
|
||||
assert.equal(scene.shouldBeActive(), false,
|
||||
'selecting Venue is a preference for the PLAYER; it is not a licence ' +
|
||||
'to paint the venue over whatever else is using the renderer');
|
||||
});
|
||||
});
|
||||
|
||||
test('venue is not active on any other screen either', () => {
|
||||
for (const id of ['v3-home', 'plugin-folder_library', 'settings', 'career']) {
|
||||
withScreen(id, () => {
|
||||
assert.equal(scene.shouldBeActive(), false, `venue must not be active on ${id}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('no active screen at all is not the player', () => {
|
||||
withScreen(null, () => {
|
||||
assert.equal(scene.isPlayerScreen(), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('a throwing document does not take the venue down with it', () => {
|
||||
const prev = global.document;
|
||||
global.document = { querySelector() { throw new Error('detached'); } };
|
||||
try {
|
||||
assert.equal(scene.isPlayerScreen(), false, 'must fail closed, not throw');
|
||||
} finally {
|
||||
global.document = prev;
|
||||
}
|
||||
});
|
||||
|
||||
// The arrival flyover must NOT replay for songs 2..N of a set. onSongLoaded
|
||||
// consults the play queue: a continuation (gig/album/playlist song 2+) carries
|
||||
// the room over with a loop crossfade, only an arrival plays the intro.
|
||||
test('a set continuation carries the room over instead of re-flying-in', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'venue-crowd.js'), 'utf8');
|
||||
const start = src.indexOf('function onSongLoaded(');
|
||||
const open = src.indexOf('{', src.indexOf(')', start));
|
||||
let depth = 1, i = open + 1;
|
||||
while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; }
|
||||
const fn = src.slice(start, i);
|
||||
const contIdx = fn.search(/_isSetContinuation\s*\(\s*\)/);
|
||||
const introIdx = fn.search(/playIntro\s*\(/);
|
||||
assert.ok(contIdx !== -1, 'onSongLoaded must consult the set-continuation signal');
|
||||
assert.ok(introIdx !== -1, 'the intro must still exist for a real arrival');
|
||||
assert.ok(contIdx < introIdx, 'the continuation check must gate the flyover — a set song 2+ must not fly in');
|
||||
});
|
||||
@@ -26,7 +26,7 @@ class FakeMetaDb:
|
||||
self.conn.execute(
|
||||
"""CREATE TABLE song_stats (
|
||||
filename TEXT, arrangement TEXT, best_accuracy REAL,
|
||||
last_played_at TEXT,
|
||||
last_accuracy REAL, last_played_at TEXT,
|
||||
seconds_total REAL NOT NULL DEFAULT 0
|
||||
)"""
|
||||
)
|
||||
@@ -38,10 +38,12 @@ class FakeMetaDb:
|
||||
)
|
||||
|
||||
def add(self, filename, arrangement, best_accuracy, in_library=True,
|
||||
genre="", arrangements=None, last_played_at=None, seconds_total=0):
|
||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?)",
|
||||
(filename, arrangement, best_accuracy, last_played_at,
|
||||
seconds_total))
|
||||
genre="", arrangements=None, last_played_at=None, seconds_total=0,
|
||||
last_accuracy=None):
|
||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(filename, arrangement, best_accuracy,
|
||||
last_accuracy if last_accuracy is not None else best_accuracy,
|
||||
last_played_at, seconds_total))
|
||||
if in_library:
|
||||
self.conn.execute(
|
||||
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
|
||||
|
||||
@@ -272,3 +272,203 @@ def test_nearest_targets_the_qualifying_bar_not_next_star(client, meta_db):
|
||||
p = _passport(client, "guitar", "soul")
|
||||
assert [s["filename"] for s in p["nearest"]] == ["one_star.feedpak", "zero_star.feedpak"]
|
||||
assert all(s["bar_at"] == 0.75 for s in p["nearest"])
|
||||
# ── Gigs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_gig_propose_mixes_owned_and_stakes(client, meta_db):
|
||||
for i in range(4):
|
||||
meta_db.add(f"own{i}.feedpak", 0, 0.85, genre="Soul", arrangements=LEAD)
|
||||
meta_db.add("stake.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD)
|
||||
meta_db.add_song_only("fresh.feedpak", genre="Soul")
|
||||
res = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Soul", "size": 4})
|
||||
assert res.status_code == 200
|
||||
gig = res.json()
|
||||
files = [s["filename"] for s in gig["songs"]]
|
||||
assert len(files) == 4
|
||||
assert "stake.feedpak" in files # a near-bar song gives the set stakes
|
||||
assert gig["venue_id"] == "bar" # 9 stars < 50: the dive bar
|
||||
|
||||
# A young passport (nothing played) still gets a playable set from the
|
||||
# library's unplayed genre songs.
|
||||
res2 = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Ska"})
|
||||
assert res2.status_code == 404 # no ska in the library at all
|
||||
meta_db.add_song_only("ska1.feedpak", genre="Ska")
|
||||
res3 = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Ska"})
|
||||
assert [s["filename"] for s in res3.json()["songs"]] == ["ska1.feedpak"]
|
||||
|
||||
|
||||
def test_gig_log_computes_encore_and_surfaces_in_passports(client, meta_db):
|
||||
for i in range(2):
|
||||
meta_db.add(f"s{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD,
|
||||
last_accuracy=0.9)
|
||||
_open(client, "guitar", "Soul")
|
||||
res = client.post("/api/plugins/career/gigs", json={
|
||||
"instrument": "guitar", "genre": "Soul", "venue_id": "bar",
|
||||
"songs": ["s0.feedpak", "s1.feedpak"]})
|
||||
assert res.status_code == 200
|
||||
gig = res.json()["gig"]
|
||||
assert gig["encore"] is True # avg 0.9 ≥ 0.75
|
||||
assert gig["songs"][0]["accuracy"] == 0.9
|
||||
view = client.get("/api/plugins/career/passports").json()
|
||||
assert view["instruments"]["guitar"]["gig_count"] == 1
|
||||
p = _passport(client, "guitar", "soul")
|
||||
assert len(p["gigs"]) == 1 and p["gigs"][0]["encore"] is True
|
||||
|
||||
|
||||
def test_gig_log_validation_and_no_fail_state(client):
|
||||
# Unknown venue / bad songs shapes are rejected; nothing is ever logged
|
||||
# as a failed gig — the endpoint only appends completed sets.
|
||||
assert client.post("/api/plugins/career/gigs", json={
|
||||
"instrument": "guitar", "genre": "Soul", "venue_id": "nope",
|
||||
"songs": ["x"]}).status_code == 400
|
||||
assert client.post("/api/plugins/career/gigs", json={
|
||||
"instrument": "guitar", "genre": "Soul", "songs": []}).status_code == 400
|
||||
assert client.post("/api/plugins/career/gigs", json={
|
||||
"instrument": "guitar", "genre": "Soul",
|
||||
"songs": ["f"] * 9}).status_code == 400
|
||||
|
||||
|
||||
def test_gig_accuracy_reads_newest_row_and_encore_needs_full_set(client, meta_db):
|
||||
# Newest row wins: a stale higher accuracy on another arrangement must
|
||||
# not inflate the gig log.
|
||||
meta_db.add("dual.feedpak", 1, 0.95, genre="Soul", arrangements=BASS,
|
||||
last_accuracy=0.95, last_played_at="2026-06-01T00:00:00")
|
||||
meta_db.add("dual.feedpak", 0, 0.60, genre="Soul", arrangements=LEAD,
|
||||
last_accuracy=0.60, last_played_at="2026-07-14T00:00:00")
|
||||
res = client.post("/api/plugins/career/gigs", json={
|
||||
"instrument": "guitar", "genre": "Soul", "songs": ["dual.feedpak"]})
|
||||
assert res.json()["gig"]["songs"][0]["accuracy"] == 0.6
|
||||
|
||||
# A set with an unscored song never earns the encore off one good song.
|
||||
meta_db.add("scored.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD,
|
||||
last_accuracy=0.9, last_played_at="2026-07-14T00:01:00")
|
||||
res2 = client.post("/api/plugins/career/gigs", json={
|
||||
"instrument": "guitar", "genre": "Soul",
|
||||
"songs": ["scored.feedpak", "ghost.feedpak"]})
|
||||
assert res2.json()["gig"]["encore"] is False
|
||||
|
||||
|
||||
def test_gig_propose_backfills_from_surplus_qualifying(client, meta_db):
|
||||
# Mature passport: plenty of qualifying songs, nothing near the bar,
|
||||
# nothing unplayed — the set still fills to size.
|
||||
for i in range(8):
|
||||
meta_db.add(f"own{i}.feedpak", 0, 0.9, genre="Ska", arrangements=LEAD)
|
||||
res = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Ska", "size": 5})
|
||||
assert len(res.json()["songs"]) == 5
|
||||
|
||||
|
||||
def test_gig_propose_backfill_offset_survives_stakes(client, meta_db):
|
||||
# 4 qualifying + 1 near-bar stake, size 5: the stake must not shift the
|
||||
# qualifying backfill window past eligible songs.
|
||||
for i in range(4):
|
||||
meta_db.add(f"q{i}.feedpak", 0, 0.9, genre="Reggae", arrangements=LEAD)
|
||||
meta_db.add("near.feedpak", 0, 0.7, genre="Reggae", arrangements=LEAD)
|
||||
res = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Reggae", "size": 5})
|
||||
files = [s["filename"] for s in res.json()["songs"]]
|
||||
assert len(files) == 5 and len(set(files)) == 5
|
||||
assert "near.feedpak" in files
|
||||
|
||||
|
||||
# ── Gold rung ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_gold_upgrades_bronze_via_family_style_artifact(client, meta_db):
|
||||
# Bronze earned on 'death metal' (family: metal); a metal gold artifact
|
||||
# from the jam verifier upgrades it — bronze-only stays 'earned' elsewhere.
|
||||
for i in range(5):
|
||||
meta_db.add(f"dm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=LEAD)
|
||||
career_routes._state["passports_content"]["genres"]["metal"] = {} # no drill gate for this test
|
||||
_open(client, "guitar", "Death Metal")
|
||||
client.post("/api/plugins/career/drill-state", json={"byNode": {}})
|
||||
assert _passport(client, "guitar", "death metal")["badge"] == "earned"
|
||||
client.post("/api/plugins/career/drill-state", json={
|
||||
"byNode": {}, "goldImprov": {"metal": {"at": 1, "verifier": "comb", "inKeyPct": 0.9}}})
|
||||
assert _passport(client, "guitar", "death metal")["badge"] == "gold"
|
||||
|
||||
|
||||
def test_gold_without_bronze_stays_in_progress(client, meta_db):
|
||||
meta_db.add("one.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD)
|
||||
_open(client, "guitar", "Soul")
|
||||
client.post("/api/plugins/career/drill-state", json={
|
||||
"byNode": {}, "goldImprov": {"soul": {"at": 1, "verifier": "comb"}}})
|
||||
assert _passport(client, "guitar", "soul")["badge"] == "in_progress"
|
||||
|
||||
|
||||
def test_gold_merge_is_gained_only(client, meta_db):
|
||||
for i in range(5):
|
||||
meta_db.add(f"s{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD)
|
||||
_open(client, "guitar", "Soul")
|
||||
client.post("/api/plugins/career/drill-state", json={
|
||||
"byNode": {}, "goldImprov": {"soul": {"at": 1, "verifier": "comb"}}})
|
||||
assert _passport(client, "guitar", "soul")["badge"] == "gold"
|
||||
# A stale relay without the artifact never un-mints.
|
||||
client.post("/api/plugins/career/drill-state", json={"byNode": {}})
|
||||
assert _passport(client, "guitar", "soul")["badge"] == "gold"
|
||||
# And a different artifact for the same style never overwrites the first —
|
||||
# asserted against the PERSISTED snapshot (the view doesn't expose
|
||||
# artifact contents), so a last-write-wins regression can't stay green.
|
||||
client.post("/api/plugins/career/drill-state", json={
|
||||
"byNode": {}, "goldImprov": {"soul": {"at": 999, "verifier": "yin"}}})
|
||||
_, _, gold = career_routes._drill_by_node()
|
||||
assert gold["soul"] == {"at": 1, "verifier": "comb"}
|
||||
|
||||
|
||||
def test_gold_matches_raw_style_id_through_family(client, meta_db):
|
||||
# Virtuoso mints under raw STYLE_PALETTES ids ('punk', not 'rock'): a
|
||||
# 'punk rock' passport (family rock) must go gold from a 'punk' artifact.
|
||||
for i in range(5):
|
||||
meta_db.add(f"pk{i}.feedpak", 0, 0.9, genre="Punk Rock", arrangements=LEAD)
|
||||
career_routes._state["passports_content"]["genres"]["rock"] = {} # no drill gate
|
||||
_open(client, "guitar", "Punk Rock")
|
||||
client.post("/api/plugins/career/drill-state", json={
|
||||
"byNode": {}, "goldImprov": {"punk": {"at": 1, "verifier": "comb"}}})
|
||||
assert _passport(client, "guitar", "punk rock")["badge"] == "gold"
|
||||
|
||||
|
||||
def test_gold_intake_rejects_junk(client, meta_db):
|
||||
# A non-dict goldImprov is a relay bug: loud 400, never a silent drop.
|
||||
res = client.post("/api/plugins/career/drill-state",
|
||||
json={"byNode": {}, "goldImprov": ["metal"]})
|
||||
assert res.status_code == 400
|
||||
# Evidence-free artifacts (no verifier) never mint.
|
||||
for i in range(5):
|
||||
meta_db.add(f"j{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD)
|
||||
_open(client, "guitar", "Soul")
|
||||
client.post("/api/plugins/career/drill-state", json={
|
||||
"byNode": {}, "goldImprov": {"soul": {}}})
|
||||
assert _passport(client, "guitar", "soul")["badge"] == "earned"
|
||||
# An oversized goldImprov is bounded BEFORE the merge, like byNode.
|
||||
blob = {f"s{i}": {"verifier": "comb", "pad": "x" * 4096} for i in range(200)}
|
||||
res = client.post("/api/plugins/career/drill-state",
|
||||
json={"byNode": {}, "goldImprov": blob})
|
||||
assert res.status_code == 413
|
||||
|
||||
|
||||
def test_gig_includes_songs_played_on_another_instrument(client, meta_db):
|
||||
# feedBack#… (tester): "Metalcore says 137 songs, only shows 1 in the gig list".
|
||||
# A song played on a DIFFERENT instrument's arrangement has a stats row, so it
|
||||
# was excluded from the unplayed filler — and its played bucket is that other
|
||||
# instrument's, not this passport's — so it fell into a gap and could never be
|
||||
# gigged. A guitar passport with a library of bass-played metalcore got a 404.
|
||||
for i in range(137):
|
||||
meta_db.add(f"mc{i}.feedpak", 0, 0.80, genre="Metalcore", arrangements=BASS)
|
||||
res = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Metalcore", "size": 4})
|
||||
assert res.status_code == 200, "a full library of the genre must never 404"
|
||||
assert len(res.json()["songs"]) == 4, "the gig must fill from the library, not the gap"
|
||||
|
||||
|
||||
def test_gig_reroll_changes_the_set(client, meta_db):
|
||||
# feedBack#… (tester): "Passport re-roll does not change songs". A set drawn
|
||||
# from the filler used to be the library's first N in table order, every time.
|
||||
for i in range(40):
|
||||
meta_db.add_song_only(f"un{i}.feedpak", genre="Metalcore")
|
||||
sets = set()
|
||||
for _ in range(5):
|
||||
r = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Metalcore", "size": 4})
|
||||
sets.add(tuple(sorted(s["filename"] for s in r.json()["songs"])))
|
||||
assert len(sets) > 1, "re-roll must be able to produce a different set"
|
||||
|
||||
@@ -121,12 +121,17 @@ def test_pack_file_serving_and_traversal_guard(client):
|
||||
|
||||
|
||||
def test_state_reports_installed_and_delete_removes(client):
|
||||
# All three venues now ship bundled, so deleting the downloaded copy
|
||||
# falls back to the bundled pack: installed stays True by design
|
||||
# (downloaded packs override bundled ones, never replace them).
|
||||
_install_fake_pack("club")
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is True
|
||||
assert client.delete("/api/plugins/career/packs/club").status_code == 200
|
||||
state = client.get("/api/plugins/career/state").json()
|
||||
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is False
|
||||
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is True
|
||||
# the downloaded override itself is gone
|
||||
assert not (career_routes._venue_dir("club") / "manifest.json").exists()
|
||||
|
||||
|
||||
def test_download_worker_end_to_end(client, tmp_path):
|
||||
@@ -169,3 +174,176 @@ def test_double_download_409s(client, monkeypatch):
|
||||
career_routes._state["downloads"]["bar"] = {"status": "running"}
|
||||
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
|
||||
assert client.delete("/api/plugins/career/packs/bar").status_code == 409
|
||||
|
||||
|
||||
# ── gig pre-extraction (the wait between songs) ─────────────────────────────
|
||||
#
|
||||
# A feedpak is a zip: the first play of one pays for its extraction into
|
||||
# sloppak_cache. Inside a set that cost landed BETWEEN songs — the player
|
||||
# finished a number and then sat waiting for the next one to unpack, mid-gig.
|
||||
# The setlist is known up front, so extract it all while the poster is up.
|
||||
|
||||
def _career_client_with_library(tmp_path, meta_db, dlc, cache):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import routes as career_routes
|
||||
app = FastAPI()
|
||||
career_routes.setup(app, {
|
||||
"config_dir": str(tmp_path),
|
||||
"meta_db": meta_db,
|
||||
"get_dlc_dir": lambda: dlc,
|
||||
"get_sloppak_cache_dir": lambda: cache,
|
||||
})
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _write_feedpak(dlc, name, title="T"):
|
||||
"""A minimal but REAL feedpak zip, so resolve_source_dir genuinely unpacks."""
|
||||
import json as _json
|
||||
import zipfile as _zip
|
||||
p = dlc / name
|
||||
with _zip.ZipFile(p, "w") as z:
|
||||
z.writestr("manifest.json", _json.dumps({"title": title, "artist": "A", "arrangements": []}))
|
||||
return p
|
||||
|
||||
|
||||
def test_gig_prepare_extracts_every_song_up_front(tmp_path, meta_db):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
for n in ("one.feedpak", "two.feedpak", "three.feedpak"):
|
||||
_write_feedpak(dlc, n)
|
||||
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
before = list(cache.iterdir())
|
||||
assert before == [], "nothing unpacked yet"
|
||||
|
||||
res = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": ["one.feedpak", "two.feedpak", "three.feedpak"]})
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["ok"] is True
|
||||
assert body["prepared"] == 3, body
|
||||
assert body["failed"] == []
|
||||
# The point of the whole exercise: the set is on disk BEFORE the first note.
|
||||
assert len(list(cache.iterdir())) == 3, "every song of the set must be unpacked"
|
||||
|
||||
|
||||
def test_gig_prepare_is_idempotent_on_a_warm_cache(tmp_path, meta_db):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_write_feedpak(dlc, "one.feedpak")
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
|
||||
first = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["one.feedpak"]}).json()
|
||||
second = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["one.feedpak"]}).json()
|
||||
assert first["prepared"] == second["prepared"] == 1
|
||||
assert len(list(cache.iterdir())) == 1, "a re-prepare must not duplicate the unpack"
|
||||
|
||||
|
||||
def test_one_bad_feedpak_does_not_stop_the_set(tmp_path, meta_db):
|
||||
# A corrupt pak in the setlist must not block the gig: the play itself will
|
||||
# surface the error exactly as it does outside a gig. Slow beats blocked.
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_write_feedpak(dlc, "good.feedpak")
|
||||
(dlc / "bad.feedpak").write_bytes(b"not a zip at all")
|
||||
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
body = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": ["good.feedpak", "bad.feedpak"]}).json()
|
||||
assert body["ok"] is True, "a bad pak must not fail the whole prepare"
|
||||
assert body["prepared"] == 1
|
||||
assert body["failed"] == ["bad.feedpak"]
|
||||
|
||||
|
||||
def test_gig_prepare_degrades_without_a_library(tmp_path, meta_db, client):
|
||||
# The stock fixture's context has no dlc/cache resolvers. That must be a
|
||||
# graceful no-op, not a 500 — pre-extraction is an optimisation and can
|
||||
# never be the reason a gig won't start.
|
||||
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["x.feedpak"]})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["prepared"] == 0
|
||||
|
||||
|
||||
def test_gig_prepare_empty_setlist(tmp_path, meta_db, client):
|
||||
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": []})
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"ok": True, "prepared": 0, "failed": []}
|
||||
|
||||
|
||||
def test_prepare_rejects_a_non_list_songs_value(tmp_path, meta_db, client):
|
||||
# A str is iterable: without the list check, "abc" would prepare three
|
||||
# one-character "songs".
|
||||
for bad in ("abc", 42, {"a": 1}, None):
|
||||
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": bad})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["prepared"] == 0
|
||||
|
||||
|
||||
def test_prepare_ignores_non_string_and_blank_entries(tmp_path, meta_db):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_write_feedpak(dlc, "good.feedpak")
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
body = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": ["good.feedpak", "", " ", 7, None, {"x": 1}]}).json()
|
||||
assert body["prepared"] == 1
|
||||
assert body["failed"] == []
|
||||
|
||||
|
||||
def test_prepare_caps_the_setlist(tmp_path, meta_db):
|
||||
# This endpoint unpacks zips — an arbitrary caller must not be able to ask for
|
||||
# unbounded work.
|
||||
#
|
||||
# The first version of this test asserted `prepared == 0` against a fixture
|
||||
# with NO library: the endpoint exits before extraction there, so it passed
|
||||
# whether or not the cap existed. Give it a real library, ask for far more than
|
||||
# the cap, and assert the endpoint only ever considered MAX_GIG_SONGS of them.
|
||||
import routes as career_routes
|
||||
assert career_routes.MAX_GIG_SONGS <= 64
|
||||
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
|
||||
n = career_routes.MAX_GIG_SONGS + 50
|
||||
# None of these exist, so every song the endpoint LOOKS AT lands in `failed`.
|
||||
# That makes `failed` an exact count of how many it considered.
|
||||
body = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": [f"missing{i}.feedpak" for i in range(n)]}).json()
|
||||
assert body["prepared"] == 0
|
||||
assert len(body["failed"]) == career_routes.MAX_GIG_SONGS, (
|
||||
f"the endpoint must consider at most MAX_GIG_SONGS "
|
||||
f"({career_routes.MAX_GIG_SONGS}), not all {n}"
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_refuses_to_escape_the_library(tmp_path, meta_db):
|
||||
# resolve_source_dir() does a bare `dlc_root / filename` with no containment
|
||||
# guard, so a crafted path would walk straight out of the library. Every
|
||||
# filename must go through _resolve_dlc_path first.
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
(tmp_path / "outside.feedpak").write_bytes(b"secret")
|
||||
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
|
||||
|
||||
for evil in ("../outside.feedpak", "..\\outside.feedpak",
|
||||
"a/../../outside.feedpak", "/etc/passwd", "C:/Windows/x.feedpak"):
|
||||
body = client.post("/api/plugins/career/gigs/prepare",
|
||||
json={"songs": [evil]}).json()
|
||||
assert body["prepared"] == 0, f"{evil!r} must never be prepared"
|
||||
assert body["failed"] == [evil]
|
||||
# Nothing outside the library may have been unpacked.
|
||||
assert list(cache.iterdir()) == []
|
||||
|
||||
@@ -137,6 +137,81 @@ def test_background_scan_discovers_both_suffixes(tmp_path, scan_server):
|
||||
assert "ignore.zip" not in seen
|
||||
|
||||
|
||||
# ── 2b. directory-signature fast path (skip the full re-stat) ────────────────
|
||||
|
||||
def test_dir_signature_fast_path_skips_unchanged_tree(tmp_path, scan_server):
|
||||
"""After a full scan records the library-dir signature, a second scan with
|
||||
an unchanged tree takes the fast path and does NOT re-glob/extract — but a
|
||||
forced scan (manual Refresh) always does the full pass, and a new song
|
||||
(which bumps the dir mtime) reverts to a full pass on its own."""
|
||||
import unittest.mock as mock
|
||||
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
(dlc / "a.feedpak").write_bytes(b"")
|
||||
(tmp_path / "config.json").write_text('{"dlc_dir": "%s"}' % dlc)
|
||||
|
||||
scan = importlib.import_module("scan")
|
||||
seen: list[str] = []
|
||||
|
||||
def mock_extract(f, dlc_dir):
|
||||
seen.append(f.name)
|
||||
return {"title": f.name, "artist": "", "album": ""}
|
||||
|
||||
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
|
||||
# 1) first pass: full scan, extracts a.feedpak (+ seeded builtins),
|
||||
# records the signature
|
||||
scan.background_scan()
|
||||
assert "a.feedpak" in seen
|
||||
assert scan._dir_signature_file().exists()
|
||||
|
||||
# 2) unchanged tree: fast path — no glob, no extraction at all
|
||||
seen.clear()
|
||||
scan.background_scan()
|
||||
assert seen == []
|
||||
assert scan.status()["stage"] == "complete"
|
||||
|
||||
# 3) a new song bumps the dlc mtime → signature mismatch → full pass
|
||||
# picks it up on its own (no manual Refresh needed for adds)
|
||||
(dlc / "b.feedpak").write_bytes(b"")
|
||||
seen.clear()
|
||||
scan.background_scan()
|
||||
assert "b.feedpak" in seen
|
||||
|
||||
# 4) force=True (Refresh) bypasses the fast path even on a settled tree:
|
||||
# with the signature now current, a plain scan skips, a forced one lists
|
||||
seen.clear()
|
||||
scan.background_scan() # fast path
|
||||
assert seen == []
|
||||
forced_listed = []
|
||||
real_delete_missing = scan.appstate.meta_db.delete_missing
|
||||
def _spy(files):
|
||||
forced_listed.append(set(files))
|
||||
return real_delete_missing(files)
|
||||
with mock.patch.object(scan.appstate.meta_db, "delete_missing", new=_spy):
|
||||
scan.background_scan(force=True)
|
||||
assert forced_listed, "force=True must run the full listing pass"
|
||||
|
||||
|
||||
def test_dir_signature_tracks_directory_form_song_own_dir(tmp_path):
|
||||
"""A directory-form song (loose folder / directory bundle) records its OWN
|
||||
directory in the signature, so an in-place file change inside it — which
|
||||
bumps that folder's mtime but not its parent's — invalidates the fast path.
|
||||
A file-form sloppak (a plain .feedpak zip) is not a dir and adds nothing."""
|
||||
scan = importlib.import_module("scan")
|
||||
dlc = tmp_path / "dlc"
|
||||
(dlc / "packs").mkdir(parents=True)
|
||||
loose = dlc / "packs" / "my_loose_song" # directory-form song
|
||||
loose.mkdir()
|
||||
zipped = dlc / "packs" / "zipped.feedpak" # file-form song
|
||||
zipped.write_bytes(b"")
|
||||
|
||||
rels = scan._library_dirs([loose, zipped], dlc)
|
||||
assert "packs/my_loose_song" in rels, "directory-form song must track its own dir"
|
||||
assert "packs" in rels and "." in rels
|
||||
assert "packs/zipped.feedpak" not in rels, "a file-form sloppak is not a tracked dir"
|
||||
|
||||
|
||||
# ── 3. POST /api/songs/upload gate (endpoint) ────────────────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""The remote transcription REQUEST — the thing that was never tested and never worked.
|
||||
|
||||
`transcribe_vocals_remote()` POSTed the vocal stem to `/align`. That endpoint is *forced
|
||||
alignment*: "here are the lyrics, tell me when each word is sung". Its `text` field is required,
|
||||
and we have no lyrics — transcribing them is the entire point. So the server rejected every
|
||||
request with a 422 from FastAPI's validation layer, before its handler ever ran, and remote
|
||||
transcription had never worked for anybody (feedBack-plugin-stem-splitter#17).
|
||||
|
||||
Nothing caught it because every test of this module tested the *mapper* — `_whisperx_to_sloppak`,
|
||||
fed a hand-written dict. The mapper was always fine. The request was never exercised, and the
|
||||
request was the bug.
|
||||
|
||||
So these tests assert the request: which endpoint, and how `language` is carried. Both are
|
||||
invisible to a mapper test, and both are wrong in ways that fail quietly rather than loudly.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from lyrics_transcribe import transcribe_vocals_remote
|
||||
|
||||
_ALIGNED = {
|
||||
"segments": [{
|
||||
"start": 1.0, "end": 2.0, "text": "hello world",
|
||||
"words": [
|
||||
{"word": "hello", "start": 1.0, "end": 1.4, "score": 0.9},
|
||||
{"word": "world", "start": 1.5, "end": 2.0, "score": 0.9},
|
||||
],
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status=200, payload=None, text=""):
|
||||
self.status_code = status
|
||||
self._payload = payload if payload is not None else _ALIGNED
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vocals(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "vocals.ogg"
|
||||
p.write_bytes(b"not really ogg, we never decode it here")
|
||||
return p
|
||||
|
||||
|
||||
def _post_call(vocals: Path, resp: _Resp, **kw):
|
||||
with mock.patch("requests.post", return_value=resp) as post:
|
||||
out = transcribe_vocals_remote(vocals, "http://server:7865", **kw)
|
||||
return post.call_args, out
|
||||
|
||||
|
||||
def test_it_posts_to_transcribe_not_align(vocals):
|
||||
"""THE regression. /align requires `text`; we have none, so it 422s every time."""
|
||||
call, out = _post_call(vocals, _Resp())
|
||||
|
||||
url = call.args[0]
|
||||
assert url.endswith("/transcribe"), (
|
||||
f"posted to {url!r} — /align is forced alignment and its `text` field is required, so "
|
||||
f"this request is rejected with a 422 before the server's handler ever runs"
|
||||
)
|
||||
assert "/align" not in url
|
||||
assert out, "a successful transcription must return syllables"
|
||||
|
||||
|
||||
def test_the_language_hint_is_a_form_field_not_a_query_param(vocals):
|
||||
"""The server reads `language` with Form(""). Sent as a query param it is silently ignored —
|
||||
so an explicit hint does nothing, Whisper's auto-detection quietly decides instead, and the
|
||||
wrong wav2vec2 aligner gets loaded. It "works", it's just wrong: the failure mode that hides
|
||||
for months."""
|
||||
call, _ = _post_call(vocals, _Resp(), language="es")
|
||||
|
||||
assert (call.kwargs.get("data") or {}).get("language") == "es", (
|
||||
"the language hint must ride in the form body — the server reads Form('language'), and "
|
||||
"a query param is dropped without a word"
|
||||
)
|
||||
assert "language" not in (call.kwargs.get("params") or {})
|
||||
|
||||
|
||||
def test_no_language_sends_no_hint(vocals):
|
||||
# Absent is not the empty string: "" would pin detection to a language named "".
|
||||
call, _ = _post_call(vocals, _Resp())
|
||||
assert not (call.kwargs.get("data") or {})
|
||||
|
||||
|
||||
def test_the_file_is_sent_as_a_multipart_upload(vocals):
|
||||
call, _ = _post_call(vocals, _Resp())
|
||||
files = call.kwargs.get("files") or {}
|
||||
assert "file" in files, "the server reads File('file')"
|
||||
assert files["file"][0] == "vocals.ogg"
|
||||
|
||||
|
||||
def test_an_api_key_is_sent_as_a_bearer_token(vocals):
|
||||
call, _ = _post_call(vocals, _Resp(), api_key="secret")
|
||||
assert (call.kwargs.get("headers") or {})["Authorization"] == "Bearer secret"
|
||||
|
||||
|
||||
def test_an_instrumental_is_an_answer_not_a_crash(vocals):
|
||||
# The server returns 200 + no segments for a stem with no singing in it. That is a valid
|
||||
# answer ("this song has no vocals"), and it must not read as a failure.
|
||||
_, out = _post_call(vocals, _Resp(payload={"segments": [], "language": "en"}))
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_a_server_error_surfaces_the_whole_body(vocals):
|
||||
"""The error body IS the diagnosis. A 422's JSON names the field it rejected; a 500's
|
||||
traceback answers on its last line. The old 300-char cap decapitated both — which is how
|
||||
this bug stayed invisible: the message explaining it was inside the part that got cut."""
|
||||
tb = "Traceback (most recent call last):\n" + (" File x, line 1\n" * 40) + \
|
||||
"RuntimeError: CUDA out of memory"
|
||||
assert len(tb) > 300 and "CUDA out of memory" not in tb[:300]
|
||||
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
_post_call(vocals, _Resp(status=500, text=tb))
|
||||
assert "CUDA out of memory" in str(exc.value)
|
||||
|
||||
|
||||
def test_truncation_keeps_the_exception_line_not_just_the_header():
|
||||
"""A traceback's ANSWER is its last line. Head-only truncation throws it away.
|
||||
|
||||
This is the same mistake as the 300-char cap, one level up: cutting off precisely the part
|
||||
the function exists to preserve. A 4000-char window that contains "Traceback (most recent
|
||||
call last)" and none of the exception is a window onto nothing."""
|
||||
from lyrics_transcribe import _MAX_ERR_BODY, _err_body
|
||||
|
||||
frames = "".join(f' File "/app/server.py", line {i}, in run\n step()\n'
|
||||
for i in range(2000)) # far over the cap on its own
|
||||
tb = "Traceback (most recent call last):\n" + frames + \
|
||||
"RuntimeError: CUDA out of memory. Tried to allocate 2.20 GiB"
|
||||
|
||||
body = _err_body(_Resp(text=tb))
|
||||
assert len(body) <= _MAX_ERR_BODY
|
||||
assert "CUDA out of memory" in body, (
|
||||
"the exception line is the diagnosis — a truncation that drops it keeps the part that "
|
||||
"says work was happening and discards the part that says what went wrong"
|
||||
)
|
||||
assert "Traceback (most recent call last)" in body, "the head is context worth keeping too"
|
||||
assert "truncated" in body
|
||||
|
||||
|
||||
def test_the_cap_is_a_bound_not_a_suggestion():
|
||||
"""The truncation marker must fit INSIDE _MAX_ERR_BODY, not be appended past it.
|
||||
|
||||
Otherwise the cap is advisory, and the callers who trust it — a log line, a job record
|
||||
persisted to disk and re-read on every load — are the ones that get surprised."""
|
||||
from lyrics_transcribe import _MAX_ERR_BODY, _err_body
|
||||
|
||||
body = _err_body(_Resp(text="x" * 500_000))
|
||||
assert len(body) <= _MAX_ERR_BODY, (
|
||||
f"body is {len(body)} chars, over the {_MAX_ERR_BODY} cap it claims to enforce"
|
||||
)
|
||||
assert "truncated" in body and "500000" in body
|
||||
|
||||
|
||||
def test_trailing_whitespace_is_not_content():
|
||||
# A 300-char JSON body followed by 3900 blanks is not a long body, and cutting real content
|
||||
# to make room for whitespace would be a silly way to lose the diagnosis.
|
||||
from lyrics_transcribe import _err_body
|
||||
|
||||
payload = '{"detail":"nope"}'
|
||||
assert _err_body(_Resp(text=payload + " " * 8000)) == payload
|
||||
|
||||
|
||||
def test_a_404_explains_that_the_server_is_too_old(vocals):
|
||||
"""A bare "404" sends someone hunting for a typo in their URL. The real answer is that their
|
||||
server predates the endpoint, and only we can know that."""
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
_post_call(vocals, _Resp(status=404, text='{"detail":"Not Found"}'))
|
||||
msg = str(exc.value)
|
||||
assert "404" in msg
|
||||
assert "/transcribe" in msg
|
||||
assert "predates" in msg or "Update the server" in msg
|
||||
|
||||
|
||||
class TestEverythingFailsAsRuntimeError:
|
||||
"""The docstring promises one failure mode: RuntimeError. The caller
|
||||
(`_maybe_transcribe_lyrics`) catches exactly that so one song's failed lyrics don't take down
|
||||
the batch around it. A transport error escaping as requests.RequestException walks straight
|
||||
past that handler — turning "this song's lyrics failed" into "the whole batch died"."""
|
||||
|
||||
def test_a_connection_failure(self, vocals):
|
||||
import requests
|
||||
with mock.patch("requests.post",
|
||||
side_effect=requests.ConnectionError("name resolution failed")):
|
||||
with pytest.raises(RuntimeError, match="could not reach"):
|
||||
transcribe_vocals_remote(vocals, "http://nope:7865")
|
||||
|
||||
def test_a_timeout(self, vocals):
|
||||
import requests
|
||||
with mock.patch("requests.post", side_effect=requests.Timeout("timed out")):
|
||||
with pytest.raises(RuntimeError, match="could not reach"):
|
||||
transcribe_vocals_remote(vocals, "http://server:7865")
|
||||
|
||||
def test_an_unreadable_stem(self, tmp_path):
|
||||
missing = tmp_path / "gone.ogg" # never created
|
||||
with pytest.raises(RuntimeError, match="could not read"):
|
||||
transcribe_vocals_remote(missing, "http://server:7865")
|
||||
@@ -0,0 +1,103 @@
|
||||
"""The library root must be resolved ONCE, not on every path check.
|
||||
|
||||
`Path.resolve()` lstats every component of a path. `_resolve_dlc_path` and
|
||||
`safe_join` run once per song / art fetch / scanned row, and both used to
|
||||
re-resolve their root every single call.
|
||||
|
||||
Measured on a real 50,944-song library sitting on an NTFS-3G (FUSE) mount:
|
||||
~23,500 stat/lstat calls per second, re-walking the same three parent
|
||||
directories, pinning a core of the server. Every stat crosses into userspace on
|
||||
FUSE, so the constant re-resolution — not the work itself — was the cost.
|
||||
|
||||
These tests pin the fix (root resolved once) AND that caching it did not weaken
|
||||
containment, which is the thing that matters: `safe_join` is the zip-slip guard.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from dlc_paths import _resolve_dlc_path
|
||||
from safepath import resolved_root, safe_join
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache():
|
||||
resolved_root.cache_clear()
|
||||
yield
|
||||
resolved_root.cache_clear()
|
||||
|
||||
|
||||
def test_dlc_root_is_resolved_once_across_many_lookups(tmp_path):
|
||||
"""The regression: 500 lookups must not mean 500 root resolutions."""
|
||||
(tmp_path / "a.feedpak").write_bytes(b"x")
|
||||
|
||||
for i in range(500):
|
||||
assert _resolve_dlc_path(tmp_path, f"song{i}.feedpak") is not None
|
||||
|
||||
info = resolved_root.cache_info()
|
||||
assert info.misses == 1, (
|
||||
f"the library root must be resolved ONCE, not per call "
|
||||
f"(got {info.misses} resolutions for 500 lookups)"
|
||||
)
|
||||
assert info.hits == 499
|
||||
|
||||
|
||||
def test_safe_join_resolves_its_root_once_too(tmp_path):
|
||||
for i in range(200):
|
||||
assert safe_join(tmp_path, f"asset{i}.png") is not None
|
||||
assert resolved_root.cache_info().misses == 1
|
||||
|
||||
|
||||
def test_a_different_root_is_a_different_cache_entry(tmp_path):
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
_resolve_dlc_path(tmp_path, "a.feedpak")
|
||||
_resolve_dlc_path(other, "a.feedpak")
|
||||
assert resolved_root.cache_info().misses == 2, "switching library dir must re-resolve"
|
||||
|
||||
|
||||
# ── containment must be unchanged (the part that matters) ───────────────────
|
||||
|
||||
@pytest.mark.parametrize("evil", [
|
||||
"../etc/passwd",
|
||||
"..\\etc\\passwd",
|
||||
"a/../../etc/passwd",
|
||||
"/etc/passwd",
|
||||
"C:/Windows/system.ini",
|
||||
"",
|
||||
])
|
||||
def test_resolve_dlc_path_still_rejects_escapes(tmp_path, evil):
|
||||
assert _resolve_dlc_path(tmp_path, evil) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("evil", [
|
||||
"../outside.txt",
|
||||
"..\\outside.txt",
|
||||
"a/../../outside.txt",
|
||||
"",
|
||||
])
|
||||
def test_safe_join_still_rejects_escapes(tmp_path, evil):
|
||||
assert safe_join(tmp_path, evil) is None
|
||||
|
||||
|
||||
def test_safe_join_still_follows_symlinks_out(tmp_path):
|
||||
"""safe_join's candidate resolution is the zip-slip defence and is NOT cached:
|
||||
a symlink pointing outside the root must still be refused."""
|
||||
outside = tmp_path.parent / "outside_secret"
|
||||
outside.mkdir(exist_ok=True)
|
||||
(outside / "secret.txt").write_text("x")
|
||||
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
(root / "escape").symlink_to(outside)
|
||||
|
||||
assert safe_join(root, "escape/secret.txt") is None, (
|
||||
"a symlink escaping the root must still be rejected — caching the ROOT "
|
||||
"must not disable resolution of the CANDIDATE"
|
||||
)
|
||||
|
||||
|
||||
def test_in_library_paths_still_resolve(tmp_path):
|
||||
assert _resolve_dlc_path(tmp_path, "sub/song.feedpak") == tmp_path / "sub" / "song.feedpak"
|
||||
assert safe_join(tmp_path, "art/cover.png") == (tmp_path / "art" / "cover.png").resolve()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""`/api/song/{f}?stems=1` — the playable stem list, for preloading.
|
||||
|
||||
The stems plugin could only learn its stem list from the highway's WS `ready`,
|
||||
which arrives once the highway is already up. So it decoded the stems and then
|
||||
copied the whole song's PCM to its audio worklet with the player on screen —
|
||||
half a gigabyte of memcpy in one frame, ~700 ms, which froze the venue video.
|
||||
|
||||
Given the list at `song:loading` it can do all of that BEFORE the highway
|
||||
appears, behind the loading overlay where a stall costs nothing.
|
||||
|
||||
The safety property these tests exist for: the REST payload must be the SAME
|
||||
list the WS builds. If they disagree, the plugin preloads one graph and then
|
||||
throws it away and rebuilds another — strictly worse than not preloading. So
|
||||
they are pinned against each other, not just against a snapshot.
|
||||
"""
|
||||
|
||||
import zipfile
|
||||
|
||||
import yaml
|
||||
|
||||
import sloppak
|
||||
|
||||
|
||||
def _pak(tmp_path, stems, full=None, name="song.feedpak", original_audio=None):
|
||||
manifest = {
|
||||
"title": "T", "artist": "A", "duration": 10.0,
|
||||
"arrangements": [],
|
||||
"stems": stems + ([full] if full else []),
|
||||
}
|
||||
if original_audio:
|
||||
# The deprecated pre-1.15.0 shape: the mixdown lives outside `stems`.
|
||||
manifest["original_audio"] = original_audio
|
||||
p = tmp_path / name
|
||||
with zipfile.ZipFile(p, "w") as z:
|
||||
# Real packs carry manifest.yaml — a JSON manifest is not read at all.
|
||||
z.writestr("manifest.yaml", yaml.safe_dump(manifest))
|
||||
# _legacy_full_mix only returns a path that actually EXISTS on disk.
|
||||
if original_audio:
|
||||
z.writestr(original_audio, b"\0" * 16)
|
||||
return p
|
||||
|
||||
|
||||
def _payload(tmp_path, pak):
|
||||
from routers.song import _playable_stems_payload
|
||||
import appstate
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir(exist_ok=True)
|
||||
appstate.sloppak_cache_dir = cache
|
||||
return _playable_stems_payload(pak.name, tmp_path)
|
||||
|
||||
|
||||
def _ws_payload(tmp_path, pak):
|
||||
"""Rebuild the WS `ready` stems payload exactly as ws_highway.py does."""
|
||||
from urllib.parse import quote
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir(exist_ok=True)
|
||||
loaded = sloppak.load_song(pak.name, tmp_path, cache)
|
||||
q = quote(pak.name, safe="")
|
||||
return {
|
||||
"stems": [
|
||||
{"id": s["id"], "url": f"/api/sloppak/{q}/file/{quote(s['file'])}",
|
||||
"default": s["default"]}
|
||||
for s in loaded.stems
|
||||
],
|
||||
"full_mix_url": f"/api/sloppak/{q}/file/{quote(loaded.full_mix)}" if loaded.full_mix else None,
|
||||
}
|
||||
|
||||
|
||||
def test_default_resolution_is_shared_with_load_song():
|
||||
assert sloppak.stem_default_on(True) is True
|
||||
assert sloppak.stem_default_on(False) is False
|
||||
assert sloppak.stem_default_on("off") is False
|
||||
assert sloppak.stem_default_on("false") is False
|
||||
assert sloppak.stem_default_on("0") is False
|
||||
assert sloppak.stem_default_on("no") is False
|
||||
assert sloppak.stem_default_on("on") is True
|
||||
assert sloppak.stem_default_on(1) is True
|
||||
|
||||
|
||||
def test_rest_matches_the_ws_for_a_reserved_full_stem(tmp_path):
|
||||
pak = _pak(tmp_path,
|
||||
[{"id": "guitar", "file": "stems/guitar.ogg"},
|
||||
{"id": "vocals", "file": "stems/vocals.ogg", "default": "off"}],
|
||||
full={"id": "full", "file": "stems/full.ogg"},
|
||||
name="Iron Maiden - Phantom.feedpak")
|
||||
rest = _payload(tmp_path, pak)
|
||||
assert rest == _ws_payload(tmp_path, pak)
|
||||
assert [s["id"] for s in rest["stems"]] == ["guitar", "vocals"], "the mixdown is not a layer"
|
||||
assert rest["full_mix_url"].endswith("stems/full.ogg")
|
||||
assert rest["stems"][1]["default"] is False
|
||||
|
||||
|
||||
def test_rest_matches_the_ws_for_a_LEGACY_original_audio_pack(tmp_path):
|
||||
"""The one CodeRabbit caught, and the one that matters most in practice.
|
||||
|
||||
load_song falls back to the DEPRECATED `original_audio:` key when a pack has
|
||||
no reserved `full` stem — which is every pack written before feedpak 1.15.0,
|
||||
i.e. most of a real library. My first version of this payload reimplemented
|
||||
the full-mix rule from extract_meta and silently returned None for them: REST
|
||||
would say "no full mix" while the WS said there was one. The plugin would then
|
||||
preload a graph WITHOUT the pristine mix and, because the signature still
|
||||
matched, never rebuild — unity playback silently downgraded to the lossy
|
||||
recombination.
|
||||
|
||||
The payload now calls load_song itself, so this cannot drift. Pinned anyway.
|
||||
"""
|
||||
pak = _pak(tmp_path, [
|
||||
{"id": "guitar", "file": "stems/guitar.ogg"},
|
||||
{"id": "bass", "file": "stems/bass.ogg"},
|
||||
], name="Legacy Pack.feedpak", original_audio="original/full.ogg")
|
||||
|
||||
rest = _payload(tmp_path, pak)
|
||||
assert rest == _ws_payload(tmp_path, pak)
|
||||
assert rest["full_mix_url"] is not None, (
|
||||
"a pre-1.15.0 pack's full mix must survive — dropping it downgrades unity "
|
||||
"playback to the lossy stem recombination, silently"
|
||||
)
|
||||
assert rest["full_mix_url"].endswith("original/full.ogg")
|
||||
|
||||
|
||||
def test_rest_matches_the_ws_for_a_single_full_pack(tmp_path):
|
||||
# Its ONE stem IS the mixdown: nothing to be pristine against, so `full` stays
|
||||
# the sole playable stem and no separate mixdown is surfaced.
|
||||
pak = _pak(tmp_path, [{"id": "full", "file": "stems/full.ogg"}], name="Single.feedpak")
|
||||
rest = _payload(tmp_path, pak)
|
||||
assert rest == _ws_payload(tmp_path, pak)
|
||||
assert [s["id"] for s in rest["stems"]] == ["full"]
|
||||
assert rest["full_mix_url"] is None
|
||||
|
||||
|
||||
def test_a_broken_pack_yields_an_empty_list_not_an_error(tmp_path):
|
||||
# Preloading is an optimisation: an unreadable pack must fall back to the
|
||||
# normal WS-driven path, never break the song-info request.
|
||||
from routers.song import _playable_stems_payload
|
||||
import appstate
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
appstate.sloppak_cache_dir = cache
|
||||
(tmp_path / "bad.feedpak").write_bytes(b"not a zip")
|
||||
assert _playable_stems_payload("bad.feedpak", tmp_path) == {"stems": [], "full_mix_url": None}
|
||||
Reference in New Issue
Block a user