@hraness/kb 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/DISCLOSURE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/agent-context.js +35 -0
  5. package/dist/agent-guide-audit.js +18 -0
  6. package/dist/attachments.js +19 -0
  7. package/dist/authoring.js +33 -0
  8. package/dist/benchmark.js +19 -0
  9. package/dist/capture.js +49 -0
  10. package/dist/cli.js +3646 -0
  11. package/dist/clip/acquire.js +57 -0
  12. package/dist/clip/args.js +21 -0
  13. package/dist/clip/bounded-byte-buffer.js +9 -0
  14. package/dist/clip/browser-profiles.js +17 -0
  15. package/dist/clip/bundle-reader.js +11 -0
  16. package/dist/clip/cli.js +27 -0
  17. package/dist/clip/cookies.js +21 -0
  18. package/dist/clip/defuddle-worker.js +84 -0
  19. package/dist/clip/doctor.js +32 -0
  20. package/dist/clip/jobs.js +784 -0
  21. package/dist/clip/network-proxy.js +9 -0
  22. package/dist/clip/network.js +28 -0
  23. package/dist/clip/persist.js +30 -0
  24. package/dist/clip/refresh.js +11 -0
  25. package/dist/clip/terminal.js +11 -0
  26. package/dist/evaluation-builder.js +12238 -0
  27. package/dist/evaluation-kb.js +26 -0
  28. package/dist/evaluation.js +35 -0
  29. package/dist/git.js +39 -0
  30. package/dist/graph.js +39 -0
  31. package/dist/index-01jj6rbv.js +1103 -0
  32. package/dist/index-06c9ctr6.js +738 -0
  33. package/dist/index-0kavxzqj.js +2480 -0
  34. package/dist/index-1gwbassd.js +743 -0
  35. package/dist/index-1n418kb9.js +827 -0
  36. package/dist/index-1vrd1rmn.js +56 -0
  37. package/dist/index-1xxnjn0d.js +171 -0
  38. package/dist/index-3rm7cz6h.js +373 -0
  39. package/dist/index-3v2z4f0q.js +328 -0
  40. package/dist/index-48pz4jpc.js +392 -0
  41. package/dist/index-4j3tt0c3.js +234 -0
  42. package/dist/index-5n05se68.js +307 -0
  43. package/dist/index-5vwpzb5a.js +764 -0
  44. package/dist/index-6g2pv9d2.js +370 -0
  45. package/dist/index-7fzc32gf.js +712 -0
  46. package/dist/index-84x0vjjp.js +432 -0
  47. package/dist/index-adx6khj5.js +488 -0
  48. package/dist/index-b88v3vtm.js +805 -0
  49. package/dist/index-cv6fh7z5.js +462 -0
  50. package/dist/index-cxfrakt7.js +1067 -0
  51. package/dist/index-d13v9ckt.js +150 -0
  52. package/dist/index-dycjbhw2.js +1943 -0
  53. package/dist/index-dyqwejk5.js +531 -0
  54. package/dist/index-e5fbsywq.js +658 -0
  55. package/dist/index-f984hw45.js +927 -0
  56. package/dist/index-gh719d91.js +63 -0
  57. package/dist/index-hgve9rh2.js +2046 -0
  58. package/dist/index-hya40gb2.js +474 -0
  59. package/dist/index-j4zgmzjr.js +152 -0
  60. package/dist/index-jsmvyyvf.js +1166 -0
  61. package/dist/index-kh0pwvdd.js +1974 -0
  62. package/dist/index-mqx4nd6v.js +119 -0
  63. package/dist/index-mxxxytys.js +1254 -0
  64. package/dist/index-n5dd7r0v.js +711 -0
  65. package/dist/index-npg9z1a4.js +431 -0
  66. package/dist/index-pj501bh1.js +140 -0
  67. package/dist/index-qry4vhxk.js +1041 -0
  68. package/dist/index-s2gw5aw9.js +199 -0
  69. package/dist/index-vvdj6kca.js +70 -0
  70. package/dist/index-w2zc0vwa.js +571 -0
  71. package/dist/index-x3fthpsc.js +585 -0
  72. package/dist/index-zr53sf63.js +44 -0
  73. package/dist/index-zxdy5pby.js +2728 -0
  74. package/dist/index-zzhgcwyt.js +966 -0
  75. package/dist/index.js +453 -0
  76. package/dist/navigation.js +13 -0
  77. package/dist/pdf.js +85 -0
  78. package/dist/percolate.js +24 -0
  79. package/dist/portfolio.js +77 -0
  80. package/dist/query.js +29 -0
  81. package/dist/repository-memory.js +60 -0
  82. package/dist/sdk.js +36 -0
  83. package/dist/search-rules.js +34 -0
  84. package/dist/search.js +22 -0
  85. package/dist/semantic.js +39 -0
  86. package/dist/source-inbox.js +17 -0
  87. package/dist/untrusted-content.js +32 -0
  88. package/dist/url-intelligence.js +74 -0
  89. package/dist/workflow.js +23 -0
  90. package/dist/workflows/decision-context.js +20 -0
  91. package/dist/workflows/explain-change.js +8 -0
  92. package/dist/workflows/index.js +28 -0
  93. package/dist/workflows/plan-radar.js +10 -0
  94. package/package.json +409 -0
  95. package/skills/kb/AGENTS.md +19 -0
  96. package/skills/kb/SKILL.md +82 -0
  97. package/skills/kb/agents/openai.yaml +4 -0
  98. package/skills/kb/references/pdf-review.md +51 -0
  99. package/skills/kb/references/percolate.md +144 -0
  100. package/skills/kb/references/plan-structure.md +105 -0
  101. package/skills/kb/references/plan.md +115 -0
  102. package/skills/kb/references/query.md +231 -0
  103. package/skills/kb/references/refresh.md +133 -0
  104. package/skills/kb/references/save-pdf.md +125 -0
  105. package/skills/kb/references/save-url.md +178 -0
  106. package/skills/kb/references/url-authentication.md +75 -0
  107. package/skills/kb/references/url-platforms.md +25 -0
  108. package/src/agent-context.ts +1241 -0
  109. package/src/agent-guide-audit.ts +750 -0
  110. package/src/attachments.ts +723 -0
  111. package/src/authoring.ts +1725 -0
  112. package/src/benchmark.ts +362 -0
  113. package/src/capture.ts +68 -0
  114. package/src/cli.ts +3660 -0
  115. package/src/clip/acquire.ts +1273 -0
  116. package/src/clip/archive-today.ts +402 -0
  117. package/src/clip/args.ts +423 -0
  118. package/src/clip/assets.ts +262 -0
  119. package/src/clip/bounded-byte-buffer.ts +75 -0
  120. package/src/clip/browser-profiles.ts +372 -0
  121. package/src/clip/bundle-reader.ts +608 -0
  122. package/src/clip/capture.ts +1054 -0
  123. package/src/clip/cli.ts +181 -0
  124. package/src/clip/cookies.ts +512 -0
  125. package/src/clip/doctor.ts +1110 -0
  126. package/src/clip/extract.ts +868 -0
  127. package/src/clip/jobs.ts +959 -0
  128. package/src/clip/lib.ts +861 -0
  129. package/src/clip/media.ts +1474 -0
  130. package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
  131. package/src/clip/metadata-search-tool/Cargo.toml +18 -0
  132. package/src/clip/metadata-search-tool/runner.ts +685 -0
  133. package/src/clip/metadata-search-tool/src/main.rs +389 -0
  134. package/src/clip/metadata-search.ts +687 -0
  135. package/src/clip/network-proxy.ts +627 -0
  136. package/src/clip/network.ts +950 -0
  137. package/src/clip/package-root.ts +54 -0
  138. package/src/clip/persist.ts +1472 -0
  139. package/src/clip/platforms.ts +1824 -0
  140. package/src/clip/refresh.ts +197 -0
  141. package/src/clip/structured.ts +432 -0
  142. package/src/clip/terminal.ts +197 -0
  143. package/src/clip/url-intelligence.ts +581 -0
  144. package/src/clip/url-metadata-backfill.ts +616 -0
  145. package/src/clip/url-metadata-cli.ts +251 -0
  146. package/src/clip/url-metadata.ts +1397 -0
  147. package/src/evaluation-analysis-v2.ts +1440 -0
  148. package/src/evaluation-builder.ts +2539 -0
  149. package/src/evaluation-corpus-authoring.ts +2052 -0
  150. package/src/evaluation-evidence.ts +1147 -0
  151. package/src/evaluation-execution-v2.ts +426 -0
  152. package/src/evaluation-implementation.ts +131 -0
  153. package/src/evaluation-kb-closure.ts +1430 -0
  154. package/src/evaluation-kb-runner-v2.ts +735 -0
  155. package/src/evaluation-kb-v2.ts +2567 -0
  156. package/src/evaluation-kb.ts +1018 -0
  157. package/src/evaluation-measurement-v2.ts +1462 -0
  158. package/src/evaluation-packing-v2.ts +452 -0
  159. package/src/evaluation-redaction.ts +28 -0
  160. package/src/evaluation-v2.ts +3857 -0
  161. package/src/evaluation.ts +1220 -0
  162. package/src/git.ts +1276 -0
  163. package/src/graph.ts +1487 -0
  164. package/src/index.ts +20 -0
  165. package/src/init.ts +123 -0
  166. package/src/navigation.ts +277 -0
  167. package/src/note-lock.ts +529 -0
  168. package/src/pdf/args.ts +202 -0
  169. package/src/pdf/capture.ts +298 -0
  170. package/src/pdf/cli.ts +159 -0
  171. package/src/pdf/extract.ts +303 -0
  172. package/src/pdf/layout.ts +618 -0
  173. package/src/pdf/markdown.ts +154 -0
  174. package/src/pdf/model.ts +234 -0
  175. package/src/pdf/ocr.ts +190 -0
  176. package/src/pdf/persist.ts +314 -0
  177. package/src/pdf/source.ts +135 -0
  178. package/src/pdf/tools.ts +146 -0
  179. package/src/pdf.ts +92 -0
  180. package/src/percolate.ts +877 -0
  181. package/src/portfolio-audit.ts +574 -0
  182. package/src/portfolio-identity.ts +162 -0
  183. package/src/portfolio-registry.ts +473 -0
  184. package/src/portfolio.ts +765 -0
  185. package/src/query.ts +646 -0
  186. package/src/repository-memory.ts +1150 -0
  187. package/src/sdk.ts +1452 -0
  188. package/src/search-rules.ts +712 -0
  189. package/src/search.ts +710 -0
  190. package/src/semantic-runtime.ts +1139 -0
  191. package/src/semantic.ts +2480 -0
  192. package/src/source-inbox.ts +232 -0
  193. package/src/untrusted-content.ts +401 -0
  194. package/src/url-intelligence.ts +104 -0
  195. package/src/vault.ts +636 -0
  196. package/src/workflow.ts +653 -0
  197. package/src/workflows/decision-context.ts +81 -0
  198. package/src/workflows/explain-change.ts +73 -0
  199. package/src/workflows/index.ts +3 -0
  200. package/src/workflows/plan-radar.ts +101 -0
