#!/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 ``.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())