#!/usr/bin/env python3 """Upload up to eight videos per batch. Requires videoclick_upload_v2.py alongside.""" import argparse import hashlib import json import time from pathlib import Path from urllib.error import URLError from videoclick_upload_v2 import ApiError, call, get_upload, complete_with_recovery def fingerprint(path): digest = hashlib.sha256() with path.open("rb") as stream: while True: block = stream.read(1024 * 1024) if not block: break digest.update(block) return digest.hexdigest() def transfer(upload, path, chunk_size): upload_id = upload["uploadId"] print("Upload ID:", upload_id, "for", path.name) if upload["State"] == "completed": return upload["VideoId"] if upload["State"] in ("uncertain", "transferring"): result = complete_with_recovery(upload_id) return result.get("videoId") or result.get("upload", {}).get("VideoId") if upload["State"] != "uploading": raise RuntimeError("Inspect upload state before retrying: " + upload["State"]) offset = upload["NextOffset"] with path.open("rb") as stream: while offset < path.stat().st_size: stream.seek(offset) piece = stream.read(min(chunk_size, path.stat().st_size - offset)) failures = 0 while True: try: offset = call("PUT", f"/upload-sessions/{upload_id}/chunks?offset={offset}", piece, "application/octet-stream")["nextOffset"] print(path.name, offset, "/", path.stat().st_size) break except (ApiError, URLError) as error: failures += 1 if failures > 5: raise current = get_upload(upload_id) if current["State"] != "uploading": raise RuntimeError("Inspect upload state before retrying") from error if current["NextOffset"] != offset: offset = current["NextOffset"] break delay = int(error.retry_after or 2) if isinstance(error, ApiError) else 2 time.sleep(max(1, min(delay, 30))) result = complete_with_recovery(upload_id) return result.get("videoId") or result.get("upload", {}).get("VideoId") def main(): parser = argparse.ArgumentParser(description="Batch upload up to 8 VideoClick videos") parser.add_argument("manifest", type=Path, help="JSON list of file, groupId, title and durationSeconds") args = parser.parse_args() rows = json.loads(args.manifest.read_text(encoding="utf-8")) if not isinstance(rows, list) or not 1 <= len(rows) <= 8: parser.error("Manifest must contain 1 to 8 videos") base = args.manifest.resolve().parent capabilities = call("GET", "/capabilities") if not capabilities["batchEnabled"]: parser.error("Batch upload is not enabled for this plan") groups = {group["groupId"] for group in call("GET", "/groups")["groups"]} requests = [] paths = [] for row in rows: path = (base / row["file"]).resolve() if not path.is_file() or path.suffix.lower() not in (".mp4", ".webm"): parser.error("Missing or unsupported file: " + str(path)) if row["groupId"] not in groups or path.stat().st_size > capabilities["maxFileBytes"]: parser.error("Invalid group or file size: " + str(path)) sha = fingerprint(path) stable = hashlib.sha256(json.dumps([sha, path.name, row["groupId"], row["title"], row["durationSeconds"]], ensure_ascii=False).encode("utf-8")).hexdigest()[:40] requests.append({"groupId": row["groupId"], "title": row["title"], "fileName": path.name, "fileSize": path.stat().st_size, "durationSeconds": row["durationSeconds"], "sha256": sha, "idempotencyKey": "bulk-" + stable}) paths.append(path) # On an uncertain batch response, rerun the same manifest: each stable key # resolves to the original session instead of creating another video. result = call("POST", "/upload-sessions/batch", json.dumps({"videos": requests}, ensure_ascii=False).encode("utf-8"), "application/json") pending = {} failed = False for row, item, path in zip(rows, result["results"], paths): if not item["success"]: print("Rejected:", path.name, item.get("error")) failed = True continue try: video_id = transfer(item["upload"], path, capabilities["chunkSize"]) pending[str(video_id)] = path.name except (ApiError, URLError, RuntimeError) as error: print("Failed:", path.name, error) failed = True for _ in range(180): for video_id, name in list(pending.items()): state = call("GET", "/videos/" + video_id)["state"] print(name, state) if state in ("ready", "failed"): failed = failed or state == "failed" del pending[video_id] if not pending: break time.sleep(10) if pending: print("Still processing; check these video IDs later:", ", ".join(pending)) if failed: raise SystemExit(1) if __name__ == "__main__": main()