mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 13:54:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03ca3a33d2 | ||
|
|
73e25fedc2 | ||
|
|
b083d2dd45 | ||
|
|
fc52d1f9d2 | ||
|
|
b6169af6aa | ||
|
|
a3f1bceb15 | ||
|
|
c2153b277b | ||
|
|
2ffeeaca0b | ||
|
|
14eaad09e9 | ||
|
|
6ab1ed95c9 | ||
|
|
7c873f5cc2 | ||
|
|
68e29a8b6e | ||
|
|
d2b2a7e9f7 | ||
|
|
b6442dda75 | ||
|
|
336132e049 | ||
|
|
a2f43009f7 | ||
|
|
425f72b33f |
@@ -7,6 +7,9 @@ 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
@@ -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-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
|
||||
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
|
||||
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-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_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
|
||||
|
||||
# Apply latest security updates to base packages (clears glibc deb13u3 and
|
||||
# similar). Done first so any subsequent installs resolve against the
|
||||
|
||||
Executable
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
+77
-10
@@ -39,6 +39,14 @@ DURATION_BONUS_LOOSE = 0.025 # …within 15s
|
||||
_DURATION_TIGHT = 5
|
||||
_DURATION_LOOSE = 15
|
||||
|
||||
# Release-group secondary types that mark a NON-canonical release (a live album,
|
||||
# a greatest-hits comp, a remix/DJ set, …). Used both to pick the canonical
|
||||
# studio album for display and to reward studio recordings in ranking.
|
||||
_SECONDARY_SKIP = {
|
||||
"live", "compilation", "remix", "dj-mix", "mixtape/street",
|
||||
"demo", "interview", "audiobook", "spokenword",
|
||||
}
|
||||
|
||||
# ── Denoise ───────────────────────────────────────────────────────────────────
|
||||
# A parenthetical/bracketed group is dropped when it contains any of these
|
||||
# noise terms as a whole word (chart-variant markers, tuning/pitch notes,
|
||||
@@ -154,6 +162,10 @@ def score_candidate(song: dict, cand: dict) -> float:
|
||||
score += DURATION_BONUS
|
||||
elif diff <= _DURATION_LOOSE:
|
||||
score += DURATION_BONUS_LOOSE
|
||||
# NB: the studio-vs-live distinction is deliberately NOT scored here — a live
|
||||
# take is still the RIGHT SONG (same title/artist), so it must not change the
|
||||
# auto/review confidence. Canonical-version preference lives in the RANK sort
|
||||
# (rank_candidates) instead, where it only reorders same-song candidates.
|
||||
return min(score, 1.0)
|
||||
|
||||
|
||||
@@ -179,15 +191,34 @@ def classify(song: dict, cand: dict, score: float, auto_min: float | None = None
|
||||
|
||||
|
||||
def rank_candidates(song: dict, candidates: list[dict]) -> list[dict]:
|
||||
"""Score every candidate against the song and return them sorted by our
|
||||
score (MusicBrainz's own search score is only a tiebreak). Each returned
|
||||
dict is a copy carrying `score` (rounded — it's displayed and stored)."""
|
||||
"""Score every candidate against the song and return them sorted best-first.
|
||||
The combined `score` caps at 1.0, so a perfect-text-match query (every "AC/DC
|
||||
Highway to Hell" recording) ties at the top — there the studio flag and, when
|
||||
the caller knows the audio length, the duration match break the tie so the
|
||||
canonical studio take wins over live/promo/extended cuts. Each returned dict
|
||||
is a copy carrying `score` (rounded — it's displayed and stored)."""
|
||||
sd = _duration_int(song.get("duration"))
|
||||
# For a chart that IS a live take (build_recording_query keeps live
|
||||
# recordings for these) the studio take is the WRONG recording, so drop the
|
||||
# studio tiebreak — duration proximity + text/mb score then pick the right
|
||||
# live version instead of auto-matching the studio one.
|
||||
prefer_studio = not _LIVE_GROUP_RE.search(str(song.get("title") or ""))
|
||||
|
||||
def _dur_diff(c):
|
||||
cd = _duration_int(c.get("duration"))
|
||||
return abs(sd - cd) if (sd and cd) else 10 ** 6
|
||||
|
||||
ranked = []
|
||||
for cand in candidates or []:
|
||||
c = dict(cand)
|
||||
c["score"] = round(score_candidate(song, cand), 4)
|
||||
ranked.append(c)
|
||||
ranked.sort(key=lambda c: (c["score"], c.get("mb_score") or 0), reverse=True)
|
||||
ranked.sort(
|
||||
key=lambda c: (c["score"],
|
||||
(1 if c.get("studio") else 0) if prefer_studio else 0,
|
||||
-_dur_diff(c), # closest to the audio length
|
||||
c.get("mb_score") or 0),
|
||||
reverse=True)
|
||||
return ranked
|
||||
|
||||
|
||||
@@ -198,6 +229,11 @@ def _lucene_escape_phrase(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
# A parenthetical/bracketed "(Live …)" marker — the live signal denoise() strips
|
||||
# from the title. Mirrors _NOISE_GROUP_RE but for the `live` term only.
|
||||
_LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE)
|
||||
|
||||
|
||||
def build_recording_query(artist, title) -> str:
|
||||
"""Lucene query for /ws/2/recording. Built from the DENOISED fields —
|
||||
the noise we strip (author credits, "(Live)", "(v2)") would otherwise
|
||||
@@ -209,7 +245,22 @@ def build_recording_query(artist, title) -> str:
|
||||
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
|
||||
if a:
|
||||
parts.append('artist:"%s"' % _lucene_escape_phrase(a))
|
||||
return " AND ".join(parts)
|
||||
q = " AND ".join(parts)
|
||||
# Drop live-ONLY recordings (bootlegs, live albums) — the canonical studio
|
||||
# take is never tagged Live, and this is the single biggest source of junk in
|
||||
# a flat recording search. Compilations are deliberately NOT excluded: they
|
||||
# REUSE the studio recording, so filtering them would drop the very recording
|
||||
# we want (verified against MusicBrainz — `-secondarytype:Compilation` cut the
|
||||
# AC/DC studio "Highway to Hell" recording entirely).
|
||||
#
|
||||
# EXCEPT when the source chart is itself a live take: denoise() strips the
|
||||
# "(Live at …)" qualifier from the query, so filtering Live would leave the
|
||||
# genuinely-live chart with NO correct recording. Only a parenthetical marker
|
||||
# counts — a bare title word ("Live and Let Die") is a real word, not a live
|
||||
# tag — mirroring what denoise removes.
|
||||
if q and not _LIVE_GROUP_RE.search(str(title or "")):
|
||||
q += " AND -secondarytype:Live"
|
||||
return q
|
||||
|
||||
|
||||
def _artist_credit(doc: dict) -> tuple[str, str, str]:
|
||||
@@ -226,19 +277,33 @@ def _artist_credit(doc: dict) -> tuple[str, str, str]:
|
||||
return name, str(artist.get("id", "") or ""), str(artist.get("sort-name", "") or "")
|
||||
|
||||
|
||||
def _is_clean_studio_album(rg: dict) -> bool:
|
||||
"""A release-group that is a primary-type Album with NO non-canonical
|
||||
secondary type (Live / Compilation / Remix / …) — i.e. a studio album."""
|
||||
if str(rg.get("primary-type", "")).lower() != "album":
|
||||
return False
|
||||
secs = {str(s).lower() for s in (rg.get("secondary-types") or [])}
|
||||
return not (secs & _SECONDARY_SKIP)
|
||||
|
||||
|
||||
def _best_release(doc: dict) -> dict:
|
||||
"""Pick the release used for canon album/year: prefer Official status and
|
||||
an Album release-group, then the earliest date. Returns {} if none."""
|
||||
"""Pick the release used for canon album/year: prefer an OFFICIAL studio
|
||||
Album (primary Album with no Live/Compilation/… secondary type), then the
|
||||
earliest date. Falls back to any release when none is clean. {} if none."""
|
||||
releases = [r for r in (doc.get("releases") or []) if isinstance(r, dict)]
|
||||
if not releases:
|
||||
return {}
|
||||
|
||||
def sort_key(r):
|
||||
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
|
||||
rg = r.get("release-group") or {}
|
||||
album_ok = 0 if str(rg.get("primary-type", "")).lower() == "album" else 1
|
||||
clean = 0 if _is_clean_studio_album(rg) else 1
|
||||
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
|
||||
date = str(r.get("date", "") or "9999")
|
||||
return (status_ok, album_ok, date)
|
||||
# Official FIRST, then prefer a clean studio album: this still surfaces
|
||||
# the studio album over an (official) live/comp album for the display
|
||||
# album/year, but never lets an UNofficial bootleg album outrank an
|
||||
# official single/EP/comp — which `(clean, status_ok, …)` would.
|
||||
return (status_ok, clean, date)
|
||||
|
||||
return sorted(releases, key=sort_key)[0]
|
||||
|
||||
@@ -261,6 +326,7 @@ def parse_recording_doc(doc: dict) -> dict | None:
|
||||
return None
|
||||
artist_name, artist_id, artist_sort = _artist_credit(doc)
|
||||
release = _best_release(doc)
|
||||
studio = _is_clean_studio_album(release.get("release-group") or {})
|
||||
length = doc.get("length")
|
||||
try:
|
||||
duration = int(round(float(length) / 1000.0)) if length else None
|
||||
@@ -281,6 +347,7 @@ def parse_recording_doc(doc: dict) -> dict | None:
|
||||
"isrc": isrcs[0] if isrcs else "",
|
||||
"genres": _genres(doc),
|
||||
"mb_score": int(doc.get("score") or 0),
|
||||
"studio": studio,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
import math
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
@@ -5576,13 +5577,217 @@ 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/``). Logs and continues on missing source or copy errors.
|
||||
``diagnostics-test/``). Re-seeds whenever the destination is missing so the
|
||||
diagnostic target is always available. Logs and continues on errors.
|
||||
"""
|
||||
try:
|
||||
if dlc is None:
|
||||
@@ -5590,86 +5795,108 @@ 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
|
||||
|
||||
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
|
||||
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:
|
||||
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,
|
||||
)
|
||||
_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),
|
||||
)
|
||||
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)
|
||||
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)
|
||||
except Exception:
|
||||
log.warning("Starter content seed: unexpected error", exc_info=True)
|
||||
|
||||
|
||||
def _background_scan():
|
||||
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
|
||||
|
||||
@@ -5690,6 +5917,7 @@ 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
|
||||
@@ -6007,8 +6235,11 @@ def _mb_http_get(path: str, params: dict) -> dict | None:
|
||||
raise EnrichTransportError("bad JSON from musicbrainz") from e
|
||||
|
||||
|
||||
def _mb_search_recordings(artist, title, limit: int = 8) -> list[dict]:
|
||||
"""Text search (tier 2–4): denoised Lucene query over /recording."""
|
||||
def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
|
||||
"""Text search (tier 2–4): denoised Lucene query over /recording. The query
|
||||
now drops live-only recordings and our ranker rewards the studio take, so a
|
||||
slightly larger default result set gives the re-ranker room to surface the
|
||||
canonical version (one request per song regardless of limit)."""
|
||||
query = mb_match.build_recording_query(artist, title)
|
||||
if not query:
|
||||
return []
|
||||
@@ -7185,13 +7416,16 @@ def api_enrichment_pick(filename: str, data: dict = Body(...)):
|
||||
|
||||
@app.get("/api/enrichment/search")
|
||||
def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
|
||||
filename: str = ""):
|
||||
filename: str = "", duration: float = 0.0):
|
||||
"""Manual-search proxy to MusicBrainz (throttled + identified like the
|
||||
background matcher — a user typing in the drawer must not sidestep the
|
||||
rate limit). `filename` optionally scores results against that song's
|
||||
stored identity (year/duration corroboration) instead of just the typed
|
||||
text. Sync route on purpose: FastAPI runs it in the threadpool, so the
|
||||
throttle's sleep never blocks the event loop."""
|
||||
text. `duration` (seconds) lets a caller that HAS the audio but no library
|
||||
row — e.g. the editor's create modal, which holds the master track — pass
|
||||
its length so the studio take ranks above live/extended cuts. Sync route on
|
||||
purpose: FastAPI runs it in the threadpool, so the throttle's sleep never
|
||||
blocks the event loop."""
|
||||
if not (artist.strip() or title.strip()):
|
||||
raise HTTPException(status_code=400, detail="artist or title required")
|
||||
limit = max(1, min(int(limit), 25))
|
||||
@@ -7205,6 +7439,10 @@ def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
|
||||
ref = meta_db.enrichment_song_row(filename)
|
||||
if ref is None:
|
||||
ref = {"artist": artist, "title": title}
|
||||
# A caller-supplied duration corroborates the take even without a library row.
|
||||
if duration and duration > 0 and not ref.get("duration"):
|
||||
ref = dict(ref)
|
||||
ref["duration"] = duration
|
||||
return {"candidates": mb_match.rank_candidates(ref, cands)}
|
||||
|
||||
|
||||
|
||||
+52
-23
@@ -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), 'resume'))
|
||||
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'session-resume'))
|
||||
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
|
||||
.catch((err) => console.warn('[app] resume failed:', err));
|
||||
});
|
||||
@@ -6761,7 +6761,16 @@ window.feedBack.playQueue = (function () {
|
||||
if (!files.length) return false;
|
||||
list = files.slice(); idx = 0;
|
||||
source = (opts && opts.source) || '';
|
||||
arrangements = (opts && opts.arrangements) || null;
|
||||
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]];
|
||||
}
|
||||
}
|
||||
if (window.fbNotify) {
|
||||
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
|
||||
}
|
||||
@@ -10968,20 +10977,19 @@ async function loadPlugins() {
|
||||
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
|
||||
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
|
||||
});
|
||||
const livePluginIds = new Set(plugins.map((plugin) => plugin.id));
|
||||
for (const [pluginId, contributions] of _pluginUiContributions) {
|
||||
if (livePluginIds.has(pluginId)) continue;
|
||||
const stalePlugin = { id: pluginId };
|
||||
for (const contribution of contributions) {
|
||||
await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution);
|
||||
}
|
||||
try {
|
||||
window.feedBack?.capabilities?.unregisterParticipant?.(pluginId);
|
||||
} catch (e) {
|
||||
console.warn(`capability participant unregister failed for ${pluginId}:`, e);
|
||||
}
|
||||
_pluginUiContributions.delete(pluginId);
|
||||
}
|
||||
// NOTE deliberately NO stale-contribution sweep for plugins absent
|
||||
// from this response. Absent ≠ uninstalled: the backend clears its
|
||||
// plugin registry at the start of load_plugins() and repopulates it
|
||||
// incrementally while HTTP stays up, so every backend restart serves a
|
||||
// window of partial (even empty) responses. The old sweep unmounted UI
|
||||
// contributions and unregistered capability participants on mere
|
||||
// absence, permanently breaking still-loaded plugins — their scripts
|
||||
// don't re-run (loadedScripts guard below), so nothing ever
|
||||
// re-registered. A genuine mid-session uninstall now leaves the
|
||||
// (already-evaluated, un-unloadable) script's contributions in place
|
||||
// until reload; its nav entry still disappears because nav is rebuilt
|
||||
// from the response each round. Same invariant as the settings/screen
|
||||
// DOM wipe and _reconcilePluginStyles below.
|
||||
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
|
||||
|
||||
try {
|
||||
@@ -11123,17 +11131,23 @@ async function loadPlugins() {
|
||||
loadedStyles.set(plugin.id, wantedVersion);
|
||||
};
|
||||
const _reconcilePluginStyles = (currentPlugins) => {
|
||||
// Drop stylesheets for plugins that vanished from /api/plugins or are
|
||||
// no longer ready+styled this round. _injectPluginStyles below only
|
||||
// visits plugins still returned by the API, so an uninstalled or
|
||||
// newly-not-ready plugin would otherwise keep its <link> applying.
|
||||
// Drop stylesheets for plugins the response KNOWS about but that
|
||||
// are no longer ready+styled this round. _injectPluginStyles below
|
||||
// only visits plugins still returned by the API, so a newly-not-
|
||||
// ready or unstyled plugin would otherwise keep its <link>
|
||||
// applying. Plugins merely ABSENT from the response keep their
|
||||
// stylesheet — a transient partial response during a backend
|
||||
// restart is not an uninstall (same invariant as the screen/
|
||||
// settings wipe below), and stripping the <link> would leave a
|
||||
// still-loaded plugin visible but unstyled.
|
||||
const responded = new Set(currentPlugins.map((p) => p.id));
|
||||
const styled = new Set(
|
||||
currentPlugins
|
||||
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
|
||||
.map((p) => p.id),
|
||||
);
|
||||
for (const id of Array.from(loadedStyles.keys())) {
|
||||
if (!styled.has(id)) {
|
||||
if (responded.has(id) && !styled.has(id)) {
|
||||
_removePluginStyleTags(id);
|
||||
loadedStyles.delete(id);
|
||||
}
|
||||
@@ -11146,6 +11160,18 @@ async function loadPlugins() {
|
||||
if (pid) existingSettingsByPluginId.set(pid, child);
|
||||
}
|
||||
}
|
||||
// Plugins named in THIS response. A plugin can be transiently absent
|
||||
// from /api/plugins — the backend clears its registry at the start of
|
||||
// load_plugins() and repopulates it incrementally while HTTP stays up,
|
||||
// so every backend restart serves a window of partial (even empty)
|
||||
// responses. The wipe loops below must never treat that absence as an
|
||||
// uninstall: stripping a still-loaded plugin's DOM while keeping its
|
||||
// loadedScripts entry made the NEXT refetch fail the DOM check and
|
||||
// re-evaluate its screen.js mid-session — which duplicated the desktop
|
||||
// audio_engine's native signal chain (its init re-ran against the
|
||||
// surviving engine chain). Absent plugins keep their DOM and script;
|
||||
// they're re-reconciled when they reappear in a later response.
|
||||
const respondedIds = new Set(plugins.map((p) => p.id));
|
||||
const alreadyHydrated = new Set();
|
||||
for (const p of plugins) {
|
||||
if (!p.has_script) continue;
|
||||
@@ -11173,7 +11199,10 @@ async function loadPlugins() {
|
||||
for (const container of _pluginSettingsContainers()) {
|
||||
[...container.children].forEach((el) => {
|
||||
const pid = el.dataset ? el.dataset.pluginId : null;
|
||||
if (!pid || !alreadyHydrated.has(pid)) el.remove();
|
||||
// Remove junk (no plugin id) and plugins the response KNOWS
|
||||
// about but that failed hydration; leave plugins absent from
|
||||
// the response untouched (see respondedIds above).
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
|
||||
@@ -11182,7 +11211,7 @@ async function loadPlugins() {
|
||||
// change shipped — both forms strip a single leading "plugin-".
|
||||
const pid = (el.dataset && el.dataset.pluginId)
|
||||
|| el.id.replace(/^plugin-/, '');
|
||||
if (!alreadyHydrated.has(pid)) el.remove();
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
});
|
||||
|
||||
// Plugin settings area hosts both "Plugin Updates" and per-plugin
|
||||
|
||||
+28
-3
@@ -211,7 +211,12 @@
|
||||
'<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-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-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>'
|
||||
: '') +
|
||||
(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>' : '') +
|
||||
@@ -226,6 +231,26 @@
|
||||
: '<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
|
||||
@@ -244,8 +269,8 @@
|
||||
if (!files.length) return;
|
||||
if (window.feedBack && window.feedBack.playQueue) {
|
||||
window.feedBack.playQueue.start(files, isAlbum
|
||||
? { source: pl.name, arrangements: arrs }
|
||||
: { source: pl.name });
|
||||
? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() }
|
||||
: { source: pl.name, shuffle: shuffleOn() });
|
||||
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
|
||||
});
|
||||
const listEl = root.querySelector('#v3-pl-songs');
|
||||
|
||||
@@ -35,10 +35,11 @@ function buildFacade() {
|
||||
'return _hwcInstallFacade;',
|
||||
].join('\n');
|
||||
const params = [
|
||||
'window', 'HWC_SLOTS', 'console',
|
||||
'window', 'HWC_SLOTS', 'HWC_PRESETS', 'console',
|
||||
'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors',
|
||||
'_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape',
|
||||
'applyHighwayStringColors', 'encodeHighwayColorShare', 'decodeHighwayColorShare',
|
||||
'applyHighwayStringColors', 'applyHighwayStringPreset',
|
||||
'encodeHighwayColorShare', 'decodeHighwayColorShare',
|
||||
];
|
||||
|
||||
const listeners = {};
|
||||
@@ -64,14 +65,19 @@ 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, console,
|
||||
win, HWC_SLOTS, HWC_PRESETS, console,
|
||||
stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors,
|
||||
stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape,
|
||||
stubs.applyHighwayStringColors, stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
||||
stubs.applyHighwayStringColors, stubs.applyHighwayStringPreset,
|
||||
stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
||||
);
|
||||
installer();
|
||||
return { api: win.feedBack.highwayColors, win, bus, calls, installer, stubs };
|
||||
@@ -87,11 +93,13 @@ 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', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
|
||||
'getCurrent', 'apply', 'applyPreset', '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', () => {
|
||||
|
||||
@@ -74,7 +74,10 @@ const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||
|
||||
function source(file) {
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
// 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');
|
||||
}
|
||||
|
||||
function region(src, needle, length = 1200) {
|
||||
|
||||
@@ -40,7 +40,9 @@ 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/);
|
||||
assert.match(html, /won’t warn that no internal amp tone is loaded/);
|
||||
// Apostrophe form drifted from the ’ entity to the literal ’ in a
|
||||
// copy pass — accept entity, typographic, or plain apostrophe.
|
||||
assert.match(html, /won(?:’|’|')t warn that no internal amp tone is loaded/);
|
||||
});
|
||||
|
||||
test('player audio rail exposes tone source select', () => {
|
||||
|
||||
@@ -107,6 +107,7 @@ 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(')}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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]);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
|
||||
// merely ABSENT from the current /api/plugins response (transient partial
|
||||
// response while the backend's plugin registry is repopulating after a
|
||||
// restart) must keep its settings panel and screen DOM. Wiping it while its
|
||||
// _loadedPluginScripts entry survives made the next refetch fail the
|
||||
// DOM-existence check and re-evaluate the plugin's screen.js mid-session —
|
||||
// which duplicated the desktop audio_engine's native signal chain. Plugins
|
||||
// the response knows about but that failed hydration are still wiped, as is
|
||||
// junk DOM carrying no plugin id.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
|
||||
// nav reset that opens it to the comment introducing the next section.
|
||||
function extractWipeBlock(src) {
|
||||
const start = src.indexOf("navContainer.innerHTML = '';");
|
||||
assert.ok(start !== -1, 'wipe block start (nav reset) not found');
|
||||
const end = src.indexOf('// Plugin settings area hosts', start);
|
||||
assert.ok(end !== -1, 'wipe block end marker not found');
|
||||
return src.slice(start, end);
|
||||
}
|
||||
|
||||
function makeEl(pluginId, id) {
|
||||
return {
|
||||
dataset: pluginId != null ? { pluginId } : {},
|
||||
id: id || (pluginId != null ? `plugin-${pluginId}` : ''),
|
||||
removed: false,
|
||||
remove() {
|
||||
this.removed = true;
|
||||
const idx = this._parent ? this._parent.indexOf(this) : -1;
|
||||
if (idx >= 0) this._parent.splice(idx, 1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const block = extractWipeBlock(src);
|
||||
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
|
||||
const container = { children: settingsChildren };
|
||||
const sandbox = {
|
||||
navContainer: { innerHTML: 'seed' },
|
||||
mobileNavContainer: { innerHTML: 'seed' },
|
||||
_pluginSettingsContainers: () => [container],
|
||||
respondedIds,
|
||||
alreadyHydrated,
|
||||
document: {
|
||||
querySelectorAll: (sel) => {
|
||||
assert.equal(sel, '.screen[id^="plugin-"]');
|
||||
return screens.slice();
|
||||
},
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(block, sandbox, { filename: 'wipe-block.js' });
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('plugin absent from the response keeps its settings + screen DOM', () => {
|
||||
const settings = makeEl('audio_engine');
|
||||
const screen = makeEl('audio_engine');
|
||||
runWipe({
|
||||
respondedIds: new Set(), // partial response: plugin missing
|
||||
alreadyHydrated: new Set(), // scan loop never saw it either
|
||||
settingsChildren: [settings],
|
||||
screens: [screen],
|
||||
});
|
||||
assert.equal(settings.removed, false, 'settings panel must survive a partial response');
|
||||
assert.equal(screen.removed, false, 'screen must survive a partial response');
|
||||
});
|
||||
|
||||
test('plugin present in the response but not hydrated is wiped', () => {
|
||||
const settings = makeEl('stale_plugin');
|
||||
const screen = makeEl('stale_plugin');
|
||||
runWipe({
|
||||
respondedIds: new Set(['stale_plugin']),
|
||||
alreadyHydrated: new Set(),
|
||||
settingsChildren: [settings],
|
||||
screens: [screen],
|
||||
});
|
||||
assert.equal(settings.removed, true);
|
||||
assert.equal(screen.removed, true);
|
||||
});
|
||||
|
||||
test('hydrated plugin present in the response is preserved', () => {
|
||||
const settings = makeEl('audio_engine');
|
||||
const screen = makeEl('audio_engine');
|
||||
runWipe({
|
||||
respondedIds: new Set(['audio_engine']),
|
||||
alreadyHydrated: new Set(['audio_engine']),
|
||||
settingsChildren: [settings],
|
||||
screens: [screen],
|
||||
});
|
||||
assert.equal(settings.removed, false);
|
||||
assert.equal(screen.removed, false);
|
||||
});
|
||||
|
||||
test('junk DOM without a plugin id is still removed', () => {
|
||||
const junkSettings = makeEl(null);
|
||||
// Screen whose id strips to '' (no dataset.pluginId, bare "plugin-" id).
|
||||
const junkScreen = makeEl(null, 'plugin-');
|
||||
runWipe({
|
||||
respondedIds: new Set(['whatever']),
|
||||
alreadyHydrated: new Set(),
|
||||
settingsChildren: [junkSettings],
|
||||
screens: [junkScreen],
|
||||
});
|
||||
assert.equal(junkSettings.removed, true);
|
||||
assert.equal(junkScreen.removed, true);
|
||||
});
|
||||
@@ -204,15 +204,20 @@ test('does not collide tags across two different plugins', () => {
|
||||
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('reconcile removes the <link> of a plugin that vanished from /api/plugins', () => {
|
||||
test('reconcile keeps the <link> of a plugin absent from a partial response', () => {
|
||||
const { inject, reconcile, headLinks } = setupSandbox();
|
||||
inject(plug({ id: 'a' }));
|
||||
inject(plug({ id: 'b' }));
|
||||
assert.equal(headLinks.length, 2);
|
||||
// `a` is no longer returned (uninstalled) — its stylesheet must be dropped.
|
||||
// `a` is missing from this response. That happens transiently during a
|
||||
// backend restart (the plugin registry repopulates while HTTP stays up),
|
||||
// so absence is NOT an uninstall signal — the still-loaded plugin must
|
||||
// keep its stylesheet or it renders visible-but-unstyled until it
|
||||
// reappears. Explicit removal still happens via the not-ready/unstyled
|
||||
// paths (tests below).
|
||||
reconcile([plug({ id: 'b' })]);
|
||||
assert.equal(headLinks.length, 1);
|
||||
assert.equal(headLinks[0].dataset.pluginId, 'b');
|
||||
assert.equal(headLinks.length, 2);
|
||||
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('reconcile removes the <link> of a plugin that is no longer ready', () => {
|
||||
|
||||
@@ -42,7 +42,10 @@ 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; },
|
||||
@@ -75,6 +78,7 @@ 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);
|
||||
|
||||
@@ -31,21 +31,23 @@ test('the home is the unfiltered grid front door, local provider only', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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.
|
||||
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.
|
||||
assert.match(
|
||||
src,
|
||||
/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',
|
||||
/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',
|
||||
);
|
||||
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]*?stats\/recent/,
|
||||
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?practice-suggestions/,
|
||||
'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');
|
||||
|
||||
@@ -64,7 +64,9 @@ const helpers = loadTuningHelpers();
|
||||
|
||||
test('v3 songs.js uses display helpers for album-art tuning badge', () => {
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
assert.match(src, /displayTuningName\(song\.tuning_name \|\| song\.tuning\)/);
|
||||
// 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, /displayTuningTargets/);
|
||||
assert.match(src, /parseRawTuningOffsets/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""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)}"
|
||||
)
|
||||
+53
-1
@@ -145,11 +145,40 @@ def test_rank_candidates_orders_by_our_score():
|
||||
assert all("score" in c for c in ranked)
|
||||
|
||||
|
||||
def test_rank_candidates_studio_preference_is_dropped_for_live_charts():
|
||||
"""Tied-score candidates: a studio chart prefers the studio take, but a
|
||||
LIVE chart must NOT be forced to the studio recording."""
|
||||
studio = {"recording_id": "studio", "artist": "AC/DC", "title": "Highway to Hell",
|
||||
"studio": True, "mb_score": 90}
|
||||
live = {"recording_id": "live", "artist": "AC/DC", "title": "Highway to Hell",
|
||||
"studio": False, "mb_score": 95}
|
||||
# Studio chart -> studio take wins the tie (studio flag), despite lower mb_score.
|
||||
studio_song = {"artist": "AC/DC", "title": "Highway to Hell"}
|
||||
assert m.rank_candidates(studio_song, [live, studio])[0]["recording_id"] == "studio"
|
||||
# Live chart -> studio preference dropped, so the higher-mb_score live take wins.
|
||||
live_song = {"artist": "AC/DC", "title": "Highway to Hell (Live at Donington)"}
|
||||
assert m.rank_candidates(live_song, [studio, live])[0]["recording_id"] == "live"
|
||||
|
||||
|
||||
# ── query building ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_build_recording_query_denoises_and_quotes():
|
||||
q = m.build_recording_query("ACDC", 'Thunderstruck (v2)')
|
||||
assert q == 'recording:"thunderstruck" AND artist:"acdc"'
|
||||
# Live-only recordings are excluded — the studio take is never tagged Live,
|
||||
# and it's the biggest source of junk in a flat recording search.
|
||||
assert q == 'recording:"thunderstruck" AND artist:"acdc" AND -secondarytype:Live'
|
||||
|
||||
|
||||
def test_build_recording_query_keeps_live_for_live_charts():
|
||||
"""A chart that IS a live take must NOT get the live filter, or its only
|
||||
correct recording is excluded. A bare title word ("Live and Let Die") is a
|
||||
real word, not a marker, so it still filters."""
|
||||
live = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)")
|
||||
assert "-secondarytype:Live" not in live
|
||||
assert 'recording:"highway to hell"' in live
|
||||
# A real word "live" in the title is not a live marker → still filtered.
|
||||
bare = m.build_recording_query("Wings", "Live and Let Die")
|
||||
assert "-secondarytype:Live" in bare
|
||||
|
||||
|
||||
def test_build_recording_query_escapes_and_handles_missing_artist():
|
||||
@@ -200,6 +229,29 @@ def test_parse_recording_doc_normalizes():
|
||||
assert c["mb_score"] == 98
|
||||
|
||||
|
||||
def test_best_release_prefers_official_single_over_unofficial_album():
|
||||
"""An OFFICIAL single/EP must outrank an UNofficial bootleg album for the
|
||||
canonical album/year: official comes before the studio-album preference, so
|
||||
a single-only song is never seeded from a bootleg. (`(clean, status_ok, …)`
|
||||
would wrongly pick the bootleg.)"""
|
||||
doc = {
|
||||
"id": "rec-x", "title": "One-Off", "score": 90,
|
||||
"artist-credit": [
|
||||
{"name": "A", "joinphrase": "",
|
||||
"artist": {"id": "a", "name": "A", "sort-name": "A"}}],
|
||||
"releases": [
|
||||
{"id": "rel-boot", "title": "Boot LP", "status": "Bootleg",
|
||||
"date": "1990-01-01", "release-group": {"primary-type": "Album"}},
|
||||
{"id": "rel-single", "title": "The Single", "status": "Official",
|
||||
"date": "1988-01-01", "release-group": {"primary-type": "Single"}},
|
||||
],
|
||||
}
|
||||
c = m.parse_recording_doc(doc)
|
||||
assert c["release_id"] == "rel-single"
|
||||
assert c["album"] == "The Single"
|
||||
assert c["studio"] is False # a Single isn't a clean studio ALBUM
|
||||
|
||||
|
||||
def test_parse_recording_doc_joined_artist_credit():
|
||||
doc = dict(MB_DOC)
|
||||
doc["artist-credit"] = [
|
||||
|
||||
@@ -38,15 +38,25 @@ def test_plugin_loader_unmounts_previous_ui_contributions_before_reregistering()
|
||||
assert "await _commandUiDomain(contribution.domain, 'mount', plugin, contribution)" in source
|
||||
|
||||
|
||||
def test_plugin_loader_unmounts_contributions_for_removed_plugins():
|
||||
def test_plugin_loader_does_not_treat_response_absence_as_uninstall():
|
||||
# A plugin transiently absent from /api/plugins (the backend clears its
|
||||
# registry at the start of load_plugins() and repopulates incrementally
|
||||
# while HTTP stays up, so restarts serve partial responses) must NOT be
|
||||
# torn down: the old absence sweep unmounted UI contributions and
|
||||
# unregistered the capability participant with no re-registration path
|
||||
# (plugin scripts don't re-run), and the DOM/style wipes forced a
|
||||
# mid-session screen.js re-evaluation that duplicated the desktop
|
||||
# audio_engine's native signal chain.
|
||||
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "const livePluginIds = new Set(plugins.map((plugin) => plugin.id))" in source
|
||||
assert "for (const [pluginId, contributions] of _pluginUiContributions)" in source
|
||||
assert "const stalePlugin = { id: pluginId }" in source
|
||||
assert "await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution)" in source
|
||||
assert "window.feedBack?.capabilities?.unregisterParticipant?.(pluginId)" in source
|
||||
assert "_pluginUiContributions.delete(pluginId)" in source
|
||||
# The absence-triggered sweep is gone (rationale comment in its place)...
|
||||
assert "const livePluginIds" not in source
|
||||
assert "const stalePlugin = { id: pluginId }" not in source
|
||||
assert "deliberately NO stale-contribution sweep" in source
|
||||
# ...and the DOM/style reconcilers only act on plugins the response names.
|
||||
assert "const respondedIds = new Set(plugins.map((p) => p.id))" in source
|
||||
assert "respondedIds.has(pid) && !alreadyHydrated.has(pid)" in source
|
||||
assert "responded.has(id) && !styled.has(id)" in source
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user