mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 06:34:30 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8341d21416 | ||
|
|
dbee1b2489 | ||
|
|
ecc7e61251 | ||
|
|
2f532a6957 | ||
|
|
cc75cb876a | ||
|
|
f0d9c3abc0 | ||
|
|
2413991c5a | ||
|
|
1c077c9ab7 | ||
|
|
3717e4338d | ||
|
|
0b4b174d33 | ||
|
|
2f2a095e4c | ||
|
|
e14ef64224 | ||
|
|
365cec1d29 |
+542
-519
File diff suppressed because it is too large
Load Diff
+105
-26
@@ -17,7 +17,12 @@ import threading
|
||||
from typing import ClassVar
|
||||
|
||||
import appstate
|
||||
from metadata_db import MetadataDB, _tuning_group_key_sql
|
||||
from metadata_db import (
|
||||
MetadataDB, _effective_tuning_cols_sql, _perspective_is_inferred_sql,
|
||||
_tuning_group_key_sql,
|
||||
)
|
||||
import tunings as tunings_mod
|
||||
from tunings import DEFAULT_PERSPECTIVE, PERSPECTIVES
|
||||
from routers import art as art_router
|
||||
|
||||
import logging
|
||||
@@ -39,9 +44,6 @@ def _safe_art_redirect_url(url: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
_TUNING_GROUP_KEY_SQL = _tuning_group_key_sql("songs")
|
||||
|
||||
|
||||
class LocalLibraryProvider:
|
||||
id = "local"
|
||||
label = "My Library"
|
||||
@@ -69,28 +71,43 @@ class LocalLibraryProvider:
|
||||
def query_stats(self, **kwargs) -> dict:
|
||||
return self._db.query_stats(**kwargs)
|
||||
|
||||
def tuning_names(self) -> dict:
|
||||
def tuning_names(self, instrument: str = DEFAULT_PERSPECTIVE) -> dict:
|
||||
# Group custom tunings on their raw offsets so distinct ones stay
|
||||
# distinct (tuning_name collapses them all to "Custom Tuning"); named
|
||||
# tunings keep grouping by name (stable across the rescan boundary, no
|
||||
# offsets/name split). `key` is the value the client sends back as the
|
||||
# filter selector — equal to the name for named tunings, the offsets
|
||||
# string for customs; offsets also feed the client's custom-pill label.
|
||||
#
|
||||
# `instrument=bass` swaps every column for its effective bass-facing
|
||||
# expression (bass arrangement's tuning, guitar fallback) — the SAME
|
||||
# expressions _build_intrinsic_where filters on, so a facet entry
|
||||
# always selects exactly the songs it counted.
|
||||
name_sql, offsets_sql, sort_sql = _effective_tuning_cols_sql("songs", instrument)
|
||||
gkey_sql = _tuning_group_key_sql("songs", instrument)
|
||||
# How many of a row's songs are showing an INFERRED tuning — i.e. have
|
||||
# no bass chart of their own and are falling back to the guitar-derived
|
||||
# one. Reported per entry so the UI can be honest about it instead of
|
||||
# presenting a borrowed tuning as a measured one. Always 0 for guitar.
|
||||
inferred_sql = f"SUM({_perspective_is_inferred_sql('songs', instrument)})"
|
||||
with self._db._lock:
|
||||
rows = self._db.conn.execute(
|
||||
f"SELECT tuning_name, {_TUNING_GROUP_KEY_SQL} AS gkey, "
|
||||
"MIN(tuning_sort_key), COUNT(*), MIN(tuning_offsets) "
|
||||
"FROM songs WHERE title != '' AND COALESCE(tuning_name, '') != '' "
|
||||
f"SELECT {name_sql}, {gkey_sql} AS gkey, "
|
||||
f"MIN({sort_sql}), COUNT(*), MIN({offsets_sql}), {inferred_sql} "
|
||||
f"FROM songs WHERE title != '' AND COALESCE({name_sql}, '') != '' "
|
||||
"GROUP BY gkey COLLATE NOCASE "
|
||||
"ORDER BY ABS(COALESCE(MIN(tuning_sort_key), 0)), "
|
||||
"COALESCE(MIN(tuning_sort_key), 0) ASC, "
|
||||
"tuning_name COLLATE NOCASE"
|
||||
f"ORDER BY ABS(COALESCE(MIN({sort_sql}), 0)), "
|
||||
f"COALESCE(MIN({sort_sql}), 0) ASC, "
|
||||
f"{name_sql} COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return {
|
||||
"instrument": instrument,
|
||||
"tunings": [
|
||||
{"name": name, "key": gkey, "offsets": offs or "",
|
||||
"sort_key": int(sk or 0), "count": count}
|
||||
for name, gkey, sk, count, offs in rows
|
||||
"sort_key": int(sk or 0), "count": count,
|
||||
# Portion of `count` borrowed from the guitar chart.
|
||||
"inferred_count": int(inferred or 0)}
|
||||
for name, gkey, sk, count, offs, inferred in rows
|
||||
],
|
||||
}
|
||||
|
||||
@@ -330,9 +347,16 @@ class SmartCollectionProvider:
|
||||
# have been hand-edited; never let a bad value reach a query.
|
||||
self._rules = _sanitize_collection_rules(collection.get("rules") or {})
|
||||
|
||||
def _filter_kwargs(self) -> dict:
|
||||
return _library_filter_args(**{k: v for k, v in self._rules.items()
|
||||
def _filter_kwargs(self, instrument: str = "", playable_from_pitch=None) -> dict:
|
||||
# `instrument` is the CALLER's play perspective (rides every request),
|
||||
# never part of the saved rules — a collection saved by a guitarist
|
||||
# must still read in bass tunings for a bass player, and vice versa.
|
||||
args = _library_filter_args(**{k: v for k, v in self._rules.items()
|
||||
if k in _LIBRARY_FILTER_PARAM_KEYS})
|
||||
args["instrument"] = _normalize_instrument(instrument)
|
||||
# The caller's CURRENT tuning is likewise per-request, never a saved rule.
|
||||
args["playable_from_pitch"] = playable_from_pitch
|
||||
return args
|
||||
|
||||
def _sort(self, fallback: str) -> str:
|
||||
# A collection may pin its own sort (e.g. "recently added"); query_page
|
||||
@@ -340,28 +364,31 @@ class SmartCollectionProvider:
|
||||
return self._rules.get("sort") or fallback
|
||||
|
||||
def query_page(self, *, page=0, size=24, sort="artist", direction="asc",
|
||||
naming_mode="legacy", **_ignore):
|
||||
naming_mode="legacy", instrument="", playable_from_pitch=None, **_ignore):
|
||||
return self._local._db.query_page(
|
||||
page=page, size=size, sort=self._sort(sort), direction=direction,
|
||||
naming_mode=naming_mode, **self._filter_kwargs())
|
||||
naming_mode=naming_mode, **self._filter_kwargs(instrument, playable_from_pitch))
|
||||
|
||||
def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy", **_ignore):
|
||||
def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy",
|
||||
instrument="", playable_from_pitch=None, **_ignore):
|
||||
return self._local._db.query_artists(
|
||||
letter=letter, page=page, size=size, naming_mode=naming_mode,
|
||||
**self._filter_kwargs())
|
||||
**self._filter_kwargs(instrument, playable_from_pitch))
|
||||
|
||||
def query_albums(self, *, page=0, size=120, naming_mode="legacy", **_ignore):
|
||||
def query_albums(self, *, page=0, size=120, naming_mode="legacy",
|
||||
instrument="", playable_from_pitch=None, **_ignore):
|
||||
return self._local._db.query_albums(
|
||||
page=page, size=size, naming_mode=naming_mode, **self._filter_kwargs())
|
||||
page=page, size=size, naming_mode=naming_mode,
|
||||
**self._filter_kwargs(instrument, playable_from_pitch))
|
||||
|
||||
def query_stats(self, *, sort="artist", want_sort_letters=False,
|
||||
naming_mode="legacy", **_ignore):
|
||||
naming_mode="legacy", instrument="", playable_from_pitch=None, **_ignore):
|
||||
return self._local._db.query_stats(
|
||||
sort=self._sort(sort), want_sort_letters=want_sort_letters,
|
||||
naming_mode=naming_mode, **self._filter_kwargs())
|
||||
naming_mode=naming_mode, **self._filter_kwargs(instrument, playable_from_pitch))
|
||||
|
||||
def tuning_names(self):
|
||||
return self._local.tuning_names()
|
||||
def tuning_names(self, instrument: str = "guitar"):
|
||||
return self._local.tuning_names(instrument=_normalize_instrument(instrument))
|
||||
|
||||
async def get_art(self, song_id: str):
|
||||
return await self._local.get_art(song_id)
|
||||
@@ -390,7 +417,10 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
|
||||
artist: str = "", album: str = "",
|
||||
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||
stems_has: str = "", stems_lacks: str = "",
|
||||
has_lyrics: str = "", tunings: str = "") -> dict:
|
||||
has_lyrics: str = "", tunings: str = "",
|
||||
instrument: str = "", tuning_match: str = "",
|
||||
playable_offsets: str = "", playable_instrument: str = "",
|
||||
playable_string_count: str = "") -> dict:
|
||||
fmt = format if format in ("archive", "sloppak", "loose") else ""
|
||||
return {
|
||||
"q": q,
|
||||
@@ -404,9 +434,58 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
|
||||
"stems_lacks": _split_csv(stems_lacks),
|
||||
"has_lyrics": _parse_has_lyrics(has_lyrics),
|
||||
"tunings": _split_csv(tunings),
|
||||
# Which perspective the tuning facet/filter/sort speaks for (the
|
||||
# caller's play role, NOT a saved rule — see _sanitize_collection_rules).
|
||||
"instrument": _normalize_instrument(instrument),
|
||||
# "Playable without retuning" mode: the caller's CURRENT tuning,
|
||||
# resolved to the one number the comparison needs. None = exact-match
|
||||
# mode (the default), so the tuning pills behave exactly as before.
|
||||
"playable_from_pitch": (
|
||||
_playable_from_pitch(playable_offsets, playable_instrument,
|
||||
playable_string_count)
|
||||
if tuning_match == "playable" else None),
|
||||
}
|
||||
|
||||
|
||||
def _playable_from_pitch(offsets_csv: str, instrument: str, string_count: str):
|
||||
"""Lowest open-string MIDI pitch of the CALLER's current tuning.
|
||||
|
||||
The client sends its live working tuning (offsets + instrument + string
|
||||
count) rather than a precomputed pitch, so the pitch tables stay in one
|
||||
place (lib/tunings.py) instead of being duplicated in JS.
|
||||
|
||||
Returns None for anything unusable — the caller then applies NO playable
|
||||
filter at all. That is the neutral state, not a claim: a malformed tuning
|
||||
must not silently assert that everything is playable OR that nothing is.
|
||||
"""
|
||||
try:
|
||||
offsets = [int(x) for x in _split_csv(offsets_csv)]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not offsets:
|
||||
return None
|
||||
inst = "bass" if instrument == "bass" else "guitar"
|
||||
try:
|
||||
sc = int(string_count)
|
||||
except (TypeError, ValueError):
|
||||
sc = len(offsets)
|
||||
key = tunings_mod.instrument_key(inst, sc)
|
||||
if key not in tunings_mod.STANDARD_OPEN_MIDIS or len(offsets) != sc:
|
||||
return None
|
||||
midis = tunings_mod.tuning_midis_from_offsets(key, offsets)
|
||||
return min(midis) if midis else None
|
||||
|
||||
|
||||
def _normalize_instrument(raw: str) -> str:
|
||||
"""Resolve a tuning PERSPECTIVE id (guitar-lead | guitar-rhythm | bass).
|
||||
|
||||
Tolerates the legacy two-valued vocabulary ("guitar" -> guitar-lead) and
|
||||
falls back to the default for anything unknown — an unrecognised value
|
||||
must never silently change filter semantics."""
|
||||
return raw if raw in PERSPECTIVES else (
|
||||
DEFAULT_PERSPECTIVE if raw != "bass" else "bass")
|
||||
|
||||
|
||||
def _sync_collection_provider(collection: dict) -> None:
|
||||
"""Register (or replace) the provider for one collection."""
|
||||
appstate.library_providers.register(
|
||||
|
||||
+21
-1
@@ -225,13 +225,18 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
|
||||
Returns (arrangements_list, shared_meta).
|
||||
shared_meta contains title/artist/album/year/duration/tuning_offsets
|
||||
sourced from the highest-priority arrangement (lead > combo > rhythm >
|
||||
bass) — picking the guitar tuning when both bass and lead are present.
|
||||
bass) — picking the guitar tuning when both bass and lead are present —
|
||||
plus `bass_tuning_offsets` from the first bass arrangement (None when the
|
||||
folder has none), so the index can carry both tunings.
|
||||
"""
|
||||
arrangements = []
|
||||
# Track which arrangement priority sourced shared_meta so a later,
|
||||
# higher-priority arrangement (lead < bass in sort order) overrides.
|
||||
shared_meta = {}
|
||||
shared_priority = None
|
||||
# First tuning seen per arrangement ROLE, kept alongside the guitar-first
|
||||
# song tuning so the library can answer for the part a player plays.
|
||||
role_tunings: dict[str, list[int] | None] = {"bass": None, "rhythm": None}
|
||||
|
||||
for xml in sorted(_iter_local_xmls(path)):
|
||||
# Trust the XML root over the filename — a custom named
|
||||
@@ -269,6 +274,10 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
|
||||
"duration", "tuning_offsets")}
|
||||
shared_priority = priority
|
||||
|
||||
if (arr_type in role_tunings and role_tunings[arr_type] is None
|
||||
and meta.get("tuning_offsets")):
|
||||
role_tunings[arr_type] = list(meta["tuning_offsets"])
|
||||
|
||||
arrangements.append({
|
||||
"type": arr_type,
|
||||
"name": arr_name,
|
||||
@@ -281,6 +290,8 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
|
||||
a["index"] = i
|
||||
del a["priority"]
|
||||
|
||||
for role, offs in role_tunings.items():
|
||||
shared_meta[f"{role}_tuning_offsets"] = offs
|
||||
return arrangements, shared_meta
|
||||
|
||||
|
||||
@@ -412,6 +423,14 @@ def extract_meta(path: Path, dlc_root: Path | None = None) -> dict:
|
||||
xml_meta.get("duration", 0))
|
||||
tuning_offsets = _coerce_tuning_offsets(manifest.get("tuning_offsets"),
|
||||
xml_meta.get("tuning_offsets"))
|
||||
# Per-role tunings: XML-derived only. A manifest `tuning_offsets` overrides
|
||||
# the SONG tuning (above) but says nothing about WHICH chart it describes,
|
||||
# so it must never be mistaken for a specific part's tuning.
|
||||
role_tunings = {}
|
||||
for role in ("bass", "rhythm"):
|
||||
offs = xml_meta.get(f"{role}_tuning_offsets")
|
||||
role_tunings[f"{role}_tuning_offsets"] = (
|
||||
offs if isinstance(offs, list) and offs else None)
|
||||
|
||||
manifest_arr = _validate_manifest_arrangements(manifest.get("arrangements"))
|
||||
if manifest_arr is not None:
|
||||
@@ -427,6 +446,7 @@ def extract_meta(path: Path, dlc_root: Path | None = None) -> dict:
|
||||
"year": year,
|
||||
"duration": duration,
|
||||
"tuning_offsets": tuning_offsets,
|
||||
**role_tunings, # None = no arrangement in that role
|
||||
"arrangements": arrangements,
|
||||
"audio_path": str(audio) if audio else None,
|
||||
"art_path": str(art) if art else None,
|
||||
|
||||
+344
-36
@@ -25,6 +25,8 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from song import compute_smart_names
|
||||
from tunings import DEFAULT_PERSPECTIVE, ROLE_PERSPECTIVES
|
||||
from tunings import perspective as _perspective
|
||||
|
||||
log = logging.getLogger("feedBack.server")
|
||||
|
||||
@@ -34,17 +36,113 @@ log = logging.getLogger("feedBack.server")
|
||||
# raw offsets so distinct customs stay distinct, while named tunings keep
|
||||
# grouping by name (stable across the offsets-column migration). Used by both
|
||||
# the tuning-names listing and the filter WHERE so the contract matches.
|
||||
def _tuning_group_key_sql(alias: str) -> str:
|
||||
"""The tuning grouping key (name for named tunings, raw offsets for
|
||||
customs) against an explicit table alias — the grouped filter law (§7.1)
|
||||
evaluates chart-intrinsic predicates inside a member subquery, where bare
|
||||
column names would resolve against the wrong scope."""
|
||||
return (f"CASE WHEN {alias}.tuning_name = 'Custom Tuning' AND COALESCE({alias}.tuning_offsets, '') != '' "
|
||||
f"THEN {alias}.tuning_offsets ELSE {alias}.tuning_name END")
|
||||
#
|
||||
# A non-default PERSPECTIVE (guitar-rhythm / bass) swaps every tuning column
|
||||
# for its EFFECTIVE expression: that role's indexed tuning when the song has
|
||||
# such an arrangement, falling back to the guitar-derived song tuning
|
||||
# otherwise — so a song with no rhythm/bass chart (or a row that predates the
|
||||
# columns, NULL there) still groups/filters/sorts instead of disappearing.
|
||||
# guitar-lead reads the original unprefixed columns, so it is byte-identical
|
||||
# to the historical behaviour.
|
||||
def _effective_tuning_cols_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> tuple[str, str, str]:
|
||||
"""(name_sql, offsets_sql, sort_key_sql) for the given perspective."""
|
||||
persp = _perspective(perspective)
|
||||
if not persp.column_prefix:
|
||||
return (f"{alias}.tuning_name", f"{alias}.tuning_offsets", f"{alias}.tuning_sort_key")
|
||||
has_own = f"COALESCE({alias}.{persp.column('name')}, '') != ''"
|
||||
return (
|
||||
f"COALESCE(NULLIF({alias}.{persp.column('name')}, ''), {alias}.tuning_name)",
|
||||
f"CASE WHEN {has_own} THEN {alias}.{persp.column('offsets')} ELSE {alias}.tuning_offsets END",
|
||||
f"CASE WHEN {has_own} THEN {alias}.{persp.column('sort_key')} ELSE {alias}.tuning_sort_key END",
|
||||
)
|
||||
|
||||
|
||||
def _effective_low_pitch_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> str:
|
||||
"""Lowest open-string MIDI pitch under this perspective, with the same
|
||||
fallback as the tuning columns — the "playable without retuning"
|
||||
comparison reads it (see tunings.chart_is_playable_in)."""
|
||||
persp = _perspective(perspective)
|
||||
if not persp.column_prefix:
|
||||
return f"{alias}.tuning_low_pitch"
|
||||
has_own = f"COALESCE({alias}.{persp.column('name')}, '') != ''"
|
||||
return (f"CASE WHEN {has_own} THEN {alias}.{persp.column('low_pitch')} "
|
||||
f"ELSE {alias}.tuning_low_pitch END")
|
||||
|
||||
|
||||
def _perspective_is_inferred_sql(alias: str, perspective: str) -> str:
|
||||
"""1 when this row is BORROWING the guitar-derived song tuning because it
|
||||
has no chart in the perspective's role. Always 0 for guitar-lead, which is
|
||||
never a fallback."""
|
||||
persp = _perspective(perspective)
|
||||
if not persp.column_prefix:
|
||||
return "0"
|
||||
return f"(CASE WHEN COALESCE({alias}.{persp.column('name')}, '') = '' THEN 1 ELSE 0 END)"
|
||||
|
||||
|
||||
# ── The custom-tuning group key ──────────────────────────────────────────────
|
||||
#
|
||||
# Named tunings group by NAME, which is already serialization-agnostic. Custom
|
||||
# tunings group on a raw offsets STRING, which is not: the same physical bass
|
||||
# tuning stored as "-2 0 0 0" and "-2 0 0 0 0 0" would fragment into two facet
|
||||
# rows with split counts.
|
||||
#
|
||||
# For BASS we therefore group customs on `bass_tuning_key` — the tuning's
|
||||
# absolute open-string PITCHES, computed once at scan time
|
||||
# (tunings.bass_tuning_key) after the padded tail is truncated away. Pitch is
|
||||
# the identity that matters musically and it is serialization-independent, so
|
||||
# one physical tuning is one entry however it was authored. Guitar keeps the
|
||||
# offsets string (unchanged; six-element guitar arrays are not padded).
|
||||
#
|
||||
# The key is built HERE, once, and read by the facet listing, the filter WHERE
|
||||
# and the grouped member-match alike — a facet row that selected a different
|
||||
# set than it counted is exactly the bug this shared expression prevents.
|
||||
def _tuning_group_key_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> str:
|
||||
"""The tuning grouping key (name for named tunings, canonical pitches or
|
||||
raw offsets for customs) against an explicit table alias — the grouped
|
||||
filter law (§7.1) evaluates chart-intrinsic predicates inside a member
|
||||
subquery, where bare column names would resolve against the wrong scope."""
|
||||
persp = _perspective(perspective)
|
||||
name_sql, offsets_sql, _ = _effective_tuning_cols_sql(alias, perspective)
|
||||
if persp.column_prefix:
|
||||
# Fall back to the offsets string when the canonical key is absent
|
||||
# (a fallback row borrowing the guitar tuning, or a row scanned before
|
||||
# the key column existed) so a custom never groups under an empty key.
|
||||
offsets_sql = (f"COALESCE(NULLIF({alias}.{persp.column('key')}, ''), "
|
||||
f"{offsets_sql})")
|
||||
return (f"CASE WHEN {name_sql} = 'Custom Tuning' AND COALESCE({offsets_sql}, '') != '' "
|
||||
f"THEN {offsets_sql} ELSE {name_sql} END")
|
||||
|
||||
|
||||
def _put_perspective_value(meta: dict, col: str):
|
||||
"""Value to store for one per-perspective column on a freshly-scanned row."""
|
||||
if col.endswith("_low_pitch"):
|
||||
val = meta.get(col)
|
||||
return int(val) if isinstance(val, int) else None
|
||||
if col.endswith("_sort_key"):
|
||||
return int(meta.get(col, 0) or 0)
|
||||
return meta.get(col, "") or ""
|
||||
|
||||
|
||||
# ── SQLite metadata cache ─────────────────────────────────────────────────────
|
||||
|
||||
def _arrangements_all_bass(raw) -> bool:
|
||||
"""True when EVERY arrangement on a chart is a bass part (raw ``arrangements``
|
||||
JSON, as stored). Mirrors the library grid's card rule: such a chart's tuning
|
||||
must be scored against bass base pitches, or a 4-string bass tuning read as
|
||||
guitar can false-match a guitarist. A chart with no arrangements is not bass.
|
||||
"""
|
||||
try:
|
||||
arrs = json.loads(raw) if raw else []
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if not isinstance(arrs, list) or not arrs:
|
||||
return False
|
||||
return all(
|
||||
isinstance(a, dict) and re.search(r"\bbass\b", str(a.get("name") or ""), re.I)
|
||||
for a in arrs
|
||||
)
|
||||
|
||||
|
||||
def _ensure_smart_names(arrangements: list[dict]) -> list[dict]:
|
||||
"""Fill in missing ``smart_name`` fields and sort arrangements by smart order.
|
||||
|
||||
@@ -381,7 +479,18 @@ class MetadataDB:
|
||||
tuning_offsets TEXT DEFAULT '',
|
||||
genre TEXT DEFAULT '',
|
||||
track_number INTEGER,
|
||||
disc INTEGER
|
||||
disc INTEGER,
|
||||
bass_tuning_name TEXT,
|
||||
bass_tuning_sort_key INTEGER,
|
||||
bass_tuning_offsets TEXT,
|
||||
bass_tuning_key TEXT,
|
||||
bass_tuning_low_pitch INTEGER,
|
||||
rhythm_tuning_name TEXT,
|
||||
rhythm_tuning_sort_key INTEGER,
|
||||
rhythm_tuning_offsets TEXT,
|
||||
rhythm_tuning_key TEXT,
|
||||
rhythm_tuning_low_pitch INTEGER,
|
||||
tuning_low_pitch INTEGER
|
||||
)
|
||||
""")
|
||||
# Idempotent migrations for installs that predate each column.
|
||||
@@ -408,6 +517,32 @@ class MetadataDB:
|
||||
# falls back to title order. Cache; repopulated on rescan.
|
||||
"ALTER TABLE songs ADD COLUMN track_number INTEGER",
|
||||
"ALTER TABLE songs ADD COLUMN disc INTEGER",
|
||||
# Bass-arrangement tuning (the KwasimodoZAZA report): the song-level
|
||||
# tuning columns above are guitar-first, so the library filter lied
|
||||
# to bass players when the bass chart is tuned differently. Caches;
|
||||
# repopulated on rescan. NULL (no literal default) is deliberate —
|
||||
# it marks a pre-migration row the scanner must re-extract, while
|
||||
# '' means "extracted, song has no bass arrangement" (see scan.py).
|
||||
"ALTER TABLE songs ADD COLUMN bass_tuning_name TEXT",
|
||||
"ALTER TABLE songs ADD COLUMN bass_tuning_sort_key INTEGER",
|
||||
"ALTER TABLE songs ADD COLUMN bass_tuning_offsets TEXT",
|
||||
# Canonical grouping key: the bass tuning's absolute open-string
|
||||
# pitches. Keyed on PITCH, not the serialization-dependent offsets
|
||||
# string, so one physical tuning is one facet entry however it was
|
||||
# stored. See tunings.bass_tuning_key.
|
||||
"ALTER TABLE songs ADD COLUMN bass_tuning_key TEXT",
|
||||
# Lowest open-string MIDI pitch per perspective — the "playable
|
||||
# without retuning" comparison (tunings.chart_is_playable_in).
|
||||
"ALTER TABLE songs ADD COLUMN bass_tuning_low_pitch INTEGER",
|
||||
"ALTER TABLE songs ADD COLUMN tuning_low_pitch INTEGER",
|
||||
# The RHYTHM chart's own tuning: lead and rhythm arrangements can
|
||||
# be tuned differently, which is the same bug a bassist hit,
|
||||
# inside guitar. Same NULL-vs-'' contract as the bass family.
|
||||
"ALTER TABLE songs ADD COLUMN rhythm_tuning_name TEXT",
|
||||
"ALTER TABLE songs ADD COLUMN rhythm_tuning_sort_key INTEGER",
|
||||
"ALTER TABLE songs ADD COLUMN rhythm_tuning_offsets TEXT",
|
||||
"ALTER TABLE songs ADD COLUMN rhythm_tuning_key TEXT",
|
||||
"ALTER TABLE songs ADD COLUMN rhythm_tuning_low_pitch INTEGER",
|
||||
):
|
||||
try:
|
||||
self.conn.execute(ddl)
|
||||
@@ -667,6 +802,16 @@ class MetadataDB:
|
||||
self.conn.execute(_ddl)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Manual playlist ordering (tester ask): `position` orders the
|
||||
# PLAYLISTS themselves (playlist_songs.position orders songs within
|
||||
# one). NULL = unpositioned — those sort alphabetically AFTER the
|
||||
# manually positioned ones, and system playlists stay pinned first
|
||||
# regardless (see list_playlists). Additive, idempotent — same
|
||||
# pattern as `rules`/`kind` above.
|
||||
try:
|
||||
self.conn.execute("ALTER TABLE playlists ADD COLUMN position INTEGER")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# Wishlist / "wanted" (feedBack#636 item 4): a persisted, actionable
|
||||
# list of songs the user does NOT own yet — the *arr "Wanted/Monitored"
|
||||
# analogue. Unlike playlists (which reference owned local songs by
|
||||
@@ -2405,10 +2550,14 @@ class MetadataDB:
|
||||
|
||||
def list_playlists(self) -> list[dict]:
|
||||
from urllib.parse import quote
|
||||
# Order: system playlists pinned first, then manually positioned user
|
||||
# playlists (position = drag order), then unpositioned ones
|
||||
# alphabetically — so a manual order wins and a playlist created after
|
||||
# a reorder still lands somewhere predictable (see reorder_playlists).
|
||||
rows = self.conn.execute(
|
||||
"SELECT id, name, system_key, created_at, updated_at, kind FROM playlists "
|
||||
"WHERE rules IS NULL " # smart collections live in the source picker, not here
|
||||
"ORDER BY (system_key IS NULL), name COLLATE NOCASE"
|
||||
"ORDER BY (system_key IS NULL), (position IS NULL), position, name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
@@ -2566,7 +2715,9 @@ class MetadataDB:
|
||||
rows = self.conn.execute(
|
||||
f"""SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name,
|
||||
ps.arrangement, ps.work_key, s.arrangements,
|
||||
(s.filename IS NULL) AS dead
|
||||
(s.filename IS NULL) AS dead, s.tuning_offsets,
|
||||
s.bass_tuning_name, s.bass_tuning_offsets,
|
||||
s.rhythm_tuning_name, s.rhythm_tuning_offsets
|
||||
FROM playlist_songs ps LEFT JOIN songs s ON s.filename = ps.filename
|
||||
WHERE ps.playlist_id = ? {dead_filter}
|
||||
ORDER BY ps.position, ps.filename""",
|
||||
@@ -2578,6 +2729,17 @@ class MetadataDB:
|
||||
entry = {
|
||||
"filename": r[0], "position": r[1],
|
||||
"title": r[2] or r[0], "artist": r[3] or "", "tuning_name": r[4] or "",
|
||||
# Offsets + the bass-only flag let the playlist tuning check score a
|
||||
# row against the player's working tuning the same way the library
|
||||
# grid's chips do: a NAME alone can't be scored (two "Custom Tuning"
|
||||
# rows are different tunings), and coverage needs to know whether to
|
||||
# measure against bass or guitar base pitches.
|
||||
"tuning_offsets": r[9] or "",
|
||||
"bass_tuning_name": r[10] or "",
|
||||
"bass_tuning_offsets": r[11] or "",
|
||||
"rhythm_tuning_name": r[12] or "",
|
||||
"rhythm_tuning_offsets": r[13] or "",
|
||||
"bass_only": _arrangements_all_bass(r[7]),
|
||||
"art_url": f"/api/song/{quote(r[0])}/art",
|
||||
}
|
||||
if is_album:
|
||||
@@ -2605,7 +2767,9 @@ class MetadataDB:
|
||||
if work_key:
|
||||
self._ensure_work_display()
|
||||
row = self.conn.execute(
|
||||
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements "
|
||||
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements, "
|
||||
"s.tuning_offsets, s.bass_tuning_name, s.bass_tuning_offsets, "
|
||||
"s.rhythm_tuning_name, s.rhythm_tuning_offsets "
|
||||
"FROM work_display wd JOIN songs s ON s.filename = wd.filename "
|
||||
"WHERE wd.effective_work_key = ? AND wd.is_group_representative = 1",
|
||||
(work_key,)).fetchone()
|
||||
@@ -2615,8 +2779,16 @@ class MetadataDB:
|
||||
arrs = _ensure_smart_names(json.loads(row[4]) if row[4] else [])
|
||||
except Exception:
|
||||
arrs = []
|
||||
# An orphan-resolved slot PLAYS a different chart, so it must report
|
||||
# that chart's tuning to the check — not the dead pin's.
|
||||
return {"resolved_filename": row[0], "title": row[1] or row[0],
|
||||
"artist": row[2] or "", "tuning_name": row[3] or "",
|
||||
"tuning_offsets": row[5] or "",
|
||||
"bass_tuning_name": row[6] or "",
|
||||
"bass_tuning_offsets": row[7] or "",
|
||||
"rhythm_tuning_name": row[8] or "",
|
||||
"rhythm_tuning_offsets": row[9] or "",
|
||||
"bass_only": _arrangements_all_bass(row[4]),
|
||||
"arrangements": arrs,
|
||||
"art_url": f"/api/song/{quote(row[0])}/art",
|
||||
"resolved_from_orphan": True}
|
||||
@@ -2710,6 +2882,30 @@ class MetadataDB:
|
||||
self.conn.commit()
|
||||
return True
|
||||
|
||||
def reorder_playlists(self, ordered_ids: list[int]) -> bool:
|
||||
"""Persist a manual ordering of the playlists THEMSELVES: position =
|
||||
index in `ordered_ids` (the songs-within sibling is reorder_playlist).
|
||||
Caller (the route) validates the list is an exact permutation of the
|
||||
current non-system playlist ids."""
|
||||
with self._lock:
|
||||
for pos, pid in enumerate(ordered_ids):
|
||||
self.conn.execute(
|
||||
"UPDATE playlists SET position = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(pos, pid),
|
||||
)
|
||||
self.conn.commit()
|
||||
return True
|
||||
|
||||
def clear_playlist_positions(self) -> bool:
|
||||
"""Drop every manual playlist position → back to alphabetical
|
||||
(the "Sort A–Z" affordance)."""
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
"UPDATE playlists SET position = NULL, updated_at = datetime('now') "
|
||||
"WHERE position IS NOT NULL")
|
||||
self.conn.commit()
|
||||
return True
|
||||
|
||||
def toggle_saved(self, filename: str) -> bool:
|
||||
"""Add/remove a song on the Saved-for-Later playlist. Returns new state.
|
||||
The presence check and the add/remove run under one lock so two
|
||||
@@ -2810,16 +3006,39 @@ class MetadataDB:
|
||||
def favorite_set(self) -> set[str]:
|
||||
return {r[0] for r in self.conn.execute("SELECT filename FROM favorites").fetchall()}
|
||||
|
||||
# Every per-perspective column, in one place, so the SELECT, the INSERT and
|
||||
# the scanner's "was this ever extracted?" check can never drift apart.
|
||||
# NULL is meaningful on `name`/`key`/`low_pitch`: it marks a row written
|
||||
# before the column existed, which the scanner re-extracts (see
|
||||
# scan._has_unextracted_columns). '' / 0 means "extracted, no such chart".
|
||||
_PERSPECTIVE_COLS = tuple(
|
||||
p.column(suffix)
|
||||
for p in ROLE_PERSPECTIVES
|
||||
for suffix in ("name", "sort_key", "offsets", "key", "low_pitch")
|
||||
) + ("tuning_low_pitch",)
|
||||
# Columns whose NULL means "never extracted" rather than "no such chart".
|
||||
#
|
||||
# low_pitch is deliberately NOT a marker: a song with no chart in that role
|
||||
# legitimately has NULL there (nothing to compute a pitch from), so keying
|
||||
# re-extraction on it would re-scan those rows on every single pass and
|
||||
# never converge. `name` and `key` carry the signal instead — they are ''
|
||||
# when extracted-but-absent, NULL only when the column predates the row.
|
||||
_EXTRACTION_MARKER_COLS = tuple(
|
||||
p.column(suffix) for p in ROLE_PERSPECTIVES for suffix in ("name", "key")
|
||||
)
|
||||
|
||||
def get(self, filename: str, mtime: float, size: int) -> dict | None:
|
||||
cache_key = str(filename)
|
||||
pcols = ", ".join(self._PERSPECTIVE_COLS)
|
||||
with self._lock:
|
||||
row = self.conn.execute(
|
||||
"SELECT mtime, size, title, artist, album, year, duration, tuning, arrangements, has_lyrics, "
|
||||
"format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets "
|
||||
"format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, "
|
||||
f"{pcols} "
|
||||
"FROM songs WHERE filename = ?", (cache_key,)
|
||||
).fetchone()
|
||||
if row and row[0] == mtime and row[1] == size and row[2]:
|
||||
return {
|
||||
out = {
|
||||
"title": row[2], "artist": row[3], "album": row[4],
|
||||
"year": row[5], "duration": row[6], "tuning": row[7],
|
||||
"arrangements": json.loads(row[8]) if row[8] else [],
|
||||
@@ -2831,6 +3050,15 @@ class MetadataDB:
|
||||
"tuning_sort_key": int(row[14] or 0),
|
||||
"tuning_offsets": row[15] or "",
|
||||
}
|
||||
for i, col in enumerate(self._PERSPECTIVE_COLS, start=16):
|
||||
val = row[i]
|
||||
if col in self._EXTRACTION_MARKER_COLS:
|
||||
out[col] = val # NULL preserved — drives re-extraction
|
||||
elif col.endswith("_sort_key"):
|
||||
out[col] = int(val or 0)
|
||||
else:
|
||||
out[col] = val or ""
|
||||
return out
|
||||
return None
|
||||
|
||||
def put(self, filename: str, mtime: float, size: int, meta: dict):
|
||||
@@ -2838,8 +3066,9 @@ class MetadataDB:
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO songs "
|
||||
"(filename, mtime, size, title, artist, album, year, duration, tuning, arrangements, "
|
||||
"has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc, "
|
||||
+ ", ".join(self._PERSPECTIVE_COLS) + ") "
|
||||
"VALUES (" + ", ".join(["?"] * (20 + len(self._PERSPECTIVE_COLS))) + ")",
|
||||
(filename, mtime, size, meta.get("title", ""), meta.get("artist", ""),
|
||||
meta.get("album", ""), meta.get("year", ""), meta.get("duration", 0),
|
||||
meta.get("tuning", ""), json.dumps(meta.get("arrangements", [])),
|
||||
@@ -2852,7 +3081,14 @@ class MetadataDB:
|
||||
meta.get("tuning_offsets", "") or "",
|
||||
meta.get("genre", "") or "",
|
||||
meta.get("track_number"),
|
||||
meta.get("disc")),
|
||||
meta.get("disc"),
|
||||
# A put() row is by definition freshly extracted, so the
|
||||
# marker columns must never be written NULL — that state is
|
||||
# reserved for rows predating the column, which re-extract.
|
||||
# low_pitch is the exception: NULL there means "this tuning
|
||||
# has no computable pitch" (unusable offsets), and the
|
||||
# playable filter treats unknown as not-playable.
|
||||
*[_put_perspective_value(meta, col) for col in self._PERSPECTIVE_COLS]),
|
||||
)
|
||||
self.conn.commit()
|
||||
# A song's identity may have changed → the grouping read-model is stale.
|
||||
@@ -3332,6 +3568,8 @@ class MetadataDB:
|
||||
match_states: list[str] | None = None,
|
||||
genre: list[str] | None = None,
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None,
|
||||
include_intrinsic: bool = True) -> tuple[str, list]:
|
||||
"""Shared WHERE-clause builder for query_page / query_artists /
|
||||
query_stats. Returns (where_sql, params). Leading 'WHERE' is
|
||||
@@ -3438,7 +3676,8 @@ class MetadataDB:
|
||||
"songs", format_filter=format_filter,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
|
||||
instrument=instrument, playable_from_pitch=playable_from_pitch)
|
||||
where += ifrag
|
||||
params += iparams
|
||||
return where, params
|
||||
@@ -3450,7 +3689,9 @@ class MetadataDB:
|
||||
stems_lacks: list[str] | None = None,
|
||||
has_lyrics: int | None = None,
|
||||
tunings: list[str] | None = None,
|
||||
naming_mode: str = "legacy") -> tuple[str, list]:
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None) -> tuple[str, list]:
|
||||
"""CHART-INTRINSIC predicates (format / arrangements / stems / lyrics /
|
||||
tuning) as ' AND …' fragments against an explicit table alias. Flat
|
||||
queries apply them to `songs` directly; grouped queries evaluate them
|
||||
@@ -3593,10 +3834,32 @@ class MetadataDB:
|
||||
placeholders = ",".join(["?"] * len(tn))
|
||||
# Match the same grouping key tuning_names() returns so a single
|
||||
# "Custom Tuning" pill selects exactly its offset set while named
|
||||
# tunings still match by name.
|
||||
where += (f" AND {_tuning_group_key_sql(alias)} "
|
||||
# tunings still match by name. `instrument` swaps in the
|
||||
# effective bass tuning key (guitar fallback) — the facet and
|
||||
# this WHERE must use the same expression or they disagree.
|
||||
where += (f" AND {_tuning_group_key_sql(alias, instrument)} "
|
||||
f"COLLATE NOCASE IN ({placeholders})")
|
||||
params += tn
|
||||
if playable_from_pitch is not None:
|
||||
# "Playable without retuning" — the mode the tester actually wants
|
||||
# ("don't make me retune"), offered ALONGSIDE exact match, not
|
||||
# instead of it. A chart needs no retune when its lowest required
|
||||
# pitch is reachable, and every pitch above your lowest open string
|
||||
# is reachable by fretting, so the comparison is:
|
||||
#
|
||||
# your lowest open pitch <= the chart's lowest open pitch
|
||||
#
|
||||
# That is why a 5-string bass (low B) covers every 4-string
|
||||
# standard AND every drop-D chart untouched.
|
||||
#
|
||||
# CONSERVATIVE BY CONSTRUCTION: a chart whose low pitch we could
|
||||
# not compute (NULL) is EXCLUDED rather than assumed playable —
|
||||
# wrongly claiming playability costs a mid-practice retune, which
|
||||
# is the failure this whole feature exists to prevent. See
|
||||
# tunings.chart_is_playable_in for the full reasoning + limits.
|
||||
low_sql = _effective_low_pitch_sql(alias, instrument)
|
||||
where += f" AND {low_sql} IS NOT NULL AND {low_sql} >= ?"
|
||||
params.append(int(playable_from_pitch))
|
||||
return where, params
|
||||
|
||||
# Under group=1, chart-intrinsic filters match if ANY member of the work
|
||||
@@ -3864,7 +4127,9 @@ class MetadataDB:
|
||||
genre: list[str] | None = None,
|
||||
after: str | None = None,
|
||||
group: bool = False,
|
||||
naming_mode: str = "legacy") -> tuple[list[dict], int]:
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None) -> tuple[list[dict], int]:
|
||||
"""Server-side paginated search. Returns (songs, total_count).
|
||||
|
||||
`after` is an opaque keyset cursor (the last row of the previous page).
|
||||
@@ -3893,7 +4158,9 @@ class MetadataDB:
|
||||
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
|
||||
tags_has=tags_has, user_difficulty_in=user_difficulty_in,
|
||||
match_states=match_states, genre=genre,
|
||||
naming_mode=naming_mode, include_intrinsic=not group,
|
||||
naming_mode=naming_mode, instrument=instrument,
|
||||
playable_from_pitch=playable_from_pitch,
|
||||
include_intrinsic=not group,
|
||||
)
|
||||
ifrag, iparams = "", []
|
||||
if group:
|
||||
@@ -3902,12 +4169,14 @@ class MetadataDB:
|
||||
"m", format_filter=format_filter,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
|
||||
instrument=instrument, playable_from_pitch=playable_from_pitch)
|
||||
mfrag, mparams = self._grouped_member_match(ifrag, iparams)
|
||||
where += mfrag
|
||||
params += mparams
|
||||
where += self._GROUP_REP_PREDICATE
|
||||
|
||||
_eff_tuning_name, _, _eff_tuning_sort = _effective_tuning_cols_sql("songs", instrument)
|
||||
sort_map = {
|
||||
# Artist sorts order WITHIN an artist by title (the tree view's
|
||||
# artist -> album -> title feel) instead of raw filename — the
|
||||
@@ -3941,11 +4210,15 @@ class MetadataDB:
|
||||
# behind, and a NULL `tuning_name` in `(tuning_name = '')`
|
||||
# evaluates to NULL itself (which sorts ahead of 0 in
|
||||
# ASC), defeating the push-to-bottom intent.
|
||||
#
|
||||
# Under `instrument=bass` the effective expressions swap in
|
||||
# the bass arrangement's tuning (guitar fallback) so a bass
|
||||
# player's tuning sort orders by the tuning they'd play.
|
||||
"tuning": (
|
||||
"(COALESCE(tuning_name, '') = '') ASC, "
|
||||
"ABS(COALESCE(tuning_sort_key, 0)), "
|
||||
"COALESCE(tuning_sort_key, 0) ASC, "
|
||||
"COALESCE(tuning_name, '') COLLATE NOCASE"
|
||||
f"(COALESCE({_eff_tuning_name}, '') = '') ASC, "
|
||||
f"ABS(COALESCE({_eff_tuning_sort}, 0)), "
|
||||
f"COALESCE({_eff_tuning_sort}, 0) ASC, "
|
||||
f"COALESCE({_eff_tuning_name}, '') COLLATE NOCASE"
|
||||
),
|
||||
# Year sort (feedBack#128). Empty-year rows pushed to the
|
||||
# bottom for both directions; otherwise CAST so '2010' >
|
||||
@@ -4038,7 +4311,9 @@ class MetadataDB:
|
||||
|
||||
cols = ("SELECT filename, title, artist, album, year, duration, tuning, "
|
||||
"arrangements, has_lyrics, mtime, format, stem_count, stem_ids, "
|
||||
"tuning_name, tuning_offsets FROM songs ")
|
||||
"tuning_name, tuning_offsets, bass_tuning_name, bass_tuning_offsets, "
|
||||
"rhythm_tuning_name, rhythm_tuning_offsets "
|
||||
"FROM songs ")
|
||||
cursor = _decode_cursor(after) if after else None
|
||||
eff_sort = _effective_keyset_sort(sort, direction)
|
||||
if cursor and eff_sort in _KEYSET_SORTS:
|
||||
@@ -4071,8 +4346,30 @@ class MetadataDB:
|
||||
"stem_ids": json.loads(r[12]) if r[12] else [],
|
||||
"tuning_name": r[13] or "",
|
||||
"tuning_offsets": r[14] or "",
|
||||
# '' when the song has no bass arrangement (or the row predates
|
||||
# '' when the song has no such chart (or the row predates the
|
||||
# columns) — clients fall back to tuning_name.
|
||||
"bass_tuning_name": r[15] or "",
|
||||
"bass_tuning_offsets": r[16] or "",
|
||||
"rhythm_tuning_name": r[17] or "",
|
||||
"rhythm_tuning_offsets": r[18] or "",
|
||||
"has_estd": r[0] in estd, "favorite": r[0] in favs,
|
||||
})
|
||||
# PROVENANCE (non-default perspectives): a row shown to a bass or
|
||||
# rhythm player either carries that chart's own tuning (native) or is
|
||||
# borrowing the guitar-derived song tuning (inferred). The fallback is
|
||||
# deliberate — a third of a real library has no bass chart and
|
||||
# excluding it would be worse — but it must never be SILENT, or we
|
||||
# reproduce the original bug in a new place. The client marks inferred
|
||||
# rows; it can't infer this itself without duplicating the COALESCE.
|
||||
#
|
||||
# guitar-lead adds NOTHING here, so the default payload is unchanged.
|
||||
_persp = _perspective(instrument)
|
||||
if _persp.column_prefix:
|
||||
_name_key = _persp.column("name")
|
||||
for s in songs:
|
||||
s["tuning_perspective"] = _persp.id
|
||||
s["tuning_inferred"] = not s.get(_name_key)
|
||||
# Personal layer (difficulty + tags) rides along like `favorite`, so a
|
||||
# card can badge it without a second request. Notes stay OUT of the list
|
||||
# payload (they can be long) — fetch per-song via /user-meta. Batched to
|
||||
@@ -4169,7 +4466,7 @@ class MetadataDB:
|
||||
rows = self.conn.execute(
|
||||
"SELECT mw.effective_work_key, m.filename, m.title, m.duration, m.tuning, "
|
||||
"m.arrangements, m.has_lyrics, m.mtime, m.format, m.stem_count, m.stem_ids, "
|
||||
"m.tuning_name, m.tuning_offsets "
|
||||
"m.tuning_name, m.tuning_offsets, m.bass_tuning_name, m.bass_tuning_offsets "
|
||||
"FROM songs m JOIN work_display mw ON mw.filename = m.filename "
|
||||
f"WHERE mw.effective_work_key IN ({ph}){intrinsic_frag} "
|
||||
"ORDER BY mw.is_group_representative DESC, m.mtime DESC, m.filename",
|
||||
@@ -4190,6 +4487,7 @@ class MetadataDB:
|
||||
"stem_count": int(m[9] or 0),
|
||||
"stem_ids": json.loads(m[10]) if m[10] else [],
|
||||
"tuning_name": m[11] or "", "tuning_offsets": m[12] or "",
|
||||
"bass_tuning_name": m[13] or "", "bass_tuning_offsets": m[14] or "",
|
||||
}
|
||||
|
||||
def query_artists(self, letter: str = "", q: str = "",
|
||||
@@ -4204,7 +4502,9 @@ class MetadataDB:
|
||||
stems_lacks: list[str] | None = None,
|
||||
has_lyrics: int | None = None,
|
||||
tunings: list[str] | None = None,
|
||||
naming_mode: str = "legacy") -> tuple[list[dict], int]:
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None) -> tuple[list[dict], int]:
|
||||
"""Get artists grouped by letter with their albums and songs. Returns (artists, total_artists)."""
|
||||
where, params = self._build_where(
|
||||
q=q, favorites_only=favorites_only, format_filter=format_filter,
|
||||
@@ -4212,6 +4512,7 @@ class MetadataDB:
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
|
||||
instrument=instrument, playable_from_pitch=playable_from_pitch,
|
||||
)
|
||||
# Canonicalize artists at display when aliases exist (P4): dedupe / group /
|
||||
# letter / order on the EFFECTIVE artist so "ACDC" + "AC/DC" list as one
|
||||
@@ -4247,7 +4548,7 @@ class MetadataDB:
|
||||
|
||||
rows = self.conn.execute(
|
||||
f"SELECT filename, title, ({art_expr}) as artist, album, year, duration, tuning, arrangements, has_lyrics, "
|
||||
f"format, stem_count, stem_ids, tuning_name "
|
||||
f"format, stem_count, stem_ids, tuning_name, bass_tuning_name "
|
||||
f"FROM songs {song_where} ORDER BY ({art_expr}) COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE",
|
||||
song_params
|
||||
).fetchall()
|
||||
@@ -4280,6 +4581,7 @@ class MetadataDB:
|
||||
"stem_count": int(r[10] or 0),
|
||||
"stem_ids": json.loads(r[11]) if r[11] else [],
|
||||
"tuning_name": r[12] or "",
|
||||
"bass_tuning_name": r[13] or "",
|
||||
"has_estd": r[0] in estd,
|
||||
"favorite": r[0] in favs,
|
||||
"user_difficulty": udm.get(r[0]),
|
||||
@@ -4301,7 +4603,8 @@ class MetadataDB:
|
||||
stems_has=None, stems_lacks=None,
|
||||
has_lyrics=None, tunings=None, mastery=None,
|
||||
match_states=None, genre=None,
|
||||
naming_mode="legacy", page=0, size=120):
|
||||
naming_mode="legacy", instrument=DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch=None, page=0, size=120):
|
||||
"""Distinct (artist, album) groups with a track count + a representative
|
||||
cover song, for the album-condensed browse (paged by album). Rows with no
|
||||
album name are excluded -- they can't form an album card. Same filters as
|
||||
@@ -4313,7 +4616,8 @@ class MetadataDB:
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
|
||||
match_states=match_states, genre=genre,
|
||||
naming_mode=naming_mode,
|
||||
naming_mode=naming_mode, instrument=instrument,
|
||||
playable_from_pitch=playable_from_pitch,
|
||||
)
|
||||
awhere = where + " AND album IS NOT NULL AND album != ''"
|
||||
total = self.conn.execute(
|
||||
@@ -4344,7 +4648,9 @@ class MetadataDB:
|
||||
sort: str = "artist",
|
||||
want_sort_letters: bool = False,
|
||||
group: bool = False,
|
||||
naming_mode: str = "legacy") -> dict:
|
||||
naming_mode: str = "legacy",
|
||||
instrument: str = DEFAULT_PERSPECTIVE,
|
||||
playable_from_pitch: int | None = None) -> dict:
|
||||
"""Aggregate stats for the letter bar. Accepts the same filter
|
||||
params as query_page so the letter counts stay synchronized
|
||||
with the grid when filters are active.
|
||||
@@ -4371,7 +4677,8 @@ class MetadataDB:
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings, match_states=match_states,
|
||||
naming_mode=naming_mode,
|
||||
naming_mode=naming_mode, instrument=instrument,
|
||||
playable_from_pitch=playable_from_pitch,
|
||||
include_intrinsic=not group,
|
||||
)
|
||||
if group:
|
||||
@@ -4383,7 +4690,8 @@ class MetadataDB:
|
||||
"m", format_filter=format_filter,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
|
||||
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
|
||||
instrument=instrument, playable_from_pitch=playable_from_pitch)
|
||||
mfrag, mparams = self._grouped_member_match(ifrag, iparams)
|
||||
where += mfrag
|
||||
params += mparams
|
||||
|
||||
+42
-13
@@ -19,7 +19,7 @@ from starlette.concurrency import run_in_threadpool
|
||||
|
||||
import appstate
|
||||
from library_registry import (
|
||||
_library_filter_args, _sanitize_collection_rules,
|
||||
_library_filter_args, _normalize_instrument, _sanitize_collection_rules,
|
||||
_safe_art_redirect_url, _split_csv, _sync_collection_provider,
|
||||
_unregister_collection_provider,
|
||||
)
|
||||
@@ -52,7 +52,8 @@ def _require_library_provider_capability(provider: object, capability: str) -> N
|
||||
|
||||
|
||||
_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters", "after",
|
||||
"mastery", "match_states")
|
||||
"mastery", "match_states", "instrument",
|
||||
"playable_from_pitch")
|
||||
|
||||
|
||||
def _filter_provider_kwargs(method: object, kwargs: dict) -> dict:
|
||||
@@ -235,9 +236,20 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
|
||||
has_lyrics: str = "", tunings: str = "", provider: str = "local",
|
||||
mastery: str = "", tags: str = "", user_difficulty: str = "",
|
||||
match: str = "", genre: str = "", after: str = "", group: int = 0,
|
||||
naming_mode: str = "legacy"):
|
||||
naming_mode: str = "legacy", instrument: str = "",
|
||||
tuning_match: str = "", playable_offsets: str = "",
|
||||
playable_instrument: str = "", playable_string_count: str = ""):
|
||||
"""Paginated library search through the selected library provider.
|
||||
|
||||
`instrument` is the tuning PERSPECTIVE ("guitar-lead" default |
|
||||
"guitar-rhythm" | "bass"): which arrangement's tuning the tuning
|
||||
filter/sort speaks for, with a guitar fallback when a song has no chart in
|
||||
that role.
|
||||
|
||||
`tuning_match=playable` switches the tuning filter from exact-match to
|
||||
"playable without retuning" against the caller's current tuning
|
||||
(`playable_offsets` + `playable_instrument` + `playable_string_count`).
|
||||
|
||||
`after` is an opaque keyset cursor (feedBack#636 item 3): pass back the
|
||||
`next_cursor` from the previous response to fetch the next page with a
|
||||
WHERE-seek instead of OFFSET. Providers that don't support it ignore it and
|
||||
@@ -270,7 +282,10 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
|
||||
artist=artist, album=album,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
|
||||
tuning_match=tuning_match, playable_offsets=playable_offsets,
|
||||
playable_instrument=playable_instrument,
|
||||
playable_string_count=playable_string_count,
|
||||
),
|
||||
)
|
||||
# The cursor to resume after this page (effective sort folds in dir=desc).
|
||||
@@ -292,7 +307,7 @@ async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
|
||||
stems_has: str = "", stems_lacks: str = "",
|
||||
has_lyrics: str = "", tunings: str = "", mastery: str = "",
|
||||
match: str = "", genre: str = "",
|
||||
provider: str = "local"):
|
||||
provider: str = "local", instrument: str = ""):
|
||||
"""Album-condensed browse: distinct (artist, album) groups with a track count
|
||||
and a representative cover song. Paged by album. Same filters as /api/library."""
|
||||
size = min(size, 500)
|
||||
@@ -306,7 +321,7 @@ async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
|
||||
q=q, favorites=favorites, format=format, artist=artist, album=album,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
|
||||
),
|
||||
)
|
||||
return {"albums": albums, "total": total, "page": page, "size": size}
|
||||
@@ -319,7 +334,9 @@ async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page:
|
||||
arrangements_has: str = "", arrangements_lacks: str = "",
|
||||
stems_has: str = "", stems_lacks: str = "",
|
||||
has_lyrics: str = "", tunings: str = "", provider: str = "local",
|
||||
naming_mode: str = "legacy"):
|
||||
naming_mode: str = "legacy", instrument: str = "",
|
||||
tuning_match: str = "", playable_offsets: str = "",
|
||||
playable_instrument: str = "", playable_string_count: str = ""):
|
||||
"""Get artists grouped by letter with albums and songs (for tree view)."""
|
||||
size = min(size, 100)
|
||||
library_provider = _get_library_provider(provider)
|
||||
@@ -336,7 +353,7 @@ async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page:
|
||||
artist=artist, album=album,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
|
||||
),
|
||||
)
|
||||
return {"artists": artists, "total_artists": total, "page": page, "size": size}
|
||||
@@ -350,7 +367,10 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "",
|
||||
has_lyrics: str = "", tunings: str = "", provider: str = "local",
|
||||
match: str = "",
|
||||
sort: str = "artist", sort_letters: int = 0,
|
||||
group: int = 0, naming_mode: str = "legacy"):
|
||||
group: int = 0, naming_mode: str = "legacy",
|
||||
instrument: str = "", tuning_match: str = "",
|
||||
playable_offsets: str = "", playable_instrument: str = "",
|
||||
playable_string_count: str = ""):
|
||||
"""Aggregate stats for the UI. Accepts the same filter params as
|
||||
/api/library so the letter bar mirrors the active grid filter set.
|
||||
`sort` selects the column the jump rail's `sort_letters` keys on;
|
||||
@@ -375,7 +395,10 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "",
|
||||
artist=artist, album=album,
|
||||
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
|
||||
stems_has=stems_has, stems_lacks=stems_lacks,
|
||||
has_lyrics=has_lyrics, tunings=tunings,
|
||||
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
|
||||
tuning_match=tuning_match, playable_offsets=playable_offsets,
|
||||
playable_instrument=playable_instrument,
|
||||
playable_string_count=playable_string_count,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -407,14 +430,20 @@ def library_genres(provider: str = "local"):
|
||||
|
||||
|
||||
@router.get("/api/library/tuning-names")
|
||||
async def list_tuning_names(provider: str = "local"):
|
||||
async def list_tuning_names(provider: str = "local", instrument: str = ""):
|
||||
"""Distinct tuning names present in the library, with per-tuning
|
||||
counts. Powers the tuning multi-select. Sorted by `tuning_sort_key`
|
||||
so names appear in the same musical order the sort uses
|
||||
(feedBack#22) — E Standard first, then nearest neighbors."""
|
||||
(feedBack#22) — E Standard first, then nearest neighbors.
|
||||
|
||||
`instrument=bass` groups by each song's bass-arrangement tuning
|
||||
(guitar-derived fallback for songs without a bass chart) so bass
|
||||
players see the tunings they'd actually play. Providers that predate
|
||||
the kwarg simply don't receive it (signature-filtered)."""
|
||||
library_provider = _get_library_provider(provider)
|
||||
_require_library_provider_capability(library_provider, "library.read")
|
||||
return await _call_library_provider_async(library_provider, "tuning_names")
|
||||
return await _call_library_provider_async(
|
||||
library_provider, "tuning_names", instrument=_normalize_instrument(instrument))
|
||||
|
||||
|
||||
@router.get("/api/library/practice-suggestions")
|
||||
|
||||
@@ -70,6 +70,37 @@ def api_create_playlist(data: dict):
|
||||
return appstate.meta_db.create_playlist(name, kind=kind)
|
||||
|
||||
|
||||
@router.post("/api/playlists/reorder")
|
||||
def api_reorder_playlists(data: dict):
|
||||
"""Manual ordering of the playlists themselves (position = index in
|
||||
`order`); the songs-within sibling is /api/playlists/{pid}/reorder.
|
||||
System playlists stay pinned first and are not part of the order."""
|
||||
order = data.get("order")
|
||||
if not isinstance(order, list) or not all(
|
||||
isinstance(i, int) and not isinstance(i, bool) for i in order):
|
||||
return JSONResponse({"error": "order must be a list of playlist ids"}, status_code=400)
|
||||
# Require an exact permutation of the current non-system playlist ids: a
|
||||
# list with duplicates, omissions, extras, unknown ids, or a system id
|
||||
# would otherwise produce duplicate positions / a partial reorder while
|
||||
# still returning 200 (mirrors the songs-within validation).
|
||||
current = [p["id"] for p in appstate.meta_db.list_playlists() if not p["system_key"]]
|
||||
if len(order) != len(current) or sorted(order) != sorted(current):
|
||||
return JSONResponse(
|
||||
{"error": "order must be a permutation of your playlists' ids"},
|
||||
status_code=400,
|
||||
)
|
||||
appstate.meta_db.reorder_playlists(order)
|
||||
return api_list_playlists()
|
||||
|
||||
|
||||
@router.post("/api/playlists/sort-alpha")
|
||||
def api_sort_playlists_alpha():
|
||||
"""Clear every manual playlist position → back to the alphabetical
|
||||
default (system playlists were pinned first either way)."""
|
||||
appstate.meta_db.clear_playlist_positions()
|
||||
return api_list_playlists()
|
||||
|
||||
|
||||
@router.get("/api/playlists/{pid}")
|
||||
def api_get_playlist(pid: int):
|
||||
pl = appstate.meta_db.get_playlist(pid)
|
||||
|
||||
+169
-5
@@ -51,6 +51,120 @@ from scan_worker import _relpath, _scan_one
|
||||
|
||||
log = logging.getLogger("feedBack.scan")
|
||||
|
||||
import json
|
||||
|
||||
|
||||
# ── Directory-signature fast path ─────────────────────────────────────────────
|
||||
#
|
||||
# A startup scan globs the whole library twice (*.feedpak, *.wem) and stats every
|
||||
# file to detect what changed. On a 50k-song library that lives on a slow mount
|
||||
# (an NTFS-3G FUSE volume here) it is ~100k filesystem round trips every launch —
|
||||
# the "big drive churns on every startup" report.
|
||||
#
|
||||
# But adds / removes / renames of songs all bump the mtime of the DIRECTORY that
|
||||
# holds them (verified on the target NTFS-3G mount), and so does the addition of
|
||||
# a subdirectory (a new entry in its parent). So after a scan we record every
|
||||
# library directory and its mtime; on the next scan we re-stat ONLY those
|
||||
# directories (a handful, vs 100k file ops). If none changed, the file set is
|
||||
# unchanged and the whole listing/stat pass is skipped.
|
||||
#
|
||||
# The one thing this cannot see is a file edited IN PLACE under the same name —
|
||||
# that bumps the file's mtime but not its directory's. That is rare for a song
|
||||
# library (you add and remove packs, you don't rewrite them under the same name),
|
||||
# and the manual Refresh forces a full scan (force=True) for exactly that case.
|
||||
def _dir_signature_file() -> Path:
|
||||
return appstate.config_dir / "scan_dir_signature.json"
|
||||
|
||||
|
||||
def _load_dir_signature() -> dict | None:
|
||||
try:
|
||||
data = json.loads(_dir_signature_file().read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict) and isinstance(data.get("dirs"), dict):
|
||||
return data
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _save_dir_signature(dlc: Path, dirs: dict[str, int]) -> None:
|
||||
# Keyed by the DLC path so switching libraries never matches a stale
|
||||
# signature. Best-effort: a failed write just means the next scan is a full
|
||||
# one, never a wrong one.
|
||||
try:
|
||||
_dir_signature_file().write_text(
|
||||
json.dumps({"dlc": str(dlc), "dirs": dirs}), encoding="utf-8")
|
||||
except OSError as e:
|
||||
log.debug("scan: could not persist dir signature: %s", e)
|
||||
|
||||
|
||||
def _library_dirs(all_songs, dlc: Path) -> set[str]:
|
||||
"""Every directory whose mtime reflects an add/remove of a library song:
|
||||
each song's containing directory and all of its ancestors up to the DLC
|
||||
root (the root itself always included, as "."). Derived from the already-
|
||||
listed songs — no extra filesystem walk. The builtin carve-outs
|
||||
(tutorials-builtin / minigames-builtin) are absent because the caller
|
||||
already excluded them from `all_songs`, so a minigame writing a drill there
|
||||
never invalidates the fast path.
|
||||
|
||||
Directory-form songs (loose-song folders, directory sloppak bundles) also
|
||||
record their OWN directory: a file added/removed/replaced INSIDE the folder
|
||||
bumps that folder's mtime but not its parent's, so tracking only the parent
|
||||
would miss an in-place change to such a song. File-form sloppaks (a single
|
||||
.feedpak zip) aren't dirs, so they add nothing here — the flat file library
|
||||
stays at a handful of dir stats."""
|
||||
rels = {"."}
|
||||
for f in all_songs:
|
||||
rel = Path(_relpath(f, dlc))
|
||||
if f.is_dir():
|
||||
rels.add(rel.as_posix())
|
||||
parent = rel.parent
|
||||
rels.add(parent.as_posix())
|
||||
for anc in parent.parents:
|
||||
rels.add(anc.as_posix())
|
||||
return rels
|
||||
|
||||
|
||||
def _has_unextracted_columns() -> bool:
|
||||
"""True while any `songs` row still carries NULL in a column added by an
|
||||
additive migration — i.e. metadata the current extractor would fill but
|
||||
that no existing row has yet (currently `bass_tuning_name`).
|
||||
|
||||
The tree-signature fast path only asks "did the file set change"; on a
|
||||
settled library the answer is no forever, so a schema addition would never
|
||||
reach extraction. This one-row probe forces the full pass exactly until the
|
||||
backfill completes — `put()` writes '' rather than NULL, so it self-clears
|
||||
after the rescan instead of disabling the fast path permanently."""
|
||||
try:
|
||||
from metadata_db import MetadataDB
|
||||
cond = " OR ".join(f"{c} IS NULL" for c in MetadataDB._EXTRACTION_MARKER_COLS)
|
||||
row = appstate.meta_db.conn.execute(
|
||||
f"SELECT 1 FROM songs WHERE {cond} LIMIT 1").fetchone()
|
||||
except Exception as e:
|
||||
# A probe failure must not take the scan down; falling back to the fast
|
||||
# path costs at most a delayed backfill.
|
||||
log.debug("scan: unextracted-column probe failed: %s", e)
|
||||
return False
|
||||
return row is not None
|
||||
|
||||
|
||||
def _record_dir_signature(all_songs, dlc: Path) -> None:
|
||||
sig = _stat_dirs(dlc, _library_dirs(all_songs, dlc))
|
||||
if sig is not None: # a dir vanished mid-scan → skip; next scan is full
|
||||
_save_dir_signature(dlc, sig)
|
||||
|
||||
|
||||
def _stat_dirs(dlc: Path, rels) -> dict[str, int] | None:
|
||||
"""{reldir: mtime_ns} for the given library dirs, or None if any is gone or
|
||||
unreadable — a vanished recorded dir means the tree changed, so fail to a
|
||||
full scan rather than a false match."""
|
||||
out: dict[str, int] = {}
|
||||
for rel in rels:
|
||||
try:
|
||||
out[rel] = (dlc if rel == "." else dlc / rel).stat().st_mtime_ns
|
||||
except OSError:
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
_SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False, "added": 0, "removed": 0}
|
||||
|
||||
@@ -99,9 +213,12 @@ def _make_scan_executor():
|
||||
)
|
||||
|
||||
|
||||
def background_scan():
|
||||
def background_scan(force: bool = False):
|
||||
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
|
||||
|
||||
`force` skips the directory-signature fast path and always does the full
|
||||
listing/stat pass — the manual Refresh sets it (see _dir_signature_file).
|
||||
|
||||
Never sets `_scan_status["running"] = False` — ownership of that flag
|
||||
lives in `_scan_runner` so a `kick_scan()` racing this function's
|
||||
terminal write cannot observe a stale False and start a second runner.
|
||||
@@ -121,6 +238,22 @@ def background_scan():
|
||||
builtin_content.seed_builtin_diagnostic_sloppaks(appstate.server_root, dlc)
|
||||
builtin_content.seed_builtin_starter_content(appstate.server_root, dlc)
|
||||
|
||||
# Fast path: if every library directory recorded by the last scan still has
|
||||
# the same mtime, nothing was added, removed, or renamed, so the whole
|
||||
# glob-and-stat pass below can be skipped (see the signature comment above).
|
||||
# `force` (manual Refresh) always does the full pass. Seeding above is
|
||||
# idempotent — it only writes when a builtin is missing — so it does not
|
||||
# perturb the mtimes on a settled library.
|
||||
if not force and not _has_unextracted_columns():
|
||||
stored = _load_dir_signature()
|
||||
if stored is not None and stored.get("dlc") == str(dlc):
|
||||
current = _stat_dirs(dlc, stored["dirs"].keys())
|
||||
if current is not None and current == stored["dirs"]:
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete"}
|
||||
log.info("Scan: library tree unchanged (%d dirs) — skipped the full listing/stat pass",
|
||||
len(current))
|
||||
return
|
||||
|
||||
# Listing can fail on macOS without Full Disk Access, or on Docker if the
|
||||
# path isn't shared. Report the failure explicitly rather than silently
|
||||
# appearing to scan nothing.
|
||||
@@ -209,6 +342,15 @@ def background_scan():
|
||||
cached = None
|
||||
if not cached:
|
||||
to_scan.append((f, mtime, size, dlc))
|
||||
elif any(cached.get(c) is None for c in appstate.meta_db._EXTRACTION_MARKER_COLS):
|
||||
# Row predates one of the per-perspective tuning columns (NULL
|
||||
# from the additive migration), so that perspective's tuning was
|
||||
# never extracted for it. Without this
|
||||
# re-queue an existing library would keep every bass column empty
|
||||
# forever — mtime/size still match, so nothing else would ever
|
||||
# bring the row back through extraction. Converges: put() always
|
||||
# writes '' (never NULL), so a rescanned row is never re-queued.
|
||||
to_scan.append((f, mtime, size, dlc))
|
||||
elif cached.get("arrangements") and any(
|
||||
"smart_name" not in a for a in cached["arrangements"]
|
||||
):
|
||||
@@ -223,6 +365,9 @@ def background_scan():
|
||||
to_scan.append((f, mtime, size, dlc))
|
||||
|
||||
if not to_scan:
|
||||
# Full pass completed with the DB already up to date — record the tree
|
||||
# signature so the next startup can take the fast path.
|
||||
_record_dir_signature(all_songs, dlc)
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
|
||||
log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs))
|
||||
return
|
||||
@@ -247,6 +392,9 @@ def background_scan():
|
||||
_scan_status["done"] += 1
|
||||
_scan_status["current"] = fname
|
||||
|
||||
# Record the tree signature after a completed full pass so the next startup
|
||||
# can skip it when nothing has changed.
|
||||
_record_dir_signature(all_songs, dlc)
|
||||
log.info("Scan complete: %d songs cached", len(to_scan))
|
||||
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
|
||||
|
||||
@@ -255,6 +403,9 @@ _scan_kick_lock = threading.Lock()
|
||||
|
||||
|
||||
_scan_rescan_pending = False
|
||||
# Set by kick_scan(force=True); consumed by _scan_runner for the next pass so a
|
||||
# manual Refresh bypasses the directory-signature fast path.
|
||||
_scan_force_next = False
|
||||
|
||||
|
||||
# Handles to the running scan / enrichment worker threads. Both use the shared
|
||||
@@ -265,9 +416,15 @@ _scan_rescan_pending = False
|
||||
_scan_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def kick_scan() -> bool:
|
||||
def kick_scan(force: bool = False) -> bool:
|
||||
"""Request a library rescan, single-flight + coalescing.
|
||||
|
||||
`force` skips the directory-signature fast path for the resulting pass (the
|
||||
manual Refresh uses it so an in-place same-name edit — the one thing the
|
||||
fast path can't see — is always picked up). A forced request that coalesces
|
||||
onto a running or queued scan keeps the force intent: the pass is forced if
|
||||
ANY pending request asked for it.
|
||||
|
||||
Returns True if a new scan thread was started, False if one was already
|
||||
running. In the latter case a follow-up pass is queued and runs as soon
|
||||
as the current scan finishes so files landing mid-scan (e.g. an upload
|
||||
@@ -275,8 +432,10 @@ def kick_scan() -> bool:
|
||||
until the next periodic pass. Multiple late-arriving requests coalesce
|
||||
into a single follow-up.
|
||||
"""
|
||||
global _scan_rescan_pending, _scan_thread
|
||||
global _scan_rescan_pending, _scan_thread, _scan_force_next
|
||||
with _scan_kick_lock:
|
||||
if force:
|
||||
_scan_force_next = True
|
||||
if _scan_status["running"]:
|
||||
_scan_rescan_pending = True
|
||||
return False
|
||||
@@ -290,10 +449,15 @@ def kick_scan() -> bool:
|
||||
|
||||
def _scan_runner():
|
||||
"""Run _background_scan, then re-run if requests arrived mid-scan."""
|
||||
global _scan_rescan_pending
|
||||
global _scan_rescan_pending, _scan_force_next
|
||||
while True:
|
||||
# Consume the force flag for THIS pass; a forced request queued mid-scan
|
||||
# sets it again for the follow-up.
|
||||
with _scan_kick_lock:
|
||||
forced = _scan_force_next
|
||||
_scan_force_next = False
|
||||
try:
|
||||
background_scan()
|
||||
background_scan(force=forced)
|
||||
except Exception:
|
||||
log.exception("background scan failed unexpectedly")
|
||||
|
||||
|
||||
+59
-1
@@ -27,7 +27,11 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from song import compute_smart_names
|
||||
from tunings import tuning_name
|
||||
from tunings import (
|
||||
DEFAULT_PERSPECTIVE, PERSPECTIVES, ROLE_PERSPECTIVES, normalize_offsets,
|
||||
perspective_low_pitch, perspective_tuning_key, perspective_tuning_name,
|
||||
tuning_name,
|
||||
)
|
||||
import sloppak as sloppak_mod
|
||||
import loosefolder as loosefolder_mod
|
||||
|
||||
@@ -43,6 +47,56 @@ def _relpath(f: Path, dlc: Path) -> str:
|
||||
return f.name
|
||||
|
||||
|
||||
def _apply_role_tunings(meta: dict) -> None:
|
||||
"""Derive each ROLE perspective's tuning columns from the raw offsets the
|
||||
extractor emitted (currently bass + rhythm; guitar-lead reads the
|
||||
song-level columns the scanner has always written).
|
||||
|
||||
The domain rules live in `tunings` (see the PERSPECTIVES table and the
|
||||
block above it for the evidence behind each):
|
||||
|
||||
1. NORMALIZE FIRST. Stored bass arrays are commonly six elements whose
|
||||
last two slots are padding, so bass truncates to four strings before
|
||||
anything looks at them — padding must never reach the namer or the
|
||||
grouping key. Guitar does NOT truncate (a 7-string array is real).
|
||||
2. Refuse to name data the perspective distrusts (bass up-tuning), so the
|
||||
library can't send a player off to a tuning nobody plays.
|
||||
3. Group on CANONICAL PITCHES, not the raw offsets string — the same
|
||||
physical tuning serialized two ways must be ONE facet entry.
|
||||
|
||||
A song with no arrangement in that role gets EMPTY strings / 0, not NULL:
|
||||
'' is the indexed "we looked, there is no such chart" state the library's
|
||||
fallback keys on, while NULL means "never extracted" and re-scans.
|
||||
"""
|
||||
for persp in ROLE_PERSPECTIVES:
|
||||
raw = meta.pop(f"{persp.role}_tuning_offsets", None)
|
||||
offsets = normalize_offsets(raw, persp)
|
||||
if offsets is None:
|
||||
meta[persp.column("name")] = ""
|
||||
meta[persp.column("sort_key")] = 0
|
||||
meta[persp.column("offsets")] = ""
|
||||
meta[persp.column("key")] = ""
|
||||
meta[persp.column("low_pitch")] = None
|
||||
continue
|
||||
meta[persp.column("name")] = perspective_tuning_name(offsets, persp)
|
||||
meta[persp.column("sort_key")] = sum(offsets)
|
||||
# The NORMALIZED offsets are what we store: padding is not data, and a
|
||||
# client rendering target notes must not print phantom strings.
|
||||
meta[persp.column("offsets")] = " ".join(str(o) for o in offsets)
|
||||
meta[persp.column("key")] = perspective_tuning_key(offsets, persp)
|
||||
meta[persp.column("low_pitch")] = perspective_low_pitch(offsets, persp)
|
||||
|
||||
|
||||
def _apply_song_low_pitch(meta: dict, offsets: list[int]) -> None:
|
||||
"""Lowest open-string pitch of the SONG-level (guitar-lead) tuning, for
|
||||
the "playable without retuning" comparison. Indexed here, on the existing
|
||||
manifest-only pass — never by reopening chart JSON."""
|
||||
persp = PERSPECTIVES[DEFAULT_PERSPECTIVE]
|
||||
norm = normalize_offsets(offsets, persp)
|
||||
meta["tuning_low_pitch"] = (
|
||||
perspective_low_pitch(norm, persp) if norm is not None else None)
|
||||
|
||||
|
||||
def _extract_meta_sloppak(path: Path) -> dict:
|
||||
"""Extract metadata for a sloppak (file or directory)."""
|
||||
meta = sloppak_mod.extract_meta(path)
|
||||
@@ -52,6 +106,8 @@ def _extract_meta_sloppak(path: Path) -> dict:
|
||||
meta["tuning_name"] = name
|
||||
meta["tuning_sort_key"] = sum(offsets)
|
||||
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
|
||||
_apply_song_low_pitch(meta, offsets)
|
||||
_apply_role_tunings(meta)
|
||||
meta["format"] = "sloppak"
|
||||
# `extract_meta` already populates `stem_ids` (feedBack#129);
|
||||
# default to empty for older callers / mocks.
|
||||
@@ -86,6 +142,8 @@ def _extract_meta_loosefolder(path: Path, dlc_root: Path | None) -> dict:
|
||||
meta["tuning_name"] = name
|
||||
meta["tuning_sort_key"] = sum(offsets)
|
||||
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
|
||||
_apply_song_low_pitch(meta, offsets)
|
||||
_apply_role_tunings(meta)
|
||||
meta["format"] = "loose"
|
||||
meta.setdefault("stem_ids", [])
|
||||
# The library helper exposes absolute filesystem paths for audio/art
|
||||
|
||||
@@ -1240,6 +1240,27 @@ def _tuning_for_meta(arrangements_manifest: list[dict]) -> list[int]:
|
||||
return [0] * 6
|
||||
|
||||
|
||||
def _role_tuning_for_meta(arrangements_manifest: list[dict], role: str) -> list[int] | None:
|
||||
"""Per-ROLE companion to _tuning_for_meta: the tuning of the arrangement
|
||||
playing `role` ("bass" / "rhythm"), or None when the pack has no such
|
||||
arrangement with a tuning — the index then leaves that perspective's
|
||||
columns empty and the library falls back to the song (guitar-first)
|
||||
tuning, marking the row inferred.
|
||||
|
||||
Exact name first, then a looser containment pass so an alt/bonus chart
|
||||
("Bass 2", "Alt Rhythm") still beats pretending the part is in the lead
|
||||
guitar's tuning."""
|
||||
for match_exact in (True, False):
|
||||
for entry in arrangements_manifest:
|
||||
name = str(entry.get("name", "")).lower()
|
||||
tun = entry.get("tuning")
|
||||
if not (tun and isinstance(tun, list)):
|
||||
continue
|
||||
if name == role if match_exact else role in name:
|
||||
return list(tun)
|
||||
return None
|
||||
|
||||
|
||||
def extract_meta(path: Path) -> dict:
|
||||
"""Fast metadata for the library scanner. Reads only the manifest."""
|
||||
manifest = load_manifest(path)
|
||||
@@ -1262,6 +1283,10 @@ def extract_meta(path: Path) -> dict:
|
||||
|
||||
has_lyrics = bool(manifest.get("lyrics"))
|
||||
tuning_offsets = _tuning_for_meta(arr_list)
|
||||
# Per-role tunings alongside the song-level one, so the library can answer
|
||||
# for whichever arrangement the player actually plays.
|
||||
role_tunings = {f"{role}_tuning_offsets": _role_tuning_for_meta(arr_list, role)
|
||||
for role in ("bass", "rhythm")}
|
||||
|
||||
stems_list = manifest.get("stems", []) or []
|
||||
valid_stems: list[dict] = []
|
||||
@@ -1300,6 +1325,8 @@ def extract_meta(path: Path) -> dict:
|
||||
"disc": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("disc")),
|
||||
"duration": float(manifest.get("duration", 0) or 0),
|
||||
"tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name
|
||||
# None = the pack has no arrangement in that role.
|
||||
**role_tunings,
|
||||
"arrangements": arrangements,
|
||||
"has_lyrics": has_lyrics,
|
||||
"stem_count": stem_count,
|
||||
|
||||
+239
-8
@@ -416,27 +416,258 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict:
|
||||
})
|
||||
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
|
||||
# tuning has a low B, not an E, so labeling it "E Standard" would be wrong.
|
||||
# 7+-string community content falls through to the numeric fallback. See #43.
|
||||
# ── Bass tuning normalization (library indexing) ─────────────────────────────
|
||||
#
|
||||
# Bass charts in the wild store SIX-element tuning arrays even when the chart is
|
||||
# a 4-string part: slots 4-5 are PADDING. Confirmed by inspecting the charts
|
||||
# themselves — across every pack whose bass and guitar tunings diverge, no bass
|
||||
# note ever references string index 4 or 5 (the deepest reach is index 3).
|
||||
#
|
||||
# The feedpak spec carries NO string-count field (manifest `arrangement.tuning`
|
||||
# is an untyped integer array, `minItems: 1`), and counting strings for real
|
||||
# would mean parsing the 600KB-1.2MB arrangement JSON of every song on the
|
||||
# manifest-only fast scan path — unacceptable for scan time. So we DEFAULT BASS
|
||||
# TO 4 STRINGS and truncate.
|
||||
#
|
||||
# KNOWN GAP (deliberate, documented): a genuine 5- or 6-string bass is
|
||||
# truncated to its low four. That is harmless for the overwhelmingly common
|
||||
# case — a 5-string in standard truncates to [0,0,0,0] and still names
|
||||
# "Standard" — and only misreads a tuning that DIFFERS at string 4 or above.
|
||||
# Revisit if the spec ever gains a string count.
|
||||
BASS_DEFAULT_STRING_COUNT = 4
|
||||
|
||||
# Standard tunings (all six strings same offset)
|
||||
# Bassists tune DOWN, essentially never up: a whole-instrument up-tune fights
|
||||
# string tension. Anything above +1 semitone across the board is data we do not
|
||||
# trust, not a tuning a human plays (the real-world example that motivated this
|
||||
# is a bass array of [5,5,5,5,4,4] — "all four strings up a perfect fourth" —
|
||||
# on a song whose guitar chart is dead standard and whose own note content is
|
||||
# consistent with standard tuning; the offsets were almost certainly computed
|
||||
# against a 6-string-bass reference with an uninitialised tail).
|
||||
#
|
||||
# Such a tuning MUST NOT be named: printing "A Standard" would send a player
|
||||
# off to retune to something nobody plays. It degrades to the custom path,
|
||||
# where it stays visible and distinct but makes no pitch claim.
|
||||
BASS_MAX_PLAUSIBLE_OFFSET = 1
|
||||
|
||||
|
||||
# ── Tuning PERSPECTIVES ──────────────────────────────────────────────────────
|
||||
#
|
||||
# The library's tuning facet/filter/sort always answers for ONE arrangement
|
||||
# role. There are three, matching `active_instrument_profile`:
|
||||
#
|
||||
# guitar-lead the song-level (guitar-first) tuning — the historical
|
||||
# default. Its columns are the original unprefixed
|
||||
# `tuning_*` family, so today's behaviour is byte-identical.
|
||||
# guitar-rhythm the RHYTHM chart's own tuning. Lead and rhythm charts can
|
||||
# disagree (the same bug a bassist hit, inside guitar).
|
||||
# bass the BASS chart's own tuning.
|
||||
#
|
||||
# One table drives extraction, the derived columns, the SQL, and the labels —
|
||||
# rather than three near-identical column families maintained in parallel.
|
||||
class TuningPerspective:
|
||||
__slots__ = ("id", "role", "instrument", "string_count", "column_prefix",
|
||||
"truncate", "guard_up_tuning", "label")
|
||||
|
||||
def __init__(self, id, role, instrument, string_count, column_prefix,
|
||||
truncate, guard_up_tuning, label):
|
||||
self.id = id
|
||||
self.role = role # arrangement name to look for ('' = song-level)
|
||||
self.instrument = instrument
|
||||
self.string_count = string_count
|
||||
self.column_prefix = column_prefix # '' | 'rhythm_' | 'bass_'
|
||||
self.truncate = truncate
|
||||
self.guard_up_tuning = guard_up_tuning
|
||||
self.label = label
|
||||
|
||||
@property
|
||||
def instrument_key(self) -> str:
|
||||
return instrument_key(self.instrument, self.string_count)
|
||||
|
||||
def column(self, suffix: str) -> str:
|
||||
return f"{self.column_prefix}tuning_{suffix}"
|
||||
|
||||
|
||||
PERSPECTIVES: dict[str, TuningPerspective] = {
|
||||
"guitar-lead": TuningPerspective(
|
||||
"guitar-lead", "", "guitar", 6, "", False, False, "lead"),
|
||||
"guitar-rhythm": TuningPerspective(
|
||||
"guitar-rhythm", "rhythm", "guitar", 6, "rhythm_", False, False, "rhythm"),
|
||||
# Bass alone truncates (padded arrays) and guards against up-tuned data —
|
||||
# both are bass-specific findings, see the block above.
|
||||
"bass": TuningPerspective(
|
||||
"bass", "bass", "bass", BASS_DEFAULT_STRING_COUNT, "bass_", True, True, "bass"),
|
||||
}
|
||||
|
||||
DEFAULT_PERSPECTIVE = "guitar-lead"
|
||||
|
||||
# Perspectives that carry their OWN indexed columns (guitar-lead reads the
|
||||
# song-level ones, which the scanner has always written).
|
||||
ROLE_PERSPECTIVES = tuple(p for p in PERSPECTIVES.values() if p.column_prefix)
|
||||
|
||||
|
||||
def perspective(perspective_id) -> TuningPerspective:
|
||||
"""Resolve a perspective id, tolerating the legacy two-valued vocabulary
|
||||
('guitar' -> guitar-lead) and anything unknown (-> the default). An
|
||||
unrecognised value must never change filter semantics."""
|
||||
if perspective_id in PERSPECTIVES:
|
||||
return PERSPECTIVES[perspective_id]
|
||||
if perspective_id == "guitar":
|
||||
return PERSPECTIVES[DEFAULT_PERSPECTIVE]
|
||||
return PERSPECTIVES[DEFAULT_PERSPECTIVE]
|
||||
|
||||
|
||||
def normalize_offsets(offsets, persp: TuningPerspective) -> list[int] | None:
|
||||
"""Coerce a stored tuning array to the strings the perspective's
|
||||
instrument actually has. Returns None for anything unusable (empty /
|
||||
non-integer / too short), so callers leave the index empty rather than
|
||||
record a guess."""
|
||||
if not isinstance(offsets, list) or not offsets:
|
||||
return None
|
||||
if any(isinstance(o, bool) for o in offsets):
|
||||
return None
|
||||
try:
|
||||
vals = [int(o) for o in offsets]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if len(vals) < persp.string_count:
|
||||
return None
|
||||
# Only bass truncates: its arrays are padded (see above). A guitar array
|
||||
# longer than 6 is a genuine 7/8-string chart, and cutting it to 6 would
|
||||
# invent a tuning the chart does not have.
|
||||
if persp.truncate:
|
||||
return vals[:persp.string_count]
|
||||
return vals
|
||||
|
||||
|
||||
def offsets_are_plausible(offsets: list[int], persp: TuningPerspective) -> bool:
|
||||
"""False for data the perspective refuses to trust — currently only the
|
||||
bass up-tuning guard (see BASS_MAX_PLAUSIBLE_OFFSET)."""
|
||||
if not persp.guard_up_tuning:
|
||||
return True
|
||||
return all(o <= BASS_MAX_PLAUSIBLE_OFFSET for o in offsets)
|
||||
|
||||
|
||||
def perspective_tuning_name(offsets: list[int], persp: TuningPerspective) -> str:
|
||||
"""Name a NORMALIZED tuning for this perspective, refusing to name data the
|
||||
perspective distrusts — that becomes "Custom Tuning", which stays distinct
|
||||
by its canonical pitches without asserting a tuning anyone plays."""
|
||||
if not offsets_are_plausible(offsets, persp):
|
||||
return "Custom Tuning"
|
||||
return tuning_name(offsets)
|
||||
|
||||
|
||||
def perspective_tuning_key(offsets: list[int], persp: TuningPerspective) -> str:
|
||||
"""CANONICAL grouping key: the tuning's absolute open-string pitches, so
|
||||
the same physical tuning groups as ONE facet entry no matter how it was
|
||||
serialized. Keyed on pitch rather than the raw offsets string, which is
|
||||
serialization-dependent and fragments.
|
||||
|
||||
Joined with ':' and NOT ',' — this key travels back as a `tunings` filter
|
||||
selector, and that query param is a COMMA-separated list, so a comma here
|
||||
would be split into meaningless fragments and match nothing.
|
||||
"""
|
||||
midis = tuning_midis_from_offsets(persp.instrument_key, offsets)
|
||||
if not midis:
|
||||
return ""
|
||||
return persp.id + ":" + ":".join(str(m) for m in midis)
|
||||
|
||||
|
||||
def perspective_low_pitch(offsets: list[int], persp: TuningPerspective) -> int | None:
|
||||
"""Absolute MIDI pitch of the tuning's LOWEST open string — the value the
|
||||
"playable without retuning" comparison is built on (see
|
||||
`chart_is_playable_in`)."""
|
||||
midis = tuning_midis_from_offsets(persp.instrument_key, offsets)
|
||||
if not midis:
|
||||
return None
|
||||
return min(midis)
|
||||
|
||||
|
||||
# ── "Playable without retuning" ──────────────────────────────────────────────
|
||||
#
|
||||
# What the player actually wants is "don't make me retune", not "match this
|
||||
# label". A chart is playable as-is when every pitch it needs is reachable on
|
||||
# the instrument as currently tuned.
|
||||
#
|
||||
# WHAT WE CAN HONESTLY COMPUTE. We index open-string TUNINGS, not the notes a
|
||||
# chart plays — note data lives in the 600KB-1.2MB arrangement JSON, and the
|
||||
# library scan is deliberately manifest-only, so we do not read it (indexing a
|
||||
# per-song lowest note would mean opening every chart on every scan).
|
||||
#
|
||||
# So the comparison is on OPEN-STRING PITCH, with a conservative assumption:
|
||||
# a chart may require its own lowest open string. That gives
|
||||
#
|
||||
# playable <=> your lowest open pitch <= the chart's lowest open pitch
|
||||
#
|
||||
# On a fretted instrument every pitch ABOVE your lowest open string is
|
||||
# reachable by fretting (strings sit within an octave of each other and the
|
||||
# neck gives ~2 octaves), so the low end is the binding constraint. This is
|
||||
# exactly the dominant real case: a 5-string bass (low B) plays every 4-string
|
||||
# standard chart AND every drop-D chart untouched, because the low D is just
|
||||
# fretted on the B string.
|
||||
#
|
||||
# DELIBERATE LIMITATIONS, both erring toward NOT claiming playability:
|
||||
# * A chart that never actually touches its lowest open string is excluded
|
||||
# anyway. Conservative: excluding a playable chart costs a scroll;
|
||||
# including an unplayable one costs a mid-practice retune, which is the
|
||||
# failure this feature exists to prevent.
|
||||
# * The UPPER bound is not checked — a chart tuned far above you could in
|
||||
# principle exceed your neck. Checking it needs the note range we do not
|
||||
# have. It is the rare direction (and the guard above already refuses
|
||||
# up-tuned bass data), but it is a real gap, not an oversight.
|
||||
def chart_is_playable_in(chart_low_pitch, your_low_pitch) -> bool:
|
||||
"""True when a chart whose lowest open string is `chart_low_pitch` needs no
|
||||
retune for a player tuned to `your_low_pitch`. Unknown chart pitch => False
|
||||
(never claim playability we cannot support)."""
|
||||
if chart_low_pitch is None or your_low_pitch is None:
|
||||
return False
|
||||
return int(your_low_pitch) <= int(chart_low_pitch)
|
||||
|
||||
|
||||
# Back-compat wrappers over the generic helpers — bass was the first
|
||||
# perspective and reads better spelled out at bass-specific call sites.
|
||||
def normalize_bass_offsets(offsets) -> list[int] | None:
|
||||
return normalize_offsets(offsets, PERSPECTIVES["bass"])
|
||||
|
||||
|
||||
def bass_offsets_are_plausible(offsets: list[int]) -> bool:
|
||||
return offsets_are_plausible(offsets, PERSPECTIVES["bass"])
|
||||
|
||||
|
||||
def bass_tuning_name(offsets: list[int]) -> str:
|
||||
return perspective_tuning_name(offsets, PERSPECTIVES["bass"])
|
||||
|
||||
|
||||
def bass_tuning_key(offsets: list[int]) -> str:
|
||||
return perspective_tuning_key(offsets, PERSPECTIVES["bass"])
|
||||
|
||||
|
||||
def tuning_name(offsets: list[int]) -> str:
|
||||
# The pattern checks below are gated on `len(offsets)` being 6 or 4. The
|
||||
# naming conventions are E-standard-rooted — e.g. a 7-string all-zeros
|
||||
# tuning has a low B, not an E, so labeling it "E Standard" would be wrong.
|
||||
# 7+-string community content falls through to the numeric fallback (#43).
|
||||
#
|
||||
# Length 4 is accepted because a bass's open strings (EADG) are the low
|
||||
# four of the guitar, so the same standard/drop names apply at the same
|
||||
# offsets. Bass callers must normalize FIRST (`normalize_bass_offsets`):
|
||||
# stored bass arrays are commonly six elements with a padded tail, and the
|
||||
# padding must never reach this namer. See the block above.
|
||||
|
||||
# Standard tunings (all strings same offset)
|
||||
standard = {
|
||||
0: "E Standard", -1: "Eb Standard", -2: "D Standard",
|
||||
-3: "C# Standard", -4: "C Standard", -5: "B Standard",
|
||||
-6: "Bb Standard", -7: "A Standard",
|
||||
1: "F Standard", 2: "F# Standard",
|
||||
}
|
||||
if len(offsets) == 6 and all(o == offsets[0] for o in offsets):
|
||||
if len(offsets) in (4, 6) and all(o == offsets[0] for o in offsets):
|
||||
name = standard.get(offsets[0])
|
||||
if name:
|
||||
return name
|
||||
|
||||
# Drop tunings (low string 2 semitones below the rest)
|
||||
# Named after the low string's note: e.g. offsets[-2,0,0,0,0,0] = Drop D (low E dropped to D)
|
||||
if len(offsets) == 6 and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]):
|
||||
if len(offsets) in (4, 6) and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]):
|
||||
note_names = ["E", "F", "F#", "G", "Ab", "A", "Bb", "B", "C", "C#", "D", "Eb"]
|
||||
low_note = note_names[offsets[0] % 12]
|
||||
return f"Drop {low_note}"
|
||||
|
||||
+29
-17
@@ -522,27 +522,39 @@ def _current_venue():
|
||||
return best
|
||||
|
||||
|
||||
def _unplayed_genre_songs(gkey, exclude, limit):
|
||||
"""Library songs of a genre with no stats yet — a young passport's gig
|
||||
still gets a full set (playing them is how stubs start).
|
||||
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
|
||||
songs, single-user); push the match into SQL if propose ever feels slow."""
|
||||
def _fill_genre_songs(gkey, exclude, limit):
|
||||
"""Library songs of a genre to round out a gig — ANY song of the genre the
|
||||
set hasn't already picked.
|
||||
|
||||
Was `_unplayed_genre_songs`, restricted to `filename NOT IN song_stats`.
|
||||
That restriction created a hole: a song you'd played on a DIFFERENT
|
||||
instrument's arrangement has a stats row, so it was excluded here — and it
|
||||
lives in the played bucket for THAT instrument, not this passport's, so it
|
||||
was excluded there too. It could never be gigged. A player with 137 metalcore
|
||||
songs, all played on another instrument, got a 404 (reproduced). The player's
|
||||
library is the pool; whether a song has stats on some other instrument has no
|
||||
bearing on whether it can be in THIS gig.
|
||||
|
||||
Shuffled, so re-roll actually changes the set. The old version returned the
|
||||
library's first N in table order every time, so re-roll was a no-op for any
|
||||
set drawn from the filler (reproduced).
|
||||
|
||||
ponytail: full genre scan + python-side match + shuffle (a few ms at 7k
|
||||
songs, single-user); push into SQL if propose ever feels slow.
|
||||
"""
|
||||
db = _state["meta_db"]
|
||||
if db is None:
|
||||
return []
|
||||
rows = db.conn.execute(
|
||||
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
|
||||
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
|
||||
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs"
|
||||
).fetchall()
|
||||
out = []
|
||||
for filename, title, artist, genre in rows:
|
||||
if _genre_key(genre) != gkey or filename in exclude:
|
||||
continue
|
||||
out.append({"filename": filename, "title": title or filename,
|
||||
"artist": artist or ""})
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
pool = [
|
||||
{"filename": filename, "title": title or filename, "artist": artist or ""}
|
||||
for filename, title, artist, genre in rows
|
||||
if _genre_key(genre) == gkey and filename not in exclude
|
||||
]
|
||||
random.shuffle(pool) # re-roll must vary; free per call
|
||||
return pool[:limit]
|
||||
|
||||
|
||||
def _validate_pack_dir(pack_dir: Path):
|
||||
@@ -836,7 +848,7 @@ def setup(app, context):
|
||||
picks.append(s)
|
||||
if len(picks) < size:
|
||||
exclude = {s["filename"] for s in picks}
|
||||
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
|
||||
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks)))
|
||||
if not picks:
|
||||
raise HTTPException(404, "No songs of this genre in the library.")
|
||||
venue = _current_venue()
|
||||
|
||||
@@ -1192,7 +1192,21 @@
|
||||
if (typeof window.setViz === 'function') window.setViz('venue');
|
||||
} catch (_) { /* viz optional — restore stays intact */ }
|
||||
}
|
||||
// Push the gig's venue pack to the crowd layer NOW.
|
||||
//
|
||||
// crowd.setManifest(venue) is reached only through pushCrowdManifest,
|
||||
// and pushCrowdManifest is called only from refresh() — the career
|
||||
// tab's own reload. A gig navigates AWAY from the career tab to the
|
||||
// player, so refresh() never runs during it, and setting the override
|
||||
// above does nothing on its own. The result the testers saw: the venue
|
||||
// visualization turns on (3D highway) but its crowd/stage pack never
|
||||
// loads, so the song plays over the bare highway backdrop ("standard
|
||||
// particles"), or over whatever venue a previous refresh() happened to
|
||||
// leave applied. We just changed the override to this gig's venue, so
|
||||
// re-push for it. _state is the career state the booking screen already
|
||||
// fetched; guard for the rare null.
|
||||
_appliedManifestVenue = null;
|
||||
if (_state) pushCrowdManifest(_state);
|
||||
_ppGigRun = {
|
||||
songs: prop.songs,
|
||||
venue_id: prop.venue_id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.31.5",
|
||||
"version": "3.32.0",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
+16112
-15864
File diff suppressed because it is too large
Load Diff
@@ -1115,7 +1115,10 @@ async def startup_status_stream(request: Request):
|
||||
@app.post("/api/rescan")
|
||||
def trigger_rescan():
|
||||
"""Manually trigger a library rescan."""
|
||||
if not scan.kick_scan():
|
||||
# force=True: a manual Refresh must skip the directory-signature fast path —
|
||||
# it is the escape hatch for the one change dir mtimes can't see (a pack
|
||||
# rewritten in place under the same name).
|
||||
if not scan.kick_scan(force=True):
|
||||
return {"message": "Scan already in progress"}
|
||||
return {"message": "Rescan started"}
|
||||
|
||||
@@ -1133,7 +1136,7 @@ def trigger_full_rescan():
|
||||
# delete_missing() prunes anything genuinely gone at the end.
|
||||
meta_db.conn.execute("UPDATE songs SET mtime = -1")
|
||||
meta_db.conn.commit()
|
||||
if not scan.kick_scan():
|
||||
if not scan.kick_scan(force=True):
|
||||
return {"message": "Scan already in progress"}
|
||||
return {"message": "Full rescan started"}
|
||||
|
||||
|
||||
+27
-2
@@ -1334,12 +1334,25 @@ if (window.feedBack) window.feedBack.closeCurrentSong = closeCurrentSong;
|
||||
// leaving the player still leaves — and abandons the queue.
|
||||
window.feedBack.playQueue = (function () {
|
||||
let list = [], idx = -1, source = '', arrangements = null;
|
||||
// Set true by _play() right before it drives playSong, consumed once by
|
||||
// playSong's clear-guard. The primary "don't clear the queue I'm driving"
|
||||
// signal is options.fromQueue, but a chain of plugin playSong wrappers
|
||||
// (nam_tone, midi_amp, fretboard, invert_highway, tabview, ...) forward only
|
||||
// (filename, arrangement) and silently drop the options object — so the flag
|
||||
// never arrived and the queue cleared itself the instant its first song
|
||||
// started (a gig/album/playlist never advanced). This flag rides beside the
|
||||
// wrapper chain, not through it.
|
||||
let _internalPlay = false;
|
||||
const active = () => idx >= 0 && idx < list.length;
|
||||
const hasNext = () => active() && idx < list.length - 1;
|
||||
function clear() { list = []; idx = -1; source = ''; arrangements = null; }
|
||||
function _play(i) {
|
||||
const fn = list[i];
|
||||
// fromQueue keeps the queue from clearing itself; playSong decodeURIs.
|
||||
// fromQueue is the in-band signal; _internalPlay is the out-of-band one
|
||||
// that survives wrapper chains dropping the options arg. Both set; either
|
||||
// suffices. playSong runs its clear-guard synchronously at entry, and the
|
||||
// wrapper chain reaches it synchronously, so the flag is still set then.
|
||||
_internalPlay = true;
|
||||
window.playSong(encodeURIComponent(fn), arrangements ? arrangements[i] : undefined, { fromQueue: true });
|
||||
}
|
||||
function start(files, opts) {
|
||||
@@ -1371,6 +1384,15 @@ window.feedBack.playQueue = (function () {
|
||||
}
|
||||
return {
|
||||
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
|
||||
// True when the current song is a queue ADVANCE (song 2..N of a set),
|
||||
// false for its first song or a standalone play. The venue uses this to
|
||||
// fly in once on arrival at the set, then continue the room between
|
||||
// songs instead of replaying the arrival flyover every track.
|
||||
isContinuation: function () { return active() && idx > 0; },
|
||||
// One-shot: true iff _play just kicked off this playSong. Consumed on
|
||||
// read so a later MANUAL play still clears the queue. playSong calls this
|
||||
// instead of trusting options.fromQueue to survive the wrapper chain.
|
||||
_consumeInternalPlay: function () { const v = _internalPlay; _internalPlay = false; return v; },
|
||||
source: function () { return source; },
|
||||
remaining: function () { return active() ? list.length - idx - 1 : 0; },
|
||||
// What's coming, for consumers that RENDER the queue (a results
|
||||
@@ -2297,11 +2319,14 @@ configureHost({
|
||||
currentFilename: () => currentFilename,
|
||||
});
|
||||
|
||||
// `esc` is here for out-of-tree plugins only: their screen.js loads as a classic
|
||||
// script and called esc() back when app.js was one too and it was an implicit
|
||||
// global. Nothing in core reads window.esc — import it from ./js/dom.js instead.
|
||||
Object.assign(window, {
|
||||
_confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl,
|
||||
_librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal,
|
||||
changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop,
|
||||
deleteSelectedLoop, exportDiagnostics, exportSettings, filterFavorites,
|
||||
deleteSelectedLoop, esc, exportDiagnostics, exportSettings, filterFavorites,
|
||||
filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput,
|
||||
hideScanBanner, importSettings, loadPlugins, loadSavedLoop,
|
||||
loadSettings, onSectionPracticeModeChange, openEditModal, persistSetting,
|
||||
|
||||
+71
-4
@@ -410,6 +410,51 @@ function _applyLibraryProviderToParams(params) {
|
||||
return params;
|
||||
}
|
||||
|
||||
// ── Instrument-aware tuning (the bass-player tuning-filter report) ───────────
|
||||
// A song's bass chart is often tuned differently from its guitar chart, so the
|
||||
// tuning facet, the `tunings` filter, the tuning sort and the row's tuning
|
||||
// badge must all speak for the instrument the player actually plays. Read the
|
||||
// host's working-tuning capability (the live selection, seeded from
|
||||
// /api/settings at boot) rather than adding another settings fetch; hosts
|
||||
// without the capability keep the guitar behaviour.
|
||||
const _LIB_PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass'];
|
||||
let _libSettingsProfile = '';
|
||||
|
||||
export function _setLibraryProfile(profileId) {
|
||||
_libSettingsProfile = _LIB_PERSPECTIVES.includes(profileId) ? profileId : '';
|
||||
}
|
||||
|
||||
export function _libraryInstrument() {
|
||||
// The PROFILE is the only three-valued source (lead / rhythm / bass); the
|
||||
// working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm,
|
||||
// so it is only the fallback.
|
||||
if (_libSettingsProfile) return _libSettingsProfile;
|
||||
try {
|
||||
const wt = window.feedBack?.workingTuning;
|
||||
if (wt && typeof wt.get === 'function') {
|
||||
const cur = wt.get();
|
||||
if (cur?.instrument === 'bass') return 'bass';
|
||||
}
|
||||
} catch { /* capability absent/erroring — lead guitar is the safe default */ }
|
||||
return 'guitar-lead';
|
||||
}
|
||||
|
||||
export function _libraryInstrumentLabel() {
|
||||
const p = _libraryInstrument();
|
||||
return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead';
|
||||
}
|
||||
|
||||
// The tuning a row should SHOW: the bass chart's for a bass player, falling
|
||||
// back to the song (guitar-derived) tuning when the song has no bass
|
||||
// arrangement — the common case, not an edge path.
|
||||
function _rowTuningRaw(song) {
|
||||
const p = _libraryInstrument();
|
||||
const field = p === 'bass' ? 'bass_tuning_name'
|
||||
: p === 'guitar-rhythm' ? 'rhythm_tuning_name' : '';
|
||||
if (field && song[field]) return song[field];
|
||||
return song.tuning || song.tuning_name || '';
|
||||
}
|
||||
|
||||
export function _resetLibraryProviderViewState() {
|
||||
L.libEpoch++;
|
||||
L.currentPage = 0;
|
||||
@@ -768,6 +813,8 @@ export function _applyLibFiltersToParams(params) {
|
||||
if (_libFilters.stemsLacks.length) params.set('stems_lacks', _libFilters.stemsLacks.join(','));
|
||||
if (_libFilters.lyrics !== null) params.set('has_lyrics', String(_libFilters.lyrics));
|
||||
if (_libFilters.tunings.length) params.set('tunings', _libFilters.tunings.join(','));
|
||||
// Which instrument's tuning the `tunings` filter + the tuning sort read.
|
||||
if (_libraryInstrument() !== 'guitar-lead') params.set('instrument', _libraryInstrument());
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -851,6 +898,7 @@ async function _renderTuningList() {
|
||||
c.innerHTML = '<div class="text-xs text-gray-500 px-2">Loading...</div>';
|
||||
try {
|
||||
const params = _applyLibraryProviderToParams(new URLSearchParams());
|
||||
params.set('instrument', _libraryInstrument());
|
||||
const resp = await fetch(`/api/library/tuning-names?${params}`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
@@ -869,6 +917,11 @@ async function _renderTuningList() {
|
||||
fetchError = e.message || 'request failed';
|
||||
}
|
||||
}
|
||||
// NAME the perspective: silent instrument-following is the original bug in
|
||||
// a new place — the user must be able to see which instrument these
|
||||
// tunings describe.
|
||||
const labelEl = document.getElementById('filter-tunings-label');
|
||||
if (labelEl) labelEl.textContent = `Tuning (${_libraryInstrumentLabel()})`;
|
||||
c.innerHTML = '';
|
||||
if (fetchError) {
|
||||
c.innerHTML = `<div class="text-xs text-red-400 px-2">Failed to load tunings (${esc(fetchError)}). Reopen the drawer to retry.</div>`;
|
||||
@@ -894,10 +947,17 @@ async function _renderTuningList() {
|
||||
const checked = _libFilters.tunings.includes(val);
|
||||
const row = document.createElement('label');
|
||||
row.className = 'tuning-row';
|
||||
// Be honest about the fallback: songs with no bass arrangement borrow
|
||||
// the guitar chart's tuning, and that must be visible rather than
|
||||
// presented as a measured bass tuning.
|
||||
const inferred = t.inferred_count || 0;
|
||||
if (inferred) {
|
||||
row.title = `${inferred} of ${t.count} inferred from the guitar chart (no bass arrangement)`;
|
||||
}
|
||||
row.innerHTML =
|
||||
`<input type="checkbox" ${checked ? 'checked' : ''} class="rounded border-gray-600 bg-dark-700 text-accent">` +
|
||||
`<span class="flex-1">${esc(label)}</span>` +
|
||||
`<span class="tuning-count">${t.count}</span>`;
|
||||
`<span class="tuning-count">${t.count}${inferred ? ` (${inferred}~)` : ''}</span>`;
|
||||
const cb = row.querySelector('input');
|
||||
cb.onchange = () => {
|
||||
const i = _libFilters.tunings.indexOf(val);
|
||||
@@ -1244,6 +1304,10 @@ export function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace
|
||||
const duration = song.duration ? formatTime(song.duration) : '';
|
||||
const tuningRaw = song.tuning || song.tuning_name || '';
|
||||
const tuning = displayTuningName(tuningRaw);
|
||||
// The BADGE follows the player's instrument; `tuning` above stays the
|
||||
// song's guitar-derived tuning because the retune action below rewrites
|
||||
// the chart to E Standard and must not key on the bass part.
|
||||
const tuningBadge = displayTuningName(_rowTuningRaw(song));
|
||||
const artUrl = _librarySongArtUrl(song, providerId);
|
||||
const isLocalProvider = _isLocalLibraryProvider(providerId);
|
||||
const isSloppak = song.format === 'sloppak';
|
||||
@@ -1299,7 +1363,7 @@ export function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-1.5 mt-3 text-xs">
|
||||
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
|
||||
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
|
||||
${tuningBadge ? `<span class="px-1.5 py-0.5 rounded ${tuningBadge === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuningBadge)}</span>` : ''}
|
||||
${song.has_lyrics ? `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>` : ''}
|
||||
${song.user_difficulty != null ? `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>` : ''}
|
||||
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
|
||||
@@ -1470,6 +1534,9 @@ export async function renderTreeInto(containerId, countId, stats, letter, q, fav
|
||||
const duration = song.duration ? formatTime(song.duration) : '';
|
||||
const tuningRaw = song.tuning || song.tuning_name || '';
|
||||
const tuning = displayTuningName(tuningRaw);
|
||||
// Badge follows the player's instrument; the retune action below
|
||||
// keeps operating on the song's guitar-derived tuning.
|
||||
const tuningBadge = displayTuningName(_rowTuningRaw(song));
|
||||
const isLocalProvider = _isLocalLibraryProvider(providerId);
|
||||
const isSloppak = song.format === 'sloppak';
|
||||
const stdRetune = isLocalProvider && localFilename && !isSloppak && tuningRaw && !song.has_estd &&
|
||||
@@ -1496,8 +1563,8 @@ export async function renderTreeInto(containerId, countId, stats, letter, q, fav
|
||||
{ const _nm = _getArrangementNamingMode();
|
||||
for (const arrangement of (song.arrangements || []))
|
||||
html += _arrangementBadgeHtml(arrangement, _nm); }
|
||||
if (tuning)
|
||||
html += `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>`;
|
||||
if (tuningBadge)
|
||||
html += `<span class="px-1.5 py-0.5 rounded ${tuningBadge === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuningBadge)}</span>`;
|
||||
if (song.has_lyrics)
|
||||
html += `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>`;
|
||||
if (song.user_difficulty != null)
|
||||
|
||||
+12
-3
@@ -638,9 +638,18 @@ export let artAbortController = null;
|
||||
export async function playSong(filename, arrangement, options) {
|
||||
console.log('playSong called:', filename);
|
||||
// A manual (non-queue) play abandons any active play-queue, so a stale queue
|
||||
// can't hijack the next song's end. The queue passes fromQueue to keep itself.
|
||||
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
|
||||
window.feedBack.playQueue.clear();
|
||||
// can't hijack the next song's end. The queue signals a play it is DRIVING
|
||||
// two ways: options.fromQueue (in-band) and _consumeInternalPlay() (out-of-
|
||||
// band). The out-of-band one exists because plugin playSong wrappers forward
|
||||
// only (filename, arrangement) and drop the options object — with just the
|
||||
// in-band flag, the queue cleared itself the instant its first song played
|
||||
// and a gig never advanced. Consume the flag whether or not we go on to clear,
|
||||
// so it can't leak into a later manual play.
|
||||
const _pq = window.feedBack && window.feedBack.playQueue;
|
||||
const _queueDriven = (options && options.fromQueue)
|
||||
|| (_pq && typeof _pq._consumeInternalPlay === 'function' && _pq._consumeInternalPlay());
|
||||
if (!_queueDriven && _pq) {
|
||||
_pq.clear();
|
||||
}
|
||||
if (!options || options.bridge !== false) {
|
||||
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// back-import would close a cycle. player-controls keeps reading it through the host seam, and
|
||||
// app.js — the root, which imports both — wires it. That is exactly what the seam is for.
|
||||
import { hwcInitSettingsUI } from './highway-colors.js';
|
||||
import { _getArrangementNamingMode } from './library.js';
|
||||
import { _getArrangementNamingMode, _setLibraryProfile } from './library.js';
|
||||
import {
|
||||
_applyMastery, _autoplayExitEnabled, _exitConfirmEnabled, _showUpNextEnabled,
|
||||
} from './player-controls.js';
|
||||
@@ -111,6 +111,10 @@ export async function loadSettings() {
|
||||
if (dlcEl) dlcEl.value = data.dlc_dir || '';
|
||||
_defaultArrangement = data.default_arrangement || '';
|
||||
_syncDefaultArrangementSelect(_defaultArrangement);
|
||||
// Feed the library its tuning PERSPECTIVE (lead / rhythm / bass) — the
|
||||
// tuning facet, filter, sort and badges all answer for the profile the
|
||||
// player actually plays.
|
||||
_setLibraryProfile(data.active_instrument_profile);
|
||||
const pathwayEl = document.getElementById('setting-instrument-pathway');
|
||||
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
|
||||
const demucsEl = document.getElementById('demucs-server-url');
|
||||
|
||||
@@ -326,7 +326,7 @@
|
||||
<section>
|
||||
<details>
|
||||
<summary class="cursor-pointer flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">
|
||||
<span>Tuning</span>
|
||||
<span id="filter-tunings-label">Tuning</span>
|
||||
<span id="filter-tunings-summary" class="text-gray-600 normal-case font-normal text-xs">All tunings</span>
|
||||
</summary>
|
||||
<div id="filter-tunings" class="mt-3 space-y-1 max-h-64 overflow-y-auto pr-1"></div>
|
||||
|
||||
+236
-4
@@ -53,12 +53,192 @@
|
||||
return (m && m.index != null) ? m.index : null;
|
||||
}
|
||||
|
||||
// ── Playlist tuning check ────────────────────────────────────────────────
|
||||
// Playlists are commonly grouped BY TUNING so a practice run needs no
|
||||
// retune mid-session (retuning a bass is minutes of settling, and detuning
|
||||
// far on standard gauges goes floppy). A playlist built before the tuning
|
||||
// filter knew about your instrument can hold songs you can't actually play
|
||||
// without stopping. This flags them. It is READ-ONLY: nothing here edits a
|
||||
// playlist — removal is a separate, explicit, itemised action.
|
||||
|
||||
// Pick the indexed perspective that matches the player's live instrument.
|
||||
// #1003 supplies bass-specific columns; when a song has no bass chart we
|
||||
// deliberately fall back to the historical song-level guitar tuning.
|
||||
function rowTuningForCheck(s) {
|
||||
let wantsBass = false;
|
||||
try {
|
||||
const wt = window.feedBack && window.feedBack.workingTuning;
|
||||
const cur = wt && typeof wt.get === 'function' ? wt.get() : null;
|
||||
wantsBass = !!cur && cur.instrument === 'bass';
|
||||
} catch (_) { /* capability errors degrade to the song-level tuning */ }
|
||||
const hasBassTuning = wantsBass && !!s.bass_tuning_offsets;
|
||||
return {
|
||||
offsets: hasBassTuning
|
||||
? s.bass_tuning_offsets : (s.tuning_offsets || s.tuning_name),
|
||||
// The selected bass perspective uses bass base pitches. A bass-only
|
||||
// fallback row does too; every other fallback is the lead chart.
|
||||
isBass: hasBassTuning || !!s.bass_only,
|
||||
};
|
||||
}
|
||||
// A coverage report says "not covered" BOTH for a real mismatch and for
|
||||
// "I couldn't work it out" (missing settings/tuner data → an all-empty
|
||||
// report). Only a report carrying an actual reason — named string changes,
|
||||
// a reference-pitch gap, or too few strings — is a mismatch. An unexplained
|
||||
// not-covered is UNKNOWN. A false "wrong tuning" on a hand-curated playlist
|
||||
// costs more trust than saying nothing.
|
||||
function tuningStateFromReport(rep) {
|
||||
if (!rep) return 'unknown';
|
||||
if (rep.covered) return 'match';
|
||||
if (rep.cantCover || rep.reference
|
||||
|| (Array.isArray(rep.retune) && rep.retune.length)) return 'mismatch';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// Score every row. Returns null when the host exposes no tuning perspective
|
||||
// at all (no working-tuning capability / no tuner coverage) — the caller
|
||||
// then renders the playlist exactly as before rather than claiming anything.
|
||||
async function checkPlaylistTuning(songs) {
|
||||
const cov = window._tunerAutoOpen && window._tunerAutoOpen.coverageReport;
|
||||
const hasWT = window.feedBack && window.feedBack.workingTuning
|
||||
&& typeof window.feedBack.workingTuning.get === 'function';
|
||||
if (typeof cov !== 'function' || !hasWT) return null;
|
||||
const parse = window.parseRawTuningOffsets;
|
||||
const out = [];
|
||||
for (const s of songs || []) {
|
||||
const t = rowTuningForCheck(s);
|
||||
const offs = (typeof parse === 'function') ? parse(t.offsets) : null;
|
||||
if (!offs || !offs.length || offs.some((n) => !isFinite(n))) {
|
||||
out.push({ song: s, state: 'unknown' });
|
||||
continue;
|
||||
}
|
||||
let rep = null;
|
||||
try {
|
||||
rep = await cov({
|
||||
tuning: offs, stringCount: offs.length,
|
||||
arrangement: t.isBass ? 'Bass' : 'Lead',
|
||||
});
|
||||
} catch (_) { rep = null; }
|
||||
out.push({ song: s, state: tuningStateFromReport(rep) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Colour + a TEXT marker per state — unknown is deliberately neutral-and-
|
||||
// dimmed rather than amber, because "I couldn't check this" is a different
|
||||
// claim from "this is the wrong tuning" and must not read as the latter.
|
||||
function paintTuningChip(chip, state) {
|
||||
if (!chip) return;
|
||||
if (chip.dataset.baseTitle == null) chip.dataset.baseTitle = chip.getAttribute('title') || '';
|
||||
chip.classList.remove('bg-fb-mid', 'bg-emerald-500', 'bg-amber-400', 'opacity-60');
|
||||
chip.classList.add(state === 'match' ? 'bg-emerald-500'
|
||||
: state === 'mismatch' ? 'bg-amber-400' : 'bg-fb-mid');
|
||||
if (state === 'unknown') chip.classList.add('opacity-60');
|
||||
chip.setAttribute('title', chip.dataset.baseTitle + (state === 'match'
|
||||
? ' — matches your tuning'
|
||||
: state === 'mismatch' ? ' — needs a retune'
|
||||
: ' — no tuning data, not checked'));
|
||||
// Never signal by colour alone.
|
||||
const mark = state === 'mismatch' ? ' ⚠' : state === 'unknown' ? ' ?' : '';
|
||||
let m = chip.querySelector('[data-tuning-mark]');
|
||||
if (!m) {
|
||||
m = document.createElement('span');
|
||||
m.setAttribute('data-tuning-mark', '');
|
||||
chip.appendChild(m);
|
||||
}
|
||||
m.textContent = mark;
|
||||
}
|
||||
|
||||
function tuningSummaryHtml(results) {
|
||||
const total = results.length;
|
||||
if (!total) return '';
|
||||
const mism = results.filter((r) => r.state === 'mismatch').length;
|
||||
const unk = results.filter((r) => r.state === 'unknown').length;
|
||||
// Plain gap-3 rather than gap-x-3/gap-y-2: the axis-specific pair isn't
|
||||
// in the committed tailwind.min.css, and regenerating it is not
|
||||
// reproducible outside CI (autoprefixer/caniuse drift changes unrelated
|
||||
// bytes), so the summary bar stays within the shipped class set.
|
||||
const box = 'mb-4 rounded-lg border px-3 py-2 text-sm flex flex-wrap items-center gap-3 ';
|
||||
if (!mism) {
|
||||
return '<div class="' + box + 'border-fb-good/40 bg-fb-good/30 text-fb-good">' +
|
||||
'<span>✓ All ' + total + ' songs are in your tuning.</span>' +
|
||||
(unk ? '<span class="text-fb-textDim text-xs">' + unk + ' couldn\'t be checked (no tuning data).</span>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
return '<div class="' + box + 'border-amber-400/40 bg-amber-400/10 text-fb-text">' +
|
||||
'<span><strong>' + mism + '</strong> of ' + total + ' songs aren\'t in your tuning.</span>' +
|
||||
(unk ? '<span class="text-fb-textDim text-xs">' + unk + ' couldn\'t be checked (no tuning data) — left alone.</span>' : '') +
|
||||
'<span class="flex-1"></span>' +
|
||||
'<button id="v3-pl-tune-only" class="text-xs px-2 py-1 rounded border border-fb-border text-fb-textDim hover:text-fb-text" aria-pressed="false">Show only these</button>' +
|
||||
'<button id="v3-pl-tune-remove" class="text-xs px-2 py-1 rounded border border-amber-400/40 text-fb-text hover:bg-fb-card">Remove them…</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Run the check and wire its affordances. Read-only: the only mutation is
|
||||
// the explicit, itemised, confirmed removal below.
|
||||
async function applyTuningCheck(root, pl, pid, rerender) {
|
||||
const host = root.querySelector('#v3-pl-tuning');
|
||||
const listEl = root.querySelector('#v3-pl-songs');
|
||||
if (!host || !listEl) return;
|
||||
const results = await checkPlaylistTuning(pl.songs);
|
||||
if (!results) return; // no perspective → say nothing
|
||||
const rows = listEl.querySelectorAll('li[data-fn]');
|
||||
results.forEach((r, i) => {
|
||||
const li = rows[i];
|
||||
if (!li) return;
|
||||
li.setAttribute('data-tuning-state', r.state);
|
||||
paintTuningChip(li.querySelector('[data-tuning-chip]'), r.state);
|
||||
});
|
||||
host.innerHTML = tuningSummaryHtml(results);
|
||||
|
||||
const onlyBtn = host.querySelector('#v3-pl-tune-only');
|
||||
onlyBtn?.addEventListener('click', () => {
|
||||
const on = onlyBtn.getAttribute('aria-pressed') !== 'true';
|
||||
onlyBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
onlyBtn.textContent = on ? 'Show all' : 'Show only these';
|
||||
rows.forEach((li) => {
|
||||
li.classList.toggle('hidden', on && li.getAttribute('data-tuning-state') !== 'mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
host.querySelector('#v3-pl-tune-remove')?.addEventListener('click', async () => {
|
||||
// Name every song BEFORE removing anything — a curated playlist is
|
||||
// user data, so the confirm has to be a list, not a count.
|
||||
const doomed = results.filter((r) => r.state === 'mismatch').map((r) => r.song);
|
||||
if (!doomed.length) return;
|
||||
const names = doomed.map((s) => '<div>• ' + esc(s.title || s.filename) + '</div>').join('');
|
||||
const msg = 'Remove these ' + doomed.length + ' song' + (doomed.length === 1 ? '' : 's')
|
||||
+ ' from "' + esc(pl.name) + '"?'
|
||||
// Bulleted with a literal •, and sized with max-h-32, so the
|
||||
// confirm needs no Tailwind class the committed CSS lacks —
|
||||
// regenerating tailwind.min.css is not reproducible off CI.
|
||||
+ '<div class="mt-2 text-xs max-h-32 overflow-y-auto">' + names + '</div>'
|
||||
+ '<p class="text-xs text-fb-textDim mt-2">They stay in your library — only this playlist changes, and you can add them back.</p>';
|
||||
const ok = (typeof window.uiConfirm === 'function')
|
||||
? await window.uiConfirm({
|
||||
title: 'Remove mismatched songs?', html: msg,
|
||||
confirmText: 'Remove ' + doomed.length, cancelText: 'Cancel', danger: true,
|
||||
})
|
||||
: window.confirm('Remove ' + doomed.length + ' song(s) from "' + pl.name + '"?\n\n'
|
||||
+ doomed.map((s) => '• ' + (s.title || s.filename)).join('\n')
|
||||
+ '\n\nThey stay in your library.');
|
||||
if (!ok) return;
|
||||
for (const s of doomed) {
|
||||
await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(s.filename),
|
||||
{ method: 'DELETE' });
|
||||
}
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
|
||||
function songRow(s, opts) {
|
||||
opts = opts || {};
|
||||
const handle = opts.draggable
|
||||
? '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>' : '';
|
||||
// The chip carries its own tuning so the post-paint check can colour it
|
||||
// in place (green = play it now, amber = needs a retune, dimmed ? =
|
||||
// couldn't tell) without re-rendering the list.
|
||||
const tuning = s.tuning_name
|
||||
? '<span class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
|
||||
? '<span data-tuning-chip class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm" title="' + esc(s.tuning_name) + '">' + esc(s.tuning_name) + '</span>' : '';
|
||||
// ── Curated-album slot extras (P6) — mixes/saved emit none of this ──
|
||||
// A slot plays its RESOLVED chart (data-play-fn: the pinned file, or
|
||||
// the work's current keeper when the pinned file is gone) with its
|
||||
@@ -144,19 +324,29 @@
|
||||
const root = document.getElementById('v3-playlists');
|
||||
if (!root) return;
|
||||
const lists = (await jget('/api/playlists')) || [];
|
||||
// Drag-to-reorder is for user playlists only — system ones (Saved for
|
||||
// Later) stay pinned first by the server ordering.
|
||||
const userCount = lists.filter((p) => !p.system_key).length;
|
||||
root.innerHTML =
|
||||
'<div class="max-w-5xl mx-auto px-6 md:px-8 pb-8">' +
|
||||
'<div class="flex items-center justify-end gap-2 mb-6">' +
|
||||
// Sort A–Z: clears the manual (drag) order server-side. Only worth
|
||||
// showing once there are two user playlists to order.
|
||||
(userCount > 1
|
||||
? '<button id="v3-pl-sort-az" title="Sort playlists alphabetically (clears manual order)" class="text-sm text-fb-textDim hover:text-fb-text px-2">Sort A–Z</button>' : '') +
|
||||
// Curated album (P6): a hand-picked ORDERED set with a chosen chart
|
||||
// per track — same machinery as a playlist, kind='album'.
|
||||
'<button id="v3-pl-new-album" title="A hand-picked, ordered set of songs — your version of an album, with your chosen chart per track" class="bg-fb-card/80 hover:bg-fb-card border border-fb-border/60 text-fb-text px-4 py-2 rounded-md text-sm font-medium">💿 New album</button>' +
|
||||
'<button id="v3-pl-new" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm font-medium shadow-lg shadow-fb-primary/20">New playlist</button>' +
|
||||
'</div>' +
|
||||
(lists.length
|
||||
? '<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
|
||||
'<button data-pl="' + p.id + '" class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
|
||||
? '<div id="v3-pl-grid" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
|
||||
'<button data-pl="' + p.id + '"' + (p.system_key ? '' : ' draggable="true"') + ' class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
|
||||
playlistCoverHtml(p) +
|
||||
'<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' +
|
||||
'<div class="flex items-center gap-1">' +
|
||||
'<span class="flex-1 min-w-0 text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</span>' +
|
||||
(p.system_key ? '' : '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>') +
|
||||
'</div>' +
|
||||
'<div class="text-xs text-fb-textDim">' + (p.kind === 'album' ? '💿 Album · ' : '') + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
|
||||
'</button>').join('') + '</div>'
|
||||
: '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') +
|
||||
@@ -173,8 +363,44 @@
|
||||
await jsend('POST', '/api/playlists', { name, kind: 'album' });
|
||||
renderPlaylists();
|
||||
});
|
||||
root.querySelector('#v3-pl-sort-az')?.addEventListener('click', async () => {
|
||||
await jsend('POST', '/api/playlists/sort-alpha');
|
||||
renderPlaylists();
|
||||
});
|
||||
root.querySelectorAll('[data-pl]').forEach((b) =>
|
||||
b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10))));
|
||||
// Drag-reorder of the playlist cards themselves (mirrors wireSongRows).
|
||||
// Only user playlists carry draggable="true"; system cards are neither
|
||||
// drag sources nor drop targets, so nothing can be inserted ahead of
|
||||
// them (and the server pins them first regardless).
|
||||
const grid = root.querySelector('#v3-pl-grid');
|
||||
if (grid) {
|
||||
let dragEl = null;
|
||||
grid.querySelectorAll('button[data-pl][draggable="true"]').forEach((card) => {
|
||||
card.addEventListener('dragstart', () => { dragEl = card; card.classList.add('opacity-50'); });
|
||||
card.addEventListener('dragend', () => { card.classList.remove('opacity-50'); });
|
||||
card.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (!dragEl || dragEl === card) return;
|
||||
// Grid tiles flow left→right then wrap, so the insert side
|
||||
// is horizontal (the song rows' vertical-midpoint idiom,
|
||||
// rotated); moving to another row targets that row's cards.
|
||||
const rect = card.getBoundingClientRect();
|
||||
const after = (e.clientX - rect.left) > rect.width / 2;
|
||||
card.parentNode.insertBefore(dragEl, after ? card.nextSibling : card);
|
||||
});
|
||||
card.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
const order = Array.from(grid.querySelectorAll('button[data-pl][draggable="true"]'))
|
||||
.map((x) => parseInt(x.getAttribute('data-pl'), 10));
|
||||
await jsend('POST', '/api/playlists/reorder', { order });
|
||||
// Re-sync from the server: if /reorder was rejected
|
||||
// (concurrent change) or the request failed, the optimistic
|
||||
// DOM order would otherwise diverge from what persisted.
|
||||
renderPlaylists();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPlaylistDetail(pid) {
|
||||
@@ -226,6 +452,9 @@
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
meter +
|
||||
// Filled in after paint by applyTuningCheck (async, feature-detected)
|
||||
// — stays empty when the host exposes no tuning perspective.
|
||||
(pl.songs.length ? '<div id="v3-pl-tuning"></div>' : '') +
|
||||
(pl.songs.length
|
||||
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem, album: isAlbum, acc: isAlbum ? slotAcc(s) : undefined })).join('') + '</ul>'
|
||||
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
|
||||
@@ -275,6 +504,9 @@
|
||||
});
|
||||
const listEl = root.querySelector('#v3-pl-songs');
|
||||
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
|
||||
// Post-paint so the list is interactive immediately; a per-song coverage
|
||||
// call can await the tuner plugin's settings fetch.
|
||||
if (listEl) applyTuningCheck(root, pl, pid, () => renderPlaylistDetail(pid));
|
||||
// Album slot editor (▾ per row): pick the slot's chart + arrangement.
|
||||
if (listEl && isAlbum) {
|
||||
listEl.querySelectorAll('li[data-fn]').forEach((li) => {
|
||||
|
||||
+199
-20
@@ -62,7 +62,7 @@
|
||||
artist: '', album: '',
|
||||
grouping: true, // one card per song (multi-chart grouping); persisted
|
||||
|
||||
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [] },
|
||||
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' },
|
||||
page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [], genres: [],
|
||||
artistCatalog: [], renderedHash: '',
|
||||
scrollBound: false,
|
||||
@@ -120,7 +120,7 @@
|
||||
function activeFilterCount() {
|
||||
const f = state.filters;
|
||||
return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length +
|
||||
(f.lyrics ? 1 : 0) + f.tunings.length + (f.mastery ? f.mastery.length : 0) +
|
||||
(f.lyrics ? 1 : 0) + (f.tuningMatch === 'playable' ? 1 : f.tunings.length) + (f.mastery ? f.mastery.length : 0) +
|
||||
(f.match ? f.match.length : 0) + (f.genre ? f.genre.length : 0) +
|
||||
(state.artist ? 1 : 0) + (state.album ? 1 : 0);
|
||||
}
|
||||
@@ -280,7 +280,15 @@
|
||||
if (f.stem_has.length) p.set('stems_has', f.stem_has.join(','));
|
||||
if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(','));
|
||||
if (f.lyrics) p.set('has_lyrics', f.lyrics);
|
||||
if (f.tunings.length) p.set('tunings', f.tunings.join(','));
|
||||
// The two modes answer different questions, so only one filters at a
|
||||
// time: sending both would silently intersect them.
|
||||
if (f.tunings.length && f.tuningMatch !== 'playable') p.set('tunings', f.tunings.join(','));
|
||||
// Which perspective the `tunings` filter + the tuning sort read.
|
||||
if (libInstrument() !== 'guitar-lead') p.set('instrument', libInstrument());
|
||||
// "Playable without retuning": send the player's LIVE tuning and let the
|
||||
// server do the pitch maths (the pitch tables live in lib/tunings.py —
|
||||
// duplicating them here is how the two drift apart).
|
||||
applyPlayableParams(p, f);
|
||||
if (f.mastery && f.mastery.length) p.set('mastery', f.mastery.join(','));
|
||||
if (f.match && f.match.length) p.set('match', f.match.join(','));
|
||||
if (f.genre && f.genre.length) p.set('genre', f.genre.join(','));
|
||||
@@ -786,6 +794,10 @@
|
||||
// so without them the chips render exactly as before. Decoration runs AFTER the
|
||||
// (sync) window paint so scrolling stays snappy; a token cancels a superseded pass.
|
||||
let _tuningDecorToken = 0;
|
||||
// The instrument the current grid was queried/painted for, so a
|
||||
// working-tuning change can tell a guitar<->bass SWITCH (re-query) from a
|
||||
// retune within the same instrument (re-colour only).
|
||||
let _lastRenderInstrument = null;
|
||||
function _applyChipMatch(chip, stateName) {
|
||||
chip.classList.remove('bg-fb-mid', 'bg-emerald-500', 'bg-amber-400');
|
||||
chip.classList.add(stateName === 'match' ? 'bg-emerald-500'
|
||||
@@ -825,22 +837,31 @@
|
||||
const shown = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
|
||||
// In select mode the checkbox occupies top-2 left-2, so shift the
|
||||
// tuning chip right (left-9) to avoid overlapping it.
|
||||
// Bass players see the bass chart's tuning (guitar fallback) — the card
|
||||
// must agree with the facet/filter or the grid contradicts the pills.
|
||||
const shownTuning = shownTuningName(shown);
|
||||
const tuningLabel = (typeof window.displayTuningName === 'function')
|
||||
? window.displayTuningName(shown.tuning_name || shown.tuning)
|
||||
: (shown.tuning_name || '');
|
||||
? window.displayTuningName(shownTuning)
|
||||
: (shownTuning || '');
|
||||
let tuning = '';
|
||||
if (tuningLabel) {
|
||||
const rawOffsets = (typeof window.parseRawTuningOffsets === 'function')
|
||||
? (window.parseRawTuningOffsets(shown.tuning_offsets)
|
||||
|| window.parseRawTuningOffsets(shown.tuning_name || shown.tuning))
|
||||
? (window.parseRawTuningOffsets(shownTuningOffsets(shown))
|
||||
|| window.parseRawTuningOffsets(shownTuning))
|
||||
: null;
|
||||
const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets
|
||||
&& typeof window.displayTuningTargets === 'function')
|
||||
? window.displayTuningTargets(rawOffsets, { tuningName: tuningLabel })
|
||||
: '';
|
||||
const badgeTitle = targetNotes
|
||||
// Mark a tuning we INFERRED from the guitar chart (this song has no
|
||||
// bass arrangement) so a bass player isn't shown a borrowed tuning
|
||||
// as if it were their part's. `~` keeps the chip compact; the title
|
||||
// spells it out.
|
||||
const inferred = shown.tuning_inferred === true;
|
||||
const badgeTitle = (targetNotes
|
||||
? ('Custom Tuning: ' + targetNotes)
|
||||
: tuningLabel;
|
||||
: tuningLabel)
|
||||
+ (inferred ? ' — from the guitar chart (no bass arrangement)' : '');
|
||||
const pos = 'absolute top-2 ' + (state.selectMode ? 'left-9' : 'left-2');
|
||||
// Tag the chip with its offsets so decorateTuningChips() can colour it
|
||||
// green (matches your current tuning) / amber (needs a retune) after paint.
|
||||
@@ -848,8 +869,14 @@
|
||||
// scores its bass tuning against the bass base pitches, not guitar — otherwise
|
||||
// a 4-string bass tuning read as guitar can false-match a guitar player.
|
||||
const chipArrs = shown.arrangements || [];
|
||||
const chipIsBass = chipArrs.length > 0
|
||||
&& chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || ''));
|
||||
// Bass either because the chip is SHOWING the bass chart's tuning
|
||||
// (a bass player on a song that has one), or because every
|
||||
// arrangement is a bass part. Checked via libInstrument() rather
|
||||
// than comparing the two names — they are EQUAL for most songs, so
|
||||
// a value comparison would flag a guitarist's chip as bass.
|
||||
const chipIsBass = (libInstrument() === 'bass' && !!shown.bass_tuning_name)
|
||||
|| (chipArrs.length > 0
|
||||
&& chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || '')));
|
||||
const matchAttr = (rawOffsets && rawOffsets.length)
|
||||
? ' data-tuning-chip data-tuning-offsets="' + esc(rawOffsets.join(',')) + '"'
|
||||
+ (chipIsBass ? ' data-tuning-bass="1"' : '') : '';
|
||||
@@ -857,7 +884,7 @@
|
||||
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight max-w-[5.5rem] text-center"' + matchAttr + ' title="' + esc(badgeTitle) + '">'
|
||||
+ esc('Custom Tuning') + '<br><span class="font-semibold tracking-wide">' + esc(targetNotes) + '</span></span>';
|
||||
} else {
|
||||
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[0.625rem] font-bold px-1.5 py-0.5 rounded-sm"' + matchAttr + ' title="' + esc(badgeTitle) + '">' + esc(tuningLabel) + '</span>';
|
||||
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[0.625rem] font-bold px-1.5 py-0.5 rounded-sm"' + matchAttr + ' title="' + esc(badgeTitle) + '">' + esc(tuningLabel) + (inferred ? '<span class="opacity-60"> ~</span>' : '') + '</span>';
|
||||
}
|
||||
}
|
||||
// Display-only (pointer-events-none) so a click falls through to the
|
||||
@@ -2489,6 +2516,89 @@
|
||||
|
||||
function _artistHostEl() { return document.getElementById('v3-songs-artistpage'); }
|
||||
|
||||
// ── Instrument-aware tuning (the bass-player tuning-filter report) ────────
|
||||
// A song's bass chart is often in a different tuning from its guitar chart,
|
||||
// so the tuning facet/filter/sort and the card chip must speak for the
|
||||
// instrument the player actually plays. The host's working-tuning
|
||||
// capability already holds the live selection (seeded from /api/settings on
|
||||
// boot, updated when the player switches) — read it rather than adding
|
||||
// another settings fetch. `state.settingsInstrument` is the fallback for
|
||||
// hosts where the capability isn't mounted.
|
||||
// Three perspectives, matching `active_instrument_profile`: lead and rhythm
|
||||
// guitar charts can be tuned differently too, so a rhythm player hits the
|
||||
// same bug a bassist did. The PROFILE is the only three-valued source (the
|
||||
// working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm),
|
||||
// so it wins; the capability is the live fallback for hosts where the
|
||||
// profile hasn't loaded.
|
||||
const PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass'];
|
||||
function libInstrument() {
|
||||
if (PERSPECTIVES.indexOf(state.settingsProfile) >= 0) return state.settingsProfile;
|
||||
try {
|
||||
const wt = window.feedBack && window.feedBack.workingTuning;
|
||||
if (wt && typeof wt.get === 'function') {
|
||||
const cur = wt.get();
|
||||
if (cur && cur.instrument === 'bass') return 'bass';
|
||||
}
|
||||
} catch (_) { /* capability absent/erroring — fall through to settings */ }
|
||||
return state.settingsInstrument === 'bass' ? 'bass' : 'guitar-lead';
|
||||
}
|
||||
|
||||
// "Playable without retuning" mode reads the player's CURRENT tuning from
|
||||
// the working-tuning capability (the live session state the tuner writes),
|
||||
// not a separate setting. No capability => we cannot know the current
|
||||
// tuning, so the mode is unavailable rather than guessed.
|
||||
function currentWorkingTuning() {
|
||||
try {
|
||||
const wt = window.feedBack && window.feedBack.workingTuning;
|
||||
if (!wt || typeof wt.get !== 'function') return null;
|
||||
const cur = wt.get();
|
||||
if (!cur || !Array.isArray(cur.offsets) || !cur.offsets.length) return null;
|
||||
return cur;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function playableAvailable() { return !!currentWorkingTuning(); }
|
||||
|
||||
function applyPlayableParams(p, f) {
|
||||
if (f.tuningMatch !== 'playable') return;
|
||||
const cur = currentWorkingTuning();
|
||||
if (!cur) return;
|
||||
p.set('tuning_match', 'playable');
|
||||
p.set('playable_offsets', cur.offsets.join(','));
|
||||
p.set('playable_instrument', cur.instrument === 'bass' ? 'bass' : 'guitar');
|
||||
p.set('playable_string_count', String(cur.stringCount || cur.offsets.length));
|
||||
}
|
||||
|
||||
// Short human label for the perspective, for the facet/sort headers.
|
||||
function libInstrumentLabel() {
|
||||
const p = libInstrument();
|
||||
return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead';
|
||||
}
|
||||
|
||||
// The column a row's tuning lives in for the active perspective.
|
||||
function perspectiveTuningField() {
|
||||
const p = libInstrument();
|
||||
return p === 'bass' ? 'bass_tuning_name'
|
||||
: p === 'guitar-rhythm' ? 'rhythm_tuning_name' : '';
|
||||
}
|
||||
|
||||
// The tuning a card should SHOW: bass players see the bass chart's tuning,
|
||||
// falling back to the song (guitar-derived) tuning when the song has no
|
||||
// bass arrangement — the common case, so the fallback is not an edge path.
|
||||
function shownTuningName(song) {
|
||||
const f = perspectiveTuningField();
|
||||
if (f && song[f]) return song[f];
|
||||
return song.tuning_name || song.tuning;
|
||||
}
|
||||
|
||||
function shownTuningOffsets(song) {
|
||||
const f = perspectiveTuningField();
|
||||
if (f && song[f]) {
|
||||
return song[f.replace('_name', '_offsets')] || song.tuning_offsets;
|
||||
}
|
||||
return song.tuning_offsets;
|
||||
}
|
||||
|
||||
// Sync the two Settings gates into module state (fire-and-forget — the
|
||||
// cached flags gate entry-point rendering; openArtistPage re-checks).
|
||||
function refreshArtistPageGates() {
|
||||
@@ -2496,6 +2606,10 @@
|
||||
if (!cfg) return;
|
||||
state.artistPagesEnabled = cfg.artist_pages_enabled !== false;
|
||||
state.artistLinksEnabled = cfg.artist_external_links === true;
|
||||
// Fallback instrument for hosts without the working-tuning capability.
|
||||
state.settingsInstrument = cfg.instrument === 'bass' ? 'bass' : 'guitar';
|
||||
// The three-valued perspective source (lead / rhythm / bass).
|
||||
state.settingsProfile = cfg.active_instrument_profile || '';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2809,12 +2923,13 @@
|
||||
else if (s === 'has') lacksArr.push(value);
|
||||
// 'lacks' → cycles back to any (already removed)
|
||||
}
|
||||
function triPill(group, value, label, st) {
|
||||
function triPill(group, value, label, st, title) {
|
||||
const cls = st === 'has' ? 'bg-fb-good/30 text-fb-good border-fb-good/40'
|
||||
: st === 'lacks' ? 'bg-fb-low/30 text-fb-low border-fb-low/40'
|
||||
: 'bg-gray-800/50 text-fb-textDim border-gray-700';
|
||||
const mark = st === 'has' ? '✓ ' : st === 'lacks' ? '✕ ' : '';
|
||||
return '<button data-tri="' + group + '" data-val="' + esc(value) + '" class="px-2 py-1 rounded-md text-xs border ' + cls + '">' + mark + esc(label) + '</button>';
|
||||
const tip = title ? ' title="' + esc(title) + '"' : '';
|
||||
return '<button data-tri="' + group + '" data-val="' + esc(value) + '" class="px-2 py-1 rounded-md text-xs border ' + cls + '"' + tip + '>' + mark + esc(label) + '</button>';
|
||||
}
|
||||
function renderDrawer() {
|
||||
const d = document.getElementById('v3-songs-drawer');
|
||||
@@ -2834,7 +2949,32 @@
|
||||
section('Match', [['review', 'To review'], ['matched', 'Matched'], ['unmatched', 'Unmatched'], ['pending', 'Not scanned']].map((it) => '<button data-match="' + it[0] + '" class="px-2 py-1 rounded-md text-xs border ' + (f.match.includes(it[0]) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + it[1] + '</button>').join('')) +
|
||||
// Genre facet — dynamic list from /api/library/genres (primary genre).
|
||||
(state.genres && state.genres.length ? section('Genre', state.genres.map((g) => '<button data-genre="' + esc(g) + '" class="px-2 py-1 rounded-md text-xs border ' + (f.genre.includes(g) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + esc(g) + '</button>').join('')) : '') +
|
||||
section('Tuning', (state.tuningNames || []).map((t) => {
|
||||
// The facet header NAMES the perspective. Silent instrument-following
|
||||
// is the original bug in a new place: the user must be able to tell
|
||||
// which instrument these tunings describe.
|
||||
section('Tuning (' + libInstrumentLabel() + ')',
|
||||
// MODE toggle. Exact match answers "which tuning is this
|
||||
// labelled"; Playable answers "will this cost me a retune" —
|
||||
// which is what a player actually wants. Both are offered;
|
||||
// exact stays the default so nothing changes unasked.
|
||||
'<div class="flex gap-1 mb-2">'
|
||||
+ [['exact', 'Exact tuning'], ['playable', 'Playable without retuning']].map((m) => {
|
||||
const on = (f.tuningMatch || 'exact') === m[0];
|
||||
const dis = m[0] === 'playable' && !playableAvailable();
|
||||
return '<button data-tuning-match="' + m[0] + '"'
|
||||
+ (dis ? ' disabled' : '')
|
||||
+ (dis ? ' title="Needs your current tuning — open the tuner first"' : '')
|
||||
+ ' class="px-2 py-1 rounded-md text-xs border '
|
||||
+ (on ? 'bg-fb-primary text-white border-fb-primary'
|
||||
: 'bg-gray-800/50 text-fb-textDim border-gray-700')
|
||||
+ (dis ? ' opacity-40 cursor-not-allowed' : '') + '">'
|
||||
+ esc(m[1]) + '</button>';
|
||||
}).join('')
|
||||
+ '</div>'
|
||||
+ (f.tuningMatch === 'playable'
|
||||
? '<div class="text-xs text-fb-textDim mb-2">Charts you can play in your current tuning, no retune. Songs whose lowest string sits below yours are excluded.</div>'
|
||||
: '')
|
||||
+ ((state.tuningNames || []).map((t) => {
|
||||
// Filter on the server's grouping key (raw offsets for customs)
|
||||
// so two "Custom Tuning" entries are distinct; show their target
|
||||
// notes in the label so they're distinguishable.
|
||||
@@ -2847,8 +2987,17 @@
|
||||
const notes = offs ? window.displayTuningTargets(offs, { tuningName: t.name }) : '';
|
||||
if (notes) label = 'Custom · ' + notes;
|
||||
}
|
||||
return triPill('tuning', val, label + ' (' + t.count + ')', f.tunings.includes(val) ? 'has' : 'any');
|
||||
}).join('') || '<span class="text-xs text-fb-textDim">No tunings</span>') +
|
||||
// Be honest about the fallback: when some of a row's songs have
|
||||
// no bass chart and are borrowing the guitar tuning, say so
|
||||
// rather than presenting a borrowed tuning as a measured one.
|
||||
const inf = t.inferred_count || 0;
|
||||
const title = inf
|
||||
? inf + ' of ' + t.count + ' inferred from the guitar chart (no bass arrangement)'
|
||||
: '';
|
||||
const countLabel = inf ? t.count + ', ' + inf + ' inferred' : String(t.count);
|
||||
return triPill('tuning', val, label + ' (' + countLabel + ')',
|
||||
f.tunings.includes(val) ? 'has' : 'any', title);
|
||||
}).join('') || '<span class="text-xs text-fb-textDim">No tunings</span>')) +
|
||||
// Multi-chart grouping toggle (P5e) — a VIEW mode, not a filter
|
||||
// (never counted in the badge, never saved into collection rules).
|
||||
// Local provider only: it's the one that implements group=.
|
||||
@@ -2878,6 +3027,11 @@
|
||||
else if (g === 'tuning') { const i = f.tunings.indexOf(v); if (i >= 0) f.tunings.splice(i, 1); else f.tunings.push(v); }
|
||||
renderDrawer();
|
||||
}));
|
||||
d.querySelectorAll('[data-tuning-match]').forEach((b) => b.addEventListener('click', () => {
|
||||
if (b.disabled) return;
|
||||
f.tuningMatch = b.getAttribute('data-tuning-match');
|
||||
renderDrawer();
|
||||
}));
|
||||
d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); }));
|
||||
d.querySelectorAll('[data-mastery]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-mastery'); const i = f.mastery.indexOf(v); if (i >= 0) f.mastery.splice(i, 1); else f.mastery.push(v); renderDrawer(); }));
|
||||
d.querySelector('[data-grouping]')?.addEventListener('click', () => {
|
||||
@@ -2891,7 +3045,7 @@
|
||||
d.querySelector('[data-drawer-tidy]')?.addEventListener('click', openArtistTidyUp);
|
||||
d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
|
||||
d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => {
|
||||
state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [] };
|
||||
state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' };
|
||||
state.artist = '';
|
||||
state.album = '';
|
||||
renderDrawer();
|
||||
@@ -3478,13 +3632,15 @@
|
||||
const providers = await loadProviders();
|
||||
const [, tn] = await Promise.all([
|
||||
(async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(),
|
||||
jget('/api/library/tuning-names?provider=' + enc(state.provider)),
|
||||
jget('/api/library/tuning-names?provider=' + enc(state.provider)
|
||||
+ '&instrument=' + enc(libInstrument())),
|
||||
loadArtistCatalog(),
|
||||
// Artist-page gates (PR-B) ride the initial fetch batch so the
|
||||
// first card paint already knows whether artist lines are links.
|
||||
refreshArtistPageGates(),
|
||||
]);
|
||||
state.tuningNames = (tn && tn.tunings) || [];
|
||||
_lastRenderInstrument = libInstrument();
|
||||
try { const _g = await jget('/api/library/genres?provider=' + enc(state.provider)); state.genres = (_g && _g.genres) || []; } catch (e) { state.genres = []; }
|
||||
|
||||
const opt = (arr, sel) => arr.map(([v, l]) => '<option value="' + esc(v) + '"' + (v === sel ? ' selected' : '') + '>' + esc(l) + '</option>').join('');
|
||||
@@ -3512,7 +3668,13 @@
|
||||
'<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
|
||||
'<select id="v3-songs-album" class="' + ctrl + ' max-w-[11rem]" aria-label="Album"' + (state.artist ? '' : ' disabled') + '>' + albumSelectHtml() + '</select>' +
|
||||
'<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button><button id="v3-songs-albums-btn" title="Albums" class="px-3 py-2 text-sm">💿</button><button id="v3-songs-folder-btn" class="px-3 py-2 text-sm" style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:2.25rem"><svg fill="currentColor" viewBox="0 0 16 16" style="width:12px;height:12px;flex-shrink:0"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg></button></div>' +
|
||||
'<select id="v3-songs-sort" class="' + ctrl + '">' + opt(SORTS, state.sort) + '</select>' +
|
||||
// Name the perspective on the SORT too, not just the filter: tuning
|
||||
// sort orders by musical distance from standard, and for a bass
|
||||
// player that distance is measured on the bass tuning. Unlabelled,
|
||||
// the grid silently reorders with no visible cause.
|
||||
'<select id="v3-songs-sort" class="' + ctrl + '">' + opt(
|
||||
SORTS.map(([v, l]) => [v, v === 'tuning' ? l + ' (' + libInstrumentLabel() + ')' : l]),
|
||||
state.sort) + '</select>' +
|
||||
'<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
|
||||
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
|
||||
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
|
||||
@@ -4130,6 +4292,23 @@
|
||||
// visible tuning chips against the new tuning. Cheap: re-decorates in place,
|
||||
// no re-fetch or re-paint. No-op off the Songs grid or without the capability.
|
||||
sm.on('working-tuning-changed', () => {
|
||||
// A guitar<->bass SWITCH changes which tuning the facet, the filter,
|
||||
// the sort and the card chip speak for, so the grid must re-query —
|
||||
// re-colouring chips would leave the guitar tuning on screen and a
|
||||
// guitar-keyed filter applied. A retune within one instrument still
|
||||
// takes the cheap in-place path below.
|
||||
const inst = libInstrument();
|
||||
if (inst !== _lastRenderInstrument) {
|
||||
_lastRenderInstrument = inst;
|
||||
// A tuning selection keyed to the old instrument means nothing
|
||||
// for the new one; clearing avoids an empty grid the user can't
|
||||
// explain (the pills are re-rendered from the new facet).
|
||||
state.filters.tunings = [];
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (active && active.id === 'v3-songs') reload();
|
||||
else _libraryDirty = true;
|
||||
return;
|
||||
}
|
||||
if (typeof songsActive === 'function' && !songsActive()) return;
|
||||
if (state.view !== 'grid') return;
|
||||
decorateTuningChips(_gridEl());
|
||||
|
||||
@@ -529,7 +529,27 @@
|
||||
_loadingLoop = null;
|
||||
_fadingLoop = null;
|
||||
if (_venueActive && _manifest) {
|
||||
if (!playIntro()) showLoop(machine.current, FADE_MS);
|
||||
// The flyover is ARRIVING at the venue, and you arrive once. Songs
|
||||
// 2..N of a set (a gig / album / playlist) are a NEW song but the
|
||||
// SAME arrival — the camera should not fly in from the back of the
|
||||
// room before every track (tester: "it showed the flyover intro
|
||||
// again" on a gig's second song). Continue the room to the new song's
|
||||
// loop; only a first-song / standalone arrival flies in.
|
||||
if (_isSetContinuation()) showLoop(machine.current, FADE_MS);
|
||||
else if (!playIntro()) showLoop(machine.current, FADE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
// Is this song load a continuation of a play queue (a set already in
|
||||
// progress), rather than an arrival? True for song 2..N of a gig/album/
|
||||
// playlist. The queue owns the answer; treat any error / absent queue as
|
||||
// "not a continuation" so a standalone play still flies in.
|
||||
function _isSetContinuation() {
|
||||
try {
|
||||
const q = window.feedBack && window.feedBack.playQueue;
|
||||
return !!(q && typeof q.isContinuation === 'function' && q.isContinuation());
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// The window globals are a THIRD-PARTY CONTRACT. Pin them.
|
||||
//
|
||||
// Out-of-tree plugins load their screen.js as a CLASSIC script and call these
|
||||
// as bare globals. Nothing in core reads most of them, so a call-graph scan,
|
||||
// ESLint's no-undef, and a grep all come back clean while the plugin breaks in
|
||||
// the field. This is the frontend twin of tests/test_plugin_context_contract.py
|
||||
// — same reasoning, same literal-list rule.
|
||||
//
|
||||
// This guard is retroactive: `esc` was an implicit global back when app.js was
|
||||
// a classic script, went module-scoped in a9fce29, and got carved into
|
||||
// js/dom.js in 14b4058. The re-export list at the bottom of app.js was rebuilt
|
||||
// without it, and the MIDI plugin's device list threw "esc is not defined" for
|
||||
// testers — reported as "MIDI Access denied", because the ReferenceError landed
|
||||
// in a try/catch meant for permission failures.
|
||||
//
|
||||
// WHY A LITERAL LIST AND NOT A DERIVED ONE. Deriving the expected set from
|
||||
// app.js would assert the code equals itself. The point is that a human has to
|
||||
// look at a diff and consciously agree to change the contract.
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const PLUGIN_GLOBALS = [
|
||||
'_confirmDialog', '_getArrangementNamingMode', '_libraryLocalFilename', '_librarySongArtUrl',
|
||||
'_librarySongId', '_onHeaderClick', '_onNamingModeChange', '_trapFocusInModal',
|
||||
'changeArrangement', 'checkPluginUpdates', 'clearLibFilters', 'clearLoop',
|
||||
'deleteSelectedLoop', 'esc', 'exportDiagnostics', 'exportSettings', 'filterFavorites',
|
||||
'filterLibrary', 'fullRescanLibrary', 'goFavPage', 'handleSliderInput',
|
||||
'hideScanBanner', 'importSettings', 'loadPlugins', 'loadSavedLoop',
|
||||
'loadSettings', 'onSectionPracticeModeChange', 'openEditModal', 'persistSetting',
|
||||
'pickDlcFolder', 'pinCurrentArrangementDefault', 'playSong', 'previewDiagnostics',
|
||||
'previewEditArt', 'renderGridCards', 'renderTreeInto', 'rescanLibrary',
|
||||
'retuneSong', 'saveCurrentLoop', 'saveSettings', 'seekBy',
|
||||
'setAvOffsetMs', 'setFavView', 'setInstrumentPathway', 'setLibView',
|
||||
'setLibraryProvider', 'setLoopEnd', 'setLoopStart', 'setMastery',
|
||||
'setSpeed', 'setViz', 'showScreen', 'sortFavorites',
|
||||
'sortLibrary', 'syncLibrarySong', 'toggleAllArtists', 'toggleAllFavoriteArtists',
|
||||
'toggleLibFilters', 'togglePlay', 'toggleSectionPracticePopover', 'uiPrompt',
|
||||
'updatePlugin', 'uploadSongs',
|
||||
'filterFavTreeLetter', 'filterTreeLetter', 'goFavTreePage', 'goTreePage',
|
||||
];
|
||||
|
||||
test('plugin-facing window globals are all callable', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
|
||||
const missing = await page.evaluate(
|
||||
(names) => names.filter((n) => typeof (window as any)[n] !== 'function'),
|
||||
PLUGIN_GLOBALS,
|
||||
);
|
||||
|
||||
expect(missing, `window globals plugins depend on are missing or not functions: ${missing.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
// The plugin call site that actually broke: esc() interpolated into a template
|
||||
// string. A global that exists but doesn't escape is its own bug.
|
||||
test('window.esc escapes HTML metacharacters', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||
|
||||
const escaped = await page.evaluate(() => (window as any).esc('<img src=x onerror=alert(1)>'));
|
||||
expect(escaped).not.toContain('<img');
|
||||
expect(escaped).toContain('<');
|
||||
});
|
||||
@@ -116,3 +116,30 @@ test('career screen pushes the crowd manifest with a base URL', () => {
|
||||
// Degrades without the crowd layer (PR1 not merged / older desktop).
|
||||
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
|
||||
});
|
||||
|
||||
// feedBack#… (tester): "Venue doesn't load when starting song from passport.
|
||||
// Loads standard particles." crowd.setManifest(venue) is reached ONLY through
|
||||
// pushCrowdManifest, and pushCrowdManifest is called ONLY from refresh() (the
|
||||
// career tab's own reload). A gig navigates away from that tab, so refresh()
|
||||
// never runs during it — the venue viz turns on but its crowd/stage pack never
|
||||
// loads. startGig must push the manifest itself after setting the override.
|
||||
test('startGig pushes the crowd manifest for the gig venue', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'plugins', 'career', 'screen.js'), 'utf8');
|
||||
const start = src.indexOf('async function startGig(');
|
||||
assert.ok(start !== -1, 'startGig not found');
|
||||
const open = src.indexOf('{', src.indexOf(')', start));
|
||||
let depth = 1, i = open + 1;
|
||||
while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; }
|
||||
const fn = src.slice(start, i);
|
||||
// The override is set, then the manifest must be (re)pushed for it.
|
||||
const overrideIdx = fn.search(/VENUE_OVERRIDE_KEY,\s*prop\.venue_id/);
|
||||
const pushIdx = fn.search(/pushCrowdManifest\s*\(/);
|
||||
assert.ok(overrideIdx !== -1, 'startGig must set the venue override');
|
||||
assert.ok(pushIdx !== -1,
|
||||
'startGig must push the crowd manifest — refresh() (its only other caller) ' +
|
||||
'never runs during a gig, so the venue pack would never load');
|
||||
assert.ok(overrideIdx < pushIdx, 'the manifest must be pushed AFTER the override is set to the gig venue');
|
||||
});
|
||||
|
||||
@@ -1,461 +1,480 @@
|
||||
// Pins the renderOrder hierarchy in plugins/highway_3d/screen.js.
|
||||
//
|
||||
// Three.js renders transparent objects by renderOrder first, then back-to-front
|
||||
// Z sort within the same renderOrder. All 3D-highway materials use depthTest:false, so
|
||||
// renderOrder is the *only* draw-order control — getting it wrong silently
|
||||
// causes one layer to bleed through another (gems clipping through chord frames,
|
||||
// strings buried under notes, etc.).
|
||||
//
|
||||
// Full hierarchy bottom → top:
|
||||
//
|
||||
// -1 background stage traversal
|
||||
// 1 lane quads
|
||||
// 2 fret dividers
|
||||
// 4 sus-rail bloom (pSusRailBloom seed) ← highway_3d_sustain_bloom.test.js
|
||||
// 5 sus-rail core (pSusRail seed) ← highway_3d_sustain_rail.test.js
|
||||
// 7 string-line glows (in-lane glow lines)
|
||||
// 14 board-projection frame
|
||||
// [renderOrderForLayerAtZ(z, FRET_COLUMN)] fret-column markers (pFretColMarker) — between chord frame and gem
|
||||
// [layered below chordFrameRenderOrder] chord fill / PM-FH fill / PM-FH lines
|
||||
// [chordFrameRenderOrder] chord frame edges = renderOrderForLayerAtZ(z, CHORD_FRAME)
|
||||
// [layered above chordFrameRenderOrder] chord-frame glow, connector/drop lines
|
||||
// [below chordFrameRenderOrder] sustain-trail strip segments (Z-proportional, always < frame)
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)] note gem outline
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_CORE)] note gem core
|
||||
// [techniqueMarkerRenderOrder] technique markers
|
||||
// [after board wire layers] note fret labels, above gem symbols and fret wires
|
||||
// [renderOrderForLayerAtZ(0, BOARD_STRING)] string mesh (drawn over gems but under fret wires)
|
||||
// [renderOrderForLayerAtZ(0, BOARD_FRET_WIRE)] static fret wires (above strings, as on a real guitar)
|
||||
// 1000 technique labels, ghost-fret overlay
|
||||
//
|
||||
// Tests are source-level regex checks — no need to load Three.js or a DOM.
|
||||
//
|
||||
// Any PR that changes a renderOrder value must update the relevant test(s) here
|
||||
// and provide a visual justification in the PR description.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _src;
|
||||
/** Returns the cached 3D highway screen source under test. */
|
||||
function src() {
|
||||
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
return _src;
|
||||
}
|
||||
|
||||
/** Parses the declared render-order layer stack from screen.js. */
|
||||
function layers() {
|
||||
const match = src().match(/const\s+RENDER_ORDER_LAYER_STACK\s*=\s*Object\.freeze\(\s*\[([\s\S]*?)\]\s*\)/);
|
||||
assert.ok(match, 'RENDER_ORDER_LAYER_STACK must be declared');
|
||||
return Array.from(match[1].matchAll(/'([^']+)'/g), m => m[1]);
|
||||
}
|
||||
|
||||
/** Returns the position of a named layer in the render-order stack. */
|
||||
function layerIndex(name) {
|
||||
const ordered = layers();
|
||||
const idx = ordered.indexOf(name);
|
||||
assert.ok(idx !== -1, `${name} must be present in RENDER_ORDER_LAYER_STACK`);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Reads the render-order base used for objects at z = 0. */
|
||||
function zZeroRenderOrder() {
|
||||
const match = src().match(/const\s+RENDER_ORDER_AT_Z_ZERO\s*=\s*(-?\d+(?:\.\d+)?)\s*;/);
|
||||
assert.ok(match, 'RENDER_ORDER_AT_Z_ZERO must be declared');
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static / fixed renderOrder values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('lane quads use renderOrder 1', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/lane\.renderOrder\s*=\s*1\s*;/,
|
||||
'lane quads must use renderOrder = 1 (bottom-most visible layer)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret dividers use renderOrder 2', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/div\.renderOrder\s*=\s*2\s*;/,
|
||||
'fret dividers must use renderOrder = 2, above lane (1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret inlay dots use renderOrder 3, above lane (1) and dividers (2)', () => {
|
||||
// The translucent lane would otherwise paint over and hide the inlay.
|
||||
// The dots must draw after the lane/dividers but stay below the depth-layer stack.
|
||||
assert.match(
|
||||
src(),
|
||||
/d\.renderOrder\s*=\s*3\s*;/,
|
||||
'fret inlay dots must use renderOrder = 3 so the lane no longer hides them',
|
||||
);
|
||||
});
|
||||
|
||||
test('string-line glows use renderOrder 7, above sus-rails (4/5)', () => {
|
||||
// The in-lane string glow lines sit at 7 — above sus-rail bloom (4) and
|
||||
// core (5) so the glow is visible, but below chord fill (chordFrameRenderOrder-4,
|
||||
// min=44) so chord interiors don't disappear behind glow overdraw.
|
||||
assert.match(
|
||||
src(),
|
||||
/line\.renderOrder\s*=\s*7\s*;/,
|
||||
'string glow lines must use renderOrder = 7',
|
||||
);
|
||||
});
|
||||
|
||||
test('board-projection frame mesh uses renderOrder 14', () => {
|
||||
// The fretboard projection plane sits above string glows (7) but below
|
||||
// chord fill (min 44). Value 14 keeps it sandwiched cleanly.
|
||||
// Anchor to the board-projection pool (projMeshArr = activePalette.map(...))
|
||||
// so the assertion only passes when THAT block seeds renderOrder = 14 —
|
||||
// not any unrelated renderOrder = 14 elsewhere in the source.
|
||||
const boardProjRO = /projMeshArr\s*=\s*activePalette\.map\b[\s\S]{0,1200}?m\.renderOrder\s*=\s*14\s*;/;
|
||||
assert.match(
|
||||
src(),
|
||||
boardProjRO,
|
||||
'board-projection pool (projMeshArr) must seed meshes with renderOrder = 14',
|
||||
);
|
||||
const boardMatch = src().match(boardProjRO);
|
||||
assert.ok(boardMatch, 'board projection mesh must be assigned renderOrder = 14');
|
||||
});
|
||||
|
||||
test('string mesh in buildBoard uses the named board-string layer', () => {
|
||||
// The physical string cylinders/planes rendered on the fretboard sit above
|
||||
// the note-gem layers but below fret wires.
|
||||
assert.match(
|
||||
src(),
|
||||
/mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/,
|
||||
'buildBoard string mesh must use BOARD_STRING',
|
||||
);
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'));
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('BOARD_FRET_WIRE'));
|
||||
});
|
||||
|
||||
test('static fret wires use bowed TubeGeometry + MeshStandardMaterial, named board-fret-wire layer, depthTest+depthWrite false, default gray 0x666688', () => {
|
||||
// Fret wires are a single shared, bowed TubeGeometry (backported from
|
||||
// highway_babylon): a CatmullRom curve whose middle pushes away from the
|
||||
// camera by FRET_BOW_DZ so the row of frets reads as wrapping a cylindrical
|
||||
// neck. T.Line is avoided — WebGL ignores linewidth > 1px so a Line always
|
||||
// renders as a hairline. The lit MeshStandardMaterial lets scene light glint
|
||||
// across the rounded surface (gold in-anchor → brass). depthTest:false is
|
||||
// required: the string BoxGeometry (MeshStandardMaterial, depthWrite:true)
|
||||
// writes depth at Z = +STR_THICK/2, so fret wires near Z=0 would fail the
|
||||
// depth test at string pixels despite the higher layer; depthWrite:false
|
||||
// keeps the transparent fret from polluting depth for later overlays.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.TubeGeometry\(\s*tubeCurve\s*,\s*FRET_TUBE_SEG\s*,\s*FRET_TUBE_RADIUS\s*,\s*FRET_TUBE_RADIAL\s*,\s*false\s*,?\s*\)/,
|
||||
'buildBoard fret wires must use a TubeGeometry built from tubeCurve + FRET_TUBE_* params',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.CatmullRomCurve3\(\s*tubePath\s*\)/,
|
||||
'buildBoard fret tube must follow a CatmullRomCurve3 through the bowed path',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_BOW_DZ\s*\*\s*zm/,
|
||||
'fret tube path must bow in Z by FRET_BOW_DZ so the neck reads as curved',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.Mesh\(\s*fretTubeGeo\s*,\s*mat\s*\)/,
|
||||
'buildBoard fret wires must reuse the shared fretTubeGeo (not T.Line)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/,
|
||||
'buildBoard fret wire mesh must use BOARD_FRET_WIRE',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.MeshStandardMaterial\(/,
|
||||
'fret wires must use MeshStandardMaterial so scene light shades the metal',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*0x666688/,
|
||||
'fret wire material must have default gray color 0x666688',
|
||||
);
|
||||
// Both depth flags asserted independently so the test doesn't pin property
|
||||
// order in the material literal.
|
||||
assert.match(
|
||||
s,
|
||||
/depthTest\s*:\s*false/,
|
||||
'fret wire material must set depthTest: false',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/depthWrite\s*:\s*false/,
|
||||
'fret wire material must set depthWrite: false (no z-buffer pollution)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\s*\[\s*f\s*\]\s*=\s*mat\s*;/,
|
||||
'buildBoard must store each wire material in fretWireMats[f]',
|
||||
);
|
||||
});
|
||||
|
||||
test('update() sets fret wire gold (0xD8A636) for in-anchor frets, gray (0x666688) otherwise', () => {
|
||||
// Uses anchorLaneBoundsAt() — the same helper the dynamic lane uses —
|
||||
// so fret wire highlight aligns exactly with the lane edges:
|
||||
// dMin = fret - 1, dMax = fret + width - 1
|
||||
// Example: { fret: 3, width: 4 } → dMin=2, dMax=6 → wires 2..6 gold.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\.length/,
|
||||
'update() must guard the per-frame fret wire loop on fretWireMats.length',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/anchorLaneBoundsAt\(\s*anchors\s*,\s*now\s*\)/,
|
||||
'update() must use anchorLaneBoundsAt(anchors, now) to get fret wire range',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*0xD8A636\s*\)/,
|
||||
'update() must set gold 0xD8A636 for in-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*0x666688\s*\)/,
|
||||
'update() must set gray 0x666688 for out-of-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMin/,
|
||||
'update() must use dMin from anchorLaneBoundsAt (= fret - 1)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMax/,
|
||||
'update() must use dMax from anchorLaneBoundsAt (= fret + width - 1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret-column markers use Z-proportional renderOrder between chord frame and gem', () => {
|
||||
// pFretColMarker labels use the named stack: one step above chord frame
|
||||
// and one step below note gems at the same depth.
|
||||
// This ensures chord frame borders never overdraw the label and the label
|
||||
// never overdraws gems, at every Z position across the lookahead window.
|
||||
assert.match(
|
||||
src(),
|
||||
/sp\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)\s*;/,
|
||||
'pFretColMarker renderOrder must use renderOrderForLayerAtZ(z, FRET_COLUMN)',
|
||||
);
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('technique labels and ghost-fret overlay use renderOrder 1000', () => {
|
||||
// 1000 is well above the entire Z-proportional range and the
|
||||
// string/cadence layer — labels must always be readable.
|
||||
const matches = src().match(/m\.renderOrder\s*=\s*1000\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'at least two renderOrder = 1000 assignments must exist (technique labels + ghost fret)',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Z-proportional formulas — chord frame / note gem / technique marker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chordFrameRenderOrder uses renderOrderForLayerAtZ(z, CHORD_FRAME)', () => {
|
||||
// Per-chord frame renderOrder mirrors the note-gem scale with an earlier
|
||||
// layer from RENDER_ORDER_LAYER_STACK.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/,
|
||||
'chordFrameRenderOrder must use renderOrderForLayerAtZ(z, CHORD_FRAME)',
|
||||
);
|
||||
assert.match(src(), /const\s+RENDER_ORDER_LAYER_INDEX\s*=\s*Object\.freeze\(\s*RENDER_ORDER_LAYER_STACK\.reduce\(/);
|
||||
assert.match(src(), /const\s+layerIndex\s*=\s*RENDER_ORDER_LAYER_INDEX\[layerName\]\s*;/);
|
||||
assert.match(src(), /if\s*\(\s*layerIndex\s*===\s*undefined\s*\)\s*throw\s+new\s+Error\(`Unknown 3D highway depth layer: \$\{layerName\}`\)\s*;/);
|
||||
assert.match(src(), /const\s+depthRenderOrder\s*=\s*Math\.max\(\s*RENDER_ORDER_FAR_CLAMP\s*,\s*Math\.round\(\s*RENDER_ORDER_AT_Z_ZERO\s*\+\s*worldZ\s*\/\s*K\s*\)\s*\)\s*;/);
|
||||
// Layer is a sub-unit fraction so the integer depth bucket strictly
|
||||
// dominates (a farther object can't outrank a nearer one via a higher
|
||||
// layer); the layer only breaks ties within the same depth bucket.
|
||||
assert.match(src(), /return\s+depthRenderOrder\s*\+\s*layerIndex\s*\/\s*RENDER_ORDER_LAYER_STACK\.length\s*;/);
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('note outline uses renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)', () => {
|
||||
// Per-note gem renderOrder. noteZ is negative (ahead of hit line → negative
|
||||
// Z in world space). At noteZ=0 (on the hit line), the note outline uses
|
||||
// the near render-order base plus its layer index; far notes clamp to the
|
||||
// far render-order base plus that same layer index.
|
||||
// The ordered layer list keeps gems above chord frames everywhere.
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note outline must use renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)',
|
||||
);
|
||||
assert.strictEqual(layerIndex('CHORD_FILL'), 0);
|
||||
});
|
||||
|
||||
test('techniqueMarkerRenderOrder uses the named technique marker layer above gem core', () => {
|
||||
// Technique markers (PM cross, bend arrow, H/P chevron, etc.) must overlay
|
||||
// the gem itself.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+techniqueMarkerRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'TECHNIQUE_MARKER'\s*\)/,
|
||||
'techniqueMarkerRenderOrder must use TECHNIQUE_MARKER',
|
||||
);
|
||||
assert.ok(layerIndex('TECHNIQUE_MARKER') > layerIndex('NOTE_CORE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intra-chord layering (chord fill < PM/FH fill < PM/FH lines < frame edge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord fill interior uses the named layer below chord frame', () => {
|
||||
// The translucent chord-box fill sits below the frame edge so the edge
|
||||
// always wins when both cover the same pixel.
|
||||
assert.match(
|
||||
src(),
|
||||
/fill\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FILL'\s*\)\s*;/,
|
||||
'chord fill must use CHORD_FILL',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('PM/FH X fill (pPMXFill / pFHXFill) uses its ordered layer', () => {
|
||||
// The black background fill of the muted-note X symbol is above chord fill
|
||||
// but below the X lines — same chord, so same chord-frame renderOrder base.
|
||||
const matches = src().match(/xf\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_FILL'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-fill meshes must use CHORD_STRUM_FILL (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_STRUM_FILL'));
|
||||
assert.ok(layerIndex('CHORD_STRUM_FILL') < layerIndex('CHORD_STRUM_LINE'));
|
||||
});
|
||||
|
||||
test('PM/FH X lines (pMuteXLines / pFHXLines) use their ordered layer', () => {
|
||||
// The coloured X stroke lines are above the black fill but below
|
||||
// the chord frame border edge, so they don't escape the box.
|
||||
const matches = src().match(/xl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_LINE'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-line meshes must use CHORD_STRUM_LINE (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_STRUM_LINE') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('chord frame glow uses the layer after chord frame', () => {
|
||||
// Accent glow draws after the frame while still remaining below connectors
|
||||
// and note symbols in the ordered layer list.
|
||||
assert.match(
|
||||
src(),
|
||||
/b\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_EDGE_GLOW'\s*\)\s*;/,
|
||||
'chord frame edge slabs must use CHORD_EDGE_GLOW',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') < layerIndex('CONNECTOR_LINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sustain-trail strip & ribbon — always below chord frame of same depth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('sus-trail strip renderOrder formula keeps trails strictly below chord frames at same Z', () => {
|
||||
// Sustain trails use the ordered layer immediately below chord frames at
|
||||
// the same depth.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+trailRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*Math\.min\(\s*0\s*,\s*zCenter\s*\)\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail strip renderOrder must use renderOrderForLayerAtZ(min zCenter, SUSTAIN_TRAIL)',
|
||||
);
|
||||
assert.ok(layerIndex('SUSTAIN_TRAIL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('sus-trail ribbon renderOrder formula mirrors strip formula using time-based depth', () => {
|
||||
// Ribbons use _ribDt (time from now to ribbon midpoint) converted to the
|
||||
// same Z scale as dZ() on the sustain-trail layer.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+ribbonRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*-\s*_ribDt\s*\*\s*TS\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail ribbon renderOrder must use renderOrderForLayerAtZ on the sustain-trail layer',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Note gem ordering (outline < core, both driven by named depth layers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('note gem outline uses the named outline layer', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note gem outline must use NOTE_OUTLINE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_OUTLINE') > layerIndex('FRET_COLUMN'));
|
||||
});
|
||||
|
||||
test('note gem core uses the named layer above outline', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/core\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_CORE'\s*\)\s*;/,
|
||||
'note gem core must use NOTE_CORE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_CORE') > layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key relative-ordering invariants (derived constants)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord frame layer is below note outline layer', () => {
|
||||
// Chord frames must always render below note gems, even at maximum depth
|
||||
// (far end of the lookahead).
|
||||
//
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('fret labels are above note symbols in the named stack', () => {
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('NOTE_CORE'), 'note fret labels must draw above gem core');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('TECHNIQUE_MARKER'), 'note fret labels must draw above technique markers');
|
||||
assert.ok(layerIndex('ARP_NOTE_FRET_LABEL') > layerIndex('NOTE_FRET_LABEL'), 'arp labels retain a one-layer tie-breaker');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('NOTE_CORE'), 'chord-loop fret labels must draw above gem core at the same depth');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'note fret labels must clear static fret wires');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'chord fret labels must clear static fret wires');
|
||||
});
|
||||
|
||||
test('string mesh layer is above note symbols and below labels', () => {
|
||||
// Board strings are never occluded by flying gems, but labels still appear above strings.
|
||||
const s = src();
|
||||
assert.match(s, /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
// Confirm 1000 also exists (labels above strings)
|
||||
assert.match(s, /m\.renderOrder\s*=\s*1000\s*;/, 'technique label renderOrder 1000 must exist');
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'), 'string mesh layer must be above note symbols');
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('NOTE_FRET_LABEL'), 'string mesh layer must be below fret labels');
|
||||
});
|
||||
|
||||
test('fret-column marker layer is above chord frame and below gem outline', () => {
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'), 'fret-column marker layer must be above chord frame');
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'), 'fret-column marker layer must be below gem outline');
|
||||
assert.match(src(), /renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)/);
|
||||
});
|
||||
|
||||
test('static fret wire layer is above string mesh and note symbols', () => {
|
||||
// Structural invariant: fret wires must always draw after (on top of) strings.
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('BOARD_STRING'), 'fret wire must be above string mesh');
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('TECHNIQUE_MARKER'), 'fret wire must be above note symbols');
|
||||
assert.ok(zZeroRenderOrder() + layerIndex('BOARD_FRET_WIRE') < 1000, 'fret wire must be below technique labels (1000)');
|
||||
assert.match(src(), /fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/, 'buildBoard fret wire must use BOARD_FRET_WIRE');
|
||||
assert.match(src(), /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
});
|
||||
// Pins the renderOrder hierarchy in plugins/highway_3d/screen.js.
|
||||
//
|
||||
// Three.js renders transparent objects by renderOrder first, then back-to-front
|
||||
// Z sort within the same renderOrder. Nearly all 3D-highway materials use
|
||||
// depthTest:false (exceptions exist — e.g. the accent halo mats set
|
||||
// depthTest:true), so renderOrder is the primary draw-order control — getting it wrong silently
|
||||
// causes one layer to bleed through another (gems clipping through chord frames,
|
||||
// strings buried under notes, etc.).
|
||||
//
|
||||
// Full hierarchy bottom → top:
|
||||
//
|
||||
// -1 background stage traversal
|
||||
// 1 lane quads
|
||||
// 2 fret dividers
|
||||
// 3 fret inlay dots (above the lane so it no longer hides them)
|
||||
// 4 sus-rail bloom (pSusRailBloom seed) ← highway_3d_sustain_bloom.test.js
|
||||
// 5 sus-rail core (pSusRail seed) ← highway_3d_sustain_rail.test.js
|
||||
// 7 string-line glows (in-lane glow lines)
|
||||
// 14 board-projection frame
|
||||
// [renderOrderForLayerAtZ(z, FRET_COLUMN)] fret-column markers (pFretColMarker) — between chord frame and gem
|
||||
// [layered below chordFrameRenderOrder] chord fill / PM-FH fill / PM-FH lines
|
||||
// [chordFrameRenderOrder] chord frame edges = renderOrderForLayerAtZ(z, CHORD_FRAME)
|
||||
// [layered above chordFrameRenderOrder] chord-frame glow, connector/drop lines
|
||||
// [below chordFrameRenderOrder] sustain-trail strip segments (Z-proportional, always < frame)
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)] note gem outline
|
||||
// [renderOrderForLayerAtZ(noteZ, NOTE_CORE)] note gem core
|
||||
// [techniqueMarkerRenderOrder] technique markers
|
||||
// [after board wire layers] note fret labels, above gem symbols and fret wires
|
||||
// [renderOrderForLayerAtZ(0, BOARD_STRING)] string mesh (drawn over gems but under fret wires)
|
||||
// [renderOrderForLayerAtZ(0, BOARD_FRET_WIRE)] static fret wires (above strings, as on a real guitar)
|
||||
// 1000 technique labels, ghost-fret overlay
|
||||
//
|
||||
// Tests are source-level regex checks — no need to load Three.js or a DOM.
|
||||
//
|
||||
// Any PR that changes a renderOrder value must update the relevant test(s) here
|
||||
// and provide a visual justification in the PR description.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _src;
|
||||
/** Returns the cached 3D highway screen source under test. */
|
||||
function src() {
|
||||
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
return _src;
|
||||
}
|
||||
|
||||
/** Parses the declared render-order layer stack from screen.js. */
|
||||
function layers() {
|
||||
const match = src().match(/const\s+RENDER_ORDER_LAYER_STACK\s*=\s*Object\.freeze\(\s*\[([\s\S]*?)\]\s*\)/);
|
||||
assert.ok(match, 'RENDER_ORDER_LAYER_STACK must be declared');
|
||||
return Array.from(match[1].matchAll(/'([^']+)'/g), m => m[1]);
|
||||
}
|
||||
|
||||
/** Returns the position of a named layer in the render-order stack. */
|
||||
function layerIndex(name) {
|
||||
const ordered = layers();
|
||||
const idx = ordered.indexOf(name);
|
||||
assert.ok(idx !== -1, `${name} must be present in RENDER_ORDER_LAYER_STACK`);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Reads the render-order base used for objects at z = 0. */
|
||||
function zZeroRenderOrder() {
|
||||
const match = src().match(/const\s+RENDER_ORDER_AT_Z_ZERO\s*=\s*(-?\d+(?:\.\d+)?)\s*;/);
|
||||
assert.ok(match, 'RENDER_ORDER_AT_Z_ZERO must be declared');
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static / fixed renderOrder values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('lane quads use renderOrder 1', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/lane\.renderOrder\s*=\s*1\s*;/,
|
||||
'lane quads must use renderOrder = 1 (bottom-most visible layer)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret dividers use renderOrder 2', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/div\.renderOrder\s*=\s*2\s*;/,
|
||||
'fret dividers must use renderOrder = 2, above lane (1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret inlay dots use renderOrder 3, above lane (1) and dividers (2)', () => {
|
||||
// The translucent lane would otherwise paint over and hide the inlay.
|
||||
// The dots must draw after the lane/dividers but stay below the depth-layer stack.
|
||||
assert.match(
|
||||
src(),
|
||||
/d\.renderOrder\s*=\s*3\s*;/,
|
||||
'fret inlay dots must use renderOrder = 3 so the lane no longer hides them',
|
||||
);
|
||||
});
|
||||
|
||||
test('string-line glows use renderOrder 7, above sus-rails (4/5)', () => {
|
||||
// The in-lane string glow lines sit at 7 — above sus-rail bloom (4) and
|
||||
// core (5) so the glow is visible, but below chord fill (chordFrameRenderOrder-4,
|
||||
// min=44) so chord interiors don't disappear behind glow overdraw.
|
||||
assert.match(
|
||||
src(),
|
||||
/line\.renderOrder\s*=\s*7\s*;/,
|
||||
'string glow lines must use renderOrder = 7',
|
||||
);
|
||||
});
|
||||
|
||||
test('board-projection frame mesh uses renderOrder 14', () => {
|
||||
// The fretboard projection plane sits above string glows (7) but below
|
||||
// chord fill (min 44). Value 14 keeps it sandwiched cleanly.
|
||||
// Anchor to the board-projection pool (projMeshArr = activePalette.map(...))
|
||||
// so the assertion only passes when THAT block seeds renderOrder = 14 —
|
||||
// not any unrelated renderOrder = 14 elsewhere in the source.
|
||||
const boardProjRO = /projMeshArr\s*=\s*activePalette\.map\b[\s\S]{0,1200}?m\.renderOrder\s*=\s*14\s*;/;
|
||||
assert.match(
|
||||
src(),
|
||||
boardProjRO,
|
||||
'board-projection pool (projMeshArr) must seed meshes with renderOrder = 14',
|
||||
);
|
||||
const boardMatch = src().match(boardProjRO);
|
||||
assert.ok(boardMatch, 'board projection mesh must be assigned renderOrder = 14');
|
||||
});
|
||||
|
||||
test('string mesh in buildBoard uses the named board-string layer', () => {
|
||||
// The physical string cylinders/planes rendered on the fretboard sit above
|
||||
// the note-gem layers but below fret wires.
|
||||
assert.match(
|
||||
src(),
|
||||
/mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/,
|
||||
'buildBoard string mesh must use BOARD_STRING',
|
||||
);
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'));
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('BOARD_FRET_WIRE'));
|
||||
});
|
||||
|
||||
test('static fret wires use bowed TubeGeometry + MeshStandardMaterial, named board-fret-wire layer, depthTest+depthWrite false, idle tier FRET_WIRE_IDLE_HEX', () => {
|
||||
// Fret wires are a single shared, bowed TubeGeometry (backported from
|
||||
// highway_babylon): a CatmullRom curve whose middle pushes away from the
|
||||
// camera by FRET_BOW_DZ so the row of frets reads as wrapping a cylindrical
|
||||
// neck. T.Line is avoided — WebGL ignores linewidth > 1px so a Line always
|
||||
// renders as a hairline. The lit MeshStandardMaterial lets scene light glint
|
||||
// across the rounded surface (gold in-anchor → brass). depthTest:false is
|
||||
// required: the string BoxGeometry (MeshStandardMaterial, depthWrite:true)
|
||||
// writes depth at Z = +STR_THICK/2, so fret wires near Z=0 would fail the
|
||||
// depth test at string pixels despite the higher layer; depthWrite:false
|
||||
// keeps the transparent fret from polluting depth for later overlays.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.TubeGeometry\(\s*tubeCurve\s*,\s*FRET_TUBE_SEG\s*,\s*FRET_TUBE_RADIUS\s*,\s*FRET_TUBE_RADIAL\s*,\s*false\s*,?\s*\)/,
|
||||
'buildBoard fret wires must use a TubeGeometry built from tubeCurve + FRET_TUBE_* params',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.CatmullRomCurve3\(\s*tubePath\s*\)/,
|
||||
'buildBoard fret tube must follow a CatmullRomCurve3 through the bowed path',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_BOW_DZ\s*\*\s*zm/,
|
||||
'fret tube path must bow in Z by FRET_BOW_DZ so the neck reads as curved',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.Mesh\(\s*fretTubeGeo\s*,\s*mat\s*\)/,
|
||||
'buildBoard fret wires must reuse the shared fretTubeGeo (not T.Line)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/,
|
||||
'buildBoard fret wire mesh must use BOARD_FRET_WIRE',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/new\s+T\.MeshStandardMaterial\(/,
|
||||
'fret wires must use MeshStandardMaterial so scene light shades the metal',
|
||||
);
|
||||
// The wire tiers moved to named constants (feedBack#969): idle is the
|
||||
// dimmed 0x4A4A60 so the neck recedes and the anchor lane reads as the
|
||||
// focus cue. Assert the material uses the constant AND pin the constant's
|
||||
// value, so a retune is a deliberate two-line change here.
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*FRET_WIRE_IDLE_HEX/,
|
||||
'fret wire material must take its default color from FRET_WIRE_IDLE_HEX',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_WIRE_IDLE_HEX\s*=\s*0x4A4A60/,
|
||||
'FRET_WIRE_IDLE_HEX must be the dimmed idle gray-violet 0x4A4A60',
|
||||
);
|
||||
// Both depth flags anchored to the fret-wire material literal (via its
|
||||
// FRET_WIRE_IDLE_HEX color, unique to it) — an unscoped match would pass
|
||||
// off any other depthTest:false material in the file. Asserted as two
|
||||
// separate anchored matches so property order inside the literal still
|
||||
// isn't pinned.
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*FRET_WIRE_IDLE_HEX[\s\S]{0,400}?depthTest\s*:\s*false/,
|
||||
'the fret wire material itself must set depthTest: false',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/color\s*:\s*FRET_WIRE_IDLE_HEX[\s\S]{0,400}?depthWrite\s*:\s*false/,
|
||||
'the fret wire material itself must set depthWrite: false (no z-buffer pollution)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\s*\[\s*f\s*\]\s*=\s*mat\s*;/,
|
||||
'buildBoard must store each wire material in fretWireMats[f]',
|
||||
);
|
||||
});
|
||||
|
||||
test('update() sets fret wire FRET_WIRE_ACTIVE_HEX (gold) for in-anchor frets, FRET_WIRE_IDLE_HEX otherwise', () => {
|
||||
// Uses anchorLaneBoundsAt() — the same helper the dynamic lane uses —
|
||||
// so fret wire highlight aligns exactly with the lane edges:
|
||||
// dMin = fret - 1, dMax = fret + width - 1
|
||||
// Example: { fret: 3, width: 4 } → dMin=2, dMax=6 → wires 2..6 gold.
|
||||
const s = src();
|
||||
assert.match(
|
||||
s,
|
||||
/fretWireMats\.length/,
|
||||
'update() must guard the per-frame fret wire loop on fretWireMats.length',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/anchorLaneBoundsAt\(\s*anchors\s*,\s*now\s*\)/,
|
||||
'update() must use anchorLaneBoundsAt(anchors, now) to get fret wire range',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*FRET_WIRE_ACTIVE_HEX\s*\)/,
|
||||
'update() must set FRET_WIRE_ACTIVE_HEX for in-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/FRET_WIRE_ACTIVE_HEX\s*=\s*0xD8A636/,
|
||||
'FRET_WIRE_ACTIVE_HEX must stay the anchor-lane gold 0xD8A636',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_m\.color\.setHex\(\s*FRET_WIRE_IDLE_HEX\s*\)/,
|
||||
'update() must set FRET_WIRE_IDLE_HEX for out-of-anchor fret wires',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMin/,
|
||||
'update() must use dMin from anchorLaneBoundsAt (= fret - 1)',
|
||||
);
|
||||
assert.match(
|
||||
s,
|
||||
/_fwBounds\.dMax/,
|
||||
'update() must use dMax from anchorLaneBoundsAt (= fret + width - 1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('fret-column markers use Z-proportional renderOrder between chord frame and gem', () => {
|
||||
// pFretColMarker labels use the named stack: one step above chord frame
|
||||
// and one step below note gems at the same depth.
|
||||
// This ensures chord frame borders never overdraw the label and the label
|
||||
// never overdraws gems, at every Z position across the lookahead window.
|
||||
assert.match(
|
||||
src(),
|
||||
/sp\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)\s*;/,
|
||||
'pFretColMarker renderOrder must use renderOrderForLayerAtZ(z, FRET_COLUMN)',
|
||||
);
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('technique labels and ghost-fret overlay use renderOrder 1000', () => {
|
||||
// 1000 is well above the entire Z-proportional range and the
|
||||
// string/cadence layer — labels must always be readable.
|
||||
const matches = src().match(/m\.renderOrder\s*=\s*1000\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'at least two renderOrder = 1000 assignments must exist (technique labels + ghost fret)',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Z-proportional formulas — chord frame / note gem / technique marker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chordFrameRenderOrder uses renderOrderForLayerAtZ(z, CHORD_FRAME)', () => {
|
||||
// Per-chord frame renderOrder mirrors the note-gem scale with an earlier
|
||||
// layer from RENDER_ORDER_LAYER_STACK.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/,
|
||||
'chordFrameRenderOrder must use renderOrderForLayerAtZ(z, CHORD_FRAME)',
|
||||
);
|
||||
assert.match(src(), /const\s+RENDER_ORDER_LAYER_INDEX\s*=\s*Object\.freeze\(\s*RENDER_ORDER_LAYER_STACK\.reduce\(/);
|
||||
assert.match(src(), /const\s+layerIndex\s*=\s*RENDER_ORDER_LAYER_INDEX\[layerName\]\s*;/);
|
||||
assert.match(src(), /if\s*\(\s*layerIndex\s*===\s*undefined\s*\)\s*throw\s+new\s+Error\(`Unknown 3D highway depth layer: \$\{layerName\}`\)\s*;/);
|
||||
assert.match(src(), /const\s+depthRenderOrder\s*=\s*Math\.max\(\s*RENDER_ORDER_FAR_CLAMP\s*,\s*Math\.round\(\s*RENDER_ORDER_AT_Z_ZERO\s*\+\s*worldZ\s*\/\s*K\s*\)\s*\)\s*;/);
|
||||
// Layer is a sub-unit fraction so the integer depth bucket strictly
|
||||
// dominates (a farther object can't outrank a nearer one via a higher
|
||||
// layer); the layer only breaks ties within the same depth bucket.
|
||||
assert.match(src(), /return\s+depthRenderOrder\s*\+\s*layerIndex\s*\/\s*RENDER_ORDER_LAYER_STACK\.length\s*;/);
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('note outline uses renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)', () => {
|
||||
// Per-note gem renderOrder. noteZ is negative (ahead of hit line → negative
|
||||
// Z in world space). At noteZ=0 (on the hit line), the note outline uses
|
||||
// the near render-order base plus its layer index; far notes clamp to the
|
||||
// far render-order base plus that same layer index.
|
||||
// The ordered layer list keeps gems above chord frames everywhere.
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note outline must use renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)',
|
||||
);
|
||||
assert.strictEqual(layerIndex('CHORD_FILL'), 0);
|
||||
});
|
||||
|
||||
test('techniqueMarkerRenderOrder uses the named technique marker layer above gem core', () => {
|
||||
// Technique markers (PM cross, bend arrow, H/P chevron, etc.) must overlay
|
||||
// the gem itself.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+techniqueMarkerRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'TECHNIQUE_MARKER'\s*\)/,
|
||||
'techniqueMarkerRenderOrder must use TECHNIQUE_MARKER',
|
||||
);
|
||||
assert.ok(layerIndex('TECHNIQUE_MARKER') > layerIndex('NOTE_CORE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intra-chord layering (chord fill < PM/FH fill < PM/FH lines < frame edge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord fill interior uses the named layer below chord frame', () => {
|
||||
// The translucent chord-box fill sits below the frame edge so the edge
|
||||
// always wins when both cover the same pixel.
|
||||
assert.match(
|
||||
src(),
|
||||
/fill\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FILL'\s*\)\s*;/,
|
||||
'chord fill must use CHORD_FILL',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('PM/FH X fill (pPMXFill / pFHXFill) uses its ordered layer', () => {
|
||||
// The black background fill of the muted-note X symbol is above chord fill
|
||||
// but below the X lines — same chord, so same chord-frame renderOrder base.
|
||||
const matches = src().match(/xf\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_FILL'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-fill meshes must use CHORD_STRUM_FILL (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_STRUM_FILL'));
|
||||
assert.ok(layerIndex('CHORD_STRUM_FILL') < layerIndex('CHORD_STRUM_LINE'));
|
||||
});
|
||||
|
||||
test('PM/FH X lines (pMuteXLines / pFHXLines) use their ordered layer', () => {
|
||||
// The coloured X stroke lines are above the black fill but below
|
||||
// the chord frame border edge, so they don't escape the box.
|
||||
const matches = src().match(/xl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_LINE'\s*\)\s*;/g) || [];
|
||||
assert.ok(
|
||||
matches.length >= 2,
|
||||
'both PM and FH X-line meshes must use CHORD_STRUM_LINE (found ' + matches.length + ')',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_STRUM_LINE') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('chord frame glow uses the layer after chord frame', () => {
|
||||
// Accent glow draws after the frame while still remaining below connectors
|
||||
// and note symbols in the ordered layer list.
|
||||
assert.match(
|
||||
src(),
|
||||
/b\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_EDGE_GLOW'\s*\)\s*;/,
|
||||
'chord frame edge slabs must use CHORD_EDGE_GLOW',
|
||||
);
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') > layerIndex('CHORD_FRAME'));
|
||||
assert.ok(layerIndex('CHORD_EDGE_GLOW') < layerIndex('CONNECTOR_LINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sustain-trail strip & ribbon — always below chord frame of same depth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('sus-trail strip renderOrder formula keeps trails strictly below chord frames at same Z', () => {
|
||||
// Sustain trails use the ordered layer immediately below chord frames at
|
||||
// the same depth.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+trailRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*Math\.min\(\s*0\s*,\s*zCenter\s*\)\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail strip renderOrder must use renderOrderForLayerAtZ(min zCenter, SUSTAIN_TRAIL)',
|
||||
);
|
||||
assert.ok(layerIndex('SUSTAIN_TRAIL') < layerIndex('CHORD_FRAME'));
|
||||
});
|
||||
|
||||
test('sus-trail ribbon renderOrder formula mirrors strip formula using time-based depth', () => {
|
||||
// Ribbons use _ribDt (time from now to ribbon midpoint) converted to the
|
||||
// same Z scale as dZ() on the sustain-trail layer.
|
||||
assert.match(
|
||||
src(),
|
||||
/const\s+ribbonRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*-\s*_ribDt\s*\*\s*TS\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
|
||||
'sus-trail ribbon renderOrder must use renderOrderForLayerAtZ on the sustain-trail layer',
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Note gem ordering (outline < core, both driven by named depth layers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('note gem outline uses the named outline layer', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
|
||||
'note gem outline must use NOTE_OUTLINE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_OUTLINE') > layerIndex('FRET_COLUMN'));
|
||||
});
|
||||
|
||||
test('note gem core uses the named layer above outline', () => {
|
||||
assert.match(
|
||||
src(),
|
||||
/core\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_CORE'\s*\)\s*;/,
|
||||
'note gem core must use NOTE_CORE',
|
||||
);
|
||||
assert.ok(layerIndex('NOTE_CORE') > layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key relative-ordering invariants (derived constants)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('chord frame layer is below note outline layer', () => {
|
||||
// Chord frames must always render below note gems, even at maximum depth
|
||||
// (far end of the lookahead).
|
||||
//
|
||||
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
|
||||
});
|
||||
|
||||
test('fret labels are above note symbols in the named stack', () => {
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('NOTE_CORE'), 'note fret labels must draw above gem core');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('TECHNIQUE_MARKER'), 'note fret labels must draw above technique markers');
|
||||
assert.ok(layerIndex('ARP_NOTE_FRET_LABEL') > layerIndex('NOTE_FRET_LABEL'), 'arp labels retain a one-layer tie-breaker');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('NOTE_CORE'), 'chord-loop fret labels must draw above gem core at the same depth');
|
||||
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'note fret labels must clear static fret wires');
|
||||
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'chord fret labels must clear static fret wires');
|
||||
});
|
||||
|
||||
test('string mesh layer is above note symbols and below labels', () => {
|
||||
// Board strings are never occluded by flying gems, but labels still appear above strings.
|
||||
const s = src();
|
||||
assert.match(s, /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
// Confirm 1000 also exists (labels above strings)
|
||||
assert.match(s, /m\.renderOrder\s*=\s*1000\s*;/, 'technique label renderOrder 1000 must exist');
|
||||
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'), 'string mesh layer must be above note symbols');
|
||||
assert.ok(layerIndex('BOARD_STRING') < layerIndex('NOTE_FRET_LABEL'), 'string mesh layer must be below fret labels');
|
||||
});
|
||||
|
||||
test('fret-column marker layer is above chord frame and below gem outline', () => {
|
||||
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'), 'fret-column marker layer must be above chord frame');
|
||||
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'), 'fret-column marker layer must be below gem outline');
|
||||
assert.match(src(), /renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)/);
|
||||
});
|
||||
|
||||
test('static fret wire layer is above string mesh and note symbols', () => {
|
||||
// Structural invariant: fret wires must always draw after (on top of) strings.
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('BOARD_STRING'), 'fret wire must be above string mesh');
|
||||
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('TECHNIQUE_MARKER'), 'fret wire must be above note symbols');
|
||||
assert.ok(zZeroRenderOrder() + layerIndex('BOARD_FRET_WIRE') < 1000, 'fret wire must be below technique labels (1000)');
|
||||
assert.match(src(), /fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/, 'buildBoard fret wire must use BOARD_FRET_WIRE');
|
||||
assert.match(src(), /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
|
||||
});
|
||||
|
||||
@@ -49,3 +49,80 @@ test('peekNext is null after clear', () => {
|
||||
q.clear();
|
||||
assert.strictEqual(q.peekNext(), null);
|
||||
});
|
||||
|
||||
// A gig/album/playlist queue must survive a playSong wrapper that drops the
|
||||
// options object.
|
||||
//
|
||||
// The queue tells playSong "don't clear the queue I'm driving" via
|
||||
// options.fromQueue. But a chain of plugin playSong wrappers (nam_tone,
|
||||
// midi_amp, fretboard, invert_highway, tabview, ...) forward only
|
||||
// (filename, arrangement) and silently drop the 3rd arg. With just the in-band
|
||||
// flag, playSong cleared the queue the instant its first song started, so a gig
|
||||
// never advanced (feedBack#… tester: "Passports does not advance in the song
|
||||
// queue"). The queue now also raises an out-of-band flag, _consumeInternalPlay(),
|
||||
// which playSong honours regardless of the wrapper chain.
|
||||
|
||||
// The real clear-guard from session.js, driven against the queue.
|
||||
function clearGuard(win, options) {
|
||||
const pq = win.feedBack && win.feedBack.playQueue;
|
||||
const queueDriven = (options && options.fromQueue)
|
||||
|| (pq && typeof pq._consumeInternalPlay === 'function' && pq._consumeInternalPlay());
|
||||
if (!queueDriven && pq) pq.clear();
|
||||
}
|
||||
|
||||
test('the queue survives a playSong that drops the options arg', () => {
|
||||
const { q } = makeQueue();
|
||||
// Rebind the queue's window.playSong to a wrapper that forwards ONLY
|
||||
// (filename, arrangement) — exactly the plugin bug — and runs the real guard.
|
||||
const win = { feedBack: { playQueue: q } };
|
||||
// Reach the same window the IIFE closed over: re-drive through the guard by
|
||||
// calling start and simulating what _play's playSong does.
|
||||
// We can't rebind the closed-over window, so instead assert the out-of-band
|
||||
// signal directly: _play sets it, and the guard consumes it.
|
||||
q.start(['a.sloppak', 'b.sloppak', 'c.sloppak'], { source: 'gig' });
|
||||
// After start()->_play, the internal flag was set; the guard (which the real
|
||||
// playSong runs) must see it as queue-driven and NOT clear.
|
||||
win.feedBack.playQueue = q;
|
||||
clearGuard(win, undefined /* wrapper dropped options */);
|
||||
assert.strictEqual(q.active(), true, 'a dropped options arg must not clear the queue');
|
||||
assert.strictEqual(q.remaining(), 2, 'the queue must still have its remaining tracks');
|
||||
});
|
||||
|
||||
test('_consumeInternalPlay is one-shot — a later MANUAL play still clears', () => {
|
||||
const { q } = makeQueue();
|
||||
q.start(['a.sloppak', 'b.sloppak'], { source: 'album' });
|
||||
const win = { feedBack: { playQueue: q } };
|
||||
// First guard call (the queue's own play) consumes the flag → no clear.
|
||||
clearGuard(win, undefined);
|
||||
assert.strictEqual(q.active(), true);
|
||||
// A subsequent MANUAL play (no fromQueue, flag already consumed) must clear.
|
||||
clearGuard(win, undefined);
|
||||
assert.strictEqual(q.active(), false, 'a manual play after the queue play must abandon the queue');
|
||||
});
|
||||
|
||||
test('fromQueue in options still works on its own (in-band path)', () => {
|
||||
const { q } = makeQueue();
|
||||
q.start(['a.sloppak', 'b.sloppak'], { source: 'gig' });
|
||||
// consume the internal flag first so ONLY options.fromQueue is under test
|
||||
q._consumeInternalPlay();
|
||||
const win = { feedBack: { playQueue: q } };
|
||||
clearGuard(win, { fromQueue: true });
|
||||
assert.strictEqual(q.active(), true, 'options.fromQueue alone must still keep the queue');
|
||||
});
|
||||
|
||||
// isContinuation(): true for song 2..N of a set, false for the first song / a
|
||||
// standalone play. The venue uses it to fly in once on arrival, then carry the
|
||||
// room between songs instead of replaying the arrival flyover every track
|
||||
// (tester: "it showed the flyover intro again" on a gig's second song).
|
||||
test('isContinuation is false on the first song, true after advancing', () => {
|
||||
const { q } = makeQueue();
|
||||
assert.strictEqual(q.isContinuation(), false, 'idle queue is not a continuation');
|
||||
q.start(['a.sloppak', 'b.sloppak', 'c.sloppak'], { source: 'gig' });
|
||||
assert.strictEqual(q.isContinuation(), false, 'the FIRST song of a set is an arrival, not a continuation');
|
||||
q.advance();
|
||||
assert.strictEqual(q.isContinuation(), true, 'song 2 is a continuation — no re-flyover');
|
||||
q.advance();
|
||||
assert.strictEqual(q.isContinuation(), true, 'song 3 too');
|
||||
q.clear();
|
||||
assert.strictEqual(q.isContinuation(), false, 'a cleared queue is not a continuation');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// The playlist tuning check (static/v3/playlists.js).
|
||||
//
|
||||
// A bass-playing tester built playlists grouped BY TUNING so a practice run
|
||||
// needs no retune, using a library filter that only ever looked at the guitar
|
||||
// tuning. Those playlists still hold songs he can't play without stopping. The
|
||||
// check flags them; it must never quietly edit the playlist, and — the part
|
||||
// that decides whether he trusts it — it must not call a song "wrong tuning"
|
||||
// when it simply couldn't work the song out.
|
||||
//
|
||||
// The real functions are lifted out of playlists.js and run in a vm (the module
|
||||
// is a browser IIFE with no export surface, and there is no jsdom here). No
|
||||
// re-implementation: if the source changes, these tests run the changed code.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const PL_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'playlists.js');
|
||||
const TUNING_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
|
||||
const TUNER_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
|
||||
|
||||
const PL_SRC = fs.readFileSync(PL_JS, 'utf8');
|
||||
|
||||
function extractBlock(src, startMarker) {
|
||||
const start = src.indexOf(startMarker);
|
||||
if (start === -1) throw new Error(`extractBlock: '${startMarker}' not found`);
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
if (depth !== 0) throw new Error(`extractBlock: unbalanced braces after '${startMarker}'`);
|
||||
return src.slice(start, i);
|
||||
}
|
||||
|
||||
// The REAL offset parser the checker calls through window.parseRawTuningOffsets.
|
||||
function loadParseRawTuningOffsets() {
|
||||
const body = fs.readFileSync(TUNING_JS, 'utf8').replace(/^export /gm, '');
|
||||
const sandbox = { window: { feedBack: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(body + '\nexports.parseRawTuningOffsets = parseRawTuningOffsets;', sandbox);
|
||||
return sandbox.exports.parseRawTuningOffsets;
|
||||
}
|
||||
|
||||
// Build a sandbox holding the real checker functions, over a caller-supplied
|
||||
// window (so each test controls the host capabilities and the coverage stub
|
||||
// boundary). `coverage` stands in for the tuner plugin's coverageReport — a
|
||||
// genuinely external collaborator, not the subject under test; the contract
|
||||
// test at the bottom pins its report shape so these fixtures can't drift.
|
||||
function loadChecker(opts) {
|
||||
opts = opts || {};
|
||||
const calls = [];
|
||||
const window = {
|
||||
parseRawTuningOffsets: loadParseRawTuningOffsets(),
|
||||
feedBack: opts.noWorkingTuning ? {} : { workingTuning: { get: () => ({ instrument: opts.instrument || 'bass' }) } },
|
||||
_tunerAutoOpen: opts.noCoverage ? undefined : {
|
||||
coverageReport: async (info) => {
|
||||
calls.push(info);
|
||||
if (opts.coverage) return opts.coverage(info);
|
||||
throw new Error('no coverage fixture supplied');
|
||||
},
|
||||
},
|
||||
};
|
||||
const sandbox = { window, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
extractBlock(PL_SRC, 'function rowTuningForCheck(') + '\n'
|
||||
+ extractBlock(PL_SRC, 'function tuningStateFromReport(') + '\n'
|
||||
+ extractBlock(PL_SRC, 'async function checkPlaylistTuning(') + '\n'
|
||||
+ extractBlock(PL_SRC, 'function tuningSummaryHtml(') + '\n'
|
||||
+ 'exports.rowTuningForCheck = rowTuningForCheck;\n'
|
||||
+ 'exports.tuningStateFromReport = tuningStateFromReport;\n'
|
||||
+ 'exports.checkPlaylistTuning = checkPlaylistTuning;\n'
|
||||
+ 'exports.tuningSummaryHtml = tuningSummaryHtml;\n',
|
||||
sandbox
|
||||
);
|
||||
return { ...sandbox.exports, calls };
|
||||
}
|
||||
|
||||
// Report shapes exactly as plugins/tuner/screen.js documents and returns them.
|
||||
const REPORT_COVERED = { covered: true, retune: [], reference: false, cantCover: false };
|
||||
const REPORT_RETUNE = { covered: false, retune: [{ from: 'E', to: 'D' }], reference: false, cantCover: false };
|
||||
const REPORT_REFERENCE = { covered: false, retune: [], reference: true, cantCover: false };
|
||||
const REPORT_CANT_COVER = { covered: false, retune: [], reference: false, cantCover: true };
|
||||
// The "I couldn't work it out" report — the tuner's `none` bail-out. Byte-for-byte
|
||||
// a not-covered report with no reason attached.
|
||||
const REPORT_UNKNOWN = { covered: false, retune: [], reference: false, cantCover: false };
|
||||
|
||||
// The checker runs inside the vm, so the arrays it returns belong to another
|
||||
// realm and would fail deepStrictEqual's prototype check. Copy into host arrays.
|
||||
const plain = (a) => Array.from(a);
|
||||
|
||||
const song = (over) => Object.assign(
|
||||
{ filename: 'a.sloppak', title: 'A', tuning_name: 'E Standard', tuning_offsets: '0 0 0 0 0 0', bass_only: false },
|
||||
over
|
||||
);
|
||||
|
||||
// ── The unknown-vs-mismatch distinction ─────────────────────────────────────
|
||||
|
||||
test('a covered report is a match', () => {
|
||||
const { tuningStateFromReport } = loadChecker();
|
||||
assert.equal(tuningStateFromReport(REPORT_COVERED), 'match');
|
||||
});
|
||||
|
||||
test('a not-covered report WITH a reason is a mismatch', () => {
|
||||
const { tuningStateFromReport } = loadChecker();
|
||||
assert.equal(tuningStateFromReport(REPORT_RETUNE), 'mismatch');
|
||||
assert.equal(tuningStateFromReport(REPORT_REFERENCE), 'mismatch');
|
||||
assert.equal(tuningStateFromReport(REPORT_CANT_COVER), 'mismatch');
|
||||
});
|
||||
|
||||
test('a not-covered report with NO reason is unknown, not a mismatch', () => {
|
||||
// This is the whole trust argument. The tuner returns this identical shape
|
||||
// when settings/tuner data are missing. Treating it as "wrong tuning" (which
|
||||
// the library grid's chip decorator does) would put a false ⚠ on songs that
|
||||
// are perfectly playable, on a playlist the user curated by hand.
|
||||
const { tuningStateFromReport } = loadChecker();
|
||||
assert.equal(tuningStateFromReport(REPORT_UNKNOWN), 'unknown');
|
||||
});
|
||||
|
||||
test('a null/absent report is unknown', () => {
|
||||
const { tuningStateFromReport } = loadChecker();
|
||||
assert.equal(tuningStateFromReport(null), 'unknown');
|
||||
assert.equal(tuningStateFromReport(undefined), 'unknown');
|
||||
});
|
||||
|
||||
// ── Round-tripping a whole playlist ─────────────────────────────────────────
|
||||
|
||||
test('each song is scored and reported in playlist order', async () => {
|
||||
const byFile = {
|
||||
'match.sloppak': REPORT_COVERED,
|
||||
'bad.sloppak': REPORT_RETUNE,
|
||||
'huh.sloppak': REPORT_UNKNOWN,
|
||||
};
|
||||
const songs = [
|
||||
song({ filename: 'match.sloppak', title: 'Match' }),
|
||||
song({ filename: 'bad.sloppak', title: 'Bad', tuning_offsets: '-2 -2 -2 -2 -2 -2' }),
|
||||
song({ filename: 'huh.sloppak', title: 'Huh', tuning_offsets: '-1 0 0 0 0 0' }),
|
||||
];
|
||||
// Resolve the fixture from the offsets the checker actually passed, so the
|
||||
// mapping can't silently drift out of playlist order.
|
||||
const byOffsets = new Map(songs.map((s) => [s.tuning_offsets.replace(/\s+/g, ','), byFile[s.filename]]));
|
||||
const checker = loadChecker({ coverage: async (info) => byOffsets.get(info.tuning.join(',')) });
|
||||
const out = await checker.checkPlaylistTuning(songs);
|
||||
assert.deepEqual(plain(out.map((r) => r.state)), ['match', 'mismatch', 'unknown']);
|
||||
assert.deepEqual(plain(out.map((r) => r.song.filename)), songs.map((s) => s.filename));
|
||||
});
|
||||
|
||||
test('a song with no usable tuning data is unknown WITHOUT consulting coverage', async () => {
|
||||
// Adversarial payloads: empty, whitespace, a non-numeric name with no
|
||||
// offsets, and a garbage offsets string. None of these can be scored, and
|
||||
// asking coverage about them would invite a bogus not-covered → false ⚠.
|
||||
const checker = loadChecker({ coverage: async () => REPORT_RETUNE });
|
||||
const out = await checker.checkPlaylistTuning([
|
||||
song({ filename: 'a', tuning_offsets: '', tuning_name: '' }),
|
||||
song({ filename: 'b', tuning_offsets: ' ', tuning_name: ' ' }),
|
||||
song({ filename: 'c', tuning_offsets: '', tuning_name: 'E Standard' }),
|
||||
song({ filename: 'd', tuning_offsets: 'not offsets', tuning_name: 'x' }),
|
||||
song({ filename: 'e', tuning_offsets: null, tuning_name: null }),
|
||||
]);
|
||||
assert.deepEqual(plain(out.map((r) => r.state)), ['unknown', 'unknown', 'unknown', 'unknown', 'unknown']);
|
||||
assert.equal(checker.calls.length, 0, 'coverage must not be asked about unscoreable rows');
|
||||
});
|
||||
|
||||
test('a coverage call that throws degrades to unknown, not mismatch', async () => {
|
||||
const checker = loadChecker({ coverage: async () => { throw new Error('tuner exploded'); } });
|
||||
const out = await checker.checkPlaylistTuning([song({})]);
|
||||
assert.deepEqual(plain(out.map((r) => r.state)), ['unknown']);
|
||||
});
|
||||
|
||||
test('the bass perspective uses #1003 bass offsets instead of guitar offsets', async () => {
|
||||
const checker = loadChecker({ instrument: 'bass', coverage: async () => REPORT_COVERED });
|
||||
await checker.checkPlaylistTuning([song({
|
||||
tuning_offsets: '0 0 0 0 0 0',
|
||||
bass_tuning_offsets: '-2 -2 -2 -2 -2 -2',
|
||||
})]);
|
||||
assert.deepEqual(plain(checker.calls[0].tuning), [-2, -2, -2, -2, -2, -2]);
|
||||
assert.equal(checker.calls[0].arrangement, 'Bass');
|
||||
});
|
||||
|
||||
test("the guitar perspective ignores a song's bass offsets", async () => {
|
||||
const checker = loadChecker({ instrument: 'guitar', coverage: async () => REPORT_COVERED });
|
||||
await checker.checkPlaylistTuning([song({
|
||||
tuning_offsets: '0 0 0 0 0 0',
|
||||
bass_tuning_offsets: '-2 -2 -2 -2 -2 -2',
|
||||
})]);
|
||||
assert.deepEqual(plain(checker.calls[0].tuning), [0, 0, 0, 0, 0, 0]);
|
||||
assert.equal(checker.calls[0].arrangement, 'Lead');
|
||||
});
|
||||
|
||||
test('a bass-only chart is scored against bass base pitches', async () => {
|
||||
// Otherwise a 4-string bass tuning read as guitar can false-match — the
|
||||
// cross-instrument confusion this whole feature exists to undo.
|
||||
const checker = loadChecker({ coverage: async () => REPORT_COVERED });
|
||||
await checker.checkPlaylistTuning([
|
||||
song({ filename: 'bass', tuning_offsets: '0 0 0 0', bass_only: true }),
|
||||
song({ filename: 'gtr', tuning_offsets: '0 0 0 0 0 0', bass_only: false }),
|
||||
]);
|
||||
assert.deepEqual(checker.calls.map((c) => c.arrangement), ['Bass', 'Lead']);
|
||||
assert.deepEqual(checker.calls.map((c) => c.stringCount), [4, 6]);
|
||||
});
|
||||
|
||||
test('the check stays silent when the host exposes no tuning perspective', async () => {
|
||||
// No working-tuning capability, or no tuner coverage → null, and the caller
|
||||
// renders the playlist exactly as before. Guessing "guitar" here would
|
||||
// reproduce the original bug in a new place.
|
||||
for (const opts of [{ noWorkingTuning: true }, { noCoverage: true }]) {
|
||||
const checker = loadChecker(Object.assign({ coverage: async () => REPORT_COVERED }, opts));
|
||||
assert.equal(await checker.checkPlaylistTuning([song({})]), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty playlist yields an empty result, not a crash', async () => {
|
||||
const checker = loadChecker({ coverage: async () => REPORT_COVERED });
|
||||
assert.deepEqual(plain(await checker.checkPlaylistTuning([])), []);
|
||||
assert.deepEqual(plain(await checker.checkPlaylistTuning(null)), []);
|
||||
});
|
||||
|
||||
// ── The summary ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('the summary counts mismatches against the playlist total', () => {
|
||||
const { tuningSummaryHtml } = loadChecker();
|
||||
const results = [
|
||||
{ state: 'mismatch' }, { state: 'mismatch' }, { state: 'mismatch' },
|
||||
...Array(21).fill({ state: 'match' }),
|
||||
];
|
||||
const html = tuningSummaryHtml(results);
|
||||
assert.match(html, /<strong>3<\/strong> of 24 songs aren't in your tuning/);
|
||||
});
|
||||
|
||||
test('unknowns are reported separately from mismatches and never counted as them', () => {
|
||||
const { tuningSummaryHtml } = loadChecker();
|
||||
const html = tuningSummaryHtml([{ state: 'mismatch' }, { state: 'unknown' }, { state: 'match' }]);
|
||||
assert.match(html, /<strong>1<\/strong> of 3 songs aren't in your tuning/);
|
||||
assert.match(html, /1 couldn't be checked/);
|
||||
assert.match(html, /left alone/);
|
||||
});
|
||||
|
||||
test('an all-unknown playlist makes no mismatch claim and offers no removal', () => {
|
||||
const { tuningSummaryHtml } = loadChecker();
|
||||
const html = tuningSummaryHtml([{ state: 'unknown' }, { state: 'unknown' }]);
|
||||
assert.doesNotMatch(html, /aren't in your tuning/);
|
||||
assert.doesNotMatch(html, /v3-pl-tune-remove/);
|
||||
assert.match(html, /2 couldn't be checked/);
|
||||
});
|
||||
|
||||
test('a clean playlist offers no filter and no removal button', () => {
|
||||
const { tuningSummaryHtml } = loadChecker();
|
||||
const html = tuningSummaryHtml([{ state: 'match' }, { state: 'match' }]);
|
||||
assert.match(html, /All 2 songs are in your tuning/);
|
||||
assert.doesNotMatch(html, /v3-pl-tune-only/);
|
||||
assert.doesNotMatch(html, /v3-pl-tune-remove/);
|
||||
});
|
||||
|
||||
test('an empty playlist renders no summary at all', () => {
|
||||
const { tuningSummaryHtml } = loadChecker();
|
||||
assert.equal(tuningSummaryHtml([]), '');
|
||||
});
|
||||
|
||||
// ── Read-only / explicit-action guarantees (source-level) ───────────────────
|
||||
|
||||
test('the check itself never mutates the playlist', () => {
|
||||
// checkPlaylistTuning and its helpers must contain no write verbs. The only
|
||||
// DELETE in the module's tuning path is inside the confirmed removal.
|
||||
const fns = ['function rowTuningForCheck(', 'function tuningStateFromReport(',
|
||||
'async function checkPlaylistTuning(', 'function tuningSummaryHtml('];
|
||||
for (const marker of fns) {
|
||||
const body = extractBlock(PL_SRC, marker);
|
||||
assert.doesNotMatch(body, /DELETE|jsend\(|method:/,
|
||||
marker + ' must not mutate the playlist');
|
||||
}
|
||||
});
|
||||
|
||||
test('bulk removal names every song and is confirmed before any DELETE', () => {
|
||||
const body = extractBlock(PL_SRC, 'async function applyTuningCheck(');
|
||||
// The confirm is built from the doomed titles, each escaped. The row markup
|
||||
// moved from <li> to a bulleted <div> so the confirm needs no Tailwind class
|
||||
// the committed CSS lacks — what matters is that every song is named and
|
||||
// escaped, not which element wraps it.
|
||||
assert.match(body, /doomed\.map\(\(s\) => '<(?:li|div)>[^']*' \+ esc\(s\.title \|\| s\.filename\)/);
|
||||
// … it is awaited, and an early return happens before the delete loop.
|
||||
const confirmAt = body.indexOf('uiConfirm');
|
||||
const bailAt = body.indexOf('if (!ok) return;');
|
||||
const deleteAt = body.indexOf("method: 'DELETE'");
|
||||
assert.ok(confirmAt > -1 && bailAt > confirmAt && deleteAt > bailAt,
|
||||
'DELETE must come after an awaited confirm and its bail-out');
|
||||
// And it says the songs survive in the library — the "reversible-feeling" ask.
|
||||
assert.match(body, /stay in your library/);
|
||||
});
|
||||
|
||||
test('removal targets only mismatches — never unknowns', () => {
|
||||
const body = extractBlock(PL_SRC, 'async function applyTuningCheck(');
|
||||
assert.match(body, /results\.filter\(\(r\) => r\.state === 'mismatch'\)\.map\(\(r\) => r\.song\)/);
|
||||
assert.doesNotMatch(body, /doomed[\s\S]{0,200}'unknown'/);
|
||||
});
|
||||
|
||||
test('unknown is styled distinctly from mismatch', () => {
|
||||
const body = extractBlock(PL_SRC, 'function paintTuningChip(');
|
||||
// Mismatch is amber; unknown is the neutral chip, dimmed — not amber.
|
||||
assert.match(body, /state === 'mismatch' \? 'bg-amber-400'/);
|
||||
assert.match(body, /state === 'unknown'\) chip\.classList\.add\('opacity-60'\)/);
|
||||
// …and both carry a text marker, so the states never rest on colour alone.
|
||||
assert.match(body, /state === 'mismatch' \? ' ⚠' : state === 'unknown' \? ' \?'/);
|
||||
});
|
||||
|
||||
// ── Collaborator contract ───────────────────────────────────────────────────
|
||||
|
||||
test('the tuner coverage report still carries the fields the states are read from', () => {
|
||||
// If the tuner plugin drops `retune`/`reference`/`cantCover`, every mismatch
|
||||
// silently degrades to "unknown" and the feature goes quiet. Pin the shape
|
||||
// the fixtures above rely on.
|
||||
const tuner = fs.readFileSync(TUNER_JS, 'utf8');
|
||||
const body = extractBlock(tuner, 'async function _computeCoverageReport(');
|
||||
for (const field of ['covered', 'retune', 'reference', 'cantCover']) {
|
||||
assert.match(body, new RegExp(field), `coverage report must still carry ${field}`);
|
||||
}
|
||||
assert.match(body, /const none = \{ covered: false, retune: \[\], reference: false, cantCover: false \}/,
|
||||
'the no-data bail-out must stay a reasonless not-covered report — that is what "unknown" detects');
|
||||
});
|
||||
@@ -67,11 +67,27 @@ test('v3 songs.js uses display helpers for album-art tuning badge', () => {
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
// The card renderer's row variable was renamed song → shown when grouped
|
||||
// cards landed (the badge reads the representative chart); accept either.
|
||||
assert.match(src, /displayTuningName\((?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning\)/);
|
||||
// The raw read then moved behind shownTuningName() so the badge can answer
|
||||
// for the active tuning perspective — accept that indirection too, and pin
|
||||
// the fallback inside the helper below so this stays a real guard.
|
||||
assert.match(
|
||||
src,
|
||||
/displayTuningName\((?:(?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning|shownTuning)\)/,
|
||||
);
|
||||
assert.match(src, /displayTuningTargets/);
|
||||
assert.match(src, /parseRawTuningOffsets/);
|
||||
});
|
||||
|
||||
test('the tuning-perspective helper still falls back to tuning_name || tuning', () => {
|
||||
// shownTuningName() is what the badge now reads. With no perspective field
|
||||
// set (guitar-lead, the default) it must resolve exactly what the badge
|
||||
// used to read inline, or guitar players silently lose their tuning label.
|
||||
const src = fs.readFileSync(SONGS_JS, 'utf8');
|
||||
const body = src.match(/function shownTuningName\(song\)\s*\{[\s\S]*?\n {4}\}/);
|
||||
assert.ok(body, 'shownTuningName() not found — the badge read moved again');
|
||||
assert.match(body[0], /return song\.tuning_name \|\| song\.tuning;/);
|
||||
});
|
||||
|
||||
test('raw offset tuning_name does not appear in rendered card HTML', () => {
|
||||
const html = renderSongCardBadge({ tuning_name: '-2 0 0 0 -2' }, helpers);
|
||||
assert.doesNotMatch(html, /-2 0 0 0 -2/);
|
||||
|
||||
@@ -117,3 +117,22 @@ test('a throwing document does not take the venue down with it', () => {
|
||||
global.document = prev;
|
||||
}
|
||||
});
|
||||
|
||||
// The arrival flyover must NOT replay for songs 2..N of a set. onSongLoaded
|
||||
// consults the play queue: a continuation (gig/album/playlist song 2+) carries
|
||||
// the room over with a loop crossfade, only an arrival plays the intro.
|
||||
test('a set continuation carries the room over instead of re-flying-in', () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'venue-crowd.js'), 'utf8');
|
||||
const start = src.indexOf('function onSongLoaded(');
|
||||
const open = src.indexOf('{', src.indexOf(')', start));
|
||||
let depth = 1, i = open + 1;
|
||||
while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; }
|
||||
const fn = src.slice(start, i);
|
||||
const contIdx = fn.search(/_isSetContinuation\s*\(\s*\)/);
|
||||
const introIdx = fn.search(/playIntro\s*\(/);
|
||||
assert.ok(contIdx !== -1, 'onSongLoaded must consult the set-continuation signal');
|
||||
assert.ok(introIdx !== -1, 'the intro must still exist for a real arrival');
|
||||
assert.ok(contIdx < introIdx, 'the continuation check must gate the flyover — a set song 2+ must not fly in');
|
||||
});
|
||||
|
||||
@@ -445,3 +445,30 @@ def test_gold_intake_rejects_junk(client, meta_db):
|
||||
res = client.post("/api/plugins/career/drill-state",
|
||||
json={"byNode": {}, "goldImprov": blob})
|
||||
assert res.status_code == 413
|
||||
|
||||
|
||||
def test_gig_includes_songs_played_on_another_instrument(client, meta_db):
|
||||
# feedBack#… (tester): "Metalcore says 137 songs, only shows 1 in the gig list".
|
||||
# A song played on a DIFFERENT instrument's arrangement has a stats row, so it
|
||||
# was excluded from the unplayed filler — and its played bucket is that other
|
||||
# instrument's, not this passport's — so it fell into a gap and could never be
|
||||
# gigged. A guitar passport with a library of bass-played metalcore got a 404.
|
||||
for i in range(137):
|
||||
meta_db.add(f"mc{i}.feedpak", 0, 0.80, genre="Metalcore", arrangements=BASS)
|
||||
res = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Metalcore", "size": 4})
|
||||
assert res.status_code == 200, "a full library of the genre must never 404"
|
||||
assert len(res.json()["songs"]) == 4, "the gig must fill from the library, not the gap"
|
||||
|
||||
|
||||
def test_gig_reroll_changes_the_set(client, meta_db):
|
||||
# feedBack#… (tester): "Passport re-roll does not change songs". A set drawn
|
||||
# from the filler used to be the library's first N in table order, every time.
|
||||
for i in range(40):
|
||||
meta_db.add_song_only(f"un{i}.feedpak", genre="Metalcore")
|
||||
sets = set()
|
||||
for _ in range(5):
|
||||
r = client.post("/api/plugins/career/gigs/propose",
|
||||
json={"instrument": "guitar", "genre": "Metalcore", "size": 4})
|
||||
sets.add(tuple(sorted(s["filename"] for s in r.json()["songs"])))
|
||||
assert len(sets) > 1, "re-roll must be able to produce a different set"
|
||||
|
||||
@@ -137,6 +137,81 @@ def test_background_scan_discovers_both_suffixes(tmp_path, scan_server):
|
||||
assert "ignore.zip" not in seen
|
||||
|
||||
|
||||
# ── 2b. directory-signature fast path (skip the full re-stat) ────────────────
|
||||
|
||||
def test_dir_signature_fast_path_skips_unchanged_tree(tmp_path, scan_server):
|
||||
"""After a full scan records the library-dir signature, a second scan with
|
||||
an unchanged tree takes the fast path and does NOT re-glob/extract — but a
|
||||
forced scan (manual Refresh) always does the full pass, and a new song
|
||||
(which bumps the dir mtime) reverts to a full pass on its own."""
|
||||
import unittest.mock as mock
|
||||
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
(dlc / "a.feedpak").write_bytes(b"")
|
||||
(tmp_path / "config.json").write_text('{"dlc_dir": "%s"}' % dlc)
|
||||
|
||||
scan = importlib.import_module("scan")
|
||||
seen: list[str] = []
|
||||
|
||||
def mock_extract(f, dlc_dir):
|
||||
seen.append(f.name)
|
||||
return {"title": f.name, "artist": "", "album": ""}
|
||||
|
||||
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
|
||||
# 1) first pass: full scan, extracts a.feedpak (+ seeded builtins),
|
||||
# records the signature
|
||||
scan.background_scan()
|
||||
assert "a.feedpak" in seen
|
||||
assert scan._dir_signature_file().exists()
|
||||
|
||||
# 2) unchanged tree: fast path — no glob, no extraction at all
|
||||
seen.clear()
|
||||
scan.background_scan()
|
||||
assert seen == []
|
||||
assert scan.status()["stage"] == "complete"
|
||||
|
||||
# 3) a new song bumps the dlc mtime → signature mismatch → full pass
|
||||
# picks it up on its own (no manual Refresh needed for adds)
|
||||
(dlc / "b.feedpak").write_bytes(b"")
|
||||
seen.clear()
|
||||
scan.background_scan()
|
||||
assert "b.feedpak" in seen
|
||||
|
||||
# 4) force=True (Refresh) bypasses the fast path even on a settled tree:
|
||||
# with the signature now current, a plain scan skips, a forced one lists
|
||||
seen.clear()
|
||||
scan.background_scan() # fast path
|
||||
assert seen == []
|
||||
forced_listed = []
|
||||
real_delete_missing = scan.appstate.meta_db.delete_missing
|
||||
def _spy(files):
|
||||
forced_listed.append(set(files))
|
||||
return real_delete_missing(files)
|
||||
with mock.patch.object(scan.appstate.meta_db, "delete_missing", new=_spy):
|
||||
scan.background_scan(force=True)
|
||||
assert forced_listed, "force=True must run the full listing pass"
|
||||
|
||||
|
||||
def test_dir_signature_tracks_directory_form_song_own_dir(tmp_path):
|
||||
"""A directory-form song (loose folder / directory bundle) records its OWN
|
||||
directory in the signature, so an in-place file change inside it — which
|
||||
bumps that folder's mtime but not its parent's — invalidates the fast path.
|
||||
A file-form sloppak (a plain .feedpak zip) is not a dir and adds nothing."""
|
||||
scan = importlib.import_module("scan")
|
||||
dlc = tmp_path / "dlc"
|
||||
(dlc / "packs").mkdir(parents=True)
|
||||
loose = dlc / "packs" / "my_loose_song" # directory-form song
|
||||
loose.mkdir()
|
||||
zipped = dlc / "packs" / "zipped.feedpak" # file-form song
|
||||
zipped.write_bytes(b"")
|
||||
|
||||
rels = scan._library_dirs([loose, zipped], dlc)
|
||||
assert "packs/my_loose_song" in rels, "directory-form song must track its own dir"
|
||||
assert "packs" in rels and "." in rels
|
||||
assert "packs/zipped.feedpak" not in rels, "a file-form sloppak is not a tracked dir"
|
||||
|
||||
|
||||
# ── 3. POST /api/songs/upload gate (endpoint) ────────────────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
"""Instrument-aware tuning in the library (the KwasimodoZAZA bass report).
|
||||
|
||||
A song's BASS chart is often tuned differently from its guitar chart, but the
|
||||
library indexed exactly one guitar-first tuning per song — so a bass player
|
||||
filtering "Drop D" got songs whose GUITAR is in Drop D, and playlists built
|
||||
that way were wrong.
|
||||
|
||||
These tests round-trip through the real extractors, the real scanner
|
||||
derivation, the real SQLite schema/migration, and the real HTTP surface. The
|
||||
only thing stubbed is metadata EXTRACTION in the scan tests (the production
|
||||
process pool can't reach an in-process mock) — never the code under test.
|
||||
|
||||
Real-library notes, all confirmed against actual pack contents:
|
||||
|
||||
* Bass arrangements usually store SIX-element offset arrays even when the
|
||||
chart is a 4-string part — slots 4-5 are PADDING (no bass chart in the
|
||||
corpus references string index 4 or 5). So bass offsets are truncated to 4
|
||||
before naming or grouping. The feedpak spec has no string-count field, so 4
|
||||
is a documented default, not a read value.
|
||||
* AC/DC "Girls Got Rhythm" stores [5,5,5,5,4,4] — every string up a fourth,
|
||||
which no bassist plays. That is BAD DATA, and it must never be NAMED, or the
|
||||
library sends a player to retune to a tuning that does not exist.
|
||||
* Covet "Shibuya" (custom guitar tuning, dead-standard bass) is the headline
|
||||
regression: the tester's bug in a single song.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import sloppak as sloppak_mod
|
||||
from scan_worker import _extract_meta_for_file
|
||||
from tunings import (
|
||||
PERSPECTIVES, bass_offsets_are_plausible, bass_tuning_key, bass_tuning_name,
|
||||
chart_is_playable_in, normalize_bass_offsets, perspective_tuning_key,
|
||||
tuning_name,
|
||||
)
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server_mod):
|
||||
c = TestClient(server_mod.app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def _pack(root, name, arrangements):
|
||||
"""A directory-form pack whose manifest carries per-arrangement tunings."""
|
||||
d = root / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.yaml").write_text(yaml.safe_dump({
|
||||
"title": name, "artist": "A", "duration": 100,
|
||||
"arrangements": arrangements, "stems": [],
|
||||
}), encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
def _put(server_mod, *, filename, title, tuning_name_="E Standard",
|
||||
tuning_sort_key=0, tuning_offsets="0 0 0 0 0 0",
|
||||
bass_tuning_name="", bass_tuning_sort_key=0, bass_tuning_offsets="",
|
||||
bass_tuning_key=""):
|
||||
server_mod.meta_db.put(filename, 1.0, 1, {
|
||||
"title": title, "artist": "A", "album": "A - LP", "year": "2010",
|
||||
"duration": 200.0, "tuning": tuning_name_, "arrangements": [],
|
||||
"has_lyrics": False, "format": "sloppak", "stem_ids": [],
|
||||
"tuning_name": tuning_name_,
|
||||
"tuning_sort_key": tuning_sort_key,
|
||||
"tuning_offsets": tuning_offsets,
|
||||
"bass_tuning_name": bass_tuning_name,
|
||||
"bass_tuning_sort_key": bass_tuning_sort_key,
|
||||
"bass_tuning_offsets": bass_tuning_offsets,
|
||||
"bass_tuning_key": bass_tuning_key,
|
||||
})
|
||||
|
||||
|
||||
# ── 1. Extraction: sloppak ───────────────────────────────────────────────────
|
||||
|
||||
def test_sloppak_extract_indexes_both_tunings_when_they_differ(tmp_path):
|
||||
"""The reported case: guitar down a step, bass in standard. BOTH must be
|
||||
indexed — previously only the guitar tuning survived."""
|
||||
d = _pack(tmp_path, "differ.sloppak", [
|
||||
{"name": "Lead", "tuning": [-2, 0, 0, -1, -2, 0]},
|
||||
{"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
])
|
||||
meta = sloppak_mod.extract_meta(d)
|
||||
assert meta["tuning_offsets"] == [-2, 0, 0, -1, -2, 0]
|
||||
assert meta["bass_tuning_offsets"] == [0, 0, 0, 0, 0, 0]
|
||||
|
||||
|
||||
def test_sloppak_extract_leaves_bass_absent_without_bass_arrangement(tmp_path):
|
||||
"""No bass chart → None, NOT a copy of the guitar tuning. The library
|
||||
falls back explicitly, so 'no bass part' stays distinguishable."""
|
||||
d = _pack(tmp_path, "nobass.sloppak", [
|
||||
{"name": "Lead", "tuning": [-2, -2, -2, -2, -2, -2]},
|
||||
{"name": "Rhythm", "tuning": [-2, -2, -2, -2, -2, -2]},
|
||||
])
|
||||
meta = sloppak_mod.extract_meta(d)
|
||||
assert meta["tuning_offsets"] == [-2, -2, -2, -2, -2, -2]
|
||||
assert meta["bass_tuning_offsets"] is None
|
||||
|
||||
|
||||
def test_sloppak_extract_bass_wins_over_guitar_first_ordering(tmp_path):
|
||||
"""The bass entry is listed FIRST in the manifest; the song tuning must
|
||||
still be the guitar's while the bass column takes the bass entry — the two
|
||||
selections are independent, not 'first wins'."""
|
||||
d = _pack(tmp_path, "order.sloppak", [
|
||||
{"name": "Bass", "tuning": [-4, -4, -4, -4, -4, -4]},
|
||||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
])
|
||||
meta = sloppak_mod.extract_meta(d)
|
||||
assert meta["tuning_offsets"] == [0, 0, 0, 0, 0, 0]
|
||||
assert meta["bass_tuning_offsets"] == [-4, -4, -4, -4, -4, -4]
|
||||
|
||||
|
||||
def test_sloppak_extract_ignores_bass_arrangement_without_a_tuning(tmp_path):
|
||||
"""A bass chart that authors no tuning gives us nothing to index; the
|
||||
column stays empty rather than defaulting to a wrong all-zeros."""
|
||||
d = _pack(tmp_path, "untuned.sloppak", [
|
||||
{"name": "Lead", "tuning": [-2, -2, -2, -2, -2, -2]},
|
||||
{"name": "Bass"},
|
||||
])
|
||||
assert sloppak_mod.extract_meta(d)["bass_tuning_offsets"] is None
|
||||
|
||||
|
||||
def test_sloppak_extract_falls_back_to_an_alt_bass_chart(tmp_path):
|
||||
"""Only a "Bass 2" chart exists. Using it beats reporting the guitar
|
||||
tuning as the player's bass tuning."""
|
||||
d = _pack(tmp_path, "altbass.sloppak", [
|
||||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
{"name": "Bass 2", "tuning": [-2, 0, 0, 0, 0, 0]},
|
||||
])
|
||||
assert sloppak_mod.extract_meta(d)["bass_tuning_offsets"] == [-2, 0, 0, 0, 0, 0]
|
||||
|
||||
|
||||
# ── 2. Scanner derivation (name / sort key / offsets string) ─────────────────
|
||||
|
||||
def test_scan_worker_derives_bass_columns_like_the_guitar_ones(tmp_path):
|
||||
"""Guitar columns keep all six strings; bass columns are TRUNCATED to the
|
||||
bass's four (the stored tail is padding — see tunings.normalize_bass_offsets)."""
|
||||
d = _pack(tmp_path, "derive.sloppak", [
|
||||
{"name": "Lead", "tuning": [-2, -2, -2, -2, -2, -2]},
|
||||
{"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["tuning_name"] == "D Standard"
|
||||
assert meta["tuning_sort_key"] == -12
|
||||
assert meta["tuning_offsets"] == "-2 -2 -2 -2 -2 -2"
|
||||
assert meta["bass_tuning_name"] == "E Standard"
|
||||
assert meta["bass_tuning_sort_key"] == 0
|
||||
assert meta["bass_tuning_offsets"] == "0 0 0 0"
|
||||
# Canonical key = absolute open pitches of a 4-string bass in standard.
|
||||
assert meta["bass_tuning_key"] == "bass:28:33:38:43"
|
||||
|
||||
|
||||
def test_bass_padding_is_truncated_before_naming_and_grouping(tmp_path):
|
||||
"""The padded tail must never reach the namer or the group key: a bass
|
||||
stored six-wide and the same tuning stored four-wide must produce
|
||||
IDENTICAL indexed columns."""
|
||||
six = _extract_meta_for_file(_pack(tmp_path, "six.sloppak", [
|
||||
{"name": "Bass", "tuning": [-2, 0, 0, 0, 0, 0]}]))
|
||||
four = _extract_meta_for_file(_pack(tmp_path, "four.sloppak", [
|
||||
{"name": "Bass", "tuning": [-2, 0, 0, 0]}]))
|
||||
for col in ("bass_tuning_name", "bass_tuning_offsets",
|
||||
"bass_tuning_sort_key", "bass_tuning_key"):
|
||||
assert six[col] == four[col], col
|
||||
assert six["bass_tuning_name"] == "Drop D"
|
||||
|
||||
|
||||
def test_scan_worker_bass_columns_empty_without_a_bass_arrangement(tmp_path):
|
||||
"""Empty string, never None: '' is the indexed 'we looked, no bass chart'
|
||||
state, while NULL means 'never extracted' and triggers a re-scan."""
|
||||
d = _pack(tmp_path, "nobass2.sloppak", [{"name": "Lead", "tuning": [0] * 6}])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["bass_tuning_name"] == ""
|
||||
assert meta["bass_tuning_sort_key"] == 0
|
||||
assert meta["bass_tuning_offsets"] == ""
|
||||
|
||||
|
||||
def test_implausible_bass_tuning_is_never_named(tmp_path):
|
||||
"""Real library data, and it is BAD DATA: AC/DC "Girls Got Rhythm" stores
|
||||
a bass tuning of [5,5,5,5,4,4] — every string up a perfect fourth, which
|
||||
no bassist plays (roughly double string tension), on a song whose guitar
|
||||
chart is dead standard.
|
||||
|
||||
Truncation alone would leave [5,5,5,5] = "all strings up a 4th", which the
|
||||
namer WOULD happily name. Naming it would send a player off to retune to a
|
||||
tuning that does not exist, so the plausibility guard must refuse: bassists
|
||||
tune down, essentially never up."""
|
||||
d = _pack(tmp_path, "weird.sloppak", [
|
||||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
{"name": "Bass", "tuning": [5, 5, 5, 5, 4, 4]},
|
||||
])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["bass_tuning_name"] == "Custom Tuning"
|
||||
assert meta["bass_tuning_offsets"] == "5 5 5 5"
|
||||
assert meta["bass_tuning_sort_key"] == 20
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offsets", [
|
||||
[5, 5, 5, 5], [5, 5, 5, 5, 4, 4], [2, 2, 2, 2], [12, 12, 12, 12],
|
||||
])
|
||||
def test_up_tuned_bass_offsets_are_refused_by_the_guard(offsets):
|
||||
"""Anything above +1 semitone is data we do not trust. Note the namer
|
||||
ALONE would name several of these ([2,2,2,2] -> "F# Standard"), which is
|
||||
exactly the retune-to-nowhere the guard exists to prevent."""
|
||||
norm = normalize_bass_offsets(offsets)
|
||||
assert bass_offsets_are_plausible(norm) is False
|
||||
assert bass_tuning_name(norm) == "Custom Tuning"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offsets,expected", [
|
||||
([0, 0, 0, 0], "E Standard"), # standard
|
||||
([-1, -1, -1, -1], "Eb Standard"), # down a semitone
|
||||
([-2, 0, 0, 0], "Drop D"), # drop
|
||||
([1, 1, 1, 1], "F Standard"), # +1 is the plausible ceiling, still named
|
||||
])
|
||||
def test_plausible_bass_tunings_are_still_named(offsets, expected):
|
||||
"""The guard must not over-fire: real down-tunings, standard, and the +1
|
||||
ceiling all keep their names."""
|
||||
assert bass_tuning_name(offsets) == expected
|
||||
|
||||
|
||||
# ── 3. Storage round-trip + the pre-migration re-extract marker ──────────────
|
||||
|
||||
def test_put_get_round_trips_the_bass_columns(server_mod):
|
||||
_put(server_mod, filename="rt.sloppak", title="RT",
|
||||
tuning_name_="D Standard", tuning_sort_key=-12,
|
||||
tuning_offsets="-2 -2 -2 -2 -2 -2",
|
||||
bass_tuning_name="E Standard", bass_tuning_offsets="0 0 0 0 0 0")
|
||||
got = server_mod.meta_db.get("rt.sloppak", 1.0, 1)
|
||||
assert got["tuning_name"] == "D Standard"
|
||||
assert got["bass_tuning_name"] == "E Standard"
|
||||
assert got["bass_tuning_offsets"] == "0 0 0 0 0 0"
|
||||
|
||||
|
||||
def test_put_never_writes_null_bass_columns(server_mod):
|
||||
"""A freshly-scanned row is by definition extracted, so even a song with
|
||||
no bass chart stores '' — otherwise it would look pre-migration forever
|
||||
and the scanner would re-extract it on every single pass."""
|
||||
_put(server_mod, filename="fresh.sloppak", title="Fresh")
|
||||
row = server_mod.meta_db.conn.execute(
|
||||
"SELECT bass_tuning_name FROM songs WHERE filename = 'fresh.sloppak'").fetchone()
|
||||
assert row[0] == ""
|
||||
assert server_mod.meta_db.get("fresh.sloppak", 1.0, 1)["bass_tuning_name"] == ""
|
||||
|
||||
|
||||
def test_pre_migration_row_reads_back_as_null(server_mod):
|
||||
"""A row written before the columns existed (simulated with raw SQL that
|
||||
omits them) reads back None — the marker the scanner keys its re-extract
|
||||
on. If this ever became '' the backfill would silently never run."""
|
||||
server_mod.meta_db.conn.execute(
|
||||
"INSERT INTO songs (filename, mtime, size, title, artist, album, year, "
|
||||
"duration, tuning, arrangements, has_lyrics, format, stem_count, "
|
||||
"stem_ids, tuning_name, tuning_sort_key, tuning_offsets) "
|
||||
"VALUES ('old.sloppak', 1.0, 1, 'Old', 'A', 'A - LP', '2010', 200.0, "
|
||||
"'E Standard', '[]', 0, 'sloppak', 0, '[]', 'E Standard', 0, '0 0 0 0 0 0')")
|
||||
server_mod.meta_db.conn.commit()
|
||||
got = server_mod.meta_db.get("old.sloppak", 1.0, 1)
|
||||
assert got["bass_tuning_name"] is None
|
||||
# Same for the canonical key: coalescing this to '' would make the
|
||||
# scanner's re-extract check unfireable and strand the backfill.
|
||||
assert got["bass_tuning_key"] is None
|
||||
|
||||
|
||||
def test_a_row_missing_only_the_canonical_key_still_re_extracts(server_mod):
|
||||
"""A row scanned by an EARLIER build of this feature has bass_tuning_name
|
||||
but no bass_tuning_key. It must still be re-queued, or its custom tunings
|
||||
would group on the old serialization-dependent key forever."""
|
||||
_put(server_mod, filename="halfway.sloppak", title="Halfway",
|
||||
bass_tuning_name="Drop D", bass_tuning_offsets="-2 0 0 0")
|
||||
server_mod.meta_db.conn.execute(
|
||||
"UPDATE songs SET bass_tuning_key = NULL WHERE filename = 'halfway.sloppak'")
|
||||
server_mod.meta_db.conn.commit()
|
||||
cached = server_mod.meta_db.get("halfway.sloppak", 1.0, 1)
|
||||
assert cached["bass_tuning_name"] == "Drop D"
|
||||
assert cached["bass_tuning_key"] is None # → the scanner re-queues it
|
||||
|
||||
|
||||
# ── 4. The migration actually backfills (the highest-risk gap) ───────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def scan_server(tmp_path, monkeypatch, isolate_logging, reset_scan_state):
|
||||
"""Server with the background scan forced in-process (see
|
||||
test_feedpak_extension.py::scan_server — the production spawn pool can't
|
||||
reach an in-process mock)."""
|
||||
import concurrent.futures
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
monkeypatch.delenv("DLC_DIR", raising=False)
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
import scan as scan_mod
|
||||
monkeypatch.setattr(
|
||||
scan_mod, "_make_scan_executor",
|
||||
lambda: concurrent.futures.ThreadPoolExecutor(max_workers=4),
|
||||
)
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_existing_library_backfills_bass_tuning_on_next_scan(tmp_path, scan_server):
|
||||
"""END TO END for every CURRENT user: a settled library whose rows predate
|
||||
the bass columns must re-extract on the next scan.
|
||||
|
||||
Both guards are exercised together — the row-level "bass column is NULL →
|
||||
re-queue" AND the tree-signature fast path, which on an unchanged library
|
||||
would otherwise skip the listing pass entirely and strand the backfill.
|
||||
Then a second scan must NOT re-extract (the backfill converges, it doesn't
|
||||
re-scan the whole library every launch).
|
||||
"""
|
||||
import unittest.mock as mock
|
||||
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
(dlc / "song.feedpak").write_bytes(b"")
|
||||
# json.dumps, not %s: a Windows path interpolated raw produces invalid JSON
|
||||
# escapes (\U, \d), the config silently fails to parse, and the scan then
|
||||
# reports "no DLC folder configured" and extracts nothing.
|
||||
(tmp_path / "config.json").write_text(
|
||||
json.dumps({"dlc_dir": str(dlc)}), encoding="utf-8")
|
||||
|
||||
scan = importlib.import_module("scan")
|
||||
seen: list[str] = []
|
||||
|
||||
def mock_extract(f, dlc_dir):
|
||||
seen.append(f.name)
|
||||
return {"title": f.name, "artist": "A", "album": "",
|
||||
"bass_tuning_name": "Drop D", "bass_tuning_sort_key": -2,
|
||||
"bass_tuning_offsets": "-2 0 0 0 0 0"}
|
||||
|
||||
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
|
||||
scan.background_scan()
|
||||
assert "song.feedpak" in seen
|
||||
|
||||
# Simulate the pre-migration state: the row exists and is otherwise
|
||||
# fresh (mtime/size match), but its bass columns were never extracted.
|
||||
scan.appstate.meta_db.conn.execute(
|
||||
"UPDATE songs SET bass_tuning_name = NULL, bass_tuning_sort_key = NULL, "
|
||||
"bass_tuning_offsets = NULL")
|
||||
scan.appstate.meta_db.conn.commit()
|
||||
|
||||
seen.clear()
|
||||
scan.background_scan()
|
||||
assert "song.feedpak" in seen, (
|
||||
"a row with NULL bass columns must re-extract — otherwise no "
|
||||
"existing library ever gets the bass tuning")
|
||||
|
||||
row = scan.appstate.meta_db.conn.execute(
|
||||
"SELECT bass_tuning_name, bass_tuning_offsets FROM songs "
|
||||
"WHERE filename = 'song.feedpak'").fetchone()
|
||||
assert row == ("Drop D", "-2 0 0 0 0 0")
|
||||
|
||||
# Converged: the fast path is back and nothing re-extracts.
|
||||
seen.clear()
|
||||
scan.background_scan()
|
||||
assert seen == []
|
||||
|
||||
|
||||
# ── 5. The facet endpoint ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def facet_seeded(server_mod):
|
||||
"""Three shapes, matching the real library's distribution:
|
||||
differ — guitar D Standard, bass E Standard (the bug)
|
||||
match — both Drop D (common)
|
||||
nobass — guitar Drop D, no bass chart (fallback, common)
|
||||
"""
|
||||
_put(server_mod, filename="differ.sloppak", title="Differ",
|
||||
tuning_name_="D Standard", tuning_sort_key=-12,
|
||||
tuning_offsets="-2 -2 -2 -2 -2 -2",
|
||||
bass_tuning_name="E Standard", bass_tuning_sort_key=0,
|
||||
bass_tuning_offsets="0 0 0 0 0 0")
|
||||
_put(server_mod, filename="match.sloppak", title="Match",
|
||||
tuning_name_="Drop D", tuning_sort_key=-2, tuning_offsets="-2 0 0 0 0 0",
|
||||
bass_tuning_name="Drop D", bass_tuning_sort_key=-2,
|
||||
bass_tuning_offsets="-2 0 0 0 0 0")
|
||||
_put(server_mod, filename="nobass.sloppak", title="NoBass",
|
||||
tuning_name_="Drop D", tuning_sort_key=-2, tuning_offsets="-2 0 0 0 0 0")
|
||||
|
||||
|
||||
def _facet(client, **kw):
|
||||
return {t["name"]: t["count"]
|
||||
for t in client.get("/api/library/tuning-names", params=kw).json()["tunings"]}
|
||||
|
||||
|
||||
def test_facet_defaults_to_the_guitar_tuning(client, facet_seeded):
|
||||
assert _facet(client) == {"D Standard": 1, "Drop D": 2}
|
||||
|
||||
|
||||
def test_facet_bass_groups_by_bass_tuning_with_guitar_fallback(client, facet_seeded):
|
||||
"""differ counts under its BASS tuning (E Standard), match under Drop D,
|
||||
and nobass — having no bass chart — falls back to its guitar Drop D rather
|
||||
than vanishing from the facet."""
|
||||
assert _facet(client, instrument="bass") == {"E Standard": 1, "Drop D": 2}
|
||||
|
||||
|
||||
def test_facet_ignores_an_unknown_instrument(client, facet_seeded):
|
||||
"""An unknown value must not silently change filter semantics."""
|
||||
assert _facet(client, instrument="theremin") == _facet(client)
|
||||
|
||||
|
||||
# ── 6. The filter: the actual reported bug ───────────────────────────────────
|
||||
|
||||
def _files(client, **kw):
|
||||
return {s["filename"] for s in client.get("/api/library", params=kw).json()["songs"]}
|
||||
|
||||
|
||||
def test_bass_filter_excludes_a_song_whose_only_match_is_its_guitar_tuning(
|
||||
client, facet_seeded):
|
||||
"""THE BUG. Filtering bass "D Standard" must NOT return `differ` — its
|
||||
D Standard is the GUITAR chart; its bass is in E Standard."""
|
||||
assert _files(client, tunings="D Standard") == {"differ.sloppak"}
|
||||
assert _files(client, tunings="D Standard", instrument="bass") == set()
|
||||
|
||||
|
||||
def test_bass_filter_returns_songs_by_their_bass_tuning(client, facet_seeded):
|
||||
"""…and the converse: bass "E Standard" finds `differ`, which the guitar
|
||||
filter would never return."""
|
||||
assert _files(client, tunings="E Standard") == set()
|
||||
assert _files(client, tunings="E Standard", instrument="bass") == {"differ.sloppak"}
|
||||
|
||||
|
||||
def test_bass_filter_keeps_songs_without_a_bass_arrangement_via_fallback(
|
||||
client, facet_seeded):
|
||||
"""The most common shape. `nobass` has no bass chart, so it must still be
|
||||
reachable under its guitar tuning instead of disappearing for bass users —
|
||||
and the facet's count for that pill must equal what the filter returns."""
|
||||
got = _files(client, tunings="Drop D", instrument="bass")
|
||||
assert got == {"match.sloppak", "nobass.sloppak"}
|
||||
assert _facet(client, instrument="bass")["Drop D"] == len(got)
|
||||
|
||||
|
||||
def test_custom_bass_tunings_stay_distinct_under_their_offsets(client, server_mod):
|
||||
"""Two unnameable bass tunings both label "Custom Tuning"; the facet keys
|
||||
them on raw offsets so selecting one doesn't drag in the other. Uses the
|
||||
real [5,5,5,5,4,4] shape from the library."""
|
||||
_put(server_mod, filename="c1.sloppak", title="C1",
|
||||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=28,
|
||||
bass_tuning_offsets="5 5 5 5 4 4")
|
||||
_put(server_mod, filename="c2.sloppak", title="C2",
|
||||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=-7,
|
||||
bass_tuning_offsets="-3 -1 -1 -1 -1 0")
|
||||
keys = [t["key"] for t in client.get(
|
||||
"/api/library/tuning-names", params={"instrument": "bass"}).json()["tunings"]
|
||||
if t["name"] == "Custom Tuning"]
|
||||
assert sorted(keys) == sorted(["5 5 5 5 4 4", "-3 -1 -1 -1 -1 0"])
|
||||
assert _files(client, tunings="5 5 5 5 4 4", instrument="bass") == {"c1.sloppak"}
|
||||
|
||||
|
||||
def test_stats_facet_counts_agree_with_the_bass_filter(client, facet_seeded):
|
||||
"""The A–Z rail / count surface must apply the same instrument-aware
|
||||
predicate as the grid, or the header count contradicts the results."""
|
||||
body = client.get("/api/library/stats", params={
|
||||
"tunings": "Drop D", "instrument": "bass"}).json()
|
||||
assert body["total_songs"] == 2
|
||||
|
||||
|
||||
# ── 7. Sort ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_tuning_sort_respects_the_instrument(client, facet_seeded):
|
||||
"""Tuning sort is musical distance from E Standard. For a bass player that
|
||||
distance must be measured on the BASS tuning: `differ` is the furthest
|
||||
song by guitar (D Standard, |−12|) but the nearest by bass (E Standard, 0),
|
||||
so it moves from last to first."""
|
||||
def order(**kw):
|
||||
return [s["filename"] for s in client.get(
|
||||
"/api/library", params={"sort": "tuning", **kw}).json()["songs"]]
|
||||
|
||||
guitar = order()
|
||||
assert guitar[-1] == "differ.sloppak"
|
||||
bass = order(instrument="bass")
|
||||
assert bass[0] == "differ.sloppak"
|
||||
|
||||
|
||||
# ── 8. Song payload ──────────────────────────────────────────────────────────
|
||||
|
||||
# ── 9. Real-library offset SHAPES ────────────────────────────────────────────
|
||||
# Measured across the 59-pack test library: bass offset lists are NOT reliably
|
||||
# 4 or reliably 6 — 41 store six elements, 1 stores four. Two six-element ones
|
||||
# diverge in the tail (AC/DC "Girls Got Rhythm" [5,5,5,5,4,4]; Intervals
|
||||
# "Libra" [-2,0,0,0,0,0]). Nothing may crash or mislabel on any of them.
|
||||
|
||||
@pytest.mark.parametrize("offsets,expected", [
|
||||
([0, 0, 0, 0], "E Standard"), # four-element (the 1 outlier)
|
||||
([0, 0, 0, 0, 0, 0], "E Standard"), # six-element all-equal (39 of them)
|
||||
([-1, -1, -1, -1], "Eb Standard"), # four-element, down a semitone
|
||||
([5, 5, 5, 5, 4, 4], "Custom Tuning"), # AC/DC — divergent tail
|
||||
([-2, 0, 0, 0, 0, 0], "Drop D"), # Intervals — drop + trailing zeros
|
||||
([0, 0, 0, 0, 0], "Custom Tuning"), # five: no naming convention → custom
|
||||
])
|
||||
def test_real_library_bass_offset_shapes_name_without_crashing(offsets, expected):
|
||||
assert tuning_name(offsets) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offsets", [
|
||||
[0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [5, 5, 5, 5, 4, 4], [-2, 0, 0, 0, 0, 0],
|
||||
])
|
||||
def test_real_library_bass_offset_shapes_survive_extraction(tmp_path, offsets):
|
||||
"""Each shape must round-trip the real extractor + scanner derivation,
|
||||
landing on the NORMALIZED (truncated, plausibility-checked) columns."""
|
||||
norm = normalize_bass_offsets(offsets)
|
||||
d = _pack(tmp_path, "shape.sloppak", [
|
||||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
{"name": "Bass", "tuning": offsets},
|
||||
])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["bass_tuning_name"] == bass_tuning_name(norm)
|
||||
assert meta["bass_tuning_offsets"] == " ".join(str(o) for o in norm)
|
||||
assert meta["bass_tuning_sort_key"] == sum(norm)
|
||||
assert meta["bass_tuning_key"] == bass_tuning_key(norm)
|
||||
|
||||
|
||||
def test_named_bass_tunings_group_across_serialization_lengths(client, server_mod):
|
||||
"""The length question does NOT fragment NAMED tunings: a bass stored as
|
||||
four elements and one stored as six both name "E Standard", and the facet
|
||||
groups by name — so they land in ONE row with a combined count. This is the
|
||||
common case (40 of the 42 bass arrangements in the real library)."""
|
||||
_put(server_mod, filename="four.sloppak", title="Four",
|
||||
bass_tuning_name=tuning_name([0, 0, 0, 0]), bass_tuning_offsets="0 0 0 0")
|
||||
_put(server_mod, filename="six.sloppak", title="Six",
|
||||
bass_tuning_name=tuning_name([0, 0, 0, 0, 0, 0]),
|
||||
bass_tuning_offsets="0 0 0 0 0 0")
|
||||
assert _facet(client, instrument="bass") == {"E Standard": 2}
|
||||
assert _files(client, tunings="E Standard", instrument="bass") == {
|
||||
"four.sloppak", "six.sloppak"}
|
||||
|
||||
|
||||
def test_drop_d_bass_groups_across_serialization_lengths(client, server_mod):
|
||||
"""Same for the Intervals shape: [-2,0,0,0,0,0] and [-2,0,0,0] both name
|
||||
"Drop D", so trailing zeros can't split a named tuning into two rows."""
|
||||
_put(server_mod, filename="d6.sloppak", title="D6",
|
||||
bass_tuning_name=tuning_name([-2, 0, 0, 0, 0, 0]),
|
||||
bass_tuning_sort_key=-2, bass_tuning_offsets="-2 0 0 0 0 0")
|
||||
_put(server_mod, filename="d4.sloppak", title="D4",
|
||||
bass_tuning_name=tuning_name([-2, 0, 0, 0]),
|
||||
bass_tuning_sort_key=-2, bass_tuning_offsets="-2 0 0 0")
|
||||
assert _facet(client, instrument="bass") == {"Drop D": 2}
|
||||
|
||||
|
||||
def test_equivalent_custom_bass_tunings_group_into_one_facet_row(client, server_mod):
|
||||
"""Two CUSTOM bass tunings that are the same physical tuning must be ONE
|
||||
facet row, however they were serialized. They group on canonical PITCHES
|
||||
(bass_tuning_key), so the offsets string no longer fragments them —
|
||||
previously this produced two rows with split counts."""
|
||||
key = bass_tuning_key([-3, -1, -1, -1])
|
||||
_put(server_mod, filename="c6.sloppak", title="C6",
|
||||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=-6,
|
||||
bass_tuning_offsets="-3 -1 -1 -1", bass_tuning_key=key)
|
||||
_put(server_mod, filename="c4.sloppak", title="C4",
|
||||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=-6,
|
||||
bass_tuning_offsets="-3 -1 -1 -1", bass_tuning_key=key)
|
||||
rows = client.get("/api/library/tuning-names",
|
||||
params={"instrument": "bass"}).json()["tunings"]
|
||||
customs = [t for t in rows if t["name"] == "Custom Tuning"]
|
||||
assert len(customs) == 1 and customs[0]["count"] == 2
|
||||
assert _files(client, tunings=customs[0]["key"], instrument="bass") == {
|
||||
"c6.sloppak", "c4.sloppak"}
|
||||
|
||||
|
||||
def test_canonical_key_is_pitch_not_serialization(tmp_path):
|
||||
"""The property that makes the grouping robust: two serializations of one
|
||||
tuning yield the same key, and two genuinely different tunings do not."""
|
||||
assert bass_tuning_key(normalize_bass_offsets([-2, 0, 0, 0, 0, 0])) == \
|
||||
bass_tuning_key(normalize_bass_offsets([-2, 0, 0, 0]))
|
||||
assert bass_tuning_key([-2, 0, 0, 0]) != bass_tuning_key([-3, 0, 0, 0])
|
||||
# Absolute open pitches of a standard 4-string bass (E1 A1 D2 G2).
|
||||
assert bass_tuning_key([0, 0, 0, 0]) == "bass:28:33:38:43"
|
||||
|
||||
|
||||
def test_custom_bass_facet_row_selects_exactly_what_it_counted(client, server_mod):
|
||||
"""Whatever the grouping rule, the invariant that must NEVER break: every
|
||||
facet row's count equals the number of songs its own key returns. This is
|
||||
what makes the seam safe to change — a normalization that merged rows but
|
||||
not the filter would fail here."""
|
||||
_put(server_mod, filename="x6.sloppak", title="X6",
|
||||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=28,
|
||||
bass_tuning_offsets="5 5 5 5 4 4")
|
||||
_put(server_mod, filename="x4.sloppak", title="X4",
|
||||
bass_tuning_name="Custom Tuning", bass_tuning_sort_key=20,
|
||||
bass_tuning_offsets="5 5 5 5")
|
||||
_put(server_mod, filename="plain.sloppak", title="Plain",
|
||||
bass_tuning_name="E Standard", bass_tuning_offsets="0 0 0 0 0 0")
|
||||
for row in client.get("/api/library/tuning-names",
|
||||
params={"instrument": "bass"}).json()["tunings"]:
|
||||
got = _files(client, tunings=row["key"], instrument="bass")
|
||||
assert len(got) == row["count"], (
|
||||
f"facet row {row['key']!r} counted {row['count']} but selects {len(got)}")
|
||||
|
||||
|
||||
# ── 10. THE HEADLINE REGRESSION ──────────────────────────────────────────────
|
||||
|
||||
def test_covet_shibuya_is_findable_by_a_bassist(tmp_path, server_mod, client):
|
||||
"""Covet - "Shibuya" (Effloresce): the guitar is in a custom tuning
|
||||
[-2,0,0,-1,-2,0] while the bass is dead standard. This is the tester's bug
|
||||
in one song — a bassist filtering "E Standard" never saw it, because the
|
||||
library only knew the guitar's custom tuning.
|
||||
|
||||
Round-tripped through the REAL extractor and scanner derivation, not
|
||||
hand-written columns, so it covers the whole chain."""
|
||||
d = _pack(tmp_path, "shibuya.sloppak", [
|
||||
{"name": "Lead", "tuning": [-2, 0, 0, -1, -2, 0]},
|
||||
{"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
])
|
||||
meta = _extract_meta_for_file(d)
|
||||
server_mod.meta_db.put("shibuya.sloppak", 1.0, 1, {
|
||||
**meta, "title": "Shibuya", "artist": "Covet", "album": "Effloresce"})
|
||||
|
||||
# The guitar chart really is a custom tuning…
|
||||
assert meta["tuning_name"] == "Custom Tuning"
|
||||
# …and the bass chart really is standard.
|
||||
assert meta["bass_tuning_name"] == "E Standard"
|
||||
|
||||
# Before the fix a bassist filtering E Standard got nothing.
|
||||
assert _files(client, tunings="E Standard") == set()
|
||||
assert _files(client, tunings="E Standard", instrument="bass") == {"shibuya.sloppak"}
|
||||
|
||||
# And it appears in the bass facet under E Standard, as a REAL bass chart
|
||||
# (not an inferred fallback).
|
||||
row = next(t for t in client.get(
|
||||
"/api/library/tuning-names", params={"instrument": "bass"}).json()["tunings"]
|
||||
if t["name"] == "E Standard")
|
||||
assert row["count"] == 1 and row["inferred_count"] == 0
|
||||
|
||||
|
||||
# ── 11. Provenance: the fallback must be honest, never silent ────────────────
|
||||
|
||||
def test_facet_reports_how_many_rows_are_inferred_from_the_guitar_chart(
|
||||
client, facet_seeded):
|
||||
"""The fallback keeps no-bass-chart songs visible (a third of a real
|
||||
library), but the UI must be able to say so. `nobass` has no bass chart and
|
||||
rides under the guitar's Drop D; `match` has a real one."""
|
||||
rows = {t["name"]: t for t in client.get(
|
||||
"/api/library/tuning-names", params={"instrument": "bass"}).json()["tunings"]}
|
||||
assert rows["Drop D"]["count"] == 2
|
||||
assert rows["Drop D"]["inferred_count"] == 1 # nobass only
|
||||
assert rows["E Standard"]["inferred_count"] == 0 # differ has a real bass chart
|
||||
|
||||
|
||||
def test_guitar_facet_reports_no_inferred_rows(client, facet_seeded):
|
||||
"""Guitar is never a fallback perspective, so nothing is ever inferred."""
|
||||
rows = client.get("/api/library/tuning-names").json()["tunings"]
|
||||
assert all(t["inferred_count"] == 0 for t in rows)
|
||||
|
||||
|
||||
def test_song_rows_mark_an_inferred_tuning(client, facet_seeded):
|
||||
"""A bass player's row must be distinguishable: native bass chart vs
|
||||
borrowed from the guitar. Without this the card silently presents a guitar
|
||||
tuning as the bass tuning — the original bug in a new place."""
|
||||
rows = {s["filename"]: s for s in client.get(
|
||||
"/api/library", params={"instrument": "bass"}).json()["songs"]}
|
||||
assert rows["differ.sloppak"]["tuning_inferred"] is False
|
||||
assert rows["nobass.sloppak"]["tuning_inferred"] is True
|
||||
assert rows["differ.sloppak"]["tuning_perspective"] == "bass"
|
||||
|
||||
|
||||
def test_guitar_rows_carry_no_bass_perspective_fields(client, facet_seeded):
|
||||
"""The guitar payload is untouched — no perspective/inferred keys at all."""
|
||||
row = client.get("/api/library").json()["songs"][0]
|
||||
assert "tuning_inferred" not in row and "tuning_perspective" not in row
|
||||
|
||||
|
||||
def test_arrangements_has_bass_is_the_real_bass_chart_lever(server_mod, client):
|
||||
"""'Only songs with a real bass chart' is the EXISTING `arrangements_has`
|
||||
filter — no new filter, no "confirmed tunings" checkbox. It composes with
|
||||
the tuning filter, so a bassist who wants to exclude inferred rows already
|
||||
can, and it is already expressible in a saved collection rule."""
|
||||
def put_with_arrs(fn, arrs, **kw):
|
||||
server_mod.meta_db.put(fn, 1.0, 1, {
|
||||
"title": fn, "artist": "A", "album": "A - LP", "year": "2010",
|
||||
"duration": 200.0, "tuning": "Drop D", "arrangements": arrs,
|
||||
"has_lyrics": False, "format": "sloppak", "stem_ids": [],
|
||||
"tuning_name": "Drop D", "tuning_sort_key": -2,
|
||||
"tuning_offsets": "-2 0 0 0 0 0", **kw})
|
||||
|
||||
put_with_arrs("withbass.sloppak",
|
||||
[{"index": 0, "name": "Lead"}, {"index": 1, "name": "Bass"}],
|
||||
bass_tuning_name="Drop D", bass_tuning_sort_key=-2,
|
||||
bass_tuning_offsets="-2 0 0 0",
|
||||
bass_tuning_key=bass_tuning_key([-2, 0, 0, 0]))
|
||||
put_with_arrs("nobass.sloppak", [{"index": 0, "name": "Lead"}])
|
||||
|
||||
# Both are reachable under the bass Drop D pill (the fallback keeps the
|
||||
# no-bass-chart song visible)…
|
||||
assert _files(client, tunings="Drop D", instrument="bass") == {
|
||||
"withbass.sloppak", "nobass.sloppak"}
|
||||
# …and the existing arrangements_has lever narrows to real bass charts.
|
||||
assert _files(client, tunings="Drop D", instrument="bass",
|
||||
arrangements_has="Bass") == {"withbass.sloppak"}
|
||||
|
||||
|
||||
def test_song_rows_carry_the_bass_tuning_for_the_client(client, facet_seeded):
|
||||
"""The card renders the bass tuning client-side, so the row must ship it —
|
||||
and ship '' (not the guitar value) when there is no bass chart, so the
|
||||
client's fallback stays the client's decision."""
|
||||
rows = {s["filename"]: s for s in client.get("/api/library").json()["songs"]}
|
||||
assert rows["differ.sloppak"]["tuning_name"] == "D Standard"
|
||||
assert rows["differ.sloppak"]["bass_tuning_name"] == "E Standard"
|
||||
assert rows["differ.sloppak"]["bass_tuning_offsets"] == "0 0 0 0 0 0"
|
||||
assert rows["nobass.sloppak"]["bass_tuning_name"] == ""
|
||||
@@ -0,0 +1,294 @@
|
||||
"""The three-valued tuning PERSPECTIVE, and "playable without retuning".
|
||||
|
||||
Two behaviours that extend the bass tuning fix (see
|
||||
test_library_tuning_instrument.py):
|
||||
|
||||
1. `active_instrument_profile` has three values (guitar-lead / guitar-rhythm /
|
||||
bass), so the tuning perspective must too. Lead and rhythm charts can be
|
||||
tuned differently, which is the identical bug a bassist hit, inside guitar.
|
||||
|
||||
2. Exact tuning match answers "which tuning is this labelled". A player
|
||||
actually wants "will this cost me a retune". Both are offered; exact stays
|
||||
the default.
|
||||
|
||||
Everything round-trips through the real extractor, the real scanner
|
||||
derivation, the real schema and the real HTTP surface.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from scan_worker import _extract_meta_for_file
|
||||
from tunings import (
|
||||
PERSPECTIVES, bass_tuning_key, chart_is_playable_in, perspective_tuning_key,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server_mod(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
sys.modules.pop("server", None)
|
||||
mod = importlib.import_module("server")
|
||||
yield mod
|
||||
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server_mod):
|
||||
c = TestClient(server_mod.app)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def _pack(root, name, arrangements):
|
||||
d = root / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.yaml").write_text(yaml.safe_dump({
|
||||
"title": name, "artist": "A", "duration": 100,
|
||||
"arrangements": arrangements, "stems": [],
|
||||
}), encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
def _files(client, **kw):
|
||||
return {s["filename"] for s in client.get("/api/library", params=kw).json()["songs"]}
|
||||
|
||||
|
||||
# ── 1. The same bug WITHIN guitar: lead vs rhythm ────────────────────────────
|
||||
|
||||
def test_rhythm_chart_tuning_is_indexed_separately(tmp_path):
|
||||
"""A song whose LEAD is in E standard but whose RHYTHM is in Drop D must
|
||||
index both — through the real extractor + scanner derivation."""
|
||||
d = _pack(tmp_path, "split.sloppak", [
|
||||
{"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
|
||||
{"name": "Rhythm", "tuning": [-2, 0, 0, 0, 0, 0]},
|
||||
])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["tuning_name"] == "E Standard" # song-level = guitar-first
|
||||
assert meta["rhythm_tuning_name"] == "Drop D" # the rhythm chart's own
|
||||
assert meta["rhythm_tuning_offsets"] == "-2 0 0 0 0 0"
|
||||
assert meta["rhythm_tuning_low_pitch"] == 38 # low D
|
||||
|
||||
|
||||
def test_rhythm_offsets_are_not_truncated(tmp_path):
|
||||
"""Only BASS truncates (its arrays are padded). A 7-string guitar array is
|
||||
real data — cutting it to 6 would invent a tuning the chart doesn't have."""
|
||||
d = _pack(tmp_path, "seven.sloppak", [
|
||||
{"name": "Rhythm", "tuning": [-2, -2, -2, -2, -2, -2, -2]},
|
||||
])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["rhythm_tuning_offsets"] == "-2 -2 -2 -2 -2 -2 -2"
|
||||
|
||||
|
||||
def test_no_rhythm_arrangement_leaves_the_columns_empty(tmp_path):
|
||||
d = _pack(tmp_path, "leadonly.sloppak", [{"name": "Lead", "tuning": [0] * 6}])
|
||||
meta = _extract_meta_for_file(d)
|
||||
assert meta["rhythm_tuning_name"] == ""
|
||||
assert meta["rhythm_tuning_key"] == ""
|
||||
|
||||
|
||||
def _put(server_mod, fn, **kw):
|
||||
base = dict(title=fn, artist="A", album="LP", year="2010", duration=200.0,
|
||||
tuning="E Standard", arrangements=[], has_lyrics=False,
|
||||
format="sloppak", stem_ids=[], tuning_name="E Standard",
|
||||
tuning_sort_key=0, tuning_offsets="0 0 0 0 0 0",
|
||||
tuning_low_pitch=40)
|
||||
base.update(kw)
|
||||
server_mod.meta_db.put(fn, 1.0, 1, base)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def rhythm_seeded(server_mod):
|
||||
"""Both songs are E Standard by LEAD. One has a Drop D rhythm chart; the
|
||||
other has no rhythm chart at all (so it falls back + is marked inferred)."""
|
||||
_put(server_mod, "rdiffer.sloppak",
|
||||
rhythm_tuning_name="Drop D", rhythm_tuning_sort_key=-2,
|
||||
rhythm_tuning_offsets="-2 0 0 0 0 0",
|
||||
rhythm_tuning_key=perspective_tuning_key(
|
||||
[-2, 0, 0, 0, 0, 0], PERSPECTIVES["guitar-rhythm"]),
|
||||
rhythm_tuning_low_pitch=38)
|
||||
_put(server_mod, "rnone.sloppak")
|
||||
|
||||
|
||||
def test_rhythm_filter_excludes_a_lead_only_tuning_match(client, rhythm_seeded):
|
||||
"""THE WITHIN-GUITAR BUG. Filtering rhythm "E Standard" must not return
|
||||
rdiffer — that is its LEAD tuning; its rhythm chart is in Drop D."""
|
||||
assert _files(client, tunings="E Standard") == {"rdiffer.sloppak", "rnone.sloppak"}
|
||||
# rnone has no rhythm chart, so it falls back to its lead tuning and stays.
|
||||
assert _files(client, tunings="E Standard", instrument="guitar-rhythm") == {
|
||||
"rnone.sloppak"}
|
||||
assert _files(client, tunings="Drop D", instrument="guitar-rhythm") == {
|
||||
"rdiffer.sloppak"}
|
||||
# …and Drop D finds nothing from the lead perspective.
|
||||
assert _files(client, tunings="Drop D") == set()
|
||||
|
||||
|
||||
def test_rhythm_perspective_marks_inferred_rows(client, rhythm_seeded):
|
||||
rows = {s["filename"]: s for s in client.get(
|
||||
"/api/library", params={"instrument": "guitar-rhythm"}).json()["songs"]}
|
||||
assert rows["rdiffer.sloppak"]["tuning_inferred"] is False
|
||||
assert rows["rnone.sloppak"]["tuning_inferred"] is True
|
||||
assert rows["rdiffer.sloppak"]["tuning_perspective"] == "guitar-rhythm"
|
||||
|
||||
|
||||
def test_rhythm_facet_reports_inferred_portion(client, rhythm_seeded):
|
||||
rows = {t["name"]: t for t in client.get(
|
||||
"/api/library/tuning-names",
|
||||
params={"instrument": "guitar-rhythm"}).json()["tunings"]}
|
||||
assert rows["Drop D"]["count"] == 1 and rows["Drop D"]["inferred_count"] == 0
|
||||
assert rows["E Standard"]["count"] == 1 and rows["E Standard"]["inferred_count"] == 1
|
||||
|
||||
|
||||
def test_facet_row_selects_exactly_what_it_counted_for_rhythm(client, rhythm_seeded):
|
||||
"""The invariant that must hold for EVERY perspective."""
|
||||
for row in client.get("/api/library/tuning-names",
|
||||
params={"instrument": "guitar-rhythm"}).json()["tunings"]:
|
||||
got = _files(client, tunings=row["key"], instrument="guitar-rhythm")
|
||||
assert len(got) == row["count"], row["key"]
|
||||
|
||||
|
||||
def test_guitar_lead_is_byte_identical_to_the_legacy_default(client, rhythm_seeded):
|
||||
"""The majority path must not regress: the default payload gains no keys,
|
||||
and the legacy two-valued vocabulary still resolves to it."""
|
||||
default = client.get("/api/library").json()
|
||||
explicit = client.get("/api/library", params={"instrument": "guitar-lead"}).json()
|
||||
legacy = client.get("/api/library", params={"instrument": "guitar"}).json()
|
||||
assert default == explicit == legacy
|
||||
row = default["songs"][0]
|
||||
assert "tuning_inferred" not in row and "tuning_perspective" not in row
|
||||
|
||||
|
||||
def test_unknown_perspective_falls_back_to_lead(client, rhythm_seeded):
|
||||
"""An unrecognised value must never silently change filter semantics."""
|
||||
assert client.get("/api/library", params={"instrument": "kazoo"}).json() == \
|
||||
client.get("/api/library").json()
|
||||
|
||||
|
||||
def test_tuning_sort_respects_the_rhythm_perspective(client, rhythm_seeded):
|
||||
"""Sort is musical distance from standard. rdiffer is 0 away by lead but
|
||||
-2 by rhythm, so the perspective changes its position."""
|
||||
def order(**kw):
|
||||
return [s["filename"] for s in client.get(
|
||||
"/api/library", params={"sort": "tuning", **kw}).json()["songs"]]
|
||||
assert order()[0] == "rdiffer.sloppak" # tie → filename
|
||||
assert order(instrument="guitar-rhythm")[0] == "rnone.sloppak" # 0 beats -2
|
||||
|
||||
|
||||
# ── 2. "Playable without retuning" ───────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("your_low,chart_low,expected", [
|
||||
(23, 28, True), # 5-string bass (low B) plays a 4-string standard chart
|
||||
(23, 26, True), # …and a drop-D chart: the low D is fretted on the B string
|
||||
(28, 26, False), # 4-string standard CANNOT reach a drop-D chart's low D
|
||||
(28, 28, True), # identical tuning
|
||||
(40, 38, False), # guitar standard vs a drop-D chart
|
||||
(38, 40, True), # a drop-D guitar covers a standard chart
|
||||
(None, 28, False), # unknown chart pitch is never claimed playable
|
||||
(28, None, False),
|
||||
])
|
||||
def test_playability_rule(your_low, chart_low, expected):
|
||||
"""The core comparison as a property: your lowest open string vs the
|
||||
chart's lowest required pitch. Unknown => not playable (conservative)."""
|
||||
assert chart_is_playable_in(chart_low, your_low) is expected
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def pitched(server_mod):
|
||||
_put(server_mod, "std.sloppak", tuning_low_pitch=40)
|
||||
_put(server_mod, "dropd.sloppak", tuning="Drop D", tuning_name="Drop D",
|
||||
tuning_offsets="-2 0 0 0 0 0", tuning_sort_key=-2, tuning_low_pitch=38)
|
||||
_put(server_mod, "dropc.sloppak", tuning="Drop C", tuning_name="Drop C",
|
||||
tuning_offsets="-4 -2 -2 -2 -2 -2", tuning_sort_key=-14, tuning_low_pitch=36)
|
||||
|
||||
|
||||
def _playable(client, offsets, instrument="guitar", sc=6, **kw):
|
||||
return {s["filename"] for s in client.get("/api/library", params={
|
||||
"tuning_match": "playable", "playable_offsets": offsets,
|
||||
"playable_instrument": instrument, "playable_string_count": str(sc), **kw,
|
||||
}).json()["songs"]}
|
||||
|
||||
|
||||
def test_playable_from_standard_excludes_lower_tuned_charts(client, pitched):
|
||||
"""In E standard you can play the standard chart, but the drop-D and
|
||||
drop-C charts need a retune — exactly what the tester wants surfaced."""
|
||||
assert _playable(client, "0,0,0,0,0,0") == {"std.sloppak"}
|
||||
|
||||
|
||||
def test_playable_from_drop_c_covers_everything_above_it(client, pitched):
|
||||
"""Tuned DOWN to drop C, every higher-tuned chart is reachable by fretting
|
||||
— the dominant real case this feature exists for."""
|
||||
assert _playable(client, "-4,-2,-2,-2,-2,-2") == {
|
||||
"std.sloppak", "dropd.sloppak", "dropc.sloppak"}
|
||||
|
||||
|
||||
def test_playable_is_a_mode_not_a_replacement_for_exact(client, pitched):
|
||||
"""Exact match still works untouched, and returns something DIFFERENT from
|
||||
playable — they answer different questions."""
|
||||
exact = {s["filename"] for s in client.get(
|
||||
"/api/library", params={"tunings": "Drop D"}).json()["songs"]}
|
||||
assert exact == {"dropd.sloppak"}
|
||||
assert _playable(client, "-2,0,0,0,0,0") == {"std.sloppak", "dropd.sloppak"}
|
||||
|
||||
|
||||
def test_playable_excludes_rows_with_no_indexed_pitch(client, server_mod, pitched):
|
||||
"""Conservative by construction: a chart whose low pitch we could not
|
||||
compute is EXCLUDED, never assumed playable. Wrongly claiming playability
|
||||
costs a mid-practice retune — the failure this feature prevents."""
|
||||
_put(server_mod, "unknown.sloppak", tuning_low_pitch=None)
|
||||
assert "unknown.sloppak" not in _playable(client, "-4,-2,-2,-2,-2,-2")
|
||||
# …but it is still reachable normally, so it isn't lost from the library.
|
||||
assert any(s["filename"] == "unknown.sloppak"
|
||||
for s in client.get("/api/library").json()["songs"])
|
||||
|
||||
|
||||
def test_malformed_playable_tuning_applies_no_filter(client, pitched):
|
||||
"""A tuning we cannot resolve must not silently claim everything is
|
||||
playable OR that nothing is — it applies no filter at all."""
|
||||
everything = {s["filename"] for s in client.get("/api/library").json()["songs"]}
|
||||
assert _playable(client, "not,a,tuning") == everything
|
||||
assert _playable(client, "") == everything
|
||||
# A string count that disagrees with the offsets is equally unusable.
|
||||
assert _playable(client, "0,0,0,0", instrument="guitar", sc=6) == everything
|
||||
|
||||
|
||||
def test_playable_respects_the_bass_perspective(client, server_mod):
|
||||
"""A 5-string bass (low B) can play a 4-string standard bass chart. The
|
||||
comparison must run on the BASS tuning — this song's GUITAR chart is tuned
|
||||
far lower, so reading the wrong column would flip the answer."""
|
||||
_put(server_mod, "bassy.sloppak",
|
||||
tuning="Custom Tuning", tuning_name="Custom Tuning",
|
||||
tuning_offsets="-4 -2 -2 -1 -2 0", tuning_sort_key=-11,
|
||||
tuning_low_pitch=36,
|
||||
bass_tuning_name="E Standard", bass_tuning_sort_key=0,
|
||||
bass_tuning_offsets="0 0 0 0",
|
||||
bass_tuning_key=bass_tuning_key([0, 0, 0, 0]),
|
||||
bass_tuning_low_pitch=28)
|
||||
# 5-string bass low B (23) <= the chart low E (28) → playable.
|
||||
got = {s["filename"] for s in client.get("/api/library", params={
|
||||
"tuning_match": "playable", "playable_offsets": "0,0,0,0,0",
|
||||
"playable_instrument": "bass", "playable_string_count": "5",
|
||||
"instrument": "bass"}).json()["songs"]}
|
||||
assert got == {"bassy.sloppak"}
|
||||
# A 4-string bass tuned UP a semitone (low F, 29) cannot reach the low E.
|
||||
got_up = {s["filename"] for s in client.get("/api/library", params={
|
||||
"tuning_match": "playable", "playable_offsets": "1,1,1,1",
|
||||
"playable_instrument": "bass", "playable_string_count": "4",
|
||||
"instrument": "bass"}).json()["songs"]}
|
||||
assert got_up == set()
|
||||
|
||||
|
||||
def test_playable_and_stats_agree(client, pitched):
|
||||
"""The count surface must apply the same predicate as the grid."""
|
||||
body = client.get("/api/library/stats", params={
|
||||
"tuning_match": "playable", "playable_offsets": "0,0,0,0,0,0",
|
||||
"playable_instrument": "guitar", "playable_string_count": "6"}).json()
|
||||
assert body["total_songs"] == 1
|
||||
@@ -302,3 +302,44 @@ def test_extract_meta_uses_lead_tuning_when_bass_sorts_first(tmp_path):
|
||||
|
||||
meta = loosefolder.extract_meta(tmp_path)
|
||||
assert meta["tuning_offsets"] == [0, 0, 0, 0, 0, 0]
|
||||
# …and the bass chart's OWN tuning is indexed alongside it, so a bass
|
||||
# player's library filter isn't answered with the guitar tuning.
|
||||
assert meta["bass_tuning_offsets"] == [-4, -4, -4, -4, 0, 0]
|
||||
|
||||
|
||||
def test_extract_meta_bass_tuning_absent_without_bass_arrangement(tmp_path):
|
||||
"""A folder with no bass chart leaves the bass tuning EMPTY (None) rather
|
||||
than echoing the guitar tuning — the library then falls back explicitly,
|
||||
and 'no bass part' stays distinguishable from 'bass part in E Standard'."""
|
||||
(tmp_path / "audio.wem").write_bytes(b"\0")
|
||||
(tmp_path / "lead.xml").write_text(_LEAD_STD_XML, encoding="utf-8")
|
||||
|
||||
meta = loosefolder.extract_meta(tmp_path)
|
||||
assert meta["tuning_offsets"] == [0, 0, 0, 0, 0, 0]
|
||||
assert meta["bass_tuning_offsets"] is None
|
||||
|
||||
|
||||
def test_extract_meta_bass_tuning_matches_guitar_is_still_indexed(tmp_path):
|
||||
"""The COMMON case: bass and guitar in the same tuning. The bass column
|
||||
must still be populated — an empty one would be read as 'no bass chart'."""
|
||||
(tmp_path / "audio.wem").write_bytes(b"\0")
|
||||
_write_min_xml(tmp_path / "lead.xml", arrangement="Lead")
|
||||
_write_min_xml(tmp_path / "bass.xml", arrangement="Bass")
|
||||
|
||||
meta = loosefolder.extract_meta(tmp_path)
|
||||
assert meta["bass_tuning_offsets"] == [0, 0, 0, 0, 0, 0]
|
||||
|
||||
|
||||
def test_extract_meta_manifest_tuning_does_not_become_the_bass_tuning(tmp_path):
|
||||
"""A manifest `tuning_offsets` overrides the SONG tuning but says nothing
|
||||
about which chart it describes, so it must never be mistaken for the bass
|
||||
part's tuning — with no bass chart the bass column stays empty."""
|
||||
(tmp_path / "audio.wem").write_bytes(b"\0")
|
||||
(tmp_path / "lead.xml").write_text(_LEAD_STD_XML, encoding="utf-8")
|
||||
(tmp_path / "manifest.json").write_text(json.dumps({
|
||||
"tuning_offsets": [-2, -2, -2, -2, -2, -2],
|
||||
}), encoding="utf-8")
|
||||
|
||||
meta = loosefolder.extract_meta(tmp_path)
|
||||
assert meta["tuning_offsets"] == [-2, -2, -2, -2, -2, -2]
|
||||
assert meta["bass_tuning_offsets"] is None
|
||||
|
||||
@@ -175,3 +175,185 @@ def test_deleting_playlist_removes_custom_cover(client, server):
|
||||
assert _playlist_cover_path(pid).exists()
|
||||
client.delete(f"/api/playlists/{pid}")
|
||||
assert not _playlist_cover_path(pid).exists()
|
||||
|
||||
|
||||
# ── Reordering the playlists THEMSELVES (not songs-within) ───────────────────
|
||||
|
||||
def _mk(client, name):
|
||||
return client.post("/api/playlists", json={"name": name}).json()["id"]
|
||||
|
||||
|
||||
def _ids(client):
|
||||
return [p["id"] for p in client.get("/api/playlists").json()]
|
||||
|
||||
|
||||
def test_playlists_default_order_is_alphabetical(client):
|
||||
b = _mk(client, "Bravo")
|
||||
a = _mk(client, "alpha") # NOCASE: lowercase still sorts by letter
|
||||
z = _mk(client, "Zulu")
|
||||
assert _ids(client) == [a, b, z]
|
||||
|
||||
|
||||
def test_playlist_manual_reorder_persists(client):
|
||||
a = _mk(client, "Alpha")
|
||||
b = _mk(client, "Bravo")
|
||||
c = _mk(client, "Charlie")
|
||||
r = client.post("/api/playlists/reorder", json={"order": [c, a, b]})
|
||||
assert r.status_code == 200
|
||||
assert [p["id"] for p in r.json()] == [c, a, b]
|
||||
# persists across independent list calls
|
||||
assert _ids(client) == [c, a, b]
|
||||
assert _ids(client) == [c, a, b]
|
||||
|
||||
|
||||
def test_playlist_reorder_excludes_system_and_keeps_it_pinned(client):
|
||||
# First toggle creates the "Saved for Later" system playlist.
|
||||
client.post("/api/saved/toggle", json={"filename": "x.archive"})
|
||||
a = _mk(client, "Alpha")
|
||||
b = _mk(client, "Bravo")
|
||||
saved = next(p["id"] for p in client.get("/api/playlists").json() if p["system_key"])
|
||||
# A system id in the order is rejected — it isn't reorderable.
|
||||
assert client.post("/api/playlists/reorder", json={"order": [saved, b, a]}).status_code == 400
|
||||
# User playlists reorder; the system playlist stays pinned first.
|
||||
assert client.post("/api/playlists/reorder", json={"order": [b, a]}).status_code == 200
|
||||
listing = client.get("/api/playlists").json()
|
||||
assert listing[0]["system_key"] == "saved_for_later"
|
||||
assert [p["id"] for p in listing[1:]] == [b, a]
|
||||
|
||||
|
||||
def test_playlist_reorder_rejects_bad_orders(client):
|
||||
a = _mk(client, "Alpha")
|
||||
b = _mk(client, "Bravo")
|
||||
for bad in (
|
||||
[a], # missing an id (partial order)
|
||||
[a, b, 999999], # extra unknown id
|
||||
[a, a], # duplicate (drops b)
|
||||
[a, 999999], # unknown id in place of b
|
||||
"nope", # not a list
|
||||
[a, str(b)], # non-int entry
|
||||
[True, False], # bools are ints to Python — must still be rejected
|
||||
None, # {"order": null}
|
||||
):
|
||||
assert client.post("/api/playlists/reorder", json={"order": bad}).status_code == 400, bad
|
||||
assert client.post("/api/playlists/reorder", json={}).status_code == 400
|
||||
# Nothing was persisted by any rejected request.
|
||||
assert _ids(client) == [a, b]
|
||||
|
||||
|
||||
def test_sort_alpha_clears_manual_order(client):
|
||||
a = _mk(client, "Alpha")
|
||||
b = _mk(client, "Bravo")
|
||||
z = _mk(client, "Zulu")
|
||||
client.post("/api/playlists/reorder", json={"order": [z, b, a]})
|
||||
assert _ids(client) == [z, b, a]
|
||||
r = client.post("/api/playlists/sort-alpha")
|
||||
assert r.status_code == 200
|
||||
assert [p["id"] for p in r.json()] == [a, b, z]
|
||||
assert _ids(client) == [a, b, z]
|
||||
|
||||
|
||||
def test_new_playlist_after_manual_reorder_sorts_alphabetically_after_positioned(client):
|
||||
a = _mk(client, "Alpha")
|
||||
b = _mk(client, "Bravo")
|
||||
client.post("/api/playlists/reorder", json={"order": [b, a]})
|
||||
# New playlists are unpositioned → they follow the manually positioned
|
||||
# ones, alphabetically among themselves, and never disturb the manual
|
||||
# order ("Aardvark" would be first alphabetically).
|
||||
z = _mk(client, "Zebra")
|
||||
aa = _mk(client, "Aardvark")
|
||||
assert _ids(client) == [b, a, aa, z]
|
||||
# A subsequent full reorder must include the newcomers (exact permutation).
|
||||
assert client.post("/api/playlists/reorder", json={"order": [b, a]}).status_code == 400
|
||||
assert client.post("/api/playlists/reorder", json={"order": [z, aa, b, a]}).status_code == 200
|
||||
assert _ids(client) == [z, aa, b, a]
|
||||
|
||||
|
||||
# ── Tuning-check payload (per-song data the playlist tuning check scores) ────
|
||||
# A playlist grouped BY TUNING is a run you can practise without retuning, so
|
||||
# the detail view flags rows your instrument can't reach. Scoring needs more
|
||||
# than the tuning NAME: two "Custom Tuning" rows are different tunings, and a
|
||||
# bass-only chart has to be measured against bass base pitches.
|
||||
|
||||
def test_playlist_songs_carry_tuning_offsets_for_the_check(client, server):
|
||||
db = server.meta_db
|
||||
db.put("drop.archive", 0, 0, {"title": "Drop", "tuning_name": "Drop D",
|
||||
"tuning_offsets": "-2 0 0 0 0 0"})
|
||||
pid = client.post("/api/playlists", json={"name": "T"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "drop.archive"})
|
||||
song = client.get(f"/api/playlists/{pid}").json()["songs"][0]
|
||||
assert song["tuning_offsets"] == "-2 0 0 0 0 0"
|
||||
assert song["tuning_name"] == "Drop D"
|
||||
|
||||
|
||||
def test_playlist_songs_carry_role_specific_tunings(client, server):
|
||||
db = server.meta_db
|
||||
db.put("roles.archive", 0, 0, {
|
||||
"title": "Roles",
|
||||
"tuning_name": "E Standard",
|
||||
"tuning_offsets": "0 0 0 0 0 0",
|
||||
"bass_tuning_name": "A Standard",
|
||||
"bass_tuning_offsets": "-2 -2 -2 -2 -2 -2",
|
||||
"rhythm_tuning_name": "Drop D",
|
||||
"rhythm_tuning_offsets": "-2 0 0 0 0 0",
|
||||
})
|
||||
pid = client.post("/api/playlists", json={"name": "Roles"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "roles.archive"})
|
||||
song = client.get(f"/api/playlists/{pid}").json()["songs"][0]
|
||||
assert song["bass_tuning_name"] == "A Standard"
|
||||
assert song["bass_tuning_offsets"] == "-2 -2 -2 -2 -2 -2"
|
||||
assert song["rhythm_tuning_name"] == "Drop D"
|
||||
assert song["rhythm_tuning_offsets"] == "-2 0 0 0 0 0"
|
||||
|
||||
|
||||
def test_playlist_songs_flag_bass_only_charts(client, server):
|
||||
# Every arrangement a bass part → bass_only, so coverage scores the row
|
||||
# against bass strings. A chart that ALSO has a guitar part must not be
|
||||
# flagged, or a guitarist's row gets measured on the wrong instrument.
|
||||
db = server.meta_db
|
||||
db.put("bassonly.archive", 0, 0, {"title": "Bass Only", "arrangements": [
|
||||
{"name": "Bass"}, {"name": "Alt. Bass"}]})
|
||||
db.put("mixed.archive", 0, 0, {"title": "Mixed", "arrangements": [
|
||||
{"name": "Lead"}, {"name": "Bass"}]})
|
||||
db.put("noarr.archive", 0, 0, {"title": "No Arrangements"})
|
||||
pid = client.post("/api/playlists", json={"name": "B"}).json()["id"]
|
||||
for fn in ("bassonly.archive", "mixed.archive", "noarr.archive"):
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": fn})
|
||||
got = {s["filename"]: s["bass_only"] for s in client.get(f"/api/playlists/{pid}").json()["songs"]}
|
||||
assert got == {"bassonly.archive": True, "mixed.archive": False, "noarr.archive": False}
|
||||
|
||||
|
||||
def test_bass_only_flag_survives_adversarial_arrangement_data(client, server):
|
||||
# Corrupt/odd `arrangements` must not 500 the playlist, and must not claim
|
||||
# bass — an unscoreable row is left for the client to report as "unknown".
|
||||
db = server.meta_db
|
||||
cases = {
|
||||
"empty.archive": [],
|
||||
"unnamed.archive": [{"name": ""}],
|
||||
"nullname.archive": [{"name": None}],
|
||||
"substring.archive": [{"name": "Bassoon"}], # not a bass part
|
||||
"cased.archive": [{"name": "BASS"}], # is one
|
||||
}
|
||||
for fn, arrs in cases.items():
|
||||
db.put(fn, 0, 0, {"title": fn, "arrangements": arrs})
|
||||
pid = client.post("/api/playlists", json={"name": "Adv"}).json()["id"]
|
||||
for fn in cases:
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": fn})
|
||||
r = client.get(f"/api/playlists/{pid}")
|
||||
assert r.status_code == 200
|
||||
got = {s["filename"]: s["bass_only"] for s in r.json()["songs"]}
|
||||
assert got == {"empty.archive": False, "unnamed.archive": False,
|
||||
"nullname.archive": False, "substring.archive": False,
|
||||
"cased.archive": True}
|
||||
|
||||
|
||||
def test_playlist_song_with_no_tuning_data_reports_empty_not_missing(client, server):
|
||||
# The key must always be present: the client distinguishes "no tuning data"
|
||||
# (unknown — say nothing) from "wrong tuning" (flag it), and a missing key
|
||||
# would make every row unscoreable by accident rather than by fact.
|
||||
db = server.meta_db
|
||||
db.put("bare.archive", 0, 0, {"title": "Bare"})
|
||||
pid = client.post("/api/playlists", json={"name": "Bare"}).json()["id"]
|
||||
client.post(f"/api/playlists/{pid}/songs", json={"filename": "bare.archive"})
|
||||
song = client.get(f"/api/playlists/{pid}").json()["songs"][0]
|
||||
assert song["tuning_offsets"] == ""
|
||||
assert song["bass_only"] is False
|
||||
|
||||
Reference in New Issue
Block a user