Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit d77b126fc1a8eb9333c998d5aedbc8fdee9a70cb
parent 7f91177c75088089fdf8556b05ca9af26d5a5c89
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 10 Oct 2026 00:43:54 -0400

Merge r20/d2-r20 part 1 (release 21 D2: clip windows cut from a saved container) into r20/integration

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/controller/fetchWindows.test.ts | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/fetchWindows.ts | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcommon/lib/clipWindow.ts | 10++++++++++
Mcommon/lib/queueKeys.ts | 8++++++++
Acommon/lib/savedVideoWindow-server.test.ts | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/savedVideoWindow-server.ts | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Aeditor/app/api/media/fetch-window/route.saved.test.ts | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/videos/[id]/components/cards/FetchedWindows.tsx | 1+
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/videos/fetchWindowsAction.test.ts | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/videos/fetchWindowsAction.ts | 44+++++++++++++++++++++++++++++++++++++++++++-
Mmcp/src/instructions.ts | 3++-
Mplans/release-21.md | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
14 files changed, 1045 insertions(+), 4 deletions(-)

diff --git a/common/controller/fetchWindows.test.ts b/common/controller/fetchWindows.test.ts @@ -274,3 +274,79 @@ test("a second run on the same platform waits out the gap the first one set", as await go(await corpus(), [item("v3", "/ok"), item("v4", "/ok")], second); assert.deepEqual(second.rec.sleeps, [1000, 1000], "the first fetch waits for the earlier run's gap"); }); + +// THE SAVED CONTAINER (release 21 D2): a window it holds is cut, not fetched; +// a pointer it cannot be read for fails the item, never a fetch in its place; +// a container that ends too early is fetched as before. +test("saved containers: a covered window is cut with no request and no pause; unreadable fails; not-covering is fetched", async () => { + const paths = await corpus(); + const cuts: { id: string; from: number; to: number; clipId?: string }[] = []; + const h = harness({ + savedWindowSource: async ({ videoDir }) => { + const id = path.basename(videoDir); + if (id === "held") { + return { + kind: "covers", + pointer: { storedAt: "", dir: "/store", file: "source-media.mp4", bytes: 1 }, + container: "/store/source-media.mp4", + durationSec: 600, + probe: { durationSec: 600, width: 160, height: 90, hasVideo: true, hasAudio: true }, + }; + } + if (id === "unplugged") { + return { kind: "unreadable", error: `Channel "c": media is not reachable — its saved video is not there` }; + } + if (id === "short") return { kind: "not-covering", container: "/store/x.mp4", durationSec: 5 }; + return { kind: "none" }; + }, + cutSavedWindow: async ({ videoDir, from, to, provenance }) => { + cuts.push({ id: path.basename(videoDir), from, to, clipId: provenance.clipId }); + return { file: "x.mp4", path: "/x.mp4", from, to, bytes: 1, provenance: { requestedBy: "test", source: "saved-video" } }; + }, + }); + const before = await spawns(); + const r = await go( + paths, + [ + { ...item("held", "/a"), clipId: "r#1" }, + item("unplugged", "/b"), + item("short", "/c"), + { ...item("held", "/d", 30, 40), clipId: "r#2" }, + ], + h, + ); + assert.deepEqual(cuts, [ + { id: "held", from: 10, to: 20, clipId: "r#1" }, + { id: "held", from: 30, to: 40, clipId: "r#2" }, + ]); + assert.deepEqual(r.cut.map((i) => i.id), ["held", "held"]); + assert.equal(r.failed.length, 1); + assert.equal(r.failed[0].class, "unreachable"); + assert.equal(r.failed[0].item.id, "unplugged"); + assert.match(r.failed[0].message, /media is not reachable/); + assert.deepEqual(r.fetched.map((i) => i.id), ["short"]); + assert.equal((await spawns()) - before, 1, "only the not-covering window touched the network"); + assert.deepEqual(h.rec.sleeps, [], "a cut owes no pause, and one fetch owes none either"); + assert.deepEqual(h.rec.progress.at(-1), { metric: "clips", initial: 0, target: 4, current: 4 }); +}); + +test("saved containers: a cut that fails is a cut-failed item and the run carries on", async () => { + const h = harness({ + savedWindowSource: async () => ({ + kind: "covers", + pointer: { storedAt: "", dir: "/store", file: "source-media.mp4", bytes: 1 }, + container: "/store/source-media.mp4", + durationSec: 600, + probe: { durationSec: 600, width: 160, height: 90, hasVideo: true, hasAudio: true }, + }), + cutSavedWindow: async ({ from }) => { + if (from === 10) throw new Error("ffmpeg failed cutting 10.00–20.00"); + return { file: "x.mp4", path: "/x.mp4", from, to: from + 10, bytes: 1, provenance: null }; + }, + }); + const r = await go(await corpus(), [item("v1", "/a"), item("v2", "/b", 30, 40)], h); + assert.equal(r.failed.length, 1); + assert.equal(r.failed[0].class, "cut-failed"); + assert.deepEqual(r.cut.map((i) => i.id), ["v2"]); + assert.equal(r.stopped, undefined); +}); diff --git a/common/controller/fetchWindows.ts b/common/controller/fetchWindows.ts @@ -8,6 +8,10 @@ import { resolveCookiePolicy } from "../lib/cookiePolicy"; import { assertChannelTextReadable } from "../lib/channelMedia"; import { detectPlatform } from "../lib/platform"; import { findContainingClipWindow } from "../lib/clipWindow-server"; +import { + cutSavedVideoWindow, + savedWindowSource, +} from "../lib/savedVideoWindow-server"; import type { JobProgress } from "../jobs/registry"; import { downloadGapMs } from "../jobs/platformBackoff"; import { notePlatformGap, platformGapRemainingMs } from "../jobs/platformGap"; @@ -49,6 +53,10 @@ import { findVideoSourceUrl } from "./undownloadedVideos"; // - A CACHED WINDOW costs nothing: no request, no pause. The cache is asked // here first (the same containing-window rule fetchWindowManaged applies), // so a re-run of a half-done list walks straight to what is missing. +// - A WINDOW HELD BY A SAVED CONTAINER (release 21 D2) is CUT from it, with +// no request and no pause either (lib/savedVideoWindow-server.ts). A +// pointer whose container cannot be read fails the item as `unreachable` +// — never a fetch in its place; one that ends too early is fetched. // - BETWEEN TWO NETWORK FETCHES the batch downloads' own gap // (downloadGapMs), with a floor of CLIP_WINDOW_MIN_GAP_SECONDS — jittered // up to half again, so a platform is never asked on a fixed beat. @@ -107,8 +115,11 @@ export type FetchWindowsFailureClass = | DownloadFailureClass // No channel config for the slug. | "unknown-channel" - // The channel's text (and so its clips/) is not readable. + // The channel's text (and so its clips/) is not readable, or the video's + // saved container is not. | "unreachable" + // ffmpeg could not cut the window out of a covering saved container. + | "cut-failed" // No page URL to fetch from. | "no-url"; @@ -129,6 +140,8 @@ export type FetchWindowsStop = export type FetchWindowsResult = { fetched: FetchWindowsItem[]; cached: FetchWindowsItem[]; + // Cut from the video's saved container, not fetched (release 21 D2). + cut: FetchWindowsItem[]; failed: FetchWindowsFailure[]; // Due a fetch but not attempted because the run stopped first. notAttempted: FetchWindowsItem[]; @@ -146,6 +159,9 @@ export type FetchWindowsProvenance = { // is. export type FetchWindowsDeps = { fetchWindow: (opts: FetchWindowOpts) => Promise<FetchWindowResult>; + // The saved-container tier (lib/savedVideoWindow-server.ts). + savedWindowSource: typeof savedWindowSource; + cutSavedWindow: typeof cutSavedVideoWindow; sleep: (ms: number, signal?: AbortSignal) => Promise<void>; getSettings: () => SiteSettings; readChannelConfig: (paths: Paths, slug: string) => Promise<ChannelConfig | null>; @@ -188,6 +204,8 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> { const DEFAULT_DEPS: FetchWindowsDeps = { fetchWindow: fetchWindowManaged, + savedWindowSource, + cutSavedWindow: cutSavedVideoWindow, sleep: abortableSleep, getSettings, readChannelConfig, @@ -262,6 +280,7 @@ export async function fetchWindows({ const result: FetchWindowsResult = { fetched: [], cached: [], + cut: [], failed: [], notAttempted: [], }; @@ -351,6 +370,49 @@ export async function fetchWindows({ continue; } + // THEN THE SAVED CONTAINER: cut, no request, no pause, not a network + // attempt. + const saved = await deps.savedWindowSource({ + paths, + slug: item.slug, + videoDir, + from: item.from, + to: item.to, + }); + if (saved.kind === "unreadable") { + fail(item, "unreachable", saved.error); + done += 1; + progress(); + continue; + } + if (saved.kind === "covers") { + try { + const cut = await deps.cutSavedWindow({ + paths, + videoDir, + source: saved, + from: item.from, + to: item.to, + provenance: { + requestedBy: provenance.requestedBy, + manifest: provenance.manifest, + clipId: item.clipId, + reason: item.reason, + pad: item.pad, + requestedAt, + }, + signal: fetchSignal, + }); + result.cut.push(item); + log(` ✂ ${label}: cut from the saved video into clips/${cut.file}`); + } catch (e) { + fail(item, "cut-failed", (e as Error).message); + } + done += 1; + progress(); + continue; + } + const url = item.webpageUrl?.trim() || (await deps.findVideoSourceUrl(paths, item.slug, item.id, config)); @@ -490,7 +552,9 @@ export async function fetchWindows({ } log( - `Fetch windows: ${result.fetched.length} fetched, ${result.cached.length} cached, ` + + `Fetch windows: ${result.fetched.length} fetched, ` + + (result.cut.length ? `${result.cut.length} cut from saved videos, ` : "") + + `${result.cached.length} cached, ` + `${result.failed.length} failed` + (result.notAttempted.length ? `, ${result.notAttempted.length} not attempted (stopped: ${result.stopped})` diff --git a/common/lib/clipWindow.ts b/common/lib/clipWindow.ts @@ -152,8 +152,15 @@ export type ClipProvenance = { // The argv the fetch actually ran, minus the binary. For the case where a // window looks wrong and the question is which format selector produced it. ytdlp?: { args: string[] }; + // WHERE THE BYTES CAME FROM, when it was not the network: "saved-video" for + // a window cut from the video's saved container (release 21 D2, + // lib/savedVideoWindow-server.ts). Absent on every fetched window. + source?: ClipWindowSource; }; +export const CLIP_WINDOW_SOURCES = ["saved-video"] as const; +export type ClipWindowSource = (typeof CLIP_WINDOW_SOURCES)[number]; + const str = (v: unknown): string | undefined => typeof v === "string" && v !== "" ? v : undefined; const num = (v: unknown): number | undefined => @@ -188,6 +195,9 @@ export function parseClipProvenance(raw: unknown): ClipProvenance | null { ); out.ytdlp = { args }; } + if ((CLIP_WINDOW_SOURCES as readonly unknown[]).includes(r.source)) { + out.source = r.source as ClipWindowSource; + } return out; } diff --git a/common/lib/queueKeys.ts b/common/lib/queueKeys.ts @@ -110,3 +110,11 @@ export function resolveQueueKey( ): string { return override === undefined ? defaultKey : override.trim(); } + +// WINDOWS CUT FROM SAVED CONTAINERS (release 21 D2) run on a queue of their own: +// no platform is asked, so none of a platform's queues — or its hold, cooldown +// and gap — applies, and a batch of local cuts never waits behind a network +// window. The pseudo-platform a batch's group of them carries is +// SAVED_VIDEO_CLIP_PLATFORM. +export const SAVED_VIDEO_CLIP_QUEUE = "clips:saved-video"; +export const SAVED_VIDEO_CLIP_PLATFORM = "saved-video"; diff --git a/common/lib/savedVideoWindow-server.test.ts b/common/lib/savedVideoWindow-server.test.ts @@ -0,0 +1,206 @@ +// A clip window cut from a saved container (release 21 D2): which pointer +// answers which way, and the cut itself. +// +// Real ffmpeg over tiny lavfi containers (160×90, a few seconds), so a run +// costs a second or two. +// +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test lib/savedVideoWindow-server.test.ts + +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { cutSavedVideoWindow, savedWindowSource } from "./savedVideoWindow-server"; +import { findContainingClipWindow } from "./clipWindow-server"; +import { probeMedia } from "./evidenceClip-server"; +import { SAVED_VIDEOS_MARKER_FILENAME } from "./savedVideoStore"; + +const SLUG = "demo-channel"; +const ID = "abc123"; + +let ROOT = ""; +// 8 s of 160×90 picture with sound, and 8 s of sound alone. +let PICTURE = ""; +let SOUND = ""; + +function ff(args: string[]): void { + execFileSync("ffmpeg", ["-nostdin", "-v", "error", "-y", ...args]); +} + +before(async () => { + ROOT = await mkdtemp(path.join(tmpdir(), "saved-window-")); + const fx = path.join(ROOT, "fixtures"); + await mkdir(fx, { recursive: true }); + PICTURE = path.join(fx, "picture.mp4"); + SOUND = path.join(fx, "sound.m4a"); + ff([ + "-f", "lavfi", "-i", "testsrc=size=160x90:rate=10:duration=8", + "-f", "lavfi", "-i", "sine=frequency=440:duration=8", + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", PICTURE, + ]); + ff(["-f", "lavfi", "-i", "sine=frequency=220:duration=8", "-c:a", "aac", SOUND]); +}); + +after(() => rm(ROOT, { recursive: true, force: true })); + +let n = 0; +// A fresh corpus per test: `<root>/<n>/transcripts/channels/<slug>/data/<id>/` +// and its saved-video store `<root>/<n>/transcripts/saved-videos/<slug>/<id>/`, +// holding `container` (when given) under the pointer's name. +async function corpus(container?: string, opts: { pointer?: boolean } = {}) { + const root = path.join(ROOT, String(++n)); + const transcriptsDir = path.join(root, "transcripts"); + const savedVideosDir = path.join(transcriptsDir, "saved-videos"); + const videoDir = path.join(transcriptsDir, "channels", SLUG, "data", ID); + const storeDir = path.join(savedVideosDir, SLUG, ID); + await mkdir(videoDir, { recursive: true }); + await mkdir(storeDir, { recursive: true }); + const file = `source-media${container ? path.extname(container) : ".mp4"}`; + if (container) await copyFile(container, path.join(storeDir, file)); + if (opts.pointer !== false) { + await writeFile( + path.join(videoDir, "saved-video.json"), + JSON.stringify({ + storedAt: "2026-10-10T00:00:00.000Z", + dir: storeDir, + file, + bytes: 1, + keepReason: "pin", + origin: { + requestedBy: "attach-media", + kind: "local-archive", + archive: "/archive/example.zip", + entry: "Example (abc123)/example.mp4", + }, + }), + ); + } + const paths = { transcriptsDir, savedVideosDir, ffmpegBin: "ffmpeg", ffprobeBin: "ffprobe" }; + return { paths, videoDir, storeDir, transcriptsDir }; +} + +test("no pointer: none — the caller goes to the network", async () => { + const c = await corpus(undefined, { pointer: false }); + const r = await savedWindowSource({ paths: c.paths, slug: SLUG, videoDir: c.videoDir, from: 1, to: 3 }); + assert.deepEqual(r, { kind: "none" }); +}); + +test("a container that ends before the window: not-covering", async () => { + const c = await corpus(PICTURE); + const r = await savedWindowSource({ paths: c.paths, slug: SLUG, videoDir: c.videoDir, from: 5, to: 12 }); + assert.equal(r.kind, "not-covering"); + assert.ok(r.kind === "not-covering" && Math.abs(r.durationSec - 8) < 0.2); +}); + +test("a pointer whose container is missing: unreadable, in the media guard's words", async () => { + const c = await corpus(undefined); + const r = await savedWindowSource({ paths: c.paths, slug: SLUG, videoDir: c.videoDir, from: 1, to: 3 }); + assert.equal(r.kind, "unreadable"); + assert.match( + (r as { error: string }).error, + /^Channel "demo-channel": media is not reachable — its saved video .*source-media\.mp4 is not there \(drive not mounted\?\)$/, + ); +}); + +test("a pointer whose store is unmounted (its directory gone): unreadable", async () => { + const c = await corpus(PICTURE); + await rm(c.storeDir, { recursive: true, force: true }); + const r = await savedWindowSource({ paths: c.paths, slug: SLUG, videoDir: c.videoDir, from: 1, to: 3 }); + assert.equal(r.kind, "unreadable"); + assert.match((r as { error: string }).error, /media is not reachable/); +}); + +test("a missing container while the store is being moved: the store guard's sentence", async () => { + const c = await corpus(undefined); + await writeFile( + path.join(c.transcriptsDir, SAVED_VIDEOS_MARKER_FILENAME), + JSON.stringify({ target: "/mnt/elsewhere/saved-videos", direction: "out", startedAt: "", phase: "swap" }), + ); + const r = await savedWindowSource({ paths: c.paths, slug: SLUG, videoDir: c.videoDir, from: 1, to: 3 }); + assert.equal(r.kind, "unreadable"); + assert.match((r as { error: string }).error, /^The saved-video store is being moved \(out to \/mnt\/elsewhere\/saved-videos, phase "swap"\)/); +}); + +test("a container ffprobe cannot read: unreadable, never a guess", async () => { + const c = await corpus(undefined); + await writeFile(path.join(c.storeDir, "source-media.mp4"), "not a container"); + const r = await savedWindowSource({ paths: c.paths, slug: SLUG, videoDir: c.videoDir, from: 1, to: 3 }); + assert.equal(r.kind, "unreadable"); + assert.match((r as { error: string }).error, /could not be read by ffprobe/); +}); + +test("a covering container is cut into clips/ with a saved-video sidecar, and the cache then answers it", async () => { + const c = await corpus(PICTURE); + const src = await savedWindowSource({ paths: c.paths, slug: SLUG, videoDir: c.videoDir, from: 2, to: 5 }); + assert.equal(src.kind, "covers"); + if (src.kind !== "covers") return; + const logs: string[] = []; + const w = await cutSavedVideoWindow({ + paths: c.paths, + videoDir: c.videoDir, + source: src, + from: 2, + to: 5, + provenance: { requestedBy: "test", manifest: "m", clipId: "c1", pad: 1, requestedAt: "2026-10-10T01:00:00.000Z" }, + onLog: (l) => logs.push(l), + }); + assert.equal(w.file, "2.00-5.00.mp4"); + assert.equal(path.dirname(w.path), path.join(c.videoDir, "clips")); + // Only the window and its sidecar: no temp file left behind. + assert.deepEqual((await readdir(path.join(c.videoDir, "clips"))).sort(), ["2.00-5.00.json", "2.00-5.00.mp4"]); + const probe = await probeMedia(w.path); + assert.ok(probe?.hasVideo && probe.hasAudio); + assert.ok(Math.abs((probe?.durationSec ?? 0) - 3) < 0.25, `duration ${probe?.durationSec}`); + const sidecar = JSON.parse(await readFile(path.join(c.videoDir, "clips", "2.00-5.00.json"), "utf8")); + assert.equal(sidecar.source, "saved-video"); + assert.equal(sidecar.requestedBy, "test"); + assert.equal(sidecar.manifest, "m"); + assert.equal(sidecar.clipId, "c1"); + assert.equal(sidecar.pad, 1); + assert.equal(sidecar.bytes, w.bytes); + assert.equal(sidecar.ytdlp, undefined, "nothing was fetched"); + assert.match(logs.join(""), /Cut 2\.00–5\.00 from the saved video source-media\.mp4/); + // The fetched-window cache reads it back like any window. + const hit = await findContainingClipWindow(c.videoDir, 2.5, 4.5); + assert.equal(hit?.file, "2.00-5.00.mp4"); + assert.equal(hit?.provenance?.source, "saved-video"); +}); + +test("a container with no picture is cut as sound", async () => { + const c = await corpus(SOUND); + const src = await savedWindowSource({ paths: c.paths, slug: SLUG, videoDir: c.videoDir, from: 1, to: 4 }); + assert.equal(src.kind, "covers"); + if (src.kind !== "covers") return; + const w = await cutSavedVideoWindow({ + paths: c.paths, + videoDir: c.videoDir, + source: src, + from: 1, + to: 4, + provenance: { requestedBy: "test" }, + }); + const probe = await probeMedia(w.path); + assert.equal(probe?.hasVideo, false); + assert.equal(probe?.hasAudio, true); +}); + +test("an ffmpeg that fails leaves no window behind", async () => { + const c = await corpus(PICTURE); + const src = await savedWindowSource({ paths: c.paths, slug: SLUG, videoDir: c.videoDir, from: 1, to: 2 }); + assert.equal(src.kind, "covers"); + if (src.kind !== "covers") return; + await assert.rejects( + cutSavedVideoWindow({ + paths: { ...c.paths, ffmpegBin: "false" }, + videoDir: c.videoDir, + source: src, + from: 1, + to: 2, + provenance: { requestedBy: "test" }, + }), + /ffmpeg failed cutting 1\.00–2\.00 from the saved video source-media\.mp4/, + ); + assert.deepEqual(await readdir(path.join(c.videoDir, "clips")), []); +}); diff --git a/common/lib/savedVideoWindow-server.ts b/common/lib/savedVideoWindow-server.ts @@ -0,0 +1,208 @@ +// A CLIP WINDOW CUT FROM A SAVED CONTAINER, not fetched (release 21 D2). +// +// A video whose whole source is in the saved-video store (a persist, a +// `full: true` fetch, or a container attached from a local archive — release 21 +// D1's `origin.kind: "local-archive"`) holds every second a window could ask +// for. Fetching those seconds again spends the source's patience for bytes +// already on a disk here — and for a deleted channel there is no source to ask: +// the fetch fails every time. So the window paths (the single +// `fetchWindowAction` and the `fetch-windows` batch) ask here after the +// clip-window cache and BEFORE the network. +// +// THE THREE ANSWERS, and what each caller does with them (ruled 2026-10-10): +// none no pointer: the network path, exactly as before. +// unreadable a pointer whose container cannot be read — the store's drive +// is not mounted or not answering, the file is missing, ffprobe +// cannot open it. REFUSED with the sentence below, never a +// silent fall-through to the network: the operator who can plug +// the drive in is the one who needs to hear it. +// not-covering a pointer whose container ends before the window does: the +// network path. +// covers cut it here. +// +// THE CUT IS evidenceClip-server's (`evidenceCutArgs`): seeked on the input +// and re-encoded, so the window's first frame is the asked-for second; H.264 + +// AAC in an mp4, fitted inside 1280×720 — the container a fetched window is +// pinned to (lib/clipWindow.ts), at most the default clip height. A container +// with no picture is cut as sound (still an `.mp4` name, the window's one +// extension). The window lands in `data/<id>/clips/<from>-<to>.mp4` with a +// provenance sidecar of the fetched shape plus `source: "saved-video"`, so +// every reader of the cache (the video page, the build, umtool) sees it as a +// window like any other. +// +// SERVER-ONLY (node:fs, ffmpeg). + +import { mkdir, rename, rm, stat } from "node:fs/promises"; +import path from "node:path"; +import { execa } from "execa"; +import { + WIN_EPS, + clipWindowFile, + type ClipProvenance, +} from "./clipWindow"; +import { + clipWindowPath, + writeClipProvenance, + type ClipWindow, +} from "./clipWindow-server"; +import { evidenceCutArgs, probeMedia, type MediaProbe } from "./evidenceClip-server"; +import { tmpPathFor } from "./jsonFile-server"; +import { savedVideoPath, type SavedVideoPointer } from "./savedVideo"; +import { loadSavedVideo } from "./savedVideo-server"; +import { readSavedVideosMarker, SavedVideosStoreInTransitionError } from "./savedVideoStore"; +import { isDriveNotAnswering, onDrive } from "./storageHealth"; +import type { Paths } from "./paths"; + +// A window cut from a container is seconds of re-encoding; a whole container +// on a platter seeks once. Generous, so only a wedged ffmpeg reaches it — +// evidenceClip-server's limit for the same work. +const CUT_TIMEOUT_MS = 10 * 60_000; + +export type SavedWindowSource = + | { kind: "none" } + | { kind: "unreadable"; error: string } + | { kind: "not-covering"; container: string; durationSec: number } + | { + kind: "covers"; + pointer: SavedVideoPointer; + container: string; + durationSec: number; + probe: MediaProbe; + }; + +// The media guard's sentence (lib/channelMedia.ts, ChannelMediaUnreachableError) +// with the saved container as its detail, so a refusal here reads like every +// other unreachable-media refusal the operator has seen. +function unreachable(slug: string, detail: string): SavedWindowSource { + return { kind: "unreadable", error: `Channel "${slug}": media is not reachable — ${detail}` }; +} + +// Does this video's saved container hold [from, to]? Reads the pointer, stats +// the container through the drive watchdog (an unanswering drive is refused in +// seconds, not waited on), and probes it once for its duration and streams. +export async function savedWindowSource(opts: { + paths: Pick<Paths, "transcriptsDir" | "savedVideosDir" | "ffprobeBin">; + slug: string; + videoDir: string; + from: number; + to: number; +}): Promise<SavedWindowSource> { + const pointer = await loadSavedVideo(opts.videoDir); + if (!pointer) return { kind: "none" }; + const container = savedVideoPath(pointer); + let isFile = false; + try { + isFile = await onDrive(pointer.dir, () => + stat(container).then( + (s) => s.isFile(), + () => false, + ), + ); + } catch (err) { + if (isDriveNotAnswering(err)) { + return unreachable(opts.slug, `its saved video ${container}: ${(err as Error).message}`); + } + throw err; + } + if (!isFile) { + // The store mid-move is the one case with a sentence of its own: the + // store guard's (assertSavedVideosStoreWritable), which names the move. + const marker = await readSavedVideosMarker(opts.paths); + if (marker) { + return { kind: "unreadable", error: new SavedVideosStoreInTransitionError(marker).message }; + } + return unreachable( + opts.slug, + `its saved video ${container} is not there (drive not mounted?)`, + ); + } + const probe = await probeMedia(container, opts.paths.ffprobeBin); + if (!probe || probe.durationSec === null || (!probe.hasVideo && !probe.hasAudio)) { + return unreachable(opts.slug, `its saved video ${container} could not be read by ffprobe`); + } + const durationSec = probe.durationSec; + if (opts.from < 0 || opts.to > durationSec + WIN_EPS) { + return { kind: "not-covering", container, durationSec }; + } + return { kind: "covers", pointer, container, durationSec, probe }; +} + +export type SavedWindowProvenance = { + requestedBy: string; + manifest?: string; + clipId?: string; + reason?: string; + pad?: number; + requestedAt?: string; +}; + +export class SavedWindowCutError extends Error { + constructor(message: string) { + super(message); + this.name = "SavedWindowCutError"; + } +} + +// Cut [from, to] out of a covering container into the video's clips/, with its +// sidecar. Written to a temp name first (which no window parser accepts) and +// renamed, so a reader never sees half a window. +export async function cutSavedVideoWindow(opts: { + paths: Pick<Paths, "ffmpegBin" | "ffprobeBin">; + videoDir: string; + source: Extract<SavedWindowSource, { kind: "covers" }>; + from: number; + to: number; + provenance: SavedWindowProvenance; + onLog?: (line: string) => void; + signal?: AbortSignal; +}): Promise<ClipWindow> { + const { from, to, source } = opts; + const dest = clipWindowPath(opts.videoDir, from, to); + await mkdir(path.dirname(dest), { recursive: true }); + const tmp = tmpPathFor(dest); + const kind = source.probe.hasVideo ? "video" : "audio"; + try { + const r = await execa(opts.paths.ffmpegBin, evidenceCutArgs(source.container, from, to, kind, tmp), { + reject: false, + timeout: CUT_TIMEOUT_MS, + ...(opts.signal ? { cancelSignal: opts.signal } : {}), + }); + if (r.exitCode !== 0) { + const err = String(r.stderr ?? "").trim().split("\n").slice(-3).join(" | "); + throw new SavedWindowCutError( + `ffmpeg failed cutting ${from.toFixed(2)}–${to.toFixed(2)} from the saved video ` + + `${source.pointer.file}${err ? `: ${err}` : ` (exit ${r.exitCode ?? "?"})`}`, + ); + } + const bytes = (await stat(tmp)).size; + await rename(tmp, dest); + const now = new Date().toISOString(); + const provenance: ClipProvenance = { + requestedBy: opts.provenance.requestedBy, + ...(opts.provenance.manifest ? { manifest: opts.provenance.manifest } : {}), + ...(opts.provenance.clipId ? { clipId: opts.provenance.clipId } : {}), + ...(opts.provenance.reason ? { reason: opts.provenance.reason } : {}), + requestedAt: opts.provenance.requestedAt ?? now, + ...(typeof opts.provenance.pad === "number" ? { pad: opts.provenance.pad } : {}), + bytes, + // When the window landed — for a cut, when it was cut. + fetchedAt: now, + source: "saved-video", + }; + await writeClipProvenance(opts.videoDir, from, to, provenance); + opts.onLog?.( + `Cut ${from.toFixed(2)}–${to.toFixed(2)} from the saved video ${source.pointer.file} into ` + + `clips/${clipWindowFile(from, to)} (${bytes} bytes); nothing fetched.\n`, + ); + return { + file: clipWindowFile(from, to), + path: dest, + from, + to, + bytes, + provenance, + }; + } finally { + await rm(tmp, { force: true }).catch(() => {}); + } +} diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **A clip window of a video whose source is saved is cut from it, not fetched.** `fetch_clip`, `POST /api/media/fetch-window` and `pnpm ops fetch-windows` now cut a window out of the video's saved container (a persisted source, a full-source fetch, or media attached from a local archive) when it covers the seconds asked for, and answer at once as a cached window — no request to the platform, so a deleted channel's held videos are clippable. The window's sidecar records `source: "saved-video"`, and the video page marks it "cut from the saved video". A batch runs such windows as their own job on `clips:saved-video`, outside every platform's queue, hold and cooldown. A saved video whose file cannot be read (its drive unplugged, the file gone) is refused with the media guard's sentence rather than fetched; one that ends before the window is fetched as before. - **An X fetch with a `limit` stops at that many posts.** "Fetch posts" with `limit` (`pnpm ops fetch-posts {"limit": 400}`) on a gallery-dl X channel read the whole history instead — a new channel walked 3,803 posts under the rate limit and held the platform queue for hours — because the cap counted media files, which a metadata-only read has almost none of. It now caps the posts themselves. - **A home seeder of last resort, behind a VPN.** `archilyzer seed` seeds the playable torrents of the sites named in the new `settings.seeder` (`sites`, `trackers`, `maxUploadKiBps`, `maxConnections`, `pollSeconds`, `standbyAfterSeconds`, `bindInterface`; SETTINGS.md) to desktop clients over TCP and to browsers over WebRTC — but each torrent only while no other seeder has it: other seeders seen on every poll for `standbyAfterSeconds` puts that torrent on standby (it stops announcing and closes its peers, keeping the data), and it comes back at once when a leecher is waiting with no other source, or after the same window with no other seeder. Every change is logged with its reason. No DHT, no local discovery, no UPnP. `archilyzer tracker` is a self-hosted HTTP + WebSocket tracker that tracks only those torrents. `docker-compose.seeder.yml` (profile `seeder`) runs both inside a WireGuard container's network namespace (gluetun, its firewall always on), so a tunnel that is down means no network, never the home connection; the WireGuard config is yours (`SEEDER_WG_CONF`, required, mounted read-only). `archilyzer doctor` compares the seeder's egress address with the host's and fails when they are the same; it says "seeder not configured" until `seeder.sites` names a site. - **Saved videos can be made browser-playable, with a torrent each.** `pnpm ops prepare-playable` (`POST /api/ops/prepare-playable`) and `archilyzer media playable <slug>` remux each of a channel's saved containers — without re-encoding (`-c copy`) — into an mp4 with its index in front, or a webm when it already is one (VP9/AV1 with Opus), drop subtitles, metadata and chapters, and make one single-file torrent of the copy: named `<id>.<ext>`, no web seed, no comment, no "created by", 256 KiB–1 MiB pieces. They go to `playable/<slug>/<id>/` beside the saved-video store, listed in `playable/<slug>/playable.json` with each infohash. `"trackers"` is the announce list written into each torrent (none by default); it is not part of the infohash, so the same torrent can be announced elsewhere later. A video already prepared from the same source (by sha256) is skipped, so a re-run is a no-op; a codec a browser cannot play without re-encoding (HEVC, MPEG-4 Part 2) is listed and left alone. diff --git a/editor/app/api/media/fetch-window/route.saved.test.ts b/editor/app/api/media/fetch-window/route.saved.test.ts @@ -0,0 +1,151 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +// Run with: +// pnpm -C editor exec tsx --test "app/api/media/fetch-window/route.saved.test.ts" +// +// A window of a video whose source is in the saved-video store (release 21 +// D2): cut on the spot and answered `cached: true`, with no job and no +// network; a container that does not reach the window goes to the network +// path exactly as before; a pointer whose container is missing is refused. +// +// THE NETWORK PATH IS SEEN BY ITS DOOR: YouTube is put in a rate-limit +// cooldown before the first request, so a request that reaches the network +// path answers 409 with the cooldown's sentence — before any job is queued, +// and never a real fetch. Real ffmpeg over an 8 s 160×90 lavfi container. + +const ROOT = await mkdtemp(path.join(os.tmpdir(), "fetch-window-saved-")); +const SLUG = "demo-channel"; +// Set before the route (and getPaths, which caches) is first imported. +process.env.WORKER_TOKEN = "test-token"; +process.env.TRANSCRIPTS_DIR = ROOT; +process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); +delete process.env.FFPROBE_BIN; +delete process.env.FFMPEG_BIN; + +await mkdir(path.join(ROOT, "channels", SLUG), { recursive: true }); +await writeFile( + path.join(ROOT, "channels", SLUG, "config.json"), + JSON.stringify({ name: "Demo", handling: "youtube", url: "https://www.youtube.com/@demo" }), +); + +// `held1` has its container in the store; `gone1` has a pointer to a file that +// is not there (the store's drive unplugged). +async function pointTo(id: string, withFile: boolean): Promise<string> { + const videoDir = path.join(ROOT, "channels", SLUG, "data", id); + const store = path.join(ROOT, "saved-videos", SLUG, id); + await mkdir(videoDir, { recursive: true }); + await mkdir(store, { recursive: true }); + if (withFile) { + execFileSync("ffmpeg", [ + "-nostdin", "-v", "error", "-y", + "-f", "lavfi", "-i", "testsrc=size=160x90:rate=10:duration=8", + "-f", "lavfi", "-i", "sine=frequency=440:duration=8", + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", + path.join(store, "source-media.mp4"), + ]); + } + await writeFile( + path.join(videoDir, "saved-video.json"), + JSON.stringify({ + storedAt: "2026-10-10T00:00:00.000Z", + dir: store, + file: "source-media.mp4", + bytes: 1, + keepReason: "pin", + origin: { requestedBy: "attach-media", kind: "local-archive", archive: "/archive/example.zip", entry: "e.mp4" }, + }), + ); + return videoDir; +} +const HELD = await pointTo("held1", true); +const GONE = await pointTo("gone1", false); + +const { POST } = await import("./route"); +const { getRegistry } = await import("yt-dlp-transcript-common/jobs/registry"); +const { recordDownloadBackoff } = await import("yt-dlp-transcript-common/jobs/downloadBackoff"); +const { getPaths } = await import("yt-dlp-transcript-common/lib/paths"); +await recordDownloadBackoff("youtube", getPaths()); +const NETWORK_DOOR = /youtube is in a rate-limit cooldown/; +test.after(() => rm(ROOT, { recursive: true, force: true })); + +function post(body: Record<string, unknown>): Promise<Response> { + return POST( + new Request("http://localhost/api/media/fetch-window", { + method: "POST", + headers: { authorization: "Bearer test-token", "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +const base = { channelSlug: SLUG, requestedBy: "test", manifest: "m", clipId: "c1", pad: 1 }; +const jobsNow = () => getRegistry().list().length; + +test("a window the saved container holds is cut and answered cached, with no job", async () => { + const jobs = jobsNow(); + const res = await post({ ...base, videoId: "held1", from: 2, to: 5 }); + assert.equal(res.status, 200); + const body = (await res.json()) as { + cached: boolean; + file: string; + from: number; + to: number; + bytes: number; + height?: number; + provenance: Record<string, unknown>; + }; + assert.equal(body.cached, true); + assert.equal(body.from, 2); + assert.equal(body.to, 5); + assert.equal(body.file, path.join(HELD, "clips", "2.00-5.00.mp4")); + assert.ok(body.bytes > 0); + assert.equal(body.height, 90); + assert.equal(body.provenance.source, "saved-video"); + assert.equal(body.provenance.requestedBy, "test"); + assert.equal(body.provenance.clipId, "c1"); + assert.equal(jobsNow(), jobs, "no job was queued"); + const sidecar = JSON.parse(await readFile(path.join(HELD, "clips", "2.00-5.00.json"), "utf8")); + assert.equal(sidecar.source, "saved-video"); + + // Asked again (or for seconds inside it), the clip cache answers it. + const again = await post({ ...base, videoId: "held1", from: 2.5, to: 4.5 }); + assert.equal(again.status, 200); + const b2 = (await again.json()) as { file: string; provenance: Record<string, unknown> }; + assert.equal(b2.file, body.file); + assert.equal(b2.provenance.source, "saved-video"); + assert.deepEqual((await readdir(path.join(HELD, "clips"))).sort(), ["2.00-5.00.json", "2.00-5.00.mp4"]); +}); + +test("a window past the container's end goes to the network path, as before", async () => { + const res = await post({ ...base, videoId: "held1", from: 6, to: 12 }); + assert.equal(res.status, 409); + const body = (await res.json()) as { error: string }; + assert.match(body.error, NETWORK_DOOR); + assert.ok( + !(await readdir(path.join(HELD, "clips"))).some((f) => f.startsWith("6.00-")), + "nothing was cut", + ); +}); + +test("a pointer whose container is missing is refused with the media guard's sentence, never fetched", async () => { + const res = await post({ ...base, videoId: "gone1", from: 2, to: 5 }); + assert.equal(res.status, 503); + const body = (await res.json()) as { error: string }; + assert.match( + body.error, + /^Channel "demo-channel": media is not reachable — its saved video .*source-media\.mp4 is not there \(drive not mounted\?\)$/, + ); + assert.deepEqual(await readdir(GONE), ["saved-video.json"]); +}); + +test("a video with no pointer goes to the network path, as before", async () => { + await mkdir(path.join(ROOT, "channels", SLUG, "data", "plain1"), { recursive: true }); + const res = await post({ ...base, videoId: "plain1", from: 2, to: 5 }); + assert.equal(res.status, 409); + assert.match(((await res.json()) as { error: string }).error, NETWORK_DOOR); +}); diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/FetchedWindows.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/FetchedWindows.tsx @@ -46,6 +46,7 @@ export function FetchedWindows({ windows }: { windows: FetchedWindow[] }) { requested by {p.requestedBy} {what ? ` for ${what}` : ""} {p.reason ? ` · \u201c${p.reason}\u201d` : ""} + {p.source === "saved-video" ? " · cut from the saved video" : ""} {p.fetchedAt ? ` · ${new Date(p.fetchedAt).toLocaleString()}` : ""} diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts @@ -43,6 +43,7 @@ import { } from "yt-dlp-transcript-common/controller/keepVideosMatching"; import { ChannelMediaUnreachableError, + assertChannelTextReadable, channelTextStall, inspectChannelMedia, } from "yt-dlp-transcript-common/lib/channelMedia"; @@ -78,6 +79,10 @@ import { findContainingClipWindow, } from "yt-dlp-transcript-common/lib/clipWindow-server"; import { + cutSavedVideoWindow, + savedWindowSource, +} from "yt-dlp-transcript-common/lib/savedVideoWindow-server"; +import { fetchWindowManaged, type FetchWindowProvenance, } from "yt-dlp-transcript-common/ytdlp/fetchWindowManaged"; @@ -1133,6 +1138,57 @@ export async function fetchWindowAction(req: { } } + // ...THEN THE SAVED CONTAINER (release 21 D2), before any network: a video + // whose whole source is in the saved-video store is CUT here, not fetched, + // and the answer is a cached window like the one above + // (lib/savedVideoWindow-server.ts). No + // pointer, or a container that ends before the window, goes on to the + // network as before; a pointer whose container cannot be read is REFUSED — + // falling through would spend the source's patience (or, for a deleted + // channel, fail) on seconds that are on a drive somebody can plug in. + const saved = await savedWindowSource({ paths, slug, videoDir, from, to }); + if (saved.kind === "unreadable") { + return { ok: false, status: 503, error: saved.error }; + } + if (saved.kind === "covers") { + // The window lands in data/<id>/clips/, the channel's TEXT tier: the guard a + // fetch-window job's `needsText` would have asked, asked here because no + // job runs. + try { + await assertChannelTextReadable(paths, slug); + } catch (e) { + return { ok: false, status: 503, error: (e as Error).message }; + } + let cut; + try { + cut = await cutSavedVideoWindow({ + paths, + videoDir, + source: saved, + from, + to, + provenance: req.provenance, + }); + } catch (e) { + return { ok: false, status: 500, error: (e as Error).message }; + } + const height = await probeVideoHeight({ + ffprobeBin: paths.ffprobeBin, + file: cut.path, + }); + safeRevalidate([`/channels/${slug}/videos/${videoId}`]); + return { + ok: true, + cached: true, + file: cut.path, + from: cut.from, + to: cut.to, + bytes: cut.bytes, + provenance: cut.provenance, + ...(height !== null ? { height } : {}), + }; + } + const url = req.webpageUrl?.trim() || (await findVideoSourceUrl(paths, slug, videoId, r.config)); diff --git a/editor/app/channels/[slug]/videos/fetchWindowsAction.test.ts b/editor/app/channels/[slug]/videos/fetchWindowsAction.test.ts @@ -0,0 +1,155 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +// Run with: +// pnpm -C editor exec tsx --test "app/channels/*/videos/fetchWindowsAction.test.ts" +// (a wildcard: node's --test reads `[slug]` as a glob class) +// +// The batch fetch over videos whose source is in the saved-video store +// (release 21 D2): a window a container holds joins the `saved-video` group on +// `clips:saved-video` — no platform's queue, hold or cooldown — and its job +// cuts it; a pointer whose container is missing is unresolved with the media +// guard's sentence; a window past the container's end joins its platform's +// group as before. YouTube is in a cooldown throughout, so its group is +// refused at the door and nothing here ever reaches the network. + +const ROOT = await mkdtemp(path.join(os.tmpdir(), "fetch-windows-saved-")); +const SLUG = "demo-channel"; +process.env.TRANSCRIPTS_DIR = ROOT; +process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); +delete process.env.FFPROBE_BIN; +delete process.env.FFMPEG_BIN; + +// The temp corpus sits on whatever disk os.tmpdir() is; the floor is not what +// is under test. +await writeFile(process.env.SETTINGS_FILE, JSON.stringify({ minFreeDiskGB: 0 })); +await mkdir(path.join(ROOT, "channels", SLUG), { recursive: true }); +await writeFile( + path.join(ROOT, "channels", SLUG, "config.json"), + JSON.stringify({ name: "Demo", handling: "youtube", url: "https://www.youtube.com/@demo" }), +); + +async function pointTo(id: string, withFile: boolean): Promise<string> { + const videoDir = path.join(ROOT, "channels", SLUG, "data", id); + const store = path.join(ROOT, "saved-videos", SLUG, id); + await mkdir(videoDir, { recursive: true }); + await mkdir(store, { recursive: true }); + await writeFile( + path.join(videoDir, "metadata.info.json"), + JSON.stringify({ id, webpage_url: `https://www.youtube.com/watch?v=${id}` }), + ); + if (withFile) { + execFileSync("ffmpeg", [ + "-nostdin", "-v", "error", "-y", + "-f", "lavfi", "-i", "testsrc=size=160x90:rate=10:duration=8", + "-f", "lavfi", "-i", "sine=frequency=440:duration=8", + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", + path.join(store, "source-media.mp4"), + ]); + } + await writeFile( + path.join(videoDir, "saved-video.json"), + JSON.stringify({ storedAt: "", dir: store, file: "source-media.mp4", bytes: 1, keepReason: "pin" }), + ); + return videoDir; +} +const HELD = await pointTo("held1", true); +await pointTo("gone1", false); + +const { fetchWindowsAction } = await import("./fetchWindowsAction"); +const { getRegistry } = await import("yt-dlp-transcript-common/jobs/registry"); +const { recordDownloadBackoff } = await import("yt-dlp-transcript-common/jobs/downloadBackoff"); +const { getPaths } = await import("yt-dlp-transcript-common/lib/paths"); +await recordDownloadBackoff("youtube", getPaths()); +test.after(() => rm(ROOT, { recursive: true, force: true })); + +const items = [ + { slug: SLUG, id: "held1", from: 1, to: 3, clipId: "r#1" }, + { slug: SLUG, id: "held1", from: 6, to: 12, clipId: "r#2" }, // past the container's end + { slug: SLUG, id: "gone1", from: 1, to: 3, clipId: "r#3" }, +]; + +test("a dry run groups held windows on clips:saved-video, refuses the unreadable, and sends the rest to their platform", async () => { + const r = await fetchWindowsAction({ items, requestedBy: "test", dryRun: true }); + assert.ok(r.ok); + if (!r.ok) return; + assert.deepEqual( + r.groups.map((g) => [g.platform, g.queueKey, g.items.map((i) => i.clipId)]).sort(), + [ + ["saved-video", "clips:saved-video", ["r#1"]], + ["youtube", "clips:youtube", ["r#2"]], + ], + ); + assert.equal(r.unresolved.length, 1); + assert.equal(r.unresolved[0].item.id, "gone1"); + assert.match(r.unresolved[0].error, /^Channel "demo-channel": media is not reachable — its saved video .* is not there/); +}); + +test("a real run cuts the held window in a saved-video job while the cooling platform is refused", async () => { + const r = await fetchWindowsAction({ items, requestedBy: "test", manifest: "m" }); + assert.ok(r.ok, JSON.stringify(r)); + if (!r.ok) return; + assert.deepEqual( + r.jobs.map((j) => [j.platform, j.queueKey, j.items]), + [["saved-video", "clips:saved-video", 1]], + ); + assert.deepEqual(r.refused.map((x) => x.platform), ["youtube"]); + const jobId = r.jobs[0].jobId; + const reg = getRegistry(); + for (let i = 0; i < 200; i++) { + const s = reg.get(jobId)?.status; + if (s !== "queued" && s !== "running") break; + await new Promise((res) => setTimeout(res, 50)); + } + assert.equal(reg.get(jobId)?.status, "done", `job ${jobId}: ${reg.get(jobId)?.status}`); + assert.deepEqual((await readdir(path.join(HELD, "clips"))).sort(), ["1.00-3.00.json", "1.00-3.00.mp4"]); + const sidecar = JSON.parse(await readFile(path.join(HELD, "clips", "1.00-3.00.json"), "utf8")); + assert.equal(sidecar.source, "saved-video"); + assert.equal(sidecar.manifest, "m"); + assert.equal(sidecar.clipId, "r#1"); + + // Sent again, the cut window is cached and joins no job. + const again = await fetchWindowsAction({ items: [items[0]], requestedBy: "test", dryRun: true }); + assert.ok(again.ok); + if (!again.ok) return; + assert.deepEqual(again.cached.map((i) => i.clipId), ["r#1"]); + assert.deepEqual(again.groups, []); +}); + +test("a held window a queued saved-video job will already cut is answered in inFlight (A5's dedupe)", async () => { + const reg = getRegistry(); + const make = (id: string, params: Record<string, unknown>) => { + const record = { + id, + kind: "fetch-windows", + queueKey: "clips:saved-video", + channelSlug: SLUG, + status: "queued" as const, + queuedAt: Date.now(), + logPath: "/dev/null", + spec: { kind: "fetch-windows", slug: SLUG, params }, + }; + reg.register(record); + reg.enqueue(record, { start: () => {}, onCancel: () => {} }); + }; + make("HOLDER", { items: [{ slug: SLUG, id: "other", from: 0, to: 1 }] }); + make("CUT4", { items: [{ slug: SLUG, id: "held1", from: 4, to: 7 }] }); + try { + const r = await fetchWindowsAction({ + items: [{ slug: SLUG, id: "held1", from: 4.5, to: 6.5 }], + requestedBy: "test", + dryRun: true, + }); + assert.ok(r.ok); + if (!r.ok) return; + assert.deepEqual(r.inFlight.map((f) => f.jobId), ["CUT4"]); + assert.deepEqual(r.groups, []); + } finally { + reg.cancel("CUT4"); + reg.forceRelease("HOLDER"); + } +}); diff --git a/editor/app/channels/[slug]/videos/fetchWindowsAction.ts b/editor/app/channels/[slug]/videos/fetchWindowsAction.ts @@ -5,7 +5,12 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { diskGate } from "yt-dlp-transcript-common/lib/diskSpace"; import { formatBytes } from "yt-dlp-transcript-common/lib/format"; -import { clipWindowQueueKey, resolveQueueKey } from "yt-dlp-transcript-common/lib/queueKeys"; +import { + SAVED_VIDEO_CLIP_PLATFORM, + SAVED_VIDEO_CLIP_QUEUE, + clipWindowQueueKey, + resolveQueueKey, +} from "yt-dlp-transcript-common/lib/queueKeys"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { windowInFlight } from "yt-dlp-transcript-common/jobs/windowJobs"; import { detectPlatform, queueKeyForUrl } from "yt-dlp-transcript-common/lib/platform"; @@ -14,6 +19,7 @@ import { isFetchMaxHeight, } from "yt-dlp-transcript-common/lib/clipWindow"; import { findContainingClipWindow } from "yt-dlp-transcript-common/lib/clipWindow-server"; +import { savedWindowSource } from "yt-dlp-transcript-common/lib/savedVideoWindow-server"; import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; import { isValidChannelSlug, @@ -59,6 +65,14 @@ import { safeRevalidate } from "../../../lib/safeRevalidate"; // queued or running job will already write (`inFlight`, with that job's id — // jobs/windowJobs.ts): the same manifest sent twice queues nothing twice. // +// A WINDOW A SAVED CONTAINER HOLDS (release 21 D2) joins no platform's job: it +// goes to one group of its own, platform `saved-video` on the +// `clips:saved-video` queue, whose job CUTS each window from the container +// (controller/fetchWindows.ts) — no request, no pause, no hold or cooldown to +// pass. A pointer whose container cannot be read is `unresolved` with the +// media guard's sentence, never sent to the network; one whose container ends +// before the window is fetched as before. +// // Like the single fetch, NOT GATED BY THE DOWNLOAD PAUSE: an operator (or a // tool they are driving) asked for these seconds by hand. @@ -80,6 +94,8 @@ export type FetchWindowsUnresolved = { item: FetchWindowsItem; error: string }; export type FetchWindowsGroup = { // The cooldown key (`detectPlatform(url)`), and the queue the job runs on. + // `saved-video` (SAVED_VIDEO_CLIP_PLATFORM) for the windows cut from saved + // containers, on SAVED_VIDEO_CLIP_QUEUE. platform: string; queueKey: string; items: FetchWindowsItem[]; @@ -174,6 +190,30 @@ async function planFetchWindows( inFlight.push({ item, jobId: running.jobId }); continue; } + // The saved container: a window it holds is cut, on a queue no platform + // shares; one it cannot be read for is refused here, not fetched. + const saved = await savedWindowSource({ + paths, + slug: item.slug, + videoDir, + from: item.from, + to: item.to, + }); + if (saved.kind === "unreadable") { + unresolved.push({ item, error: saved.error }); + continue; + } + if (saved.kind === "covers") { + const queueKey = resolveQueueKey(SAVED_VIDEO_CLIP_QUEUE, queueOverride); + const group = groups.get(queueKey) ?? { + platform: SAVED_VIDEO_CLIP_PLATFORM, + queueKey, + items: [], + }; + group.items.push(item); + groups.set(queueKey, group); + continue; + } const url = item.webpageUrl?.trim() || (await findVideoSourceUrl(paths, item.slug, item.id, config)); @@ -204,6 +244,8 @@ async function planFetchWindows( // The door each group passes before its job is queued: the platform's hold, // then its cooldown — fetchWindowAction's checks and sentences. async function groupRefusal(platform: string): Promise<string | null> { + // Cuts from saved containers ask no platform. + if (platform === SAVED_VIDEO_CLIP_PLATFORM) return null; const paths = getPaths(); const held = await heldPlatformRefusal(platform, "This batch", paths); if (held) return held; diff --git a/mcp/src/instructions.ts b/mcp/src/instructions.ts @@ -155,7 +155,8 @@ function clipStep(ctx: PlanContext): string { `NEVER run yt-dlp yourself, in any form. If the tool reports no editor is ` + `configured, say so and stop — the README's yt-dlp command is the ` + `operator's fallback, not yours. If it returns queued, call it again with ` + - `the job it names.` + `the job it names. A video the archive holds locally is cut from its ` + + `saved copy without a fetch (cached at once).` ); } diff --git a/plans/release-21.md b/plans/release-21.md @@ -299,6 +299,68 @@ the three new records is the operator's (`transcribe-one`), as ruled. D2, D4, D5 `test:scripts` 708 + 3 skipped, four builds, the full editor suite (start mode) green after re-running three load flakes alone. The pilot below waits for D2. +#### D2, as shipped — clip windows cut from a saved container + +Release 21's D2, on `r20/d2-r20` off `r20/integration` (`585be292`), overnight 2026-10-10 (Track D, part 1). + +**What it does.** `common/lib/savedVideoWindow-server.ts`: `savedWindowSource` reads the video's saved-video +pointer (any pointer — a persist, a `full: true` fetch, a D1 `local-archive` attach), stats the container through +the drive watchdog (`onDrive`) and probes it once (`probeMedia`), answering `none` (no pointer), `unreadable`, +`not-covering` (the window ends past the container's duration, to `WIN_EPS`) or `covers`; `cutSavedVideoWindow` +cuts with evidenceClip-server's `evidenceCutArgs` (input-seeked, H.264 + AAC mp4 fitted inside 1280×720; a container +with no picture is cut as sound) to a temp name, renames it to `clips/<from>-<to>.mp4`, and writes the fetched +sidecar shape (`requestedBy`, `manifest`, `clipId`, `reason`, `requestedAt`, `pad`, `bytes`, `fetchedAt` = when it +was cut) plus `source: "saved-video"` — a new optional `ClipProvenance.source` that `parseClipProvenance` reads; +absent on every fetched window. +- **Single** (`fetchWindowAction`): the clip cache, then the in-flight jobs (A5), then the saved container, then + the network. `covers` → the channel's text guard (`assertChannelTextReadable`, which a `fetch-window` job's + `needsText` would have asked), the cut, `cached: true` with the window's probed `height`, no job. `unreadable` → + 503 with the media guard's sentence (`Channel "<slug>": media is not reachable — its saved video <path> is not + there (drive not mounted?)`, or `… could not be read by ffprobe`, or the watchdog's `drive not answering …`), or + the store guard's (`SavedVideosStoreInTransitionError`) when the file is missing while the store's move marker + is present. `none` and `not-covering` → the network path, unchanged. A failed cut → 500 with ffmpeg's last lines. +- **Batch** (`fetchWindowsAction` → `controller/fetchWindows.ts`): after the cache and the in-flight check, a + covered window joins one group of its own — platform `saved-video` on `clips:saved-video` + (`SAVED_VIDEO_CLIP_QUEUE` / `SAVED_VIDEO_CLIP_PLATFORM`, `lib/queueKeys.ts`), which `groupRefusal` does not + ask about a hold or cooldown; an unreadable one is `unresolved` with the guard's sentence; a not-covering one joins + its platform's group. The controller asks the same tier for every item before the URL and the gap: a cut costs no + pause and is not a network attempt (`result.cut`, logged `✂`, and "N cut from saved videos" in the summary); + `unreadable` fails the item as `unreachable`; a failed cut is the new class `cut-failed` and the run carries on. + A5's dedupe covers the new queue unchanged: a queued `saved-video` job's items are in its spec. +- The video page's fetched-windows card adds "· cut from the saved video" to such a window. `mcp/src/instructions.ts` + (the clip step): "A video the archive holds locally is cut from its saved copy without a fetch (cached at once)." + +**Rulings applied.** No pointer → the network exactly as before; a pointer whose container is unreadable → refused, +never a fall-through; a container that does not cover the window → the network (orchestrator, 2026-10-10). + +**Found and left.** +- A local cut is at most 1280×720 (evidenceCutArgs' box), whatever `maxHeight` asks; the answer carries the window's + `height`, as a cached window's does. A smaller `maxHeight` gets a cut taller than it asked for. +- `ops-api.spec` cannot reach a cut: the e2e server runs with `FFMPEG_BIN`/`FFPROBE_BIN` set to the fixture fakes + (`editor/package.json` `dev:test`/`start:test`), whose ffprobe answers a bare duration, never `-of json`, so every + saved container there probes as unreadable. The nearest tests run the real thing instead: the route test cuts + over HTTP with real ffmpeg, and the action test runs the `saved-video` batch job through `runManagedFunction` to + `done` (below). No e2e case was added. + +**Commits** + +| Commit | What | +|---|---| +| `11753809` | `channels:` release 21 D2 — the saved-container tier in the single and batch window paths, `ClipProvenance.source`, the `clips:saved-video` queue, the card's marker, the MCP line; 18 tests | + +**Gates.** `pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit` clean. New tests, all real ffmpeg over +8 s lavfi containers in temp dirs: `lib/savedVideoWindow-server.test.ts` 9 (no pointer; not covering; a missing +container; an unmounted store dir; the store mid-move; a file ffprobe cannot read; the cut and its sidecar, read +back by the clip cache; sound only; a failing ffmpeg leaves nothing), `controller/fetchWindows.test.ts` +2 (cut / +unreadable / not-covering in one run with no request and no pause; a failed cut), `app/api/media/fetch-window/ +route.saved.test.ts` 4 (a covered window → 200 `cached: true`, `provenance.source` `saved-video`, height 90, no job, +then a clip-cache hit; past the container's end → the network door, a YouTube cooldown's 409; a missing container +→ 503 with the guard's sentence; no pointer → the network door), `app/channels/[slug]/videos/ +fetchWindowsAction.test.ts` 3 (the dry run's groups and unresolved; a real run whose `saved-video` job ends `done` +with the window and sidecar on disk while the cooling YouTube group is refused; A5's dedupe on `clips:saved-video`). +Editor unit 225/225; the touched common files (clipWindow, evidenceClip, savedVideo*, fetchWindows, windowJobs) +61/61; architecture 6/6; mcp 293/293. The whole common suite runs once at the end of the track (part 2). + ## Rollout (pilot) 1. `pnpm ops attach-media --json '{"slug":"the-incredible-salt-mine","source":"<archive>/Uploads/YouTube.zip","dryRun":true}'`.