mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 14:54:29 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b10bad351c | ||
|
|
de002cdc24 | ||
|
|
3100d68a45 | ||
|
|
6397a959a4 | ||
|
|
5f499a8a3a | ||
|
|
af1170cec3 | ||
|
|
3e036e3db6 | ||
|
|
92dc321fdf | ||
|
|
74cff4e0d6 |
@@ -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
|
||||
- **"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`.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1506,6 +1506,10 @@ def convert_file(
|
||||
# Surface that to the caller rather than only the docstring: if the score
|
||||
# actually uses repeats, the produced bar count/timing will differ from the
|
||||
# equivalent .gp5. Warn once so plugin code/logs don't silently drift.
|
||||
# NB: lib/gp_autosync.gp_has_expandable_repeats() encodes this single-pass
|
||||
# behaviour (.gp/.gpx never expand). Implementing GPIF expansion here MUST
|
||||
# update that helper in the same change, or the editor's per-bar sync warp
|
||||
# would silently retime repeated sections onto the wrong bars.
|
||||
if expand_repeats and any(
|
||||
mb.find('Repeat') is not None or mb.find('AlternateEndings') is not None
|
||||
for mb in masterbars
|
||||
|
||||
+524
-29
@@ -18,8 +18,22 @@ plugin is installed; graceful ImportError otherwise with clear message).
|
||||
Public API:
|
||||
is_available() -> bool
|
||||
auto_sync(gp_path, audio_path, ...) -> GpSyncData
|
||||
refine_sync(sync, audio_path, ...) -> GpSyncData
|
||||
estimate_audio_offset(gp_path,
|
||||
audio_path) -> float
|
||||
bar_start_times(gp_path) -> list[float]
|
||||
gp_has_expandable_repeats(gp_path) -> bool
|
||||
build_warp_anchors(sync_points,
|
||||
bar_starts) -> list[tuple[float, float]]
|
||||
warp_time(t, anchors) -> float
|
||||
warp_song_times(song, warp) -> None
|
||||
|
||||
The warp helpers (bar_start_times / build_warp_anchors / warp_time /
|
||||
warp_song_times) are librosa-free: they turn a GpSyncData produced by
|
||||
auto_sync (or extracted from a GP8 file) into a piecewise-linear
|
||||
score-time -> audio-time mapping and apply it to a lib.song.Song, so
|
||||
converted charts follow the recording's actual tempo drift instead of a
|
||||
single scalar offset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -353,15 +367,22 @@ def _synthesise_score_chroma(
|
||||
return chroma
|
||||
|
||||
_GP345_TICKS_PER_QUARTER = 960
|
||||
# PyGuitarPro absolute ticks start at quarterTime (measure 1 begins at tick
|
||||
# 960, not 0). All tick math in this module runs on a 0-based axis (cumulative
|
||||
# measure starts), so raw beat.start values must be shifted by this origin —
|
||||
# mixing the two axes applied every mid-song tempo change a quarter note late
|
||||
# and skewed the synthesised chroma against the bar timeline.
|
||||
_GP345_TICK_ORIGIN = 960
|
||||
|
||||
|
||||
def _gp345_tempo_events(song) -> list[tuple[int, float]]:
|
||||
"""Sorted, tick-deduplicated ``[(tick, bpm)]`` tempo events for a GP3/4/5 song.
|
||||
|
||||
Seeds with the song's initial tempo at tick 0, then appends every
|
||||
``mixTableChange`` tempo. Shared by chroma synthesis and bar-time
|
||||
computation so both use one identical tempo model (mirrors
|
||||
``gp2rs._build_tempo_map``).
|
||||
``mixTableChange`` tempo. Ticks are normalised to the 0-based axis
|
||||
(raw ``beat.start`` minus ``_GP345_TICK_ORIGIN``). Shared by chroma
|
||||
synthesis and bar-time computation so both use one identical tempo
|
||||
model (mirrors ``gp2rs._build_tempo_map``).
|
||||
"""
|
||||
events: list[tuple[int, float]] = [(0, float(song.tempo))]
|
||||
for track in song.tracks:
|
||||
@@ -371,7 +392,10 @@ def _gp345_tempo_events(song) -> list[tuple[int, float]]:
|
||||
if beat.effect and beat.effect.mixTableChange:
|
||||
mtc = beat.effect.mixTableChange
|
||||
if mtc.tempo and mtc.tempo.value > 0:
|
||||
events.append((beat.start, float(mtc.tempo.value)))
|
||||
events.append((
|
||||
max(0, beat.start - _GP345_TICK_ORIGIN),
|
||||
float(mtc.tempo.value),
|
||||
))
|
||||
events.sort(key=lambda e: e[0])
|
||||
seen_ticks: set[int] = set()
|
||||
unique: list[tuple[int, float]] = []
|
||||
@@ -459,8 +483,9 @@ def _synthesise_score_chroma_gp345(
|
||||
for beat in voice.beats:
|
||||
if not beat.notes:
|
||||
continue
|
||||
beat_secs = tick_to_secs(beat.start)
|
||||
cur_tempo = tempo_at_tick(beat.start)
|
||||
beat_tick = max(0, beat.start - _GP345_TICK_ORIGIN)
|
||||
beat_secs = tick_to_secs(beat_tick)
|
||||
cur_tempo = tempo_at_tick(beat_tick)
|
||||
dur_secs = duration_to_secs(beat.duration, cur_tempo)
|
||||
|
||||
for note in beat.notes:
|
||||
@@ -513,13 +538,75 @@ 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 ──────────────────────────────────────
|
||||
|
||||
def _gpif_bar_starts(root: ET.Element) -> list[float]:
|
||||
"""Score-time (seconds) at the start of each masterbar in a GPIF score.
|
||||
|
||||
Integrates bar durations from the bar-resolution tempo map and each
|
||||
masterbar's time signature — the same time model _synthesise_score_chroma
|
||||
uses, so bar times land where the bars sit in the synthesised chroma.
|
||||
"""
|
||||
tempo_map = _get_tempo_map(root)
|
||||
masterbars = _children(root, 'MasterBars')
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
|
||||
ct = tempo_map[0][1]
|
||||
t_cur = 0.0
|
||||
bar_starts: list[float] = []
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tb:
|
||||
ct = next_bpm
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, ct))
|
||||
bar_starts.append(t_cur)
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
n_b, d_b = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
n_b, d_b = 4, 4
|
||||
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
|
||||
return bar_starts
|
||||
|
||||
|
||||
def _gp345_measure_start_ticks(song) -> list[int]:
|
||||
"""Cumulative start tick of each measure in a PyGuitarPro song."""
|
||||
starts: list[int] = []
|
||||
cum = 0
|
||||
for mh in song.measureHeaders:
|
||||
starts.append(cum)
|
||||
ts = mh.timeSignature
|
||||
cum += int(ts.numerator * (4.0 / ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
|
||||
return starts
|
||||
|
||||
|
||||
def _extract_sync_points(
|
||||
wp: 'np.ndarray',
|
||||
root: ET.Element,
|
||||
@@ -565,22 +652,7 @@ def _extract_sync_points(
|
||||
if bar_starts_override is not None:
|
||||
bar_starts_score = list(bar_starts_override)
|
||||
else:
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
|
||||
ct = tempo_map[0][1]
|
||||
t_cur = 0.0
|
||||
bar_starts_score = []
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tb:
|
||||
ct = next_bpm
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, ct))
|
||||
bar_starts_score.append(t_cur)
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
n_b, d_b = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
n_b, d_b = 4, 4
|
||||
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
|
||||
bar_starts_score = _gpif_bar_starts(root)
|
||||
|
||||
# Map each sampled bar to its audio time via the DTW path
|
||||
sync_points: list[SyncPoint] = []
|
||||
@@ -663,6 +735,224 @@ def _tempo_at_bar(tempo_map: list[tuple[int, float]], bar: int) -> float:
|
||||
|
||||
# ── Audio offset estimation ───────────────────────────────────────────────────
|
||||
|
||||
# ── Piecewise time warp (librosa-free) ───────────────────────────────────────
|
||||
#
|
||||
# auto_sync's per-bar sync points describe where each sampled bar of the tab
|
||||
# falls in the real recording. Applying only the scalar audio_offset (bar 1)
|
||||
# assumes the recording holds the authored tempo for the whole song — any
|
||||
# drift accumulates. These helpers build the full piecewise-linear
|
||||
# score-time -> audio-time mapping and apply it to a converted Song, so the
|
||||
# chart follows the recording bar by bar (Songsterr-style sync).
|
||||
|
||||
def bar_start_times(gp_path: str) -> list[float]:
|
||||
"""Score-time (seconds) at the start of every bar of a GP file.
|
||||
|
||||
Uses the same tempo models as auto_sync's chroma synthesis (GPIF
|
||||
bar-resolution map for .gp/.gpx, per-tick integration for .gp3/4/5), so
|
||||
the returned times share an axis with auto_sync's sync points.
|
||||
|
||||
Raises ValueError if the file cannot be parsed, ImportError if the file
|
||||
is GP3/4/5 and PyGuitarPro is not installed.
|
||||
"""
|
||||
try:
|
||||
root = _load_gpif(gp_path)
|
||||
except _Gp345FileError:
|
||||
import guitarpro
|
||||
try:
|
||||
song = guitarpro.parse(gp_path)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Cannot parse GP3/4/5 file {gp_path!r}: {exc}") from exc
|
||||
tempo_events = _gp345_tempo_events(song)
|
||||
return [
|
||||
_gp345_tick_to_secs(tempo_events, tick)
|
||||
for tick in _gp345_measure_start_ticks(song)
|
||||
]
|
||||
return _gpif_bar_starts(root)
|
||||
|
||||
|
||||
def gp_has_expandable_repeats(gp_path: str) -> bool:
|
||||
"""True when converting `gp_path` expands repeats into a longer timeline
|
||||
than the as-written score auto_sync aligned against.
|
||||
|
||||
gp2rs.convert_file walks the GP3/4/5 playback graph (repeat brackets,
|
||||
voltas, D.S./D.C. directions), so a file using any of those produces an
|
||||
as-performed timeline that auto_sync's as-written sync points cannot be
|
||||
mapped onto. GPIF (.gp/.gpx) conversion is single-pass as-written today,
|
||||
so those files always return False — both sides share one bar order.
|
||||
|
||||
Returns False when the file cannot be parsed (callers fall back to
|
||||
offset-only sync on parse failure anyway).
|
||||
"""
|
||||
if Path(gp_path).suffix.lower() in ('.gp', '.gpx'):
|
||||
return False
|
||||
try:
|
||||
import guitarpro
|
||||
song = guitarpro.parse(gp_path)
|
||||
except Exception:
|
||||
return False
|
||||
for mh in song.measureHeaders:
|
||||
if mh.isRepeatOpen or mh.repeatClose >= 0 or mh.repeatAlternative:
|
||||
return True
|
||||
# Both jump SOURCES (fromDirection: D.C., D.S., Da Coda) and jump
|
||||
# TARGETS (direction: Segno, Coda, Fine) count — a plain Da Capo
|
||||
# needs no target marker, so checking `direction` alone would miss
|
||||
# it while gp2rs's playback walker still expands the jump.
|
||||
if (getattr(mh, 'direction', None) is not None
|
||||
or getattr(mh, 'fromDirection', None) is not None):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_warp_anchors(
|
||||
sync_points: list[SyncPoint],
|
||||
bar_starts: list[float],
|
||||
) -> list[tuple[float, float]]:
|
||||
"""Turn sync points into (score_secs, audio_secs) anchor pairs.
|
||||
|
||||
Drops points whose bar index is out of range, points that would break
|
||||
strict monotonicity on either axis (DTW can locally fold on noisy audio;
|
||||
a non-monotonic anchor would make the warp non-invertible and reorder
|
||||
notes), and points whose segment slope implies a physically implausible
|
||||
tempo ratio (outside 0.2x-5x authored). Returns [] when fewer than 2
|
||||
usable anchors remain — callers should fall back to scalar-offset sync
|
||||
in that case.
|
||||
"""
|
||||
anchors: list[tuple[float, float]] = []
|
||||
for sp in sorted(sync_points, key=lambda p: p.bar):
|
||||
if not 0 <= sp.bar < len(bar_starts):
|
||||
continue
|
||||
score_t = bar_starts[sp.bar]
|
||||
audio_t = float(sp.time_secs)
|
||||
if anchors and (score_t <= anchors[-1][0] + 1e-6
|
||||
or audio_t <= anchors[-1][1] + 1e-3):
|
||||
continue
|
||||
if anchors:
|
||||
# Slope sanity gate: a segment whose audio/score tempo ratio is
|
||||
# outside [0.2, 5] is not a performance — it's a DTW fold onto a
|
||||
# repeated section, an abridged recording, or a run of
|
||||
# monotonicity-clamped refine points. Keeping it would crush (or
|
||||
# absurdly stretch) every bar in the span, which is far worse
|
||||
# than interpolating through from the neighbouring anchors.
|
||||
slope = (audio_t - anchors[-1][1]) / (score_t - anchors[-1][0])
|
||||
if not 0.2 <= slope <= 5.0:
|
||||
continue
|
||||
anchors.append((score_t, audio_t))
|
||||
return anchors if len(anchors) >= 2 else []
|
||||
|
||||
|
||||
def warp_time(t: float, anchors: list[tuple[float, float]]) -> float:
|
||||
"""Map a score-time (seconds) to audio-time via piecewise-linear anchors.
|
||||
|
||||
Between anchors: linear interpolation. Outside the anchor range: the
|
||||
nearest segment's slope is extended, so a count-in before bar 1 and the
|
||||
tail after the last sampled bar keep the local tempo ratio.
|
||||
|
||||
`anchors` must be the >=2-point strictly-monotonic list produced by
|
||||
build_warp_anchors.
|
||||
"""
|
||||
lo = 0
|
||||
hi = len(anchors) - 1
|
||||
if t <= anchors[0][0]:
|
||||
seg = (anchors[0], anchors[1])
|
||||
elif t >= anchors[hi][0]:
|
||||
seg = (anchors[hi - 1], anchors[hi])
|
||||
else:
|
||||
# Binary search for the segment containing t
|
||||
while hi - lo > 1:
|
||||
mid = (lo + hi) // 2
|
||||
if anchors[mid][0] <= t:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
seg = (anchors[lo], anchors[hi])
|
||||
(s0, a0), (s1, a1) = seg
|
||||
slope = (a1 - a0) / (s1 - s0)
|
||||
return a0 + (t - s0) * slope
|
||||
|
||||
|
||||
def warp_song_times(song, warp) -> None:
|
||||
"""Apply a monotonic time-mapping callable to every absolute time in a
|
||||
lib.song.Song, in place.
|
||||
|
||||
Covers beats, sections, song_length, and per-arrangement notes (onset +
|
||||
sustain), chords (incl. chord notes), anchors, hand shapes, per-phrase
|
||||
difficulty levels, tone changes, and tempo overrides. Durations (note
|
||||
sustain, handshape span) are warped as end-start so they stretch with the
|
||||
local tempo ratio; sub-second intra-note envelopes (bend curves, which are
|
||||
relative to the note onset) are left untouched.
|
||||
|
||||
Duck-typed: accepts any object with the lib.song.Song surface.
|
||||
|
||||
Identity-safe: parse_arrangement shares the SAME Note/Chord/Anchor/
|
||||
HandShape objects between the flat arrangement lists and the
|
||||
max-difficulty phrase level, so each object is warped at most once no
|
||||
matter how many containers reference it.
|
||||
"""
|
||||
seen: set[int] = set()
|
||||
|
||||
def _once(obj) -> bool:
|
||||
key = id(obj)
|
||||
if key in seen:
|
||||
return False
|
||||
seen.add(key)
|
||||
return True
|
||||
|
||||
def _warp_notes(notes):
|
||||
for n in notes or []:
|
||||
if not _once(n):
|
||||
continue
|
||||
end = warp(n.time + n.sustain)
|
||||
n.time = warp(n.time)
|
||||
n.sustain = max(0.0, end - n.time)
|
||||
|
||||
def _warp_chords(chords):
|
||||
for c in chords or []:
|
||||
if not _once(c):
|
||||
continue
|
||||
c.time = warp(c.time)
|
||||
_warp_notes(c.notes)
|
||||
|
||||
def _warp_anchors(anchors):
|
||||
for a in anchors or []:
|
||||
if _once(a):
|
||||
a.time = warp(a.time)
|
||||
|
||||
def _warp_handshapes(shapes):
|
||||
for h in shapes or []:
|
||||
if not _once(h):
|
||||
continue
|
||||
start = warp(h.start_time)
|
||||
end = warp(h.end_time)
|
||||
h.start_time = start
|
||||
h.end_time = max(start, end)
|
||||
|
||||
song.song_length = max(0.0, warp(song.song_length))
|
||||
for b in song.beats:
|
||||
b.time = warp(b.time)
|
||||
for s in song.sections:
|
||||
s.start_time = warp(s.start_time)
|
||||
for arr in song.arrangements:
|
||||
_warp_notes(arr.notes)
|
||||
_warp_chords(arr.chords)
|
||||
_warp_anchors(arr.anchors)
|
||||
_warp_handshapes(arr.hand_shapes)
|
||||
for ph in arr.phrases or []:
|
||||
ph.start_time = warp(ph.start_time)
|
||||
ph.end_time = warp(ph.end_time)
|
||||
for lvl in ph.levels or []:
|
||||
_warp_notes(lvl.notes)
|
||||
_warp_chords(lvl.chords)
|
||||
_warp_anchors(lvl.anchors)
|
||||
_warp_handshapes(lvl.hand_shapes)
|
||||
if arr.tones and isinstance(arr.tones, dict):
|
||||
for change in arr.tones.get('changes') or []:
|
||||
if isinstance(change, dict) and isinstance(change.get('t'), (int, float)):
|
||||
change['t'] = warp(float(change['t']))
|
||||
for tempo_ev in arr.tempos or []:
|
||||
if isinstance(tempo_ev, dict) and isinstance(tempo_ev.get('time'), (int, float)):
|
||||
tempo_ev['time'] = warp(float(tempo_ev['time']))
|
||||
|
||||
|
||||
def _estimate_audio_offset(
|
||||
root: ET.Element,
|
||||
audio_path: str,
|
||||
@@ -932,12 +1222,7 @@ def auto_sync(
|
||||
# below line up with the chroma timeline.
|
||||
_tempo_events_gp345 = _gp345_tempo_events(_gp345x_song)
|
||||
# Convert tick events to bar events using actual measure start ticks
|
||||
_measure_starts = [] # cumulative tick at start of each bar
|
||||
_cum = 0
|
||||
for _mh2 in _gp345x_song.measureHeaders:
|
||||
_measure_starts.append(_cum)
|
||||
_ts = _mh2.timeSignature
|
||||
_cum += int(_ts.numerator * (4.0 / _ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
|
||||
_measure_starts = _gp345_measure_start_ticks(_gp345x_song)
|
||||
|
||||
def _tick_to_bar(tick):
|
||||
"""Return 0-based bar index for a given tick position."""
|
||||
@@ -1024,6 +1309,216 @@ def auto_sync(
|
||||
sync_points=sync_points,
|
||||
)
|
||||
|
||||
def refine_sync(
|
||||
sync: GpSyncData,
|
||||
audio_path: str,
|
||||
bars_per_point: int = 8,
|
||||
gp_path: str | None = None,
|
||||
sr: int = _SR,
|
||||
search_radius: float = 0.35,
|
||||
phase_step: float = 0.005,
|
||||
onset_tolerance: float = 0.05,
|
||||
) -> GpSyncData:
|
||||
"""Refine coarse DTW sync points with a per-bar onset phase sweep.
|
||||
|
||||
auto_sync's mid-song points inherit the DTW frame granularity (~186ms at
|
||||
the default hop). This pass re-times a denser grid of bars — every
|
||||
`bars_per_point`-th bar plus the first and last — by sweeping a local
|
||||
beat grid (±`search_radius`s in `phase_step` steps) against detected
|
||||
onsets and keeping the phase that aligns best, narrowing each kept point
|
||||
to roughly the phase-step resolution on percussive material.
|
||||
|
||||
Args:
|
||||
sync: Coarse sync data from auto_sync (or a prior refine).
|
||||
audio_path: The same audio file auto_sync aligned against.
|
||||
bars_per_point: Refined-point density; every Nth bar gets a point.
|
||||
gp_path: Optional path to the GP file. When given, exact
|
||||
per-bar score times (bar_start_times) drive the
|
||||
densified grid; without it the grid is limited to
|
||||
a 4/4 approximation built from the points' authored
|
||||
tempos, and accuracy degrades on odd meters.
|
||||
sr: Analysis sample rate.
|
||||
search_radius: ±seconds around each coarse estimate to sweep.
|
||||
phase_step: Sweep resolution in seconds.
|
||||
onset_tolerance: Max onset-to-click distance that counts as aligned.
|
||||
|
||||
Returns:
|
||||
A new GpSyncData with the refined (and usually denser) points and a
|
||||
recomputed audio_offset. Returns `sync` unchanged when it has no
|
||||
usable points. Quiet bars (fewer than 4 onsets nearby) keep their
|
||||
coarse interpolated time rather than locking onto noise.
|
||||
"""
|
||||
if not sync.sync_points:
|
||||
return sync
|
||||
|
||||
pts = sorted(sync.sync_points, key=lambda p: p.bar)
|
||||
|
||||
bar_starts: list[float] | None = None
|
||||
if gp_path:
|
||||
try:
|
||||
bar_starts = bar_start_times(gp_path)
|
||||
except Exception as exc:
|
||||
_log.warning("refine_sync: bar_start_times(%s) failed (%s) — "
|
||||
"falling back to 4/4 tempo model", gp_path, exc)
|
||||
if bar_starts is None:
|
||||
# Approximate score bar starts from the points' authored tempos,
|
||||
# assuming 4 beats per bar (all GpSyncData carries without the file).
|
||||
max_bar = pts[-1].bar
|
||||
bar_starts = [0.0]
|
||||
ti = 0
|
||||
cur_bpm = pts[0].original_tempo or 120.0
|
||||
for b in range(1, max_bar + 1):
|
||||
while ti + 1 < len(pts) and pts[ti + 1].bar <= b - 1:
|
||||
ti += 1
|
||||
cur_bpm = pts[ti].original_tempo or cur_bpm
|
||||
bar_starts.append(bar_starts[-1] + 4 * 60.0 / max(cur_bpm, 1e-3))
|
||||
|
||||
anchors = build_warp_anchors(pts, bar_starts)
|
||||
if len(anchors) < 2:
|
||||
_log.warning("refine_sync: fewer than 2 usable anchors — returning "
|
||||
"input unchanged")
|
||||
return sync
|
||||
|
||||
# Authored-tempo lookup via the shared bar-map scan (_tempo_at_bar) so
|
||||
# boundary semantics can't drift from the rest of the module.
|
||||
_orig_map = [(p.bar, p.original_tempo or 120.0) for p in pts]
|
||||
|
||||
def _orig_bpm_at(bar: int) -> float:
|
||||
return max(_tempo_at_bar(_orig_map, bar), 1e-3)
|
||||
|
||||
n_bars = len(bar_starts)
|
||||
step = max(1, int(bars_per_point))
|
||||
targets = sorted(set(range(0, n_bars, step)) | {n_bars - 1})
|
||||
|
||||
# Deferred past the pure early-return paths above so degenerate inputs
|
||||
# (no points, <2 anchors) resolve without librosa installed.
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
y, _ = librosa.load(audio_path, sr=sr, mono=True)
|
||||
audio_dur = len(y) / sr
|
||||
hop = 512 # ~23ms at 22050Hz — fine enough for onset timing
|
||||
onset_frames = librosa.onset.onset_detect(
|
||||
y=y, sr=sr, hop_length=hop, backtrack=True
|
||||
)
|
||||
onset_times = np.asarray(
|
||||
librosa.frames_to_time(onset_frames, sr=sr, hop_length=hop)
|
||||
)
|
||||
|
||||
refined: list[tuple[int, float]] = []
|
||||
for b in targets:
|
||||
score_t = bar_starts[b]
|
||||
coarse = warp_time(score_t, anchors)
|
||||
if coarse > audio_dur + 1.0:
|
||||
break # bar falls past the end of the recording
|
||||
# Local beat period in AUDIO time: authored beat period scaled by the
|
||||
# local warp slope (recording tempo / authored tempo around this bar).
|
||||
slope = warp_time(score_t + 1.0, anchors) - coarse
|
||||
slope = min(max(slope, 0.25), 4.0)
|
||||
beat_period = (60.0 / _orig_bpm_at(b)) * slope
|
||||
|
||||
# Keep the scoring grid short: beat_period is estimated from the
|
||||
# coarse anchors (a few % off), and grid drift grows linearly with
|
||||
# distance — 16 beats at 2% error is already ~150ms of skew at the
|
||||
# far end, which drags the sweep. 8 beats bounds that to ~beat noise.
|
||||
grid_span = 8 * beat_period
|
||||
# Clamp the sweep window below half a beat so the neighbouring beat
|
||||
# is never a candidate — on periodic material (steady drums) a grid
|
||||
# shifted by one whole beat scores identically and the sweep could
|
||||
# lock a full beat off. DTW coarse error is ~1 analysis frame, which
|
||||
# this window still covers at all but extreme tempos.
|
||||
radius = min(search_radius, 0.45 * beat_period)
|
||||
w_lo = coarse - radius - onset_tolerance
|
||||
w_hi = coarse + radius + grid_span + onset_tolerance
|
||||
local = onset_times[(onset_times >= w_lo) & (onset_times <= w_hi)]
|
||||
if len(local) < 4:
|
||||
refined.append((b, coarse))
|
||||
continue
|
||||
|
||||
best_t, best_score, best_dist = coarse, -1, 0.0
|
||||
for phase in np.arange(coarse - radius, coarse + radius + 1e-9,
|
||||
phase_step):
|
||||
clicks = np.arange(phase, phase + grid_span, beat_period)
|
||||
score = int(sum(
|
||||
1 for t in local
|
||||
if float(np.min(np.abs(clicks - t))) < onset_tolerance
|
||||
))
|
||||
dist = abs(float(phase) - coarse)
|
||||
# Ties break toward the coarse estimate so a flat score surface
|
||||
# (sustained pads, sparse onsets) can't drag the point sideways.
|
||||
if score > best_score or (score == best_score and dist < best_dist):
|
||||
best_score, best_t, best_dist = score, float(phase), dist
|
||||
|
||||
# A sweep that matched almost nothing found a spurious edge
|
||||
# alignment, not the beat grid — this happens when the true phase
|
||||
# lies outside the (ambiguity-clamped) window, e.g. fast tempos
|
||||
# where the DTW coarse error exceeds half a beat. Keeping the
|
||||
# coarse estimate degrades gracefully instead of locking a
|
||||
# fraction of a beat off.
|
||||
if best_score < 3:
|
||||
refined.append((b, coarse))
|
||||
continue
|
||||
|
||||
# The onset-count score is flat within ±onset_tolerance of the true
|
||||
# phase, so the sweep alone can be off by up to the tolerance. Snap
|
||||
# inside that plateau: shift by the median residual between matched
|
||||
# onsets and their nearest grid click. Only the first few beats
|
||||
# count here — they are nearly insensitive to beat_period error,
|
||||
# while far clicks would leak that error into the residuals.
|
||||
if best_score > 0:
|
||||
clicks = np.arange(best_t, best_t + 4 * beat_period + 1e-9,
|
||||
beat_period)
|
||||
residuals = []
|
||||
for t in local:
|
||||
d = clicks - float(t)
|
||||
j = int(np.argmin(np.abs(d)))
|
||||
if abs(d[j]) < onset_tolerance:
|
||||
residuals.append(-float(d[j])) # onset minus click
|
||||
if residuals:
|
||||
best_t += float(np.median(residuals))
|
||||
refined.append((b, best_t))
|
||||
|
||||
if not refined:
|
||||
return sync
|
||||
|
||||
# Enforce monotonicity: a point refined earlier than its predecessor
|
||||
# would fold the warp. Clamp to a small positive gap.
|
||||
mono: list[tuple[int, float]] = []
|
||||
prev_t: float | None = None
|
||||
for b, t in refined:
|
||||
t = max(t, 0.0)
|
||||
if prev_t is not None and t <= prev_t + 0.02:
|
||||
t = prev_t + 0.02
|
||||
mono.append((b, t))
|
||||
prev_t = t
|
||||
|
||||
# Recompute per-segment modified tempos from the refined times (same
|
||||
# formula _extract_sync_points uses; the last point carries the previous
|
||||
# segment's tempo forward).
|
||||
new_points: list[SyncPoint] = []
|
||||
for i, (b, t) in enumerate(mono):
|
||||
obpm = _orig_bpm_at(b)
|
||||
if i + 1 < len(mono):
|
||||
b2, t2 = mono[i + 1]
|
||||
score_seg = bar_starts[b2] - bar_starts[b]
|
||||
audio_seg = t2 - t
|
||||
mod = obpm * (score_seg / audio_seg) if audio_seg > 1e-3 else obpm
|
||||
mod = max(20.0, min(300.0, mod))
|
||||
else:
|
||||
mod = new_points[-1].modified_tempo if new_points else obpm
|
||||
new_points.append(SyncPoint(
|
||||
bar=b, time_secs=t, modified_tempo=mod, original_tempo=obpm,
|
||||
))
|
||||
|
||||
_log.info("refine_sync: %d points (was %d), audio_offset=%.3fs",
|
||||
len(new_points), len(pts), -new_points[0].time_secs)
|
||||
return GpSyncData(
|
||||
audio_offset=-new_points[0].time_secs,
|
||||
audio_asset_id=sync.audio_asset_id,
|
||||
sync_points=new_points,
|
||||
)
|
||||
|
||||
|
||||
def estimate_audio_offset(gp_path: str, audio_path: str) -> float:
|
||||
"""
|
||||
Estimate the audio_offset for a GP file aligned to an audio file.
|
||||
|
||||
@@ -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,109 @@ 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 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()
|
||||
return {k: ((row[i] or "") if row else "") for i, k in enumerate(keys)}
|
||||
|
||||
# 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 +1362,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:
|
||||
@@ -3038,6 +3169,22 @@ class MetadataDB:
|
||||
out[fn] = st
|
||||
return out
|
||||
|
||||
def _unmatched_set(self, filenames) -> set:
|
||||
"""The subset of `filenames` whose enrichment landed in the 'failed'
|
||||
(no-match) state — feeds the grid's persistent per-card "no match" badge,
|
||||
so the misses stay visible at rest (the batch tile only shows while a
|
||||
refresh runs). Chunked set membership, like favorite_set."""
|
||||
fns = list(filenames)
|
||||
out: set = set()
|
||||
for i in range(0, len(fns), 400):
|
||||
chunk = fns[i:i + 400]
|
||||
if not chunk:
|
||||
break
|
||||
q = ("SELECT filename FROM song_enrichment WHERE match_state = 'failed' "
|
||||
"AND filename IN (%s)" % ",".join("?" * len(chunk)))
|
||||
out.update(r[0] for r in self.conn.execute(q, chunk).fetchall())
|
||||
return out
|
||||
|
||||
def enrichment_song_row(self, filename: str) -> dict | None:
|
||||
"""The identity fields the matcher/scorer keys on, for one song."""
|
||||
row = self.conn.execute(
|
||||
@@ -3338,7 +3485,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 =
|
||||
@@ -4042,6 +4189,20 @@ class MetadataDB:
|
||||
fns = [s["filename"] for s in songs]
|
||||
udm = self.user_meta_map(fns)
|
||||
tgm = self.tags_map(fns)
|
||||
# Enrichment "no match" (failed) set for the page, so a card can show a
|
||||
# persistent "no match" badge — the Refresh-Metadata batch's transient
|
||||
# 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
|
||||
@@ -4051,8 +4212,21 @@ class MetadataDB:
|
||||
for s in songs:
|
||||
s["user_difficulty"] = udm.get(s["filename"])
|
||||
s["tags"] = tgm.get(s["filename"], [])
|
||||
s["unmatched"] = s["filename"] in um
|
||||
if amap:
|
||||
s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist"))
|
||||
# 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":
|
||||
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
|
||||
@@ -6964,6 +7138,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:
|
||||
@@ -6989,6 +7191,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:
|
||||
@@ -8674,6 +8884,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}
|
||||
|
||||
@@ -8925,9 +9139,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]}
|
||||
|
||||
@@ -9012,6 +9227,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
|
||||
@@ -12180,15 +12449,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()
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+363
-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,355 @@
|
||||
(_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;
|
||||
song._pendingDetails = {
|
||||
title: String(cand.title || ''), artist: String(cand.artist || ''),
|
||||
album: String(cand.album || ''), year: String(cand.year || ''),
|
||||
genre: (Array.isArray(cand.genres) && cand.genres[0]) ? String(cand.genres[0]) : String(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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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 +743,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 +766,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);
|
||||
|
||||
+52
-11
@@ -498,21 +498,32 @@
|
||||
// to before (keeps the windowed grid's height math untouched). Honest state
|
||||
// transitions, NOT a fake per-song %: a match is binary (design §11).
|
||||
const _metaTile = {}; // fn -> 'queued' | 'working' | 'done' | 'nochange'
|
||||
function enrichBadge(fn) {
|
||||
const st = _metaTile[fn];
|
||||
// Cards whose enrichment landed 'failed' (from the grid payload) — tracked so
|
||||
// the PERSISTENT "no match" badge survives a batch tile clearing (a
|
||||
// _patchCardEnrich with no flag falls back to this instead of wiping it).
|
||||
// Populated as cards render (enrichBadge is called per card with the flag).
|
||||
const _unmatched = new Set();
|
||||
function enrichBadge(fn, unmatched) {
|
||||
if (unmatched !== undefined) { if (unmatched) _unmatched.add(fn); else _unmatched.delete(fn); }
|
||||
// A live batch tile wins over the resting no-match marker (they never
|
||||
// coexist — the batch clears its tiles when it finishes).
|
||||
const st = _metaTile[fn] || (_unmatched.has(fn) ? 'nomatch' : null);
|
||||
if (!st) return '';
|
||||
const M = {
|
||||
queued: ['bg-black/60 text-fb-textDim', '• Queued'],
|
||||
working: ['bg-fb-primary text-white', '⟳ Matching…'],
|
||||
done: ['bg-fb-good/90 text-black', '✓ Updated'],
|
||||
nochange: ['bg-black/60 text-fb-textDim', '— No match'],
|
||||
queued: ['bg-black/60 text-fb-textDim', '• Queued', ''],
|
||||
working: ['bg-fb-primary text-white', '⟳ Matching…', ''],
|
||||
done: ['bg-fb-good/90 text-black', '✓ Updated', ''],
|
||||
nochange: ['bg-black/60 text-fb-textDim', '— No match', ''],
|
||||
// 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'],
|
||||
};
|
||||
const conf = M[st] || M.queued;
|
||||
// top-10 clears the tuning chip (top-2) in both normal and select mode;
|
||||
// z-20 sits it above the art. Non-interactive so it never eats a click.
|
||||
return '<span class="v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
|
||||
' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight pointer-events-none">' +
|
||||
conf[1] + '</span>';
|
||||
' 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>';
|
||||
}
|
||||
|
||||
// After a song is scored, the badge for that card is stale until the next
|
||||
@@ -870,7 +881,7 @@
|
||||
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
|
||||
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
|
||||
'<img src="' + esc(artUrl(shown)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
|
||||
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key) + overlay +
|
||||
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key, song.unmatched) + overlay +
|
||||
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
|
||||
inlineBtns +
|
||||
'<button data-fav data-fav-idle="text-white" title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
|
||||
@@ -938,7 +949,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…' },
|
||||
@@ -3489,6 +3500,7 @@
|
||||
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
|
||||
'<button id="v3-songs-refresh" title="Refresh library (scan for new songs)" class="' + ctrl + '">⟳ Refresh</button>' +
|
||||
'<button id="v3-songs-refresh-meta" title="Refresh metadata for the songs shown (re-match titles, artwork & more)" class="' + ctrl + '">🏷 Metadata</button>' +
|
||||
'<button id="v3-songs-unmatched" title="Show only songs with no metadata match" class="' + ctrl + ((state.filters.match || []).includes('unmatched') ? ' bg-fb-primary text-white' : '') + '">Unmatched</button>' +
|
||||
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
|
||||
'</div></div></div>' +
|
||||
// Practice-aware library home: a repertoire progress meter + a
|
||||
@@ -3559,6 +3571,7 @@
|
||||
// Refresh Metadata: local-only, so hide it for remote providers. The
|
||||
// button doubles as its own Stop while a pass runs (see onMetaBtnClick).
|
||||
byId('v3-songs-refresh-meta')?.addEventListener('click', onMetaBtnClick);
|
||||
byId('v3-songs-unmatched')?.addEventListener('click', toggleUnmatchedFilter);
|
||||
_updateMetaBtnVisibility();
|
||||
// Reflect a scan already in progress (Settings button or a background
|
||||
// pass) on the Refresh button, so its state isn't just tied to clicks here.
|
||||
@@ -3808,8 +3821,24 @@
|
||||
let _metaRunning = false;
|
||||
|
||||
function _updateMetaBtnVisibility() {
|
||||
const local = state.provider === 'local'; // enrichment + its filter are local-only
|
||||
const btn = document.getElementById('v3-songs-refresh-meta');
|
||||
if (btn) btn.style.display = (state.provider === 'local') ? '' : 'none';
|
||||
if (btn) btn.style.display = local ? '' : 'none';
|
||||
const um = document.getElementById('v3-songs-unmatched');
|
||||
if (um) um.style.display = local ? '' : 'none';
|
||||
}
|
||||
|
||||
// Quick "Show unmatched" — the same filter as the drawer's Match → Unmatched,
|
||||
// one click from the toolbar so the no-match pile is reachable right after a
|
||||
// batch. Toggles the button + re-queries the grid.
|
||||
function toggleUnmatchedFilter() {
|
||||
const m = state.filters.match || (state.filters.match = []);
|
||||
const i = m.indexOf('unmatched');
|
||||
const on = i < 0;
|
||||
if (on) m.push('unmatched'); else m.splice(i, 1);
|
||||
const btn = document.getElementById('v3-songs-unmatched');
|
||||
if (btn) { btn.classList.toggle('bg-fb-primary', on); btn.classList.toggle('text-white', on); }
|
||||
reload();
|
||||
}
|
||||
|
||||
// The local filenames the grid is currently SHOWING (data-fn is the local
|
||||
@@ -3838,6 +3867,18 @@
|
||||
function _clearMetaTiles() {
|
||||
Object.keys(_metaTile).forEach((fn) => { delete _metaTile[fn]; });
|
||||
document.querySelectorAll('.v3-meta-tile').forEach((el) => el.remove());
|
||||
// The persistent "No match" badge derives from _unmatched (not _metaTile),
|
||||
// yet shares the .v3-meta-tile class — so the blanket remove above strips it.
|
||||
// Repaint the resting indicator on any rendered card so a metadata rescan's
|
||||
// tile-clear doesn't silently drop it until the next scroll/re-render.
|
||||
_unmatched.forEach((fn) => {
|
||||
const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
|
||||
document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => {
|
||||
if (play.querySelector('.v3-meta-tile')) return;
|
||||
const html = enrichBadge(fn);
|
||||
if (html) play.insertAdjacentHTML('beforeend', html);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""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_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
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Tests for the librosa-free piecewise time-warp helpers in lib/gp_autosync.py
|
||||
(bar_start_times / build_warp_anchors / warp_time / warp_song_times /
|
||||
gp_has_expandable_repeats) plus refine_sync's pure fallbacks.
|
||||
|
||||
Fixture-free, matching tests/test_gp_audio_sync.py: every test drives a pure
|
||||
helper with hand-built inputs (in-memory GPIF zips, synthetic sync points,
|
||||
hand-rolled Song objects). The librosa-backed sweep inside refine_sync needs
|
||||
real audio and is covered by manual validation in the PR.
|
||||
"""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
import gp_autosync as ga
|
||||
from gp8_audio_sync import GpSyncData, SyncPoint
|
||||
from song import (
|
||||
Anchor,
|
||||
Arrangement,
|
||||
Beat,
|
||||
Chord,
|
||||
HandShape,
|
||||
Note,
|
||||
Phrase,
|
||||
PhraseLevel,
|
||||
Section,
|
||||
Song,
|
||||
)
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _gpif_zip(gpif_xml: str) -> bytes:
|
||||
"""Build an in-memory .gp container holding the given score.gpif."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("Content/score.gpif", gpif_xml)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _gpif(tempo_autos: list[tuple[int, float]], bar_sigs: list[str]) -> str:
|
||||
autos = "".join(
|
||||
f"<Automation><Type>Tempo</Type><Bar>{bar}</Bar>"
|
||||
f"<Value>{bpm} 2</Value></Automation>"
|
||||
for bar, bpm in tempo_autos
|
||||
)
|
||||
bars = "".join(f"<MasterBar><Time>{sig}</Time></MasterBar>" for sig in bar_sigs)
|
||||
return (
|
||||
"<GPIF><MasterTrack><Automations>"
|
||||
f"{autos}</Automations></MasterTrack>"
|
||||
f"<MasterBars>{bars}</MasterBars></GPIF>"
|
||||
)
|
||||
|
||||
|
||||
def _sp(bar, t, mod=120.0, orig=120.0):
|
||||
return SyncPoint(bar=bar, time_secs=t, modified_tempo=mod, original_tempo=orig)
|
||||
|
||||
|
||||
# ── bar_start_times ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_bar_start_times_constant_tempo(tmp_path):
|
||||
# 120 BPM, 4/4 → every bar is exactly 2s
|
||||
gp = tmp_path / "song.gp"
|
||||
gp.write_bytes(_gpif_zip(_gpif([(0, 120.0)], ["4/4"] * 4)))
|
||||
assert ga.bar_start_times(str(gp)) == pytest.approx([0.0, 2.0, 4.0, 6.0])
|
||||
|
||||
|
||||
def test_bar_start_times_tempo_change_and_meter(tmp_path):
|
||||
# Bar 0-1 at 120 (4/4 → 2s each), bar 2 switches to 60 in 3/4 (3s)
|
||||
gp = tmp_path / "song.gp"
|
||||
gp.write_bytes(
|
||||
_gpif_zip(_gpif([(0, 120.0), (2, 60.0)], ["4/4", "4/4", "3/4", "3/4"]))
|
||||
)
|
||||
assert ga.bar_start_times(str(gp)) == pytest.approx([0.0, 2.0, 4.0, 7.0])
|
||||
|
||||
|
||||
# ── build_warp_anchors ────────────────────────────────────────────────────────
|
||||
|
||||
def test_build_warp_anchors_maps_bars_to_score_time():
|
||||
bar_starts = [0.0, 2.0, 4.0, 6.0]
|
||||
points = [_sp(0, 1.0), _sp(2, 5.4)]
|
||||
assert ga.build_warp_anchors(points, bar_starts) == [(0.0, 1.0), (4.0, 5.4)]
|
||||
|
||||
|
||||
def test_build_warp_anchors_drops_nonmonotonic_and_out_of_range():
|
||||
bar_starts = [0.0, 2.0, 4.0, 6.0]
|
||||
points = [
|
||||
_sp(0, 1.0),
|
||||
_sp(1, 0.5), # audio time goes backwards — dropped
|
||||
_sp(2, 5.4),
|
||||
_sp(99, 9.9), # bar out of range — dropped
|
||||
]
|
||||
assert ga.build_warp_anchors(points, bar_starts) == [(0.0, 1.0), (4.0, 5.4)]
|
||||
|
||||
|
||||
def test_build_warp_anchors_drops_implausible_slopes():
|
||||
# 2s score bars. A DTW fold (or a run of monotonicity-clamped refine
|
||||
# points) can produce a near-flat audio segment — slope far below the
|
||||
# 0.2x plausibility floor — which would crush every bar in the span.
|
||||
bar_starts = [float(2 * b) for b in range(11)]
|
||||
points = [
|
||||
_sp(0, 1.0),
|
||||
_sp(4, 9.0), # slope 1.0 — kept
|
||||
_sp(8, 9.1), # slope 0.0125 over 8s of score — dropped
|
||||
_sp(10, 21.0), # slope 1.0 vs the last KEPT anchor — kept
|
||||
]
|
||||
assert ga.build_warp_anchors(points, bar_starts) == [
|
||||
(0.0, 1.0), (8.0, 9.0), (20.0, 21.0)
|
||||
]
|
||||
|
||||
|
||||
def test_build_warp_anchors_requires_two_points():
|
||||
assert ga.build_warp_anchors([_sp(0, 1.0)], [0.0, 2.0]) == []
|
||||
assert ga.build_warp_anchors([], [0.0, 2.0]) == []
|
||||
|
||||
|
||||
# ── warp_time ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_warp_time_interpolates_between_anchors():
|
||||
anchors = [(0.0, 1.0), (10.0, 21.0)] # slope 2, offset 1
|
||||
assert ga.warp_time(0.0, anchors) == pytest.approx(1.0)
|
||||
assert ga.warp_time(5.0, anchors) == pytest.approx(11.0)
|
||||
assert ga.warp_time(10.0, anchors) == pytest.approx(21.0)
|
||||
|
||||
|
||||
def test_warp_time_piecewise_segments():
|
||||
# First half plays at authored speed, second half at half speed
|
||||
anchors = [(0.0, 0.0), (10.0, 10.0), (20.0, 30.0)]
|
||||
assert ga.warp_time(5.0, anchors) == pytest.approx(5.0)
|
||||
assert ga.warp_time(15.0, anchors) == pytest.approx(20.0)
|
||||
|
||||
|
||||
def test_warp_time_extrapolates_with_edge_slopes():
|
||||
anchors = [(10.0, 20.0), (20.0, 40.0)] # slope 2
|
||||
assert ga.warp_time(5.0, anchors) == pytest.approx(10.0) # before first
|
||||
assert ga.warp_time(25.0, anchors) == pytest.approx(50.0) # after last
|
||||
|
||||
|
||||
def test_warp_time_preserves_order():
|
||||
anchors = [(0.0, 0.5), (4.0, 4.1), (8.0, 9.3), (12.0, 12.9)]
|
||||
times = [i * 0.37 for i in range(40)]
|
||||
warped = [ga.warp_time(t, anchors) for t in times]
|
||||
assert warped == sorted(warped)
|
||||
|
||||
|
||||
# ── warp_song_times ───────────────────────────────────────────────────────────
|
||||
|
||||
def _shifted_double(t):
|
||||
return 2.0 * t + 1.0
|
||||
|
||||
|
||||
def test_warp_song_times_covers_all_time_fields():
|
||||
song = Song(
|
||||
song_length=100.0,
|
||||
beats=[Beat(time=0.0, measure=1), Beat(time=1.0, measure=-1)],
|
||||
sections=[Section(name="verse", number=1, start_time=10.0)],
|
||||
arrangements=[
|
||||
Arrangement(
|
||||
name="Lead",
|
||||
notes=[Note(time=2.0, string=0, fret=3, sustain=1.0)],
|
||||
chords=[
|
||||
Chord(
|
||||
time=4.0,
|
||||
chord_id=0,
|
||||
notes=[Note(time=4.0, string=1, fret=2, sustain=0.5)],
|
||||
)
|
||||
],
|
||||
anchors=[Anchor(time=6.0, fret=3)],
|
||||
hand_shapes=[HandShape(chord_id=0, start_time=4.0, end_time=5.0)],
|
||||
phrases=[
|
||||
Phrase(
|
||||
start_time=0.0,
|
||||
end_time=8.0,
|
||||
max_difficulty=0,
|
||||
levels=[
|
||||
PhraseLevel(
|
||||
difficulty=0,
|
||||
notes=[Note(time=3.0, string=0, fret=0, sustain=2.0)],
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
tones={"base": "clean", "changes": [{"t": 7.0, "name": "lead"}]},
|
||||
tempos=[{"time": 0.0, "bpm": 120.0}],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ga.warp_song_times(song, _shifted_double)
|
||||
|
||||
assert song.song_length == pytest.approx(201.0)
|
||||
assert [b.time for b in song.beats] == pytest.approx([1.0, 3.0])
|
||||
assert song.sections[0].start_time == pytest.approx(21.0)
|
||||
|
||||
arr = song.arrangements[0]
|
||||
n = arr.notes[0]
|
||||
assert n.time == pytest.approx(5.0)
|
||||
assert n.sustain == pytest.approx(2.0) # (2+1)*2+1 - 5
|
||||
ch = arr.chords[0]
|
||||
assert ch.time == pytest.approx(9.0)
|
||||
assert ch.notes[0].time == pytest.approx(9.0)
|
||||
assert ch.notes[0].sustain == pytest.approx(1.0)
|
||||
assert arr.anchors[0].time == pytest.approx(13.0)
|
||||
hs = arr.hand_shapes[0]
|
||||
assert (hs.start_time, hs.end_time) == (pytest.approx(9.0), pytest.approx(11.0))
|
||||
ph = arr.phrases[0]
|
||||
assert (ph.start_time, ph.end_time) == (pytest.approx(1.0), pytest.approx(17.0))
|
||||
assert ph.levels[0].notes[0].time == pytest.approx(7.0)
|
||||
assert ph.levels[0].notes[0].sustain == pytest.approx(4.0)
|
||||
assert arr.tones["changes"][0]["t"] == pytest.approx(15.0)
|
||||
assert arr.tempos[0]["time"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_warp_song_times_clamps_negative_sustain():
|
||||
# A non-monotonic warp callable must not produce negative sustains
|
||||
song = Song(arrangements=[
|
||||
Arrangement(name="Lead",
|
||||
notes=[Note(time=1.0, string=0, fret=0, sustain=1.0)])
|
||||
])
|
||||
ga.warp_song_times(song, lambda t: 5.0 - t) # decreasing map
|
||||
assert song.arrangements[0].notes[0].sustain == 0.0
|
||||
|
||||
|
||||
# ── gp_has_expandable_repeats ─────────────────────────────────────────────────
|
||||
|
||||
def test_gpif_files_never_expand_repeats(tmp_path):
|
||||
# GPIF conversion is single-pass as-written, so .gp/.gpx are always False
|
||||
gp = tmp_path / "song.gp"
|
||||
gp.write_bytes(_gpif_zip(_gpif([(0, 120.0)], ["4/4"])))
|
||||
assert ga.gp_has_expandable_repeats(str(gp)) is False
|
||||
|
||||
|
||||
def test_gp345_unparseable_returns_false(tmp_path):
|
||||
bad = tmp_path / "song.gp5"
|
||||
bad.write_bytes(b"not a real gp5 file")
|
||||
assert ga.gp_has_expandable_repeats(str(bad)) is False
|
||||
|
||||
|
||||
def test_gp345_repeats_detected(tmp_path):
|
||||
guitarpro = pytest.importorskip("guitarpro")
|
||||
song = guitarpro.models.Song()
|
||||
track = guitarpro.models.Track(song)
|
||||
song.tracks = [track]
|
||||
# Bar 2 of 3 opens a repeat
|
||||
for _ in range(2):
|
||||
header = guitarpro.models.MeasureHeader()
|
||||
song.addMeasureHeader(header)
|
||||
song.measureHeaders[1].isRepeatOpen = True
|
||||
for header in song.measureHeaders:
|
||||
track.measures.append(guitarpro.models.Measure(track, header))
|
||||
path = tmp_path / "repeat.gp5"
|
||||
guitarpro.write(song, str(path))
|
||||
assert ga.gp_has_expandable_repeats(str(path)) is True
|
||||
|
||||
|
||||
def test_gp345_plain_song_no_repeats(tmp_path):
|
||||
guitarpro = pytest.importorskip("guitarpro")
|
||||
song = guitarpro.models.Song()
|
||||
track = guitarpro.models.Track(song)
|
||||
song.tracks = [track]
|
||||
for _ in range(2):
|
||||
header = guitarpro.models.MeasureHeader()
|
||||
song.addMeasureHeader(header)
|
||||
for header in song.measureHeaders:
|
||||
track.measures.append(guitarpro.models.Measure(track, header))
|
||||
path = tmp_path / "plain.gp5"
|
||||
guitarpro.write(song, str(path))
|
||||
assert ga.gp_has_expandable_repeats(str(path)) is False
|
||||
|
||||
|
||||
# ── refine_sync pure fallbacks ────────────────────────────────────────────────
|
||||
|
||||
def test_refine_sync_empty_points_returns_input():
|
||||
sync = GpSyncData(audio_offset=0.0, audio_asset_id="", sync_points=[])
|
||||
assert ga.refine_sync(sync, "/nonexistent.ogg") is sync
|
||||
|
||||
|
||||
def test_refine_sync_single_point_returns_input():
|
||||
# One point → fewer than 2 warp anchors → unchanged, no audio load
|
||||
sync = GpSyncData(audio_offset=-1.0, audio_asset_id="",
|
||||
sync_points=[_sp(0, 1.0)])
|
||||
assert ga.refine_sync(sync, "/nonexistent.ogg") is sync
|
||||
@@ -614,3 +614,16 @@ def test_artist_filter_is_case_insensitive(client, seeded):
|
||||
data = _get(client, artist="a band")
|
||||
assert data["total"] == 1
|
||||
assert data["songs"][0]["filename"] == "a.archive"
|
||||
|
||||
|
||||
def test_unmatched_flag_and_quick_filter(server_mod, client):
|
||||
# A per-card "no match" badge needs the row to carry the enrichment state.
|
||||
_put(server_mod, filename="a.archive", title="Matched", artist="A")
|
||||
_put(server_mod, filename="b.archive", title="Missed", artist="B")
|
||||
server_mod.meta_db.apply_enrichment_match("b.archive", "h", "failed") # no-match
|
||||
rows = {s["filename"]: s for s in server_mod.meta_db.query_page()[0]}
|
||||
assert rows["b.archive"]["unmatched"] is True
|
||||
assert rows["a.archive"]["unmatched"] is False
|
||||
# The "Unmatched" quick-filter (match=unmatched) returns only the failed song.
|
||||
fns = [s["filename"] for s in client.get("/api/library?match=unmatched").json()["songs"]]
|
||||
assert fns == ["b.archive"]
|
||||
|
||||
@@ -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