feedBack/lib/loosefolder.py
ChrisBeWithYou cc75cb876a
fix(library): tuning filter answers for your instrument, not always guitar (#1003)
* fix(library): tuning filter answers for your instrument, not always guitar

The library indexed exactly one tuning per song, chosen guitar-first (lead >
rhythm > combo, bass only as a last resort), and nothing consulted the
player's instrument. A bassist filtering by tuning was shown the guitar
chart's tuning, so playlists built by tuning contained songs needing a
retune. Reported by a tester building bass practice sets; Covet "Shibuya" is
the clean case, with a custom guitar tuning over a standard bass chart.

Indexes each arrangement role's own tuning and makes the facet, filter, sort
and labels answer for one perspective. `guitar-lead` reads the original
unprefixed columns and adds no payload keys, so the default response is
unchanged. The same defect existed inside guitar -- lead and rhythm charts
can disagree -- so perspective is three-valued (guitar-lead, guitar-rhythm,
bass) driven by one PERSPECTIVES table rather than parallel column families.

Songs with no chart for the perspective fall back to the song-level tuning
rather than vanishing (18 of 59 packs in the test library have no bass
chart), but the fallback is marked inferred in the facet counts and on the
row instead of being silently coalesced. "Only real charts" reuses the
existing `arrangements_has` filter rather than adding one.

Bass-specific handling, from measured content:
- Bass tuning arrays are padded to six entries; charts never reference
  string index 4 or 5. Truncated to four before naming and grouping.
- Grouping uses a canonical open-pitch key, so [-2,0,0,0] and
  [-2,0,0,0,0,0] are one facet row instead of two.
- Offsets above +1 semitone are refused a name. Bassists tune down, near
  never up; one pack ships [5,5,5,5,4,4] (A-D-G-C, unplayable, and its own
  notes sit in the song's real key under standard tuning). Naming that
  would send a player to retune to a tuning that does not exist.

Rhythm deliberately does not truncate -- padding is a bass finding, and
cutting a seven-string array would invent a tuning the chart lacks.

Adds an opt-in `tuning_match=playable` mode alongside exact match: a chart is
offered when your lowest open pitch is at or below its lowest open pitch, so
a five-string bass covers four-string standard and drop-D with no retune.
Open strings only -- note range is not indexed and the scan stays
manifest-only -- so it fails conservative: unknown low pitch is excluded, and
the upper bound is unchecked and documented rather than guessed.

Existing installs would otherwise never populate: the tree-signature fast
path reports "unchanged" forever on a settled library. Rows with NULL marker
columns re-extract, and the fast path is disabled until that backfill
converges (writes use '' rather than NULL, so it self-clears).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* test(v3): accept the tuning-perspective indirection in the badge guard

The album-art badge now reads shownTuningName(), so the source-pattern guard
no longer matched the inline `tuning_name || tuning` form and CI went red.
Accept the helper, and pin the helper's own fallback in a companion test so
the guard still fails if a guitar player's tuning label is ever dropped.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:04:30 -05:00

455 lines
16 KiB
Python

"""loosefolder.py — treat a directory of raw RS2014 assets as a playable song.
Expected layout (artist/album/song_dir structure is optional but used for
metadata inference when manifest.json is absent):
song_dir/
audio.wem (required)
lead.xml (at least one arrangement XML required)
rhythm.xml
bass.xml
manifest.json (optional)
album_art.jpg / cover.jpg / album.png (optional)
manifest.json fields (all optional — XML metadata fills in gaps):
{
"title": "Song Name",
"artist": "Artist Name",
"album": "Album Name",
"year": "2024",
"tuning_offsets": [0, 0, 0, 0, 0, 0]
}
"""
import json
import math
import xml.etree.ElementTree as ET
from pathlib import Path
AUDIO_NAMES = ["audio.wem", "song.wem"]
# Match every extension server.get_song_art is prepared to serve
# (jpeg/png/webp). Without `.jpeg`/`.webp`, loose folders shipping
# `cover.jpeg` or `album_art.webp` would never have their art surfaced.
_ART_STEMS = ["album_art", "cover", "album", "art", "folder"]
_ART_EXTS = [".jpg", ".jpeg", ".png", ".webp"]
ART_NAMES = [f"{stem}{ext}" for stem in _ART_STEMS for ext in _ART_EXTS]
# Arrangement type detection from filename keywords or <arrangement> tag
# Format: keyword -> (type, display_name, sort_priority)
ARR_TYPE_MAP = {
"lead": ("lead", "Lead", 0),
"rhythm": ("rhythm", "Rhythm", 2),
"bass": ("bass", "Bass", 3),
"combo": ("combo", "Combo", 1),
"chord": ("combo", "Combo", 1),
"humstrum": ("combo", "Combo", 1),
}
def _iter_local(path: Path, pattern: str):
"""Yield regular files matching `pattern` in `path` that resolve
inside `path`.
Two guards in one helper:
* Reject directories (a folder named `audio.wem` or `lead.xml`
would otherwise be matched by glob and break downstream
readers / converters).
* Reject symlinks escaping the folder so a crafted custom song can't
smuggle external content into the scan.
"""
root = path.resolve()
for match in path.glob(pattern):
if not match.is_file():
continue
try:
resolved = match.resolve()
resolved.relative_to(root)
except (OSError, ValueError):
continue
yield resolved
def _iter_local_xmls(path: Path):
"""Backwards-compatible wrapper for `_iter_local(path, '*.xml')`."""
yield from _iter_local(path, "*.xml")
def is_loose_song(path: Path) -> bool:
"""True if this directory looks like a playable loose song folder.
Requires both a non-preview WEM and at least one arrangement XML
that isn't a vocals or showlights track — otherwise highway_ws would
later fail when it tries to pick an arrangement from an empty list.
Classification looks at the XML root element rather than the
filename so a custom named `lead_vocals_fix.xml` (root `<song>`)
still counts as a playable arrangement.
"""
if not path.is_dir():
return False
# Require an actual file that resolves inside `path` — both
# rejects directories named `audio.wem` and refuses symlinks
# escaping the song folder.
def _named_audio_ok(name: str) -> bool:
p = path / name
if not p.is_file():
return False
try:
p.resolve().relative_to(path.resolve())
except (OSError, ValueError):
return False
return True
has_audio = (
any(_named_audio_ok(a) for a in AUDIO_NAMES)
or any("preview" not in f.stem.lower()
for f in _iter_local(path, "*.wem"))
)
if not has_audio:
return False
for xml in _iter_local_xmls(path):
try:
root_tag = ET.parse(str(xml)).getroot().tag
except Exception:
continue
if root_tag == "song":
return True
return False
def find_audio(path: Path) -> Path | None:
"""Return the path to the best audio file in the folder.
Prefers known names, then falls back to any WEM that isn't a preview clip.
Only returns regular files that resolve inside `path` — a directory,
broken symlink, or symlink escaping the folder would otherwise be
returned and either break convert_wem or read external content.
"""
root = path.resolve()
def _in_folder(p: Path) -> bool:
try:
p.resolve().relative_to(root)
except (OSError, ValueError):
return False
return True
# Check known names first
for a in AUDIO_NAMES:
cand = path / a
if cand.is_file() and _in_folder(cand):
return cand
def _safe_size(f: Path) -> int:
# Treat unreadable files (broken symlinks, permission errors)
# as zero-byte so they sort last and never get picked.
try:
return f.stat().st_size
except OSError:
return 0
candidates = sorted(
[f for f in _iter_local(path, "*.wem")
if "preview" not in f.stem.lower()],
key=_safe_size,
reverse=True,
)
return candidates[0] if candidates else None
def find_art(path: Path) -> Path | None:
"""Return the path to the first recognised album art file in the folder.
Only matches regular files — a directory named `cover.jpg` would
otherwise be returned and trip the FileResponse / containment
checks downstream.
"""
return next((path / a for a in ART_NAMES if (path / a).is_file()), None)
def _arr_type_from_filename(stem: str) -> tuple:
"""Infer arrangement type from filename keywords."""
s = stem.lower()
for key, val in ARR_TYPE_MAP.items():
if key in s:
return val
return ("lead", "Lead", 0) # fallback
def _parse_xml_meta(xml_path: Path) -> dict:
"""Parse a chart arrangement XML and return song-level metadata."""
try:
root = ET.parse(str(xml_path)).getroot()
if root.tag != "song":
return {}
def txt(tag, default=""):
el = root.find(tag)
return el.text.strip() if el is not None and el.text else default
# Tuning from attributes
tuning_el = root.find("tuning")
if tuning_el is not None:
offsets = [int(tuning_el.get(f"string{i}", 0)) for i in range(6)]
else:
offsets = [0] * 6
# Arrangement type — filename is more reliable than the XML tag
# because some authoring tools write "Lead" for all arrangements.
# We read the XML tag here and let _detect_arrangements decide
# which source to trust.
arr_tag = txt("arrangement", "").lower()
arr_from_tag = ARR_TYPE_MAP.get(arr_tag, None)
duration = 0.0
try:
duration = float(txt("songLength", "0"))
except (ValueError, TypeError):
pass
return {
"title": txt("title"),
"artist": txt("artistName"),
"album": txt("albumName"),
"year": txt("albumYear", ""),
"duration": duration,
"tuning_offsets": offsets,
"arr_from_tag": arr_from_tag, # may be None
}
except Exception:
return {}
def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
"""
Parse all arrangement XMLs.
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 —
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
# `lead_vocals_fix.xml` is still a real arrangement.
# `_parse_xml_meta` returns {} for any root other than <song>,
# which is how vocals/showlights tracks get filtered out.
stem = xml.stem.lower()
meta = _parse_xml_meta(xml)
if not meta:
continue
# Arrangement type: prefer filename keywords over the XML tag
# because some tools write "Lead" for all arrangements regardless
# of actual type. Only fall back to the XML tag when the filename
# gives no useful signal (i.e. no recognisable keyword found).
filename_type = _arr_type_from_filename(stem)
if filename_type[0] != "lead" or "lead" in stem:
# Filename gave a confident answer
arr_type, arr_name, priority = filename_type
else:
# Filename wasn't specific — try the XML tag
arr_from_tag = meta.get("arr_from_tag")
if arr_from_tag:
arr_type, arr_name, priority = arr_from_tag
else:
arr_type, arr_name, priority = filename_type
# Take song-level fields from the highest-priority arrangement
# (lowest `priority` number) so tuning reflects the main guitar
# instead of whatever sorted first alphabetically.
if meta.get("title") and (shared_priority is None or priority < shared_priority):
shared_meta = {k: meta[k] for k in
("title", "artist", "album", "year",
"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,
"file": xml.name,
"priority": priority,
})
arrangements.sort(key=lambda a: a["priority"])
for i, a in enumerate(arrangements):
a["index"] = i
del a["priority"]
for role, offs in role_tunings.items():
shared_meta[f"{role}_tuning_offsets"] = offs
return arrangements, shared_meta
def _has_lyrics(path: Path) -> bool:
"""Return True if any XML in the folder is a vocals track."""
for xml in _iter_local_xmls(path):
try:
if ET.parse(str(xml)).getroot().tag == "vocals":
return True
except Exception:
pass
return False
def _coerce_duration(raw, fallback) -> float:
"""Coerce a manifest duration to a finite float, falling back on bad input.
Rejects NaN / Infinity so a manifest like `{"duration": "Infinity"}`
can't poison `meta_db` and then crash Starlette's JSON encoder when
the row is served back through `/api/song/...`.
"""
try:
v = float(raw)
if math.isfinite(v):
return v
except (TypeError, ValueError):
pass
try:
v = float(fallback or 0.0)
return v if math.isfinite(v) else 0.0
except (TypeError, ValueError):
return 0.0
def _coerce_text(raw) -> str | None:
"""Return raw if it's a non-empty string, else None.
Manifest fields like `title` / `artist` / `album` can arrive as
JSON nulls, lists, or numbers (e.g. someone setting album to a
year by mistake). Returning None lets the caller fall back to
XML / folder inference instead of crashing the DB row write.
"""
if isinstance(raw, str) and raw:
return raw
return None
def _coerce_tuning_offsets(raw, fallback) -> list[int]:
"""Validate manifest tuning_offsets: must be a list of 6 numeric values."""
if isinstance(raw, list) and len(raw) == 6:
try:
return [int(v) for v in raw]
except (TypeError, ValueError):
pass
if isinstance(fallback, list) and len(fallback) == 6:
return list(fallback)
return [0] * 6
def _validate_manifest_arrangements(raw) -> list[dict] | None:
"""Return raw if it's a well-formed arrangement list, else None.
Each entry must be a dict carrying at least `type`, `name`, `file`
(string-typed). Bad shapes get dropped so the parsed XML list wins.
"""
if not isinstance(raw, list) or not raw:
return None
out = []
for entry in raw:
if not isinstance(entry, dict):
return None
if not all(isinstance(entry.get(k), str) and entry.get(k)
for k in ("type", "name", "file")):
return None
out.append(entry)
return out
def extract_meta(path: Path, dlc_root: Path | None = None) -> dict:
"""Return metadata dict for a loose song folder.
Priority chain:
1. manifest.json (explicit user-supplied data)
2. XML metadata (parsed from arrangement XMLs)
3. Folder name (last resort inference from directory structure)
`dlc_root` is used to bound the folder-name inference: artist/album
are only inferred from `path.relative_to(dlc_root)` components, so
a loose folder placed at `<DLC>/song/` doesn't accidentally surface
the user's home-directory name as the artist.
"""
# 1. Try manifest.json
manifest_path = path / "manifest.json"
manifest = {}
if manifest_path.exists():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception:
pass
if not isinstance(manifest, dict):
manifest = {}
# 2. Parse XMLs for song metadata + arrangements
arrangements, xml_meta = _detect_arrangements(path)
# 3. Folder inference — only from path components under DLC_DIR so
# absolute-path parts (`/home/<user>/...`) can never leak as artist.
rel_parts: tuple[str, ...] = ()
if dlc_root is not None:
try:
rel_parts = path.resolve().relative_to(dlc_root.resolve()).parts
except (ValueError, OSError):
rel_parts = ()
# Coerce manifest text fields — non-strings (lists, numbers, null)
# would otherwise propagate into meta_db rows and break DB writes.
title = _coerce_text(manifest.get("title")) or xml_meta.get("title") or path.name
artist = (_coerce_text(manifest.get("artist")) or xml_meta.get("artist")
or (rel_parts[-3] if len(rel_parts) >= 3 else ""))
album = (_coerce_text(manifest.get("album")) or xml_meta.get("album")
or (rel_parts[-2] if len(rel_parts) >= 2 else ""))
raw_year = manifest.get("year")
if isinstance(raw_year, (int, float)) and not isinstance(raw_year, bool):
manifest_year = str(int(raw_year))
else:
manifest_year = _coerce_text(raw_year) or ""
year = manifest_year or str(xml_meta.get("year", ""))
duration = _coerce_duration(manifest.get("duration"),
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:
arrangements = manifest_arr
audio = find_audio(path)
art = find_art(path)
return {
"title": title,
"artist": artist,
"album": album,
"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,
"has_lyrics": _has_lyrics(path),
}