mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 11:54:30 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7b065d236 | ||
|
|
0b4b174d33 | ||
|
|
2f2a095e4c | ||
|
|
e14ef64224 | ||
|
|
365cec1d29 | ||
|
|
1702afa379 | ||
|
|
917d81c2d2 | ||
|
|
939c98214b | ||
|
|
4e0e3c5417 | ||
|
|
8ef97708ef | ||
|
|
e729c44d5b | ||
|
|
2991612531 | ||
|
|
af611770aa | ||
|
|
ea9da0acde | ||
|
|
be473dc7af | ||
|
|
0d35228d56 | ||
|
|
dd1927e27b | ||
|
|
7c897e9f2b | ||
|
|
6272af8d33 | ||
|
|
831117fb96 | ||
|
|
6cc0312661 | ||
|
|
329cc86315 | ||
|
|
d876ded00f | ||
|
|
18d77d2d41 | ||
|
|
5921157f35 |
@@ -8,6 +8,102 @@ 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
|
||||
seam the achievements plugin uses (now documented in docs/plugin-v3-ui.md).
|
||||
The home page's plugin-count stat tile becomes a career trading card
|
||||
(badges, hours, the closest stamp ask, foil shine) with the old stat as the
|
||||
built-in fallback when career has no state. Earned passports gain **Save
|
||||
card / Copy card** — a natively-drawn PNG passport card, downloadable or
|
||||
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
|
||||
(feedpak-spec#53) reserves the id **`full`** for it, so that is where core reads it
|
||||
from now.
|
||||
|
||||
`full` is a mixdown, not a layer — it already contains every instrument — so
|
||||
`load_song()` lifts it OUT of `LoadedSloppak.stems` onto `LoadedSloppak.full_mix`.
|
||||
Nothing that sums stems or renders one fader per stem can see it, which is what
|
||||
makes retaining it safe; leaving it in the list would double the whole song and
|
||||
leave "guitar" audible with the guitar fader muted. That trap is exactly why the
|
||||
packer invented the key instead of putting the mixdown where the format says it
|
||||
goes — the bug was in the reader, and this fixes the reader.
|
||||
|
||||
Consequences worth knowing:
|
||||
- The highway WS `song_info` frame gains `full_mix_url` / `has_full_mix`.
|
||||
`original_audio_url` / `has_original_audio` remain as **deprecated aliases**
|
||||
(same values) for one release so a client built against the old frame keeps
|
||||
working; they go with the fallback below (#945).
|
||||
- `stems` on `song_info`, and `stem_ids` / `stem_count` in the library index, now
|
||||
describe *instrument* stems only — a separated pack that retains its mixdown no
|
||||
longer advertises a bogus "full" stem chip or an inflated stem count.
|
||||
- Audio fingerprinting (`lib/enrichment.py`) now resolves the mixdown the same
|
||||
way, which **widens** its coverage: it previously returned `None` for any pack
|
||||
without the invented key, so fingerprinting silently did nothing for the
|
||||
overwhelming majority of packs.
|
||||
- Core still **reads** `original_audio:` as a deprecated fallback, because every
|
||||
pack written before the spec caught up carries it and would otherwise lose its
|
||||
pristine mix. `tools/migrate_full_mix_stem.py` rewrites those packs into the
|
||||
spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem
|
||||
at `default: off`, drops the key); the fallback and the aliases are removed once
|
||||
they are migrated (#945).
|
||||
|
||||
### Added
|
||||
- **Genres fall back to MusicBrainz enrichment** — the effective genre now
|
||||
resolves override → pack genre → the enrichment match's primary genre
|
||||
(matched or user-pinned manual rows only). Converted packs rarely carry a `genres` manifest key,
|
||||
which starved the library genre facet and career passports on real
|
||||
libraries; with the fallback, every enriched song's genre is browsable and
|
||||
passport-able immediately, and coverage grows as enrichment runs.
|
||||
- **Badge ceremony in the venue** — earning a genre badge now stages a moment:
|
||||
the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant
|
||||
ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger;
|
||||
|
||||
@@ -189,3 +189,23 @@ out of the capability graph.
|
||||
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
|
||||
rail 30, popovers 40).
|
||||
- [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell.
|
||||
|
||||
## Injecting into core shells (profile, dashboard)
|
||||
|
||||
Core screens that accept plugin sections render **mount points** — usually
|
||||
empty, sometimes holding core's own **fallback content** (the Dashboard's
|
||||
career slot ships the plugin-count stat) — and announce each (re)build with a
|
||||
DOM event, because their `innerHTML` swap wipes anything previously injected.
|
||||
A plugin listens for the event and **replaces the mount's content** (never
|
||||
append — a fallback may be present) by id — the same seam every time:
|
||||
|
||||
| Shell | Event | Mounts |
|
||||
| --- | --- | --- |
|
||||
| Profile | `v3:profile-rendered` | `#v3-profile-passports-mount` (career wall), `#v3-profile-feats-slot`, `#v3-profile-achievements-mount` |
|
||||
| Dashboard | `v3:dashboard-rendered` | `#v3-dash-career-slot` (career card; core's plugin-count stat is the fallback content a plugin may replace) |
|
||||
| Settings | `v3:settings-rendered` | per-plugin `settings.html` panels |
|
||||
|
||||
Rules: inject on every event (the mount is fresh), keep the section
|
||||
**absent-not-empty** (no state → leave the mount alone / empty), and guard
|
||||
re-wired listeners with a `dataset` flag when your own refresh path can run
|
||||
against an unwiped mount.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -34,17 +34,23 @@
|
||||
|
||||
exceptions:
|
||||
- key: original_audio
|
||||
issue: https://github.com/got-feedback/feedback/issues/933
|
||||
issue: https://github.com/got-feedback/feedback/issues/945
|
||||
reason: >-
|
||||
Added by #583 (the full mix played while every stem fader sits at unity,
|
||||
since demucs recombination is lossy). Core, lib/enrichment.py, and the
|
||||
stems plugin all depend on it, but it never went through a FEP and the
|
||||
since demucs recombination is lossy). It never went through a FEP and the
|
||||
spec does not define it — the drift this gate exists to prevent.
|
||||
|
||||
The resolution is REMOVAL, not a FEP: the spec already carries the mixdown
|
||||
as a stem ({id: full, file: stems/full.ogg}), so this key added a second,
|
||||
redundant location for audio to a format that already had one. See #933.
|
||||
#933 fixed the drift: feedpak 1.15.0 RESERVES the stem id `full` for the
|
||||
complete mixdown (feedpak-spec#53), and core now reads the full mix from
|
||||
that stem. Nothing depends on this key any more — not the loader, not
|
||||
lib/enrichment.py, not the stems plugin, and the packer no longer writes it.
|
||||
|
||||
Grandfathered so the gate can land green and start blocking the *next*
|
||||
instance immediately, rather than blocking on #933. This entry goes away
|
||||
when core no longer reads or writes the key.
|
||||
What remains is a READ-ONLY deprecated fallback in lib/sloppak.py
|
||||
(_legacy_full_mix), kept for one release because every pack produced before
|
||||
the spec caught up carries `original_audio: original/full.ogg` and would
|
||||
otherwise silently lose its pristine mix. tools/migrate_full_mix_stem.py
|
||||
rewrites those packs into the spec shape.
|
||||
|
||||
This entry disappears with that fallback — tracked by #945, which cannot be
|
||||
forgotten: the gate fails if the entry goes stale, and deleting the read is
|
||||
what makes it stale.
|
||||
|
||||
+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
|
||||
|
||||
+20
-5
@@ -368,10 +368,12 @@ def _acoustid_gate() -> "JSONResponse | None":
|
||||
|
||||
def _song_audio_file(filename: str) -> "str | None":
|
||||
"""Resolve a LIBRARY song (by filename/id) to a local master-audio file for
|
||||
fingerprinting: the full-mix `original_audio` extracted from a sloppak, or a
|
||||
loose folder's audio. None when the song can't be found or ships no full-mix
|
||||
audio (some packs carry only stems). Mirrors serve_sloppak_file's containment
|
||||
guards so a crafted filename can't read outside DLC_DIR / the pack."""
|
||||
fingerprinting: a sloppak's complete mixdown, or a loose folder's audio. None
|
||||
when the song can't be found or carries no mixdown (a pack that kept only its
|
||||
separated stems — an acoustic fingerprint of one re-summed from them would not
|
||||
match the recording, so we decline rather than submit a lossy reconstruction).
|
||||
Mirrors serve_sloppak_file's containment guards so a crafted filename can't
|
||||
read outside DLC_DIR / the pack."""
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
return None
|
||||
@@ -383,7 +385,20 @@ def _song_audio_file(filename: str) -> "str | None":
|
||||
canon = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
return None
|
||||
rel = (sloppak_mod.load_manifest(resolved) or {}).get("original_audio")
|
||||
manifest = sloppak_mod.load_manifest(resolved) or {}
|
||||
# The mixdown is the RESERVED `full` stem (spec §5.3). Unlike playback,
|
||||
# fingerprinting wants it even when it is the pack's ONLY stem — a
|
||||
# single-mix pack is exactly the master audio we want to fingerprint —
|
||||
# so this asks find_full_mix() rather than partition_stems().
|
||||
stems = manifest.get("stems") or []
|
||||
full = sloppak_mod.find_full_mix(
|
||||
[s for s in stems if isinstance(s, dict)]
|
||||
)
|
||||
rel = full.get("file") if full else None
|
||||
# DEPRECATED fallback: packs written before the spec reserved `full` put
|
||||
# the mixdown behind a top-level `original_audio:` key instead (#933).
|
||||
if not isinstance(rel, str) or not rel.strip():
|
||||
rel = manifest.get("original_audio")
|
||||
if not isinstance(rel, str) or not rel.strip():
|
||||
return None
|
||||
src = sloppak_mod.get_cached_source_dir(canon)
|
||||
|
||||
+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()
|
||||
|
||||
|
||||
+40
-9
@@ -1085,26 +1085,57 @@ class MetadataDB:
|
||||
vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
|
||||
return vals
|
||||
|
||||
# Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the
|
||||
# scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias)
|
||||
# so a corrected genre is browsable — the correlated subquery is used ONLY
|
||||
# when genre overrides actually exist; the common case stays on the plain
|
||||
# indexed `genre` column. Genre stays a library-only overlay (it isn't a
|
||||
# write-to-file field), so it never touches the pack.
|
||||
_EFFECTIVE_GENRE_SQL = (
|
||||
# Effective genre precedence: per-song OVERRIDE (Fix-metadata popup) →
|
||||
# scanned pack genre → MusicBrainz enrichment primary genre (matched/manual rows
|
||||
# only — a 'review'/'failed' candidate's genres could belong to the wrong
|
||||
# recording). Applied at FILTER/FACET time (like the P4 artist alias) so a
|
||||
# corrected or enriched genre is browsable. The vast majority of converted
|
||||
# packs carry no `genres` manifest key, so without the enrichment leg the
|
||||
# genre facet (and career passports) starve on real libraries. The
|
||||
# correlated subqueries are used ONLY when overrides/enrichment genres
|
||||
# actually exist; the common case stays on the plain indexed `genre`
|
||||
# column. Genre stays a library-only overlay (it isn't a write-to-file
|
||||
# field), so it never touches the pack.
|
||||
_EFFECTIVE_GENRE_OVERRIDE_SQL = (
|
||||
"COALESCE((SELECT o.value FROM song_field_override o "
|
||||
"WHERE o.filename = songs.filename AND o.field = 'genre' "
|
||||
"AND o.value IS NOT NULL AND o.value != ''), genre)"
|
||||
)
|
||||
_EFFECTIVE_GENRE_SQL = (
|
||||
"COALESCE((SELECT o.value FROM song_field_override o "
|
||||
"WHERE o.filename = songs.filename AND o.field = 'genre' "
|
||||
"AND o.value IS NOT NULL AND o.value != ''), "
|
||||
"NULLIF(genre, ''), "
|
||||
"(SELECT json_extract(e.genres, '$[0]') FROM song_enrichment e "
|
||||
"WHERE e.filename = songs.filename AND e.match_state IN ('matched', 'manual') "
|
||||
"AND e.genres IS NOT NULL AND e.genres NOT IN ('', '[]')), "
|
||||
"'')"
|
||||
)
|
||||
|
||||
def _has_genre_overrides(self) -> bool:
|
||||
return self.conn.execute(
|
||||
"SELECT 1 FROM song_field_override WHERE field = 'genre' "
|
||||
"AND value IS NOT NULL AND value != '' LIMIT 1").fetchone() is not None
|
||||
|
||||
def _has_enrichment_genres(self) -> bool:
|
||||
try:
|
||||
return self.conn.execute(
|
||||
"SELECT 1 FROM song_enrichment WHERE match_state IN ('matched', 'manual') "
|
||||
"AND genres IS NOT NULL AND genres NOT IN ('', '[]') "
|
||||
"LIMIT 1").fetchone() is not None
|
||||
except sqlite3.OperationalError:
|
||||
return False # stand-ins / DBs without the enrichment table
|
||||
|
||||
def _effective_genre_expr(self) -> str:
|
||||
"""`genre` normally; the override-aware COALESCE only when overrides exist."""
|
||||
return self._EFFECTIVE_GENRE_SQL if self._has_genre_overrides() else "genre"
|
||||
"""`genre` normally; the enrichment-aware COALESCE only when trusted
|
||||
enrichment genres exist (which also proves the table exists — a
|
||||
stand-in DB without song_enrichment must never receive SQL that
|
||||
references it); the override-only form when just overrides exist."""
|
||||
if self._has_enrichment_genres():
|
||||
return self._EFFECTIVE_GENRE_SQL
|
||||
if self._has_genre_overrides():
|
||||
return self._EFFECTIVE_GENRE_OVERRIDE_SQL
|
||||
return "genre"
|
||||
|
||||
def set_song_tags(self, filename: str, tags) -> list:
|
||||
"""Replace ALL of a song's tags with the given set (each normalized;
|
||||
|
||||
+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)
|
||||
|
||||
+43
-19
@@ -321,11 +321,16 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
audio_url = None
|
||||
audio_error: str | None = None # Surfaced in song_info when audio_url is None
|
||||
stems_payload: list[dict] = []
|
||||
# URL of the single full-mix audio (sloppak `original_audio:`), when the
|
||||
# pack ships one. The stems plugin uses this to play the untouched mix
|
||||
# while every stem slider is at unity; None otherwise (separate stems
|
||||
# only, loose folder, or archive).
|
||||
original_audio_url: str | None = None
|
||||
# URL of the pack's complete mixdown — the RESERVED `full` stem (spec
|
||||
# §5.3), which sloppak.load_song() lifts out of `stems` because it is a
|
||||
# mixdown, not a layer. The stems plugin plays it while every stem slider
|
||||
# is at unity (separation is lossy, so it beats re-summing the stems) and
|
||||
# crosses to the separated stems as soon as one is attenuated.
|
||||
#
|
||||
# None when the pack has no mixdown to offer separately from its stems:
|
||||
# a single-mix pack (its one stem IS the mixdown), a loose folder, or an
|
||||
# archive.
|
||||
full_mix_url: str | None = None
|
||||
if is_loose:
|
||||
# Loose folder filenames are relative paths (artist/album/song).
|
||||
# Hash the *canonical* dlc-relative path (so two URL spellings
|
||||
@@ -365,21 +370,25 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
url = f"/api/sloppak/{q_fn}/file/{quote(s['file'])}"
|
||||
stems_payload.append({"id": s["id"], "url": url, "default": s["default"]})
|
||||
# Full-mix URL (served by the same /api/sloppak/.../file/ endpoint).
|
||||
if loaded_slop is not None and loaded_slop.original_audio:
|
||||
original_audio_url = (
|
||||
f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.original_audio)}"
|
||||
if loaded_slop is not None and loaded_slop.full_mix:
|
||||
full_mix_url = (
|
||||
f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.full_mix)}"
|
||||
)
|
||||
if stems_payload:
|
||||
# Stems present: keep the core <audio> pointed at stem[0]. This
|
||||
# URL is only ever heard in the degraded path (stems plugin
|
||||
# refuses takeover / decode fails); the full-mix↔stems switch is
|
||||
# driven client-side by `original_audio_url`, not `audio_url`.
|
||||
# driven client-side by `full_mix_url`, not `audio_url`.
|
||||
audio_url = stems_payload[0]["url"]
|
||||
elif original_audio_url:
|
||||
elif full_mix_url:
|
||||
# Stem-less full-mix pack: nothing to separate, so play the full
|
||||
# mix natively through the core <audio>. The stems plugin's
|
||||
# onSongReady returns early on an empty stems list (no graph).
|
||||
audio_url = original_audio_url
|
||||
# Reachable only via the deprecated `original_audio:` key, whose
|
||||
# packs put the mixdown outside `stems` — a pack that carries its
|
||||
# mixdown as the `full` stem has it IN `stems`, so it lands in the
|
||||
# branch above with stems_payload == [full].
|
||||
audio_url = full_mix_url
|
||||
else:
|
||||
audio_error = "This sloppak has no playable stems."
|
||||
else:
|
||||
@@ -521,16 +530,31 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
# for the credits overlay, so minigames / synthetic highway uses
|
||||
# (no manifest) never trigger it.
|
||||
"authors": _sanitize_authors(loaded_slop.manifest) if (is_slop and loaded_slop is not None) else [],
|
||||
# Instrument stems ONLY. The pack's complete mixdown (the RESERVED
|
||||
# `full` stem, spec §5.3) is deliberately NOT in this list: consumers
|
||||
# sum `stems` into one mix and render one fader per entry, and the
|
||||
# mixdown is neither a layer nor an instrument — summing it would
|
||||
# double the whole song. It is surfaced separately, below.
|
||||
"stems": stems_payload,
|
||||
# Full-mix audio (sloppak `original_audio:`) served alongside the
|
||||
# separate `stems`. The stems plugin plays this single file while
|
||||
# every stem slider is at unity and switches to the separate stems
|
||||
# the moment one drops below 100%. None when the pack ships stems
|
||||
# only. `has_*` flags mirror the has_drum_tab/has_keys convention so
|
||||
# a client can branch without re-deriving from the URLs.
|
||||
"original_audio_url": original_audio_url,
|
||||
"has_original_audio": bool(original_audio_url),
|
||||
# The complete mixdown, served by the same /api/sloppak/.../file/
|
||||
# endpoint as the stems. The stems plugin plays this single file
|
||||
# while every stem slider is at unity and crosses to the separated
|
||||
# stems the moment one drops below 100% — separation is lossy, so the
|
||||
# mixdown is strictly better audio when nothing is muted. None when
|
||||
# the pack has no mixdown apart from its stems. The `has_*` flags
|
||||
# mirror the has_drum_tab/has_keys convention so a client can branch
|
||||
# without re-deriving from the URLs.
|
||||
"full_mix_url": full_mix_url,
|
||||
"has_full_mix": bool(full_mix_url),
|
||||
"has_stems": bool(stems_payload),
|
||||
# DEPRECATED aliases of the two keys above, kept so a client built
|
||||
# against the old frame keeps working across one release. They were
|
||||
# named after `original_audio:` — a manifest key this repo invented
|
||||
# and the feedpak spec never had (#933). The key is gone; the mixdown
|
||||
# is a stem. Remove these once the shipped stems plugin reads
|
||||
# `full_mix_url` (#945).
|
||||
"original_audio_url": full_mix_url,
|
||||
"has_original_audio": bool(full_mix_url),
|
||||
# Surface a drum_tab presence flag so the visualization picker
|
||||
# can auto-activate the drums plugin even when the chosen
|
||||
# arrangement isn't named "Drums" (drum_tab.json lives next
|
||||
|
||||
+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")
|
||||
|
||||
|
||||
+428
-67
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import zipfile
|
||||
@@ -34,6 +35,21 @@ FEEDPAK_EXT = ".feedpak"
|
||||
SLOPPAK_EXT = ".sloppak"
|
||||
SONG_EXTS = (FEEDPAK_EXT, SLOPPAK_EXT) # accepted on read/discovery
|
||||
|
||||
# ── The full mix ──────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Spec §5.3 RESERVES the stem id `full` for the song's complete mixdown: the
|
||||
# whole song in one file, as heard before source separation. It is a stem — it
|
||||
# lives in `stems` like every other audio file in a pack — but it is a *mixdown,
|
||||
# not a layer*. A reader that sums stems must never include it in the sum: it
|
||||
# already contains every instrument, so summing it doubles the whole song and
|
||||
# muting `guitar` still leaves guitar audible inside it.
|
||||
#
|
||||
# Keeping it matters because separation is lossy: re-summing guitar+bass+drums+
|
||||
# vocals does NOT reproduce the file they came from. The mixdown is the only
|
||||
# faithful rendering of the song a pack can carry, so we play it whenever every
|
||||
# stem sits at unity and nothing is muted.
|
||||
FULL_MIX_STEM_ID = "full"
|
||||
|
||||
import yaml
|
||||
|
||||
from jsonc import load_json
|
||||
@@ -51,6 +67,111 @@ import drums as drums_mod
|
||||
import notation as notation_mod
|
||||
|
||||
|
||||
def find_full_mix(stems: list[dict]) -> dict | None:
|
||||
"""The RESERVED `full` stem (spec §5.3) — the pack's complete mixdown — or None.
|
||||
|
||||
Answers "what is this pack's master audio", which is what fingerprinting
|
||||
wants. For playback use partition_stems() instead: a pack whose *only* stem
|
||||
is `full` has no mixdown to play *separately from* its stems, and this
|
||||
function still returns it.
|
||||
"""
|
||||
return next(
|
||||
(s for s in stems if str(s.get("id", "")) == FULL_MIX_STEM_ID), 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.
|
||||
|
||||
The mixdown is lifted OUT of the stem list because every consumer of `stems`
|
||||
treats that list as layers to sum or to show as mixer channels, and `full` is
|
||||
neither (spec §5.3). Leaving it in is precisely the bug that made the packer
|
||||
invent `original_audio` in the first place: a listed full mix plays on top of
|
||||
the stems.
|
||||
|
||||
A pack whose only stem is `full` is a single-mix pack, not a separated one:
|
||||
there are no instruments to be pristine *against*, so `full` stays the sole
|
||||
playable stem and no mixdown is surfaced. That keeps the freshly-converted
|
||||
single-stem pack — much the most common shape — behaving exactly as before.
|
||||
|
||||
EVERY entry with the reserved id is removed, not just the one we surface. A
|
||||
malformed pack that lists `full` twice would otherwise leave a copy of the
|
||||
whole song behind in the stem list, to be summed with the instruments — the
|
||||
precise failure this function exists to prevent, reintroduced by a duplicate.
|
||||
"""
|
||||
if len(stems) < 2:
|
||||
return None, stems
|
||||
full = find_full_mix(stems)
|
||||
if full is None:
|
||||
return None, stems
|
||||
return full, [s for s in stems if str(s.get("id", "")) != FULL_MIX_STEM_ID]
|
||||
|
||||
|
||||
def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
|
||||
"""Full mix from the DEPRECATED `original_audio:` manifest key, or None.
|
||||
|
||||
Before feedpak 1.15.0 reserved `full`, §5.3 said the mixdown was "commonly
|
||||
replaced" by the per-instrument stems on splitting — so it had nowhere to
|
||||
live, and this repo invented a top-level key pointing at a parallel
|
||||
`original/` directory (#583) to hold it. That key was never in the spec, and
|
||||
#933 removed our dependence on it: the mixdown is a stem.
|
||||
|
||||
We still READ it, because every pack written before the spec caught up
|
||||
carries `original_audio: original/full.ogg` and would otherwise lose its full
|
||||
mix. We never write it. Delete this once those packs are migrated (#945);
|
||||
`tools/migrate_full_mix_stem.py` is the migration.
|
||||
|
||||
NOTE the string literal below. tools/check_spec_conformance.py AST-scans for
|
||||
`manifest.get("<literal>")` to prove every manifest key core reads is one the
|
||||
spec declares. Hoisting "original_audio" into a named constant would hide
|
||||
this read from that scan — the gate would conclude core no longer touches the
|
||||
key, and the grandfather entry that documents this debt would go stale. The
|
||||
literal is what keeps the deprecation honest and visible to CI. Leave it.
|
||||
|
||||
Same permissive, path-traversal-guarded posture as the optional side-files: a
|
||||
missing / escaping / unreadable file leaves the pack without a full mix (the
|
||||
player falls back to the separated stems) rather than aborting the load.
|
||||
Returns the manifest-relative string, so callers build its URL exactly as
|
||||
they build a stem's.
|
||||
"""
|
||||
rel_raw = manifest.get("original_audio")
|
||||
if not isinstance(rel_raw, str) or not rel_raw.strip():
|
||||
return None
|
||||
rel = rel_raw.strip()
|
||||
try:
|
||||
target = (source_dir / rel).resolve()
|
||||
target.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
|
||||
return None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
|
||||
return None
|
||||
if not target.is_file():
|
||||
return None
|
||||
log.info(
|
||||
"sloppak: pack uses the deprecated `original_audio:` key (%r) — the full mix "
|
||||
"is a stem (id `full`, feedpak spec §5.3). Re-pack with "
|
||||
"tools/migrate_full_mix_stem.py; support for this key will be removed.",
|
||||
rel,
|
||||
)
|
||||
return rel
|
||||
|
||||
|
||||
# ── Format detection ──────────────────────────────────────────────────────────
|
||||
|
||||
def is_sloppak(path: Path) -> bool:
|
||||
@@ -81,6 +202,116 @@ _unpack_semaphore = threading.BoundedSemaphore(_UNPACK_MAX_CONCURRENCY)
|
||||
_unpack_locks: dict[str, threading.Lock] = {}
|
||||
_unpack_locks_guard = threading.Lock()
|
||||
|
||||
# Destinations with an unpack in flight right now. Eviction MUST skip these: two
|
||||
# unpacks run concurrently, so one finishing could otherwise rmtree the other's
|
||||
# half-written directory and leave that resolver caching an incomplete song.
|
||||
_unpacking: set[Path] = set()
|
||||
_unpacking_guard = threading.Lock()
|
||||
|
||||
# Cap the unpack cache. Stems are already-compressed audio, so an unpacked song
|
||||
# is ~1.1x its zip — the cache is effectively a second, DECOMPRESSED copy of
|
||||
# every song it holds, and it used to grow without any bound at all. A tester
|
||||
# reached 60 GB from a 1800-song library: their whole library, unpacked, because
|
||||
# one caller looped the library calling load_song(). Nothing ever deleted any of
|
||||
# it — not even when the song itself was deleted.
|
||||
#
|
||||
# Default 4 GB ≈ 130 average songs of recency, which is far more than the "the
|
||||
# song I'm playing, and the last few I played" that this cache actually exists
|
||||
# to serve. Override with FEEDBACK_SLOPPAK_CACHE_MAX_MB (0 disables eviction).
|
||||
def _unpack_cache_cap_bytes() -> int:
|
||||
raw = os.environ.get("FEEDBACK_SLOPPAK_CACHE_MAX_MB", "").strip()
|
||||
try:
|
||||
mb = int(raw) if raw else 4096
|
||||
except ValueError:
|
||||
mb = 4096
|
||||
return max(0, mb) * 1024 * 1024
|
||||
|
||||
|
||||
def _dir_size(path: Path) -> int:
|
||||
total = 0
|
||||
for f in path.rglob("*"):
|
||||
try:
|
||||
if f.is_file():
|
||||
total += f.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def _touch(path: Path) -> None:
|
||||
"""Bump mtime so the LRU sweep below treats this song as recently used.
|
||||
|
||||
Reading files out of an unpacked dir doesn't change the DIRECTORY's mtime,
|
||||
so without this the song you are actively playing looks as stale as one you
|
||||
unpacked days ago — and a burst of unpacks could evict it mid-song.
|
||||
"""
|
||||
try:
|
||||
os.utime(path, None)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _evict_unpack_cache(root: Path, keep: Path | None = None) -> None:
|
||||
"""Bound the unpack cache: drop least-recently-used songs until under the cap.
|
||||
|
||||
`keep` is never evicted — it's the song the caller just resolved, i.e. almost
|
||||
certainly the one about to be played.
|
||||
|
||||
Evicting a directory MUST also drop its `_source_cache` entry. Otherwise
|
||||
get_cached_source_dir() keeps handing out a path that no longer exists and
|
||||
the media route 404s on every stem instead of re-unpacking (it only falls
|
||||
back to resolve_source_dir when the cache returns None).
|
||||
"""
|
||||
cap = _unpack_cache_cap_bytes()
|
||||
if cap <= 0:
|
||||
return
|
||||
try:
|
||||
entries = []
|
||||
total = 0
|
||||
for d in root.iterdir():
|
||||
if not d.is_dir():
|
||||
continue
|
||||
try:
|
||||
size = _dir_size(d)
|
||||
mtime = d.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
entries.append((mtime, size, d))
|
||||
total += size
|
||||
if total <= cap:
|
||||
return
|
||||
|
||||
keep_resolved = keep.resolve() if keep else None
|
||||
entries.sort(key=lambda e: e[0]) # oldest first
|
||||
for _mtime, size, d in entries:
|
||||
if total <= cap:
|
||||
break
|
||||
try:
|
||||
if keep_resolved and d.resolve() == keep_resolved:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
# Check-and-delete under ONE hold of the guard. Releasing between the
|
||||
# two would let a resolver mark this dest in-flight and start writing
|
||||
# into it in the gap, and we'd rmtree a song mid-unpack. A resolver
|
||||
# that blocks here simply proceeds afterwards — _unpack_zip recreates
|
||||
# the directory anyway.
|
||||
with _unpacking_guard:
|
||||
if d in _unpacking:
|
||||
continue # another thread is writing this
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
if d.exists():
|
||||
continue # couldn't remove — don't claim the bytes back
|
||||
total -= size
|
||||
with _source_lock:
|
||||
for fn, (cached_dir, _m, _s) in list(_source_cache.items()):
|
||||
if cached_dir == d:
|
||||
_source_cache.pop(fn, None)
|
||||
log.info("sloppak: evicted %s from the unpack cache (%.0f MB)",
|
||||
d.name, size / 1e6)
|
||||
except OSError:
|
||||
log.warning("sloppak: unpack-cache eviction failed", exc_info=True)
|
||||
|
||||
|
||||
def _unpack_lock_for(filename: str) -> threading.Lock:
|
||||
"""Return a stable per-file lock so concurrent unpacks of the same sloppak
|
||||
@@ -145,10 +376,17 @@ def resolve_source_dir(
|
||||
re-unpacks if mtime/size changed, then returns that dir.
|
||||
|
||||
Caches the resolution so subsequent calls are ~free.
|
||||
|
||||
NOTE: this writes the WHOLE pack — every stem — to disk. Only call it for a
|
||||
song you are about to play. To read a *part* of a song (an arrangement, the
|
||||
lyrics, a tone blob), use read_member_bytes(): unpacking a pack to read a few
|
||||
KB of JSON is ~45x write amplification, and doing it in a loop over the
|
||||
library fills the disk with a decompressed copy of every song.
|
||||
"""
|
||||
path = dlc_root / filename
|
||||
stat = path.stat()
|
||||
mtime, size = stat.st_mtime, stat.st_size
|
||||
guarded: Path | None = None # a dir WE unpacked, shielded from eviction
|
||||
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
@@ -159,42 +397,76 @@ def resolve_source_dir(
|
||||
and cached_size == size
|
||||
and cached_dir.exists()
|
||||
):
|
||||
# Mark it recently-used before returning — see _touch().
|
||||
if cached_dir != path:
|
||||
_touch(cached_dir)
|
||||
return cached_dir
|
||||
|
||||
if path.is_dir():
|
||||
resolved = path
|
||||
else:
|
||||
# Zip form — unpack to the cache. Serialize per-file (so concurrent
|
||||
# callers don't rmtree + re-extract the same dest at once) and cap
|
||||
# global unpack concurrency (so a burst can't saturate disk/CPU).
|
||||
dest = unpack_cache_root / _safe_id(filename)
|
||||
with _unpack_lock_for(filename):
|
||||
# Re-check the cache inside the per-file lock — a prior holder may
|
||||
# have just finished unpacking this exact (mtime, size).
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
if (
|
||||
cached
|
||||
and cached[1] == mtime
|
||||
and cached[2] == size
|
||||
and cached[0].exists()
|
||||
):
|
||||
resolved = cached[0]
|
||||
else:
|
||||
with _unpack_semaphore:
|
||||
_unpack_zip(path, dest)
|
||||
resolved = dest
|
||||
try:
|
||||
if path.is_dir():
|
||||
resolved = path
|
||||
else:
|
||||
# Zip form — unpack to the cache. Serialize per-file (so concurrent
|
||||
# callers don't rmtree + re-extract the same dest at once) and cap
|
||||
# global unpack concurrency (so a burst can't saturate disk/CPU).
|
||||
dest = unpack_cache_root / _safe_id(filename)
|
||||
with _unpack_lock_for(filename):
|
||||
# Re-check the cache inside the per-file lock — a prior holder may
|
||||
# have just finished unpacking this exact (mtime, size).
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
if (
|
||||
cached
|
||||
and cached[1] == mtime
|
||||
and cached[2] == size
|
||||
and cached[0].exists()
|
||||
):
|
||||
resolved = cached[0]
|
||||
else:
|
||||
# Shield `dest` from eviction from the moment we start writing
|
||||
# until it is safely in _source_cache. `keep` only shields it
|
||||
# from OUR OWN sweep — a concurrent resolver sweeping with a
|
||||
# different `keep` would delete it, and we would then cache and
|
||||
# return a path that no longer exists. The `finally` below
|
||||
# releases it on EVERY exit, including a failed unpack: leaving
|
||||
# a dest marked in-flight would make it un-evictable forever.
|
||||
with _unpacking_guard:
|
||||
_unpacking.add(dest)
|
||||
guarded = dest
|
||||
with _unpack_semaphore:
|
||||
_unpack_zip(path, dest)
|
||||
resolved = dest
|
||||
# The only moment this cache grows. Sweep here rather than on a
|
||||
# timer so it can never drift far past the cap.
|
||||
_evict_unpack_cache(unpack_cache_root, keep=dest)
|
||||
|
||||
with _source_lock:
|
||||
_source_cache[filename] = (resolved, mtime, size)
|
||||
return resolved
|
||||
with _source_lock:
|
||||
_source_cache[filename] = (resolved, mtime, size)
|
||||
return resolved
|
||||
finally:
|
||||
if guarded is not None:
|
||||
with _unpacking_guard:
|
||||
_unpacking.discard(guarded)
|
||||
|
||||
|
||||
def get_cached_source_dir(filename: str) -> Path | None:
|
||||
"""Return the cached source dir for a sloppak if one is known."""
|
||||
"""Return the cached source dir for a sloppak if one is known AND still there.
|
||||
|
||||
The existence check is load-bearing: callers (media.py) only fall back to
|
||||
resolve_source_dir() when this returns None, so handing back a path that has
|
||||
been evicted — or that the user deleted by hand to reclaim disk — would 404
|
||||
every stem for the rest of the process instead of re-unpacking.
|
||||
"""
|
||||
with _source_lock:
|
||||
cached = _source_cache.get(filename)
|
||||
return cached[0] if cached else None
|
||||
if not cached:
|
||||
return None
|
||||
src = cached[0]
|
||||
if not src.is_dir():
|
||||
_source_cache.pop(filename, None)
|
||||
return None
|
||||
_touch(src)
|
||||
return src
|
||||
|
||||
|
||||
# ── Manifest + song loading ───────────────────────────────────────────────────
|
||||
@@ -233,6 +505,82 @@ def load_manifest(path: Path) -> dict:
|
||||
return _read_manifest_from_zip(path)
|
||||
|
||||
|
||||
_ZIP_ROOT = Path("/_root").resolve()
|
||||
|
||||
|
||||
def _zip_member_key(name: str) -> str | None:
|
||||
"""Canonical lookup key for a zip member name, or None if it escapes the root.
|
||||
|
||||
Collapses './', 'a/../b' and backslash separators — the same normalization
|
||||
_unpack_zip()/safe_join() apply when extracting. Both the name the caller asks
|
||||
for AND the names the archive actually stores must go through this, or a pack
|
||||
that stores './arrangements/lead.json' unpacks fine but reads back as missing.
|
||||
"""
|
||||
safe = safe_join(_ZIP_ROOT, name or "")
|
||||
# None → escapes the root; == root → a degenerate name like "." or "a/..".
|
||||
if safe is None or safe == _ZIP_ROOT:
|
||||
return None
|
||||
return safe.relative_to(_ZIP_ROOT).as_posix()
|
||||
|
||||
|
||||
def read_member_bytes(path: Path, rel: str) -> bytes | None:
|
||||
"""Return the bytes of ONE file inside a sloppak, or None if it isn't there.
|
||||
|
||||
For a zipped sloppak this opens that single member instead of unpacking the
|
||||
archive — the same trick read_cover_bytes() uses to keep the library grid
|
||||
from exploding every pack just to show a cover.
|
||||
|
||||
Reach for this whenever you want a *part* of a song (an arrangement's JSON,
|
||||
the lyrics, a tone blob) rather than a song you're about to play. The
|
||||
alternative, load_song(), calls resolve_source_dir() and writes the WHOLE
|
||||
pack — every stem — into the unpack cache. That is a ~45x write amplification
|
||||
when all you wanted was a few KB of JSON, and looping the library on it
|
||||
unpacks the entire library (got-feedBack/feedBack: a tester hit 60 GB that
|
||||
way). Stems are already-compressed audio, so an unpacked song is ~1.1x its
|
||||
zip: the cache becomes a second, decompressed copy of everything it touches.
|
||||
"""
|
||||
rel = (rel or "").strip()
|
||||
if not rel:
|
||||
return None
|
||||
|
||||
if path.is_dir():
|
||||
target = safe_join(path.resolve(), rel)
|
||||
if target is None or not target.is_file():
|
||||
return None
|
||||
try:
|
||||
return target.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# Zip form — read just that member, no unpack. Zip-slip is rejected before we
|
||||
# open anything, and both sides of the comparison are normalized, so a
|
||||
# non-canonical-but-valid name ('./arrangements/lead.json') resolves the same
|
||||
# way it did when we unpacked first.
|
||||
member = _zip_member_key(rel)
|
||||
if member is None:
|
||||
log.warning("sloppak: rejected unsafe member name %r in %r", rel, path)
|
||||
return None
|
||||
try:
|
||||
with zipfile.ZipFile(str(path), "r") as zf:
|
||||
# Match on the NORMALIZED stored name, and take the LAST match — the
|
||||
# archive may store './x' or a backslash path (Windows tooling), and
|
||||
# if it stores two names that normalize to the same file, _unpack_zip
|
||||
# writes them in order so the last one wins. Reading the raw member by
|
||||
# exact name would miss the first case and return the wrong bytes in
|
||||
# the second. A pack has a handful of members; the scan is free.
|
||||
info = None
|
||||
for cand in zf.infolist():
|
||||
if _zip_member_key(cand.filename) == member:
|
||||
info = cand
|
||||
if info is None or info.is_dir():
|
||||
return None
|
||||
with zf.open(info) as f:
|
||||
return f.read()
|
||||
except (zipfile.BadZipFile, OSError, RuntimeError) as e:
|
||||
log.warning("sloppak: failed to read %r from %s: %s", rel, path.name, e)
|
||||
return None
|
||||
|
||||
|
||||
_COVER_MEDIA_TYPES = {
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".png": "image/png", ".webp": "image/webp",
|
||||
@@ -367,14 +715,21 @@ class LoadedSloppak:
|
||||
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
|
||||
# absent so indexing by song.arrangements index is safe.
|
||||
arrangement_ids: list[str | None] = field(default_factory=list)
|
||||
# Manifest-relative path to the single full-mix audio file, taken from the
|
||||
# manifest `original_audio:` key (e.g. "original/full.ogg"). This is the
|
||||
# pre-separation mixdown that exists alongside the per-instrument `stems`.
|
||||
# None when the key is absent, points outside source_dir, or the file is
|
||||
# missing on disk. Served to the front-end via the highway WS as
|
||||
# `original_audio_url`; the stems plugin uses it to play the untouched mix
|
||||
# when every stem slider is at unity (and the separate stems otherwise).
|
||||
original_audio: str | None = None
|
||||
# Manifest-relative path to the pack's complete mixdown — the whole song in
|
||||
# one file, as heard before source separation. This is the RESERVED `full`
|
||||
# stem (spec §5.3), lifted out of `stems` above precisely because it is NOT
|
||||
# an instrument layer: summing it with the per-instrument stems it was split
|
||||
# into would double the entire song. See partition_stems().
|
||||
#
|
||||
# None when the pack has no mixdown to offer *separately* from its stems —
|
||||
# which includes the common single-mix pack, whose only stem IS the mixdown
|
||||
# (there is nothing to be pristine against, so it stays in `stems`).
|
||||
#
|
||||
# Served to the front-end via the highway WS as `full_mix_url`; the stems
|
||||
# plugin plays it while every stem slider sits at unity and crosses to the
|
||||
# separated stems the moment one drops below 100% — demucs recombination is
|
||||
# lossy, so the mixdown is strictly the better audio when nothing is muted.
|
||||
full_mix: str | None = None
|
||||
|
||||
|
||||
def load_song(
|
||||
@@ -759,12 +1114,18 @@ 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
|
||||
# chips, the WS payload — sums it with, or lists it beside, the instruments
|
||||
# it was separated into. `full_mix_stem` is None for a single-mix pack,
|
||||
# whose only stem IS the mixdown and stays in the list.
|
||||
full_mix_stem, stems = partition_stems(stems)
|
||||
|
||||
# Optional keys.json — song-level, instrument-independent key/scale track
|
||||
# (manifest `keys:` key, spec §7.7). Permissive like the other side-files:
|
||||
@@ -828,28 +1189,22 @@ def load_song(
|
||||
}
|
||||
|
||||
_fpv = manifest.get("feedpak_version")
|
||||
# Optional full-mix audio — manifest `original_audio:` key. The single
|
||||
# pre-separation mixdown that ships alongside the per-instrument stems.
|
||||
# Same permissive, path-traversal-guarded posture as drum_tab above: a
|
||||
# missing/escaping/absent file simply leaves the full mix unavailable (the
|
||||
# player falls back to the separate stems) rather than aborting the load.
|
||||
# We store the manifest-relative string so server.py can build its URL the
|
||||
# same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint).
|
||||
original_audio_data: str | None = None
|
||||
original_audio_rel = manifest.get("original_audio")
|
||||
if isinstance(original_audio_rel, str) and original_audio_rel.strip():
|
||||
rel = original_audio_rel.strip()
|
||||
try:
|
||||
oa_path = (source_dir / rel).resolve()
|
||||
oa_path.relative_to(source_dir.resolve())
|
||||
except ValueError:
|
||||
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
|
||||
oa_path = None
|
||||
except OSError as e:
|
||||
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
|
||||
oa_path = None
|
||||
if oa_path is not None and oa_path.is_file():
|
||||
original_audio_data = rel
|
||||
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
|
||||
# above (spec §5.3) — no path work needed, it was validated with the other
|
||||
# stems and its URL is built the same way. Only when the pack has no `full`
|
||||
# stem do we fall back to the DEPRECATED `original_audio:` key, which is the
|
||||
# shape every pack written before feedpak 1.15.0 uses.
|
||||
if full_mix_stem is not None:
|
||||
full_mix_data: str | None = full_mix_stem["file"]
|
||||
elif find_full_mix(stems) is not None:
|
||||
# Single-mix pack: its ONE stem is the mixdown, so there is no mixdown to
|
||||
# offer *apart from* the stems. Never fall through to the legacy key here
|
||||
# — a pack that both carries a `full` stem and names the old key would
|
||||
# otherwise surface the mixdown twice (once as the stem the player is
|
||||
# already playing, once as a "pristine" track to cross to).
|
||||
full_mix_data = None
|
||||
else:
|
||||
full_mix_data = _legacy_full_mix(manifest, source_dir)
|
||||
|
||||
return LoadedSloppak(
|
||||
song=song,
|
||||
@@ -864,7 +1219,7 @@ def load_song(
|
||||
keys=keys_data,
|
||||
notation_by_id=notation_by_id_data,
|
||||
arrangement_ids=arrangement_ids_acc,
|
||||
original_audio=original_audio_data,
|
||||
full_mix=full_mix_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -909,7 +1264,7 @@ def extract_meta(path: Path) -> dict:
|
||||
tuning_offsets = _tuning_for_meta(arr_list)
|
||||
|
||||
stems_list = manifest.get("stems", []) or []
|
||||
stem_ids: list[str] = []
|
||||
valid_stems: list[dict] = []
|
||||
for s in stems_list:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
@@ -923,7 +1278,13 @@ def extract_meta(path: Path) -> dict:
|
||||
isinstance(sid, str) and sid
|
||||
and isinstance(sfile, str) and sfile
|
||||
):
|
||||
stem_ids.append(sid)
|
||||
valid_stems.append({"id": sid, "file": sfile})
|
||||
# Partition exactly as load_song() does, for the same reason the library
|
||||
# filter must not lie: `full` is the mixdown, not an instrument (spec §5.3).
|
||||
# A separated pack that retains it would otherwise offer the user a "full"
|
||||
# stem chip alongside guitar/bass/drums and count it as a seventh stem.
|
||||
_full, instrument_stems = partition_stems(valid_stems)
|
||||
stem_ids = [s["id"] for s in instrument_stems]
|
||||
stem_count = len(stem_ids)
|
||||
|
||||
return {
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
.pp-inst-plus { color: #6b7280; }
|
||||
|
||||
/* Leather covers — per-instrument hue, embossed with layered shadows and a
|
||||
subtle grain gradient (no image assets). */
|
||||
subtle grain gradient (no image assets). Keep the hex pairs in sync with
|
||||
PP_LEATHER_HEX in screen.js (the canvas card draws the same leather). */
|
||||
.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); }
|
||||
.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); }
|
||||
.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); }
|
||||
@@ -524,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;
|
||||
@@ -534,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;
|
||||
@@ -559,3 +571,221 @@
|
||||
/* The hover glint is motion theatrics too — not just the JS tilt. */
|
||||
.pp-tilt::after { display: none; }
|
||||
}
|
||||
|
||||
/* Practice invitations — closest stamps + bring-these-up */
|
||||
.pp-closest {
|
||||
border: 1px solid rgba(75, 85, 99, 0.45);
|
||||
border-radius: 0.6rem;
|
||||
background: linear-gradient(165deg, rgba(45, 55, 72, 0.4), rgba(31, 41, 55, 0.4));
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.pp-closest-head {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.pp-closest-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
.pp-closest-row:hover { background: rgba(55, 65, 81, 0.5); }
|
||||
.pp-closest-genre { color: #e5e7eb; font-weight: 600; white-space: nowrap; }
|
||||
.pp-closest-ask { color: #9ca3af; font-size: 0.72rem; }
|
||||
.pp-closest-ask em { color: #cbd5e1; font-style: italic; }
|
||||
|
||||
.pp-nearest { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
|
||||
.pp-nearest-head {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #8a7a5e;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.pp-nearest-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
|
||||
.pp-nearest-row em { color: #3f3428; }
|
||||
/* ── Career surfaces outside the plugin: profile wall + home card ───────── */
|
||||
|
||||
.pp-wall { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.pp-wall-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.pp-wall-meta { color: #9ca3af; font-size: 0.7rem; font-weight: 400; }
|
||||
.pp-wall-shelf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid rgba(75, 85, 99, 0.25);
|
||||
}
|
||||
.pp-wall-inst {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
min-width: 3.6rem;
|
||||
}
|
||||
.pp-wall-cover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1rem;
|
||||
width: 4.2rem;
|
||||
height: 5.6rem;
|
||||
border-radius: 0.3rem 0.45rem 0.45rem 0.3rem;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07),
|
||||
inset 0.25rem 0 0.4rem -0.25rem rgba(0, 0, 0, 0.8),
|
||||
0 3px 8px rgba(0, 0, 0, 0.4);
|
||||
padding: 0.3rem;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.pp-wall-cover:hover { transform: translateY(-3px); }
|
||||
.pp-wall-cover span {
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(240, 226, 195, 0.9);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
.pp-wall-cover em {
|
||||
font-size: 0.42rem;
|
||||
letter-spacing: 0.22em;
|
||||
font-style: normal;
|
||||
color: #d9a253;
|
||||
}
|
||||
.pp-wall-none { font-size: 0.7rem; color: #6b7280; font-style: italic; }
|
||||
.pp-wall-link {
|
||||
align-self: flex-end;
|
||||
font-size: 0.72rem;
|
||||
color: #22d3ee;
|
||||
padding: 0.15rem 0.3rem;
|
||||
}
|
||||
.pp-wall-link:hover { text-decoration: underline; }
|
||||
|
||||
/* The home-page career card — a trading card among stat tiles. */
|
||||
.pp-dash-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(217, 162, 83, 0.35);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(92, 35, 33, 0.85), rgba(30, 27, 34, 0.92)),
|
||||
linear-gradient(160deg, #2b1414, #17111c);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.pp-dash-card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5); }
|
||||
.pp-dash-shine {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(105deg, transparent 42%, rgba(255, 223, 128, 0.18) 50%, transparent 58%);
|
||||
transform: translateX(-130%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pp-dash-card:hover .pp-dash-shine { animation: pp-foil 1.4s ease-out; }
|
||||
.pp-dash-head {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: #d9a253;
|
||||
}
|
||||
.pp-dash-badges { color: #f3ead2; font-size: 1.05rem; }
|
||||
.pp-dash-badges b { font-weight: 700; margin: 0 0.25rem 0 0.35rem; }
|
||||
.pp-dash-meta { color: #b5a488; font-size: 0.72rem; }
|
||||
.pp-dash-ask { color: #8d9aa8; font-size: 0.66rem; }
|
||||
.pp-dash-ask em { color: #cbd5e1; }
|
||||
|
||||
.pp-card-actions { display: flex; gap: 0.5rem; margin-top: 0.9rem; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.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,19 @@
|
||||
"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"] },
|
||||
{ "key": "jazz", "match": ["jazz", "bebop", "swing", "bossa"] },
|
||||
{ "key": "funk", "match": ["funk", "disco"] },
|
||||
{ "key": "rock", "match": ["rock", "punk", "grunge", "shoegaze"] }
|
||||
],
|
||||
"genres": {
|
||||
"blues": { "virtuoso_nodes": { "guitar": ["blues_shuffle"] } },
|
||||
"rock": { "virtuoso_nodes": { "guitar": ["rock_power_backbeat"] } },
|
||||
|
||||
+344
-11
@@ -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 = {
|
||||
@@ -114,10 +122,9 @@ def _stars():
|
||||
detail = []
|
||||
for filename, acc, title, artist in rows:
|
||||
acc = acc or 0.0
|
||||
stars = sum(1 for t in thresholds if acc >= t)
|
||||
stars, next_at = _star_progress(acc, thresholds)
|
||||
if stars:
|
||||
per_song[filename] = stars
|
||||
next_at = next((t for t in thresholds if acc < t), None)
|
||||
detail.append({
|
||||
"filename": filename,
|
||||
"title": title or filename,
|
||||
@@ -249,10 +256,18 @@ def _played_by_instrument_genre():
|
||||
for stub in stubs.values():
|
||||
acc = stub["best_accuracy"]
|
||||
stub["best_accuracy"] = round(acc, 4)
|
||||
stub["stars"] = sum(1 for t in thresholds if acc >= t)
|
||||
stub["stars"], stub["next_star_at"] = _star_progress(acc, thresholds)
|
||||
return out, seconds
|
||||
|
||||
|
||||
def _star_progress(acc, thresholds):
|
||||
"""(stars, next_star_at) — the one place the ascending-thresholds
|
||||
assumption lives; _stars() and the passport stubs both use it."""
|
||||
stars = sum(1 for t in thresholds if acc >= t)
|
||||
next_at = next((t for t in thresholds if acc < t), None)
|
||||
return stars, next_at
|
||||
|
||||
|
||||
def _library_genres():
|
||||
"""Distinct effective genres across the live library (the brochure rack)."""
|
||||
db = _state["meta_db"]
|
||||
@@ -276,12 +291,33 @@ def _library_genres():
|
||||
key=lambda r: (-r["songs_in_library"], r["genre_key"]))
|
||||
|
||||
|
||||
def _genre_family(gkey):
|
||||
"""First family whose keyword appears in the genre key (substring — MB's
|
||||
vocabulary is open: 'metalcore' must hit the 'metal' family without an
|
||||
exact alias). List order decides ambiguity: families are checked top to
|
||||
bottom, so 'blues rock' lands on whichever of blues/rock is listed first."""
|
||||
for fam in _state["passports_content"].get("families") or []:
|
||||
if not isinstance(fam, dict):
|
||||
continue
|
||||
for kw in fam.get("match") or []:
|
||||
if isinstance(kw, str) and kw and kw in gkey:
|
||||
return fam.get("key")
|
||||
return None
|
||||
|
||||
|
||||
def _badge_requirement(gkey, instrument="guitar"):
|
||||
cfg = _state["passports_content"]
|
||||
req = dict(cfg.get("badge_requirement") or {})
|
||||
req.setdefault("songs", 5)
|
||||
req.setdefault("min_stars", 2)
|
||||
override = (cfg.get("genres") or {}).get(gkey)
|
||||
# Exact per-genre override wins; otherwise the genre inherits its FAMILY's
|
||||
# requirement — so 'death metal' / 'metalcore' passports carry the metal
|
||||
# drill without curating every MB sub-genre by hand.
|
||||
genres_cfg = cfg.get("genres") or {}
|
||||
override = genres_cfg.get(gkey)
|
||||
if not isinstance(override, dict):
|
||||
family = _genre_family(gkey)
|
||||
override = genres_cfg.get(family) if family else None
|
||||
if isinstance(override, dict):
|
||||
req.update(override)
|
||||
# virtuoso_nodes: {instrument: [node_ids]} — a passport only carries its
|
||||
@@ -299,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):
|
||||
@@ -336,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
|
||||
@@ -355,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")
|
||||
@@ -382,9 +430,33 @@ 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
|
||||
# QUALIFYING bar (the badge ask), nearest first — invitation
|
||||
# data, the UI voices it without meters.
|
||||
thresholds = _state["content"]["star_accuracy_thresholds"]
|
||||
bar = (thresholds[req["min_stars"] - 1]
|
||||
if 0 < req["min_stars"] <= len(thresholds) else None)
|
||||
nearest = [] if bar is None else sorted(
|
||||
(s for s in songs if not s["qualifies"]),
|
||||
key=lambda s: bar - s["best_accuracy"])[:3]
|
||||
for s in nearest:
|
||||
s["bar_at"] = bar
|
||||
passports.append({
|
||||
"genre_key": gkey,
|
||||
"genre": meta.get("genre") or gkey,
|
||||
@@ -393,13 +465,18 @@ def _passports_view():
|
||||
"graded": is_graded,
|
||||
"songs": songs,
|
||||
"qualifying_count": qualifying,
|
||||
"nearest": nearest,
|
||||
# Honest hours odometer (Stage 5 post-cap): a true fact that
|
||||
# only grows — never a target, never a meter.
|
||||
"seconds_total": round(played_seconds.get((inst, gkey), 0.0), 1),
|
||||
"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 {},
|
||||
@@ -414,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"
|
||||
@@ -585,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
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<div id="career-tab-passports" class="hidden" role="tabpanel" aria-labelledby="career-tab-btn-passports">
|
||||
<p class="text-sm text-gray-400 mb-4">Commit to an instrument, pick a genre, and stamp your way to its badge — five ★★ songs mint a Bronze. Your passport wall is who you are as a musician.</p>
|
||||
<div id="pp-instruments" class="pp-instruments"></div>
|
||||
<div id="pp-closest" class="mt-4"></div>
|
||||
<div id="pp-shelf-wrap" class="mt-5">
|
||||
<div id="pp-shelf" class="pp-shelf"></div>
|
||||
</div>
|
||||
|
||||
+731
-38
@@ -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);
|
||||
}
|
||||
@@ -458,6 +476,8 @@
|
||||
_pp = view;
|
||||
detectNewBadges(view);
|
||||
renderPassports();
|
||||
renderProfileWall();
|
||||
renderDashCard();
|
||||
if (!_ppBootstrapped) {
|
||||
_ppBootstrapped = true;
|
||||
// Sync the local drill snapshot once per session — drill progress
|
||||
@@ -479,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);
|
||||
@@ -497,6 +517,56 @@
|
||||
</button>`;
|
||||
}
|
||||
|
||||
// Practice invitations: which stamps are closest, and what would bring
|
||||
// them home. Invitations only — no meters, no obligations.
|
||||
|
||||
// Floor, never round: 74.9% must not display as the already-met "75%".
|
||||
function pct(frac) { return Math.floor((Number(frac) || 0) * 100); }
|
||||
|
||||
function ppNeed(p) {
|
||||
return Math.max(0, ((p.requirement || {}).songs || 0) - (p.qualifying_count || 0));
|
||||
}
|
||||
|
||||
// The one blocker phrase — shared by the Closest-stamps strip and the
|
||||
// passport book's invite line so they can never contradict each other.
|
||||
function ppAskHTML(p, withHint) {
|
||||
const req = p.requirement || {};
|
||||
const need = ppNeed(p);
|
||||
const starGl = '★'.repeat(req.min_stars || 0);
|
||||
if (need > 0) {
|
||||
const near = withHint ? (p.nearest || [])[0] : null;
|
||||
const hint = near
|
||||
? ` · nearest: <em>${esc(near.title)}</em> at ${pct(near.best_accuracy)}%`
|
||||
: '';
|
||||
return `${need === 1 ? `one more ${starGl} song` : `${need} more ${starGl} songs`}${hint}`;
|
||||
}
|
||||
const labels = ((_pp && _pp.config) || {}).drill_labels || {};
|
||||
const drills = p.drills || {};
|
||||
const pending = (drills.required || []).filter((n) => !(drills.cleared || []).includes(n));
|
||||
return `clear ${pending.map((n) => esc(labels[n] || n)).join(', ') || 'the genre drill'} in Virtuoso`;
|
||||
}
|
||||
|
||||
function closestLineHTML(p) {
|
||||
return `<button class="pp-closest-row" data-pp-open="${esc(p.genre_key)}">
|
||||
<span class="pp-closest-genre">${esc(p.genre)}</span>
|
||||
<span class="pp-closest-ask">${ppAskHTML(p, true)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function renderClosest(inst, data) {
|
||||
const host = $('pp-closest');
|
||||
if (!host) return;
|
||||
const candidates = (data.passports || [])
|
||||
.filter((p) => p.badge === 'in_progress')
|
||||
.sort((a, b) => ppNeed(a) - ppNeed(b))
|
||||
.slice(0, 3);
|
||||
if (!candidates.length) { host.innerHTML = ''; return; }
|
||||
host.innerHTML = `<div class="pp-closest">
|
||||
<div class="pp-closest-head">Closest stamps</div>
|
||||
${candidates.map(closestLineHTML).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderShelf(inst, data) {
|
||||
const shelf = $('pp-shelf');
|
||||
if (!shelf) return;
|
||||
@@ -545,12 +615,13 @@
|
||||
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>'}
|
||||
</button>`;
|
||||
}).join('');
|
||||
renderClosest(inst, data);
|
||||
renderShelf(inst, data);
|
||||
renderRack(inst, data);
|
||||
}
|
||||
@@ -576,39 +647,36 @@
|
||||
|
||||
function ppBookHTML(inst, p, pendingSlam) {
|
||||
const req = p.requirement || {};
|
||||
const need = Math.max(0, (req.songs || 0) - p.qualifying_count);
|
||||
const starGl = '★'.repeat(req.min_stars || 0);
|
||||
const reqNodes = (p.drills || {}).required || [];
|
||||
const clearedNodes = new Set((p.drills || {}).cleared || []);
|
||||
const labels = ((_pp && _pp.config) || {}).drill_labels || {};
|
||||
const pendingDrills = reqNodes.filter((n) => !clearedNodes.has(n));
|
||||
// The invite names what actually blocks the stamp: songs first, then
|
||||
// the genre drill once the song bar is met.
|
||||
let invite;
|
||||
if (need > 0) {
|
||||
invite = need === 1 ? `One more ${starGl} song mints this stamp.`
|
||||
: `${need} more ${starGl} songs mint this stamp.`;
|
||||
} else {
|
||||
const names = pendingDrills.map((n) => labels[n] || n).join(', ');
|
||||
invite = `Clear ${names || 'the genre drill'} in Virtuoso to mint this stamp.`;
|
||||
}
|
||||
// The invite names what actually blocks the stamp — same shared
|
||||
// phrase as the Closest-stamps strip, so they can't contradict.
|
||||
const invite = `${ppAskHTML(p, false)} mints this stamp.`;
|
||||
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>
|
||||
</div>`;
|
||||
} else {
|
||||
const fill = (ppFillFraction(p) * 100).toFixed(0);
|
||||
badgeArea = `<div class="pp-stamp pp-stamp-page pp-stamp-ghost" style="--pp-rot:${ppJitter(p.genre_key, 7).toFixed(1)}deg; --pp-fill:${fill}%">
|
||||
<span class="pp-stamp-genre">${esc(p.genre.toUpperCase())}</span>
|
||||
<span class="pp-stamp-tier">BRONZE</span>
|
||||
</div>
|
||||
<div class="pp-invite">${esc(invite)}</div>`;
|
||||
<div class="pp-invite">${invite.charAt(0).toUpperCase()}${invite.slice(1)}</div>`;
|
||||
}
|
||||
const hours = fmtHours(p.seconds_total);
|
||||
const odometer = hours
|
||||
@@ -628,15 +696,34 @@
|
||||
: `Play ${esc(p.genre)} songs at ${starGl} to collect ticket stubs.`;
|
||||
const stubsHTML = stubs.length ? stubs.map(ppStubHTML).join('')
|
||||
: `<div class="pp-stub-empty">${emptyLine}</div>`;
|
||||
// Bring-these-up: nearest-to-the-bar songs (graded, unearned only —
|
||||
// an earned page is memorabilia, not homework).
|
||||
let nearest = '';
|
||||
if (p.badge === 'in_progress' && (p.nearest || []).length) {
|
||||
nearest = `<div class="pp-nearest">
|
||||
<div class="pp-nearest-head">Bring these up</div>
|
||||
${p.nearest.map((s) =>
|
||||
`<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}</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>
|
||||
@@ -655,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');
|
||||
@@ -677,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);
|
||||
}
|
||||
@@ -685,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' &&
|
||||
@@ -771,6 +860,579 @@
|
||||
if (_tiltEl) { resetTilt(_tiltEl); _tiltEl = null; }
|
||||
}
|
||||
|
||||
// ── Shareable passport card (canvas → PNG, save or clipboard) ─────────
|
||||
// Keep in sync with the .pp-leather-* gradients in assets/career.css —
|
||||
// canvas can't consume a CSS class, so the pairs live twice on purpose.
|
||||
const PP_LEATHER_HEX = {
|
||||
guitar: ['#5c2321', '#401412'],
|
||||
bass: ['#1f3252', '#131f36'],
|
||||
keys: ['#1e4034', '#122a21'],
|
||||
drums: ['#3f3f46', '#26262b'],
|
||||
};
|
||||
|
||||
function drawPassportCard(inst, p) {
|
||||
const W = 480;
|
||||
const H = 640;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const [c1, c2] = PP_LEATHER_HEX[inst] || PP_LEATHER_HEX.guitar;
|
||||
const bg = ctx.createLinearGradient(0, 0, W, H);
|
||||
bg.addColorStop(0, c1);
|
||||
bg.addColorStop(1, c2);
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
// Emboss frame
|
||||
ctx.strokeStyle = 'rgba(240,226,195,0.35)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(18, 18, W - 36, H - 36);
|
||||
// Genre title
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.95)';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = '700 34px Georgia, serif';
|
||||
ctx.fillText(p.genre.toUpperCase(), W / 2, 92, W - 80);
|
||||
ctx.font = '400 15px Georgia, serif';
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.55)';
|
||||
ctx.fillText(`${ppLabel(inst).toUpperCase()} PASSPORT`, W / 2, 122);
|
||||
// Stamp ring
|
||||
const gold = p.badge === 'gold';
|
||||
const ink = gold ? '#d9a253' : '#b06a2a';
|
||||
const cy = 330;
|
||||
ctx.strokeStyle = ink;
|
||||
ctx.lineWidth = 6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(W / 2, cy, 118, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(W / 2, cy, 106, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = ink;
|
||||
ctx.font = '800 26px Georgia, serif';
|
||||
ctx.fillText(p.genre.toUpperCase(), W / 2, cy - 6, 190);
|
||||
ctx.font = '600 16px Georgia, serif';
|
||||
ctx.fillText(gold ? 'G O L D' : 'B R O N Z E', W / 2, cy + 28);
|
||||
// Facts
|
||||
const stubCount = (p.songs || []).filter((sng) => sng.qualifies).length;
|
||||
const hours = fmtHours(p.seconds_total);
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.75)';
|
||||
ctx.font = '400 17px Georgia, serif';
|
||||
ctx.fillText(`${stubCount} ticket stub${stubCount === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}`, W / 2, 512);
|
||||
ctx.fillStyle = 'rgba(240,226,195,0.4)';
|
||||
ctx.font = '400 13px Georgia, serif';
|
||||
ctx.fillText('fee[dB]ack · career passport', W / 2, H - 44);
|
||||
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;
|
||||
const p = (((_pp.instruments || {})[inst] || {}).passports || [])
|
||||
.find((x) => x.genre_key === gkey);
|
||||
if (!p) return;
|
||||
const canvas = drawPassportCard(inst, p);
|
||||
exportCanvasPng(canvas, `passport-${inst}-${gkey.replace(/[^a-z0-9-]+/g, '-')}.png`, mode, 'Card');
|
||||
}
|
||||
|
||||
// ── Career surfaces outside the plugin screen ─────────────────────────
|
||||
// Profile passport wall + the home-page career card. Both inject into
|
||||
// core-owned mounts announced by v3:profile-rendered /
|
||||
// v3:dashboard-rendered (the achievements seam). Absent-not-empty: with
|
||||
// no committed instrument they render nothing and the dashboard keeps
|
||||
// its built-in fallback stat.
|
||||
|
||||
function careerTotals() {
|
||||
if (!_pp) return null;
|
||||
let badges = 0;
|
||||
let seconds = 0;
|
||||
let gigs = 0;
|
||||
const walls = [];
|
||||
for (const inst of (_pp.config || {}).instruments || []) {
|
||||
const d = (_pp.instruments || {})[inst];
|
||||
// A commitment with no opened passport isn't a wall yet — the
|
||||
// external surfaces (profile, home card) stay ABSENT until a
|
||||
// passport exists (absent-not-empty).
|
||||
if (!d || !d.committed_at || !(d.passports || []).length) continue;
|
||||
const earned = (d.passports || []).filter((p) => p.badge === 'earned' || p.badge === 'gold');
|
||||
badges += earned.length;
|
||||
seconds += (d.passports || []).reduce((t, p) => t + (p.seconds_total || 0), 0);
|
||||
gigs += d.gig_count || 0;
|
||||
walls.push({ inst, earned, opened: d.passports.length });
|
||||
}
|
||||
if (!walls.length) return null;
|
||||
return { badges, seconds, gigs, walls };
|
||||
}
|
||||
|
||||
function closestAskHTML() {
|
||||
if (!_pp) return '';
|
||||
let best = null;
|
||||
for (const inst of (_pp.config || {}).instruments || []) {
|
||||
for (const p of (((_pp.instruments || {})[inst] || {}).passports || [])) {
|
||||
if (p.badge !== 'in_progress') continue;
|
||||
const need = Math.max(0, ((p.requirement || {}).songs || 0) - p.qualifying_count);
|
||||
if (!best || need < best.need) best = { p, need };
|
||||
}
|
||||
}
|
||||
if (!best) return '';
|
||||
const starGl = '★'.repeat((best.p.requirement || {}).min_stars || 0);
|
||||
if (best.need > 0) {
|
||||
return `${esc(best.p.genre)} — ${best.need === 1 ? `one more ${starGl} song` : `${best.need} more ${starGl} songs`}`;
|
||||
}
|
||||
return `${esc(best.p.genre)} — one drill away`;
|
||||
}
|
||||
|
||||
function renderProfileWall() {
|
||||
const mount = document.getElementById('v3-profile-passports-mount');
|
||||
if (!mount) return;
|
||||
const totals = careerTotals();
|
||||
if (!totals) { mount.innerHTML = ''; return; }
|
||||
const shelves = totals.walls.map(({ inst, earned, opened }) => {
|
||||
const covers = earned.map((p) =>
|
||||
`<button class="pp-wall-cover pp-leather-${esc(inst)}" data-pp-wall-inst="${esc(inst)}" data-pp-wall-gkey="${esc(p.genre_key)}" title="${esc(p.genre)}">
|
||||
<span>${esc(p.genre.toUpperCase())}</span>
|
||||
<em>${p.badge === 'gold' ? 'GOLD' : 'BRONZE'}</em>
|
||||
</button>`).join('');
|
||||
const line = earned.length
|
||||
? covers
|
||||
: `<span class="pp-wall-none">${opened} passport${opened === 1 ? '' : 's'} open — first stamp pending</span>`;
|
||||
return `<div class="pp-wall-shelf"><span class="pp-wall-inst">${esc(ppLabel(inst))}</span>${line}</div>`;
|
||||
}).join('');
|
||||
const hours = fmtHours(totals.seconds);
|
||||
mount.innerHTML = `<div class="bg-fb-card/80 backdrop-blur rounded-lg p-4 border border-fb-border/50 pp-wall">
|
||||
<div class="pp-wall-head">
|
||||
<span>Passport wall</span>
|
||||
<span class="pp-wall-meta">${totals.badges} badge${totals.badges === 1 ? '' : 's'}${hours ? ` · ${hours} played` : ''}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}</span>
|
||||
</div>
|
||||
${shelves}
|
||||
<button class="pp-wall-link" data-pp-wall-career="1">Open career →</button>
|
||||
</div>`;
|
||||
if (!mount.dataset.ppWired) {
|
||||
mount.dataset.ppWired = '1';
|
||||
mount.addEventListener('click', onWallClick);
|
||||
}
|
||||
}
|
||||
|
||||
function onWallClick(e) {
|
||||
const open = e.target.closest('[data-pp-wall-inst]');
|
||||
if (open) {
|
||||
// Two attributes, not a '/'-joined pair: a genre key may itself
|
||||
// contain '/' ("drum/bass") and must round-trip intact.
|
||||
const inst = open.dataset.ppWallInst;
|
||||
const gkey = open.dataset.ppWallGkey;
|
||||
lsSet(PP_INST_KEY, inst);
|
||||
if (window.showScreen) window.showScreen('plugin-career');
|
||||
showCareerTab('passports');
|
||||
renderPassports();
|
||||
openBook(inst, gkey);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('[data-pp-wall-career]')) {
|
||||
if (window.showScreen) window.showScreen('plugin-career');
|
||||
showCareerTab('passports');
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashCard() {
|
||||
const slot = document.getElementById('v3-dash-career-slot');
|
||||
if (!slot) return;
|
||||
const totals = careerTotals();
|
||||
if (!totals) return; // keep core's fallback stat card
|
||||
const hours = fmtHours(totals.seconds);
|
||||
const ask = closestAskHTML();
|
||||
slot.innerHTML = `<button class="pp-dash-card" data-pp-wall-career="1">
|
||||
<span class="pp-dash-shine" aria-hidden="true"></span>
|
||||
<span class="pp-dash-head">Career</span>
|
||||
<span class="pp-dash-badges">${'⚡'.repeat(Math.min(totals.badges, 5))}<b>${totals.badges}</b> badge${totals.badges === 1 ? '' : 's'}</span>
|
||||
<span class="pp-dash-meta">${hours ? `${hours} played` : 'the stage is set'}${totals.gigs ? ` · ${totals.gigs} gig${totals.gigs === 1 ? '' : 's'}` : ''}</span>
|
||||
${ask ? `<span class="pp-dash-ask">closest: ${ask}</span>` : ''}
|
||||
</button>`;
|
||||
if (!slot.dataset.ppWired) {
|
||||
slot.dataset.ppWired = '1';
|
||||
slot.addEventListener('click', onWallClick);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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',
|
||||
@@ -820,6 +1482,23 @@
|
||||
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);
|
||||
return;
|
||||
}
|
||||
const dlBtn = e.target.closest('[data-career-download]');
|
||||
const delBtn = e.target.closest('[data-career-delete]');
|
||||
const playBtn = e.target.closest('[data-career-play]');
|
||||
@@ -870,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).
|
||||
@@ -877,8 +1560,14 @@
|
||||
}
|
||||
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.
|
||||
document.addEventListener('v3:profile-rendered', renderProfileWall);
|
||||
document.addEventListener('v3:dashboard-rendered', renderDashCard);
|
||||
refresh();
|
||||
}
|
||||
|
||||
@@ -886,7 +1575,11 @@
|
||||
// the badge-diff logic; nothing here touches the DOM.
|
||||
window.__careerPassportTest = {
|
||||
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
|
||||
fmtHours, ppFillFraction,
|
||||
fmtHours, ppFillFraction, careerTotals, closestAskHTML,
|
||||
onGigSongEnded, onGigSongStop,
|
||||
setGigRun(r) { _ppGigRun = r; },
|
||||
getGigRun() { return _ppGigRun; },
|
||||
setView(v) { _pp = v; },
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -150,3 +150,100 @@ test('ppFillFraction: song progress toward the bar, in-progress only', () => {
|
||||
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
|
||||
assert.equal(ppFillFraction(null), 0);
|
||||
});
|
||||
|
||||
test('careerTotals / wall + dash card stay absent without commitment', () => {
|
||||
const w = load();
|
||||
const t = w.__careerPassportTest;
|
||||
// No _pp at all → null; committed-less view → null (absent-not-empty).
|
||||
assert.equal(t.careerTotals(), null);
|
||||
t.setView({ config: { instruments: ['guitar'] },
|
||||
instruments: { guitar: { committed_at: null, passports: [] } } });
|
||||
assert.equal(t.careerTotals(), null);
|
||||
// Committed but zero passports opened: still absent (no zero-wall).
|
||||
t.setView({ config: { instruments: ['guitar'] },
|
||||
instruments: { guitar: { committed_at: 'x', passports: [] } } });
|
||||
assert.equal(t.careerTotals(), null);
|
||||
// Committed with an earned badge + hours → totals aggregate.
|
||||
t.setView({ config: { instruments: ['guitar', 'bass'] },
|
||||
instruments: {
|
||||
guitar: { committed_at: 'x', passports: [
|
||||
{ badge: 'earned', seconds_total: 3600, genre: 'Blues', genre_key: 'blues' },
|
||||
{ badge: 'in_progress', seconds_total: 120, genre: 'Funk', genre_key: 'funk',
|
||||
qualifying_count: 4, requirement: { songs: 5, min_stars: 2 } }] },
|
||||
bass: { committed_at: null, passports: [] },
|
||||
} });
|
||||
const totals = t.careerTotals();
|
||||
assert.equal(totals.badges, 1);
|
||||
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');
|
||||
});
|
||||
@@ -15388,6 +15388,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,
|
||||
|
||||
+50
-11
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1908,17 +1943,21 @@ function createHighway() {
|
||||
// never routable.
|
||||
const isAudioUrl = msg.audio_url.startsWith('/audio/');
|
||||
// "Full mix" covers BOTH single-mix pack shapes:
|
||||
// - stem-less packs (original_audio: in the manifest,
|
||||
// audio_url == original_audio_url), and
|
||||
// - single-stem packs (stems: [full.ogg] only) — the server
|
||||
// puts the full mix in the stems list, has_original_audio
|
||||
// is false, and audio_url points at the one stem. With one
|
||||
// stem there is no per-stem mix to preserve, so routing it
|
||||
// natively loses nothing. Real multi-stem (>1) stays out
|
||||
// until Phase 2.
|
||||
// - single-stem packs (stems: [full.ogg] only) — the pack's
|
||||
// one stem IS its mixdown, so the server leaves it in the
|
||||
// stems list, has_full_mix is false, and audio_url points
|
||||
// at that one stem; and
|
||||
// - legacy stem-less packs, whose mixdown sits outside stems
|
||||
// behind the deprecated original_audio: key, so has_stems
|
||||
// is false and audio_url == full_mix_url.
|
||||
// Either way there is one audible source and no per-stem mix
|
||||
// to preserve, so routing it natively loses nothing. A pack
|
||||
// that retains its `full` stem ALONGSIDE separated stems is
|
||||
// multi-stem (has_full_mix && has_stems) and stays out until
|
||||
// Phase 2 — routing it natively would drop the mixer.
|
||||
const isFeedpakFullMix = !isAudioUrl
|
||||
&& msg.audio_url.startsWith('/api/sloppak/')
|
||||
&& ((!!msg.has_original_audio && !msg.has_stems)
|
||||
&& ((!!msg.has_full_mix && !msg.has_stems)
|
||||
|| (msg.stems || []).length === 1);
|
||||
// Record the loaded song's audio so app.js can re-route it
|
||||
// between the HTML5 and JUCE paths if the audio engine is
|
||||
@@ -1943,7 +1982,7 @@ function createHighway() {
|
||||
'isFeedpakFullMix=', isFeedpakFullMix,
|
||||
'has_stems=', !!msg.has_stems,
|
||||
'stems=', (msg.stems || []).length,
|
||||
'has_original_audio=', !!msg.has_original_audio,
|
||||
'has_full_mix=', !!msg.has_full_mix,
|
||||
'format=', msg.format,
|
||||
'alreadyLoaded=', alreadyLoaded,
|
||||
'juceApi=', !!window.feedBackDesktop?.audio);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Blob export helpers — the download idiom that used to be duplicated in
|
||||
// settings-io.js and diagnostics-export.js, plus image-to-clipboard for
|
||||
// shareable cards/posters. A LEAF module: imports nothing. Classic-script
|
||||
// plugins reach it via dynamic import('/static/js/blob-io.js').
|
||||
|
||||
export function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Copy an image blob to the system clipboard. Returns true on success, false
|
||||
// when the Clipboard API is unavailable or refuses (insecure context, no user
|
||||
// gesture, permission denied) — callers fall back to downloadBlob and say so.
|
||||
export async function copyImageBlob(blob) {
|
||||
try {
|
||||
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') return false;
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@
|
||||
// redact toggles.
|
||||
// 3. Stream the returned zip to disk.
|
||||
|
||||
import { downloadBlob } from './blob-io.js';
|
||||
|
||||
function _diagIncludeFromUI() {
|
||||
const v = (id) => document.getElementById(id)?.checked !== false;
|
||||
return {
|
||||
@@ -265,14 +267,7 @@ export async function exportDiagnostics() {
|
||||
}
|
||||
try {
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
downloadBlob(blob, filename);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed during download: ${e.message}`;
|
||||
|
||||
+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');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Settings backup — the export / import bundle.
|
||||
//
|
||||
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
|
||||
// Carved verbatim out of static/app.js (R3a). Imports only the blob-io leaf.
|
||||
//
|
||||
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
|
||||
// re-exposing them on window. The import is two-phase (server first, atomic; then
|
||||
@@ -29,6 +29,8 @@
|
||||
// phase 2; the localStorage side is best-effort merge after server
|
||||
// success. Failures are reported, never silenced.
|
||||
|
||||
import { downloadBlob } from './blob-io.js';
|
||||
|
||||
export async function exportSettings() {
|
||||
const status = document.getElementById('backup-status');
|
||||
status.textContent = 'Exporting...';
|
||||
@@ -66,14 +68,7 @@ export async function exportSettings() {
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
downloadBlob(blob, filename);
|
||||
status.textContent = `Exported ${filename}`;
|
||||
} catch (e) {
|
||||
status.textContent = `Export failed: ${e.message}`;
|
||||
|
||||
@@ -208,12 +208,17 @@
|
||||
'</div></div></div>' +
|
||||
continueCard +
|
||||
'</div>' +
|
||||
// Stats row
|
||||
// Stats row. The third slot belongs to the career plugin (it
|
||||
// replaces the slot's content on v3:dashboard-rendered); the
|
||||
// plugin-count stat is the built-in fallback when career is
|
||||
// absent or has no state yet.
|
||||
'<div class="grid md:grid-cols-3 gap-6 mt-6">' +
|
||||
audioRoutingCard() +
|
||||
statCard(String(songCount), 'songs', 'text-fb-gold') +
|
||||
'<div id="v3-dash-career-slot" class="grid">' +
|
||||
statCard(String(pluginCount), 'active', 'text-fb-good') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
recentSection +
|
||||
'</div>';
|
||||
|
||||
|
||||
@@ -191,6 +191,10 @@
|
||||
'<div class="space-y-6">' +
|
||||
headerCard +
|
||||
bestsCard +
|
||||
// Passport wall — rendered by the career plugin on
|
||||
// v3:profile-rendered (absent-not-empty: nothing shows until a
|
||||
// passport exists).
|
||||
'<div id="v3-profile-passports-mount"></div>' +
|
||||
// Feats of Power trophy shelf — rendered by the achievements plugin
|
||||
// (earned Feats only; hidden-until-earned, so empty when none).
|
||||
'<div id="v3-profile-feats-slot"></div>' +
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -208,3 +208,267 @@ def test_drill_state_merge_is_gained_only(client, meta_db):
|
||||
p = _passport(client)
|
||||
assert p["drills"]["cleared"] == ["blues_shuffle"]
|
||||
assert p["badge"] == "earned"
|
||||
|
||||
|
||||
def test_genre_families_inherit_drills(client, meta_db):
|
||||
# 'death metal' has no exact entry — it inherits the metal family's drill.
|
||||
for i in range(5):
|
||||
meta_db.add(f"dm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=LEAD)
|
||||
_open(client, "guitar", "Death Metal")
|
||||
p = _passport(client, "guitar", "death metal")
|
||||
assert p["drills"]["required"] == ["melodic_metal_gallop"]
|
||||
assert p["badge"] == "in_progress"
|
||||
# 'metalcore' (single word) matches by substring, no alias needed.
|
||||
_open(client, "guitar", "Metalcore")
|
||||
assert _passport(client, "guitar", "metalcore")["drills"]["required"] == \
|
||||
["melodic_metal_gallop"]
|
||||
# 'blues rock' resolves by family LIST ORDER: blues comes before rock.
|
||||
_open(client, "guitar", "Blues Rock")
|
||||
assert _passport(client, "guitar", "blues rock")["drills"]["required"] == \
|
||||
["blues_shuffle"]
|
||||
# A genre outside every family stays songs-only.
|
||||
_open(client, "guitar", "Reggae")
|
||||
assert _passport(client, "guitar", "reggae")["drills"]["required"] == []
|
||||
# Exact per-genre entries still beat the family (the shipped 'metal' entry
|
||||
# IS the exact entry for genre key 'metal').
|
||||
_open(client, "guitar", "Metal")
|
||||
assert _passport(client, "guitar", "metal")["drills"]["required"] == \
|
||||
["melodic_metal_gallop"]
|
||||
|
||||
|
||||
def test_family_drills_stay_per_instrument(client, meta_db):
|
||||
# Family inheritance must not leak guitar drills onto other instruments.
|
||||
keys_arr = [{"type": "lead", "name": "Keys"}]
|
||||
for i in range(5):
|
||||
meta_db.add(f"kdm{i}.feedpak", 0, 0.9, genre="Death Metal", arrangements=keys_arr)
|
||||
_open(client, "keys", "Death Metal")
|
||||
p = _passport(client, "keys", "death metal")
|
||||
assert p["drills"]["required"] == []
|
||||
assert p["badge"] == "earned"
|
||||
|
||||
|
||||
def test_nearest_invitations_order_and_exclusions(client, meta_db):
|
||||
# Non-qualifying songs sorted by distance to the QUALIFYING bar;
|
||||
# qualifying songs never appear; capped at 3.
|
||||
meta_db.add("q.feedpak", 0, 0.80, genre="Soul", arrangements=LEAD) # qualifies
|
||||
meta_db.add("close.feedpak", 0, 0.74, genre="Soul", arrangements=LEAD) # 1% to 2★
|
||||
meta_db.add("mid.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD) # 5% to 2★
|
||||
meta_db.add("far.feedpak", 0, 0.30, genre="Soul", arrangements=LEAD) # 30% to 1★
|
||||
meta_db.add("far2.feedpak", 0, 0.25, genre="Soul", arrangements=LEAD)
|
||||
_open(client, "guitar", "Soul")
|
||||
p = _passport(client, "guitar", "soul")
|
||||
names = [s["filename"] for s in p["nearest"]]
|
||||
assert names == ["close.feedpak", "mid.feedpak", "far.feedpak"]
|
||||
assert all(s["next_star_at"] is not None for s in p["nearest"])
|
||||
assert "q.feedpak" not in names
|
||||
|
||||
|
||||
def test_nearest_targets_the_qualifying_bar_not_next_star(client, meta_db):
|
||||
# A 0★ song 1% from its NEXT star is farther from the ★★ badge bar than
|
||||
# a 1★ song 5% from it — nearest must rank by the badge bar.
|
||||
meta_db.add("one_star.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD) # 5% to bar
|
||||
meta_db.add("zero_star.feedpak", 0, 0.59, genre="Soul", arrangements=LEAD) # 1% to next ★, 16% to bar
|
||||
_open(client, "guitar", "Soul")
|
||||
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()
|
||||
|
||||
@@ -273,3 +273,51 @@ def test_title_keyset_paging_is_complete_with_overrides(client, server):
|
||||
if not cursor:
|
||||
break
|
||||
assert sorted(seen) == ["a.archive", "b.archive", "c.archive"] # each exactly once
|
||||
|
||||
|
||||
def test_enrichment_genre_fallback_precedence(client, server):
|
||||
# Precedence: override → pack genre → MusicBrainz enrichment (matched only).
|
||||
_put(server, "a.archive", title="A", genre="Rock") # pack wins over enrichment
|
||||
_put(server, "b.archive", title="B", genre="") # falls back to enrichment
|
||||
_put(server, "c.archive", title="C", genre="") # override beats enrichment
|
||||
_put(server, "d.archive", title="D", genre="") # unmatched candidate: ignored
|
||||
ins = "INSERT INTO song_enrichment (filename, match_state, genres) VALUES (?, ?, ?)"
|
||||
server.meta_db.conn.execute(ins, ("a.archive", "matched", '["metal"]'))
|
||||
server.meta_db.conn.execute(ins, ("b.archive", "matched", '["progressive rock", "rock"]'))
|
||||
server.meta_db.conn.execute(ins, ("c.archive", "matched", '["jazz"]'))
|
||||
server.meta_db.conn.execute(ins, ("d.archive", "review", '["country"]'))
|
||||
_put(server, "e.archive", title="E", genre="") # manual pin is trusted too
|
||||
server.meta_db.conn.execute(ins, ("e.archive", "manual", '["ska"]'))
|
||||
server.meta_db.conn.commit()
|
||||
server.meta_db.set_song_override("c.archive", "genre", value="City Pop")
|
||||
|
||||
genres = client.get("/api/library/genres").json()["genres"]
|
||||
assert "Rock" in genres # pack value kept for a
|
||||
assert "metal" not in genres # enrichment never overrides a pack genre
|
||||
assert "progressive rock" in genres # b: enrichment primary ([0]) surfaces
|
||||
assert "City Pop" in genres and "jazz" not in genres # override beats enrichment
|
||||
assert "country" not in genres # review/failed candidates never leak
|
||||
assert "ska" in genres # user-pinned (manual) matches count
|
||||
|
||||
# Filtering by the enriched genre finds the song.
|
||||
r = client.get("/api/library", params={"genre": "progressive rock"}).json()
|
||||
assert [s["filename"] for s in r["songs"]] == ["b.archive"]
|
||||
|
||||
|
||||
def test_no_enrichment_and_no_overrides_uses_plain_column(server):
|
||||
_put(server, "a.archive", title="A", genre="Rock")
|
||||
assert server.meta_db._effective_genre_expr() == "genre"
|
||||
|
||||
|
||||
def test_overrides_without_enrichment_table_stay_safe(server):
|
||||
# A stand-in scenario: overrides exist but song_enrichment is gone — the
|
||||
# expression must not reference the missing table.
|
||||
server.meta_db.conn.execute("DROP TABLE song_enrichment")
|
||||
_put(server, "a.archive", title="A", genre="")
|
||||
server.meta_db.set_song_override("a.archive", "genre", value="City Pop")
|
||||
expr = server.meta_db._effective_genre_expr()
|
||||
assert "song_enrichment" not in expr
|
||||
# And it still evaluates: the override surfaces through the facet query.
|
||||
row = server.meta_db.conn.execute(
|
||||
f"SELECT {expr} FROM songs WHERE filename = 'a.archive'").fetchone()
|
||||
assert row[0] == "City Pop"
|
||||
|
||||
@@ -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,429 @@
|
||||
"""tools/migrate_full_mix_stem.py — packs off the deprecated `original_audio:` key.
|
||||
|
||||
The migration moves real audio inside tens of thousands of archives, so the
|
||||
interesting cases are the ones where it must NOT act: a pack it would corrupt, a
|
||||
pack it has already done, a pack whose mixdown isn't where the key claims.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
_SPEC = importlib.util.spec_from_file_location(
|
||||
"migrate_full_mix_stem",
|
||||
Path(__file__).resolve().parent.parent / "tools" / "migrate_full_mix_stem.py",
|
||||
)
|
||||
mig = importlib.util.module_from_spec(_SPEC)
|
||||
_SPEC.loader.exec_module(mig)
|
||||
|
||||
|
||||
def _manifest(**extra) -> dict:
|
||||
m = {
|
||||
"feedpak_version": "1.13.0",
|
||||
"title": "T",
|
||||
"artist": "A",
|
||||
"duration": 1.0,
|
||||
"arrangements": [{"id": "lead", "file": "arrangements/lead.json"}],
|
||||
"stems": [
|
||||
{"id": "guitar", "file": "stems/guitar.ogg", "default": "on"},
|
||||
{"id": "drums", "file": "stems/drums.ogg", "default": "on"},
|
||||
],
|
||||
"original_audio": "original/full.ogg",
|
||||
}
|
||||
m.update(extra)
|
||||
return m
|
||||
|
||||
|
||||
def _write_pack(path: Path, manifest: dict, files: dict[str, bytes] | None = None) -> Path:
|
||||
files = files or {
|
||||
"original/full.ogg": b"MIXDOWN",
|
||||
"stems/guitar.ogg": b"g",
|
||||
"stems/drums.ogg": b"d",
|
||||
"arrangements/lead.json": b"{}",
|
||||
}
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("manifest.yaml", yaml.safe_dump(manifest, sort_keys=False))
|
||||
for name, data in files.items():
|
||||
zf.writestr(name, data)
|
||||
return path
|
||||
|
||||
|
||||
def _read(path: Path) -> tuple[dict, set[str]]:
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
return yaml.safe_load(zf.read("manifest.yaml")), set(zf.namelist())
|
||||
|
||||
|
||||
# ── plan_manifest: the decisions, without the archives ──────────────────────
|
||||
|
||||
def test_plan_adds_the_full_stem_and_drops_the_key():
|
||||
new, move = mig.plan_manifest(_manifest())
|
||||
assert move == "original/full.ogg"
|
||||
assert "original_audio" not in new
|
||||
assert new["stems"][0] == {
|
||||
"id": "full",
|
||||
"file": "stems/full.ogg",
|
||||
"default": "off",
|
||||
}
|
||||
# The separated stems survive, in order, untouched.
|
||||
assert [s["id"] for s in new["stems"]] == ["full", "guitar", "drums"]
|
||||
assert new["feedpak_version"] == "1.15.0"
|
||||
|
||||
|
||||
def test_plan_marks_the_retained_mixdown_default_off():
|
||||
"""The one line that keeps a pre-1.15.0 reader from doubling the song: a
|
||||
reader that sums every stem still won't play `full` on open if it honours
|
||||
`default`, which has been normative since 1.0.0."""
|
||||
new, _ = mig.plan_manifest(_manifest())
|
||||
assert new["stems"][0]["default"] == "off"
|
||||
|
||||
|
||||
def test_plan_marks_a_sole_mixdown_default_on():
|
||||
"""With no separated stems the mixdown IS the audio — off would mute the pack."""
|
||||
new, _ = mig.plan_manifest(_manifest(stems=[]))
|
||||
assert new["stems"] == [{"id": "full", "file": "stems/full.ogg", "default": "on"}]
|
||||
|
||||
|
||||
def test_plan_preserves_unknown_keys_verbatim():
|
||||
"""Spec §3: a writer that re-emits a pack SHOULD preserve unknown keys."""
|
||||
new, _ = mig.plan_manifest(_manifest(source_tool="ExampleTool v1.2.3", rigs="rigs.json"))
|
||||
assert new["source_tool"] == "ExampleTool v1.2.3"
|
||||
assert new["rigs"] == "rigs.json"
|
||||
|
||||
|
||||
def test_plan_skips_an_already_migrated_pack():
|
||||
m = _manifest(
|
||||
stems=[{"id": "full", "file": "stems/full.ogg", "default": "off"}],
|
||||
)
|
||||
del m["original_audio"]
|
||||
with pytest.raises(mig.Skip):
|
||||
mig.plan_manifest(m)
|
||||
|
||||
|
||||
def test_plan_skips_a_pack_that_never_had_the_key():
|
||||
m = _manifest()
|
||||
del m["original_audio"]
|
||||
with pytest.raises(mig.Skip):
|
||||
mig.plan_manifest(m)
|
||||
|
||||
|
||||
def test_plan_drops_a_stale_key_without_moving_anything():
|
||||
"""Mixdown already a stem, dead key lingering beside it."""
|
||||
new, move = mig.plan_manifest(
|
||||
_manifest(stems=[{"id": "full", "file": "stems/full.ogg", "default": "off"}])
|
||||
)
|
||||
assert move == ""
|
||||
assert "original_audio" not in new
|
||||
assert [s["id"] for s in new["stems"]] == ["full"]
|
||||
|
||||
|
||||
def test_plan_forces_an_existing_full_stem_off_beside_instrument_stems():
|
||||
"""Dropping the stale key is not enough if the mixdown it duplicated is left
|
||||
ENABLED: a reader that honours `default` would then play the whole song on top
|
||||
of the stems on open. The migration must not hand back a pack in the exact
|
||||
state it exists to remove."""
|
||||
new, move = mig.plan_manifest(
|
||||
_manifest(
|
||||
stems=[
|
||||
{"id": "full", "file": "stems/full.ogg", "default": "on"},
|
||||
{"id": "guitar", "file": "stems/guitar.ogg", "default": "on"},
|
||||
]
|
||||
)
|
||||
)
|
||||
assert move == ""
|
||||
assert new["stems"][0] == {"id": "full", "file": "stems/full.ogg", "default": "off"}
|
||||
assert new["stems"][1]["default"] == "on" # instruments untouched
|
||||
|
||||
|
||||
def test_plan_leaves_a_sole_full_stem_enabled_when_dropping_a_stale_key():
|
||||
"""No instruments beside it — the mixdown IS the audio. Forcing it off here
|
||||
would mute the pack."""
|
||||
new, _ = mig.plan_manifest(
|
||||
_manifest(stems=[{"id": "full", "file": "stems/full.ogg", "default": "on"}])
|
||||
)
|
||||
assert new["stems"] == [{"id": "full", "file": "stems/full.ogg", "default": "on"}]
|
||||
|
||||
|
||||
def test_plan_needs_no_move_when_the_key_already_points_at_the_canonical_path():
|
||||
new, move = mig.plan_manifest(_manifest(original_audio="stems/full.ogg"))
|
||||
assert move == ""
|
||||
assert new["stems"][0]["file"] == "stems/full.ogg"
|
||||
|
||||
|
||||
# ── migrate_zip: the archive rewrite ────────────────────────────────────────
|
||||
|
||||
def test_migrate_moves_the_audio_and_rewrites_the_manifest(tmp_path: Path):
|
||||
pak = _write_pack(tmp_path / "song.feedpak", _manifest())
|
||||
assert mig.migrate_zip(pak, dry_run=False) == "migrated"
|
||||
|
||||
manifest, names = _read(pak)
|
||||
assert "original/full.ogg" not in names # the invented directory is gone
|
||||
assert "stems/full.ogg" in names # audio lives where the format says
|
||||
assert "original_audio" not in manifest
|
||||
assert manifest["stems"][0]["id"] == "full"
|
||||
assert mig.verify_zip(pak) == "ok"
|
||||
|
||||
|
||||
def test_migrate_preserves_the_mixdown_bytes(tmp_path: Path):
|
||||
"""It is a rename, not a re-encode. Losing a byte here loses the master audio."""
|
||||
pak = _write_pack(tmp_path / "song.feedpak", _manifest())
|
||||
mig.migrate_zip(pak, dry_run=False)
|
||||
with zipfile.ZipFile(pak) as zf:
|
||||
assert zf.read("stems/full.ogg") == b"MIXDOWN"
|
||||
assert zf.read("stems/guitar.ogg") == b"g"
|
||||
|
||||
|
||||
def test_migrate_is_idempotent(tmp_path: Path):
|
||||
pak = _write_pack(tmp_path / "song.feedpak", _manifest())
|
||||
assert mig.migrate_zip(pak, dry_run=False) == "migrated"
|
||||
before = pak.read_bytes()
|
||||
assert mig.migrate_zip(pak, dry_run=False) == "skip"
|
||||
assert pak.read_bytes() == before # a re-run touches nothing
|
||||
|
||||
|
||||
def test_dry_run_changes_nothing(tmp_path: Path):
|
||||
pak = _write_pack(tmp_path / "song.feedpak", _manifest())
|
||||
before = pak.read_bytes()
|
||||
assert mig.migrate_zip(pak, dry_run=True) == "would-migrate"
|
||||
assert pak.read_bytes() == before
|
||||
|
||||
|
||||
def test_migrate_refuses_when_the_mixdown_is_absent(tmp_path: Path):
|
||||
"""The key points at audio the archive doesn't contain. Fabricating a stem
|
||||
entry for a missing file would break every reader — refuse, don't guess."""
|
||||
pak = _write_pack(
|
||||
tmp_path / "song.feedpak",
|
||||
_manifest(),
|
||||
files={"stems/guitar.ogg": b"g", "arrangements/lead.json": b"{}"},
|
||||
)
|
||||
before = pak.read_bytes()
|
||||
assert mig.migrate_zip(pak, dry_run=False) == "missing-audio"
|
||||
assert pak.read_bytes() == before
|
||||
|
||||
|
||||
def test_migrate_refuses_when_the_target_path_is_taken(tmp_path: Path):
|
||||
"""A `stems/full.ogg` that is NOT the mixdown already occupies the target.
|
||||
Overwriting it would destroy a stem."""
|
||||
pak = _write_pack(
|
||||
tmp_path / "song.feedpak",
|
||||
_manifest(),
|
||||
files={
|
||||
"original/full.ogg": b"MIXDOWN",
|
||||
"stems/full.ogg": b"SOMETHING-ELSE",
|
||||
"arrangements/lead.json": b"{}",
|
||||
},
|
||||
)
|
||||
assert mig.migrate_zip(pak, dry_run=False) == "target-occupied"
|
||||
with zipfile.ZipFile(pak) as zf:
|
||||
assert zf.read("stems/full.ogg") == b"SOMETHING-ELSE"
|
||||
|
||||
|
||||
def test_migrate_drops_a_stale_key_beside_a_non_canonical_full_stem(tmp_path: Path):
|
||||
"""The mixdown is already a stem, but at a path of the pack's own choosing —
|
||||
which is legal (§2.2: readers resolve through the manifest, never by
|
||||
filename). Only the dead key needs removing. Demanding `stems/full.ogg` here
|
||||
would reject a perfectly valid pack as `missing-audio`."""
|
||||
m = _manifest(stems=[{"id": "full", "file": "audio/mixdown.ogg", "default": "off"}])
|
||||
pak = _write_pack(
|
||||
tmp_path / "song.feedpak",
|
||||
m,
|
||||
files={"audio/mixdown.ogg": b"MIXDOWN", "arrangements/lead.json": b"{}"},
|
||||
)
|
||||
assert mig.migrate_zip(pak, dry_run=False) == "migrated"
|
||||
|
||||
manifest, names = _read(pak)
|
||||
assert "original_audio" not in manifest
|
||||
assert manifest["stems"] == [
|
||||
{"id": "full", "file": "audio/mixdown.ogg", "default": "off"}
|
||||
]
|
||||
assert "audio/mixdown.ogg" in names # the audio never moved
|
||||
assert mig.verify_zip(pak) == "ok"
|
||||
|
||||
|
||||
# ── verify_zip ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_verify_rejects_a_retained_mixdown_that_plays_on_open(tmp_path: Path):
|
||||
"""The hazard the migration must never create: `full` alongside instrument
|
||||
stems AND default-on means a summing reader plays the whole song twice."""
|
||||
m = _manifest(
|
||||
stems=[
|
||||
{"id": "full", "file": "stems/full.ogg", "default": "on"},
|
||||
{"id": "guitar", "file": "stems/guitar.ogg", "default": "on"},
|
||||
]
|
||||
)
|
||||
del m["original_audio"]
|
||||
pak = _write_pack(
|
||||
tmp_path / "song.feedpak",
|
||||
m,
|
||||
files={"stems/full.ogg": b"M", "stems/guitar.ogg": b"g"},
|
||||
)
|
||||
assert mig.verify_zip(pak) == "full-stem-default-on"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"default, expected",
|
||||
[
|
||||
({"default": "off"}, "ok"), # the one safe, canonical shape
|
||||
({"default": "OFF"}, "ok"), # case-insensitive
|
||||
({"default": " off "}, "ok"), # surrounding whitespace tolerated
|
||||
({}, "full-stem-default-not-off"), # MISSING — core defaults to True (ON)
|
||||
({"default": ""}, "full-stem-default-not-off"), # empty → ON in core
|
||||
({"default": False}, "full-stem-default-not-off"), # boolean, not the string
|
||||
({"default": True}, "full-stem-default-on"), # boolean truthy → plays
|
||||
({"default": "false"}, "full-stem-default-not-off"), # off-ish but non-canonical
|
||||
({"default": "0"}, "full-stem-default-not-off"),
|
||||
({"default": "no"}, "full-stem-default-not-off"),
|
||||
({"default": "maybe"}, "full-stem-default-not-off"), # malformed
|
||||
({"default": "on"}, "full-stem-default-on"),
|
||||
({"default": "yes"}, "full-stem-default-on"),
|
||||
({"default": "1"}, "full-stem-default-on"),
|
||||
],
|
||||
)
|
||||
def test_verify_requires_an_explicit_off_on_a_retained_mixdown(tmp_path, default, expected):
|
||||
"""Beside instrument stems, `full` is safe only with an explicit normalized
|
||||
`off`. Core defaults an ABSENT `default` to ON and treats empty/unknown as
|
||||
ON, so a missing or blank default is the double-audio hazard itself, not a
|
||||
lesser one — `verify` must not certify it."""
|
||||
m = _manifest(
|
||||
stems=[
|
||||
{"id": "full", "file": "stems/full.ogg", **default},
|
||||
{"id": "guitar", "file": "stems/guitar.ogg", "default": "on"},
|
||||
]
|
||||
)
|
||||
del m["original_audio"]
|
||||
pak = _write_pack(
|
||||
tmp_path / f"{tmp_path.name}.feedpak",
|
||||
m,
|
||||
files={"stems/full.ogg": b"M", "stems/guitar.ogg": b"g"},
|
||||
)
|
||||
assert mig.verify_zip(pak) == expected
|
||||
|
||||
|
||||
def test_verify_ignores_default_on_a_sole_full_stem(tmp_path: Path):
|
||||
"""A single `full` stem IS the audio — the len>1 gate means its default is
|
||||
not policed, so an on/absent default is fine (off would mute the pack)."""
|
||||
for default in ({"default": "on"}, {}, {"default": ""}):
|
||||
m = _manifest(stems=[{"id": "full", "file": "stems/full.ogg", **default}])
|
||||
del m["original_audio"]
|
||||
pak = _write_pack(
|
||||
tmp_path / f"{tmp_path.name}-{len(default)}.feedpak",
|
||||
m,
|
||||
files={"stems/full.ogg": b"M"},
|
||||
)
|
||||
assert mig.verify_zip(pak) == "ok"
|
||||
|
||||
|
||||
def test_verify_rejects_an_unmigrated_pack(tmp_path: Path):
|
||||
pak = _write_pack(tmp_path / "song.feedpak", _manifest())
|
||||
assert mig.verify_zip(pak) == "still-has-key"
|
||||
|
||||
|
||||
# ── Unsafe manifest paths must not be laundered into playable audio ─────────
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rel", ["../outside.ogg", "/etc/passwd", "a/../../x.ogg", "C:/x.ogg", "a\\b.ogg"]
|
||||
)
|
||||
def test_migrate_refuses_an_unsafe_full_mix_path(tmp_path: Path, rel: str):
|
||||
"""Core's loader REFUSES a full-mix path that escapes the pack — such a pack
|
||||
simply has no full mix, and the audio is inert. Migrating it into
|
||||
`stems/full.ogg` would take content the reader deliberately rejected and hand
|
||||
it back as a valid, playable stem. Report it; never promote it."""
|
||||
pak = _write_pack(
|
||||
tmp_path / "song.feedpak",
|
||||
_manifest(original_audio=rel),
|
||||
files={rel: b"EVIL", "stems/guitar.ogg": b"g"},
|
||||
)
|
||||
before = pak.read_bytes()
|
||||
assert mig.migrate_zip(pak, dry_run=False) == "unsafe-path"
|
||||
assert pak.read_bytes() == before
|
||||
|
||||
|
||||
def test_safe_relpath_accepts_ordinary_pack_paths():
|
||||
assert mig.is_safe_relpath("stems/full.ogg")
|
||||
assert mig.is_safe_relpath("original/full.ogg")
|
||||
assert not mig.is_safe_relpath("")
|
||||
assert not mig.is_safe_relpath("a//b.ogg")
|
||||
|
||||
|
||||
# ── Damaged packs must not abort the run ────────────────────────────────────
|
||||
|
||||
def test_a_corrupt_archive_is_reported_not_fatal(tmp_path: Path, capsys):
|
||||
"""A real library has damage in it — a truncated download, an archive left
|
||||
half-written by an interrupted converter. One of those must not kill a
|
||||
50,000-pack run and throw away the summary: the pack is reported, skipped,
|
||||
and everything else still migrates."""
|
||||
good = _write_pack(tmp_path / "good.feedpak", _manifest())
|
||||
bad = tmp_path / "bad.feedpak"
|
||||
bad.write_bytes(b"this is not a zip file at all")
|
||||
|
||||
rc = mig.main([str(tmp_path)])
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert rc == 1 # a problem pack fails the run's exit code
|
||||
assert "corrupt-zip" in out
|
||||
assert "migrated" in out
|
||||
assert mig.verify_zip(good) == "ok" # the healthy pack still got migrated
|
||||
assert bad.read_bytes() == b"this is not a zip file at all" # untouched
|
||||
|
||||
|
||||
# ── Directory-form (authoring) packs are discovered, not silently skipped ────
|
||||
|
||||
def _write_dir_pack(path: Path, manifest: dict, files: dict[str, bytes] | None = None) -> Path:
|
||||
"""Build a directory-form pack (`song.sloppak/`), the authoring shape."""
|
||||
files = files or {
|
||||
"original/full.ogg": b"MIXDOWN",
|
||||
"stems/guitar.ogg": b"g",
|
||||
"stems/drums.ogg": b"d",
|
||||
"arrangements/lead.json": b"{}",
|
||||
}
|
||||
path.mkdir()
|
||||
(path / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
for name, data in files.items():
|
||||
p = path / name
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
def test_iter_packs_discovers_directory_form_packs(tmp_path: Path):
|
||||
"""A `song.sloppak/` directory is a pack; os.walk must yield it whole and
|
||||
NOT descend into it (its stems/ are contents, not packs)."""
|
||||
d = _write_dir_pack(tmp_path / "song.sloppak", _manifest())
|
||||
z = _write_pack(tmp_path / "other.feedpak", _manifest())
|
||||
found = set(mig.iter_packs(tmp_path))
|
||||
assert d in found and z in found
|
||||
# Nothing inside the directory pack was yielded as its own pack.
|
||||
assert not any(d in p.parents for p in found)
|
||||
|
||||
|
||||
def test_iter_packs_yields_a_directly_passed_dir_pack(tmp_path: Path):
|
||||
d = _write_dir_pack(tmp_path / "song.sloppak", _manifest())
|
||||
assert list(mig.iter_packs(d)) == [d]
|
||||
|
||||
|
||||
def test_directory_form_pack_is_reported_not_silently_skipped(tmp_path: Path, capsys):
|
||||
"""The migrator rewrites single-file packs atomically; a directory can't be
|
||||
swapped that way, so it is surfaced as a problem rather than vanishing from
|
||||
the run (the silent-skip this guards against) or being rewritten unsafely."""
|
||||
d = _write_dir_pack(tmp_path / "song.sloppak", _manifest())
|
||||
good = _write_pack(tmp_path / "good.feedpak", _manifest())
|
||||
|
||||
rc = mig.main([str(tmp_path)])
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert rc == 1 # a reported problem fails the exit code
|
||||
assert "dir-form-unsupported" in out
|
||||
assert mig.verify_zip(good) == "ok" # the zip pack still migrated
|
||||
# The directory pack is untouched: legacy key intact, mixdown not moved.
|
||||
manifest = yaml.safe_load((d / "manifest.yaml").read_text())
|
||||
assert manifest.get("original_audio") == "original/full.ogg"
|
||||
assert (d / "original" / "full.ogg").read_bytes() == b"MIXDOWN"
|
||||
|
||||
|
||||
def test_verify_reports_directory_form_packs(tmp_path: Path):
|
||||
d = _write_dir_pack(tmp_path / "song.sloppak", _manifest())
|
||||
assert mig.verify_pack(d) == "dir-form-unsupported"
|
||||
@@ -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,277 @@
|
||||
"""The sloppak loader's handling of a pack's complete mixdown (#933).
|
||||
|
||||
The mixdown is a stem: feedpak spec §5.3 RESERVES the id `full` for it. It is a
|
||||
mixdown, not a layer — it already contains every instrument, so a reader that
|
||||
sums `stems` must never include it in that sum, and `load_song()` therefore
|
||||
lifts it OUT of `LoadedSloppak.stems` and onto `LoadedSloppak.full_mix`.
|
||||
|
||||
Also covers the DEPRECATED `original_audio:` manifest key — a key this repo
|
||||
invented (#583) before the spec reserved `full`, which every pack in the wild
|
||||
still carries. We read it as a fallback so those packs keep their full mix; we
|
||||
never write it. Those tests are the deprecation contract: they go when the key
|
||||
does (#945).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
import sloppak as sloppak_mod
|
||||
|
||||
|
||||
def _write_dir_sloppak(
|
||||
root: Path,
|
||||
manifest_extras: dict,
|
||||
*,
|
||||
write_legacy_full_mix: bool = False,
|
||||
stems: list[dict] | None = None,
|
||||
) -> Path:
|
||||
"""Build a minimal directory-form sloppak that load_song will accept.
|
||||
|
||||
Uses the tmp_path leaf name to make the sloppak filename unique per test,
|
||||
avoiding the module-level ``resolve_source_dir`` cache being poisoned by a
|
||||
previous test that happened to share the same "song.sloppak" filename.
|
||||
"""
|
||||
pak = root / f"{root.name}.sloppak"
|
||||
pak.mkdir()
|
||||
arr_dir = pak / "arrangements"
|
||||
arr_dir.mkdir()
|
||||
|
||||
arr = {
|
||||
"name": "Lead",
|
||||
"tuning": [0, 0, 0, 0, 0, 0],
|
||||
"capo": 0,
|
||||
"notes": [],
|
||||
"chords": [],
|
||||
"anchors": [],
|
||||
"handshapes": [],
|
||||
"templates": [],
|
||||
"beats": [],
|
||||
"sections": [],
|
||||
}
|
||||
(arr_dir / "lead.json").write_text(json.dumps(arr))
|
||||
|
||||
manifest = {
|
||||
"title": "Test",
|
||||
"artist": "Tester",
|
||||
"album": "",
|
||||
"year": 2026,
|
||||
"duration": 10.0,
|
||||
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
|
||||
"stems": (
|
||||
stems
|
||||
if stems is not None
|
||||
else [{"id": "guitar", "file": "stems/guitar.ogg", "default": True}]
|
||||
),
|
||||
}
|
||||
manifest.update(manifest_extras)
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
|
||||
if write_legacy_full_mix:
|
||||
orig_dir = pak / "original"
|
||||
orig_dir.mkdir()
|
||||
# The loader only checks presence (is_file); contents are irrelevant.
|
||||
(orig_dir / "full.ogg").write_bytes(b"OggS-not-real")
|
||||
|
||||
return pak
|
||||
|
||||
|
||||
def _load(pak_path: Path, tmp_path: Path):
|
||||
dlc_root = pak_path.parent
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
return sloppak_mod.load_song(pak_path.name, dlc_root, cache)
|
||||
|
||||
|
||||
def _separated(**extra) -> list[dict]:
|
||||
"""A separated pack that RETAINS its mixdown, as spec §5.3 asks writers to."""
|
||||
return [
|
||||
{"id": "full", "file": "stems/full.ogg", "default": False, **extra},
|
||||
{"id": "guitar", "file": "stems/guitar.ogg", "default": True},
|
||||
{"id": "drums", "file": "stems/drums.ogg", "default": True},
|
||||
]
|
||||
|
||||
|
||||
# ── The `full` stem is the mixdown (spec §5.3) ───────────────────────────────
|
||||
|
||||
def test_full_stem_is_surfaced_as_the_mixdown(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {}, stems=_separated())
|
||||
loaded = _load(pak, tmp_path)
|
||||
# Manifest-relative, so the WS builds its URL exactly as it builds a stem's.
|
||||
assert loaded.full_mix == "stems/full.ogg"
|
||||
|
||||
|
||||
def test_full_stem_is_removed_from_the_stem_list(tmp_path: Path):
|
||||
"""The regression this whole change exists to prevent.
|
||||
|
||||
Every consumer sums `stems` into one mix and renders one fader per entry. The
|
||||
mixdown already contains every instrument, so leaving it in the list doubles
|
||||
the entire song — and muting `guitar` would still leave guitar audible inside
|
||||
it. That exact trap is why the packer invented `original_audio` rather than
|
||||
putting the mixdown where the format says it goes.
|
||||
"""
|
||||
pak = _write_dir_sloppak(tmp_path, {}, stems=_separated())
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert [s["id"] for s in loaded.stems] == ["guitar", "drums"]
|
||||
|
||||
|
||||
def test_single_mix_pack_keeps_full_as_its_only_stem(tmp_path: Path):
|
||||
"""A pack whose ONLY stem is `full` is a single-mix pack, not a separated one.
|
||||
|
||||
There are no instruments to be pristine against, so the mixdown stays the sole
|
||||
playable stem and nothing is surfaced separately. Anything else would strip the
|
||||
stem list of the most common pack shape in the library and leave it silent.
|
||||
"""
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path, {}, stems=[{"id": "full", "file": "stems/full.ogg", "default": True}]
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.full_mix is None
|
||||
assert [s["id"] for s in loaded.stems] == ["full"]
|
||||
|
||||
|
||||
def test_every_full_entry_is_removed_not_just_the_first(tmp_path: Path):
|
||||
"""A malformed pack listing `full` twice must not leave one behind.
|
||||
|
||||
Removing the mixdown by object identity would drop only the entry we surface
|
||||
and leave its duplicate in the stem list — a whole copy of the song, summed
|
||||
with the instruments. That is the exact bug this partition prevents, so a
|
||||
duplicate must not smuggle it back in.
|
||||
"""
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path,
|
||||
{},
|
||||
stems=[
|
||||
{"id": "full", "file": "stems/full.ogg", "default": False},
|
||||
{"id": "guitar", "file": "stems/guitar.ogg", "default": True},
|
||||
{"id": "full", "file": "original/full.ogg", "default": True},
|
||||
],
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.full_mix == "stems/full.ogg"
|
||||
assert [s["id"] for s in loaded.stems] == ["guitar"]
|
||||
|
||||
|
||||
def test_separated_pack_without_a_full_stem_has_no_mixdown(tmp_path: Path):
|
||||
"""Stems only, mixdown discarded — the pre-1.15.0 shape. Nothing to surface."""
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path,
|
||||
{},
|
||||
stems=[
|
||||
{"id": "guitar", "file": "stems/guitar.ogg", "default": True},
|
||||
{"id": "drums", "file": "stems/drums.ogg", "default": True},
|
||||
],
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.full_mix is None
|
||||
assert [s["id"] for s in loaded.stems] == ["guitar", "drums"]
|
||||
|
||||
|
||||
def test_single_mix_pack_ignores_a_lingering_deprecated_key(tmp_path: Path):
|
||||
"""`full` is the pack's only stem AND the old key is still there.
|
||||
|
||||
The stem wins, and it stays the sole playable stem — falling back to the key
|
||||
would surface the mixdown twice: once as the stem the player is already
|
||||
playing, and once as a "pristine" track for it to cross over to.
|
||||
"""
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path,
|
||||
{"original_audio": "original/full.ogg"},
|
||||
stems=[{"id": "full", "file": "stems/full.ogg", "default": True}],
|
||||
write_legacy_full_mix=True,
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.full_mix is None
|
||||
assert [s["id"] for s in loaded.stems] == ["full"]
|
||||
|
||||
|
||||
def test_full_stem_wins_over_the_deprecated_key(tmp_path: Path):
|
||||
"""A migrated pack that still carries the old key must use the stem."""
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path,
|
||||
{"original_audio": "original/full.ogg"},
|
||||
stems=_separated(),
|
||||
write_legacy_full_mix=True,
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.full_mix == "stems/full.ogg"
|
||||
|
||||
|
||||
# ── The library index must not advertise the mixdown as an instrument ────────
|
||||
|
||||
def test_extract_meta_excludes_the_mixdown_from_stem_ids(tmp_path: Path):
|
||||
"""The library's stem chips / stem_count come from here, and must agree with
|
||||
load_song() — otherwise the filter offers a "full" chip beside guitar+drums
|
||||
and counts a third stem that no mixer will ever show."""
|
||||
pak = _write_dir_sloppak(tmp_path, {}, stems=_separated())
|
||||
meta = sloppak_mod.extract_meta(pak)
|
||||
assert meta["stem_ids"] == ["guitar", "drums"]
|
||||
assert meta["stem_count"] == 2
|
||||
|
||||
|
||||
def test_extract_meta_keeps_full_for_a_single_mix_pack(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path, {}, stems=[{"id": "full", "file": "stems/full.ogg", "default": True}]
|
||||
)
|
||||
meta = sloppak_mod.extract_meta(pak)
|
||||
assert meta["stem_ids"] == ["full"]
|
||||
assert meta["stem_count"] == 1
|
||||
|
||||
|
||||
# ── DEPRECATED `original_audio:` fallback — delete with the key (#945) ───────
|
||||
|
||||
def test_legacy_key_still_provides_the_full_mix(tmp_path: Path):
|
||||
"""Every pack written before the spec reserved `full` looks like this. Dropping
|
||||
the read would silently take the pristine mix away from all of them."""
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path, {"original_audio": "original/full.ogg"}, write_legacy_full_mix=True
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.full_mix == "original/full.ogg"
|
||||
# The legacy mixdown lives OUTSIDE `stems`, so the stem list is untouched.
|
||||
assert [s["id"] for s in loaded.stems] == ["guitar"]
|
||||
|
||||
|
||||
def test_legacy_key_absent_means_no_full_mix(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {}, write_legacy_full_mix=True)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.full_mix is None
|
||||
|
||||
|
||||
def test_legacy_key_none_when_file_missing(tmp_path: Path):
|
||||
# Manifest points at a full mix that isn't on disk — disabled silently.
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path, {"original_audio": "original/full.ogg"}, write_legacy_full_mix=False
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.full_mix is None
|
||||
|
||||
|
||||
def test_legacy_key_none_when_value_blank(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path, {"original_audio": " "}, write_legacy_full_mix=True
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.full_mix is None
|
||||
|
||||
|
||||
# ── Security / path-traversal branches (legacy key only — a stem `file` is
|
||||
# resolved through the same /api/sloppak/.../file/ guard as every other stem)
|
||||
|
||||
def test_legacy_key_none_when_path_escapes_sloppak(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path, {"original_audio": "../outside.ogg"}, write_legacy_full_mix=True
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.full_mix is None
|
||||
|
||||
|
||||
def test_legacy_key_none_when_path_is_absolute(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path, {"original_audio": "/etc/passwd"}, write_legacy_full_mix=True
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.full_mix is None
|
||||
@@ -1,119 +0,0 @@
|
||||
"""End-to-end test for the sloppak loader recognising an `original_audio:`
|
||||
manifest key (the single full-mix file shipped alongside the separate stems)
|
||||
and surfacing the manifest-relative path on the LoadedSloppak."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
import sloppak as sloppak_mod
|
||||
|
||||
|
||||
def _write_dir_sloppak(root: Path, manifest_extras: dict, *, write_full_mix: bool) -> Path:
|
||||
"""Build a minimal directory-form sloppak that load_song will accept.
|
||||
|
||||
Uses the tmp_path leaf name to make the sloppak filename unique per test,
|
||||
avoiding the module-level ``resolve_source_dir`` cache being poisoned by a
|
||||
previous test that happened to share the same "song.sloppak" filename.
|
||||
"""
|
||||
pak = root / f"{root.name}.sloppak"
|
||||
pak.mkdir()
|
||||
arr_dir = pak / "arrangements"
|
||||
arr_dir.mkdir()
|
||||
|
||||
arr = {
|
||||
"name": "Lead",
|
||||
"tuning": [0, 0, 0, 0, 0, 0],
|
||||
"capo": 0,
|
||||
"notes": [],
|
||||
"chords": [],
|
||||
"anchors": [],
|
||||
"handshapes": [],
|
||||
"templates": [],
|
||||
"beats": [],
|
||||
"sections": [],
|
||||
}
|
||||
(arr_dir / "lead.json").write_text(json.dumps(arr))
|
||||
|
||||
manifest = {
|
||||
"title": "Test",
|
||||
"artist": "Tester",
|
||||
"album": "",
|
||||
"year": 2026,
|
||||
"duration": 10.0,
|
||||
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
|
||||
"stems": [{"id": "guitar", "file": "stems/guitar.ogg", "default": True}],
|
||||
}
|
||||
manifest.update(manifest_extras)
|
||||
(pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False))
|
||||
|
||||
if write_full_mix:
|
||||
orig_dir = pak / "original"
|
||||
orig_dir.mkdir()
|
||||
# The loader only checks presence (is_file); contents are irrelevant.
|
||||
(orig_dir / "full.ogg").write_bytes(b"OggS-not-real")
|
||||
|
||||
return pak
|
||||
|
||||
|
||||
def _load(pak_path: Path, tmp_path: Path):
|
||||
dlc_root = pak_path.parent
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
return sloppak_mod.load_song(pak_path.name, dlc_root, cache)
|
||||
|
||||
|
||||
# ── Happy path ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_load_song_attaches_original_audio_when_manifest_opts_in(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path, {"original_audio": "original/full.ogg"}, write_full_mix=True
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
# Stored as the manifest-relative string so server.py can build the URL the
|
||||
# same way it builds stem URLs.
|
||||
assert loaded.original_audio == "original/full.ogg"
|
||||
|
||||
|
||||
# ── Absent / degraded branches ───────────────────────────────────────────────
|
||||
|
||||
def test_load_song_original_audio_none_when_manifest_silent(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {}, write_full_mix=True)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.original_audio is None
|
||||
|
||||
|
||||
def test_load_song_original_audio_none_when_file_missing(tmp_path: Path):
|
||||
# Manifest points at a full mix that isn't on disk — disabled silently.
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path, {"original_audio": "original/full.ogg"}, write_full_mix=False
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.original_audio is None
|
||||
|
||||
|
||||
def test_load_song_original_audio_none_when_value_blank(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(tmp_path, {"original_audio": " "}, write_full_mix=True)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.original_audio is None
|
||||
|
||||
|
||||
# ── Security / path-traversal branches ──────────────────────────────────────
|
||||
|
||||
def test_load_song_original_audio_none_when_path_escapes_sloppak(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path, {"original_audio": "../outside.ogg"}, write_full_mix=True
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.original_audio is None
|
||||
|
||||
|
||||
def test_load_song_original_audio_none_when_path_is_absolute(tmp_path: Path):
|
||||
pak = _write_dir_sloppak(
|
||||
tmp_path, {"original_audio": "/etc/passwd"}, write_full_mix=True
|
||||
)
|
||||
loaded = _load(pak, tmp_path)
|
||||
assert loaded.original_audio is None
|
||||
@@ -0,0 +1,309 @@
|
||||
"""The unpack cache is bounded, and reading part of a song doesn't explode it.
|
||||
|
||||
`sloppak_cache/` holds every song ever unpacked, fully decompressed. Stems are
|
||||
already-compressed audio, so an unpacked song is ~1.1x its zip — the cache is a
|
||||
second copy of the library. It used to have no cap, no LRU, and no cleanup at
|
||||
all: a tester reached 60 GB from an 1800-song library because one caller looped
|
||||
the library calling load_song() (rig_builder's library-wide tone batch), which
|
||||
unpacks the WHOLE pack — stems included — to read a few KB of tone JSON.
|
||||
|
||||
Pins, so neither half can silently come back:
|
||||
- resolve_source_dir() evicts LRU songs to stay under the cap,
|
||||
- it never evicts the song the caller just asked for,
|
||||
- an evicted song is dropped from _source_cache too (otherwise the media route
|
||||
keeps serving a path that no longer exists and 404s every stem instead of
|
||||
re-unpacking),
|
||||
- get_cached_source_dir() self-heals if the cache dir is deleted by hand,
|
||||
- read_member_bytes() reads one file WITHOUT unpacking anything.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import sloppak as sloppak_mod
|
||||
|
||||
|
||||
STEM = b"\x00" * (400 * 1024) # 400 KB of "audio" — the bulk of a real pack
|
||||
ARR = b'{"tones": {"definitions": [{"Key": "clean"}]}}'
|
||||
|
||||
|
||||
def _zip_pack(path, stem_bytes=STEM):
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
zf.writestr("manifest.yaml", yaml.safe_dump({
|
||||
"title": path.stem,
|
||||
"arrangements": [{"file": "arrangements/lead.json", "name": "Lead"}],
|
||||
"stems": [{"id": "full", "file": "stems/audio.ogg"}],
|
||||
}))
|
||||
zf.writestr("arrangements/lead.json", ARR)
|
||||
zf.writestr("stems/audio.ogg", stem_bytes)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_module_state():
|
||||
# _source_cache is module state and would leak across tests.
|
||||
importlib.reload(sloppak_mod)
|
||||
yield
|
||||
importlib.reload(sloppak_mod)
|
||||
|
||||
|
||||
def _cap_mb(monkeypatch, mb):
|
||||
monkeypatch.setenv("FEEDBACK_SLOPPAK_CACHE_MAX_MB", str(mb))
|
||||
|
||||
|
||||
def test_read_member_bytes_does_not_unpack(tmp_path, monkeypatch):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
pack = _zip_pack(dlc / "song.feedpak")
|
||||
|
||||
data = sloppak_mod.read_member_bytes(pack, "arrangements/lead.json")
|
||||
|
||||
assert data == ARR
|
||||
assert list(cache.iterdir()) == [], (
|
||||
"reading one member must not unpack the pack — this is the whole point: "
|
||||
"load_song() would have written the 400 KB stem to disk to get 45 bytes of JSON"
|
||||
)
|
||||
|
||||
|
||||
def test_read_member_bytes_missing_member_is_none(tmp_path):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
pack = _zip_pack(dlc / "song.feedpak")
|
||||
assert sloppak_mod.read_member_bytes(pack, "arrangements/nope.json") is None
|
||||
assert sloppak_mod.read_member_bytes(pack, "") is None
|
||||
|
||||
|
||||
def test_unpack_cache_evicts_lru_to_stay_under_cap(tmp_path, monkeypatch):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_cap_mb(monkeypatch, 1) # 1 MB — holds ~2 of our 400 KB packs
|
||||
|
||||
for i in range(6):
|
||||
_zip_pack(dlc / f"song{i}.feedpak")
|
||||
|
||||
for i in range(6):
|
||||
sloppak_mod.resolve_source_dir(f"song{i}.feedpak", dlc, cache)
|
||||
|
||||
total = sum(f.stat().st_size for f in cache.rglob("*") if f.is_file())
|
||||
assert total <= 1 * 1024 * 1024, (
|
||||
f"unpack cache ran to {total/1e6:.1f} MB against a 1 MB cap — this is the "
|
||||
"unbounded growth that reached 60 GB in the field"
|
||||
)
|
||||
# The most recent song must survive; the oldest must not.
|
||||
names = {d.name for d in cache.iterdir()}
|
||||
assert "song5.feedpak" in names, "the song just resolved must never be evicted"
|
||||
assert "song0.feedpak" not in names, "the least-recently-used song should go first"
|
||||
|
||||
|
||||
def test_eviction_drops_the_source_cache_entry(tmp_path, monkeypatch):
|
||||
"""An evicted song must not keep being handed out by get_cached_source_dir().
|
||||
|
||||
media.py only falls back to resolve_source_dir() when this returns None. If a
|
||||
stale path survives, every stem 404s for the rest of the process instead of
|
||||
re-unpacking — a silently broken song, not a slow one.
|
||||
"""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_cap_mb(monkeypatch, 1)
|
||||
|
||||
for i in range(6):
|
||||
_zip_pack(dlc / f"song{i}.feedpak")
|
||||
for i in range(6):
|
||||
sloppak_mod.resolve_source_dir(f"song{i}.feedpak", dlc, cache)
|
||||
|
||||
evicted = sloppak_mod.get_cached_source_dir("song0.feedpak")
|
||||
assert evicted is None, "an evicted song must be dropped from _source_cache"
|
||||
|
||||
# ...and asking for it again just re-unpacks it. Self-healing, not broken.
|
||||
again = sloppak_mod.resolve_source_dir("song0.feedpak", dlc, cache)
|
||||
assert (again / "stems" / "audio.ogg").is_file()
|
||||
|
||||
|
||||
def test_get_cached_source_dir_self_heals_after_manual_delete(tmp_path, monkeypatch):
|
||||
"""Telling a user to delete sloppak_cache/ to reclaim disk must be safe."""
|
||||
import shutil
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_cap_mb(monkeypatch, 0) # eviction off — isolate the delete
|
||||
_zip_pack(dlc / "song.feedpak")
|
||||
|
||||
src = sloppak_mod.resolve_source_dir("song.feedpak", dlc, cache)
|
||||
assert sloppak_mod.get_cached_source_dir("song.feedpak") == src
|
||||
|
||||
shutil.rmtree(src) # the user clears the folder
|
||||
|
||||
assert sloppak_mod.get_cached_source_dir("song.feedpak") is None, (
|
||||
"a path that no longer exists must not be served — the caller would 404 "
|
||||
"every stem instead of re-unpacking"
|
||||
)
|
||||
assert (sloppak_mod.resolve_source_dir("song.feedpak", dlc, cache)
|
||||
/ "stems" / "audio.ogg").is_file()
|
||||
|
||||
|
||||
def test_cap_of_zero_disables_eviction(tmp_path, monkeypatch):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_cap_mb(monkeypatch, 0)
|
||||
for i in range(4):
|
||||
_zip_pack(dlc / f"song{i}.feedpak")
|
||||
for i in range(4):
|
||||
sloppak_mod.resolve_source_dir(f"song{i}.feedpak", dlc, cache)
|
||||
assert len(list(cache.iterdir())) == 4, "cap 0 must mean 'never evict'"
|
||||
|
||||
|
||||
def test_read_member_bytes_normalizes_non_canonical_names(tmp_path):
|
||||
"""A manifest may name a member './arrangements/lead.json' — valid, and it
|
||||
resolved fine once unpacked. Reading the zip member by the raw string would
|
||||
KeyError and silently report no tones. Same trap read_cover_bytes already hit."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
pack = _zip_pack(dlc / "song.feedpak")
|
||||
|
||||
assert sloppak_mod.read_member_bytes(pack, "./arrangements/lead.json") == ARR
|
||||
assert sloppak_mod.read_member_bytes(pack, "stems/../arrangements/lead.json") == ARR
|
||||
assert sloppak_mod.read_member_bytes(pack, "arrangements\\lead.json") == ARR
|
||||
|
||||
|
||||
def test_read_member_bytes_rejects_zip_slip(tmp_path):
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
pack = _zip_pack(dlc / "song.feedpak")
|
||||
assert sloppak_mod.read_member_bytes(pack, "../../etc/passwd") is None
|
||||
assert sloppak_mod.read_member_bytes(pack, "/etc/passwd") is None
|
||||
assert sloppak_mod.read_member_bytes(pack, ".") is None
|
||||
|
||||
|
||||
def test_eviction_never_deletes_an_in_flight_unpack(tmp_path, monkeypatch):
|
||||
"""Two unpacks run concurrently (_UNPACK_MAX_CONCURRENCY = 2). One finishing
|
||||
must not rmtree the other's half-written dir — that resolver would then cache
|
||||
an incomplete song and serve a broken pack.
|
||||
|
||||
Sized so the sweep genuinely has to reach the in-flight directory: each pack
|
||||
is ~700 KB against a 1 MB cap, so once `keep` is protected the sweep must
|
||||
delete EVERY other dir to get under the cap — including the one being written.
|
||||
(A naive version of this test passes even without the guard, because a
|
||||
freshly-created dir is the most-recently-used and the sweep never gets to it.)
|
||||
"""
|
||||
import threading
|
||||
|
||||
big = b"\x00" * (700 * 1024)
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_cap_mb(monkeypatch, 1)
|
||||
for i in range(3):
|
||||
_zip_pack(dlc / f"song{i}.feedpak", stem_bytes=big)
|
||||
|
||||
victim = cache / "song2.feedpak"
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
real_unpack = sloppak_mod._unpack_zip
|
||||
|
||||
def slow_unpack(zip_path, dest):
|
||||
real_unpack(zip_path, dest) # dir now exists — "half written"
|
||||
if dest == victim:
|
||||
started.set()
|
||||
release.wait(5) # hold it open while the other sweeps
|
||||
|
||||
monkeypatch.setattr(sloppak_mod, "_unpack_zip", slow_unpack)
|
||||
|
||||
t = threading.Thread(target=sloppak_mod.resolve_source_dir,
|
||||
args=("song2.feedpak", dlc, cache))
|
||||
t.start()
|
||||
assert started.wait(5), "victim unpack did not start"
|
||||
|
||||
# song0 lands and sweeps: keep=song0, cache holds song0+song2 = 1.4 MB > 1 MB,
|
||||
# so the sweep MUST try to delete song2 — which is still being written.
|
||||
sloppak_mod.resolve_source_dir("song0.feedpak", dlc, cache)
|
||||
in_flight_survived = victim.is_dir()
|
||||
|
||||
release.set()
|
||||
t.join(5)
|
||||
|
||||
assert in_flight_survived, (
|
||||
"eviction deleted a directory another thread was still unpacking into — "
|
||||
"that resolver caches an incomplete song and serves a broken pack"
|
||||
)
|
||||
|
||||
|
||||
def test_read_member_bytes_finds_backslash_members(tmp_path):
|
||||
"""Windows-authored packs store members as 'arrangements\\lead.json'.
|
||||
_unpack_zip() normalizes those on extract, so unpack-then-read found them.
|
||||
An exact getinfo() would not — and we'd silently report the song has no tones."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
pack = dlc / "win.feedpak"
|
||||
with zipfile.ZipFile(pack, "w") as zf:
|
||||
zf.writestr("manifest.yaml", yaml.safe_dump({"title": "w"}))
|
||||
zf.writestr("arrangements\\lead.json", ARR) # backslash member name
|
||||
|
||||
assert sloppak_mod.read_member_bytes(pack, "arrangements/lead.json") == ARR
|
||||
|
||||
|
||||
def test_read_member_bytes_finds_non_canonical_STORED_names(tmp_path):
|
||||
"""The archive itself may store './arrangements/lead.json'. _unpack_zip()
|
||||
normalizes stored names on extract, so unpack-then-read resolved it. Both the
|
||||
requested path and the stored name must be normalized, or the tones vanish."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
pack = dlc / "odd.feedpak"
|
||||
with zipfile.ZipFile(pack, "w") as zf:
|
||||
zf.writestr("manifest.yaml", yaml.safe_dump({"title": "o"}))
|
||||
zf.writestr("./arrangements/lead.json", ARR) # stored non-canonically
|
||||
|
||||
assert sloppak_mod.read_member_bytes(pack, "arrangements/lead.json") == ARR
|
||||
|
||||
|
||||
def test_read_member_bytes_matches_unpack_last_write_wins(tmp_path):
|
||||
"""If a pack stores two names that normalize to the same file, _unpack_zip
|
||||
writes them in order and the LAST one is what ends up on disk. Reading the
|
||||
raw member by exact name would hand back the first — stale arrangement data
|
||||
that no unpacked read would ever have produced."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
pack = dlc / "dupe.feedpak"
|
||||
with zipfile.ZipFile(pack, "w") as zf:
|
||||
zf.writestr("manifest.yaml", yaml.safe_dump({"title": "d"}))
|
||||
zf.writestr("arrangements/lead.json", b'{"tones": {"definitions": [{"Key": "STALE"}]}}')
|
||||
zf.writestr("./arrangements/lead.json", ARR) # normalizes to the same path
|
||||
|
||||
assert sloppak_mod.read_member_bytes(pack, "arrangements/lead.json") == ARR
|
||||
|
||||
|
||||
def test_failed_unpack_does_not_leave_the_dir_un_evictable(tmp_path, monkeypatch):
|
||||
"""A dir marked in-flight is skipped by eviction. If a failed unpack leaves the
|
||||
marker behind, that dir becomes permanently un-evictable — a slow leak of
|
||||
exactly the thing this cap exists to prevent."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
_cap_mb(monkeypatch, 1)
|
||||
_zip_pack(dlc / "boom.feedpak")
|
||||
|
||||
def blow_up(zip_path, dest):
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(sloppak_mod, "_unpack_zip", blow_up)
|
||||
with pytest.raises(OSError):
|
||||
sloppak_mod.resolve_source_dir("boom.feedpak", dlc, cache)
|
||||
|
||||
assert not sloppak_mod._unpacking, (
|
||||
"a failed unpack left its destination marked in-flight — eviction will "
|
||||
"skip it forever"
|
||||
)
|
||||
@@ -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}
|
||||
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migrate packs off the deprecated `original_audio:` key — the full mix is a stem.
|
||||
|
||||
Before feedpak 1.15.0 reserved the stem id `full`, spec §5.3 said the mixdown was
|
||||
"commonly replaced" by the per-instrument stems when a pack was split — so after
|
||||
separation it had nowhere to live. This repo worked around that by inventing a
|
||||
top-level `original_audio:` manifest key pointing at a parallel `original/`
|
||||
directory (#583). The key was never in the spec, and #933 removed core's
|
||||
dependence on it: the mixdown is a stem, and its id is `full`.
|
||||
|
||||
This rewrites a pack into the shape the spec now defines:
|
||||
|
||||
original/full.ogg -> stems/full.ogg (entry moved)
|
||||
original_audio: original/full.ogg -> stems: [{id: full, file: stems/full.ogg,
|
||||
default: 'off'}, ...]
|
||||
|
||||
`default: 'off'` is what makes the retained mixdown safe: a reader that honours
|
||||
`default` (normative since feedpak 1.0.0) will not play it on open, so it never
|
||||
doubles the mix even in a reader that predates the reserved id.
|
||||
|
||||
Nothing else in the pack is touched — every other key, file and stem is preserved
|
||||
verbatim, and `feedpak_version` is stamped to the version the result conforms to.
|
||||
|
||||
The rewrite is atomic per pack: a new archive is built beside the original and
|
||||
renamed over it only on success, so an interrupted run leaves every pack either
|
||||
fully migrated or untouched — never truncated.
|
||||
|
||||
Idempotent: a pack that already carries a `full` stem and no `original_audio:` is
|
||||
reported as `skip` and left alone, so a partial run can simply be re-run.
|
||||
|
||||
Usage:
|
||||
python tools/migrate_full_mix_stem.py --dry-run /path/to/packs # report only
|
||||
python tools/migrate_full_mix_stem.py /path/to/packs # migrate
|
||||
python tools/migrate_full_mix_stem.py --verify /path/to/packs # check results
|
||||
|
||||
Exit status is 0 only when every pack ended up in the migrated shape (or was
|
||||
already there).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
# The version this migration brings a pack up to: the one that reserved `full`.
|
||||
TARGET_FEEDPAK_VERSION = "1.15.0"
|
||||
FULL_MIX_STEM_ID = "full"
|
||||
LEGACY_KEY = "original_audio"
|
||||
# Where the mixdown lands. §2.1's conventional layout — readers resolve through
|
||||
# the manifest and never care about the path, but a pack that says `stems/` and
|
||||
# means it is the one a human can read.
|
||||
CANONICAL_FULL_MIX_PATH = "stems/full.ogg"
|
||||
|
||||
PACK_EXTS = (".feedpak", ".sloppak")
|
||||
|
||||
|
||||
class Skip(Exception):
|
||||
"""Pack needs no migration."""
|
||||
|
||||
|
||||
def is_safe_relpath(rel: str) -> bool:
|
||||
"""True when `rel` is a manifest path the spec allows (§2.2 rule 2).
|
||||
|
||||
POSIX-style relative: forward slashes, no leading `/`, no `..` segments, no
|
||||
empty segments, no colon (which excludes drive letters and NTFS alternate
|
||||
data streams), no backslashes.
|
||||
|
||||
This is a TRUST BOUNDARY, not a tidiness check. Core's loader refuses a
|
||||
full-mix path that escapes the pack and reports the pack as having no full
|
||||
mix — the audio is inert. A migration that moved such an entry into
|
||||
`stems/full.ogg` would take content the reader deliberately rejected and
|
||||
hand it back as a valid, playable stem. So a pack like this is reported, not
|
||||
migrated.
|
||||
"""
|
||||
if not rel or rel.startswith("/") or "\\" in rel or ":" in rel:
|
||||
return False
|
||||
parts = rel.split("/")
|
||||
return all(p and p != ".." for p in parts)
|
||||
|
||||
|
||||
def plan_manifest(manifest: dict) -> tuple[dict, str]:
|
||||
"""Return (new_manifest, relpath_of_audio_to_move); "" = no file needs moving.
|
||||
|
||||
Raises Skip when the pack needs no migration. Pure — no I/O — so the part
|
||||
with the decisions in it is testable without building archives.
|
||||
"""
|
||||
raw_stems = manifest.get("stems")
|
||||
stems: list = raw_stems if isinstance(raw_stems, list) else []
|
||||
has_full_stem = any(
|
||||
isinstance(s, dict) and str(s.get("id", "")) == FULL_MIX_STEM_ID for s in stems
|
||||
)
|
||||
legacy_rel = manifest.get(LEGACY_KEY)
|
||||
legacy_rel = legacy_rel.strip() if isinstance(legacy_rel, str) else ""
|
||||
|
||||
if not legacy_rel:
|
||||
# Nothing invented to undo: either the pack already keeps its mixdown as
|
||||
# the `full` stem, or it never carried one.
|
||||
raise Skip("already migrated" if has_full_stem else "no original_audio key")
|
||||
|
||||
if has_full_stem:
|
||||
# The mixdown is already a stem and the dead key merely lingers beside it.
|
||||
# Drop the key; move nothing. But do NOT trust its `default`: a mixdown
|
||||
# left enabled beside instrument stems is the double-audio hazard this
|
||||
# migration exists to remove, and a reader that honours `default` would
|
||||
# play the whole song on top of the stems on open. Force it off — unless
|
||||
# `full` is the only stem, in which case it IS the audio.
|
||||
others = [
|
||||
s
|
||||
for s in stems
|
||||
if isinstance(s, dict) and str(s.get("id", "")) != FULL_MIX_STEM_ID
|
||||
]
|
||||
new_stems = []
|
||||
for s in stems:
|
||||
if isinstance(s, dict) and str(s.get("id", "")) == FULL_MIX_STEM_ID and others:
|
||||
s = {**s, "default": "off"}
|
||||
new_stems.append(s)
|
||||
to_move = ""
|
||||
else:
|
||||
# `default` decides whether a reader plays this on open, and that is the
|
||||
# whole safety margin: alongside per-instrument stems the mixdown must be
|
||||
# OFF (a reader that sums the list would otherwise double the song), but
|
||||
# when it is the pack's only stem it IS the audio and must be ON.
|
||||
entry = {
|
||||
"id": FULL_MIX_STEM_ID,
|
||||
"file": CANONICAL_FULL_MIX_PATH,
|
||||
"default": "off" if stems else "on",
|
||||
}
|
||||
# First in the list, matching the spec's §5.3 example.
|
||||
new_stems = [entry, *stems]
|
||||
# If the key already pointed at the canonical path, only the manifest is wrong.
|
||||
to_move = "" if legacy_rel == CANONICAL_FULL_MIX_PATH else legacy_rel
|
||||
|
||||
out: dict = {}
|
||||
for k, v in manifest.items():
|
||||
if k == LEGACY_KEY:
|
||||
continue # the invented key disappears
|
||||
out[k] = new_stems if k == "stems" else v
|
||||
out.setdefault("stems", new_stems) # a pack that had no stems list gets one
|
||||
out["feedpak_version"] = TARGET_FEEDPAK_VERSION
|
||||
return out, to_move
|
||||
|
||||
|
||||
def migrate_zip(path: Path, dry_run: bool) -> str:
|
||||
"""Rewrite one zipped pack in place. Returns a one-word status."""
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
try:
|
||||
raw = zf.read("manifest.yaml")
|
||||
except KeyError:
|
||||
return "no-manifest"
|
||||
manifest = yaml.safe_load(raw) or {}
|
||||
try:
|
||||
new_manifest, old_rel = plan_manifest(manifest)
|
||||
except Skip:
|
||||
return "skip"
|
||||
names = set(zf.namelist())
|
||||
if old_rel:
|
||||
if not is_safe_relpath(old_rel):
|
||||
# Core refuses this path and plays no full mix for the pack. Do
|
||||
# not launder it into a valid stem — see is_safe_relpath().
|
||||
return "unsafe-path"
|
||||
if old_rel not in names:
|
||||
# The key points at audio that isn't in the archive. Core already
|
||||
# treats that as "no full mix"; migrating would fabricate a stem
|
||||
# entry for a file that does not exist and break every reader.
|
||||
return "missing-audio"
|
||||
if CANONICAL_FULL_MIX_PATH in names:
|
||||
return "target-occupied"
|
||||
else:
|
||||
# Manifest-only rewrite (a stale key beside a mixdown that is already
|
||||
# a stem, or a key that already pointed at the canonical path). Check
|
||||
# the file the resulting `full` stem will actually NAME — not the
|
||||
# canonical path, which an already-migrated pack is free not to use:
|
||||
# §2.2 says readers resolve through the manifest, so a valid pack may
|
||||
# keep its mixdown anywhere.
|
||||
full_file = next(
|
||||
(
|
||||
s.get("file")
|
||||
for s in new_manifest.get("stems", [])
|
||||
if isinstance(s, dict) and str(s.get("id", "")) == FULL_MIX_STEM_ID
|
||||
),
|
||||
None,
|
||||
)
|
||||
if full_file not in names:
|
||||
return "missing-audio"
|
||||
if dry_run:
|
||||
return "would-migrate"
|
||||
|
||||
# Build the replacement beside the original, on the same filesystem, so
|
||||
# the final rename is atomic and an interrupted run can't truncate a pack.
|
||||
tmp_fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp"
|
||||
)
|
||||
os.close(tmp_fd)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as out:
|
||||
for item in zf.infolist():
|
||||
if item.filename == "manifest.yaml":
|
||||
out.writestr(
|
||||
item,
|
||||
yaml.safe_dump(
|
||||
new_manifest, sort_keys=False, allow_unicode=True
|
||||
),
|
||||
)
|
||||
continue
|
||||
data = zf.read(item.filename)
|
||||
if old_rel and item.filename == old_rel:
|
||||
# Same bytes, same compression, new name: the mixdown moves
|
||||
# from original/ into stems/ where the format says audio goes.
|
||||
moved = zipfile.ZipInfo(
|
||||
CANONICAL_FULL_MIX_PATH, date_time=item.date_time
|
||||
)
|
||||
moved.compress_type = item.compress_type
|
||||
moved.external_attr = item.external_attr
|
||||
out.writestr(moved, data)
|
||||
continue
|
||||
out.writestr(item, data)
|
||||
except BaseException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
shutil.copystat(path, tmp_path)
|
||||
os.replace(tmp_path, path) # atomic
|
||||
return "migrated"
|
||||
|
||||
|
||||
def verify_zip(path: Path) -> str:
|
||||
"""Confirm a pack is in the migrated shape and its mixdown is really there."""
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
try:
|
||||
manifest = yaml.safe_load(zf.read("manifest.yaml")) or {}
|
||||
except KeyError:
|
||||
return "no-manifest"
|
||||
if LEGACY_KEY in manifest:
|
||||
return "still-has-key"
|
||||
stems = manifest.get("stems") or []
|
||||
full = next(
|
||||
(
|
||||
s
|
||||
for s in stems
|
||||
if isinstance(s, dict) and str(s.get("id", "")) == FULL_MIX_STEM_ID
|
||||
),
|
||||
None,
|
||||
)
|
||||
if full is None:
|
||||
return "no-full-stem"
|
||||
if full.get("file") not in set(zf.namelist()):
|
||||
return "full-stem-missing-file"
|
||||
# A retained mixdown that plays on open would double the mix in any reader
|
||||
# that sums the stem list — the whole hazard this migration must not create.
|
||||
# Beside instrument stems, `full` MUST carry an explicit, normalized "off":
|
||||
# - core (lib/sloppak.py) defaults an ABSENT `default` to True (ON) and
|
||||
# treats an empty / unrecognized string as ON, so a missing or blank
|
||||
# default is not merely non-canonical — core would play the mixdown on
|
||||
# open, doubling the song. It is the exact hazard, not a lesser one.
|
||||
# - the migrator always writes the literal "off", so requiring it also
|
||||
# certifies the pack is in the shape this tool produces — the most
|
||||
# portable spelling, understood even by a reader that only knows
|
||||
# "on"/"off" and would choke on a boolean or `false`/`0`/`no`.
|
||||
# So: `on`-ish values are reported as actively-playing; everything that is
|
||||
# not a normalized "off" (missing, empty, boolean, `false`/`no`/`0`,
|
||||
# malformed) is reported as an unsafe/non-canonical default.
|
||||
if len(stems) > 1:
|
||||
default = str(full.get("default", "")).strip().lower()
|
||||
if default in ("true", "on", "yes", "1"):
|
||||
return "full-stem-default-on"
|
||||
if default != "off":
|
||||
return "full-stem-default-not-off"
|
||||
return "ok"
|
||||
|
||||
|
||||
def migrate_pack(path: Path, dry_run: bool) -> str:
|
||||
"""Dispatch by pack form. ZIP-file packs are rewritten in place; directory
|
||||
(authoring) packs are REPORTED, not rewritten.
|
||||
|
||||
A single-file pack is replaced atomically — a fully-built temp archive
|
||||
swapped in with one os.replace(), so an interrupted run leaves it either
|
||||
fully migrated or untouched. A directory can't be swapped that way (no
|
||||
atomic replace of a populated directory), so an in-place rewrite could leave
|
||||
an authoring pack half-migrated. Rather than risk that, directory packs are
|
||||
surfaced as `dir-form-unsupported` (a problem status, so the run's exit code
|
||||
and summary flag them) for the operator to re-pack or migrate as a `.feedpak`.
|
||||
"""
|
||||
if path.is_dir():
|
||||
return "dir-form-unsupported"
|
||||
return migrate_zip(path, dry_run)
|
||||
|
||||
|
||||
def verify_pack(path: Path) -> str:
|
||||
"""Verify a pack; directory (authoring) packs are reported, see migrate_pack."""
|
||||
if path.is_dir():
|
||||
return "dir-form-unsupported"
|
||||
return verify_zip(path)
|
||||
|
||||
|
||||
def iter_packs(root: Path):
|
||||
"""Yield every pack under `root`. A pack is a suffix-named ZIP FILE or a
|
||||
suffix-named DIRECTORY (the authoring form) — both are discovered so a
|
||||
directory-form pack is never silently walked past. A directory pack is
|
||||
yielded whole, not descended into: its `stems/` and `arrangements/` are pack
|
||||
contents, not packs. (migrate/verify then report directory packs rather than
|
||||
rewriting them in place — see migrate_pack.)"""
|
||||
if root.is_file():
|
||||
yield root
|
||||
return
|
||||
# A directory whose OWN name is a pack suffix is a single directory-form
|
||||
# pack passed directly, not a tree of packs to search.
|
||||
if root.name.endswith(PACK_EXTS):
|
||||
yield root
|
||||
return
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
for fn in sorted(filenames):
|
||||
if fn.endswith(PACK_EXTS):
|
||||
yield Path(dirpath) / fn
|
||||
for dn in sorted(dn for dn in dirnames if dn.endswith(PACK_EXTS)):
|
||||
yield Path(dirpath) / dn
|
||||
# Don't descend INTO a pack directory — its contents aren't packs.
|
||||
dirnames[:] = [dn for dn in dirnames if not dn.endswith(PACK_EXTS)]
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("root", type=Path, help="pack, or directory of packs")
|
||||
ap.add_argument("--dry-run", action="store_true", help="report, change nothing")
|
||||
ap.add_argument("--verify", action="store_true", help="check the migrated shape")
|
||||
ap.add_argument("--jobs", type=int, default=8, help="parallel packs (default 8)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if not args.root.exists():
|
||||
print(f"error: {args.root} does not exist", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
packs = list(iter_packs(args.root))
|
||||
if not packs:
|
||||
print(f"no packs found under {args.root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
action = verify_pack if args.verify else (lambda p: migrate_pack(p, args.dry_run))
|
||||
|
||||
def work(pack: Path) -> str:
|
||||
"""Never raise. One unreadable pack must not kill a 50,000-pack run.
|
||||
|
||||
A library this size has damage in it — a truncated download, an archive
|
||||
left half-written by an interrupted converter. Letting that propagate
|
||||
aborts the whole job partway through and throws away the summary, which
|
||||
is exactly when you most need to know what happened. Report it as a
|
||||
problem status instead: the pack is untouched, the run continues, and the
|
||||
final report names it.
|
||||
"""
|
||||
try:
|
||||
return action(pack)
|
||||
except zipfile.BadZipFile:
|
||||
return "corrupt-zip"
|
||||
except OSError as e:
|
||||
return f"io-error ({e.__class__.__name__})"
|
||||
except Exception as e: # malformed YAML, unexpected manifest shape, …
|
||||
return f"error ({e.__class__.__name__})"
|
||||
counts: dict[str, int] = {}
|
||||
problems: list[tuple[str, Path]] = []
|
||||
# A real run rewrites every archive under `root` — tens of thousands of packs
|
||||
# and hundreds of gigabytes. Printing only a final summary means hours of
|
||||
# silence, in which a stall and steady progress look identical. Emit a
|
||||
# heartbeat instead: rate and ETA come from the packs actually finished, so
|
||||
# it stays honest when the disk slows down. stderr, so `> report.txt` keeps
|
||||
# the summary clean.
|
||||
total = len(packs)
|
||||
started = time.monotonic()
|
||||
# The heartbeat runs on its OWN CLOCK, in its own thread.
|
||||
#
|
||||
# Two weaker designs were tried and both go quiet exactly when you need them
|
||||
# to speak. Ticking every N packs ties the cadence to how slow a pack is: 500
|
||||
# packs is a blink in a --dry-run and many minutes in a real migration, so the
|
||||
# run that most needs watching says the least. Ticking on time but only when a
|
||||
# pack *finishes* is no better: if every worker is grinding on a huge archive,
|
||||
# nothing completes, so nothing prints — and a stall becomes indistinguishable
|
||||
# from progress, which is the one thing a progress meter must never allow.
|
||||
#
|
||||
# A daemon thread on a fixed interval reports regardless. If the count stops
|
||||
# advancing between beats, you are looking at a stall, and you can see it.
|
||||
HEARTBEAT_SECONDS = 10.0
|
||||
done = 0 # only the main loop writes it; the beat thread only reads
|
||||
stop_beat = threading.Event()
|
||||
|
||||
def heartbeat() -> None:
|
||||
while not stop_beat.wait(HEARTBEAT_SECONDS):
|
||||
elapsed = time.monotonic() - started
|
||||
rate = done / elapsed if elapsed > 0 else 0.0
|
||||
eta = (total - done) / rate if rate > 0 else 0.0
|
||||
print(
|
||||
f" {done}/{total} ({100 * done / total:.1f}%) "
|
||||
f"{rate:.1f} packs/s eta {eta / 60:.0f}m "
|
||||
f"[{len(problems)} problem(s)]",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print(
|
||||
f"{total} pack(s) under {args.root} — "
|
||||
f"{'verifying' if args.verify else 'dry run' if args.dry_run else 'migrating'} "
|
||||
f"with {max(1, args.jobs)} job(s)",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
beat = threading.Thread(target=heartbeat, daemon=True)
|
||||
beat.start()
|
||||
|
||||
# as_completed, not pool.map: map yields in SUBMISSION order, so the counter
|
||||
# would stall behind one slow pack while later ones were already done — a
|
||||
# progress meter that lies about progress. Count each pack as it finishes.
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool:
|
||||
futures = {pool.submit(work, p): p for p in packs}
|
||||
for fut in as_completed(futures):
|
||||
pack = futures[fut]
|
||||
status = fut.result()
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
if status not in ("migrated", "skip", "would-migrate", "ok"):
|
||||
problems.append((status, pack))
|
||||
done += 1
|
||||
finally:
|
||||
stop_beat.set()
|
||||
beat.join(timeout=1)
|
||||
|
||||
print(f"\n{len(packs)} pack(s) under {args.root}")
|
||||
for status, n in sorted(counts.items(), key=lambda kv: -kv[1]):
|
||||
print(f" {n:>7} {status}")
|
||||
if problems:
|
||||
print(f"\n{len(problems)} pack(s) need a look:", file=sys.stderr)
|
||||
for status, pack in problems[:20]:
|
||||
print(f" {status:<22} {pack}", file=sys.stderr)
|
||||
if len(problems) > 20:
|
||||
print(f" … and {len(problems) - 20} more", file=sys.stderr)
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user