diff --git a/lib/songmeta.py b/lib/songmeta.py
index 87301b7..895070a 100644
--- a/lib/songmeta.py
+++ b/lib/songmeta.py
@@ -107,6 +107,59 @@ def write_sloppak_metadata(path: Path, fields: dict) -> bool:
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.
diff --git a/server.py b/server.py
index e71c8cc..c39e58d 100644
--- a/server.py
+++ b/server.py
@@ -244,6 +244,8 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
# rate limit; Get-info exposes filesystem paths.
("POST", re.compile(r"^/api/enrichment/refresh/.+$")),
("GET", re.compile(r"^/api/chart/.+/fileinfo$")),
+ # Gap-fill (R4a) rewrites pack files on disk — never for demo visitors.
+ ("POST", re.compile(r"^/api/song/.+/gap-fill$")),
]
@@ -5711,6 +5713,11 @@ def _manifest_exact_ids(filename: str) -> dict:
if _MBID_RE.match(mbid):
out["mbid"] = mbid
isrc = str(manifest.get("isrc", "") or "").strip().upper()
+ # Spec 1.14.0: the stored form is the bare 12-char code, but ISRCs
+ # circulate hyphenated in the wild (US-ABC-24-00001) — the separators
+ # are presentation, not part of the code, so a hand-authored display
+ # form still matches (consumers SHOULD strip before comparing).
+ isrc = isrc.replace("-", "").replace(" ", "")
if _ISRC_RE.match(isrc):
out["isrc"] = isrc
return out
@@ -10231,6 +10238,158 @@ def update_song_meta(filename: str, data: dict):
return {"ok": True, "persisted": persisted}
+# ── Gap-fill: write CONFIRMED missing metadata into the pack (R4a) ────────────
+# The agreed write-back contract (spec-alignment §7): opt-in + user-initiated
+# (nothing here runs in the background), adds ABSENT keys only (never replaces
+# an author-set value — the writer refuses, and existing manifest bytes are
+# preserved verbatim by appending), spec'd-keys allowlist, values only from a
+# CONFIRMED identity (an auto/exact match or a user pin — review-tier rows are
+# not eligible until a human confirms), atomic write + .bak. Single-song only;
+# batch write-back stays an open question with the spec chair.
+_GAP_FILL_KEYS = ("album", "year", "genres", "mbid", "isrc")
+
+
+def _gap_fill_manifest_absent(manifest: dict, key: str) -> bool:
+ """A key is a GAP only when it's genuinely MISSING from the manifest.
+
+ Gap-fill is append-only: the writer's never-clobber guard raises on ANY
+ key already present, and appending a second `album:` line to a manifest
+ that already carries `album: ''` would just create a duplicate YAML key.
+ So a present-but-empty value (None / '' / [] / year 0) is NOT a gap the
+ append-only writer can fill — offering it in the preview would only lead
+ to a POST the writer refuses. Present-but-empty keys are therefore left
+ to the metadata editor (which re-serializes and can replace in place)."""
+ return key not in manifest
+
+
+def _gap_fill_proposals(cache_key: str, resolved) -> tuple[dict, str]:
+ """What gap-fill could add for this song: (proposals, reason). Empty
+ proposals explain themselves via reason — 'not-sloppak', 'no-match'
+ (nothing confirmed yet), 'review' (a human hasn't confirmed the match),
+ or 'nothing-missing'."""
+ if resolved is None or not resolved.exists() or not sloppak_mod.is_sloppak(resolved):
+ return {}, "not-sloppak"
+ row = meta_db.get_enrichment(cache_key)
+ if not row or row.get("match_state") not in ("matched", "manual"):
+ state = (row or {}).get("match_state")
+ return {}, ("review" if state == "review" else "no-match")
+ try:
+ manifest = sloppak_mod.load_manifest(resolved) or {}
+ except Exception:
+ return {}, "not-sloppak"
+ out = {}
+ album = (row.get("canon_album") or "").strip()
+ if album and _gap_fill_manifest_absent(manifest, "album"):
+ out["album"] = album
+ year = (row.get("canon_year") or "").strip()
+ if year.isdigit() and int(year) and _gap_fill_manifest_absent(manifest, "year"):
+ out["year"] = int(year)
+ genres = [str(g) for g in (row.get("genres") or []) if isinstance(g, str) and g.strip()]
+ if genres and _gap_fill_manifest_absent(manifest, "genres"):
+ out["genres"] = genres
+ # Identity keys (feedpak spec 1.14.0) — written in canonical form only.
+ mbid = (row.get("mb_recording_id") or "").strip().lower()
+ if _MBID_RE.match(mbid) and _gap_fill_manifest_absent(manifest, "mbid"):
+ out["mbid"] = mbid
+ isrc = (row.get("isrc") or "").strip().upper().replace("-", "").replace(" ", "")
+ if _ISRC_RE.match(isrc) and _gap_fill_manifest_absent(manifest, "isrc"):
+ out["isrc"] = isrc
+ return out, ("" if out else "nothing-missing")
+
+
+@app.get("/api/song/{filename:path}/gap-fill")
+def get_song_gap_fill(filename: str):
+ """Preview what "Write missing info to file" would add — the Details
+ drawer renders its confirm list straight from this. Read-only."""
+ dlc = _get_dlc_dir()
+ cache_key, resolved = filename, None
+ if dlc:
+ resolved = _resolve_dlc_path(dlc, filename)
+ if resolved is None:
+ return JSONResponse({"error": "forbidden"}, 403)
+ try:
+ cache_key = resolved.relative_to(dlc.resolve()).as_posix()
+ except ValueError:
+ pass
+ proposals, reason = _gap_fill_proposals(cache_key, resolved)
+ row = meta_db.get_enrichment(cache_key) or {}
+ return {
+ "eligible": bool(proposals),
+ "reason": reason,
+ "match_state": row.get("match_state"),
+ "missing": [{"key": k, "value": v} for k, v in proposals.items()],
+ }
+
+
+@app.post("/api/song/{filename:path}/gap-fill")
+def post_song_gap_fill(filename: str, data: dict):
+ """Write the user-confirmed subset of the preview into the pack file.
+ Proposals are recomputed under the io lock, so a key that gained an
+ author value between preview and confirm is skipped, never replaced."""
+ keys = (data or {}).get("keys")
+ if not isinstance(keys, list) or not keys:
+ return JSONResponse({"error": "keys must be a non-empty list"}, 400)
+ bad = [k for k in keys if k not in _GAP_FILL_KEYS]
+ if bad:
+ return JSONResponse(
+ {"error": "unknown key(s): " + ", ".join(sorted(set(map(str, bad))))}, 400)
+
+ dlc = _get_dlc_dir()
+ cache_key, resolved = filename, None
+ if dlc:
+ resolved = _resolve_dlc_path(dlc, filename)
+ if resolved is None:
+ return JSONResponse({"error": "forbidden"}, 403)
+ try:
+ cache_key = resolved.relative_to(dlc.resolve()).as_posix()
+ except ValueError:
+ pass
+
+ with _song_io_lock:
+ proposals, reason = _gap_fill_proposals(cache_key, resolved)
+ additions = {k: proposals[k] for k in _GAP_FILL_KEYS if k in keys and k in proposals}
+ skipped = sorted(set(keys) - set(additions))
+ if not additions:
+ return JSONResponse({"error": "nothing to write", "reason": reason,
+ "skipped": skipped}, 409)
+ try:
+ import songmeta
+ songmeta.gap_fill_sloppak(resolved, additions)
+ except Exception:
+ log.warning("gap-fill write failed for %s", cache_key, exc_info=True)
+ return JSONResponse({"error": "write failed"}, 500)
+
+ # Keep the cache row consistent with what the scanner would now derive
+ # (same contract as the metadata editor above): sync the columns the
+ # scan reads from the keys we appended, then re-stat so the row stays
+ # cache-fresh.
+ fields = {}
+ if "album" in additions:
+ fields["album"] = additions["album"]
+ if "year" in additions:
+ fields["year"] = str(additions["year"])
+ if "genres" in additions:
+ fields["genre"] = additions["genres"][0]
+ with meta_db._lock:
+ updates = [f"{field} = ?" for field in fields]
+ params = list(fields.values())
+ try:
+ mtime, size = _stat_for_cache(resolved)
+ updates += ["mtime = ?", "size = ?"]
+ params += [mtime, size]
+ except OSError:
+ pass
+ if updates:
+ params.append(cache_key)
+ meta_db.conn.execute(
+ f"UPDATE songs SET {', '.join(updates)} WHERE filename = ?", params)
+ meta_db.conn.commit()
+
+ _invalidate_song_caches(cache_key)
+ _kick_scan()
+ return {"ok": True, "written": additions, "skipped": skipped}
+
+
@app.post("/api/song/{filename:path}/art/upload")
async def upload_song_art_b64(filename: str, data: dict):
"""Upload custom album art as base64 PNG/JPG."""
diff --git a/static/v3/songs.js b/static/v3/songs.js
index 6d6a341..941f8b4 100644
--- a/static/v3/songs.js
+++ b/static/v3/songs.js
@@ -2461,6 +2461,7 @@
diff: (meta.user_difficulty != null ? meta.user_difficulty : null),
notes: meta.notes || '', tags: (meta.tags || []).slice(),
fav: !!song.favorite, artDataUrl: null,
+ gap: null, gapSel: null, // gap-fill (R4a): preview state + selected keys
};
const overlay = document.createElement('div');
@@ -2487,6 +2488,41 @@
if (first) { try { first.focus({ preventScroll: true }); const n = first.value.length; first.setSelectionRange(n, n); } catch (_) { /* */ } }
}
+ // Gap-fill (R4a) block inside the drawer's Identity section: preview →
+ // per-key confirm → written. Adds ABSENT keys only; the server re-checks
+ // under its io lock, so this UI can never replace an author-set value.
+ const GAP_KEY_LABELS = { album: 'Album', year: 'Year', genres: 'Genres', mbid: 'MusicBrainz ID', isrc: 'ISRC' };
+ function gapFillHtml(st) {
+ const g = st.gap;
+ if (!g) return '';
+ if (g.loading) return '
';
+ }
+ if (!g.eligible) {
+ const why = {
+ 'not-sloppak': 'Only feedpak songs can be written to.',
+ 'no-match': 'No confirmed match yet — nothing verified to write.',
+ 'review': 'This song’s match is waiting for review — confirm it first.',
+ 'nothing-missing': 'Nothing missing — the file already has all of this.',
+ }[g.reason] || 'Could not check the file. Try again.';
+ return '