feedBack/lib/songmeta.py
ChrisBeWithYou 4db3db4622 v3 library: per-field pack overwrite (R4b) — fix wrong author values, with receipts
DRAFT — gated on the feedpak-spec §7 amendment (got-feedback/feedpak-spec#47).
Gap-fill (#724) fills absent keys; this fixes WRONG author-set values (a
typo'd artist, a wrong year) one field at a time, with provenance and an undo.
Built fully compliant with the amendment shape so it's ready the moment §7
is amended.

Server (extends the merged gap-fill endpoints, doesn't fork):
- Preview GET /api/song/{fn}/gap-fill now also returns `differs` =
  [{key, current, proposed}] over the allowlist (title/artist/album/year/
  genres) where the manifest HAS an author value and the confirmed match
  supplies a DIFFERENT one — plus `overwrite_allowed` (the Settings gate) and
  `has_backup`. differs is EMPTY unless match_state == 'manual': a
  user-confirmed pin can authorize a replace; an auto-match never can. mbid/
  isrc are never in differs (identity changes only via re-match).
- POST accepts `overwrite_keys` alongside gap-fill's `keys`; gated on the
  allow_pack_overwrite setting + manual state + the allowlist, proposals
  recomputed under _song_io_lock so a field that changed since preview is
  skipped. Writes via the re-serialize writer (extended to handle the genres
  list). Every written key (gap-fill AND overwrite) lands in a new write_log
  receipts table (filename, key, old, new, source, score, ts), pruned to the
  newest 5000 rows.
- POST /api/song/{fn}/revert-original restores the pack from its .bak (dir +
  zip forms), re-stats + re-syncs the DB; 404 when no backup; the .bak is
  PRESERVED after revert (re-appliable). Demo-blocked, like the write.
- GET /api/song/{fn}/write-log — the song's receipts, newest first.
- Setting allow_pack_overwrite (default OFF).

The .bak safety property is unchanged from the shipped writer: the backup is
written ONCE (first write) and is always the pristine author original — a
second overwrite never clobbers it, so Revert always returns the untouched
pack.

Frontend (Details drawer, extends the gap-fill block): when the preview
returns differs AND overwriting is enabled, an "Overwrite existing fields"
sub-block renders per-key rows `Artist: "ACDC" -> "AC/DC"`, ALL UNTICKED by
default, with the warning that it replaces what the author wrote and the
original is kept as a backup; the Write button carries the ticked keys. When
differs exist but the setting is off, one muted line points to Settings. A
"Revert file to original…" link (confirm dialog) appears whenever a backup
exists. Settings gains the allow_pack_overwrite checkbox.

Tests: tests/test_pack_overwrite.py — differs manual-only + identity-excluded
+ equal-skipped, refused when setting-off / not-manual / bad keys, dir + zip
overwrite, .bak stays the pristine original across a second write, write_log
records old+new (and gap-fills), prune at cap, revert dir+zip+404, demo
blocks write & revert. 30 pass with test_gap_fill green. node --check clean;
no new Tailwind classes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
2026-07-02 20:53:36 -05:00

200 lines
8.5 KiB
Python

"""Persist user-edited song metadata back into the underlying song file.
The library scanner (`lib/scan_worker.py`) re-derives title/artist/album/year
from the file on every full rescan — from the sloppak ``manifest.yaml``
top-level keys. A DB-only edit therefore reverts the moment a full rescan
re-reads the file. Writing the edit into the file makes the file the single
source of truth, so the change survives both incremental and full rescans.
``fields`` is a partial dict of any of ``title``/``artist``/``album``/``year``;
only the keys present are overwritten, so an edit of just the title can't blank
out the artist.
Only feedBack's own song-package format (zip- or directory-form, ``.feedpak``
or the legacy ``.sloppak`` suffix) is writable. Unknown / unsupported shapes
return False and the caller keeps the DB-only update.
"""
from __future__ import annotations
import shutil
import zipfile
from pathlib import Path
import yaml
def _coerce_year(value):
"""Convert *value* to an int year, or 0 for empty/invalid (clear intent).
The scanner reads ``SongYear`` as ``str(manifest.get("year", "") or "")``
for sloppaks — so 0 round-trips back to an empty string, which is the
correct DB representation of "no year". Callers must gate on
``"year" in fields`` before calling this; they must NOT gate on the return
value being non-None/non-zero (that would silently drop a year-clear edit,
which is the bug this function fixes).
"""
try:
return int(value)
except (TypeError, ValueError):
return 0 # empty string / non-numeric → clear the year
def _apply_to_sloppak_manifest(manifest: dict, fields: dict) -> bool:
"""Update a parsed sloppak manifest in place. Returns True if changed."""
dirty = False
for key in ("title", "artist", "album"):
if fields.get(key) is not None:
manifest[key] = str(fields[key])
dirty = True
if "year" in fields:
manifest["year"] = _coerce_year(fields["year"])
dirty = True
# `genres` is the feedpak list field (spec 1.12.0). Accepts a list/tuple
# (stringified item-wise) or a single name (wrapped); None leaves the
# existing value alone, mirroring the string fields above. Sent by the
# overwrite lane (R4b) — the manual Edit Metadata path never includes it.
if fields.get("genres") is not None:
raw = fields["genres"]
manifest["genres"] = ([str(g) for g in raw]
if isinstance(raw, (list, tuple)) else [str(raw)])
dirty = True
# Opportunistically declare the format version (spec §4) when we're already
# rewriting because a metadata field was supplied. Gated on `dirty` (i.e. a
# field was given) so this never forces a *standalone* rewrite with no fields
# passed, and `not in` so an existing (possibly higher) version is preserved,
# never downgraded. NB `dirty` here means "a field was supplied" — a
# supplied-but-identical value already triggers a rewrite (pre-existing).
if dirty and "feedpak_version" not in manifest:
from sloppak import FEEDPAK_VERSION
manifest["feedpak_version"] = FEEDPAK_VERSION
return dirty
def _rewrite_zip_manifest(zip_path: Path, dumped: str) -> bool:
"""Rewrite manifest.yaml inside a zip-form sloppak, preserving every other
entry (and its original compression). Backup + temp + atomic replace."""
zip_path = Path(zip_path)
backup = zip_path.with_name(zip_path.name + ".bak")
if not backup.exists():
shutil.copy2(zip_path, backup)
out_tmp = zip_path.with_name(zip_path.name + ".tmp")
with zipfile.ZipFile(str(zip_path), "r") as zin:
names = zin.namelist()
manifest_name = "manifest.yaml"
for cand in ("manifest.yaml", "manifest.yml"):
if cand in names:
manifest_name = cand
break
with zipfile.ZipFile(str(out_tmp), "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename in ("manifest.yaml", "manifest.yml"):
continue
# Passing the original ZipInfo preserves each entry's
# compress_type — important for already-compressed ogg stems.
zout.writestr(item, zin.read(item.filename))
zout.writestr(manifest_name, dumped)
out_tmp.replace(zip_path)
return True
def write_sloppak_metadata(path: Path, fields: dict) -> bool:
"""Write metadata into a sloppak (directory or zip form). Returns True if
anything was written."""
import sloppak as sloppak_mod
path = Path(path)
manifest = sloppak_mod.load_manifest(path)
if not _apply_to_sloppak_manifest(manifest, fields):
return False
dumped = yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True)
if path.is_dir():
mf = path / "manifest.yaml"
if not mf.exists() and (path / "manifest.yml").exists():
mf = path / "manifest.yml"
# One-time backup + temp + atomic replace, exactly like the zip
# rewriter and gap_fill_sloppak's dir branch: the FIRST backup is the
# pristine author original and is never clobbered by a later write —
# it's what "Revert file to original" (R4b) restores.
backup = mf.with_name(mf.name + ".bak")
if mf.exists() and not backup.exists():
shutil.copy2(mf, backup)
tmp = mf.with_name(mf.name + ".tmp")
tmp.write_text(dumped, encoding="utf-8")
tmp.replace(mf)
return True
return _rewrite_zip_manifest(path, dumped)
def gap_fill_sloppak(path: Path, additions: dict) -> bool:
"""Append ABSENT top-level keys to a sloppak manifest (the gap-fill
contract: user-initiated, adds missing keys only, never replaces
anything the author set).
Unlike ``write_sloppak_metadata`` this does NOT re-serialize the
manifest — because every added key is absent by definition, the new
lines can simply be appended, so the author's existing bytes (key
order, comments, formatting) survive verbatim. Directory form gets a
one-time ``manifest.yaml.bak`` + temp + atomic replace; zip form goes
through the same backup/temp/replace rewriter the metadata editor
uses. Returns True if anything was written; raises ``ValueError`` if
a requested key already exists (callers are expected to have checked
— this is the last-line never-clobber guard)."""
import sloppak as sloppak_mod
path = Path(path)
if not additions:
return False
manifest = sloppak_mod.load_manifest(path) or {}
clash = sorted(k for k in additions if k in manifest)
if clash:
raise ValueError("gap-fill refused: key(s) already present: " + ", ".join(clash))
if path.is_dir():
mf = path / "manifest.yaml"
if not mf.exists() and (path / "manifest.yml").exists():
mf = path / "manifest.yml"
original = mf.read_text(encoding="utf-8")
else:
with zipfile.ZipFile(str(path), "r") as zin:
names = zin.namelist()
manifest_name = "manifest.yaml"
for cand in ("manifest.yaml", "manifest.yml"):
if cand in names:
manifest_name = cand
break
original = zin.read(manifest_name).decode("utf-8")
appended = original if original.endswith("\n") or not original else original + "\n"
appended += yaml.safe_dump(additions, sort_keys=False, allow_unicode=True)
if path.is_dir():
backup = mf.with_name(mf.name + ".bak")
if not backup.exists():
shutil.copy2(mf, backup)
tmp = mf.with_name(mf.name + ".tmp")
tmp.write_text(appended, encoding="utf-8")
tmp.replace(mf)
return True
return _rewrite_zip_manifest(path, appended)
def write_song_metadata(path: Path, fields: dict) -> bool:
"""Persist edited title/artist/album/year into the song's file.
Dispatches by shape: zip-form song packages (``.feedpak`` / legacy
``.sloppak``, per ``sloppak.SONG_EXTS``) and package directories
(manifest.yaml present). Loose-folder and unknown shapes return False
(caller keeps the DB-only update). Returns True if the file was modified.
"""
from sloppak import SONG_EXTS
path = Path(path)
suffix = path.suffix.lower()
if path.is_dir():
if (path / "manifest.yaml").exists() or (path / "manifest.yml").exists():
return write_sloppak_metadata(path, fields)
return False
if suffix in SONG_EXTS:
return write_sloppak_metadata(path, fields)
return False