Commit 719f0764 by PLN (Algolia)

feat(release): generate the upload plan from canonical sources, never by hand

`sc album --plan` wants titles, files and tags as JSON. Typing that by hand is
exactly how a release ships with the wrong album name — split_bandcamp.py once
carried an ALBUM string that was simply wrong and nothing downstream could tell.

So build_release_plan.py generates it, and every field is copied from the one
place that owns it: gig metadata from Web/www content/lives/{year}/{slug}.md,
per-track section/style/sample-banks from that gig's tracks.json, release order
and durations from the rendered segments, approval from the ear file's
release_signoff. `_provenance` records each source, so a wrong string is traced
rather than argued about.

TWO REFUSALS, both because the failure they prevent is invisible after upload.

1. NO SIGNOFF, NO PLAN. The ear file's release_signoff is pinned to the segments
   PLN approved; if it is missing, or the rendered FLACs are NEWER than it, the
   record on disk is not the record he cleared. An upload is hard to take back,
   so the check belongs before it and not in a checklist. --force overrides and
   says so in the log.

2. NO ROW, NO PLAN. The first run joined the tracklist on the raw title and
   silently missed 4 of 14 — because the canonical names are written in PLN's
   blog voice ("There's **Something About Drums** <3", "Am i _Doing it Right_",
   "GHOSTS IN THE TOILETS") while the segments carry the plain form. The plan
   looked complete: 14 tracks, right durations, right files. Four of them just
   had empty genre, empty section and NO sample banks — and the sample-bank list
   is the input to the rights question. A parser miss must never become a quiet
   gap in the catalog, so an unmatched title now refuses the whole plan and
   prints both name lists side by side.

The fix for the join itself is match_key(): strip emphasis, hearts, case and
punctuation for MATCHING ONLY, while every value that ships still comes from the
canonical record verbatim. Which surfaced a real question rather than hiding it
— the two sources disagree about three names, so --titles chooses, defaulting to
canonical, and the diff is printed:

    "There's Something About Drums" -> "There's Something About Drums <3"
    'Perfect'                       -> 'Perfect <3'
    'Ghosts in the Toilets'         -> 'GHOSTS IN THE TOILETS'

