mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 05:44:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62df3aec2e | ||
|
|
612b1f2e0d | ||
|
|
4f6dc233f1 | ||
|
|
5be70939e4 | ||
|
|
6aaa2dcf47 | ||
|
|
1a8540935b | ||
|
|
d567fd5597 | ||
|
|
b914612f9d | ||
|
|
de002cdc24 | ||
|
|
3100d68a45 | ||
|
|
6397a959a4 | ||
|
|
5f499a8a3a | ||
|
|
af1170cec3 | ||
|
|
326001d618 |
@@ -6,7 +6,7 @@ name: Nightly
|
||||
# builds from release/** come from rc.yml instead.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
- cron: '0 23 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -7,7 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x–2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
|
||||
|
||||
### Added
|
||||
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
|
||||
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing Okabe–Ito "Colorblind-friendly" preset — contributed by a deuteranopic player who found the Okabe–Ito set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
|
||||
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117–123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
|
||||
- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
|
||||
|
||||
@@ -15,6 +20,7 @@ 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
|
||||
- **3D Highway: recover from a WebGL context loss instead of crashing on alt-tab.** Switching the active window / alt-tabbing away from the app (most often on Windows) can trigger a GPU context reset; the 3D highway's WebGL renderer had **no `webglcontextlost` handler**, so a lost context was left to escalate into a render-process crash — matching the intermittent "randomly crashes when I change windows" desktop reports. The renderer now binds `webglcontextlost`/`webglcontextrestored` on its own WebGL canvas (`ren.domElement`): the loss is `preventDefault()`'d so the browser keeps the context restorable, `draw()` bails while the context is down so no GL work runs on a dead context, and on restore the viewport is re-applied and rendering resumes (Three re-uploads scene resources on the next frame). Listeners are torn down with the renderer. `plugins/highway_3d` → 3.31.3. Tests: `tests/js/highway_3d_context_loss.test.js`. (The sibling `keys_highway_3d` / `drum_highway_3d` renderers share the same gap — tracked as a follow-up in their repos.)
|
||||
- **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`).
|
||||
|
||||
+24
-1
@@ -538,9 +538,32 @@ def _dtw_align(
|
||||
Returns wp where wp[i] = [score_frame_index, audio_frame_index].
|
||||
"""
|
||||
import librosa
|
||||
import numpy as np
|
||||
cs = _safe_normalise(chroma_score)
|
||||
ca = _safe_normalise(chroma_audio)
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
# Slope-constrained step pattern ([[1,1],[1,2],[2,1]], Müller's standard
|
||||
# music-sync config): every step advances BOTH axes, bounding the local
|
||||
# tempo ratio to 0.5x-2x. librosa's default steps allow pure
|
||||
# horizontal/vertical runs, and on riff-based music (long self-similar
|
||||
# chroma stretches, e.g. stoner/doom) the flat cost surface let the path
|
||||
# collapse — whole minutes of score mapped onto a single audio frame,
|
||||
# producing garbage sync points. The constrained pattern makes that
|
||||
# degenerate path impossible.
|
||||
steps = np.array([[1, 1], [1, 2], [2, 1]])
|
||||
weights = np.array([1.0, 1.0, 1.0])
|
||||
try:
|
||||
_D, wp = librosa.sequence.dtw(
|
||||
cs, ca, metric='cosine',
|
||||
step_sizes_sigma=steps, weights_mul=weights,
|
||||
)
|
||||
except Exception as exc:
|
||||
# The constrained pattern needs the global length ratio within its
|
||||
# 0.5x-2x slope bounds; a pathological pairing (e.g. a 3-minute tab
|
||||
# against a 20-minute video) is infeasible and librosa raises. Fall
|
||||
# back to the unconstrained path rather than failing the whole sync.
|
||||
_log.warning("gp_autosync: constrained DTW infeasible (%s) — "
|
||||
"falling back to unconstrained steps", exc)
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
return wp[::-1] # reverse to forward order
|
||||
|
||||
# ── Sync point extraction from DTW path ──────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.31.2",
|
||||
"version": "3.31.3",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -3747,6 +3747,15 @@
|
||||
// ── Per-instance Three.js state ───────────────────────────────────
|
||||
let scene = null, cam = null, ren = null;
|
||||
let wrap = null;
|
||||
// WebGL context-loss recovery. Switching the active window / alt-tabbing
|
||||
// (especially on Windows) can trigger a GPU context reset; with no
|
||||
// handler the lost context escalates into a render-process crash. The
|
||||
// listeners (bound in initScene on ren.domElement, removed in teardown)
|
||||
// preventDefault the loss so the browser keeps the context restorable,
|
||||
// _ctxLost gates draw() off the dead context, and on restore we reset the
|
||||
// viewport + resume (Three re-uploads scene resources on the next render).
|
||||
let _ctxLost = false;
|
||||
let _onCtxLost = null, _onCtxRestored = null;
|
||||
let bcCtrl = null; // Butterchurn audio-reactive background (the 'butterchurn' bg-style)
|
||||
let _chartEnv = 0, _chartPrevT = -1, _bcBeatIdx = 0, _bcNoteIdx = 0, _bcChordIdx = 0, _bcTintTarget = null;
|
||||
let _tintR = 20, _tintG = 24, _tintB = 40; // smoothed instrument-color tint for the bg
|
||||
@@ -6526,6 +6535,26 @@
|
||||
ren.setClearColor(0x101820, _bcActive() ? 0 : 1);
|
||||
wrap.appendChild(ren.domElement);
|
||||
|
||||
// WebGL context-loss recovery (see the _ctxLost declaration). Bound
|
||||
// on Three's own canvas — the context that actually resets on a GPU
|
||||
// reset / alt-tab. preventDefault() keeps the context restorable
|
||||
// instead of letting the loss escalate to a render-process crash;
|
||||
// _ctxLost then makes draw() bail so no GL work runs on the dead
|
||||
// context; on restore we reset the viewport and resume (Three
|
||||
// re-uploads geometry/materials/textures lazily on the next render).
|
||||
_onCtxLost = (e) => {
|
||||
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
||||
_ctxLost = true;
|
||||
console.warn('[3D-Hwy] WebGL context lost — pausing render until it is restored.');
|
||||
};
|
||||
_onCtxRestored = () => {
|
||||
_ctxLost = false;
|
||||
console.warn('[3D-Hwy] WebGL context restored — resuming render.');
|
||||
try { const s = canvasSize(highwayCanvas); if (s.w > 0 && s.h > 0) applySize(s.w, s.h); } catch (err) {}
|
||||
};
|
||||
ren.domElement.addEventListener('webglcontextlost', _onCtxLost, false);
|
||||
ren.domElement.addEventListener('webglcontextrestored', _onCtxRestored, false);
|
||||
|
||||
lyricsCanvas = document.createElement('canvas');
|
||||
lyricsCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:1;';
|
||||
lyricsCtx = lyricsCanvas.getContext('2d');
|
||||
@@ -14829,6 +14858,15 @@
|
||||
// mid-teardown settings change doesn't try to rebuild a torn-
|
||||
// down scene; then dispose the active style's resources.
|
||||
if (_bgListener) { _bgUnsubscribe(_bgListener); _bgListener = null; }
|
||||
// WebGL context-loss listeners (bound in initScene on ren.domElement).
|
||||
// Remove before ren is disposed below so a torn-down instance can't
|
||||
// keep firing them; reset the flag so a reused instance starts clean.
|
||||
if (ren && ren.domElement) {
|
||||
if (_onCtxLost) { try { ren.domElement.removeEventListener('webglcontextlost', _onCtxLost, false); } catch (e) {} }
|
||||
if (_onCtxRestored) { try { ren.domElement.removeEventListener('webglcontextrestored', _onCtxRestored, false); } catch (e) {} }
|
||||
}
|
||||
_onCtxLost = _onCtxRestored = null;
|
||||
_ctxLost = false;
|
||||
// Notedetect listeners (issue #9). Remove on destroy so a
|
||||
// panel that stops doesn't keep accumulating marks. Marks
|
||||
// arrays are cleared too — they hold stale chart positions
|
||||
@@ -15154,6 +15192,7 @@
|
||||
|
||||
draw(bundle) {
|
||||
if (!_isReady) return;
|
||||
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
|
||||
if (!_chartPrewarmed) {
|
||||
_chartPrewarmed = true;
|
||||
_prewarmChart(bundle);
|
||||
|
||||
@@ -199,6 +199,7 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
|
||||
("DELETE", re.compile(r"^/api/audio-effects/active-mapping$")),
|
||||
("POST", re.compile(r"^/api/song/.*/meta$")),
|
||||
("POST", re.compile(r"^/api/song/.*/art/upload$")),
|
||||
("PUT", re.compile(r"^/api/song/.+/overrides$")),
|
||||
("GET", re.compile(r"^/api/plugins/updates$")),
|
||||
("POST", re.compile(r"^/api/plugins/[^/]+/update$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/save$")),
|
||||
@@ -555,7 +556,13 @@ def next_library_cursor(sort: str, last_song: dict | None) -> str | None:
|
||||
key = "mtime" if col == "mtime" else col
|
||||
if key not in last_song or "filename" not in last_song:
|
||||
return None
|
||||
return _encode_cursor([last_song[key], last_song["filename"]])
|
||||
# A title display-override (Fix-metadata popup) replaces last_song["title"]
|
||||
# for the card, but the keyset seek runs on the RAW title column — resume
|
||||
# from the raw value query_page stashed (present only when the last row's
|
||||
# title was overridden), so paging never skips/dupes.
|
||||
val = (last_song["_sort_title"] if (key == "title" and "_sort_title" in last_song)
|
||||
else last_song[key])
|
||||
return _encode_cursor([val, last_song["filename"]])
|
||||
|
||||
|
||||
# Song-level "mastered" threshold — best accuracy across a song's arrangements
|
||||
@@ -667,6 +674,26 @@ class MetadataDB:
|
||||
)
|
||||
""")
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_tags_tag ON song_tags(tag COLLATE NOCASE)")
|
||||
# Per-field metadata OVERRIDES + LOCKS (the Fix-metadata popup). A
|
||||
# reversible DISPLAY overlay, never written to the pack: `value` is the
|
||||
# user's corrected value for a catalog field (title/artist/album/year/
|
||||
# genre), `locked=1` pins the field so a metadata refresh / auto-match
|
||||
# never changes what's shown for it (Plex-style field lock). Effective
|
||||
# display value = override → matched-MusicBrainz → pack → derived.
|
||||
# Filename-keyed → purged with the song on delete_song, NEVER on a
|
||||
# rescan (delete_missing), so an edit survives re-import like every other
|
||||
# local layer.
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS song_field_override (
|
||||
filename TEXT NOT NULL,
|
||||
field TEXT NOT NULL, -- title|artist|album|year|genre
|
||||
value TEXT, -- corrected value (NULL = lock only, no override)
|
||||
locked INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT,
|
||||
PRIMARY KEY (filename, field)
|
||||
)
|
||||
""")
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_field_override_fn ON song_field_override(filename)")
|
||||
# Artist-name aliases (P4): "ACDC" → "AC/DC", "the beatles" → "The Beatles".
|
||||
# A CANONICALIZATION OVERRIDE applied AT DISPLAY only — the scanner-derived
|
||||
# `songs.artist` and the feedpak files are never rewritten (a rescan can't
|
||||
@@ -1168,6 +1195,125 @@ class MetadataDB:
|
||||
self.conn.commit()
|
||||
return self.get_song_user_meta(filename)
|
||||
|
||||
# ── Per-field metadata overrides + locks (Fix-metadata popup) ─────────────
|
||||
def get_song_overrides(self, filename: str) -> dict:
|
||||
"""{field: {"value": str|None, "locked": bool}} for one song."""
|
||||
rows = self.conn.execute(
|
||||
"SELECT field, value, locked FROM song_field_override WHERE filename = ?",
|
||||
(filename,)).fetchall()
|
||||
return {r[0]: {"value": r[1], "locked": bool(r[2])} for r in rows}
|
||||
|
||||
def set_song_override(self, filename: str, field: str, *,
|
||||
value="__keep__", locked="__keep__") -> dict:
|
||||
"""Partial upsert of one field's override value and/or lock. Pass a
|
||||
value/locked to set it or leave the sentinel to keep the current one. A
|
||||
row with neither a value nor a lock is dropped (no empty shell). Returns
|
||||
the song's full override map."""
|
||||
with self._lock:
|
||||
cur = self.conn.execute(
|
||||
"SELECT value, locked FROM song_field_override WHERE filename = ? AND field = ?",
|
||||
(filename, field)).fetchone()
|
||||
new_val = (cur[0] if cur else None) if value == "__keep__" else value
|
||||
new_lock = (bool(cur[1]) if cur else False) if locked == "__keep__" else bool(locked)
|
||||
new_val = (new_val or "").strip() or None
|
||||
if new_val is None and not new_lock:
|
||||
self.conn.execute(
|
||||
"DELETE FROM song_field_override WHERE filename = ? AND field = ?",
|
||||
(filename, field))
|
||||
else:
|
||||
self.conn.execute(
|
||||
"INSERT INTO song_field_override (filename, field, value, locked, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, datetime('now')) "
|
||||
"ON CONFLICT(filename, field) DO UPDATE SET "
|
||||
"value = excluded.value, locked = excluded.locked, updated_at = excluded.updated_at",
|
||||
(filename, field, new_val, 1 if new_lock else 0))
|
||||
self.conn.commit()
|
||||
return self.get_song_overrides(filename)
|
||||
|
||||
def locked_fields(self, filename: str) -> set:
|
||||
"""The catalog fields the user LOCKED for a song (Fix-metadata popup).
|
||||
An automatic match must never (re)canonicalize these, and gap-fill must
|
||||
never write them to the file. Locked read (the enrichment worker calls
|
||||
it), minimal projection."""
|
||||
with self._lock:
|
||||
return {r[0] for r in self.conn.execute(
|
||||
"SELECT field FROM song_field_override WHERE filename = ? AND locked = 1",
|
||||
(filename,)).fetchall()}
|
||||
|
||||
def clear_song_override(self, filename: str, field: str) -> dict:
|
||||
"""Remove a field's override + lock entirely (revert to the resolved
|
||||
pack/matched value)."""
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
"DELETE FROM song_field_override WHERE filename = ? AND field = ?",
|
||||
(filename, field))
|
||||
self.conn.commit()
|
||||
return self.get_song_overrides(filename)
|
||||
|
||||
def overrides_map(self, filenames) -> dict:
|
||||
"""{filename: {field: {value, locked}}} for a batch — feeds the grid's
|
||||
effective-value resolution (display slice). Chunked under SQLite's
|
||||
variable limit."""
|
||||
fns = list(filenames)
|
||||
out: dict = {}
|
||||
for i in range(0, len(fns), 400):
|
||||
chunk = fns[i:i + 400]
|
||||
if not chunk:
|
||||
break
|
||||
q = ("SELECT filename, field, value, locked FROM song_field_override "
|
||||
"WHERE filename IN (%s)" % ",".join("?" * len(chunk)))
|
||||
for fn, field, value, locked in self.conn.execute(q, chunk).fetchall():
|
||||
out.setdefault(fn, {})[field] = {"value": value, "locked": bool(locked)}
|
||||
return out
|
||||
|
||||
def _romaji_display(self, filename: str, artist: str, title: str):
|
||||
"""English-base display fallback. A blank-artist CDLC pack named
|
||||
'Artist_Title_v1_p' has no readable name (artist blank; title = the raw
|
||||
filename), and a match would fill it with the artist's NATIVE script
|
||||
(kanji/kana). Surface the author's own romaji parsed from the filename
|
||||
instead, so an English base reads 'Junko Yagami - BAY CITY'. Only kicks in
|
||||
when the pack has no artist of its own — a real pack artist is untouched."""
|
||||
if (artist or "").strip():
|
||||
return artist, title
|
||||
d = _artist_title_from_filename(filename)
|
||||
return (d["artist"], d["title"]) if d else (artist, title)
|
||||
|
||||
def pack_fields(self, filename: str) -> dict:
|
||||
"""The stored (pack) values for the overridable catalog fields — the
|
||||
Fix-metadata popup shows these behind each override as the 'revert to
|
||||
pack' reference + the Yours/Pack provenance. Empty strings for a missing
|
||||
song so the popup always has a value to render."""
|
||||
keys = ("title", "artist", "album", "year", "genre")
|
||||
row = self.conn.execute(
|
||||
"SELECT title, artist, album, year, genre FROM songs WHERE filename = ?",
|
||||
(filename,)).fetchone()
|
||||
vals = {k: ((row[i] or "") if row else "") for i, k in enumerate(keys)}
|
||||
# Baseline the author's romaji (from the filename) for a blank-artist pack,
|
||||
# so the Details tab's Pack reference matches what the grid shows.
|
||||
vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
|
||||
return vals
|
||||
|
||||
# Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the
|
||||
# scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias)
|
||||
# so a corrected genre is browsable — the correlated subquery is used ONLY
|
||||
# when genre overrides actually exist; the common case stays on the plain
|
||||
# indexed `genre` column. Genre stays a library-only overlay (it isn't a
|
||||
# write-to-file field), so it never touches the pack.
|
||||
_EFFECTIVE_GENRE_SQL = (
|
||||
"COALESCE((SELECT o.value FROM song_field_override o "
|
||||
"WHERE o.filename = songs.filename AND o.field = 'genre' "
|
||||
"AND o.value IS NOT NULL AND o.value != ''), genre)"
|
||||
)
|
||||
|
||||
def _has_genre_overrides(self) -> bool:
|
||||
return self.conn.execute(
|
||||
"SELECT 1 FROM song_field_override WHERE field = 'genre' "
|
||||
"AND value IS NOT NULL AND value != '' LIMIT 1").fetchone() is not None
|
||||
|
||||
def _effective_genre_expr(self) -> str:
|
||||
"""`genre` normally; the override-aware COALESCE only when overrides exist."""
|
||||
return self._EFFECTIVE_GENRE_SQL if self._has_genre_overrides() else "genre"
|
||||
|
||||
def set_song_tags(self, filename: str, tags) -> list:
|
||||
"""Replace ALL of a song's tags with the given set (each normalized;
|
||||
blanks + case-dupes dropped). Full-replace so the whole personal-meta
|
||||
@@ -1232,6 +1378,7 @@ class MetadataDB:
|
||||
INSIDE the caller's `meta_db._lock` — must not re-acquire the lock."""
|
||||
self.conn.execute("DELETE FROM song_user_meta WHERE filename = ?", (filename,))
|
||||
self.conn.execute("DELETE FROM song_tags WHERE filename = ?", (filename,))
|
||||
self.conn.execute("DELETE FROM song_field_override WHERE filename = ?", (filename,))
|
||||
|
||||
def batch_user_meta(self, filenames, *, set_difficulty="__keep__",
|
||||
add_tags=None, remove_tags=None) -> int:
|
||||
@@ -3354,7 +3501,7 @@ class MetadataDB:
|
||||
# list on scan). OR within the selected set.
|
||||
if genre:
|
||||
_gph = ",".join(["?"] * len(genre))
|
||||
where += f" AND genre COLLATE NOCASE IN ({_gph})"
|
||||
where += f" AND ({self._effective_genre_expr()}) COLLATE NOCASE IN ({_gph})"
|
||||
params += list(genre)
|
||||
# Mastery bands = best accuracy across a song's arrangements (song_stats,
|
||||
# a separate table -> correlated subquery). mastered >= 0.9, in_progress =
|
||||
@@ -4063,6 +4210,15 @@ class MetadataDB:
|
||||
# per-tile state only paints while a pass runs. Cheap set membership like
|
||||
# favs/estd, so the misses stay visible at rest.
|
||||
um = self._unmatched_set(fns)
|
||||
# Per-song display OVERRIDES (Fix-metadata popup, slice 3). "Grid shows
|
||||
# only overrides": the effective cell is the user's override else the
|
||||
# pack value — a matched MusicBrainz canon NEVER silently re-titles a
|
||||
# card (canon lives in the Details drawer + art). Overlaid in Python
|
||||
# over the visible window, keyset-safe exactly like the P4 alias re-label
|
||||
# below: the seek still runs on the raw column (the one overridable
|
||||
# keyset column, title, stashes its raw value for the cursor — see
|
||||
# _sort_title / next_library_cursor).
|
||||
omap = self.overrides_map(fns)
|
||||
# Canonical artist at display (P4): re-label the card's artist through the
|
||||
# alias override so "ACDC" reads as "AC/DC". Display-only — the row's sort
|
||||
# position (raw artist) is untouched, so a card can show a canonical name
|
||||
@@ -4075,6 +4231,29 @@ class MetadataDB:
|
||||
s["unmatched"] = s["filename"] in um
|
||||
if amap:
|
||||
s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist"))
|
||||
# English-base romaji fallback: a blank-artist CDLC pack shows nothing
|
||||
# useful (artist blank; title = the raw filename). Surface the author's
|
||||
# romaji from the "Artist_Title_v1_p" filename so the card reads
|
||||
# "Junko Yagami — BAY CITY", never blank or native script. Display-only;
|
||||
# a user override (below) still wins. Keyset-safe: stash the raw title
|
||||
# for the cursor before replacing it.
|
||||
if not (s.get("artist") or "").strip():
|
||||
r_artist, r_title = self._romaji_display(s["filename"], s.get("artist"), s.get("title"))
|
||||
if r_title != s.get("title") and "_sort_title" not in s:
|
||||
s["_sort_title"] = s["title"]
|
||||
s["artist"], s["title"] = r_artist, r_title
|
||||
# Override wins over the pack AND the alias re-label — it's the user's
|
||||
# explicit per-song choice. Only a non-empty override VALUE replaces a
|
||||
# cell; a lock-only row (value None) leaves the displayed value alone.
|
||||
ov = omap.get(s["filename"])
|
||||
if ov:
|
||||
for field in ("title", "artist", "album", "year"):
|
||||
cell = ov.get(field)
|
||||
val = cell.get("value") if cell else None
|
||||
if val:
|
||||
if field == "title" and "_sort_title" not in s:
|
||||
s["_sort_title"] = s["title"] # raw title, for the keyset cursor
|
||||
s[field] = val
|
||||
# Grouped rows carry the ⚑ N (chart_count) + the work_key from the
|
||||
# materialized read-model, so the card can render the "N charts" chip and
|
||||
# address the Charts drawer (GET /api/work/{work_key}/charts) without a
|
||||
@@ -6368,6 +6547,36 @@ def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
|
||||
return cands
|
||||
|
||||
|
||||
def _mb_search_release_groups(query: str, limit: int = 8) -> list[dict]:
|
||||
"""Text search /release-group for the Change-cover picker: albums matching a
|
||||
free query, each mapped to its Cover Art Archive front thumb. One request;
|
||||
tiles whose CAA art is missing self-hide client-side (front-250 404s). Lets a
|
||||
cover be found even for a song with no metadata match (the city-pop pile)."""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
body = _mb_http_get("release-group", {"query": q, "limit": limit})
|
||||
out: list[dict] = []
|
||||
for rg in ((body or {}).get("release-groups") or []):
|
||||
rid = rg.get("id")
|
||||
if not rid:
|
||||
continue
|
||||
# artist-credit is a list of {name, joinphrase, artist} (joinphrase glues
|
||||
# collaborations) — reconstruct the credited name.
|
||||
artist = "".join(
|
||||
(c.get("name", "") + c.get("joinphrase", "")) if isinstance(c, dict) else str(c)
|
||||
for c in (rg.get("artist-credit") or [])
|
||||
).strip()
|
||||
title = rg.get("title") or ""
|
||||
year = (rg.get("first-release-date") or "")[:4]
|
||||
out.append({
|
||||
"id": rid,
|
||||
"label": " · ".join(x for x in (title, artist, year) if x) or title or "Cover",
|
||||
"thumb_url": f"https://coverartarchive.org/release-group/{rid}/front-250",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ── 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.
|
||||
@@ -6986,6 +7195,34 @@ def _artist_title_from_filename(filename: str) -> dict | None:
|
||||
return {"artist": artist, "title": title}
|
||||
|
||||
|
||||
# A per-song LOCK (Fix-metadata popup) → the candidate display keys it
|
||||
# suppresses on an AUTOMATIC match. Identity keys (recording/release/artist ids,
|
||||
# isrc) are deliberately absent: a locked DISPLAY field still gets matched for
|
||||
# art + future re-match, it just isn't re-canonicalized behind the user's back.
|
||||
_LOCK_FIELD_TO_CAND = {
|
||||
"artist": ("artist", "artist_sort"),
|
||||
"title": ("title",),
|
||||
"album": ("album",),
|
||||
"year": ("year",),
|
||||
"genre": ("genres",),
|
||||
}
|
||||
|
||||
|
||||
def _compose_lock_filter(base_filter, locked_fields):
|
||||
"""Wrap the pass's global per-field apply-filter with a per-song filter that
|
||||
also strips the song's LOCKED display fields, so an automatic match never
|
||||
re-canonicalizes a field the user pinned. Returns base_filter unchanged when
|
||||
the song has no relevant lock (the common path)."""
|
||||
blocked = {ck for f in locked_fields for ck in _LOCK_FIELD_TO_CAND.get(f, ())}
|
||||
if not blocked:
|
||||
return base_filter
|
||||
|
||||
def lock_filter(cand):
|
||||
c = base_filter(cand) if base_filter else cand
|
||||
return {k: v for k, v in c.items() if k not in blocked}
|
||||
return lock_filter
|
||||
|
||||
|
||||
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:
|
||||
@@ -7011,6 +7248,14 @@ def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None,
|
||||
siblings. Network errors raise EnrichTransportError so the pass pauses
|
||||
instead of burning attempts while offline."""
|
||||
fn, chash = row["filename"], row["content_hash"]
|
||||
# Respect per-song field LOCKS (Fix-metadata popup): an automatic match must
|
||||
# not re-canonicalize a field the user pinned. Compose the lock filter onto
|
||||
# the pass's global apply-filter — both the cache-copy and text-match auto
|
||||
# paths run their candidate through it. (Review/manual picks bypass the
|
||||
# filter, so confirming a match in the modal is an explicit override.)
|
||||
locked = meta_db.locked_fields(fn)
|
||||
if locked:
|
||||
field_filter = _compose_lock_filter(field_filter, locked)
|
||||
|
||||
cached = meta_db.enrichment_cache_lookup(chash, exclude_filename=fn)
|
||||
if cached:
|
||||
@@ -8696,6 +8941,10 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
|
||||
# The cursor to resume after this page (effective sort folds in dir=desc).
|
||||
next_cursor = (next_library_cursor(_effective_keyset_sort(sort, dir), songs[-1])
|
||||
if (is_local and songs) else None)
|
||||
# Drop the private raw-title stash query_page attached for the cursor — it's
|
||||
# an internal keyset detail, not part of the card payload.
|
||||
for s in songs:
|
||||
s.pop("_sort_title", None)
|
||||
return {"songs": songs, "total": total, "page": page, "size": size,
|
||||
"next_cursor": next_cursor}
|
||||
|
||||
@@ -8947,9 +9196,10 @@ def library_genres(provider: str = "local"):
|
||||
if is_remote:
|
||||
return {"genres": []}
|
||||
with meta_db._lock:
|
||||
g = meta_db._effective_genre_expr()
|
||||
rows = meta_db.conn.execute(
|
||||
"SELECT DISTINCT genre FROM songs WHERE genre IS NOT NULL AND genre != '' "
|
||||
"ORDER BY genre COLLATE NOCASE"
|
||||
f"SELECT g FROM (SELECT DISTINCT ({g}) AS g FROM songs) "
|
||||
"WHERE g IS NOT NULL AND g != '' ORDER BY g COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return {"genres": [r[0] for r in rows]}
|
||||
|
||||
@@ -9034,6 +9284,60 @@ def put_song_user_meta(filename: str, data: dict):
|
||||
return meta_db.get_song_user_meta(key)
|
||||
|
||||
|
||||
# Catalog fields the Fix-metadata popup may override/lock — the intersection of
|
||||
# "displayable identity" and "safe to correct locally". Guitar/practice facts
|
||||
# and personal fields are never overrides.
|
||||
_OVERRIDE_FIELDS = frozenset({"title", "artist", "album", "year", "genre"})
|
||||
|
||||
|
||||
@app.get("/api/song/{filename:path}/overrides")
|
||||
def get_song_overrides(filename: str):
|
||||
"""Per-field metadata overrides + locks for one song (Fix-metadata popup):
|
||||
{"overrides": {field: {"value": str|null, "locked": bool}},
|
||||
"pack": {field: str}}. `pack` is the stored value each override sits on top
|
||||
of — the popup's Details tab renders it as the revert-to-pack reference and
|
||||
the Yours/Pack provenance."""
|
||||
key = meta_db._canonical_song_filename(filename)
|
||||
return {"overrides": meta_db.get_song_overrides(key),
|
||||
"pack": meta_db.pack_fields(key)}
|
||||
|
||||
|
||||
@app.put("/api/song/{filename:path}/overrides")
|
||||
def put_song_overrides(filename: str, data: dict):
|
||||
"""Set/clear per-field overrides + locks. Body:
|
||||
`{"overrides": {field: {"value": str|null, "locked": bool}}}`. Only catalog
|
||||
fields (title/artist/album/year/genre) are accepted. A field left with no
|
||||
value and unlocked is removed. Returns the merged override map.
|
||||
|
||||
Clearing rides this PUT (send value:null, locked:false) rather than a DELETE
|
||||
sub-route, because `DELETE /api/song/{filename:path}` already owns every
|
||||
DELETE under /api/song and would shadow it (same reason as tags)."""
|
||||
ov = (data or {}).get("overrides")
|
||||
if not isinstance(ov, dict) or not ov:
|
||||
return JSONResponse({"error": "overrides must be a non-empty object"}, 400)
|
||||
bad = sorted(f for f in ov if f not in _OVERRIDE_FIELDS)
|
||||
if bad:
|
||||
return JSONResponse({"error": "unknown field(s): " + ", ".join(bad)}, 400)
|
||||
key = meta_db._canonical_song_filename(filename)
|
||||
for field, spec in ov.items():
|
||||
if not isinstance(spec, dict):
|
||||
return JSONResponse({"error": f"'{field}' must be an object with value/locked"}, 400)
|
||||
kwargs: dict = {}
|
||||
if "value" in spec:
|
||||
v = spec["value"]
|
||||
if v is None:
|
||||
kwargs["value"] = None
|
||||
elif isinstance(v, (str, int, float)) and not isinstance(v, bool):
|
||||
kwargs["value"] = str(v).strip()[:500]
|
||||
else:
|
||||
return JSONResponse({"error": f"'{field}' value must be a string or null"}, 400)
|
||||
if "locked" in spec:
|
||||
kwargs["locked"] = bool(spec["locked"])
|
||||
if kwargs:
|
||||
meta_db.set_song_override(key, field, **kwargs)
|
||||
return {"overrides": meta_db.get_song_overrides(key)}
|
||||
|
||||
|
||||
@app.post("/api/songs/user-meta/batch")
|
||||
def batch_song_user_meta(data: dict):
|
||||
"""Bulk personal-meta edit over a selection — one request instead of N×2
|
||||
@@ -11975,6 +12279,26 @@ async def get_song_art(filename: str, request: Request = None, source: str = "")
|
||||
_ART_PICKER_MAX_CAA = 12
|
||||
|
||||
|
||||
@app.get("/api/song/{filename:path}/art/cover-search")
|
||||
def api_art_cover_search(filename: str, q: str = ""):
|
||||
"""Search Cover Art Archive (via MusicBrainz release-groups) for album covers
|
||||
— powers the Change-cover picker's search box, so a cover can be found even
|
||||
for a song with no metadata match (the unmatched city-pop pile, where
|
||||
/art/candidates is empty). `q` defaults to the song's own artist + album/
|
||||
title (romaji fallback applied). Read-only; the picker renders the thumbs and
|
||||
applies a pick through the existing /art/url route."""
|
||||
query = (q or "").strip()
|
||||
if not query:
|
||||
pack = meta_db.pack_fields(meta_db._canonical_song_filename(filename))
|
||||
query = " ".join(x for x in (pack.get("artist"), pack.get("album") or pack.get("title")) if x).strip()
|
||||
if not query:
|
||||
return {"query": "", "covers": []}
|
||||
try:
|
||||
return {"query": query, "covers": _mb_search_release_groups(query, limit=8)}
|
||||
except EnrichTransportError:
|
||||
return {"query": query, "covers": [], "error": "unavailable"}
|
||||
|
||||
|
||||
@app.get("/api/song/{filename:path}/art/candidates")
|
||||
def get_song_art_candidates(filename: str):
|
||||
"""Everything the cover picker can offer for one song, without fetching a
|
||||
@@ -12202,15 +12526,21 @@ def _gap_fill_proposals(cache_key: str, resolved) -> tuple[dict, str]:
|
||||
manifest = sloppak_mod.load_manifest(resolved) or {}
|
||||
except Exception:
|
||||
return {}, "not-sloppak"
|
||||
# A LOCKED field (Fix-metadata popup) is never gap-filled — the user pinned
|
||||
# it away from the matched value, so writing that value to the file would
|
||||
# be exactly the clobber the lock exists to prevent. (The lock field name is
|
||||
# `genre`; the manifest/gap-fill key is `genres`.)
|
||||
locked = meta_db.locked_fields(cache_key)
|
||||
out = {}
|
||||
album = (row.get("canon_album") or "").strip()
|
||||
if album and _gap_fill_manifest_absent(manifest, "album"):
|
||||
if album and "album" not in locked and _gap_fill_manifest_absent(manifest, "album"):
|
||||
out["album"] = album
|
||||
year = (row.get("canon_year") or "").strip()
|
||||
if year.isdigit() and int(year) and _gap_fill_manifest_absent(manifest, "year"):
|
||||
if (year.isdigit() and int(year) and "year" not in locked
|
||||
and _gap_fill_manifest_absent(manifest, "year")):
|
||||
out["year"] = int(year)
|
||||
genres = [str(g) for g in (row.get("genres") or []) if isinstance(g, str) and g.strip()]
|
||||
if genres and _gap_fill_manifest_absent(manifest, "genres"):
|
||||
if genres and "genre" not in locked and _gap_fill_manifest_absent(manifest, "genres"):
|
||||
out["genres"] = genres
|
||||
# Identity keys (feedpak spec 1.14.0) — written in canonical form only.
|
||||
mbid = (row.get("mb_recording_id") or "").strip().lower()
|
||||
|
||||
+35
-10
@@ -2870,6 +2870,10 @@ const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '
|
||||
// - colorblind: the Okabe–Ito accessible qualitative palette (vermillion,
|
||||
// orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most
|
||||
// distinguishable option for deuteranopia/protanopia.
|
||||
// - colorblind_deuteranope: a deuteranope-tuned variant of the Okabe–Ito set
|
||||
// above, contributed by a deuteranopic player who still found that set hard
|
||||
// to separate. Retunes the six main strings (red / yellow-green / blue /
|
||||
// orange / teal / deep-purple) and keeps its 7/8-string colors unchanged.
|
||||
// - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags
|
||||
// between neighbours (bright→bright→brightest→dark blue→bright green→dark
|
||||
// violet) so adjacent strings separate harder than vivid — a stage/stream
|
||||
@@ -2906,6 +2910,10 @@ const HWC_PRESETS = [
|
||||
id: 'colorblind', label: 'Colorblind-friendly',
|
||||
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'colorblind_deuteranope', label: 'Colorblind (deuteranope)',
|
||||
colors: { lowE: '#aa1414', A: '#88de00', D: '#1889e3', G: '#c6601c', B: '#00f5b2', highE: '#4d2173', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'neon', label: 'Neon',
|
||||
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
|
||||
@@ -8092,6 +8100,26 @@ function _resolveEditRegion() {
|
||||
return { a: Math.max(0, t - 4), b: t + 4 };
|
||||
}
|
||||
|
||||
/* @pure:editor-pending-view:start */
|
||||
function _buildEditorPendingViewPure(filename, arrangement, region, opts) {
|
||||
const options = opts || {};
|
||||
const view = {
|
||||
filename,
|
||||
arrangement: Number.isFinite(arrangement) && arrangement >= 0 ? arrangement : 0,
|
||||
barSel: region ? { startTime: region.a, endTime: region.b } : null,
|
||||
};
|
||||
if (options.returnToHighway) view.returnToHighway = true;
|
||||
if (typeof options.cursorTime === 'number') {
|
||||
view.cursorTime = options.cursorTime;
|
||||
} else if (region && typeof region.a === 'number') {
|
||||
view.cursorTime = region.a;
|
||||
}
|
||||
if (typeof options.scrollX === 'number') view.scrollX = Math.max(0, options.scrollX);
|
||||
if (typeof options.zoom === 'number' && options.zoom > 0) view.zoom = options.zoom;
|
||||
return view;
|
||||
}
|
||||
/* @pure:editor-pending-view:end */
|
||||
|
||||
// Enable "Edit region" whenever the editor plugin is present and a song is
|
||||
// loaded; show "↩ Editor" only while a return context is pending.
|
||||
function _updateEditRegionBtn() {
|
||||
@@ -8118,12 +8146,9 @@ function editRegionInEditor() {
|
||||
arrangement = si.arrangement_index;
|
||||
}
|
||||
} catch (_) { /* default to 0 */ }
|
||||
window._editorPendingView = {
|
||||
filename: currentFilename,
|
||||
arrangement,
|
||||
barSel: { startTime: region.a, endTime: region.b },
|
||||
window._editorPendingView = _buildEditorPendingViewPure(currentFilename, arrangement, region, {
|
||||
returnToHighway: true,
|
||||
};
|
||||
});
|
||||
window.editSong(currentFilename);
|
||||
}
|
||||
window.editRegionInEditor = editRegionInEditor;
|
||||
@@ -8135,14 +8160,14 @@ function returnToEditorFromHighway() {
|
||||
const ctx = window._highwayReturnCtx;
|
||||
if (!ctx || typeof window.editSong !== 'function') return;
|
||||
window._highwayReturnCtx = null;
|
||||
window._editorPendingView = {
|
||||
filename: ctx.filename,
|
||||
arrangement: ctx.arrangement,
|
||||
const region = ctx.barSel
|
||||
? { a: ctx.barSel.startTime, b: ctx.barSel.endTime }
|
||||
: null;
|
||||
window._editorPendingView = _buildEditorPendingViewPure(ctx.filename, ctx.arrangement, region, {
|
||||
scrollX: ctx.scrollX,
|
||||
zoom: ctx.zoom,
|
||||
cursorTime: ctx.cursorTime,
|
||||
barSel: ctx.barSel,
|
||||
};
|
||||
});
|
||||
window.editSong(ctx.filename);
|
||||
}
|
||||
window.returnToEditorFromHighway = returnToEditorFromHighway;
|
||||
|
||||
+24
-10
@@ -2004,18 +2004,32 @@ function createHighway() {
|
||||
const seedBase = (_frameIdx + n.s + ((n.t * 60) | 0)) | 0;
|
||||
ctx.save();
|
||||
ctx.fillStyle = col;
|
||||
ctx.shadowColor = col;
|
||||
ctx.shadowBlur = (8 + 6 * _shimmerNoise(seedBase)) * a; // shimmering glow
|
||||
ctx.globalAlpha = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0 - sw0, y0);
|
||||
ctx.lineTo(x0 + sw0, y0);
|
||||
ctx.lineTo(x1 + sw1, y1);
|
||||
ctx.lineTo(x1 - sw1, y1);
|
||||
ctx.fill();
|
||||
// Shimmering glow WITHOUT ctx.shadowBlur: blur cost scales with
|
||||
// the blurred DEVICE-pixel area, and a held sustain's trail can
|
||||
// span half the (DPR-scaled) canvas — profiling the "stutters
|
||||
// while playing" report put this per-frame blur pass at the top
|
||||
// exactly while a sustain is held. Three inflated low-alpha
|
||||
// fills of the same quad read as the same soft glow at a flat,
|
||||
// area-independent cost. The shimmer LUT still drives the
|
||||
// per-frame size/brightness flicker (feedBack#254 intent).
|
||||
const glowPx = (8 + 6 * _shimmerNoise(seedBase)) * a;
|
||||
const baseA = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17));
|
||||
const fillTrail = (inflate) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0 - sw0 - inflate, y0);
|
||||
ctx.lineTo(x0 + sw0 + inflate, y0);
|
||||
ctx.lineTo(x1 + sw1 + inflate, y1);
|
||||
ctx.lineTo(x1 - sw1 - inflate, y1);
|
||||
ctx.fill();
|
||||
};
|
||||
ctx.globalAlpha = baseA * 0.22;
|
||||
fillTrail(glowPx);
|
||||
ctx.globalAlpha = baseA * 0.4;
|
||||
fillTrail(glowPx * 0.45);
|
||||
ctx.globalAlpha = baseA;
|
||||
fillTrail(0);
|
||||
// Crackling "current" — a jittery white core line down
|
||||
// the trail, re-randomised each frame.
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.globalAlpha = a * (0.55 + 0.45 * _shimmerNoise(seedBase + 31));
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -451,6 +451,12 @@
|
||||
pill('inst', v, v[0].toUpperCase() + v.slice(1), settings.instrument === v)).join('')) +
|
||||
instRow('Strings', STRING_COUNTS[settings.instrument].map((v) =>
|
||||
pill('strings', v, v + '', settings.string_count === v)).join('')) +
|
||||
// Handedness — a left-hander flips the whole highway (frets mirror).
|
||||
// Lives with the other player-orientation choices so it's part of the
|
||||
// same "Choose your instrument" step the onboarding tour spotlights —
|
||||
// i.e. set before you ever tune up or calibrate.
|
||||
instRow('Handedness', pill('hand', 'right', 'Right', !_leftyPref()) +
|
||||
pill('hand', 'left', 'Left', _leftyPref())) +
|
||||
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
|
||||
'<select data-inst-tuning 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">' +
|
||||
// An offset-array tuning has no named option — surface it as a
|
||||
@@ -513,6 +519,10 @@
|
||||
setWorkingInstrument(settings.instrument, newSc);
|
||||
renderInstrument(); keepOpen();
|
||||
}));
|
||||
menu.querySelectorAll('[data-pill="hand"]').forEach((b) => b.addEventListener('click', () => {
|
||||
_setLeftyPref(b.getAttribute('data-val') === 'left');
|
||||
renderInstrument(); keepOpen(); // reflect the active pill; keep the menu open
|
||||
}));
|
||||
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]');
|
||||
@@ -528,6 +538,22 @@
|
||||
function instRow(label, inner) {
|
||||
return '<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
|
||||
}
|
||||
// Handedness (left-handed) preference. The canonical store is the highway's
|
||||
// `lefty` localStorage key; when a live highway exists, setLefty() also flips
|
||||
// it immediately. Feature-detected so it works on the dashboard before any
|
||||
// highway has been created (the value is read on the highway's next init).
|
||||
function _leftyPref() {
|
||||
try { if (window.highway && typeof window.highway.getLefty === 'function') return !!window.highway.getLefty(); } catch (_) { /* */ }
|
||||
try { return localStorage.getItem('lefty') === '1'; } catch (_) { return false; }
|
||||
}
|
||||
function _setLeftyPref(on) {
|
||||
try {
|
||||
if (window.highway && typeof window.highway.setLefty === 'function') window.highway.setLefty(!!on);
|
||||
else localStorage.setItem('lefty', on ? '1' : '0');
|
||||
} catch (_) { /* storage blocked — the pill still reflects the choice via re-render */ }
|
||||
// Keep the Settings "Left-handed" checkbox in sync when it's mounted.
|
||||
try { const cb = document.getElementById('setting-lefty'); if (cb) cb.checked = !!on; } catch (_) { /* */ }
|
||||
}
|
||||
function pill(group, val, label, active) {
|
||||
return '<button type="button" data-pill="' + group + '" data-val="' + val + '" class="px-2 py-1 rounded-md text-xs ' +
|
||||
(active ? 'bg-fb-primary text-white' : 'bg-gray-800/50 text-fb-textDim hover:text-fb-text') + '">' + esc(label) + '</button>';
|
||||
|
||||
@@ -138,6 +138,17 @@
|
||||
'<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + SKELETON_TILE + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim pt-2">Fetching covers… the source is rate-limited.</div>' +
|
||||
'</div>' +
|
||||
// Search Cover Art Archive — find an album cover even when the song has
|
||||
// no match (the auto candidates above are empty then). Pre-filled from
|
||||
// the song's artist + album/title; the source is rate-limited.
|
||||
'<div class="space-y-2 pt-1">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Search covers</div>' +
|
||||
'<div class="flex gap-2">' +
|
||||
'<input data-ip-search-input type="text" value="' + esc(_cur.query || '') + '" placeholder="artist album" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary">' +
|
||||
'<button data-ip-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button>' +
|
||||
'</div>' +
|
||||
'<div data-ip-search-results class="flex flex-wrap gap-3"></div>' +
|
||||
'</div>' +
|
||||
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
|
||||
'</div></div>' +
|
||||
'<input type="file" accept="image/*" data-ip-file class="hidden">';
|
||||
@@ -185,9 +196,47 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
const searchInput = panel.querySelector('[data-ip-search-input]');
|
||||
const runSearch = () => coverSearch(panel, (searchInput && searchInput.value) || '');
|
||||
panel.querySelector('[data-ip-search-go]')?.addEventListener('click', runSearch);
|
||||
searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); runSearch(); } });
|
||||
panel.querySelector('[data-ip-close]')?.focus();
|
||||
}
|
||||
|
||||
// Search Cover Art Archive (via the song-scoped cover-search endpoint) and
|
||||
// render the album covers as pickable tiles — the same apply('url') path as
|
||||
// the auto candidates. Covers with no CAA art self-hide (img onerror).
|
||||
async function coverSearch(panel, query) {
|
||||
const out = panel.querySelector('[data-ip-search-results]');
|
||||
const fn = _cur && _cur.filename;
|
||||
if (!out || !fn) return;
|
||||
out.innerHTML = '<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + '</div>';
|
||||
let body = null;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(fn) + '/art/cover-search?q=' + enc(String(query).trim()));
|
||||
if (r.ok) body = await r.json();
|
||||
} catch (_) { /* falls through to the empty state */ }
|
||||
if (!_cur || _cur.filename !== fn) return; // closed / changed song while searching
|
||||
const covers = (body && body.covers) || [];
|
||||
if (!covers.length) {
|
||||
out.innerHTML = '<div class="text-xs text-fb-textDim">' +
|
||||
((body && body.error) ? 'Cover search is unavailable right now.' : 'No covers found — try a different search.') +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
out.innerHTML = covers.map((c, i) =>
|
||||
tileHtml('data-ip-cover="' + i + '"', imgFace(c.thumb_url), c.label || 'Cover')).join('');
|
||||
out.querySelectorAll('[data-ip-cover]').forEach((btn) => {
|
||||
const img = btn.querySelector('img');
|
||||
if (img) img.onerror = () => btn.classList.add('hidden'); // no CAA art for this album → hide
|
||||
btn.addEventListener('click', () => {
|
||||
if (_busy) return;
|
||||
const c = covers[Number(btn.getAttribute('data-ip-cover'))];
|
||||
if (c) apply('url', c.thumb_url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The one candidates fetch, cancelled if the modal closes first. Failure
|
||||
// (offline, demo mode, aborted) is silent: the skeletons just clear and
|
||||
// the instant tiles remain — never an error wall.
|
||||
@@ -281,7 +330,13 @@
|
||||
const filename = opts && opts.filename;
|
||||
if (!filename) return;
|
||||
_lastFocus = document.activeElement;
|
||||
_cur = { filename: filename, title: (opts && opts.title) || filename };
|
||||
const title = (opts && opts.title) || filename;
|
||||
const artist = (opts && opts.artist) || '';
|
||||
const album = (opts && opts.album) || '';
|
||||
// Pre-fill the cover search: "artist album" when the album is known, else
|
||||
// just the artist, else the title — the server default backs it up.
|
||||
const query = [artist, album].filter(Boolean).join(' ').trim() || title;
|
||||
_cur = { filename: filename, title: title, query: query };
|
||||
_busy = false;
|
||||
const m = ensureModal();
|
||||
const panel = document.getElementById('v3-imgpick-panel');
|
||||
|
||||
+370
-54
@@ -131,7 +131,8 @@
|
||||
let _queue = [];
|
||||
let _idx = 0;
|
||||
let _lastFocus = null;
|
||||
let _single = false; // Fix-match mode: one song, no queue navigation
|
||||
let _single = false; // Fix-metadata mode: one song, no queue navigation
|
||||
let _tab = 'details'; // active tab in single mode: details | cover | match
|
||||
|
||||
function ensureModal() {
|
||||
let m = document.getElementById('v3-match-modal');
|
||||
@@ -180,14 +181,17 @@
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
// Fix-match (R2): the same modal for ONE song — the escape hatch for a
|
||||
// wrong (or missing) match, reachable from the card's ⋮ / right-click
|
||||
// menu. No stored candidates are required: the search panel opens
|
||||
// pre-filled, and a pick pins the match exactly like the review flow.
|
||||
// Fix metadata (R2 → popup slice 4): the tabbed per-song editor for ONE
|
||||
// song, reachable from the card's ⋮ / right-click menu. Three tabs —
|
||||
// Details (type + lock the displayed fields), Cover art (launch the picker),
|
||||
// Match (pin a MusicBrainz identity). Opens on Details: for the obscure /
|
||||
// blank-artist packs this exists to fix, typing the right title is the tool,
|
||||
// and Match is the escape hatch when text search can surface a record.
|
||||
function fixMatch(song) {
|
||||
if (!song || !song.filename) return;
|
||||
_lastFocus = document.activeElement;
|
||||
_single = true;
|
||||
_tab = 'details';
|
||||
_queue = [{
|
||||
filename: song.filename, title: song.title || song.filename,
|
||||
artist: song.artist || '', album: song.album || '',
|
||||
@@ -198,10 +202,7 @@
|
||||
const m = ensureModal();
|
||||
m.classList.remove('hidden');
|
||||
document.getElementById('v3-match-overlay')?.classList.remove('hidden');
|
||||
renderCurrent();
|
||||
// Straight to the point: the search panel is why this mode exists.
|
||||
document.getElementById('v3-match-panel')
|
||||
?.querySelector('[data-mr-search-toggle]')?.click();
|
||||
renderCurrent(); // _single ⇒ renderTabbed()
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
@@ -214,7 +215,7 @@
|
||||
}
|
||||
|
||||
function nav(step) {
|
||||
if (!_queue.length) return;
|
||||
if (_single || !_queue.length) return; // single mode has no queue to page
|
||||
_idx = Math.min(Math.max(_idx + step, 0), _queue.length - 1);
|
||||
renderCurrent();
|
||||
}
|
||||
@@ -302,20 +303,20 @@
|
||||
'<span class="text-xs text-fb-textDim shrink-0">' + esc(pct) + '</span></span>' +
|
||||
'<span class="block text-xs text-fb-textDim truncate">' + esc(meta) + '</span>' +
|
||||
diffChips(song, c) +
|
||||
(_single ? '<span class="block text-xs text-fb-primary pt-1">Use these values →</span>' : '') +
|
||||
'</button>';
|
||||
}
|
||||
|
||||
function renderCurrent() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
if (!_queue.length) { renderDone(); return; }
|
||||
_idx = Math.min(_idx, _queue.length - 1);
|
||||
const song = _queue[_idx];
|
||||
if (song._sel == null) song._sel = 0;
|
||||
// The middle content shared by the queue-review render and the single-song
|
||||
// popup's Match tab: the chart being matched, its candidate list, and the
|
||||
// "search instead" panel. Header + footer differ per surface. When there
|
||||
// are no stored candidates (a manual fix), the search panel opens pre-filled
|
||||
// — searching IS the point in that case.
|
||||
function reviewBodyHtml(song) {
|
||||
const sub = [song.artist, song.album, song.year, fmtDur(song.duration)].filter(Boolean).join(' · ');
|
||||
|
||||
panel.innerHTML = headerHtml() +
|
||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll">' +
|
||||
const noCands = !(song.candidates || []).length;
|
||||
const prefill = noCands ? [song.artist, song.title].filter(Boolean).join(' – ') : '';
|
||||
return '<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
|
||||
// The chart being matched
|
||||
'<div class="flex items-start gap-3">' +
|
||||
'<img data-mr-art src="' + esc(artUrl(song)) + '" alt="" loading="lazy" class="w-16 h-16 rounded-lg object-cover bg-fb-card shrink-0">' +
|
||||
@@ -325,25 +326,27 @@
|
||||
'<div class="text-xs text-fb-textDim/70 truncate" title="' + esc(song.filename) + '">' + esc(song.filename) + '</div>' +
|
||||
missingChips(song) +
|
||||
'</div></div>' +
|
||||
// Candidates (Fix-match mode arrives with none — the search panel
|
||||
// is its whole point, so the empty header is suppressed).
|
||||
((song.candidates || []).length
|
||||
? '<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
|
||||
(noCands
|
||||
? ''
|
||||
: '<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Candidates (MusicBrainz)</div>' +
|
||||
song.candidates.map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') +
|
||||
'</div>'
|
||||
: '') +
|
||||
// Search-instead panel
|
||||
'<div data-mr-search-panel class="hidden space-y-2">' +
|
||||
'</div>') +
|
||||
// Search panel — hidden when candidates exist (a "Search instead…"
|
||||
// toggle reveals it); open + pre-filled when there are none.
|
||||
'<div data-mr-search-panel class="' + (noCands ? '' : 'hidden') + ' space-y-2">' +
|
||||
'<div class="flex gap-2">' +
|
||||
'<input data-mr-search-input type="text" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary" placeholder="Artist – Title">' +
|
||||
'<input data-mr-search-input type="text" value="' + esc(prefill) + '" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary" placeholder="Artist – Title">' +
|
||||
'<button data-mr-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button></div>' +
|
||||
'<div data-mr-search-results class="space-y-1"></div></div>' +
|
||||
'</div>' +
|
||||
// Footer actions. Fix-match mode drops Skip (no queue) and the
|
||||
// accept button when there is nothing to accept — search-result
|
||||
// rows carry their own pick action.
|
||||
'<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Footer actions. Single mode drops Skip / Not-a-match (no queue); the
|
||||
// accept button only shows when there is a stored candidate to accept —
|
||||
// search-result rows carry their own pick action.
|
||||
function footerHtml(song) {
|
||||
return '<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>' +
|
||||
@@ -352,57 +355,362 @@
|
||||
(_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>' : '') +
|
||||
'</div></div>';
|
||||
|
||||
wireCurrent(panel, song);
|
||||
}
|
||||
|
||||
function wireCurrent(panel, song) {
|
||||
function renderCurrent() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
if (_single) { renderTabbed(); return; } // popup: the tabbed shell
|
||||
if (!_queue.length) { renderDone(); return; }
|
||||
_idx = Math.min(_idx, _queue.length - 1);
|
||||
const song = _queue[_idx];
|
||||
if (song._sel == null) song._sel = 0;
|
||||
panel.innerHTML = headerHtml() + reviewBodyHtml(song) + footerHtml(song);
|
||||
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
|
||||
panel.querySelector('[data-mr-prev]')?.addEventListener('click', () => nav(-1));
|
||||
panel.querySelector('[data-mr-next]')?.addEventListener('click', () => nav(1));
|
||||
panel.querySelector('[data-mr-skip]')?.addEventListener('click', () => nav(1));
|
||||
wireReviewBody(panel, song);
|
||||
}
|
||||
|
||||
// Candidate / search / accept-reject wiring shared by the queue render and
|
||||
// the popup's Match tab. Scoped to `root` so the tabbed shell can wire just
|
||||
// its tab body — its close + tab chrome live in the header (wired once by
|
||||
// renderTabbed), so wiring here must NOT touch close/prev/next/skip.
|
||||
function wireReviewBody(root, song) {
|
||||
// Art failure → flag + re-render once so the "cover art" chip shows.
|
||||
const img = panel.querySelector('[data-mr-art]');
|
||||
const img = root.querySelector('[data-mr-art]');
|
||||
if (img) img.onerror = () => {
|
||||
img.style.visibility = 'hidden';
|
||||
if (!song._artMissing) { song._artMissing = true; renderCurrent(); }
|
||||
};
|
||||
panel.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
root.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
song._sel = Number(btn.getAttribute('data-mr-cand'));
|
||||
renderCurrent();
|
||||
});
|
||||
});
|
||||
panel.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
|
||||
root.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
|
||||
const cand = (song.candidates || [])[song._sel || 0];
|
||||
if (!cand) return;
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/accept',
|
||||
{ recording_id: cand.recording_id });
|
||||
settle(song);
|
||||
});
|
||||
panel.querySelector('[data-mr-reject]')?.addEventListener('click', async () => {
|
||||
root.querySelector('[data-mr-reject]')?.addEventListener('click', async () => {
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/reject');
|
||||
settle(song);
|
||||
});
|
||||
const sp = panel.querySelector('[data-mr-search-panel]');
|
||||
const input = panel.querySelector('[data-mr-search-input]');
|
||||
panel.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => {
|
||||
const sp = root.querySelector('[data-mr-search-panel]');
|
||||
const input = root.querySelector('[data-mr-search-input]');
|
||||
root.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => {
|
||||
sp?.classList.toggle('hidden');
|
||||
if (sp && !sp.classList.contains('hidden') && input && !input.value) {
|
||||
input.value = [song.artist, song.title].filter(Boolean).join(' – ');
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
const go = () => runSearch(panel, song);
|
||||
panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
|
||||
const go = () => runSearch(root, song);
|
||||
root.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));
|
||||
// Identify-by-audio (AcoustID, #759) renders its hits into the same
|
||||
// search-results area — scope to `root` (the tab body / panel), not the
|
||||
// out-of-scope `panel` the pre-refactor #759 wiring referenced.
|
||||
root.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(root, song));
|
||||
}
|
||||
|
||||
// ── Tabbed single-song popup (slice 4) ───────────────────────────────────
|
||||
// Header + tab bar, then the active tab's body. The queue-review render
|
||||
// above is untouched; this is only reached in _single mode.
|
||||
function tabHeaderHtml() {
|
||||
const tab = (id, label) =>
|
||||
'<button data-mr-tab="' + id + '" role="tab" aria-selected="' + (_tab === id ? 'true' : 'false') + '" ' +
|
||||
'class="px-3 py-2 text-sm -mb-px border-b-2 ' + (_tab === id
|
||||
? 'border-fb-primary text-fb-text'
|
||||
: 'border-transparent text-fb-textDim hover:text-fb-text') + '">' + label + '</button>';
|
||||
return '<div class="flex items-center justify-between gap-3 px-5 pt-4 shrink-0">' +
|
||||
'<h3 class="text-lg font-semibold text-fb-text">Fix metadata</h3>' +
|
||||
'<button data-mr-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>' +
|
||||
'<div role="tablist" class="flex gap-1 px-4 border-b border-fb-border/40 shrink-0">' +
|
||||
tab('details', 'Details') + tab('cover', 'Cover art') + tab('match', 'Match') + '</div>';
|
||||
}
|
||||
|
||||
function renderTabbed() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
const song = _queue[0];
|
||||
if (!song) { closeModal(); return; }
|
||||
panel.innerHTML = tabHeaderHtml() +
|
||||
'<div data-mr-tabbody role="tabpanel" class="flex flex-col min-h-0 flex-1 overflow-hidden"></div>';
|
||||
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
|
||||
panel.querySelectorAll('[data-mr-tab]').forEach((b) => b.addEventListener('click', () => {
|
||||
const t = b.getAttribute('data-mr-tab');
|
||||
if (t !== _tab) { _tab = t; renderTabbed(); }
|
||||
}));
|
||||
const body = panel.querySelector('[data-mr-tabbody]');
|
||||
if (_tab === 'details') { renderDetailsTab(body, song); }
|
||||
else if (_tab === 'cover') { renderCoverTab(body, song); }
|
||||
else {
|
||||
body.innerHTML = reviewBodyHtml(song) + footerHtml(song);
|
||||
wireReviewBody(body, song);
|
||||
if (!(song.candidates || []).length) body.querySelector('[data-mr-search-input]')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Details tab: type + lock the DISPLAYED fields. Values ride the reversible
|
||||
// override store (GET/PUT /api/song/{fn}/overrides) — never the pack file.
|
||||
// Each field sits on its pack value: editing above the pack makes it an
|
||||
// override ("Yours"); a lock pins it so an auto-match can't recanonicalize
|
||||
// it; revert (↺) drops back to the pack value.
|
||||
const DETAIL_FIELDS = [['title', 'Title'], ['artist', 'Artist'], ['album', 'Album'], ['year', 'Year'], ['genre', 'Genre']];
|
||||
// Only these four are written into the pack file; genre is a library-only
|
||||
// overlay (drives the genre filter/facet + the auto-match lock), never baked
|
||||
// to the file — so Write to file leaves genre's override in place.
|
||||
const WRITE_FIELDS = ['title', 'artist', 'album', 'year'];
|
||||
|
||||
async function renderDetailsTab(body, song) {
|
||||
body.innerHTML = '<div class="p-5"><p class="text-sm text-fb-textDim">Loading…</p></div>';
|
||||
let data = { overrides: {}, pack: {} };
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides');
|
||||
if (r.ok) data = await r.json();
|
||||
} catch (_) { /* offline — fall back to the empty baseline */ }
|
||||
if (!_single || _tab !== 'details') return; // tab/modal changed while fetching
|
||||
const pack = data.pack || {};
|
||||
const ov = data.overrides || {};
|
||||
const st = {};
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
const o = ov[f] || {};
|
||||
st[f] = {
|
||||
pack: pack[f] || '',
|
||||
value: (o.value != null ? o.value : (pack[f] || '')),
|
||||
locked: !!o.locked,
|
||||
};
|
||||
}
|
||||
song._detailsState = st;
|
||||
// Match→Details bridge: a candidate picked with "Use these values" lands
|
||||
// its fields here as the pending (unsaved) input values, shown pre-filled
|
||||
// for review — the grid never adopts a match silently, so the user still
|
||||
// Saves (or Writes to file).
|
||||
const adopted = song._pendingDetails;
|
||||
if (adopted) {
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
if (f in adopted) st[f].value = String(adopted[f] || '');
|
||||
}
|
||||
song._pendingDetails = null;
|
||||
}
|
||||
paintDetails(body, song);
|
||||
if (adopted) {
|
||||
const s = body.querySelector('[data-df-status]');
|
||||
if (s) { s.className = 'text-xs leading-relaxed text-fb-textDim'; s.textContent = 'Filled from the match — review, then Save or Write to file.'; }
|
||||
}
|
||||
}
|
||||
|
||||
// Match→Details bridge: adopt a candidate's display fields into the Details
|
||||
// tab (opt-in — never silent). Pin the match too so the art/canon follow,
|
||||
// then land on Details pre-filled for review.
|
||||
async function useTheseValues(song, cand) {
|
||||
if (!cand) return;
|
||||
// Smart adopt for an English base: KEEP the readable name + title the card
|
||||
// already shows (the author's romaji, e.g. "Junko Yagami / BAY CITY") — the
|
||||
// match is often native script (kanji/kana). Take only what the pack lacks
|
||||
// — album / year / genre — from the match; the pin below still brings the
|
||||
// correct art + identity. The user can still edit any field.
|
||||
song._pendingDetails = {
|
||||
artist: String(song.artist || cand.artist || ''),
|
||||
title: String(song.title || cand.title || ''),
|
||||
album: String(cand.album || song.album || ''),
|
||||
year: String(cand.year || song.year || ''),
|
||||
genre: String((Array.isArray(cand.genres) && cand.genres[0]) || cand.genre || ''),
|
||||
};
|
||||
try {
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick', { candidate: cand });
|
||||
} catch (_) { /* pin is best-effort; the values still populate Details */ }
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { }
|
||||
_tab = 'details';
|
||||
renderTabbed();
|
||||
}
|
||||
|
||||
function paintDetails(body, song) {
|
||||
const st = song._detailsState;
|
||||
const row = ([f, label]) => {
|
||||
const s = st[f];
|
||||
const isYours = !!(String(s.value).trim() && String(s.value).trim() !== String(s.pack).trim());
|
||||
return '<div class="space-y-1">' +
|
||||
'<div class="flex items-center justify-between">' +
|
||||
'<label class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">' + esc(label) + '</label>' +
|
||||
(isYours
|
||||
? '<span class="text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-primary/15 text-fb-primary">Yours</span>'
|
||||
: '<span class="text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-card text-fb-textDim">Pack</span>') +
|
||||
'</div>' +
|
||||
'<div class="flex items-center gap-2">' +
|
||||
'<input data-df-input="' + f + '" type="text" value="' + esc(s.value) + '" ' +
|
||||
'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none focus:border-fb-primary" ' +
|
||||
'placeholder="' + esc(s.pack || label) + '">' +
|
||||
'<button data-df-lock="' + f + '" type="button" aria-pressed="' + (s.locked ? 'true' : 'false') + '" ' +
|
||||
'title="' + (s.locked ? 'Locked — auto-match won’t change this field' : 'Lock this field against auto-match') + '" ' +
|
||||
'class="px-2 py-1.5 rounded-md border ' + (s.locked ? 'border-fb-primary text-fb-primary bg-fb-primary/10' : 'border-fb-border/50 text-fb-textDim hover:text-fb-text') + '">' +
|
||||
(s.locked ? '🔒' : '🔓') + '</button>' +
|
||||
'<button data-df-revert="' + f + '" type="button" title="Revert to the pack value" ' +
|
||||
'class="px-2 py-1.5 rounded-md border border-fb-border/50 text-fb-textDim hover:text-fb-text">↺</button>' +
|
||||
'</div></div>';
|
||||
};
|
||||
body.innerHTML =
|
||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
|
||||
'<div class="flex items-start gap-3">' +
|
||||
'<img src="' + esc(artUrl(song)) + '" alt="" onerror="this.style.visibility=\'hidden\'" class="w-14 h-14 rounded-lg object-cover bg-fb-card shrink-0">' +
|
||||
'<p class="text-xs text-fb-textDim pt-1"><span class="text-fb-text">Save</span> keeps edits as a reversible library overlay — the song files aren\'t touched. <span class="text-fb-text">Write to file</span> bakes the title, artist, album and year into the pack (genre stays a library-only tag). Lock a field to keep an auto-match from changing it.</p>' +
|
||||
'</div>' +
|
||||
DETAIL_FIELDS.map(row).join('') +
|
||||
'<p data-df-status class="text-xs leading-relaxed"></p>' +
|
||||
'</div>' +
|
||||
'<div class="flex items-center justify-between gap-2 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
|
||||
'<button data-df-write type="button" title="Write these values into the song file itself — permanent, survives a full rescan. The rest of the pack is untouched." class="text-sm text-fb-textDim hover:text-fb-text border border-fb-border/50 rounded-md px-3 py-2">Write to file</button>' +
|
||||
'<button data-df-save class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Save</button>' +
|
||||
'</div>';
|
||||
body.querySelectorAll('[data-df-input]').forEach((inp) => {
|
||||
inp.addEventListener('input', () => { st[inp.getAttribute('data-df-input')].value = inp.value; });
|
||||
});
|
||||
body.querySelectorAll('[data-df-lock]').forEach((b) => {
|
||||
b.addEventListener('click', () => { const f = b.getAttribute('data-df-lock'); st[f].locked = !st[f].locked; paintDetails(body, song); });
|
||||
});
|
||||
body.querySelectorAll('[data-df-revert]').forEach((b) => {
|
||||
b.addEventListener('click', () => { const f = b.getAttribute('data-df-revert'); st[f].value = st[f].pack || ''; st[f].locked = false; paintDetails(body, song); });
|
||||
});
|
||||
body.querySelector('[data-df-save]')?.addEventListener('click', () => saveDetails(body, song));
|
||||
body.querySelector('[data-df-write]')?.addEventListener('click', () => writeToFile(body, song));
|
||||
}
|
||||
|
||||
async function saveDetails(body, song) {
|
||||
const st = song._detailsState;
|
||||
const overrides = {};
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
const v = String(st[f].value || '').trim();
|
||||
const p = String(st[f].pack || '').trim();
|
||||
// Only store a value that differs from the pack; equal / blank clears
|
||||
// the override (the server drops a value-less, unlocked row).
|
||||
overrides[f] = { value: (v && v !== p) ? v : null, locked: !!st[f].locked };
|
||||
}
|
||||
const status = body.querySelector('[data-df-status]');
|
||||
const saveBtn = body.querySelector('[data-df-save]');
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
let ok = false;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overrides }),
|
||||
});
|
||||
ok = r.ok;
|
||||
} catch (_) { ok = false; }
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
if (!ok) {
|
||||
if (status) { status.className = 'text-xs h-4 text-fb-accent'; status.textContent = 'Could not save — try again.'; }
|
||||
return;
|
||||
}
|
||||
// Reflect the new effective values on the in-memory song (keeps the Match
|
||||
// tab header consistent) and repaint the library so the card shows them —
|
||||
// the grid reloads on library:changed (slice 3 overlay does the rest).
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
const v = String(st[f].value || '').trim(); const p = String(st[f].pack || '').trim();
|
||||
song[f] = (v && v !== p) ? v : (st[f].pack || '');
|
||||
}
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'override' }); } catch (_) { }
|
||||
if (status) { status.className = 'text-xs h-4 text-fb-good'; status.textContent = 'Saved.'; }
|
||||
}
|
||||
|
||||
// "Write to file" — bake the shown title/artist/album/year INTO the pack
|
||||
// itself (the one action here that touches the file), via the existing
|
||||
// POST /api/song/{fn}/meta (writes the manifest, re-stats, coalesces a
|
||||
// rescan). On a real file write the display overrides for those fields are
|
||||
// now redundant, so clear their VALUES (keeping any locks) and re-render —
|
||||
// the field then reads from the file as "Pack". Loose-folder / unwritable
|
||||
// packs fall back to a DB-only update: we say so and keep the overlay.
|
||||
async function writeToFile(body, song) {
|
||||
const st = song._detailsState;
|
||||
const fields = {};
|
||||
for (const f of WRITE_FIELDS) fields[f] = String(st[f].value || '').trim();
|
||||
const status = body.querySelector('[data-df-status]');
|
||||
const writeBtn = body.querySelector('[data-df-write]');
|
||||
const saveBtn = body.querySelector('[data-df-save]');
|
||||
if (writeBtn) writeBtn.disabled = true;
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
if (status) { status.className = 'text-xs leading-relaxed text-fb-textDim'; status.textContent = 'Writing to the song file…'; }
|
||||
let ok = false, persisted = false;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/meta', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
ok = r.ok;
|
||||
const j = await r.json().catch(() => ({}));
|
||||
persisted = !!(j && j.persisted);
|
||||
} catch (_) { ok = false; }
|
||||
if (writeBtn) writeBtn.disabled = false;
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
if (!ok) {
|
||||
if (status) { status.className = 'text-xs leading-relaxed text-fb-accent'; status.textContent = 'Could not write to the file — try again.'; }
|
||||
return;
|
||||
}
|
||||
// Keep the in-memory song + grid in step with what was *persisted*, not
|
||||
// the raw input: the server coerces a non-numeric/empty year to "" (see
|
||||
// update_song_meta), so mirror that here or the grid card flashes the
|
||||
// typed text (e.g. "abcd") until the next natural refresh corrects it.
|
||||
const applied = { ...fields };
|
||||
if ('year' in applied) {
|
||||
const yr = /^[+-]?\d+$/.test(applied.year) ? parseInt(applied.year, 10) : 0;
|
||||
applied.year = yr ? String(yr) : '';
|
||||
}
|
||||
for (const f of WRITE_FIELDS) song[f] = applied[f];
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'write' }); } catch (_) { }
|
||||
if (persisted) {
|
||||
const clear = {};
|
||||
for (const f of WRITE_FIELDS) clear[f] = { value: null, locked: !!st[f].locked };
|
||||
try {
|
||||
await fetch('/api/song/' + enc(song.filename) + '/overrides', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overrides: clear }),
|
||||
});
|
||||
} catch (_) { /* the file write still succeeded; the overlay just lingers */ }
|
||||
await renderDetailsTab(body, song); // re-fetch: pack now = written values, overrides cleared
|
||||
const s2 = body.querySelector('[data-df-status]');
|
||||
if (s2) { s2.className = 'text-xs leading-relaxed text-fb-good'; s2.textContent = 'Written to the song file.'; }
|
||||
} else if (status) {
|
||||
status.className = 'text-xs leading-relaxed text-fb-textDim';
|
||||
status.textContent = 'Saved to the library — this pack’s file couldn’t be written, so it may revert on a full rescan.';
|
||||
}
|
||||
}
|
||||
|
||||
// Cover-art tab: the current art + a button that hands off to the shared
|
||||
// cover picker (image-picker.js, its own z-[200] modal). A pick there
|
||||
// refreshes every <img> for this song's art — including this thumbnail — so
|
||||
// there's nothing to wire back.
|
||||
function renderCoverTab(body, song) {
|
||||
body.innerHTML =
|
||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0 flex flex-col items-center text-center">' +
|
||||
'<img src="' + esc(artUrl(song)) + '" alt="" onerror="this.style.visibility=\'hidden\'" class="w-40 h-40 rounded-xl object-cover bg-fb-card">' +
|
||||
'<p class="text-sm text-fb-textDim max-w-sm">Choose from the Cover Art Archive, paste an image link, or upload your own. Your song files are never changed.</p>' +
|
||||
'<button data-cover-open class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Choose cover art…</button>' +
|
||||
'</div>';
|
||||
body.querySelector('[data-cover-open]')?.addEventListener('click', () => {
|
||||
if (window.__fbOpenImagePicker) {
|
||||
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Silent-on-success: the chart just leaves the queue and the next one
|
||||
// renders; the last one renders the done state. No toasts, no sounds.
|
||||
function settle(song) {
|
||||
if (_single) { closeModal(); return; } // Fix-match: done means done
|
||||
if (_single) {
|
||||
// Popup Match tab: a pinned identity can change the art/canon — nudge
|
||||
// the grid to repaint (silent otherwise, like the queue flow).
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { }
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
const i = _queue.indexOf(song);
|
||||
if (i >= 0) _queue.splice(i, 1);
|
||||
if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1);
|
||||
@@ -442,6 +750,7 @@
|
||||
btn.addEventListener('click', async () => {
|
||||
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
|
||||
if (!cand) return;
|
||||
if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
|
||||
{ candidate: cand });
|
||||
settle(song);
|
||||
@@ -464,30 +773,37 @@
|
||||
status = r.status;
|
||||
body = await r.json().catch(() => null);
|
||||
} catch (_) { /* falls through to the no-results line */ }
|
||||
// Honest states — never a fake hit.
|
||||
// Honest states — never a fake hit. Each says plainly WHICH outcome this
|
||||
// is, so an empty result reads as "it ran, found nothing" (not "broken")
|
||||
// and points at the manual fallback when there's nothing to pick.
|
||||
const note = (html) => { out.innerHTML = '<p class="text-xs text-fb-textDim leading-relaxed">' + html + '</p>'; };
|
||||
const manual = _single
|
||||
? ' Try <b class="text-fb-text">Search</b>, or just set the album in <b class="text-fb-text">Details</b> and the cover in <b class="text-fb-text">Cover art</b> by hand.'
|
||||
: ' Try <b class="text-fb-text">Search instead</b>.';
|
||||
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>';
|
||||
note('Audio identification is <b class="text-fb-text">off</b>. Turn it on and add a free AcoustID API key in Settings → Library to use it.');
|
||||
return;
|
||||
}
|
||||
if (status === 404) {
|
||||
out.innerHTML = "<p class=\"text-xs text-fb-textDim\">No full-mix audio to fingerprint for this song.</p>";
|
||||
note('This pack has <b class="text-fb-text">no full mix to fingerprint</b> (it\'s chart-only or stems-only).' + manual);
|
||||
return;
|
||||
}
|
||||
if (status === 503) {
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is unavailable right now — try again.</p>';
|
||||
note('Could not run the fingerprint right now — the audio tool or network is unavailable. Try again in a moment.');
|
||||
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>';
|
||||
note('<span class="text-fb-good">✓ Fingerprinted the audio</span> — but AcoustID has <b class="text-fb-text">no match</b> for this exact recording (common for obscure or import tracks).' + manual);
|
||||
return;
|
||||
}
|
||||
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Fingerprint matches (AcoustID)</div>' +
|
||||
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-good 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;
|
||||
if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
|
||||
{ candidate: cand });
|
||||
settle(song);
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
id: 'instrument', shape: 'spotlight', position: 'bottom',
|
||||
selector: '#v3-instrument-wrap', waitFor: '#v3-instrument-wrap',
|
||||
title: 'Choose your instrument',
|
||||
content: 'Set your instrument, string count and tuning here. The highway, tuner and scoring all adapt to this selection.',
|
||||
content: 'Set your instrument, string count and tuning here — and if you play left-handed, flip Handedness to Left so the whole highway mirrors. The highway, tuner and scoring all adapt to this selection.',
|
||||
},
|
||||
{
|
||||
id: 'tuner', shape: 'spotlight', position: 'bottom',
|
||||
|
||||
+23
-9
@@ -515,15 +515,22 @@
|
||||
done: ['bg-fb-good/90 text-black', '✓ Updated', ''],
|
||||
nochange: ['bg-black/60 text-fb-textDim', '— No match', ''],
|
||||
// Resting indicator: subtle, so a mostly-unmatched library isn't a
|
||||
// wall of loud badges; points at the manual fix.
|
||||
nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'No metadata match found — right-click to fix it by hand'],
|
||||
// wall of loud badges. Clickable — a one-click handoff into the
|
||||
// Fix-metadata popup for this song (see the [data-meta-fix] wiring).
|
||||
nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'Click to fix the metadata by hand'],
|
||||
};
|
||||
const conf = M[st] || M.queued;
|
||||
const fixable = st === 'nomatch'; // resting badge → opens Fix-metadata
|
||||
// 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[2] ? ' title="' + conf[2] + '"' : '') + '>' + conf[1] + '</span>';
|
||||
// z-20 sits it above the art. Batch states are non-interactive; the
|
||||
// resting "no match" badge is the handoff into the popup.
|
||||
const cls = '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 ' +
|
||||
(fixable ? 'pointer-events-auto cursor-pointer hover:bg-fb-primary hover:text-white transition-colors' : 'pointer-events-none');
|
||||
return '<span class="' + cls + '"' +
|
||||
(fixable ? ' data-meta-fix="1"' : '') +
|
||||
(conf[2] ? ' title="' + conf[2] + '"' : '') +
|
||||
'>' + conf[1] + '</span>';
|
||||
}
|
||||
|
||||
// After a song is scored, the badge for that card is stale until the next
|
||||
@@ -949,7 +956,7 @@
|
||||
// address the local DB / filesystem). Both openers (⋮ and
|
||||
// right-click) share this list, so parity is structural.
|
||||
...(state.provider === 'local' && song.filename ? [
|
||||
{ id: '__fixmatch', label: 'Fix match…' },
|
||||
{ id: '__fixmatch', label: 'Fix metadata…' },
|
||||
{ id: '__cover', label: 'Change cover…' },
|
||||
{ id: '__refreshmeta', label: 'Refresh metadata' },
|
||||
{ id: '__getinfo', label: 'Get info…' },
|
||||
@@ -1000,7 +1007,7 @@
|
||||
// the group's work_key/chart_count and pre-ticks the shown chart.)
|
||||
if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; }
|
||||
if (id === '__cover') {
|
||||
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename });
|
||||
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename, artist: playTarget.artist, album: playTarget.album });
|
||||
return;
|
||||
}
|
||||
if (id === '__refreshmeta') {
|
||||
@@ -1467,6 +1474,13 @@
|
||||
e.stopPropagation();
|
||||
openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song);
|
||||
});
|
||||
// "No match" badge → straight into the Fix-metadata popup for this
|
||||
// song (the batch → fix handoff). stopPropagation so it doesn't also
|
||||
// trigger the card's play. Follows the displayed chart, like the menu.
|
||||
el.querySelector('[data-meta-fix]')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (window.__fbFixMatch) window.__fbFixMatch(playTarget);
|
||||
});
|
||||
// Artist line → the artist page (PR-B). In select mode the grid's
|
||||
// capture-phase toggle intercepts first, so selection still wins.
|
||||
el.querySelector('[data-v3-artist]')?.addEventListener('click', (e) => {
|
||||
@@ -3112,7 +3126,7 @@
|
||||
// when image-picker.js isn't loaded.
|
||||
artWrap.addEventListener('click', () => {
|
||||
if (window.__fbOpenImagePicker) {
|
||||
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
|
||||
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
|
||||
} else {
|
||||
artFile.click();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Pins the onboarding handedness control: a Right/Left choice lives in the
|
||||
// instrument selector (the "Choose your instrument" onboarding step, which the
|
||||
// tour spotlights BEFORE the tuner/audio-calibration steps) and writes the
|
||||
// highway 'lefty' preference. Source-level, matching the other tests/js/
|
||||
// browser-heavy regression guards (the runtime path is DOM/WebGL-heavy).
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const BADGES = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'badges.js'), 'utf8');
|
||||
const TOUR = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'onboarding-tour.js'), 'utf8');
|
||||
|
||||
test('instrument selector offers a Handedness Right/Left choice', () => {
|
||||
assert.match(BADGES, /instRow\('Handedness'/, 'a Handedness row must be in the instrument menu');
|
||||
assert.match(BADGES, /pill\('hand',\s*'right'/, 'Right handedness pill');
|
||||
assert.match(BADGES, /pill\('hand',\s*'left'/, 'Left handedness pill');
|
||||
});
|
||||
|
||||
test('clicking a handedness pill writes the lefty preference from its value', () => {
|
||||
assert.match(
|
||||
BADGES,
|
||||
/\[data-pill="hand"\][\s\S]*?_setLeftyPref\(\s*b\.getAttribute\('data-val'\)\s*===\s*'left'\s*\)/,
|
||||
'the handedness click handler sets lefty from the pill value');
|
||||
});
|
||||
|
||||
test('_setLeftyPref prefers highway.setLefty and falls back to the lefty localStorage key', () => {
|
||||
const setter = BADGES.match(/function _setLeftyPref\(on\)\s*\{[\s\S]*?\n \}/);
|
||||
assert.ok(setter, '_setLeftyPref must exist');
|
||||
assert.match(setter[0], /highway\.setLefty/, 'prefers highway.setLefty (flips a live highway + persists)');
|
||||
assert.match(setter[0], /localStorage\.setItem\('lefty'/, 'falls back to the lefty localStorage key the highway reads on init');
|
||||
});
|
||||
|
||||
test('_leftyPref reads highway.getLefty with a localStorage fallback', () => {
|
||||
assert.match(
|
||||
BADGES,
|
||||
/function _leftyPref\(\)\s*\{[\s\S]*?getLefty[\s\S]*?localStorage\.getItem\('lefty'\)/,
|
||||
'_leftyPref reads the current handedness with a storage fallback');
|
||||
});
|
||||
|
||||
test('onboarding instrument step calls out left-handed players + the Handedness control', () => {
|
||||
assert.match(TOUR, /Choose your instrument/);
|
||||
assert.match(TOUR, /left-handed/i, 'the instrument step must call out left-handed players');
|
||||
assert.match(TOUR, /Handedness/, 'and name the Handedness control');
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
||||
const m = src.match(/\/\* @pure:editor-pending-view:start \*\/[\s\S]*?\/\* @pure:editor-pending-view:end \*\//);
|
||||
if (!m) throw new Error('pending-view helper block not found');
|
||||
|
||||
const api = new Function('"use strict";' + m[0] + '\nreturn { _buildEditorPendingViewPure };')();
|
||||
|
||||
test('edit-region handoff defaults cursor to region start and marks return path', () => {
|
||||
const out = api._buildEditorPendingViewPure('song.sloppak', 2, { a: 12.5, b: 20 }, { returnToHighway: true });
|
||||
assert.deepStrictEqual(out, {
|
||||
filename: 'song.sloppak',
|
||||
arrangement: 2,
|
||||
barSel: { startTime: 12.5, endTime: 20 },
|
||||
returnToHighway: true,
|
||||
cursorTime: 12.5,
|
||||
});
|
||||
});
|
||||
|
||||
test('return-trip handoff preserves explicit viewport state', () => {
|
||||
const out = api._buildEditorPendingViewPure('song.sloppak', 1, { a: 8, b: 14 }, {
|
||||
scrollX: -4,
|
||||
zoom: 160,
|
||||
cursorTime: 9.25,
|
||||
});
|
||||
assert.deepStrictEqual(out, {
|
||||
filename: 'song.sloppak',
|
||||
arrangement: 1,
|
||||
barSel: { startTime: 8, endTime: 14 },
|
||||
cursorTime: 9.25,
|
||||
scrollX: 0,
|
||||
zoom: 160,
|
||||
});
|
||||
});
|
||||
|
||||
test('missing region still produces a stable pending view shell', () => {
|
||||
const out = api._buildEditorPendingViewPure('song.sloppak', -1, null, {});
|
||||
assert.deepStrictEqual(out, {
|
||||
filename: 'song.sloppak',
|
||||
arrangement: 0,
|
||||
barSel: null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Contract test: 3D Highway WebGL context-loss recovery.
|
||||
//
|
||||
// Switching the active window / alt-tabbing (especially on Windows) can trigger
|
||||
// a GPU context reset. Without a handler the lost WebGL context escalates into a
|
||||
// render-process crash. The renderer owns its own WebGL canvas + heavy Three.js
|
||||
// lifecycle (too much to construct in a vm sandbox), so — like the other
|
||||
// highway_* source-contract tests here — this pins the wiring at the source
|
||||
// level: the loss must be preventDefault()'d (so the browser restores it), draw
|
||||
// must bail while lost, and the listeners must be torn down.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
|
||||
test('binds webglcontextlost + webglcontextrestored on the renderer canvas', () => {
|
||||
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextlost['"]/,
|
||||
'must listen for webglcontextlost on ren.domElement (the WebGL canvas)');
|
||||
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextrestored['"]/,
|
||||
'must listen for webglcontextrestored on ren.domElement');
|
||||
});
|
||||
|
||||
test('the context-lost handler preventDefaults and pauses drawing', () => {
|
||||
// Without preventDefault() the browser will not attempt to restore the
|
||||
// context and the loss can escalate to a renderer crash.
|
||||
const m = src.match(/_onCtxLost\s*=\s*\(e\)\s*=>\s*\{[\s\S]*?\};/);
|
||||
assert.ok(m, '_onCtxLost handler must exist');
|
||||
assert.match(m[0], /preventDefault\(\)/, 'context-lost handler must call preventDefault()');
|
||||
assert.match(m[0], /_ctxLost\s*=\s*true/, 'context-lost handler must set _ctxLost = true');
|
||||
});
|
||||
|
||||
test('draw() early-returns while the context is lost', () => {
|
||||
assert.match(src, /draw\(bundle\)\s*\{[\s\S]*?if\s*\(_ctxLost\)\s*return;/,
|
||||
'draw() must bail while _ctxLost is set so no GL work runs on a dead context');
|
||||
});
|
||||
|
||||
test('teardown removes the context-loss listeners', () => {
|
||||
assert.match(src, /removeEventListener\(\s*['"]webglcontextlost['"]/,
|
||||
'teardown must remove the webglcontextlost listener');
|
||||
assert.match(src, /removeEventListener\(\s*['"]webglcontextrestored['"]/,
|
||||
'teardown must remove the webglcontextrestored listener');
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Tests for the per-field metadata override + lock store (Fix-metadata popup).
|
||||
|
||||
A reversible DISPLAY overlay, never written to the pack: filename-keyed, so it
|
||||
survives a rescan (never purged by delete_missing) and is dropped only with the
|
||||
song (delete_song). Locks pin a field against a later auto-match.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch, isolate_logging):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
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)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server):
|
||||
return TestClient(server.app)
|
||||
|
||||
|
||||
def _put(server, fn, **meta):
|
||||
base = {"title": "Song", "artist": "Artist", "album": "", "duration": 100,
|
||||
"arrangements": [{"name": "Lead", "index": 0}]}
|
||||
base.update(meta)
|
||||
server.meta_db.put(fn, 0, 0, base)
|
||||
|
||||
|
||||
# ── store semantics ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_set_get_and_partial_upsert(server):
|
||||
db = server.meta_db
|
||||
assert db.get_song_overrides("a.archive") == {}
|
||||
db.set_song_override("a.archive", "artist", value="AC/DC")
|
||||
assert db.get_song_overrides("a.archive") == {"artist": {"value": "AC/DC", "locked": False}}
|
||||
# partial: lock without touching the value
|
||||
db.set_song_override("a.archive", "artist", locked=True)
|
||||
assert db.get_song_overrides("a.archive")["artist"] == {"value": "AC/DC", "locked": True}
|
||||
# partial: change the value, keep the lock
|
||||
db.set_song_override("a.archive", "artist", value="AC/DC (fixed)")
|
||||
assert db.get_song_overrides("a.archive")["artist"] == {"value": "AC/DC (fixed)", "locked": True}
|
||||
|
||||
|
||||
def test_lock_only_row_persists_without_a_value(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "year", locked=True)
|
||||
# a pure lock (no override value) is a valid, kept row
|
||||
assert db.get_song_overrides("a.archive") == {"year": {"value": None, "locked": True}}
|
||||
|
||||
|
||||
def test_empty_and_unlocked_drops_the_row(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "album", value="X", locked=True)
|
||||
db.set_song_override("a.archive", "album", value="", locked=False)
|
||||
assert db.get_song_overrides("a.archive") == {} # no empty shell
|
||||
|
||||
|
||||
def test_clear_one_field_leaves_others(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "title", value="T")
|
||||
db.set_song_override("a.archive", "artist", value="A")
|
||||
db.clear_song_override("a.archive", "title")
|
||||
assert set(db.get_song_overrides("a.archive")) == {"artist"}
|
||||
|
||||
|
||||
# ── lifecycle: rescan survival vs explicit delete ─────────────────────────────
|
||||
|
||||
def test_rescan_never_purges_overrides_delete_does(server):
|
||||
_put(server, "a.archive")
|
||||
server.meta_db.set_song_override("a.archive", "artist", value="AC/DC", locked=True)
|
||||
server.meta_db.delete_missing(set()) # file vanished from a scan
|
||||
assert server.meta_db.get_song_overrides("a.archive")["artist"]["value"] == "AC/DC"
|
||||
server.meta_db.purge_song_user_data("a.archive") # the delete_song purge
|
||||
assert server.meta_db.get_song_overrides("a.archive") == {}
|
||||
|
||||
|
||||
def test_overrides_map_batches(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "artist", value="A")
|
||||
db.set_song_override("b.archive", "title", value="B", locked=True)
|
||||
m = db.overrides_map(["a.archive", "b.archive", "missing.archive"])
|
||||
assert m["a.archive"]["artist"]["value"] == "A"
|
||||
assert m["b.archive"]["title"] == {"value": "B", "locked": True}
|
||||
assert "missing.archive" not in m
|
||||
assert db.overrides_map([]) == {}
|
||||
|
||||
|
||||
# ── API ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_api_put_get_and_clear(client, server):
|
||||
_put(server, "a.archive")
|
||||
r = client.put("/api/song/a.archive/overrides",
|
||||
json={"overrides": {"artist": {"value": "AC/DC", "locked": True},
|
||||
"year": {"value": "1979"}}})
|
||||
assert r.status_code == 200
|
||||
ov = r.json()["overrides"]
|
||||
assert ov["artist"] == {"value": "AC/DC", "locked": True}
|
||||
assert ov["year"] == {"value": "1979", "locked": False}
|
||||
assert client.get("/api/song/a.archive/overrides").json()["overrides"]["artist"]["value"] == "AC/DC"
|
||||
# clear via PUT (value null + unlocked) — DELETE is shadowed by /api/song/{path}
|
||||
client.put("/api/song/a.archive/overrides",
|
||||
json={"overrides": {"artist": {"value": None, "locked": False}}})
|
||||
assert "artist" not in client.get("/api/song/a.archive/overrides").json()["overrides"]
|
||||
|
||||
|
||||
def test_api_get_returns_pack_values(client, server):
|
||||
_put(server, "a.archive", title="Pack Title", artist="Pack Artist",
|
||||
album="Pack Album", year="1988")
|
||||
server.meta_db.set_song_override("a.archive", "title", value="Fixed Title")
|
||||
body = client.get("/api/song/a.archive/overrides").json()
|
||||
# the override rides "overrides"; the pack baseline rides "pack" (all 5 fields)
|
||||
assert body["overrides"]["title"]["value"] == "Fixed Title"
|
||||
assert body["pack"] == {"title": "Pack Title", "artist": "Pack Artist",
|
||||
"album": "Pack Album", "year": "1988", "genre": ""}
|
||||
# a song with no row still gets an all-empty pack (popup always has values)
|
||||
assert client.get("/api/song/ghost.archive/overrides").json()["pack"]["title"] == ""
|
||||
|
||||
|
||||
def test_api_rejects_unknown_field(client, server):
|
||||
_put(server, "a.archive")
|
||||
r = client.put("/api/song/a.archive/overrides",
|
||||
json={"overrides": {"tuning": {"value": "Drop D"}}})
|
||||
assert r.status_code == 400
|
||||
assert "unknown field" in r.json()["error"]
|
||||
|
||||
|
||||
# ── lock enforcement (slice 2) ────────────────────────────────────────────────
|
||||
|
||||
def test_locked_fields_reader(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "artist", value="X", locked=True)
|
||||
db.set_song_override("a.archive", "title", value="Y") # override, not locked
|
||||
db.set_song_override("a.archive", "year", locked=True) # lock only
|
||||
assert db.locked_fields("a.archive") == {"artist", "year"}
|
||||
|
||||
|
||||
def test_compose_lock_filter_strips_locked_cand_keys(server):
|
||||
f = server._compose_lock_filter(None, {"artist", "year"})
|
||||
cand = {"recording_id": "r", "artist": "X", "artist_sort": "X", "title": "T",
|
||||
"year": "1990", "album": "A", "genres": ["rock"]}
|
||||
out = f(cand)
|
||||
# locked display keys stripped (artist maps to artist + artist_sort)…
|
||||
assert not ({"artist", "artist_sort", "year"} & set(out))
|
||||
# …identity + unlocked display fields survive
|
||||
assert out["recording_id"] == "r" and out["title"] == "T" and out["album"] == "A"
|
||||
# no locks → base filter returned unchanged (zero-copy common path)
|
||||
assert server._compose_lock_filter(None, set()) is None
|
||||
|
||||
|
||||
# ── display overlay in the grid (slice 3) ─────────────────────────────────────
|
||||
# "Grid shows only overrides": the effective cell is the user's override else the
|
||||
# pack value. Display-only + keyset-safe — the seek stays on the raw column.
|
||||
|
||||
def _grid(server, **kw):
|
||||
songs, _ = server.meta_db.query_page(**kw)
|
||||
return {s["filename"]: s for s in songs}
|
||||
|
||||
|
||||
def test_grid_shows_override_value_over_pack(server):
|
||||
_put(server, "a.archive", title="Wrong Title", artist="Wrong",
|
||||
album="Pack Album", year="1999")
|
||||
server.meta_db.set_song_override("a.archive", "title", value="Right Title")
|
||||
server.meta_db.set_song_override("a.archive", "artist", value="Right Artist")
|
||||
server.meta_db.set_song_override("a.archive", "year", value="1979")
|
||||
s = _grid(server)["a.archive"]
|
||||
assert s["title"] == "Right Title"
|
||||
assert s["artist"] == "Right Artist"
|
||||
assert s["year"] == "1979"
|
||||
assert s["album"] == "Pack Album" # no override → pack value shows
|
||||
assert s["_sort_title"] == "Wrong Title" # raw title stashed for the cursor
|
||||
|
||||
|
||||
def test_grid_ignores_lock_only_override(server):
|
||||
_put(server, "a.archive", title="Pack Title")
|
||||
server.meta_db.set_song_override("a.archive", "title", locked=True) # lock, no value
|
||||
s = _grid(server)["a.archive"]
|
||||
assert s["title"] == "Pack Title" # a lock without a value never retitles
|
||||
assert "_sort_title" not in s # …and stashes nothing
|
||||
|
||||
|
||||
def test_override_beats_alias_relabel_for_artist(server):
|
||||
_put(server, "a.archive", artist="ACDC")
|
||||
server.meta_db.set_artist_alias("ACDC", "AC/DC") # P4 alias
|
||||
assert _grid(server)["a.archive"]["artist"] == "AC/DC" # alias applies alone
|
||||
server.meta_db.set_song_override("a.archive", "artist", value="AC-DC (mine)")
|
||||
assert _grid(server)["a.archive"]["artist"] == "AC-DC (mine)" # override wins over alias
|
||||
|
||||
|
||||
def test_route_strips_private_sort_title(client, server):
|
||||
_put(server, "a.archive", title="Pack")
|
||||
server.meta_db.set_song_override("a.archive", "title", value="Shown")
|
||||
row = next(s for s in client.get("/api/library?sort=title").json()["songs"]
|
||||
if s["filename"] == "a.archive")
|
||||
assert row["title"] == "Shown"
|
||||
assert "_sort_title" not in row # private keyset stash never leaks to the client
|
||||
|
||||
|
||||
def test_genre_override_drives_facet_and_filter(client, server):
|
||||
# a.archive: pack genre "Rock"; b.archive: blank genre, overridden to "City Pop".
|
||||
_put(server, "a.archive", title="A", genre="Rock")
|
||||
_put(server, "b.archive", title="B", genre="")
|
||||
server.meta_db.set_song_override("b.archive", "genre", value="City Pop")
|
||||
# Facet lists the EFFECTIVE genres (override surfaces; empty raw doesn't).
|
||||
genres = client.get("/api/library/genres").json()["genres"]
|
||||
assert "City Pop" in genres and "Rock" in genres
|
||||
# Filtering by the override genre returns the overridden song…
|
||||
fns = [s["filename"] for s in client.get("/api/library?genre=City%20Pop").json()["songs"]]
|
||||
assert fns == ["b.archive"]
|
||||
# …and its raw (blank) genre no longer matches a stale query for it.
|
||||
rock = [s["filename"] for s in client.get("/api/library?genre=Rock").json()["songs"]]
|
||||
assert rock == ["a.archive"]
|
||||
|
||||
|
||||
def test_lock_only_genre_does_not_change_facet(server):
|
||||
# A pure lock (no value) must not invent an effective genre.
|
||||
_put(server, "a.archive", title="A", genre="Metal")
|
||||
server.meta_db.set_song_override("a.archive", "genre", locked=True)
|
||||
assert server.meta_db._has_genre_overrides() is False # value-less rows don't count
|
||||
assert server.meta_db._effective_genre_expr() == "genre"
|
||||
|
||||
|
||||
def test_romaji_fallback_for_blank_artist_pack(server):
|
||||
fn = "CDLC/0 - City Pop/Junko-Yagami_BAY-CITY_v1_p.feedpak"
|
||||
_put(server, fn, title="Junko-Yagami_BAY-CITY_v1_p", artist="") # scanner fell back to the filename
|
||||
s = {x["filename"]: x for x in server.meta_db.query_page()[0]}[fn]
|
||||
# the grid shows the author's romaji, not blank / the raw filename / kanji
|
||||
assert s["artist"] == "Junko Yagami"
|
||||
assert s["title"] == "BAY CITY"
|
||||
# the Details baseline (pack_fields) matches, so the popup agrees with the grid
|
||||
pack = server.meta_db.pack_fields(fn)
|
||||
assert pack["artist"] == "Junko Yagami" and pack["title"] == "BAY CITY"
|
||||
|
||||
|
||||
def test_romaji_fallback_left_alone_when_pack_has_artist(server):
|
||||
_put(server, "a.archive", title="Real Title", artist="Real Artist")
|
||||
s = {x["filename"]: x for x in server.meta_db.query_page()[0]}["a.archive"]
|
||||
assert s["artist"] == "Real Artist" and s["title"] == "Real Title"
|
||||
|
||||
|
||||
def test_title_keyset_paging_is_complete_with_overrides(client, server):
|
||||
# Raw titles A/B/C → title-sort order is A, B, C on the RAW column.
|
||||
_put(server, "b.archive", title="B")
|
||||
_put(server, "a.archive", title="A")
|
||||
_put(server, "c.archive", title="C")
|
||||
# Overrides that would reshuffle the order IF the cursor wrongly used the
|
||||
# displayed value — the seek must stay on the raw title, so paging still
|
||||
# covers every row exactly once (no skip/dupe).
|
||||
server.meta_db.set_song_override("a.archive", "title", value="ZZZ")
|
||||
server.meta_db.set_song_override("c.archive", "title", value="AAA")
|
||||
seen, cursor = [], None
|
||||
for _ in range(10):
|
||||
url = "/api/library?sort=title&size=1" + (f"&after={cursor}" if cursor else "")
|
||||
data = client.get(url).json()
|
||||
if not data["songs"]:
|
||||
break
|
||||
seen.append(data["songs"][0]["filename"])
|
||||
cursor = data["next_cursor"]
|
||||
if not cursor:
|
||||
break
|
||||
assert sorted(seen) == ["a.archive", "b.archive", "c.archive"] # each exactly once
|
||||
@@ -110,6 +110,19 @@ def test_preview_excludes_author_set_keys(server, client):
|
||||
assert {"genres", "mbid", "isrc"} <= got
|
||||
|
||||
|
||||
def test_preview_excludes_locked_fields(server, client):
|
||||
"""A field LOCKED in the Fix-metadata popup is never gap-filled — writing
|
||||
the matched value would be exactly the clobber the lock prevents — even
|
||||
though the match has a value and the manifest lacks it."""
|
||||
make_dir_sloppak(server, "a.sloppak")
|
||||
seed_match(server, "a.sloppak")
|
||||
server.meta_db.set_song_override("a.sloppak", "album", locked=True)
|
||||
server.meta_db.set_song_override("a.sloppak", "year", locked=True)
|
||||
got = {m["key"] for m in client.get("/api/song/a.sloppak/gap-fill").json()["missing"]}
|
||||
assert "album" not in got and "year" not in got
|
||||
assert {"genres", "mbid", "isrc"} <= got # unlocked keys still offered
|
||||
|
||||
|
||||
def test_preview_excludes_present_but_empty_keys(server, client):
|
||||
"""Gap-fill is append-only, so a present-but-empty value (album: '',
|
||||
year: 0) is NOT a gap the writer can fill — appending would duplicate the
|
||||
|
||||
@@ -185,6 +185,22 @@ def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
|
||||
assert row["mb_recording_id"] == "rec-jp"
|
||||
|
||||
|
||||
# ── per-song field locks respected by the auto-matcher ───────────────────────
|
||||
|
||||
def test_locked_field_not_canonicalized_by_auto_match(server, monkeypatch):
|
||||
_put(server, "x.sloppak") # title "Thunderstruck (v2)", artist "ACDC"
|
||||
server.meta_db.set_song_override("x.sloppak", "artist", locked=True)
|
||||
monkeypatch.setattr(server, "_mb_http_get",
|
||||
lambda path, params: {"recordings": [mb_doc()]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
server._background_enrich()
|
||||
row = server.meta_db.get_enrichment("x.sloppak")
|
||||
assert row["match_state"] == "matched" # still matches (identity applies)…
|
||||
assert row["canon_artist"] is None # …but the LOCKED artist isn't canonicalized
|
||||
assert row["canon_title"] == "Thunderstruck" # unlocked display fields still apply
|
||||
assert row["mb_recording_id"] # identity keys still stored (art needs them)
|
||||
|
||||
|
||||
# ── offline safety (the pytest-never-hits-network contract) ──────────────────
|
||||
|
||||
def test_offline_default_skips_matching(server, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user