#!/usr/bin/env bun /** * Uploads a prepared video directory to R2 under `videos///`: * bun scripts/video/upload.ts --directory .artifacts/videos/intro/v1 [--bucket gitfor] [--local] * Media goes first and master.m3u8 last, so a half-finished upload is never playable. An existing version is * refused: published versions are immutable (the edge caches them forever), so re-encode as a new version. */ import { readFile, readdir, stat } from "node:fs/promises"; import { join, resolve } from "node:path"; import { parseArgs } from "node:util"; import { contentTypeForAsset, parseVideoPath, videoR2Key } from "../../src/lib/video"; const CONCURRENCY = 6; const ATTEMPTS = 3; async function wrangler(args: string[]): Promise { const proc = Bun.spawn(["bunx", "wrangler", ...args], { stdout: "ignore", stderr: "pipe" }); const code = await proc.exited; if (code !== 0 && !args.includes("--pipe")) console.error(await new Response(proc.stderr).text()); return code; } function uploadOrder(asset: string): number { if (asset === "master.m3u8") return 3; if (asset.endsWith(".m3u8")) return 2; if (asset.endsWith(".json")) return 1; return 0; } const { values } = parseArgs({ options: { directory: { type: "string" }, bucket: { type: "string", default: "gitfor" }, local: { type: "boolean" } }, }); if (!values.directory) { console.error("upload --directory [--bucket gitfor] [--local]"); process.exit(1); } const dir = resolve(values.directory); const { id, version } = JSON.parse(await readFile(join(dir, "prepared.json"), "utf8")) as { id: string; version: string; }; const where = values.local ? "--local" : "--remote"; const assets: string[] = []; for (const path of await readdir(dir, { recursive: true })) { if (path === "prepared.json" || !(await stat(join(dir, path))).isFile()) continue; if (!parseVideoPath(`/videos/${id}/${version}/${path}`)) throw new Error(`Unexpected file in package: ${path}`); assets.push(path); } const master = `${values.bucket}/${videoR2Key({ id, version, asset: "master.m3u8" })}`; if ((await wrangler(["r2", "object", "get", master, "--pipe", where])) === 0) { throw new Error(`${id}/${version} is already published; prepare a new version instead`); } for (const tier of [0, 1, 2, 3]) { const batch = assets.filter((asset) => uploadOrder(asset) === tier); let next = 0; await Promise.all( Array.from({ length: CONCURRENCY }, async () => { while (next < batch.length) { const asset = batch[next++]; const key = `${values.bucket}/${videoR2Key({ id, version, asset })}`; const args = [ "r2", "object", "put", key, "--file", join(dir, asset), "--content-type", contentTypeForAsset(asset), ]; const put = [...args, "--cache-control", "public, max-age=31536000, immutable", where]; let attempt = 1; while ((await wrangler(put)) !== 0) { if (attempt++ >= ATTEMPTS) throw new Error(`Upload failed: ${asset}`); console.warn(`retrying ${asset}`); } console.info(`uploaded ${asset}`); } }), ); } console.info(`Published ${assets.length} files: /videos/${id}/${version}/master.m3u8`);