mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-15 05:07:15 +00:00
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:
co-authored by
Claude Opus 4.8
parent
3e036e3db6
commit
af1170cec3
@@ -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()
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+257
-54
@@ -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 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">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
@@ -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…' },
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user