mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 23:08:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b02868cbc1 | ||
|
|
3717e4338d | ||
|
|
0b4b174d33 | ||
|
|
2f2a095e4c | ||
|
|
e14ef64224 | ||
|
|
365cec1d29 |
@@ -212,6 +212,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **3D Highway: the lane stops at the hit line** (#991) — the highway lane, its
|
||||||
|
dividers, and the fret boundary extension lines ran `BEHIND` seconds *past* the
|
||||||
|
hit line toward the player. Nothing is ever drawn in that strip (notes and chord
|
||||||
|
frames clamp to `Math.min(0, dZ(dt))`), so it read as lane with no notes on it.
|
||||||
|
The floor geometry now ends at the hit line; its far edge is unchanged, still
|
||||||
|
`-AHEAD*TS` at the note horizon.
|
||||||
- **Career passports review polish** — the passport tabs and book overlay carry
|
- **Career passports review polish** — the passport tabs and book overlay carry
|
||||||
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
|
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
|
||||||
`role="dialog"` + `aria-modal` with focus moved to the close button on open
|
`role="dialog"` + `aria-modal` with focus moved to the close button on open
|
||||||
|
|||||||
+137
-5
@@ -51,6 +51,97 @@ from scan_worker import _relpath, _scan_one
|
|||||||
|
|
||||||
log = logging.getLogger("feedBack.scan")
|
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}
|
_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.
|
"""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
|
Never sets `_scan_status["running"] = False` — ownership of that flag
|
||||||
lives in `_scan_runner` so a `kick_scan()` racing this function's
|
lives in `_scan_runner` so a `kick_scan()` racing this function's
|
||||||
terminal write cannot observe a stale False and start a second runner.
|
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_diagnostic_sloppaks(appstate.server_root, dlc)
|
||||||
builtin_content.seed_builtin_starter_content(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
|
# 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
|
# path isn't shared. Report the failure explicitly rather than silently
|
||||||
# appearing to scan nothing.
|
# appearing to scan nothing.
|
||||||
@@ -223,6 +333,9 @@ def background_scan():
|
|||||||
to_scan.append((f, mtime, size, dlc))
|
to_scan.append((f, mtime, size, dlc))
|
||||||
|
|
||||||
if not to_scan:
|
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}
|
_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))
|
log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs))
|
||||||
return
|
return
|
||||||
@@ -247,6 +360,9 @@ def background_scan():
|
|||||||
_scan_status["done"] += 1
|
_scan_status["done"] += 1
|
||||||
_scan_status["current"] = fname
|
_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))
|
log.info("Scan complete: %d songs cached", len(to_scan))
|
||||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
|
_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
|
_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
|
# 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
|
_scan_thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
|
||||||
def kick_scan() -> bool:
|
def kick_scan(force: bool = False) -> bool:
|
||||||
"""Request a library rescan, single-flight + coalescing.
|
"""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
|
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
|
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
|
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
|
until the next periodic pass. Multiple late-arriving requests coalesce
|
||||||
into a single follow-up.
|
into a single follow-up.
|
||||||
"""
|
"""
|
||||||
global _scan_rescan_pending, _scan_thread
|
global _scan_rescan_pending, _scan_thread, _scan_force_next
|
||||||
with _scan_kick_lock:
|
with _scan_kick_lock:
|
||||||
|
if force:
|
||||||
|
_scan_force_next = True
|
||||||
if _scan_status["running"]:
|
if _scan_status["running"]:
|
||||||
_scan_rescan_pending = True
|
_scan_rescan_pending = True
|
||||||
return False
|
return False
|
||||||
@@ -290,10 +417,15 @@ def kick_scan() -> bool:
|
|||||||
|
|
||||||
def _scan_runner():
|
def _scan_runner():
|
||||||
"""Run _background_scan, then re-run if requests arrived mid-scan."""
|
"""Run _background_scan, then re-run if requests arrived mid-scan."""
|
||||||
global _scan_rescan_pending
|
global _scan_rescan_pending, _scan_force_next
|
||||||
while True:
|
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:
|
try:
|
||||||
background_scan()
|
background_scan(force=forced)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("background scan failed unexpectedly")
|
log.exception("background scan failed unexpectedly")
|
||||||
|
|
||||||
|
|||||||
+29
-17
@@ -522,27 +522,39 @@ def _current_venue():
|
|||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
def _unplayed_genre_songs(gkey, exclude, limit):
|
def _fill_genre_songs(gkey, exclude, limit):
|
||||||
"""Library songs of a genre with no stats yet — a young passport's gig
|
"""Library songs of a genre to round out a gig — ANY song of the genre the
|
||||||
still gets a full set (playing them is how stubs start).
|
set hasn't already picked.
|
||||||
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
|
|
||||||
songs, single-user); push the match into SQL if propose ever feels slow."""
|
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"]
|
db = _state["meta_db"]
|
||||||
if db is None:
|
if db is None:
|
||||||
return []
|
return []
|
||||||
rows = db.conn.execute(
|
rows = db.conn.execute(
|
||||||
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
|
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs"
|
||||||
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
out = []
|
pool = [
|
||||||
for filename, title, artist, genre in rows:
|
{"filename": filename, "title": title or filename, "artist": artist or ""}
|
||||||
if _genre_key(genre) != gkey or filename in exclude:
|
for filename, title, artist, genre in rows
|
||||||
continue
|
if _genre_key(genre) == gkey and filename not in exclude
|
||||||
out.append({"filename": filename, "title": title or filename,
|
]
|
||||||
"artist": artist or ""})
|
random.shuffle(pool) # re-roll must vary; free per call
|
||||||
if len(out) >= limit:
|
return pool[:limit]
|
||||||
break
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_pack_dir(pack_dir: Path):
|
def _validate_pack_dir(pack_dir: Path):
|
||||||
@@ -836,7 +848,7 @@ def setup(app, context):
|
|||||||
picks.append(s)
|
picks.append(s)
|
||||||
if len(picks) < size:
|
if len(picks) < size:
|
||||||
exclude = {s["filename"] for s in picks}
|
exclude = {s["filename"] for s in picks}
|
||||||
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
|
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks)))
|
||||||
if not picks:
|
if not picks:
|
||||||
raise HTTPException(404, "No songs of this genre in the library.")
|
raise HTTPException(404, "No songs of this genre in the library.")
|
||||||
venue = _current_venue()
|
venue = _current_venue()
|
||||||
|
|||||||
@@ -1192,7 +1192,21 @@
|
|||||||
if (typeof window.setViz === 'function') window.setViz('venue');
|
if (typeof window.setViz === 'function') window.setViz('venue');
|
||||||
} catch (_) { /* viz optional — restore stays intact */ }
|
} 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;
|
_appliedManifestVenue = null;
|
||||||
|
if (_state) pushCrowdManifest(_state);
|
||||||
_ppGigRun = {
|
_ppGigRun = {
|
||||||
songs: prop.songs,
|
songs: prop.songs,
|
||||||
venue_id: prop.venue_id,
|
venue_id: prop.venue_id,
|
||||||
|
|||||||
@@ -12614,8 +12614,19 @@
|
|||||||
const tC = now + (dt0 + dt1) * 0.5 - BEHIND;
|
const tC = now + (dt0 + dt1) * 0.5 - BEHIND;
|
||||||
const b = laneBoundsFromAnchor(getChartAnchorAt(anchors, tC));
|
const b = laneBoundsFromAnchor(getChartAnchorAt(anchors, tC));
|
||||||
if (!b) continue;
|
if (!b) continue;
|
||||||
const z0 = dZ(dt0) + TS * BEHIND;
|
// The lane STOPS AT THE HIT LINE (z = 0) — issue #991. The
|
||||||
const z1 = dZ(dt1) + TS * BEHIND;
|
// slice window starts BEHIND seconds in the past, so the
|
||||||
|
// first slices map to positive z, i.e. past the hit line
|
||||||
|
// toward the player. Nothing is ever drawn there: notes and
|
||||||
|
// chord frames clamp to Math.min(0, dZ(dt)), so that strip
|
||||||
|
// is lane with nothing on it. Clamp the NEAR edge only —
|
||||||
|
// the far edge stays at dZ(AHEAD+BEHIND)+TS*BEHIND = -AHEAD*TS,
|
||||||
|
// aligned with the note horizon, exactly as before.
|
||||||
|
const z0 = Math.min(0, dZ(dt0) + TS * BEHIND);
|
||||||
|
const z1 = Math.min(0, dZ(dt1) + TS * BEHIND);
|
||||||
|
// Slice lies entirely past the hit line -> zero length, nothing
|
||||||
|
// to draw. Skip before the arp probe so it costs nothing.
|
||||||
|
if (z0 === z1) continue;
|
||||||
const arpSlice = (laneRailArpHsFlags && handShapesRails && handShapesRails.length)
|
const arpSlice = (laneRailArpHsFlags && handShapesRails && handShapesRails.length)
|
||||||
? arpeggioLaneOuterRailLaneSlice(
|
? arpeggioLaneOuterRailLaneSlice(
|
||||||
dt0, dt1, now,
|
dt0, dt1, now,
|
||||||
@@ -12764,9 +12775,13 @@
|
|||||||
divMin = dMin;
|
divMin = dMin;
|
||||||
divMax = dMax;
|
divMax = dMax;
|
||||||
|
|
||||||
// Same fix: extend to AHEAD+BEHIND so far edge = -AHEAD*TS.
|
// Far edge at -AHEAD*TS (the note horizon), near edge at the
|
||||||
const laneLen = TS * (AHEAD + BEHIND);
|
// hit line (z = 0) — the lane does not run past it toward the
|
||||||
const zLane = -laneLen / 2 + TS * BEHIND;
|
// player, where nothing is ever drawn (#991). Spanning
|
||||||
|
// AHEAD+BEHIND and shifting by +TS*BEHIND put the near edge at
|
||||||
|
// +TS*BEHIND; spanning AHEAD alone keeps the same far edge.
|
||||||
|
const laneLen = TS * AHEAD;
|
||||||
|
const zLane = -laneLen / 2;
|
||||||
const laneOp = (HWY_LANE_STRIPE_OP_BASE + highwayIntensity * HWY_LANE_STRIPE_OP_INT)
|
const laneOp = (HWY_LANE_STRIPE_OP_BASE + highwayIntensity * HWY_LANE_STRIPE_OP_INT)
|
||||||
* (_venueSceneOverride ? VENUE_LANE_OP_BOOST : 1);
|
* (_venueSceneOverride ? VENUE_LANE_OP_BOOST : 1);
|
||||||
mLaneOdd.opacity = laneOp;
|
mLaneOdd.opacity = laneOp;
|
||||||
@@ -12786,7 +12801,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (highwayIntensity > 0.05) {
|
if (highwayIntensity > 0.05) {
|
||||||
const divLen = TS * (AHEAD + BEHIND);
|
// Matches the lane above: ends at the hit line (#991).
|
||||||
|
const divLen = TS * AHEAD;
|
||||||
const yPos = boardY + 0.03 * K;
|
const yPos = boardY + 0.03 * K;
|
||||||
const divOp2 = 0.02 + highwayIntensity * 0.1;
|
const divOp2 = 0.02 + highwayIntensity * 0.1;
|
||||||
const divOpArp2 = Math.min(0.92, 0.16 + highwayIntensity * 0.42);
|
const divOpArp2 = Math.min(0.92, 0.16 + highwayIntensity * 0.42);
|
||||||
@@ -12799,7 +12815,7 @@
|
|||||||
for (let f = fDivA; f <= fDivB; f++) {
|
for (let f = fDivA; f <= fDivB; f++) {
|
||||||
if (hwyLaneArpOuterDividers && (f === fDivA || f === fDivB)) continue;
|
if (hwyLaneArpOuterDividers && (f === fDivA || f === fDivB)) continue;
|
||||||
const div = pLaneDivider.get();
|
const div = pLaneDivider.get();
|
||||||
div.position.set(xFret(f), yPos, dZ(0) - divLen * 0.5 + TS * BEHIND);
|
div.position.set(xFret(f), yPos, -divLen * 0.5);
|
||||||
div.material = mLaneDivider;
|
div.material = mLaneDivider;
|
||||||
div.scale.set(1, 1, divLen);
|
div.scale.set(1, 1, divLen);
|
||||||
div.renderOrder = 2;
|
div.renderOrder = 2;
|
||||||
@@ -12818,8 +12834,10 @@
|
|||||||
|
|
||||||
// ── Fret boundary extension lines ─────────────────────────
|
// ── Fret boundary extension lines ─────────────────────────
|
||||||
if (mLaneDividerExt && fretDividersVisible) {
|
if (mLaneDividerExt && fretDividersVisible) {
|
||||||
const extLaneLen = TS * (AHEAD + BEHIND);
|
// Same hit-line stop as the lane (#991) — otherwise these lines
|
||||||
const extZMid = -extLaneLen / 2 + TS * BEHIND;
|
// would be the only floor geometry still running past it.
|
||||||
|
const extLaneLen = TS * AHEAD;
|
||||||
|
const extZMid = -extLaneLen / 2;
|
||||||
const extYPos = boardY + 0.03 * K;
|
const extYPos = boardY + 0.03 * K;
|
||||||
mLaneDividerExt.opacity = Math.max(0.3, 0.3 + highwayIntensity * 0.15);
|
mLaneDividerExt.opacity = Math.max(0.3, 0.3 + highwayIntensity * 0.15);
|
||||||
for (let f = 0; f <= NFRETS; f++) {
|
for (let f = 0; f <= NFRETS; f++) {
|
||||||
|
|||||||
@@ -1115,7 +1115,10 @@ async def startup_status_stream(request: Request):
|
|||||||
@app.post("/api/rescan")
|
@app.post("/api/rescan")
|
||||||
def trigger_rescan():
|
def trigger_rescan():
|
||||||
"""Manually trigger a library 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": "Scan already in progress"}
|
||||||
return {"message": "Rescan started"}
|
return {"message": "Rescan started"}
|
||||||
|
|
||||||
@@ -1133,7 +1136,7 @@ def trigger_full_rescan():
|
|||||||
# delete_missing() prunes anything genuinely gone at the end.
|
# delete_missing() prunes anything genuinely gone at the end.
|
||||||
meta_db.conn.execute("UPDATE songs SET mtime = -1")
|
meta_db.conn.execute("UPDATE songs SET mtime = -1")
|
||||||
meta_db.conn.commit()
|
meta_db.conn.commit()
|
||||||
if not scan.kick_scan():
|
if not scan.kick_scan(force=True):
|
||||||
return {"message": "Scan already in progress"}
|
return {"message": "Scan already in progress"}
|
||||||
return {"message": "Full rescan started"}
|
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.
|
// leaving the player still leaves — and abandons the queue.
|
||||||
window.feedBack.playQueue = (function () {
|
window.feedBack.playQueue = (function () {
|
||||||
let list = [], idx = -1, source = '', arrangements = null;
|
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 active = () => idx >= 0 && idx < list.length;
|
||||||
const hasNext = () => active() && idx < list.length - 1;
|
const hasNext = () => active() && idx < list.length - 1;
|
||||||
function clear() { list = []; idx = -1; source = ''; arrangements = null; }
|
function clear() { list = []; idx = -1; source = ''; arrangements = null; }
|
||||||
function _play(i) {
|
function _play(i) {
|
||||||
const fn = list[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 });
|
window.playSong(encodeURIComponent(fn), arrangements ? arrangements[i] : undefined, { fromQueue: true });
|
||||||
}
|
}
|
||||||
function start(files, opts) {
|
function start(files, opts) {
|
||||||
@@ -1371,6 +1384,15 @@ window.feedBack.playQueue = (function () {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
|
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; },
|
source: function () { return source; },
|
||||||
remaining: function () { return active() ? list.length - idx - 1 : 0; },
|
remaining: function () { return active() ? list.length - idx - 1 : 0; },
|
||||||
// What's coming, for consumers that RENDER the queue (a results
|
// What's coming, for consumers that RENDER the queue (a results
|
||||||
@@ -2297,11 +2319,14 @@ configureHost({
|
|||||||
currentFilename: () => currentFilename,
|
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, {
|
Object.assign(window, {
|
||||||
_confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl,
|
_confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl,
|
||||||
_librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal,
|
_librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal,
|
||||||
changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop,
|
changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop,
|
||||||
deleteSelectedLoop, exportDiagnostics, exportSettings, filterFavorites,
|
deleteSelectedLoop, esc, exportDiagnostics, exportSettings, filterFavorites,
|
||||||
filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput,
|
filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput,
|
||||||
hideScanBanner, importSettings, loadPlugins, loadSavedLoop,
|
hideScanBanner, importSettings, loadPlugins, loadSavedLoop,
|
||||||
loadSettings, onSectionPracticeModeChange, openEditModal, persistSetting,
|
loadSettings, onSectionPracticeModeChange, openEditModal, persistSetting,
|
||||||
|
|||||||
+12
-3
@@ -638,9 +638,18 @@ export let artAbortController = null;
|
|||||||
export async function playSong(filename, arrangement, options) {
|
export async function playSong(filename, arrangement, options) {
|
||||||
console.log('playSong called:', filename);
|
console.log('playSong called:', filename);
|
||||||
// A manual (non-queue) play abandons any active play-queue, so a stale queue
|
// 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.
|
// can't hijack the next song's end. The queue signals a play it is DRIVING
|
||||||
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
|
// two ways: options.fromQueue (in-band) and _consumeInternalPlay() (out-of-
|
||||||
window.feedBack.playQueue.clear();
|
// 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) {
|
if (!options || options.bridge !== false) {
|
||||||
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
|
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
|
||||||
|
|||||||
@@ -529,7 +529,27 @@
|
|||||||
_loadingLoop = null;
|
_loadingLoop = null;
|
||||||
_fadingLoop = null;
|
_fadingLoop = null;
|
||||||
if (_venueActive && _manifest) {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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('<');
|
||||||
|
});
|
||||||
@@ -116,3 +116,30 @@ test('career screen pushes the crowd manifest with a base URL', () => {
|
|||||||
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
||||||
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -49,3 +49,80 @@ test('peekNext is null after clear', () => {
|
|||||||
q.clear();
|
q.clear();
|
||||||
assert.strictEqual(q.peekNext(), null);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -117,3 +117,22 @@ test('a throwing document does not take the venue down with it', () => {
|
|||||||
global.document = prev;
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -445,3 +445,30 @@ def test_gold_intake_rejects_junk(client, meta_db):
|
|||||||
res = client.post("/api/plugins/career/drill-state",
|
res = client.post("/api/plugins/career/drill-state",
|
||||||
json={"byNode": {}, "goldImprov": blob})
|
json={"byNode": {}, "goldImprov": blob})
|
||||||
assert res.status_code == 413
|
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"
|
||||||
|
|||||||
@@ -137,6 +137,81 @@ def test_background_scan_discovers_both_suffixes(tmp_path, scan_server):
|
|||||||
assert "ignore.zip" not in seen
|
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) ────────────────────────────────
|
# ── 3. POST /api/songs/upload gate (endpoint) ────────────────────────────────
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|||||||
Reference in New Issue
Block a user