Purge external-format terminology from code, tests and docs

Reword comments/docstrings/strings and rename identifiers that referenced
the external game and its file formats:

- format-id "psarc" -> "archive"; local vars psarc_path -> song_path,
  psarc_base -> tone_base
- lyrics provenance value "sng" -> "notechart" (legacy "sng" still accepted)
- highway_3d fret-ghost scope value "rocksmith" -> "chords" (invalid/legacy
  values fall back to the default, preserving behaviour)
- neutralise references in prose, test names/data, .gitattributes and docs

No functional change beyond the renamed identifiers; all Python compiles.
This commit is contained in:
Sin
2026-06-16 19:36:53 +01:00
parent bc0e83c345
commit 4148b0e72e
49 changed files with 397 additions and 392 deletions
+3 -3
View File
@@ -11,6 +11,6 @@
# so LICENSE / VERSION stay diff-readable for future version bumps. # so LICENSE / VERSION stay diff-readable for future version bumps.
static/vendor/three/three.module.min.js binary static/vendor/three/three.module.min.js binary
# PSARC test fixtures are zlib-compressed binary archives — diffs and # Sloppak test fixtures are zip archives — diffs and autocrlf rewrites
# autocrlf rewrites would corrupt them. # would corrupt them.
tests/fixtures/*.psarc binary tests/fixtures/*.sloppak binary
+4 -4
View File
@@ -102,8 +102,8 @@ The fix is `context["load_sibling"](name)`, which loads the sibling under a name
```python ```python
def setup(app, context): def setup(app, context):
extractor = context["load_sibling"]("extractor") helper = context["load_sibling"]("helper")
PsarcReader = extractor.PsarcReader HelperClass = helper.HelperClass
# … # …
``` ```
@@ -340,9 +340,9 @@ Plugins that add a layer on top of whichever visualization is active — HUDs, f
- `highway.hasPhraseData()` — returns `true` when the current song has phrase-level difficulty ladder data (i.e. the mastery slider is active and `getFilteredNotes()` / `getFilteredChords()` return a filtered subset). Use this to gate logic that only makes sense when difficulty filtering is available - `highway.hasPhraseData()` — returns `true` when the current song has phrase-level difficulty ladder data (i.e. the mastery slider is active and `getFilteredNotes()` / `getFilteredChords()` return a filtered subset). Use this to gate logic that only makes sense when difficulty filtering is available
- `highway.getPhrases()` — phrase timing windows `[{ index, start_time, end_time, max_difficulty }]` for the current song's difficulty ladder. Returns `null` when phrase data is absent (GP imports, single-difficulty charts). Read-only; do not mutate. Pair with `hasPhraseData()` to gate phrase-aware logic. - `highway.getPhrases()` — phrase timing windows `[{ index, start_time, end_time, max_difficulty }]` for the current song's difficulty ladder. Returns `null` when phrase data is absent (GP imports, single-difficulty charts). Read-only; do not mutate. Pair with `hasPhraseData()` to gate phrase-aware logic.
- `highway.getMastery()` — current master-difficulty slider value as a fraction `0..1`. Reflects the same value the mastery slider is set to; meaningful only when `hasPhraseData()` is true. - `highway.getMastery()` — current master-difficulty slider value as a fraction `0..1`. Reflects the same value the mastery slider is set to; meaningful only when `hasPhraseData()` is true.
- `highway.getChordTemplates()` — chord shape lookup table; index by `chord.id` from `getChords()` to get `{ name, fingers, frets }`. `fingers` and `frets` are per-string arrays (length matches the tuning's string count); within `fingers`, `-1` = unused, `0` = open string, `n > 0` = finger number. RS XML sources populate real fingerings; GP imports currently emit all `-1` since pre-import sources don't carry finger data. Not filter-aware: templates are static metadata, every `chord_id` referenced by `getChords()` is guaranteed valid - `highway.getChordTemplates()` — chord shape lookup table; index by `chord.id` from `getChords()` to get `{ name, fingers, frets }`. `fingers` and `frets` are per-string arrays (length matches the tuning's string count); within `fingers`, `-1` = unused, `0` = open string, `n > 0` = finger number. arrangement XML sources populate real fingerings; GP imports currently emit all `-1` since pre-import sources don't carry finger data. Not filter-aware: templates are static metadata, every `chord_id` referenced by `getChords()` is guaranteed valid
- `highway.getSongInfo()` — tuning, arrangement, capo - `highway.getSongInfo()` — tuning, arrangement, capo
- `highway.getStringCount()` — number of strings on the active arrangement (4 for bass, 6 for guitar, 7+ for extended-range GP imports). Derived server-side as `max(notes-max-string + 1, name-based fallback, len(tuning))` where the tuning length only contributes when it isn't the RS-XML padded 6-string form (sloppak / GP-imported sources carry trimmed tuning lengths). The name-based fallback is 4 for arrangements containing "bass" (case-insensitive) and 6 otherwise. This combination handles partial-string-usage charts (a 6-string lead that never plays string 5), extended-range GP imports (5-string bass, 7-string guitar), and sloppaks that explicitly encode the instrument range — without requiring plugins to do their own arrangement-name matching - `highway.getStringCount()` — number of strings on the active arrangement (4 for bass, 6 for guitar, 7+ for extended-range GP imports). Derived server-side as `max(notes-max-string + 1, name-based fallback, len(tuning))` where the tuning length only contributes when it isn't the arrangement XML padded 6-string form (sloppak / GP-imported sources carry trimmed tuning lengths). The name-based fallback is 4 for arrangements containing "bass" (case-insensitive) and 6 otherwise. This combination handles partial-string-usage charts (a 6-string lead that never plays string 5), extended-range GP imports (5-string bass, 7-string guitar), and sloppaks that explicitly encode the instrument range — without requiring plugins to do their own arrangement-name matching
- `highway.getLefty()` / `highway.getInverted()` — mirror + invert state - `highway.getLefty()` / `highway.getInverted()` — mirror + invert state
Overlays do NOT appear in the viz picker and do NOT declare `"type": "visualization"` in `plugin.json`. They coexist with whichever renderer (default 2D, 3D highway, piano, ...) the user has picked. Overlays do NOT appear in the viz picker and do NOT declare `"type": "visualization"` in `plugin.json`. They coexist with whichever renderer (default 2D, 3D highway, piano, ...) the user has picked.
+1 -1
View File
@@ -1,4 +1,4 @@
"""Audio extraction and conversion for Rocksmith CDLC.""" """Audio extraction and conversion for the source game custom song."""
import logging import logging
import os import os
+1 -1
View File
@@ -34,7 +34,7 @@ _QSTRING_SECRET_RE = re.compile(
r"(?i)\b(api[_-]?key|key|token|secret|password|pwd|auth)=([^\s&\"']+)" r"(?i)\b(api[_-]?key|key|token|secret|password|pwd|auth)=([^\s&\"']+)"
) )
_SONG_FILENAME_RE = re.compile( _SONG_FILENAME_RE = re.compile(
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|wem|ogg|mp3|wav)\b", r"\b[\w()'\-+&,.!?\[\]]+\.(?:archive|sloppak|wem|ogg|mp3|wav)\b",
re.IGNORECASE, re.IGNORECASE,
) )
+16 -16
View File
@@ -1,4 +1,4 @@
"""Convert Guitar Pro files (.gp5/.gp4/.gp3) to Rocksmith 2014 arrangement XML.""" """Convert Guitar Pro files (.gp5/.gp4/.gp3) to the source game arrangement XML."""
import logging import logging
import re import re
@@ -18,9 +18,9 @@ def _extract_year(song: guitarpro.Song) -> str:
"""Pull a 4-digit year out of GP metadata. """Pull a 4-digit year out of GP metadata.
GP files have no dedicated year field; the year usually appears inside the GP files have no dedicated year field; the year usually appears inside the
copyright string (e.g. "1998 Goat Head Music, WB Music Corp, USA"). RsCli copyright string (e.g. "1998 Goat Head Music, WB Music Corp, USA"). the converter
requires <albumYear> to parse as Int32, so we extract just the digits and requires <albumYear> to parse as Int32, so we extract just the digits and
fall back to empty (which RsCli treats as no year) when nothing matches. fall back to empty (which the converter treats as no year) when nothing matches.
""" """
for field_val in (getattr(song, "copyright", None), getattr(song, "subtitle", None)): for field_val in (getattr(song, "copyright", None), getattr(song, "subtitle", None)):
if not field_val: if not field_val:
@@ -544,7 +544,7 @@ def convert_track(
*, *,
expand_repeats: bool = True, expand_repeats: bool = True,
) -> str: ) -> str:
"""Convert a GP track to Rocksmith 2014 arrangement XML string. """Convert a GP track to the source game arrangement XML string.
Args: Args:
song: Parsed Guitar Pro song song: Parsed Guitar Pro song
@@ -558,7 +558,7 @@ def convert_track(
once in authored order — equivalent to the pre-expansion behavior. once in authored order — equivalent to the pre-expansion behavior.
Returns: Returns:
XML string of the Rocksmith arrangement XML string of the the source game arrangement
""" """
track = song.tracks[track_index] track = song.tracks[track_index]
num_strings = len(track.strings) num_strings = len(track.strings)
@@ -950,7 +950,7 @@ def _build_xml(
# Tuning. RS2014 schema names 6 string slots; we always emit those # Tuning. RS2014 schema names 6 string slots; we always emit those
# for compatibility, and emit additional string6+ attributes (up to # for compatibility, and emit additional string6+ attributes (up to
# `len(tuning)-1`) for 7+ string arrangements. Slopsmith parses # `len(tuning)-1`) for 7+ string arrangements. Slopsmith parses
# them; stock RS ignores them. # them; the format ignores them.
tuning_el = ET.SubElement(root, "tuning") tuning_el = ET.SubElement(root, "tuning")
for i in range(max(6, len(tuning))): for i in range(max(6, len(tuning))):
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0)) tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
@@ -1149,7 +1149,7 @@ def list_tracks(gp_path: str) -> list[dict]:
def auto_select_tracks(gp_path: str) -> tuple[list[int], dict[int, str]]: def auto_select_tracks(gp_path: str) -> tuple[list[int], dict[int, str]]:
"""Auto-select guitar/bass/keys tracks and assign Rocksmith arrangement names. """Auto-select guitar/bass/keys tracks and assign the source game arrangement names.
Includes piano/keyboard tracks as "Keys" arrangements alongside Includes piano/keyboard tracks as "Keys" arrangements alongside
guitar and bass tracks. guitar and bass tracks.
@@ -1205,7 +1205,7 @@ def auto_select_tracks(gp_path: str) -> tuple[list[int], dict[int, str]]:
role = "bass" if t["is_bass"] else "guitar" role = "bass" if t["is_bass"] else "guitar"
selected.append((t["index"], role)) selected.append((t["index"], role))
# Assign Rocksmith names: Lead, Rhythm, Combo, Bass, Keys, Drums # Assign the source game names: Lead, Rhythm, Combo, Bass, Keys, Drums
track_indices = [] track_indices = []
name_map = {} name_map = {}
lead_count = 0 lead_count = 0
@@ -1243,14 +1243,14 @@ def convert_piano_track(
*, *,
expand_repeats: bool = True, expand_repeats: bool = True,
) -> str: ) -> str:
"""Convert a GP piano/keyboard track to Rocksmith XML using MIDI encoding. """Convert a GP piano/keyboard track to the source game XML using MIDI encoding.
Encodes MIDI notes into Rocksmith's string+fret format: Encodes MIDI notes into the source game's string+fret format:
string = midi_note // 24 string = midi_note // 24
fret = midi_note % 24 fret = midi_note % 24
This gives a range of 0-143, covering the full piano range within This gives a range of 0-143, covering the full piano range within
Rocksmith's 6-string x 24-fret structure. The piano highway plugin the source game's 6-string x 24-fret structure. The piano highway plugin
decodes back via: midi = string * 24 + fret. decodes back via: midi = string * 24 + fret.
Honors GP repeat brackets and D.S./D.C./Coda/Fine jumps when Honors GP repeat brackets and D.S./D.C./Coda/Fine jumps when
@@ -1342,7 +1342,7 @@ def convert_piano_track(
base_midi = 60 # fallback to middle C base_midi = 60 # fallback to middle C
midi_note = base_midi + note.value midi_note = base_midi + note.value
# Encode into Rocksmith string+fret # Encode into the source game string+fret
rs_string = midi_note // 24 rs_string = midi_note // 24
rs_fret = midi_note % 24 rs_fret = midi_note % 24
@@ -1449,9 +1449,9 @@ def convert_drum_track(
*, *,
expand_repeats: bool = True, expand_repeats: bool = True,
) -> str: ) -> str:
"""Convert a GP drum/percussion track to Rocksmith XML using MIDI encoding. """Convert a GP drum/percussion track to the source game XML using MIDI encoding.
Encodes MIDI drum note numbers into Rocksmith's string+fret format: Encodes MIDI drum note numbers into the source game's string+fret format:
string = midi_note // 24 string = midi_note // 24
fret = midi_note % 24 fret = midi_note % 24
@@ -1534,7 +1534,7 @@ def convert_drum_track(
if midi_note not in GM_DRUM_MAP: if midi_note not in GM_DRUM_MAP:
continue # Skip unknown percussion sounds continue # Skip unknown percussion sounds
# Encode into Rocksmith string+fret # Encode into the source game string+fret
rs_string = midi_note // 24 rs_string = midi_note // 24
rs_fret = midi_note % 24 rs_fret = midi_note % 24
@@ -1778,7 +1778,7 @@ def convert_file(
*, *,
expand_repeats: bool = True, expand_repeats: bool = True,
) -> list[str]: ) -> list[str]:
"""Convert a GP file to Rocksmith XMLs. """Convert a GP file to the source game XMLs.
Args: Args:
gp_path: Path to .gp5/.gp4/.gp3 file gp_path: Path to .gp5/.gp4/.gp3 file
+11 -11
View File
@@ -663,7 +663,7 @@ def list_tracks(gp_path: str) -> list[dict]:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# convert_file — mirrors gp2rs.convert_file interface # convert_file — mirrors gp2rs.convert_file interface
# Converts GPX tracks directly to Rocksmith XML, reusing gp2rs._build_xml # Converts GPX tracks directly to the source game XML, reusing gp2rs._build_xml
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -816,7 +816,7 @@ def _collect_tone_events(
def _inject_tones(xml_str: str, tone_events: list[tuple[float, str]]) -> str: def _inject_tones(xml_str: str, tone_events: list[tuple[float, str]]) -> str:
""" """
Inject a <tones> element into a Rocksmith arrangement XML string. Inject a <tones> element into a the source game arrangement XML string.
Parses the prettified XML returned by _build_xml, inserts the tones Parses the prettified XML returned by _build_xml, inserts the tones
block before </song>, and re-serialises. Noop if tone_events is empty. block before </song>, and re-serialises. Noop if tone_events is empty.
@@ -903,7 +903,7 @@ def convert_vocal_track_to_pitch_sidecar(
{"version": 1, "notes": [{"t": float, "d": float, "midi": int}, ...]} {"version": 1, "notes": [{"t": float, "d": float, "midi": int}, ...]}
This is complementary to convert_vocal_track() which produces RS XML. This is complementary to convert_vocal_track() which produces arrangement XML.
NOTE: nothing in this module calls this helper yet — convert_file() does not NOTE: nothing in this module calls this helper yet — convert_file() does not
invoke it, so no vocal_pitch.json is emitted automatically. A caller wanting invoke it, so no vocal_pitch.json is emitted automatically. A caller wanting
the pitch ribbon must call this itself and persist the returned dict (e.g. the pitch ribbon must call this itself and persist the returned dict (e.g.
@@ -1124,7 +1124,7 @@ def convert_file(
*, *,
expand_repeats: bool = True, expand_repeats: bool = True,
) -> list[str]: ) -> list[str]:
"""Convert a .gpx file to Rocksmith XML arrangement files. """Convert a .gpx file to the source game XML arrangement files.
Mirrors gp2rs.convert_file so the editor plugin can call it transparently. Mirrors gp2rs.convert_file so the editor plugin can call it transparently.
expand_repeats is accepted for API compatibility but repeat expansion from expand_repeats is accepted for API compatibility but repeat expansion from
@@ -1758,7 +1758,7 @@ def convert_file(
# (`<stem>.notation.json`, sloppak-spec §5.3) so the sloppak assembly # (`<stem>.notation.json`, sloppak-spec §5.3) so the sloppak assembly
# step can attach a `notation_<id>.json` + manifest `notation:` # step can attach a `notation_<id>.json` + manifest `notation:`
# sub-key without re-walking the GP file. Best-effort: a notation bug # sub-key without re-walking the GP file. Best-effort: a notation bug
# must never break the RS-XML conversion itself. # must never break the arrangement XML conversion itself.
if is_keys: if is_keys:
try: try:
import gp2notation as _gp2notation import gp2notation as _gp2notation
@@ -1809,10 +1809,10 @@ def _is_vocal_track(track: dict) -> bool:
def _gpx_lyric_to_rs(raw: str) -> str: def _gpx_lyric_to_rs(raw: str) -> str:
""" """
Convert a GPX lyric token to Rocksmith vocal lyric format. Convert a GPX lyric token to the source game vocal lyric format.
GPX encodes syllable continuation with a trailing hyphen (e.g. "in-", "t-"). GPX encodes syllable continuation with a trailing hyphen (e.g. "in-", "t-").
Rocksmith uses the same convention for mid-word syllables. For word-final the source game uses the same convention for mid-word syllables. For word-final
syllables with no hyphen, RS requires a "+" suffix to signal "connect to syllables with no hyphen, RS requires a "+" suffix to signal "connect to
next syllable without a space" — but only when the next beat is a next syllable without a space" — but only when the next beat is a
continuation of the same word. We handle this at the sequence level in continuation of the same word. We handle this at the sequence level in
@@ -1849,7 +1849,7 @@ def convert_vocal_track(
arr_name: str = 'Vocals', arr_name: str = 'Vocals',
) -> str: ) -> str:
""" """
Convert a GPX vocal track to a Rocksmith 2014 vocals arrangement XML. Convert a GPX vocal track to a the source game vocals arrangement XML.
Each beat with a lyric and a note becomes a <vocal> element: Each beat with a lyric and a note becomes a <vocal> element:
time — seconds from song start + audio_offset time — seconds from song start + audio_offset
@@ -1864,7 +1864,7 @@ def convert_vocal_track(
Beats with a lyric but no pitch note are included as pitch-0 rests so the Beats with a lyric but no pitch note are included as pitch-0 rests so the
display timeline stays intact. Beats with no lyric are skipped entirely. display timeline stays intact. Beats with no lyric are skipped entirely.
The output is a minimal but valid Rocksmith vocals XML. It does not include The output is a minimal but valid the source game vocals XML. It does not include
ebeats or phrases (RS parses vocal XMLs without them). ebeats or phrases (RS parses vocal XMLs without them).
""" """
string_pitches = track['string_pitches'] # high→low, standard guitar if vocal string_pitches = track['string_pitches'] # high→low, standard guitar if vocal
@@ -2018,10 +2018,10 @@ def _build_vocals_xml(
vocals: list[dict], vocals: list[dict],
tempo: int, tempo: int,
) -> str: ) -> str:
"""Build a Rocksmith 2014 vocals arrangement XML string.""" """Build a the source game vocals arrangement XML string."""
from xml.dom import minidom from xml.dom import minidom
# Rocksmith 2014 vocals arrangement is a flat <vocals> document — NOT a # the source game vocals arrangement is a flat <vocals> document — NOT a
# <song> wrapper. Every lyric consumer in the codebase keys off the root # <song> wrapper. Every lyric consumer in the codebase keys off the root
# tag being literally "vocals" (lib/loosefolder.py, server.py highway # tag being literally "vocals" (lib/loosefolder.py, server.py highway
# loader), so a <song> root would be silently skipped and the generated # loader), so a <song> root would be silently skipped and the generated
+1 -1
View File
@@ -128,7 +128,7 @@ def _resolve_audio_asset(zf, root=None) -> tuple[str, str | None]:
# GP8 uses 44100 Hz internally for FrameOffset values regardless of the # GP8 uses 44100 Hz internally for FrameOffset values regardless of the
# OGG file's own sample rate. The embedded OGG is typically 48000 Hz # OGG file's own sample rate. The embedded OGG is typically 48000 Hz
# (Rocksmith's preferred rate) and should be passed through as-is — # (the source game's preferred rate) and should be passed through as-is —
# do NOT resample it. The 44100 constant is only used here to convert # do NOT resample it. The 44100 constant is only used here to convert
# FrameOffset integers to seconds for timing math; it never touches audio. # FrameOffset integers to seconds for timing math; it never touches audio.
# Verified: 44100 gives <10ms sync error; 48000 gives ~530ms error. # Verified: 44100 gives <10ms sync error; 48000 gives ~530ms error.
+2 -2
View File
@@ -54,7 +54,7 @@ def _iter_local(path: Path, pattern: str):
* Reject directories (a folder named `audio.wem` or `lead.xml` * Reject directories (a folder named `audio.wem` or `lead.xml`
would otherwise be matched by glob and break downstream would otherwise be matched by glob and break downstream
readers / converters). readers / converters).
* Reject symlinks escaping the folder so a crafted CDLC can't * Reject symlinks escaping the folder so a crafted custom song can't
smuggle external content into the scan. smuggle external content into the scan.
""" """
root = path.resolve() root = path.resolve()
@@ -176,7 +176,7 @@ def _arr_type_from_filename(stem: str) -> tuple:
def _parse_xml_meta(xml_path: Path) -> dict: def _parse_xml_meta(xml_path: Path) -> dict:
"""Parse a Rocksmith arrangement XML and return song-level metadata.""" """Parse a the source game arrangement XML and return song-level metadata."""
try: try:
root = ET.parse(str(xml_path)).getroot() root = ET.parse(str(xml_path)).getroot()
if root.tag != "song": if root.tag != "song":
+3 -3
View File
@@ -331,7 +331,7 @@ def load_content(root) -> tuple[dict, list]:
def instrument_for_arrangement(arr_entry) -> str: def instrument_for_arrangement(arr_entry) -> str:
"""Map a library arrangement entry to a progression instrument. """Map a library arrangement entry to a progression instrument.
PSARC/loose entries carry ``type`` (lead/rhythm/bass/combo); sloppaks may archive/loose entries carry ``type`` (lead/rhythm/bass/combo); sloppaks may
only carry ``name``. Vocals are recognised so they never count toward only carry ``name``. Vocals are recognised so they never count toward
guitar challenges; everything else defaults to guitar. guitar challenges; everything else defaults to guitar.
""" """
@@ -345,11 +345,11 @@ def instrument_for_arrangement(arr_entry) -> str:
return "drums" return "drums"
if arr_type in ("piano", "keys"): if arr_type in ("piano", "keys"):
return "keys" return "keys"
# Check name before committing to a guitar type — legacy PSARC keys # Check name before committing to a guitar type — legacy archive keys
# arrangements often carry a generic type (lead/rhythm/combo) but have a # arrangements often carry a generic type (lead/rhythm/combo) but have a
# name like "Keys" or "Piano". Name overrides the generic type for all # name like "Keys" or "Piano". Name overrides the generic type for all
# well-known non-guitar instruments so that scored keys runs advance the # well-known non-guitar instruments so that scored keys runs advance the
# keys path and quests even when the Rocksmith XML type was not updated. # keys path and quests even when the the source game XML type was not updated.
if "bass" in name: if "bass" in name:
return "bass" return "bass"
if "drum" in name or "percussion" in name: if "drum" in name or "percussion" in name:
+8 -3
View File
@@ -526,7 +526,7 @@ def load_song(
and isinstance(e.get("d"), (int, float)) and isinstance(e.get("d"), (int, float))
] ]
if song.lyrics: if song.lyrics:
# Provenance — populated by the converter (xml/sng), # Provenance — populated by the converter (xml/notechart),
# the WhisperX fallback (whisperx), or hand-edits # the WhisperX fallback (whisperx), or hand-edits
# (user). Validate against the closed enum so a # (user). Validate against the closed enum so a
# hand-edited (or otherwise malformed) manifest can't # hand-edited (or otherwise malformed) manifest can't
@@ -536,8 +536,13 @@ def load_song(
# the wrong type) falls back to "xml" — the spec's # the wrong type) falls back to "xml" — the spec's
# back-compat default — instead of being stringified # back-compat default — instead of being stringified
# and trusted. # and trusted.
_ALLOWED_LYRICS_SOURCES = {"xml", "sng", "whisperx", "user"} _ALLOWED_LYRICS_SOURCES = {"xml", "notechart", "whisperx", "user"}
# Legacy alias: older manifests labelled note-chart-derived
# lyrics with the source format's name; normalise it.
_LYRICS_SOURCE_ALIASES = {"notechart": "notechart"}
raw_source = manifest.get("lyrics_source") raw_source = manifest.get("lyrics_source")
if isinstance(raw_source, str):
raw_source = _LYRICS_SOURCE_ALIASES.get(raw_source, raw_source)
if isinstance(raw_source, str) and raw_source in _ALLOWED_LYRICS_SOURCES: if isinstance(raw_source, str) and raw_source in _ALLOWED_LYRICS_SOURCES:
song.lyrics_source = raw_source song.lyrics_source = raw_source
else: else:
@@ -615,7 +620,7 @@ def extract_meta(path: Path) -> dict:
"notes": 0, # unknown without loading; fine for the index "notes": 0, # unknown without loading; fine for the index
} }
) )
# Sort like PSARC path: Lead > Combo > Rhythm > Bass # Sort like archive path: Lead > Combo > Rhythm > Bass
priority = {"Lead": 0, "Combo": 1, "Rhythm": 2, "Bass": 3} priority = {"Lead": 0, "Combo": 1, "Rhythm": 2, "Bass": 3}
arrangements.sort(key=lambda a: priority.get(a["name"], 99)) arrangements.sort(key=lambda a: priority.get(a["name"], 99))
for i, a in enumerate(arrangements): for i, a in enumerate(arrangements):
+35 -35
View File
@@ -1,4 +1,4 @@
"""Rocksmith 2014 arrangement XML parser and song data models.""" """the source game arrangement XML parser and song data models."""
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -81,14 +81,14 @@ class HandShape:
chord_id: int chord_id: int
start_time: float start_time: float
end_time: float end_time: float
# EOF / some CDLC emit `arpeggio` on `<handShape>` (RS14+). # EOF / some custom song emit `arpeggio` on `<handShape>` (RS14+).
arpeggio: bool = False arpeggio: bool = False
@dataclass @dataclass
class PhraseLevel: class PhraseLevel:
"""One difficulty tier's worth of note/chord/anchor/hand-shape data for a """One difficulty tier's worth of note/chord/anchor/hand-shape data for a
single phrase iteration. Rocksmith's XML stores these as `<level single phrase iteration. the source game's XML stores these as `<level
difficulty="N">` blocks that repeat for every difficulty tier the chart difficulty="N">` blocks that repeat for every difficulty tier the chart
author wrote; slopsmith used to collapse them to the phrase's author wrote; slopsmith used to collapse them to the phrase's
maxDifficulty and throw the rest away. Keeping them around lets the maxDifficulty and throw the rest away. Keeping them around lets the
@@ -129,11 +129,11 @@ class Arrangement:
chord_templates: list[ChordTemplate] = field(default_factory=list) chord_templates: list[ChordTemplate] = field(default_factory=list)
# None for single-level sources (GP converter, old sloppaks) — frontends # None for single-level sources (GP converter, old sloppaks) — frontends
# should treat a missing `phrases` as "no per-phrase difficulty data # should treat a missing `phrases` as "no per-phrase difficulty data
# available, disable the slider". Populated from Rocksmith XML when # available, disable the slider". Populated from the source game XML when
# multiple `<level>` tiers exist. # multiple `<level>` tiers exist.
phrases: list[Phrase] | None = None phrases: list[Phrase] | None = None
# Tone data lifted from the source PSARC by the sloppak converter and # Tone data lifted from the source archive by the sloppak converter and
# carried inline in the arrangement JSON. None for PSARC/loose playback # carried inline in the arrangement JSON. None for archive/loose playback
# (the highway reads those tones from the XML directly) and for old # (the highway reads those tones from the XML directly) and for old
# sloppaks predating tone support. Shape: # sloppaks predating tone support. Shape:
# {"base": str, "changes": [{"t": float, "name": str}], # {"base": str, "changes": [{"t": float, "name": str}],
@@ -141,14 +141,14 @@ class Arrangement:
# `base`/`changes` drive the highway tone-change markers; `definitions` # `base`/`changes` drive the highway tone-change markers; `definitions`
# feed the Tones plugin gear panel. # feed the Tones plugin gear panel.
tones: dict | None = None tones: dict | None = None
# RS XML <arrangementProperties> flags for smart naming (slopsmith feat/arrangement). # arrangement XML <arrangementProperties> flags for smart naming (slopsmith feat/arrangement).
# Populated from the XML; default False/0 for sloppak / GP-imported sources. # Populated from the XML; default False/0 for sloppak / GP-imported sources.
path_lead: bool = False path_lead: bool = False
path_rhythm: bool = False path_rhythm: bool = False
path_bass: bool = False path_bass: bool = False
bonus_arr: bool = False bonus_arr: bool = False
represent: int = 0 represent: int = 0
# RS2014 CDLC pitch-shift field (cents). Commonly -1200.0 (one octave # RS2014 custom song pitch-shift field (cents). Commonly -1200.0 (one octave
# down) for extended-range bass arrangements. 0.0 when absent or zero. # down) for extended-range bass arrangements. 0.0 when absent or zero.
cent_offset: float = 0.0 cent_offset: float = 0.0
@@ -167,7 +167,7 @@ class Song:
audio_path: str = "" audio_path: str = ""
# Optional lyrics, one entry per syllable: {"t": float, "d": float, "w": str} # Optional lyrics, one entry per syllable: {"t": float, "d": float, "w": str}
lyrics: list[dict] = field(default_factory=list) lyrics: list[dict] = field(default_factory=list)
# Provenance of the lyrics, when present. One of "xml" | "sng" | "whisperx" | # Provenance of the lyrics, when present. One of "xml" | "notechart" | "whisperx" |
# "user" — surfaces in the highway WS payload so the UI can render a badge # "user" — surfaces in the highway WS payload so the UI can render a badge
# (e.g. "auto-transcribed — may be inaccurate" for whisperx). The sloppak # (e.g. "auto-transcribed — may be inaccurate" for whisperx). The sloppak
# loader (lib/sloppak.py) defaults missing manifest keys to "xml" at load # loader (lib/sloppak.py) defaults missing manifest keys to "xml" at load
@@ -374,7 +374,7 @@ def arrangement_string_count(arr: Arrangement) -> int:
Used by the server to emit ``stringCount`` in the song_info Used by the server to emit ``stringCount`` in the song_info
WebSocket payload (slopsmith-plugin-3dhighway#7). WebSocket payload (slopsmith-plugin-3dhighway#7).
The RS XML schema always emits 6 ``<tuning>`` slots regardless The arrangement XML schema always emits 6 ``<tuning>`` slots regardless
of instrument (bass charts populate `string0``string3` and pad of instrument (bass charts populate `string0``string3` and pad
`string4`/`string5` with zeros), so ``len(arr.tuning)`` is not `string4`/`string5` with zeros), so ``len(arr.tuning)`` is not
a reliable signal. Two independent signals get combined: a reliable signal. Two independent signals get combined:
@@ -390,10 +390,10 @@ def arrangement_string_count(arr: Arrangement) -> int:
defaults to 6. This catches the partial-string-usage case defaults to 6. This catches the partial-string-usage case
where notes don't span all the instrument's strings. where notes don't span all the instrument's strings.
A third signal — ``len(arr.tuning)`` when it isn't the RS-XML A third signal — ``len(arr.tuning)`` when it isn't the arrangement XML
padded value of 6 — folds in for sloppak / GP-imported sources padded value of 6 — folds in for sloppak / GP-imported sources
where the tuning array is explicitly trimmed (4 for bass, 5 for where the tuning array is explicitly trimmed (4 for bass, 5 for
5-string bass, 7 for 7-string guitar, etc.). RS-XML / PSARC 5-string bass, 7 for 7-string guitar, etc.). arrangement XML / archive
sources always emit length 6 regardless of instrument, so we sources always emit length 6 regardless of instrument, so we
deliberately ignore that exact value to avoid mis-classifying deliberately ignore that exact value to avoid mis-classifying
bass arrangements as guitar. ``< 6`` and ``> 6`` are both bass arrangements as guitar. ``< 6`` and ``> 6`` are both
@@ -403,13 +403,13 @@ def arrangement_string_count(arr: Arrangement) -> int:
where ``tuning_count`` is ``len(arr.tuning)`` when ``!= 6``, where ``tuning_count`` is ``len(arr.tuning)`` when ``!= 6``,
else 0. Worked examples: else 0. Worked examples:
* RS XML 4-string bass, full usage (tuning len 6, notes 0..3) → * arrangement XML 4-string bass, full usage (tuning len 6, notes 0..3) →
max(4, 4, 0) = 4 max(4, 4, 0) = 4
* RS XML 4-string bass, sparse usage (tuning len 6, notes 0..2) → * arrangement XML 4-string bass, sparse usage (tuning len 6, notes 0..2) →
max(3, 4, 0) = 4 max(3, 4, 0) = 4
* RS XML 6-string lead, full usage (tuning len 6, notes 0..5) → * arrangement XML 6-string lead, full usage (tuning len 6, notes 0..5) →
max(6, 6, 0) = 6 max(6, 6, 0) = 6
* RS XML 6-string lead, sparse usage (tuning len 6, notes 0..4) → * arrangement XML 6-string lead, sparse usage (tuning len 6, notes 0..4) →
max(5, 6, 0) = 6 max(5, 6, 0) = 6
* Sloppak 5-string bass, sparse usage (tuning len 5, notes 0..3) → * Sloppak 5-string bass, sparse usage (tuning len 5, notes 0..3) →
max(4, 4, 5) = 5 max(4, 4, 5) = 5
@@ -435,7 +435,7 @@ def arrangement_string_count(arr: Arrangement) -> int:
max_s = cn.string max_s = cn.string
notes_count = max_s + 1 if max_s >= 0 else 0 notes_count = max_s + 1 if max_s >= 0 else 0
name_based = 4 if "bass" in arr.name.lower() else 6 name_based = 4 if "bass" in arr.name.lower() else 6
# Tuning-length signal — only trustworthy when NOT the RS-XML # Tuning-length signal — only trustworthy when NOT the arrangement XML
# padded value of 6. Length 4/5 indicates explicit bass / 5-string # padded value of 6. Length 4/5 indicates explicit bass / 5-string
# bass; length 7/8 indicates an extended-range guitar from GP. # bass; length 7/8 indicates an extended-range guitar from GP.
tuning_len = len(arr.tuning) tuning_len = len(arr.tuning)
@@ -444,7 +444,7 @@ def arrangement_string_count(arr: Arrangement) -> int:
def compute_smart_names(arrangements: list[Arrangement]) -> list[str | None]: def compute_smart_names(arrangements: list[Arrangement]) -> list[str | None]:
"""Compute smart display names for arrangements based on RS XML path flags. """Compute smart display names for arrangements based on arrangement XML path flags.
Returns a list parallel to `arrangements`. Each entry is a descriptive Returns a list parallel to `arrangements`. Each entry is a descriptive
name like "Lead", "Alt. Lead", "Bonus Rhythm", "Bass", or None for name like "Lead", "Alt. Lead", "Bonus Rhythm", "Bass", or None for
@@ -454,14 +454,14 @@ def compute_smart_names(arrangements: list[Arrangement]) -> list[str | None]:
Path-type resolution (first match wins): Path-type resolution (first match wins):
1. XML <arrangementProperties> flags (path_lead / path_rhythm / path_bass) 1. XML <arrangementProperties> flags (path_lead / path_rhythm / path_bass)
2. Name-based fallback when ALL three flags are zero — keeps sloppak / 2. Name-based fallback when ALL three flags are zero — keeps sloppak /
GP-imported sources and CDLC with unset flags working by mapping GP-imported sources and custom song with unset flags working by mapping
"Lead" / "Rhythm" / "Bass" / "Combo" → the matching path. Anything "Lead" / "Rhythm" / "Bass" / "Combo" → the matching path. Anything
outside that set (Vocals, ShowLights, …) → None. outside that set (Vocals, ShowLights, …) → None.
Naming rules per path type (Lead / Rhythm / Bass): Naming rules per path type (Lead / Rhythm / Bass):
- Main group (bonusArr=False): - Main group (bonusArr=False):
represent=1 → "Lead" (or "Rhythm" / "Bass") — the canonical represent=1 → "Lead" (or "Rhythm" / "Bass") — the canonical
arrangement. If no entry has represent=1 (CDLC with all-zero arrangement. If no entry has represent=1 (custom song with all-zero
flags), the first by represent-ascending order is promoted. flags), the first by represent-ascending order is promoted.
remaining (n_alts == 1) → "Alt. Lead" remaining (n_alts == 1) → "Alt. Lead"
remaining (n_alts >= 2) → "Alt. Lead 1", "Alt. Lead 2", ... remaining (n_alts >= 2) → "Alt. Lead 1", "Alt. Lead 2", ...
@@ -495,7 +495,7 @@ def compute_smart_names(arrangements: list[Arrangement]) -> list[str | None]:
def _resolve(a: Arrangement) -> tuple[str | None, bool]: def _resolve(a: Arrangement) -> tuple[str | None, bool]:
"""Return (path_attr, bonus_arr) for an arrangement, applying the """Return (path_attr, bonus_arr) for an arrangement, applying the
name-based fallback when XML flags are all zero. Defensive against name-based fallback when XML flags are all zero. Defensive against
non-string names from hand-edited PSARCs / sloppak JSON.""" non-string names from hand-edited archives / sloppak JSON."""
if a.path_lead: if a.path_lead:
return "path_lead", bool(a.bonus_arr) return "path_lead", bool(a.bonus_arr)
if a.path_rhythm: if a.path_rhythm:
@@ -527,7 +527,7 @@ def compute_smart_names(arrangements: list[Arrangement]) -> list[str | None]:
# represent=1 → standard arrangement ("Lead") # represent=1 → standard arrangement ("Lead")
# represent=0 (or any value != 1) → alternate arrangement ("Alt. Lead") # represent=0 (or any value != 1) → alternate arrangement ("Alt. Lead")
# #
# If no arrangement has represent=1 (e.g. CDLC defaults or all-zero # If no arrangement has represent=1 (e.g. custom song defaults or all-zero
# flags with name fallback), fall back to treating the first by # flags with name fallback), fall back to treating the first by
# represent-ascending order as the standard so there is always a "Lead". # represent-ascending order as the standard so there is always a "Lead".
main_pairs = [(i, a) for i, a in type_arrs if not _resolved[i][1]] main_pairs = [(i, a) for i, a in type_arrs if not _resolved[i][1]]
@@ -573,7 +573,7 @@ def compute_smart_names(arrangements: list[Arrangement]) -> list[str | None]:
def _finite_float(value, default: float = 0.0) -> float: def _finite_float(value, default: float = 0.0) -> float:
"""Coerce ``value`` to a finite float, falling back to ``default``. """Coerce ``value`` to a finite float, falling back to ``default``.
Malformed CDLC can put ``NaN``/``Infinity`` into float fields like RS2014 Malformed custom song can put ``NaN``/``Infinity`` into float fields like RS2014
``<centOffset>``; ``float()`` accepts those, but they serialize to the ``<centOffset>``; ``float()`` accepts those, but they serialize to the
invalid JSON tokens ``NaN``/``Infinity`` (both over the highway WebSocket invalid JSON tokens ``NaN``/``Infinity`` (both over the highway WebSocket
and into sloppak ``.json`` files), which breaks downstream parsing. and into sloppak ``.json`` files), which breaks downstream parsing.
@@ -607,7 +607,7 @@ def arrangement_to_wire(arr: Arrangement) -> dict:
if arr.phrases: if arr.phrases:
out["phrases"] = [phrase_to_wire(p) for p in arr.phrases] out["phrases"] = [phrase_to_wire(p) for p in arr.phrases]
# `tones` is additive — only emitted when the source carried tone data # `tones` is additive — only emitted when the source carried tone data
# (sloppaks converted from a PSARC). Absent on PSARC/loose-derived # (sloppaks converted from a archive). Absent on archive/loose-derived
# Arrangements and old sloppaks; readers treat a missing key as # Arrangements and old sloppaks; readers treat a missing key as
# "no tones". # "no tones".
if arr.tones: if arr.tones:
@@ -681,7 +681,7 @@ def _int_optional(elem, attr, default=-1):
Use for fields that are merely metadata hints (right-hand fingering, Use for fields that are merely metadata hints (right-hand fingering,
pick direction, etc.) where a malformed value from a third-party pick direction, etc.) where a malformed value from a third-party
Rocksmith XML emitter shouldn't abort the whole arrangement parse. the source game XML emitter shouldn't abort the whole arrangement parse.
Required-field readers (`string`, `fret`, `chordId`, …) keep using Required-field readers (`string`, `fret`, `chordId`, …) keep using
`_int` so a corrupted required attribute still fails fast at parse `_int` so a corrupted required attribute still fails fast at parse
@@ -708,7 +708,7 @@ def _bool(elem, attr):
def _hand_shape_arpeggio_flag(elem) -> bool: def _hand_shape_arpeggio_flag(elem) -> bool:
"""Rocksmith / EOF may mark arpeggio on ``<handShape>`` (various casings).""" """the source game / EOF may mark arpeggio on ``<handShape>`` (various casings)."""
for attr in ("arpeggio", "Arpeggio", "arp", "Arp"): for attr in ("arpeggio", "Arpeggio", "arp", "Arp"):
if _bool(elem, attr): if _bool(elem, attr):
return True return True
@@ -716,7 +716,7 @@ def _hand_shape_arpeggio_flag(elem) -> bool:
def _chord_template_arpeggio_flag(elem) -> bool: def _chord_template_arpeggio_flag(elem) -> bool:
"""Rocksmith commonly tags arpeggio templates in ``displayName`` via ``-arp``.""" """the source game commonly tags arpeggio templates in ``displayName`` via ``-arp``."""
for attr in ("arpeggio", "Arpeggio", "arp", "Arp"): for attr in ("arpeggio", "Arpeggio", "arp", "Arp"):
if _bool(elem, attr): if _bool(elem, attr):
return True return True
@@ -766,7 +766,7 @@ def _parse_note(n) -> Note:
def parse_arrangement(xml_path: str) -> Arrangement: def parse_arrangement(xml_path: str) -> Arrangement:
"""Parse a Rocksmith arrangement XML file.""" """Parse a the source game arrangement XML file."""
tree = ET.parse(xml_path) tree = ET.parse(xml_path)
root = tree.getroot() root = tree.getroot()
@@ -800,8 +800,8 @@ def parse_arrangement(xml_path: str) -> Arrangement:
except ValueError: except ValueError:
pass pass
# CentOffset — RS2014 pitch-shift field (cents). Present in all RS XML # CentOffset — RS2014 pitch-shift field (cents). Present in all arrangement XML
# sources (PSARC, loose folders, GP-converted XML). Absent in very old # sources (archive, loose folders, GP-converted XML). Absent in very old
# files; default 0.0. # files; default 0.0.
cent_offset = 0.0 cent_offset = 0.0
el = root.find("centOffset") el = root.find("centOffset")
@@ -1049,7 +1049,7 @@ def parse_arrangement(xml_path: str) -> Arrangement:
# wrote at or below this phrase's max — these are what the # wrote at or below this phrase's max — these are what the
# master-difficulty slider selects between at render time. # master-difficulty slider selects between at render time.
# Tiers above max_diff exist in some XMLs (authoring leftovers) # Tiers above max_diff exist in some XMLs (authoring leftovers)
# and are skipped to match Rocksmith's in-game behaviour. # and are skipped to match the source game's in-game behaviour.
# Capture the extracted slices so the flat max-mastery merge # Capture the extracted slices so the flat max-mastery merge
# below can reuse one of them. # below can reuse one of them.
phrase_levels: list[PhraseLevel] = [] phrase_levels: list[PhraseLevel] = []
@@ -1144,7 +1144,7 @@ def parse_arrangement(xml_path: str) -> Arrangement:
def _convert_sng_to_xml(extracted_dir: str): def _convert_sng_to_xml(extracted_dir: str):
"""No-op stub. """No-op stub.
Historically this converted proprietary encrypted ``.sng`` arrangement Historically this converted proprietary encrypted ``.notechart`` arrangement
files to XML via an external tool. That path has been removed: slopsmith files to XML via an external tool. That path has been removed: slopsmith
reads only its own ``.sloppak`` format and loose-folder/GP/MusicXML-derived reads only its own ``.sloppak`` format and loose-folder/GP/MusicXML-derived
arrangement XML, and never decodes or decrypts proprietary archives. Kept arrangement XML, and never decodes or decrypts proprietary archives. Kept
@@ -1156,7 +1156,7 @@ def _convert_sng_to_xml(extracted_dir: str):
def load_song(extracted_dir: str) -> Song: def load_song(extracted_dir: str) -> Song:
"""Load a song from a directory of arrangement XML/JSON files.""" """Load a song from a directory of arrangement XML/JSON files."""
# Proprietary SNG→XML conversion has been removed; this is now a no-op. # Proprietary note-chart→XML conversion has been removed; this is now a no-op.
_convert_sng_to_xml(extracted_dir) _convert_sng_to_xml(extracted_dir)
song = Song() song = Song()
@@ -1165,7 +1165,7 @@ def load_song(extracted_dir: str) -> Song:
# Build manifest lookups: xml_stem (lowercase) -> ArrangementName / path flags. # Build manifest lookups: xml_stem (lowercase) -> ArrangementName / path flags.
# The manifest JSON is the authoritative source for path flags (pathLead / # The manifest JSON is the authoritative source for path flags (pathLead /
# pathRhythm / pathBass / bonusArr / represent) because the XML files bundled # pathRhythm / pathBass / bonusArr / represent) because the XML files bundled
# in official DLC PSARCs often have all path flags set to "0", while the # in official DLC archives often have all path flags set to "0", while the
# manifest correctly reflects what the authoring tool wrote. # manifest correctly reflects what the authoring tool wrote.
def _mprop_int(key: str, props: dict) -> int: def _mprop_int(key: str, props: dict) -> int:
val = props.get(key, 0) val = props.get(key, 0)
@@ -1268,7 +1268,7 @@ def load_song(extracted_dir: str) -> Song:
arrangement = parse_arrangement(str(xml_path)) arrangement = parse_arrangement(str(xml_path))
# Override path flags with manifest values when available. The XML # Override path flags with manifest values when available. The XML
# bundled inside official DLC PSARCs often has all flags as "0", while # bundled inside official DLC archives often has all flags as "0", while
# the manifest JSON carries the correct values written by the DLC author. # the manifest JSON carries the correct values written by the DLC author.
manifest_flags = _manifest_path_flags.get(xml_path.stem.lower()) manifest_flags = _manifest_path_flags.get(xml_path.stem.lower())
if manifest_flags: if manifest_flags:
+1 -1
View File
@@ -2,7 +2,7 @@
Sibling to `lyrics_transcribe.py` on the karaoke side: once we have Sibling to `lyrics_transcribe.py` on the karaoke side: once we have
isolated vocals + per-syllable lyric timing (both produced by the isolated vocals + per-syllable lyric timing (both produced by the
WhisperX fallback or shipped in the source PSARC), the /pitch endpoint WhisperX fallback or shipped in the source archive), the /pitch endpoint
runs CREPE over the vocals stem and returns one MIDI note per supplied runs CREPE over the vocals stem and returns one MIDI note per supplied
timing token. The result lands in `<sloppak>/vocal_pitch.json` in the timing token. The result lands in `<sloppak>/vocal_pitch.json` in the
shape the byrongamatos/slopsmith-plugin-lyrics-karaoke renderer shape the byrongamatos/slopsmith-plugin-lyrics-karaoke renderer
+1 -1
View File
@@ -58,7 +58,7 @@ def _convert_riff_wem(data: bytes, output_path: str) -> bool:
if codec == 0xFFFF or codec == 0x0069: if codec == 0xFFFF or codec == 0x0069:
# Wwise Vorbis — audio_data contains raw Ogg pages or encoded Vorbis # Wwise Vorbis — audio_data contains raw Ogg pages or encoded Vorbis
# For Rocksmith CDLC, the data is typically packed Vorbis # For the source game custom song, the data is typically packed Vorbis
# Try writing raw data as OGG (some WEM files have valid OGG inside) # Try writing raw data as OGG (some WEM files have valid OGG inside)
if _try_extract_ogg_pages(audio_data, output_path): if _try_extract_ogg_pages(audio_data, output_path):
return True return True
+8 -8
View File
@@ -1099,7 +1099,7 @@
return _bgBandsCache; return _bgBandsCache;
} }
const BG_DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', showFretOnNote: true, fretNumberGhostScope: 'rocksmith', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true }; const BG_DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
// User-selectable, persistable bg styles — must mirror settings.html's // User-selectable, persistable bg styles — must mirror settings.html's
// VALID_STYLES. 'venue' is deliberately NOT here: it is an internal effective // VALID_STYLES. 'venue' is deliberately NOT here: it is an internal effective
// style reached only via _venueSceneOverride (the viz-picker Venue flow), so // style reached only via _venueSceneOverride (the viz-picker Venue flow), so
@@ -1325,7 +1325,7 @@
}, },
); );
} }
const FRET_NUMBER_GHOST_SCOPE_IDS = ['rocksmith', 'all']; const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all'];
function _bgPanelKey(canvas) { function _bgPanelKey(canvas) {
const ss = window.slopsmithSplitscreen; const ss = window.slopsmithSplitscreen;
@@ -2717,7 +2717,7 @@
// Fret digits on the board ghost (hollow preview at Z=0), not on // Fret digits on the board ghost (hollow preview at Z=0), not on
// flying note bodies — see fretNumberGhostScope for chord-hand vs all. // flying note bodies — see fretNumberGhostScope for chord-hand vs all.
let showFretOnNote = false; let showFretOnNote = false;
let fretNumberGhostScope = 'rocksmith'; let fretNumberGhostScope = 'chords';
// Camera-X smoothing dial (issue #34). 0 = twitchy (track every // Camera-X smoothing dial (issue #34). 0 = twitchy (track every
// upcoming fret), 1 = calm (ignore small intra-cluster shifts). // upcoming fret), 1 = calm (ignore small intra-cluster shifts).
// Cached here and refreshed via the bg listener to avoid a // Cached here and refreshed via the bg listener to avoid a
@@ -7546,7 +7546,7 @@
const tol = 0.028; const tol = 0.028;
/** /**
* Suppress a synth chord box when a real chord with the **same trimmed * Suppress a synth chord box when a real chord with the **same trimmed
* display name** played within this window RS CDLC commonly authors * display name** played within this window Custom songs commonly authors
* several ``<chordTemplate>`` rows that share a display name (with * several ``<chordTemplate>`` rows that share a display name (with
* trailing-whitespace IDs) for fingering variants. The follow-up * trailing-whitespace IDs) for fingering variants. The follow-up
* hand-shape with no chord row is a fingering hint, not a new strum * hand-shape with no chord row is a fingering hint, not a new strum
@@ -7558,7 +7558,7 @@
const trimmedTemplateName = (cid) => { const trimmedTemplateName = (cid) => {
if (cid == null || !chordTemplates) return ''; if (cid == null || !chordTemplates) return '';
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)]; const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
// CDLC commonly authors several <chordTemplate> rows that share // custom songs commonly authors several <chordTemplate> rows that share
// a displayName for fingering variants; the suppression // a displayName for fingering variants; the suppression
// heuristic in the surrounding code dedupes on the *label*, // heuristic in the surrounding code dedupes on the *label*,
// not the underlying name, so go through chordTemplateLabel. // not the underlying name, so go through chordTemplateLabel.
@@ -12363,13 +12363,13 @@
const ghostFretOk = showFretOnNote && ( const ghostFretOk = showFretOnNote && (
arpGhostActive || arpGhostActive ||
fretNumberGhostScope === 'all' || fretNumberGhostScope === 'all' ||
(fretNumberGhostScope === 'rocksmith' && fromChord) (fretNumberGhostScope === 'chords' && fromChord)
); );
if (ghostFretOk && pGhostFretLbl) { if (ghostFretOk && pGhostFretLbl) {
// chord-hand style → show finger number (14) from the chord // chord-hand style → show finger number (14) from the chord
// template; fall back to fret number when no finger data exists // template; fall back to fret number when no finger data exists
// (GP imports, open strings, non-chord notes). // (GP imports, open strings, non-chord notes).
const ghostFretDisplay = fromChord && fretNumberGhostScope === 'rocksmith' const ghostFretDisplay = fromChord && fretNumberGhostScope === 'chords'
? (_templateFingerForChordGhost(chordId, n.s) ?? _templateFretForChordGhost(chordId, n.s, n.f)) ? (_templateFingerForChordGhost(chordId, n.s) ?? _templateFretForChordGhost(chordId, n.s, n.f))
: fromChord : fromChord
? _templateFretForChordGhost(chordId, n.s, n.f) ? _templateFretForChordGhost(chordId, n.s, n.f)
@@ -12389,7 +12389,7 @@
// frame (projFactor), instead of popping in at full alpha. // frame (projFactor), instead of popping in at full alpha.
ghostFretLblAlpha = projFactor; ghostFretLblAlpha = projFactor;
} }
const _ghostFretForScale = fromChord && fretNumberGhostScope === 'rocksmith' const _ghostFretForScale = fromChord && fretNumberGhostScope === 'chords'
? n.f ? n.f
: ghostFretDisplay; : ghostFretDisplay;
drawGhostFretLabel(x, y, projRim, ghostFretDisplay, ghostFretLblAlpha, projGrowScale, _ghostFretForScale); drawGhostFretLabel(x, y, projRim, ghostFretDisplay, ghostFretLblAlpha, projGrowScale, _ghostFretForScale);
+4 -4
View File
@@ -430,7 +430,7 @@
<div id="h3d-fret-ghost-scope-wrap" class="ml-6 mt-2 space-y-2 border-l border-gray-700 pl-3"> <div id="h3d-fret-ghost-scope-wrap" class="ml-6 mt-2 space-y-2 border-l border-gray-700 pl-3">
<p class="text-xs font-medium text-gray-400">Where to show</p> <p class="text-xs font-medium text-gray-400">Where to show</p>
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer"> <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="radio" name="h3d-fret-ghost-scope" id="h3d-fret-ghost-scope-rocksmith" value="rocksmith" <input type="radio" name="h3d-fret-ghost-scope" id="h3d-fret-ghost-scope-chords" value="chords"
class="accent-accent"> class="accent-accent">
In chords &amp; arpeggios (chord-hand events only) In chords &amp; arpeggios (chord-hand events only)
</label> </label>
@@ -712,14 +712,14 @@
no-selection / NaN state. --> no-selection / NaN state. -->
<script> <script>
(function () { (function () {
const DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', showFretOnNote: true, fretNumberGhostScope: 'rocksmith', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true }; const DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
const VALID_STYLES = new Set(['off', 'particles', 'silhouettes', 'lights', 'geometric', 'image', 'video']); const VALID_STYLES = new Set(['off', 'particles', 'silhouettes', 'lights', 'geometric', 'image', 'video']);
const VALID_CAMERA_MODES = new Set(['steady', 'lookahead']); const VALID_CAMERA_MODES = new Set(['steady', 'lookahead']);
// Chord diagram is top-only (bl/br removed). // Chord diagram is top-only (bl/br removed).
const VALID_CHORD_DIAG_POSITIONS = new Set(['tl', 'tr']); const VALID_CHORD_DIAG_POSITIONS = new Set(['tl', 'tr']);
// Section HUD still supports all four corners independently. // Section HUD still supports all four corners independently.
const VALID_SECTION_HUD_POSITIONS = new Set(['tl', 'tr', 'bl', 'br']); const VALID_SECTION_HUD_POSITIONS = new Set(['tl', 'tr', 'bl', 'br']);
const VALID_FRET_GHOST_SCOPES = new Set(['rocksmith', 'all']); const VALID_FRET_GHOST_SCOPES = new Set(['chords', 'all']);
// Custom images are persisted as base64 data URLs in // Custom images are persisted as base64 data URLs in
// localStorage. base64 expands the byte count by ~4/3, and // localStorage. base64 expands the byte count by ~4/3, and
// localStorage values are stored as UTF-16 (two bytes per // localStorage values are stored as UTF-16 (two bytes per
@@ -1015,7 +1015,7 @@
if (lbl) lbl.textContent = intensity.toFixed(2); if (lbl) lbl.textContent = intensity.toFixed(2);
if (rea) rea.checked = reactive; if (rea) rea.checked = reactive;
if (fon) fon.checked = showFretOnNote; if (fon) fon.checked = showFretOnNote;
const fgsRock = document.getElementById('h3d-fret-ghost-scope-rocksmith'); const fgsRock = document.getElementById('h3d-fret-ghost-scope-chords');
const fgsAll = document.getElementById('h3d-fret-ghost-scope-all'); const fgsAll = document.getElementById('h3d-fret-ghost-scope-all');
const fgsWrap = document.getElementById('h3d-fret-ghost-scope-wrap'); const fgsWrap = document.getElementById('h3d-fret-ghost-scope-wrap');
if (fretNumberGhostScope === 'all') { if (fgsAll) fgsAll.checked = true; } if (fretNumberGhostScope === 'all') { if (fgsAll) fgsAll.checked = true; }
+65 -65
View File
@@ -302,7 +302,7 @@ def _ensure_smart_names(arrangements: list[dict]) -> list[dict]:
In the meantime this function provides a best-effort on-the-fly computation. In the meantime this function provides a best-effort on-the-fly computation.
However, when multiple arrangements share the same name (e.g. two "Combo" However, when multiple arrangements share the same name (e.g. two "Combo"
tracks in a PSARC that bundles all path flags as zero), name-based inference tracks in a archive that bundles all path flags as zero), name-based inference
cannot distinguish Lead from Rhythm so we emit ``smart_name: null`` and cannot distinguish Lead from Rhythm so we emit ``smart_name: null`` and
let the UI fall back to the legacy name until the background rescan corrects let the UI fall back to the legacy name until the background rescan corrects
the row. Arrangements that already have the field are never modified. the row. Arrangements that already have the field are never modified.
@@ -374,7 +374,7 @@ class MetadataDB:
tuning TEXT, tuning TEXT,
arrangements TEXT, arrangements TEXT,
has_lyrics INTEGER DEFAULT 0, has_lyrics INTEGER DEFAULT 0,
format TEXT DEFAULT 'psarc', format TEXT DEFAULT 'archive',
stem_count INTEGER DEFAULT 0, stem_count INTEGER DEFAULT 0,
stem_ids TEXT DEFAULT '[]', stem_ids TEXT DEFAULT '[]',
tuning_name TEXT DEFAULT '', tuning_name TEXT DEFAULT '',
@@ -384,7 +384,7 @@ class MetadataDB:
""") """)
# Idempotent migrations for installs that predate each column. # Idempotent migrations for installs that predate each column.
for ddl in ( for ddl in (
"ALTER TABLE songs ADD COLUMN format TEXT DEFAULT 'psarc'", "ALTER TABLE songs ADD COLUMN format TEXT DEFAULT 'archive'",
"ALTER TABLE songs ADD COLUMN stem_count INTEGER DEFAULT 0", "ALTER TABLE songs ADD COLUMN stem_count INTEGER DEFAULT 0",
# slopsmith#129: per-stem filter needs the id list, not just count. # slopsmith#129: per-stem filter needs the id list, not just count.
"ALTER TABLE songs ADD COLUMN stem_ids TEXT DEFAULT '[]'", "ALTER TABLE songs ADD COLUMN stem_ids TEXT DEFAULT '[]'",
@@ -1411,7 +1411,7 @@ class MetadataDB:
"year": row[5], "duration": row[6], "tuning": row[7], "year": row[5], "duration": row[6], "tuning": row[7],
"arrangements": json.loads(row[8]) if row[8] else [], "arrangements": json.loads(row[8]) if row[8] else [],
"has_lyrics": bool(row[9]), "has_lyrics": bool(row[9]),
"format": row[10] or "psarc", "format": row[10] or "archive",
"stem_count": int(row[11] or 0), "stem_count": int(row[11] or 0),
"stem_ids": json.loads(row[12]) if row[12] else [], "stem_ids": json.loads(row[12]) if row[12] else [],
"tuning_name": row[13] or "", "tuning_name": row[13] or "",
@@ -1431,7 +1431,7 @@ class MetadataDB:
meta.get("album", ""), meta.get("year", ""), meta.get("duration", 0), meta.get("album", ""), meta.get("year", ""), meta.get("duration", 0),
meta.get("tuning", ""), json.dumps(meta.get("arrangements", [])), meta.get("tuning", ""), json.dumps(meta.get("arrangements", [])),
1 if meta.get("has_lyrics") else 0, 1 if meta.get("has_lyrics") else 0,
meta.get("format", "psarc"), meta.get("format", "archive"),
int(meta.get("stem_count", 0) or 0), int(meta.get("stem_count", 0) or 0),
json.dumps(meta.get("stem_ids", []) or []), json.dumps(meta.get("stem_ids", []) or []),
meta.get("tuning_name", "") or "", meta.get("tuning_name", "") or "",
@@ -1706,7 +1706,7 @@ class MetadataDB:
# magnitude tier we break ties by signed key ASC so the # magnitude tier we break ties by signed key ASC so the
# negative (down-tuned) variant comes before the positive # negative (down-tuned) variant comes before the positive
# (up-tuned) one — Eb Standard before F Standard, matching # (up-tuned) one — Eb Standard before F Standard, matching
# how Rocksmith groups its tuning list. Final tiebreak by # how the app groups its tuning list. Final tiebreak by
# name keeps the order fully deterministic. # name keeps the order fully deterministic.
# #
# Leading term pushes pre-migration / unscanned rows to # Leading term pushes pre-migration / unscanned rows to
@@ -1760,7 +1760,7 @@ class MetadataDB:
"year": r[4], "duration": r[5], "tuning": r[6], "year": r[4], "duration": r[5], "tuning": r[6],
"arrangements": _ensure_smart_names(json.loads(r[7]) if r[7] else []), "arrangements": _ensure_smart_names(json.loads(r[7]) if r[7] else []),
"has_lyrics": bool(r[8]), "mtime": r[9], "has_lyrics": bool(r[8]), "mtime": r[9],
"format": r[10] or "psarc", "format": r[10] or "archive",
"stem_count": int(r[11] or 0), "stem_count": int(r[11] or 0),
"stem_ids": json.loads(r[12]) if r[12] else [], "stem_ids": json.loads(r[12]) if r[12] else [],
"tuning_name": r[13] or "", "tuning_name": r[13] or "",
@@ -1841,7 +1841,7 @@ class MetadataDB:
"year": r[4], "duration": r[5], "tuning": r[6], "year": r[4], "duration": r[5], "tuning": r[6],
"arrangements": _ensure_smart_names(json.loads(r[7]) if r[7] else []), "arrangements": _ensure_smart_names(json.loads(r[7]) if r[7] else []),
"has_lyrics": bool(r[8]), "has_lyrics": bool(r[8]),
"format": r[9] or "psarc", "format": r[9] or "archive",
"stem_count": int(r[10] or 0), "stem_count": int(r[10] or 0),
"stem_ids": json.loads(r[11]) if r[11] else [], "stem_ids": json.loads(r[11]) if r[11] else [],
"tuning_name": r[12] or "", "tuning_name": r[12] or "",
@@ -2667,7 +2667,7 @@ def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
"""Resolve `filename` under DLC_DIR and refuse anything that escapes. """Resolve `filename` under DLC_DIR and refuse anything that escapes.
`filename` arrives from `:path` route params and can contain `..` `filename` arrives from `:path` route params and can contain `..`
segments. The Sloppak and PSARC paths happen to fail safely later segments. The Sloppak and archive paths happen to fail safely later
because their loaders raise on missing/invalid files, but loose- because their loaders raise on missing/invalid files, but loose-
folder format detection (`is_loose_song`) globs and parses XML on folder format detection (`is_loose_song`) globs and parses XML on
disk first, which lets a crafted path trigger filesystem reads disk first, which lets a crafted path trigger filesystem reads
@@ -2777,7 +2777,7 @@ def _stat_for_cache(f: Path) -> tuple[float, int]:
For loose-folder directories the directory's own mtime does not For loose-folder directories the directory's own mtime does not
change when inner files (audio.wem / *.xml / manifest.json) are change when inner files (audio.wem / *.xml / manifest.json) are
edited in place, so we aggregate over the contents. PSARCs and edited in place, so we aggregate over the contents. archives and
sloppak files (zip form) use their own stat directly. Sloppak sloppak files (zip form) use their own stat directly. Sloppak
*directories* are aggregated too: the editor and the library Edit *directories* are aggregated too: the editor and the library Edit
button rewrite their `manifest.yaml` / `arrangements/*.json` in button rewrite their `manifest.yaml` / `arrangements/*.json` in
@@ -2793,7 +2793,7 @@ def _stat_for_cache(f: Path) -> tuple[float, int]:
# every cache lookup. # every cache lookup.
if f.is_dir(): if f.is_dir():
# Skip symlinks pointing outside the song folder — without this # Skip symlinks pointing outside the song folder — without this
# an attacker-crafted CDLC could keep a stale cache hot by # an attacker-crafted custom song could keep a stale cache hot by
# bumping the mtime of an unrelated file via a symlink. # bumping the mtime of an unrelated file via a symlink.
root = f.resolve() root = f.resolve()
def _in_folder(p: Path) -> bool: def _in_folder(p: Path) -> bool:
@@ -3344,7 +3344,7 @@ async def startup_events():
"get_dlc_dir": _get_dlc_dir, "get_dlc_dir": _get_dlc_dir,
# Pass the DLC-root resolver (not its result) so loose-folder # Pass the DLC-root resolver (not its result) so loose-folder
# metadata keeps its dlc-relative artist/album inference while the # metadata keeps its dlc-relative artist/album inference while the
# lookup stays lazy — PSARC/sloppak extraction never reads config. # lookup stays lazy — archive/sloppak extraction never reads config.
# Plugins still call this with just a path. # Plugins still call this with just a path.
"extract_meta": lambda p: _extract_meta_for_file(p, _get_dlc_dir), "extract_meta": lambda p: _extract_meta_for_file(p, _get_dlc_dir),
"meta_db": meta_db, "meta_db": meta_db,
@@ -3888,7 +3888,7 @@ def _invalidate_song_caches(cache_key: str) -> None:
"""Drop filename-keyed derived caches when a song at ``cache_key`` is """Drop filename-keyed derived caches when a song at ``cache_key`` is
replaced or removed. Sloppak's ``_source_cache`` and loose-folder audio replaced or removed. Sloppak's ``_source_cache`` and loose-folder audio
IDs self-invalidate via stat checks; the caches purged here do not.""" IDs self-invalidate via stat checks; the caches purged here do not."""
# In-memory PSARC extraction cache (filename → tmp dir + Song). # In-memory archive extraction cache (filename → tmp dir + Song).
with _extract_cache_lock: with _extract_cache_lock:
stale = _extract_cache.pop(cache_key, None) stale = _extract_cache.pop(cache_key, None)
if stale: if stale:
@@ -3905,7 +3905,7 @@ def _invalidate_song_caches(cache_key: str) -> None:
except OSError: except OSError:
log.debug("failed to evict art cache for %s", cache_key, exc_info=True) log.debug("failed to evict art cache for %s", cache_key, exc_info=True)
# PSARC audio cache — audio_id is `Path(filename).stem.replace(" ", "_")` # archive audio cache — audio_id is `Path(filename).stem.replace(" ", "_")`
# without any stat digest, so a same-named replacement would serve the # without any stat digest, so a same-named replacement would serve the
# previous file's converted audio. Loose-folder ids include a wem stat # previous file's converted audio. Loose-folder ids include a wem stat
# digest and self-heal; sloppak streams stems directly and uses no # digest and self-heal; sloppak streams stems directly and uses no
@@ -4271,7 +4271,7 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
arrangements_has: str = "", arrangements_lacks: str = "", arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "", stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "") -> dict: has_lyrics: str = "", tunings: str = "") -> dict:
fmt = format if format in ("psarc", "sloppak", "loose") else "" fmt = format if format in ("archive", "sloppak", "loose") else ""
return { return {
"q": q, "q": q,
"favorites_only": bool(favorites), "favorites_only": bool(favorites),
@@ -6312,17 +6312,17 @@ async def get_song_art(filename: str):
if not dlc: if not dlc:
return JSONResponse({"error": "not configured"}, 404) return JSONResponse({"error": "not configured"}, 404)
psarc_path = _resolve_dlc_path(dlc, filename) song_path = _resolve_dlc_path(dlc, filename)
if psarc_path is None: if song_path is None:
return JSONResponse({"error": "forbidden"}, 403) return JSONResponse({"error": "forbidden"}, 403)
if not psarc_path.exists(): if not song_path.exists():
return JSONResponse({"error": "not found"}, 404) return JSONResponse({"error": "not found"}, 404)
# Sloppak path: pull cover.jpg from the source dir (manifest-declared or default). # Sloppak path: pull cover.jpg from the source dir (manifest-declared or default).
if sloppak_mod.is_sloppak(psarc_path): if sloppak_mod.is_sloppak(song_path):
try: try:
src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR) src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR)
manifest = sloppak_mod.load_manifest(psarc_path) manifest = sloppak_mod.load_manifest(song_path)
cover_rel = str(manifest.get("cover") or "cover.jpg") cover_rel = str(manifest.get("cover") or "cover.jpg")
cover_path = (src / cover_rel).resolve() cover_path = (src / cover_rel).resolve()
# Prevent escape and fall back to default name if missing. # Prevent escape and fall back to default name if missing.
@@ -6341,16 +6341,16 @@ async def get_song_art(filename: str):
return JSONResponse({"error": "no art"}, 404) return JSONResponse({"error": "no art"}, 404)
# Loose folder path: serve art file directly. # Loose folder path: serve art file directly.
# psarc_path is already validated against DLC_DIR by _resolve_dlc_path. # song_path is already validated against DLC_DIR by _resolve_dlc_path.
if loosefolder_mod.is_loose_song(psarc_path): if loosefolder_mod.is_loose_song(song_path):
art_path = loosefolder_mod.find_art(psarc_path) art_path = loosefolder_mod.find_art(song_path)
if art_path: if art_path:
# Re-resolve in case the matched file is a symlink — a crafted # Re-resolve in case the matched file is a symlink — a crafted
# CDLC could put `album_art.jpg` as a symlink to anywhere on # custom song could put `album_art.jpg` as a symlink to anywhere on
# disk. Insist the final target stays inside the song folder. # disk. Insist the final target stays inside the song folder.
art_resolved = art_path.resolve() art_resolved = art_path.resolve()
try: try:
art_resolved.relative_to(psarc_path) art_resolved.relative_to(song_path)
except ValueError: except ValueError:
return JSONResponse({"error": "forbidden"}, 403) return JSONResponse({"error": "forbidden"}, 403)
if art_resolved.is_file(): if art_resolved.is_file():
@@ -6377,7 +6377,7 @@ def update_song_meta(filename: str, data: dict):
"""Update song metadata, persisting it back into the underlying file. """Update song metadata, persisting it back into the underlying file.
The library scanner re-derives title/artist/album/year from the file The library scanner re-derives title/artist/album/year from the file
(PSARC manifest Attributes / sloppak manifest.yaml) on every full rescan, (archive manifest Attributes / sloppak manifest.yaml) on every full rescan,
so a DB-only edit reverts. We write the edit into the file first, then so a DB-only edit reverts. We write the edit into the file first, then
refresh the cache row (including mtime/size) to match. Loose-folder and refresh the cache row (including mtime/size) to match. Loose-folder and
unwritable songs fall back to a DB-only update (which still survives an unwritable songs fall back to a DB-only update (which still survives an
@@ -6417,7 +6417,7 @@ def update_song_meta(filename: str, data: dict):
# Hold _song_io_lock across the existence check and file write so a # Hold _song_io_lock across the existence check and file write so a
# concurrent delete cannot remove the file between our check and the # concurrent delete cannot remove the file between our check and the
# repack's atomic replace, and so a concurrent upload cannot be clobbered # repack's atomic replace, and so a concurrent upload cannot be clobbered
# by our atomic rename. PSARC repack is slow — the lock is held longer # by our atomic rename. archive repack is slow — the lock is held longer
# than a simple upload/delete, but correctness requires serialisation. # than a simple upload/delete, but correctness requires serialisation.
persisted = False persisted = False
with _song_io_lock: with _song_io_lock:
@@ -6497,29 +6497,29 @@ async def get_song_info(filename: str):
if not dlc: if not dlc:
return JSONResponse({"error": "DLC folder not configured"}, 404) return JSONResponse({"error": "DLC folder not configured"}, 404)
psarc_path = _resolve_dlc_path(dlc, filename) song_path = _resolve_dlc_path(dlc, filename)
if psarc_path is None: if song_path is None:
return JSONResponse({"error": "forbidden"}, 403) return JSONResponse({"error": "forbidden"}, 403)
if not psarc_path.exists(): if not song_path.exists():
return JSONResponse({"error": "File not found"}, 404) return JSONResponse({"error": "File not found"}, 404)
# Canonicalise the cache key against the resolved path so two URL # Canonicalise the cache key against the resolved path so two URL
# forms of the same physical file (e.g. `Artist/song.psarc` vs # forms of the same physical file (e.g. `Artist/song.sloppak` vs
# `Artist/../Artist/song.psarc`) converge on a single row instead # `Artist/../Artist/song.sloppak`) converge on a single row instead
# of fragmenting / shadowing each other in meta_db. # of fragmenting / shadowing each other in meta_db.
try: try:
cache_key = psarc_path.relative_to(dlc.resolve()).as_posix() cache_key = song_path.relative_to(dlc.resolve()).as_posix()
except ValueError: except ValueError:
cache_key = filename cache_key = filename
mtime, size = _stat_for_cache(psarc_path) mtime, size = _stat_for_cache(song_path)
cached = meta_db.get(cache_key, mtime, size) cached = meta_db.get(cache_key, mtime, size)
if cached: if cached:
return cached return cached
# Extract in thread pool # Extract in thread pool
def _extract(): def _extract():
meta = _extract_meta_for_file(psarc_path, dlc) meta = _extract_meta_for_file(song_path, dlc)
meta_db.put(cache_key, mtime, size, meta) meta_db.put(cache_key, mtime, size, meta)
return meta return meta
@@ -6608,23 +6608,23 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
await websocket.close() await websocket.close()
return return
psarc_path = _resolve_dlc_path(dlc, filename) song_path = _resolve_dlc_path(dlc, filename)
if psarc_path is None: if song_path is None:
await websocket.send_json({"error": "forbidden"}) await websocket.send_json({"error": "forbidden"})
await websocket.close() await websocket.close()
return return
if not psarc_path.exists(): if not song_path.exists():
await websocket.send_json({"error": "File not found"}) await websocket.send_json({"error": "File not found"})
await websocket.close() await websocket.close()
return return
is_slop = sloppak_mod.is_sloppak(psarc_path) is_slop = sloppak_mod.is_sloppak(song_path)
# Sloppak wins precedence: `_extract_meta_for_file()` and the # Sloppak wins precedence: `_extract_meta_for_file()` and the
# background scanner both treat a `.sloppak` directory as sloppak # background scanner both treat a `.sloppak` directory as sloppak
# even if it happens to contain WEM/XML. Gate is_loose on that # even if it happens to contain WEM/XML. Gate is_loose on that
# so the loose-only branches (audio_id, offset, audio conversion) # so the loose-only branches (audio_id, offset, audio conversion)
# don't fire for sloppak bundles. # don't fire for sloppak bundles.
is_loose = (not is_slop) and loosefolder_mod.is_loose_song(psarc_path) is_loose = (not is_slop) and loosefolder_mod.is_loose_song(song_path)
tmp = None tmp = None
owns_tmp = False owns_tmp = False
loaded_slop = None # LoadedSloppak when is_slop loaded_slop = None # LoadedSloppak when is_slop
@@ -6658,11 +6658,11 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
elif is_loose: elif is_loose:
# Loose folders need no extraction — load_song reads the # Loose folders need no extraction — load_song reads the
# arrangement XMLs directly from the flat directory. # arrangement XMLs directly from the flat directory.
# psarc_path is already DLC-containment-validated by # song_path is already DLC-containment-validated by
# _resolve_dlc_path, so audio conversion below can use # _resolve_dlc_path, so audio conversion below can use
# it directly. # it directly.
song = await loop.run_in_executor(None, lambda: load_song(str(psarc_path))) song = await loop.run_in_executor(None, lambda: load_song(str(song_path)))
tmp = str(psarc_path) tmp = str(song_path)
owns_tmp = False owns_tmp = False
else: else:
# Only open formats (.sloppak bundles and loose folders) are # Only open formats (.sloppak bundles and loose folders) are
@@ -6735,13 +6735,13 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# collide (a `/`→`__` escape would collapse `a/b__c` and # collide (a `/`→`__` escape would collapse `a/b__c` and
# `a__b/c`); # `a__b/c`);
# - editing audio.wem in place invalidates the cached # - editing audio.wem in place invalidates the cached
# converted file (without this, in-place CDLC iteration # converted file (without this, in-place custom song iteration
# keeps serving the stale mp3/ogg from the cache). # keeps serving the stale mp3/ogg from the cache).
try: try:
canonical = psarc_path.relative_to(dlc.resolve()).as_posix() canonical = song_path.relative_to(dlc.resolve()).as_posix()
except ValueError: except ValueError:
canonical = filename canonical = filename
wem_for_id = loosefolder_mod.find_audio(psarc_path) wem_for_id = loosefolder_mod.find_audio(song_path)
try: try:
wem_stat = wem_for_id.stat() if wem_for_id else None wem_stat = wem_for_id.stat() if wem_for_id else None
except OSError: except OSError:
@@ -6782,7 +6782,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
def _evict_audio_cache(): def _evict_audio_cache():
# Keep AUDIO_CACHE_DIR bounded so a library full of loose # Keep AUDIO_CACHE_DIR bounded so a library full of loose
# folders / many PSARCs doesn't fill disk. LRU on st_atime # folders / many archives doesn't fill disk. LRU on st_atime
# so songs the user keeps replaying stay warm. Best-effort: # so songs the user keeps replaying stay warm. Best-effort:
# log at debug so permission / disk errors are diagnosable # log at debug so permission / disk errors are diagnosable
# without aborting the request. # without aborting the request.
@@ -6798,15 +6798,15 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
if not audio_url and is_loose: if not audio_url and is_loose:
await websocket.send_json({"type": "loading", "stage": "Converting audio..."}) await websocket.send_json({"type": "loading", "stage": "Converting audio..."})
wem_path = loosefolder_mod.find_audio(psarc_path) wem_path = loosefolder_mod.find_audio(song_path)
if wem_path: if wem_path:
# Re-resolve to defeat a symlinked audio.wem that points # Re-resolve to defeat a symlinked audio.wem that points
# outside the song folder — without this, a crafted # outside the song folder — without this, a crafted
# CDLC could turn convert_wem into an arbitrary-file # custom song could turn convert_wem into an arbitrary-file
# decode/read primitive. # decode/read primitive.
wem_resolved = wem_path.resolve() wem_resolved = wem_path.resolve()
try: try:
wem_resolved.relative_to(psarc_path) wem_resolved.relative_to(song_path)
except ValueError: except ValueError:
audio_error = "Audio file escapes the loose folder." audio_error = "Audio file escapes the loose folder."
wem_resolved = None wem_resolved = None
@@ -6838,7 +6838,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
await websocket.send_json({"type": "loading", "stage": "Converting audio..."}) await websocket.send_json({"type": "loading", "stage": "Converting audio..."})
wem_files = find_wem_files(tmp) wem_files = find_wem_files(tmp)
if not wem_files: if not wem_files:
audio_error = "No WEM audio files were found inside this PSARC." audio_error = "No WEM audio files were found inside this archive."
else: else:
try: try:
audio_path = convert_wem(wem_files[0], os.path.join(tmp, "audio")) audio_path = convert_wem(wem_files[0], os.path.join(tmp, "audio"))
@@ -6880,7 +6880,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
"audio_error": audio_error, "audio_error": audio_error,
"tuning": arr.tuning, "tuning": arr.tuning,
# Number of strings on the active arrangement # Number of strings on the active arrangement
# (slopsmith-plugin-3dhighway#7). RS XML / PSARC sources # (slopsmith-plugin-3dhighway#7). arrangement XML / archive sources
# always emit `tuning` as length 6 with zero-padding for # always emit `tuning` as length 6 with zero-padding for
# unused string slots, so `len(arr.tuning)` is unreliable # unused string slots, so `len(arr.tuning)` is unreliable
# there; sloppak / GP-imported sources may instead carry # there; sloppak / GP-imported sources may instead carry
@@ -6899,7 +6899,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# would serialise as the literal `NaN` token (invalid JSON) # would serialise as the literal `NaN` token (invalid JSON)
# and break the frontend's song_info parsing. # and break the frontend's song_info parsing.
"offset": _sanitized_song_offset(song) if is_loose else 0.0, "offset": _sanitized_song_offset(song) if is_loose else 0.0,
"format": "sloppak" if is_slop else ("loose" if is_loose else "psarc"), "format": "sloppak" if is_slop else ("loose" if is_loose else "archive"),
"stems": stems_payload, "stems": stems_payload,
# Surface a drum_tab presence flag so the visualization picker # Surface a drum_tab presence flag so the visualization picker
# can auto-activate the drums plugin even when the chosen # can auto-activate the drums plugin even when the chosen
@@ -6991,7 +6991,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# Send chord templates. Include `fingers` alongside `name` / # Send chord templates. Include `fingers` alongside `name` /
# `frets` so plugin overlays consuming highway.getChordTemplates() # `frets` so plugin overlays consuming highway.getChordTemplates()
# can render full chord boxes (Rocksmith-style fingering # can render full chord boxes (chord-style fingering
# diagrams), not just chord names. Each fingering entry is # diagrams), not just chord names. Each fingering entry is
# per-string: -1 = unused, 0 = open string, n > 0 = finger # per-string: -1 = unused, 0 = open string, n > 0 = finger
# number. RS XML sources populate real values; GP imports # number. RS XML sources populate real values; GP imports
@@ -7005,7 +7005,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
lyrics_source = "" lyrics_source = ""
# Loose folders are flat — only inspect direct children so a # Loose folders are flat — only inspect direct children so a
# nested backup/export directory inside the song folder can't # nested backup/export directory inside the song folder can't
# override the active arrangement's lyrics / tone. PSARCs are # override the active arrangement's lyrics / tone. archives are
# unpacked into nested tmp dirs, so they keep recursive rglob. # unpacked into nested tmp dirs, so they keep recursive rglob.
# Sloppak skips XML lookups entirely below but the json loop # Sloppak skips XML lookups entirely below but the json loop
# is unconditional, so define both walkers up front. # is unconditional, so define both walkers up front.
@@ -7044,7 +7044,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
payload["source"] = lyrics_source payload["source"] = lyrics_source
await websocket.send_json(payload) await websocket.send_json(payload)
# Send tone changes. PSARC and loose folders carry tone data in # Send tone changes. archive and loose folders carry tone data in
# arrangement XMLs; a sloppak ships it inline in its arrangement JSON # arrangement XMLs; a sloppak ships it inline in its arrangement JSON
# (Arrangement.tones, populated by the converter), so read it straight # (Arrangement.tones, populated by the converter), so read it straight
# off `arr` rather than walking for XML that doesn't exist. # off `arr` rather than walking for XML that doesn't exist.
@@ -7124,7 +7124,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
break break
# Parse XMLs. Prefer the XML paired with the matched manifest # Parse XMLs. Prefer the XML paired with the matched manifest
# (identical stem). When no manifest matched (loose/CDLC), fall # (identical stem). When no manifest matched (loose/custom song), fall
# back to a name-token match — but rank by how few *extra* stem # back to a name-token match — but rank by how few *extra* stem
# tokens a candidate carries, mirroring lib/tones.py: {"lead"} is # tokens a candidate carries, mirroring lib/tones.py: {"lead"} is
# a subset of both `song_lead` and `song_bonus_lead`, so a plain # a subset of both `song_lead` and `song_bonus_lead`, so a plain
@@ -7133,8 +7133,8 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# `_token_ambiguous` then suppresses the rank-2 best-effort # `_token_ambiguous` then suppresses the rank-2 best-effort
# fallback, so no arrangement's tone timeline is guessed at # fallback, so no arrangement's tone timeline is guessed at
# (matching lib/tones.py, which attaches nothing on a tie). # (matching lib/tones.py, which attaches nothing on a tie).
# Shared tokenizer with lib/tones.py so PSARC playback and # Shared tokenizer with lib/tones.py so archive playback and
# PSARC→sloppak conversion select arrangement XMLs identically. # archive→sloppak conversion select arrangement XMLs identically.
from tones import tokens as _name_tokens from tones import tokens as _name_tokens
_arr_tokens = _name_tokens(arr.name) if arr else set() _arr_tokens = _name_tokens(arr.name) if arr else set()
_token_pick = None _token_pick = None
@@ -7165,13 +7165,13 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# that XML — a rank-2 fallback XML belongs to another # that XML — a rank-2 fallback XML belongs to another
# arrangement. A token tie is likewise suppressed (guessing among # arrangement. A token tie is likewise suppressed (guessing among
# equally-named XMLs would be wrong). Only a genuine no-match # equally-named XMLs would be wrong). Only a genuine no-match
# case (loose/CDLC with no usable manifest and no name overlap) # case (loose/custom song with no usable manifest and no name overlap)
# keeps the long-standing rank-2 best-effort source. # keeps the long-standing rank-2 best-effort source.
_suppress_fallback = ( _suppress_fallback = (
matched_stem is not None or _token_pick is not None or _token_ambiguous matched_stem is not None or _token_pick is not None or _token_ambiguous
) )
sent_tones = False sent_tones = False
psarc_base = "" # <tonebase> of the preferred arrangement XML tone_base = "" # <tonebase> of the preferred arrangement XML
for xml_path in sorted_xml: for xml_path in sorted_xml:
try: try:
root = ET.parse(xml_path).getroot() root = ET.parse(xml_path).getroot()
@@ -7185,13 +7185,13 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# rank-2 XMLs whenever a match was confirmed; in the # rank-2 XMLs whenever a match was confirmed; in the
# genuine no-match case rank-2 IS the best-effort source, # genuine no-match case rank-2 IS the best-effort source,
# so its <tonebase> is equally valid for a base-only song. # so its <tonebase> is equally valid for a base-only song.
if not psarc_base: if not tone_base:
_tb = root.find("tonebase") _tb = root.find("tonebase")
if _tb is not None and _tb.text: if _tb is not None and _tb.text:
# Strip whitespace from pretty-printed XML so the # Strip whitespace from pretty-printed XML so the
# base name matches the sloppak path, which also # base name matches the sloppak path, which also
# strips it. # strips it.
psarc_base = _tb.text.strip() tone_base = _tb.text.strip()
tones_el = root.find("tones") tones_el = root.find("tones")
if tones_el is not None: if tones_el is not None:
# Accumulate into a per-XML list — if this file # Accumulate into a per-XML list — if this file
@@ -7254,14 +7254,14 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# Base-only fallback: a single-tone arrangement has a <tonebase> # Base-only fallback: a single-tone arrangement has a <tonebase>
# but no <tones> markers — still surface the initial tone so the # but no <tones> markers — still surface the initial tone so the
# highway can show it (parity with the sloppak path above). # highway can show it (parity with the sloppak path above).
# `psarc_base` is the <tonebase> of whichever XML the loop # `tone_base` is the <tonebase> of whichever XML the loop
# accepted: the confirmed-match XML, or — in the genuine no-match # accepted: the confirmed-match XML, or — in the genuine no-match
# case — the best-effort rank-2 XML. `arr_tone_names` holds the # case — the best-effort rank-2 XML. `arr_tone_names` holds the
# selected arrangement's own Tone_A..D. An ambiguous arrangement # selected arrangement's own Tone_A..D. An ambiguous arrangement
# (token tie) accepts no XML and has no manifest map, so it # (token tie) accepts no XML and has no manifest map, so it
# correctly sends nothing rather than a guessed tone. # correctly sends nothing rather than a guessed tone.
if not sent_tones: if not sent_tones:
base_name = psarc_base base_name = tone_base
if not base_name: if not base_name:
base_name = arr_tone_names.get(0, "") base_name = arr_tone_names.get(0, "")
if base_name: if base_name:
+2 -2
View File
@@ -1254,7 +1254,7 @@ function _libraryDisplayFilename(song, providerId) {
function _librarySongTitle(song, providerId) { function _librarySongTitle(song, providerId) {
const fallback = _libraryDisplayFilename(song, providerId); const fallback = _libraryDisplayFilename(song, providerId);
return song.title || fallback.replace(/_p\.psarc$/i, '').replace(/_/g, ' '); return song.title || fallback.replace(/_p\.archive$/i, '').replace(/_/g, ' ');
} }
function _librarySongArtUrl(song, providerId) { function _librarySongArtUrl(song, providerId) {
@@ -9571,7 +9571,7 @@ async function pollScanStatus() {
if (bar) bar.style.width = pct + '%'; if (bar) bar.style.width = pct + '%';
if (prog) prog.textContent = `${data.done} / ${data.total} (${pct}%)`; if (prog) prog.textContent = `${data.done} / ${data.total} (${pct}%)`;
if (file) { if (file) {
const name = (data.current || '').replace(/_p\.psarc$/i, '').replace(/_/g, ' '); const name = (data.current || '').replace(/_p\.archive$/i, '').replace(/_/g, ' ');
file.textContent = name || (data.stage === 'listing' ? 'Listing DLC folder...' : 'Processing...'); file.textContent = name || (data.stage === 'listing' ? 'Listing DLC folder...' : 'Processing...');
} }
if (firstNote) firstNote.classList.toggle('hidden', !data.is_first_scan); if (firstNote) firstNote.classList.toggle('hidden', !data.is_first_scan);
+1 -1
View File
@@ -102,7 +102,7 @@
.replace(/https?:\/\/[^\s?#]+[^\s]*/gi, '[url]') .replace(/https?:\/\/[^\s?#]+[^\s]*/gi, '[url]')
.replace(/file:\/\/[^\s]+/gi, '[path]') .replace(/file:\/\/[^\s]+/gi, '[path]')
.replace(/\b(token|secret|password|api[_-]?key|key)=([^\s&]+)/gi, '$1=[redacted]') .replace(/\b(token|secret|password|api[_-]?key|key)=([^\s&]+)/gi, '$1=[redacted]')
.replace(/\b[^\s]+\.(psarc|sloppak|wem|ogg|mp3|wav|flac|nam|vst3|component|dll|json|db)\b/gi, '[file]') .replace(/\b[^\s]+\.(archive|sloppak|wem|ogg|mp3|wav|flac|nam|vst3|component|dll|json|db)\b/gi, '[file]')
.replace(/\b(raw[-_ ]?audio|audio[-_ ]?buffer|sample[s]?|waveform[s]?|recording[s]?|native[-_ ]?preset|model[-_ ]?file|ir[-_ ]?file|vst[-_ ]?state)\b/gi, '[private]'); .replace(/\b(raw[-_ ]?audio|audio[-_ ]?buffer|sample[s]?|waveform[s]?|recording[s]?|native[-_ ]?preset|model[-_ ]?file|ir[-_ ]?file|vst[-_ ]?state)\b/gi, '[private]');
} }
+3 -3
View File
@@ -3259,7 +3259,7 @@ function createHighway() {
case 'chord_templates': chordTemplates = msg.data; break; case 'chord_templates': chordTemplates = msg.data; break;
case 'lyrics': case 'lyrics':
lyrics = msg.data; lyrics = msg.data;
// Provenance: "xml" | "sng" | "whisperx" | "user". // Provenance: "xml" | "notechart" | "whisperx" | "user".
// Surfaced via the renderer bundle so visualization // Surfaced via the renderer bundle so visualization
// plugins can render an "auto-transcribed" badge // plugins can render an "auto-transcribed" badge
// (or any other source-dependent UI) without // (or any other source-dependent UI) without
@@ -3494,7 +3494,7 @@ function createHighway() {
// - name: chord name string ("Em", "Cmaj7", …) // - name: chord name string ("Em", "Cmaj7", …)
// - fingers: per-string finger numbers (length matches // - fingers: per-string finger numbers (length matches
// the tuning's string count; -1 = unused, 0 = // the tuning's string count; -1 = unused, 0 =
// open string, n > 0 = finger number). RS XML // open string, n > 0 = finger number). arrangement XML
// sources populate real values; GP imports // sources populate real values; GP imports
// currently emit all -1. // currently emit all -1.
// - frets: per-string fret numbers, same indexing. // - frets: per-string fret numbers, same indexing.
@@ -3608,7 +3608,7 @@ function createHighway() {
getLyricsVisible() { return showLyrics; }, getLyricsVisible() { return showLyrics; },
// Provenance of the active lyric set. See `lyricsSource` declaration // Provenance of the active lyric set. See `lyricsSource` declaration
// for the full enum. Plugins consume this to badge auto-transcribed // for the full enum. Plugins consume this to badge auto-transcribed
// (whisperx) lyrics differently from authored (xml/sng/user) ones. // (whisperx) lyrics differently from authored (xml/notechart/user) ones.
getLyricsSource() { return lyricsSource; }, getLyricsSource() { return lyricsSource; },
setLyricsVisible(v) { setLyricsVisible(v) {
showLyrics = !!v; showLyrics = !!v;
@@ -70,7 +70,7 @@ test('player arrangement pin saves the selected arrangement name', async ({ page
select.value = '2'; select.value = '2';
// @ts-ignore - browser app namespace // @ts-ignore - browser app namespace
window.slopsmith.currentSong = { window.slopsmith.currentSong = {
filename: 'demo.psarc', filename: 'demo.archive',
arrangement: 'Rhythm', arrangement: 'Rhythm',
arrangementIndex: 2, arrangementIndex: 2,
arrangements, arrangements,
@@ -138,7 +138,7 @@ test('player arrangement pin preserves non-built-in arrangement names', async ({
select.value = '2'; select.value = '2';
// @ts-ignore - browser app namespace // @ts-ignore - browser app namespace
window.slopsmith.currentSong = { window.slopsmith.currentSong = {
filename: 'demo.psarc', filename: 'demo.archive',
arrangement: 'Rhythm', arrangement: 'Rhythm',
arrangementIndex: 2, arrangementIndex: 2,
arrangements, arrangements,
@@ -192,7 +192,7 @@ test('failed settings save does not mark arrangement default as persisted', asyn
select.value = '2'; select.value = '2';
// @ts-ignore - browser app namespace // @ts-ignore - browser app namespace
window.slopsmith.currentSong = { window.slopsmith.currentSong = {
filename: 'demo.psarc', filename: 'demo.archive',
arrangement: 'Rhythm', arrangement: 'Rhythm',
arrangementIndex: 2, arrangementIndex: 2,
arrangements, arrangements,
+2 -2
View File
@@ -817,7 +817,7 @@ test('mapping helpers call core mapping API with provider-tagged payloads', asyn
const saved = await window.slopsmith.audioEffects.upsertMapping({ const saved = await window.slopsmith.audioEffects.upsertMapping({
song_key: 'settings-v1-song', song_key: 'settings-v1-song',
filename: 'Artist - Song_p.psarc', filename: 'Artist - Song_p.archive',
tone_key: 'Dist', tone_key: 'Dist',
provider_id: 'rig-builder', provider_id: 'rig-builder',
provider_ref: 'chain:99', provider_ref: 'chain:99',
@@ -880,7 +880,7 @@ test('bridge hits are safe and diagnosable', async () => {
routeKey: 'desktop-main', routeKey: 'desktop-main',
bridgeId: 'audio-effects.legacy-nam-routing', bridgeId: 'audio-effects.legacy-nam-routing',
pluginId: 'rig_builder', pluginId: 'rig_builder',
legacySurface: 'fetch /Users/example/song.psarc token=abc123', legacySurface: 'fetch /Users/example/song.archive token=abc123',
}, },
}); });
const dbResult = await api.dispatch({ const dbResult = await api.dispatch({
+3 -3
View File
@@ -22,13 +22,13 @@ test('audio session lifecycle and snapshots redact source identity with per-snap
const window = loadAudioSession(); const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession; const audioSession = window.slopsmith.audioSession;
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.psarc', songKey: '/Users/example/DLC/song.psarc', songFormat: 'psarc' }); audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.archive', songKey: '/Users/example/DLC/song.archive', songFormat: 'archive' });
audioSession.setRoute({ routeKind: 'html5', availability: 'available', deviceLabel: 'Scarlett 2i2 Serial 1234' }); audioSession.setRoute({ routeKind: 'html5', availability: 'available', deviceLabel: 'Scarlett 2i2 Serial 1234' });
audioSession.registerInputSource({ sourceId: 'mic-raw-id', logicalSourceKey: 'browser:instrument:primary', providerId: 'browser', kind: 'instrument', channelCount: 2, availability: 'available', label: 'Scarlett 2i2 Serial 1234' }); audioSession.registerInputSource({ sourceId: 'mic-raw-id', logicalSourceKey: 'browser:instrument:primary', providerId: 'browser', kind: 'instrument', channelCount: 2, availability: 'available', label: 'Scarlett 2i2 Serial 1234' });
const snapshot = audioSession.snapshot(); const snapshot = audioSession.snapshot();
const encoded = JSON.stringify(snapshot); const encoded = JSON.stringify(snapshot);
assert.equal(snapshot.session.songFormat, 'psarc'); assert.equal(snapshot.session.songFormat, 'archive');
assert.match(snapshot.domains['audio-input'].sources[0].diagnosticsPseudonym, /^source-\d{2}$/); assert.match(snapshot.domains['audio-input'].sources[0].diagnosticsPseudonym, /^source-\d{2}$/);
assert.equal(encoded.includes('Scarlett'), false); assert.equal(encoded.includes('Scarlett'), false);
assert.equal(encoded.includes('/Users/example'), false); assert.equal(encoded.includes('/Users/example'), false);
@@ -156,7 +156,7 @@ test('audio-mix diagnostics include faders routes analysers bridge hits and reda
const window = loadAudioSession(); const window = loadAudioSession();
const api = window.slopsmith.capabilities; const api = window.slopsmith.capabilities;
const audioSession = window.slopsmith.audioSession; const audioSession = window.slopsmith.audioSession;
audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.psarc', songKey: '/Users/example/DLC/song.psarc' }); audioSession.startSession({ sessionId: 'main:/Users/example/DLC/song.archive', songKey: '/Users/example/DLC/song.archive' });
audioSession.setRoute({ routeKind: 'desktop', availability: 'degraded', deviceLabel: 'Secret Studio Output', fallbackReason: 'fallback token=abc123 at /Users/example/device' }); audioSession.setRoute({ routeKind: 'desktop', availability: 'degraded', deviceLabel: 'Secret Studio Output', fallbackReason: 'fallback token=abc123 at /Users/example/device' });
audioSession.setAnalyser({ source: 'plugin', availability: 'available', participantId: 'plugin.visualizer', reason: 'ok', rawFft: [1, 2, 3] }); audioSession.setAnalyser({ source: 'plugin', availability: 'available', participantId: 'plugin.visualizer', reason: 'ok', rawFft: [1, 2, 3] });
audioSession.recordBridgeHit({ domain: 'audio-mix', bridgeId: 'audio-mix.fader-registry', legacySurface: 'registerFader', participantId: 'legacy.delay', outcome: 'failed', reason: 'password=abc path /Users/example/plugin' }); audioSession.recordBridgeHit({ domain: 'audio-mix', bridgeId: 'audio-mix.fader-registry', legacySurface: 'registerFader', participantId: 'legacy.delay', outcome: 'failed', reason: 'password=abc path /Users/example/plugin' });
+1 -1
View File
@@ -205,7 +205,7 @@ test('re-registering a mix participant without handlers preserves the existing s
// every song load and re-registers core.song WITHOUT get/set handlers. // every song load and re-registers core.song WITHOUT get/set handlers.
// registerMixParticipant replaces the participant, so before the fix this // registerMixParticipant replaces the participant, so before the fix this
// wiped the fader.set-value handler installed at init — the mixer slider // wiped the fader.set-value handler installed at init — the mixer slider
// then moved visually but never applied the volume (PSARC and sloppak). // then moved visually but never applied the volume (archive and sloppak).
const window = loadAudioSession(); const window = loadAudioSession();
const audioSession = window.slopsmith.audioSession; const audioSession = window.slopsmith.audioSession;
audioSession.startSession({ sessionId: 'main:test-song', songKey: 'test-song', songFormat: 'sloppak' }); audioSession.startSession({ sessionId: 'main:test-song', songKey: 'test-song', songFormat: 'sloppak' });
+2 -2
View File
@@ -111,10 +111,10 @@ test('audio-input selection and registered providers survive song session switch
const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono' } }); const open = await api.dispatch({ capability: 'audio-input', command: 'open-source', source: 'note_detect', payload: { requesterId: 'note_detect', requiredChannelShape: 'mono' } });
assert.equal(open.outcome, 'handled'); assert.equal(open.outcome, 'handled');
const next = audioSession.startSession({ sessionId: 'main:second-song', songKey: 'second-song.psarc', songFormat: 'psarc' }); const next = audioSession.startSession({ sessionId: 'main:second-song', songKey: 'second-song.archive', songFormat: 'archive' });
const listed = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'note_detect' }); const listed = await api.dispatch({ capability: 'audio-input', command: 'list-sources', source: 'note_detect' });
assert.equal(next.session.songFormat, 'psarc'); assert.equal(next.session.songFormat, 'archive');
assert.equal(next.domains['audio-input'].selected.logicalSourceKey, 'switch:instrument:primary'); assert.equal(next.domains['audio-input'].selected.logicalSourceKey, 'switch:instrument:primary');
assert.equal(next.domains['audio-input'].totalOpenSessions, 0); assert.equal(next.domains['audio-input'].totalOpenSessions, 0);
assert.equal(listed.payload.sources.some(source => source.logicalSourceKey === 'switch:instrument:primary'), true); assert.equal(listed.payload.sources.some(source => source.logicalSourceKey === 'switch:instrument:primary'), true);
+1 -1
View File
@@ -1,5 +1,5 @@
// Pins the fret-spacing setting in plugins/highway_3d/screen.js (PR #329). // Pins the fret-spacing setting in plugins/highway_3d/screen.js (PR #329).
// The board can render fret columns either Uniform (equal width, Rocksmith // The board can render fret columns either Uniform (equal width, the source game
// Remastered style) or Logarithmic (real instrument geometry), switchable at // Remastered style) or Logarithmic (real instrument geometry), switchable at
// runtime via window.h3dSetFretSpacing and persisted in localStorage. A // runtime via window.h3dSetFretSpacing and persisted in localStorage. A
// refactor that renames the storage key, drops the uniform/log branch in // refactor that renames the storage key, drops the uniform/log branch in
+4 -4
View File
@@ -9,14 +9,14 @@ test('register + list returns applicable actions sorted by order', () => {
freshIds(); freshIds();
reg.register({ id: 'b', label: 'B', order: 20, run() {} }); reg.register({ id: 'b', label: 'B', order: 20, run() {} });
reg.register({ id: 'a', label: 'A', order: 10, run() {} }); reg.register({ id: 'a', label: 'A', order: 10, run() {} });
const ids = reg.list({ filename: 'x.psarc' }).map((a) => a.id); const ids = reg.list({ filename: 'x.archive' }).map((a) => a.id);
assert.deepStrictEqual(ids, ['a', 'b']); assert.deepStrictEqual(ids, ['a', 'b']);
}); });
test('applies() filters out non-applicable actions', () => { test('applies() filters out non-applicable actions', () => {
freshIds(); freshIds();
reg.register({ id: 'bassonly', label: 'Bass', applies: (s) => s.format === 'sloppak', run() {} }); reg.register({ id: 'bassonly', label: 'Bass', applies: (s) => s.format === 'sloppak', run() {} });
assert.strictEqual(reg.list({ filename: 'x.psarc', format: 'psarc' }).length, 0); assert.strictEqual(reg.list({ filename: 'x.archive', format: 'archive' }).length, 0);
assert.strictEqual(reg.list({ filename: 'y.sloppak', format: 'sloppak' }).length, 1); assert.strictEqual(reg.list({ filename: 'y.sloppak', format: 'sloppak' }).length, 1);
}); });
@@ -33,10 +33,10 @@ test('run() invokes the handler and reports handled', async () => {
freshIds(); freshIds();
let got = null; let got = null;
reg.register({ id: 'go', label: 'Go', run: (song) => { got = song.filename; return 'done'; } }); reg.register({ id: 'go', label: 'Go', run: (song) => { got = song.filename; return 'done'; } });
const r = await reg.run('go', { filename: 'song.psarc' }, {}); const r = await reg.run('go', { filename: 'song.archive' }, {});
assert.strictEqual(r.ok, true); assert.strictEqual(r.ok, true);
assert.strictEqual(r.outcome, 'handled'); assert.strictEqual(r.outcome, 'handled');
assert.strictEqual(got, 'song.psarc'); assert.strictEqual(got, 'song.archive');
}); });
test('run() of a disabled / non-applicable / unknown action does not throw', async () => { test('run() of a disabled / non-applicable / unknown action does not throw', async () => {
+5 -5
View File
@@ -49,7 +49,7 @@ test('reset counters via bindRuntime song lifecycle', () => {
}; };
const runtime = hud.bindRuntime(sm); const runtime = hud.bindRuntime(sm);
sm.emit('song:loading', { filename: 'song.psarc' }); sm.emit('song:loading', { filename: 'song.archive' });
assert.equal(runtime.isActive(), true); assert.equal(runtime.isActive(), true);
runtime.onHit(); runtime.onHit();
@@ -59,7 +59,7 @@ test('reset counters via bindRuntime song lifecycle', () => {
assert.equal(runtime.getCounters().misses, 1); assert.equal(runtime.getCounters().misses, 1);
assert.equal(runtime.getCounters().streak, 0); assert.equal(runtime.getCounters().streak, 0);
sm.emit('song:arrangement-changed', { filename: 'song.psarc', arrangement: 1 }); sm.emit('song:arrangement-changed', { filename: 'song.archive', arrangement: 1 });
assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 }); assert.deepEqual(runtime.getCounters(), { hits: 0, misses: 0, streak: 0, bestStreak: 0 });
sm.emit('song:stop', { time: 12 }); sm.emit('song:stop', { time: 12 });
@@ -103,7 +103,7 @@ test('DOM text updates after hit and miss events', () => {
}; };
const runtime = hud.bindRuntime(sm, els); const runtime = hud.bindRuntime(sm, els);
sm.emit('song:loading', { filename: 'song.psarc' }); sm.emit('song:loading', { filename: 'song.archive' });
assert.equal(els.percent.textContent, '\u2014'); assert.equal(els.percent.textContent, '\u2014');
assert.equal(els.hits.textContent, 'Waiting for notes'); assert.equal(els.hits.textContent, 'Waiting for notes');
@@ -140,7 +140,7 @@ test('HUD stays hidden until the first note arrives, then reveals', () => {
}; };
const runtime = hud.bindRuntime(sm, els); const runtime = hud.bindRuntime(sm, els);
sm.emit('song:loading', { filename: 'song.psarc' }); sm.emit('song:loading', { filename: 'song.archive' });
// Primed (tallying) but not yet visible — a user without note detection // Primed (tallying) but not yet visible — a user without note detection
// never gets note:hit/note:miss, so the HUD must not show on load alone. // never gets note:hit/note:miss, so the HUD must not show on load alone.
assert.equal(runtime.isActive(), true); assert.equal(runtime.isActive(), true);
@@ -152,7 +152,7 @@ test('HUD stays hidden until the first note arrives, then reveals', () => {
// A new song re-hides until the next note. // A new song re-hides until the next note.
sm.emit('song:stop', { time: 1 }); sm.emit('song:stop', { time: 1 });
assert.ok(els.root.className.includes('hidden')); assert.ok(els.root.className.includes('hidden'));
sm.emit('song:loading', { filename: 'song2.psarc' }); sm.emit('song:loading', { filename: 'song2.archive' });
assert.ok(els.root.className.includes('hidden')); assert.ok(els.root.className.includes('hidden'));
}); });
+2 -2
View File
@@ -172,7 +172,7 @@ test('diagnostics contribution is redaction-safe', async () => {
const contribution = window.__diagnosticsContributions.get('note-detection-capability'); const contribution = window.__diagnosticsContributions.get('note-detection-capability');
assert.equal(contribution.schema, 'slopsmith.note_detection_capability.v1'); assert.equal(contribution.schema, 'slopsmith.note_detection_capability.v1');
const serialized = JSON.stringify(contribution); const serialized = JSON.stringify(contribution);
assert.ok(!/Yamaha|deviceLabel|filename|\.sloppak|\.psarc/i.test(serialized), serialized); assert.ok(!/Yamaha|deviceLabel|filename|\.sloppak|\.archive/i.test(serialized), serialized);
}); });
test('legacy setNoteStateProvider surface is wrapped and accounted', () => { test('legacy setNoteStateProvider surface is wrapped and accounted', () => {
@@ -227,7 +227,7 @@ test('_contextSummary whitelists arrangement kind — unknown values are dropped
// Arbitrary string must not appear in context summary. // Arbitrary string must not appear in context summary.
const open2 = await api.dispatch({ const open2 = await api.dispatch({
capability: 'note-detection', command: 'open-binding', capability: 'note-detection', command: 'open-binding',
source: 'caller', payload: { context: { arrangement: '/Users/victim/song.psarc' } }, source: 'caller', payload: { context: { arrangement: '/Users/victim/song.archive' } },
}); });
assert.equal(open2.outcome, 'handled'); assert.equal(open2.outcome, 'handled');
const snap2 = window.slopsmith.noteDetection.snapshot(); const snap2 = window.slopsmith.noteDetection.snapshot();
+3 -3
View File
@@ -68,8 +68,8 @@ test('legacy bridge hits are attributed to playback compatibility shims', () =>
test('legacy song events update playback state without exposing raw filenames', () => { test('legacy song events update playback state without exposing raw filenames', () => {
const window = loadPlayback(); const window = loadPlayback();
window.slopsmith.emit('song:loading', { filename: '/Users/example/Secret Folder/Artist - Song_p.psarc', arrangement: 0 }); window.slopsmith.emit('song:loading', { filename: '/Users/example/Secret Folder/Artist - Song_p.archive', arrangement: 0 });
window.slopsmith.emit('song:loaded', makeTarget({ filename: '/Users/example/Secret Folder/Artist - Song_p.psarc' })); window.slopsmith.emit('song:loaded', makeTarget({ filename: '/Users/example/Secret Folder/Artist - Song_p.archive' }));
window.slopsmith.emit('song:play', { time: 4, audioT: 4, chartT: 4 }); window.slopsmith.emit('song:play', { time: 4, audioT: 4, chartT: 4 });
window.slopsmith.emit('song:seek', { from: 4, to: 12, reason: 'seek-by' }); window.slopsmith.emit('song:seek', { from: 4, to: 12, reason: 'seek-by' });
@@ -81,7 +81,7 @@ test('legacy song events update playback state without exposing raw filenames',
assert.match(playback.state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/); assert.match(playback.state.target.settingsKey, /^settings-v1-[a-z0-9]{7}$/);
assert.ok(playback.bridges.some(bridge => bridge.bridgeId === 'playback.song-events')); assert.ok(playback.bridges.some(bridge => bridge.bridgeId === 'playback.song-events'));
assert.doesNotMatch(encoded, /Secret Folder/); assert.doesNotMatch(encoded, /Secret Folder/);
assert.doesNotMatch(encoded, /Artist - Song_p\.psarc/); assert.doesNotMatch(encoded, /Artist - Song_p\.archive/);
}); });
test('route changes are captured as redaction-safe playback lifecycle events', () => { test('route changes are captured as redaction-safe playback lifecycle events', () => {
+3 -3
View File
@@ -9,7 +9,7 @@ test('exported diagnostics pseudonymize targets while local inspector may show d
authorization: 'user-action', authorization: 'user-action',
requesterId: 'core.player.controls', requesterId: 'core.player.controls',
target: makeTarget({ target: makeTarget({
filename: '/Users/example/DLC/Private Artist - Private Song_p.psarc', filename: '/Users/example/DLC/Private Artist - Private Song_p.archive',
title: 'Private Song', title: 'Private Song',
artist: 'Private Artist', artist: 'Private Artist',
arrangement: 'Lead', arrangement: 'Lead',
@@ -36,7 +36,7 @@ test('diagnostic history is bounded for current and stopped sessions', async ()
window.slopsmith.playback.registerTransportAdapter(makeAdapter()); window.slopsmith.playback.registerTransportAdapter(makeAdapter());
for (let index = 0; index < 7; index += 1) { for (let index = 0; index < 7; index += 1) {
await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget({ filename: `song-${index}.psarc`, title: `Song ${index}` }) }); await dispatch(window, 'start', { authorization: 'user-action', requesterId: 'core.player.controls', target: makeTarget({ filename: `song-${index}.archive`, title: `Song ${index}` }) });
for (let seek = 0; seek < 12; seek += 1) { for (let seek = 0; seek < 12; seek += 1) {
await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: seek }); await dispatch(window, 'seek', { requesterId: 'core.player.controls', time: seek });
} }
@@ -96,7 +96,7 @@ test('diagnostics redact requester ids and raw camel-case payload keys', async (
accessToken: 'plain-secret-token', accessToken: 'plain-secret-token',
nativeHandleRef: 'native-secret-handle', nativeHandleRef: 'native-secret-handle',
mediaStream: 'raw-stream-id', mediaStream: 'raw-stream-id',
reason: '/Users/example/private song.psarc token=secret', reason: '/Users/example/private song.archive token=secret',
safeDetail: 'safe value', safeDetail: 'safe value',
}); });
window.slopsmith.playback.recordBridgeHit({ window.slopsmith.playback.recordBridgeHit({
+1 -1
View File
@@ -57,7 +57,7 @@ test('start requires a target and explicit user authorization for fresh audible
test('settings key is stable across arrangements while target id remains arrangement scoped', async () => { test('settings key is stable across arrangements while target id remains arrangement scoped', async () => {
const window = loadPlayback(); const window = loadPlayback();
window.slopsmith.playback.registerTransportAdapter(makeAdapter()); window.slopsmith.playback.registerTransportAdapter(makeAdapter());
const base = makeTarget({ filename: '/Users/example/DLC/Artist - Song_p.psarc', arrangement: 'Lead', arrangementIndex: 0 }); const base = makeTarget({ filename: '/Users/example/DLC/Artist - Song_p.archive', arrangement: 'Lead', arrangementIndex: 0 });
await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: base }); await dispatch(window, 'start', { requesterId: 'core.player.controls', authorization: 'user-action', target: base });
const leadTarget = diagnosticsSnapshot(window).state.target; const leadTarget = diagnosticsSnapshot(window).state.target;
+2 -2
View File
@@ -41,12 +41,12 @@ function dispatch(window, command, payload = {}, requester = 'test') {
function makeTarget(overrides = {}) { function makeTarget(overrides = {}) {
return { return {
filename: overrides.filename || '/Users/example/DLC/Secret Artist - Song_p.psarc', filename: overrides.filename || '/Users/example/DLC/Secret Artist - Song_p.archive',
title: overrides.title || 'Visible Title', title: overrides.title || 'Visible Title',
artist: overrides.artist || 'Visible Artist', artist: overrides.artist || 'Visible Artist',
arrangement: overrides.arrangement || 'Lead', arrangement: overrides.arrangement || 'Lead',
arrangementIndex: overrides.arrangementIndex ?? 0, arrangementIndex: overrides.arrangementIndex ?? 0,
format: overrides.format || 'psarc', format: overrides.format || 'archive',
sourceKind: overrides.sourceKind || 'local', sourceKind: overrides.sourceKind || 'local',
...overrides, ...overrides,
}; };
+2 -2
View File
@@ -172,7 +172,7 @@ test('new song load resets the HTML audio rate, not only the visible speed contr
const sandbox = buildSandbox(); const sandbox = buildSandbox();
loadPlaySong(sandbox); loadPlaySong(sandbox);
await sandbox.__playSong('next-song.psarc'); await sandbox.__playSong('next-song.archive');
assert.equal(sandbox.__elements.get('speed-slider').value, 100); assert.equal(sandbox.__elements.get('speed-slider').value, 100);
assert.match(sandbox.__elements.get('speed-label').textContent, /^1\.0{1,2}x$/); assert.match(sandbox.__elements.get('speed-label').textContent, /^1\.0{1,2}x$/);
@@ -184,7 +184,7 @@ test('new song load resets the desktop backing rate when the API is available',
const sandbox = buildSandbox({ juceMode: true }); const sandbox = buildSandbox({ juceMode: true });
loadPlaySong(sandbox); loadPlaySong(sandbox);
await sandbox.__playSong('next-song.psarc'); await sandbox.__playSong('next-song.archive');
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
assert.equal(sandbox.jucePlayer._speed, 1); assert.equal(sandbox.jucePlayer._speed, 1);
+1 -1
View File
@@ -240,7 +240,7 @@ test('diagnostics contribution is redaction-safe (no song identity)', () => {
const contribution = window.__diagnosticsContributions.get('visualization-capability'); const contribution = window.__diagnosticsContributions.get('visualization-capability');
assert.equal(contribution.schema, 'slopsmith.visualization_capability.v1'); assert.equal(contribution.schema, 'slopsmith.visualization_capability.v1');
const serialized = JSON.stringify(contribution); const serialized = JSON.stringify(contribution);
assert.ok(!/filename|title|artist|\.sloppak|\.psarc/i.test(serialized), serialized); assert.ok(!/filename|title|artist|\.sloppak|\.archive/i.test(serialized), serialized);
assert.deepEqual( assert.deepEqual(
JSON.parse(JSON.stringify(contribution.lastAutoMatch)), JSON.parse(JSON.stringify(contribution.lastAutoMatch)),
{ resolved: 'piano', matched: true }, { resolved: 'piano', matched: true },
+2 -2
View File
@@ -24,7 +24,7 @@ def client(tmp_path, monkeypatch):
def _post_mapping(client, **overrides): def _post_mapping(client, **overrides):
payload = { payload = {
"song_key": "settings-v1-song", "song_key": "settings-v1-song",
"filename": "Artist - Song_p.psarc", "filename": "Artist - Song_p.archive",
"tone_key": "Dist", "tone_key": "Dist",
"provider_id": "nam-tone", "provider_id": "nam-tone",
"provider_ref": "preset:42", "provider_ref": "preset:42",
@@ -80,7 +80,7 @@ def test_upsert_updates_provider_mapping_without_colliding_with_other_provider(c
assert second["label"] == "Updated" assert second["label"] == "Updated"
assert rig["id"] != first["id"] assert rig["id"] != first["id"]
listed = client.get("/api/audio-effects/mappings", params={"filename": "Artist - Song_p.psarc"}).json()["mappings"] listed = client.get("/api/audio-effects/mappings", params={"filename": "Artist - Song_p.archive"}).json()["mappings"]
assert len(listed) == 2 assert len(listed) == 2
+4 -4
View File
@@ -204,7 +204,7 @@ def test_client_audio_session_contribution_redacts_paths(tmp_path):
kw["client_contributions"] = { kw["client_contributions"] = {
"note_detect": { "note_detect": {
"schema": "slopsmith.audio_session.diagnostics.v1", "schema": "slopsmith.audio_session.diagnostics.v1",
"session": {"sessionId": str(home_path / "DLC" / "private-song.psarc")}, "session": {"sessionId": str(home_path / "DLC" / "private-song.archive")},
"domains": {"audio-input": {"sources": [{"label": str(home_path / "devices" / "raw-id")}]}}, "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 = _basic_kwargs(tmp_path)
kw["include"]["console"] = True kw["include"]["console"] = True
kw["redact"] = True kw["redact"] = True
secret_path = "/home/alice/Music/DLC/my_song.psarc" secret_path = "/home/alice/Music/DLC/my_song.archive"
kw["client_console"] = [ kw["client_console"] = [
{ {
"level": "error", "level": "error",
@@ -1567,13 +1567,13 @@ def test_console_string_args_still_redacted(tmp_path):
kw["include"]["console"] = True kw["include"]["console"] = True
kw["redact"] = True kw["redact"] = True
kw["client_console"] = [ kw["client_console"] = [
{"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.psarc ok"]}, {"level": "log", "msg": "ok", "args": ["loaded /home/alice/Music/DLC/my_song.archive ok"]},
] ]
zip_bytes, _name, _m = db.build_bundle(**kw) zip_bytes, _name, _m = db.build_bundle(**kw)
with _open_zip(zip_bytes) as zf: with _open_zip(zip_bytes) as zf:
console = json.loads(zf.read("client/console.json")) console = json.loads(zf.read("client/console.json"))
# The song filename should be replaced with a hash token, not appear verbatim. # The song filename should be replaced with a hash token, not appear verbatim.
assert "my_song.psarc" not in console["entries"][0]["args"][0] assert "my_song.archive" not in console["entries"][0]["args"][0]
def test_console_non_string_non_dict_args_pass_through(tmp_path): def test_console_non_string_non_dict_args_pass_through(tmp_path):
+5 -5
View File
@@ -5,7 +5,7 @@ from diagnostics_redact import Redactor
def test_dlc_path_replaced(): def test_dlc_path_replaced():
r = Redactor(dlc_dir=Path("/dlc/songs")) r = Redactor(dlc_dir=Path("/dlc/songs"))
out = r.redact_text("loaded from /dlc/songs/foo.psarc") out = r.redact_text("loaded from /dlc/songs/foo.archive")
assert "<DLC_DIR>" in out assert "<DLC_DIR>" in out
assert "/dlc/songs" not in out assert "/dlc/songs" not in out
assert r.counts["paths_replaced"] == 1 assert r.counts["paths_replaced"] == 1
@@ -13,8 +13,8 @@ def test_dlc_path_replaced():
def test_song_filename_redacted_consistently(): def test_song_filename_redacted_consistently():
r = Redactor() r = Redactor()
a = r.redact_text("Loading Test-Artist_Test-Song.psarc") a = r.redact_text("Loading Test-Artist_Test-Song.archive")
b = r.redact_text("Replaying Test-Artist_Test-Song.psarc again") b = r.redact_text("Replaying Test-Artist_Test-Song.archive again")
token_a = a.split("Loading ")[1].strip() token_a = a.split("Loading ")[1].strip()
token_b = b.split("Replaying ")[1].split(" ")[0] token_b = b.split("Replaying ")[1].split(" ")[0]
assert token_a == token_b assert token_a == token_b
@@ -63,8 +63,8 @@ def test_home_dir_replaced():
def test_different_redactors_produce_different_tokens(): def test_different_redactors_produce_different_tokens():
a = Redactor() a = Redactor()
b = Redactor() b = Redactor()
out_a = a.redact_text("Foo.psarc") out_a = a.redact_text("Foo.archive")
out_b = b.redact_text("Foo.psarc") out_b = b.redact_text("Foo.archive")
assert out_a != out_b assert out_a != out_b
+1 -1
View File
@@ -126,7 +126,7 @@ def test_validate_drum_tab_rejects_non_int_version():
def test_validate_drum_tab_accepts_unknown_version(): def test_validate_drum_tab_accepts_unknown_version():
"""An unknown schema version is logged but the payload is still accepted — """An unknown schema version is logged but the payload is still accepted —
forward-compat per Principle IV (backwards-compatible CDLC library).""" forward-compat per Principle IV (backwards-compatible custom song library)."""
ok, _ = drums.validate_drum_tab({"version": 99, "hits": []}) ok, _ = drums.validate_drum_tab({"version": 99, "hits": []})
assert ok assert ok
+66 -66
View File
@@ -5,7 +5,7 @@ cover the new year sort and the rewritten tuning sort (now
musical-distance-based instead of alphabetical). musical-distance-based instead of alphabetical).
Tests stub `MetadataDB` directly via `meta_db.put()`, bypassing the Tests stub `MetadataDB` directly via `meta_db.put()`, bypassing the
PSARC/sloppak scanner same approach as test_settings_api.py. archive/sloppak scanner same approach as test_settings_api.py.
""" """
import importlib import importlib
@@ -37,7 +37,7 @@ def client(server_mod):
def _put(server_mod, *, filename, title, artist, year="", arrangements=None, def _put(server_mod, *, filename, title, artist, year="", arrangements=None,
has_lyrics=False, format="psarc", stem_ids=None, tuning_name="E Standard", has_lyrics=False, format="archive", stem_ids=None, tuning_name="E Standard",
tuning_sort_key=0, tuning_offsets="", mtime=1.0, size=1): tuning_sort_key=0, tuning_offsets="", mtime=1.0, size=1):
server_mod.meta_db.put(filename, mtime, size, { server_mod.meta_db.put(filename, mtime, size, {
"title": title, "artist": artist, "album": f"{artist} - LP", "title": title, "artist": artist, "album": f"{artist} - LP",
@@ -57,13 +57,13 @@ def _put(server_mod, *, filename, title, artist, year="", arrangements=None,
@pytest.fixture() @pytest.fixture()
def seeded(server_mod): def seeded(server_mod):
"""Populate 6 deterministic rows covering the matrix of axes.""" """Populate 6 deterministic rows covering the matrix of axes."""
_put(server_mod, filename="a.psarc", title="A song", artist="A Band", _put(server_mod, filename="a.archive", title="A song", artist="A Band",
year="2010", has_lyrics=True, format="psarc", year="2010", has_lyrics=True, format="archive",
arrangements=[{"index": 0, "name": "Lead", "notes": 100}, arrangements=[{"index": 0, "name": "Lead", "notes": 100},
{"index": 1, "name": "Rhythm", "notes": 80}], {"index": 1, "name": "Rhythm", "notes": 80}],
tuning_name="E Standard", tuning_sort_key=0) tuning_name="E Standard", tuning_sort_key=0)
_put(server_mod, filename="b.psarc", title="B song", artist="B Band", _put(server_mod, filename="b.archive", title="B song", artist="B Band",
year="2005", has_lyrics=False, format="psarc", year="2005", has_lyrics=False, format="archive",
arrangements=[{"index": 0, "name": "Bass", "notes": 60}], arrangements=[{"index": 0, "name": "Bass", "notes": 60}],
tuning_name="Drop D", tuning_sort_key=-2) tuning_name="Drop D", tuning_sort_key=-2)
_put(server_mod, filename="c.sloppak", title="C song", artist="C Band", _put(server_mod, filename="c.sloppak", title="C song", artist="C Band",
@@ -87,8 +87,8 @@ def seeded(server_mod):
"Drop D", -2), "Drop D", -2),
) )
server_mod.meta_db.conn.commit() server_mod.meta_db.conn.commit()
_put(server_mod, filename="f.psarc", title="F song", artist="F Band", _put(server_mod, filename="f.archive", title="F song", artist="F Band",
year="2015", has_lyrics=True, format="psarc", year="2015", has_lyrics=True, format="archive",
arrangements=[{"index": 0, "name": "Lead", "notes": 110}, arrangements=[{"index": 0, "name": "Lead", "notes": 110},
{"index": 1, "name": "Bass", "notes": 70}], {"index": 1, "name": "Bass", "notes": 70}],
tuning_name="Eb Standard", tuning_sort_key=-6) tuning_name="Eb Standard", tuning_sort_key=-6)
@@ -104,23 +104,23 @@ def test_arrangement_has_lead(client, seeded):
data = _get(client, arrangements_has="Lead") data = _get(client, arrangements_has="Lead")
files = {s["filename"] for s in data["songs"]} files = {s["filename"] for s in data["songs"]}
# Rows with Lead: a, d, e, f. Combo (c) does NOT match strict-name "Lead". # Rows with Lead: a, d, e, f. Combo (c) does NOT match strict-name "Lead".
assert files == {"a.psarc", "d.sloppak", "e.sloppak", "f.psarc"} assert files == {"a.archive", "d.sloppak", "e.sloppak", "f.archive"}
def test_arrangement_has_or_within_axis(client, seeded): def test_arrangement_has_or_within_axis(client, seeded):
data = _get(client, arrangements_has="Lead,Bass") data = _get(client, arrangements_has="Lead,Bass")
files = {s["filename"] for s in data["songs"]} files = {s["filename"] for s in data["songs"]}
# Lead OR Bass: a, b, d, e, f. # Lead OR Bass: a, b, d, e, f.
assert files == {"a.psarc", "b.psarc", "d.sloppak", "e.sloppak", "f.psarc"} assert files == {"a.archive", "b.archive", "d.sloppak", "e.sloppak", "f.archive"}
def test_arrangement_lacks_bass(client, seeded): def test_arrangement_lacks_bass(client, seeded):
data = _get(client, arrangements_lacks="Bass") data = _get(client, arrangements_lacks="Bass")
files = {s["filename"] for s in data["songs"]} files = {s["filename"] for s in data["songs"]}
# b.psarc and f.psarc both have Bass, exclude them. # b.archive and f.archive both have Bass, exclude them.
assert "b.psarc" not in files assert "b.archive" not in files
assert "f.psarc" not in files assert "f.archive" not in files
assert "a.psarc" in files assert "a.archive" in files
# ── Arrangements axis — smart naming mode ─────────────────────────────────── # ── Arrangements axis — smart naming mode ───────────────────────────────────
@@ -130,25 +130,25 @@ def seeded_smart(server_mod):
"""Rows that exercise the smart-mode filter branches in _build_where.""" """Rows that exercise the smart-mode filter branches in _build_where."""
# Row with explicit smart_name="Alt. Lead" — must match arrangements_has=Lead # Row with explicit smart_name="Alt. Lead" — must match arrangements_has=Lead
# in smart mode (LIKE 'Alt. Lead%'). # in smart mode (LIKE 'Alt. Lead%').
_put(server_mod, filename="alt.psarc", title="Alt", artist="Alt Band", _put(server_mod, filename="alt.archive", title="Alt", artist="Alt Band",
arrangements=[{"index": 0, "name": "Lead", "notes": 100, arrangements=[{"index": 0, "name": "Lead", "notes": 100,
"smart_name": "Alt. Lead"}]) "smart_name": "Alt. Lead"}])
# Row with explicit smart_name="Bonus Rhythm". # Row with explicit smart_name="Bonus Rhythm".
_put(server_mod, filename="bonus.psarc", title="Bon", artist="Bon Band", _put(server_mod, filename="bonus.archive", title="Bon", artist="Bon Band",
arrangements=[{"index": 0, "name": "Rhythm", "notes": 50, arrangements=[{"index": 0, "name": "Rhythm", "notes": 50,
"smart_name": "Bonus Rhythm"}]) "smart_name": "Bonus Rhythm"}])
# Legacy-cached row WITHOUT smart_name key (json_type IS NULL): # Legacy-cached row WITHOUT smart_name key (json_type IS NULL):
# name="Combo" → must match arrangements_has=Lead via name fallback. # name="Combo" → must match arrangements_has=Lead via name fallback.
_put(server_mod, filename="combo-old.psarc", title="ComboOld", artist="X", _put(server_mod, filename="combo-old.archive", title="ComboOld", artist="X",
arrangements=[{"index": 0, "name": "Combo", "notes": 80}]) arrangements=[{"index": 0, "name": "Combo", "notes": 80}])
# Legacy-cached row WITHOUT smart_name where name="Bass 2" (load_song # Legacy-cached row WITHOUT smart_name where name="Bass 2" (load_song
# synthesises this for real_bass_22 when manifest data is missing): # synthesises this for real_bass_22 when manifest data is missing):
# must match arrangements_has=Bass via the extras fallback. # must match arrangements_has=Bass via the extras fallback.
_put(server_mod, filename="bass2-old.psarc", title="Bass2Old", artist="Z", _put(server_mod, filename="bass2-old.archive", title="Bass2Old", artist="Z",
arrangements=[{"index": 0, "name": "Bass 2", "notes": 70}]) arrangements=[{"index": 0, "name": "Bass 2", "notes": 70}])
# Scanned ambiguous row with explicit smart_name=None (json_type='null'): # Scanned ambiguous row with explicit smart_name=None (json_type='null'):
# name="Combo" must NOT match Lead in smart mode (suppress name-fallback). # name="Combo" must NOT match Lead in smart mode (suppress name-fallback).
_put(server_mod, filename="combo-ambig.psarc", title="ComboAmb", artist="Y", _put(server_mod, filename="combo-ambig.archive", title="ComboAmb", artist="Y",
arrangements=[{"index": 0, "name": "Combo", "notes": 90, arrangements=[{"index": 0, "name": "Combo", "notes": 90,
"smart_name": None}]) "smart_name": None}])
@@ -156,10 +156,10 @@ def seeded_smart(server_mod):
def test_smart_mode_matches_alt_lead(client, seeded_smart): def test_smart_mode_matches_alt_lead(client, seeded_smart):
data = _get(client, arrangements_has="Lead", naming_mode="smart") data = _get(client, arrangements_has="Lead", naming_mode="smart")
files = {s["filename"] for s in data["songs"]} files = {s["filename"] for s in data["songs"]}
assert "alt.psarc" in files # smart_name="Alt. Lead" assert "alt.archive" in files # smart_name="Alt. Lead"
assert "combo-old.psarc" in files # name-fallback (key absent) assert "combo-old.archive" in files # name-fallback (key absent)
assert "combo-ambig.psarc" not in files # explicit null suppresses fallback assert "combo-ambig.archive" not in files # explicit null suppresses fallback
assert "bonus.psarc" not in files # Bonus Rhythm not Lead assert "bonus.archive" not in files # Bonus Rhythm not Lead
def test_smart_mode_matches_bass_2_via_fallback(client, seeded_smart): def test_smart_mode_matches_bass_2_via_fallback(client, seeded_smart):
@@ -167,7 +167,7 @@ def test_smart_mode_matches_bass_2_via_fallback(client, seeded_smart):
# in smart mode via the NULL-smart_name name-fallback extras. # in smart mode via the NULL-smart_name name-fallback extras.
data = _get(client, arrangements_has="Bass", naming_mode="smart") data = _get(client, arrangements_has="Bass", naming_mode="smart")
files = {s["filename"] for s in data["songs"]} files = {s["filename"] for s in data["songs"]}
assert "bass2-old.psarc" in files assert "bass2-old.archive" in files
def test_smart_mode_combo_normalized_to_lead(client, seeded_smart): def test_smart_mode_combo_normalized_to_lead(client, seeded_smart):
@@ -184,10 +184,10 @@ def test_smart_mode_lacks_lead_excludes_alt_lead(client, seeded_smart):
# smart_name is explicitly null (we don't know if they have Lead). # smart_name is explicitly null (we don't know if they have Lead).
data = _get(client, arrangements_lacks="Lead", naming_mode="smart") data = _get(client, arrangements_lacks="Lead", naming_mode="smart")
files = {s["filename"] for s in data["songs"]} files = {s["filename"] for s in data["songs"]}
assert "alt.psarc" not in files assert "alt.archive" not in files
assert "combo-old.psarc" not in files assert "combo-old.archive" not in files
assert "combo-ambig.psarc" not in files # ambiguous — don't claim it lacks Lead assert "combo-ambig.archive" not in files # ambiguous — don't claim it lacks Lead
assert "bonus.psarc" in files # has Bonus Rhythm, no Lead variant assert "bonus.archive" in files # has Bonus Rhythm, no Lead variant
# ── Lyrics axis ───────────────────────────────────────────────────────────── # ── Lyrics axis ─────────────────────────────────────────────────────────────
@@ -195,13 +195,13 @@ def test_smart_mode_lacks_lead_excludes_alt_lead(client, seeded_smart):
def test_has_lyrics_require(client, seeded): def test_has_lyrics_require(client, seeded):
data = _get(client, has_lyrics="1") data = _get(client, has_lyrics="1")
files = {s["filename"] for s in data["songs"]} files = {s["filename"] for s in data["songs"]}
assert files == {"a.psarc", "c.sloppak", "f.psarc"} assert files == {"a.archive", "c.sloppak", "f.archive"}
def test_has_lyrics_exclude(client, seeded): def test_has_lyrics_exclude(client, seeded):
data = _get(client, has_lyrics="0") data = _get(client, has_lyrics="0")
files = {s["filename"] for s in data["songs"]} files = {s["filename"] for s in data["songs"]}
assert files == {"b.psarc", "d.sloppak", "e.sloppak"} assert files == {"b.archive", "d.sloppak", "e.sloppak"}
# ── Stems axis ────────────────────────────────────────────────────────────── # ── Stems axis ──────────────────────────────────────────────────────────────
@@ -218,14 +218,14 @@ def test_stems_has_or_within_axis(client, seeded):
assert {s["filename"] for s in data["songs"]} == {"c.sloppak", "d.sloppak"} assert {s["filename"] for s in data["songs"]} == {"c.sloppak", "d.sloppak"}
def test_stems_has_excludes_psarcs_and_legacy_null(client, seeded): def test_stems_has_excludes_archives_and_legacy_null(client, seeded):
"""PSARCs have empty stem_ids; legacy row has NULL. Both are """archives have empty stem_ids; legacy row has NULL. Both are
excluded by stems_has there's no proof the stem is present.""" excluded by stems_has there's no proof the stem is present."""
data = _get(client, stems_has="drums") data = _get(client, stems_has="drums")
files = {s["filename"] for s in data["songs"]} files = {s["filename"] for s in data["songs"]}
assert files == {"c.sloppak", "d.sloppak"} assert files == {"c.sloppak", "d.sloppak"}
# PSARC rows missing. # archive rows missing.
assert "a.psarc" not in files assert "a.archive" not in files
# Legacy NULL row missing. # Legacy NULL row missing.
assert "e.sloppak" not in files assert "e.sloppak" not in files
@@ -235,9 +235,9 @@ def test_stems_lacks_other(client, seeded):
files = {s["filename"] for s in data["songs"]} files = {s["filename"] for s in data["songs"]}
# c.sloppak has "other" — must be excluded. # c.sloppak has "other" — must be excluded.
assert "c.sloppak" not in files assert "c.sloppak" not in files
# Everything else lacks it (PSARCs have empty stem_ids; legacy NULL # Everything else lacks it (archives have empty stem_ids; legacy NULL
# also lacks it because json_each yields nothing). # also lacks it because json_each yields nothing).
assert "a.psarc" in files assert "a.archive" in files
# ── Tuning axis ───────────────────────────────────────────────────────────── # ── Tuning axis ─────────────────────────────────────────────────────────────
@@ -245,12 +245,12 @@ def test_stems_lacks_other(client, seeded):
def test_tunings_or_within_axis(client, seeded): def test_tunings_or_within_axis(client, seeded):
data = _get(client, tunings="E Standard,Drop D") data = _get(client, tunings="E Standard,Drop D")
files = {s["filename"] for s in data["songs"]} files = {s["filename"] for s in data["songs"]}
assert files == {"a.psarc", "b.psarc", "c.sloppak", "e.sloppak"} assert files == {"a.archive", "b.archive", "c.sloppak", "e.sloppak"}
def test_tunings_eb_standard_only(client, seeded): def test_tunings_eb_standard_only(client, seeded):
data = _get(client, tunings="Eb Standard") data = _get(client, tunings="Eb Standard")
assert {s["filename"] for s in data["songs"]} == {"d.sloppak", "f.psarc"} assert {s["filename"] for s in data["songs"]} == {"d.sloppak", "f.archive"}
# ── Combined cross-axis (AND) ─────────────────────────────────────────────── # ── Combined cross-axis (AND) ───────────────────────────────────────────────
@@ -261,7 +261,7 @@ def test_combined_axes(client, seeded):
# a (Lead, lyrics, E Std) ✓ # a (Lead, lyrics, E Std) ✓
# f (Lead, lyrics, Eb Std) ✗ (wrong tuning) # f (Lead, lyrics, Eb Std) ✗ (wrong tuning)
# c is Combo not Lead # c is Combo not Lead
assert {s["filename"] for s in data["songs"]} == {"a.psarc"} assert {s["filename"] for s in data["songs"]} == {"a.archive"}
# ── Whitelist sanitization (defense-in-depth) ─────────────────────────────── # ── Whitelist sanitization (defense-in-depth) ───────────────────────────────
@@ -282,30 +282,30 @@ def test_year_sort_desc_newest_first(client, seeded):
files = [s["filename"] for s in data["songs"]] files = [s["filename"] for s in data["songs"]]
# Years: c=2020, d=2018, f=2015, a=2010, b=2005, e=''. # Years: c=2020, d=2018, f=2015, a=2010, b=2005, e=''.
# Empty year goes to the bottom for both directions. # Empty year goes to the bottom for both directions.
assert files == ["c.sloppak", "d.sloppak", "f.psarc", "a.psarc", "b.psarc", "e.sloppak"] assert files == ["c.sloppak", "d.sloppak", "f.archive", "a.archive", "b.archive", "e.sloppak"]
def test_year_sort_asc_oldest_first(client, seeded): def test_year_sort_asc_oldest_first(client, seeded):
data = _get(client, sort="year") data = _get(client, sort="year")
files = [s["filename"] for s in data["songs"]] files = [s["filename"] for s in data["songs"]]
# Empty year still bottom — only the dated rows reverse. # Empty year still bottom — only the dated rows reverse.
assert files == ["b.psarc", "a.psarc", "f.psarc", "d.sloppak", "c.sloppak", "e.sloppak"] assert files == ["b.archive", "a.archive", "f.archive", "d.sloppak", "c.sloppak", "e.sloppak"]
def test_tuning_sort_down_tuned_before_up_tuned_at_same_distance(client, server_mod): def test_tuning_sort_down_tuned_before_up_tuned_at_same_distance(client, server_mod):
"""Within an ABS(tuning_sort_key) tier, the down-tuned variant """Within an ABS(tuning_sort_key) tier, the down-tuned variant
must come before the up-tuned one so the order matches Rocksmith's must come before the up-tuned one so the order matches the source game's
grouping (Eb Standard before F Standard at distance 6, etc.). grouping (Eb Standard before F Standard at distance 6, etc.).
Earlier code used signed-key DESC for the tiebreaker, which put Earlier code used signed-key DESC for the tiebreaker, which put
+6 before -6 the opposite of intent. Regression for Copilot +6 before -6 the opposite of intent. Regression for Copilot
finding on PR #134.""" finding on PR #134."""
_put(server_mod, filename="up.psarc", title="Up", artist="A", _put(server_mod, filename="up.archive", title="Up", artist="A",
tuning_name="F Standard", tuning_sort_key=6, tuning_name="F Standard", tuning_sort_key=6,
arrangements=[{"index": 0, "name": "Lead", "notes": 1}]) arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
_put(server_mod, filename="down.psarc", title="Down", artist="B", _put(server_mod, filename="down.archive", title="Down", artist="B",
tuning_name="Eb Standard", tuning_sort_key=-6, tuning_name="Eb Standard", tuning_sort_key=-6,
arrangements=[{"index": 0, "name": "Lead", "notes": 1}]) arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
_put(server_mod, filename="std.psarc", title="Std", artist="C", _put(server_mod, filename="std.archive", title="Std", artist="C",
tuning_name="E Standard", tuning_sort_key=0, tuning_name="E Standard", tuning_sort_key=0,
arrangements=[{"index": 0, "name": "Lead", "notes": 1}]) arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
@@ -328,15 +328,15 @@ def test_tuning_sort_pushes_empty_tuning_name_to_bottom(client, server_mod):
Without a leading `(tuning_name='') ASC` term, ABS(0) collides with Without a leading `(tuning_name='') ASC` term, ABS(0) collides with
E Standard's 0 so unscanned rows would float to the top of the E Standard's 0 so unscanned rows would float to the top of the
tuning sort. Regression for Copilot finding on PR #134.""" tuning sort. Regression for Copilot finding on PR #134."""
_put(server_mod, filename="real.psarc", title="Real", artist="A", _put(server_mod, filename="real.archive", title="Real", artist="A",
tuning_name="E Standard", tuning_sort_key=0, tuning_name="E Standard", tuning_sort_key=0,
arrangements=[{"index": 0, "name": "Lead", "notes": 1}]) arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
_put(server_mod, filename="legacy.psarc", title="Legacy", artist="B", _put(server_mod, filename="legacy.archive", title="Legacy", artist="B",
tuning_name="", tuning_sort_key=0, tuning_name="", tuning_sort_key=0,
arrangements=[{"index": 0, "name": "Lead", "notes": 1}]) arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
data = _get(client, sort="tuning") data = _get(client, sort="tuning")
files = [s["filename"] for s in data["songs"]] files = [s["filename"] for s in data["songs"]]
assert files == ["real.psarc", "legacy.psarc"] assert files == ["real.archive", "legacy.archive"]
def test_tuning_sort_pushes_null_tuning_name_to_bottom(client, server_mod): def test_tuning_sort_pushes_null_tuning_name_to_bottom(client, server_mod):
@@ -348,7 +348,7 @@ def test_tuning_sort_pushes_null_tuning_name_to_bottom(client, server_mod):
evaluates to NULL for those rows and NULLs sort *ahead of* 0 in evaluates to NULL for those rows and NULLs sort *ahead of* 0 in
SQLite's ASC ordering — the legacy row would float above E SQLite's ASC ordering — the legacy row would float above E
Standard. Regression for Copilot finding on PR #134.""" Standard. Regression for Copilot finding on PR #134."""
_put(server_mod, filename="real.psarc", title="Real", artist="A", _put(server_mod, filename="real.archive", title="Real", artist="A",
tuning_name="E Standard", tuning_sort_key=0, tuning_name="E Standard", tuning_sort_key=0,
arrangements=[{"index": 0, "name": "Lead", "notes": 1}]) arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
# Direct INSERT with NULL tuning_name AND NULL tuning_sort_key — # Direct INSERT with NULL tuning_name AND NULL tuning_sort_key —
@@ -358,14 +358,14 @@ def test_tuning_sort_pushes_null_tuning_name_to_bottom(client, server_mod):
"INSERT INTO songs (filename, mtime, size, title, artist, album, year, " "INSERT INTO songs (filename, mtime, size, title, artist, album, year, "
"duration, tuning, arrangements, has_lyrics, format, stem_count, stem_ids, " "duration, tuning, arrangements, has_lyrics, format, stem_count, stem_ids, "
"tuning_name, tuning_sort_key) " "tuning_name, tuning_sort_key) "
"VALUES (?, 1.0, 1, ?, ?, ?, '', 200.0, '', ?, 0, 'psarc', 0, '[]', NULL, NULL)", "VALUES (?, 1.0, 1, ?, ?, ?, '', 200.0, '', ?, 0, 'archive', 0, '[]', NULL, NULL)",
("legacy.psarc", "Legacy", "Z", "Z - LP", json.dumps([])), ("legacy.archive", "Legacy", "Z", "Z - LP", json.dumps([])),
) )
server_mod.meta_db.conn.commit() server_mod.meta_db.conn.commit()
data = _get(client, sort="tuning") data = _get(client, sort="tuning")
files = [s["filename"] for s in data["songs"]] files = [s["filename"] for s in data["songs"]]
assert files == ["real.psarc", "legacy.psarc"] assert files == ["real.archive", "legacy.archive"]
# /api/library/tuning-names should also exclude the NULL row from # /api/library/tuning-names should also exclude the NULL row from
# the picker entirely (users can't usefully filter by an unknown # the picker entirely (users can't usefully filter by an unknown
@@ -380,9 +380,9 @@ def test_query_stats_artist_count_is_case_insensitive(client, server_mod):
NOCASE leading to mismatched totals when the same artist was NOCASE leading to mismatched totals when the same artist was
indexed under different casings. Regression for Copilot finding indexed under different casings. Regression for Copilot finding
on PR #134.""" on PR #134."""
_put(server_mod, filename="x.psarc", title="X", artist="The Beatles", _put(server_mod, filename="x.archive", title="X", artist="The Beatles",
arrangements=[{"index": 0, "name": "Lead", "notes": 1}]) arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
_put(server_mod, filename="y.psarc", title="Y", artist="the beatles", _put(server_mod, filename="y.archive", title="Y", artist="the beatles",
arrangements=[{"index": 0, "name": "Lead", "notes": 1}]) arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
stats = client.get("/api/library/stats").json() stats = client.get("/api/library/stats").json()
assert stats["total_artists"] == 1 assert stats["total_artists"] == 1
@@ -391,7 +391,7 @@ def test_query_stats_artist_count_is_case_insensitive(client, server_mod):
def test_query_stats_groups_non_ascii_artist_letters_under_hash(client, server_mod): def test_query_stats_groups_non_ascii_artist_letters_under_hash(client, server_mod):
_put(server_mod, filename="angstrom.psarc", title="Angstrom", artist="Ångström") _put(server_mod, filename="angstrom.archive", title="Angstrom", artist="Ångström")
stats = client.get("/api/library/stats").json() stats = client.get("/api/library/stats").json()
@@ -445,7 +445,7 @@ def test_compound_sort_with_legacy_dir_desc_doesnt_error(client, seeded):
# Order matches plain `sort=year` (legacy dir is ignored on # Order matches plain `sort=year` (legacy dir is ignored on
# already-directional clauses). The point is no 500 from invalid SQL. # already-directional clauses). The point is no 500 from invalid SQL.
files = [s["filename"] for s in r.json()["songs"]] files = [s["filename"] for s in r.json()["songs"]]
assert files == ["b.psarc", "a.psarc", "f.psarc", "d.sloppak", "c.sloppak", "e.sloppak"] assert files == ["b.archive", "a.archive", "f.archive", "d.sloppak", "c.sloppak", "e.sloppak"]
# ── Tuning sort by pitch distance (slopsmith#22) ──────────────────────────── # ── Tuning sort by pitch distance (slopsmith#22) ────────────────────────────
@@ -487,11 +487,11 @@ def test_tuning_offsets_served_in_library_list(client, server_mod):
"""Raw offsets round-trip through the DB into the list payload so the v3 """Raw offsets round-trip through the DB into the list payload so the v3
client can render target notes (they are not derivable from the collapsed client can render target notes (they are not derivable from the collapsed
"Custom Tuning" name).""" "Custom Tuning" name)."""
_put(server_mod, filename="custom.psarc", title="C", artist="C Band", _put(server_mod, filename="custom.archive", title="C", artist="C Band",
tuning_name="Custom Tuning", tuning_sort_key=-6, tuning_name="Custom Tuning", tuning_sort_key=-6,
tuning_offsets="-2 0 0 0 -2 -2") tuning_offsets="-2 0 0 0 -2 -2")
songs = _get(client)["songs"] songs = _get(client)["songs"]
row = next(s for s in songs if s["filename"] == "custom.psarc") row = next(s for s in songs if s["filename"] == "custom.archive")
assert row["tuning_offsets"] == "-2 0 0 0 -2 -2" assert row["tuning_offsets"] == "-2 0 0 0 -2 -2"
assert row["tuning_name"] == "Custom Tuning" assert row["tuning_name"] == "Custom Tuning"
@@ -500,10 +500,10 @@ def test_distinct_custom_tunings_stay_distinct(client, server_mod):
"""Two different custom tunings both named "Custom Tuning" must remain """Two different custom tunings both named "Custom Tuning" must remain
separate filter entries (grouped on offsets), and each pill must select separate filter entries (grouped on offsets), and each pill must select
only its own songs.""" only its own songs."""
_put(server_mod, filename="dadgad.psarc", title="One", artist="A", _put(server_mod, filename="dadgad.archive", title="One", artist="A",
tuning_name="Custom Tuning", tuning_sort_key=-4, tuning_name="Custom Tuning", tuning_sort_key=-4,
tuning_offsets="-2 0 0 0 -2 0") tuning_offsets="-2 0 0 0 -2 0")
_put(server_mod, filename="openc.psarc", title="Two", artist="B", _put(server_mod, filename="openc.archive", title="Two", artist="B",
tuning_name="Custom Tuning", tuning_sort_key=-8, tuning_name="Custom Tuning", tuning_sort_key=-8,
tuning_offsets="-4 -2 -2 0 -2 -4") tuning_offsets="-4 -2 -2 0 -2 -4")
@@ -516,17 +516,17 @@ def test_distinct_custom_tunings_stay_distinct(client, server_mod):
# Filtering by one tuning's key returns only that song. # Filtering by one tuning's key returns only that song.
only = _get(client, tunings="-2 0 0 0 -2 0") only = _get(client, tunings="-2 0 0 0 -2 0")
assert [s["filename"] for s in only["songs"]] == ["dadgad.psarc"] assert [s["filename"] for s in only["songs"]] == ["dadgad.archive"]
def test_legacy_rows_without_offsets_group_by_name(client, server_mod): def test_legacy_rows_without_offsets_group_by_name(client, server_mod):
"""Rows predating the offsets column (tuning_offsets='') still group/filter """Rows predating the offsets column (tuning_offsets='') still group/filter
by tuning_name, preserving prior behaviour.""" by tuning_name, preserving prior behaviour."""
_put(server_mod, filename="estd.psarc", title="S", artist="A", _put(server_mod, filename="estd.archive", title="S", artist="A",
tuning_name="E Standard", tuning_sort_key=0, tuning_offsets="") tuning_name="E Standard", tuning_sort_key=0, tuning_offsets="")
names = [t["name"] for t in client.get("/api/library/tuning-names").json()["tunings"]] names = [t["name"] for t in client.get("/api/library/tuning-names").json()["tunings"]]
assert "E Standard" in names assert "E Standard" in names
assert [s["filename"] for s in _get(client, tunings="E Standard")["songs"]] == ["estd.psarc"] assert [s["filename"] for s in _get(client, tunings="E Standard")["songs"]] == ["estd.archive"]
# ── Stats endpoint mirrors filtered totals ────────────────────────────────── # ── Stats endpoint mirrors filtered totals ──────────────────────────────────
@@ -553,14 +553,14 @@ def test_empty_values_are_no_ops(client, seeded):
def test_artist_filter_returns_only_that_artist(client, seeded): def test_artist_filter_returns_only_that_artist(client, seeded):
data = _get(client, artist="A Band") data = _get(client, artist="A Band")
assert data["total"] == 1 assert data["total"] == 1
assert {s["filename"] for s in data["songs"]} == {"a.psarc"} assert {s["filename"] for s in data["songs"]} == {"a.archive"}
assert all(s["artist"] == "A Band" for s in data["songs"]) assert all(s["artist"] == "A Band" for s in data["songs"])
def test_artist_and_album_filter(client, seeded): def test_artist_and_album_filter(client, seeded):
data = _get(client, artist="A Band", album="A Band - LP") data = _get(client, artist="A Band", album="A Band - LP")
assert data["total"] == 1 assert data["total"] == 1
assert data["songs"][0]["filename"] == "a.psarc" assert data["songs"][0]["filename"] == "a.archive"
assert data["songs"][0]["album"] == "A Band - LP" assert data["songs"][0]["album"] == "A Band - LP"
@@ -575,4 +575,4 @@ def test_q_search_remains_fuzzy_with_artist_filter(client, seeded):
def test_artist_filter_is_case_insensitive(client, seeded): def test_artist_filter_is_case_insensitive(client, seeded):
data = _get(client, artist="a band") data = _get(client, artist="a band")
assert data["total"] == 1 assert data["total"] == 1
assert data["songs"][0]["filename"] == "a.psarc" assert data["songs"][0]["filename"] == "a.archive"
+8 -8
View File
@@ -27,7 +27,7 @@ def client(server_mod):
c.close() c.close()
def _put(server_mod, filename="local.psarc", title="Local Song", artist="Local Artist"): def _put(server_mod, filename="local.archive", title="Local Song", artist="Local Artist"):
server_mod.meta_db.put(filename, 1.0, 1, { server_mod.meta_db.put(filename, 1.0, 1, {
"title": title, "title": title,
"artist": artist, "artist": artist,
@@ -37,7 +37,7 @@ def _put(server_mod, filename="local.psarc", title="Local Song", artist="Local A
"tuning": "E Standard", "tuning": "E Standard",
"arrangements": [{"index": 0, "name": "Lead", "notes": 1}], "arrangements": [{"index": 0, "name": "Lead", "notes": 1}],
"has_lyrics": True, "has_lyrics": True,
"format": "psarc", "format": "archive",
"stem_count": 0, "stem_count": 0,
"stem_ids": [], "stem_ids": [],
"tuning_name": "E Standard", "tuning_name": "E Standard",
@@ -66,7 +66,7 @@ class FakeLibraryProvider:
"artist": "Remote Artist", "artist": "Remote Artist",
"album": "Remote Album", "album": "Remote Album",
"arrangements": [], "arrangements": [],
"format": "psarc", "format": "archive",
}], 1) }], 1)
def query_artists(self, **kwargs): def query_artists(self, **kwargs):
@@ -91,7 +91,7 @@ class FakeLibraryProvider:
def sync_song(self, song_id: str): def sync_song(self, song_id: str):
self.sync_song_id = song_id self.sync_song_id = song_id
return {"ok": True, "filename": "synced.psarc", "song_id": song_id} return {"ok": True, "filename": "synced.archive", "song_id": song_id}
class ReadOnlyLibraryProvider: class ReadOnlyLibraryProvider:
@@ -157,7 +157,7 @@ def test_local_provider_is_default_library_provider(server_mod, client):
default_payload = client.get("/api/library").json() default_payload = client.get("/api/library").json()
explicit_payload = client.get("/api/library", params={"provider": "local"}).json() explicit_payload = client.get("/api/library", params={"provider": "local"}).json()
assert default_payload == explicit_payload assert default_payload == explicit_payload
assert default_payload["songs"][0]["filename"] == "local.psarc" assert default_payload["songs"][0]["filename"] == "local.archive"
def test_registered_provider_handles_library_endpoints(server_mod, client): def test_registered_provider_handles_library_endpoints(server_mod, client):
@@ -178,7 +178,7 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
"size": "12", "size": "12",
"sort": "title-desc", "sort": "title-desc",
"dir": "desc", "dir": "desc",
"format": "psarc", "format": "archive",
"favorites": "1", "favorites": "1",
"arrangements_has": "Lead,Rhythm", "arrangements_has": "Lead,Rhythm",
"has_lyrics": "1", "has_lyrics": "1",
@@ -192,7 +192,7 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
assert provider.page_kwargs["sort"] == "title-desc" assert provider.page_kwargs["sort"] == "title-desc"
assert provider.page_kwargs["direction"] == "desc" assert provider.page_kwargs["direction"] == "desc"
assert provider.page_kwargs["favorites_only"] is True assert provider.page_kwargs["favorites_only"] is True
assert provider.page_kwargs["format_filter"] == "psarc" assert provider.page_kwargs["format_filter"] == "archive"
assert provider.page_kwargs["arrangements_has"] == ["Lead", "Rhythm"] assert provider.page_kwargs["arrangements_has"] == ["Lead", "Rhythm"]
assert provider.page_kwargs["has_lyrics"] == 1 assert provider.page_kwargs["has_lyrics"] == 1
assert provider.page_kwargs["tunings"] == ["E Standard", "Drop D"] assert provider.page_kwargs["tunings"] == ["E Standard", "Drop D"]
@@ -225,7 +225,7 @@ def test_registered_provider_handles_library_endpoints(server_mod, client):
assert provider.art_song_id == "remote-song-id" assert provider.art_song_id == "remote-song-id"
synced = client.post("/api/library/providers/remote:frodo/songs/remote-song-id/sync").json() synced = client.post("/api/library/providers/remote:frodo/songs/remote-song-id/sync").json()
assert synced == {"ok": True, "filename": "synced.psarc", "song_id": "remote-song-id"} assert synced == {"ok": True, "filename": "synced.archive", "song_id": "remote-song-id"}
assert provider.sync_song_id == "remote-song-id" assert provider.sync_song_id == "remote-song-id"
+1 -1
View File
@@ -58,5 +58,5 @@ def test_get_song_info_inside_dlc_is_404_not_403(dlc_client):
"""A safe-but-missing path produces 404, not 403 — guards against """A safe-but-missing path produces 404, not 403 — guards against
over-rejecting legitimate filenames.""" over-rejecting legitimate filenames."""
tc, _server, _dlc = dlc_client tc, _server, _dlc = dlc_client
r = tc.get("/api/song/some-song.psarc") r = tc.get("/api/song/some-song.archive")
assert r.status_code == 404, r.text assert r.status_code == 404, r.text
+1 -1
View File
@@ -1,4 +1,4 @@
"""Tests for lib/loosefolder.py — pure helpers for loose CDLC folders.""" """Tests for lib/loosefolder.py — pure helpers for loose custom song folders."""
import json import json
from pathlib import Path from pathlib import Path
+1 -1
View File
@@ -10,7 +10,7 @@ need the model:
* `vocals_has_signal` RMS gate over a synthesized WAV. * `vocals_has_signal` RMS gate over a synthesized WAV.
* `whisperx_available` graceful False when the package isn't installed. * `whisperx_available` graceful False when the package isn't installed.
The end-to-end positive case (PSARC without lyrics sloppak with The end-to-end positive case (archive without lyrics sloppak with
auto-transcribed lyrics) is intentionally a manual verification step; auto-transcribed lyrics) is intentionally a manual verification step;
see the plan's verification section. see the plan's verification section.
""" """
+1 -1
View File
@@ -677,7 +677,7 @@ def test_cent_offset_round_trips_through_wire_format(tmp_path):
def test_cent_offset_non_finite_sanitized_to_zero(tmp_path): def test_cent_offset_non_finite_sanitized_to_zero(tmp_path):
# Malformed CDLC can carry NaN/Infinity, which float() accepts but which # Malformed custom song can carry NaN/Infinity, which float() accepts but which
# serialize to invalid JSON tokens over the song_info WebSocket. Parsing # serialize to invalid JSON tokens over the song_info WebSocket. Parsing
# must coerce them to a finite 0.0 so the payload stays valid JSON. # must coerce them to a finite 0.0 so the payload stays valid JSON.
for bad in ("NaN", "Infinity", "-Infinity", "inf", "nan"): for bad in ("NaN", "Infinity", "-Infinity", "inf", "nan"):
+21 -21
View File
@@ -46,28 +46,28 @@ def test_create_requires_name(client):
def test_add_remove_reorder_persists(client, server): def test_add_remove_reorder_persists(client, server):
for fn in ("a.psarc", "b.psarc", "c.psarc"): for fn in ("a.archive", "b.archive", "c.archive"):
server.meta_db.put(fn, 0, 0, {}) server.meta_db.put(fn, 0, 0, {})
pid = client.post("/api/playlists", json={"name": "Set"}).json()["id"] pid = client.post("/api/playlists", json={"name": "Set"}).json()["id"]
client.post(f"/api/playlists/{pid}/songs", json={"filename": "a.psarc"}) client.post(f"/api/playlists/{pid}/songs", json={"filename": "a.archive"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "b.psarc"}) client.post(f"/api/playlists/{pid}/songs", json={"filename": "b.archive"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "c.psarc"}) client.post(f"/api/playlists/{pid}/songs", json={"filename": "c.archive"})
songs = client.get(f"/api/playlists/{pid}").json()["songs"] songs = client.get(f"/api/playlists/{pid}").json()["songs"]
assert [s["filename"] for s in songs] == ["a.psarc", "b.psarc", "c.psarc"] assert [s["filename"] for s in songs] == ["a.archive", "b.archive", "c.archive"]
# reorder # reorder
client.post(f"/api/playlists/{pid}/reorder", json={"order": ["c.psarc", "a.psarc", "b.psarc"]}) client.post(f"/api/playlists/{pid}/reorder", json={"order": ["c.archive", "a.archive", "b.archive"]})
songs2 = client.get(f"/api/playlists/{pid}").json()["songs"] songs2 = client.get(f"/api/playlists/{pid}").json()["songs"]
assert [s["filename"] for s in songs2] == ["c.psarc", "a.psarc", "b.psarc"] assert [s["filename"] for s in songs2] == ["c.archive", "a.archive", "b.archive"]
# remove # remove
client.request("DELETE", f"/api/playlists/{pid}/songs/b.psarc") client.request("DELETE", f"/api/playlists/{pid}/songs/b.archive")
songs3 = client.get(f"/api/playlists/{pid}").json()["songs"] songs3 = client.get(f"/api/playlists/{pid}").json()["songs"]
assert [s["filename"] for s in songs3] == ["c.psarc", "a.psarc"] assert [s["filename"] for s in songs3] == ["c.archive", "a.archive"]
def test_saved_for_later_toggle_and_protection(client): def test_saved_for_later_toggle_and_protection(client):
# Toggle creates the system playlist on first use. # Toggle creates the system playlist on first use.
assert client.post("/api/saved/toggle", json={"filename": "x.psarc"}).json() == {"saved": True} assert client.post("/api/saved/toggle", json={"filename": "x.archive"}).json() == {"saved": True}
assert client.post("/api/saved/toggle", json={"filename": "x.psarc"}).json() == {"saved": False} assert client.post("/api/saved/toggle", json={"filename": "x.archive"}).json() == {"saved": False}
saved = next(p for p in client.get("/api/playlists").json() if p["system_key"] == "saved_for_later") saved = next(p for p in client.get("/api/playlists").json() if p["system_key"] == "saved_for_later")
# Cannot delete or rename the system playlist. # Cannot delete or rename the system playlist.
assert client.delete(f"/api/playlists/{saved['id']}").status_code == 400 assert client.delete(f"/api/playlists/{saved['id']}").status_code == 400
@@ -76,12 +76,12 @@ def test_saved_for_later_toggle_and_protection(client):
def test_continue_session(client, server): def test_continue_session(client, server):
assert client.get("/api/session/continue").json() is None assert client.get("/api/session/continue").json() is None
for fn in ("one.psarc", "two.psarc"): for fn in ("one.archive", "two.archive"):
server.meta_db.put(fn, 0, 0, {}) server.meta_db.put(fn, 0, 0, {})
client.post("/api/stats", json={"filename": "one.psarc", "score": 100, "accuracy": 0.5, "lastPlayPosition": 12.0}) client.post("/api/stats", json={"filename": "one.archive", "score": 100, "accuracy": 0.5, "lastPlayPosition": 12.0})
client.post("/api/stats", json={"filename": "two.psarc", "score": 200, "accuracy": 0.7, "lastPlayPosition": 30.0}) client.post("/api/stats", json={"filename": "two.archive", "score": 200, "accuracy": 0.7, "lastPlayPosition": 30.0})
cont = client.get("/api/session/continue").json() cont = client.get("/api/session/continue").json()
assert cont["filename"] == "two.psarc" assert cont["filename"] == "two.archive"
assert cont["last_position"] == 30.0 assert cont["last_position"] == 30.0
assert "art_url" in cont and "title" in cont assert "art_url" in cont and "title" in cont
@@ -89,8 +89,8 @@ def test_continue_session(client, server):
def test_add_song_to_missing_playlist_is_404(client, server): def test_add_song_to_missing_playlist_is_404(client, server):
# add_playlist_song() must not insert an orphan row for a non-existent # add_playlist_song() must not insert an orphan row for a non-existent
# playlist (the concurrent-delete TOCTOU); it returns None → handler 404s. # playlist (the concurrent-delete TOCTOU); it returns None → handler 404s.
assert server.meta_db.add_playlist_song(999999, "x.psarc") is None assert server.meta_db.add_playlist_song(999999, "x.archive") is None
r = client.post("/api/playlists/999999/songs", json={"filename": "x.psarc"}) r = client.post("/api/playlists/999999/songs", json={"filename": "x.archive"})
assert r.status_code == 404 assert r.status_code == 404
@@ -98,11 +98,11 @@ def test_playlist_hides_dead_songs_when_library_populated(client, server):
# A playlist song whose file no longer exists is hidden from contents + count # A playlist song whose file no longer exists is hidden from contents + count
# (mirrors the stats read-filter), but only while the library is populated. # (mirrors the stats read-filter), but only while the library is populated.
db = server.meta_db db = server.meta_db
db.put("live.psarc", 0, 0, {"title": "Live"}) db.put("live.archive", 0, 0, {"title": "Live"})
pid = client.post("/api/playlists", json={"name": "P"}).json()["id"] pid = client.post("/api/playlists", json={"name": "P"}).json()["id"]
client.post(f"/api/playlists/{pid}/songs", json={"filename": "live.psarc"}) client.post(f"/api/playlists/{pid}/songs", json={"filename": "live.archive"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "ghost.psarc"}) # never in songs client.post(f"/api/playlists/{pid}/songs", json={"filename": "ghost.archive"}) # never in songs
pl = client.get(f"/api/playlists/{pid}").json() pl = client.get(f"/api/playlists/{pid}").json()
names = [s["filename"] for s in pl["songs"]] names = [s["filename"] for s in pl["songs"]]
assert "live.psarc" in names and "ghost.psarc" not in names assert "live.archive" in names and "ghost.archive" not in names
assert [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]["count"] == 1 assert [p for p in client.get("/api/playlists").json() if p["id"] == pid][0]["count"] == 1
+9 -9
View File
@@ -198,7 +198,7 @@ def test_load_content_missing_root_is_nonfatal(tmp_path):
({"name": "Monkeys Medley"}, "guitar"), ({"name": "Monkeys Medley"}, "guitar"),
({}, "guitar"), ({}, "guitar"),
(None, "guitar"), (None, "guitar"),
# name overrides generic guitar type (legacy PSARC keys arrangements) # name overrides generic guitar type (legacy archive keys arrangements)
({"type": "lead", "name": "Keys"}, "keys"), ({"type": "lead", "name": "Keys"}, "keys"),
({"type": "combo", "name": "Piano"}, "keys"), ({"type": "combo", "name": "Piano"}, "keys"),
({"type": "lead", "name": "Bass"}, "bass"), ({"type": "lead", "name": "Bass"}, "bass"),
@@ -270,7 +270,7 @@ def test_select_quests_small_pool_returns_all():
def _song_event(**payload): def _song_event(**payload):
base = {"filename": "song.psarc", "instrument": "guitar", "accuracy": 0.9, "score": 1000} base = {"filename": "song.archive", "instrument": "guitar", "accuracy": 0.9, "score": 1000}
base.update(payload) base.update(payload)
return {"type": "song_completed", "payload": base} return {"type": "song_completed", "payload": base}
@@ -290,10 +290,10 @@ def test_goal_matches_song_filters():
{"type": "song_completed", "instrument": "guitar", "target": 1}, _song_event() {"type": "song_completed", "instrument": "guitar", "target": 1}, _song_event()
) )
assert goal_matches_event( assert goal_matches_event(
{"type": "song_completed", "filename": "song.psarc", "target": 1}, _song_event() {"type": "song_completed", "filename": "song.archive", "target": 1}, _song_event()
) )
assert not goal_matches_event( assert not goal_matches_event(
{"type": "song_completed", "filename": "other.psarc", "target": 1}, _song_event() {"type": "song_completed", "filename": "other.archive", "target": 1}, _song_event()
) )
assert not goal_matches_event( assert not goal_matches_event(
{"type": "song_completed", "min_score": 2000, "target": 1}, _song_event() {"type": "song_completed", "min_score": 2000, "target": 1}, _song_event()
@@ -472,18 +472,18 @@ def test_evaluate_event_counts_prior_completions_toward_levelup():
def test_evaluate_event_distinct_dedupes_replays(): def test_evaluate_event_distinct_dedupes_replays():
content = _content() content = _content()
snapshot = _snapshot(paths={"guitar": 1}) # working level-2 set snapshot = _snapshot(paths={"guitar": 1}) # working level-2 set
first = evaluate_event(_song_event(filename="a.psarc"), content, snapshot) first = evaluate_event(_song_event(filename="a.archive"), content, snapshot)
ch = first["challenges"][0] ch = first["challenges"][0]
assert ch["count"] == 1 and not ch["completed"] assert ch["count"] == 1 and not ch["completed"]
assert ch["detail"] == {"seen": ["a.psarc"]} assert ch["detail"] == {"seen": ["a.archive"]}
snapshot["challenges"] = { snapshot["challenges"] = {
"guitar.l2.distinct": {"count": 1, "completed": False, "detail": ch["detail"]} "guitar.l2.distinct": {"count": 1, "completed": False, "detail": ch["detail"]}
} }
replay = evaluate_event(_song_event(filename="a.psarc"), content, snapshot) replay = evaluate_event(_song_event(filename="a.archive"), content, snapshot)
assert replay["challenges"] == [] # same song again: no advance assert replay["challenges"] == [] # same song again: no advance
other = evaluate_event(_song_event(filename="b.psarc"), content, snapshot) other = evaluate_event(_song_event(filename="b.archive"), content, snapshot)
ch2 = other["challenges"][0] ch2 = other["challenges"][0]
assert ch2["count"] == 2 and ch2["completed"] assert ch2["count"] == 2 and ch2["completed"]
assert other["level_ups"] == [{"path_id": "guitar", "new_level": 2}] assert other["level_ups"] == [{"path_id": "guitar", "new_level": 2}]
@@ -493,7 +493,7 @@ def test_evaluate_event_default_counts_replays():
# guitar.l1.three has no distinct flag: the same song three times completes it. # guitar.l1.three has no distinct flag: the same song three times completes it.
content = _content() content = _content()
snapshot = _snapshot(challenges={"guitar.l1.three": {"count": 2, "completed": False}}) snapshot = _snapshot(challenges={"guitar.l1.three": {"count": 2, "completed": False}})
outcome = evaluate_event(_song_event(filename="same.psarc", accuracy=0.1), content, snapshot) outcome = evaluate_event(_song_event(filename="same.archive", accuracy=0.1), content, snapshot)
by_id = {c["challenge_id"]: c for c in outcome["challenges"]} by_id = {c["challenge_id"]: c for c in outcome["challenges"]}
assert by_id["guitar.l1.three"]["count"] == 3 assert by_id["guitar.l1.three"]["count"] == 3
assert by_id["guitar.l1.three"]["completed"] is True assert by_id["guitar.l1.three"]["completed"] is True
+2 -2
View File
@@ -82,7 +82,7 @@ def client(server):
return TestClient(server.app) return TestClient(server.app)
def _scored_play(client, filename="song.psarc", accuracy=0.9, score=900, arrangement=0): def _scored_play(client, filename="song.archive", accuracy=0.9, score=900, arrangement=0):
return client.post("/api/stats", json={ return client.post("/api/stats", json={
"filename": filename, "arrangement": arrangement, "filename": filename, "arrangement": arrangement,
"score": score, "accuracy": accuracy, "score": score, "accuracy": accuracy,
@@ -294,7 +294,7 @@ def test_stale_quest_completion_does_not_double_award(client, server, monkeypatc
summary = server.meta_db.record_progression_event( summary = server.meta_db.record_progression_event(
"song_completed", "song_completed",
{"filename": "y.psarc", "instrument": "guitar", "accuracy": 0.5, "score": 100}, {"filename": "y.archive", "instrument": "guitar", "accuracy": 0.5, "score": 100},
server._get_progression_content(), server._get_progression_content(),
) )
assert all(q["id"] != "d.one" for q in summary["quests_completed"]) assert all(q["id"] != "d.one" for q in summary["quests_completed"])
+6 -6
View File
@@ -133,7 +133,7 @@ def test_int_optional_falls_back_on_overflow():
def test_parse_note_falls_back_to_default_on_malformed_numeric_attrs(): def test_parse_note_falls_back_to_default_on_malformed_numeric_attrs():
"""Malformed numeric XML attributes degrade gracefully. """Malformed numeric XML attributes degrade gracefully.
Third-party Rocksmith XML occasionally emits empty / non-numeric Third-party the source game XML occasionally emits empty / non-numeric
values for fields like `rightHand`. `_int_optional` (used for values for fields like `rightHand`. `_int_optional` (used for
optional metadata fields like `rightHand` and `pickDirection`) optional metadata fields like `rightHand` and `pickDirection`)
falls back to the caller's default instead of raising, so a falls back to the caller's default instead of raising, so a
@@ -442,7 +442,7 @@ def test_arrangement_from_wire_ignores_non_dict_tones():
def test_arrangement_tones_wire_is_json_safe(): def test_arrangement_tones_wire_is_json_safe():
# `definitions` is copied verbatim from the PSARC manifest — the wire # `definitions` is copied verbatim from the archive manifest — the wire
# output must still be strict JSON (allow_nan=False, as the browser's # output must still be strict JSON (allow_nan=False, as the browser's
# JSON.parse requires). # JSON.parse requires).
arr = Arrangement(name="Lead", tones={ arr = Arrangement(name="Lead", tones={
@@ -754,14 +754,14 @@ def test_string_count_uses_tuning_length_for_sparse_7_string_guitar():
def test_string_count_ignores_rs_padded_tuning_for_bass(): def test_string_count_ignores_rs_padded_tuning_for_bass():
# RS-XML bass: tuning is padded to length 6 with zeros at # arrangement XML bass: tuning is padded to length 6 with zeros at
# indices 4-5. Even though len(tuning) == 6, we MUST NOT use # indices 4-5. Even though len(tuning) == 6, we MUST NOT use
# that as a 6-string signal (would mis-classify bass as # that as a 6-string signal (would mis-classify bass as
# guitar). arrangement_string_count's `tuning_count = 0 if # guitar). arrangement_string_count's `tuning_count = 0 if
# tuning_len == 6 else tuning_len` rule takes care of this. # tuning_len == 6 else tuning_len` rule takes care of this.
arr = Arrangement( arr = Arrangement(
name="Bass", name="Bass",
tuning=[0, -5, -10, -15, 0, 0], # bass with RS XML padding tuning=[0, -5, -10, -15, 0, 0], # bass with arrangement XML padding
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)], notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
) )
assert arrangement_string_count(arr) == 4 assert arrangement_string_count(arr) == 4
@@ -852,7 +852,7 @@ def test_smart_names_unknown_name_returns_none():
def test_smart_names_name_fallback_when_path_flags_zero(): def test_smart_names_name_fallback_when_path_flags_zero():
# CDLC often leaves path flags at 0; fall back to arrangement name # custom song often leaves path flags at 0; fall back to arrangement name
arrs = [_sarr(name="Lead"), _sarr(name="Rhythm"), _sarr(name="Bass")] arrs = [_sarr(name="Lead"), _sarr(name="Rhythm"), _sarr(name="Bass")]
assert compute_smart_names(arrs) == ["Lead", "Rhythm", "Bass"] assert compute_smart_names(arrs) == ["Lead", "Rhythm", "Bass"]
@@ -883,7 +883,7 @@ def test_smart_names_multiple_combos_get_alt_names():
def test_smart_names_combo_and_bass_mixed(): def test_smart_names_combo_and_bass_mixed():
# Real-world CDLC: 3 Combo + 1 Bass, all path flags zero # Real-world custom song: 3 Combo + 1 Bass, all path flags zero
arrs = [ arrs = [
_sarr(name="Combo"), _sarr(name="Combo"),
_sarr(name="Combo"), _sarr(name="Combo"),
+64 -64
View File
@@ -28,19 +28,19 @@ def client(server):
def test_scored_session_persists_and_increments_plays(client): def test_scored_session_persists_and_increments_plays(client):
r = client.post("/api/stats", json={"filename": "song.psarc", "arrangement": 0, r = client.post("/api/stats", json={"filename": "song.archive", "arrangement": 0,
"score": 400, "accuracy": 0.6, "lastPlayPosition": 30.0}) "score": 400, "accuracy": 0.6, "lastPlayPosition": 30.0})
assert r.status_code == 200 assert r.status_code == 200
row = r.json()["stats"] row = r.json()["stats"]
assert row["plays"] == 1 and row["best_score"] == 400 and row["best_accuracy"] == pytest.approx(0.6) assert row["plays"] == 1 and row["best_score"] == 400 and row["best_accuracy"] == pytest.approx(0.6)
# A better replay raises best_* but plays keeps incrementing. # A better replay raises best_* but plays keeps incrementing.
r2 = client.post("/api/stats", json={"filename": "song.psarc", "score": 800, "accuracy": 0.9}) r2 = client.post("/api/stats", json={"filename": "song.archive", "score": 800, "accuracy": 0.9})
row2 = r2.json()["stats"] row2 = r2.json()["stats"]
assert row2["plays"] == 2 assert row2["plays"] == 2
assert row2["best_score"] == 800 and row2["best_accuracy"] == pytest.approx(0.9) assert row2["best_score"] == 800 and row2["best_accuracy"] == pytest.approx(0.9)
assert row2["last_score"] == 800 assert row2["last_score"] == 800
# A worse replay: best preserved, last replaced, plays still up. # A worse replay: best preserved, last replaced, plays still up.
r3 = client.post("/api/stats", json={"filename": "song.psarc", "score": 100, "accuracy": 0.3}) r3 = client.post("/api/stats", json={"filename": "song.archive", "score": 100, "accuracy": 0.3})
row3 = r3.json()["stats"] row3 = r3.json()["stats"]
assert row3["plays"] == 3 assert row3["plays"] == 3
assert row3["best_score"] == 800 and row3["best_accuracy"] == pytest.approx(0.9) assert row3["best_score"] == 800 and row3["best_accuracy"] == pytest.approx(0.9)
@@ -50,7 +50,7 @@ def test_scored_session_persists_and_increments_plays(client):
def test_scored_session_awards_xp_and_streak(client, server): def test_scored_session_awards_xp_and_streak(client, server):
assert server.meta_db.get_xp() == 0 assert server.meta_db.get_xp() == 0
from xp import xp_for_run from xp import xp_for_run
r = client.post("/api/stats", json={"filename": "s.psarc", "score": 900, "accuracy": 0.95}) r = client.post("/api/stats", json={"filename": "s.archive", "score": 900, "accuracy": 0.95})
prog = r.json()["progress"] prog = r.json()["progress"]
assert prog is not None assert prog is not None
assert prog["xp"] == xp_for_run(900) assert prog["xp"] == xp_for_run(900)
@@ -58,7 +58,7 @@ def test_scored_session_awards_xp_and_streak(client, server):
def test_position_only_touch_does_not_increment_plays(client): def test_position_only_touch_does_not_increment_plays(client):
r = client.post("/api/stats", json={"filename": "x.psarc", "lastPlayPosition": 42.0}) r = client.post("/api/stats", json={"filename": "x.archive", "lastPlayPosition": 42.0})
assert r.status_code == 200 assert r.status_code == 200
row = r.json()["stats"] row = r.json()["stats"]
assert row["plays"] == 0 and row["last_position"] == 42.0 assert row["plays"] == 0 and row["last_position"] == 42.0
@@ -66,7 +66,7 @@ def test_position_only_touch_does_not_increment_plays(client):
prog = r.json()["progress"] prog = r.json()["progress"]
assert prog is not None and prog["current_streak"] == 1 and prog["xp"] == 0 assert prog is not None and prog["current_streak"] == 1 and prog["xp"] == 0
# A later scored session counts as the first play. # A later scored session counts as the first play.
r2 = client.post("/api/stats", json={"filename": "x.psarc", "score": 100, "accuracy": 0.5}) r2 = client.post("/api/stats", json={"filename": "x.archive", "score": 100, "accuracy": 0.5})
assert r2.json()["stats"]["plays"] == 1 assert r2.json()["stats"]["plays"] == 1
# The earlier resume position is preserved through the scored upsert? The # The earlier resume position is preserved through the scored upsert? The
# scored session omitted last_position, so it should keep 42.0. # scored session omitted last_position, so it should keep 42.0.
@@ -78,28 +78,28 @@ def test_stats_requires_filename(client):
def test_stats_requires_score_or_position(client): def test_stats_requires_score_or_position(client):
assert client.post("/api/stats", json={"filename": "a.psarc"}).status_code == 400 assert client.post("/api/stats", json={"filename": "a.archive"}).status_code == 400
def test_get_song_stats_aggregates_arrangements(client): def test_get_song_stats_aggregates_arrangements(client):
client.post("/api/stats", json={"filename": "multi.psarc", "arrangement": 0, "score": 300, "accuracy": 0.5}) client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 0, "score": 300, "accuracy": 0.5})
client.post("/api/stats", json={"filename": "multi.psarc", "arrangement": 1, "score": 700, "accuracy": 0.8}) client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 1, "score": 700, "accuracy": 0.8})
body = client.get("/api/stats/multi.psarc").json() body = client.get("/api/stats/multi.archive").json()
assert body["plays"] == 2 assert body["plays"] == 2
assert body["best_score"] == 700 and body["best_accuracy"] == pytest.approx(0.8) assert body["best_score"] == 700 and body["best_accuracy"] == pytest.approx(0.8)
assert len(body["arrangements"]) == 2 assert len(body["arrangements"]) == 2
def test_recent_orders_by_last_played(client, server): def test_recent_orders_by_last_played(client, server):
for fn in ("first.psarc", "second.psarc"): for fn in ("first.archive", "second.archive"):
server.meta_db.put(fn, 0, 0, {}) server.meta_db.put(fn, 0, 0, {})
client.post("/api/stats", json={"filename": "first.psarc", "score": 100, "accuracy": 0.5}) client.post("/api/stats", json={"filename": "first.archive", "score": 100, "accuracy": 0.5})
client.post("/api/stats", json={"filename": "second.psarc", "score": 200, "accuracy": 0.6}) client.post("/api/stats", json={"filename": "second.archive", "score": 200, "accuracy": 0.6})
recent = client.get("/api/stats/recent?limit=10").json() recent = client.get("/api/stats/recent?limit=10").json()
names = [r["filename"] for r in recent] names = [r["filename"] for r in recent]
# Most-recent first; both present. # Most-recent first; both present.
assert names[0] == "second.psarc" assert names[0] == "second.archive"
assert "first.psarc" in names assert "first.archive" in names
# Rows carry metadata fields for the dashboard. # Rows carry metadata fields for the dashboard.
assert "art_url" in recent[0] and "title" in recent[0] assert "art_url" in recent[0] and "title" in recent[0]
@@ -111,22 +111,22 @@ def test_stats_rejects_non_finite_score_accuracy(client):
# never be persisted (a stored non-finite breaks later JSON serialization). # never be persisted (a stored non-finite breaks later JSON serialization).
# Numeric strings: # Numeric strings:
for bad in ({"score": "inf", "accuracy": 0.5}, {"score": 100, "accuracy": "NaN"}): for bad in ({"score": "inf", "accuracy": 0.5}, {"score": 100, "accuracy": "NaN"}):
r = client.post("/api/stats", json={"filename": "nf.psarc", **bad}) r = client.post("/api/stats", json={"filename": "nf.archive", **bad})
assert r.status_code == 400, bad assert r.status_code == 400, bad
# Raw JSON Infinity/NaN literals — Python's json parser accepts these, so a # Raw JSON Infinity/NaN literals — Python's json parser accepts these, so a
# client really can send them (httpx's json= serializer cannot, hence the # client really can send them (httpx's json= serializer cannot, hence the
# raw body). # raw body).
import json as _json import json as _json
for raw in (_json.dumps({"filename": "nf.psarc", "score": float("inf"), "accuracy": 0.5}), for raw in (_json.dumps({"filename": "nf.archive", "score": float("inf"), "accuracy": 0.5}),
_json.dumps({"filename": "nf.psarc", "score": 100, "accuracy": float("nan")})): _json.dumps({"filename": "nf.archive", "score": 100, "accuracy": float("nan")})):
r = client.post("/api/stats", content=raw, headers={"Content-Type": "application/json"}) r = client.post("/api/stats", content=raw, headers={"Content-Type": "application/json"})
assert r.status_code == 400, raw assert r.status_code == 400, raw
# The bad writes left no row behind. # The bad writes left no row behind.
assert client.get("/api/stats/nf.psarc").json()["plays"] == 0 assert client.get("/api/stats/nf.archive").json()["plays"] == 0
def test_stats_rejects_non_finite_position(client): def test_stats_rejects_non_finite_position(client):
r = client.post("/api/stats", json={"filename": "p.psarc", "lastPlayPosition": "inf"}) r = client.post("/api/stats", json={"filename": "p.archive", "lastPlayPosition": "inf"})
assert r.status_code == 400 assert r.status_code == 400
@@ -139,29 +139,29 @@ def test_stats_bad_typed_fields_are_400_not_500(client):
def test_position_touch_surfaces_in_recent_and_continue(client, server): def test_position_touch_surfaces_in_recent_and_continue(client, server):
# A non-scored resume touch must stamp last_played_at so it shows up in # A non-scored resume touch must stamp last_played_at so it shows up in
# both 'Jump back in' (recent) and Continue-Playing. # both 'Jump back in' (recent) and Continue-Playing.
server.meta_db.put("resume.psarc", 0, 0, {}) server.meta_db.put("resume.archive", 0, 0, {})
r = client.post("/api/stats", json={"filename": "resume.psarc", "arrangement": 2, r = client.post("/api/stats", json={"filename": "resume.archive", "arrangement": 2,
"lastPlayPosition": 42.0}) "lastPlayPosition": 42.0})
assert r.status_code == 200 assert r.status_code == 200
recent = client.get("/api/stats/recent?limit=10").json() recent = client.get("/api/stats/recent?limit=10").json()
assert "resume.psarc" in [x["filename"] for x in recent] assert "resume.archive" in [x["filename"] for x in recent]
cont = client.get("/api/session/continue").json() cont = client.get("/api/session/continue").json()
assert cont and cont["filename"] == "resume.psarc" assert cont and cont["filename"] == "resume.archive"
assert cont["arrangement"] == 2 and cont["last_position"] == pytest.approx(42.0) assert cont["arrangement"] == 2 and cont["last_position"] == pytest.approx(42.0)
def test_stats_requires_score_and_accuracy_together(client): def test_stats_requires_score_and_accuracy_together(client):
# Exactly one of score/accuracy (with or without a position) is ambiguous. # Exactly one of score/accuracy (with or without a position) is ambiguous.
assert client.post("/api/stats", json={"filename": "x.psarc", "score": 100}).status_code == 400 assert client.post("/api/stats", json={"filename": "x.archive", "score": 100}).status_code == 400
assert client.post("/api/stats", json={"filename": "x.psarc", "accuracy": 0.5, assert client.post("/api/stats", json={"filename": "x.archive", "accuracy": 0.5,
"lastPlayPosition": 10.0}).status_code == 400 "lastPlayPosition": 10.0}).status_code == 400
def test_stats_rejects_out_of_range_score(client): def test_stats_rejects_out_of_range_score(client):
# A huge but finite score passes isfinite() yet overflows SQLite INTEGER. # A huge but finite score passes isfinite() yet overflows SQLite INTEGER.
r = client.post("/api/stats", json={"filename": "big.psarc", "score": 1e308, "accuracy": 0.5}) r = client.post("/api/stats", json={"filename": "big.archive", "score": 1e308, "accuracy": 0.5})
assert r.status_code == 400 assert r.status_code == 400
assert client.get("/api/stats/big.psarc").json()["plays"] == 0 assert client.get("/api/stats/big.archive").json()["plays"] == 0
def test_xp_award_rejects_bool_and_overflow(client): def test_xp_award_rejects_bool_and_overflow(client):
@@ -171,17 +171,17 @@ def test_xp_award_rejects_bool_and_overflow(client):
def test_reorder_rejects_non_permutation(client, server): def test_reorder_rejects_non_permutation(client, server):
for fn in ("a.psarc", "b.psarc"): for fn in ("a.archive", "b.archive"):
server.meta_db.put(fn, 0, 0, {}) server.meta_db.put(fn, 0, 0, {})
pid = client.post("/api/playlists", json={"name": "P"}).json()["id"] pid = client.post("/api/playlists", json={"name": "P"}).json()["id"]
client.post(f"/api/playlists/{pid}/songs", json={"filename": "a.psarc"}) client.post(f"/api/playlists/{pid}/songs", json={"filename": "a.archive"})
client.post(f"/api/playlists/{pid}/songs", json={"filename": "b.psarc"}) client.post(f"/api/playlists/{pid}/songs", json={"filename": "b.archive"})
# Extra / missing / duplicate entries are all rejected. # Extra / missing / duplicate entries are all rejected.
assert client.post(f"/api/playlists/{pid}/reorder", json={"order": ["a.psarc"]}).status_code == 400 assert client.post(f"/api/playlists/{pid}/reorder", json={"order": ["a.archive"]}).status_code == 400
assert client.post(f"/api/playlists/{pid}/reorder", assert client.post(f"/api/playlists/{pid}/reorder",
json={"order": ["a.psarc", "a.psarc"]}).status_code == 400 json={"order": ["a.archive", "a.archive"]}).status_code == 400
assert client.post(f"/api/playlists/{pid}/reorder", assert client.post(f"/api/playlists/{pid}/reorder",
json={"order": ["b.psarc", "a.psarc"]}).status_code == 200 json={"order": ["b.archive", "a.archive"]}).status_code == 200
def test_overflow_numeric_inputs_are_not_500(client): def test_overflow_numeric_inputs_are_not_500(client):
@@ -192,29 +192,29 @@ def test_overflow_numeric_inputs_are_not_500(client):
headers={"Content-Type": "application/json"}) headers={"Content-Type": "application/json"})
assert r.status_code == 400 assert r.status_code == 400
# Huge/inf arrangement is rejected (400), never a 500. # Huge/inf arrangement is rejected (400), never a 500.
r2 = client.post("/api/stats", content=_json.dumps({"filename": "o.psarc", "arrangement": 1e309, r2 = client.post("/api/stats", content=_json.dumps({"filename": "o.archive", "arrangement": 1e309,
"score": 10, "accuracy": 0.5}), "score": 10, "accuracy": 0.5}),
headers={"Content-Type": "application/json"}) headers={"Content-Type": "application/json"})
assert r2.status_code == 400 assert r2.status_code == 400
# An out-of-int64-range playlist id is a 404, not a 500. # An out-of-int64-range playlist id is a 404, not a 500.
assert client.get("/api/playlists/%d" % (10 ** 30)).status_code == 404 assert client.get("/api/playlists/%d" % (10 ** 30)).status_code == 404
assert client.post("/api/playlists/%d/songs" % (10 ** 30), assert client.post("/api/playlists/%d/songs" % (10 ** 30),
json={"filename": "x.psarc"}).status_code == 404 json={"filename": "x.archive"}).status_code == 404
def test_stats_rejects_non_integral_arrangement(client): def test_stats_rejects_non_integral_arrangement(client):
# 1.9 / true must be rejected, not silently truncated to 1. # 1.9 / true must be rejected, not silently truncated to 1.
assert client.post("/api/stats", json={"filename": "a.psarc", "arrangement": 1.9, assert client.post("/api/stats", json={"filename": "a.archive", "arrangement": 1.9,
"score": 10, "accuracy": 0.5}).status_code == 400 "score": 10, "accuracy": 0.5}).status_code == 400
assert client.post("/api/stats", json={"filename": "a.psarc", "arrangement": True, assert client.post("/api/stats", json={"filename": "a.archive", "arrangement": True,
"score": 10, "accuracy": 0.5}).status_code == 400 "score": 10, "accuracy": 0.5}).status_code == 400
def test_stats_rejects_out_of_range_accuracy(client): def test_stats_rejects_out_of_range_accuracy(client):
for acc in (5, -1, 1.5): for acc in (5, -1, 1.5):
assert client.post("/api/stats", json={"filename": "acc.psarc", "score": 10, assert client.post("/api/stats", json={"filename": "acc.archive", "score": 10,
"accuracy": acc}).status_code == 400, acc "accuracy": acc}).status_code == 400, acc
assert client.get("/api/stats/acc.psarc").json()["plays"] == 0 assert client.get("/api/stats/acc.archive").json()["plays"] == 0
def test_xp_award_rejects_non_integral_amount(client): def test_xp_award_rejects_non_integral_amount(client):
@@ -224,10 +224,10 @@ def test_xp_award_rejects_non_integral_amount(client):
def test_stats_rejects_boolean_numeric_fields(client): def test_stats_rejects_boolean_numeric_fields(client):
# JSON booleans must not be coerced via float() into a recorded play / position. # JSON booleans must not be coerced via float() into a recorded play / position.
assert client.post("/api/stats", json={"filename": "b.psarc", "score": True, "accuracy": 0.5}).status_code == 400 assert client.post("/api/stats", json={"filename": "b.archive", "score": True, "accuracy": 0.5}).status_code == 400
assert client.post("/api/stats", json={"filename": "b.psarc", "score": 10, "accuracy": False}).status_code == 400 assert client.post("/api/stats", json={"filename": "b.archive", "score": 10, "accuracy": False}).status_code == 400
assert client.post("/api/stats", json={"filename": "b.psarc", "lastPlayPosition": False}).status_code == 400 assert client.post("/api/stats", json={"filename": "b.archive", "lastPlayPosition": False}).status_code == 400
assert client.get("/api/stats/b.psarc").json()["plays"] == 0 assert client.get("/api/stats/b.archive").json()["plays"] == 0
def test_award_xp_service_tolerates_bad_amount(client, server): def test_award_xp_service_tolerates_bad_amount(client, server):
@@ -245,26 +245,26 @@ def test_best_map_includes_scored_zero_excludes_resume_only(client, server):
# A scored 0% song (plays>0) must appear in /api/stats/best; a resume-only # A scored 0% song (plays>0) must appear in /api/stats/best; a resume-only
# touch (plays==0, default best 0) must not — both are real library songs, # touch (plays==0, default best 0) must not — both are real library songs,
# so the exclusion is the plays>0 rule, not the existing-song filter. # so the exclusion is the plays>0 rule, not the existing-song filter.
for fn in ("zero.psarc", "resume.psarc"): for fn in ("zero.archive", "resume.archive"):
server.meta_db.put(fn, 0, 0, {}) server.meta_db.put(fn, 0, 0, {})
client.post("/api/stats", json={"filename": "zero.psarc", "score": 0, "accuracy": 0.0}) client.post("/api/stats", json={"filename": "zero.archive", "score": 0, "accuracy": 0.0})
client.post("/api/stats", json={"filename": "resume.psarc", "lastPlayPosition": 12.0}) client.post("/api/stats", json={"filename": "resume.archive", "lastPlayPosition": 12.0})
best = client.get("/api/stats/best").json() best = client.get("/api/stats/best").json()
assert "zero.psarc" in best and best["zero.psarc"] == 0.0 assert "zero.archive" in best and best["zero.archive"] == 0.0
assert "resume.psarc" not in best assert "resume.archive" not in best
def test_dead_song_stats_hidden_when_library_populated(client, server): def test_dead_song_stats_hidden_when_library_populated(client, server):
# When the songs table is populated, stats for a song that ISN'T in it are # When the songs table is populated, stats for a song that ISN'T in it are
# hidden from reads (race-free orphan handling) — but a present song shows. # hidden from reads (race-free orphan handling) — but a present song shows.
db = server.meta_db db = server.meta_db
db.put("keep.psarc", 0, 0, {"title": "Keep"}) db.put("keep.archive", 0, 0, {"title": "Keep"})
client.post("/api/stats", json={"filename": "keep.psarc", "score": 200, "accuracy": 0.8}) client.post("/api/stats", json={"filename": "keep.archive", "score": 200, "accuracy": 0.8})
client.post("/api/stats", json={"filename": "ghost.psarc", "score": 100, "accuracy": 0.5}) # no songs row client.post("/api/stats", json={"filename": "ghost.archive", "score": 100, "accuracy": 0.5}) # no songs row
best = client.get("/api/stats/best").json() best = client.get("/api/stats/best").json()
assert "keep.psarc" in best and "ghost.psarc" not in best assert "keep.archive" in best and "ghost.archive" not in best
recent = [r["filename"] for r in client.get("/api/stats/recent?limit=10").json()] recent = [r["filename"] for r in client.get("/api/stats/recent?limit=10").json()]
assert "keep.psarc" in recent and "ghost.psarc" not in recent assert "keep.archive" in recent and "ghost.archive" not in recent
def test_delete_missing_does_not_destroy_stats(client, server): def test_delete_missing_does_not_destroy_stats(client, server):
@@ -273,23 +273,23 @@ def test_delete_missing_does_not_destroy_stats(client, server):
# its full history. A second song keeps the library non-empty so the # its full history. A second song keeps the library non-empty so the
# existing-song read filter stays active. # existing-song read filter stays active.
db = server.meta_db db = server.meta_db
db.put("s.psarc", 0, 0, {"title": "S"}) db.put("s.archive", 0, 0, {"title": "S"})
db.put("other.psarc", 0, 0, {"title": "Other"}) db.put("other.archive", 0, 0, {"title": "Other"})
client.post("/api/stats", json={"filename": "s.psarc", "score": 300, "accuracy": 0.9}) client.post("/api/stats", json={"filename": "s.archive", "score": 300, "accuracy": 0.9})
db.delete_missing({"other.psarc"}) # s no longer "on disk" → songs row removed (other kept) db.delete_missing({"other.archive"}) # s no longer "on disk" → songs row removed (other kept)
assert "s.psarc" not in client.get("/api/stats/best").json() # hidden, library still populated assert "s.archive" not in client.get("/api/stats/best").json() # hidden, library still populated
db.put("s.psarc", 0, 0, {"title": "S"}) # song comes back under the same name db.put("s.archive", 0, 0, {"title": "S"}) # song comes back under the same name
best = client.get("/api/stats/best").json() best = client.get("/api/stats/best").json()
assert "s.psarc" in best and best["s.psarc"] == pytest.approx(0.9) # stats survived the prune assert "s.archive" in best and best["s.archive"] == pytest.approx(0.9) # stats survived the prune
def test_stats_arrangement_bounded_to_song_arrangements(client, server): def test_stats_arrangement_bounded_to_song_arrangements(client, server):
# For a known library song, an out-of-range arrangement index is rejected # For a known library song, an out-of-range arrangement index is rejected
# (can't create a fake arrangement bucket); index 0 is fine. # (can't create a fake arrangement bucket); index 0 is fine.
server.meta_db.put("multi.psarc", 0, 0, {"arrangements": [{"name": "Lead"}, {"name": "Bass"}]}) server.meta_db.put("multi.archive", 0, 0, {"arrangements": [{"name": "Lead"}, {"name": "Bass"}]})
assert client.post("/api/stats", json={"filename": "multi.psarc", "arrangement": 5, assert client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 5,
"score": 10, "accuracy": 0.5}).status_code == 400 "score": 10, "accuracy": 0.5}).status_code == 400
assert client.post("/api/stats", json={"filename": "multi.psarc", "arrangement": 1, assert client.post("/api/stats", json={"filename": "multi.archive", "arrangement": 1,
"score": 10, "accuracy": 0.5}).status_code == 200 "score": 10, "accuracy": 0.5}).status_code == 200