import { importArchiveOrgAction, type ArchiveOrgImportEntry, } from "../../../channels/[slug]/pipelineActions"; import { OpsInputError, jobResponse, ops, optBool, optPositiveInt, optString, reqSlug, type OpsBody, } from "../_lib"; export const dynamic = "force-dynamic"; // POST { slug, item, files?: string[], match?: string, dryRun? } // | { slug, items: (string | { item, files? | match? })[], match?, dryRun? } // | { slug, query: string, limit?: number, match?, dryRun? } // -> { ok: true, jobId } // // Import media files from archive.org into an existing channel, as ONE job on // archive.org's own queue: one file at a time, a jittered pause between them // and between items, records already held (on disk or in the saved-video // store) skipped, a rate limit or three failures in a row ending it, three // items in a row refused with 401/403 ending it too (controller/ // archiveOrgImport.ts). `item` is one item and needs exactly one of `files` // (exact paths in the item, untrimmed) and `match` (a case-insensitive regex // over them); `items` is many (a bare identifier takes `match`, else every // media original); `query` is an archive.org search, its first `limit` (100, // at most 500) items. `dryRun` logs each file as held, RESTRICTED (archive.org // marks it not for download — a fetch would answer 401/403) or would-get, and // fetches nothing. The log ends with `summary: {…}`. const MAX_ARCHIVE_ORG_ITEMS = 500; function optFiles(v: unknown, key: string): string[] | undefined { if (v === undefined) return undefined; if (!Array.isArray(v) || v.length === 0 || v.some((s) => typeof s !== "string" || !s)) { throw new OpsInputError(`"${key}" must be a non-empty array of file names`); } return v as string[]; } function optItems(body: OpsBody): ArchiveOrgImportEntry[] | undefined { const v = body.items; if (v === undefined) return undefined; if (!Array.isArray(v) || v.length === 0) { throw new OpsInputError('"items" must be a non-empty array of identifiers or { "item", "files"? | "match"? }'); } if (v.length > MAX_ARCHIVE_ORG_ITEMS) { throw new OpsInputError(`"items" holds ${v.length} items; at most ${MAX_ARCHIVE_ORG_ITEMS} per job`); } return v.map((entry, i) => { if (typeof entry === "string" && entry.trim()) return entry.trim(); if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { throw new OpsInputError(`"items[${i}]" must be an identifier or { "item", "files"? | "match"? }`); } const e = entry as Record; const stray = Object.keys(e).filter((k) => !["item", "files", "match"].includes(k)); if (stray.length) { throw new OpsInputError(`"items[${i}]" has unknown key(s): ${stray.join(", ")} — accepted: item, files, match`); } if (typeof e.item !== "string" || !e.item.trim()) { throw new OpsInputError(`"items[${i}].item" is required and must be a non-empty string`); } if (e.match !== undefined && typeof e.match !== "string") { throw new OpsInputError(`"items[${i}].match" must be a string`); } return { item: e.item.trim(), ...(e.files !== undefined ? { files: optFiles(e.files, `items[${i}].files`) } : {}), ...(e.match !== undefined ? { match: e.match as string } : {}), }; }); } export async function POST(request: Request) { return ops( request, ["slug", "item", "items", "query", "limit", "files", "match", "dryRun"], async (body) => { const query = optString(body, "query"); if (query !== undefined && !query.trim()) throw new OpsInputError('"query" must be a non-empty string'); const limit = optPositiveInt(body, "limit"); if (limit !== undefined && limit > 500) throw new OpsInputError('"limit" is at most 500'); const item = optString(body, "item"); if (item !== undefined && !item.trim()) throw new OpsInputError('"item" must be a non-empty string'); return jobResponse( await importArchiveOrgAction(reqSlug(body, "slug"), { item: item?.trim(), items: optItems(body), query: query?.trim(), limit, files: optFiles(body.files, "files"), match: optString(body, "match"), dryRun: optBool(body, "dryRun"), }), ); }, ); }