import { MAX_CLIP_WINDOW_SECONDS, MAX_FETCH_MAX_HEIGHT, MIN_FETCH_MAX_HEIGHT, isFetchMaxHeight, } from "yt-dlp-transcript-common/lib/clipWindow"; // ─── fetch_clip: ask the local Archilyzer editor for a clip's media ─── // // The operator's rule is that no fetch happens by hand: every byte of media // goes through the editor, which has the cookie policy, the per-platform // sleeps, the 429 cooldown and the provenance note. umtool already obeys it // (umtool/report-to-video/fetch-via-editor.mjs); this is the same client for // the MCP, so an agent that needs the seconds behind a citation asks the // editor instead of shelling out to yt-dlp. // // THE MCP PROCESS STILL WRITES NOTHING. Everything here is HTTP through // `deps.fetch`; the editor decides where the bytes go and says so. The file it // names is a corpus artifact the agent may read, never one it may move. // // Pure apart from the injected deps (env, fetch, sleep, now), so the tests need // no network, no timers and no editor. No MCP imports: server.ts adapts. export type HttpInit = { method?: string; headers?: Record; body?: string; signal?: AbortSignal; }; export type HttpResponse = { status: number; json(): Promise }; export type FetchLike = (url: string, init?: HttpInit) => Promise; export type FetchClipDeps = { env: Record; fetch: FetchLike; sleep: (ms: number) => Promise; now: () => number; // Per-request bound (default REQUEST_TIMEOUT_MS). Only tests shorten it. requestTimeoutMs?: number; }; // The client family's names and defaults (fetch-via-editor.mjs, // scripts/archilyzer-ops.mjs): one editor URL, one shared token. export const DEFAULT_EDITOR_URL = "http://localhost:3001"; export const POLL_MS = 1000; // EVERY request — the POST and each poll — gives up after this. Without it a // POST that stats a hung mount, or an editor whose event loop has stalled, // holds the call until undici's own 300 s headers timeout, and wait_seconds // bounds nothing. A healthy editor answers both in milliseconds: the POST only // checks the cache and queues, the poll only reads the job's state. export const REQUEST_TIMEOUT_MS = 15_000; export const DEFAULT_PAD_SECONDS = 3; export const DEFAULT_WAIT_SECONDS = 90; export const MAX_WAIT_SECONDS = 300; export const MAX_REASON_CHARS = 400; // Who asked, as the editor records it beside the file. export const REQUESTED_BY = "mcp"; // A read-back window can sit a hair off the request (2 dp names); the same // tolerance as common/lib/clipWindow.ts WIN_EPS. const SAME_WINDOW_EPS = 0.02; export const NO_EDITOR_TEXT = "fetch_clip: no editor configured. Set ARCHILYZER_EDITOR_URL (e.g. " + "http://localhost:3001) and WORKER_TOKEN (the editor's own WORKER_TOKEN) " + "when registering the MCP server. A public-only setup — no local Archilyzer " + "editor — cannot fetch media through Archilyzer; see README \"Clips and " + "video\" for the no-editor fallback."; // The editor's own id grammar (editor/app/api/media/fetch-window/route.ts): // anchored, and `.` / `..` refused separately because the class allows a dot. const ID_RE = /^[\w.-]+$/; export function isVideoId(v: string): boolean { return ID_RE.test(v) && v !== "." && v !== ".."; } export type EditorConfig = { url: string; token: string }; // The editor to ask, or null when this MCP was registered without a token — // which is every public-only setup, and is not an error until a fetch is asked. export function editorFromEnv( env: Record, ): EditorConfig | null { const token = (env.WORKER_TOKEN ?? "").trim(); if (!token) return null; const raw = (env.ARCHILYZER_EDITOR_URL ?? "").trim() || DEFAULT_EDITOR_URL; return { url: raw.replace(/\/+$/, ""), token }; } // A citation time: a number of seconds, or "ss", "mm:ss", "h:mm:ss" (a // fraction allowed on the seconds). Minutes may run past 59 in the two-part // form, because "75:30" is how a long video's moment is often written. export function parseSeconds(v: unknown): number | null { if (typeof v === "number") return Number.isFinite(v) && v >= 0 ? v : null; if (typeof v !== "string") return null; const s = v.trim(); let m = /^(\d+(?:\.\d+)?)$/.exec(s); if (m) return Number(m[1]); m = /^(\d+):([0-5]?\d(?:\.\d+)?)$/.exec(s); if (m) return Number(m[1]) * 60 + Number(m[2]); m = /^(\d+):([0-5]?\d):([0-5]?\d(?:\.\d+)?)$/.exec(s); if (m) return Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]); return null; } // Seconds as the editor names a window: two decimals, always. export function fmtSeconds(n: number): string { return n.toFixed(2); } function fmtSpan(n: number): string { return `${Number(n.toFixed(2))}s`; } // The padded window, exactly as umtool's client computes it: the name IS the // window, so a request that rounds differently addresses a different file and // the cache misses forever. The 900 s cap applies AFTER padding — it is the // span the editor is asked for. export function planWindow(a: { video: string; start: number; end: number; pad: number; }): { from: number; to: number } | { error: string } { if (!isVideoId(a.video)) { return { error: `fetch_clip: video "${a.video}" must match /^[\\w.-]+$/` }; } if (!(a.start < a.end)) { return { error: `fetch_clip: start (${a.start}) must be less than end (${a.end})`, }; } const from = Number(Math.max(0, a.start - a.pad).toFixed(2)); const to = Number((a.end + a.pad).toFixed(2)); if (!(from < to)) { return { error: `fetch_clip: start (${a.start}) must be less than end (${a.end})` }; } const span = Number((to - from).toFixed(2)); if (span > MAX_CLIP_WINDOW_SECONDS) { return { error: `fetch_clip: the window ${fmtSeconds(from)}–${fmtSeconds(to)} is ` + `${span}s; the editor fetches at most ${MAX_CLIP_WINDOW_SECONDS}s per ` + `window — cite a narrower span`, }; } return { from, to }; } // ─── Arguments ─── // `maxHeight`, when the caller gave one: the source height to cap the fetch // at. Absent, the editor's own default applies (720 for a window; the // channel's, else the global, source-video quality for a whole recording). export type ClipTarget = | { kind: "window"; channel: string; video: string; webpageUrl?: string; from: number; to: number; pad: number; maxHeight?: number; } | { kind: "full"; channel: string; video: string; maxHeight?: number }; export type FetchClipRequest = | { job: string; waitSeconds: number } | { target: ClipTarget; reason: string; report?: string; waitSeconds: number; }; const trimmed = (v: unknown): string => typeof v === "string" ? v.trim() : ""; function waitSecondsOf(v: unknown): number { const n = typeof v === "number" ? v : Number.NaN; if (!Number.isFinite(n)) return DEFAULT_WAIT_SECONDS; return Math.min(MAX_WAIT_SECONDS, Math.max(0, n)); } // Validate the tool's raw arguments into a request, before any HTTP. A `job` // alone is a whole request (resume): every other argument is then ignored. In // full mode start/end/pad are ignored — not required, not validated. The // returned video id is the one AS CITED; server.ts maps it to the editor's // directory id (Rumble) before fetching. export function validateFetchClipArgs( args: Record, ): { ok: true; request: FetchClipRequest } | { ok: false; error: string } { const waitSeconds = waitSecondsOf(args.wait_seconds); const job = trimmed(args.job); if (job) return { ok: true, request: { job, waitSeconds } }; const channel = trimmed(args.channel); if (!channel) { return { ok: false, error: "fetch_clip: channel is required (the channel slug)" }; } const video = trimmed(args.video); if (!video) { return { ok: false, error: "fetch_clip: video is required (the archive's video id)", }; } if (!isVideoId(video)) { return { ok: false, error: `fetch_clip: video "${video}" must match /^[\\w.-]+$/` }; } // Checked here rather than passed through: it ends up inside a yt-dlp // format selector on the editor, which refuses it too, but a refusal before // any HTTP says what to fix in the tool's own words. const rawHeight = args.maxHeight; if (rawHeight !== undefined && rawHeight !== null && !isFetchMaxHeight(rawHeight)) { return { ok: false, error: `fetch_clip: maxHeight "${String(rawHeight)}" must be a whole number ` + `of pixels from ${MIN_FETCH_MAX_HEIGHT} to ${MAX_FETCH_MAX_HEIGHT}`, }; } const maxHeight = isFetchMaxHeight(rawHeight) ? rawHeight : undefined; const capped = maxHeight !== undefined ? { maxHeight } : {}; let target: ClipTarget; if (args.full === true) { target = { kind: "full", channel, video, ...capped }; } else { const times: Record<"start" | "end", number> = { start: 0, end: 0 }; for (const key of ["start", "end"] as const) { const raw = args[key]; if (raw === undefined || raw === null || raw === "") { return { ok: false, error: `fetch_clip: ${key} is required (seconds, mm:ss or h:mm:ss) — or ` + `pass full: true for the whole recording`, }; } const secs = parseSeconds(raw); if (secs === null) { return { ok: false, error: `fetch_clip: ${key} "${String(raw)}" is not a time (use seconds, ` + `mm:ss or h:mm:ss)`, }; } times[key] = secs; } const pad = args.pad === undefined ? DEFAULT_PAD_SECONDS : args.pad; if (typeof pad !== "number" || !Number.isFinite(pad) || pad < 0) { return { ok: false, error: `fetch_clip: pad "${String(pad)}" must be a finite number of seconds, 0 or more`, }; } const w = planWindow({ video, start: times.start, end: times.end, pad }); if ("error" in w) return { ok: false, error: w.error }; target = { kind: "window", channel, video, from: w.from, to: w.to, pad, ...capped }; } const reason = trimmed(args.reason); if (!reason) { return { ok: false, error: "fetch_clip: reason is required — one line saying why these seconds " + "are needed (it is stored beside the file)", }; } const report = trimmed(args.report) || undefined; return { ok: true, request: { target, reason, report, waitSeconds } }; } // ─── The HTTP exchange ─── export type FetchClipOutcome = | { kind: "no_editor" } | { kind: "cached"; mode: "window" | "full"; // The span that was asked for (window mode). reqFrom: number; reqTo: number; file: string; from: number; to: number; bytes: number; requestedBy?: string; // How tall the file on disk is, when the editor knows; and the cap this // call asked for, so the answer can say when a cached file is taller. height?: number; maxHeight?: number; } | { kind: "fetched"; // For a resume, read off the poll's answer: a window job's carries // from/to, a whole-recording job's does not. mode: "window" | "full"; jobId: string; file?: string; from?: number; to?: number; bytes?: number; waited: number; height?: number; maxHeight?: number; } | { kind: "queued"; jobId: string; status: string; waited: number } | { kind: "cooldown"; platform: string; cooldownMs: number; error: string } | { kind: "refused"; phase: "post" | "poll"; status: number; error: string; jobId?: string; full?: boolean; } | { kind: "failed"; jobId: string; status: string; log: string } // `jobId` when the editor stopped answering WHILE POLLING: the job was // queued and may still be running, so the answer must hand back the id to // resume with. Repeating the original request instead would queue a second // fetch — for full: true, a second whole-recording download. | { kind: "unreachable"; url: string; message: string; jobId?: string; // The POST timed out: the editor may have queued the fetch anyway. postTimedOut?: boolean; }; type Json = Record; // The two height fields of an outcome, each only when there is one — so an // answer from an editor that reports no height reads exactly as it did. function heightFields( height: number | undefined, maxHeight: number | undefined, ): { height?: number; maxHeight?: number } { return { ...(height !== undefined ? { height } : {}), ...(maxHeight !== undefined ? { maxHeight } : {}), }; } function isTimeout(e: unknown): boolean { return typeof e === "object" && e !== null && (e as { name?: unknown }).name === "TimeoutError"; } // What went wrong, in words the operator can act on. Node's fetch throws a // bare `TypeError: fetch failed` and keeps the reason (ECONNREFUSED, ENOTFOUND, // a reset) in `cause`, so the cause is appended; a timeout says how long. export function describeFetchError(e: unknown, timeoutMs: number): string { if (isTimeout(e)) return `no answer within ${timeoutMs / 1000} s (request timed out)`; const obj = typeof e === "object" && e !== null ? (e as { message?: unknown; cause?: unknown }) : null; const message = obj && typeof obj.message === "string" ? obj.message : String(e); const cause = obj?.cause; const causeText = typeof cause === "string" ? cause : typeof cause === "object" && cause !== null && typeof (cause as { message?: unknown }).message === "string" ? (cause as { message: string }).message : ""; return causeText && causeText !== message ? `${message} (${causeText})` : message; } async function readJson(res: HttpResponse): Promise { try { const body = await res.json(); return body && typeof body === "object" ? (body as Json) : {}; } catch { return {}; } } const num = (v: unknown): number | undefined => typeof v === "number" && Number.isFinite(v) ? v : undefined; const str = (v: unknown): string | undefined => typeof v === "string" && v !== "" ? v : undefined; // Told after every poll that finds the job still waiting — server.ts turns it // into an MCP progress notification, which keeps a client's reset-on-progress // request timeout alive through a long wait. export type PollProgress = { jobId: string; status: string; polls: number; waited: number; }; // POST the request (unless it is a resume), then poll every POLL_MS until the // job is terminal or the wait runs out. A wait that runs out is not a failure: // the job keeps running on the editor, and the answer says how to resume. export async function fetchClip( request: FetchClipRequest, deps: FetchClipDeps, hooks: { onPoll?: (p: PollProgress) => void | Promise } = {}, ): Promise { const editor = editorFromEnv(deps.env); if (!editor) return { kind: "no_editor" }; const headers = { authorization: `Bearer ${editor.token}`, "content-type": "application/json", }; const started = deps.now(); const deadline = started + request.waitSeconds * 1000; const waited = () => Math.round((deps.now() - started) / 1000); const timeoutMs = deps.requestTimeoutMs ?? REQUEST_TIMEOUT_MS; async function call( url: string, init: HttpInit, ): Promise { try { return await deps.fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); } catch (e) { return { failed: describeFetchError(e, timeoutMs), timedOut: isTimeout(e) }; } } // Poll until terminal or out of time. `pollFirst` is a resume: the job may // well have finished since it was queued, so ask before sleeping. async function waitFor( jobId: string, mode: "window" | "full" | "unknown", pollFirst: boolean, maxHeight?: number, ): Promise { let status = "queued"; let skipSleep = pollFirst; let polls = 0; for (;;) { if (!skipSleep) { if (deps.now() >= deadline) { return { kind: "queued", jobId, status, waited: waited() }; } await deps.sleep(POLL_MS); } skipSleep = false; const res = await call( `${editor!.url}/api/media/fetch-window/${encodeURIComponent(jobId)}`, { method: "GET", headers }, ); if ("failed" in res) { return { kind: "unreachable", url: editor!.url, message: res.failed, jobId }; } const body = await readJson(res); if (res.status < 200 || res.status >= 300) { return { kind: "refused", phase: "poll", status: res.status, error: str(body.error) ?? "no reason given", jobId, }; } status = str(body.status) ?? status; if (status === "done") { const from = num(body.from); const to = num(body.to); return { kind: "fetched", mode: mode === "unknown" ? from !== undefined && to !== undefined ? "window" : "full" : mode, jobId, file: str(body.file), from, to, bytes: num(body.bytes), waited: waited(), ...heightFields(num(body.height), maxHeight), }; } if (status === "failed" || status === "cancelled") { return { kind: "failed", jobId, status, log: str(body.error) ?? "" }; } polls++; if (hooks.onPoll) { // A progress report is a courtesy: a client that went away must not // turn a running fetch into an error. try { await hooks.onPoll({ jobId, status, polls, waited: waited() }); } catch { /* ignored */ } } } } if ("job" in request) return waitFor(request.job, "unknown", true); const { target } = request; const provenance = { requestedBy: REQUESTED_BY, manifest: request.report, reason: request.reason.slice(0, MAX_REASON_CHARS), }; // `full` REPLACES the span (as in fetch-via-editor.mjs): the route reads // `full === true` before it validates from/to, and the saved-video path // resolves the URL itself, so a webpageUrl would describe a request the // editor does not have. const capped = target.maxHeight !== undefined ? { maxHeight: target.maxHeight } : {}; const body = target.kind === "full" ? { channelSlug: target.channel, videoId: target.video, full: true, ...capped, ...provenance, } : { channelSlug: target.channel, videoId: target.video, webpageUrl: target.webpageUrl, from: target.from, to: target.to, pad: target.pad, ...capped, ...provenance, }; const res = await call(`${editor.url}/api/media/fetch-window`, { method: "POST", headers, body: JSON.stringify(body), }); if ("failed" in res) { return { kind: "unreachable", url: editor.url, message: res.failed, postTimedOut: res.timedOut, }; } const answer = await readJson(res); const reqFrom = target.kind === "window" ? target.from : 0; const reqTo = target.kind === "window" ? target.to : 0; if (res.status === 200 && str(answer.file)) { const prov = answer.provenance as Json | null | undefined; return { kind: "cached", mode: target.kind, reqFrom, reqTo, file: str(answer.file)!, from: num(answer.from) ?? reqFrom, to: num(answer.to) ?? reqTo, bytes: num(answer.bytes) ?? 0, requestedBy: prov && typeof prov === "object" ? str(prov.requestedBy) : undefined, ...heightFields(num(answer.height), target.maxHeight), }; } if (res.status === 202 && str(answer.jobId)) { return waitFor(str(answer.jobId)!, target.kind, false, target.maxHeight); } if (res.status === 409) { return { kind: "cooldown", platform: str(answer.platform) ?? "platform", cooldownMs: num(answer.cooldownMs) ?? 0, error: str(answer.error) ?? "", }; } return { kind: "refused", phase: "post", status: res.status, error: str(answer.error) ?? "no reason given", full: target.kind === "full", }; } // ─── The answer an agent reads ─── const READ_ONLY_NOTE = "This path is a read-only corpus artifact: play or copy it, never move, " + "edit or delete it."; function footer( mode: "window" | "full", f: { file: string; from?: number; to?: number; bytes?: number; requestedBy?: string; height?: number; maxHeight?: number; }, cached = false, ): string { const lines = [`file: ${f.file}`]; if (mode === "window" && f.from !== undefined && f.to !== undefined) { lines.push( `window: ${fmtSeconds(f.from)}–${fmtSeconds(f.to)} (${fmtSpan(f.to - f.from)})`, ); } if (f.height !== undefined) { // TALLER THAN ASKED is said, not hidden. A cached file was fetched for an // earlier ask and is served as it is; a whole recording is saved at one of // two qualities, not at the exact height asked for. const over = f.maxHeight !== undefined && f.height > f.maxHeight ? ` — taller than the maxHeight ${f.maxHeight} asked for` + (cached ? "; this file was fetched earlier and is served as it is" : "; a whole recording is saved at 720p (falling back to what " + "the source has) or at its original quality, not at an exact height") : ""; lines.push(`height: ${f.height}p${over}`); } if (f.bytes !== undefined) lines.push(`bytes: ${f.bytes}`); if (f.requestedBy) lines.push(`requested by ${f.requestedBy}`); if (mode === "window") { const name = f.from !== undefined && f.to !== undefined ? `${fmtSeconds(f.from)}-${fmtSeconds(f.to)}.json` : "-.json"; lines.push( `${READ_ONLY_NOTE} The editor prunes clips by age (evict-clips); ` + `provenance sits beside it as ${name}.`, ); } else { lines.push( `${READ_ONLY_NOTE} It lives in the editor's saved-video store, not in ` + `clips/; the editor's keep-videos rule decides how long it stays.`, ); } return lines.join("\n"); } const TOKEN_HINT = "WORKER_TOKEN must equal the value the editor runs with (editor/.env)."; // Only a 503 that SAYS the endpoint is disabled is a token problem: the same // status also carries an unreachable-media refusal (a relocated channel whose // drive is not mounted), which is the editor operator's to fix, verbatim. function isTokenRefusal(status: number, error: string): boolean { return status === 401 || (status === 503 && /disabled/i.test(error)); } export function renderFetchClip( outcome: FetchClipOutcome, ctx: { channel?: string; video?: string } = {}, ): { text: string; isError: boolean } { const of = ctx.channel && ctx.video ? ` of ${ctx.channel}/${ctx.video}` : ""; switch (outcome.kind) { case "no_editor": return { text: NO_EDITOR_TEXT, isError: true }; case "cached": { let head: string; if (outcome.mode === "full") { head = `Already on disk — the whole recording${of}.`; } else { const same = Math.abs(outcome.from - outcome.reqFrom) <= SAME_WINDOW_EPS && Math.abs(outcome.to - outcome.reqTo) <= SAME_WINDOW_EPS; head = same ? `Already on disk — the exact window: ${fmtSeconds(outcome.from)}–${fmtSeconds(outcome.to)}.` : `Already on disk — a WIDER cached window that contains ` + `${fmtSeconds(outcome.reqFrom)}–${fmtSeconds(outcome.reqTo)}: ` + `${fmtSeconds(outcome.from)}–${fmtSeconds(outcome.to)}.`; } return { text: `${head}\n\n${footer(outcome.mode, outcome, true)}`, isError: false }; } case "fetched": { if (!outcome.file) { return { text: `Editor job ${outcome.jobId} finished but named no file; check ` + `the video's page in the editor.`, isError: true, }; } const head = outcome.mode === "full" ? `Fetched the whole recording${of} (job ${outcome.jobId}, ${outcome.waited}s waited).` : `Fetched ${ outcome.from !== undefined && outcome.to !== undefined ? `${fmtSeconds(outcome.from)}–${fmtSeconds(outcome.to)}` : "the window" }${of} (job ${outcome.jobId}, ${outcome.waited}s waited).`; return { text: `${head}\n\n${footer(outcome.mode, { ...outcome, file: outcome.file })}`, isError: false, }; } case "queued": return { text: `Still ${outcome.status} on the editor (job ${outcome.jobId}, waited ` + `${outcome.waited}s). Call fetch_clip again with job: ` + `"${outcome.jobId}" to keep waiting — never repeat the original ` + `request while it runs (that would queue a second fetch). Nothing is ` + `lost: the fetch continues on the editor, and once it has finished ` + `the same request finds it cached.`, isError: false, }; case "cooldown": return { text: `The editor is in a ${outcome.platform} rate-limit cooldown — ` + `${Math.ceil(outcome.cooldownMs / 1000)}s remaining. Wait, then call ` + `fetch_clip again. (${outcome.error})`, isError: true, }; case "refused": { if (outcome.phase === "poll") { const extra = outcome.status === 404 ? "; the job is unknown to this editor (restarted? wrong ARCHILYZER_EDITOR_URL?)" : isTokenRefusal(outcome.status, outcome.error) ? `. ${TOKEN_HINT}` : ""; return { text: `Polling editor job ${outcome.jobId} failed (HTTP ${outcome.status}): ` + `${outcome.error}${extra}`, isError: true, }; } if (isTokenRefusal(outcome.status, outcome.error)) { const off = outcome.status === 503 ? " The editor has no WORKER_TOKEN set, so its fetch endpoint is off." : ""; return { text: `The editor refused the token (HTTP ${outcome.status}): ` + `${outcome.error}. ${TOKEN_HINT}${off}`, isError: true, }; } const known = outcome.full && outcome.status === 404 ? " A whole-recording fetch needs a video the editor already knows " + "(a metadata.info.json or a playlist entry); fetch a window " + "instead, or add the video to the channel first." : ""; return { text: `The editor refused (HTTP ${outcome.status}): ${outcome.error}${known}`, isError: true, }; } case "failed": return { text: `Editor job ${outcome.jobId} ${outcome.status}.\n\nlog tail:\n` + (outcome.log || "(the job left no log)"), isError: true, }; case "unreachable": if (outcome.jobId) { return { text: `Could not reach the editor at ${outcome.url} while polling job ` + `${outcome.jobId}: ${outcome.message}. Is it running? Call ` + `fetch_clip again with job: "${outcome.jobId}" — do not repeat the ` + `original request, the fetch may still be running.`, isError: true, }; } return { text: `Could not reach the editor at ${outcome.url}: ${outcome.message}. Is it running?` + (outcome.postTimedOut ? " It may have queued the fetch anyway: check the editor's /jobs page " + "before asking again." : ""), isError: true, }; } } // The prefix for a cited id the corpus does not hold: the id goes to the editor // as-is, which for a Rumble EMBED id is the wrong directory. export function notFoundNote(video: string, handle: string): string { return ( `note: "${video}" was not found in corpus ${handle}; the id was passed to ` + `the editor as-is (for a Rumble citation this may be the embed id — pass ` + `the corpus the citation came from as source).` ); }