@@ -0,0 +1,1474 @@
1
+ import { createHash } from "node:crypto";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ readdirSync,
11
+ realpathSync,
12
+ renameSync,
13
+ rmSync,
14
+ statSync,
15
+ unlinkSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { homedir, tmpdir } from "node:os";
19
+ import { basename, extname, join, resolve } from "node:path";
20
+
21
+ import { getCookies, type GetCookiesOptions } from "@steipete/sweet-cookie";
22
+
23
+ import { BoundedByteBuffer } from "./bounded-byte-buffer.js";
24
+ import { sniffImage } from "./assets.js";
25
+ import {
26
+ filterCookieProviderResult,
27
+ MAX_COOKIE_BYTES,
28
+ readCookieFile,
29
+ renderNetscapeCookieJar,
30
+ } from "./cookies.js";
31
+ import { startNetworkProxy, type LocalNetworkProxy } from "./network-proxy.js";
32
+
33
+ export type MediaCookieBrowser = "chrome" | "arc" | "brave" | "chromium" | "edge" | "firefox" | "safari";
34
+
35
+ export type MediaCaptureStatus = "captured" | "unavailable" | "unsupported" | "failed";
36
+
37
+ export type MediaMetadata = {
38
+ readonly id?: string;
39
+ readonly title?: string;
40
+ readonly description?: string;
41
+ readonly uploader?: string;
42
+ readonly uploaderId?: string;
43
+ readonly channel?: string;
44
+ readonly channelId?: string;
45
+ readonly webpageUrl?: string;
46
+ readonly extractor?: string;
47
+ readonly durationSeconds?: number;
48
+ readonly timestamp?: number;
49
+ };
50
+
51
+ export type MediaRecord = {
52
+ readonly path: string;
53
+ readonly mimeType: string;
54
+ readonly bytes: number;
55
+ readonly sha256: string;
56
+ };
57
+
58
+ export type MediaCaptureResult = {
59
+ readonly status: MediaCaptureStatus;
60
+ readonly records: readonly MediaRecord[];
61
+ readonly metadata: MediaMetadata | null;
62
+ readonly warnings: readonly string[];
63
+ };
64
+
65
+ export type VideoContextCaptureStatus = MediaCaptureStatus | "partial";
66
+
67
+ export type VideoTranscript = {
68
+ readonly language: string;
69
+ readonly markdown: string;
70
+ readonly cueCount: number;
71
+ readonly truncated: boolean;
72
+ };
73
+
74
+ export type VideoContextCaptureResult = {
75
+ readonly status: VideoContextCaptureStatus;
76
+ readonly thumbnail: MediaRecord | null;
77
+ readonly transcript: VideoTranscript | null;
78
+ readonly metadata: MediaMetadata | null;
79
+ readonly warnings: readonly string[];
80
+ };
81
+
82
+ export type MediaCommand = {
83
+ readonly command: readonly string[];
84
+ /** Private process input; secret-bearing media URLs must never be placed in argv. */
85
+ readonly stdin?: string;
86
+ /** Dedicated working directory that confines relative yt-dlp outputs. */
87
+ readonly cwd?: string;
88
+ readonly timeoutMs: number;
89
+ readonly maxOutputBytes: number;
90
+ readonly monitoredDirectory: string;
91
+ readonly maxFiles: number;
92
+ readonly maxFileBytes: number;
93
+ readonly maxTotalBytes: number;
94
+ };
95
+
96
+ export type MediaCommandResult = {
97
+ readonly stdout: string;
98
+ readonly stderr: string;
99
+ readonly exitCode: number;
100
+ };
101
+
102
+ export type MediaCommandRunner = (specification: MediaCommand) => Promise<MediaCommandResult>;
103
+
104
+ export type BrowserMediaCookieRequest = {
105
+ readonly url: URL;
106
+ readonly source: MediaCookieBrowser;
107
+ readonly profile?: string;
108
+ readonly timeoutMs: number;
109
+ };
110
+
111
+ export type FileMediaCookieRequest = {
112
+ readonly url: URL;
113
+ readonly source: "file";
114
+ readonly file: string;
115
+ readonly timeoutMs: number;
116
+ };
117
+
118
+ export type MediaCookieRequest = BrowserMediaCookieRequest | FileMediaCookieRequest;
119
+
120
+ /** Foreign cookie-provider results are deliberately parsed from unknown. */
121
+ export type MediaCookieProvider = (request: MediaCookieRequest) => Promise<unknown>;
122
+ export type SweetCookieReader = (options: GetCookiesOptions) => Promise<unknown>;
123
+
124
+ export type CaptureMediaOptions = {
125
+ readonly url: URL;
126
+ /** Dedicated media destination. The command itself writes into a private child staging directory. */
127
+ readonly outputDirectory: string;
128
+ /** Prefix stored in manifest paths, for example `assets/media`. */
129
+ readonly relativePrefix?: string;
130
+ readonly timeoutMs: number;
131
+ readonly maxFileBytes: number;
132
+ readonly maxTotalBytes: number;
133
+ readonly allowPrivateNetwork?: boolean;
134
+ readonly maxFiles?: number;
135
+ readonly maxOutputBytes?: number;
136
+ readonly executable?: string;
137
+ readonly userAgent?: string;
138
+ /** Set only in response to an explicit user cookie-source choice. */
139
+ readonly cookieBrowser?: {
140
+ readonly source: MediaCookieBrowser;
141
+ readonly profile?: string;
142
+ };
143
+ /** Set only in response to an explicit user cookie-file choice. */
144
+ readonly cookiesFile?: string;
145
+ readonly exists?: (path: string) => boolean;
146
+ readonly which?: (executable: string) => string | null;
147
+ readonly homeDirectory?: string;
148
+ readonly run?: MediaCommandRunner;
149
+ readonly cookieProvider?: MediaCookieProvider;
150
+ /** Test seam. Production callers leave this unset. */
151
+ readonly startProxy?: typeof startNetworkProxy;
152
+ };
153
+
154
+ export type CaptureVideoContextOptions = CaptureMediaOptions & {
155
+ /** One exact yt-dlp subtitle language identifier, such as `en` or `en-US`. */
156
+ readonly transcriptLanguage?: string;
157
+ /** Explicit Node.js runtime for yt-dlp's JavaScript challenge support; null disables discovery. */
158
+ readonly nodeExecutable?: string | null;
159
+ /** Test seam used to validate a discovered Node.js runtime. */
160
+ readonly readNodeVersion?: (path: string) => string | null;
161
+ };
162
+
163
+ export type ParsedWebVtt = {
164
+ readonly markdown: string;
165
+ readonly cueCount: number;
166
+ readonly truncated: boolean;
167
+ };
168
+
169
+ export type ParseWebVttOptions = {
170
+ readonly maxInputBytes?: number;
171
+ readonly maxCues?: number;
172
+ readonly maxOutputBytes?: number;
173
+ };
174
+
175
+ type JsonRecord = Readonly<Record<string, unknown>>;
176
+
177
+ const metadataPrefix = "CLIP_MEDIA_JSON\t";
178
+ const isRecord = (value: unknown): value is JsonRecord =>
179
+ typeof value === "object" && value !== null && !Array.isArray(value);
180
+
181
+ function startsWith(bytes: Uint8Array, signature: readonly number[]): boolean {
182
+ return signature.every((byte, index) => bytes[index] === byte);
183
+ }
184
+
185
+ function ascii(bytes: Uint8Array, start: number, length: number): string {
186
+ let result = "";
187
+ for (let index = start; index < start + length && index < bytes.length; index += 1) {
188
+ result += String.fromCharCode(bytes[index] ?? 0);
189
+ }
190
+ return result;
191
+ }
192
+
193
+ /** Validate common audio/video containers by signature before promoting them into the clip. */
194
+ export function sniffMediaMimeType(bytes: Uint8Array, extension: string): string | null {
195
+ const normalized = extension.toLowerCase();
196
+ if (ascii(bytes, 4, 4) === "ftyp") {
197
+ if (normalized === ".mov") return "video/quicktime";
198
+ if (normalized === ".m4a") return "audio/mp4";
199
+ if (normalized === ".mp4") return "video/mp4";
200
+ if (normalized === ".m4v") return "video/x-m4v";
201
+ return null;
202
+ }
203
+ if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) {
204
+ if (normalized === ".webm") return "video/webm";
205
+ if (normalized === ".mkv") return "video/x-matroska";
206
+ return null;
207
+ }
208
+ if (ascii(bytes, 0, 3) === "ID3" || (bytes[0] === 0xff && ((bytes[1] ?? 0) & 0xe0) === 0xe0)) {
209
+ return normalized === ".mp3" ? "audio/mpeg" : null;
210
+ }
211
+ if (ascii(bytes, 0, 4) === "OggS") {
212
+ if (normalized === ".opus") return "audio/opus";
213
+ if (normalized === ".ogg") return "audio/ogg";
214
+ return null;
215
+ }
216
+ if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WAVE") {
217
+ return normalized === ".wav" ? "audio/wav" : null;
218
+ }
219
+ if (ascii(bytes, 0, 4) === "fLaC") return normalized === ".flac" ? "audio/flac" : null;
220
+ if (bytes[0] === 0xff && (((bytes[1] ?? 0) & 0xf6) === 0xf0)) {
221
+ return normalized === ".aac" ? "audio/aac" : null;
222
+ }
223
+ return null;
224
+ }
225
+
226
+ async function readBoundedStream(stream: NodeJS.ReadableStream, maxBytes: number): Promise<string> {
227
+ const iterable: AsyncIterable<unknown> = stream;
228
+ const bytes = new BoundedByteBuffer(maxBytes);
229
+ for await (const value of iterable) {
230
+ let chunk: Buffer;
231
+ if (Buffer.isBuffer(value)) chunk = value;
232
+ else if (typeof value === "string") chunk = Buffer.from(value);
233
+ else if (value instanceof Uint8Array) chunk = Buffer.from(value);
234
+ else throw new Error("media command returned an unsupported output chunk");
235
+ if (!bytes.append(chunk)) throw new Error(`media command output exceeded ${maxBytes} bytes`);
236
+ }
237
+ return new TextDecoder().decode(bytes.toUint8Array());
238
+ }
239
+
240
+ function inspectMonitoredDirectory(
241
+ directory: string,
242
+ maxFiles: number,
243
+ maxFileBytes: number,
244
+ maxTotalBytes: number,
245
+ ): string | null {
246
+ let entries;
247
+ try {
248
+ entries = readdirSync(directory, { withFileTypes: true });
249
+ } catch (error) {
250
+ return `could not inspect media staging directory: ${error instanceof Error ? error.message : String(error)}`;
251
+ }
252
+ if (entries.length > maxFiles) return `media capture created more than ${maxFiles} files`;
253
+ let totalBytes = 0;
254
+ for (const entry of entries) {
255
+ const path = join(directory, entry.name);
256
+ let stats;
257
+ try {
258
+ stats = lstatSync(path);
259
+ } catch {
260
+ // yt-dlp and ffmpeg atomically rename temporary fragments while this monitor runs.
261
+ continue;
262
+ }
263
+ if (!entry.isFile() || stats.isSymbolicLink()) return "media capture created an unexpected non-file output";
264
+ if (stats.size > maxFileBytes) return `media capture created a file larger than ${maxFileBytes} bytes`;
265
+ totalBytes += stats.size;
266
+ if (totalBytes > maxTotalBytes) return `media capture exceeded the ${maxTotalBytes}-byte total limit`;
267
+ }
268
+ return null;
269
+ }
270
+
271
+ /** Run yt-dlp with wall-clock, process-output, file-count, per-file, and total-byte limits. */
272
+ export const runMediaCommand: MediaCommandRunner = async (specification) => {
273
+ const executable = specification.command[0];
274
+ if (executable === undefined) throw new Error("media command is empty");
275
+ const useProcessGroup = process.platform !== "win32";
276
+ const child = spawn(executable, specification.command.slice(1), {
277
+ ...(specification.cwd === undefined ? {} : { cwd: specification.cwd }),
278
+ detached: useProcessGroup,
279
+ stdio: ["pipe", "pipe", "pipe"],
280
+ windowsHide: true,
281
+ });
282
+ child.stdin.once("error", () => {
283
+ // A child that exits before reading its private input is handled by its exit status.
284
+ });
285
+ child.stdin.end(specification.stdin ?? "");
286
+ const exited = new Promise<number>((resolveExit, rejectExit) => {
287
+ child.once("error", rejectExit);
288
+ child.once("close", (code) => resolveExit(code ?? 1));
289
+ });
290
+ const signalProcessTree = (signal: NodeJS.Signals): void => {
291
+ if (useProcessGroup && child.pid !== undefined) {
292
+ try {
293
+ process.kill(-child.pid, signal);
294
+ return;
295
+ } catch {
296
+ // The process may have exited before group signaling; fall back to the direct child handle.
297
+ }
298
+ }
299
+ try {
300
+ child.kill(signal);
301
+ } catch {
302
+ // Killing an already-exited fallback process is harmless.
303
+ }
304
+ };
305
+ let failure: string | null = null;
306
+ let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
307
+ const requestStop = (reason: string): void => {
308
+ if (failure === null) failure = reason;
309
+ if (forceKillTimer !== null) return;
310
+ signalProcessTree("SIGTERM");
311
+ forceKillTimer = setTimeout(() => signalProcessTree("SIGKILL"), 1_000);
312
+ };
313
+ const timeout = setTimeout(() => {
314
+ requestStop(`media command timed out after ${specification.timeoutMs}ms`);
315
+ }, specification.timeoutMs);
316
+ const monitor = setInterval(() => {
317
+ const violation = inspectMonitoredDirectory(
318
+ specification.monitoredDirectory,
319
+ specification.maxFiles,
320
+ specification.maxFileBytes,
321
+ specification.maxTotalBytes,
322
+ );
323
+ if (violation !== null) requestStop(violation);
324
+ }, 100);
325
+ try {
326
+ const [stdout, stderr, exitCode] = await Promise.all([
327
+ readBoundedStream(child.stdout, specification.maxOutputBytes),
328
+ readBoundedStream(child.stderr, specification.maxOutputBytes),
329
+ exited,
330
+ ]);
331
+ const finalViolation = inspectMonitoredDirectory(
332
+ specification.monitoredDirectory,
333
+ specification.maxFiles,
334
+ specification.maxFileBytes,
335
+ specification.maxTotalBytes,
336
+ );
337
+ if (failure !== null) throw new Error(failure);
338
+ if (finalViolation !== null) throw new Error(finalViolation);
339
+ return { stdout, stderr, exitCode };
340
+ } catch (error) {
341
+ if (failure === null) requestStop(error instanceof Error ? error.message : "media command failed");
342
+ await exited.catch(() => 1);
343
+ throw error;
344
+ } finally {
345
+ clearTimeout(timeout);
346
+ clearInterval(monitor);
347
+ if (forceKillTimer !== null) clearTimeout(forceKillTimer);
348
+ }
349
+ };
350
+
351
+ export function discoverYtDlp(options: {
352
+ readonly homeDirectory?: string;
353
+ readonly exists?: (path: string) => boolean;
354
+ readonly which?: (executable: string) => string | null;
355
+ } = {}): string | null {
356
+ const exists = options.exists ?? existsSync;
357
+ const which = options.which ?? ((name: string) => Bun.which(name));
358
+ const fromPath = which("yt-dlp");
359
+ if (fromPath !== null && exists(fromPath)) return fromPath;
360
+ const homeDirectory = options.homeDirectory ?? homedir();
361
+ return [
362
+ join(homeDirectory, ".local", "bin", "yt-dlp"),
363
+ "/opt/homebrew/bin/yt-dlp",
364
+ "/usr/local/bin/yt-dlp",
365
+ ].find((path) => exists(path)) ?? null;
366
+ }
367
+
368
+ function readInstalledNodeVersion(executable: string): string | null {
369
+ try {
370
+ const result = spawnSync(executable, ["--version"], {
371
+ encoding: "utf8",
372
+ timeout: 1_000,
373
+ maxBuffer: 4_096,
374
+ windowsHide: true,
375
+ });
376
+ if (result.status !== 0 || typeof result.stdout !== "string") return null;
377
+ return result.stdout.trim();
378
+ } catch {
379
+ return null;
380
+ }
381
+ }
382
+
383
+ function supportsYtDlpNodeRuntime(version: string | null): boolean {
384
+ if (version === null) return false;
385
+ const match = /^v?(\d+)(?:\.|$)/.exec(version.trim());
386
+ if (match?.[1] === undefined) return false;
387
+ const major = Number(match[1]);
388
+ return Number.isSafeInteger(major) && major >= 22;
389
+ }
390
+
391
+ /** Discover a Node.js >=22 runtime that yt-dlp can use for supported JavaScript challenges. */
392
+ export function discoverNodeRuntime(options: {
393
+ readonly homeDirectory?: string;
394
+ readonly exists?: (path: string) => boolean;
395
+ readonly which?: (executable: string) => string | null;
396
+ readonly readVersion?: (path: string) => string | null;
397
+ } = {}): string | null {
398
+ const exists = options.exists ?? existsSync;
399
+ const which = options.which ?? ((name: string) => Bun.which(name));
400
+ const readVersion = options.readVersion ?? readInstalledNodeVersion;
401
+ const homeDirectory = options.homeDirectory ?? homedir();
402
+ const candidates = [
403
+ which("node"),
404
+ join(homeDirectory, ".local", "bin", "node"),
405
+ "/opt/homebrew/bin/node",
406
+ "/usr/local/bin/node",
407
+ "/usr/bin/node",
408
+ ];
409
+ const seen = new Set<string>();
410
+ for (const candidate of candidates) {
411
+ if (candidate === null || seen.has(candidate)) continue;
412
+ seen.add(candidate);
413
+ if (exists(candidate) && supportsYtDlpNodeRuntime(readVersion(candidate))) return candidate;
414
+ }
415
+ return null;
416
+ }
417
+
418
+ function cleanString(value: unknown, maximumLength: number): string | undefined {
419
+ if (typeof value !== "string") return undefined;
420
+ const cleaned = value.replace(/\0/g, "").trim();
421
+ if (cleaned === "" || cleaned.length > maximumLength) return undefined;
422
+ return cleaned;
423
+ }
424
+
425
+ function finiteNonNegative(value: unknown): number | undefined {
426
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
427
+ }
428
+
429
+ function safeWebUrl(value: unknown): string | undefined {
430
+ const candidate = cleanString(value, 8_192);
431
+ if (candidate === undefined) return undefined;
432
+ try {
433
+ const url = new URL(candidate);
434
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username !== "" || url.password !== "") {
435
+ return undefined;
436
+ }
437
+ return url.href;
438
+ } catch {
439
+ return undefined;
440
+ }
441
+ }
442
+
443
+ /** Parse only the explicitly printed metadata allowlist; format URLs and cookie material are never accepted. */
444
+ export function parseMediaMetadata(stdout: string): MediaMetadata | null {
445
+ const scanStart = Math.max(0, stdout.length - 2 * 1024 * 1024);
446
+ let lineEnd = stdout.length;
447
+ while (lineEnd >= scanStart) {
448
+ const newline = stdout.lastIndexOf("\n", lineEnd - 1);
449
+ const lineStart = Math.max(scanStart, newline + 1);
450
+ const rawLine = stdout.slice(lineStart, lineEnd);
451
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
452
+ lineEnd = newline < scanStart ? scanStart - 1 : newline;
453
+ if (!line.startsWith(metadataPrefix)) continue;
454
+ try {
455
+ const parsed: unknown = JSON.parse(line.slice(metadataPrefix.length));
456
+ if (!isRecord(parsed)) continue;
457
+ const id = cleanString(parsed.id, 512);
458
+ const title = cleanString(parsed.title, 8_192);
459
+ const description = cleanString(parsed.description, 500_000);
460
+ const uploader = cleanString(parsed.uploader, 8_192);
461
+ const uploaderId = cleanString(parsed.uploader_id, 8_192);
462
+ const channel = cleanString(parsed.channel, 8_192);
463
+ const channelId = cleanString(parsed.channel_id, 8_192);
464
+ const webpageUrl = safeWebUrl(parsed.webpage_url);
465
+ const extractor = cleanString(parsed.extractor, 1_024);
466
+ const durationSeconds = finiteNonNegative(parsed.duration);
467
+ const timestamp = finiteNonNegative(parsed.timestamp);
468
+ return {
469
+ ...(id === undefined ? {} : { id }),
470
+ ...(title === undefined ? {} : { title }),
471
+ ...(description === undefined ? {} : { description }),
472
+ ...(uploader === undefined ? {} : { uploader }),
473
+ ...(uploaderId === undefined ? {} : { uploaderId }),
474
+ ...(channel === undefined ? {} : { channel }),
475
+ ...(channelId === undefined ? {} : { channelId }),
476
+ ...(webpageUrl === undefined ? {} : { webpageUrl }),
477
+ ...(extractor === undefined ? {} : { extractor }),
478
+ ...(durationSeconds === undefined ? {} : { durationSeconds }),
479
+ ...(timestamp === undefined ? {} : { timestamp }),
480
+ };
481
+ } catch {
482
+ // Ignore malformed or truncated diagnostic lines.
483
+ }
484
+ }
485
+ return null;
486
+ }
487
+
488
+ function validProfile(profile: string | undefined): boolean {
489
+ return profile === undefined || (profile.trim() !== "" && profile.length <= 4_096 && !/\p{Cc}/u.test(profile));
490
+ }
491
+
492
+ export function buildMediaCookieOptions(request: BrowserMediaCookieRequest): GetCookiesOptions {
493
+ const common = {
494
+ url: request.url.href,
495
+ timeoutMs: request.timeoutMs,
496
+ mode: "first",
497
+ debug: false,
498
+ } as const;
499
+ const profile = request.profile?.trim();
500
+ if (request.source === "edge") {
501
+ return {
502
+ ...common,
503
+ browsers: ["edge"],
504
+ edgeProfile: profile ?? "",
505
+ };
506
+ }
507
+ if (request.source === "firefox") {
508
+ return {
509
+ ...common,
510
+ browsers: ["firefox"],
511
+ firefoxProfile: profile ?? "",
512
+ };
513
+ }
514
+ if (request.source === "safari") {
515
+ return {
516
+ ...common,
517
+ browsers: ["safari"],
518
+ ...(profile === undefined ? {} : { safariCookiesFile: profile }),
519
+ };
520
+ }
521
+ return {
522
+ ...common,
523
+ browsers: ["chrome"],
524
+ chromiumBrowser: request.source,
525
+ chromeProfile: profile ?? "",
526
+ };
527
+ }
528
+
529
+ type CookieJarResult =
530
+ | { readonly ok: true; readonly path: string; readonly warnings: readonly string[] }
531
+ | { readonly ok: false; readonly warning: string };
532
+
533
+ /** Build an injectable provider. Explicit files bypass Sweet Cookie and can never probe browser stores. */
534
+ export function createMediaCookieProvider(reader: SweetCookieReader): MediaCookieProvider {
535
+ return (request) => {
536
+ if (request.source !== "file") return reader(buildMediaCookieOptions(request));
537
+ const parsed = readCookieFile(request.file, request.url);
538
+ return Promise.resolve(parsed.ok
539
+ ? {
540
+ cookies: parsed.cookies,
541
+ warnings: parsed.rejected === 0
542
+ ? []
543
+ : [`Ignored ${parsed.rejected} malformed, expired, or out-of-scope cookie record(s).`],
544
+ }
545
+ : { cookies: [], warnings: [] });
546
+ };
547
+ }
548
+
549
+ /** Resolve only cookies matching the requested URL through Sweet Cookie's selected local source. */
550
+ export const readMediaCookies = createMediaCookieProvider((options) => getCookies(options));
551
+
552
+ async function prepareCookieJar(
553
+ request: MediaCookieRequest,
554
+ directory: string,
555
+ provider: MediaCookieProvider,
556
+ ): Promise<CookieJarResult> {
557
+ let provided: unknown;
558
+ try {
559
+ provided = await provider(request);
560
+ } catch {
561
+ return { ok: false, warning: "Could not read cookies from the explicitly selected browser." };
562
+ }
563
+ const filtered = filterCookieProviderResult(provided, request.url);
564
+ if (!filtered.validShape) {
565
+ return { ok: false, warning: "The selected browser cookie provider returned malformed data." };
566
+ }
567
+ if (filtered.cookies.length === 0) {
568
+ return {
569
+ ok: false,
570
+ warning: filtered.rejected === 0
571
+ ? "No origin-scoped cookies were found in the explicitly selected browser."
572
+ : `No usable origin-scoped cookies were found; rejected ${filtered.rejected} malformed, expired, or out-of-scope record(s).`,
573
+ };
574
+ }
575
+
576
+ const body = renderNetscapeCookieJar(filtered.cookies, request.url);
577
+ if (Buffer.byteLength(body, "utf8") > MAX_COOKIE_BYTES) {
578
+ return { ok: false, warning: "Origin-scoped browser cookies exceeded the private jar size limit." };
579
+ }
580
+ const path = join(directory, "cookies.txt");
581
+ try {
582
+ writeFileSync(path, body, { encoding: "utf8", flag: "wx", mode: 0o600 });
583
+ chmodSync(path, 0o600);
584
+ } catch {
585
+ return { ok: false, warning: "Could not create the private temporary cookie jar." };
586
+ }
587
+ const warnings: string[] = [];
588
+ if (filtered.rejected > 0) {
589
+ warnings.push(`Ignored ${filtered.rejected} malformed, expired, or out-of-scope browser cookie record(s).`);
590
+ }
591
+ if (filtered.providerWarningCount > 0) {
592
+ warnings.push(`The browser cookie provider reported ${filtered.providerWarningCount} non-fatal warning(s).`);
593
+ }
594
+ return { ok: true, path, warnings };
595
+ }
596
+
597
+ function metadataTemplate(): string {
598
+ return `${metadataPrefix}{"id":%(id)j,"title":%(title)j,"description":%(description)j,` +
599
+ `"uploader":%(uploader)j,"uploader_id":%(uploader_id)j,"webpage_url":%(webpage_url)j,` +
600
+ `"channel":%(channel)j,"channel_id":%(channel_id)j,"extractor":%(extractor)j,` +
601
+ `"duration":%(duration)j,"timestamp":%(timestamp)j}`;
602
+ }
603
+
604
+ function commandArguments(
605
+ executable: string,
606
+ options: CaptureMediaOptions,
607
+ cookieFile: string | undefined,
608
+ proxyUrl: string,
609
+ ): readonly string[] {
610
+ const arguments_: string[] = [
611
+ executable,
612
+ "--ignore-config",
613
+ "--no-playlist",
614
+ "--max-downloads",
615
+ "1",
616
+ "--max-filesize",
617
+ String(options.maxFileBytes),
618
+ "--restrict-filenames",
619
+ "--no-overwrites",
620
+ "--no-progress",
621
+ "--newline",
622
+ "--no-colors",
623
+ "--socket-timeout",
624
+ String(Math.max(1, Math.ceil(options.timeoutMs / 1_000))),
625
+ "--retries",
626
+ "2",
627
+ "--fragment-retries",
628
+ "2",
629
+ "--proxy",
630
+ proxyUrl,
631
+ "--batch-file",
632
+ "-",
633
+ "--downloader",
634
+ "native",
635
+ "--output",
636
+ "media-%(id).80B.%(ext)s",
637
+ "--print",
638
+ `after_move:${metadataTemplate()}`,
639
+ ];
640
+ if (options.userAgent !== undefined) arguments_.push("--user-agent", options.userAgent);
641
+ if (cookieFile !== undefined) arguments_.push("--cookies", cookieFile);
642
+ return arguments_;
643
+ }
644
+
645
+ function videoContextCommandArguments(
646
+ executable: string,
647
+ options: CaptureVideoContextOptions,
648
+ language: string,
649
+ cookieFile: string | undefined,
650
+ proxyUrl: string,
651
+ nodeRuntime: string | null,
652
+ ): readonly string[] {
653
+ const arguments_: string[] = [
654
+ executable,
655
+ "--ignore-config",
656
+ "--no-playlist",
657
+ "--max-downloads",
658
+ "1",
659
+ "--max-filesize",
660
+ String(options.maxFileBytes),
661
+ "--restrict-filenames",
662
+ "--no-overwrites",
663
+ "--no-progress",
664
+ "--newline",
665
+ "--no-colors",
666
+ "--socket-timeout",
667
+ String(Math.max(1, Math.ceil(options.timeoutMs / 1_000))),
668
+ "--retries",
669
+ "2",
670
+ "--fragment-retries",
671
+ "2",
672
+ "--proxy",
673
+ proxyUrl,
674
+ "--batch-file",
675
+ "-",
676
+ "--skip-download",
677
+ "--no-simulate",
678
+ "--ignore-no-formats-error",
679
+ "--write-thumbnail",
680
+ "--write-subs",
681
+ "--write-auto-subs",
682
+ "--sub-langs",
683
+ language,
684
+ "--sub-format",
685
+ "vtt",
686
+ "--output",
687
+ "unused-%(id).80B.%(ext)s",
688
+ "--output",
689
+ "thumbnail:thumbnail-%(id).80B.%(ext)s",
690
+ "--output",
691
+ "subtitle:transcript-%(id).80B.%(language).16B.%(ext)s",
692
+ "--print",
693
+ metadataTemplate(),
694
+ ];
695
+ if (nodeRuntime !== null) arguments_.push("--js-runtimes", `node:${nodeRuntime}`);
696
+ if (options.userAgent !== undefined) arguments_.push("--user-agent", options.userAgent);
697
+ if (cookieFile !== undefined) arguments_.push("--cookies", cookieFile);
698
+ return arguments_;
699
+ }
700
+
701
+ function privateMediaUrlInput(url: URL): string {
702
+ const value = url.href;
703
+ if (/[\0\r\n]/.test(value)) throw new Error("media URL contains an invalid batch-file control character");
704
+ return `${value}\n`;
705
+ }
706
+
707
+ function errorClassification(stderr: string): { readonly status: MediaCaptureStatus; readonly warning: string } {
708
+ const normalized = stderr.toLowerCase();
709
+ if (normalized.includes("unsupported url") || normalized.includes("no suitable extractor")) {
710
+ return { status: "unsupported", warning: "yt-dlp does not support media capture for this URL." };
711
+ }
712
+ if (normalized.includes("drm")) {
713
+ return { status: "unsupported", warning: "The media is DRM-protected; clip does not bypass DRM or access controls." };
714
+ }
715
+ if (normalized.includes("login") || normalized.includes("sign in") || normalized.includes("cookies")) {
716
+ return { status: "failed", warning: "The site requires an authorized session; explicitly select a cookie source or cookie file." };
717
+ }
718
+ if (normalized.includes("requested format is not available") || normalized.includes("no video formats found")) {
719
+ return { status: "unsupported", warning: "No downloadable, non-DRM media format was exposed for this page." };
720
+ }
721
+ return { status: "failed", warning: "yt-dlp could not capture media for this page; page text and images can still be clipped." };
722
+ }
723
+
724
+ function safeRunnerFailure(message: string): string {
725
+ const safePatterns = [
726
+ /^media command timed out after \d+ms$/,
727
+ /^media command output exceeded \d+ bytes$/,
728
+ /^media capture created more than \d+ files$/,
729
+ /^media capture created a file larger than \d+ bytes$/,
730
+ /^media capture exceeded the \d+-byte total limit$/,
731
+ ];
732
+ return safePatterns.some((pattern) => pattern.test(message))
733
+ ? message
734
+ : "yt-dlp media capture failed; page text and images can still be clipped.";
735
+ }
736
+
737
+ function positiveBound(value: number | undefined, fallback: number, maximum: number): number {
738
+ return value === undefined || !Number.isSafeInteger(value) || value < 1
739
+ ? fallback
740
+ : Math.min(value, maximum);
741
+ }
742
+
743
+ function utf8Prefix(value: string, maxBytes: number): string {
744
+ const bytes = Buffer.from(value);
745
+ if (bytes.byteLength <= maxBytes) return value;
746
+ return bytes.subarray(0, maxBytes).toString("utf8").replace(/\ufffd$/u, "");
747
+ }
748
+
749
+ function decodeVttEntities(value: string): string {
750
+ return value
751
+ .replace(/&nbsp;/gi, " ")
752
+ .replace(/&amp;/gi, "&")
753
+ .replace(/&lt;/gi, "<")
754
+ .replace(/&gt;/gi, ">")
755
+ .replace(/&quot;/gi, '"')
756
+ .replace(/&#39;|&apos;/gi, "'")
757
+ .replace(/&#(?:x([0-9a-f]{1,6})|(\d{1,7}));/gi, (_match, hexadecimal: string | undefined, decimal: string | undefined) => {
758
+ const codePoint = Number.parseInt(hexadecimal ?? decimal ?? "", hexadecimal === undefined ? 10 : 16);
759
+ return Number.isSafeInteger(codePoint)
760
+ && codePoint > 0
761
+ && codePoint <= 0x10ffff
762
+ && !(codePoint >= 0xd800 && codePoint <= 0xdfff)
763
+ ? String.fromCodePoint(codePoint)
764
+ : "";
765
+ });
766
+ }
767
+
768
+ function cleanVttCue(value: string): string {
769
+ return decodeVttEntities(value.replace(/<[^>\n]{0,512}>/g, ""))
770
+ .replace(/\s+/g, " ")
771
+ .trim()
772
+ .slice(0, 16_384);
773
+ }
774
+
775
+ function escapeTranscriptMarkdown(value: string): string {
776
+ return value
777
+ .replace(/\\/g, "\\\\")
778
+ .replace(/([`*_~[\]])/g, "\\$1")
779
+ .replace(/</g, "&lt;")
780
+ .replace(/>/g, "&gt;");
781
+ }
782
+
783
+ function vttTimestamp(value: string): number | null {
784
+ const timestamp = value.trim().split(/\s+/u)[0];
785
+ if (timestamp === undefined) return null;
786
+ const clock = timestamp.replace(",", ".").split(".");
787
+ if (clock.length !== 2 || clock[1] === undefined || !/^\d{3}$/.test(clock[1])) return null;
788
+ const pieces = clock[0]?.split(":") ?? [];
789
+ if (pieces.length !== 2 && pieces.length !== 3) return null;
790
+ if (pieces.some((piece) => !/^\d{2}$/.test(piece))) return null;
791
+ const seconds = Number(pieces.at(-1));
792
+ const minutes = Number(pieces.at(-2));
793
+ const hours = pieces.length === 3 ? Number(pieces[0]) : 0;
794
+ if (
795
+ !Number.isSafeInteger(hours)
796
+ || !Number.isSafeInteger(minutes)
797
+ || !Number.isSafeInteger(seconds)
798
+ || minutes > 59
799
+ || seconds > 59
800
+ ) return null;
801
+ return hours * 3_600 + minutes * 60 + seconds;
802
+ }
803
+
804
+ function formatTranscriptTimestamp(seconds: number): string {
805
+ const hours = Math.floor(seconds / 3_600);
806
+ const minutes = Math.floor((seconds % 3_600) / 60);
807
+ const remainingSeconds = seconds % 60;
808
+ return hours > 0
809
+ ? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
810
+ : `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`;
811
+ }
812
+
813
+ function rollingCaptionDelta(previous: string | null, current: string): string | null {
814
+ if (previous === null) return current;
815
+ if (previous === current || previous.includes(current)) return null;
816
+ const previousWords = previous.split(" ");
817
+ const currentWords = current.split(" ");
818
+ const maximumOverlap = Math.min(previousWords.length, currentWords.length, 256);
819
+ for (let overlap = maximumOverlap; overlap > 0; overlap -= 1) {
820
+ let matches = true;
821
+ for (let index = 0; index < overlap; index += 1) {
822
+ if (previousWords[previousWords.length - overlap + index] !== currentWords[index]) {
823
+ matches = false;
824
+ break;
825
+ }
826
+ }
827
+ if (matches) {
828
+ const delta = currentWords.slice(overlap).join(" ").trim();
829
+ return delta === "" ? null : delta;
830
+ }
831
+ }
832
+ return current;
833
+ }
834
+
835
+ /** Parse bounded WebVTT into timestamped Markdown while removing rolling-caption overlap. */
836
+ export function parseWebVtt(input: string, options: ParseWebVttOptions = {}): ParsedWebVtt {
837
+ const maxInputBytes = positiveBound(options.maxInputBytes, 8 * 1024 * 1024, 32 * 1024 * 1024);
838
+ const maxCues = positiveBound(options.maxCues, 20_000, 100_000);
839
+ const maxOutputBytes = positiveBound(options.maxOutputBytes, 8 * 1024 * 1024, 32 * 1024 * 1024);
840
+ const inputWasTruncated = Buffer.byteLength(input) > maxInputBytes;
841
+ const boundedInput = inputWasTruncated ? utf8Prefix(input, maxInputBytes) : input;
842
+ const lines = boundedInput.replace(/\r\n?/g, "\n").split("\n");
843
+ const markdown: string[] = [];
844
+ let outputBytes = 0;
845
+ let previousCue: string | null = null;
846
+ let truncated = inputWasTruncated;
847
+
848
+ for (let index = 0; index < lines.length;) {
849
+ const timing = lines[index] ?? "";
850
+ const arrow = timing.indexOf("-->");
851
+ if (arrow < 0) {
852
+ index += 1;
853
+ continue;
854
+ }
855
+ const seconds = vttTimestamp(timing.slice(0, arrow));
856
+ index += 1;
857
+ const cueLines: string[] = [];
858
+ while (index < lines.length && (lines[index] ?? "").trim() !== "") {
859
+ if ((lines[index] ?? "").includes("-->")) break;
860
+ cueLines.push(lines[index] ?? "");
861
+ index += 1;
862
+ }
863
+ if (seconds === null) continue;
864
+ const current = cleanVttCue(cueLines.join(" "));
865
+ if (current === "") continue;
866
+ const delta = rollingCaptionDelta(previousCue, current);
867
+ previousCue = current;
868
+ if (delta === null) continue;
869
+ if (markdown.length >= maxCues) {
870
+ truncated = true;
871
+ break;
872
+ }
873
+ const line = `- [${formatTranscriptTimestamp(seconds)}] ${escapeTranscriptMarkdown(delta)}`;
874
+ const lineBytes = Buffer.byteLength(line) + (markdown.length === 0 ? 0 : 1);
875
+ if (outputBytes + lineBytes > maxOutputBytes) {
876
+ truncated = true;
877
+ break;
878
+ }
879
+ markdown.push(line);
880
+ outputBytes += lineBytes;
881
+ }
882
+
883
+ return {
884
+ markdown: markdown.length === 0 ? "" : `${markdown.join("\n")}\n`,
885
+ cueCount: markdown.length,
886
+ truncated,
887
+ };
888
+ }
889
+
890
+ function sha256(bytes: Uint8Array): string {
891
+ return createHash("sha256").update(bytes).digest("hex");
892
+ }
893
+
894
+ function normalizePrefix(value: string | undefined): string {
895
+ if (value === undefined || value === "") return "media";
896
+ const pieces = value.split("/").filter((piece) => piece !== "" && piece !== ".");
897
+ if (pieces.length === 0 || pieces.some((piece) => piece === ".." || /[\\\0]/.test(piece))) return "media";
898
+ return pieces.join("/");
899
+ }
900
+
901
+ function promoteMediaFiles(
902
+ runDirectory: string,
903
+ outputDirectory: string,
904
+ relativePrefix: string,
905
+ maxFiles: number,
906
+ maxFileBytes: number,
907
+ maxTotalBytes: number,
908
+ ): { readonly records: readonly MediaRecord[]; readonly warnings: readonly string[] } {
909
+ const violation = inspectMonitoredDirectory(runDirectory, maxFiles, maxFileBytes, maxTotalBytes);
910
+ if (violation !== null) return { records: [], warnings: [violation] };
911
+ const recordsByHash = new Map<string, MediaRecord>();
912
+ const warnings: string[] = [];
913
+ for (const entry of readdirSync(runDirectory, { withFileTypes: true })) {
914
+ if (!entry.isFile()) continue;
915
+ const extension = extname(entry.name).toLowerCase();
916
+ const source = join(runDirectory, entry.name);
917
+ const stats = statSync(source);
918
+ if (!stats.isFile() || stats.size > maxFileBytes) {
919
+ warnings.push(`Ignored invalid or oversized yt-dlp output ${basename(entry.name)}.`);
920
+ continue;
921
+ }
922
+ const bytes = readFileSync(source);
923
+ const mimeType = sniffMediaMimeType(bytes, extension);
924
+ if (mimeType === null) {
925
+ warnings.push(`Ignored unrecognized or mislabeled yt-dlp output ${basename(entry.name)}.`);
926
+ continue;
927
+ }
928
+ const digest = sha256(bytes);
929
+ const filename = `${digest}${extension}`;
930
+ const destination = join(outputDirectory, filename);
931
+ if (existsSync(destination)) {
932
+ const destinationStats = lstatSync(destination);
933
+ if (!destinationStats.isFile() || destinationStats.isSymbolicLink()) {
934
+ warnings.push(`Refused unsafe existing media destination ${filename}.`);
935
+ continue;
936
+ }
937
+ const destinationDigest = sha256(readFileSync(destination));
938
+ if (destinationDigest !== digest) {
939
+ warnings.push(`Refused conflicting existing media destination ${filename}.`);
940
+ continue;
941
+ }
942
+ unlinkSync(source);
943
+ } else renameSync(source, destination);
944
+ if (!recordsByHash.has(digest)) {
945
+ recordsByHash.set(digest, {
946
+ path: `${relativePrefix}/${filename}`,
947
+ mimeType,
948
+ bytes: stats.size,
949
+ sha256: digest,
950
+ });
951
+ }
952
+ }
953
+ return {
954
+ records: [...recordsByHash.values()].sort((left, right) => left.path.localeCompare(right.path)),
955
+ warnings,
956
+ };
957
+ }
958
+
959
+ function promoteVideoContextFiles(
960
+ runDirectory: string,
961
+ outputDirectory: string,
962
+ relativePrefix: string,
963
+ language: string,
964
+ maxFiles: number,
965
+ maxFileBytes: number,
966
+ maxTotalBytes: number,
967
+ ): {
968
+ readonly thumbnail: MediaRecord | null;
969
+ readonly transcript: VideoTranscript | null;
970
+ readonly warnings: readonly string[];
971
+ } {
972
+ const violation = inspectMonitoredDirectory(runDirectory, maxFiles, maxFileBytes, maxTotalBytes);
973
+ if (violation !== null) return { thumbnail: null, transcript: null, warnings: [violation] };
974
+ const warnings: string[] = [];
975
+ let thumbnailCandidate: {
976
+ readonly source: string;
977
+ readonly bytes: Uint8Array;
978
+ readonly mimeType: string;
979
+ readonly extension: string;
980
+ } | null = null;
981
+ let transcriptCandidate: ParsedWebVtt | null = null;
982
+
983
+ const entries = readdirSync(runDirectory, { withFileTypes: true })
984
+ .filter((entry) => entry.isFile())
985
+ .sort((left, right) => left.name.localeCompare(right.name));
986
+ for (const entry of entries) {
987
+ const source = join(runDirectory, entry.name);
988
+ const stats = statSync(source);
989
+ if (!stats.isFile() || stats.size > maxFileBytes) {
990
+ warnings.push("Ignored an invalid or oversized yt-dlp sidecar.");
991
+ continue;
992
+ }
993
+ const extension = extname(entry.name).toLowerCase();
994
+ if (extension === ".vtt") {
995
+ const contents = readFileSync(source, "utf8");
996
+ unlinkSync(source);
997
+ const parsed = parseWebVtt(contents, {
998
+ maxInputBytes: Math.min(maxFileBytes, 8 * 1024 * 1024),
999
+ maxOutputBytes: Math.min(maxFileBytes, 8 * 1024 * 1024),
1000
+ });
1001
+ if (parsed.cueCount > 0 && (transcriptCandidate === null || parsed.cueCount > transcriptCandidate.cueCount)) {
1002
+ transcriptCandidate = parsed;
1003
+ }
1004
+ continue;
1005
+ }
1006
+ const bytes = readFileSync(source);
1007
+ const image = sniffImage(bytes);
1008
+ if (image === null) {
1009
+ warnings.push("Ignored an unrecognized yt-dlp sidecar.");
1010
+ continue;
1011
+ }
1012
+ if (thumbnailCandidate === null || bytes.byteLength > thumbnailCandidate.bytes.byteLength) {
1013
+ thumbnailCandidate = {
1014
+ source,
1015
+ bytes,
1016
+ mimeType: image.mimeType,
1017
+ extension: `.${image.extension}`,
1018
+ };
1019
+ }
1020
+ }
1021
+
1022
+ let thumbnail: MediaRecord | null = null;
1023
+ if (thumbnailCandidate !== null) {
1024
+ const digest = sha256(thumbnailCandidate.bytes);
1025
+ const filename = `${digest}${thumbnailCandidate.extension}`;
1026
+ const destination = join(outputDirectory, filename);
1027
+ let promoted = false;
1028
+ if (existsSync(destination)) {
1029
+ const destinationStats = lstatSync(destination);
1030
+ if (!destinationStats.isFile() || destinationStats.isSymbolicLink()) {
1031
+ warnings.push("Refused an unsafe existing video-thumbnail destination.");
1032
+ } else if (sha256(readFileSync(destination)) !== digest) {
1033
+ warnings.push("Refused a conflicting existing video-thumbnail destination.");
1034
+ } else {
1035
+ unlinkSync(thumbnailCandidate.source);
1036
+ promoted = true;
1037
+ }
1038
+ } else {
1039
+ renameSync(thumbnailCandidate.source, destination);
1040
+ promoted = true;
1041
+ }
1042
+ if (promoted) {
1043
+ thumbnail = {
1044
+ path: `${relativePrefix}/${filename}`,
1045
+ mimeType: thumbnailCandidate.mimeType,
1046
+ bytes: thumbnailCandidate.bytes.byteLength,
1047
+ sha256: digest,
1048
+ };
1049
+ }
1050
+ }
1051
+
1052
+ if (transcriptCandidate?.truncated === true) {
1053
+ warnings.push("The video transcript reached its bounded parse limit and was truncated.");
1054
+ }
1055
+ return {
1056
+ thumbnail,
1057
+ transcript: transcriptCandidate === null
1058
+ ? null
1059
+ : {
1060
+ language,
1061
+ markdown: transcriptCandidate.markdown,
1062
+ cueCount: transcriptCandidate.cueCount,
1063
+ truncated: transcriptCandidate.truncated,
1064
+ },
1065
+ warnings,
1066
+ };
1067
+ }
1068
+
1069
+ function validateOptions(options: CaptureMediaOptions): string | null {
1070
+ if (options.url.protocol !== "http:" && options.url.protocol !== "https:") return "Media URL must use HTTP or HTTPS.";
1071
+ if (options.url.username !== "" || options.url.password !== "") return "Media URL must not contain credentials.";
1072
+ if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1) return "Media timeout must be a positive integer.";
1073
+ if (!Number.isSafeInteger(options.maxFileBytes) || options.maxFileBytes < 1) return "Per-file media limit must be positive.";
1074
+ if (!Number.isSafeInteger(options.maxTotalBytes) || options.maxTotalBytes < options.maxFileBytes) {
1075
+ return "Total media limit must be at least the per-file limit.";
1076
+ }
1077
+ if (options.cookiesFile !== undefined) {
1078
+ try {
1079
+ const cookieFileStats = lstatSync(resolve(options.cookiesFile));
1080
+ if (!cookieFileStats.isFile()) return "The explicitly selected cookie file is not a regular file.";
1081
+ if (cookieFileStats.size > MAX_COOKIE_BYTES) return "The explicitly selected cookie file exceeds the 2mb input limit.";
1082
+ } catch {
1083
+ return "The explicitly selected cookie file is unavailable.";
1084
+ }
1085
+ }
1086
+ if (options.cookieBrowser !== undefined && !validProfile(options.cookieBrowser.profile)) {
1087
+ return "The explicitly selected browser cookie profile is invalid.";
1088
+ }
1089
+ return null;
1090
+ }
1091
+
1092
+ /**
1093
+ * Capture accessible audio/video with yt-dlp. This is opt-in, never bypasses DRM or access controls,
1094
+ * and converts all operational failures into an honest status plus warnings.
1095
+ */
1096
+ export async function captureMedia(options: CaptureMediaOptions): Promise<MediaCaptureResult> {
1097
+ const validation = validateOptions(options);
1098
+ if (validation !== null) return { status: "failed", records: [], metadata: null, warnings: [validation] };
1099
+ const exists = options.exists ?? existsSync;
1100
+ const executable = options.executable ?? discoverYtDlp({
1101
+ ...(options.homeDirectory === undefined ? {} : { homeDirectory: options.homeDirectory }),
1102
+ exists,
1103
+ ...(options.which === undefined ? {} : { which: options.which }),
1104
+ });
1105
+ if (executable === null || !exists(executable)) {
1106
+ return {
1107
+ status: "unavailable",
1108
+ records: [],
1109
+ metadata: null,
1110
+ warnings: ["yt-dlp is not installed; skipped optional audio/video capture."],
1111
+ };
1112
+ }
1113
+
1114
+ const maxFiles = Math.max(1, Math.min(options.maxFiles ?? 12, 100));
1115
+ const maxOutputBytes = Math.max(4_096, Math.min(options.maxOutputBytes ?? 2 * 1024 * 1024, 16 * 1024 * 1024));
1116
+ const outputDirectory = resolve(options.outputDirectory);
1117
+ try {
1118
+ mkdirSync(outputDirectory, { recursive: true, mode: 0o755 });
1119
+ const outputStats = lstatSync(outputDirectory);
1120
+ if (!outputStats.isDirectory() || outputStats.isSymbolicLink()) {
1121
+ return { status: "failed", records: [], metadata: null, warnings: ["Media destination must be a real directory, not a symlink."] };
1122
+ }
1123
+ } catch {
1124
+ return { status: "failed", records: [], metadata: null, warnings: ["Could not create the media destination directory."] };
1125
+ }
1126
+
1127
+ const realOutputDirectory = realpathSync(outputDirectory);
1128
+ const runDirectory = mkdtempSync(join(realOutputDirectory, ".clip-media-"));
1129
+ let authDirectory: string | null = null;
1130
+ let networkProxy: LocalNetworkProxy | null = null;
1131
+ const run = options.run ?? runMediaCommand;
1132
+ const authenticationWarnings: string[] = [];
1133
+ try {
1134
+ let cookieFile: string | undefined;
1135
+ let cookieRequest: MediaCookieRequest | undefined;
1136
+ if (options.cookiesFile !== undefined) {
1137
+ cookieRequest = {
1138
+ url: options.url,
1139
+ source: "file",
1140
+ file: resolve(options.cookiesFile),
1141
+ timeoutMs: options.timeoutMs,
1142
+ };
1143
+ if (options.cookieBrowser !== undefined) {
1144
+ authenticationWarnings.push("The explicit cookie file took precedence over the selected browser cookie source.");
1145
+ }
1146
+ } else if (options.cookieBrowser !== undefined) {
1147
+ cookieRequest = {
1148
+ url: options.url,
1149
+ source: options.cookieBrowser.source,
1150
+ timeoutMs: options.timeoutMs,
1151
+ ...(options.cookieBrowser.profile === undefined ? {} : { profile: options.cookieBrowser.profile }),
1152
+ };
1153
+ }
1154
+ if (cookieRequest !== undefined) {
1155
+ // Credential material must never enter the repository/output staging tree, even transiently.
1156
+ authDirectory = mkdtempSync(join(tmpdir(), "hraness-kb-auth-"));
1157
+ chmodSync(authDirectory, 0o700);
1158
+ const prepared = await prepareCookieJar(cookieRequest, authDirectory, options.cookieProvider ?? readMediaCookies);
1159
+ if (!prepared.ok) {
1160
+ return { status: "failed", records: [], metadata: null, warnings: [prepared.warning] };
1161
+ }
1162
+ cookieFile = prepared.path;
1163
+ authenticationWarnings.push(...prepared.warnings);
1164
+ }
1165
+
1166
+ networkProxy = await (options.startProxy ?? startNetworkProxy)({
1167
+ allowPrivateNetwork: options.allowPrivateNetwork ?? false,
1168
+ timeoutMs: options.timeoutMs,
1169
+ maxTransferredBytes: Math.max(
1170
+ 64 * 1024 * 1024,
1171
+ Math.min(Number.MAX_SAFE_INTEGER, options.maxTotalBytes * 3),
1172
+ ),
1173
+ });
1174
+
1175
+ const result = await run({
1176
+ command: commandArguments(executable, options, cookieFile, networkProxy.url),
1177
+ stdin: privateMediaUrlInput(options.url),
1178
+ cwd: runDirectory,
1179
+ timeoutMs: options.timeoutMs,
1180
+ maxOutputBytes,
1181
+ monitoredDirectory: runDirectory,
1182
+ maxFiles,
1183
+ maxFileBytes: options.maxFileBytes,
1184
+ maxTotalBytes: options.maxTotalBytes,
1185
+ });
1186
+ const metadata = parseMediaMetadata(result.stdout);
1187
+ if (result.exitCode !== 0 && result.exitCode !== 101) {
1188
+ const classification = errorClassification(result.stderr);
1189
+ return {
1190
+ status: classification.status,
1191
+ records: [],
1192
+ metadata,
1193
+ warnings: [...authenticationWarnings, classification.warning],
1194
+ };
1195
+ }
1196
+ const promoted = promoteMediaFiles(
1197
+ runDirectory,
1198
+ realOutputDirectory,
1199
+ normalizePrefix(options.relativePrefix),
1200
+ maxFiles,
1201
+ options.maxFileBytes,
1202
+ options.maxTotalBytes,
1203
+ );
1204
+ // yt-dlp reports its intentional --max-downloads boundary as 101 after emitting
1205
+ // the one requested item. A validated promoted file makes that a bounded success.
1206
+ if (result.exitCode === 101 && promoted.records.length === 0) {
1207
+ const classification = errorClassification(result.stderr);
1208
+ return {
1209
+ status: classification.status,
1210
+ records: [],
1211
+ metadata,
1212
+ warnings: [...authenticationWarnings, ...promoted.warnings, classification.warning],
1213
+ };
1214
+ }
1215
+ if (promoted.records.length === 0) {
1216
+ return {
1217
+ status: "unsupported",
1218
+ records: [],
1219
+ metadata,
1220
+ warnings: [
1221
+ ...authenticationWarnings,
1222
+ ...promoted.warnings,
1223
+ "yt-dlp completed without a supported audio/video file.",
1224
+ ],
1225
+ };
1226
+ }
1227
+ return {
1228
+ status: "captured",
1229
+ records: promoted.records,
1230
+ metadata,
1231
+ warnings: [...authenticationWarnings, ...promoted.warnings],
1232
+ };
1233
+ } catch (error) {
1234
+ const message = error instanceof Error ? error.message : String(error);
1235
+ return {
1236
+ status: "failed",
1237
+ records: [],
1238
+ metadata: null,
1239
+ warnings: [...authenticationWarnings, safeRunnerFailure(message)],
1240
+ };
1241
+ } finally {
1242
+ try {
1243
+ await networkProxy?.close();
1244
+ } finally {
1245
+ if (authDirectory !== null) rmSync(authDirectory, { recursive: true, force: true });
1246
+ rmSync(runDirectory, { recursive: true, force: true });
1247
+ }
1248
+ }
1249
+ }
1250
+
1251
+ function transcriptLanguage(value: string | undefined): string | null {
1252
+ const language = (value ?? "en").trim();
1253
+ return language.length <= 64 && /^[A-Za-z0-9]{1,16}(?:[-_][A-Za-z0-9]{1,16})*$/.test(language)
1254
+ ? language
1255
+ : null;
1256
+ }
1257
+
1258
+ function nodeRuntimeFor(
1259
+ options: CaptureVideoContextOptions,
1260
+ exists: (path: string) => boolean,
1261
+ ): { readonly path: string | null; readonly warnings: readonly string[] } {
1262
+ if (options.nodeExecutable === null) return { path: null, warnings: [] };
1263
+ const readVersion = options.readNodeVersion ?? readInstalledNodeVersion;
1264
+ if (options.nodeExecutable !== undefined) {
1265
+ if (
1266
+ exists(options.nodeExecutable)
1267
+ && supportsYtDlpNodeRuntime(readVersion(options.nodeExecutable))
1268
+ ) return { path: options.nodeExecutable, warnings: [] };
1269
+ return {
1270
+ path: null,
1271
+ warnings: ["The selected Node.js runtime was unavailable or older than Node.js 22; yt-dlp used its default runtime discovery."],
1272
+ };
1273
+ }
1274
+ return {
1275
+ path: discoverNodeRuntime({
1276
+ ...(options.homeDirectory === undefined ? {} : { homeDirectory: options.homeDirectory }),
1277
+ exists,
1278
+ ...(options.which === undefined ? {} : { which: options.which }),
1279
+ readVersion,
1280
+ }),
1281
+ warnings: [],
1282
+ };
1283
+ }
1284
+
1285
+ /**
1286
+ * Capture only reusable video context: metadata, one thumbnail, and one exact-language transcript.
1287
+ * The main audio/video payload is deliberately skipped; `captureMedia` remains the explicit full-media API.
1288
+ */
1289
+ export async function captureVideoContext(
1290
+ options: CaptureVideoContextOptions,
1291
+ ): Promise<VideoContextCaptureResult> {
1292
+ const validation = validateOptions(options);
1293
+ if (validation !== null) {
1294
+ return { status: "failed", thumbnail: null, transcript: null, metadata: null, warnings: [validation] };
1295
+ }
1296
+ const language = transcriptLanguage(options.transcriptLanguage);
1297
+ if (language === null) {
1298
+ return {
1299
+ status: "failed",
1300
+ thumbnail: null,
1301
+ transcript: null,
1302
+ metadata: null,
1303
+ warnings: ["Transcript language must be one exact language identifier such as en or en-US."],
1304
+ };
1305
+ }
1306
+
1307
+ const exists = options.exists ?? existsSync;
1308
+ const executable = options.executable ?? discoverYtDlp({
1309
+ ...(options.homeDirectory === undefined ? {} : { homeDirectory: options.homeDirectory }),
1310
+ exists,
1311
+ ...(options.which === undefined ? {} : { which: options.which }),
1312
+ });
1313
+ if (executable === null || !exists(executable)) {
1314
+ return {
1315
+ status: "unavailable",
1316
+ thumbnail: null,
1317
+ transcript: null,
1318
+ metadata: null,
1319
+ warnings: ["yt-dlp is not installed; skipped optional video context capture."],
1320
+ };
1321
+ }
1322
+
1323
+ const maxFiles = Math.max(1, Math.min(options.maxFiles ?? 12, 100));
1324
+ const maxOutputBytes = Math.max(4_096, Math.min(options.maxOutputBytes ?? 2 * 1024 * 1024, 16 * 1024 * 1024));
1325
+ const outputDirectory = resolve(options.outputDirectory);
1326
+ try {
1327
+ mkdirSync(outputDirectory, { recursive: true, mode: 0o755 });
1328
+ const outputStats = lstatSync(outputDirectory);
1329
+ if (!outputStats.isDirectory() || outputStats.isSymbolicLink()) {
1330
+ return {
1331
+ status: "failed",
1332
+ thumbnail: null,
1333
+ transcript: null,
1334
+ metadata: null,
1335
+ warnings: ["Video-context destination must be a real directory, not a symlink."],
1336
+ };
1337
+ }
1338
+ } catch {
1339
+ return {
1340
+ status: "failed",
1341
+ thumbnail: null,
1342
+ transcript: null,
1343
+ metadata: null,
1344
+ warnings: ["Could not create the video-context destination directory."],
1345
+ };
1346
+ }
1347
+
1348
+ const realOutputDirectory = realpathSync(outputDirectory);
1349
+ const runDirectory = mkdtempSync(join(realOutputDirectory, ".clip-video-context-"));
1350
+ let authDirectory: string | null = null;
1351
+ let networkProxy: LocalNetworkProxy | null = null;
1352
+ const run = options.run ?? runMediaCommand;
1353
+ const authenticationWarnings: string[] = [];
1354
+ try {
1355
+ let cookieFile: string | undefined;
1356
+ let cookieRequest: MediaCookieRequest | undefined;
1357
+ if (options.cookiesFile !== undefined) {
1358
+ cookieRequest = {
1359
+ url: options.url,
1360
+ source: "file",
1361
+ file: resolve(options.cookiesFile),
1362
+ timeoutMs: options.timeoutMs,
1363
+ };
1364
+ if (options.cookieBrowser !== undefined) {
1365
+ authenticationWarnings.push("The explicit cookie file took precedence over the selected browser cookie source.");
1366
+ }
1367
+ } else if (options.cookieBrowser !== undefined) {
1368
+ cookieRequest = {
1369
+ url: options.url,
1370
+ source: options.cookieBrowser.source,
1371
+ timeoutMs: options.timeoutMs,
1372
+ ...(options.cookieBrowser.profile === undefined ? {} : { profile: options.cookieBrowser.profile }),
1373
+ };
1374
+ }
1375
+ if (cookieRequest !== undefined) {
1376
+ authDirectory = mkdtempSync(join(tmpdir(), "hraness-kb-auth-"));
1377
+ chmodSync(authDirectory, 0o700);
1378
+ const prepared = await prepareCookieJar(cookieRequest, authDirectory, options.cookieProvider ?? readMediaCookies);
1379
+ if (!prepared.ok) {
1380
+ return {
1381
+ status: "failed",
1382
+ thumbnail: null,
1383
+ transcript: null,
1384
+ metadata: null,
1385
+ warnings: [prepared.warning],
1386
+ };
1387
+ }
1388
+ cookieFile = prepared.path;
1389
+ authenticationWarnings.push(...prepared.warnings);
1390
+ }
1391
+
1392
+ networkProxy = await (options.startProxy ?? startNetworkProxy)({
1393
+ allowPrivateNetwork: options.allowPrivateNetwork ?? false,
1394
+ timeoutMs: options.timeoutMs,
1395
+ maxTransferredBytes: Math.max(
1396
+ 64 * 1024 * 1024,
1397
+ Math.min(Number.MAX_SAFE_INTEGER, options.maxTotalBytes * 3),
1398
+ ),
1399
+ });
1400
+ const nodeRuntime = nodeRuntimeFor(options, exists);
1401
+ const result = await run({
1402
+ command: videoContextCommandArguments(
1403
+ executable,
1404
+ options,
1405
+ language,
1406
+ cookieFile,
1407
+ networkProxy.url,
1408
+ nodeRuntime.path,
1409
+ ),
1410
+ stdin: privateMediaUrlInput(options.url),
1411
+ cwd: runDirectory,
1412
+ timeoutMs: options.timeoutMs,
1413
+ maxOutputBytes,
1414
+ monitoredDirectory: runDirectory,
1415
+ maxFiles,
1416
+ maxFileBytes: options.maxFileBytes,
1417
+ maxTotalBytes: options.maxTotalBytes,
1418
+ });
1419
+ const metadata = parseMediaMetadata(result.stdout);
1420
+ const promoted = promoteVideoContextFiles(
1421
+ runDirectory,
1422
+ realOutputDirectory,
1423
+ normalizePrefix(options.relativePrefix),
1424
+ language,
1425
+ maxFiles,
1426
+ options.maxFileBytes,
1427
+ options.maxTotalBytes,
1428
+ );
1429
+ const capturedSomething = metadata !== null || promoted.thumbnail !== null || promoted.transcript !== null;
1430
+ const warnings = [...authenticationWarnings, ...nodeRuntime.warnings, ...promoted.warnings];
1431
+ // yt-dlp reports its intentional --max-downloads boundary as 101 after emitting
1432
+ // the one requested item's sidecars. Retained context makes that a bounded success.
1433
+ if (result.exitCode !== 0 && !(result.exitCode === 101 && capturedSomething)) {
1434
+ const classification = errorClassification(result.stderr);
1435
+ return {
1436
+ status: capturedSomething ? "partial" : classification.status,
1437
+ thumbnail: promoted.thumbnail,
1438
+ transcript: promoted.transcript,
1439
+ metadata,
1440
+ warnings: [...warnings, classification.warning],
1441
+ };
1442
+ }
1443
+ if (metadata === null) warnings.push("yt-dlp returned no allowlisted video metadata.");
1444
+ if (promoted.thumbnail === null) warnings.push("yt-dlp returned no supported video thumbnail.");
1445
+ if (promoted.transcript === null) {
1446
+ warnings.push(`yt-dlp returned no ${language} transcript for this video.`);
1447
+ }
1448
+ return {
1449
+ status: metadata !== null && promoted.thumbnail !== null && promoted.transcript !== null
1450
+ ? "captured"
1451
+ : "partial",
1452
+ thumbnail: promoted.thumbnail,
1453
+ transcript: promoted.transcript,
1454
+ metadata,
1455
+ warnings,
1456
+ };
1457
+ } catch (error) {
1458
+ const message = error instanceof Error ? error.message : String(error);
1459
+ return {
1460
+ status: "failed",
1461
+ thumbnail: null,
1462
+ transcript: null,
1463
+ metadata: null,
1464
+ warnings: [...authenticationWarnings, safeRunnerFailure(message)],
1465
+ };
1466
+ } finally {
1467
+ try {
1468
+ await networkProxy?.close();
1469
+ } finally {
1470
+ if (authDirectory !== null) rmSync(authDirectory, { recursive: true, force: true });
1471
+ rmSync(runDirectory, { recursive: true, force: true });
1472
+ }
1473
+ }
1474
+ }