quranic-universal-audio / scripts /backfills /patch_release_audio_sources.py
hetchyy's picture
deploy: inspector prod @ 79dbec7
8362cf3 verified
Raw
History Blame Contribute Delete
12.8 kB
#!/usr/bin/env python3
"""Patch a shipped GitHub release so playlist (YouTube/Drive) recitations carry
their NATIVE source URL + per-chapter offset in ``catalog.json`` instead of the
internal inspector bucket URL.
Background. Before the ``cut_release`` fix, the GH release ``catalog.json`` was
built from the audio manifest's ``chapters[*].url`` verbatim. For a combined
non-CDN source (one YouTube/Drive file serving several chapters) the manifest
stores a per-chapter *bucket* path in ``url`` and the real source in
``source_url`` (+ ``source_offset_ms``), so the release leaked the bucket URL
and dropped the offset. The HF dataset already resolved ``source_url`` per row;
this brings already-shipped GH releases in line and is a no-op once the cut fix
ships (re-running on a patched release finds nothing to change).
What it does, per affected recitation (one whose bucket manifest carries a
``source_url`` or a non-zero ``source_offset_ms`` on any chapter):
1. rebuilds the in-zip ``catalog.json`` ``audio.chapter_urls`` from the bucket
manifest's native ``source_url``/``url`` and adds ``audio.chapter_offsets_ms``
(same resolution as ``cut_release._audio_sources_from_manifest``), keeping
every other field byte-for-byte;
2. re-zips ``<slug>.zip`` (tier files untouched) and recomputes its sha256/bytes
+ the manifest ``content_hash`` (``sha256(letter_tier.gz || catalog.json)``);
3. rewrites the release-level ``catalog.json`` + ``manifest.json`` accordingly;
4. re-uploads only the changed assets to the SAME release tag (``--apply``).
Safety gates abort before any upload if the deterministic zip re-pack doesn't
reproduce the shipped zip byte-for-byte, if a recomputed ``content_hash``
doesn't match the shipped manifest, or if a no-change round-trip of
``catalog.json`` / ``manifest.json`` isn't byte-identical — i.e. it only ever
ships bytes it can prove it reproduced.
The bucket manifests are read from the mount (``INSPECTOR_BUCKET_MOUNT`` or
``--bucket-mount``); pass the PROD bucket mount since releases are cut from prod.
Usage (dry-run prints the plan; ``--apply`` performs the upload)::
python scripts/backfills/patch_release_audio_sources.py --tag v2.0.0 \
--bucket-mount ~/qua-prod-bucket
python scripts/backfills/patch_release_audio_sources.py --tag v2.0.0 \
--bucket-mount ~/qua-prod-bucket --apply
"""
from __future__ import annotations
import argparse
import io
import json
import subprocess
import sys
import zipfile
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from qua_jobs.cut_release import ( # noqa: E402
_audio_sources_from_manifest,
_json_model_bytes,
_pack_recitation_zip,
_sha256_hex,
)
from qua_shared.schemas import ( # noqa: E402
ReleaseCatalog,
ReleaseManifest,
ReleaseRecitationCatalog,
)
_TIER_FILES = (
"verse_timestamps.json.gz",
"word_timestamps.json.gz",
"letter_timestamps.json.gz",
)
def _gh(*args: str, repo: str, capture: bool = True) -> str:
"""Run a ``gh`` subcommand, raising on failure."""
cmd = ["gh", *args, "--repo", repo]
proc = subprocess.run(cmd, capture_output=capture, text=True)
if proc.returncode != 0:
raise RuntimeError(f"gh {' '.join(args)} failed: {proc.stderr.strip()[:400]}")
return proc.stdout
def _latest_tag(repo: str) -> str:
out = _gh("release", "view", "--json", "tagName", repo=repo)
return json.loads(out)["tagName"]
def _download_asset(repo: str, tag: str, name: str, dest_dir: Path) -> Path:
dest = dest_dir / name
if dest.exists():
dest.unlink()
_gh("release", "download", tag, "--pattern", name, "--dir", str(dest_dir), repo=repo)
if not dest.exists():
raise RuntimeError(f"{name} not found in release {tag}")
return dest
def _load_bucket_manifest(bucket_mount: Path, slug: str) -> dict | None:
path = bucket_mount / "catalog" / "audio_manifest" / f"{slug}.json"
if not path.exists():
return None
return json.loads(path.read_bytes())
def _zip_members(zip_bytes: bytes) -> dict[str, bytes]:
out: dict[str, bytes] = {}
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
for name in zf.namelist():
out[name] = zf.read(name)
return out
def _fixed_audio_block(
existing_audio: dict, bucket_manifest: dict, slug: str
) -> tuple[dict, int, int]:
"""Return ``(new_audio, n_urls_changed, n_offsets_added)``.
Keys are constrained to the recitation's *shipped* ``chapter_urls`` so
coverage can't drift if the bucket manifest changed since the cut; each key's
URL/offset is re-resolved from the manifest (native source + offset), falling
back to the shipped URL when the manifest lacks the chapter.
"""
native_urls, native_offsets = _audio_sources_from_manifest(slug, bucket_manifest)
old_urls: dict[str, str] = existing_audio.get("chapter_urls", {})
new_urls: dict[str, str] = {}
new_offsets: dict[str, int] = {}
urls_changed = 0
for key, old_url in old_urls.items():
new_url = native_urls.get(key, old_url)
new_urls[key] = new_url
if new_url != old_url:
urls_changed += 1
if key in native_offsets:
new_offsets[key] = native_offsets[key]
new_audio = dict(existing_audio)
new_audio["chapter_urls"] = new_urls
if new_offsets:
new_audio["chapter_offsets_ms"] = new_offsets
else:
new_audio.pop("chapter_offsets_ms", None)
return new_audio, urls_changed, len(new_offsets)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tag", help="Release tag (default: latest)")
parser.add_argument("--repo", default="Wider-Community/quranic-universal-audio")
parser.add_argument(
"--bucket-mount",
type=Path,
help="Bucket mount root (default: $INSPECTOR_BUCKET_MOUNT)",
)
parser.add_argument(
"--work-dir",
type=Path,
default=_REPO_ROOT / ".local" / "release-patch",
help="Where release assets are downloaded + rebuilt",
)
parser.add_argument("--apply", action="store_true", help="Upload the patched assets")
args = parser.parse_args(argv)
import os
bucket_mount = args.bucket_mount or (
Path(os.environ["INSPECTOR_BUCKET_MOUNT"])
if os.environ.get("INSPECTOR_BUCKET_MOUNT")
else None
)
if not bucket_mount or not bucket_mount.exists():
print(
f"error: bucket mount not found ({bucket_mount}); pass --bucket-mount",
file=sys.stderr,
)
return 2
repo = args.repo
tag = args.tag or _latest_tag(repo)
work = args.work_dir / tag
work.mkdir(parents=True, exist_ok=True)
print(f"== patching {repo} release {tag} (bucket: {bucket_mount}) ==")
catalog_path = _download_asset(repo, tag, "catalog.json", work)
manifest_path = _download_asset(repo, tag, "manifest.json", work)
root_catalog = json.loads(catalog_path.read_bytes())
root_manifest = json.loads(manifest_path.read_bytes())
# Gate 0: a no-change round-trip of both release-level files must be
# byte-identical, else our serialization would mutate untouched bytes.
cat_rt = _json_model_bytes(ReleaseCatalog.model_validate(root_catalog))
if cat_rt != catalog_path.read_bytes():
print("error: catalog.json no-op round-trip not byte-identical — aborting", file=sys.stderr)
return 3
man_rt = _json_model_bytes(ReleaseManifest.model_validate(root_manifest))
if man_rt != manifest_path.read_bytes():
print(
"error: manifest.json no-op round-trip not byte-identical — aborting", file=sys.stderr
)
return 3
recitations: list[dict] = root_catalog["recitations"]
manifest_recs: dict = root_manifest["recitations"]
patched_slugs: list[str] = []
new_zip_bytes_by_slug: dict[str, bytes] = {}
summary: list[dict] = []
for rec in recitations:
slug = rec["slug"]
bm = _load_bucket_manifest(bucket_mount, slug)
if not bm:
continue
native_urls, native_offsets = _audio_sources_from_manifest(slug, bm)
# Affected iff the manifest expresses any provenance the shipped catalog
# can't have: a swapped bucket URL (source_url) or a non-zero offset.
chapters = bm.get("chapters") or {}
has_provenance = any(
(c.get("source_url") or (c.get("source_offset_ms") or 0) > 0)
for c in chapters.values()
if isinstance(c, dict)
)
if not has_provenance:
continue
zip_path = _download_asset(repo, tag, f"{slug}.zip", work)
zbytes = zip_path.read_bytes()
members = _zip_members(zbytes)
# Gate 1: deterministic re-pack of the UNCHANGED members must reproduce
# the shipped zip byte-for-byte (proves the pack path matches the cut).
if _pack_recitation_zip(slug, members) != zbytes:
print(f"error: {slug}: zip re-pack not byte-identical — aborting", file=sys.stderr)
return 4
letter_gz = members["letter_timestamps.json.gz"]
old_catalog_bytes = members["catalog.json"]
# Gate 2: our content_hash formula must reproduce the shipped manifest's.
old_hash = _sha256_hex(letter_gz + old_catalog_bytes)
shipped_hash = manifest_recs.get(slug, {}).get("content_hash")
if shipped_hash and old_hash != shipped_hash:
print(
f"error: {slug}: content_hash formula mismatch "
f"({old_hash[:12]} != {shipped_hash[:12]}) — aborting",
file=sys.stderr,
)
return 5
existing_catalog = json.loads(old_catalog_bytes)
new_audio, n_url, n_off = _fixed_audio_block(existing_catalog.get("audio", {}), bm, slug)
if n_url == 0 and "chapter_offsets_ms" not in new_audio:
continue # already native — nothing to do (idempotent re-run)
new_catalog_dict = dict(existing_catalog)
new_catalog_dict["audio"] = new_audio
# Re-validate + canonical-serialize through the real model so the bytes
# match what a future fixed cut would emit.
new_catalog_bytes = _json_model_bytes(
ReleaseRecitationCatalog.model_validate(new_catalog_dict)
)
new_members = dict(members)
new_members["catalog.json"] = new_catalog_bytes
new_zip = _pack_recitation_zip(slug, new_members)
new_zip_bytes_by_slug[slug] = new_zip
new_hash = _sha256_hex(letter_gz + new_catalog_bytes)
# Update the release-level structures in place.
rec["audio"] = json.loads(new_catalog_bytes)["audio"]
mrec = manifest_recs[slug]
mrec["sha256"] = _sha256_hex(new_zip)
mrec["bytes"] = len(new_zip)
mrec["content_hash"] = new_hash
patched_slugs.append(slug)
summary.append(
{
"slug": slug,
"urls_fixed": n_url,
"offsets_added": n_off,
"content_hash": f"{old_hash[:10]} -> {new_hash[:10]}",
}
)
if not patched_slugs:
print("no affected recitations — release already carries native sources.")
return 0
print(f"\n{len(patched_slugs)} recitation(s) to patch:")
for s in summary:
print(
f" {s['slug']:42s} urls_fixed={s['urls_fixed']:<4d} "
f"offsets_added={s['offsets_added']:<4d} content_hash {s['content_hash']}"
)
# Re-serialize the release-level files (only patched entries changed; the
# rest are byte-identical, per Gate 0).
new_catalog_bytes = _json_model_bytes(ReleaseCatalog.model_validate(root_catalog))
new_manifest_bytes = _json_model_bytes(ReleaseManifest.model_validate(root_manifest))
(work / "catalog.json").write_bytes(new_catalog_bytes)
(work / "manifest.json").write_bytes(new_manifest_bytes)
for slug, zb in new_zip_bytes_by_slug.items():
(work / f"{slug}.zip").write_bytes(zb)
if not args.apply:
print(f"\n[dry-run] rebuilt assets written to {work}. Re-run with --apply to upload.")
return 0
print(f"\nuploading {len(patched_slugs) + 2} asset(s) to {tag} ...")
upload = [work / "manifest.json", work / "catalog.json"]
upload += [work / f"{slug}.zip" for slug in patched_slugs]
for path in upload:
print(f" upload {path.name} ...")
_gh("release", "upload", tag, str(path), "--clobber", repo=repo, capture=True)
print("done.")
return 0
if __name__ == "__main__":
sys.exit(main())