feat(v3 library): "Fix metadata" popup — per-song override + lock, cover picker, MusicBrainz + AcoustID (#777)

* feat(library): metadata override + lock store, enforced by enrichment (popup slices 1–2)

Backend foundation for the Fix-metadata popup. Not yet surfaced in the UI (the
display + 3-tab popup are the next slices); no PR until it's user-visible.

Slice 1 — the store:
- `song_field_override(filename, field, value, locked)` table: 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.
- DB methods (partial upsert that drops empty+unlocked rows; batch map) +
  `GET`/`PUT /api/song/{fn}/overrides` (field allowlist title/artist/album/
  year/genre; clearing rides PUT since DELETE /api/song/{path} shadows sub-
  routes; PUT demo-blocked).

Slice 2 — locks respected by enrichment:
- The auto-matcher composes a per-song `_compose_lock_filter` onto the global
  apply-filter, so a match still applies IDENTITY (mbid/release → art) but never
  re-canonicalizes a LOCKED display field.
- Gap-fill (write-to-file) skips locked album/year/genre — writing the matched
  value would be exactly the clobber the lock exists to prevent.
- Review/manual picks bypass the filter (an explicit confirm overrides a lock).

Tests: store semantics + rescan-survival + API; the lock filter + reader; an
auto-match leaving a locked field un-canonicalized; gap-fill excluding locked
keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(library): show per-song overrides in the grid (popup slice 3)

The grid now displays the user's per-song title/artist/album/year override
in place of the pack value ("grid shows only overrides") — a matched
MusicBrainz canon never silently re-titles a card; canon stays in the
Details drawer + art. Overlaid in Python over the visible window, keyset-safe
like the P4 artist-alias re-label: the seek still runs on the raw column, and
the one overridable keyset column (title) stashes its raw value for the cursor
so paging never skips/dupes. The private stash is dropped from the payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(library): 3-tab Fix-metadata popup — Details / Cover art / Match (slice 4)

Turns the thin single-song fix-match modal into the Plex-style metadata
editor reached from a card's "Fix metadata…" menu:

- Details tab: type + lock the displayed title/artist/album/year. Values
  ride the reversible override store (GET/PUT /api/song/{fn}/overrides); each
  field sits on its pack value (Yours/Pack provenance + revert-to-pack), a lock
  pins it against auto-match, and Save repaints the grid via library:changed
  (slice-3 overlay). This is the real tool for the blank-artist city-pop pile
  MusicBrainz can't surface — you just type the right title.
- Cover art tab: hands off to the shared image picker (its own modal); the
  pick refreshes the thumbnail everywhere.
- Match tab: the existing MusicBrainz search + candidate/pick flow, refactored
  into shared body/footer helpers (the queue-review flow is untouched).

Backend: GET /overrides now also returns the pack baseline so the Details tab
can pre-fill + show provenance. tailwind.min.css regenerated (build-tailwind.sh)
for the popup's new utility classes.

Identify-by-audio (AcoustID) is deferred: it lives in unmerged PR #759, off
main — the Match tab gains the button once #759 lands and this branch rebases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(library): wire "Identify by audio" in the tabbed popup's Match tab

The AcoustID Identify button (#759) merged in referencing an out-of-scope
`panel` in the wiring — a leftover from the pre-popup fix-match modal that my
tab refactor renamed to `root`. Under strict mode that threw, so the handler
never attached and the button did nothing. Scope it to `root` (the tab body),
which is where the search-results area it renders into lives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(library): make "Identify by audio" outcomes unmistakable

An empty AcoustID result read the same as a broken button. Each state now says
plainly which outcome it is — ✓ fingerprinted-but-no-match vs no-audio vs off vs
unavailable — and, in the popup, points at the manual fallback (Search, or set
the album in Details + cover in Cover art by hand). A ✓ marks the states that
actually ran, so "worked, found nothing" no longer looks like a failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ChrisBeWithYou
2026-07-05 21:09:38 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 3e036e3db6
commit af1170cec3
7 changed files with 755 additions and 60 deletions
+235 -4
View File
@@ -199,6 +199,7 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
("DELETE", re.compile(r"^/api/audio-effects/active-mapping$")), ("DELETE", re.compile(r"^/api/audio-effects/active-mapping$")),
("POST", re.compile(r"^/api/song/.*/meta$")), ("POST", re.compile(r"^/api/song/.*/meta$")),
("POST", re.compile(r"^/api/song/.*/art/upload$")), ("POST", re.compile(r"^/api/song/.*/art/upload$")),
("PUT", re.compile(r"^/api/song/.+/overrides$")),
("GET", re.compile(r"^/api/plugins/updates$")), ("GET", re.compile(r"^/api/plugins/updates$")),
("POST", re.compile(r"^/api/plugins/[^/]+/update$")), ("POST", re.compile(r"^/api/plugins/[^/]+/update$")),
("POST", re.compile(r"^/api/plugins/editor/save$")), ("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 key = "mtime" if col == "mtime" else col
if key not in last_song or "filename" not in last_song: if key not in last_song or "filename" not in last_song:
return None 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 # 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)") 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". # Artist-name aliases (P4): "ACDC" → "AC/DC", "the beatles" → "The Beatles".
# A CANONICALIZATION OVERRIDE applied AT DISPLAY only — the scanner-derived # A CANONICALIZATION OVERRIDE applied AT DISPLAY only — the scanner-derived
# `songs.artist` and the feedpak files are never rewritten (a rescan can't # `songs.artist` and the feedpak files are never rewritten (a rescan can't
@@ -1168,6 +1195,88 @@ class MetadataDB:
self.conn.commit() self.conn.commit()
return self.get_song_user_meta(filename) 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)}
def set_song_tags(self, filename: str, tags) -> list: def set_song_tags(self, filename: str, tags) -> list:
"""Replace ALL of a song's tags with the given set (each normalized; """Replace ALL of a song's tags with the given set (each normalized;
blanks + case-dupes dropped). Full-replace so the whole personal-meta blanks + case-dupes dropped). Full-replace so the whole personal-meta
@@ -1232,6 +1341,7 @@ class MetadataDB:
INSIDE the caller's `meta_db._lock` — must not re-acquire the lock.""" 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_user_meta WHERE filename = ?", (filename,))
self.conn.execute("DELETE FROM song_tags 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__", def batch_user_meta(self, filenames, *, set_difficulty="__keep__",
add_tags=None, remove_tags=None) -> int: add_tags=None, remove_tags=None) -> int:
@@ -4063,6 +4173,15 @@ class MetadataDB:
# per-tile state only paints while a pass runs. Cheap set membership like # per-tile state only paints while a pass runs. Cheap set membership like
# favs/estd, so the misses stay visible at rest. # favs/estd, so the misses stay visible at rest.
um = self._unmatched_set(fns) 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 # 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 # 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 # position (raw artist) is untouched, so a card can show a canonical name
@@ -4075,6 +4194,18 @@ class MetadataDB:
s["unmatched"] = s["filename"] in um s["unmatched"] = s["filename"] in um
if amap: if amap:
s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist")) 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 # 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 # 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 # address the Charts drawer (GET /api/work/{work_key}/charts) without a
@@ -6986,6 +7117,34 @@ def _artist_title_from_filename(filename: str) -> dict | None:
return {"artist": artist, "title": title} 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, def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None,
apply_mask: str = "") -> None: apply_mask: str = "") -> None:
"""The matcher (P8; replaces P7's no-op). Precedence per design §5: """The matcher (P8; replaces P7's no-op). Precedence per design §5:
@@ -7011,6 +7170,14 @@ def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None,
siblings. Network errors raise EnrichTransportError so the pass pauses siblings. Network errors raise EnrichTransportError so the pass pauses
instead of burning attempts while offline.""" instead of burning attempts while offline."""
fn, chash = row["filename"], row["content_hash"] 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) cached = meta_db.enrichment_cache_lookup(chash, exclude_filename=fn)
if cached: if cached:
@@ -8696,6 +8863,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). # 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]) next_cursor = (next_library_cursor(_effective_keyset_sort(sort, dir), songs[-1])
if (is_local and songs) else None) 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, return {"songs": songs, "total": total, "page": page, "size": size,
"next_cursor": next_cursor} "next_cursor": next_cursor}
@@ -9034,6 +9205,60 @@ def put_song_user_meta(filename: str, data: dict):
return meta_db.get_song_user_meta(key) 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") @app.post("/api/songs/user-meta/batch")
def batch_song_user_meta(data: dict): def batch_song_user_meta(data: dict):
"""Bulk personal-meta edit over a selection — one request instead of N×2 """Bulk personal-meta edit over a selection — one request instead of N×2
@@ -12202,15 +12427,21 @@ def _gap_fill_proposals(cache_key: str, resolved) -> tuple[dict, str]:
manifest = sloppak_mod.load_manifest(resolved) or {} manifest = sloppak_mod.load_manifest(resolved) or {}
except Exception: except Exception:
return {}, "not-sloppak" 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 = {} out = {}
album = (row.get("canon_album") or "").strip() 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 out["album"] = album
year = (row.get("canon_year") or "").strip() 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) out["year"] = int(year)
genres = [str(g) for g in (row.get("genres") or []) if isinstance(g, str) and g.strip()] 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 out["genres"] = genres
# Identity keys (feedpak spec 1.14.0) — written in canonical form only. # Identity keys (feedpak spec 1.14.0) — written in canonical form only.
mbid = (row.get("mb_recording_id") or "").strip().lower() mbid = (row.get("mb_recording_id") or "").strip().lower()
+1 -1
View File
File diff suppressed because one or more lines are too long
+257 -54
View File
@@ -131,7 +131,8 @@
let _queue = []; let _queue = [];
let _idx = 0; let _idx = 0;
let _lastFocus = null; 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() { function ensureModal() {
let m = document.getElementById('v3-match-modal'); let m = document.getElementById('v3-match-modal');
@@ -180,14 +181,17 @@
loadQueue(); loadQueue();
} }
// Fix-match (R2): the same modal for ONE song — the escape hatch for a // Fix metadata (R2 → popup slice 4): the tabbed per-song editor for ONE
// wrong (or missing) match, reachable from the card's ⋮ / right-click // song, reachable from the card's ⋮ / right-click menu. Three tabs —
// menu. No stored candidates are required: the search panel opens // Details (type + lock the displayed fields), Cover art (launch the picker),
// pre-filled, and a pick pins the match exactly like the review flow. // 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) { function fixMatch(song) {
if (!song || !song.filename) return; if (!song || !song.filename) return;
_lastFocus = document.activeElement; _lastFocus = document.activeElement;
_single = true; _single = true;
_tab = 'details';
_queue = [{ _queue = [{
filename: song.filename, title: song.title || song.filename, filename: song.filename, title: song.title || song.filename,
artist: song.artist || '', album: song.album || '', artist: song.artist || '', album: song.album || '',
@@ -198,10 +202,7 @@
const m = ensureModal(); const m = ensureModal();
m.classList.remove('hidden'); m.classList.remove('hidden');
document.getElementById('v3-match-overlay')?.classList.remove('hidden'); document.getElementById('v3-match-overlay')?.classList.remove('hidden');
renderCurrent(); renderCurrent(); // _single ⇒ renderTabbed()
// Straight to the point: the search panel is why this mode exists.
document.getElementById('v3-match-panel')
?.querySelector('[data-mr-search-toggle]')?.click();
} }
function closeModal() { function closeModal() {
@@ -214,7 +215,7 @@
} }
function nav(step) { 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); _idx = Math.min(Math.max(_idx + step, 0), _queue.length - 1);
renderCurrent(); renderCurrent();
} }
@@ -305,17 +306,16 @@
'</button>'; '</button>';
} }
function renderCurrent() { // The middle content shared by the queue-review render and the single-song
const panel = document.getElementById('v3-match-panel'); // popup's Match tab: the chart being matched, its candidate list, and the
if (!panel) return; // "search instead" panel. Header + footer differ per surface. When there
if (!_queue.length) { renderDone(); return; } // are no stored candidates (a manual fix), the search panel opens pre-filled
_idx = Math.min(_idx, _queue.length - 1); // — searching IS the point in that case.
const song = _queue[_idx]; function reviewBodyHtml(song) {
if (song._sel == null) song._sel = 0;
const sub = [song.artist, song.album, song.year, fmtDur(song.duration)].filter(Boolean).join(' · '); const sub = [song.artist, song.album, song.year, fmtDur(song.duration)].filter(Boolean).join(' · ');
const noCands = !(song.candidates || []).length;
panel.innerHTML = headerHtml() + const prefill = noCands ? [song.artist, song.title].filter(Boolean).join(' ') : '';
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll">' + return '<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
// The chart being matched // The chart being matched
'<div class="flex items-start gap-3">' + '<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">' + '<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 +325,27 @@
'<div class="text-xs text-fb-textDim/70 truncate" title="' + esc(song.filename) + '">' + esc(song.filename) + '</div>' + '<div class="text-xs text-fb-textDim/70 truncate" title="' + esc(song.filename) + '">' + esc(song.filename) + '</div>' +
missingChips(song) + missingChips(song) +
'</div></div>' + '</div></div>' +
// Candidates (Fix-match mode arrives with none — the search panel (noCands
// is its whole point, so the empty header is suppressed). ? ''
((song.candidates || []).length : '<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
? '<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>' + '<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('') + song.candidates.map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') +
'</div>' '</div>') +
: '') + // Search panel — hidden when candidates exist (a "Search instead…"
// Search-instead panel // toggle reveals it); open + pre-filled when there are none.
'<div data-mr-search-panel class="hidden space-y-2">' + '<div data-mr-search-panel class="' + (noCands ? '' : 'hidden') + ' space-y-2">' +
'<div class="flex gap-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>' + '<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 data-mr-search-results class="space-y-1"></div></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. // Footer actions. Single mode drops Skip / Not-a-match (no queue); the
'<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' + // 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">' + '<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>') + (_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>' + '<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button>' +
@@ -352,57 +354,252 @@
(_single ? '' : '<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>') + (_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>' : '') + ((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>'; '</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-close]')?.addEventListener('click', closeModal);
panel.querySelector('[data-mr-prev]')?.addEventListener('click', () => nav(-1)); panel.querySelector('[data-mr-prev]')?.addEventListener('click', () => nav(-1));
panel.querySelector('[data-mr-next]')?.addEventListener('click', () => nav(1)); panel.querySelector('[data-mr-next]')?.addEventListener('click', () => nav(1));
panel.querySelector('[data-mr-skip]')?.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. // 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 = () => { if (img) img.onerror = () => {
img.style.visibility = 'hidden'; img.style.visibility = 'hidden';
if (!song._artMissing) { song._artMissing = true; renderCurrent(); } if (!song._artMissing) { song._artMissing = true; renderCurrent(); }
}; };
panel.querySelectorAll('[data-mr-cand]').forEach((btn) => { root.querySelectorAll('[data-mr-cand]').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
song._sel = Number(btn.getAttribute('data-mr-cand')); song._sel = Number(btn.getAttribute('data-mr-cand'));
renderCurrent(); renderCurrent();
}); });
}); });
panel.querySelector('[data-mr-accept]')?.addEventListener('click', async () => { root.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
const cand = (song.candidates || [])[song._sel || 0]; const cand = (song.candidates || [])[song._sel || 0];
if (!cand) return; if (!cand) return;
await post('/api/enrichment/review/' + enc(song.filename) + '/accept', await post('/api/enrichment/review/' + enc(song.filename) + '/accept',
{ recording_id: cand.recording_id }); { recording_id: cand.recording_id });
settle(song); 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'); await post('/api/enrichment/review/' + enc(song.filename) + '/reject');
settle(song); settle(song);
}); });
const sp = panel.querySelector('[data-mr-search-panel]'); const sp = root.querySelector('[data-mr-search-panel]');
const input = panel.querySelector('[data-mr-search-input]'); const input = root.querySelector('[data-mr-search-input]');
panel.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => { root.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => {
sp?.classList.toggle('hidden'); sp?.classList.toggle('hidden');
if (sp && !sp.classList.contains('hidden') && input && !input.value) { if (sp && !sp.classList.contains('hidden') && input && !input.value) {
input.value = [song.artist, song.title].filter(Boolean).join(' '); input.value = [song.artist, song.title].filter(Boolean).join(' ');
input.focus(); input.focus();
} }
}); });
const go = () => runSearch(panel, song); const go = () => runSearch(root, song);
panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go); root.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); 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']];
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;
paintDetails(body, song);
}
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 wont 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">Your edits show in the library right away and are never written to the song files. Use <span class="text-fb-text">Match</span> to pull info from MusicBrainz, or lock a field to keep it.</p>' +
'</div>' +
DETAIL_FIELDS.map(row).join('') +
'<p data-df-status class="text-xs h-4"></p>' +
'</div>' +
'<div class="flex items-center justify-end gap-2 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
'<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));
}
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.'; }
}
// 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 // 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. // renders; the last one renders the done state. No toasts, no sounds.
function settle(song) { 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); const i = _queue.indexOf(song);
if (i >= 0) _queue.splice(i, 1); if (i >= 0) _queue.splice(i, 1);
if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1); if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1);
@@ -464,25 +661,31 @@
status = r.status; status = r.status;
body = await r.json().catch(() => null); body = await r.json().catch(() => null);
} catch (_) { /* falls through to the no-results line */ } } 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)) { 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; return;
} }
if (status === 404) { 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; return;
} }
if (status === 503) { 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; return;
} }
const cands = (body && body.candidates) || []; const cands = (body && body.candidates) || [];
if (!cands.length) { 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; 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(''); cands.map((c, i) => candRowHtml(song, c, i, false)).join('');
out.querySelectorAll('[data-mr-cand]').forEach((btn) => { out.querySelectorAll('[data-mr-cand]').forEach((btn) => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
+1 -1
View File
@@ -949,7 +949,7 @@
// address the local DB / filesystem). Both openers (⋮ and // address the local DB / filesystem). Both openers (⋮ and
// right-click) share this list, so parity is structural. // right-click) share this list, so parity is structural.
...(state.provider === 'local' && song.filename ? [ ...(state.provider === 'local' && song.filename ? [
{ id: '__fixmatch', label: 'Fix match…' }, { id: '__fixmatch', label: 'Fix metadata…' },
{ id: '__cover', label: 'Change cover…' }, { id: '__cover', label: 'Change cover…' },
{ id: '__refreshmeta', label: 'Refresh metadata' }, { id: '__refreshmeta', label: 'Refresh metadata' },
{ id: '__getinfo', label: 'Get info…' }, { id: '__getinfo', label: 'Get info…' },
+232
View File
@@ -0,0 +1,232 @@
"""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_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
+13
View File
@@ -110,6 +110,19 @@ def test_preview_excludes_author_set_keys(server, client):
assert {"genres", "mbid", "isrc"} <= got 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): def test_preview_excludes_present_but_empty_keys(server, client):
"""Gap-fill is append-only, so a present-but-empty value (album: '', """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 year: 0) is NOT a gap the writer can fill — appending would duplicate the
+16
View File
@@ -185,6 +185,22 @@ def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
assert row["mb_recording_id"] == "rec-jp" 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) ────────────────── # ── offline safety (the pytest-never-hits-network contract) ──────────────────
def test_offline_default_skips_matching(server, monkeypatch): def test_offline_default_skips_matching(server, monkeypatch):