mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 03:41:40 +00:00
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:
+3
-3
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 (1–4) from the chord
|
// chord-hand style → show finger number (1–4) 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);
|
||||||
|
|||||||
@@ -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 & arpeggios (chord-hand events only)
|
In chords & 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; }
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user