mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 12:54:29 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb354f9c38 | ||
|
|
51085048aa | ||
|
|
bde25c0bc8 | ||
|
|
c7aa5a10b0 | ||
|
|
fa2d12222a | ||
|
|
73c5ab149e | ||
|
|
a65d8cfa13 | ||
|
|
a86abadb14 | ||
|
|
41e907fa52 | ||
|
|
2c1c6f7eac | ||
|
|
117d260723 |
@@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
|
||||
|
||||
### Fixed
|
||||
- **Guitar Pro 6 (`.gpx`) import no longer fails on every real file.** The GPX BCFS container reader (`lib/gp2rs_gpx.py`) rejected any file whose final sector wasn't a full `0x1000` block — but a real `.gpx`'s BCFZ-declared decompressed size isn't sector-aligned, so the last (small) container file always lands in a partial trailing sector. The bounds check *raised* `GPX BCFS sector pointer out of range (malformed file)` instead of clamping the tail read, so `_load_gpif` threw before `score.gpif` could be extracted and **no GP6 file could be imported into the song editor** (both real test files failed identically — this wasn't file-specific). GP7/GP8 `.gp` files were unaffected — they take the ZIP path, not BCFS, which is why prior GP-import work didn't surface it. The reader now **clamps the final sector read to the buffer end** (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro); a sector whose *start* is past the end still raises, preserving the malformed-file guard. Verified against two real GP6 files — both now unpack to valid GPIF with all tracks. Tests: `tests/test_gp2rs_gpx.py` (partial-final-sector round-trip, multi-file container, sector-aligned baseline, and the preserved out-of-range guard).
|
||||
- **v3 Songs grid: fixed the scroll stutter that "skips every so many scrolls," up or down.** The virtualized grid rebuilt its **entire** visible window (`grid.innerHTML = …` + a full `wireCards` pass) every time it slid by one row, so each row-boundary crossing was a heavy synchronous frame that stalled the main thread and buffered held-arrow key-repeats into a visible lurch (a tester's "super fast for a second then slowed down") at fixed scroll offsets — in **both directions and regardless of whether the page was already loaded** (the cost was DOM teardown, not fetching, which is why scrolling back up over cached songs hitched too). `renderWindow()` now **reconciles the window in place**: it reuses the card nodes that stay on-screen and builds only the row that enters/leaves (~6 nodes per slide instead of ~60), keyed by absolute index with a real-vs-skeleton + select-mode signature so hole-fills (after a page fetch) and select-mode toggles still rebuild exactly the nodes that changed. `wireCards`'s `data-wired` guard then wires only the freshly-built nodes, so per-slide listener churn drops with it. Follow-up to the stage-2 virtualized grid (got-feedback/feedBack#636 item 3). Frontend-only: `static/v3/songs.js`. Tests: `tests/js/v3_songs_window_recycle.test.js` (window stays `[start,end)` contiguous + in-window node identity reused across a down-then-up scroll; select-mode toggle and rail-seek jump rebuild correctly).
|
||||
- **Starter content seeds again (and now ships The Adicts' "Ode to Joy").** `_BUILTIN_STARTER_SOURCES` still listed `beethoven-ode_to_joy.feedpak` after that pack was deleted, and never wired up its replacement `the_adicts-ode-to-joy_vst_cover.feedpak` that landed on disk. The listed-but-missing file made the all-present gate never fire, so **no** starter songs seeded on first run. Synced the manifest to what's on disk (Für Elise, Star Spangled Banner, The Adicts' Ode to Joy). Tests: `tests/test_builtin_starter_seed.py` (the present/unlisted guards were red on `main`).
|
||||
- **Edit Metadata now writes into `.feedpak` files, not just legacy `.sloppak` ones.** `lib/songmeta.py`'s suffix gate predated the format rename — core reads both suffixes everywhere else (`sloppak.SONG_EXTS`), but the metadata writer only dispatched on `.sloppak`, so editing a zip-form `.feedpak`'s title/artist/album/year silently fell back to a DB-only update. That looked fine until the next **full library rescan** re-derived metadata from the file and reverted the edit (directory-form packages were unaffected — they dispatch on manifest presence, not suffix). The gate now accepts both package suffixes. Tests: `tests/test_songmeta.py` `TestWriteSongMetadata` (both zip suffixes, mixed-case suffix, directory form, unknown-suffix fallback).
|
||||
- **3D Drum & Keys highways now re-frame on fullscreen/layout drift under splitscreen.** The guitar/bass `highway_3d` self-detects when its panel canvas changes size and re-runs `applySize()` every frame, because the splitscreen host overrides `hw.resize` and never calls `renderer.resize()`. The drum and keys highways lacked that fallback — they only re-framed when the host explicitly called `resize(w, h)` — so their panels stayed framed for the pre-fullscreen size while the guitar/bass panels adapted (visible as a too-small, off-center highway after maximizing a split-screen session). Both draw loops now port `highway_3d`'s per-frame drift check: they re-apply on backing-store change (`canvas.width/height`) AND on CSS-box drift (`clientWidth/clientHeight` vs the last applied logical size, throttled to every 10th frame), and reset the tracking in `destroy()` so a reused instance re-frames on the next song. `plugins/drum_highway_3d` → 0.3.1, `plugins/keys_highway_3d` → 0.1.1. Tests: `tests/js/drum_keys_highway_3d_resize_reframe.test.js`.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""AcoustID audio-fingerprint identification for MusicBrainz enrichment.
|
||||
|
||||
A flat MusicBrainz *text* search ties every take of a song at the same score —
|
||||
studio, a dozen live bootlegs, and every compilation — so "AC/DC — Highway to
|
||||
Hell" returns junk (see lib/mb_match.py's canonical re-ranking, which mitigates
|
||||
it). The definitive fix is content-based: fingerprint the actual audio with
|
||||
Chromaprint (`fpcalc`) and look it up on AcoustID, which maps the fingerprint
|
||||
straight to the *exact* MusicBrainz recording — the same approach Lidarr uses.
|
||||
|
||||
This module is the PURE half (no network, no subprocess): response parsing +
|
||||
config gating, so it is unit-testable in isolation. server.py owns the `fpcalc`
|
||||
subprocess and the throttled HTTP GET to api.acoustid.org.
|
||||
|
||||
Operational requirements (both optional — absent ⇒ this path is a graceful
|
||||
no-op and the text matcher still runs):
|
||||
* `fpcalc` (Chromaprint) on PATH or at $FPCALC — generates the fingerprint.
|
||||
* an AcoustID application API key in $ACOUSTID_API_KEY — free from
|
||||
https://acoustid.org/new-application ; AcoustID etiquette limits to ~3 req/s.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
ACOUSTID_API_ROOT = "https://api.acoustid.org/v2"
|
||||
|
||||
# The `meta` fields we ask AcoustID to return so a hit resolves to displayable
|
||||
# metadata without a second MusicBrainz round-trip. SPACE-separated, not
|
||||
# `+`-joined: a literal `+` in the value gets percent-encoded to %2B, which
|
||||
# AcoustID does NOT split into flags — it then attaches no recording metadata
|
||||
# and every hit comes back empty (verified: `+` → 0 recordings, space → 28).
|
||||
# `releases` is what carries the per-release DATE (nested under each
|
||||
# releasegroup), which we need to pick the earliest original album + fill year.
|
||||
LOOKUP_META = "recordings releasegroups releases compress"
|
||||
|
||||
# Mirror mb_match._SECONDARY_SKIP: release-group secondary types that mark a
|
||||
# non-canonical (live/comp/remix) release, so we can flag the studio take.
|
||||
_SECONDARY_SKIP = {
|
||||
"live", "compilation", "remix", "dj-mix", "mixtape/street",
|
||||
"demo", "interview", "audiobook", "spokenword",
|
||||
}
|
||||
|
||||
|
||||
def api_key(explicit: str | None = None) -> str:
|
||||
"""The AcoustID application API key: an explicit value (e.g. a host setting)
|
||||
wins, else $ACOUSTID_API_KEY, else "" (⇒ fingerprinting disabled)."""
|
||||
return (explicit or os.environ.get("ACOUSTID_API_KEY") or "").strip()
|
||||
|
||||
|
||||
def is_configured(explicit_key: str | None = None) -> bool:
|
||||
"""True when an API key is available. `fpcalc` presence is checked by
|
||||
server.py (it owns the binary lookup); both are required to actually run."""
|
||||
return bool(api_key(explicit_key))
|
||||
|
||||
|
||||
def _rg_is_studio(rg: dict) -> bool:
|
||||
if str(rg.get("type", "")).lower() != "album":
|
||||
return False
|
||||
secs = {str(s).lower() for s in (rg.get("secondarytypes") or [])}
|
||||
return not (secs & _SECONDARY_SKIP)
|
||||
|
||||
|
||||
def _rg_earliest_year(rg: dict) -> "int | None":
|
||||
"""Earliest release YEAR in a release-group (min over its nested releases'
|
||||
dates). None when no release carries a date. This is what separates the
|
||||
original pressing from later reissues/comps sharing the same group."""
|
||||
years = []
|
||||
for rel in (rg.get("releases") or []):
|
||||
d = (rel or {}).get("date")
|
||||
if isinstance(d, dict) and d.get("year"):
|
||||
try:
|
||||
years.append(int(d["year"]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return min(years) if years else None
|
||||
|
||||
|
||||
def _best_group(recording: dict) -> dict:
|
||||
"""Pick the display album: a clean studio Album first, and among those the
|
||||
EARLIEST-released one — the original, not a later reissue or a compilation
|
||||
that happens to be typed 'Album' (e.g. a soundtrack). This is what pulls
|
||||
"Machine Head" ahead of a later comp for "Smoke on the Water". Falls back to
|
||||
the first group when nothing is a studio album or nothing carries a date."""
|
||||
groups = [g for g in (recording.get("releasegroups") or []) if isinstance(g, dict)]
|
||||
if not groups:
|
||||
return {}
|
||||
|
||||
def sort_key(g):
|
||||
yr = _rg_earliest_year(g)
|
||||
# studio (0) before non-studio (1); then earliest year (undated last).
|
||||
return (0 if _rg_is_studio(g) else 1, yr if yr is not None else 9999)
|
||||
|
||||
return sorted(groups, key=sort_key)[0]
|
||||
|
||||
|
||||
def _first_artist(recording: dict) -> str:
|
||||
for a in (recording.get("artists") or []):
|
||||
if isinstance(a, dict) and a.get("name"):
|
||||
return str(a["name"])
|
||||
return ""
|
||||
|
||||
|
||||
def parse_lookup_response(body: dict) -> list[dict]:
|
||||
"""Normalize an AcoustID /v2/lookup response into the same flat candidate
|
||||
shape as mb_match (recording_id / title / artist / album / year / duration /
|
||||
studio / mb_score / score), so the review UI and the editor's Match popup
|
||||
render fingerprint hits and text hits identically. `mb_score` carries the
|
||||
AcoustID confidence (0-100) — a fingerprint hit is high-signal by nature."""
|
||||
if not isinstance(body, dict) or body.get("status") != "ok":
|
||||
return []
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for result in (body.get("results") or []):
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
try:
|
||||
score = float(result.get("score") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
score = 0.0
|
||||
for rec in (result.get("recordings") or []):
|
||||
if not isinstance(rec, dict) or not rec.get("id"):
|
||||
continue
|
||||
rid = str(rec["id"])
|
||||
if rid in seen:
|
||||
continue
|
||||
seen.add(rid)
|
||||
rg = _best_group(rec)
|
||||
_yr = _rg_earliest_year(rg)
|
||||
year = str(_yr) if _yr else ""
|
||||
dur = rec.get("duration")
|
||||
try:
|
||||
duration = int(round(float(dur))) if dur else None
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
out.append({
|
||||
"recording_id": rid,
|
||||
"title": str(rec.get("title", "") or ""),
|
||||
"artist": _first_artist(rec),
|
||||
"album": str(rg.get("title", "") or ""),
|
||||
"year": year,
|
||||
"duration": duration,
|
||||
"isrc": "",
|
||||
"genres": [],
|
||||
"studio": _rg_is_studio(rg),
|
||||
"acoustid_score": round(score, 4),
|
||||
# Fingerprint hits are content-verified, not text-guessed — carry
|
||||
# the AcoustID confidence as the display score band.
|
||||
"mb_score": int(round(score * 100)),
|
||||
"score": round(score, 4),
|
||||
"source": "acoustid",
|
||||
})
|
||||
# Best AcoustID confidence first; studio take breaks ties.
|
||||
out.sort(key=lambda c: (c["acoustid_score"], 1 if c["studio"] else 0), reverse=True)
|
||||
return out
|
||||
+11
-3
@@ -121,10 +121,18 @@ def _parse_bcfs(bcfs: bytes) -> dict:
|
||||
while sc <= max_sectors:
|
||||
s = _gi(po + 4 * sc); sc += 1
|
||||
if s == 0: break
|
||||
so = s * SECTOR
|
||||
if HDR + so + SECTOR > len(data):
|
||||
start = HDR + s * SECTOR
|
||||
# Real .gpx files' final sector is a few bytes short of a full
|
||||
# 0x1000 block: the BCFZ-declared decompressed size isn't
|
||||
# sector-aligned, so the last (small) container file lands in a
|
||||
# partial trailing sector. Clamp the read to the buffer end —
|
||||
# the per-file size field (`fs`, applied below) trims any
|
||||
# padding — matching canonical GPX readers (alphaTab /
|
||||
# PyGuitarPro slice-and-clamp). Only a sector whose *start* is
|
||||
# past the end is genuinely malformed.
|
||||
if start < 0 or start >= len(data):
|
||||
raise ValueError("GPX BCFS sector pointer out of range (malformed file)")
|
||||
fb.extend(data[HDR + so: HDR + so + SECTOR])
|
||||
fb.extend(data[start: min(start + SECTOR, len(data))])
|
||||
else:
|
||||
raise ValueError("GPX BCFS sector chain too long (malformed file)")
|
||||
files[fn] = bytes(fb[:fs])
|
||||
|
||||
+104
-12
@@ -39,6 +39,14 @@ DURATION_BONUS_LOOSE = 0.025 # …within 15s
|
||||
_DURATION_TIGHT = 5
|
||||
_DURATION_LOOSE = 15
|
||||
|
||||
# Release-group secondary types that mark a NON-canonical release (a live album,
|
||||
# a greatest-hits comp, a remix/DJ set, …). Used both to pick the canonical
|
||||
# studio album for display and to reward studio recordings in ranking.
|
||||
_SECONDARY_SKIP = {
|
||||
"live", "compilation", "remix", "dj-mix", "mixtape/street",
|
||||
"demo", "interview", "audiobook", "spokenword",
|
||||
}
|
||||
|
||||
# ── Denoise ───────────────────────────────────────────────────────────────────
|
||||
# A parenthetical/bracketed group is dropped when it contains any of these
|
||||
# noise terms as a whole word (chart-variant markers, tuning/pitch notes,
|
||||
@@ -154,6 +162,10 @@ def score_candidate(song: dict, cand: dict) -> float:
|
||||
score += DURATION_BONUS
|
||||
elif diff <= _DURATION_LOOSE:
|
||||
score += DURATION_BONUS_LOOSE
|
||||
# NB: the studio-vs-live distinction is deliberately NOT scored here — a live
|
||||
# take is still the RIGHT SONG (same title/artist), so it must not change the
|
||||
# auto/review confidence. Canonical-version preference lives in the RANK sort
|
||||
# (rank_candidates) instead, where it only reorders same-song candidates.
|
||||
return min(score, 1.0)
|
||||
|
||||
|
||||
@@ -179,15 +191,34 @@ def classify(song: dict, cand: dict, score: float, auto_min: float | None = None
|
||||
|
||||
|
||||
def rank_candidates(song: dict, candidates: list[dict]) -> list[dict]:
|
||||
"""Score every candidate against the song and return them sorted by our
|
||||
score (MusicBrainz's own search score is only a tiebreak). Each returned
|
||||
dict is a copy carrying `score` (rounded — it's displayed and stored)."""
|
||||
"""Score every candidate against the song and return them sorted best-first.
|
||||
The combined `score` caps at 1.0, so a perfect-text-match query (every "AC/DC
|
||||
Highway to Hell" recording) ties at the top — there the studio flag and, when
|
||||
the caller knows the audio length, the duration match break the tie so the
|
||||
canonical studio take wins over live/promo/extended cuts. Each returned dict
|
||||
is a copy carrying `score` (rounded — it's displayed and stored)."""
|
||||
sd = _duration_int(song.get("duration"))
|
||||
# For a chart that IS a live take (build_recording_query keeps live
|
||||
# recordings for these) the studio take is the WRONG recording, so drop the
|
||||
# studio tiebreak — duration proximity + text/mb score then pick the right
|
||||
# live version instead of auto-matching the studio one.
|
||||
prefer_studio = not _LIVE_GROUP_RE.search(str(song.get("title") or ""))
|
||||
|
||||
def _dur_diff(c):
|
||||
cd = _duration_int(c.get("duration"))
|
||||
return abs(sd - cd) if (sd and cd) else 10 ** 6
|
||||
|
||||
ranked = []
|
||||
for cand in candidates or []:
|
||||
c = dict(cand)
|
||||
c["score"] = round(score_candidate(song, cand), 4)
|
||||
ranked.append(c)
|
||||
ranked.sort(key=lambda c: (c["score"], c.get("mb_score") or 0), reverse=True)
|
||||
ranked.sort(
|
||||
key=lambda c: (c["score"],
|
||||
(1 if c.get("studio") else 0) if prefer_studio else 0,
|
||||
-_dur_diff(c), # closest to the audio length
|
||||
c.get("mb_score") or 0),
|
||||
reverse=True)
|
||||
return ranked
|
||||
|
||||
|
||||
@@ -198,18 +229,63 @@ def _lucene_escape_phrase(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def build_recording_query(artist, title) -> str:
|
||||
# A parenthetical/bracketed "(Live …)" marker — the live signal denoise() strips
|
||||
# from the title. Mirrors _NOISE_GROUP_RE but for the `live` term only.
|
||||
_LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE)
|
||||
|
||||
|
||||
def build_recording_query(artist, title, *, loose: bool = False) -> str:
|
||||
"""Lucene query for /ws/2/recording. Built from the DENOISED fields —
|
||||
the noise we strip (author credits, "(Live)", "(v2)") would otherwise
|
||||
poison the search server's own scoring."""
|
||||
poison the search server's own scoring.
|
||||
|
||||
``loose=True`` drops the field-scoped quoted PHRASES for plain AND-ed
|
||||
term groups (``(telephone number) AND (junko ohashi)``). The point:
|
||||
a field phrase like ``artist:"Junko Ohashi"`` only matches MusicBrainz's
|
||||
*primary* artist name — it never searches ALIASES — so a recording stored
|
||||
under a non-Latin primary (大橋純子) whose romanized name is only an alias
|
||||
is invisible to the strict query. A loose term query searches the whole
|
||||
document, aliases included, and surfaces it. Lower precision by design: it
|
||||
is a FALLBACK for when the strict query returns nothing, and its results
|
||||
are re-scored by ``rank_candidates`` (and, for auto-match, gated by the
|
||||
per-field floors), so noise never auto-applies."""
|
||||
t = denoise(title)
|
||||
a = denoise(artist)
|
||||
if loose:
|
||||
# denoise() already reduced each field to lowercase [a-z0-9 and] tokens
|
||||
# (punctuation → spaces, diacritics stripped, & → "and"), so no
|
||||
# Lucene-special character survives to need escaping. Group each field's
|
||||
# terms and require both groups.
|
||||
q = " AND ".join("(%s)" % g for g in (t, a) if g)
|
||||
# Keep the SAME live exclusion as the strict path: the loose query is
|
||||
# lower-precision, and score_candidate doesn't penalize a live take, so
|
||||
# without this a studio chart whose strict query missed could fall back
|
||||
# to — and auto-confirm — a live-only recording. Skipped only when the
|
||||
# source title is itself a live take (mirrors the strict path).
|
||||
if q and not _LIVE_GROUP_RE.search(str(title or "")):
|
||||
q += " AND -secondarytype:Live"
|
||||
return q
|
||||
parts = []
|
||||
if t:
|
||||
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
|
||||
if a:
|
||||
parts.append('artist:"%s"' % _lucene_escape_phrase(a))
|
||||
return " AND ".join(parts)
|
||||
q = " AND ".join(parts)
|
||||
# Drop live-ONLY recordings (bootlegs, live albums) — the canonical studio
|
||||
# take is never tagged Live, and this is the single biggest source of junk in
|
||||
# a flat recording search. Compilations are deliberately NOT excluded: they
|
||||
# REUSE the studio recording, so filtering them would drop the very recording
|
||||
# we want (verified against MusicBrainz — `-secondarytype:Compilation` cut the
|
||||
# AC/DC studio "Highway to Hell" recording entirely).
|
||||
#
|
||||
# EXCEPT when the source chart is itself a live take: denoise() strips the
|
||||
# "(Live at …)" qualifier from the query, so filtering Live would leave the
|
||||
# genuinely-live chart with NO correct recording. Only a parenthetical marker
|
||||
# counts — a bare title word ("Live and Let Die") is a real word, not a live
|
||||
# tag — mirroring what denoise removes.
|
||||
if q and not _LIVE_GROUP_RE.search(str(title or "")):
|
||||
q += " AND -secondarytype:Live"
|
||||
return q
|
||||
|
||||
|
||||
def _artist_credit(doc: dict) -> tuple[str, str, str]:
|
||||
@@ -226,19 +302,33 @@ def _artist_credit(doc: dict) -> tuple[str, str, str]:
|
||||
return name, str(artist.get("id", "") or ""), str(artist.get("sort-name", "") or "")
|
||||
|
||||
|
||||
def _is_clean_studio_album(rg: dict) -> bool:
|
||||
"""A release-group that is a primary-type Album with NO non-canonical
|
||||
secondary type (Live / Compilation / Remix / …) — i.e. a studio album."""
|
||||
if str(rg.get("primary-type", "")).lower() != "album":
|
||||
return False
|
||||
secs = {str(s).lower() for s in (rg.get("secondary-types") or [])}
|
||||
return not (secs & _SECONDARY_SKIP)
|
||||
|
||||
|
||||
def _best_release(doc: dict) -> dict:
|
||||
"""Pick the release used for canon album/year: prefer Official status and
|
||||
an Album release-group, then the earliest date. Returns {} if none."""
|
||||
"""Pick the release used for canon album/year: prefer an OFFICIAL studio
|
||||
Album (primary Album with no Live/Compilation/… secondary type), then the
|
||||
earliest date. Falls back to any release when none is clean. {} if none."""
|
||||
releases = [r for r in (doc.get("releases") or []) if isinstance(r, dict)]
|
||||
if not releases:
|
||||
return {}
|
||||
|
||||
def sort_key(r):
|
||||
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
|
||||
rg = r.get("release-group") or {}
|
||||
album_ok = 0 if str(rg.get("primary-type", "")).lower() == "album" else 1
|
||||
clean = 0 if _is_clean_studio_album(rg) else 1
|
||||
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
|
||||
date = str(r.get("date", "") or "9999")
|
||||
return (status_ok, album_ok, date)
|
||||
# Official FIRST, then prefer a clean studio album: this still surfaces
|
||||
# the studio album over an (official) live/comp album for the display
|
||||
# album/year, but never lets an UNofficial bootleg album outrank an
|
||||
# official single/EP/comp — which `(clean, status_ok, …)` would.
|
||||
return (status_ok, clean, date)
|
||||
|
||||
return sorted(releases, key=sort_key)[0]
|
||||
|
||||
@@ -261,6 +351,7 @@ def parse_recording_doc(doc: dict) -> dict | None:
|
||||
return None
|
||||
artist_name, artist_id, artist_sort = _artist_credit(doc)
|
||||
release = _best_release(doc)
|
||||
studio = _is_clean_studio_album(release.get("release-group") or {})
|
||||
length = doc.get("length")
|
||||
try:
|
||||
duration = int(round(float(length) / 1000.0)) if length else None
|
||||
@@ -281,6 +372,7 @@ def parse_recording_doc(doc: dict) -> dict | None:
|
||||
"isrc": isrcs[0] if isrcs else "",
|
||||
"genres": _genres(doc),
|
||||
"mb_score": int(doc.get("score") or 0),
|
||||
"studio": studio,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ def safe_join(root: Path, name: str) -> Path | None:
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
# Reject embedded NULs explicitly. This used to ride on `.resolve()`
|
||||
# raising ValueError, but on Python 3.13 (Windows) resolve() no longer
|
||||
# raises for an embedded NUL, so the byte would otherwise leak through
|
||||
# containment. An explicit guard is strictly-more-rejection (no effect on
|
||||
# the zip-slip / traversal contract).
|
||||
if "\x00" in name:
|
||||
return None
|
||||
safe = name.replace("\\", "/")
|
||||
try:
|
||||
root_resolved = root.resolve()
|
||||
|
||||
+364
-33
@@ -4,51 +4,132 @@ Kept separate from server.py so tests can import it without triggering
|
||||
FastAPI / SQLite module-level side effects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
DEFAULT_REFERENCE_PITCH = 440.0
|
||||
|
||||
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then
|
||||
# tuning name. This is the authoritative source; tuner/routes.py previously
|
||||
# held a copy — it was removed in favour of this one.
|
||||
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = {
|
||||
# Canonical open strings, low to high, as MIDI notes. This is the host-level
|
||||
# source of truth for guitar/bass tuning profiles; UI surfaces derive names,
|
||||
# frequencies, and semitone offsets from these absolute pitches.
|
||||
STANDARD_OPEN_MIDIS: dict[str, list[int]] = {
|
||||
"guitar-6": [40, 45, 50, 55, 59, 64],
|
||||
"guitar-7": [35, 40, 45, 50, 55, 59, 64],
|
||||
"guitar-8": [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
"bass-4": [28, 33, 38, 43],
|
||||
"bass-5": [23, 28, 33, 38, 43],
|
||||
"bass-6": [23, 28, 33, 38, 43, 48],
|
||||
}
|
||||
|
||||
# Curated built-in profiles. This intentionally starts by absorbing the useful
|
||||
# Virtuoso guitar/bass coverage into host-owned data so the host selector,
|
||||
# tuner, practice tools, and plugins can converge on one profile model.
|
||||
TUNING_PRESET_MIDIS: dict[str, dict[str, list[int]]] = {
|
||||
"guitar-6": {
|
||||
"Standard": [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Eb Standard": [77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
|
||||
"Drop D": [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"D Standard": [73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop C": [65.41, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Open G": [73.42, 98.00, 146.83, 196.00, 246.94, 293.66],
|
||||
"Open D": [73.42, 110.00, 146.83, 185.00, 220.00, 293.66],
|
||||
"DADGAD": [73.42, 110.00, 146.83, 196.00, 220.00, 293.66],
|
||||
"Open E": [82.41, 123.47, 164.81, 207.65, 246.94, 329.63],
|
||||
"Standard": [40, 45, 50, 55, 59, 64],
|
||||
"Eb Standard": [39, 44, 49, 54, 58, 63],
|
||||
"D Standard": [38, 43, 48, 53, 57, 62],
|
||||
"C# Standard": [37, 42, 47, 52, 56, 61],
|
||||
"C Standard": [36, 41, 46, 51, 55, 60],
|
||||
"Drop D": [38, 45, 50, 55, 59, 64],
|
||||
"Drop C": [36, 43, 48, 53, 57, 62],
|
||||
"Drop B": [35, 42, 47, 52, 56, 61],
|
||||
"Drop A": [33, 40, 45, 50, 54, 59],
|
||||
"Drop Ab": [32, 39, 44, 49, 53, 58],
|
||||
"Open G": [38, 43, 50, 55, 59, 62],
|
||||
"Open D": [38, 45, 50, 54, 57, 62],
|
||||
"DADGAD": [38, 45, 50, 55, 57, 62],
|
||||
"Open E": [40, 47, 52, 56, 59, 64],
|
||||
},
|
||||
"guitar-7": {
|
||||
"Standard": [61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Drop A": [55.00, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"A Standard": [55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop G": [49.00, 73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Bb Standard": [58.27, 77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
|
||||
"Standard": [35, 40, 45, 50, 55, 59, 64],
|
||||
"Bb Standard": [34, 39, 44, 49, 54, 58, 63],
|
||||
"A Standard": [33, 38, 43, 48, 53, 57, 62],
|
||||
"G Standard": [31, 36, 41, 46, 51, 55, 60],
|
||||
"Drop A": [33, 40, 45, 50, 55, 59, 64],
|
||||
"Drop G": [31, 38, 43, 48, 53, 57, 62],
|
||||
"Drop F#": [30, 37, 42, 47, 52, 56, 61],
|
||||
},
|
||||
"guitar-8": {
|
||||
"Standard": [46.25, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Drop E": [41.20, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"E Standard": [41.20, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop D": [36.71, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Eb Standard": [38.89, 51.91, 69.30, 92.50, 123.47, 164.81, 207.65, 277.18],
|
||||
"Standard": [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
"Drop E": [28, 35, 40, 45, 50, 55, 59, 64],
|
||||
"Drop A + Drop E": [28, 33, 40, 45, 50, 55, 59, 64],
|
||||
"E Standard": [28, 33, 38, 43, 48, 53, 57, 62],
|
||||
"Eb Standard": [27, 32, 37, 42, 47, 52, 56, 61],
|
||||
"Drop D": [26, 33, 38, 43, 48, 53, 57, 62],
|
||||
},
|
||||
"bass-4": {
|
||||
"Standard": [41.20, 55.00, 73.42, 98.00],
|
||||
"Eb Standard": [38.89, 51.91, 69.30, 92.50],
|
||||
"Drop D": [36.71, 55.00, 73.42, 98.00],
|
||||
"D Standard": [36.71, 48.99, 65.41, 87.31],
|
||||
"Drop C": [32.70, 48.99, 65.41, 87.31],
|
||||
"Standard": [28, 33, 38, 43],
|
||||
"Eb Standard": [27, 32, 37, 42],
|
||||
"D Standard": [26, 31, 36, 41],
|
||||
"C# Standard": [25, 30, 35, 40],
|
||||
"C Standard": [24, 29, 34, 39],
|
||||
"Drop D": [26, 33, 38, 43],
|
||||
"Drop C": [24, 31, 36, 41],
|
||||
"BEAD": [23, 28, 33, 38],
|
||||
},
|
||||
"bass-5": {
|
||||
"Standard": [30.87, 41.20, 55.00, 73.42, 98.00],
|
||||
"Eb Standard": [29.14, 38.89, 51.91, 69.30, 92.50],
|
||||
"Drop D": [30.87, 36.71, 55.00, 73.42, 98.00],
|
||||
"D Standard": [27.50, 36.71, 48.99, 65.41, 87.31],
|
||||
"Drop C": [27.50, 32.70, 48.99, 65.41, 87.31],
|
||||
"Standard": [23, 28, 33, 38, 43],
|
||||
"High C": [28, 33, 38, 43, 48],
|
||||
"Eb Standard": [22, 27, 32, 37, 42],
|
||||
"D Standard": [21, 26, 31, 36, 41],
|
||||
"C# Standard": [20, 25, 30, 35, 40],
|
||||
"C Standard": [19, 24, 29, 34, 39],
|
||||
"Drop A": [21, 28, 33, 38, 43],
|
||||
},
|
||||
"bass-6": {
|
||||
"Standard": [23, 28, 33, 38, 43, 48],
|
||||
"Eb Standard": [22, 27, 32, 37, 42, 47],
|
||||
"D Standard": [21, 26, 31, 36, 41, 46],
|
||||
"C# Standard": [20, 25, 30, 35, 40, 45],
|
||||
"C Standard": [19, 24, 29, 34, 39, 44],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def midi_to_freq(midi: int, reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> float:
|
||||
"""Return the frequency for a MIDI note at the supplied A4 reference."""
|
||||
return reference_pitch * math.pow(2, (midi - 69) / 12)
|
||||
|
||||
|
||||
def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[float]:
|
||||
"""Return rounded frequencies for low-to-high MIDI open strings."""
|
||||
return [round(midi_to_freq(m, reference_pitch), 2) for m in midis]
|
||||
|
||||
|
||||
def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None:
|
||||
"""Return semitone offsets from the instrument's standard open strings."""
|
||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||
if not standard or len(standard) != len(midis):
|
||||
return None
|
||||
return [int(m - s) for m, s in zip(midis, standard)]
|
||||
|
||||
|
||||
def tuning_midis_from_offsets(instrument_key: str, offsets: list[int]) -> list[int] | None:
|
||||
"""Return absolute open-string MIDI notes for host semitone offsets."""
|
||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||
if not standard or len(standard) != len(offsets):
|
||||
return None
|
||||
return [int(s + o) for s, o in zip(standard, offsets)]
|
||||
|
||||
|
||||
def tuning_preset_offsets(instrument_key: str, name: str) -> list[int] | None:
|
||||
"""Return host semitone offsets for a named preset."""
|
||||
midis = TUNING_PRESET_MIDIS.get(instrument_key, {}).get(name)
|
||||
if not midis:
|
||||
return None
|
||||
return tuning_offsets_from_midis(instrument_key, midis)
|
||||
|
||||
|
||||
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then
|
||||
# tuning name. Kept for the existing /api/tunings contract.
|
||||
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = {
|
||||
instrument: {
|
||||
name: open_midis_to_freqs(midis)
|
||||
for name, midis in presets.items()
|
||||
}
|
||||
for instrument, presets in TUNING_PRESET_MIDIS.items()
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +148,256 @@ def apply_reference_pitch(
|
||||
}
|
||||
|
||||
|
||||
PROFILE_IDS = ("guitar-lead", "guitar-rhythm", "bass")
|
||||
PROFILE_PATHWAYS = ("songs", "practice", "learn", "studio")
|
||||
DEFAULT_ACTIVE_INSTRUMENT_PROFILE = "guitar-lead"
|
||||
PROFILE_DEFAULTS: dict[str, dict] = {
|
||||
"guitar-lead": {
|
||||
"id": "guitar-lead",
|
||||
"label": "Lead Guitar",
|
||||
"instrument": "guitar",
|
||||
"role": "lead",
|
||||
"string_count": 6,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
"guitar-rhythm": {
|
||||
"id": "guitar-rhythm",
|
||||
"label": "Rhythm Guitar",
|
||||
"instrument": "guitar",
|
||||
"role": "rhythm",
|
||||
"string_count": 6,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
"bass": {
|
||||
"id": "bass",
|
||||
"label": "Bass",
|
||||
"instrument": "bass",
|
||||
"role": "bass",
|
||||
"string_count": 4,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def instrument_key(instrument: str, string_count: int) -> str:
|
||||
return f"{instrument}-{string_count}"
|
||||
|
||||
|
||||
def default_instrument_profiles() -> dict[str, dict]:
|
||||
return {profile_id: dict(profile) for profile_id, profile in PROFILE_DEFAULTS.items()}
|
||||
|
||||
|
||||
def _valid_reference_pitch(value) -> float | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
ref = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if not math.isfinite(ref) or ref < 430.0 or ref > 450.0:
|
||||
return None
|
||||
return ref
|
||||
|
||||
|
||||
def _valid_tuning_for_key(key: str, tuning):
|
||||
if isinstance(tuning, str):
|
||||
if len(tuning) > 64:
|
||||
return None
|
||||
if tuning in TUNING_PRESET_MIDIS.get(key, {}):
|
||||
return tuning
|
||||
# A name that IS a built-in preset for a different key is a misapplied
|
||||
# built-in (e.g. "Drop D" on a 5-string bass, whose low string is B) —
|
||||
# reject it. A name unknown to every built-in table is a provider/custom
|
||||
# tuning (the tuner plugin's, exposed via /api/tunings) that this pure
|
||||
# layer can't resolve — accept it so settings round-trip; the provider
|
||||
# owns its validity.
|
||||
if any(tuning in names for names in TUNING_PRESET_MIDIS.values()):
|
||||
return None
|
||||
return tuning
|
||||
if isinstance(tuning, list):
|
||||
expected = len(STANDARD_OPEN_MIDIS.get(key, []))
|
||||
if len(tuning) != expected:
|
||||
return None
|
||||
if any(isinstance(o, bool) or not isinstance(o, int) or o < -12 or o > 12 for o in tuning):
|
||||
return None
|
||||
return list(tuning)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_instrument_profile(profile_id: str, raw) -> tuple[dict | None, str | None]:
|
||||
"""Validate one persisted host instrument profile."""
|
||||
base = dict(PROFILE_DEFAULTS.get(profile_id, {}))
|
||||
if not base:
|
||||
return None, f"unknown instrument profile: {profile_id}"
|
||||
if raw is None:
|
||||
return base, None
|
||||
if not isinstance(raw, dict):
|
||||
return None, f"instrument_profiles.{profile_id} must be an object"
|
||||
|
||||
instrument = raw.get("instrument", base["instrument"])
|
||||
if instrument not in ("guitar", "bass"):
|
||||
return None, f"instrument_profiles.{profile_id}.instrument must be 'guitar' or 'bass'"
|
||||
|
||||
try:
|
||||
string_count = int(raw.get("string_count", base["string_count"]))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument"
|
||||
key = instrument_key(instrument, string_count)
|
||||
if key not in STANDARD_OPEN_MIDIS:
|
||||
return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument"
|
||||
|
||||
tuning = _valid_tuning_for_key(key, raw.get("tuning", base["tuning"]))
|
||||
if tuning is None:
|
||||
return None, f"instrument_profiles.{profile_id}.tuning must match {key}"
|
||||
|
||||
ref = _valid_reference_pitch(raw.get("reference_pitch", base["reference_pitch"]))
|
||||
if ref is None:
|
||||
return None, f"instrument_profiles.{profile_id}.reference_pitch must be a number between 430 and 450"
|
||||
|
||||
label = raw.get("label", base["label"])
|
||||
if not isinstance(label, str) or len(label) > 64:
|
||||
return None, f"instrument_profiles.{profile_id}.label must be a short string"
|
||||
role = raw.get("role", base["role"])
|
||||
if not isinstance(role, str) or len(role) > 32:
|
||||
return None, f"instrument_profiles.{profile_id}.role must be a short string"
|
||||
pathway = raw.get("pathway", base["pathway"])
|
||||
if not isinstance(pathway, str) or pathway not in PROFILE_PATHWAYS:
|
||||
return None, f"instrument_profiles.{profile_id}.pathway must be one of songs, practice, learn, studio"
|
||||
|
||||
out = dict(base)
|
||||
out.update({
|
||||
"id": profile_id,
|
||||
"label": label,
|
||||
"instrument": instrument,
|
||||
"role": role,
|
||||
"string_count": string_count,
|
||||
"tuning": tuning,
|
||||
"reference_pitch": ref,
|
||||
"pathway": pathway,
|
||||
})
|
||||
return out, None
|
||||
|
||||
|
||||
def normalize_instrument_profiles(raw_profiles=None) -> tuple[dict[str, dict] | None, str | None]:
|
||||
"""Validate persisted host profiles, filling omitted built-ins with defaults."""
|
||||
if raw_profiles is None:
|
||||
return default_instrument_profiles(), None
|
||||
if not isinstance(raw_profiles, dict):
|
||||
return None, "instrument_profiles must be an object"
|
||||
profiles = {}
|
||||
for profile_id in PROFILE_IDS:
|
||||
profile, error = normalize_instrument_profile(profile_id, raw_profiles.get(profile_id))
|
||||
if error:
|
||||
return None, error
|
||||
profiles[profile_id] = profile
|
||||
return profiles, None
|
||||
|
||||
|
||||
def active_profile_id(raw) -> str:
|
||||
return raw if raw in PROFILE_DEFAULTS else DEFAULT_ACTIVE_INSTRUMENT_PROFILE
|
||||
|
||||
|
||||
def profile_from_legacy_settings(cfg: dict) -> dict:
|
||||
"""Build an active profile from the old flat settings keys."""
|
||||
instrument = cfg.get("instrument") if cfg.get("instrument") in ("guitar", "bass") else "guitar"
|
||||
fallback_sc = 4 if instrument == "bass" else 6
|
||||
try:
|
||||
sc = int(cfg.get("string_count", fallback_sc))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
sc = fallback_sc
|
||||
key = instrument_key(instrument, sc)
|
||||
if key not in STANDARD_OPEN_MIDIS:
|
||||
sc = fallback_sc
|
||||
key = instrument_key(instrument, sc)
|
||||
tuning = _valid_tuning_for_key(key, cfg.get("tuning", "Standard")) or "Standard"
|
||||
ref = _valid_reference_pitch(cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH)) or DEFAULT_REFERENCE_PITCH
|
||||
pathway = cfg.get("pathway") if cfg.get("pathway") in PROFILE_PATHWAYS else "songs"
|
||||
profile_id = "bass" if instrument == "bass" else DEFAULT_ACTIVE_INSTRUMENT_PROFILE
|
||||
profile = dict(PROFILE_DEFAULTS[profile_id])
|
||||
profile.update({
|
||||
"instrument": instrument,
|
||||
"string_count": sc,
|
||||
"tuning": tuning,
|
||||
"reference_pitch": ref,
|
||||
"pathway": pathway,
|
||||
})
|
||||
return profile
|
||||
|
||||
|
||||
def settings_with_instrument_profiles(cfg: dict) -> dict:
|
||||
"""Return settings with canonical host profiles and mirrored flat keys."""
|
||||
out = dict(cfg)
|
||||
profiles, _error = normalize_instrument_profiles(out.get("instrument_profiles"))
|
||||
if profiles is None:
|
||||
profiles = default_instrument_profiles()
|
||||
if "instrument_profiles" not in out:
|
||||
legacy = profile_from_legacy_settings(out)
|
||||
profiles[legacy["id"]] = legacy
|
||||
# Default the active profile to the one migrated from the legacy flat
|
||||
# fields, but DON'T clobber an explicit request — a fresh-config
|
||||
# `POST {"active_instrument_profile": "bass"}` must switch, not be
|
||||
# overwritten by the guitar-lead inferred from defaults. active_profile_id
|
||||
# below normalizes an invalid value.
|
||||
out.setdefault("active_instrument_profile", legacy["id"])
|
||||
active = active_profile_id(out.get("active_instrument_profile"))
|
||||
selected = profiles[active]
|
||||
out["instrument_profiles"] = profiles
|
||||
out["active_instrument_profile"] = active
|
||||
out["instrument"] = selected["instrument"]
|
||||
out["string_count"] = selected["string_count"]
|
||||
out["tuning"] = selected["tuning"]
|
||||
out["reference_pitch"] = selected["reference_pitch"]
|
||||
out["pathway"] = selected["pathway"]
|
||||
return out
|
||||
|
||||
|
||||
def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict:
|
||||
"""Mirror legacy flat instrument updates into the active host profile."""
|
||||
out = settings_with_instrument_profiles(cfg)
|
||||
if not any(k in updates for k in ("instrument", "string_count", "tuning", "reference_pitch", "pathway")):
|
||||
return out
|
||||
active = active_profile_id(out.get("active_instrument_profile"))
|
||||
if "instrument" in updates:
|
||||
active = "bass" if updates["instrument"] == "bass" else "guitar-lead"
|
||||
out["active_instrument_profile"] = active
|
||||
current = dict(out["instrument_profiles"][active])
|
||||
|
||||
if "instrument" in updates:
|
||||
current["instrument"] = updates["instrument"]
|
||||
if "string_count" not in updates:
|
||||
current["string_count"] = 4 if updates["instrument"] == "bass" else 6
|
||||
if "string_count" in updates:
|
||||
current["string_count"] = updates["string_count"]
|
||||
if "reference_pitch" in updates:
|
||||
current["reference_pitch"] = updates["reference_pitch"]
|
||||
if "pathway" in updates:
|
||||
current["pathway"] = updates["pathway"]
|
||||
if "tuning" in updates:
|
||||
current["tuning"] = updates["tuning"]
|
||||
else:
|
||||
key = instrument_key(current["instrument"], current["string_count"])
|
||||
if _valid_tuning_for_key(key, current.get("tuning")) is None:
|
||||
current["tuning"] = "Standard"
|
||||
|
||||
profile, error = normalize_instrument_profile(active, current)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
out["instrument_profiles"][active] = profile
|
||||
out.update({
|
||||
"instrument": profile["instrument"],
|
||||
"string_count": profile["string_count"],
|
||||
"tuning": profile["tuning"],
|
||||
"reference_pitch": profile["reference_pitch"],
|
||||
"pathway": profile["pathway"],
|
||||
})
|
||||
return out
|
||||
|
||||
def tuning_name(offsets: list[int]) -> str:
|
||||
# All three pattern checks below are gated on `len(offsets) == 6`. The
|
||||
# naming conventions here are 6-string-specific — e.g. a 7-string all-zeros
|
||||
|
||||
@@ -43,7 +43,12 @@ from song import (
|
||||
scale_degree_for_pitch,
|
||||
)
|
||||
from audio import find_wem_files, convert_wem
|
||||
from tunings import tuning_name, DEFAULT_TUNINGS, DEFAULT_REFERENCE_PITCH, apply_reference_pitch
|
||||
from tunings import (
|
||||
DEFAULT_REFERENCE_PITCH, DEFAULT_TUNINGS, PROFILE_IDS, PROFILE_PATHWAYS,
|
||||
apply_flat_instrument_patch_to_profiles, apply_reference_pitch,
|
||||
normalize_instrument_profile, normalize_instrument_profiles,
|
||||
settings_with_instrument_profiles, tuning_name,
|
||||
)
|
||||
import sloppak as sloppak_mod
|
||||
import drums as drums_mod
|
||||
import notation as notation_mod
|
||||
@@ -52,6 +57,7 @@ import loosefolder as loosefolder_mod
|
||||
# tier classification + response parsing. No network/DB in there — the
|
||||
# throttled transport and the song_enrichment writes live in this module.
|
||||
import mb_match
|
||||
import acoustid_match
|
||||
# Metadata extraction lives in a side-effect-free module so ProcessPool
|
||||
# scan workers can import + unpickle _scan_one without re-running this
|
||||
# module's import-time side effects (see lib/scan_worker.py).
|
||||
@@ -240,7 +246,14 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
|
||||
# anonymous demo visitors (they'd spend the shared rate limit).
|
||||
("POST", re.compile(r"^/api/enrichment/review/.+$")),
|
||||
("POST", re.compile(r"^/api/enrichment/kick$")),
|
||||
("POST", re.compile(r"^/api/enrichment/cancel$")),
|
||||
("POST", re.compile(r"^/api/enrichment/rematch$")),
|
||||
("GET", re.compile(r"^/api/enrichment/search$")),
|
||||
# AcoustID audio fingerprinting: both identify endpoints run fpcalc (CPU)
|
||||
# and spend the shared AcoustID rate budget on the caller's behalf — same
|
||||
# rule as the search/kick relays above; not for anonymous demo visitors.
|
||||
("POST", re.compile(r"^/api/enrichment/identify$")),
|
||||
("POST", re.compile(r"^/api/enrichment/identify/.+$")),
|
||||
# Context menus (R2): the per-song re-match mutates the cache + spends
|
||||
# rate limit; Get-info exposes filesystem paths.
|
||||
("POST", re.compile(r"^/api/enrichment/refresh/.+$")),
|
||||
@@ -3005,6 +3018,26 @@ class MetadataDB:
|
||||
"JOIN songs s ON s.filename = e.filename GROUP BY e.match_state").fetchall()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
def enrichment_states_for(self, filenames: list[str]) -> dict:
|
||||
"""{filename: match_state} for the given songs — a never-enriched (or
|
||||
unknown) filename is simply absent from the result. Powers the per-tile
|
||||
badges on the "Refresh Metadata" batch: the grid polls only the
|
||||
filenames in its visible window, not the whole library, so a card can
|
||||
animate queued→working→result without a per-song round-trip."""
|
||||
if not filenames:
|
||||
return {}
|
||||
out: dict = {}
|
||||
with self._lock:
|
||||
# Chunk under SQLite's variable limit so a huge visible window (or a
|
||||
# hostile caller) can't overflow the single IN (...) parameter list.
|
||||
for i in range(0, len(filenames), 400):
|
||||
chunk = filenames[i:i + 400]
|
||||
q = ("SELECT filename, match_state FROM song_enrichment "
|
||||
"WHERE filename IN (%s)" % ",".join("?" * len(chunk)))
|
||||
for fn, st in self.conn.execute(q, chunk).fetchall():
|
||||
out[fn] = st
|
||||
return out
|
||||
|
||||
def enrichment_song_row(self, filename: str) -> dict | None:
|
||||
"""The identity fields the matcher/scorer keys on, for one song."""
|
||||
row = self.conn.execute(
|
||||
@@ -5229,10 +5262,52 @@ def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
|
||||
check so every filename-bound handler validates before touching the
|
||||
filesystem.
|
||||
|
||||
Returns the validated resolved Path, or None if the path is empty
|
||||
or escapes the DLC root.
|
||||
Containment here is LEXICAL (normalize `.`/`..` WITHOUT following
|
||||
symlinks), not `safe_join`'s `.resolve()`-based check — because users
|
||||
commonly mount their song library through a directory JUNCTION/symlink
|
||||
(a library shared across app installs; the desktop app's own mounts).
|
||||
`.resolve()` follows that junction to its real target, sees it sits
|
||||
outside DLC_DIR, and wrongly rejects every song reached through it — the
|
||||
scanner's `rglob` indexes those songs, but art/load then 403/404s (broken
|
||||
covers, unplayable songs). Lexical normalization still rejects the only
|
||||
escapes a `:path` filename can express — `..` traversal and absolute
|
||||
paths — which the traversal tests pin. `safe_join` stays strict (it is
|
||||
the zip-slip / plugin-asset guard, where following a symlink out IS the
|
||||
defense); the loose-folder art handler keeps its own per-file symlink
|
||||
re-check for defence-in-depth.
|
||||
|
||||
Returns the validated Path (not necessarily link-resolved), or None if
|
||||
the filename is empty, contains a NUL, or escapes the DLC root.
|
||||
"""
|
||||
return safe_join(dlc, filename)
|
||||
if not filename:
|
||||
return None
|
||||
# Backslashes → forward slashes so a Windows-style `..\\x` traversal is
|
||||
# rejected identically on POSIX (mirrors safe_join's normalisation).
|
||||
safe = filename.replace("\\", "/")
|
||||
if "\x00" in safe:
|
||||
return None
|
||||
# Reject drive-letter / absolute paths in BOTH conventions. A POSIX "/x" is
|
||||
# caught by the containment check below (the `/` operator discards `root`),
|
||||
# but a Windows drive-absolute "C:/x" is treated as a relative "C:" dir on
|
||||
# POSIX and would otherwise slip in as `<root>/C:/x` — so the contract must
|
||||
# hold cross-platform (a shared library is reached from either OS).
|
||||
from pathlib import PurePosixPath, PureWindowsPath
|
||||
if (PurePosixPath(safe).is_absolute()
|
||||
or PureWindowsPath(safe).is_absolute()
|
||||
or PureWindowsPath(safe).drive):
|
||||
return None
|
||||
try:
|
||||
root = dlc.resolve()
|
||||
# normpath collapses `.`/`..`/duplicate separators purely lexically —
|
||||
# it never touches the filesystem, so an in-library junction component
|
||||
# is preserved (allowed) while `..`/absolute segments still escape and
|
||||
# get caught by the containment check below.
|
||||
candidate = Path(os.path.normpath(root / safe))
|
||||
if not candidate.is_relative_to(root):
|
||||
return None
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
_SMART_TYPE_BASE: dict[str, int] = {"Lead": 0, "Rhythm": 10, "Bass": 20}
|
||||
@@ -6131,7 +6206,16 @@ def _scan_runner():
|
||||
|
||||
_enrich_kick_lock = threading.Lock()
|
||||
_enrich_pending_pass = False
|
||||
_enrich_status = {"running": False, "processed": 0, "last_pass_at": None}
|
||||
# processed = phase-1 stubs stamped this pass (legacy field). total/matched =
|
||||
# the phase-2 MATCHING progress the "Refresh Metadata" batch bar reads (the
|
||||
# slow, rate-limited part worth a progress readout); current = the song being
|
||||
# matched right now, which drives the per-tile "working" badge.
|
||||
_enrich_status = {"running": False, "processed": 0, "last_pass_at": None,
|
||||
"total": 0, "matched": 0, "current": None}
|
||||
# Cooperative cancel for the Stop button: the matching/art loops check it
|
||||
# between songs (an in-flight ≤1/s lookup can't be interrupted, but no new one
|
||||
# is started). Set by /api/enrichment/cancel, cleared when a fresh pass kicks.
|
||||
_enrich_cancel = threading.Event()
|
||||
# Minimum spacing between EXTERNAL lookups (design: ≤1 req/s + local cache).
|
||||
_ENRICH_MIN_INTERVAL = 1.1
|
||||
_enrich_last_fetch = 0.0
|
||||
@@ -6235,13 +6319,204 @@ def _mb_http_get(path: str, params: dict) -> dict | None:
|
||||
raise EnrichTransportError("bad JSON from musicbrainz") from e
|
||||
|
||||
|
||||
def _mb_search_recordings(artist, title, limit: int = 8) -> list[dict]:
|
||||
"""Text search (tier 2–4): denoised Lucene query over /recording."""
|
||||
def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
|
||||
"""Text search (tier 2–4): denoised Lucene query over /recording. The strict
|
||||
query drops live-only recordings and the ranker rewards the studio take, so a
|
||||
slightly larger default result set gives the re-ranker room to surface the
|
||||
canonical version.
|
||||
|
||||
Runs the strict field-phrase query first (high precision); if it finds
|
||||
nothing, retries ONCE with a loose term query. The strict phrase only matches
|
||||
MusicBrainz's *primary* artist/title, so a recording stored under a non-Latin
|
||||
primary name (大橋純子) whose romanized form ("Junko Ohashi") is only an alias
|
||||
is invisible to it — the loose query searches aliases and rescues it. The
|
||||
retry spends a second throttled request only on a miss; results are re-scored
|
||||
by rank_candidates, so the looser recall doesn't lower match quality
|
||||
(auto-accept still needs the per-field floors)."""
|
||||
query = mb_match.build_recording_query(artist, title)
|
||||
if not query:
|
||||
cands: list[dict] = []
|
||||
if query:
|
||||
body = _mb_http_get("recording", {"query": query, "limit": limit})
|
||||
cands = mb_match.parse_search_response(body or {})
|
||||
if not cands:
|
||||
loose = mb_match.build_recording_query(artist, title, loose=True)
|
||||
if loose and loose != query:
|
||||
body = _mb_http_get("recording", {"query": loose, "limit": limit})
|
||||
cands = mb_match.parse_search_response(body or {})
|
||||
return cands
|
||||
|
||||
|
||||
# ── AcoustID audio fingerprinting (content-based identification) ──────────────
|
||||
# Optional path: requires the Chromaprint `fpcalc` binary AND an AcoustID API
|
||||
# key ($ACOUSTID_API_KEY). Both absent ⇒ graceful no-op; the text matcher runs.
|
||||
|
||||
_ACOUSTID_MAX_UPLOAD_BYTES = 256 * 1024 * 1024 # 256 MB — an uncompressed master
|
||||
|
||||
|
||||
def _fpcalc_bin() -> str | None:
|
||||
"""Locate the Chromaprint `fpcalc` binary: $FPCALC override, else PATH."""
|
||||
import shutil
|
||||
cand = os.environ.get("FPCALC")
|
||||
if cand and Path(cand).exists():
|
||||
return cand
|
||||
return shutil.which("fpcalc")
|
||||
|
||||
|
||||
def _acoustid_settings() -> "tuple[bool, str]":
|
||||
"""(enabled, api_key) for AcoustID, resolved from settings with an env-var
|
||||
fallback for the key. Opt-in: `acoustid_enabled` defaults off. The key lives
|
||||
in settings so a user can set it themselves in the UI; $ACOUSTID_API_KEY is a
|
||||
server-wide fallback for a headless deploy."""
|
||||
cfg = _load_config(CONFIG_DIR / "config.json") or {}
|
||||
enabled = cfg.get("acoustid_enabled", False) is True
|
||||
key = cfg.get("acoustid_api_key")
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
key = os.environ.get("ACOUSTID_API_KEY", "")
|
||||
return enabled, (key or "").strip()
|
||||
|
||||
|
||||
def _acoustid_available() -> bool:
|
||||
"""True only when the user opted in, a key is set (settings or env), the
|
||||
network is on, AND fpcalc exists."""
|
||||
enabled, key = _acoustid_settings()
|
||||
return (enabled
|
||||
and _enrich_network_enabled()
|
||||
and acoustid_match.is_configured(key)
|
||||
and _fpcalc_bin() is not None)
|
||||
|
||||
|
||||
def _fpcalc(path: str) -> "tuple[int, str] | None":
|
||||
"""Fingerprint a local audio file → (duration_seconds, fingerprint). None on
|
||||
any failure (missing binary/file, decode error, timeout)."""
|
||||
binp = _fpcalc_bin()
|
||||
if not binp or not Path(path).exists():
|
||||
return None
|
||||
import subprocess
|
||||
import json as _json
|
||||
try:
|
||||
pr = subprocess.run([binp, "-json", str(path)],
|
||||
capture_output=True, timeout=30)
|
||||
except Exception:
|
||||
return None
|
||||
if pr.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
data = _json.loads(pr.stdout.decode("utf-8", "replace"))
|
||||
dur = int(round(float(data.get("duration"))))
|
||||
fp = str(data.get("fingerprint") or "")
|
||||
except Exception:
|
||||
return None
|
||||
if not fp or dur <= 0:
|
||||
return None
|
||||
return dur, fp
|
||||
|
||||
|
||||
def _acoustid_lookup(duration: int, fingerprint: str) -> list[dict]:
|
||||
"""Look a fingerprint up on AcoustID → candidate dicts (mb_match shape).
|
||||
Throttled + offline-guarded like the MusicBrainz path. [] when unavailable
|
||||
or no hit; raises EnrichTransportError for network-shaped failures."""
|
||||
_, key = _acoustid_settings()
|
||||
if not key or not _enrich_network_enabled():
|
||||
return []
|
||||
body = _mb_http_get("recording", {"query": query, "limit": limit})
|
||||
return mb_match.parse_search_response(body or {})
|
||||
import requests
|
||||
_enrich_throttle()
|
||||
try:
|
||||
# POST, not GET: a fingerprint is multi-KB (a 3.5-min track is ~3.5k
|
||||
# chars), so a GET crams it into the URL and a long song overflows the
|
||||
# server's URL limit → a spurious failure. AcoustID accepts the same
|
||||
# params form-encoded in the body.
|
||||
resp = requests.post(
|
||||
f"{acoustid_match.ACOUSTID_API_ROOT}/lookup",
|
||||
data={
|
||||
"client": key, "format": "json",
|
||||
"meta": acoustid_match.LOOKUP_META,
|
||||
"duration": duration, "fingerprint": fingerprint,
|
||||
},
|
||||
headers={"User-Agent": _enrich_user_agent()},
|
||||
timeout=10,
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise EnrichTransportError(str(e)) from e
|
||||
if resp.status_code == 429:
|
||||
raise EnrichTransportError("acoustid 429 (rate limited)")
|
||||
if resp.status_code != 200:
|
||||
raise EnrichTransportError(f"acoustid HTTP {resp.status_code}")
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError as e:
|
||||
raise EnrichTransportError("bad JSON from acoustid") from e
|
||||
return acoustid_match.parse_lookup_response(body)
|
||||
|
||||
|
||||
def _identify_by_fingerprint(path: str) -> list[dict]:
|
||||
"""fpcalc + AcoustID lookup for a local audio file. [] if fingerprinting is
|
||||
unavailable, the file can't be read, or nothing matched. Available to the
|
||||
library-enrichment pipeline as well as the /identify endpoint."""
|
||||
if not _acoustid_available():
|
||||
return []
|
||||
fp = _fpcalc(path)
|
||||
if not fp:
|
||||
return []
|
||||
return _acoustid_lookup(fp[0], fp[1])
|
||||
|
||||
|
||||
def _acoustid_gate() -> "JSONResponse | None":
|
||||
"""Shared availability gate for the identify endpoints: None when ready,
|
||||
else a 412 needs_setup (opt-in off / no key → the UI re-prompts) or a 503
|
||||
(set up but fpcalc/network missing). Never lets a caller pretend a
|
||||
fingerprint ran."""
|
||||
if _acoustid_available():
|
||||
return None
|
||||
enabled, key = _acoustid_settings()
|
||||
if not enabled or not key:
|
||||
return JSONResponse(
|
||||
{"error": "audio fingerprinting not set up", "needs_setup": True,
|
||||
"detail": "Turn on AcoustID and add a free API key to identify by audio — "
|
||||
"it reads the recording itself, far more reliable than text search."},
|
||||
status_code=412)
|
||||
return JSONResponse(
|
||||
{"error": "audio fingerprinting unavailable", "needs_setup": False,
|
||||
"detail": "the fpcalc (Chromaprint) binary was not found on the server"},
|
||||
status_code=503)
|
||||
|
||||
|
||||
def _song_audio_file(filename: str) -> "str | None":
|
||||
"""Resolve a LIBRARY song (by filename/id) to a local master-audio file for
|
||||
fingerprinting: the full-mix `original_audio` extracted from a sloppak, or a
|
||||
loose folder's audio. None when the song can't be found or ships no full-mix
|
||||
audio (some packs carry only stems). Mirrors serve_sloppak_file's containment
|
||||
guards so a crafted filename can't read outside DLC_DIR / the pack."""
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
return None
|
||||
resolved = _resolve_dlc_path(dlc, filename)
|
||||
if resolved is None or not resolved.exists():
|
||||
return None
|
||||
if sloppak_mod.is_sloppak(resolved):
|
||||
try:
|
||||
canon = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
return None
|
||||
rel = (sloppak_mod.load_manifest(resolved) or {}).get("original_audio")
|
||||
if not isinstance(rel, str) or not rel.strip():
|
||||
return None
|
||||
src = sloppak_mod.get_cached_source_dir(canon)
|
||||
if src is None:
|
||||
try:
|
||||
src = sloppak_mod.resolve_source_dir(canon, dlc, SLOPPAK_CACHE_DIR)
|
||||
except Exception:
|
||||
return None
|
||||
target = (src / rel.strip()).resolve()
|
||||
try:
|
||||
target.relative_to(src.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return str(target) if target.is_file() else None
|
||||
try:
|
||||
audio = loosefolder_mod.find_audio(resolved)
|
||||
except Exception:
|
||||
audio = None
|
||||
return str(audio) if audio and Path(str(audio)).is_file() else None
|
||||
|
||||
|
||||
def _mb_lookup_recording(mbid: str) -> dict | None:
|
||||
@@ -6588,6 +6863,35 @@ def _enrich_field_filter(cfg: dict):
|
||||
return lambda cand: {k: v for k, v in cand.items() if k not in blocked}
|
||||
|
||||
|
||||
# Strips a trailing tag parenthetical from a filename stem — "(440Hz)",
|
||||
# "(Live)", "(No Lead)", the retune/arrangement noise CDLC names carry.
|
||||
_FN_TAG_RE = re.compile(r"\s*\([^)]*\)")
|
||||
|
||||
|
||||
def _artist_title_from_filename(filename: str) -> dict | None:
|
||||
"""Derive artist + title from the CDLC filename convention
|
||||
'Artist_Song-Title_v1_p.feedpak' — spaces written as hyphens WITHIN a
|
||||
field, underscores separating Artist | Title | version/arrangement. Used
|
||||
ONLY as a match SEED for packs whose own `artist` field is blank (a large
|
||||
slice of community charts): text search needs an artist, and the filename
|
||||
reliably carries it. This never becomes displayed metadata — the shown
|
||||
values still come from the confirmed MusicBrainz match (provenance
|
||||
'matched'), so nothing estimated is presented as author-set; if no match is
|
||||
found, the pack stays exactly as-is. Returns None when the name doesn't fit
|
||||
the convention (so a non-CDLC pack falls through untouched)."""
|
||||
base = filename.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
base = base.rsplit(".", 1)[0] # drop the extension
|
||||
base = _FN_TAG_RE.sub("", base).strip() # drop "(440Hz)" etc.
|
||||
parts = [p for p in base.split("_") if p]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
artist = parts[0].replace("-", " ").strip()
|
||||
title = parts[1].replace("-", " ").strip()
|
||||
if not artist or not title:
|
||||
return None
|
||||
return {"artist": artist, "title": title}
|
||||
|
||||
|
||||
def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None,
|
||||
apply_mask: str = "") -> None:
|
||||
"""The matcher (P8; replaces P7's no-op). Precedence per design §5:
|
||||
@@ -6632,17 +6936,29 @@ def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None,
|
||||
cand=field_filter(cand) if field_filter else cand)
|
||||
return
|
||||
# A 404'd mbid (typo'd manifest) falls through to the text tiers.
|
||||
# A pack that left `artist` blank can't be text-matched (search needs an
|
||||
# artist, and the per-field floor rejects a blank one) — so when it's blank,
|
||||
# seed the query/scoring from the filename's Artist_Song convention. Seed
|
||||
# only: fn/chash and the stored row are untouched, and the DISPLAYED values
|
||||
# still come from the confirmed match. The exact-key tiers above don't need
|
||||
# it (mbid/isrc identify without text).
|
||||
ref = row
|
||||
if not (row.get("artist") or "").strip():
|
||||
derived = _artist_title_from_filename(fn)
|
||||
if derived:
|
||||
ref = {**row, **derived}
|
||||
|
||||
if ids.get("isrc"):
|
||||
cands = mb_match.rank_candidates(row, _mb_lookup_isrc(ids["isrc"]))
|
||||
cands = mb_match.rank_candidates(ref, _mb_lookup_isrc(ids["isrc"]))
|
||||
if cands:
|
||||
meta_db.apply_enrichment_match(fn, chash, "matched", source="isrc",
|
||||
score=1.0, apply_mask=apply_mask,
|
||||
cand=field_filter(cands[0]) if field_filter else cands[0])
|
||||
return
|
||||
|
||||
ranked = mb_match.rank_candidates(row, _mb_search_recordings(row.get("artist"), row.get("title")))
|
||||
ranked = mb_match.rank_candidates(ref, _mb_search_recordings(ref.get("artist"), ref.get("title")))
|
||||
best = ranked[0] if ranked else None
|
||||
tier = mb_match.classify(row, best, best["score"], auto_min=auto_min) if best else "none"
|
||||
tier = mb_match.classify(ref, best, best["score"], auto_min=auto_min) if best else "none"
|
||||
if tier == "auto":
|
||||
meta_db.apply_enrichment_match(fn, chash, "matched", source="text",
|
||||
score=best["score"], apply_mask=apply_mask,
|
||||
@@ -6666,8 +6982,13 @@ def _background_enrich():
|
||||
`failed` rows whose backoff has elapsed; a transport failure pauses it
|
||||
(state untouched, no attempt burned) and the next kick retries. Offline
|
||||
(kill-switch or the test env) skips phase 2 entirely. Never drains in a
|
||||
loop — a dead network would make that spin forever."""
|
||||
loop — a dead network would make that spin forever. Between songs it
|
||||
honours the Stop button's cancel flag (phases 2 and 3), so a long trickle
|
||||
can be halted without waiting for the whole queue to drain."""
|
||||
_enrich_status["processed"] = 0
|
||||
_enrich_status["total"] = 0
|
||||
_enrich_status["matched"] = 0
|
||||
_enrich_status["current"] = None
|
||||
# User settings gate the BACKGROUND matcher only (the review modal's
|
||||
# manual search/fix stays available when it's off); read once per pass,
|
||||
# up front so the pending query can honour the per-field apply mask
|
||||
@@ -6732,11 +7053,17 @@ def _background_enrich():
|
||||
continue
|
||||
seen_filenames.add(fn)
|
||||
queue.append(row)
|
||||
_enrich_status["total"] = len(queue)
|
||||
for row in queue:
|
||||
if _enrich_cancel.is_set():
|
||||
log.info("enrichment: pass cancelled by user after %d matched", matched)
|
||||
break
|
||||
_enrich_status["current"] = row.get("filename")
|
||||
try:
|
||||
_enrich_one(row, auto_min=auto_min, field_filter=field_filter,
|
||||
apply_mask=apply_mask)
|
||||
matched += 1
|
||||
_enrich_status["matched"] = matched
|
||||
except EnrichTransportError as e:
|
||||
log.info("enrichment: network unavailable, pass paused (%s)", e)
|
||||
break
|
||||
@@ -6750,6 +7077,7 @@ def _background_enrich():
|
||||
source="error", bump_attempts=True)
|
||||
except Exception:
|
||||
pass
|
||||
_enrich_status["current"] = None
|
||||
if mb_on and (pending or retriable):
|
||||
log.info("Enrichment pass: %d rows stamped, %d matched", len(pending), matched)
|
||||
|
||||
@@ -6769,6 +7097,9 @@ def _background_enrich():
|
||||
return
|
||||
fetched = 0
|
||||
for row in art_rows:
|
||||
if _enrich_cancel.is_set():
|
||||
log.info("enrichment: art pass cancelled by user after %d fetched", fetched)
|
||||
break
|
||||
try:
|
||||
fetched += 1 if _enrich_art_one(row) else 0
|
||||
except EnrichTransportError as e:
|
||||
@@ -6793,6 +7124,10 @@ def _kick_enrich() -> bool:
|
||||
if _enrich_status["running"]:
|
||||
_enrich_pending_pass = True
|
||||
return False
|
||||
# A fresh pass supersedes any prior Stop — clear the flag so the new
|
||||
# pass isn't cancelled the instant it checks (a stale set() from a
|
||||
# cancelled-then-re-kicked run would otherwise abort it immediately).
|
||||
_enrich_cancel.clear()
|
||||
_enrich_status["running"] = True
|
||||
_enrich_thread = threading.Thread(target=_enrich_runner, daemon=True)
|
||||
_enrich_thread.start()
|
||||
@@ -6807,6 +7142,15 @@ def _enrich_runner():
|
||||
except Exception:
|
||||
log.exception("background enrichment failed unexpectedly")
|
||||
with _enrich_kick_lock:
|
||||
_enrich_status["current"] = None
|
||||
if _enrich_cancel.is_set():
|
||||
# Stop: abandon any coalesced follow-up and clear the flag so the
|
||||
# next kick starts clean. The current pass already broke out of
|
||||
# its loop between songs (see _background_enrich).
|
||||
_enrich_pending_pass = False
|
||||
_enrich_cancel.clear()
|
||||
_enrich_status["running"] = False
|
||||
return
|
||||
if not _enrich_pending_pass:
|
||||
_enrich_status["running"] = False
|
||||
return
|
||||
@@ -7294,6 +7638,13 @@ def enrichment_status():
|
||||
"last_pass_at": _enrich_status["last_pass_at"],
|
||||
"states": meta_db.enrichment_state_counts(),
|
||||
"total_songs": meta_db.count(),
|
||||
# Per-pass matching progress for the "Refresh Metadata" batch bar +
|
||||
# per-tile badges (total = songs queued to match this pass, matched =
|
||||
# done so far, current = the one being matched now).
|
||||
"total": _enrich_status.get("total", 0),
|
||||
"matched": _enrich_status.get("matched", 0),
|
||||
"current": _enrich_status.get("current"),
|
||||
"cancelling": _enrich_cancel.is_set(),
|
||||
}
|
||||
|
||||
|
||||
@@ -7312,12 +7663,72 @@ def api_enrichment_song(filename: str):
|
||||
|
||||
@app.post("/api/enrichment/kick")
|
||||
def api_enrichment_kick():
|
||||
"""The Settings "Match now" button: request an enrichment pass without
|
||||
waiting for a scan to complete. Single-flight + coalescing like every
|
||||
other kick — spamming it queues at most one follow-up pass."""
|
||||
"""The Settings "Match now" button AND the library's "Refresh Metadata"
|
||||
button: request an enrichment pass without waiting for a scan to complete.
|
||||
Processes the songs that still need it (unscanned/changed + retriable
|
||||
failures) — already-matched songs are left alone, so on a fully-matched
|
||||
library this is a fast no-op. Single-flight + coalescing like every other
|
||||
kick — spamming it queues at most one follow-up pass."""
|
||||
return {"started": _kick_enrich()}
|
||||
|
||||
|
||||
@app.post("/api/enrichment/cancel")
|
||||
def api_enrichment_cancel():
|
||||
"""Stop button on the "Refresh Metadata" batch: signal the running pass to
|
||||
halt after the current song (an in-flight ≤1/s lookup can't be interrupted,
|
||||
but no new one is started) and drop any coalesced follow-up. A no-op when
|
||||
nothing is running."""
|
||||
was_running = _enrich_status["running"]
|
||||
if was_running:
|
||||
_enrich_cancel.set()
|
||||
return {"ok": True, "was_running": was_running}
|
||||
|
||||
|
||||
@app.post("/api/enrichment/rematch")
|
||||
def api_enrichment_rematch(data: dict = Body(...)):
|
||||
"""The library "Refresh Metadata" button: force a fresh re-match of the
|
||||
songs the grid is SHOWING (its visible/filtered window). Resets each to
|
||||
`unscanned` so the next pass re-fetches it from scratch — EXCEPT user-pinned
|
||||
`manual` rows, which are never auto-overwritten (apply_enrichment_match
|
||||
guards that) — then kicks one pass. Scoped to the visible set on purpose:
|
||||
fast (dozens of songs), visible (tiles animate), and it can't blow the whole
|
||||
≤1/s rate budget on a 1000-song library the way a full re-sweep would.
|
||||
Returns the filenames actually queued so the UI badges exactly those."""
|
||||
raw = (data or {}).get("filenames") or []
|
||||
fns = [str(f) for f in raw if isinstance(f, str)][:500]
|
||||
queued: list[str] = []
|
||||
for fn in fns:
|
||||
song = meta_db.enrichment_song_row(fn)
|
||||
if not song:
|
||||
continue
|
||||
h = meta_db.enrichment_content_hash(
|
||||
song["artist"], song["title"], song["album"], song["duration"])
|
||||
# allow_manual_overwrite=False → a manual pin is left as-is (returns
|
||||
# False), everything else resets to unscanned (returns True).
|
||||
if meta_db.apply_enrichment_match(fn, h, "unscanned",
|
||||
allow_manual_overwrite=False):
|
||||
queued.append(fn)
|
||||
started = _kick_enrich() if queued else False
|
||||
return {"queued": queued, "count": len(queued), "started": started}
|
||||
|
||||
|
||||
@app.post("/api/enrichment/states")
|
||||
def api_enrichment_states(data: dict = Body(...)):
|
||||
"""Per-tile match states for the grid's VISIBLE window during a metadata
|
||||
refresh: the client posts the filenames it is showing and gets back each
|
||||
one's match_state (+ the song being matched right now, + whether a pass is
|
||||
running), so a card can animate queued→working→result without a per-song
|
||||
round-trip. Read-only — safe for demo visitors (no network, no mutation)."""
|
||||
raw = (data or {}).get("filenames") or []
|
||||
# Bound the batch: a visible grid window is dozens of cards; cap defensively.
|
||||
fns = [str(f) for f in raw if isinstance(f, str)][:500]
|
||||
return {
|
||||
"states": meta_db.enrichment_states_for(fns),
|
||||
"current": _enrich_status.get("current"),
|
||||
"running": _enrich_status["running"],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/enrichment/refresh/{filename:path}")
|
||||
def api_enrichment_refresh(filename: str):
|
||||
"""The context menu's "Refresh metadata": reset THIS song's match to
|
||||
@@ -7413,13 +7824,16 @@ def api_enrichment_pick(filename: str, data: dict = Body(...)):
|
||||
|
||||
@app.get("/api/enrichment/search")
|
||||
def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
|
||||
filename: str = ""):
|
||||
filename: str = "", duration: float = 0.0):
|
||||
"""Manual-search proxy to MusicBrainz (throttled + identified like the
|
||||
background matcher — a user typing in the drawer must not sidestep the
|
||||
rate limit). `filename` optionally scores results against that song's
|
||||
stored identity (year/duration corroboration) instead of just the typed
|
||||
text. Sync route on purpose: FastAPI runs it in the threadpool, so the
|
||||
throttle's sleep never blocks the event loop."""
|
||||
text. `duration` (seconds) lets a caller that HAS the audio but no library
|
||||
row — e.g. the editor's create modal, which holds the master track — pass
|
||||
its length so the studio take ranks above live/extended cuts. Sync route on
|
||||
purpose: FastAPI runs it in the threadpool, so the throttle's sleep never
|
||||
blocks the event loop."""
|
||||
if not (artist.strip() or title.strip()):
|
||||
raise HTTPException(status_code=400, detail="artist or title required")
|
||||
limit = max(1, min(int(limit), 25))
|
||||
@@ -7433,9 +7847,100 @@ def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
|
||||
ref = meta_db.enrichment_song_row(filename)
|
||||
if ref is None:
|
||||
ref = {"artist": artist, "title": title}
|
||||
# A caller-supplied duration corroborates the take even without a library row.
|
||||
if duration and duration > 0 and not ref.get("duration"):
|
||||
ref = dict(ref)
|
||||
ref["duration"] = duration
|
||||
return {"candidates": mb_match.rank_candidates(ref, cands)}
|
||||
|
||||
|
||||
@app.post("/api/enrichment/identify")
|
||||
async def api_enrichment_identify(request: Request):
|
||||
"""Identify a song by AUDIO FINGERPRINT (AcoustID) rather than text — the
|
||||
reliable way to get the EXACT recording/version (the studio take, not a live
|
||||
bootleg or an extended cut). Upload the master audio; returns candidates in
|
||||
the same shape as /search, so the review UI and the editor's Match popup can
|
||||
render fingerprint hits identically. 412 `needs_setup` when the user hasn't
|
||||
opted in / has no key (the UI nudges them to Settings); 503 when it's set up
|
||||
but the fpcalc Chromaprint binary is missing or the network is off. Async so
|
||||
the multipart is size-capped BEFORE spooling; the blocking fpcalc subprocess
|
||||
+ AcoustID HTTP run in the threadpool via run_in_executor."""
|
||||
gate = _acoustid_gate()
|
||||
if gate is not None:
|
||||
return gate
|
||||
# Pre-parse Content-Length guard — reject an oversized body before Starlette
|
||||
# spools the multipart to temp disk (mirrors the song-upload endpoint). The
|
||||
# per-part cap below is the authoritative limit; this is the fast up-front no.
|
||||
cl = request.headers.get("content-length")
|
||||
if cl is not None:
|
||||
try:
|
||||
cl_int = int(cl)
|
||||
except ValueError:
|
||||
return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400)
|
||||
if cl_int > _ACOUSTID_MAX_UPLOAD_BYTES + _MULTIPART_OVERHEAD_SLACK:
|
||||
return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413)
|
||||
try:
|
||||
form = await request.form(max_part_size=_ACOUSTID_MAX_UPLOAD_BYTES)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413)
|
||||
file = form.get("file")
|
||||
if not isinstance(file, UploadFile):
|
||||
raise HTTPException(status_code=400, detail="missing file upload")
|
||||
import tempfile
|
||||
ext = (Path(file.filename or "").suffix or ".bin").lower()
|
||||
tmpdir = tempfile.mkdtemp(prefix="feedback_acoustid_")
|
||||
tmp = os.path.join(tmpdir, "audio" + ext)
|
||||
try:
|
||||
total = 0
|
||||
with open(tmp, "wb") as fh:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > _ACOUSTID_MAX_UPLOAD_BYTES:
|
||||
return JSONResponse(
|
||||
{"error": "audio upload too large (256 MB max)"}, status_code=413)
|
||||
fh.write(chunk)
|
||||
if total == 0:
|
||||
raise HTTPException(status_code=400, detail="empty upload")
|
||||
# fpcalc subprocess + AcoustID HTTP are blocking — off the event loop.
|
||||
cands = await asyncio.get_event_loop().run_in_executor(
|
||||
None, _identify_by_fingerprint, tmp)
|
||||
except EnrichTransportError as e:
|
||||
return JSONResponse({"error": "acoustid unavailable", "detail": str(e)},
|
||||
status_code=503)
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
return {"candidates": cands}
|
||||
|
||||
|
||||
@app.post("/api/enrichment/identify/{filename:path}")
|
||||
def api_enrichment_identify_song(filename: str):
|
||||
"""Identify an EXISTING library song by AUDIO FINGERPRINT — the library-side
|
||||
counterpart to /api/enrichment/identify (which takes an upload). Fingerprints
|
||||
the song's own master audio on disk (the manual "Identify by audio" action in
|
||||
the Fix-metadata / match-review flow). Same candidate shape as /search, so the
|
||||
review UI renders fingerprint hits like text hits. Same 412/503 gating; 404
|
||||
when the song has no full-mix audio to fingerprint."""
|
||||
gate = _acoustid_gate()
|
||||
if gate is not None:
|
||||
return gate
|
||||
audio = _song_audio_file(filename)
|
||||
if not audio:
|
||||
return JSONResponse(
|
||||
{"error": "no audio",
|
||||
"detail": "couldn't find this song's master audio to fingerprint "
|
||||
"(a stems-only pack has no full mix to identify)."},
|
||||
status_code=404)
|
||||
try:
|
||||
cands = _identify_by_fingerprint(audio)
|
||||
except EnrichTransportError as e:
|
||||
return JSONResponse({"error": "acoustid unavailable", "detail": str(e)},
|
||||
status_code=503)
|
||||
return {"candidates": cands}
|
||||
|
||||
|
||||
@app.get("/api/startup-status")
|
||||
def startup_status():
|
||||
return _get_startup_status()
|
||||
@@ -9782,6 +10287,17 @@ def _default_settings():
|
||||
# the external browser, never media delivered in-app.
|
||||
"artist_pages_enabled": True,
|
||||
"artist_external_links": False,
|
||||
# Audio fingerprinting (AcoustID + Chromaprint). OPT-IN, default OFF.
|
||||
# Text matching (MusicBrainz) can't reliably pick the exact recording
|
||||
# for a song with many comp/live/reissue takes (especially a
|
||||
# non-title-track — the title can't find the album); fingerprinting
|
||||
# reads the audio itself and resolves the EXACT recording. Needs the
|
||||
# user's own free AcoustID application key
|
||||
# (https://acoustid.org/new-application) plus the `fpcalc` binary. The
|
||||
# key lives here (settings) — not only an env var — so a user can set it
|
||||
# themselves in the UI; $ACOUSTID_API_KEY stays a server-wide fallback.
|
||||
"acoustid_enabled": False,
|
||||
"acoustid_api_key": "",
|
||||
}
|
||||
|
||||
|
||||
@@ -9820,7 +10336,7 @@ def get_tunings():
|
||||
@app.get("/api/settings")
|
||||
def get_settings():
|
||||
cfg = _load_config(CONFIG_DIR / "config.json")
|
||||
return cfg if cfg is not None else _default_settings()
|
||||
return settings_with_instrument_profiles(cfg if cfg is not None else _default_settings())
|
||||
|
||||
|
||||
@app.post("/api/settings")
|
||||
@@ -9956,13 +10472,24 @@ def save_settings(data: dict):
|
||||
"enrich_apply_names", "enrich_apply_year",
|
||||
"enrich_apply_genres", "enrich_apply_art",
|
||||
# Artist pages (PR-B): page on/off + external-links opt-in.
|
||||
"artist_pages_enabled", "artist_external_links"):
|
||||
"artist_pages_enabled", "artist_external_links",
|
||||
# AcoustID audio-fingerprinting opt-in (default off).
|
||||
"acoustid_enabled"):
|
||||
if _bool_key in data:
|
||||
raw = data[_bool_key]
|
||||
if raw is not None:
|
||||
if not isinstance(raw, bool):
|
||||
return {"error": f"{_bool_key} must be a boolean"}
|
||||
updates[_bool_key] = raw
|
||||
if "acoustid_api_key" in data:
|
||||
# Free AcoustID application key (opaque token). null is a no-op, empty
|
||||
# string clears; length-capped so a bad POST can't bloat config.json.
|
||||
# Never logged. The matcher trims + validates presence at read time.
|
||||
raw = data["acoustid_api_key"]
|
||||
if raw is not None:
|
||||
if not isinstance(raw, str) or len(raw) > 128:
|
||||
return {"error": "acoustid_api_key must be a string (at most 128 chars)"}
|
||||
updates["acoustid_api_key"] = raw.strip()
|
||||
if "enrich_review_order" in data:
|
||||
raw = data["enrich_review_order"]
|
||||
if raw is not None:
|
||||
@@ -10031,6 +10558,38 @@ def save_settings(data: dict):
|
||||
else:
|
||||
return {"error": "tuning must be a name (string) or a list of semitone offsets"}
|
||||
|
||||
if "pathway" in data:
|
||||
raw = data["pathway"]
|
||||
if raw is not None:
|
||||
if not isinstance(raw, str) or raw not in PROFILE_PATHWAYS:
|
||||
return {"error": "pathway must be one of songs, practice, learn, studio"}
|
||||
updates["pathway"] = raw
|
||||
|
||||
_profile_patch = None
|
||||
if "instrument_profiles" in data:
|
||||
raw = data["instrument_profiles"]
|
||||
if raw is not None:
|
||||
if not isinstance(raw, dict):
|
||||
return {"error": "instrument_profiles must be an object"}
|
||||
# Validate each PROVIDED profile individually and keep the patch
|
||||
# PARTIAL — /api/settings is a partial-merge endpoint, so updating one
|
||||
# profile must NOT reset the others to defaults. Merged over the
|
||||
# persisted profiles inside the lock below (not via the wholesale
|
||||
# `updates` merge, which would clobber the unspecified ones).
|
||||
_profile_patch = {}
|
||||
for _pid, _praw in raw.items():
|
||||
if _pid not in PROFILE_IDS:
|
||||
return {"error": f"unknown instrument profile: {_pid}"}
|
||||
_prof, _perr = normalize_instrument_profile(_pid, _praw)
|
||||
if _perr:
|
||||
return {"error": _perr}
|
||||
_profile_patch[_pid] = _prof
|
||||
if "active_instrument_profile" in data:
|
||||
raw = data["active_instrument_profile"]
|
||||
if raw is not None:
|
||||
if not isinstance(raw, str) or raw not in PROFILE_IDS:
|
||||
return {"error": "active_instrument_profile must be one of guitar-lead, guitar-rhythm, bass"}
|
||||
updates["active_instrument_profile"] = raw
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Critical section — the read-merge-write must be atomic. FastAPI runs
|
||||
# sync handlers in a threadpool, so two concurrent partial POSTs (e.g.
|
||||
@@ -10047,6 +10606,29 @@ def save_settings(data: dict):
|
||||
if cfg is None:
|
||||
cfg = _default_settings()
|
||||
cfg.update(updates)
|
||||
if _profile_patch is not None:
|
||||
# Merge the validated partial over the persisted profiles so a
|
||||
# single-profile update leaves the others intact (a fresh config
|
||||
# falls back to the built-in defaults for the unspecified ones).
|
||||
_existing, _ = normalize_instrument_profiles(cfg.get("instrument_profiles"))
|
||||
if _existing is None:
|
||||
_existing = {}
|
||||
_existing.update(_profile_patch)
|
||||
cfg["instrument_profiles"] = _existing
|
||||
# Only canonicalize/persist the instrument profiles when this save
|
||||
# actually touches them (or the config already carries them). GET always
|
||||
# virtualizes profiles via settings_with_instrument_profiles, so a save
|
||||
# that doesn't touch instrument settings must stay a plain partial merge
|
||||
# — otherwise an empty (or unrelated) POST would freeze the default
|
||||
# profiles into the on-disk config.
|
||||
_profile_keys = ("instrument", "string_count", "tuning", "reference_pitch",
|
||||
"pathway", "instrument_profiles", "active_instrument_profile")
|
||||
if "instrument_profiles" in cfg or any(k in updates for k in _profile_keys):
|
||||
try:
|
||||
cfg = apply_flat_instrument_patch_to_profiles(cfg, updates)
|
||||
except ValueError as exc:
|
||||
return {"error": str(exc)}
|
||||
cfg = settings_with_instrument_profiles(cfg)
|
||||
_atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8"))
|
||||
return {"message": ". ".join(messages) if messages else "Settings saved"}
|
||||
|
||||
@@ -10058,7 +10640,8 @@ def save_settings(data: dict):
|
||||
_RESETTABLE_SETTINGS_KEYS = frozenset({
|
||||
"default_arrangement", "demucs_server_url", "master_difficulty",
|
||||
"av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior",
|
||||
"reference_pitch", "instrument", "string_count", "tuning",
|
||||
"reference_pitch", "instrument", "string_count", "tuning", "pathway",
|
||||
"instrument_profiles", "active_instrument_profile",
|
||||
"achievements_enabled", "use_amp_sims",
|
||||
})
|
||||
|
||||
@@ -10083,6 +10666,16 @@ def reset_settings(data: dict):
|
||||
removed = [k for k in keys if k in cfg]
|
||||
for k in removed:
|
||||
del cfg[k]
|
||||
# `pathway` is mirrored into every instrument profile, so deleting the
|
||||
# flat key alone doesn't reset it — GET re-derives the value from the
|
||||
# active profile. Reset it inside the persisted profiles too (back to the
|
||||
# "songs" default), without disturbing the rest of the instrument config.
|
||||
if "pathway" in keys and isinstance(cfg.get("instrument_profiles"), dict):
|
||||
for prof in cfg["instrument_profiles"].values():
|
||||
if isinstance(prof, dict):
|
||||
prof["pathway"] = "songs"
|
||||
if "pathway" not in removed:
|
||||
removed.append("pathway")
|
||||
_atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8"))
|
||||
return {"message": "Settings reset", "reset": removed}
|
||||
|
||||
@@ -10174,6 +10767,18 @@ def _validate_server_config_types(cfg: dict) -> str | None:
|
||||
return "server_config.tuning offsets must be ≤8 integers between -12 and 12"
|
||||
else:
|
||||
return "server_config.tuning must be a name (string) or a list of semitone offsets"
|
||||
if "pathway" in cfg:
|
||||
v = cfg["pathway"]
|
||||
if v is not None and (not isinstance(v, str) or v not in PROFILE_PATHWAYS):
|
||||
return "server_config.pathway must be one of songs, practice, learn, studio"
|
||||
if "instrument_profiles" in cfg:
|
||||
profiles, error = normalize_instrument_profiles(cfg["instrument_profiles"])
|
||||
if error:
|
||||
return f"server_config.{error}"
|
||||
if "active_instrument_profile" in cfg:
|
||||
v = cfg["active_instrument_profile"]
|
||||
if v is not None and (not isinstance(v, str) or v not in PROFILE_IDS):
|
||||
return "server_config.active_instrument_profile must be one of guitar-lead, guitar-rhythm, bass"
|
||||
return None
|
||||
|
||||
|
||||
@@ -10543,6 +11148,7 @@ def export_settings():
|
||||
server_config = _load_config(config_file)
|
||||
if server_config is None:
|
||||
server_config = _default_settings()
|
||||
server_config = settings_with_instrument_profiles(server_config)
|
||||
|
||||
# Snapshot the library DB + custom art FIRST: if the irreplaceable state
|
||||
# can't be captured, abort with an error rather than hand back a bundle
|
||||
@@ -10785,7 +11391,7 @@ def import_settings(bundle: dict):
|
||||
with _settings_lock:
|
||||
_atomic_write_file(
|
||||
CONFIG_DIR / "config.json",
|
||||
json.dumps(server_config, indent=2).encode("utf-8"),
|
||||
json.dumps(settings_with_instrument_profiles(server_config), indent=2).encode("utf-8"),
|
||||
)
|
||||
except OSError as e:
|
||||
# Phase-1 validation should have caught all foreseeable
|
||||
|
||||
@@ -2758,6 +2758,12 @@ function goFavTreePage(p) {
|
||||
// ── Settings ─────────────────────────────────────────────────────────────
|
||||
let _defaultArrangement = '';
|
||||
|
||||
const INSTRUMENT_PATHWAYS = ['songs', 'practice', 'learn', 'studio'];
|
||||
|
||||
function _normalizeInstrumentPathway(value) {
|
||||
return INSTRUMENT_PATHWAYS.includes(value) ? value : 'songs';
|
||||
}
|
||||
|
||||
function _syncDefaultArrangementSelect(value) {
|
||||
const sel = document.getElementById('default-arrangement');
|
||||
if (!sel) return;
|
||||
@@ -3410,6 +3416,8 @@ async function loadSettings() {
|
||||
if (dlcEl) dlcEl.value = data.dlc_dir || '';
|
||||
_defaultArrangement = data.default_arrangement || '';
|
||||
_syncDefaultArrangementSelect(_defaultArrangement);
|
||||
const pathwayEl = document.getElementById('setting-instrument-pathway');
|
||||
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
|
||||
const demucsEl = document.getElementById('demucs-server-url');
|
||||
if (demucsEl) demucsEl.value = data.demucs_server_url || '';
|
||||
const leftyEl = document.getElementById('setting-lefty');
|
||||
@@ -3901,6 +3909,18 @@ function persistSetting(key, value) {
|
||||
_settingSaveChain = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
function setInstrumentPathway(value) {
|
||||
const pathway = _normalizeInstrumentPathway(value);
|
||||
const el = document.getElementById('setting-instrument-pathway');
|
||||
if (el) el.value = pathway;
|
||||
persistSetting('pathway', pathway).then(() => {
|
||||
if (window.v3Badges && typeof window.v3Badges.reload === 'function') {
|
||||
try { window.v3Badges.reload(); } catch (_) { /* noop */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function _postSetting(key, value) {
|
||||
const status = document.getElementById('settings-status');
|
||||
try {
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
return fetch('/api/tunings')
|
||||
.then(function (r) { return r && r.ok ? r.json() : null; })
|
||||
.then(function (t) {
|
||||
const byName = t && t[key];
|
||||
const byName = t && ((t.tunings && t.tunings[key]) || t[key]);
|
||||
commit(byName ? _offsetsFromFreqs(byName[s.tuning], byName.Standard) : null);
|
||||
})
|
||||
.catch(function () { commit(null); });
|
||||
|
||||
+53
-5
@@ -21,7 +21,13 @@
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5] };
|
||||
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5, 6] };
|
||||
const PATHWAY_OPTIONS = [
|
||||
{ id: 'songs', label: 'Songs' },
|
||||
{ id: 'practice', label: 'Practice' },
|
||||
{ id: 'learn', label: 'Learn' },
|
||||
{ id: 'studio', label: 'Studio' },
|
||||
];
|
||||
// Tuning names per instrument key (e.g. 'guitar-6', 'bass-4'), loaded from
|
||||
// GET /api/tunings. Falls back to empty arrays until the fetch resolves.
|
||||
let _tuningsByKey = {};
|
||||
@@ -106,7 +112,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 };
|
||||
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' };
|
||||
|
||||
async function loadTunings() {
|
||||
try {
|
||||
@@ -126,6 +132,15 @@
|
||||
} catch (_) { /* non-fatal — TUNINGS falls back to empty, dropdown shows nothing */ }
|
||||
}
|
||||
|
||||
function pathwayForProfile(profiles, profileId, fallback) {
|
||||
const p = profiles && profiles[profileId];
|
||||
return p && PATHWAY_OPTIONS.some((o) => o.id === p.pathway) ? p.pathway : (fallback || 'songs');
|
||||
}
|
||||
|
||||
function profileIdForInstrument(inst) {
|
||||
return inst === 'bass' ? 'bass' : 'guitar-lead';
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const r = await fetch('/api/settings');
|
||||
@@ -150,16 +165,34 @@
|
||||
if (typeof s.tuning === 'string') tuning = tunings.includes(s.tuning) ? s.tuning : (tunings[0] || 'Standard');
|
||||
else if (Array.isArray(s.tuning)) tuning = s.tuning;
|
||||
else tuning = tunings[0] || 'Standard';
|
||||
const profiles = s.instrument_profiles && typeof s.instrument_profiles === 'object' ? s.instrument_profiles : {};
|
||||
const pathway = PATHWAY_OPTIONS.some((o) => o.id === s.pathway) ? s.pathway : 'songs';
|
||||
settings = {
|
||||
instrument: instrument,
|
||||
string_count: scValid,
|
||||
tuning: tuning,
|
||||
reference_pitch: Math.min(450, Math.max(430, ref)),
|
||||
pathway: pathway,
|
||||
instrument_profiles: profiles,
|
||||
active_instrument_profile: typeof s.active_instrument_profile === 'string' ? s.active_instrument_profile : profileIdForInstrument(instrument),
|
||||
};
|
||||
}
|
||||
} catch (e) { /* settings endpoint always present */ }
|
||||
}
|
||||
|
||||
function syncLocalProfilePatch(patch) {
|
||||
const profileId = profileIdForInstrument(patch.instrument || settings.instrument);
|
||||
if (!settings.instrument_profiles || typeof settings.instrument_profiles !== 'object') settings.instrument_profiles = {};
|
||||
if (patch.instrument) settings.active_instrument_profile = profileId;
|
||||
const profile = Object.assign({}, settings.instrument_profiles[profileId] || {});
|
||||
let changed = false;
|
||||
if (patch.instrument) { profile.instrument = patch.instrument; changed = true; }
|
||||
if (patch.string_count != null) { profile.string_count = patch.string_count; changed = true; }
|
||||
if (patch.tuning != null) { profile.tuning = patch.tuning; changed = true; }
|
||||
if (patch.reference_pitch != null) { profile.reference_pitch = patch.reference_pitch; changed = true; }
|
||||
if (patch.pathway != null) { profile.pathway = patch.pathway; changed = true; }
|
||||
if (changed) settings.instrument_profiles[profileId] = profile;
|
||||
}
|
||||
async function saveSettings(patch) {
|
||||
// Only adopt the patch once the server accepts it. /api/settings returns
|
||||
// {error: ...} with HTTP 200 on a validation failure, so a rejected
|
||||
@@ -177,8 +210,9 @@
|
||||
} catch (e) { /* non-fatal — leave settings unchanged */ }
|
||||
if (!accepted) return false;
|
||||
Object.assign(settings, patch);
|
||||
syncLocalProfilePatch(patch);
|
||||
if (sm && sm.emit) sm.emit('instrument:changed', {
|
||||
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning,
|
||||
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning, pathway: settings.pathway,
|
||||
});
|
||||
pushToTuner();
|
||||
renderTuner(); // reflect new tuning on the tuner card
|
||||
@@ -424,6 +458,9 @@
|
||||
// (picking a named tuning still works and replaces the custom one).
|
||||
(typeof settings.tuning === 'string' ? '' : '<option selected disabled>Custom</option>') +
|
||||
_tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '<option' + (t === settings.tuning ? ' selected' : '') + '>' + esc(t) + '</option>').join('') + '</select></div>' +
|
||||
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Pathway</div>' +
|
||||
'<select data-inst-pathway class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
|
||||
PATHWAY_OPTIONS.map((p) => '<option value="' + esc(p.id) + '"' + (p.id === settings.pathway ? ' selected' : '') + '>' + esc(p.label) + '</option>').join('') + '</select></div>' +
|
||||
'<div><div class="flex justify-between text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
|
||||
'<input data-inst-ref type="range" min="430" max="450" step="1" value="' + settings.reference_pitch + '" class="w-full slider-input"></div>' +
|
||||
'</div></div>';
|
||||
@@ -454,6 +491,7 @@
|
||||
instrument: v,
|
||||
string_count: newSc,
|
||||
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
|
||||
pathway: pathwayForProfile(settings.instrument_profiles, profileIdForInstrument(v), settings.pathway),
|
||||
});
|
||||
// Only move the working-tuning context once the switch was actually persisted —
|
||||
// otherwise the selector stays on the old instrument while the card shows the
|
||||
@@ -462,11 +500,21 @@
|
||||
renderInstrument(); keepOpen();
|
||||
}));
|
||||
menu.querySelectorAll('[data-pill="strings"]').forEach((b) => b.addEventListener('click', async () => {
|
||||
await saveSettings({ string_count: Number(b.getAttribute('data-val')) });
|
||||
setWorkingInstrument(settings.instrument, settings.string_count);
|
||||
const newSc = Number(b.getAttribute('data-val'));
|
||||
// Clamp the tuning to one valid for the new string count and post it
|
||||
// alongside string_count — otherwise the backend silently resets a
|
||||
// now-invalid tuning to Standard while this UI keeps showing the old
|
||||
// one (settings/tuner desync). Mirrors the instrument-switch clamp.
|
||||
const tunings = _tuningsForInstrument(settings.instrument, newSc);
|
||||
await saveSettings({
|
||||
string_count: newSc,
|
||||
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
|
||||
});
|
||||
setWorkingInstrument(settings.instrument, newSc);
|
||||
renderInstrument(); keepOpen();
|
||||
}));
|
||||
menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: e.target.value }));
|
||||
menu.querySelector('[data-inst-pathway]').addEventListener('change', (e) => saveSettings({ pathway: e.target.value }));
|
||||
const ref = menu.querySelector('[data-inst-ref]');
|
||||
ref.addEventListener('input', (e) => { menu.querySelector('[data-ref-val]').textContent = e.target.value + ' Hz'; });
|
||||
ref.addEventListener('change', (e) => saveSettings({ reference_pitch: Number(e.target.value) }));
|
||||
|
||||
@@ -429,6 +429,23 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Instrument pathway -->
|
||||
<div class="fb-srow">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Instrument pathway</div>
|
||||
<div class="fb-srow-desc">Preferred path for the selected instrument. This is remembered per instrument profile.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<select id="setting-instrument-pathway" onchange="setInstrumentPathway(this.value)"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="songs">Songs</option>
|
||||
<option value="practice">Practice</option>
|
||||
<option value="learn">Learn</option>
|
||||
<option value="studio">Studio</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Arrangement routes (naming mode) -->
|
||||
<div class="fb-srow">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
|
||||
@@ -771,6 +788,13 @@
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-600 mt-1">What a confident match may fill in on its own — matches you confirm in the review queue always apply in full.</div>
|
||||
</div>
|
||||
<!-- Audio fingerprint (AcoustID) — opt-in, default OFF. Wired by match-review.js. -->
|
||||
<div class="fb-srow-wide mb-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Audio fingerprint (AcoustID)</div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-400 mb-1"><input type="checkbox" id="acoustid-enabled" class="rounded border-gray-600 bg-dark-700 text-accent"> Identify by audio — reads the recording itself for the exact version (studio vs live/extended)</label>
|
||||
<input type="text" id="acoustid-api-key" placeholder="AcoustID application key" class="w-full bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<div class="text-[11px] text-gray-600 mt-1">Opt-in. Get a free key at acoustid.org/new-application; the fpcalc (Chromaprint) binary must be on the server's PATH.</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
|
||||
<label class="flex items-center gap-2">Review queue order
|
||||
<select id="enrich-review-order" class="bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
|
||||
@@ -346,7 +346,8 @@
|
||||
'<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
|
||||
'<div class="flex items-center gap-3">' +
|
||||
(_single ? '' : '<button data-mr-reject class="text-sm text-fb-textDim hover:text-fb-text">Not a match</button>') +
|
||||
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button></div>' +
|
||||
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button>' +
|
||||
'<button data-mr-identify class="text-sm text-fb-primary hover:text-fb-primaryHi" title="Fingerprint this song\'s audio to find the exact recording">Identify by audio</button></div>' +
|
||||
'<div class="flex items-center gap-2">' +
|
||||
(_single ? '' : '<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>') +
|
||||
((song.candidates || []).length ? '<button data-mr-accept class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Use selected</button>' : '') +
|
||||
@@ -395,6 +396,7 @@
|
||||
const go = () => runSearch(panel, song);
|
||||
panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
|
||||
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } });
|
||||
panel.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(panel, song));
|
||||
}
|
||||
|
||||
// Silent-on-success: the chart just leaves the queue and the next one
|
||||
@@ -447,6 +449,52 @@
|
||||
});
|
||||
}
|
||||
|
||||
// "Identify by audio" — fingerprint the song's OWN master audio (AcoustID)
|
||||
// and render the hits into the same search-results area. The reliable path
|
||||
// when text search can't tell the studio take from live/comp versions.
|
||||
async function runIdentify(panel, song) {
|
||||
const out = panel.querySelector('[data-mr-search-results]');
|
||||
const sp = panel.querySelector('[data-mr-search-panel]');
|
||||
if (!out) return;
|
||||
sp?.classList.remove('hidden'); // give the results somewhere to render
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">Fingerprinting audio…</p>';
|
||||
let body = null, status = 0;
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/identify/' + enc(song.filename), { method: 'POST' });
|
||||
status = r.status;
|
||||
body = await r.json().catch(() => null);
|
||||
} catch (_) { /* falls through to the no-results line */ }
|
||||
// Honest states — never a fake hit.
|
||||
if (status === 412 || (body && body.needs_setup)) {
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is off — enable AcoustID and add a free API key to use it.</p>';
|
||||
return;
|
||||
}
|
||||
if (status === 404) {
|
||||
out.innerHTML = "<p class=\"text-xs text-fb-textDim\">No full-mix audio to fingerprint for this song.</p>";
|
||||
return;
|
||||
}
|
||||
if (status === 503) {
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is unavailable right now — try again.</p>';
|
||||
return;
|
||||
}
|
||||
const cands = (body && body.candidates) || [];
|
||||
if (!cands.length) {
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">No fingerprint match — try text search.</p>';
|
||||
return;
|
||||
}
|
||||
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Fingerprint matches (AcoustID)</div>' +
|
||||
cands.map((c, i) => candRowHtml(song, c, i, false)).join('');
|
||||
out.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
|
||||
if (!cand) return;
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
|
||||
{ candidate: cand });
|
||||
settle(song);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function post(url, payload) {
|
||||
try {
|
||||
await fetch(url, {
|
||||
@@ -483,7 +531,10 @@
|
||||
// opt-IN per the dev-chat thread.
|
||||
const optInToggles = [
|
||||
['artist-external-links', 'artist_external_links'],
|
||||
// Audio fingerprinting is opt-in (needs a key + fpcalc), default OFF.
|
||||
['acoustid-enabled', 'acoustid_enabled'],
|
||||
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
|
||||
const acoustidKeyEl = document.getElementById('acoustid-api-key');
|
||||
if (!toggles.length && !optInToggles.length && !sel && !btn) return;
|
||||
(async () => {
|
||||
try {
|
||||
@@ -492,6 +543,7 @@
|
||||
const cfg = await r.json();
|
||||
for (const [el, key] of toggles) el.checked = cfg[key] !== false;
|
||||
for (const [el, key] of optInToggles) el.checked = cfg[key] === true;
|
||||
if (acoustidKeyEl) acoustidKeyEl.value = cfg.acoustid_api_key || '';
|
||||
if (sel) {
|
||||
const t = Number(cfg.enrich_auto_threshold);
|
||||
const want = Number.isFinite(t) ? t : 0.9;
|
||||
@@ -516,6 +568,7 @@
|
||||
}
|
||||
sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value)));
|
||||
order?.addEventListener('change', () => save('enrich_review_order', order.value));
|
||||
acoustidKeyEl?.addEventListener('change', () => save('acoustid_api_key', acoustidKeyEl.value.trim()));
|
||||
btn?.addEventListener('click', async () => {
|
||||
await post('/api/enrichment/kick');
|
||||
const line = document.getElementById('enrich-status');
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
var RESET_MAP = {
|
||||
gameplay: {
|
||||
server: ['master_difficulty', 'av_offset_ms', 'miss_penalty',
|
||||
'fail_behavior', 'countdown_before_song', 'default_arrangement'],
|
||||
'fail_behavior', 'countdown_before_song', 'default_arrangement', 'pathway'],
|
||||
local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'],
|
||||
after: function () {
|
||||
// Left-handed is held on the highway object, not re-derived
|
||||
|
||||
+279
-8
@@ -490,6 +490,31 @@
|
||||
'<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/></svg>' + pct + '%</span>';
|
||||
}
|
||||
|
||||
// ── Metadata-refresh per-tile state (the "Refresh Metadata" batch) ─────────
|
||||
// A transient badge painted ONLY while a metadata refresh is running: the
|
||||
// songs actually being (re)matched animate queued → working → done. Keyed by
|
||||
// the card's data-fn (= the local filename the enrichment cache keys on).
|
||||
// Empty for every song outside a refresh, so an idle card is byte-identical
|
||||
// to before (keeps the windowed grid's height math untouched). Honest state
|
||||
// transitions, NOT a fake per-song %: a match is binary (design §11).
|
||||
const _metaTile = {}; // fn -> 'queued' | 'working' | 'done' | 'nochange'
|
||||
function enrichBadge(fn) {
|
||||
const st = _metaTile[fn];
|
||||
if (!st) return '';
|
||||
const M = {
|
||||
queued: ['bg-black/60 text-fb-textDim', '• Queued'],
|
||||
working: ['bg-fb-primary text-white', '⟳ Matching…'],
|
||||
done: ['bg-fb-good/90 text-black', '✓ Updated'],
|
||||
nochange: ['bg-black/60 text-fb-textDim', '— No match'],
|
||||
};
|
||||
const conf = M[st] || M.queued;
|
||||
// top-10 clears the tuning chip (top-2) in both normal and select mode;
|
||||
// z-20 sits it above the art. Non-interactive so it never eats a click.
|
||||
return '<span class="v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
|
||||
' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight pointer-events-none">' +
|
||||
conf[1] + '</span>';
|
||||
}
|
||||
|
||||
// After a song is scored, the badge for that card is stale until the next
|
||||
// full render(). Refresh state.accuracy from the server and patch the badge
|
||||
// of any currently-rendered card/row in place (grid + tree). `_dirtyScores`
|
||||
@@ -845,7 +870,7 @@
|
||||
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
|
||||
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
|
||||
'<img src="' + esc(artUrl(shown)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
|
||||
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + overlay +
|
||||
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key) + overlay +
|
||||
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
|
||||
inlineBtns +
|
||||
'<button data-fav data-fav-idle="text-white" title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
|
||||
@@ -1834,13 +1859,57 @@
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function _renderCardsRange(start, end) {
|
||||
let html = '';
|
||||
// Signature of the card at absolute index i: real-card vs skeleton, plus the
|
||||
// select-mode it was built under. A change here is the ONLY reason a recycled
|
||||
// node must be rebuilt (a hole filled after a fetch, or select mode toggled) —
|
||||
// otherwise the node is reused as-is across window slides.
|
||||
function _cardSig(i) {
|
||||
return (state.songs[i] ? 'r' : 's') + (state.selectMode ? '1' : '0');
|
||||
}
|
||||
|
||||
function _buildCardNode(i) {
|
||||
const s = state.songs[i];
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = s ? songCard(s) : _skeletonCard();
|
||||
const node = tmp.firstElementChild;
|
||||
node.setAttribute('data-idx', String(i));
|
||||
node.setAttribute('data-sig', _cardSig(i));
|
||||
return node;
|
||||
}
|
||||
|
||||
// Reconcile the grid's children to exactly cover [start, end) in ascending
|
||||
// index order, REUSING the card nodes that stay in-window. Sliding the window
|
||||
// one row now mutates only the row that entered/left instead of tearing down +
|
||||
// rebuilding (+ re-wiring) the whole ~60-card window every frame — that
|
||||
// per-slide teardown was the main-thread stall behind the "library skips every
|
||||
// so many scrolls, up or down" report (the stall buffers held-arrow key-repeats
|
||||
// that then flush in a burst). wireCards()'s data-wired guard wires only the
|
||||
// freshly-built nodes.
|
||||
function _syncWindow(grid, start, end) {
|
||||
// Pass 1: drop nodes that left the window, are untagged, or whose content
|
||||
// signature is stale (skeleton→real, or select-mode toggled). What remains
|
||||
// is a reusable, correctly-rendered subset in ascending DOM order.
|
||||
for (const el of Array.from(grid.children)) {
|
||||
const a = el.getAttribute('data-idx');
|
||||
const idx = a == null ? NaN : Number(a);
|
||||
if (!(idx >= start && idx < end) || el.getAttribute('data-sig') !== _cardSig(idx)) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
// Pass 2: walk [start, end) in order, reusing survivors and inserting new
|
||||
// nodes into their correct slot; `ref` tracks the child expected next.
|
||||
const existing = new Map();
|
||||
for (const el of grid.children) existing.set(Number(el.getAttribute('data-idx')), el);
|
||||
let ref = grid.firstChild;
|
||||
for (let i = start; i < end; i++) {
|
||||
const s = state.songs[i];
|
||||
html += s ? songCard(s) : _skeletonCard();
|
||||
let node = existing.get(i);
|
||||
if (!node) node = _buildCardNode(i);
|
||||
if (node === ref) {
|
||||
ref = ref.nextSibling;
|
||||
} else {
|
||||
grid.insertBefore(node, ref);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
// Fetch a single OFFSET page into the sparse store. Uses the stage-1 keyset
|
||||
@@ -1958,7 +2027,7 @@
|
||||
}
|
||||
if (_closeCardMenu) _closeCardMenu(); // its DOM is about to be replaced
|
||||
grid.style.top = (firstRow * rowH) + 'px';
|
||||
grid.innerHTML = _renderCardsRange(start, end);
|
||||
_syncWindow(grid, start, end); // recycle in-window nodes; only the entering/leaving row rebuilds
|
||||
wireCards(grid);
|
||||
decorateTuningChips(grid); // colour tuning chips by working-tuning match (async, feature-detected)
|
||||
state.winRange = { start, end };
|
||||
@@ -3402,7 +3471,13 @@
|
||||
// shown by match-review.js (window.__fbMatchReviewChip), which
|
||||
// also owns the drawer the click opens.
|
||||
'<div class="flex items-baseline gap-3"><p class="text-fb-textDim text-sm" id="v3-songs-count"></p>' +
|
||||
'<button id="v3-songs-match-review" class="hidden text-xs text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-full px-2.5 py-0.5"></button></div>' +
|
||||
'<button id="v3-songs-match-review" class="hidden text-xs text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-full px-2.5 py-0.5"></button>' +
|
||||
// Batch progress for the Refresh Metadata button (shown only while a
|
||||
// pass runs). A real songs-processed ratio, not a fake per-song %.
|
||||
'<span id="v3-meta-progress" class="hidden items-center gap-2 text-xs text-fb-textDim">' +
|
||||
'<span id="v3-meta-progress-label"></span>' +
|
||||
'<span class="inline-block w-24 rounded-full bg-fb-border/40 overflow-hidden align-middle" style="height:6px"><span id="v3-meta-progress-fill" class="block h-full bg-fb-primary transition-all" style="width:0%"></span></span>' +
|
||||
'</span></div>' +
|
||||
'<div class="flex flex-wrap gap-2">' +
|
||||
(providers.length > 1 ? '<select id="v3-songs-provider" class="' + ctrl + '">' + provOpts + '</select>' : '') +
|
||||
'<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
|
||||
@@ -3413,6 +3488,7 @@
|
||||
'<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>' +
|
||||
'<button id="v3-songs-refresh" title="Refresh library (scan for new songs)" class="' + ctrl + '">⟳ Refresh</button>' +
|
||||
'<button id="v3-songs-refresh-meta" title="Refresh metadata for the songs shown (re-match titles, artwork & more)" class="' + ctrl + '">🏷 Metadata</button>' +
|
||||
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
|
||||
'</div></div></div>' +
|
||||
// Practice-aware library home: a repertoire progress meter + a
|
||||
@@ -3450,6 +3526,7 @@
|
||||
state.artist = '';
|
||||
state.album = '';
|
||||
try { sm.libraryProviders && await sm.libraryProviders.select(state.provider); } catch (err) { /* */ }
|
||||
_updateMetaBtnVisibility(); // enrichment is local-only
|
||||
await loadArtistCatalog();
|
||||
refreshArtistAlbumSelects();
|
||||
reload();
|
||||
@@ -3479,6 +3556,10 @@
|
||||
});
|
||||
byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode));
|
||||
byId('v3-songs-refresh')?.addEventListener('click', refreshLibrary);
|
||||
// Refresh Metadata: local-only, so hide it for remote providers. The
|
||||
// button doubles as its own Stop while a pass runs (see onMetaBtnClick).
|
||||
byId('v3-songs-refresh-meta')?.addEventListener('click', onMetaBtnClick);
|
||||
_updateMetaBtnVisibility();
|
||||
// Reflect a scan already in progress (Settings button or a background
|
||||
// pass) on the Refresh button, so its state isn't just tied to clicks here.
|
||||
(async () => {
|
||||
@@ -3488,6 +3569,15 @@
|
||||
if (sd && sd.running) { _setRefreshState(sd); _watchScan({ announce: false }); }
|
||||
} catch (e) { /* */ }
|
||||
})();
|
||||
// Reflect an enrichment pass already running (Settings "Match now" or a
|
||||
// post-scan background pass) on the Metadata button + bar.
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/status');
|
||||
const es = r.ok ? await r.json() : null;
|
||||
if (es && es.running) { _setMetaState(es); _watchEnrich({ announce: false }); }
|
||||
} catch (e) { /* */ }
|
||||
})();
|
||||
|
||||
// Capture-phase select-mode guard on each persistent list host. Without
|
||||
// it, clicking a card/row (or its arrangement chip) in select mode falls
|
||||
@@ -3706,6 +3796,187 @@
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ── Refresh Metadata (batch enrichment) from the Songs toolbar ─────────────
|
||||
// The metadata counterpart to ⟳ Refresh (which scans FILES): matches
|
||||
// titles/artist/album/artwork against MusicBrainz for the songs that still
|
||||
// need it — the ambient background matcher, run on demand (a media-server's
|
||||
// "Refresh Metadata" vs "Scan Files"). Mirrors the scan machinery: a 1 Hz
|
||||
// poll of /api/enrichment/status drives the button + batch bar, while
|
||||
// /api/enrichment/states drives per-tile badges on the visible window.
|
||||
// Enrichment is local-only, so the button hides for remote providers.
|
||||
let _metaPoll = null;
|
||||
let _metaRunning = false;
|
||||
|
||||
function _updateMetaBtnVisibility() {
|
||||
const btn = document.getElementById('v3-songs-refresh-meta');
|
||||
if (btn) btn.style.display = (state.provider === 'local') ? '' : 'none';
|
||||
}
|
||||
|
||||
// The local filenames the grid is currently SHOWING (data-fn is the local
|
||||
// filename the enrichment cache keys on). The grid is windowed, so this is
|
||||
// the visible slice only — exactly what the per-tile poll should cover.
|
||||
function _visibleLocalFilenames() {
|
||||
const grid = document.getElementById('v3-songs-grid');
|
||||
if (!grid) return [];
|
||||
return [...grid.querySelectorAll('[data-fn]')]
|
||||
.map((el) => el.getAttribute('data-fn')).filter(Boolean);
|
||||
}
|
||||
|
||||
// Set/clear one card's live badge (recycled cards re-derive from _metaTile on
|
||||
// the next paint, so update the map too — mirrors _patchCardFav).
|
||||
function _patchCardEnrich(fn, st) {
|
||||
if (st) _metaTile[fn] = st; else delete _metaTile[fn];
|
||||
const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
|
||||
document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => {
|
||||
const el = play.querySelector('.v3-meta-tile');
|
||||
const html = enrichBadge(fn);
|
||||
if (!html) { if (el) el.remove(); return; }
|
||||
if (el) el.outerHTML = html; else play.insertAdjacentHTML('beforeend', html);
|
||||
});
|
||||
}
|
||||
|
||||
function _clearMetaTiles() {
|
||||
Object.keys(_metaTile).forEach((fn) => { delete _metaTile[fn]; });
|
||||
document.querySelectorAll('.v3-meta-tile').forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
|
||||
// Drive the button (which doubles as Stop) + the batch bar from a status body.
|
||||
function _setMetaState(es) {
|
||||
const btn = document.getElementById('v3-songs-refresh-meta');
|
||||
const prog = document.getElementById('v3-meta-progress');
|
||||
const fill = document.getElementById('v3-meta-progress-fill');
|
||||
const label = document.getElementById('v3-meta-progress-label');
|
||||
if (!btn) return;
|
||||
const running = !!(es && es.running);
|
||||
_metaRunning = running;
|
||||
if (running) {
|
||||
const total = (es && es.total) || 0, done = (es && es.matched) || 0;
|
||||
const cancelling = !!(es && es.cancelling);
|
||||
btn.textContent = cancelling ? 'Stopping…' : ('⏹ Stop' + (total ? ' · ' + done + '/' + total : ''));
|
||||
btn.disabled = cancelling;
|
||||
btn.classList.toggle('opacity-70', cancelling);
|
||||
btn.title = cancelling ? 'Stopping after the current song…' : 'Stop refreshing metadata';
|
||||
if (prog) {
|
||||
prog.classList.remove('hidden'); prog.classList.add('flex');
|
||||
if (label) label.textContent = total ? ('Matching metadata ' + done + '/' + total) : 'Matching metadata…';
|
||||
// Real songs-processed ratio; a tiny sliver while the queue size
|
||||
// is still being computed (phase 1) so the bar isn't dead-empty.
|
||||
if (fill) fill.style.width = (total ? Math.round((done / total) * 100) : 6) + '%';
|
||||
}
|
||||
} else {
|
||||
btn.textContent = '🏷 Metadata';
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-70');
|
||||
btn.title = 'Refresh metadata for the songs shown (re-match titles, artwork & more)';
|
||||
if (prog) { prog.classList.add('hidden'); prog.classList.remove('flex'); }
|
||||
}
|
||||
}
|
||||
|
||||
// Completion toast — reuse the shared fbNotify surface (visual-only, so
|
||||
// hearing-safe for free). Honest + never-punishing copy, in-game suppressed.
|
||||
function _metaCompleteToast(es) {
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (active && active.id === 'player') return;
|
||||
if (!window.fbNotify) return;
|
||||
const matched = (es && es.matched) || 0;
|
||||
const msg = matched
|
||||
? (matched + ' song' + (matched === 1 ? '' : 's') + ' matched')
|
||||
: 'Your library metadata is up to date';
|
||||
try { window.fbNotify.show({ title: 'Metadata refresh complete', message: msg, icon: '🏷️', accent: '#22C55E' }); } catch (e) { /* */ }
|
||||
}
|
||||
|
||||
// Poll enrichment status (button + bar) AND the visible window's per-song
|
||||
// states (tile badges) until the pass finishes. announce:false = we only
|
||||
// attached to a pass we didn't start (no toast unless it actually changed
|
||||
// something).
|
||||
function _watchEnrich(opts) {
|
||||
if (_metaPoll) return;
|
||||
const announce = !opts || opts.announce !== false;
|
||||
let sawRunning = false, ticks = 0, lastStatus = null;
|
||||
_metaPoll = setInterval(async () => {
|
||||
ticks++;
|
||||
let es = null;
|
||||
try { const r = await fetch('/api/enrichment/status'); if (r.ok) es = await r.json(); } catch (e) { /* */ }
|
||||
if (es) { lastStatus = es; _setMetaState(es); if (es.running) sawRunning = true; }
|
||||
// Per-tile badges: only songs we're tracking (seeded 'queued'). A
|
||||
// tile flips to 'working' when it's the current song, then to
|
||||
// 'done' (matched) / 'nochange' (failed) once it leaves unscanned.
|
||||
if (Object.keys(_metaTile).length) {
|
||||
const fns = _visibleLocalFilenames();
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/states', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filenames: fns }),
|
||||
});
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
const states = j.states || {}, current = j.current;
|
||||
fns.forEach((fn) => {
|
||||
if (!(fn in _metaTile)) return;
|
||||
if (fn === current) { _patchCardEnrich(fn, 'working'); return; }
|
||||
const s = states[fn];
|
||||
if (s && s !== 'unscanned' && s !== 'pending') {
|
||||
_patchCardEnrich(fn, s === 'failed' ? 'nochange' : 'done');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) { /* */ }
|
||||
}
|
||||
// Cap at 20 min (a ~1000-song trickle at ≤1/s is ~17 min); a
|
||||
// user-initiated no-op that never saw a running pass ends quickly.
|
||||
const noopDone = announce && !sawRunning && ticks >= 3;
|
||||
if ((sawRunning && es && !es.running) || noopDone || ticks >= 1200) {
|
||||
clearInterval(_metaPoll); _metaPoll = null;
|
||||
_setMetaState(null);
|
||||
const changed = sawRunning && lastStatus && (lastStatus.matched || 0) > 0;
|
||||
if (announce || changed) _metaCompleteToast(lastStatus);
|
||||
// Let the final 'done' badges register, then clear + (if anything
|
||||
// matched) reload so new canonical titles/art show.
|
||||
setTimeout(() => {
|
||||
_clearMetaTiles();
|
||||
if (changed && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'enrich', matched: lastStatus.matched }); } catch (e) { /* */ } }
|
||||
}, 1600);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Force a fresh re-match of the songs currently SHOWN (the visible grid
|
||||
// window) — a media-server-style per-view "Refresh Metadata". Resets those
|
||||
// songs and re-fetches, so it's visible even on an already-matched library.
|
||||
// Manual pins are skipped server-side; scoped to the visible set so it's
|
||||
// fast + can't blow the whole rate budget.
|
||||
async function refreshMetadata() {
|
||||
if (_metaRunning || _metaPoll) return; // already running
|
||||
const fns = _visibleLocalFilenames();
|
||||
_clearMetaTiles();
|
||||
if (!fns.length) { _metaCompleteToast({ matched: 0 }); return; }
|
||||
let queued = [];
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/rematch', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filenames: fns }),
|
||||
});
|
||||
if (r.ok) queued = (await r.json()).queued || [];
|
||||
} catch (e) { /* offline → nothing queued */ }
|
||||
// Badge exactly what the server queued (everything visible except your
|
||||
// manual pins). Nothing queued = all visible songs are pinned/unknown.
|
||||
queued.forEach((fn) => _patchCardEnrich(fn, 'queued'));
|
||||
if (!queued.length) { _metaCompleteToast({ matched: 0 }); return; }
|
||||
_watchEnrich({ announce: true });
|
||||
}
|
||||
|
||||
async function stopMetadata() {
|
||||
try { await fetch('/api/enrichment/cancel', { method: 'POST' }); } catch (e) { /* */ }
|
||||
_setMetaState({ running: true, cancelling: true }); // optimistic; the poll confirms
|
||||
}
|
||||
|
||||
// The Metadata button toggles role: kick a refresh when idle, Stop when a
|
||||
// pass is running.
|
||||
function onMetaBtnClick() {
|
||||
if (_metaRunning) stopMetadata(); else refreshMetadata();
|
||||
}
|
||||
|
||||
// Topbar search drives this screen.
|
||||
async function search(q) {
|
||||
state.q = q || '';
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
// Mirror of static/v3/songs.js _cardSig / _buildCardNode / _syncWindow (the
|
||||
// windowed-grid recycle path, #636 item 3 follow-up) — keep in sync. Exercised
|
||||
// against a minimal DOM shim so the reconcile invariants are covered off-browser:
|
||||
// (1) after every slide the grid's children are exactly [start,end) ascending,
|
||||
// (2) card nodes for indices that stay in-window are REUSED (identity kept) —
|
||||
// i.e. sliding one row never tears down + rebuilds the whole window (the
|
||||
// per-slide stall behind the "skips every so many scrolls" report), and
|
||||
// (3) a select-mode toggle rebuilds the visible window (checkbox/ring change).
|
||||
|
||||
let NODE_SEQ = 0;
|
||||
function makeNode() {
|
||||
const attrs = {};
|
||||
return {
|
||||
_uid: ++NODE_SEQ,
|
||||
parent: null,
|
||||
getAttribute(k) { return k in attrs ? attrs[k] : null; },
|
||||
setAttribute(k, v) { attrs[k] = String(v); },
|
||||
get nextSibling() {
|
||||
const p = this.parent; if (!p) return null;
|
||||
const i = p._kids.indexOf(this);
|
||||
return i >= 0 && i + 1 < p._kids.length ? p._kids[i + 1] : null;
|
||||
},
|
||||
remove() {
|
||||
const p = this.parent; if (!p) return;
|
||||
const i = p._kids.indexOf(this);
|
||||
if (i >= 0) p._kids.splice(i, 1);
|
||||
this.parent = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
function makeGrid() {
|
||||
return {
|
||||
_kids: [],
|
||||
get children() { return this._kids.slice(); },
|
||||
get firstChild() { return this._kids[0] || null; },
|
||||
insertBefore(node, ref) {
|
||||
if (node.parent) node.remove();
|
||||
if (ref == null) this._kids.push(node);
|
||||
else { const i = this._kids.indexOf(ref); this._kids.splice(i < 0 ? this._kids.length : i, 0, node); }
|
||||
node.parent = this;
|
||||
return node;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- state + the three helpers, mirrored from songs.js ---
|
||||
const state = { songs: [], selectMode: false };
|
||||
for (let i = 0; i < 5000; i++) state.songs[i] = { filename: 'song' + i };
|
||||
|
||||
function _cardSig(i) { return (state.songs[i] ? 'r' : 's') + (state.selectMode ? '1' : '0'); }
|
||||
function _buildCardNode(i) {
|
||||
const node = makeNode();
|
||||
node.setAttribute('data-idx', String(i));
|
||||
node.setAttribute('data-sig', _cardSig(i));
|
||||
return node;
|
||||
}
|
||||
function _syncWindow(grid, start, end) {
|
||||
for (const el of Array.from(grid.children)) {
|
||||
const a = el.getAttribute('data-idx');
|
||||
const idx = a == null ? NaN : Number(a);
|
||||
if (!(idx >= start && idx < end) || el.getAttribute('data-sig') !== _cardSig(idx)) el.remove();
|
||||
}
|
||||
const existing = new Map();
|
||||
for (const el of grid.children) existing.set(Number(el.getAttribute('data-idx')), el);
|
||||
let ref = grid.firstChild;
|
||||
for (let i = start; i < end; i++) {
|
||||
let node = existing.get(i);
|
||||
if (!node) node = _buildCardNode(i);
|
||||
if (node === ref) ref = ref.nextSibling;
|
||||
else grid.insertBefore(node, ref);
|
||||
}
|
||||
}
|
||||
|
||||
const idxOf = (g) => g._kids.map((n) => Number(n.getAttribute('data-idx')));
|
||||
const uidOf = (g) => { const m = new Map(); for (const n of g._kids) m.set(Number(n.getAttribute('data-idx')), n._uid); return m; };
|
||||
function assertContig(g, start, end) {
|
||||
const a = idxOf(g);
|
||||
assert.strictEqual(a.length, end - start, `len == ${end - start}`);
|
||||
for (let k = 0; k < a.length; k++) assert.strictEqual(a[k], start + k, `child ${k} == ${start + k}`);
|
||||
}
|
||||
|
||||
const COLS = 6, WIN = 12 * COLS; // 12 rows visible
|
||||
|
||||
test('window stays [start,end) contiguous scrolling down, one row at a time', () => {
|
||||
const grid = makeGrid();
|
||||
for (let row = 0; row < 40; row++) {
|
||||
const start = row * COLS;
|
||||
_syncWindow(grid, start, start + WIN);
|
||||
assertContig(grid, start, start + WIN);
|
||||
}
|
||||
});
|
||||
|
||||
test('in-window card nodes are reused across a slide (no whole-window teardown)', () => {
|
||||
const grid = makeGrid();
|
||||
_syncWindow(grid, 0, WIN);
|
||||
const before = uidOf(grid);
|
||||
_syncWindow(grid, COLS, COLS + WIN); // slide down one row
|
||||
const after = uidOf(grid);
|
||||
let reused = 0, built = 0;
|
||||
for (const [i, uid] of after) (before.get(i) === uid ? reused++ : built++);
|
||||
assert.strictEqual(built, COLS, `only the entering row is built (${COLS}), got ${built}`);
|
||||
assert.strictEqual(reused, WIN - COLS, 'every overlapping card node is reused');
|
||||
});
|
||||
|
||||
test('scrolling back UP reuses nodes too and keeps order', () => {
|
||||
const grid = makeGrid();
|
||||
for (let row = 0; row < 30; row++) _syncWindow(grid, row * COLS, row * COLS + WIN);
|
||||
let prev = uidOf(grid);
|
||||
for (let row = 29; row >= 0; row--) {
|
||||
const start = row * COLS;
|
||||
_syncWindow(grid, start, start + WIN);
|
||||
assertContig(grid, start, start + WIN);
|
||||
const now = uidOf(grid);
|
||||
for (const [i, uid] of prev) if (i >= start && i < start + WIN) assert.strictEqual(now.get(i), uid, `idx ${i} reused going up`);
|
||||
prev = now;
|
||||
}
|
||||
});
|
||||
|
||||
test('a select-mode toggle rebuilds the visible window', () => {
|
||||
const grid = makeGrid();
|
||||
const start = 6 * COLS;
|
||||
_syncWindow(grid, start, start + WIN);
|
||||
const before = uidOf(grid);
|
||||
state.selectMode = true;
|
||||
_syncWindow(grid, start, start + WIN);
|
||||
const after = uidOf(grid);
|
||||
let rebuilt = 0;
|
||||
for (const [i, uid] of before) if (after.get(i) !== uid) rebuilt++;
|
||||
assert.strictEqual(rebuilt, WIN, 'select-mode change rebuilds every visible card');
|
||||
assertContig(grid, start, start + WIN);
|
||||
state.selectMode = false;
|
||||
});
|
||||
|
||||
test('a large jump (rail seek) rebuilds cleanly with no stale survivors', () => {
|
||||
const grid = makeGrid();
|
||||
_syncWindow(grid, 0, WIN);
|
||||
_syncWindow(grid, 1000 * COLS, 1000 * COLS + WIN); // non-overlapping jump
|
||||
assertContig(grid, 1000 * COLS, 1000 * COLS + WIN);
|
||||
});
|
||||
@@ -16,8 +16,8 @@ const { createWindow, ROOT } = require('./capabilities_test_harness');
|
||||
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
|
||||
const WORKING_TUNING_JS = path.join(ROOT, 'static', 'capabilities', 'working-tuning.js');
|
||||
|
||||
// A /api/tunings-shaped fixture (frequencies at 440), enough to resolve names to offsets.
|
||||
const TUNINGS = {
|
||||
// Tuning frequency fixture at 440 Hz, enough to resolve names to offsets.
|
||||
const TUNING_TABLE = {
|
||||
'guitar-6': {
|
||||
Standard: [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
'Drop D': [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
@@ -26,6 +26,7 @@ const TUNINGS = {
|
||||
Standard: [30.87, 41.20, 55.00, 73.42, 98.00],
|
||||
},
|
||||
};
|
||||
const API_TUNINGS = { referencePitch: 440, tunings: TUNING_TABLE };
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
@@ -159,7 +160,7 @@ test('bare-instrument writes target the current selection, not a hard-coded defa
|
||||
test('seed resolves a NAMED tuning to offsets via /api/tunings', async () => {
|
||||
const { wt, changes } = loadWorkingTuning({
|
||||
'/api/settings': { instrument: 'guitar', string_count: 6, tuning: 'Drop D', reference_pitch: 440 },
|
||||
'/api/tunings': TUNINGS,
|
||||
'/api/tunings': API_TUNINGS,
|
||||
});
|
||||
await flush();
|
||||
const s = wt.get('guitar-6');
|
||||
@@ -183,7 +184,7 @@ test('boot race: an explicit set() before settings resolve is not clobbered by t
|
||||
const settings = deferred();
|
||||
const { wt } = loadWorkingTuning({
|
||||
'/api/settings': settings.promise, // held open
|
||||
'/api/tunings': TUNINGS,
|
||||
'/api/tunings': API_TUNINGS,
|
||||
});
|
||||
// A consumer writes before the seed lands.
|
||||
wt.set({ offsets: [-5, -5, -5, -5, -5, -5] }, { instrument: 'guitar-6' });
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Pure-function tests for AcoustID fingerprint response parsing + config
|
||||
gating. No network, no fpcalc binary — server.py owns those seams."""
|
||||
import acoustid_match as a
|
||||
|
||||
|
||||
def _resp(score=0.97, rec_id="rec-1", title="Highway to Hell", artist="AC/DC",
|
||||
rg_title="Highway to Hell", rg_type="Album", secondary=None,
|
||||
year=1979, duration=208.4):
|
||||
return {
|
||||
"status": "ok",
|
||||
"results": [{
|
||||
"id": "acoustid-uuid",
|
||||
"score": score,
|
||||
"recordings": [{
|
||||
"id": rec_id,
|
||||
"title": title,
|
||||
"duration": duration,
|
||||
"artists": [{"id": "a1", "name": artist}],
|
||||
"releasegroups": [{
|
||||
"id": "rg1", "title": rg_title, "type": rg_type,
|
||||
"secondarytypes": secondary or [],
|
||||
"releases": [{"date": {"year": year}}],
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_maps_the_studio_recording():
|
||||
out = a.parse_lookup_response(_resp())
|
||||
assert len(out) == 1
|
||||
c = out[0]
|
||||
assert c["recording_id"] == "rec-1"
|
||||
assert c["title"] == "Highway to Hell"
|
||||
assert c["artist"] == "AC/DC"
|
||||
assert c["album"] == "Highway to Hell"
|
||||
assert c["year"] == "1979"
|
||||
assert c["duration"] == 208
|
||||
assert c["studio"] is True
|
||||
assert c["source"] == "acoustid"
|
||||
assert c["mb_score"] == 97 # 0.97 → 0..100 confidence band
|
||||
assert c["score"] == 0.97
|
||||
|
||||
|
||||
def test_live_release_group_is_not_studio():
|
||||
out = a.parse_lookup_response(_resp(rg_type="Album", secondary=["Live"]))
|
||||
assert out[0]["studio"] is False
|
||||
|
||||
|
||||
def test_compilation_is_not_studio():
|
||||
out = a.parse_lookup_response(_resp(secondary=["Compilation"]))
|
||||
assert out[0]["studio"] is False
|
||||
|
||||
|
||||
def test_prefers_studio_group_for_album_display():
|
||||
resp = _resp()
|
||||
# Add a comp release-group first; the studio one must win the album pick.
|
||||
resp["results"][0]["recordings"][0]["releasegroups"].insert(0, {
|
||||
"id": "rg0", "title": "Greatest Hits", "type": "Album",
|
||||
"secondarytypes": ["Compilation"], "releases": [{"date": {"year": 2000}}],
|
||||
})
|
||||
c = a.parse_lookup_response(resp)[0]
|
||||
assert c["album"] == "Highway to Hell"
|
||||
assert c["studio"] is True
|
||||
|
||||
|
||||
def test_earliest_studio_album_wins_over_later_one():
|
||||
# Two studio "Album" groups (e.g. a later soundtrack typed Album). The
|
||||
# ORIGINAL — earliest release year — must win the album pick, not whichever
|
||||
# AcoustID happened to list first. (Real case: "Machine Head" over a later
|
||||
# comp for "Smoke on the Water".)
|
||||
resp = _resp(rg_title="Machine Head", year=1972)
|
||||
resp["results"][0]["recordings"][0]["releasegroups"].insert(0, {
|
||||
"id": "rg-late", "title": "Later Studio Album", "type": "Album",
|
||||
"secondarytypes": [], "releases": [{"date": {"year": 1997}}],
|
||||
})
|
||||
c = a.parse_lookup_response(resp)[0]
|
||||
assert c["album"] == "Machine Head"
|
||||
assert c["year"] == "1972"
|
||||
|
||||
|
||||
def test_year_is_earliest_release_not_a_reissue():
|
||||
# A group's first-listed release is often a reissue; the year must be the
|
||||
# EARLIEST across the group's releases (real case: British Steel's 1980
|
||||
# original, not a 2010 reissue listed first).
|
||||
resp = _resp(rg_title="British Steel", year=2010)
|
||||
resp["results"][0]["recordings"][0]["releasegroups"][0]["releases"].append(
|
||||
{"date": {"year": 1980}})
|
||||
c = a.parse_lookup_response(resp)[0]
|
||||
assert c["year"] == "1980"
|
||||
|
||||
|
||||
def test_dedupes_recording_across_results():
|
||||
resp = _resp()
|
||||
resp["results"].append(dict(resp["results"][0])) # same recording again
|
||||
assert len(a.parse_lookup_response(resp)) == 1
|
||||
|
||||
|
||||
def test_non_ok_status_and_garbage_return_empty():
|
||||
assert a.parse_lookup_response({"status": "error"}) == []
|
||||
assert a.parse_lookup_response({}) == []
|
||||
assert a.parse_lookup_response(None) == []
|
||||
assert a.parse_lookup_response({"status": "ok", "results": []}) == []
|
||||
|
||||
|
||||
def test_higher_acoustid_score_ranks_first():
|
||||
resp = _resp(score=0.55, rec_id="low")
|
||||
resp["results"].append(_resp(score=0.99, rec_id="high")["results"][0])
|
||||
out = a.parse_lookup_response(resp)
|
||||
assert out[0]["recording_id"] == "high"
|
||||
|
||||
|
||||
def test_config_gating(monkeypatch):
|
||||
monkeypatch.delenv("ACOUSTID_API_KEY", raising=False)
|
||||
assert a.api_key() == ""
|
||||
assert a.is_configured() is False
|
||||
assert a.is_configured("explicit-key") is True
|
||||
monkeypatch.setenv("ACOUSTID_API_KEY", "envkey")
|
||||
assert a.api_key() == "envkey"
|
||||
assert a.is_configured() is True
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Unit tests for ``server._resolve_dlc_path`` — the DLC-library containment
|
||||
guard.
|
||||
|
||||
It must (1) allow a library mounted through a directory JUNCTION/symlink (the
|
||||
shared-library-across-installs / desktop-app case that a ``.resolve()``-based
|
||||
check wrongly rejected, breaking album art + song load), while (2) still
|
||||
rejecting ``..`` traversal and absolute paths — the only escapes a ``:path``
|
||||
filename can express. ``safe_join`` stays strict on purpose (zip-slip guard),
|
||||
so the contrast is pinned here too.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch):
|
||||
(tmp_path / "cfg").mkdir()
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "cfg"))
|
||||
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
|
||||
sys.modules.pop("server", None)
|
||||
srv = importlib.import_module("server")
|
||||
try:
|
||||
yield srv
|
||||
finally:
|
||||
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
sys.modules.pop("server", None)
|
||||
|
||||
|
||||
def _dlc(tmp_path):
|
||||
d = tmp_path / "dlc"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
# ── still-rejected escapes (the security contract) ────────────────────────────
|
||||
|
||||
def test_dotdot_traversal_rejected(server, tmp_path):
|
||||
dlc = _dlc(tmp_path)
|
||||
assert server._resolve_dlc_path(dlc, "../../etc/passwd") is None
|
||||
# a Windows-style backslash traversal is normalised + rejected identically
|
||||
assert server._resolve_dlc_path(dlc, "..\\..\\secret") is None
|
||||
assert server._resolve_dlc_path(dlc, "a/../../b") is None
|
||||
|
||||
|
||||
def test_absolute_path_rejected(server, tmp_path):
|
||||
dlc = _dlc(tmp_path)
|
||||
assert server._resolve_dlc_path(dlc, "/etc/passwd") is None
|
||||
assert server._resolve_dlc_path(dlc, "C:/Windows/system32/x") is None
|
||||
|
||||
|
||||
def test_empty_and_nul_rejected(server, tmp_path):
|
||||
dlc = _dlc(tmp_path)
|
||||
assert server._resolve_dlc_path(dlc, "") is None
|
||||
assert server._resolve_dlc_path(dlc, "a\x00b") is None
|
||||
|
||||
|
||||
# ── allowed: legitimate in-library paths ──────────────────────────────────────
|
||||
|
||||
def test_safe_relative_allowed(server, tmp_path):
|
||||
dlc = _dlc(tmp_path)
|
||||
p = server._resolve_dlc_path(dlc, "CDLC/City Pop/song.feedpak")
|
||||
assert p is not None
|
||||
assert p.is_relative_to(dlc.resolve())
|
||||
|
||||
|
||||
def test_junction_subfolder_allowed(server, tmp_path):
|
||||
"""A library mounted through a directory junction/symlink must resolve —
|
||||
the case that broke album art for Christian's shared city-pop library."""
|
||||
dlc = _dlc(tmp_path)
|
||||
real = tmp_path / "real_library"
|
||||
real.mkdir()
|
||||
(real / "song.feedpak").write_bytes(b"pack")
|
||||
link = dlc / "CDLC"
|
||||
try:
|
||||
os.symlink(real, link, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlink/junction creation not permitted on this host")
|
||||
|
||||
p = server._resolve_dlc_path(dlc, "CDLC/song.feedpak")
|
||||
assert p is not None, "a junctioned library subfolder was wrongly rejected"
|
||||
assert p.exists(), "the resolved path should reach the file through the junction"
|
||||
# Contrast: safe_join stays strict (it .resolve()s and follows the junction
|
||||
# to its real target outside the root), which is correct for its zip-slip
|
||||
# callers but is exactly why _resolve_dlc_path can't reuse it here.
|
||||
assert server.safe_join(dlc, "CDLC/song.feedpak") is None
|
||||
@@ -150,3 +150,152 @@ def test_art_cache_dir_created(server):
|
||||
d = server._enrichment_art_dir()
|
||||
assert d.is_dir()
|
||||
assert d.name == "art_cache"
|
||||
|
||||
|
||||
# ── Refresh Metadata batch: per-tile states, progress, Stop ───────────────────
|
||||
|
||||
def test_states_for_returns_only_known_filenames(server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
got = server.meta_db.enrichment_states_for(["a.archive", "nope.archive"])
|
||||
assert got == {"a.archive": "unscanned"} # unknown filename absent
|
||||
assert server.meta_db.enrichment_states_for([]) == {}
|
||||
|
||||
|
||||
def test_states_endpoint(client, server):
|
||||
_put(server, "a.archive")
|
||||
_put(server, "b.archive", title="Other")
|
||||
server._background_enrich()
|
||||
body = client.post("/api/enrichment/states",
|
||||
json={"filenames": ["a.archive", "zzz.missing"]}).json()
|
||||
assert body["states"] == {"a.archive": "unscanned"}
|
||||
assert body["running"] is False
|
||||
assert body["current"] is None
|
||||
|
||||
|
||||
def test_status_exposes_progress_fields(client, server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
body = client.get("/api/enrichment/status").json()
|
||||
for k in ("total", "matched", "current", "cancelling"):
|
||||
assert k in body
|
||||
assert body["cancelling"] is False
|
||||
|
||||
|
||||
def test_cancel_is_noop_when_idle(client, server):
|
||||
body = client.post("/api/enrichment/cancel").json()
|
||||
assert body == {"ok": True, "was_running": False}
|
||||
# A no-op must not arm the flag (which would then poison the next pass).
|
||||
assert server._enrich_cancel.is_set() is False
|
||||
|
||||
|
||||
def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
|
||||
for i in range(4):
|
||||
_put(server, f"s{i}.archive", title=f"Song {i}")
|
||||
# Force the matcher path on (the test env is offline by default) and stub the
|
||||
# per-song matcher so nothing touches the network — it just trips Stop after
|
||||
# the first song, exactly as the /cancel route would mid-pass.
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
calls = []
|
||||
|
||||
def fake_enrich_one(row, **_kw):
|
||||
calls.append(row["filename"])
|
||||
server._enrich_cancel.set()
|
||||
|
||||
monkeypatch.setattr(server, "_enrich_one", fake_enrich_one)
|
||||
server._enrich_cancel.clear()
|
||||
server._background_enrich()
|
||||
# The loop checks cancel BEFORE each song, so exactly one is processed before
|
||||
# it breaks — not the whole 4-row queue.
|
||||
assert calls == ["s0.archive"]
|
||||
assert server._enrich_status["total"] == 4
|
||||
assert server._enrich_status["matched"] == 1
|
||||
|
||||
|
||||
def test_rematch_requeues_visible_but_skips_manual(server, client):
|
||||
_put(server, "a.archive") # will be 'matched'
|
||||
_put(server, "b.archive", title="Other") # will be 'failed'
|
||||
_put(server, "c.archive", title="Pinned") # will be 'manual' — untouchable
|
||||
server._background_enrich()
|
||||
with server.meta_db._lock:
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state='matched' WHERE filename='a.archive'")
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state='failed' WHERE filename='b.archive'")
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state='manual' WHERE filename='c.archive'")
|
||||
server.meta_db.conn.commit()
|
||||
body = client.post("/api/enrichment/rematch", json={
|
||||
"filenames": ["a.archive", "b.archive", "c.archive", "nope.archive"]}).json()
|
||||
# A per-view refresh re-runs everything shown EXCEPT the manual pin (and an
|
||||
# unknown filename); matched + failed are both re-queued.
|
||||
assert set(body["queued"]) == {"a.archive", "b.archive"}
|
||||
assert body["count"] == 2
|
||||
server._join_background_db_threads()
|
||||
assert server.meta_db.get_enrichment("a.archive")["match_state"] == "unscanned"
|
||||
assert server.meta_db.get_enrichment("b.archive")["match_state"] == "unscanned"
|
||||
assert server.meta_db.get_enrichment("c.archive")["match_state"] == "manual"
|
||||
|
||||
|
||||
# ── filename-derived artist/title fallback (blank-artist packs) ───────────────
|
||||
|
||||
def test_filename_artist_title_parse(server):
|
||||
f = server._artist_title_from_filename
|
||||
assert f("CDLC/0 - City Pop/Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak") == \
|
||||
{"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
|
||||
assert f("Anri_Windy-Summer_v1_p.feedpak") == {"artist": "Anri", "title": "Windy Summer"}
|
||||
# a trailing "(440Hz)" retune tag is stripped before parsing
|
||||
assert f("Cindy_Watashitachi-o-Shinjite-Ite_v1_p (440Hz).feedpak") == \
|
||||
{"artist": "Cindy", "title": "Watashitachi o Shinjite Ite"}
|
||||
# doesn't fit the convention → no guess
|
||||
assert f("nounderscore.feedpak") is None
|
||||
|
||||
|
||||
def test_blank_artist_seeds_match_from_filename(server, monkeypatch):
|
||||
server.meta_db.put("Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak", 0, 0, {
|
||||
"title": "Tatsuro-Yamashita_Ride-On-Time_v1_p", "artist": "", "album": "",
|
||||
"duration": 240, "arrangements": [{"name": "Bass", "index": 0}]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
|
||||
seen = {}
|
||||
|
||||
def fake_search(artist, title, limit=8):
|
||||
seen["artist"], seen["title"] = artist, title
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
|
||||
row = next(r for r in server.meta_db.enrichment_pending()
|
||||
if r["filename"].startswith("Tatsuro"))
|
||||
server._enrich_one(row)
|
||||
# the blank pack artist was replaced by the filename-derived identity for
|
||||
# the search (this is exactly what rescues the 'failed' pile)
|
||||
assert seen == {"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
|
||||
|
||||
|
||||
def test_present_artist_is_not_overridden_by_filename(server, monkeypatch):
|
||||
server.meta_db.put("Weird-Filename_x_y.feedpak", 0, 0, {
|
||||
"title": "Real Title", "artist": "Real Artist", "album": "", "duration": 100,
|
||||
"arrangements": [{"name": "Lead", "index": 0}]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
|
||||
seen = {}
|
||||
|
||||
def fake_search(artist, title, limit=8):
|
||||
seen["artist"], seen["title"] = artist, title
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
|
||||
row = next(r for r in server.meta_db.enrichment_pending()
|
||||
if r["filename"].startswith("Weird"))
|
||||
server._enrich_one(row)
|
||||
# a pack that DOES carry an artist keeps it — the filename is never consulted
|
||||
assert seen == {"artist": "Real Artist", "title": "Real Title"}
|
||||
|
||||
|
||||
def test_kick_clears_a_stale_cancel(server):
|
||||
# A cancelled-then-rekicked pass must start clean: _kick_enrich clears the
|
||||
# flag so the fresh pass isn't aborted the instant it checks.
|
||||
server._enrich_cancel.set()
|
||||
server._kick_enrich()
|
||||
server._join_background_db_threads()
|
||||
assert server._enrich_cancel.is_set() is False
|
||||
|
||||
@@ -122,6 +122,79 @@ def test_parse_bcfs_rejects_bad_magic():
|
||||
_parse_bcfs(b"NOPE" + b"\x00" * 16)
|
||||
|
||||
|
||||
# ── _parse_bcfs container round-trip (GP6 .gpx partial final-sector) ─────────
|
||||
|
||||
def _build_bcfs(entries, short_by=0):
|
||||
"""Assemble a minimal in-memory BCFS container for _parse_bcfs.
|
||||
|
||||
``entries`` is ``[(name: bytes, payload: bytes, data_sector: int), ...]``.
|
||||
The directory entry for entry *i* is written to sector ``i + 1``; each
|
||||
entry's payload goes in the sector index it names. ``short_by`` truncates
|
||||
the final buffer by N bytes to emulate a real .gpx's partial trailing
|
||||
sector (the BCFZ-declared decompressed size isn't 0x1000-aligned). Layout
|
||||
mirrors the reader: a 4-byte ``BCFS`` header, then 0x1000-byte sectors,
|
||||
with every value read at ``HDR + sector * 0x1000``.
|
||||
"""
|
||||
SECTOR = 0x1000
|
||||
HDR = 4
|
||||
max_sector = max([e[2] for e in entries] + [len(entries)])
|
||||
buf = bytearray(b"BCFS" + b"\x00" * ((max_sector + 1) * SECTOR))
|
||||
|
||||
def put_u32(off, val):
|
||||
struct.pack_into("<I", buf, HDR + off, val)
|
||||
|
||||
for i, (name, payload, data_sector) in enumerate(entries):
|
||||
dir_off = (i + 1) * SECTOR # directory entry -> sector i+1
|
||||
put_u32(dir_off + 0x00, 2) # entry type: file
|
||||
nm = name[:127]
|
||||
buf[HDR + dir_off + 0x04: HDR + dir_off + 0x04 + len(nm)] = nm
|
||||
put_u32(dir_off + 0x8C, len(payload)) # declared file size
|
||||
put_u32(dir_off + 0x94, data_sector) # first data-sector pointer
|
||||
put_u32(dir_off + 0x94 + 4, 0) # chain terminator
|
||||
dpos = HDR + data_sector * SECTOR
|
||||
buf[dpos: dpos + len(payload)] = payload
|
||||
if short_by:
|
||||
del buf[len(buf) - short_by:]
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def test_parse_bcfs_reads_short_final_sector():
|
||||
"""The regression: a real .gpx ends a byte short of a full 0x1000 sector,
|
||||
so its last (small) container file lands in a partial trailing sector. The
|
||||
reader must clamp that read, not reject the whole container — rejecting it
|
||||
is what made every GP6 .gpx fail to import with 'sector pointer out of
|
||||
range'."""
|
||||
bcfs = _build_bcfs([(b"score.gpif", b"hello", 2)], short_by=1)
|
||||
assert (len(bcfs) - 4) % 0x1000 == 0x1000 - 1 # final sector is 1 short
|
||||
assert _parse_bcfs(bcfs)["score.gpif"] == b"hello"
|
||||
|
||||
|
||||
def test_parse_bcfs_full_sector_round_trip():
|
||||
"""A sector-aligned container round-trips unchanged (baseline)."""
|
||||
assert _parse_bcfs(_build_bcfs([(b"misc.xml", b"<x/>", 2)]))["misc.xml"] == b"<x/>"
|
||||
|
||||
|
||||
def test_parse_bcfs_multi_file_short_final_sector():
|
||||
"""Real-world shape: score.gpif plus small config files, the last one in
|
||||
the partial trailing sector."""
|
||||
out = _parse_bcfs(_build_bcfs([
|
||||
(b"score.gpif", b"<GPIF/>", 3),
|
||||
(b"LayoutConfiguration", b"AB", 4),
|
||||
], short_by=1))
|
||||
assert out["score.gpif"] == b"<GPIF/>"
|
||||
assert out["LayoutConfiguration"] == b"AB"
|
||||
|
||||
|
||||
def test_parse_bcfs_rejects_sector_starting_past_end():
|
||||
"""A sector pointer whose *start* is beyond the container is genuinely
|
||||
malformed and must still raise — the clamp tolerates a partial final
|
||||
sector, not arbitrary out-of-range pointers."""
|
||||
bcfs = bytearray(_build_bcfs([(b"x", b"y", 2)]))
|
||||
struct.pack_into("<I", bcfs, 4 + 0x1000 + 0x94, 9999) # absurd data-sector ptr
|
||||
with pytest.raises(ValueError, match="out of range"):
|
||||
_parse_bcfs(bytes(bcfs))
|
||||
|
||||
|
||||
# ── _note_is_tie ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_note_is_tie_destination():
|
||||
|
||||
@@ -97,6 +97,43 @@ def mb_doc(rid="rec-1", title="Thunderstruck", artist="AC/DC", artist_id="art-1"
|
||||
}
|
||||
|
||||
|
||||
# ── strict-then-loose search fallback ────────────────────────────────────────
|
||||
|
||||
def test_search_falls_back_to_loose_when_strict_is_empty(server, monkeypatch):
|
||||
"""The strict field-phrase query misses a non-Latin-primary artist; the
|
||||
loose retry (no field scoping) searches aliases and finds it."""
|
||||
calls = []
|
||||
|
||||
def _routed(path, params):
|
||||
q = params.get("query", "")
|
||||
calls.append(q)
|
||||
if q.startswith("recording:"): # strict phrase → nothing
|
||||
return {"recordings": []}
|
||||
return {"recordings": [mb_doc(rid="rec-x", title="Telephone Number")]}
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", _routed)
|
||||
cands = server._mb_search_recordings("Junko Ohashi", "Telephone Number")
|
||||
assert len(cands) == 1
|
||||
assert len(calls) == 2 # strict first, then the loose retry
|
||||
assert calls[0].startswith("recording:") # strict is the field-phrase form
|
||||
assert "artist:" not in calls[1] and '"' not in calls[1] # loose retry
|
||||
|
||||
|
||||
def test_search_does_not_retry_when_strict_hits(server, monkeypatch):
|
||||
"""A strict hit must not spend a second (throttled) request on the loose
|
||||
query."""
|
||||
calls = []
|
||||
|
||||
def _routed(path, params):
|
||||
calls.append(params.get("query", ""))
|
||||
return {"recordings": [mb_doc()]}
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", _routed)
|
||||
cands = server._mb_search_recordings("AC/DC", "Thunderstruck")
|
||||
assert len(cands) == 1
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
# ── offline safety (the pytest-never-hits-network contract) ──────────────────
|
||||
|
||||
def test_offline_default_skips_matching(server, monkeypatch):
|
||||
|
||||
+80
-1
@@ -145,11 +145,40 @@ def test_rank_candidates_orders_by_our_score():
|
||||
assert all("score" in c for c in ranked)
|
||||
|
||||
|
||||
def test_rank_candidates_studio_preference_is_dropped_for_live_charts():
|
||||
"""Tied-score candidates: a studio chart prefers the studio take, but a
|
||||
LIVE chart must NOT be forced to the studio recording."""
|
||||
studio = {"recording_id": "studio", "artist": "AC/DC", "title": "Highway to Hell",
|
||||
"studio": True, "mb_score": 90}
|
||||
live = {"recording_id": "live", "artist": "AC/DC", "title": "Highway to Hell",
|
||||
"studio": False, "mb_score": 95}
|
||||
# Studio chart -> studio take wins the tie (studio flag), despite lower mb_score.
|
||||
studio_song = {"artist": "AC/DC", "title": "Highway to Hell"}
|
||||
assert m.rank_candidates(studio_song, [live, studio])[0]["recording_id"] == "studio"
|
||||
# Live chart -> studio preference dropped, so the higher-mb_score live take wins.
|
||||
live_song = {"artist": "AC/DC", "title": "Highway to Hell (Live at Donington)"}
|
||||
assert m.rank_candidates(live_song, [studio, live])[0]["recording_id"] == "live"
|
||||
|
||||
|
||||
# ── query building ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_build_recording_query_denoises_and_quotes():
|
||||
q = m.build_recording_query("ACDC", 'Thunderstruck (v2)')
|
||||
assert q == 'recording:"thunderstruck" AND artist:"acdc"'
|
||||
# Live-only recordings are excluded — the studio take is never tagged Live,
|
||||
# and it's the biggest source of junk in a flat recording search.
|
||||
assert q == 'recording:"thunderstruck" AND artist:"acdc" AND -secondarytype:Live'
|
||||
|
||||
|
||||
def test_build_recording_query_keeps_live_for_live_charts():
|
||||
"""A chart that IS a live take must NOT get the live filter, or its only
|
||||
correct recording is excluded. A bare title word ("Live and Let Die") is a
|
||||
real word, not a marker, so it still filters."""
|
||||
live = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)")
|
||||
assert "-secondarytype:Live" not in live
|
||||
assert 'recording:"highway to hell"' in live
|
||||
# A real word "live" in the title is not a live marker → still filtered.
|
||||
bare = m.build_recording_query("Wings", "Live and Let Die")
|
||||
assert "-secondarytype:Live" in bare
|
||||
|
||||
|
||||
def test_build_recording_query_escapes_and_handles_missing_artist():
|
||||
@@ -160,6 +189,33 @@ def test_build_recording_query_escapes_and_handles_missing_artist():
|
||||
assert "artist:" not in q
|
||||
|
||||
|
||||
def test_build_recording_query_loose_drops_field_phrases():
|
||||
# The strict form locks to the *primary* artist/title phrase (and drops
|
||||
# live-only recordings — the chart isn't a live take).
|
||||
assert m.build_recording_query("Junko Ohashi", "Telephone Number") == \
|
||||
'recording:"telephone number" AND artist:"junko ohashi" AND -secondarytype:Live'
|
||||
# The loose form has no field scoping and no phrases, so MusicBrainz also
|
||||
# searches artist ALIASES — rescues non-Latin-primary artists (大橋純子) —
|
||||
# but keeps the same live exclusion (a studio chart must not fall back to a
|
||||
# live-only recording).
|
||||
loose = m.build_recording_query("Junko Ohashi", "Telephone Number", loose=True)
|
||||
assert loose == "(telephone number) AND (junko ohashi) AND -secondarytype:Live"
|
||||
assert "artist:" not in loose and '"' not in loose
|
||||
|
||||
|
||||
def test_build_recording_query_loose_missing_artist():
|
||||
assert m.build_recording_query("", "Fantasy", loose=True) == \
|
||||
"(fantasy) AND -secondarytype:Live"
|
||||
|
||||
|
||||
def test_build_recording_query_loose_keeps_live_for_live_charts():
|
||||
# A live chart's loose fallback must NOT exclude live recordings (same gate
|
||||
# as the strict path) — else its only correct recording is filtered out.
|
||||
loose = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)", loose=True)
|
||||
assert "-secondarytype:Live" not in loose
|
||||
assert loose == "(highway to hell) AND (ac dc)"
|
||||
|
||||
|
||||
# ── MusicBrainz response parsing ──────────────────────────────────────────────
|
||||
|
||||
MB_DOC = {
|
||||
@@ -200,6 +256,29 @@ def test_parse_recording_doc_normalizes():
|
||||
assert c["mb_score"] == 98
|
||||
|
||||
|
||||
def test_best_release_prefers_official_single_over_unofficial_album():
|
||||
"""An OFFICIAL single/EP must outrank an UNofficial bootleg album for the
|
||||
canonical album/year: official comes before the studio-album preference, so
|
||||
a single-only song is never seeded from a bootleg. (`(clean, status_ok, …)`
|
||||
would wrongly pick the bootleg.)"""
|
||||
doc = {
|
||||
"id": "rec-x", "title": "One-Off", "score": 90,
|
||||
"artist-credit": [
|
||||
{"name": "A", "joinphrase": "",
|
||||
"artist": {"id": "a", "name": "A", "sort-name": "A"}}],
|
||||
"releases": [
|
||||
{"id": "rel-boot", "title": "Boot LP", "status": "Bootleg",
|
||||
"date": "1990-01-01", "release-group": {"primary-type": "Album"}},
|
||||
{"id": "rel-single", "title": "The Single", "status": "Official",
|
||||
"date": "1988-01-01", "release-group": {"primary-type": "Single"}},
|
||||
],
|
||||
}
|
||||
c = m.parse_recording_doc(doc)
|
||||
assert c["release_id"] == "rel-single"
|
||||
assert c["album"] == "The Single"
|
||||
assert c["studio"] is False # a Single isn't a clean studio ALBUM
|
||||
|
||||
|
||||
def test_parse_recording_doc_joined_artist_credit():
|
||||
doc = dict(MB_DOC)
|
||||
doc["artist-credit"] = [
|
||||
|
||||
+115
-2
@@ -753,29 +753,142 @@ def test_defaults_include_gameplay_keys(client, tmp_path):
|
||||
assert data["fail_behavior"] == "continue"
|
||||
|
||||
|
||||
|
||||
def test_get_settings_exposes_default_instrument_profiles(client, tmp_path):
|
||||
data = client.get("/api/settings").json()
|
||||
assert data["active_instrument_profile"] == "guitar-lead"
|
||||
assert set(data["instrument_profiles"]) == {"guitar-lead", "guitar-rhythm", "bass"}
|
||||
assert data["instrument"] == "guitar"
|
||||
assert data["string_count"] == 6
|
||||
assert data["tuning"] == "Standard"
|
||||
assert data["pathway"] == "songs"
|
||||
|
||||
|
||||
def test_post_flat_instrument_updates_active_profile(client, tmp_path):
|
||||
r = client.post("/api/settings", json={"instrument": "bass", "pathway": "practice"})
|
||||
assert r.status_code == 200
|
||||
cfg = _read_cfg(tmp_path)
|
||||
assert cfg["active_instrument_profile"] == "bass"
|
||||
assert cfg["instrument"] == "bass"
|
||||
assert cfg["string_count"] == 4
|
||||
assert cfg["tuning"] == "Standard"
|
||||
assert cfg["pathway"] == "practice"
|
||||
assert cfg["instrument_profiles"]["bass"]["string_count"] == 4
|
||||
assert cfg["instrument_profiles"]["bass"]["pathway"] == "practice"
|
||||
|
||||
|
||||
def test_post_instrument_profiles_mirrors_active_profile(client, tmp_path):
|
||||
r = client.post("/api/settings", json={
|
||||
"active_instrument_profile": "guitar-rhythm",
|
||||
"instrument_profiles": {
|
||||
"guitar-rhythm": {
|
||||
"string_count": 7,
|
||||
"tuning": "Drop A",
|
||||
"reference_pitch": 432,
|
||||
"pathway": "studio",
|
||||
},
|
||||
"bass": {
|
||||
"string_count": 6,
|
||||
"tuning": "C Standard",
|
||||
},
|
||||
},
|
||||
})
|
||||
assert r.status_code == 200
|
||||
cfg = _read_cfg(tmp_path)
|
||||
assert cfg["active_instrument_profile"] == "guitar-rhythm"
|
||||
assert cfg["instrument"] == "guitar"
|
||||
assert cfg["string_count"] == 7
|
||||
assert cfg["tuning"] == "Drop A"
|
||||
assert cfg["reference_pitch"] == 432
|
||||
assert cfg["pathway"] == "studio"
|
||||
|
||||
|
||||
def test_post_pathway_rejects_bad_value(client, tmp_path):
|
||||
(tmp_path / "config.json").write_text(json.dumps({"pathway": "songs"}))
|
||||
r = client.post("/api/settings", json={"pathway": "invalid"})
|
||||
assert "error" in r.json()
|
||||
assert _read_cfg(tmp_path)["pathway"] == "songs"
|
||||
|
||||
|
||||
def test_post_instrument_profiles_rejects_bad_custom_string_count(client, tmp_path):
|
||||
r = client.post("/api/settings", json={
|
||||
"instrument_profiles": {
|
||||
"bass": {"string_count": 6, "tuning": [0, 0, 0, 0]},
|
||||
},
|
||||
})
|
||||
assert "error" in r.json()
|
||||
|
||||
# ── /api/settings/reset ─────────────────────────────────────────────────────
|
||||
|
||||
def test_reset_clears_requested_keys(client, tmp_path):
|
||||
(tmp_path / "config.json").write_text(json.dumps({
|
||||
"master_difficulty": 40,
|
||||
"countdown_before_song": True,
|
||||
"pathway": "studio",
|
||||
"default_arrangement": "Lead",
|
||||
"demucs_server_url": "http://demucs.example:9000",
|
||||
}))
|
||||
r = client.post("/api/settings/reset",
|
||||
json={"keys": ["master_difficulty", "countdown_before_song"]})
|
||||
json={"keys": ["master_difficulty", "countdown_before_song", "pathway"]})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body["reset"]) == {"master_difficulty", "countdown_before_song"}
|
||||
assert set(body["reset"]) == {"master_difficulty", "countdown_before_song", "pathway"}
|
||||
cfg = _read_cfg(tmp_path)
|
||||
# Reset removes the key so GET falls back to the default.
|
||||
assert "master_difficulty" not in cfg
|
||||
assert "countdown_before_song" not in cfg
|
||||
assert "pathway" not in cfg
|
||||
# Unlisted keys are untouched.
|
||||
assert cfg["default_arrangement"] == "Lead"
|
||||
assert cfg["demucs_server_url"] == "http://demucs.example:9000"
|
||||
|
||||
|
||||
def test_partial_instrument_profiles_update_preserves_others(client, tmp_path):
|
||||
# /api/settings is a partial-merge endpoint, so a POST that carries only ONE
|
||||
# instrument profile must not reset the others to defaults.
|
||||
gl = client.get("/api/settings").json()["instrument_profiles"]["guitar-lead"]
|
||||
gl = dict(gl); gl["tuning"] = "Drop D"
|
||||
client.post("/api/settings", json={"instrument_profiles": {"guitar-lead": gl}})
|
||||
assert (client.get("/api/settings").json()["instrument_profiles"]
|
||||
["guitar-lead"]["tuning"] == "Drop D")
|
||||
# Now update ONLY bass (Drop D is valid for a 4-string bass).
|
||||
bass = client.get("/api/settings").json()["instrument_profiles"]["bass"]
|
||||
bass = dict(bass); bass["tuning"] = "Drop D"
|
||||
client.post("/api/settings", json={"instrument_profiles": {"bass": bass}})
|
||||
out = client.get("/api/settings").json()["instrument_profiles"]
|
||||
assert out["guitar-lead"]["tuning"] == "Drop D", "the untouched profile survived"
|
||||
assert out["bass"]["tuning"] == "Drop D"
|
||||
|
||||
|
||||
def test_active_profile_switch_on_fresh_config(client, tmp_path):
|
||||
# A fresh config has no instrument_profiles; an explicit active-profile
|
||||
# switch must be honored, not overwritten by the profile inferred from the
|
||||
# legacy flat defaults (guitar-lead).
|
||||
r = client.post("/api/settings", json={"active_instrument_profile": "bass"})
|
||||
assert r.status_code == 200 and "error" not in r.json()
|
||||
got = client.get("/api/settings").json()
|
||||
assert got["active_instrument_profile"] == "bass"
|
||||
assert got["instrument"] == "bass"
|
||||
|
||||
|
||||
def test_reset_pathway_reaches_into_instrument_profiles(client, tmp_path):
|
||||
# pathway is mirrored into every instrument profile, so a Gameplay reset
|
||||
# that only deleted the flat key would leave GET re-deriving the old value
|
||||
# from the profile. The reset must reach into the persisted profiles too.
|
||||
client.post("/api/settings", json={"pathway": "studio"})
|
||||
assert client.get("/api/settings").json()["pathway"] == "studio"
|
||||
profiles = _read_cfg(tmp_path)["instrument_profiles"]
|
||||
assert any(p["pathway"] == "studio" for p in profiles.values())
|
||||
|
||||
r = client.post("/api/settings/reset", json={"keys": ["pathway"]})
|
||||
assert r.status_code == 200
|
||||
assert "pathway" in r.json()["reset"]
|
||||
# GET re-derives from the profile — which must now be back to the default.
|
||||
assert client.get("/api/settings").json()["pathway"] == "songs"
|
||||
for prof in _read_cfg(tmp_path)["instrument_profiles"].values():
|
||||
assert prof["pathway"] == "songs"
|
||||
|
||||
|
||||
def test_reset_ignores_unknown_keys(client, tmp_path):
|
||||
(tmp_path / "config.json").write_text(json.dumps({"master_difficulty": 40}))
|
||||
# Unknown / non-resettable keys are silently ignored, not an error, and
|
||||
|
||||
@@ -31,12 +31,14 @@ def _cfg(tmp_path):
|
||||
def test_instrument_fields_persist(env):
|
||||
srv, tmp = env
|
||||
c = TestClient(srv.app)
|
||||
# "Drop A" is the 5-string bass drop tuning (its low string is B, not E, so
|
||||
# "Drop D" is a 4-string tuning — now correctly rejected per-profile).
|
||||
r = c.post("/api/settings", json={"instrument": "bass", "string_count": 5,
|
||||
"tuning": "Drop D", "reference_pitch": 442})
|
||||
"tuning": "Drop A", "reference_pitch": 442})
|
||||
assert r.status_code == 200
|
||||
cfg = _cfg(tmp)
|
||||
assert cfg["instrument"] == "bass" and cfg["string_count"] == 5
|
||||
assert cfg["tuning"] == "Drop D" and cfg["reference_pitch"] == 442.0
|
||||
assert cfg["tuning"] == "Drop A" and cfg["reference_pitch"] == 442.0
|
||||
# Reflected back through GET.
|
||||
got = c.get("/api/settings").json()
|
||||
assert got["instrument"] == "bass" and got["reference_pitch"] == 442.0
|
||||
|
||||
+118
-1
@@ -2,7 +2,31 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from tunings import tuning_name
|
||||
from tunings import (
|
||||
DEFAULT_TUNINGS,
|
||||
TUNING_PRESET_MIDIS,
|
||||
_valid_tuning_for_key,
|
||||
apply_flat_instrument_patch_to_profiles,
|
||||
open_midis_to_freqs,
|
||||
settings_with_instrument_profiles,
|
||||
tuning_midis_from_offsets,
|
||||
tuning_name,
|
||||
tuning_offsets_from_midis,
|
||||
tuning_preset_offsets,
|
||||
)
|
||||
|
||||
|
||||
def test_valid_tuning_for_key_builtin_and_provider_names():
|
||||
# A built-in valid for the key is accepted; a built-in valid only for a
|
||||
# DIFFERENT key (misapplied, e.g. "Drop D" on a 5-string bass) is rejected.
|
||||
assert _valid_tuning_for_key("bass-5", "Drop A") == "Drop A"
|
||||
assert _valid_tuning_for_key("bass-5", "Drop D") is None
|
||||
assert _valid_tuning_for_key("guitar-6", "Standard") == "Standard"
|
||||
# A name unknown to every built-in table is a provider/custom tuning (tuner
|
||||
# plugin, /api/tunings) the pure layer can't resolve — accept it so settings
|
||||
# round-trip rather than normalizing it away to Standard.
|
||||
assert _valid_tuning_for_key("bass-5", "My Custom DADGAD") == "My Custom DADGAD"
|
||||
assert _valid_tuning_for_key("guitar-6", "x" * 65) is None # length cap kept
|
||||
|
||||
|
||||
# ── Standard tunings (all six strings share the same offset) ─────────────────
|
||||
@@ -132,3 +156,96 @@ def test_drop_pattern_takes_precedence_over_named_dict():
|
||||
# auto-generator fires first and produces the same string. The named dict entry
|
||||
# is effectively dead code for this case — this test documents the behavior.
|
||||
assert tuning_name([-2, 0, 0, 0, 0, 0]) == "Drop D"
|
||||
|
||||
|
||||
# ── Host tuning profile catalogue -------------------------------------------
|
||||
|
||||
def test_default_tunings_include_extended_host_profiles():
|
||||
assert "bass-6" in DEFAULT_TUNINGS
|
||||
assert "C Standard" in DEFAULT_TUNINGS["guitar-6"]
|
||||
assert "C# Standard" in DEFAULT_TUNINGS["guitar-6"]
|
||||
assert "Drop Ab" in DEFAULT_TUNINGS["guitar-6"]
|
||||
assert "BEAD" in DEFAULT_TUNINGS["bass-4"]
|
||||
assert "High C" in DEFAULT_TUNINGS["bass-5"]
|
||||
assert "Drop A + Drop E" in DEFAULT_TUNINGS["guitar-8"]
|
||||
|
||||
|
||||
def test_default_tuning_frequencies_are_derived_from_midis():
|
||||
assert DEFAULT_TUNINGS["guitar-6"]["Standard"] == open_midis_to_freqs([40, 45, 50, 55, 59, 64])
|
||||
assert DEFAULT_TUNINGS["bass-6"]["Standard"] == open_midis_to_freqs([23, 28, 33, 38, 43, 48])
|
||||
|
||||
|
||||
def test_tuning_offsets_from_named_presets():
|
||||
assert tuning_preset_offsets("guitar-6", "Drop D") == [-2, 0, 0, 0, 0, 0]
|
||||
assert tuning_preset_offsets("guitar-6", "C Standard") == [-4, -4, -4, -4, -4, -4]
|
||||
assert tuning_preset_offsets("bass-4", "BEAD") == [-5, -5, -5, -5]
|
||||
assert tuning_preset_offsets("bass-5", "High C") == [5, 5, 5, 5, 5]
|
||||
|
||||
|
||||
def test_tuning_midis_round_trip_offsets():
|
||||
offsets = [-2, 0, 0, 0, 0, 0]
|
||||
midis = tuning_midis_from_offsets("guitar-6", offsets)
|
||||
assert midis == TUNING_PRESET_MIDIS["guitar-6"]["Drop D"]
|
||||
assert tuning_offsets_from_midis("guitar-6", midis) == offsets
|
||||
|
||||
|
||||
def test_tuning_conversion_rejects_wrong_string_count():
|
||||
assert tuning_offsets_from_midis("guitar-6", [40, 45, 50, 55]) is None
|
||||
assert tuning_midis_from_offsets("bass-4", [0, 0, 0, 0, 0]) is None
|
||||
|
||||
def test_settings_profiles_default_to_lead_rhythm_and_bass():
|
||||
settings = settings_with_instrument_profiles({})
|
||||
assert settings["active_instrument_profile"] == "guitar-lead"
|
||||
assert set(settings["instrument_profiles"]) == {"guitar-lead", "guitar-rhythm", "bass"}
|
||||
assert settings["instrument"] == "guitar"
|
||||
assert settings["string_count"] == 6
|
||||
assert settings["tuning"] == "Standard"
|
||||
assert settings["pathway"] == "songs"
|
||||
assert settings["instrument_profiles"]["guitar-lead"]["pathway"] == "songs"
|
||||
|
||||
|
||||
def test_settings_profiles_migrate_legacy_flat_bass_selection():
|
||||
settings = settings_with_instrument_profiles({
|
||||
"instrument": "bass",
|
||||
"string_count": 6,
|
||||
"tuning": "C Standard",
|
||||
"reference_pitch": 432,
|
||||
"pathway": "practice",
|
||||
})
|
||||
assert settings["active_instrument_profile"] == "bass"
|
||||
assert settings["instrument_profiles"]["bass"]["string_count"] == 6
|
||||
assert settings["instrument_profiles"]["bass"]["tuning"] == "C Standard"
|
||||
assert settings["reference_pitch"] == 432
|
||||
assert settings["pathway"] == "practice"
|
||||
assert settings["instrument_profiles"]["bass"]["pathway"] == "practice"
|
||||
|
||||
|
||||
def test_flat_patch_updates_active_profile_and_mirrors_legacy_keys():
|
||||
settings = settings_with_instrument_profiles({})
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"tuning": "Drop D"})
|
||||
assert patched["tuning"] == "Drop D"
|
||||
assert patched["instrument_profiles"]["guitar-lead"]["tuning"] == "Drop D"
|
||||
|
||||
|
||||
def test_flat_pathway_patch_updates_active_profile_and_mirrors_legacy_key():
|
||||
settings = settings_with_instrument_profiles({})
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"pathway": "studio"})
|
||||
assert patched["pathway"] == "studio"
|
||||
assert patched["instrument_profiles"]["guitar-lead"]["pathway"] == "studio"
|
||||
|
||||
|
||||
def test_flat_instrument_patch_defaults_to_target_string_count():
|
||||
settings = settings_with_instrument_profiles({"instrument": "guitar", "string_count": 6, "tuning": "Drop D"})
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"instrument": "bass"})
|
||||
assert patched["instrument"] == "bass"
|
||||
assert patched["string_count"] == 4
|
||||
assert patched["tuning"] == "Standard"
|
||||
assert patched["active_instrument_profile"] == "bass"
|
||||
assert patched["instrument_profiles"]["bass"]["string_count"] == 4
|
||||
|
||||
|
||||
def test_flat_string_count_patch_resets_incompatible_named_tuning():
|
||||
settings = settings_with_instrument_profiles({"instrument": "guitar", "string_count": 6, "tuning": "DADGAD"})
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7})
|
||||
assert patched["string_count"] == 7
|
||||
assert patched["tuning"] == "Standard"
|
||||
|
||||
Reference in New Issue
Block a user