mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 15:38:31 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c4f16412 | ||
|
|
6ab1ed95c9 | ||
|
|
7c873f5cc2 | ||
|
|
68e29a8b6e | ||
|
|
d2b2a7e9f7 | ||
|
|
b6442dda75 | ||
|
|
d27cbe78ba | ||
|
|
803bd0cdf3 | ||
|
|
9456790083 | ||
|
|
286c59707b |
@@ -35,9 +35,9 @@ jobs:
|
||||
# stable releases (no pre-release suffix).
|
||||
{
|
||||
echo "tags<<TAGS_EOF"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY}:${version}"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY,,}:${version}"
|
||||
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY}:latest"
|
||||
echo "ghcr.io/${GITHUB_REPOSITORY,,}:latest"
|
||||
fi
|
||||
echo "TAGS_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# fee[dB]ack
|
||||
|
||||
## Plugins
|
||||
|
||||
| Plugin | Description | Install |
|
||||
|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||
| [Create from Tab](https://github.com/got-feedback/feedBack-plugin-ug) | Search Ultimate Guitar for GP tabs and convert to playable songs | `git clone ...feedBack-plugin-ug.git ultimate_guitar` |
|
||||
| [Import Tab](https://github.com/got-feedback/feedBack-plugin-tabimport) | Drag and drop Guitar Pro files to create songs | `git clone ...feedBack-plugin-tabimport.git tab_import` |
|
||||
| [Practice Journal](https://github.com/got-feedback/feedBack-plugin-practice) | Auto-track practice time, speed, loops. Dashboard with charts | `git clone ...feedBack-plugin-practice.git practice_journal` |
|
||||
| [Setlist Builder](https://github.com/got-feedback/feedBack-plugin-setlist) | Create ordered playlists with sequential playback | `git clone ...feedBack-plugin-setlist.git setlist` |
|
||||
| [Metronome](https://github.com/got-feedback/feedBack-plugin-metronome) | Audible click and visual beat flash synced to song tempo | `git clone ...feedBack-plugin-metronome.git metronome` |
|
||||
| [Tone Player](https://github.com/got-feedback/feedBack-plugin-tones) | View amp/pedal/cab signal chains with gear artwork | `git clone ...feedBack-plugin-tones.git tones` |
|
||||
| [Fretboard View](https://github.com/got-feedback/feedBack-plugin-fretboard) | Live fretboard overlay showing active notes in real-time | `git clone ...feedBack-plugin-fretboard.git fretboard` |
|
||||
| [Tab View](https://github.com/got-feedback/feedBack-plugin-tabview) | Scrolling guitar tablature notation via alphaTab | `git clone ...feedBack-plugin-tabview.git tab_view` |
|
||||
| [MIDI Amp Control](https://github.com/got-feedback/feedBack-plugin-midi) | Auto-switch amp/modeler presets via MIDI on tone changes | `git clone ...feedBack-plugin-midi.git midi_amp` |
|
||||
| [Section Map](https://github.com/got-feedback/feedBack-plugin-sectionmap) | Color-coded song structure minimap with clickable navigation | `git clone ...feedBack-plugin-sectionmap.git section_map` |
|
||||
| [Arrangement Editor](https://github.com/got-feedback/feedBack-plugin-editor) | DAW-like visual editor for creating and editing song note charts | `git clone ...feedBack-plugin-editor.git editor` |
|
||||
| [MIDI Capo](https://github.com/masc0t/slopsmith-plugin-midi-capo) | MIDI capo control for real-time transposition | `git clone ...slopsmith-plugin-midi-capo.git midi_capo` |
|
||||
| [Note Detection](https://github.com/got-feedback/feedBack-plugin-notedetect) | Real-time pitch detection and scoring against highway notes | `git clone ...feedBack-plugin-notedetect.git note_detect` |
|
||||
| [Find More](https://github.com/masc0t/slopsmith-plugin-find-more) | Search for more songs by the same artist | `git clone ...slopsmith-plugin-find-more.git find_more` |
|
||||
| [Piano Highway](https://github.com/got-feedback/feedBack-plugin-piano) | Scrolling piano/keyboard view for Keys arrangements with MIDI input | `git clone ...feedBack-plugin-piano.git piano` |
|
||||
| [Studio](https://github.com/got-feedback/feedBack-plugin-studio) | Collaborative band recording and multi-track mixing | `git clone ...feedBack-plugin-studio.git studio` |
|
||||
| [Drum Highway](https://github.com/got-feedback/feedBack-plugin-drums) | Lane-based drum highway with MIDI drum pad input and built-in sounds | `git clone ...feedBack-plugin-drums.git drums` |
|
||||
| [Invert Highway](https://github.com/masc0t/slopsmith-plugin-invert-highway) | Flip the highway note direction | `git clone ...slopsmith-plugin-invert-highway.git invert_highway` |
|
||||
| [Jumping Tab](https://github.com/renanboni/slopsmith-plugin-jumpingtab) | Yousician-style 2D horizontal tab with trajectory arcs and hopping ball | `git clone ...slopsmith-plugin-jumpingtab.git jumpingtab` |
|
||||
| [Step Mode](https://github.com/got-feedback/feedBack-plugin-stepmode) | Step-by-step practice mode — highway freezes at each note until played (via Note Detection) or Space | `git clone ...feedBack-plugin-stepmode.git step_mode` |
|
||||
| [Lyrics Sync](https://github.com/got-feedback/feedBack-plugin-lyrics-sync) | Generate synced LRC lyrics from text + vocals stem via Whisper alignment | `git clone ...feedBack-plugin-lyrics-sync.git lyrics_sync` |
|
||||
| [Lyrics Karaoke](https://github.com/got-feedback/feedBack-plugin-lyrics-karaoke) | Per-syllable karaoke pitch ribbon for sloppak songs (Whisper alignment + librosa pYIN) | `git clone ...feedBack-plugin-lyrics-karaoke.git lyrics_karaoke` |
|
||||
| [NAM Tone Engine](https://github.com/got-feedback/feedBack-plugin-nam-tone) | In-browser amp modeling with NAM WASM, cabinet IRs, tone auto-switching | `git clone ...feedBack-plugin-nam-tone.git nam_tone` |
|
||||
| [Guitar Theory Lab](https://github.com/topkoa/slopsmith-plugin-guitar-theory) | Explore scales, chords, intervals, tunings, and voicings on a fully interactive fretboard | `git clone ...slopsmith-plugin-guitar-theory.git guitar-theory-lab` |
|
||||
| [Themes](https://github.com/masc0t/slopsmith-plugin-themes) | Offers several basic recolorings of the interface | `git clone ...slopsmith-plugin-themes.git themes` |
|
||||
| [Update Manager](https://github.com/masc0t/slopsmith-update-manager) | Installs, updates, and uninstalls other plugins and the feedBack core itself | `git clone ...slopsmith-update-manager.git update_manager` |
|
||||
| [Simplify Chords](https://github.com/bkranendonk/slopsmith-plugin-simplify-chords) | Changes complex chords on the note highway to simpler ones. Inspired by Ultimate Guitar's Simplify button. | `git clone ...slopsmith-plugin-simplify-chords.git simplify-chords` |
|
||||
| [Key Bindings](https://github.com/jackipicco/slopsmith-plugin-key-bindings) | Highway key bindings for keyboard and TV remote | `git clone ...slopsmith-plugin-key-bindings.git key_bindings` |
|
||||
| [Virtuoso](https://github.com/got-feedback/feedBack-plugin-virtuoso) | Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen. | `git clone ...feedBack-plugin-virtuoso.git virtuoso` |
|
||||
| [Audio Preview](https://github.com/saleemk/slopsmith-plugin-audio-preview) | Quick audio previews from library cards with configurable start time, volume, and duration | `git clone ...slopsmith-plugin-audio-preview.git audio_preview` |
|
||||
| [Song Mastery](https://github.com/jamesgaiser/slopsmith-plugin-song-mastery) | Auto-adjusts difficulty based on your rolling note accuracy and saves the slider position per song | `git clone ...slopsmith-plugin-song-mastery.git song_mastery` |
|
||||
| [Mobile Note Highway](https://github.com/saleemk/slopsmith-plugin-mobile-note-highway) | Touch-optimized player with collapsible controls, highway gestures, and device-adaptive layouts for phones and tablets | `git clone ...slopsmith-plugin-mobile-note-highway.git mobile_note_highway` |
|
||||
|
||||
Install any plugin by cloning it into your `plugins/` directory and restarting:
|
||||
|
||||
```bash
|
||||
cd plugins
|
||||
git clone https://github.com/got-feedback/feedBack-plugin-ug.git ultimate_guitar
|
||||
docker compose restart
|
||||
```
|
||||
Executable
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -34,7 +34,7 @@ _QSTRING_SECRET_RE = re.compile(
|
||||
r"(?i)\b(api[_-]?key|key|token|secret|password|pwd|auth)=([^\s&\"']+)"
|
||||
)
|
||||
_SONG_FILENAME_RE = re.compile(
|
||||
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|wem|ogg|mp3|wav)\b",
|
||||
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|feedpak|wem|ogg|mp3|wav)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
import math
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
@@ -5576,13 +5577,217 @@ def _get_progression_content() -> dict:
|
||||
return _progression_content
|
||||
|
||||
|
||||
def _copy_builtin_packs(
|
||||
root: Path,
|
||||
dest_dir: Path,
|
||||
sources: list[tuple[str, str]],
|
||||
label: str,
|
||||
update_existing: bool = True,
|
||||
) -> int:
|
||||
"""Symlink-safe, mtime-aware copy of bundled packs into ``dest_dir``.
|
||||
|
||||
``sources`` is a list of ``(dest_name, rel_source)`` pairs; each source is
|
||||
resolved under ``root`` (the repo root in dev, ``resources/feedBack`` when
|
||||
bundled). A pack is copied when its destination is missing. Never deletes
|
||||
user files; refuses to follow a symlinked seed directory or destination and
|
||||
refuses to clobber a non-regular destination (any would let a copy escape
|
||||
``dest_dir`` or destroy user data). Logs and continues on error. ``label``
|
||||
prefixes every log line.
|
||||
|
||||
``update_existing`` controls what happens when a *regular* destination file
|
||||
already exists: when True (diagnostic seed) a bundle copy newer than the
|
||||
destination refreshes it; when False (one-time starter content) an existing
|
||||
file is always left as-is so the user's copy is never overwritten.
|
||||
|
||||
Returns the number of ``sources`` that are present at their destination
|
||||
afterwards (freshly seeded, refreshed, or already current) — so callers can
|
||||
tell whether every pack made it. A skip (missing source, symlink/non-regular
|
||||
refusal, copy error) does not count.
|
||||
"""
|
||||
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept it
|
||||
# and copies would land at the link target, outside the DLC tree. The
|
||||
# per-file symlink guard below cannot catch this.
|
||||
if dest_dir.is_symlink():
|
||||
log.warning("%s: %s is a symlink, skipping all seeding", label, dest_dir.name)
|
||||
return 0
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Pin the seed directory by an O_NOFOLLOW fd so a symlink swapped in for
|
||||
# dest_dir *after* the check above cannot redirect the per-file stat /
|
||||
# temp-create / replace outside the DLC tree (parent-directory TOCTOU).
|
||||
# os.replace accepts dir_fd on POSIX even though it isn't listed in
|
||||
# os.supports_dir_fd, so gate on os.rename (the reliable proxy); platforms
|
||||
# without dir_fd/O_NOFOLLOW (e.g. Windows) fall back to path-based ops.
|
||||
dir_fd = None
|
||||
if (
|
||||
hasattr(os, "O_NOFOLLOW")
|
||||
and hasattr(os, "O_DIRECTORY")
|
||||
and os.open in os.supports_dir_fd
|
||||
and os.rename in os.supports_dir_fd
|
||||
):
|
||||
try:
|
||||
dir_fd = os.open(dest_dir, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot open seed dir %s: %s", label, dest_dir, exc)
|
||||
return 0
|
||||
|
||||
try:
|
||||
present = 0
|
||||
for dest_name, rel_source in sources:
|
||||
source = root / rel_source
|
||||
if not source.is_file():
|
||||
log.warning("%s: source missing, skipping %s (%s)", label, dest_name, source)
|
||||
continue
|
||||
|
||||
# lstat the destination without following symlinks. Pinned by dir_fd
|
||||
# this resolves within the real seed dir, immune to a parent swap.
|
||||
try:
|
||||
if dir_fd is not None:
|
||||
dstat = os.lstat(dest_name, dir_fd=dir_fd)
|
||||
else:
|
||||
dstat = os.lstat(dest_dir / dest_name)
|
||||
dest_exists = True
|
||||
dest_islink = stat.S_ISLNK(dstat.st_mode)
|
||||
except FileNotFoundError:
|
||||
dest_exists = False
|
||||
dest_islink = False
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot stat %s: %s", label, dest_name, exc)
|
||||
continue
|
||||
|
||||
# Refuse to seed through a symlink at the destination name.
|
||||
if dest_islink:
|
||||
log.warning("%s: destination is a symlink, skipping %s", label, dest_name)
|
||||
continue
|
||||
|
||||
# A non-regular destination (directory, fifo, …) the user placed
|
||||
# there: never clobber it, and never count it as present — otherwise
|
||||
# a one-time seed would mark itself done without a real pack on disk.
|
||||
if dest_exists and not stat.S_ISREG(dstat.st_mode):
|
||||
log.warning("%s: destination is not a regular file, skipping %s", label, dest_name)
|
||||
continue
|
||||
|
||||
if dest_exists:
|
||||
# A regular file is already there. One-time seeds (starter
|
||||
# content) must never overwrite the user's copy; refreshing
|
||||
# seeds (diagnostics) replace it only when the bundle is newer.
|
||||
if not update_existing:
|
||||
log.info("%s: already present %s", label, dest_name)
|
||||
present += 1
|
||||
continue
|
||||
try:
|
||||
src_mtime = source.stat().st_mtime
|
||||
except OSError as exc:
|
||||
log.warning("%s: cannot stat source %s: %s", label, source, exc)
|
||||
continue
|
||||
if src_mtime <= dstat.st_mtime:
|
||||
log.info("%s: already present %s", label, dest_name)
|
||||
present += 1
|
||||
continue
|
||||
action = "updated"
|
||||
else:
|
||||
action = "seeded"
|
||||
|
||||
if _write_builtin_pack(source, dest_dir, dest_name, dir_fd):
|
||||
present += 1
|
||||
log.info("%s: %s %s -> %s", label, action, source.name, dest_name)
|
||||
else:
|
||||
log.warning("%s: failed to copy %s -> %s/%s", label, source, dest_dir.name, dest_name)
|
||||
|
||||
return present
|
||||
finally:
|
||||
if dir_fd is not None:
|
||||
os.close(dir_fd)
|
||||
|
||||
|
||||
def _write_builtin_pack(
|
||||
source: Path,
|
||||
dest_dir: Path,
|
||||
dest_name: str,
|
||||
dir_fd: int | None,
|
||||
) -> bool:
|
||||
"""Atomically write ``source`` to ``dest_name`` inside ``dest_dir``.
|
||||
|
||||
Writes to a temp file then ``os.replace()``s onto the final name so a
|
||||
symlink raced in at the destination is overwritten (rename semantics), not
|
||||
followed, and a crash never leaves a half-written pack. When ``dir_fd`` is
|
||||
given, every step is anchored to that fd (O_NOFOLLOW temp create + dir_fd
|
||||
replace), closing the parent-directory TOCTOU; otherwise falls back to
|
||||
path-based temp+replace. Returns True on success. Never raises.
|
||||
"""
|
||||
# Unique per-attempt name (O_EXCL create) so a crash that orphans a temp
|
||||
# can't permanently block later seeds via an EEXIST collision.
|
||||
tmp_name = f".seed-{dest_name}.{os.getpid()}.{secrets.token_hex(4)}.tmp"
|
||||
try:
|
||||
src_stat = source.stat()
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack: cannot stat source %s: %s", source, exc)
|
||||
return False
|
||||
if dir_fd is not None:
|
||||
tmp_fd = None
|
||||
try:
|
||||
tmp_fd = os.open(
|
||||
tmp_name,
|
||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW,
|
||||
0o644,
|
||||
dir_fd=dir_fd,
|
||||
)
|
||||
with open(source, "rb") as sf, os.fdopen(tmp_fd, "wb") as tf:
|
||||
tmp_fd = None # fdopen now owns the descriptor
|
||||
shutil.copyfileobj(sf, tf)
|
||||
os.replace(tmp_name, dest_name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
||||
# Preserve the bundle mtime (copyfileobj doesn't) so the mtime-based
|
||||
# refresh check matches the shutil.copy2 fallback path. Best-effort.
|
||||
try:
|
||||
os.utime(
|
||||
dest_name,
|
||||
ns=(src_stat.st_atime_ns, src_stat.st_mtime_ns),
|
||||
dir_fd=dir_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack: could not set mtime on %s: %s", dest_name, exc)
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack write (dir_fd) failed for %s: %s", dest_name, exc)
|
||||
if tmp_fd is not None:
|
||||
try:
|
||||
os.close(tmp_fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(tmp_name, dir_fd=dir_fd)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
tmp = None
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".seed-", suffix=".tmp")
|
||||
os.close(fd)
|
||||
shutil.copy2(source, tmp)
|
||||
os.replace(tmp, dest_dir / dest_name)
|
||||
tmp = None
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.debug("builtin pack write failed for %s: %s", dest_name, exc)
|
||||
return False
|
||||
finally:
|
||||
if tmp is not None:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None:
|
||||
"""Copy bundled diagnostic sloppaks into DLC before library scan.
|
||||
|
||||
Creates ``DLC_DIR/diagnostics-builtin/`` and copies each bundled sloppak
|
||||
when the destination is missing or older than the repo/bundle source.
|
||||
Never deletes user files or touches manually copied paths (e.g.
|
||||
``diagnostics-test/``). Logs and continues on missing source or copy errors.
|
||||
``diagnostics-test/``). Re-seeds whenever the destination is missing so the
|
||||
diagnostic target is always available. Logs and continues on errors.
|
||||
"""
|
||||
try:
|
||||
if dlc is None:
|
||||
@@ -5590,86 +5795,108 @@ def _seed_builtin_diagnostic_sloppaks(dlc: Path | None = None) -> None:
|
||||
if dlc is None:
|
||||
log.debug("Builtin diagnostic seed: no DLC folder configured, skipping")
|
||||
return
|
||||
|
||||
root = _feedBack_server_root()
|
||||
dest_dir = dlc / _BUILTIN_DIAGNOSTIC_SUBDIR
|
||||
# Refuse a symlinked seed directory: mkdir(exist_ok=True) would accept
|
||||
# it and copies would land at the link target, outside the DLC tree.
|
||||
# The per-file is_symlink() guard below cannot catch this.
|
||||
if dest_dir.is_symlink():
|
||||
log.warning(
|
||||
"Builtin diagnostic seed: %s is a symlink, skipping all seeding",
|
||||
_BUILTIN_DIAGNOSTIC_SUBDIR,
|
||||
)
|
||||
return
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for dest_name, rel_source in _BUILTIN_DIAGNOSTIC_SOURCES:
|
||||
source = root / rel_source
|
||||
dest = dest_dir / dest_name
|
||||
|
||||
if not source.is_file():
|
||||
log.warning(
|
||||
"Builtin diagnostic seed: source missing, skipping %s (%s)",
|
||||
dest_name,
|
||||
source,
|
||||
)
|
||||
continue
|
||||
|
||||
# Refuse to seed through a symlink. is_file()/stat()/copy2 all
|
||||
# follow links, so a symlink planted at the destination would let
|
||||
# the copy redirect outside diagnostics-builtin/ and overwrite an
|
||||
# arbitrary file the server user can write. Skip and warn; never
|
||||
# touch the link or its target.
|
||||
if dest.is_symlink():
|
||||
log.warning(
|
||||
"Builtin diagnostic seed: destination is a symlink, skipping %s/%s",
|
||||
_BUILTIN_DIAGNOSTIC_SUBDIR,
|
||||
dest_name,
|
||||
)
|
||||
continue
|
||||
|
||||
if dest.is_file():
|
||||
try:
|
||||
if source.stat().st_mtime <= dest.stat().st_mtime:
|
||||
log.info(
|
||||
"Builtin diagnostic seed: already present %s/%s",
|
||||
_BUILTIN_DIAGNOSTIC_SUBDIR,
|
||||
dest_name,
|
||||
)
|
||||
continue
|
||||
action = "updated"
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"Builtin diagnostic seed: cannot compare %s and %s: %s",
|
||||
source,
|
||||
dest,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
else:
|
||||
action = "seeded"
|
||||
|
||||
try:
|
||||
shutil.copy2(source, dest)
|
||||
log.info(
|
||||
"Builtin diagnostic seed: %s %s -> %s/%s",
|
||||
action,
|
||||
source.name,
|
||||
_BUILTIN_DIAGNOSTIC_SUBDIR,
|
||||
dest_name,
|
||||
)
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"Builtin diagnostic seed: failed to copy %s -> %s: %s",
|
||||
source,
|
||||
dest,
|
||||
exc,
|
||||
)
|
||||
_copy_builtin_packs(
|
||||
_feedBack_server_root(),
|
||||
dlc / _BUILTIN_DIAGNOSTIC_SUBDIR,
|
||||
_BUILTIN_DIAGNOSTIC_SOURCES,
|
||||
"Builtin diagnostic seed",
|
||||
)
|
||||
except Exception:
|
||||
log.warning("Builtin diagnostic seed: unexpected error", exc_info=True)
|
||||
|
||||
|
||||
# Starter content: bundled songs copied into ``DLC_DIR/starter/`` exactly ONCE,
|
||||
# on first run, as a welcome library so a fresh install isn't empty. Unlike the
|
||||
# diagnostic seed this is one-time — guarded by a marker in CONFIG_DIR — so if
|
||||
# the user deletes the starter song it stays gone. ``starter/`` is NOT in the
|
||||
# library scan carve-out (unlike diagnostics-builtin/ / tutorials-builtin/), so
|
||||
# seeded packs surface as ordinary library songs.
|
||||
_BUILTIN_STARTER_SUBDIR = "starter"
|
||||
_BUILTIN_STARTER_SOURCES: list[tuple[str, str]] = [
|
||||
(
|
||||
"beethoven-fur_elise.feedpak",
|
||||
"content/starter/beethoven-fur_elise.feedpak",
|
||||
),
|
||||
(
|
||||
"star_spangled_banner.feedpak",
|
||||
"content/starter/star_spangled_banner.feedpak",
|
||||
),
|
||||
(
|
||||
"beethoven-ode_to_joy.feedpak",
|
||||
"content/starter/beethoven-ode_to_joy.feedpak",
|
||||
),
|
||||
]
|
||||
_STARTER_SEED_MARKER = ".starter-content-seeded"
|
||||
|
||||
|
||||
def _seed_builtin_starter_content(dlc: Path | None = None) -> None:
|
||||
"""Copy bundled starter songs into ``DLC_DIR/starter/`` exactly once.
|
||||
|
||||
Guarded by ``CONFIG_DIR/.starter-content-seeded``: the first run with a DLC
|
||||
folder configured seeds the packs and writes the marker; subsequent runs are
|
||||
no-ops, so a user who deletes the starter song does not get it back on the
|
||||
next launch. Symlink-safe; never deletes user files. Logs, never raises.
|
||||
"""
|
||||
try:
|
||||
marker = CONFIG_DIR / _STARTER_SEED_MARKER
|
||||
# Already seeded? The marker is a sentinel: any existing path there
|
||||
# (regular file, or a symlink/dir a user deliberately planted to opt
|
||||
# out) means "done" — lstat so we detect it without following a symlink.
|
||||
# Worst case of a planted marker is simply no starter content, never a
|
||||
# data write; the O_EXCL|O_NOFOLLOW create below refuses to write
|
||||
# *through* a symlink regardless.
|
||||
try:
|
||||
os.lstat(marker)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
log.warning("Starter content seed: cannot stat marker %s: %s", marker, exc)
|
||||
return
|
||||
if dlc is None:
|
||||
dlc = _get_dlc_dir()
|
||||
if dlc is None:
|
||||
# No DLC yet — leave the marker unwritten so we retry once a
|
||||
# library folder is configured.
|
||||
log.debug("Starter content seed: no DLC folder configured, skipping")
|
||||
return
|
||||
present = _copy_builtin_packs(
|
||||
_feedBack_server_root(),
|
||||
dlc / _BUILTIN_STARTER_SUBDIR,
|
||||
_BUILTIN_STARTER_SOURCES,
|
||||
"Starter content seed",
|
||||
update_existing=False,
|
||||
)
|
||||
# Only mark seeding complete once every starter pack is actually in
|
||||
# place. If a source was missing or a copy failed, leave the marker
|
||||
# unwritten so the next launch retries rather than permanently skipping.
|
||||
if present < len(_BUILTIN_STARTER_SOURCES):
|
||||
log.info(
|
||||
"Starter content seed: %d/%d packs present, will retry next launch",
|
||||
present,
|
||||
len(_BUILTIN_STARTER_SOURCES),
|
||||
)
|
||||
return
|
||||
# Record completion with an exclusive, no-follow create so a planted or
|
||||
# raced symlink at the marker path can't redirect the write outside
|
||||
# CONFIG_DIR. O_EXCL fails (EEXIST) on any existing path including a
|
||||
# symlink, so we never write through one.
|
||||
try:
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = os.open(marker, flags, 0o644)
|
||||
try:
|
||||
os.write(fd, b"1\n")
|
||||
finally:
|
||||
os.close(fd)
|
||||
except FileExistsError:
|
||||
pass # already marked (or a non-regular path is squatting) — fine
|
||||
except OSError as exc:
|
||||
log.warning("Starter content seed: could not write marker %s: %s", marker, exc)
|
||||
except Exception:
|
||||
log.warning("Starter content seed: unexpected error", exc_info=True)
|
||||
|
||||
|
||||
def _background_scan():
|
||||
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
|
||||
|
||||
@@ -5690,6 +5917,7 @@ def _background_scan():
|
||||
return
|
||||
|
||||
_seed_builtin_diagnostic_sloppaks(dlc)
|
||||
_seed_builtin_starter_content(dlc)
|
||||
|
||||
# Listing can fail on macOS without Full Disk Access, or on Docker if the
|
||||
# path isn't shared. Report the failure explicitly rather than silently
|
||||
|
||||
+42
-22
@@ -6195,7 +6195,7 @@ window.feedBack.on('song:ready', () => {
|
||||
setSpeed(pend.speed);
|
||||
}
|
||||
} catch (_) { /* speed restore is best-effort */ }
|
||||
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'resume'))
|
||||
Promise.resolve(_audioSeek(Math.max(0, Number(pend.position) || 0), 'session-resume'))
|
||||
.then(() => { if (_autoplayExitEnabled() && !isPlaying) return togglePlay(); })
|
||||
.catch((err) => console.warn('[app] resume failed:', err));
|
||||
});
|
||||
@@ -10977,20 +10977,19 @@ async function loadPlugins() {
|
||||
const nameDelta = String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
|
||||
return nameDelta || String(a.id || '').localeCompare(String(b.id || ''));
|
||||
});
|
||||
const livePluginIds = new Set(plugins.map((plugin) => plugin.id));
|
||||
for (const [pluginId, contributions] of _pluginUiContributions) {
|
||||
if (livePluginIds.has(pluginId)) continue;
|
||||
const stalePlugin = { id: pluginId };
|
||||
for (const contribution of contributions) {
|
||||
await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution);
|
||||
}
|
||||
try {
|
||||
window.feedBack?.capabilities?.unregisterParticipant?.(pluginId);
|
||||
} catch (e) {
|
||||
console.warn(`capability participant unregister failed for ${pluginId}:`, e);
|
||||
}
|
||||
_pluginUiContributions.delete(pluginId);
|
||||
}
|
||||
// NOTE deliberately NO stale-contribution sweep for plugins absent
|
||||
// from this response. Absent ≠ uninstalled: the backend clears its
|
||||
// plugin registry at the start of load_plugins() and repopulates it
|
||||
// incrementally while HTTP stays up, so every backend restart serves a
|
||||
// window of partial (even empty) responses. The old sweep unmounted UI
|
||||
// contributions and unregistered capability participants on mere
|
||||
// absence, permanently breaking still-loaded plugins — their scripts
|
||||
// don't re-run (loadedScripts guard below), so nothing ever
|
||||
// re-registered. A genuine mid-session uninstall now leaves the
|
||||
// (already-evaluated, un-unloadable) script's contributions in place
|
||||
// until reload; its nav entry still disappears because nav is rebuilt
|
||||
// from the response each round. Same invariant as the settings/screen
|
||||
// DOM wipe and _reconcilePluginStyles below.
|
||||
console.log('[feedBack] loadPlugins: got', plugins.length, 'plugins');
|
||||
|
||||
try {
|
||||
@@ -11132,17 +11131,23 @@ async function loadPlugins() {
|
||||
loadedStyles.set(plugin.id, wantedVersion);
|
||||
};
|
||||
const _reconcilePluginStyles = (currentPlugins) => {
|
||||
// Drop stylesheets for plugins that vanished from /api/plugins or are
|
||||
// no longer ready+styled this round. _injectPluginStyles below only
|
||||
// visits plugins still returned by the API, so an uninstalled or
|
||||
// newly-not-ready plugin would otherwise keep its <link> applying.
|
||||
// Drop stylesheets for plugins the response KNOWS about but that
|
||||
// are no longer ready+styled this round. _injectPluginStyles below
|
||||
// only visits plugins still returned by the API, so a newly-not-
|
||||
// ready or unstyled plugin would otherwise keep its <link>
|
||||
// applying. Plugins merely ABSENT from the response keep their
|
||||
// stylesheet — a transient partial response during a backend
|
||||
// restart is not an uninstall (same invariant as the screen/
|
||||
// settings wipe below), and stripping the <link> would leave a
|
||||
// still-loaded plugin visible but unstyled.
|
||||
const responded = new Set(currentPlugins.map((p) => p.id));
|
||||
const styled = new Set(
|
||||
currentPlugins
|
||||
.filter((p) => (p.status || 'ready') === 'ready' && p.has_styles && p.styles)
|
||||
.map((p) => p.id),
|
||||
);
|
||||
for (const id of Array.from(loadedStyles.keys())) {
|
||||
if (!styled.has(id)) {
|
||||
if (responded.has(id) && !styled.has(id)) {
|
||||
_removePluginStyleTags(id);
|
||||
loadedStyles.delete(id);
|
||||
}
|
||||
@@ -11155,6 +11160,18 @@ async function loadPlugins() {
|
||||
if (pid) existingSettingsByPluginId.set(pid, child);
|
||||
}
|
||||
}
|
||||
// Plugins named in THIS response. A plugin can be transiently absent
|
||||
// from /api/plugins — the backend clears its registry at the start of
|
||||
// load_plugins() and repopulates it incrementally while HTTP stays up,
|
||||
// so every backend restart serves a window of partial (even empty)
|
||||
// responses. The wipe loops below must never treat that absence as an
|
||||
// uninstall: stripping a still-loaded plugin's DOM while keeping its
|
||||
// loadedScripts entry made the NEXT refetch fail the DOM check and
|
||||
// re-evaluate its screen.js mid-session — which duplicated the desktop
|
||||
// audio_engine's native signal chain (its init re-ran against the
|
||||
// surviving engine chain). Absent plugins keep their DOM and script;
|
||||
// they're re-reconciled when they reappear in a later response.
|
||||
const respondedIds = new Set(plugins.map((p) => p.id));
|
||||
const alreadyHydrated = new Set();
|
||||
for (const p of plugins) {
|
||||
if (!p.has_script) continue;
|
||||
@@ -11182,7 +11199,10 @@ async function loadPlugins() {
|
||||
for (const container of _pluginSettingsContainers()) {
|
||||
[...container.children].forEach((el) => {
|
||||
const pid = el.dataset ? el.dataset.pluginId : null;
|
||||
if (!pid || !alreadyHydrated.has(pid)) el.remove();
|
||||
// Remove junk (no plugin id) and plugins the response KNOWS
|
||||
// about but that failed hydration; leave plugins absent from
|
||||
// the response untouched (see respondedIds above).
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.screen[id^="plugin-"]').forEach((el) => {
|
||||
@@ -11191,7 +11211,7 @@ async function loadPlugins() {
|
||||
// change shipped — both forms strip a single leading "plugin-".
|
||||
const pid = (el.dataset && el.dataset.pluginId)
|
||||
|| el.id.replace(/^plugin-/, '');
|
||||
if (!alreadyHydrated.has(pid)) el.remove();
|
||||
if (!pid || (respondedIds.has(pid) && !alreadyHydrated.has(pid))) el.remove();
|
||||
});
|
||||
|
||||
// Plugin settings area hosts both "Plugin Updates" and per-plugin
|
||||
|
||||
@@ -35,10 +35,11 @@ function buildFacade() {
|
||||
'return _hwcInstallFacade;',
|
||||
].join('\n');
|
||||
const params = [
|
||||
'window', 'HWC_SLOTS', 'console',
|
||||
'window', 'HWC_SLOTS', 'HWC_PRESETS', 'console',
|
||||
'getHighwayStringColors', 'getHighwayDefaultSlotColors', '_hwcMergedSlotColors',
|
||||
'_hwcSlotKeysForChart', '_hwcEffectiveIndexColors', '_hwcChartShape',
|
||||
'applyHighwayStringColors', 'encodeHighwayColorShare', 'decodeHighwayColorShare',
|
||||
'applyHighwayStringColors', 'applyHighwayStringPreset',
|
||||
'encodeHighwayColorShare', 'decodeHighwayColorShare',
|
||||
];
|
||||
|
||||
const listeners = {};
|
||||
@@ -64,14 +65,19 @@ function buildFacade() {
|
||||
_hwcEffectiveIndexColors: (map, sc, isBass) => ['eff', sc, isBass],
|
||||
_hwcChartShape: () => ({ sc: 6, isBass: false }),
|
||||
applyHighwayStringColors: (m) => { calls.push(['apply', m]); },
|
||||
applyHighwayStringPreset: (id) => { calls.push(['preset', id]); return true; },
|
||||
encodeHighwayColorShare: (n, m) => 'SLOPHWY2.CODE',
|
||||
decodeHighwayColorShare: (c) => ({ name: 'x', colors: {} }),
|
||||
};
|
||||
const HWC_PRESETS = [
|
||||
{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } },
|
||||
];
|
||||
const installer = new Function(...params, body)(
|
||||
win, HWC_SLOTS, console,
|
||||
win, HWC_SLOTS, HWC_PRESETS, console,
|
||||
stubs.getHighwayStringColors, stubs.getHighwayDefaultSlotColors, stubs._hwcMergedSlotColors,
|
||||
stubs._hwcSlotKeysForChart, stubs._hwcEffectiveIndexColors, stubs._hwcChartShape,
|
||||
stubs.applyHighwayStringColors, stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
||||
stubs.applyHighwayStringColors, stubs.applyHighwayStringPreset,
|
||||
stubs.encodeHighwayColorShare, stubs.decodeHighwayColorShare,
|
||||
);
|
||||
installer();
|
||||
return { api: win.feedBack.highwayColors, win, bus, calls, installer, stubs };
|
||||
@@ -87,11 +93,13 @@ test('facade exposes the documented surface', () => {
|
||||
const { api } = buildFacade();
|
||||
assert.equal(api.version, 1);
|
||||
for (const m of ['get', 'getDefaults', 'getResolved', 'keysForChart', 'toEffective',
|
||||
'getCurrent', 'apply', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
|
||||
'getCurrent', 'apply', 'applyPreset', 'encodeShare', 'decodeShare', 'onChange', 'offChange']) {
|
||||
assert.equal(typeof api[m], 'function', `highwayColors.${m} must be a function`);
|
||||
}
|
||||
assert.deepEqual(api.slots.map((s) => s.key),
|
||||
['highE', 'B', 'G', 'D', 'A', 'lowE', 'low7', 'low8'], 'slots in display order');
|
||||
// One-click presets: exposed as detached [{ id, label, colors }] copies.
|
||||
assert.deepEqual(api.presets, [{ id: 'stock', label: 'Stock', colors: { lowE: '#cc0000' } }]);
|
||||
});
|
||||
|
||||
test('facade read methods delegate to the manager', () => {
|
||||
|
||||
@@ -74,7 +74,10 @@ const APP_JS = path.join(ROOT, 'static', 'app.js');
|
||||
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
||||
|
||||
function source(file) {
|
||||
return fs.readFileSync(file, 'utf8');
|
||||
// Normalize CRLF: region() slices fixed CHARACTER windows, so on a
|
||||
// Windows checkout (autocrlf) every line costs one extra char and the
|
||||
// assertion target can fall outside the window.
|
||||
return fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
function region(src, needle, length = 1200) {
|
||||
|
||||
@@ -40,7 +40,9 @@ test('settings UI exposes tone source select with all options', () => {
|
||||
assert.match(html, /value="external_hardware"/);
|
||||
assert.match(html, /value="spark_control_x"/);
|
||||
assert.match(html, /Live guitar tone source/);
|
||||
assert.match(html, /won’t warn that no internal amp tone is loaded/);
|
||||
// Apostrophe form drifted from the ’ entity to the literal ’ in a
|
||||
// copy pass — accept entity, typographic, or plain apostrophe.
|
||||
assert.match(html, /won(?:’|’|')t warn that no internal amp tone is loaded/);
|
||||
});
|
||||
|
||||
test('player audio rail exposes tone source select', () => {
|
||||
|
||||
@@ -107,6 +107,7 @@ function loadFunctions(sandbox, src) {
|
||||
sectionPracticeModeCalls.push({ on, opts: opts || {} });
|
||||
}
|
||||
function _updateSectionPracticeHighlight(ct) {}
|
||||
function _updateEditRegionBtn() {}
|
||||
${extractFunction(src, 'function clearLoop(')}
|
||||
${extractFunction(src, 'function _syncSavedLoopSelection()')}
|
||||
${extractFunction(src, 'async function setLoop(')}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
|
||||
// merely ABSENT from the current /api/plugins response (transient partial
|
||||
// response while the backend's plugin registry is repopulating after a
|
||||
// restart) must keep its settings panel and screen DOM. Wiping it while its
|
||||
// _loadedPluginScripts entry survives made the next refetch fail the
|
||||
// DOM-existence check and re-evaluate the plugin's screen.js mid-session —
|
||||
// which duplicated the desktop audio_engine's native signal chain. Plugins
|
||||
// the response knows about but that failed hydration are still wiped, as is
|
||||
// junk DOM carrying no plugin id.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
|
||||
// nav reset that opens it to the comment introducing the next section.
|
||||
function extractWipeBlock(src) {
|
||||
const start = src.indexOf("navContainer.innerHTML = '';");
|
||||
assert.ok(start !== -1, 'wipe block start (nav reset) not found');
|
||||
const end = src.indexOf('// Plugin settings area hosts', start);
|
||||
assert.ok(end !== -1, 'wipe block end marker not found');
|
||||
return src.slice(start, end);
|
||||
}
|
||||
|
||||
function makeEl(pluginId, id) {
|
||||
return {
|
||||
dataset: pluginId != null ? { pluginId } : {},
|
||||
id: id || (pluginId != null ? `plugin-${pluginId}` : ''),
|
||||
removed: false,
|
||||
remove() {
|
||||
this.removed = true;
|
||||
const idx = this._parent ? this._parent.indexOf(this) : -1;
|
||||
if (idx >= 0) this._parent.splice(idx, 1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const block = extractWipeBlock(src);
|
||||
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
|
||||
const container = { children: settingsChildren };
|
||||
const sandbox = {
|
||||
navContainer: { innerHTML: 'seed' },
|
||||
mobileNavContainer: { innerHTML: 'seed' },
|
||||
_pluginSettingsContainers: () => [container],
|
||||
respondedIds,
|
||||
alreadyHydrated,
|
||||
document: {
|
||||
querySelectorAll: (sel) => {
|
||||
assert.equal(sel, '.screen[id^="plugin-"]');
|
||||
return screens.slice();
|
||||
},
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(block, sandbox, { filename: 'wipe-block.js' });
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('plugin absent from the response keeps its settings + screen DOM', () => {
|
||||
const settings = makeEl('audio_engine');
|
||||
const screen = makeEl('audio_engine');
|
||||
runWipe({
|
||||
respondedIds: new Set(), // partial response: plugin missing
|
||||
alreadyHydrated: new Set(), // scan loop never saw it either
|
||||
settingsChildren: [settings],
|
||||
screens: [screen],
|
||||
});
|
||||
assert.equal(settings.removed, false, 'settings panel must survive a partial response');
|
||||
assert.equal(screen.removed, false, 'screen must survive a partial response');
|
||||
});
|
||||
|
||||
test('plugin present in the response but not hydrated is wiped', () => {
|
||||
const settings = makeEl('stale_plugin');
|
||||
const screen = makeEl('stale_plugin');
|
||||
runWipe({
|
||||
respondedIds: new Set(['stale_plugin']),
|
||||
alreadyHydrated: new Set(),
|
||||
settingsChildren: [settings],
|
||||
screens: [screen],
|
||||
});
|
||||
assert.equal(settings.removed, true);
|
||||
assert.equal(screen.removed, true);
|
||||
});
|
||||
|
||||
test('hydrated plugin present in the response is preserved', () => {
|
||||
const settings = makeEl('audio_engine');
|
||||
const screen = makeEl('audio_engine');
|
||||
runWipe({
|
||||
respondedIds: new Set(['audio_engine']),
|
||||
alreadyHydrated: new Set(['audio_engine']),
|
||||
settingsChildren: [settings],
|
||||
screens: [screen],
|
||||
});
|
||||
assert.equal(settings.removed, false);
|
||||
assert.equal(screen.removed, false);
|
||||
});
|
||||
|
||||
test('junk DOM without a plugin id is still removed', () => {
|
||||
const junkSettings = makeEl(null);
|
||||
// Screen whose id strips to '' (no dataset.pluginId, bare "plugin-" id).
|
||||
const junkScreen = makeEl(null, 'plugin-');
|
||||
runWipe({
|
||||
respondedIds: new Set(['whatever']),
|
||||
alreadyHydrated: new Set(),
|
||||
settingsChildren: [junkSettings],
|
||||
screens: [junkScreen],
|
||||
});
|
||||
assert.equal(junkSettings.removed, true);
|
||||
assert.equal(junkScreen.removed, true);
|
||||
});
|
||||
@@ -204,15 +204,20 @@ test('does not collide tags across two different plugins', () => {
|
||||
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('reconcile removes the <link> of a plugin that vanished from /api/plugins', () => {
|
||||
test('reconcile keeps the <link> of a plugin absent from a partial response', () => {
|
||||
const { inject, reconcile, headLinks } = setupSandbox();
|
||||
inject(plug({ id: 'a' }));
|
||||
inject(plug({ id: 'b' }));
|
||||
assert.equal(headLinks.length, 2);
|
||||
// `a` is no longer returned (uninstalled) — its stylesheet must be dropped.
|
||||
// `a` is missing from this response. That happens transiently during a
|
||||
// backend restart (the plugin registry repopulates while HTTP stays up),
|
||||
// so absence is NOT an uninstall signal — the still-loaded plugin must
|
||||
// keep its stylesheet or it renders visible-but-unstyled until it
|
||||
// reappears. Explicit removal still happens via the not-ready/unstyled
|
||||
// paths (tests below).
|
||||
reconcile([plug({ id: 'b' })]);
|
||||
assert.equal(headLinks.length, 1);
|
||||
assert.equal(headLinks[0].dataset.pluginId, 'b');
|
||||
assert.equal(headLinks.length, 2);
|
||||
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('reconcile removes the <link> of a plugin that is no longer ready', () => {
|
||||
|
||||
@@ -42,7 +42,10 @@ function loadClose(sandbox, src) {
|
||||
globalThis.__seekCalls = 0;
|
||||
globalThis.__playSongCalls = 0;
|
||||
globalThis.__clearLoopCalls = 0;
|
||||
globalThis.__queueClearCalls = 0;
|
||||
globalThis.__audioCurrentTimeSets = [];
|
||||
// closeCurrentSong abandons any play-queue before leaving the player.
|
||||
var window = { feedBack: { playQueue: { clear() { globalThis.__queueClearCalls++; } } } };
|
||||
var audio = {
|
||||
_t: 42,
|
||||
get currentTime() { return this._t; },
|
||||
@@ -75,6 +78,7 @@ test('closeCurrentSong uses _playerOriginScreen when set', async () => {
|
||||
await sandbox.__closeCurrentSong();
|
||||
assert.equal(sandbox.__showScreenCalls.length, 1);
|
||||
assert.equal(sandbox.__showScreenCalls[0], 'favorites');
|
||||
assert.equal(sandbox.__queueClearCalls, 1, 'a real close abandons the play-queue');
|
||||
assert.equal(sandbox.__restartCalls, 0);
|
||||
assert.equal(sandbox.__seekCalls, 0);
|
||||
assert.equal(sandbox.__playSongCalls, 0);
|
||||
|
||||
@@ -31,21 +31,23 @@ test('the home is the unfiltered grid front door, local provider only', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the shelf is recently-played, not-yet-mastered songs (per-song, deduped)', () => {
|
||||
assert.match(src, /\/api\/stats\/recent\?limit=/);
|
||||
// Mastery is gated on the per-SONG best (state.accuracy, what the badge
|
||||
// shows), not the per-arrangement recents row, and each filename appears
|
||||
// once — so no green-badged "keep practicing" card and no duplicates.
|
||||
test('the shelf is the server-side practice-suggestions recommender', () => {
|
||||
// The old client-side pipeline (fetch /api/stats/recent, dedupe by
|
||||
// filename, gate on state.accuracy) moved server-side: the growth-edge
|
||||
// recommender gates (not-mastered) + aggregates per song and picks the
|
||||
// arrangement closest to mastery. The client renders its rows as-is.
|
||||
assert.match(src, /\/api\/library\/practice-suggestions\?limit=/);
|
||||
// A shelf card click opens the row's recommended arrangement, not the
|
||||
// song's default.
|
||||
assert.match(
|
||||
src,
|
||||
/const\s+best\s*=\s*acc\[r\.filename\][\s\S]*?best\s*>=\s*MASTERY_ACCURACY/,
|
||||
'the shelf must gate on the per-song best (state.accuracy) at MASTERY_ACCURACY',
|
||||
/data-arr="[\s\S]*?getAttribute\('data-arr'\)[\s\S]*?playSong\(enc\(fn\), arr === '' \? undefined : Number\(arr\)\)/,
|
||||
'shelf cards must pass the recommended arrangement to playSong',
|
||||
);
|
||||
assert.match(src, /seen\.has\(r\.filename\)/, 'the shelf must dedupe recents by filename');
|
||||
});
|
||||
|
||||
test('the meter + shelf fetch together and a stale render is discarded', () => {
|
||||
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?stats\/recent/,
|
||||
assert.match(src, /Promise\.all\(\[[\s\S]*?library\/stats[\s\S]*?practice-suggestions/,
|
||||
'the two reads must be issued together (Promise.all), not sequentially');
|
||||
assert.match(src, /_homeToken[\s\S]*?_homeToken !== myToken/,
|
||||
'a stale render must be superseded by a newer one via a token');
|
||||
|
||||
@@ -64,7 +64,9 @@ const helpers = loadTuningHelpers();
|
||||
|
||||
test('v3 songs.js uses display helpers for album-art tuning badge', () => {
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
assert.match(src, /displayTuningName\(song\.tuning_name \|\| song\.tuning\)/);
|
||||
// The card renderer's row variable was renamed song → shown when grouped
|
||||
// cards landed (the badge reads the representative chart); accept either.
|
||||
assert.match(src, /displayTuningName\((?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning\)/);
|
||||
assert.match(src, /displayTuningTargets/);
|
||||
assert.match(src, /parseRawTuningOffsets/);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
|
||||
sys.modules.pop('routes', None)
|
||||
import routes as ach_routes
|
||||
|
||||
|
||||
@@ -26,3 +28,17 @@ def client(tmp_path):
|
||||
app = FastAPI()
|
||||
ach_routes.setup(app, {"config_dir": str(tmp_path)})
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_ach_routes():
|
||||
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these tests."""
|
||||
prev = sys.modules.get('routes')
|
||||
sys.modules['routes'] = ach_routes
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if prev is not None:
|
||||
sys.modules['routes'] = prev
|
||||
else:
|
||||
sys.modules.pop('routes', None)
|
||||
|
||||
@@ -5,6 +5,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'plugins' /
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
# Drop a sibling 'routes' cached by another plugin's tests (bare-name collision).
|
||||
sys.modules.pop('routes', None)
|
||||
import routes as tuner_routes
|
||||
|
||||
|
||||
@@ -22,3 +24,19 @@ def client(config_dir):
|
||||
"unregister_tuning_provider": lambda pid: None,
|
||||
})
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_tuner_routes():
|
||||
"""Keep sys.modules['routes'] pointing at THIS plugin's routes for these
|
||||
tests, so a runtime `import routes` in a test body resolves correctly
|
||||
regardless of which other plugin's bare-named routes ran first."""
|
||||
prev = sys.modules.get('routes')
|
||||
sys.modules['routes'] = tuner_routes
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if prev is not None:
|
||||
sys.modules['routes'] = prev
|
||||
else:
|
||||
sys.modules.pop('routes', None)
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -204,7 +204,7 @@ def test_client_audio_session_contribution_redacts_paths(tmp_path):
|
||||
kw["client_contributions"] = {
|
||||
"note_detect": {
|
||||
"schema": "feedBack.audio_session.diagnostics.v1",
|
||||
"session": {"sessionId": str(home_path / "DLC" / "private-song.archive")},
|
||||
"session": {"sessionId": str(home_path / "DLC" / "private-song.feedpak")},
|
||||
"domains": {"audio-input": {"sources": [{"label": str(home_path / "devices" / "raw-id")}]}},
|
||||
}
|
||||
}
|
||||
@@ -1541,7 +1541,7 @@ def test_console_error_object_args_are_redacted(tmp_path):
|
||||
kw = _basic_kwargs(tmp_path)
|
||||
kw["include"]["console"] = True
|
||||
kw["redact"] = True
|
||||
secret_path = "/home/alice/Music/DLC/my_song.archive"
|
||||
secret_path = "/home/alice/Music/DLC/my_song.feedpak"
|
||||
kw["client_console"] = [
|
||||
{
|
||||
"level": "error",
|
||||
@@ -1567,13 +1567,13 @@ def test_console_string_args_still_redacted(tmp_path):
|
||||
kw["include"]["console"] = True
|
||||
kw["redact"] = True
|
||||
kw["client_console"] = [
|
||||
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.archive ok"]},
|
||||
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.feedpak ok"]},
|
||||
]
|
||||
zip_bytes, _name, _m = db.build_bundle(**kw)
|
||||
with _open_zip(zip_bytes) as zf:
|
||||
console = json.loads(zf.read("client/console.json"))
|
||||
# The song filename should be replaced with a hash token, not appear verbatim.
|
||||
assert "my_song.archive" not in console["entries"][0]["args"][0]
|
||||
assert "my_song.feedpak" not in console["entries"][0]["args"][0]
|
||||
|
||||
|
||||
def test_console_non_string_non_dict_args_pass_through(tmp_path):
|
||||
|
||||
@@ -5,7 +5,7 @@ from diagnostics_redact import Redactor
|
||||
|
||||
def test_dlc_path_replaced():
|
||||
r = Redactor(dlc_dir=Path("/dlc/songs"))
|
||||
out = r.redact_text("loaded from /dlc/songs/foo.archive")
|
||||
out = r.redact_text("loaded from /dlc/songs/foo.feedpak")
|
||||
assert "<DLC_DIR>" in out
|
||||
assert "/dlc/songs" not in out
|
||||
assert r.counts["paths_replaced"] == 1
|
||||
@@ -13,8 +13,8 @@ def test_dlc_path_replaced():
|
||||
|
||||
def test_song_filename_redacted_consistently():
|
||||
r = Redactor()
|
||||
a = r.redact_text("Loading Test-Artist_Test-Song.archive")
|
||||
b = r.redact_text("Replaying Test-Artist_Test-Song.archive again")
|
||||
a = r.redact_text("Loading Test-Artist_Test-Song.feedpak")
|
||||
b = r.redact_text("Replaying Test-Artist_Test-Song.feedpak again")
|
||||
token_a = a.split("Loading ")[1].strip()
|
||||
token_b = b.split("Replaying ")[1].split(" ")[0]
|
||||
assert token_a == token_b
|
||||
@@ -63,8 +63,8 @@ def test_home_dir_replaced():
|
||||
def test_different_redactors_produce_different_tokens():
|
||||
a = Redactor()
|
||||
b = Redactor()
|
||||
out_a = a.redact_text("Foo.archive")
|
||||
out_b = b.redact_text("Foo.archive")
|
||||
out_a = a.redact_text("Foo.feedpak")
|
||||
out_b = b.redact_text("Foo.feedpak")
|
||||
assert out_a != out_b
|
||||
|
||||
|
||||
|
||||
@@ -38,15 +38,25 @@ def test_plugin_loader_unmounts_previous_ui_contributions_before_reregistering()
|
||||
assert "await _commandUiDomain(contribution.domain, 'mount', plugin, contribution)" in source
|
||||
|
||||
|
||||
def test_plugin_loader_unmounts_contributions_for_removed_plugins():
|
||||
def test_plugin_loader_does_not_treat_response_absence_as_uninstall():
|
||||
# A plugin transiently absent from /api/plugins (the backend clears its
|
||||
# registry at the start of load_plugins() and repopulates incrementally
|
||||
# while HTTP stays up, so restarts serve partial responses) must NOT be
|
||||
# torn down: the old absence sweep unmounted UI contributions and
|
||||
# unregistered the capability participant with no re-registration path
|
||||
# (plugin scripts don't re-run), and the DOM/style wipes forced a
|
||||
# mid-session screen.js re-evaluation that duplicated the desktop
|
||||
# audio_engine's native signal chain.
|
||||
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "const livePluginIds = new Set(plugins.map((plugin) => plugin.id))" in source
|
||||
assert "for (const [pluginId, contributions] of _pluginUiContributions)" in source
|
||||
assert "const stalePlugin = { id: pluginId }" in source
|
||||
assert "await _commandUiDomain(contribution.domain, 'unmount', stalePlugin, contribution)" in source
|
||||
assert "window.feedBack?.capabilities?.unregisterParticipant?.(pluginId)" in source
|
||||
assert "_pluginUiContributions.delete(pluginId)" in source
|
||||
# The absence-triggered sweep is gone (rationale comment in its place)...
|
||||
assert "const livePluginIds" not in source
|
||||
assert "const stalePlugin = { id: pluginId }" not in source
|
||||
assert "deliberately NO stale-contribution sweep" in source
|
||||
# ...and the DOM/style reconcilers only act on plugins the response names.
|
||||
assert "const respondedIds = new Set(plugins.map((p) => p.id))" in source
|
||||
assert "respondedIds.has(pid) && !alreadyHydrated.has(pid)" in source
|
||||
assert "responded.has(id) && !styled.has(id)" in source
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user