Those decorations are his voice, not markup, and clean_title already knew to
keep them. Verified against the shipped audio: 14 tracks, durations matching the
rendered files including CRIME's 2s rest (271.1s) and REVOLUTION's reverb tail
(205.6s). Every track carries `_rights_checked: false` — the banks are listed,
nobody has cleared them.
parent c7751920
#!/usr/bin/env python3
"""build_release_plan — the upload plan, generated from canonical sources only.
`tidal-ears master sc album --plan …` wants a JSON list of tracks with titles,
files and tags. Typing that by hand is exactly how a release goes out with the
wrong album name: `split_bandcamp.py` once carried an ALBUM string that was
simply wrong, and nothing downstream could tell.
So this generates it, and every field is copied from the one place that owns it:
gig metadata (title, date, venue, description, tags)
<- Web/www content/lives/{year}/{slug}.md -- CANONICAL, never invented
tracklist (per-track bpm, section, sample banks)
<- .../{slug}/tracks.json -- CANONICAL
what actually ships (release position, duration, edits)
<- the rendered segments + the split FLACs -- the artefact itself
the ear's approval
<- judge_specs/<gig>_boundaries_ear.json -- release_signoff
Nothing is composed from a script variable, and `_provenance` records where each
value came from so a wrong string can be traced instead of argued about.
**It refuses to emit an unsigned plan.** The ear file carries a `release_signoff`
pinned to the segments it approved; if that block is missing, or the rendered
tracks are newer than it, the record on disk is not the record PLN cleared and
the plan is not written. An upload is hard to take back — the check belongs
before it, not in a checklist.
python3 build_release_plan.py judge_specs/opal26.json \\
--variant streaming --out /tmp/opal26_plan.json
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from build_judge_set import clean_title, ffprobe_dur # noqa: E402
WWW = Path("/home/pln/Work/Web/www")
def match_key(s: str) -> str:
"""A join key that survives PLN's prose, for MATCHING only.
The canonical names are written in a blog voice — `There's **Something
About Drums** <3`, `Am i _Doing it Right_`, `GHOSTS IN THE TOILETS` — while
the segments carry the plain form. Joining on the raw strings silently
missed 4 of 14 tracks and produced a plan with empty genre, empty section
and NO sample banks, which is the rights input. It looked fine.
So the key throws away everything that is presentation (emphasis, hearts,
case, punctuation, spacing) and keeps only letters and digits. It is used
ONLY to find the row; every value that ships still comes from the canonical
record verbatim.
"""
s = clean_title(s).lower().replace("<3", "")
return "".join(c for c in s if c.isalnum())
def frontmatter(md: Path) -> dict:
"""Minimal YAML front-matter reader — flat scalars and inline lists only.
Deliberately not a YAML dependency: this reads four fields out of a file
whose shape we control, and a parser that silently accepts more than the
format allows is how a typo becomes a released album title.
"""
text = md.read_text()
if not text.startswith("---"):
raise ValueError(f"{md}: no front matter")
body = text.split("---", 2)[1]
out: dict = {}
for line in body.splitlines():
if not line.strip() or line.lstrip().startswith("#") or ":" not in line:
continue
k, v = line.split(":", 1)
v = v.strip()
if v.startswith("[") and v.endswith("]"):
out[k.strip()] = [x.strip().strip('"\'') for x in v[1:-1].split(",") if x.strip()]
else:
out[k.strip()] = v.strip('"\'')
return out
def check_signoff(ear: dict, tracks: list[Path], force: bool) -> list[str]:
"""The gate. Returns problems; empty means the record is the approved one."""
problems = []
so = ear.get("release_signoff")
if not so:
problems.append(
"no `release_signoff` in the ear file — nobody has confirmed this "
"record by ear. Run render_release + build_release_joins and listen.")
return problems
signed = datetime.fromisoformat(so["date"]).date()
newest = max((datetime.fromtimestamp(p.stat().st_mtime).date() for p in tracks),
default=None)
if newest and newest > signed:
problems.append(
f"the rendered tracks ({newest}) are NEWER than the signoff "
f"({signed}). Audio has changed since PLN approved it; re-audition "
f"before uploading.")
if force and problems:
for p in problems:
print(f" ! OVERRIDDEN: {p}", file=sys.stderr)
return []
return problems
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("spec")
ap.add_argument("--variant", default="streaming")
ap.add_argument("--artwork", help="cover image applied to every track")
ap.add_argument("--out", required=True)
ap.add_argument("--titles", default="canonical", choices=["canonical", "file"],
help="track names from the canonical tracklist (default) or "
"from the rendered filenames")
ap.add_argument("--force", action="store_true",
help="emit even without a valid ear signoff (say why in the log)")
a = ap.parse_args()
spec = json.loads(Path(a.spec).read_text())
gig = spec["gig"]
year = spec["date"][:4]
md_path = WWW / "content" / "lives" / year / f"{gig}.md"
tj_path = WWW / "content" / "lives" / year / gig / "tracks.json"
if not md_path.exists():
sys.exit(f"canonical gig metadata not found: {md_path}\n"
f" Gig metadata is never invented here — create it there first.")
fm = frontmatter(md_path)
tj = json.loads(tj_path.read_text())
tj_tracks = tj.get("tracks", tj) if isinstance(tj, dict) else tj
by_name = {match_key(t["name"]): t for t in tj_tracks}
ear = json.loads(Path(spec["earBoundaries"]).read_text())
segs = json.loads(Path(spec["segmentsRelease"]).read_text())
tdir = Path(spec["releaseRoot"]) / f"tracks_{spec['releaseTag']}_{a.variant}"
files = sorted(tdir.glob("*.flac"))
if len(files) != len(segs):
sys.exit(f"{len(files)} rendered files but {len(segs)} segments in {tdir}")
problems = check_signoff(ear, files, a.force)
if problems:
print("REFUSING TO WRITE A PLAN — the record is not ear-approved:")
for p in problems:
print(" ✗ " + p)
return 1
album = fm.get("title") or spec.get("album")
base_tags = list(fm.get("tags", []))
# A parser miss must never become a quiet gap in the catalog. Refuse the
# whole plan rather than upload four tracks with no genre, no section and
# no sample-bank list — a missing field is invisible once it is on the shelf.
unmatched = [s["title"] for s in segs if match_key(s["title"]) not in by_name]
if unmatched:
print(f"REFUSING TO WRITE A PLAN — {len(unmatched)} track(s) have no row "
f"in the canonical tracklist:")
for t in unmatched:
print(f" ✗ {t!r} (key {match_key(t)!r})")
print(f" canonical names in {tj_path.name}:")
for t in tj_tracks:
print(f" {t['name']!r}")
return 1
tracks, renamed = [], []
for seg, f in zip(segs, files):
src = by_name[match_key(seg["title"])]
# The canonical record owns the NAME, including the decorations that are
# PLN's voice ("Perfect <3"). The segment title is the working name the
# rendered file carries. They differ on four tracks, so say so out loud
# rather than pick one silently.
title = clean_title(src["name"]) if a.titles == "canonical" else seg["title"]
if title != seg["title"]:
renamed.append((seg["title"], title))
# Sample banks are per-track FACT from the score, and they are the input
# to the rights question (A4). Carried into the plan so that question is
# answerable per track rather than re-derived later.
banks = src.get("samples", [])
tags = base_tags + [t for t in (src.get("style"), src.get("section")) if t]
tracks.append({
"title": title,
"audio": str(f),
"artwork": a.artwork,
"genre": src.get("style", ""),
"tag_list": " ".join(f'"{t}"' if " " in t else t for t in dict.fromkeys(tags)),
"description": (
f"{album} — {fm.get('address','')}, {fm.get('date','')}. "
f"Track {seg['track']}/{len(segs)}"
+ (f" · {src['section']}" if src.get("section") else "")
+ (f" · {seg['bpm']} BPM" if seg.get("bpm") else "")
+ ". Live coded in TidalCycles."),
"_bpm": seg.get("bpm"),
"_perf_track": seg.get("perf_track"),
"_file_title": seg["title"],
"_duration_s": round(ffprobe_dur(f), 3),
"_sample_banks": banks,
"_rights_checked": False,
})
plan = {
"_comment": [
"GENERATED by build_release_plan.py — do not hand-edit.",
"Every value traces to `_provenance`; fix the source, re-generate.",
"`_rights_checked` is false on every track: the sample banks are",
"listed but nobody has cleared them. The continuous mix is the",
"lower-risk upload; per-track needs that pass first.",
],
"album": album,
"artist": spec.get("artist", "ParVagues"),
"date": fm.get("date"),
"_provenance": {
"album/date/description/tags": str(md_path.relative_to(WWW)),
"per-track section/style/samples": str(tj_path.relative_to(WWW)),
"release order/duration/edits": spec["segmentsRelease"],
"audio": str(tdir),
"ear signoff": f"{Path(spec['earBoundaries']).name} :: release_signoff "
f"({ear.get('release_signoff', {}).get('date', 'NONE')})",
"generated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
},
"tracks": tracks,
}
Path(a.out).write_text(json.dumps(plan, indent=1, ensure_ascii=False) + "\n")
print(f"{album} · {plan['artist']} · {fm.get('date')} · {len(tracks)} tracks "
f"({a.variant})")
for t in tracks:
print(f" {t['_duration_s']:>8.1f}s {t['title'][:34]:<34} {t['genre']}")
if renamed:
print(f"\n {len(renamed)} title(s) taken from the canonical tracklist "
f"rather than the filename (--titles file to keep the filename):")
for old, new in renamed:
print(f" {old!r} -> {new!r}")
if not a.artwork:
print("\n ! no --artwork: tracks would go up without a cover")
print(f"\n✓ {a.out}")
print(" Next: tidal-ears master sc album --plan <this> --sharing private "
"--transport browser")
return 0
if __name__ == "__main__":
sys.exit(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment