mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 07:54:31 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f985b4dd04 | ||
|
|
c0e23e885b | ||
|
|
48a95646d8 | ||
|
|
ab0b89ea28 |
+1
-19
@@ -49,15 +49,6 @@ def _apply_to_sloppak_manifest(manifest: dict, fields: dict) -> bool:
|
||||
if "year" in fields:
|
||||
manifest["year"] = _coerce_year(fields["year"])
|
||||
dirty = True
|
||||
# `genres` is the feedpak list field (spec 1.12.0). Accepts a list/tuple
|
||||
# (stringified item-wise) or a single name (wrapped); None leaves the
|
||||
# existing value alone, mirroring the string fields above. Sent by the
|
||||
# overwrite lane (R4b) — the manual Edit Metadata path never includes it.
|
||||
if fields.get("genres") is not None:
|
||||
raw = fields["genres"]
|
||||
manifest["genres"] = ([str(g) for g in raw]
|
||||
if isinstance(raw, (list, tuple)) else [str(raw)])
|
||||
dirty = True
|
||||
# Opportunistically declare the format version (spec §4) when we're already
|
||||
# rewriting because a metadata field was supplied. Gated on `dirty` (i.e. a
|
||||
# field was given) so this never forces a *standalone* rewrite with no fields
|
||||
@@ -111,16 +102,7 @@ def write_sloppak_metadata(path: Path, fields: dict) -> bool:
|
||||
mf = path / "manifest.yaml"
|
||||
if not mf.exists() and (path / "manifest.yml").exists():
|
||||
mf = path / "manifest.yml"
|
||||
# One-time backup + temp + atomic replace, exactly like the zip
|
||||
# rewriter and gap_fill_sloppak's dir branch: the FIRST backup is the
|
||||
# pristine author original and is never clobbered by a later write —
|
||||
# it's what "Revert file to original" (R4b) restores.
|
||||
backup = mf.with_name(mf.name + ".bak")
|
||||
if mf.exists() and not backup.exists():
|
||||
shutil.copy2(mf, backup)
|
||||
tmp = mf.with_name(mf.name + ".tmp")
|
||||
tmp.write_text(dumped, encoding="utf-8")
|
||||
tmp.replace(mf)
|
||||
mf.write_text(dumped, encoding="utf-8")
|
||||
return True
|
||||
return _rewrite_zip_manifest(path, dumped)
|
||||
|
||||
|
||||
+364
-33
@@ -4,51 +4,132 @@ Kept separate from server.py so tests can import it without triggering
|
||||
FastAPI / SQLite module-level side effects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
DEFAULT_REFERENCE_PITCH = 440.0
|
||||
|
||||
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then
|
||||
# tuning name. This is the authoritative source; tuner/routes.py previously
|
||||
# held a copy — it was removed in favour of this one.
|
||||
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = {
|
||||
# Canonical open strings, low to high, as MIDI notes. This is the host-level
|
||||
# source of truth for guitar/bass tuning profiles; UI surfaces derive names,
|
||||
# frequencies, and semitone offsets from these absolute pitches.
|
||||
STANDARD_OPEN_MIDIS: dict[str, list[int]] = {
|
||||
"guitar-6": [40, 45, 50, 55, 59, 64],
|
||||
"guitar-7": [35, 40, 45, 50, 55, 59, 64],
|
||||
"guitar-8": [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
"bass-4": [28, 33, 38, 43],
|
||||
"bass-5": [23, 28, 33, 38, 43],
|
||||
"bass-6": [23, 28, 33, 38, 43, 48],
|
||||
}
|
||||
|
||||
# Curated built-in profiles. This intentionally starts by absorbing the useful
|
||||
# Virtuoso guitar/bass coverage into host-owned data so the host selector,
|
||||
# tuner, practice tools, and plugins can converge on one profile model.
|
||||
TUNING_PRESET_MIDIS: dict[str, dict[str, list[int]]] = {
|
||||
"guitar-6": {
|
||||
"Standard": [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Eb Standard": [77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
|
||||
"Drop D": [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"D Standard": [73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop C": [65.41, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Open G": [73.42, 98.00, 146.83, 196.00, 246.94, 293.66],
|
||||
"Open D": [73.42, 110.00, 146.83, 185.00, 220.00, 293.66],
|
||||
"DADGAD": [73.42, 110.00, 146.83, 196.00, 220.00, 293.66],
|
||||
"Open E": [82.41, 123.47, 164.81, 207.65, 246.94, 329.63],
|
||||
"Standard": [40, 45, 50, 55, 59, 64],
|
||||
"Eb Standard": [39, 44, 49, 54, 58, 63],
|
||||
"D Standard": [38, 43, 48, 53, 57, 62],
|
||||
"C# Standard": [37, 42, 47, 52, 56, 61],
|
||||
"C Standard": [36, 41, 46, 51, 55, 60],
|
||||
"Drop D": [38, 45, 50, 55, 59, 64],
|
||||
"Drop C": [36, 43, 48, 53, 57, 62],
|
||||
"Drop B": [35, 42, 47, 52, 56, 61],
|
||||
"Drop A": [33, 40, 45, 50, 54, 59],
|
||||
"Drop Ab": [32, 39, 44, 49, 53, 58],
|
||||
"Open G": [38, 43, 50, 55, 59, 62],
|
||||
"Open D": [38, 45, 50, 54, 57, 62],
|
||||
"DADGAD": [38, 45, 50, 55, 57, 62],
|
||||
"Open E": [40, 47, 52, 56, 59, 64],
|
||||
},
|
||||
"guitar-7": {
|
||||
"Standard": [61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Drop A": [55.00, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"A Standard": [55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop G": [49.00, 73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Bb Standard": [58.27, 77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
|
||||
"Standard": [35, 40, 45, 50, 55, 59, 64],
|
||||
"Bb Standard": [34, 39, 44, 49, 54, 58, 63],
|
||||
"A Standard": [33, 38, 43, 48, 53, 57, 62],
|
||||
"G Standard": [31, 36, 41, 46, 51, 55, 60],
|
||||
"Drop A": [33, 40, 45, 50, 55, 59, 64],
|
||||
"Drop G": [31, 38, 43, 48, 53, 57, 62],
|
||||
"Drop F#": [30, 37, 42, 47, 52, 56, 61],
|
||||
},
|
||||
"guitar-8": {
|
||||
"Standard": [46.25, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Drop E": [41.20, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"E Standard": [41.20, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop D": [36.71, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Eb Standard": [38.89, 51.91, 69.30, 92.50, 123.47, 164.81, 207.65, 277.18],
|
||||
"Standard": [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
"Drop E": [28, 35, 40, 45, 50, 55, 59, 64],
|
||||
"Drop A + Drop E": [28, 33, 40, 45, 50, 55, 59, 64],
|
||||
"E Standard": [28, 33, 38, 43, 48, 53, 57, 62],
|
||||
"Eb Standard": [27, 32, 37, 42, 47, 52, 56, 61],
|
||||
"Drop D": [26, 33, 38, 43, 48, 53, 57, 62],
|
||||
},
|
||||
"bass-4": {
|
||||
"Standard": [41.20, 55.00, 73.42, 98.00],
|
||||
"Eb Standard": [38.89, 51.91, 69.30, 92.50],
|
||||
"Drop D": [36.71, 55.00, 73.42, 98.00],
|
||||
"D Standard": [36.71, 48.99, 65.41, 87.31],
|
||||
"Drop C": [32.70, 48.99, 65.41, 87.31],
|
||||
"Standard": [28, 33, 38, 43],
|
||||
"Eb Standard": [27, 32, 37, 42],
|
||||
"D Standard": [26, 31, 36, 41],
|
||||
"C# Standard": [25, 30, 35, 40],
|
||||
"C Standard": [24, 29, 34, 39],
|
||||
"Drop D": [26, 33, 38, 43],
|
||||
"Drop C": [24, 31, 36, 41],
|
||||
"BEAD": [23, 28, 33, 38],
|
||||
},
|
||||
"bass-5": {
|
||||
"Standard": [30.87, 41.20, 55.00, 73.42, 98.00],
|
||||
"Eb Standard": [29.14, 38.89, 51.91, 69.30, 92.50],
|
||||
"Drop D": [30.87, 36.71, 55.00, 73.42, 98.00],
|
||||
"D Standard": [27.50, 36.71, 48.99, 65.41, 87.31],
|
||||
"Drop C": [27.50, 32.70, 48.99, 65.41, 87.31],
|
||||
"Standard": [23, 28, 33, 38, 43],
|
||||
"High C": [28, 33, 38, 43, 48],
|
||||
"Eb Standard": [22, 27, 32, 37, 42],
|
||||
"D Standard": [21, 26, 31, 36, 41],
|
||||
"C# Standard": [20, 25, 30, 35, 40],
|
||||
"C Standard": [19, 24, 29, 34, 39],
|
||||
"Drop A": [21, 28, 33, 38, 43],
|
||||
},
|
||||
"bass-6": {
|
||||
"Standard": [23, 28, 33, 38, 43, 48],
|
||||
"Eb Standard": [22, 27, 32, 37, 42, 47],
|
||||
"D Standard": [21, 26, 31, 36, 41, 46],
|
||||
"C# Standard": [20, 25, 30, 35, 40, 45],
|
||||
"C Standard": [19, 24, 29, 34, 39, 44],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def midi_to_freq(midi: int, reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> float:
|
||||
"""Return the frequency for a MIDI note at the supplied A4 reference."""
|
||||
return reference_pitch * math.pow(2, (midi - 69) / 12)
|
||||
|
||||
|
||||
def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[float]:
|
||||
"""Return rounded frequencies for low-to-high MIDI open strings."""
|
||||
return [round(midi_to_freq(m, reference_pitch), 2) for m in midis]
|
||||
|
||||
|
||||
def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None:
|
||||
"""Return semitone offsets from the instrument's standard open strings."""
|
||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||
if not standard or len(standard) != len(midis):
|
||||
return None
|
||||
return [int(m - s) for m, s in zip(midis, standard)]
|
||||
|
||||
|
||||
def tuning_midis_from_offsets(instrument_key: str, offsets: list[int]) -> list[int] | None:
|
||||
"""Return absolute open-string MIDI notes for host semitone offsets."""
|
||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||
if not standard or len(standard) != len(offsets):
|
||||
return None
|
||||
return [int(s + o) for s, o in zip(standard, offsets)]
|
||||
|
||||
|
||||
def tuning_preset_offsets(instrument_key: str, name: str) -> list[int] | None:
|
||||
"""Return host semitone offsets for a named preset."""
|
||||
midis = TUNING_PRESET_MIDIS.get(instrument_key, {}).get(name)
|
||||
if not midis:
|
||||
return None
|
||||
return tuning_offsets_from_midis(instrument_key, midis)
|
||||
|
||||
|
||||
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then
|
||||
# tuning name. Kept for the existing /api/tunings contract.
|
||||
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = {
|
||||
instrument: {
|
||||
name: open_midis_to_freqs(midis)
|
||||
for name, midis in presets.items()
|
||||
}
|
||||
for instrument, presets in TUNING_PRESET_MIDIS.items()
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +148,256 @@ def apply_reference_pitch(
|
||||
}
|
||||
|
||||
|
||||
PROFILE_IDS = ("guitar-lead", "guitar-rhythm", "bass")
|
||||
PROFILE_PATHWAYS = ("songs", "practice", "learn", "studio")
|
||||
DEFAULT_ACTIVE_INSTRUMENT_PROFILE = "guitar-lead"
|
||||
PROFILE_DEFAULTS: dict[str, dict] = {
|
||||
"guitar-lead": {
|
||||
"id": "guitar-lead",
|
||||
"label": "Lead Guitar",
|
||||
"instrument": "guitar",
|
||||
"role": "lead",
|
||||
"string_count": 6,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
"guitar-rhythm": {
|
||||
"id": "guitar-rhythm",
|
||||
"label": "Rhythm Guitar",
|
||||
"instrument": "guitar",
|
||||
"role": "rhythm",
|
||||
"string_count": 6,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
"bass": {
|
||||
"id": "bass",
|
||||
"label": "Bass",
|
||||
"instrument": "bass",
|
||||
"role": "bass",
|
||||
"string_count": 4,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def instrument_key(instrument: str, string_count: int) -> str:
|
||||
return f"{instrument}-{string_count}"
|
||||
|
||||
|
||||
def default_instrument_profiles() -> dict[str, dict]:
|
||||
return {profile_id: dict(profile) for profile_id, profile in PROFILE_DEFAULTS.items()}
|
||||
|
||||
|
||||
def _valid_reference_pitch(value) -> float | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
ref = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if not math.isfinite(ref) or ref < 430.0 or ref > 450.0:
|
||||
return None
|
||||
return ref
|
||||
|
||||
|
||||
def _valid_tuning_for_key(key: str, tuning):
|
||||
if isinstance(tuning, str):
|
||||
if len(tuning) > 64:
|
||||
return None
|
||||
if tuning in TUNING_PRESET_MIDIS.get(key, {}):
|
||||
return tuning
|
||||
# A name that IS a built-in preset for a different key is a misapplied
|
||||
# built-in (e.g. "Drop D" on a 5-string bass, whose low string is B) —
|
||||
# reject it. A name unknown to every built-in table is a provider/custom
|
||||
# tuning (the tuner plugin's, exposed via /api/tunings) that this pure
|
||||
# layer can't resolve — accept it so settings round-trip; the provider
|
||||
# owns its validity.
|
||||
if any(tuning in names for names in TUNING_PRESET_MIDIS.values()):
|
||||
return None
|
||||
return tuning
|
||||
if isinstance(tuning, list):
|
||||
expected = len(STANDARD_OPEN_MIDIS.get(key, []))
|
||||
if len(tuning) != expected:
|
||||
return None
|
||||
if any(isinstance(o, bool) or not isinstance(o, int) or o < -12 or o > 12 for o in tuning):
|
||||
return None
|
||||
return list(tuning)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_instrument_profile(profile_id: str, raw) -> tuple[dict | None, str | None]:
|
||||
"""Validate one persisted host instrument profile."""
|
||||
base = dict(PROFILE_DEFAULTS.get(profile_id, {}))
|
||||
if not base:
|
||||
return None, f"unknown instrument profile: {profile_id}"
|
||||
if raw is None:
|
||||
return base, None
|
||||
if not isinstance(raw, dict):
|
||||
return None, f"instrument_profiles.{profile_id} must be an object"
|
||||
|
||||
instrument = raw.get("instrument", base["instrument"])
|
||||
if instrument not in ("guitar", "bass"):
|
||||
return None, f"instrument_profiles.{profile_id}.instrument must be 'guitar' or 'bass'"
|
||||
|
||||
try:
|
||||
string_count = int(raw.get("string_count", base["string_count"]))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument"
|
||||
key = instrument_key(instrument, string_count)
|
||||
if key not in STANDARD_OPEN_MIDIS:
|
||||
return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument"
|
||||
|
||||
tuning = _valid_tuning_for_key(key, raw.get("tuning", base["tuning"]))
|
||||
if tuning is None:
|
||||
return None, f"instrument_profiles.{profile_id}.tuning must match {key}"
|
||||
|
||||
ref = _valid_reference_pitch(raw.get("reference_pitch", base["reference_pitch"]))
|
||||
if ref is None:
|
||||
return None, f"instrument_profiles.{profile_id}.reference_pitch must be a number between 430 and 450"
|
||||
|
||||
label = raw.get("label", base["label"])
|
||||
if not isinstance(label, str) or len(label) > 64:
|
||||
return None, f"instrument_profiles.{profile_id}.label must be a short string"
|
||||
role = raw.get("role", base["role"])
|
||||
if not isinstance(role, str) or len(role) > 32:
|
||||
return None, f"instrument_profiles.{profile_id}.role must be a short string"
|
||||
pathway = raw.get("pathway", base["pathway"])
|
||||
if not isinstance(pathway, str) or pathway not in PROFILE_PATHWAYS:
|
||||
return None, f"instrument_profiles.{profile_id}.pathway must be one of songs, practice, learn, studio"
|
||||
|
||||
out = dict(base)
|
||||
out.update({
|
||||
"id": profile_id,
|
||||
"label": label,
|
||||
"instrument": instrument,
|
||||
"role": role,
|
||||
"string_count": string_count,
|
||||
"tuning": tuning,
|
||||
"reference_pitch": ref,
|
||||
"pathway": pathway,
|
||||
})
|
||||
return out, None
|
||||
|
||||
|
||||
def normalize_instrument_profiles(raw_profiles=None) -> tuple[dict[str, dict] | None, str | None]:
|
||||
"""Validate persisted host profiles, filling omitted built-ins with defaults."""
|
||||
if raw_profiles is None:
|
||||
return default_instrument_profiles(), None
|
||||
if not isinstance(raw_profiles, dict):
|
||||
return None, "instrument_profiles must be an object"
|
||||
profiles = {}
|
||||
for profile_id in PROFILE_IDS:
|
||||
profile, error = normalize_instrument_profile(profile_id, raw_profiles.get(profile_id))
|
||||
if error:
|
||||
return None, error
|
||||
profiles[profile_id] = profile
|
||||
return profiles, None
|
||||
|
||||
|
||||
def active_profile_id(raw) -> str:
|
||||
return raw if raw in PROFILE_DEFAULTS else DEFAULT_ACTIVE_INSTRUMENT_PROFILE
|
||||
|
||||
|
||||
def profile_from_legacy_settings(cfg: dict) -> dict:
|
||||
"""Build an active profile from the old flat settings keys."""
|
||||
instrument = cfg.get("instrument") if cfg.get("instrument") in ("guitar", "bass") else "guitar"
|
||||
fallback_sc = 4 if instrument == "bass" else 6
|
||||
try:
|
||||
sc = int(cfg.get("string_count", fallback_sc))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
sc = fallback_sc
|
||||
key = instrument_key(instrument, sc)
|
||||
if key not in STANDARD_OPEN_MIDIS:
|
||||
sc = fallback_sc
|
||||
key = instrument_key(instrument, sc)
|
||||
tuning = _valid_tuning_for_key(key, cfg.get("tuning", "Standard")) or "Standard"
|
||||
ref = _valid_reference_pitch(cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH)) or DEFAULT_REFERENCE_PITCH
|
||||
pathway = cfg.get("pathway") if cfg.get("pathway") in PROFILE_PATHWAYS else "songs"
|
||||
profile_id = "bass" if instrument == "bass" else DEFAULT_ACTIVE_INSTRUMENT_PROFILE
|
||||
profile = dict(PROFILE_DEFAULTS[profile_id])
|
||||
profile.update({
|
||||
"instrument": instrument,
|
||||
"string_count": sc,
|
||||
"tuning": tuning,
|
||||
"reference_pitch": ref,
|
||||
"pathway": pathway,
|
||||
})
|
||||
return profile
|
||||
|
||||
|
||||
def settings_with_instrument_profiles(cfg: dict) -> dict:
|
||||
"""Return settings with canonical host profiles and mirrored flat keys."""
|
||||
out = dict(cfg)
|
||||
profiles, _error = normalize_instrument_profiles(out.get("instrument_profiles"))
|
||||
if profiles is None:
|
||||
profiles = default_instrument_profiles()
|
||||
if "instrument_profiles" not in out:
|
||||
legacy = profile_from_legacy_settings(out)
|
||||
profiles[legacy["id"]] = legacy
|
||||
# Default the active profile to the one migrated from the legacy flat
|
||||
# fields, but DON'T clobber an explicit request — a fresh-config
|
||||
# `POST {"active_instrument_profile": "bass"}` must switch, not be
|
||||
# overwritten by the guitar-lead inferred from defaults. active_profile_id
|
||||
# below normalizes an invalid value.
|
||||
out.setdefault("active_instrument_profile", legacy["id"])
|
||||
active = active_profile_id(out.get("active_instrument_profile"))
|
||||
selected = profiles[active]
|
||||
out["instrument_profiles"] = profiles
|
||||
out["active_instrument_profile"] = active
|
||||
out["instrument"] = selected["instrument"]
|
||||
out["string_count"] = selected["string_count"]
|
||||
out["tuning"] = selected["tuning"]
|
||||
out["reference_pitch"] = selected["reference_pitch"]
|
||||
out["pathway"] = selected["pathway"]
|
||||
return out
|
||||
|
||||
|
||||
def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict:
|
||||
"""Mirror legacy flat instrument updates into the active host profile."""
|
||||
out = settings_with_instrument_profiles(cfg)
|
||||
if not any(k in updates for k in ("instrument", "string_count", "tuning", "reference_pitch", "pathway")):
|
||||
return out
|
||||
active = active_profile_id(out.get("active_instrument_profile"))
|
||||
if "instrument" in updates:
|
||||
active = "bass" if updates["instrument"] == "bass" else "guitar-lead"
|
||||
out["active_instrument_profile"] = active
|
||||
current = dict(out["instrument_profiles"][active])
|
||||
|
||||
if "instrument" in updates:
|
||||
current["instrument"] = updates["instrument"]
|
||||
if "string_count" not in updates:
|
||||
current["string_count"] = 4 if updates["instrument"] == "bass" else 6
|
||||
if "string_count" in updates:
|
||||
current["string_count"] = updates["string_count"]
|
||||
if "reference_pitch" in updates:
|
||||
current["reference_pitch"] = updates["reference_pitch"]
|
||||
if "pathway" in updates:
|
||||
current["pathway"] = updates["pathway"]
|
||||
if "tuning" in updates:
|
||||
current["tuning"] = updates["tuning"]
|
||||
else:
|
||||
key = instrument_key(current["instrument"], current["string_count"])
|
||||
if _valid_tuning_for_key(key, current.get("tuning")) is None:
|
||||
current["tuning"] = "Standard"
|
||||
|
||||
profile, error = normalize_instrument_profile(active, current)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
out["instrument_profiles"][active] = profile
|
||||
out.update({
|
||||
"instrument": profile["instrument"],
|
||||
"string_count": profile["string_count"],
|
||||
"tuning": profile["tuning"],
|
||||
"reference_pitch": profile["reference_pitch"],
|
||||
"pathway": profile["pathway"],
|
||||
})
|
||||
return out
|
||||
|
||||
def tuning_name(offsets: list[int]) -> str:
|
||||
# All three pattern checks below are gated on `len(offsets) == 6`. The
|
||||
# naming conventions here are 6-string-specific — e.g. a 7-string all-zeros
|
||||
|
||||
@@ -42,7 +42,12 @@ from song import (
|
||||
scale_degree_for_pitch,
|
||||
)
|
||||
from audio import find_wem_files, convert_wem
|
||||
from tunings import tuning_name, DEFAULT_TUNINGS, DEFAULT_REFERENCE_PITCH, apply_reference_pitch
|
||||
from tunings import (
|
||||
DEFAULT_REFERENCE_PITCH, DEFAULT_TUNINGS, PROFILE_IDS, PROFILE_PATHWAYS,
|
||||
apply_flat_instrument_patch_to_profiles, apply_reference_pitch,
|
||||
normalize_instrument_profile, normalize_instrument_profiles,
|
||||
settings_with_instrument_profiles, tuning_name,
|
||||
)
|
||||
import sloppak as sloppak_mod
|
||||
import drums as drums_mod
|
||||
import notation as notation_mod
|
||||
@@ -246,8 +251,6 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
|
||||
("GET", re.compile(r"^/api/chart/.+/fileinfo$")),
|
||||
# Gap-fill (R4a) rewrites pack files on disk — never for demo visitors.
|
||||
("POST", re.compile(r"^/api/song/.+/gap-fill$")),
|
||||
# Overwrite (R4b): revert-original rewrites the pack file from its backup.
|
||||
("POST", re.compile(r"^/api/song/.+/revert-original$")),
|
||||
# Art layer (R3): all three mutate server state / touch the network on a
|
||||
# visitor's behalf — the base64 upload writes files, the URL fetch makes the
|
||||
# server request arbitrary images, and the override delete removes files.
|
||||
@@ -912,25 +915,6 @@ class MetadataDB:
|
||||
self.conn.execute(ddl)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Write-back provenance (R4b): one row per key actually written into a
|
||||
# pack file — gap-fill additions (old_value NULL) and overwrite
|
||||
# replacements alike — with the match source/score that authorized the
|
||||
# value. Local receipts until the spec's provenance FEP gives an
|
||||
# in-file shape. Capped at _WRITE_LOG_MAX rows (oldest pruned on
|
||||
# insert) so the ledger can't grow unbounded. Additive + idempotent.
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS write_log (
|
||||
id INTEGER PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
source TEXT,
|
||||
score REAL,
|
||||
ts REAL
|
||||
)
|
||||
""")
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_write_log_filename ON write_log(filename)")
|
||||
# Progression (spec 010): instrument paths, challenges, quests, the
|
||||
# Decibels wallet, and the cosmetics shop. Targets/titles live in the
|
||||
# bundled content (data/progression/); these tables hold only player
|
||||
@@ -2786,53 +2770,6 @@ class MetadataDB:
|
||||
out[k] = []
|
||||
return out
|
||||
|
||||
# ── Write-back provenance (R4b) ──────────────────────────────────────────
|
||||
|
||||
_WRITE_LOG_MAX = 5000
|
||||
|
||||
@staticmethod
|
||||
def _write_log_text(value):
|
||||
"""Normalize a logged value to TEXT: None stays NULL (a gap-filled key
|
||||
had no old value), lists keep JSON shape, everything else str()s."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (list, tuple)):
|
||||
return json.dumps(list(value), ensure_ascii=False)
|
||||
return str(value)
|
||||
|
||||
def add_write_log(self, filename: str, entries, source=None, score=None):
|
||||
"""Record file write-backs: one row per (key, old, new) entry, all
|
||||
stamped with the source/score of the match that authorized the values.
|
||||
Prunes the oldest rows beyond _WRITE_LOG_MAX after the insert so the
|
||||
receipts table stays bounded."""
|
||||
now = time.time()
|
||||
rows = [(filename, k, self._write_log_text(old), self._write_log_text(new),
|
||||
source, score, now) for k, old, new in entries]
|
||||
if not rows:
|
||||
return
|
||||
with self._lock:
|
||||
self.conn.executemany(
|
||||
"INSERT INTO write_log (filename, key, old_value, new_value, source, score, ts) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)", rows)
|
||||
# Keep the newest _WRITE_LOG_MAX rows. The subquery yields the id
|
||||
# just below the cut (NULL while under the cap → no-op delete).
|
||||
self.conn.execute(
|
||||
"DELETE FROM write_log WHERE id <= ("
|
||||
"SELECT id FROM write_log ORDER BY id DESC LIMIT 1 OFFSET ?)",
|
||||
(self._WRITE_LOG_MAX,))
|
||||
self.conn.commit()
|
||||
|
||||
def write_log_rows(self, filename: str, limit: int = 200) -> list[dict]:
|
||||
"""One song's write-back receipts, newest first (id order — ts has
|
||||
second granularity, so same-request rows would tie on it)."""
|
||||
with self._lock:
|
||||
rows = self.conn.execute(
|
||||
"SELECT id, key, old_value, new_value, source, score, ts "
|
||||
"FROM write_log WHERE filename = ? ORDER BY id DESC LIMIT ?",
|
||||
(filename, max(1, int(limit)))).fetchall()
|
||||
return [dict(zip(("id", "key", "old_value", "new_value", "source", "score", "ts"), r))
|
||||
for r in rows]
|
||||
|
||||
def enrichment_state_counts(self) -> dict:
|
||||
"""{match_state: count} over rows whose song still exists (dead rows are
|
||||
filtered at read time, matching the never-purged-on-rescan contract)."""
|
||||
@@ -9141,13 +9078,6 @@ def _default_settings():
|
||||
# surface first (they gain the most), artist = A–Z, recent = newest
|
||||
# files first.
|
||||
"enrich_review_order": "missing_first",
|
||||
# Overwrite (R4b). Whether the gap-fill endpoint may also REPLACE
|
||||
# author-set pack fields with a user-confirmed (pinned) match's values
|
||||
# — per-field confirmation in the drawer, a .bak of the original is
|
||||
# always kept, receipts land in write_log. Default OFF: without
|
||||
# opting in, writes to your files stay adds-absent-keys-only (the
|
||||
# §7 contract).
|
||||
"allow_pack_overwrite": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -9186,7 +9116,7 @@ def get_tunings():
|
||||
@app.get("/api/settings")
|
||||
def get_settings():
|
||||
cfg = _load_config(CONFIG_DIR / "config.json")
|
||||
return cfg if cfg is not None else _default_settings()
|
||||
return settings_with_instrument_profiles(cfg if cfg is not None else _default_settings())
|
||||
|
||||
|
||||
@app.post("/api/settings")
|
||||
@@ -9318,12 +9248,9 @@ def save_settings(data: dict):
|
||||
if not math.isfinite(t) or not (0.5 <= t <= 1.01):
|
||||
return {"error": "enrich_auto_threshold must be a number between 0.5 and 1.01"}
|
||||
updates["enrich_auto_threshold"] = t
|
||||
# allow_pack_overwrite (R4b) shares the plain-boolean shape; unlike the
|
||||
# enrich toggles its default is OFF (see _default_settings).
|
||||
for _bool_key in ("enrich_src_musicbrainz", "enrich_src_caa",
|
||||
"enrich_apply_names", "enrich_apply_year",
|
||||
"enrich_apply_genres", "enrich_apply_art",
|
||||
"allow_pack_overwrite"):
|
||||
"enrich_apply_genres", "enrich_apply_art"):
|
||||
if _bool_key in data:
|
||||
raw = data[_bool_key]
|
||||
if raw is not None:
|
||||
@@ -9398,6 +9325,38 @@ def save_settings(data: dict):
|
||||
else:
|
||||
return {"error": "tuning must be a name (string) or a list of semitone offsets"}
|
||||
|
||||
if "pathway" in data:
|
||||
raw = data["pathway"]
|
||||
if raw is not None:
|
||||
if not isinstance(raw, str) or raw not in PROFILE_PATHWAYS:
|
||||
return {"error": "pathway must be one of songs, practice, learn, studio"}
|
||||
updates["pathway"] = raw
|
||||
|
||||
_profile_patch = None
|
||||
if "instrument_profiles" in data:
|
||||
raw = data["instrument_profiles"]
|
||||
if raw is not None:
|
||||
if not isinstance(raw, dict):
|
||||
return {"error": "instrument_profiles must be an object"}
|
||||
# Validate each PROVIDED profile individually and keep the patch
|
||||
# PARTIAL — /api/settings is a partial-merge endpoint, so updating one
|
||||
# profile must NOT reset the others to defaults. Merged over the
|
||||
# persisted profiles inside the lock below (not via the wholesale
|
||||
# `updates` merge, which would clobber the unspecified ones).
|
||||
_profile_patch = {}
|
||||
for _pid, _praw in raw.items():
|
||||
if _pid not in PROFILE_IDS:
|
||||
return {"error": f"unknown instrument profile: {_pid}"}
|
||||
_prof, _perr = normalize_instrument_profile(_pid, _praw)
|
||||
if _perr:
|
||||
return {"error": _perr}
|
||||
_profile_patch[_pid] = _prof
|
||||
if "active_instrument_profile" in data:
|
||||
raw = data["active_instrument_profile"]
|
||||
if raw is not None:
|
||||
if not isinstance(raw, str) or raw not in PROFILE_IDS:
|
||||
return {"error": "active_instrument_profile must be one of guitar-lead, guitar-rhythm, bass"}
|
||||
updates["active_instrument_profile"] = raw
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Critical section — the read-merge-write must be atomic. FastAPI runs
|
||||
# sync handlers in a threadpool, so two concurrent partial POSTs (e.g.
|
||||
@@ -9414,6 +9373,29 @@ def save_settings(data: dict):
|
||||
if cfg is None:
|
||||
cfg = _default_settings()
|
||||
cfg.update(updates)
|
||||
if _profile_patch is not None:
|
||||
# Merge the validated partial over the persisted profiles so a
|
||||
# single-profile update leaves the others intact (a fresh config
|
||||
# falls back to the built-in defaults for the unspecified ones).
|
||||
_existing, _ = normalize_instrument_profiles(cfg.get("instrument_profiles"))
|
||||
if _existing is None:
|
||||
_existing = {}
|
||||
_existing.update(_profile_patch)
|
||||
cfg["instrument_profiles"] = _existing
|
||||
# Only canonicalize/persist the instrument profiles when this save
|
||||
# actually touches them (or the config already carries them). GET always
|
||||
# virtualizes profiles via settings_with_instrument_profiles, so a save
|
||||
# that doesn't touch instrument settings must stay a plain partial merge
|
||||
# — otherwise an empty (or unrelated) POST would freeze the default
|
||||
# profiles into the on-disk config.
|
||||
_profile_keys = ("instrument", "string_count", "tuning", "reference_pitch",
|
||||
"pathway", "instrument_profiles", "active_instrument_profile")
|
||||
if "instrument_profiles" in cfg or any(k in updates for k in _profile_keys):
|
||||
try:
|
||||
cfg = apply_flat_instrument_patch_to_profiles(cfg, updates)
|
||||
except ValueError as exc:
|
||||
return {"error": str(exc)}
|
||||
cfg = settings_with_instrument_profiles(cfg)
|
||||
_atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8"))
|
||||
return {"message": ". ".join(messages) if messages else "Settings saved"}
|
||||
|
||||
@@ -9425,7 +9407,8 @@ def save_settings(data: dict):
|
||||
_RESETTABLE_SETTINGS_KEYS = frozenset({
|
||||
"default_arrangement", "demucs_server_url", "master_difficulty",
|
||||
"av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior",
|
||||
"reference_pitch", "instrument", "string_count", "tuning",
|
||||
"reference_pitch", "instrument", "string_count", "tuning", "pathway",
|
||||
"instrument_profiles", "active_instrument_profile",
|
||||
"achievements_enabled", "use_amp_sims",
|
||||
})
|
||||
|
||||
@@ -9450,6 +9433,16 @@ def reset_settings(data: dict):
|
||||
removed = [k for k in keys if k in cfg]
|
||||
for k in removed:
|
||||
del cfg[k]
|
||||
# `pathway` is mirrored into every instrument profile, so deleting the
|
||||
# flat key alone doesn't reset it — GET re-derives the value from the
|
||||
# active profile. Reset it inside the persisted profiles too (back to the
|
||||
# "songs" default), without disturbing the rest of the instrument config.
|
||||
if "pathway" in keys and isinstance(cfg.get("instrument_profiles"), dict):
|
||||
for prof in cfg["instrument_profiles"].values():
|
||||
if isinstance(prof, dict):
|
||||
prof["pathway"] = "songs"
|
||||
if "pathway" not in removed:
|
||||
removed.append("pathway")
|
||||
_atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8"))
|
||||
return {"message": "Settings reset", "reset": removed}
|
||||
|
||||
@@ -9541,6 +9534,18 @@ def _validate_server_config_types(cfg: dict) -> str | None:
|
||||
return "server_config.tuning offsets must be ≤8 integers between -12 and 12"
|
||||
else:
|
||||
return "server_config.tuning must be a name (string) or a list of semitone offsets"
|
||||
if "pathway" in cfg:
|
||||
v = cfg["pathway"]
|
||||
if v is not None and (not isinstance(v, str) or v not in PROFILE_PATHWAYS):
|
||||
return "server_config.pathway must be one of songs, practice, learn, studio"
|
||||
if "instrument_profiles" in cfg:
|
||||
profiles, error = normalize_instrument_profiles(cfg["instrument_profiles"])
|
||||
if error:
|
||||
return f"server_config.{error}"
|
||||
if "active_instrument_profile" in cfg:
|
||||
v = cfg["active_instrument_profile"]
|
||||
if v is not None and (not isinstance(v, str) or v not in PROFILE_IDS):
|
||||
return "server_config.active_instrument_profile must be one of guitar-lead, guitar-rhythm, bass"
|
||||
return None
|
||||
|
||||
|
||||
@@ -9910,6 +9915,7 @@ def export_settings():
|
||||
server_config = _load_config(config_file)
|
||||
if server_config is None:
|
||||
server_config = _default_settings()
|
||||
server_config = settings_with_instrument_profiles(server_config)
|
||||
|
||||
# Snapshot the library DB + custom art FIRST: if the irreplaceable state
|
||||
# can't be captured, abort with an error rather than hand back a bundle
|
||||
@@ -10152,7 +10158,7 @@ def import_settings(bundle: dict):
|
||||
with _settings_lock:
|
||||
_atomic_write_file(
|
||||
CONFIG_DIR / "config.json",
|
||||
json.dumps(server_config, indent=2).encode("utf-8"),
|
||||
json.dumps(settings_with_instrument_profiles(server_config), indent=2).encode("utf-8"),
|
||||
)
|
||||
except OSError as e:
|
||||
# Phase-1 validation should have caught all foreseeable
|
||||
@@ -10753,79 +10759,6 @@ def _gap_fill_proposals(cache_key: str, resolved) -> tuple[dict, str]:
|
||||
return out, ("" if out else "nothing-missing")
|
||||
|
||||
|
||||
# ── Overwrite (R4b): replace author-set fields with the confirmed match ───────
|
||||
# The §7-AMENDMENT shape (draft, pending the spec chair's ack — ships as a
|
||||
# DRAFT PR): still user-initiated + single-song, per-field with default-off
|
||||
# confirmation, values only from a match the user EXPLICITLY confirmed
|
||||
# (`manual` — an automatic match may gap-fill absent keys but is never
|
||||
# authority to destroy author bytes), gated by the default-off
|
||||
# allow_pack_overwrite setting, receipts in write_log, .bak = the pristine
|
||||
# author original + a visible Revert. Identity keys (mbid/isrc) are
|
||||
# deliberately NOT overwritable: they change only via explicit manual
|
||||
# re-match. Chart/practice fields never appear here at all.
|
||||
_OVERWRITE_KEYS = ("title", "artist", "album", "year", "genres")
|
||||
|
||||
|
||||
def _overwrite_proposals(cache_key: str, resolved) -> list[dict]:
|
||||
"""What overwrite could REPLACE for this song: allowlisted keys where the
|
||||
manifest carries an author-set value AND the user-confirmed match supplies
|
||||
a DIFFERENT one — [{key, current, proposed}] in _OVERWRITE_KEYS order.
|
||||
|
||||
Empty unless match_state == 'manual' (the librarian rule above).
|
||||
Present-but-empty values ('' / year 0 / []) are not author values — those
|
||||
stay the metadata editor's job, exactly as append-only gap-fill skips
|
||||
them."""
|
||||
if resolved is None or not resolved.exists() or not sloppak_mod.is_sloppak(resolved):
|
||||
return []
|
||||
row = meta_db.get_enrichment(cache_key)
|
||||
if not row or row.get("match_state") != "manual":
|
||||
return []
|
||||
try:
|
||||
manifest = sloppak_mod.load_manifest(resolved) or {}
|
||||
except Exception:
|
||||
return []
|
||||
out = []
|
||||
for key, canon in (("title", "canon_title"), ("artist", "canon_artist"),
|
||||
("album", "canon_album")):
|
||||
proposed = (row.get(canon) or "").strip()
|
||||
cur = manifest.get(key)
|
||||
cur_s = str(cur).strip() if cur is not None else ""
|
||||
if proposed and cur_s and cur_s != proposed:
|
||||
out.append({"key": key, "current": cur_s, "proposed": proposed})
|
||||
year = (row.get("canon_year") or "").strip()
|
||||
cur = manifest.get("year")
|
||||
cur_s = str(cur).strip() if cur is not None else ""
|
||||
if (year.isdigit() and int(year) and cur_s and cur_s != "0"
|
||||
and cur_s != str(int(year))):
|
||||
out.append({"key": "year", "current": cur_s, "proposed": int(year)})
|
||||
genres = [str(g) for g in (row.get("genres") or []) if isinstance(g, str) and g.strip()]
|
||||
cur = manifest.get("genres")
|
||||
if isinstance(cur, (list, tuple)):
|
||||
cur_list = [str(g).strip() for g in cur if str(g).strip()]
|
||||
else:
|
||||
cur_list = [str(cur).strip()] if cur is not None and str(cur).strip() else []
|
||||
if genres and cur_list and cur_list != genres:
|
||||
out.append({"key": "genres", "current": cur_list, "proposed": genres})
|
||||
return out
|
||||
|
||||
|
||||
def _song_backup_path(resolved) -> Path | None:
|
||||
"""The pack's pristine-original backup, if one exists: dir-form packs back
|
||||
up the manifest (`manifest.yaml.bak`, written once by the first metadata
|
||||
write), zip-form packs back up the whole file (`<name>.bak`). None when
|
||||
the pack has never been written to (nothing to revert)."""
|
||||
if resolved is None or not resolved.exists():
|
||||
return None
|
||||
if resolved.is_dir():
|
||||
for name in ("manifest.yaml.bak", "manifest.yml.bak"):
|
||||
b = resolved / name
|
||||
if b.exists():
|
||||
return b
|
||||
return None
|
||||
b = resolved.with_name(resolved.name + ".bak")
|
||||
return b if b.exists() else None
|
||||
|
||||
|
||||
@app.get("/api/song/{filename:path}/gap-fill")
|
||||
def get_song_gap_fill(filename: str):
|
||||
"""Preview what "Write missing info to file" would add — the Details
|
||||
@@ -10842,18 +10775,11 @@ def get_song_gap_fill(filename: str):
|
||||
pass
|
||||
proposals, reason = _gap_fill_proposals(cache_key, resolved)
|
||||
row = meta_db.get_enrichment(cache_key) or {}
|
||||
cfg = _load_config(CONFIG_DIR / "config.json") or _default_settings()
|
||||
return {
|
||||
"eligible": bool(proposals),
|
||||
"reason": reason,
|
||||
"match_state": row.get("match_state"),
|
||||
"missing": [{"key": k, "value": v} for k, v in proposals.items()],
|
||||
# Overwrite (R4b): what a user-confirmed match could REPLACE, and
|
||||
# whether the Settings gate currently allows it. has_backup drives
|
||||
# the drawer's "Revert file to original…" affordance.
|
||||
"differs": _overwrite_proposals(cache_key, resolved),
|
||||
"overwrite_allowed": cfg.get("allow_pack_overwrite") is True,
|
||||
"has_backup": _song_backup_path(resolved) is not None,
|
||||
}
|
||||
|
||||
|
||||
@@ -10861,36 +10787,14 @@ def get_song_gap_fill(filename: str):
|
||||
def post_song_gap_fill(filename: str, data: dict):
|
||||
"""Write the user-confirmed subset of the preview into the pack file.
|
||||
Proposals are recomputed under the io lock, so a key that gained an
|
||||
author value between preview and confirm is skipped, never replaced.
|
||||
|
||||
`keys` (gap-fill: add absent keys) and `overwrite_keys` (R4b: replace an
|
||||
author-set value with the confirmed match's) may arrive together —
|
||||
additions append first (author bytes preserved), replacements then
|
||||
re-serialize. Overwrites are triple-gated: the default-off
|
||||
allow_pack_overwrite setting, `manual` match state (recomputed under the
|
||||
lock via _overwrite_proposals), and the _OVERWRITE_KEYS allowlist. Every
|
||||
key actually written lands in the write_log receipts table."""
|
||||
keys = (data or {}).get("keys") or []
|
||||
ow_keys = (data or {}).get("overwrite_keys") or []
|
||||
if not isinstance(keys, list) or not isinstance(ow_keys, list) or not (keys or ow_keys):
|
||||
return JSONResponse(
|
||||
{"error": "keys and/or overwrite_keys must be a non-empty list"}, 400)
|
||||
author value between preview and confirm is skipped, never replaced."""
|
||||
keys = (data or {}).get("keys")
|
||||
if not isinstance(keys, list) or not keys:
|
||||
return JSONResponse({"error": "keys must be a non-empty list"}, 400)
|
||||
bad = [k for k in keys if k not in _GAP_FILL_KEYS]
|
||||
if bad:
|
||||
return JSONResponse(
|
||||
{"error": "unknown key(s): " + ", ".join(sorted(set(map(str, bad))))}, 400)
|
||||
bad = [k for k in ow_keys if k not in _OVERWRITE_KEYS]
|
||||
if bad:
|
||||
return JSONResponse(
|
||||
{"error": "unknown overwrite key(s): " + ", ".join(sorted(set(map(str, bad))))}, 400)
|
||||
if ow_keys:
|
||||
# The Settings gate refuses the WHOLE request (nothing partial): the
|
||||
# user explicitly asked to overwrite, so failing loudly beats silently
|
||||
# writing only the gap-fill half.
|
||||
cfg = _load_config(CONFIG_DIR / "config.json") or _default_settings()
|
||||
if cfg.get("allow_pack_overwrite") is not True:
|
||||
return JSONResponse(
|
||||
{"error": "overwriting pack fields is disabled — enable it in Settings"}, 409)
|
||||
|
||||
dlc = _get_dlc_dir()
|
||||
cache_key, resolved = filename, None
|
||||
@@ -10906,36 +10810,20 @@ def post_song_gap_fill(filename: str, data: dict):
|
||||
with _song_io_lock:
|
||||
proposals, reason = _gap_fill_proposals(cache_key, resolved)
|
||||
additions = {k: proposals[k] for k in _GAP_FILL_KEYS if k in keys and k in proposals}
|
||||
# Overwrites recomputed under the lock too: a row that lost its manual
|
||||
# pin, or a manifest edit that erased the difference, falls out here
|
||||
# and is reported skipped — never written.
|
||||
diff_map = ({d["key"]: d for d in _overwrite_proposals(cache_key, resolved)}
|
||||
if ow_keys else {})
|
||||
replace = {k: diff_map[k] for k in _OVERWRITE_KEYS if k in ow_keys and k in diff_map}
|
||||
skipped = sorted((set(keys) - set(additions)) | (set(ow_keys) - set(replace)))
|
||||
if not additions and not replace:
|
||||
skipped = sorted(set(keys) - set(additions))
|
||||
if not additions:
|
||||
return JSONResponse({"error": "nothing to write", "reason": reason,
|
||||
"skipped": skipped}, 409)
|
||||
try:
|
||||
import songmeta
|
||||
# Order matters: append the absent keys FIRST (gap-fill preserves
|
||||
# the author's existing bytes), then re-serialize for the
|
||||
# replacements — write_sloppak_metadata re-reads the manifest, so
|
||||
# it keeps the just-appended keys. Both writers share the
|
||||
# .bak-once contract, so whichever runs first snapshots the
|
||||
# pristine original.
|
||||
if additions:
|
||||
songmeta.gap_fill_sloppak(resolved, additions)
|
||||
if replace:
|
||||
songmeta.write_sloppak_metadata(
|
||||
resolved, {k: d["proposed"] for k, d in replace.items()})
|
||||
songmeta.gap_fill_sloppak(resolved, additions)
|
||||
except Exception:
|
||||
log.warning("gap-fill write failed for %s", cache_key, exc_info=True)
|
||||
return JSONResponse({"error": "write failed"}, 500)
|
||||
|
||||
# Keep the cache row consistent with what the scanner would now derive
|
||||
# (same contract as the metadata editor above): sync the columns the
|
||||
# scan reads from the keys we wrote, then re-stat so the row stays
|
||||
# scan reads from the keys we appended, then re-stat so the row stays
|
||||
# cache-fresh.
|
||||
fields = {}
|
||||
if "album" in additions:
|
||||
@@ -10944,13 +10832,6 @@ def post_song_gap_fill(filename: str, data: dict):
|
||||
fields["year"] = str(additions["year"])
|
||||
if "genres" in additions:
|
||||
fields["genre"] = additions["genres"][0]
|
||||
for k, d in replace.items():
|
||||
if k == "genres":
|
||||
fields["genre"] = d["proposed"][0]
|
||||
elif k == "year":
|
||||
fields["year"] = str(d["proposed"])
|
||||
else: # title / artist / album
|
||||
fields[k] = d["proposed"]
|
||||
with meta_db._lock:
|
||||
updates = [f"{field} = ?" for field in fields]
|
||||
params = list(fields.values())
|
||||
@@ -10966,103 +10847,9 @@ def post_song_gap_fill(filename: str, data: dict):
|
||||
f"UPDATE songs SET {', '.join(updates)} WHERE filename = ?", params)
|
||||
meta_db.conn.commit()
|
||||
|
||||
# Receipts (R4b): one row per key actually written — gap-fills carry
|
||||
# no old value, overwrites carry old + new. Source/score identify the
|
||||
# match that authorized the values.
|
||||
row = meta_db.get_enrichment(cache_key) or {}
|
||||
meta_db.add_write_log(
|
||||
cache_key,
|
||||
[(k, None, v) for k, v in additions.items()]
|
||||
+ [(k, d["current"], d["proposed"]) for k, d in replace.items()],
|
||||
source=row.get("match_source"), score=row.get("match_score"))
|
||||
|
||||
_invalidate_song_caches(cache_key)
|
||||
_kick_scan()
|
||||
out = {"ok": True, "written": additions, "skipped": skipped}
|
||||
if ow_keys:
|
||||
# Only present when overwriting was requested, so pre-R4b consumers
|
||||
# (and their exact-shape tests) see the unchanged response.
|
||||
out["overwritten"] = {k: {"old": d["current"], "new": d["proposed"]}
|
||||
for k, d in replace.items()}
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/song/{filename:path}/write-log")
|
||||
def get_song_write_log(filename: str):
|
||||
"""This song's write-back receipts (R4b provenance), newest first —
|
||||
read-only. The local ledger of every key gap-fill/overwrite wrote into
|
||||
the pack file, until the spec's provenance FEP gives an in-file shape."""
|
||||
dlc = _get_dlc_dir()
|
||||
cache_key = filename
|
||||
if dlc:
|
||||
resolved = _resolve_dlc_path(dlc, filename)
|
||||
if resolved is None:
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
try:
|
||||
cache_key = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
pass
|
||||
return {"rows": meta_db.write_log_rows(cache_key)}
|
||||
|
||||
|
||||
@app.post("/api/song/{filename:path}/revert-original")
|
||||
def post_song_revert_original(filename: str):
|
||||
"""Restore the pack file from its backup — the pristine author original
|
||||
that the FIRST metadata write snapshotted (dir form: manifest.yaml.bak →
|
||||
manifest.yaml; zip form: the whole-file .bak → the file). The backup
|
||||
itself is PRESERVED after the revert (the user may re-apply later).
|
||||
Refuses with 404 when no backup exists. Demo-blocked (middleware)."""
|
||||
dlc = _get_dlc_dir()
|
||||
cache_key, resolved = filename, None
|
||||
if dlc:
|
||||
resolved = _resolve_dlc_path(dlc, filename)
|
||||
if resolved is None:
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
try:
|
||||
cache_key = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Confine revert to actual song packages, mirroring the write path's guard
|
||||
# (_gap_fill_proposals / _overwrite_proposals both refuse non-sloppak
|
||||
# targets). Without this, a non-package path under DLC_DIR that happens to
|
||||
# have a sibling `.bak` (or a plain directory carrying a manifest.yaml.bak)
|
||||
# would get its backup copied over it and the DB re-synced — mutating a file
|
||||
# this feature was never meant to touch. Same 404 the art/source endpoints
|
||||
# return for a non-sloppak target.
|
||||
if resolved is None or not resolved.exists() or not sloppak_mod.is_sloppak(resolved):
|
||||
return JSONResponse({"error": "not found"}, 404)
|
||||
|
||||
with _song_io_lock:
|
||||
bak = _song_backup_path(resolved)
|
||||
if bak is None:
|
||||
return JSONResponse({"error": "no backup to revert to"}, 404)
|
||||
try:
|
||||
# Copy (never move) + temp + atomic replace: the .bak survives.
|
||||
if resolved.is_dir():
|
||||
target = resolved / bak.name[: -len(".bak")]
|
||||
else:
|
||||
target = resolved
|
||||
tmp = target.with_name(target.name + ".tmp")
|
||||
shutil.copy2(bak, tmp)
|
||||
tmp.replace(target)
|
||||
except Exception:
|
||||
log.warning("revert-original failed for %s", cache_key, exc_info=True)
|
||||
return JSONResponse({"error": "revert failed"}, 500)
|
||||
|
||||
# The file's identity may have changed wholesale — re-extract and
|
||||
# re-stat so the DB row matches what the scanner would now derive
|
||||
# (the full-row mirror of the metadata editor's column sync).
|
||||
try:
|
||||
meta = _extract_meta_for_file(resolved, _get_dlc_dir)
|
||||
mtime, size = _stat_for_cache(resolved)
|
||||
meta_db.put(cache_key, mtime, size, meta)
|
||||
except Exception:
|
||||
log.warning("revert-original DB resync failed for %s", cache_key, exc_info=True)
|
||||
|
||||
_invalidate_song_caches(cache_key)
|
||||
_kick_scan()
|
||||
return {"ok": True}
|
||||
return {"ok": True, "written": additions, "skipped": skipped}
|
||||
|
||||
|
||||
def _save_art_override(filename: str, img_data: bytes) -> dict:
|
||||
|
||||
@@ -2758,6 +2758,12 @@ function goFavTreePage(p) {
|
||||
// ── Settings ─────────────────────────────────────────────────────────────
|
||||
let _defaultArrangement = '';
|
||||
|
||||
const INSTRUMENT_PATHWAYS = ['songs', 'practice', 'learn', 'studio'];
|
||||
|
||||
function _normalizeInstrumentPathway(value) {
|
||||
return INSTRUMENT_PATHWAYS.includes(value) ? value : 'songs';
|
||||
}
|
||||
|
||||
function _syncDefaultArrangementSelect(value) {
|
||||
const sel = document.getElementById('default-arrangement');
|
||||
if (!sel) return;
|
||||
@@ -3410,6 +3416,8 @@ async function loadSettings() {
|
||||
if (dlcEl) dlcEl.value = data.dlc_dir || '';
|
||||
_defaultArrangement = data.default_arrangement || '';
|
||||
_syncDefaultArrangementSelect(_defaultArrangement);
|
||||
const pathwayEl = document.getElementById('setting-instrument-pathway');
|
||||
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
|
||||
const demucsEl = document.getElementById('demucs-server-url');
|
||||
if (demucsEl) demucsEl.value = data.demucs_server_url || '';
|
||||
const leftyEl = document.getElementById('setting-lefty');
|
||||
@@ -3901,6 +3909,18 @@ function persistSetting(key, value) {
|
||||
_settingSaveChain = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
function setInstrumentPathway(value) {
|
||||
const pathway = _normalizeInstrumentPathway(value);
|
||||
const el = document.getElementById('setting-instrument-pathway');
|
||||
if (el) el.value = pathway;
|
||||
persistSetting('pathway', pathway).then(() => {
|
||||
if (window.v3Badges && typeof window.v3Badges.reload === 'function') {
|
||||
try { window.v3Badges.reload(); } catch (_) { /* noop */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function _postSetting(key, value) {
|
||||
const status = document.getElementById('settings-status');
|
||||
try {
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
return fetch('/api/tunings')
|
||||
.then(function (r) { return r && r.ok ? r.json() : null; })
|
||||
.then(function (t) {
|
||||
const byName = t && t[key];
|
||||
const byName = t && ((t.tunings && t.tunings[key]) || t[key]);
|
||||
commit(byName ? _offsetsFromFreqs(byName[s.tuning], byName.Standard) : null);
|
||||
})
|
||||
.catch(function () { commit(null); });
|
||||
|
||||
+53
-5
@@ -21,7 +21,13 @@
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5] };
|
||||
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5, 6] };
|
||||
const PATHWAY_OPTIONS = [
|
||||
{ id: 'songs', label: 'Songs' },
|
||||
{ id: 'practice', label: 'Practice' },
|
||||
{ id: 'learn', label: 'Learn' },
|
||||
{ id: 'studio', label: 'Studio' },
|
||||
];
|
||||
// Tuning names per instrument key (e.g. 'guitar-6', 'bass-4'), loaded from
|
||||
// GET /api/tunings. Falls back to empty arrays until the fetch resolves.
|
||||
let _tuningsByKey = {};
|
||||
@@ -106,7 +112,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 };
|
||||
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' };
|
||||
|
||||
async function loadTunings() {
|
||||
try {
|
||||
@@ -126,6 +132,15 @@
|
||||
} catch (_) { /* non-fatal — TUNINGS falls back to empty, dropdown shows nothing */ }
|
||||
}
|
||||
|
||||
function pathwayForProfile(profiles, profileId, fallback) {
|
||||
const p = profiles && profiles[profileId];
|
||||
return p && PATHWAY_OPTIONS.some((o) => o.id === p.pathway) ? p.pathway : (fallback || 'songs');
|
||||
}
|
||||
|
||||
function profileIdForInstrument(inst) {
|
||||
return inst === 'bass' ? 'bass' : 'guitar-lead';
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const r = await fetch('/api/settings');
|
||||
@@ -150,16 +165,34 @@
|
||||
if (typeof s.tuning === 'string') tuning = tunings.includes(s.tuning) ? s.tuning : (tunings[0] || 'Standard');
|
||||
else if (Array.isArray(s.tuning)) tuning = s.tuning;
|
||||
else tuning = tunings[0] || 'Standard';
|
||||
const profiles = s.instrument_profiles && typeof s.instrument_profiles === 'object' ? s.instrument_profiles : {};
|
||||
const pathway = PATHWAY_OPTIONS.some((o) => o.id === s.pathway) ? s.pathway : 'songs';
|
||||
settings = {
|
||||
instrument: instrument,
|
||||
string_count: scValid,
|
||||
tuning: tuning,
|
||||
reference_pitch: Math.min(450, Math.max(430, ref)),
|
||||
pathway: pathway,
|
||||
instrument_profiles: profiles,
|
||||
active_instrument_profile: typeof s.active_instrument_profile === 'string' ? s.active_instrument_profile : profileIdForInstrument(instrument),
|
||||
};
|
||||
}
|
||||
} catch (e) { /* settings endpoint always present */ }
|
||||
}
|
||||
|
||||
function syncLocalProfilePatch(patch) {
|
||||
const profileId = profileIdForInstrument(patch.instrument || settings.instrument);
|
||||
if (!settings.instrument_profiles || typeof settings.instrument_profiles !== 'object') settings.instrument_profiles = {};
|
||||
if (patch.instrument) settings.active_instrument_profile = profileId;
|
||||
const profile = Object.assign({}, settings.instrument_profiles[profileId] || {});
|
||||
let changed = false;
|
||||
if (patch.instrument) { profile.instrument = patch.instrument; changed = true; }
|
||||
if (patch.string_count != null) { profile.string_count = patch.string_count; changed = true; }
|
||||
if (patch.tuning != null) { profile.tuning = patch.tuning; changed = true; }
|
||||
if (patch.reference_pitch != null) { profile.reference_pitch = patch.reference_pitch; changed = true; }
|
||||
if (patch.pathway != null) { profile.pathway = patch.pathway; changed = true; }
|
||||
if (changed) settings.instrument_profiles[profileId] = profile;
|
||||
}
|
||||
async function saveSettings(patch) {
|
||||
// Only adopt the patch once the server accepts it. /api/settings returns
|
||||
// {error: ...} with HTTP 200 on a validation failure, so a rejected
|
||||
@@ -177,8 +210,9 @@
|
||||
} catch (e) { /* non-fatal — leave settings unchanged */ }
|
||||
if (!accepted) return false;
|
||||
Object.assign(settings, patch);
|
||||
syncLocalProfilePatch(patch);
|
||||
if (sm && sm.emit) sm.emit('instrument:changed', {
|
||||
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning,
|
||||
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning, pathway: settings.pathway,
|
||||
});
|
||||
pushToTuner();
|
||||
renderTuner(); // reflect new tuning on the tuner card
|
||||
@@ -424,6 +458,9 @@
|
||||
// (picking a named tuning still works and replaces the custom one).
|
||||
(typeof settings.tuning === 'string' ? '' : '<option selected disabled>Custom</option>') +
|
||||
_tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '<option' + (t === settings.tuning ? ' selected' : '') + '>' + esc(t) + '</option>').join('') + '</select></div>' +
|
||||
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Pathway</div>' +
|
||||
'<select data-inst-pathway class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
|
||||
PATHWAY_OPTIONS.map((p) => '<option value="' + esc(p.id) + '"' + (p.id === settings.pathway ? ' selected' : '') + '>' + esc(p.label) + '</option>').join('') + '</select></div>' +
|
||||
'<div><div class="flex justify-between text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
|
||||
'<input data-inst-ref type="range" min="430" max="450" step="1" value="' + settings.reference_pitch + '" class="w-full slider-input"></div>' +
|
||||
'</div></div>';
|
||||
@@ -454,6 +491,7 @@
|
||||
instrument: v,
|
||||
string_count: newSc,
|
||||
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
|
||||
pathway: pathwayForProfile(settings.instrument_profiles, profileIdForInstrument(v), settings.pathway),
|
||||
});
|
||||
// Only move the working-tuning context once the switch was actually persisted —
|
||||
// otherwise the selector stays on the old instrument while the card shows the
|
||||
@@ -462,11 +500,21 @@
|
||||
renderInstrument(); keepOpen();
|
||||
}));
|
||||
menu.querySelectorAll('[data-pill="strings"]').forEach((b) => b.addEventListener('click', async () => {
|
||||
await saveSettings({ string_count: Number(b.getAttribute('data-val')) });
|
||||
setWorkingInstrument(settings.instrument, settings.string_count);
|
||||
const newSc = Number(b.getAttribute('data-val'));
|
||||
// Clamp the tuning to one valid for the new string count and post it
|
||||
// alongside string_count — otherwise the backend silently resets a
|
||||
// now-invalid tuning to Standard while this UI keeps showing the old
|
||||
// one (settings/tuner desync). Mirrors the instrument-switch clamp.
|
||||
const tunings = _tuningsForInstrument(settings.instrument, newSc);
|
||||
await saveSettings({
|
||||
string_count: newSc,
|
||||
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
|
||||
});
|
||||
setWorkingInstrument(settings.instrument, newSc);
|
||||
renderInstrument(); keepOpen();
|
||||
}));
|
||||
menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: e.target.value }));
|
||||
menu.querySelector('[data-inst-pathway]').addEventListener('change', (e) => saveSettings({ pathway: e.target.value }));
|
||||
const ref = menu.querySelector('[data-inst-ref]');
|
||||
ref.addEventListener('input', (e) => { menu.querySelector('[data-ref-val]').textContent = e.target.value + ' Hz'; });
|
||||
ref.addEventListener('change', (e) => saveSettings({ reference_pitch: Number(e.target.value) }));
|
||||
|
||||
+17
-10
@@ -429,6 +429,23 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Instrument pathway -->
|
||||
<div class="fb-srow">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Instrument pathway</div>
|
||||
<div class="fb-srow-desc">Preferred path for the selected instrument. This is remembered per instrument profile.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<select id="setting-instrument-pathway" onchange="setInstrumentPathway(this.value)"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="songs">Songs</option>
|
||||
<option value="practice">Practice</option>
|
||||
<option value="learn">Learn</option>
|
||||
<option value="studio">Studio</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Arrangement routes (naming mode) -->
|
||||
<div class="fb-srow">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
|
||||
@@ -785,16 +802,6 @@
|
||||
<span id="enrich-status" class="text-xs text-gray-500"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Writing to your files (R4a gap-fill + R4b overwrite — wired by static/v3/match-review.js) -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Writing to your files</div>
|
||||
<div class="fb-srow-desc">"Write missing info to file" in a song's details only ever adds fields the pack is missing. Overwriting additionally lets a match you confirmed replace existing fields — always per-field, never automatic.</div>
|
||||
</div>
|
||||
<div class="fb-srow-wide">
|
||||
<label class="flex items-center gap-2 text-xs text-gray-400"><input type="checkbox" id="allow-pack-overwrite" class="rounded border-gray-600 bg-dark-700 text-accent"> Allow overwriting existing pack fields — per-field confirmation, a backup of the original is always kept</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Backup -->
|
||||
<div class="fb-srow fb-srow-stack">
|
||||
<div class="fb-srow-main">
|
||||
|
||||
@@ -406,7 +406,7 @@
|
||||
const btn = document.getElementById('enrich-match-now');
|
||||
// Boolean toggles, element id → settings key. enrich-enabled is the
|
||||
// master background switch; the rest are the R1 scraper options
|
||||
// (per-source + per-field auto-apply) plus the R4b overwrite gate.
|
||||
// (per-source + per-field auto-apply).
|
||||
const toggles = [
|
||||
['enrich-enabled', 'enrich_enabled'],
|
||||
['enrich-src-musicbrainz', 'enrich_src_musicbrainz'],
|
||||
@@ -415,9 +415,6 @@
|
||||
['enrich-apply-year', 'enrich_apply_year'],
|
||||
['enrich-apply-genres', 'enrich_apply_genres'],
|
||||
['enrich-apply-art', 'enrich_apply_art'],
|
||||
// R4b pack-overwrite gate — the one DEFAULT-OFF key in this list
|
||||
// (see the load logic below).
|
||||
['allow-pack-overwrite', 'allow_pack_overwrite'],
|
||||
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
|
||||
if (!toggles.length && !sel && !btn) return;
|
||||
(async () => {
|
||||
@@ -425,11 +422,7 @@
|
||||
const r = await fetch('/api/settings');
|
||||
if (r.ok) {
|
||||
const cfg = await r.json();
|
||||
// The enrich keys default ON (absent → checked); the
|
||||
// overwrite gate defaults OFF (only an explicit true ticks it).
|
||||
for (const [el, key] of toggles) {
|
||||
el.checked = key === 'allow_pack_overwrite' ? cfg[key] === true : cfg[key] !== false;
|
||||
}
|
||||
for (const [el, key] of toggles) el.checked = cfg[key] !== false;
|
||||
if (sel) {
|
||||
const t = Number(cfg.enrich_auto_threshold);
|
||||
const want = Number.isFinite(t) ? t : 0.9;
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
var RESET_MAP = {
|
||||
gameplay: {
|
||||
server: ['master_difficulty', 'av_offset_ms', 'miss_penalty',
|
||||
'fail_behavior', 'countdown_before_song', 'default_arrangement'],
|
||||
'fail_behavior', 'countdown_before_song', 'default_arrangement', 'pathway'],
|
||||
local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'],
|
||||
after: function () {
|
||||
// Left-handed is held on the highway object, not re-derived
|
||||
|
||||
+19
-108
@@ -2462,7 +2462,6 @@
|
||||
notes: meta.notes || '', tags: (meta.tags || []).slice(),
|
||||
fav: !!song.favorite, artDataUrl: null,
|
||||
gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys
|
||||
owSel: null, // overwrite (R4b): selected differs keys — default NONE ticked
|
||||
};
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
@@ -2489,76 +2488,39 @@
|
||||
if (first) { try { first.focus({ preventScroll: true }); const n = first.value.length; first.setSelectionRange(n, n); } catch (_) { /* */ } }
|
||||
}
|
||||
|
||||
// Gap-fill (R4a) + overwrite (R4b) block inside the drawer's Identity
|
||||
// section: preview → per-key confirm → written. Gap-fill adds ABSENT keys
|
||||
// only (defaults all ticked); overwrite (`differs`) REPLACES author-set
|
||||
// values — gated by the Settings allow_pack_overwrite toggle + a manual
|
||||
// (user-pinned) match, and its rows default UNTICKED (the per-field
|
||||
// checkbox IS the are-you-sure). The server re-checks everything under
|
||||
// its io lock, so this UI can never write a value the server wouldn't.
|
||||
const GAP_KEY_LABELS = { title: 'Title', artist: 'Artist', album: 'Album', year: 'Year', genres: 'Genres', mbid: 'MusicBrainz ID', isrc: 'ISRC' };
|
||||
// Gap-fill (R4a) block inside the drawer's Identity section: preview →
|
||||
// per-key confirm → written. Adds ABSENT keys only; the server re-checks
|
||||
// under its io lock, so this UI can never replace an author-set value.
|
||||
const GAP_KEY_LABELS = { album: 'Album', year: 'Year', genres: 'Genres', mbid: 'MusicBrainz ID', isrc: 'ISRC' };
|
||||
function gapFillHtml(st) {
|
||||
const g = st.gap;
|
||||
if (!g) return '<button data-gapfill-check class="text-xs text-fb-textDim hover:text-fb-text">Write missing info to file…</button>';
|
||||
if (g.loading) return '<div class="text-xs text-fb-textDim">Checking the file…</div>';
|
||||
// Small revert affordance whenever a pristine-original backup exists.
|
||||
const revertLink = g.has_backup
|
||||
? '<div><button data-gapfill-revert class="text-[0.6875rem] text-fb-textDim hover:text-fb-text underline decoration-dotted">Revert file to original…</button></div>'
|
||||
: '';
|
||||
if (g.written || g.overwritten) {
|
||||
const names = (obj) => Object.keys(obj || {}).map((k) => GAP_KEY_LABELS[k] || k).join(', ');
|
||||
const added = names(g.written), replaced = names(g.overwritten);
|
||||
return '<div class="space-y-1">' +
|
||||
(added ? '<div class="text-xs text-fb-text">✓ Added to file: ' + esc(added) + '</div>' : '') +
|
||||
(replaced ? '<div class="text-xs text-fb-text">✓ Replaced in file: ' + esc(replaced) + '</div>' : '') +
|
||||
revertLink + '</div>';
|
||||
if (g.written) {
|
||||
const names = Object.keys(g.written).map((k) => GAP_KEY_LABELS[k] || k).join(', ');
|
||||
return '<div class="text-xs text-fb-text">✓ Added to file: ' + esc(names) + '</div>';
|
||||
}
|
||||
const missing = g.missing || [], differs = g.differs || [];
|
||||
const lockedLine = (differs.length && !g.overwrite_allowed)
|
||||
? '<div class="text-[0.6875rem] text-fb-textDim">' + differs.length +
|
||||
(differs.length === 1 ? ' field differs' : ' fields differ') +
|
||||
' from the matched data — enable overwriting in Settings to change them.</div>'
|
||||
: '';
|
||||
const canOverwrite = differs.length && g.overwrite_allowed;
|
||||
if (!missing.length && !canOverwrite) {
|
||||
if (!g.eligible) {
|
||||
const why = {
|
||||
'not-sloppak': 'Only feedpak songs can be written to.',
|
||||
'no-match': 'No confirmed match yet — nothing verified to write.',
|
||||
'review': 'This song’s match is waiting for review — confirm it first.',
|
||||
'nothing-missing': 'Nothing missing — the file already has all of this.',
|
||||
}[g.reason] || 'Could not check the file. Try again.';
|
||||
return '<div class="space-y-1"><div class="text-xs text-fb-textDim">' + esc(why) + '</div>' + lockedLine + revertLink + '</div>';
|
||||
return '<div class="text-xs text-fb-textDim">' + esc(why) + '</div>';
|
||||
}
|
||||
const rows = missing.map((m) => {
|
||||
const rows = (g.missing || []).map((m) => {
|
||||
const val = Array.isArray(m.value) ? m.value.join(', ') : String(m.value);
|
||||
return '<label class="flex items-center gap-2 text-sm text-fb-text">' +
|
||||
'<input type="checkbox" data-gapfill-key="' + esc(m.key) + '"' + (st.gapSel && st.gapSel.has(m.key) ? ' checked' : '') + '>' +
|
||||
'<span class="text-fb-textDim shrink-0">' + esc(GAP_KEY_LABELS[m.key] || m.key) + '</span>' +
|
||||
'<span class="truncate" title="' + esc(val) + '">' + esc(val) + '</span></label>';
|
||||
}).join('');
|
||||
const owVal = (v) => Array.isArray(v) ? v.join(', ') : String(v);
|
||||
const owRows = canOverwrite ? differs.map((d) => {
|
||||
const cur = owVal(d.current), nxt = owVal(d.proposed);
|
||||
const pair = '“' + cur + '” → “' + nxt + '”';
|
||||
return '<label class="flex items-center gap-2 text-sm text-fb-text">' +
|
||||
'<input type="checkbox" data-ow-key="' + esc(d.key) + '"' + (st.owSel && st.owSel.has(d.key) ? ' checked' : '') + '>' +
|
||||
'<span class="text-fb-textDim shrink-0">' + esc(GAP_KEY_LABELS[d.key] || d.key) + '</span>' +
|
||||
'<span class="truncate" title="' + esc(pair) + '">' + esc(pair) + '</span></label>';
|
||||
}).join('') : '';
|
||||
return '<div class="space-y-2">' +
|
||||
(missing.length
|
||||
? '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Write to file</div>' + rows +
|
||||
'<div class="text-[0.6875rem] text-fb-textDim">Only adds what’s missing — nothing already in the file is changed. A backup (.bak) is kept beside the file.</div>'
|
||||
: '') +
|
||||
(owRows
|
||||
? '<div class="pt-2 border-t border-fb-border/40 space-y-2">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Overwrite existing fields</div>' + owRows +
|
||||
'<div class="text-[0.6875rem] text-yellow-500/90">Replaces what the pack author wrote. The original file is kept as a backup.</div></div>'
|
||||
: '') +
|
||||
lockedLine +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Write to file</div>' + rows +
|
||||
'<div class="text-[0.6875rem] text-fb-textDim">Only adds what’s missing — nothing already in the file is changed. A backup (.bak) is kept beside the file.</div>' +
|
||||
'<div class="flex gap-2"><button data-gapfill-write class="bg-fb-primary hover:bg-fb-primaryHi text-white px-3 py-1.5 rounded-lg text-xs font-semibold">Write to file</button>' +
|
||||
'<button data-gapfill-cancel class="px-3 py-1.5 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-lg text-xs text-fb-text">Cancel</button></div>' +
|
||||
revertLink + '</div>';
|
||||
'<button data-gapfill-cancel class="px-3 py-1.5 bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 rounded-lg text-xs text-fb-text">Cancel</button></div></div>';
|
||||
}
|
||||
|
||||
function detailsHtml(song, st, vocab) {
|
||||
@@ -2662,17 +2624,15 @@
|
||||
$('[data-det-save]')?.addEventListener('click', () => saveDetails(song, st));
|
||||
$('[data-det-remove]')?.addEventListener('click', () => removeFromLibrary(song));
|
||||
|
||||
// Gap-fill (R4a) + overwrite (R4b): user-initiated write of CONFIRMED
|
||||
// info into the pack file. The server recomputes proposals under its
|
||||
// io lock, so a key that gained an author value since the preview is
|
||||
// skipped, and an overwrite that lost eligibility is never written.
|
||||
// Gap-fill (R4a): user-initiated write of CONFIRMED missing info into
|
||||
// the pack file. The server recomputes proposals under its io lock, so
|
||||
// a key that gained an author value since the preview is skipped.
|
||||
$('[data-gapfill-check]')?.addEventListener('click', async () => {
|
||||
st.gap = { loading: true }; render();
|
||||
let d = null;
|
||||
try { const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill'); if (r.ok) d = await r.json(); } catch (_) { /* offline */ }
|
||||
st.gap = d || { eligible: false, reason: 'error' };
|
||||
st.gapSel = new Set(((d && d.missing) || []).map((m) => m.key));
|
||||
st.owSel = new Set(); // overwrite rows start UNTICKED — always
|
||||
render();
|
||||
});
|
||||
drawer.querySelectorAll('[data-gapfill-key]').forEach((cb) => cb.addEventListener('change', () => {
|
||||
@@ -2680,21 +2640,13 @@
|
||||
if (!st.gapSel) st.gapSel = new Set();
|
||||
if (cb.checked) st.gapSel.add(k); else st.gapSel.delete(k);
|
||||
}));
|
||||
drawer.querySelectorAll('[data-ow-key]').forEach((cb) => cb.addEventListener('change', () => {
|
||||
const k = cb.getAttribute('data-ow-key');
|
||||
if (!st.owSel) st.owSel = new Set();
|
||||
if (cb.checked) st.owSel.add(k); else st.owSel.delete(k);
|
||||
}));
|
||||
$('[data-gapfill-cancel]')?.addEventListener('click', () => { st.gap = null; render(); });
|
||||
$('[data-gapfill-write]')?.addEventListener('click', async () => {
|
||||
const keys = st.gapSel ? Array.from(st.gapSel) : [];
|
||||
const owKeys = st.owSel ? Array.from(st.owSel) : [];
|
||||
if (!keys.length && !owKeys.length) return;
|
||||
const body = { keys };
|
||||
if (owKeys.length) body.overwrite_keys = owKeys;
|
||||
if (!keys.length) return;
|
||||
let d = null, ok = false;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/gap-fill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys }) });
|
||||
ok = r.ok; d = await r.json();
|
||||
} catch (_) { /* offline */ }
|
||||
if (!ok || !d || !d.written) {
|
||||
@@ -2706,48 +2658,7 @@
|
||||
// the grid quietly.
|
||||
if (d.written.album != null) { st.al = String(d.written.album); song.album = st.al; }
|
||||
if (d.written.year != null) { st.y = String(d.written.year); song.year = d.written.year; }
|
||||
const ow = d.overwritten || {};
|
||||
if (ow.title) { st.t = String(ow.title.new); song.title = st.t; }
|
||||
if (ow.artist) { st.a = String(ow.artist.new); song.artist = st.a; }
|
||||
if (ow.album) { st.al = String(ow.album.new); song.album = st.al; }
|
||||
if (ow.year) { st.y = String(ow.year.new); song.year = ow.year.new; }
|
||||
// A write always leaves a backup behind → keep the revert link.
|
||||
st.gap = { written: d.written, overwritten: (Object.keys(ow).length ? ow : null), has_backup: true };
|
||||
render();
|
||||
try { reload(); } catch (_) { /* not on the songs grid */ }
|
||||
});
|
||||
// Revert (R4b): restore the pack file from its pristine-original
|
||||
// backup. Confirmed like removeFromLibrary; the .bak is preserved.
|
||||
$('[data-gapfill-revert]')?.addEventListener('click', async () => {
|
||||
const title = song.title || song.filename;
|
||||
let sure;
|
||||
if (window._confirmDialog) {
|
||||
sure = await window._confirmDialog({
|
||||
title: 'Revert file to original?',
|
||||
body: '<p class="text-sm text-gray-300">Restore <span class="font-semibold text-white">' + esc(title) + '</span>'s file to what the pack author originally wrote? Everything written to the file since (added and overwritten fields) is undone.</p>' +
|
||||
'<p class="text-xs text-gray-500 mt-2">The backup is kept, so you can write the matched data again later.</p>',
|
||||
confirmText: 'Revert', cancelText: 'Cancel', danger: true,
|
||||
});
|
||||
} else { sure = window.confirm('Revert "' + title + '" to the pack author\'s original file?'); }
|
||||
if (!sure) return;
|
||||
let ok = false;
|
||||
try { const r = await fetch('/api/song/' + enc(song.filename) + '/revert-original', { method: 'POST' }); ok = r.ok; } catch (_) { /* offline */ }
|
||||
if (!ok) {
|
||||
if (window.fbNotify) { try { window.fbNotify.show({ title: 'Revert failed', message: 'Could not restore the original file. Please try again.', icon: '⚠️', accent: '#EF4444' }); } catch (e) { /* */ } }
|
||||
return;
|
||||
}
|
||||
// Refresh the drawer from the restored file, then the grid.
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename));
|
||||
if (r.ok) {
|
||||
const m = await r.json();
|
||||
song.title = m.title || ''; song.artist = m.artist || '';
|
||||
song.album = m.album || ''; song.year = m.year;
|
||||
st.t = song.title; st.a = song.artist; st.al = song.album;
|
||||
st.y = (m.year != null && m.year !== '') ? String(m.year) : '';
|
||||
}
|
||||
} catch (_) { /* keep the stale fields; reload() below still runs */ }
|
||||
st.gap = null; st.gapSel = null; st.owSel = null; render();
|
||||
st.gap = { written: d.written }; render();
|
||||
try { reload(); } catch (_) { /* not on the songs grid */ }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ const { createWindow, ROOT } = require('./capabilities_test_harness');
|
||||
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
|
||||
const WORKING_TUNING_JS = path.join(ROOT, 'static', 'capabilities', 'working-tuning.js');
|
||||
|
||||
// A /api/tunings-shaped fixture (frequencies at 440), enough to resolve names to offsets.
|
||||
const TUNINGS = {
|
||||
// Tuning frequency fixture at 440 Hz, enough to resolve names to offsets.
|
||||
const TUNING_TABLE = {
|
||||
'guitar-6': {
|
||||
Standard: [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
'Drop D': [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
@@ -26,6 +26,7 @@ const TUNINGS = {
|
||||
Standard: [30.87, 41.20, 55.00, 73.42, 98.00],
|
||||
},
|
||||
};
|
||||
const API_TUNINGS = { referencePitch: 440, tunings: TUNING_TABLE };
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
@@ -159,7 +160,7 @@ test('bare-instrument writes target the current selection, not a hard-coded defa
|
||||
test('seed resolves a NAMED tuning to offsets via /api/tunings', async () => {
|
||||
const { wt, changes } = loadWorkingTuning({
|
||||
'/api/settings': { instrument: 'guitar', string_count: 6, tuning: 'Drop D', reference_pitch: 440 },
|
||||
'/api/tunings': TUNINGS,
|
||||
'/api/tunings': API_TUNINGS,
|
||||
});
|
||||
await flush();
|
||||
const s = wt.get('guitar-6');
|
||||
@@ -183,7 +184,7 @@ test('boot race: an explicit set() before settings resolve is not clobbered by t
|
||||
const settings = deferred();
|
||||
const { wt } = loadWorkingTuning({
|
||||
'/api/settings': settings.promise, // held open
|
||||
'/api/tunings': TUNINGS,
|
||||
'/api/tunings': API_TUNINGS,
|
||||
});
|
||||
// A consumer writes before the seed lands.
|
||||
wt.set({ offsets: [-5, -5, -5, -5, -5, -5] }, { instrument: 'guitar-6' });
|
||||
|
||||
@@ -8,7 +8,6 @@ No network anywhere: matches are seeded straight into the enrichment cache
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
@@ -28,16 +27,6 @@ def server(tmp_path, monkeypatch, isolate_logging):
|
||||
try:
|
||||
yield srv
|
||||
finally:
|
||||
# A write endpoint kicks a background scan (which then kicks the
|
||||
# enrichment worker); both daemon threads share meta_db's sqlite
|
||||
# connection. Drain them before closing it — closing mid-scan
|
||||
# crashes the thread with an access violation on Windows. Network
|
||||
# is off (FEEDBACK_SKIP_STARTUP_TASKS), so both drain in ms.
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline and (
|
||||
srv._scan_status.get("running")
|
||||
or srv._enrich_status.get("running")):
|
||||
time.sleep(0.05)
|
||||
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
"""Tests for the R4b pack-field overwrite — the §7-AMENDMENT shape made
|
||||
executable: per-field confirmation with values only from a match the user
|
||||
EXPLICITLY confirmed (`manual` — an automatic match may gap-fill but never
|
||||
replace), a default-OFF Settings gate (allow_pack_overwrite), identity keys
|
||||
(mbid/isrc) never overwritable, receipts in write_log (old/new/source/score,
|
||||
pruned at 5000 rows), and .bak = the pristine author original with a working
|
||||
Revert that preserves the backup.
|
||||
|
||||
Reuses the gap-fill fixtures/helpers (tests/test_gap_fill.py); no network
|
||||
anywhere — matches are seeded straight into the enrichment cache.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.test_gap_fill import ( # noqa: F401 (server/client fixtures)
|
||||
BASE_MANIFEST,
|
||||
client,
|
||||
make_dir_sloppak,
|
||||
make_zip_sloppak,
|
||||
seed_match,
|
||||
server,
|
||||
)
|
||||
|
||||
# Author-set values that all DIFFER from the seeded match (artist/album/year/
|
||||
# genres), plus a title equal to the match (equal values are never offered).
|
||||
DIFF_MANIFEST = ("# my hand-made pack\n"
|
||||
"title: Thunderstruck\n"
|
||||
"artist: ACDC # typo the match fixes\n"
|
||||
"album: Razors Edge\n"
|
||||
"year: 1991\n"
|
||||
"genres:\n"
|
||||
"- Rock\n"
|
||||
"duration: 292\n"
|
||||
"arrangements: []\n"
|
||||
"stems: []\n")
|
||||
|
||||
|
||||
def enable_overwrite(client):
|
||||
r = client.post("/api/settings", json={"allow_pack_overwrite": True})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ── differs (preview) ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_differs_only_for_manual_rows(server, client):
|
||||
"""The librarian rule: an automatic match may gap-fill absent keys but is
|
||||
not authority to replace author bytes — differs is empty until the user
|
||||
pins the match."""
|
||||
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="matched")
|
||||
d = client.get("/api/song/a.sloppak/gap-fill").json()
|
||||
assert d["differs"] == []
|
||||
# The same row, user-pinned → the differences surface.
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
d = client.get("/api/song/a.sloppak/gap-fill").json()
|
||||
got = {x["key"]: x for x in d["differs"]}
|
||||
assert set(got) == {"artist", "album", "year", "genres"}
|
||||
assert got["artist"]["current"] == "ACDC" and got["artist"]["proposed"] == "AC/DC"
|
||||
assert got["album"]["current"] == "Razors Edge"
|
||||
assert got["album"]["proposed"] == "The Razors Edge"
|
||||
assert got["year"]["current"] == "1991" and got["year"]["proposed"] == 1990
|
||||
assert got["genres"]["current"] == ["Rock"]
|
||||
assert got["genres"]["proposed"] == ["hard rock", "rock"]
|
||||
|
||||
|
||||
def test_differs_excludes_identity_keys_and_equal_values(server, client):
|
||||
"""mbid/isrc present in the file AND different from the match are still
|
||||
never offered (identity changes only via explicit re-match); a value equal
|
||||
to the match isn't a difference; an ABSENT key is a gap (missing), not a
|
||||
differ."""
|
||||
make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST +
|
||||
"mbid: 00000000-0000-4000-8000-000000000000\n"
|
||||
"isrc: USZZZ0000001\n")
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
d = client.get("/api/song/a.sloppak/gap-fill").json()
|
||||
assert d["differs"] == [] # title/artist equal; mbid/isrc barred
|
||||
assert {m["key"] for m in d["missing"]} == {"album", "year", "genres"}
|
||||
|
||||
|
||||
def test_preview_reports_gate_state_and_backup(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
d = client.get("/api/song/a.sloppak/gap-fill").json()
|
||||
assert d["overwrite_allowed"] is False # default OFF
|
||||
assert d["has_backup"] is False # nothing written yet
|
||||
enable_overwrite(client)
|
||||
assert client.get("/api/song/a.sloppak/gap-fill").json()["overwrite_allowed"] is True
|
||||
|
||||
|
||||
# ── refusals ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_overwrite_refused_when_setting_off(server, client):
|
||||
"""The gate refuses the WHOLE request before anything is written."""
|
||||
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
before = (d / "manifest.yaml").read_text(encoding="utf-8")
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["artist"]})
|
||||
assert r.status_code == 409
|
||||
assert (d / "manifest.yaml").read_text(encoding="utf-8") == before
|
||||
assert not (d / "manifest.yaml.bak").exists()
|
||||
|
||||
|
||||
def test_overwrite_refused_when_not_manual(server, client):
|
||||
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="matched")
|
||||
enable_overwrite(client)
|
||||
before = (d / "manifest.yaml").read_text(encoding="utf-8")
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["artist"]})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["skipped"] == ["artist"]
|
||||
assert (d / "manifest.yaml").read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_overwrite_validates_keys(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
# Identity keys and unknown keys are turned away wholesale (400).
|
||||
for bad in (["mbid"], ["isrc"], ["nope"]):
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": bad})
|
||||
assert r.status_code == 400
|
||||
# Both lists empty is still a 400.
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"keys": [], "overwrite_keys": []}).status_code == 400
|
||||
|
||||
|
||||
def test_overwrite_equal_value_is_skipped(server, client):
|
||||
"""A requested key whose value already equals the match is not in differs
|
||||
→ skipped, and with nothing else to write the request 409s untouched."""
|
||||
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["title"]})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["skipped"] == ["title"]
|
||||
assert (d / "manifest.yaml").read_text(encoding="utf-8") == DIFF_MANIFEST
|
||||
|
||||
|
||||
# ── happy path ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_overwrite_dir_form_replaces_and_keeps_pristine_bak(server, client):
|
||||
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
r = client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"overwrite_keys": ["artist", "genres"]})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["overwritten"] == {
|
||||
"artist": {"old": "ACDC", "new": "AC/DC"},
|
||||
"genres": {"old": ["Rock"], "new": ["hard rock", "rock"]},
|
||||
}
|
||||
assert body["written"] == {}
|
||||
manifest = yaml.safe_load((d / "manifest.yaml").read_text(encoding="utf-8"))
|
||||
assert manifest["artist"] == "AC/DC"
|
||||
assert manifest["genres"] == ["hard rock", "rock"]
|
||||
assert manifest["album"] == "Razors Edge" # unrequested keys untouched
|
||||
assert manifest["year"] == 1991
|
||||
# The backup is the author's pristine original…
|
||||
assert (d / "manifest.yaml.bak").read_text(encoding="utf-8") == DIFF_MANIFEST
|
||||
# …and a SECOND write never clobbers it.
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["album"]})
|
||||
assert r.status_code == 200
|
||||
assert (d / "manifest.yaml.bak").read_text(encoding="utf-8") == DIFF_MANIFEST
|
||||
manifest = yaml.safe_load((d / "manifest.yaml").read_text(encoding="utf-8"))
|
||||
assert manifest["album"] == "The Razors Edge"
|
||||
assert manifest["artist"] == "AC/DC" # the first write survives
|
||||
# DB sync: the songs row reflects the replaced values.
|
||||
row = client.get("/api/song/a.sloppak").json()
|
||||
assert row["artist"] == "AC/DC"
|
||||
assert row["album"] == "The Razors Edge"
|
||||
|
||||
|
||||
def test_gap_fill_and_overwrite_in_one_request(server, client):
|
||||
"""`keys` keeps working unchanged alongside `overwrite_keys`: absent keys
|
||||
append (author bytes preserved into the one pristine backup), the differing
|
||||
key is replaced."""
|
||||
d = make_dir_sloppak(server, "a.sloppak", BASE_MANIFEST + "year: 1991\n")
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
r = client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"keys": ["album", "mbid"], "overwrite_keys": ["year"]})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["written"] == {"album": "The Razors Edge",
|
||||
"mbid": "12345678-abcd-4ef0-9876-0123456789ab"}
|
||||
assert body["overwritten"] == {"year": {"old": "1991", "new": 1990}}
|
||||
manifest = yaml.safe_load((d / "manifest.yaml").read_text(encoding="utf-8"))
|
||||
assert manifest["album"] == "The Razors Edge"
|
||||
assert manifest["mbid"] == "12345678-abcd-4ef0-9876-0123456789ab"
|
||||
assert manifest["year"] == 1990
|
||||
# One request, one pristine backup: the pre-request original bytes.
|
||||
assert ((d / "manifest.yaml.bak").read_text(encoding="utf-8")
|
||||
== BASE_MANIFEST + "year: 1991\n")
|
||||
|
||||
|
||||
def test_zip_form_overwrite(server, client):
|
||||
p = make_zip_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"overwrite_keys": ["artist"]})
|
||||
assert r.status_code == 200
|
||||
with zipfile.ZipFile(p) as z:
|
||||
manifest = yaml.safe_load(z.read("manifest.yaml"))
|
||||
assert manifest["artist"] == "AC/DC"
|
||||
assert z.read("stems/full.ogg") == b"OggS-fake" # pack intact
|
||||
bak = p.with_name(p.name + ".bak")
|
||||
with zipfile.ZipFile(bak) as z:
|
||||
assert z.read("manifest.yaml").decode("utf-8") == DIFF_MANIFEST
|
||||
|
||||
|
||||
# ── write_log (provenance receipts) ───────────────────────────────────────────
|
||||
|
||||
def test_write_log_records_old_and_new(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
r = client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"overwrite_keys": ["artist", "year"]})
|
||||
assert r.status_code == 200
|
||||
rows = client.get("/api/song/a.sloppak/write-log").json()["rows"]
|
||||
by_key = {x["key"]: x for x in rows}
|
||||
assert by_key["artist"]["old_value"] == "ACDC"
|
||||
assert by_key["artist"]["new_value"] == "AC/DC"
|
||||
assert by_key["year"]["old_value"] == "1991"
|
||||
assert by_key["year"]["new_value"] == "1990"
|
||||
for x in rows:
|
||||
assert x["source"] == "text" and x["score"] == 1.0 and x["ts"]
|
||||
|
||||
|
||||
def test_write_log_records_gap_fills_too(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak")
|
||||
seed_match(server, "a.sloppak")
|
||||
r = client.post("/api/song/a.sloppak/gap-fill", json={"keys": ["album", "genres"]})
|
||||
assert r.status_code == 200
|
||||
rows = client.get("/api/song/a.sloppak/write-log").json()["rows"]
|
||||
by_key = {x["key"]: x for x in rows}
|
||||
assert by_key["album"]["old_value"] is None # was a gap — no old value
|
||||
assert by_key["album"]["new_value"] == "The Razors Edge"
|
||||
assert by_key["genres"]["new_value"] == '["hard rock", "rock"]'
|
||||
|
||||
|
||||
def test_write_log_endpoint_shape_and_order(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"overwrite_keys": ["artist"]}).status_code == 200
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"overwrite_keys": ["album"]}).status_code == 200
|
||||
rows = client.get("/api/song/a.sloppak/write-log").json()["rows"]
|
||||
assert [x["key"] for x in rows] == ["album", "artist"] # newest first
|
||||
assert set(rows[0]) == {"id", "key", "old_value", "new_value",
|
||||
"source", "score", "ts"}
|
||||
# Another song's rows don't bleed in.
|
||||
make_dir_sloppak(server, "b.sloppak")
|
||||
assert client.get("/api/song/b.sloppak/write-log").json()["rows"] == []
|
||||
|
||||
|
||||
def test_write_log_prunes_beyond_cap(server):
|
||||
"""The receipts table stays bounded at 5000 rows — oldest pruned first."""
|
||||
db = server.meta_db
|
||||
db.add_write_log("bulk.sloppak",
|
||||
[("album", None, str(i)) for i in range(5100)],
|
||||
source="text", score=1.0)
|
||||
n = db.conn.execute("SELECT COUNT(*) FROM write_log").fetchone()[0]
|
||||
assert n == 5000
|
||||
oldest = db.conn.execute(
|
||||
"SELECT new_value FROM write_log ORDER BY id ASC LIMIT 1").fetchone()[0]
|
||||
assert oldest == "100" # rows 0..99 fell off the bottom
|
||||
|
||||
|
||||
# ── revert ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_revert_dir_form_restores_original_and_resyncs_db(server, client):
|
||||
d = make_dir_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"overwrite_keys": ["artist"]}).status_code == 200
|
||||
assert client.get("/api/song/a.sloppak").json()["artist"] == "AC/DC"
|
||||
r = client.post("/api/song/a.sloppak/revert-original")
|
||||
assert r.status_code == 200
|
||||
# Author bytes restored verbatim; the backup is PRESERVED (re-apply later).
|
||||
assert (d / "manifest.yaml").read_text(encoding="utf-8") == DIFF_MANIFEST
|
||||
assert (d / "manifest.yaml.bak").exists()
|
||||
row = client.get("/api/song/a.sloppak").json()
|
||||
assert row["artist"] == "ACDC"
|
||||
assert str(row["year"]) == "1991"
|
||||
# And the preview still offers the revert + the differences again.
|
||||
d2 = client.get("/api/song/a.sloppak/gap-fill").json()
|
||||
assert d2["has_backup"] is True
|
||||
assert {x["key"] for x in d2["differs"]} >= {"artist"}
|
||||
|
||||
|
||||
def test_revert_zip_form(server, client):
|
||||
p = make_zip_sloppak(server, "a.sloppak", DIFF_MANIFEST)
|
||||
seed_match(server, "a.sloppak", state="manual")
|
||||
enable_overwrite(client)
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"overwrite_keys": ["artist"]}).status_code == 200
|
||||
assert client.post("/api/song/a.sloppak/revert-original").status_code == 200
|
||||
with zipfile.ZipFile(p) as z:
|
||||
assert z.read("manifest.yaml").decode("utf-8") == DIFF_MANIFEST
|
||||
assert z.read("stems/full.ogg") == b"OggS-fake" # pack intact
|
||||
assert p.with_name(p.name + ".bak").exists() # backup preserved
|
||||
assert client.get("/api/song/a.sloppak").json()["artist"] == "ACDC"
|
||||
|
||||
|
||||
def test_revert_without_backup_is_404(server, client):
|
||||
make_dir_sloppak(server, "a.sloppak")
|
||||
assert client.post("/api/song/a.sloppak/revert-original").status_code == 404
|
||||
|
||||
|
||||
def test_revert_refuses_non_package_target_with_sibling_bak(server, client):
|
||||
"""A non-package file under DLC_DIR with a sibling `.bak` must NOT be
|
||||
reverted — revert mirrors the write path's is_sloppak guard, so it never
|
||||
restores a stray backup over a file the feature was not meant to touch."""
|
||||
target = server.DLC_DIR / "notes.txt"
|
||||
target.write_bytes(b"user notes, not a pack")
|
||||
(server.DLC_DIR / "notes.txt.bak").write_bytes(b"stray backup")
|
||||
r = client.post("/api/song/notes.txt/revert-original")
|
||||
assert r.status_code == 404
|
||||
# The target is left byte-for-byte untouched.
|
||||
assert target.read_bytes() == b"user notes, not a pack"
|
||||
|
||||
|
||||
def test_preview_reports_backup_after_gap_fill(server, client):
|
||||
"""Plain gap-fill (R4a) also leaves the one-time backup — the preview
|
||||
surfaces it so the drawer can offer Revert."""
|
||||
make_dir_sloppak(server, "a.sloppak")
|
||||
seed_match(server, "a.sloppak")
|
||||
assert client.post("/api/song/a.sloppak/gap-fill",
|
||||
json={"keys": ["album"]}).status_code == 200
|
||||
assert client.get("/api/song/a.sloppak/gap-fill").json()["has_backup"] is True
|
||||
|
||||
|
||||
def test_demo_mode_blocks_revert(tmp_path, monkeypatch, isolate_logging):
|
||||
"""The middleware turns revert away before any handler runs — demo
|
||||
visitors can never rewrite pack files."""
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
monkeypatch.setenv("DLC_DIR", str(dlc))
|
||||
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
|
||||
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
|
||||
sys.modules.pop("server", None)
|
||||
srv = importlib.import_module("server")
|
||||
try:
|
||||
r = TestClient(srv.app).post("/api/song/a.sloppak/revert-original")
|
||||
assert r.status_code == 403
|
||||
finally:
|
||||
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
sys.modules.pop("server", None)
|
||||
+115
-2
@@ -744,29 +744,142 @@ def test_defaults_include_gameplay_keys(client, tmp_path):
|
||||
assert data["fail_behavior"] == "continue"
|
||||
|
||||
|
||||
|
||||
def test_get_settings_exposes_default_instrument_profiles(client, tmp_path):
|
||||
data = client.get("/api/settings").json()
|
||||
assert data["active_instrument_profile"] == "guitar-lead"
|
||||
assert set(data["instrument_profiles"]) == {"guitar-lead", "guitar-rhythm", "bass"}
|
||||
assert data["instrument"] == "guitar"
|
||||
assert data["string_count"] == 6
|
||||
assert data["tuning"] == "Standard"
|
||||
assert data["pathway"] == "songs"
|
||||
|
||||
|
||||
def test_post_flat_instrument_updates_active_profile(client, tmp_path):
|
||||
r = client.post("/api/settings", json={"instrument": "bass", "pathway": "practice"})
|
||||
assert r.status_code == 200
|
||||
cfg = _read_cfg(tmp_path)
|
||||
assert cfg["active_instrument_profile"] == "bass"
|
||||
assert cfg["instrument"] == "bass"
|
||||
assert cfg["string_count"] == 4
|
||||
assert cfg["tuning"] == "Standard"
|
||||
assert cfg["pathway"] == "practice"
|
||||
assert cfg["instrument_profiles"]["bass"]["string_count"] == 4
|
||||
assert cfg["instrument_profiles"]["bass"]["pathway"] == "practice"
|
||||
|
||||
|
||||
def test_post_instrument_profiles_mirrors_active_profile(client, tmp_path):
|
||||
r = client.post("/api/settings", json={
|
||||
"active_instrument_profile": "guitar-rhythm",
|
||||
"instrument_profiles": {
|
||||
"guitar-rhythm": {
|
||||
"string_count": 7,
|
||||
"tuning": "Drop A",
|
||||
"reference_pitch": 432,
|
||||
"pathway": "studio",
|
||||
},
|
||||
"bass": {
|
||||
"string_count": 6,
|
||||
"tuning": "C Standard",
|
||||
},
|
||||
},
|
||||
})
|
||||
assert r.status_code == 200
|
||||
cfg = _read_cfg(tmp_path)
|
||||
assert cfg["active_instrument_profile"] == "guitar-rhythm"
|
||||
assert cfg["instrument"] == "guitar"
|
||||
assert cfg["string_count"] == 7
|
||||
assert cfg["tuning"] == "Drop A"
|
||||
assert cfg["reference_pitch"] == 432
|
||||
assert cfg["pathway"] == "studio"
|
||||
|
||||
|
||||
def test_post_pathway_rejects_bad_value(client, tmp_path):
|
||||
(tmp_path / "config.json").write_text(json.dumps({"pathway": "songs"}))
|
||||
r = client.post("/api/settings", json={"pathway": "invalid"})
|
||||
assert "error" in r.json()
|
||||
assert _read_cfg(tmp_path)["pathway"] == "songs"
|
||||
|
||||
|
||||
def test_post_instrument_profiles_rejects_bad_custom_string_count(client, tmp_path):
|
||||
r = client.post("/api/settings", json={
|
||||
"instrument_profiles": {
|
||||
"bass": {"string_count": 6, "tuning": [0, 0, 0, 0]},
|
||||
},
|
||||
})
|
||||
assert "error" in r.json()
|
||||
|
||||
# ── /api/settings/reset ─────────────────────────────────────────────────────
|
||||
|
||||
def test_reset_clears_requested_keys(client, tmp_path):
|
||||
(tmp_path / "config.json").write_text(json.dumps({
|
||||
"master_difficulty": 40,
|
||||
"countdown_before_song": True,
|
||||
"pathway": "studio",
|
||||
"default_arrangement": "Lead",
|
||||
"demucs_server_url": "http://demucs.example:9000",
|
||||
}))
|
||||
r = client.post("/api/settings/reset",
|
||||
json={"keys": ["master_difficulty", "countdown_before_song"]})
|
||||
json={"keys": ["master_difficulty", "countdown_before_song", "pathway"]})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body["reset"]) == {"master_difficulty", "countdown_before_song"}
|
||||
assert set(body["reset"]) == {"master_difficulty", "countdown_before_song", "pathway"}
|
||||
cfg = _read_cfg(tmp_path)
|
||||
# Reset removes the key so GET falls back to the default.
|
||||
assert "master_difficulty" not in cfg
|
||||
assert "countdown_before_song" not in cfg
|
||||
assert "pathway" not in cfg
|
||||
# Unlisted keys are untouched.
|
||||
assert cfg["default_arrangement"] == "Lead"
|
||||
assert cfg["demucs_server_url"] == "http://demucs.example:9000"
|
||||
|
||||
|
||||
def test_partial_instrument_profiles_update_preserves_others(client, tmp_path):
|
||||
# /api/settings is a partial-merge endpoint, so a POST that carries only ONE
|
||||
# instrument profile must not reset the others to defaults.
|
||||
gl = client.get("/api/settings").json()["instrument_profiles"]["guitar-lead"]
|
||||
gl = dict(gl); gl["tuning"] = "Drop D"
|
||||
client.post("/api/settings", json={"instrument_profiles": {"guitar-lead": gl}})
|
||||
assert (client.get("/api/settings").json()["instrument_profiles"]
|
||||
["guitar-lead"]["tuning"] == "Drop D")
|
||||
# Now update ONLY bass (Drop D is valid for a 4-string bass).
|
||||
bass = client.get("/api/settings").json()["instrument_profiles"]["bass"]
|
||||
bass = dict(bass); bass["tuning"] = "Drop D"
|
||||
client.post("/api/settings", json={"instrument_profiles": {"bass": bass}})
|
||||
out = client.get("/api/settings").json()["instrument_profiles"]
|
||||
assert out["guitar-lead"]["tuning"] == "Drop D", "the untouched profile survived"
|
||||
assert out["bass"]["tuning"] == "Drop D"
|
||||
|
||||
|
||||
def test_active_profile_switch_on_fresh_config(client, tmp_path):
|
||||
# A fresh config has no instrument_profiles; an explicit active-profile
|
||||
# switch must be honored, not overwritten by the profile inferred from the
|
||||
# legacy flat defaults (guitar-lead).
|
||||
r = client.post("/api/settings", json={"active_instrument_profile": "bass"})
|
||||
assert r.status_code == 200 and "error" not in r.json()
|
||||
got = client.get("/api/settings").json()
|
||||
assert got["active_instrument_profile"] == "bass"
|
||||
assert got["instrument"] == "bass"
|
||||
|
||||
|
||||
def test_reset_pathway_reaches_into_instrument_profiles(client, tmp_path):
|
||||
# pathway is mirrored into every instrument profile, so a Gameplay reset
|
||||
# that only deleted the flat key would leave GET re-deriving the old value
|
||||
# from the profile. The reset must reach into the persisted profiles too.
|
||||
client.post("/api/settings", json={"pathway": "studio"})
|
||||
assert client.get("/api/settings").json()["pathway"] == "studio"
|
||||
profiles = _read_cfg(tmp_path)["instrument_profiles"]
|
||||
assert any(p["pathway"] == "studio" for p in profiles.values())
|
||||
|
||||
r = client.post("/api/settings/reset", json={"keys": ["pathway"]})
|
||||
assert r.status_code == 200
|
||||
assert "pathway" in r.json()["reset"]
|
||||
# GET re-derives from the profile — which must now be back to the default.
|
||||
assert client.get("/api/settings").json()["pathway"] == "songs"
|
||||
for prof in _read_cfg(tmp_path)["instrument_profiles"].values():
|
||||
assert prof["pathway"] == "songs"
|
||||
|
||||
|
||||
def test_reset_ignores_unknown_keys(client, tmp_path):
|
||||
(tmp_path / "config.json").write_text(json.dumps({"master_difficulty": 40}))
|
||||
# Unknown / non-resettable keys are silently ignored, not an error, and
|
||||
|
||||
@@ -30,12 +30,14 @@ def _cfg(tmp_path):
|
||||
def test_instrument_fields_persist(env):
|
||||
srv, tmp = env
|
||||
c = TestClient(srv.app)
|
||||
# "Drop A" is the 5-string bass drop tuning (its low string is B, not E, so
|
||||
# "Drop D" is a 4-string tuning — now correctly rejected per-profile).
|
||||
r = c.post("/api/settings", json={"instrument": "bass", "string_count": 5,
|
||||
"tuning": "Drop D", "reference_pitch": 442})
|
||||
"tuning": "Drop A", "reference_pitch": 442})
|
||||
assert r.status_code == 200
|
||||
cfg = _cfg(tmp)
|
||||
assert cfg["instrument"] == "bass" and cfg["string_count"] == 5
|
||||
assert cfg["tuning"] == "Drop D" and cfg["reference_pitch"] == 442.0
|
||||
assert cfg["tuning"] == "Drop A" and cfg["reference_pitch"] == 442.0
|
||||
# Reflected back through GET.
|
||||
got = c.get("/api/settings").json()
|
||||
assert got["instrument"] == "bass" and got["reference_pitch"] == 442.0
|
||||
|
||||
+118
-1
@@ -2,7 +2,31 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from tunings import tuning_name
|
||||
from tunings import (
|
||||
DEFAULT_TUNINGS,
|
||||
TUNING_PRESET_MIDIS,
|
||||
_valid_tuning_for_key,
|
||||
apply_flat_instrument_patch_to_profiles,
|
||||
open_midis_to_freqs,
|
||||
settings_with_instrument_profiles,
|
||||
tuning_midis_from_offsets,
|
||||
tuning_name,
|
||||
tuning_offsets_from_midis,
|
||||
tuning_preset_offsets,
|
||||
)
|
||||
|
||||
|
||||
def test_valid_tuning_for_key_builtin_and_provider_names():
|
||||
# A built-in valid for the key is accepted; a built-in valid only for a
|
||||
# DIFFERENT key (misapplied, e.g. "Drop D" on a 5-string bass) is rejected.
|
||||
assert _valid_tuning_for_key("bass-5", "Drop A") == "Drop A"
|
||||
assert _valid_tuning_for_key("bass-5", "Drop D") is None
|
||||
assert _valid_tuning_for_key("guitar-6", "Standard") == "Standard"
|
||||
# A name unknown to every built-in table is a provider/custom tuning (tuner
|
||||
# plugin, /api/tunings) the pure layer can't resolve — accept it so settings
|
||||
# round-trip rather than normalizing it away to Standard.
|
||||
assert _valid_tuning_for_key("bass-5", "My Custom DADGAD") == "My Custom DADGAD"
|
||||
assert _valid_tuning_for_key("guitar-6", "x" * 65) is None # length cap kept
|
||||
|
||||
|
||||
# ── Standard tunings (all six strings share the same offset) ─────────────────
|
||||
@@ -132,3 +156,96 @@ def test_drop_pattern_takes_precedence_over_named_dict():
|
||||
# auto-generator fires first and produces the same string. The named dict entry
|
||||
# is effectively dead code for this case — this test documents the behavior.
|
||||
assert tuning_name([-2, 0, 0, 0, 0, 0]) == "Drop D"
|
||||
|
||||
|
||||
# ── Host tuning profile catalogue -------------------------------------------
|
||||
|
||||
def test_default_tunings_include_extended_host_profiles():
|
||||
assert "bass-6" in DEFAULT_TUNINGS
|
||||
assert "C Standard" in DEFAULT_TUNINGS["guitar-6"]
|
||||
assert "C# Standard" in DEFAULT_TUNINGS["guitar-6"]
|
||||
assert "Drop Ab" in DEFAULT_TUNINGS["guitar-6"]
|
||||
assert "BEAD" in DEFAULT_TUNINGS["bass-4"]
|
||||
assert "High C" in DEFAULT_TUNINGS["bass-5"]
|
||||
assert "Drop A + Drop E" in DEFAULT_TUNINGS["guitar-8"]
|
||||
|
||||
|
||||
def test_default_tuning_frequencies_are_derived_from_midis():
|
||||
assert DEFAULT_TUNINGS["guitar-6"]["Standard"] == open_midis_to_freqs([40, 45, 50, 55, 59, 64])
|
||||
assert DEFAULT_TUNINGS["bass-6"]["Standard"] == open_midis_to_freqs([23, 28, 33, 38, 43, 48])
|
||||
|
||||
|
||||
def test_tuning_offsets_from_named_presets():
|
||||
assert tuning_preset_offsets("guitar-6", "Drop D") == [-2, 0, 0, 0, 0, 0]
|
||||
assert tuning_preset_offsets("guitar-6", "C Standard") == [-4, -4, -4, -4, -4, -4]
|
||||
assert tuning_preset_offsets("bass-4", "BEAD") == [-5, -5, -5, -5]
|
||||
assert tuning_preset_offsets("bass-5", "High C") == [5, 5, 5, 5, 5]
|
||||
|
||||
|
||||
def test_tuning_midis_round_trip_offsets():
|
||||
offsets = [-2, 0, 0, 0, 0, 0]
|
||||
midis = tuning_midis_from_offsets("guitar-6", offsets)
|
||||
assert midis == TUNING_PRESET_MIDIS["guitar-6"]["Drop D"]
|
||||
assert tuning_offsets_from_midis("guitar-6", midis) == offsets
|
||||
|
||||
|
||||
def test_tuning_conversion_rejects_wrong_string_count():
|
||||
assert tuning_offsets_from_midis("guitar-6", [40, 45, 50, 55]) is None
|
||||
assert tuning_midis_from_offsets("bass-4", [0, 0, 0, 0, 0]) is None
|
||||
|
||||
def test_settings_profiles_default_to_lead_rhythm_and_bass():
|
||||
settings = settings_with_instrument_profiles({})
|
||||
assert settings["active_instrument_profile"] == "guitar-lead"
|
||||
assert set(settings["instrument_profiles"]) == {"guitar-lead", "guitar-rhythm", "bass"}
|
||||
assert settings["instrument"] == "guitar"
|
||||
assert settings["string_count"] == 6
|
||||
assert settings["tuning"] == "Standard"
|
||||
assert settings["pathway"] == "songs"
|
||||
assert settings["instrument_profiles"]["guitar-lead"]["pathway"] == "songs"
|
||||
|
||||
|
||||
def test_settings_profiles_migrate_legacy_flat_bass_selection():
|
||||
settings = settings_with_instrument_profiles({
|
||||
"instrument": "bass",
|
||||
"string_count": 6,
|
||||
"tuning": "C Standard",
|
||||
"reference_pitch": 432,
|
||||
"pathway": "practice",
|
||||
})
|
||||
assert settings["active_instrument_profile"] == "bass"
|
||||
assert settings["instrument_profiles"]["bass"]["string_count"] == 6
|
||||
assert settings["instrument_profiles"]["bass"]["tuning"] == "C Standard"
|
||||
assert settings["reference_pitch"] == 432
|
||||
assert settings["pathway"] == "practice"
|
||||
assert settings["instrument_profiles"]["bass"]["pathway"] == "practice"
|
||||
|
||||
|
||||
def test_flat_patch_updates_active_profile_and_mirrors_legacy_keys():
|
||||
settings = settings_with_instrument_profiles({})
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"tuning": "Drop D"})
|
||||
assert patched["tuning"] == "Drop D"
|
||||
assert patched["instrument_profiles"]["guitar-lead"]["tuning"] == "Drop D"
|
||||
|
||||
|
||||
def test_flat_pathway_patch_updates_active_profile_and_mirrors_legacy_key():
|
||||
settings = settings_with_instrument_profiles({})
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"pathway": "studio"})
|
||||
assert patched["pathway"] == "studio"
|
||||
assert patched["instrument_profiles"]["guitar-lead"]["pathway"] == "studio"
|
||||
|
||||
|
||||
def test_flat_instrument_patch_defaults_to_target_string_count():
|
||||
settings = settings_with_instrument_profiles({"instrument": "guitar", "string_count": 6, "tuning": "Drop D"})
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"instrument": "bass"})
|
||||
assert patched["instrument"] == "bass"
|
||||
assert patched["string_count"] == 4
|
||||
assert patched["tuning"] == "Standard"
|
||||
assert patched["active_instrument_profile"] == "bass"
|
||||
assert patched["instrument_profiles"]["bass"]["string_count"] == 4
|
||||
|
||||
|
||||
def test_flat_string_count_patch_resets_incompatible_named_tuning():
|
||||
settings = settings_with_instrument_profiles({"instrument": "guitar", "string_count": 6, "tuning": "DADGAD"})
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7})
|
||||
assert patched["string_count"] == 7
|
||||
assert patched["tuning"] == "Standard"
|
||||
|
||||
Reference in New Issue
Block a user