mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 10:38:32 +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]
|
## [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
|
### 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.
|
- **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.
|
# and update FFMPEG_RELEASE + both SHA256 ARGs below.
|
||||||
FROM alpine:3.20 AS ffmpeg-fetcher
|
FROM alpine:3.20 AS ffmpeg-fetcher
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
|
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
|
||||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
|
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
|
||||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-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=03c0431e0d1aa75cc343d83bda9d2d4cd8eaa37f35b7b93465e9ff6864f5d7f8
|
ARG FFMPEG_SHA256_AMD64=1390e1c320a1e38dae106d6d0b05a6f08eb8b30f732bc1aa0d45a4aa17f13795
|
||||||
ARG FFMPEG_SHA256_ARM64=74629b88342fd94eea12b7481c8b8560ca6d497744123c0a27b98f39d767fd93
|
ARG FFMPEG_SHA256_ARM64=53b2e30df04d56932b7782234c9bc97abfe0bb242192ca50346474a41b100ab0
|
||||||
RUN apk add --no-cache curl xz \
|
RUN apk add --no-cache curl xz \
|
||||||
&& arch="${TARGETARCH:-$(apk --print-arch)}" \
|
&& arch="${TARGETARCH:-$(apk --print-arch)}" \
|
||||||
&& case "$arch" in \
|
&& 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.
|
# 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
|
# 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.
|
# must be repeated here to take effect when no --build-arg is supplied.
|
||||||
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
|
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
|
||||||
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
|
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
|
||||||
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-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
|
# Apply latest security updates to base packages (clears glibc deb13u3 and
|
||||||
# similar). Done first so any subsequent installs resolve against the
|
# 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_TIGHT = 5
|
||||||
_DURATION_LOOSE = 15
|
_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 ───────────────────────────────────────────────────────────────────
|
# ── Denoise ───────────────────────────────────────────────────────────────────
|
||||||
# A parenthetical/bracketed group is dropped when it contains any of these
|
# A parenthetical/bracketed group is dropped when it contains any of these
|
||||||
# noise terms as a whole word (chart-variant markers, tuning/pitch notes,
|
# 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
|
score += DURATION_BONUS
|
||||||
elif diff <= _DURATION_LOOSE:
|
elif diff <= _DURATION_LOOSE:
|
||||||
score += DURATION_BONUS_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)
|
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]:
|
def rank_candidates(song: dict, candidates: list[dict]) -> list[dict]:
|
||||||
"""Score every candidate against the song and return them sorted by our
|
"""Score every candidate against the song and return them sorted best-first.
|
||||||
score (MusicBrainz's own search score is only a tiebreak). Each returned
|
The combined `score` caps at 1.0, so a perfect-text-match query (every "AC/DC
|
||||||
dict is a copy carrying `score` (rounded — it's displayed and stored)."""
|
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 = []
|
ranked = []
|
||||||
for cand in candidates or []:
|
for cand in candidates or []:
|
||||||
c = dict(cand)
|
c = dict(cand)
|
||||||
c["score"] = round(score_candidate(song, cand), 4)
|
c["score"] = round(score_candidate(song, cand), 4)
|
||||||
ranked.append(c)
|
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
|
return ranked
|
||||||
|
|
||||||
|
|
||||||
@@ -198,6 +229,11 @@ def _lucene_escape_phrase(s: str) -> str:
|
|||||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
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:
|
def build_recording_query(artist, title) -> str:
|
||||||
"""Lucene query for /ws/2/recording. Built from the DENOISED fields —
|
"""Lucene query for /ws/2/recording. Built from the DENOISED fields —
|
||||||
the noise we strip (author credits, "(Live)", "(v2)") would otherwise
|
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))
|
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
|
||||||
if a:
|
if a:
|
||||||
parts.append('artist:"%s"' % _lucene_escape_phrase(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]:
|
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 "")
|
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:
|
def _best_release(doc: dict) -> dict:
|
||||||
"""Pick the release used for canon album/year: prefer Official status and
|
"""Pick the release used for canon album/year: prefer an OFFICIAL studio
|
||||||
an Album release-group, then the earliest date. Returns {} if none."""
|
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)]
|
releases = [r for r in (doc.get("releases") or []) if isinstance(r, dict)]
|
||||||
if not releases:
|
if not releases:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def sort_key(r):
|
def sort_key(r):
|
||||||
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
|
|
||||||
rg = r.get("release-group") or {}
|
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")
|
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]
|
return sorted(releases, key=sort_key)[0]
|
||||||
|
|
||||||
@@ -261,6 +326,7 @@ def parse_recording_doc(doc: dict) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
artist_name, artist_id, artist_sort = _artist_credit(doc)
|
artist_name, artist_id, artist_sort = _artist_credit(doc)
|
||||||
release = _best_release(doc)
|
release = _best_release(doc)
|
||||||
|
studio = _is_clean_studio_album(release.get("release-group") or {})
|
||||||
length = doc.get("length")
|
length = doc.get("length")
|
||||||
try:
|
try:
|
||||||
duration = int(round(float(length) / 1000.0)) if length else None
|
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 "",
|
"isrc": isrcs[0] if isrcs else "",
|
||||||
"genres": _genres(doc),
|
"genres": _genres(doc),
|
||||||
"mb_score": int(doc.get("score") or 0),
|
"mb_score": int(doc.get("score") or 0),
|
||||||
|
"studio": studio,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import logging
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import stat
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
@@ -5576,13 +5577,217 @@ def _get_progression_content() -> dict:
|
|||||||
return _progression_content
|
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:
|
def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None:
|
||||||
"""Copy bundled diagnostic sloppaks into DLC before library scan.
|
"""Copy bundled diagnostic sloppaks into DLC before library scan.
|
||||||
|
|
||||||
Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak
|
Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak
|
||||||
when the destination is missing or older than the repo/bundle source.
|
when the destination is missing or older than the repo/bundle source.
|
||||||
Never deletes user files or touches manually copied paths (e.g.
|
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:
|
try:
|
||||||
if dlc is None:
|
if dlc is None:
|
||||||
@@ -5590,86 +5795,108 @@ def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None:
|
|||||||
if dlc is None:
|
if dlc is None:
|
||||||
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
|
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
|
||||||
return
|
return
|
||||||
|
_copy_builtin_packs(
|
||||||
root = _feedBack_server_root()
|
_feedBack_server_root(),
|
||||||
dest_dir = dlc / _BUILTIN_DIAGNOSTIC_SUBDIR
|
dlc / _BUILTIN_DIAGNOSTIC_SUBDIR,
|
||||||
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept
|
_BUILTIN_DIAGNOSTIC_SOURCES,
|
||||||
# it and copies would land at the link target, outside the DLC tree.
|
"Builtin diagnostic seed",
|
||||||
# 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,
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning("Builtin diagnostic seed: unexpected error", exc_info=True)
|
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():
|
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.
|
"""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
|
return
|
||||||
|
|
||||||
_seed_builtin_diagnostic_sloppaks(dlc)
|
_seed_builtin_diagnostic_sloppaks(dlc)
|
||||||
|
_seed_builtin_starter_content(dlc)
|
||||||
|
|
||||||
# Listing can fail on macOS without Full Disk Access, or on Docker if the
|
# Listing can fail on macOS without Full Disk Access, or on Docker if the
|
||||||
# path isn't shared. Report the failure explicitly rather than silently
|
# path isn't shared. Report the failure explicitly rather than silently
|
||||||
@@ -6007,8 +6235,11 @@ def _mb_http_get(path: str, params: dict) -> dict | None:
|
|||||||
raise EnrichTransportError("bad JSON from musicbrainz") from e
|
raise EnrichTransportError("bad JSON from musicbrainz") from e
|
||||||
|
|
||||||
|
|
||||||
def _mb_search_recordings(artist, title, limit: int = 8) -> list[dict]:
|
def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
|
||||||
"""Text search (tier 2–4): denoised Lucene query over /recording."""
|
"""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)
|
query = mb_match.build_recording_query(artist, title)
|
||||||
if not query:
|
if not query:
|
||||||
return []
|
return []
|
||||||
@@ -7185,13 +7416,16 @@ def api_enrichment_pick(filename: str, data: dict = Body(...)):
|
|||||||
|
|
||||||
@app.get("/api/enrichment/search")
|
@app.get("/api/enrichment/search")
|
||||||
def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
|
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
|
"""Manual-search proxy to MusicBrainz (throttled + identified like the
|
||||||
background matcher — a user typing in the drawer must not sidestep the
|
background matcher — a user typing in the drawer must not sidestep the
|
||||||
rate limit). `filename` optionally scores results against that song's
|
rate limit). `filename` optionally scores results against that song's
|
||||||
stored identity (year/duration corroboration) instead of just the typed
|
stored identity (year/duration corroboration) instead of just the typed
|
||||||
text. Sync route on purpose: FastAPI runs it in the threadpool, so the
|
text. `duration` (seconds) lets a caller that HAS the audio but no library
|
||||||
throttle's sleep never blocks the event loop."""
|
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()):
|
if not (artist.strip() or title.strip()):
|
||||||
raise HTTPException(status_code=400, detail="artist or title required")
|
raise HTTPException(status_code=400, detail="artist or title required")
|
||||||
limit = max(1, min(int(limit), 25))
|
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)
|
ref = meta_db.enrichment_song_row(filename)
|
||||||
if ref is None:
|
if ref is None:
|
||||||
ref = {"artist": artist, "title": title}
|
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)}
|
return {"candidates": mb_match.rank_candidates(ref, cands)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+52
-23
@@ -6195,7 +6195,7 @@ window.feedBack.on('song:ready', () => {
|
|||||||
setSpeed(pend.speed);
|
setSpeed(pend.speed);
|
||||||
}
|
}
|
||||||
} catch (_) { /* speed restore is best-effort */ }
|
} 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(); })
|
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
|
||||||
.catch((err) => console.warn('[app] resume failed:', err));
|
.catch((err) => console.warn('[app] resume failed:', err));
|
||||||
});
|
});
|
||||||
@@ -6761,7 +6761,16 @@ window.feedBack.playQueue = (function () {
|
|||||||
if (!files.length) return false;
|
if (!files.length) return false;
|
||||||
list = files.slice(); idx = 0;
|
list = files.slice(); idx = 0;
|
||||||
source = (opts && opts.source) || '';
|
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) {
|
if (window.fbNotify) {
|
||||||
try { window.fbNotify.show({ title: 'Playing ' + (source || 'queue'), message: files.length + ' songs', icon: '▶' }); } catch (e) { /* */ }
|
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 || ''));
|
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
|
||||||
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
|
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
|
||||||
});
|
});
|
||||||
const livePluginIds = new Set(plugins.map((plugin) => plugin.id));
|
// NOTE deliberately NO stale-contribution sweep for plugins absent
|
||||||
for (const [pluginId, contributions] of _pluginUiContributions) {
|
// from this response. Absent ≠ uninstalled: the backend clears its
|
||||||
if (livePluginIds.has(pluginId)) continue;
|
// plugin registry at the start of load_plugins() and repopulates it
|
||||||
const stalePlugin = { id: pluginId };
|
// incrementally while HTTP stays up, so every backend restart serves a
|
||||||
for (const contribution of contributions) {
|
// window of partial (even empty) responses. The old sweep unmounted UI
|
||||||
await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution);
|
// contributions and unregistered capability participants on mere
|
||||||
}
|
// absence, permanently breaking still-loaded plugins — their scripts
|
||||||
try {
|
// don't re-run (loadedScripts guard below), so nothing ever
|
||||||
window.feedBack?.capabilities?.unregisterParticipant?.(pluginId);
|
// re-registered. A genuine mid-session uninstall now leaves the
|
||||||
} catch (e) {
|
// (already-evaluated, un-unloadable) script's contributions in place
|
||||||
console.warn(`capability participant unregister failed for ${pluginId}:`, e);
|
// until reload; its nav entry still disappears because nav is rebuilt
|
||||||
}
|
// from the response each round. Same invariant as the settings/screen
|
||||||
_pluginUiContributions.delete(pluginId);
|
// DOM wipe and _reconcilePluginStyles below.
|
||||||
}
|
|
||||||
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
|
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -11123,17 +11131,23 @@ async function loadPlugins() {
|
|||||||
loadedStyles.set(plugin.id, wantedVersion);
|
loadedStyles.set(plugin.id, wantedVersion);
|
||||||
};
|
};
|
||||||
const _reconcilePluginStyles = (currentPlugins) => {
|
const _reconcilePluginStyles = (currentPlugins) => {
|
||||||
// Drop stylesheets for plugins that vanished from /api/plugins or are
|
// Drop stylesheets for plugins the response KNOWS about but that
|
||||||
// no longer ready+styled this round. _injectPluginStyles below only
|
// are no longer ready+styled this round. _injectPluginStyles below
|
||||||
// visits plugins still returned by the API, so an uninstalled or
|
// only visits plugins still returned by the API, so a newly-not-
|
||||||
// newly-not-ready plugin would otherwise keep its <link> applying.
|
// 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(
|
const styled = new Set(
|
||||||
currentPlugins
|
currentPlugins
|
||||||
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
|
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
|
||||||
.map((p) => p.id),
|
.map((p) => p.id),
|
||||||
);
|
);
|
||||||
for (const id of Array.from(loadedStyles.keys())) {
|
for (const id of Array.from(loadedStyles.keys())) {
|
||||||
if (!styled.has(id)) {
|
if (responded.has(id) && !styled.has(id)) {
|
||||||
_removePluginStyleTags(id);
|
_removePluginStyleTags(id);
|
||||||
loadedStyles.delete(id);
|
loadedStyles.delete(id);
|
||||||
}
|
}
|
||||||
@@ -11146,6 +11160,18 @@ async function loadPlugins() {
|
|||||||
if (pid) existingSettingsByPluginId.set(pid, child);
|
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();
|
const alreadyHydrated = new Set();
|
||||||
for (const p of plugins) {
|
for (const p of plugins) {
|
||||||
if (!p.has_script) continue;
|
if (!p.has_script) continue;
|
||||||
@@ -11173,7 +11199,10 @@ async function loadPlugins() {
|
|||||||
for (const container of _pluginSettingsContainers()) {
|
for (const container of _pluginSettingsContainers()) {
|
||||||
[...container.children].forEach((el) => {
|
[...container.children].forEach((el) => {
|
||||||
const pid = el.dataset ? el.dataset.pluginId : null;
|
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) => {
|
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
|
||||||
@@ -11182,7 +11211,7 @@ async function loadPlugins() {
|
|||||||
// change shipped — both forms strip a single leading "plugin-".
|
// change shipped — both forms strip a single leading "plugin-".
|
||||||
const pid = (el.dataset && el.dataset.pluginId)
|
const pid = (el.dataset && el.dataset.pluginId)
|
||||||
|| el.id.replace(/^plugin-/, '');
|
|| 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
|
// 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">' +
|
'<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>' +
|
'<h2 class="text-3xl font-bold text-fb-text truncate">' + (isAlbum ? '💿 ' : '') + esc(pl.name) + '</h2>' +
|
||||||
'<div class="flex gap-2 shrink-0 items-center">' +
|
'<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 ? '' :
|
(isSystem ? '' :
|
||||||
'<button id="v3-pl-cover" class="text-sm text-fb-textDim hover:text-fb-text px-2">Cover</button>' +
|
'<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>' : '') +
|
(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>') +
|
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
|
||||||
'</div>';
|
'</div>';
|
||||||
root.querySelector('#v3-pl-back')?.addEventListener('click', renderPlaylists);
|
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
|
// 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
|
// 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
|
// without the queue, so the button always does something. An ALBUM plays
|
||||||
@@ -244,8 +269,8 @@
|
|||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
if (window.feedBack && window.feedBack.playQueue) {
|
if (window.feedBack && window.feedBack.playQueue) {
|
||||||
window.feedBack.playQueue.start(files, isAlbum
|
window.feedBack.playQueue.start(files, isAlbum
|
||||||
? { source: pl.name, arrangements: arrs }
|
? { source: pl.name, arrangements: arrs, shuffle: shuffleOn() }
|
||||||
: { source: pl.name });
|
: { source: pl.name, shuffle: shuffleOn() });
|
||||||
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
|
} else if (typeof window.playSong === 'function') window.playSong(encodeURIComponent(files[0]));
|
||||||
});
|
});
|
||||||
const listEl = root.querySelector('#v3-pl-songs');
|
const listEl = root.querySelector('#v3-pl-songs');
|
||||||
|
|||||||
@@ -35,10 +35,11 @@ function buildFacade() {
|
|||||||
'return _hwcInstallFacade;',
|
'return _hwcInstallFacade;',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const params = [
|
const params = [
|
||||||
'window', 'HWC_SLOTS', 'console',
|
'window', 'HWC_SLOTS', 'HWC_PRESETS', 'console',
|
||||||
'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors',
|
'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors',
|
||||||
'_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape',
|
'_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape',
|
||||||
'applyHighwayStringColors', 'encodeHighwayColorShare', 'decodeHighwayColorShare',
|
'applyHighwayStringColors', 'applyHighwayStringPreset',
|
||||||
|
'encodeHighwayColorShare', 'decodeHighwayColorShare',
|
||||||
];
|
];
|
||||||
|
|
||||||
const listeners = {};
|
const listeners = {};
|
||||||
@@ -64,14 +65,19 @@ function buildFacade() {
|
|||||||
_hwcEffectiveIndexColors: (map, sc, isBass) => ['eff', sc, isBass],
|
_hwcEffectiveIndexColors: (map, sc, isBass) => ['eff', sc, isBass],
|
||||||
_hwcChartShape: () => ({ sc: 6, isBass: false }),
|
_hwcChartShape: () => ({ sc: 6, isBass: false }),
|
||||||
applyHighwayStringColors: (m) => { calls.push(['apply', m]); },
|
applyHighwayStringColors: (m) => { calls.push(['apply', m]); },
|
||||||
|
applyHighwayStringPreset: (id) => { calls.push(['preset', id]); return true; },
|
||||||
encodeHighwayColorShare: (n, m) => 'SLOPHWY2.CODE',
|
encodeHighwayColorShare: (n, m) => 'SLOPHWY2.CODE',
|
||||||
decodeHighwayColorShare: (c) => ({ name: 'x', colors: {} }),
|
decodeHighwayColorShare: (c) => ({ name: 'x', colors: {} }),
|
||||||
};
|
};
|
||||||
|
const HWC_PRESETS = [
|
||||||
|
{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } },
|
||||||
|
];
|
||||||
const installer = new Function(...params, body)(
|
const installer = new Function(...params, body)(
|
||||||
win, HWC_SLOTS, console,
|
win, HWC_SLOTS, HWC_PRESETS, console,
|
||||||
stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors,
|
stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors,
|
||||||
stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape,
|
stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape,
|
||||||
stubs.applyHighwayStringColors, stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
stubs.applyHighwayStringColors, stubs.applyHighwayStringPreset,
|
||||||
|
stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
||||||
);
|
);
|
||||||
installer();
|
installer();
|
||||||
return { api: win.feedBack.highwayColors, win, bus, calls, installer, stubs };
|
return { api: win.feedBack.highwayColors, win, bus, calls, installer, stubs };
|
||||||
@@ -87,11 +93,13 @@ test('facade exposes the documented surface', () => {
|
|||||||
const { api } = buildFacade();
|
const { api } = buildFacade();
|
||||||
assert.equal(api.version, 1);
|
assert.equal(api.version, 1);
|
||||||
for (const m of ['get', 'getDefaults', 'getResolved', 'keysForChart', 'toEffective',
|
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.equal(typeof api[m], 'function', `highwayColors.${m} must be a function`);
|
||||||
}
|
}
|
||||||
assert.deepEqual(api.slots.map((s) => s.key),
|
assert.deepEqual(api.slots.map((s) => s.key),
|
||||||
['highE', 'B', 'G', 'D', 'A', 'lowE', 'low7', 'low8'], 'slots in display order');
|
['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', () => {
|
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');
|
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||||
|
|
||||||
function source(file) {
|
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) {
|
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="external_hardware"/);
|
||||||
assert.match(html, /value="spark_control_x"/);
|
assert.match(html, /value="spark_control_x"/);
|
||||||
assert.match(html, /Live guitar tone source/);
|
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', () => {
|
test('player audio rail exposes tone source select', () => {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ function loadFunctions(sandbox, src) {
|
|||||||
sectionPracticeModeCalls.push({ on, opts: opts || {} });
|
sectionPracticeModeCalls.push({ on, opts: opts || {} });
|
||||||
}
|
}
|
||||||
function _updateSectionPracticeHighlight(ct) {}
|
function _updateSectionPracticeHighlight(ct) {}
|
||||||
|
function _updateEditRegionBtn() {}
|
||||||
${extractFunction(src, 'function clearLoop(')}
|
${extractFunction(src, 'function clearLoop(')}
|
||||||
${extractFunction(src, 'function _syncSavedLoopSelection()')}
|
${extractFunction(src, 'function _syncSavedLoopSelection()')}
|
||||||
${extractFunction(src, 'async function setLoop(')}
|
${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']);
|
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();
|
const { inject, reconcile, headLinks } = setupSandbox();
|
||||||
inject(plug({ id: 'a' }));
|
inject(plug({ id: 'a' }));
|
||||||
inject(plug({ id: 'b' }));
|
inject(plug({ id: 'b' }));
|
||||||
assert.equal(headLinks.length, 2);
|
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' })]);
|
reconcile([plug({ id: 'b' })]);
|
||||||
assert.equal(headLinks.length, 1);
|
assert.equal(headLinks.length, 2);
|
||||||
assert.equal(headLinks[0].dataset.pluginId, 'b');
|
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reconcile removes the <link> of a plugin that is no longer ready', () => {
|
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.__seekCalls = 0;
|
||||||
globalThis.__playSongCalls = 0;
|
globalThis.__playSongCalls = 0;
|
||||||
globalThis.__clearLoopCalls = 0;
|
globalThis.__clearLoopCalls = 0;
|
||||||
|
globalThis.__queueClearCalls = 0;
|
||||||
globalThis.__audioCurrentTimeSets = [];
|
globalThis.__audioCurrentTimeSets = [];
|
||||||
|
// closeCurrentSong abandons any play-queue before leaving the player.
|
||||||
|
var window = { feedBack: { playQueue: { clear() { globalThis.__queueClearCalls++; } } } };
|
||||||
var audio = {
|
var audio = {
|
||||||
_t: 42,
|
_t: 42,
|
||||||
get currentTime() { return this._t; },
|
get currentTime() { return this._t; },
|
||||||
@@ -75,6 +78,7 @@ test('closeCurrentSong uses _playerOriginScreen when set', async () => {
|
|||||||
await sandbox.__closeCurrentSong();
|
await sandbox.__closeCurrentSong();
|
||||||
assert.equal(sandbox.__showScreenCalls.length, 1);
|
assert.equal(sandbox.__showScreenCalls.length, 1);
|
||||||
assert.equal(sandbox.__showScreenCalls[0], 'favorites');
|
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.__restartCalls, 0);
|
||||||
assert.equal(sandbox.__seekCalls, 0);
|
assert.equal(sandbox.__seekCalls, 0);
|
||||||
assert.equal(sandbox.__playSongCalls, 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)', () => {
|
test('the shelf is the server-side practice-suggestions recommender', () => {
|
||||||
assert.match(src, /\/api\/stats\/recent\?limit=/);
|
// The old client-side pipeline (fetch /api/stats/recent, dedupe by
|
||||||
// Mastery is gated on the per-SONG best (state.accuracy, what the badge
|
// filename, gate on state.accuracy) moved server-side: the growth-edge
|
||||||
// shows), not the per-arrangement recents row, and each filename appears
|
// recommender gates (not-mastered) + aggregates per song and picks the
|
||||||
// once — so no green-badged "keep practicing" card and no duplicates.
|
// 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(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/,
|
/data-arr="[\s\S]*?getAttribute\('data-arr'\)[\s\S]*?playSong\(enc\(fn\), arr === '' \? undefined : Number\(arr\)\)/,
|
||||||
'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY',
|
'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', () => {
|
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');
|
'the two reads must be issued together (Promise.all), not sequentially');
|
||||||
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
|
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
|
||||||
'a stale render must be superseded by a newer one via a token');
|
'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', () => {
|
test('v3 songs.js uses display helpers for album-art tuning badge', () => {
|
||||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
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, /displayTuningTargets/);
|
||||||
assert.match(src, /parseRawTuningOffsets/);
|
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)
|
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 ────────────────────────────────────────────────────────────
|
# ── query building ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_build_recording_query_denoises_and_quotes():
|
def test_build_recording_query_denoises_and_quotes():
|
||||||
q = m.build_recording_query("ACDC", 'Thunderstruck (v2)')
|
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():
|
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
|
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():
|
def test_parse_recording_doc_joined_artist_credit():
|
||||||
doc = dict(MB_DOC)
|
doc = dict(MB_DOC)
|
||||||
doc["artist-credit"] = [
|
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
|
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")
|
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "const livePluginIds = new Set(plugins.map((plugin) => plugin.id))" in source
|
# The absence-triggered sweep is gone (rationale comment in its place)...
|
||||||
assert "for (const [pluginId, contributions] of _pluginUiContributions)" in source
|
assert "const livePluginIds" not in source
|
||||||
assert "const stalePlugin = { id: pluginId }" in source
|
assert "const stalePlugin = { id: pluginId }" not in source
|
||||||
assert "await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution)" in source
|
assert "deliberately NO stale-contribution sweep" in source
|
||||||
assert "window.feedBack?.capabilities?.unregisterParticipant?.(pluginId)" in source
|
# ...and the DOM/style reconcilers only act on plugins the response names.
|
||||||
assert "_pluginUiContributions.delete(pluginId)" in source
|
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