Compare commits

..
Author SHA1 Message Date
byrongamatosandClaude Fable 5 108aa66af1 test: update idempotence contract to the absence-is-not-uninstall invariant
The removed-plugin sweep contract pinned the old behavior this branch
deletes; pin the new invariant instead (no absence sweep + respondedIds
scoping on the DOM/style reconcilers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:41:47 +02:00
byrongamatosandClaude Fable 5 1a839d8ed9 fix(plugins): don't treat transient absence from /api/plugins as uninstall
The backend clears its plugin registry at the start of load_plugins()
and repopulates it incrementally while HTTP stays up, so every backend
restart (desktop: Audio Quality soundfont switch, LAN toggle, update
restart) serves a window of partial — even empty — /api/plugins
responses. loadPlugins() treated absence from the current response as
an uninstall, with three destructive consequences for still-loaded
plugins:

1. Their settings-panel and screen DOM were wiped while their
   _loadedPluginScripts entry survived, so the NEXT refetch failed the
   DOM-existence check and re-evaluated the plugin's screen.js
   mid-session. For the desktop audio_engine plugin that re-ran init()
   against the surviving native audio chain and exactly duplicated
   every VST/NAM/IR stage (the alpha testers' "chain duplicates after
   leaving the Audio menu" / blown-out gain reports).
2. _reconcilePluginStyles dropped their stylesheet, leaving them
   visible but unstyled until they reappeared.
3. The stale-contribution sweep unmounted their UI contributions and
   unregistered their capability participant with no re-registration
   path (plugin scripts don't re-run thanks to the loadedScripts
   guard).

Absence is now a non-signal everywhere in loadPlugins: the DOM wipe and
style reconcile are scoped to plugins the response actually names, and
the absence sweep is removed. Present plugins still fully re-sync via
_registerLegacyPluginUiContributions each round; failed plugins are
present in the response and still cleaned up; nav is rebuilt from the
response so genuinely uninstalled plugins drop out of it, and their
(un-unloadable) already-evaluated scripts keep their DOM until reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:28:11 +02:00
17 changed files with 104 additions and 711 deletions
-3
View File
@@ -7,9 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
### Changed
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
+8 -8
View File
@@ -47,11 +47,11 @@ RUN cmake -S /tmp/vgmstream -B /tmp/vgmstream/build \
# and update FFMPEG_RELEASE + both SHA256 ARGs below.
FROM alpine:3.20 AS ffmpeg-fetcher
ARG TARGETARCH
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
ARG FFMPEG_SHA256_AMD64=1390e1c320a1e38dae106d6d0b05a6f08eb8b30f732bc1aa0d45a4aa17f13795
ARG FFMPEG_SHA256_ARM64=53b2e30df04d56932b7782234c9bc97abfe0bb242192ca50346474a41b100ab0
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
ARG FFMPEG_SHA256_AMD64=03c0431e0d1aa75cc343d83bda9d2d4cd8eaa37f35b7b93465e9ff6864f5d7f8
ARG FFMPEG_SHA256_ARM64=74629b88342fd94eea12b7481c8b8560ca6d497744123c0a27b98f39d767fd93
RUN apk add --no-cache curl xz \
&& arch="${TARGETARCH:-$(apk --print-arch)}" \
&& case "$arch" in \
@@ -94,9 +94,9 @@ FROM python:3.12-slim
# Re-declare the ffmpeg ARGs so their values are available to LABEL below.
# ARG values don't cross stage boundaries in multi-stage builds; defaults
# must be repeated here to take effect when no --build-arg is supplied.
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
# Apply latest security updates to base packages (clears glibc deb13u3 and
# similar). Done first so any subsequent installs resolve against the
Binary file not shown.
Binary file not shown.
+74 -302
View File
@@ -8,7 +8,6 @@ import logging
import math
import os
import secrets
import stat
import sys
import tempfile
import shutil
@@ -5577,217 +5576,13 @@ def _get_progression_content() -> dict:
return _progression_content
def _copy_builtin_packs(
root: Path,
dest_dir: Path,
sources: list[tuple[str, str]],
label: str,
update_existing: bool = True,
) -> int:
"""Symlink-safe, mtime-aware copy of bundled packs into ``dest_dir``.
``sources`` is a list of ``(dest_name, rel_source)`` pairs; each source is
resolved under ``root`` (the repo root in dev, ``resources/feedBack`` when
bundled). A pack is copied when its destination is missing. Never deletes
user files; refuses to follow a symlinked seed directory or destination and
refuses to clobber a non-regular destination (any would let a copy escape
``dest_dir`` or destroy user data). Logs and continues on error. ``label``
prefixes every log line.
``update_existing`` controls what happens when a *regular* destination file
already exists: when True (diagnostic seed) a bundle copy newer than the
destination refreshes it; when False (one-time starter content) an existing
file is always left as-is so the user's copy is never overwritten.
Returns the number of ``sources`` that are present at their destination
afterwards (freshly seeded, refreshed, or already current) so callers can
tell whether every pack made it. A skip (missing source, symlink/non-regular
refusal, copy error) does not count.
"""
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept it
# and copies would land at the link target, outside the DLC tree. The
# per-file symlink guard below cannot catch this.
if dest_dir.is_symlink():
log.warning("%s: %s is a symlink, skipping all seeding", label, dest_dir.name)
return 0
dest_dir.mkdir(parents=True, exist_ok=True)
# Pin the seed directory by an O_NOFOLLOW fd so a symlink swapped in for
# dest_dir *after* the check above cannot redirect the per-file stat /
# temp-create / replace outside the DLC tree (parent-directory TOCTOU).
# os.replace accepts dir_fd on POSIX even though it isn't listed in
# os.supports_dir_fd, so gate on os.rename (the reliable proxy); platforms
# without dir_fd/O_NOFOLLOW (e.g. Windows) fall back to path-based ops.
dir_fd = None
if (
hasattr(os, "O_NOFOLLOW")
and hasattr(os, "O_DIRECTORY")
and os.open in os.supports_dir_fd
and os.rename in os.supports_dir_fd
):
try:
dir_fd = os.open(dest_dir, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
except OSError as exc:
log.warning("%s: cannot open seed dir %s: %s", label, dest_dir, exc)
return 0
try:
present = 0
for dest_name, rel_source in sources:
source = root / rel_source
if not source.is_file():
log.warning("%s: source missing, skipping %s (%s)", label, dest_name, source)
continue
# lstat the destination without following symlinks. Pinned by dir_fd
# this resolves within the real seed dir, immune to a parent swap.
try:
if dir_fd is not None:
dstat = os.lstat(dest_name, dir_fd=dir_fd)
else:
dstat = os.lstat(dest_dir / dest_name)
dest_exists = True
dest_islink = stat.S_ISLNK(dstat.st_mode)
except FileNotFoundError:
dest_exists = False
dest_islink = False
except OSError as exc:
log.warning("%s: cannot stat %s: %s", label, dest_name, exc)
continue
# Refuse to seed through a symlink at the destination name.
if dest_islink:
log.warning("%s: destination is a symlink, skipping %s", label, dest_name)
continue
# A non-regular destination (directory, fifo, …) the user placed
# there: never clobber it, and never count it as present — otherwise
# a one-time seed would mark itself done without a real pack on disk.
if dest_exists and not stat.S_ISREG(dstat.st_mode):
log.warning("%s: destination is not a regular file, skipping %s", label, dest_name)
continue
if dest_exists:
# A regular file is already there. One-time seeds (starter
# content) must never overwrite the user's copy; refreshing
# seeds (diagnostics) replace it only when the bundle is newer.
if not update_existing:
log.info("%s: already present %s", label, dest_name)
present += 1
continue
try:
src_mtime = source.stat().st_mtime
except OSError as exc:
log.warning("%s: cannot stat source %s: %s", label, source, exc)
continue
if src_mtime <= dstat.st_mtime:
log.info("%s: already present %s", label, dest_name)
present += 1
continue
action = "updated"
else:
action = "seeded"
if _write_builtin_pack(source, dest_dir, dest_name, dir_fd):
present += 1
log.info("%s: %s %s -> %s", label, action, source.name, dest_name)
else:
log.warning("%s: failed to copy %s -> %s/%s", label, source, dest_dir.name, dest_name)
return present
finally:
if dir_fd is not None:
os.close(dir_fd)
def _write_builtin_pack(
source: Path,
dest_dir: Path,
dest_name: str,
dir_fd: int | None,
) -> bool:
"""Atomically write ``source`` to ``dest_name`` inside ``dest_dir``.
Writes to a temp file then ``os.replace()``s onto the final name so a
symlink raced in at the destination is overwritten (rename semantics), not
followed, and a crash never leaves a half-written pack. When ``dir_fd`` is
given, every step is anchored to that fd (O_NOFOLLOW temp create + dir_fd
replace), closing the parent-directory TOCTOU; otherwise falls back to
path-based temp+replace. Returns True on success. Never raises.
"""
# Unique per-attempt name (O_EXCL create) so a crash that orphans a temp
# can't permanently block later seeds via an EEXIST collision.
tmp_name = f".seed-{dest_name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
try:
src_stat = source.stat()
except OSError as exc:
log.debug("builtin pack: cannot stat source %s: %s", source, exc)
return False
if dir_fd is not None:
tmp_fd = None
try:
tmp_fd = os.open(
tmp_name,
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW,
0o644,
dir_fd=dir_fd,
)
with open(source, "rb") as sf, os.fdopen(tmp_fd, "wb") as tf:
tmp_fd = None # fdopen now owns the descriptor
shutil.copyfileobj(sf, tf)
os.replace(tmp_name, dest_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
# Preserve the bundle mtime (copyfileobj doesn't) so the mtime-based
# refresh check matches the shutil.copy2 fallback path. Best-effort.
try:
os.utime(
dest_name,
ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns),
dir_fd=dir_fd,
follow_symlinks=False,
)
except OSError as exc:
log.debug("builtin pack: could not set mtime on %s: %s", dest_name, exc)
return True
except OSError as exc:
log.debug("builtin pack write (dir_fd) failed for %s: %s", dest_name, exc)
if tmp_fd is not None:
try:
os.close(tmp_fd)
except OSError:
pass
try:
os.unlink(tmp_name, dir_fd=dir_fd)
except OSError:
pass
return False
tmp = None
try:
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".seed-", suffix=".tmp")
os.close(fd)
shutil.copy2(source, tmp)
os.replace(tmp, dest_dir / dest_name)
tmp = None
return True
except OSError as exc:
log.debug("builtin pack write failed for %s: %s", dest_name, exc)
return False
finally:
if tmp is not None:
try:
os.unlink(tmp)
except OSError:
pass
def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None:
"""Copy bundled diagnostic sloppaks into DLC before library scan.
Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak
when the destination is missing or older than the repo/bundle source.
Never deletes user files or touches manually copied paths (e.g.
``diagnostics-test/``). Re-seeds whenever the destination is missing so the
diagnostic target is always available. Logs and continues on errors.
``diagnostics-test/``). Logs and continues on missing source or copy errors.
"""
try:
if dlc is None:
@@ -5795,106 +5590,84 @@ def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None:
if dlc is None:
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
return
_copy_builtin_packs(
_feedBack_server_root(),
dlc / _BUILTIN_DIAGNOSTIC_SUBDIR,
_BUILTIN_DIAGNOSTIC_SOURCES,
"Builtin diagnostic seed",
)
except Exception:
log.warning("Builtin diagnostic seed: unexpected error", exc_info=True)
# Starter content: bundled songs copied into ``DLC_DIR/starter/`` exactly ONCE,
# on first run, as a welcome library so a fresh install isn't empty. Unlike the
# diagnostic seed this is one-time — guarded by a marker in CONFIG_DIR — so if
# the user deletes the starter song it stays gone. ``starter/`` is NOT in the
# library scan carve-out (unlike diagnostics-builtin/ / tutorials-builtin/), so
# seeded packs surface as ordinary library songs.
_BUILTIN_STARTER_SUBDIR = "starter"
_BUILTIN_STARTER_SOURCES: list[tuple[str, str]] = [
(
"beethoven-fur_elise.feedpak",
"content/starter/beethoven-fur_elise.feedpak",
),
(
"star_spangled_banner.feedpak",
"content/starter/star_spangled_banner.feedpak",
),
(
"beethoven-ode_to_joy.feedpak",
"content/starter/beethoven-ode_to_joy.feedpak",
),
]
_STARTER_SEED_MARKER = ".starter-content-seeded"
def _seed_builtin_starter_content(dlc: Path | None = None) -> None:
"""Copy bundled starter songs into ``DLC_DIR/starter/`` exactly once.
Guarded by ``CONFIG_DIR/.starter-content-seeded``: the first run with a DLC
folder configured seeds the packs and writes the marker; subsequent runs are
no-ops, so a user who deletes the starter song does not get it back on the
next launch. Symlink-safe; never deletes user files. Logs, never raises.
"""
try:
marker = CONFIG_DIR / _STARTER_SEED_MARKER
# Already seeded? The marker is a sentinel: any existing path there
# (regular file, or a symlink/dir a user deliberately planted to opt
# out) means "done" — lstat so we detect it without following a symlink.
# Worst case of a planted marker is simply no starter content, never a
# data write; the O_EXCL|O_NOFOLLOW create below refuses to write
# *through* a symlink regardless.
try:
os.lstat(marker)
return
except FileNotFoundError:
pass
except OSError as exc:
log.warning("Starter content seed: cannot stat marker %s: %s", marker, exc)
return
if dlc is None:
dlc = _get_dlc_dir()
if dlc is None:
# No DLC yet — leave the marker unwritten so we retry once a
# library folder is configured.
log.debug("Starter content seed: no DLC folder configured, skipping")
return
present = _copy_builtin_packs(
_feedBack_server_root(),
dlc / _BUILTIN_STARTER_SUBDIR,
_BUILTIN_STARTER_SOURCES,
"Starter content seed",
update_existing=False,
)
# Only mark seeding complete once every starter pack is actually in
# place. If a source was missing or a copy failed, leave the marker
# unwritten so the next launch retries rather than permanently skipping.
if present < len(_BUILTIN_STARTER_SOURCES):
log.info(
"Starter content seed: %d/%d packs present, will retry next launch",
present,
len(_BUILTIN_STARTER_SOURCES),
root = _feedBack_server_root()
dest_dir = dlc / _BUILTIN_DIAGNOSTIC_SUBDIR
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept
# it and copies would land at the link target, outside the DLC tree.
# The per-file is_symlink() guard below cannot catch this.
if dest_dir.is_symlink():
log.warning(
"Builtin diagnostic seed: %s is a symlink, skipping all seeding",
_BUILTIN_DIAGNOSTIC_SUBDIR,
)
return
# Record completion with an exclusive, no-follow create so a planted or
# raced symlink at the marker path can't redirect the write outside
# CONFIG_DIR. O_EXCL fails (EEXIST) on any existing path including a
# symlink, so we never write through one.
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(marker, flags, 0o644)
dest_dir.mkdir(parents=True, exist_ok=True)
for dest_name, rel_source in _BUILTIN_DIAGNOSTIC_SOURCES:
source = root / rel_source
dest = dest_dir / dest_name
if not source.is_file():
log.warning(
"Builtin diagnostic seed: source missing, skipping %s (%s)",
dest_name,
source,
)
continue
# Refuse to seed through a symlink. is_file()/stat()/copy2 all
# follow links, so a symlink planted at the destination would let
# the copy redirect outside diagnostics-builtin/ and overwrite an
# arbitrary file the server user can write. Skip and warn; never
# touch the link or its target.
if dest.is_symlink():
log.warning(
"Builtin diagnostic seed: destination is a symlink, skipping %s/%s",
_BUILTIN_DIAGNOSTIC_SUBDIR,
dest_name,
)
continue
if dest.is_file():
try:
if source.stat().st_mtime <= dest.stat().st_mtime:
log.info(
"Builtin diagnostic seed: already present %s/%s",
_BUILTIN_DIAGNOSTIC_SUBDIR,
dest_name,
)
continue
action = "updated"
except OSError as exc:
log.warning(
"Builtin diagnostic seed: cannot compare %s and %s: %s",
source,
dest,
exc,
)
continue
else:
action = "seeded"
try:
os.write(fd, b"1\n")
finally:
os.close(fd)
except FileExistsError:
pass # already marked (or a non-regular path is squatting) — fine
except OSError as exc:
log.warning("Starter content seed: could not write marker %s: %s", marker, exc)
shutil.copy2(source, dest)
log.info(
"Builtin diagnostic seed: %s %s -> %s/%s",
action,
source.name,
_BUILTIN_DIAGNOSTIC_SUBDIR,
dest_name,
)
except OSError as exc:
log.warning(
"Builtin diagnostic seed: failed to copy %s -> %s: %s",
source,
dest,
exc,
)
except Exception:
log.warning("Starter content seed: unexpected error", exc_info=True)
log.warning("Builtin diagnostic seed: unexpected error", exc_info=True)
def _background_scan():
@@ -5917,7 +5690,6 @@ def _background_scan():
return
_seed_builtin_diagnostic_sloppaks(dlc)
_seed_builtin_starter_content(dlc)
# 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
+2 -11
View File
@@ -6195,7 +6195,7 @@ window.feedBack.on('song:ready', () => {
setSpeed(pend.speed);
}
} catch (_) { /* speed restore is best-effort */ }
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'session-resume'))
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'resume'))
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
.catch((err) => console.warn('[app] resume failed:', err));
});
@@ -6761,16 +6761,7 @@ window.feedBack.playQueue = (function () {
if (!files.length) return false;
list = files.slice(); idx = 0;
source = (opts && opts.source) || '';
arrangements = (opts && opts.arrangements) ? opts.arrangements.slice() : null;
if (opts && opts.shuffle && list.length > 1) {
// Fisher-Yates, once at start. Swap arrangements in lockstep so an
// album slot's pinned arrangement stays glued to its file (#685).
for (let i = list.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[list[i], list[j]] = [list[j], list[i]];
if (arrangements) [arrangements[i], arrangements[j]] = [arrangements[j], arrangements[i]];
}
}
arrangements = (opts && opts.arrangements) || null;
if (window.fbNotify) {
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
}
+3 -28
View File
@@ -211,12 +211,7 @@
'<div class="flex items-center justify-between mb-6 gap-3">' +
'<h2 class="text-3xl font-bold text-fb-text truncate">' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '</h2>' +
'<div class="flex gap-2 shrink-0 items-center">' +
(pl.songs.length
? '<button id="v3-pl-shuffle" class="px-2 py-2 rounded-md" aria-pressed="false">' +
'<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/></svg>' +
'</button>' +
'<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>'
: '') +
(pl.songs.length ? '<button id="v3-pl-playall" class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md">▶ Play ' + (isAlbum ? 'album' : 'all') + '</button>' : '') +
(isSystem ? '' :
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
(pl.cover_url ? '<button id="v3-pl-cover-rm" class="text-sm text-fb-textDim hover:text-fb-accent px-2">Remove cover</button>' : '') +
@@ -231,26 +226,6 @@
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
'</div>';
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
// Shuffle toggle (crossing arrows, next to Play). Persisted globally —
// one preference, not per playlist. The queue is shuffled once when
// Play starts (playQueue.start's shuffle opt); the stored playlist
// order is never touched.
const shuffleBtn = root.querySelector('#v3-pl-shuffle');
const shuffleOn = () => { try { return localStorage.getItem('v3PlaylistShuffle') === '1'; } catch (_) { return false; } };
const paintShuffle = () => {
if (!shuffleBtn) return;
const on = shuffleOn();
shuffleBtn.className = on
? 'px-2 py-2 rounded-md border border-fb-primary bg-fb-primary hover:bg-fb-primaryHi text-white'
: 'px-2 py-2 rounded-md border border-fb-border text-fb-textDim hover:text-fb-text';
shuffleBtn.title = on ? 'Shuffle: on' : 'Shuffle: off';
shuffleBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
};
paintShuffle();
shuffleBtn?.addEventListener('click', () => {
try { localStorage.setItem('v3PlaylistShuffle', shuffleOn() ? '0' : '1'); } catch (_) { /* private mode */ }
paintShuffle();
});
// Play all: start the play-queue with this playlist's songs (auto-advances
// track to track). Falls back to playing the first song on an older core
// without the queue, so the button always does something. An ALBUM plays
@@ -269,8 +244,8 @@
if (!files.length) return;
if (window.feedBack && window.feedBack.playQueue) {
window.feedBack.playQueue.start(files, isAlbum
? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() }
: { source: pl.name, shuffle: shuffleOn() });
? { source: pl.name, arrangements: arrs }
: { source: pl.name });
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
});
const listEl = root.querySelector('#v3-pl-songs');
+5 -13
View File
@@ -35,11 +35,10 @@ function buildFacade() {
'return _hwcInstallFacade;',
].join('\n');
const params = [
'window', 'HWC_SLOTS', 'HWC_PRESETS', 'console',
'window', 'HWC_SLOTS', 'console',
'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors',
'_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape',
'applyHighwayStringColors', 'applyHighwayStringPreset',
'encodeHighwayColorShare', 'decodeHighwayColorShare',
'applyHighwayStringColors', 'encodeHighwayColorShare', 'decodeHighwayColorShare',
];
const listeners = {};
@@ -65,19 +64,14 @@ function buildFacade() {
_hwcEffectiveIndexColors: (map, sc, isBass) => ['eff', sc, isBass],
_hwcChartShape: () => ({ sc: 6, isBass: false }),
applyHighwayStringColors: (m) => { calls.push(['apply', m]); },
applyHighwayStringPreset: (id) => { calls.push(['preset', id]); return true; },
encodeHighwayColorShare: (n, m) => 'SLOPHWY2.CODE',
decodeHighwayColorShare: (c) => ({ name: 'x', colors: {} }),
};
const HWC_PRESETS = [
{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } },
];
const installer = new Function(...params, body)(
win, HWC_SLOTS, HWC_PRESETS, console,
win, HWC_SLOTS, console,
stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors,
stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape,
stubs.applyHighwayStringColors, stubs.applyHighwayStringPreset,
stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
stubs.applyHighwayStringColors, stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
);
installer();
return { api: win.feedBack.highwayColors, win, bus, calls, installer, stubs };
@@ -93,13 +87,11 @@ test('facade exposes the documented surface', () => {
const { api } = buildFacade();
assert.equal(api.version, 1);
for (const m of ['get', 'getDefaults', 'getResolved', 'keysForChart', 'toEffective',
'getCurrent', 'apply', 'applyPreset', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
'getCurrent', 'apply', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
assert.equal(typeof api[m], 'function', `highwayColors.${m} must be a function`);
}
assert.deepEqual(api.slots.map((s) => s.key),
['highE', 'B', 'G', 'D', 'A', 'lowE', 'low7', 'low8'], 'slots in display order');
// One-click presets: exposed as detached [{ id, label, colors }] copies.
assert.deepEqual(api.presets, [{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } }]);
});
test('facade read methods delegate to the manager', () => {
+1 -4
View File
@@ -74,10 +74,7 @@ const APP_JS = path.join(ROOT, 'static', 'app.js');
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
function source(file) {
// Normalize CRLF: region() slices fixed CHARACTER windows, so on a
// Windows checkout (autocrlf) every line costs one extra char and the
// assertion target can fall outside the window.
return fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
return fs.readFileSync(file, 'utf8');
}
function region(src, needle, length = 1200) {
+1 -3
View File
@@ -40,9 +40,7 @@ test('settings UI exposes tone source select with all options', () => {
assert.match(html, /value="external_hardware"/);
assert.match(html, /value="spark_control_x"/);
assert.match(html, /Live guitar tone source/);
// Apostrophe form drifted from the &rsquo; entity to the literal in a
// copy pass — accept entity, typographic, or plain apostrophe.
assert.match(html, /won(?:&rsquo;||')t warn that no internal amp tone is loaded/);
assert.match(html, /won&rsquo;t warn that no internal amp tone is loaded/);
});
test('player audio rail exposes tone source select', () => {
-1
View File
@@ -107,7 +107,6 @@ function loadFunctions(sandbox, src) {
sectionPracticeModeCalls.push({ on, opts: opts || {} });
}
function _updateSectionPracticeHighlight(ct) {}
function _updateEditRegionBtn() {}
${extractFunction(src, 'function clearLoop(')}
${extractFunction(src, 'function _syncSavedLoopSelection()')}
${extractFunction(src, 'async function setLoop(')}
-86
View File
@@ -1,86 +0,0 @@
// playQueue.start({ shuffle: true }): the queue is Fisher-Yates-shuffled ONCE
// at start. Per-slot arrangements must swap in lockstep with their files
// (albums pass arrangements aligned by index, #685), the caller's arrays must
// not be mutated, and shuffle:false / absent must preserve order. Extract the
// playQueue IIFE from app.js and drive it against a playSong stub.
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
function makeQueue() {
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
const start = src.indexOf('window.feedBack.playQueue = (function () {');
assert.ok(start !== -1, 'playQueue IIFE found in app.js');
const end = src.indexOf('})();', start);
assert.ok(end !== -1, 'playQueue IIFE terminator found');
const iife = src.slice(start, end + 5);
const played = [];
const sandbox = {
window: {
feedBack: {},
playSong: (fn, arr, opts) => played.push({ fn: decodeURIComponent(fn), arr, opts }),
fbNotify: null,
},
};
// eslint-disable-next-line no-new-func
new Function('window', 'encodeURIComponent', iife)(sandbox.window, encodeURIComponent);
return { q: sandbox.window.feedBack.playQueue, played };
}
function drain(q, played) {
while (q.hasNext()) q.advance();
return played.map((p) => p.fn);
}
test('shuffle: same multiset, order from the seeded RNG, arrangements follow files', () => {
const files = ['a.sloppak', 'b.sloppak', 'c.sloppak', 'd.sloppak'];
const arrs = [0, 1, 2, 3]; // arrangement i belongs to files[i]
const origRandom = Math.random;
try {
// Deterministic RNG so the expected order is checkable.
let calls = 0;
const seq = [0.1, 0.9, 0.5];
Math.random = () => seq[calls++ % seq.length];
const { q, played } = makeQueue();
q.start(files.slice(), { arrangements: arrs.slice(), shuffle: true });
const order = drain(q, played);
assert.deepStrictEqual(order.slice().sort(), files.slice().sort()); // nothing lost/duplicated
// Each played file carries the arrangement it started with.
played.forEach((p) => {
assert.strictEqual(p.arr, arrs[files.indexOf(p.fn)]);
});
} finally {
Math.random = origRandom;
}
});
test('shuffle can change the order', () => {
const origRandom = Math.random;
try {
Math.random = () => 0; // j = 0 every swap → deterministic rotation, ≠ input order
const { q, played } = makeQueue();
q.start(['a', 'b', 'c'], { shuffle: true });
const order = drain(q, played);
assert.notDeepStrictEqual(order, ['a', 'b', 'c']);
} finally {
Math.random = origRandom;
}
});
test('no shuffle opt preserves order and caller arrays are never mutated', () => {
const files = ['a', 'b', 'c'];
const arrs = [2, 0, 1];
const { q, played } = makeQueue();
q.start(files, { arrangements: arrs });
assert.deepStrictEqual(drain(q, played), ['a', 'b', 'c']);
assert.deepStrictEqual(files, ['a', 'b', 'c']);
assert.deepStrictEqual(arrs, [2, 0, 1]);
// shuffle:true must also leave the caller's arrays alone (start slices).
const { q: q2 } = makeQueue();
q2.start(files, { arrangements: arrs, shuffle: true });
assert.deepStrictEqual(files, ['a', 'b', 'c']);
assert.deepStrictEqual(arrs, [2, 0, 1]);
});
-4
View File
@@ -42,10 +42,7 @@ function loadClose(sandbox, src) {
globalThis.__seekCalls = 0;
globalThis.__playSongCalls = 0;
globalThis.__clearLoopCalls = 0;
globalThis.__queueClearCalls = 0;
globalThis.__audioCurrentTimeSets = [];
// closeCurrentSong abandons any play-queue before leaving the player.
var window = { feedBack: { playQueue: { clear() { globalThis.__queueClearCalls++; } } } };
var audio = {
_t: 42,
get currentTime() { return this._t; },
@@ -78,7 +75,6 @@ test('closeCurrentSong uses _playerOriginScreen when set', async () => {
await sandbox.__closeCurrentSong();
assert.equal(sandbox.__showScreenCalls.length, 1);
assert.equal(sandbox.__showScreenCalls[0], 'favorites');
assert.equal(sandbox.__queueClearCalls, 1, 'a real close abandons the play-queue');
assert.equal(sandbox.__restartCalls, 0);
assert.equal(sandbox.__seekCalls, 0);
assert.equal(sandbox.__playSongCalls, 0);
+9 -11
View File
@@ -31,23 +31,21 @@ test('the home is the unfiltered grid front door, local provider only', () => {
);
});
test('the shelf is the server-side practice-suggestions recommender', () => {
// The old client-side pipeline (fetch /api/stats/recent, dedupe by
// filename, gate on state.accuracy) moved server-side: the growth-edge
// recommender gates (not-mastered) + aggregates per song and picks the
// arrangement closest to mastery. The client renders its rows as-is.
assert.match(src, /\/api\/library\/practice-suggestions\?limit=/);
// A shelf card click opens the row's recommended arrangement, not the
// song's default.
test('the shelf is recently-played, not-yet-mastered songs (per-song, deduped)', () => {
assert.match(src, /\/api\/stats\/recent\?limit=/);
// Mastery is gated on the per-SONG best (state.accuracy, what the badge
// shows), not the per-arrangement recents row, and each filename appears
// once — so no green-badged "keep practicing" card and no duplicates.
assert.match(
src,
/data-arr="[\s\S]*?getAttribute\('data-arr'\)[\s\S]*?playSong\(enc\(fn\), arr === '' \? undefined : Number\(arr\)\)/,
'shelf cards must pass the recommended arrangement to playSong',
/const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/,
'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY',
);
assert.match(src, /seen\.has\(r\.filename\)/, 'the shelf must dedupe recents by filename');
});
test('the meter + shelf fetch together and a stale render is discarded', () => {
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?practice-suggestions/,
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?stats\/recent/,
'the two reads must be issued together (Promise.all), not sequentially');
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
'a stale render must be superseded by a newer one via a token');
+1 -3
View File
@@ -64,9 +64,7 @@ const helpers = loadTuningHelpers();
test('v3 songs.js uses display helpers for album-art tuning badge', () => {
const src = fs.readFileSync(SONGS_JS, 'utf8');
// The card renderer's row variable was renamed song → shown when grouped
// cards landed (the badge reads the representative chart); accept either.
assert.match(src, /displayTuningName\((?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning\)/);
assert.match(src, /displayTuningName\(song\.tuning_name \|\| song\.tuning\)/);
assert.match(src, /displayTuningTargets/);
assert.match(src, /parseRawTuningOffsets/);
});
-234
View File
@@ -1,234 +0,0 @@
"""Tests for one-time builtin starter-content seeding into DLC."""
from __future__ import annotations
import importlib
import sys
import pytest
@pytest.fixture()
def server_mod(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
(tmp_path / "config").mkdir()
monkeypatch.delenv("DLC_DIR", raising=False)
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
def _source(server_mod):
return (
server_mod._feedBack_server_root()
/ server_mod._BUILTIN_STARTER_SOURCES[0][1]
)
def _dest(server_mod, dlc):
return (
dlc
/ server_mod._BUILTIN_STARTER_SUBDIR
/ server_mod._BUILTIN_STARTER_SOURCES[0][0]
)
def test_seed_creates_starter_content_and_marker(tmp_path, server_mod):
"""First run copies the bundled feedpak into starter/ and writes the marker."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
dest = _dest(server_mod, dlc)
assert dest.is_file()
assert dest.stat().st_size == source.stat().st_size
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_seed_preserves_source_mtime(tmp_path, server_mod):
"""The seeded pack keeps the bundle's mtime so the diagnostic refresh check
(source newer than dest -> update) stays correct across both write paths."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
assert _dest(server_mod, dlc).stat().st_mtime_ns == source.stat().st_mtime_ns
def test_starter_is_not_carved_out_of_the_library():
"""`starter/` must NOT collide with the diagnostics/tutorials carve-out —
otherwise seeded songs would never appear in the library listing."""
assert "starter" not in {"diagnostics-builtin", "tutorials-builtin"}
def test_seed_runs_only_once_and_respects_deletion(tmp_path, server_mod):
"""After the first seed, deleting the song does NOT bring it back: the
marker makes starter seeding a one-time welcome."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
server_mod._seed_builtin_starter_content(dlc)
dest = _dest(server_mod, dlc)
assert dest.is_file()
# User removes the starter song.
dest.unlink()
# A subsequent launch must not re-seed it.
server_mod._seed_builtin_starter_content(dlc)
assert not dest.exists()
def test_seed_deferred_until_dlc_configured(tmp_path, server_mod):
"""With no DLC folder, seeding is skipped WITHOUT writing the marker, so it
retries once a library folder exists."""
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
# dlc is None and DLC_DIR unset -> _get_dlc_dir() returns None.
server_mod._seed_builtin_starter_content(None)
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
# Now a DLC is configured: the deferred seed runs.
dlc = tmp_path / "dlc"
dlc.mkdir()
server_mod._seed_builtin_starter_content(dlc)
assert _dest(server_mod, dlc).is_file()
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_seed_refuses_symlinked_seed_directory(tmp_path, server_mod):
"""A symlinked starter/ dir is refused so copies can't escape the DLC tree."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
(dlc / server_mod._BUILTIN_STARTER_SUBDIR).symlink_to(outside_dir)
server_mod._seed_builtin_starter_content(dlc)
assert list(outside_dir.iterdir()) == []
# An incomplete seed must NOT write the marker, so a later launch retries.
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
def test_seed_never_overwrites_an_existing_user_file(tmp_path, server_mod):
"""One-time starter seeding must never replace a user's own file at the
destination, even if the bundled pack has a newer mtime."""
import os as _os
dlc = tmp_path / "dlc"
dlc.mkdir()
dest = _dest(server_mod, dlc)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(b"user's own edited pack")
_os.utime(dest, (1_000_000, 1_000_000)) # far older than the bundled source
server_mod._seed_builtin_starter_content(dlc)
assert dest.read_bytes() == b"user's own edited pack" # untouched
# counted as already-present, so the one-time seed considers itself done
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_seed_does_not_mark_when_destination_is_a_directory(tmp_path, server_mod):
"""A directory sitting at the destination name is neither clobbered nor
counted as present, so the marker stays unwritten and seeding retries."""
dlc = tmp_path / "dlc"
dlc.mkdir()
source = _source(server_mod)
if not source.is_file():
pytest.skip(f"starter source not present in checkout: {source}")
bogus = _dest(server_mod, dlc)
bogus.parent.mkdir(parents=True, exist_ok=True)
bogus.mkdir() # user (or junk) placed a directory where the pack goes
server_mod._seed_builtin_starter_content(dlc)
assert bogus.is_dir() # untouched
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
def test_seed_does_not_mark_when_source_missing(tmp_path, server_mod, monkeypatch):
"""If a starter source can't be found, the marker stays unwritten and the
seed is retried on the next launch (rather than permanently skipped)."""
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setattr(
server_mod,
"_BUILTIN_STARTER_SOURCES",
[("missing.feedpak", "content/starter/does-not-exist.feedpak")],
)
server_mod._seed_builtin_starter_content(dlc)
assert not (dlc / server_mod._BUILTIN_STARTER_SUBDIR / "missing.feedpak").exists()
assert not (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).exists()
def test_every_starter_source_file_is_present(server_mod):
"""Every entry in _BUILTIN_STARTER_SOURCES must have its bundled file on
disk — otherwise the all-present gate never fires and NOTHING seeds (a
listed-but-missing pack silently disables starter seeding entirely). In CI
the checkout is clean, so "on disk" == committed."""
root = server_mod._feedBack_server_root()
missing = [
rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES
if not (root / rel).is_file()
]
assert not missing, f"listed starter sources missing on disk: {missing}"
def test_seed_lands_every_listed_starter_pack(tmp_path, server_mod):
"""A real seed run copies every listed pack into starter/ and marks done."""
root = server_mod._feedBack_server_root()
for _, rel in server_mod._BUILTIN_STARTER_SOURCES:
if not (root / rel).is_file():
pytest.skip(f"starter source not present in checkout: {rel}")
dlc = tmp_path / "dlc"
dlc.mkdir()
server_mod._seed_builtin_starter_content(dlc)
for dest_name, _ in server_mod._BUILTIN_STARTER_SOURCES:
dest = dlc / server_mod._BUILTIN_STARTER_SUBDIR / dest_name
assert dest.is_file(), f"pack not seeded: {dest_name}"
assert (server_mod.CONFIG_DIR / server_mod._STARTER_SEED_MARKER).is_file()
def test_no_unlisted_starter_pack_on_disk(server_mod):
"""The inverse guard: every content/starter/*.feedpak on disk must be wired
into _BUILTIN_STARTER_SOURCES. An unlisted pack bundles into builds as dead
weight and never seeds — exactly how the raw Ode-to-Joy pack slipped onto
main before being wired up. In CI the checkout is clean, so this flags any
stray/committed pack that isn't listed."""
root = server_mod._feedBack_server_root()
listed = {rel for _, rel in server_mod._BUILTIN_STARTER_SOURCES}
if not listed:
pytest.skip("no starter sources declared")
content_dir = (root / next(iter(listed))).parent # all sources share this dir
if not content_dir.is_dir():
pytest.skip(f"starter content dir absent: {content_dir}")
on_disk = {p.relative_to(root).as_posix() for p in content_dir.glob("*.feedpak")}
unlisted = on_disk - listed
assert not unlisted, (
"committed but not in _BUILTIN_STARTER_SOURCES (would bundle as dead "
f"weight and never seed): {sorted(unlisted)}"
)