cursedbelt-server 4.42.0 → 4.43.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.
- package/README.md +2 -1
- package/dist/server/youtube/youtubeApi.d.ts +93 -0
- package/dist/server/youtube/youtubeApi.js +219 -0
- package/dist/server/youtube/ytdlp.d.ts +129 -0
- package/dist/server/youtube/ytdlp.js +334 -0
- package/docs/ctgr.md +24 -0
- package/docs/skipped-tests.md +10 -10
- package/package.json +14 -2
- package/src/cursedopsRange.spec.ts +26 -0
- package/src/leafSubpathsImportNothing.spec.ts +18 -0
- package/src/server/storage/binaryServerProcessing.integration.spec.ts +31 -11
- package/src/server/storage/chunked.integration.spec.ts +6 -2
- package/src/server/storage/fileUploadClient-chunked.integration.spec.ts +6 -2
- package/src/server/storage/transcode.integration.spec.ts +31 -10
- package/src/server/youtube/youtubeApi.spec.ts +90 -0
- package/src/server/youtube/youtubeApi.ts +325 -0
- package/src/server/youtube/ytdlp.spec.ts +259 -0
- package/src/server/youtube/ytdlp.ts +454 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/youtube/ytdlp` — run yt-dlp on the Mac and hand back the file it made.
|
|
3
|
+
*
|
|
4
|
+
* ── Why it is here (forge task 2349) ────────────────────────────────────────────────────
|
|
5
|
+
* Music downloads the owner's YouTube playlists as AUDIO (task 2348) and Collections downloads
|
|
6
|
+
* them as VIDEO (task 2349). Both runners were the same machine with a different `-f`: spawn
|
|
7
|
+
* yt-dlp, read its progress off a marker line, sort a failure by what fixes it, install the
|
|
8
|
+
* binary when it is missing and update it when YouTube has moved on. So that machine is here,
|
|
9
|
+
* once, and each app hands it a {@link YtDlpProfile} — the format, the size ceiling, the noun
|
|
10
|
+
* its sentences use — plus the one command that installs or updates yt-dlp from that app.
|
|
11
|
+
*
|
|
12
|
+
* What is NOT here, on purpose: where the binary lives (each app passes `binary`, normally
|
|
13
|
+
* {@link ytDlpPath} of the generation's state root), what a downloaded file becomes (Music files
|
|
14
|
+
* a track; Collections ingests a video), and any re-encode (only Collections has one, and only
|
|
15
|
+
* when no H.264 stream exists).
|
|
16
|
+
*
|
|
17
|
+
* ── The file stays on disk ──────────────────────────────────────────────────────────────
|
|
18
|
+
* A download resolves to a PATH inside a fresh temporary directory, and the caller disposes of
|
|
19
|
+
* it. A 1080p video is gigabytes, and reading it into memory to hand it over would make the
|
|
20
|
+
* agent's footprint the size of the largest video in a playlist. Music, whose files are a few
|
|
21
|
+
* MB, reads the bytes and disposes at once.
|
|
22
|
+
*
|
|
23
|
+
* ── Self-healing in the two ways that need no owner ─────────────────────────────────────
|
|
24
|
+
* A missing binary is installed before the first run, and a failure that reads as "this
|
|
25
|
+
* yt-dlp is out of date" updates it (at most once an hour) and tries again. What is left after
|
|
26
|
+
* that is a sentence that NAMES the fix — "yt-dlp is out of date … Run: cd … && bun run yt-dlp".
|
|
27
|
+
*
|
|
28
|
+
* YouTube's JS challenges need a JavaScript runtime and yt-dlp enables only Deno by default,
|
|
29
|
+
* so it is handed the Bun running this process — which it supports — rather than a second
|
|
30
|
+
* runtime installed for one tool.
|
|
31
|
+
*
|
|
32
|
+
* 🔴 Mac-only. It spawns a process and writes files; nothing a Worker mounts may import it.
|
|
33
|
+
* It imports `node:*` and nothing else, which `leafSubpathsImportNothing.spec.ts` holds it to.
|
|
34
|
+
*/
|
|
35
|
+
import { spawn } from "node:child_process";
|
|
36
|
+
import { chmod, mkdir, mkdtemp, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
37
|
+
import { tmpdir } from "node:os";
|
|
38
|
+
import { dirname, join } from "node:path";
|
|
39
|
+
|
|
40
|
+
/** Where the standalone build is published. `latest` always names the newest release. */
|
|
41
|
+
const YTDLP_RELEASE_URL = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos";
|
|
42
|
+
|
|
43
|
+
/** The markers this module asks yt-dlp to print, so its own chatter can never be mistaken for them. */
|
|
44
|
+
const PROGRESS_MARK = "ytprogress ";
|
|
45
|
+
const INFO_MARK = "ytinfo ";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A download that failed and was tried again. yt-dlp answers an intermittent `403` on the
|
|
49
|
+
* media request (measured 2026-09-30: one in three identical runs), and a retry is the fix.
|
|
50
|
+
*/
|
|
51
|
+
const ATTEMPTS = 3;
|
|
52
|
+
|
|
53
|
+
/** How often a fetcher may update yt-dlp by itself. A release that does not fix it will not
|
|
54
|
+
* start fixing it on the tenth try. */
|
|
55
|
+
const SELF_UPDATE_EVERY_MS = 60 * 60_000;
|
|
56
|
+
|
|
57
|
+
/** `$STATE/bin/yt-dlp` — one binary for every app on the Mac. `state` is the generation's state root. */
|
|
58
|
+
export function ytDlpPath(state: string): string {
|
|
59
|
+
return join(state, "bin", "yt-dlp");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** yt-dlp's cache (the player JS it has already solved), kept with the generation's state. */
|
|
63
|
+
export function ytDlpCacheDir(state: string): string {
|
|
64
|
+
return join(state, "cache", "yt-dlp");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What one app downloads. Music's is audio as YouTube serves it; Collections' is H.264 + AAC
|
|
69
|
+
* in MP4. Everything else about a run is the same.
|
|
70
|
+
*/
|
|
71
|
+
export interface YtDlpProfile {
|
|
72
|
+
/** yt-dlp's `-f` selector. */
|
|
73
|
+
format: string;
|
|
74
|
+
/** Arguments placed after the format — e.g. `--merge-output-format mp4`. */
|
|
75
|
+
extraArgs?: readonly string[];
|
|
76
|
+
/** `--max-filesize`, in yt-dlp's own notation (`200M`, `4G`). */
|
|
77
|
+
maxFilesize: string;
|
|
78
|
+
/** What a file is called in a sentence: "That video's audio …", "That video …". */
|
|
79
|
+
noun: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What yt-dlp says about a video — the fields an app reads. */
|
|
83
|
+
export interface YtInfo {
|
|
84
|
+
id: string;
|
|
85
|
+
title?: string;
|
|
86
|
+
/** Set for YouTube Music tracks (the "Topic" channels): the song, not the video title. */
|
|
87
|
+
track?: string;
|
|
88
|
+
artist?: string;
|
|
89
|
+
creator?: string;
|
|
90
|
+
channel?: string;
|
|
91
|
+
uploader?: string;
|
|
92
|
+
album?: string;
|
|
93
|
+
release_year?: number;
|
|
94
|
+
/** `YYYYMMDD`. */
|
|
95
|
+
upload_date?: string;
|
|
96
|
+
duration?: number;
|
|
97
|
+
thumbnail?: string;
|
|
98
|
+
ext?: string;
|
|
99
|
+
filepath?: string;
|
|
100
|
+
/** The chosen video stream's codec — `avc1.640028`, `vp09…`, `none` for audio. */
|
|
101
|
+
vcodec?: string;
|
|
102
|
+
acodec?: string;
|
|
103
|
+
width?: number;
|
|
104
|
+
height?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One finished download. `path` is inside `dir`; {@link dispose} removes both. */
|
|
108
|
+
export interface YtDownload {
|
|
109
|
+
path: string;
|
|
110
|
+
/** `webm`, `m4a`, `mp4` — what the file IS, for its name and its MIME type. */
|
|
111
|
+
extension: string;
|
|
112
|
+
info: YtInfo;
|
|
113
|
+
/** The temporary directory the file is in — a caller may write a re-encode beside it. */
|
|
114
|
+
dir: string;
|
|
115
|
+
dispose(): Promise<void>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Fetch one video. `onProgress` gets the fraction of the file received, 0–1. */
|
|
119
|
+
export type YtDlpFetch = (videoId: string, onProgress?: (fraction: number) => void) => Promise<YtDownload>;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Why a download failed, sorted by what the owner can do about it.
|
|
123
|
+
*
|
|
124
|
+
* · `missing` — no binary, and installing one failed.
|
|
125
|
+
* · `outdated` — YouTube changed and this yt-dlp has not caught up. Update fixes it.
|
|
126
|
+
* · `blocked` — YouTube is refusing this Mac as a bot. Time fixes it, usually.
|
|
127
|
+
* · `unavailable` — the video itself: private, deleted, region-locked, members-only.
|
|
128
|
+
* · `too-big` — over the one-download ceiling.
|
|
129
|
+
* · `failed` — anything else, with yt-dlp's own last error line.
|
|
130
|
+
*/
|
|
131
|
+
export type YtDlpFailure = "missing" | "outdated" | "blocked" | "unavailable" | "too-big" | "failed";
|
|
132
|
+
|
|
133
|
+
export class YtDlpError extends Error {
|
|
134
|
+
constructor(
|
|
135
|
+
message: string,
|
|
136
|
+
readonly kind: YtDlpFailure,
|
|
137
|
+
) {
|
|
138
|
+
super(message);
|
|
139
|
+
this.name = "YtDlpError";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A failure no retry and no update can change — the video's own fault, or the ceiling. */
|
|
144
|
+
export const isFinalYtDlpFailure = (kind: YtDlpFailure): boolean => kind === "unavailable" || kind === "too-big";
|
|
145
|
+
|
|
146
|
+
// ── Pure: reading what yt-dlp prints ───────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One progress line, as a fraction — or `null` for any other line.
|
|
150
|
+
*
|
|
151
|
+
* The template prints `downloaded total estimate`, and yt-dlp writes `NA` for a field it does
|
|
152
|
+
* not know. The exact total wins; the estimate is what a stream without a `content-length`
|
|
153
|
+
* has. With neither there is no honest fraction, so none is claimed.
|
|
154
|
+
*/
|
|
155
|
+
export function parseYtProgress(line: string): number | null {
|
|
156
|
+
if (!line.startsWith(PROGRESS_MARK)) return null;
|
|
157
|
+
const [downloaded, total, estimate] = line.slice(PROGRESS_MARK.length).trim().split(/\s+/).map(Number);
|
|
158
|
+
const whole = Number.isFinite(total) && (total as number) > 0 ? total : estimate;
|
|
159
|
+
if (!Number.isFinite(downloaded) || !Number.isFinite(whole) || (whole as number) <= 0) return null;
|
|
160
|
+
return Math.min(Math.max((downloaded as number) / (whole as number), 0), 1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The info JSON printed after the file is in place, or `null` for any other line. */
|
|
164
|
+
export function parseYtInfo(line: string): YtInfo | null {
|
|
165
|
+
if (!line.startsWith(INFO_MARK)) return null;
|
|
166
|
+
try {
|
|
167
|
+
const value = JSON.parse(line.slice(INFO_MARK.length)) as unknown;
|
|
168
|
+
return value && typeof value === "object" && typeof (value as YtInfo).id === "string" ? (value as YtInfo) : null;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** yt-dlp's last `ERROR:` line — the one that says why — or the stderr's last line. */
|
|
175
|
+
function lastYtDlpError(stderr: string): string {
|
|
176
|
+
const lines = stderr
|
|
177
|
+
.split("\n")
|
|
178
|
+
.map((l) => l.trim())
|
|
179
|
+
.filter(Boolean);
|
|
180
|
+
const errors = lines.filter((l) => l.startsWith("ERROR:"));
|
|
181
|
+
return (errors.at(-1) ?? lines.at(-1) ?? "").replace(/^ERROR:\s*/, "");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const UNAVAILABLE =
|
|
185
|
+
/video unavailable|this video is unavailable|private video|has been removed|no longer available|account associated with this video has been terminated|not available in your country|blocked it in your country|members[- ]only|join this channel|confirm your age|age-restricted|copyright/i;
|
|
186
|
+
const BLOCKED = /confirm you.?re not a bot|sign in to confirm|too many requests|http error 429/i;
|
|
187
|
+
const OUTDATED =
|
|
188
|
+
/unable to extract|nsig|n challenge|signature (extraction|function)|precondition check failed|http error 403|requested format is not available|please report this issue|js challenge|player .*(response|js)|unsupported url|jsc/i;
|
|
189
|
+
const TOO_BIG = /larger than max-filesize|max-filesize/i;
|
|
190
|
+
|
|
191
|
+
/** Sort a failure by what can be done about it — see {@link YtDlpFailure}. */
|
|
192
|
+
export function classifyYtDlpFailure(stderr: string): YtDlpFailure {
|
|
193
|
+
const line = lastYtDlpError(stderr);
|
|
194
|
+
if (TOO_BIG.test(line)) return "too-big";
|
|
195
|
+
if (UNAVAILABLE.test(line)) return "unavailable";
|
|
196
|
+
if (BLOCKED.test(line)) return "blocked";
|
|
197
|
+
if (OUTDATED.test(line)) return "outdated";
|
|
198
|
+
return "failed";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The sentence a downloads table shows. Every kind that has a remedy NAMES it. */
|
|
202
|
+
function describeYtDlpFailure(
|
|
203
|
+
kind: YtDlpFailure,
|
|
204
|
+
detail: string,
|
|
205
|
+
context: { updated: boolean; fixCommand: string; profile: Pick<YtDlpProfile, "maxFilesize" | "noun"> },
|
|
206
|
+
): string {
|
|
207
|
+
const fix = context.fixCommand;
|
|
208
|
+
switch (kind) {
|
|
209
|
+
case "missing":
|
|
210
|
+
return `yt-dlp is not installed on the Mac and installing it failed (${detail}). Run: ${fix}`;
|
|
211
|
+
case "outdated":
|
|
212
|
+
return (
|
|
213
|
+
`yt-dlp could not read YouTube — it is out of date for a change YouTube made (${detail}). ` +
|
|
214
|
+
(context.updated
|
|
215
|
+
? `The agent already updated it and it still fails, so the fix is not released yet; try again in a day, or run: ${fix}`
|
|
216
|
+
: `Run: ${fix}`)
|
|
217
|
+
);
|
|
218
|
+
case "blocked":
|
|
219
|
+
return `YouTube is refusing this Mac's requests as a bot (${detail}). It usually lifts within hours — try again later. If it persists, update yt-dlp: ${fix}`;
|
|
220
|
+
case "unavailable":
|
|
221
|
+
return `YouTube will not play this video to anyone signed out (${detail}), so there is nothing to download.`;
|
|
222
|
+
case "too-big":
|
|
223
|
+
return `${context.profile.noun} is over this app's ${context.profile.maxFilesize}B ceiling for one download.`;
|
|
224
|
+
default:
|
|
225
|
+
return `yt-dlp failed: ${detail || "no reason given"}. If YouTube links keep failing, update it: ${fix}`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The arguments for one download. Exported so a test pins the flags that matter. */
|
|
230
|
+
export function ytDlpArgs(
|
|
231
|
+
videoId: string,
|
|
232
|
+
outDir: string,
|
|
233
|
+
profile: Pick<YtDlpProfile, "format" | "extraArgs" | "maxFilesize">,
|
|
234
|
+
options: { cacheDir?: string; jsRuntime?: string } = {},
|
|
235
|
+
): string[] {
|
|
236
|
+
return [
|
|
237
|
+
...(options.jsRuntime ? ["--js-runtimes", `bun:${options.jsRuntime}`] : []),
|
|
238
|
+
...(options.cacheDir ? ["--cache-dir", options.cacheDir] : []),
|
|
239
|
+
"--no-playlist",
|
|
240
|
+
"-f",
|
|
241
|
+
profile.format,
|
|
242
|
+
...(profile.extraArgs ?? []),
|
|
243
|
+
"--max-filesize",
|
|
244
|
+
profile.maxFilesize,
|
|
245
|
+
"--no-part",
|
|
246
|
+
"--no-mtime",
|
|
247
|
+
"--no-colors",
|
|
248
|
+
"--newline",
|
|
249
|
+
// `--print` implies `--quiet`, which would swallow the progress lines — hence `--progress`.
|
|
250
|
+
"--progress",
|
|
251
|
+
"--progress-template",
|
|
252
|
+
`download:${PROGRESS_MARK}%(progress.downloaded_bytes)s %(progress.total_bytes)s %(progress.total_bytes_estimate)s`,
|
|
253
|
+
"--print",
|
|
254
|
+
`after_move:${INFO_MARK}%()j`,
|
|
255
|
+
"-o",
|
|
256
|
+
join(outDir, "%(id)s.%(ext)s"),
|
|
257
|
+
"--",
|
|
258
|
+
`https://www.youtube.com/watch?v=${videoId}`,
|
|
259
|
+
];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── The process ────────────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
/** Run a program, handing each stdout line over as it arrives. Injected in tests. */
|
|
265
|
+
export type RunLines = (
|
|
266
|
+
binary: string,
|
|
267
|
+
args: string[],
|
|
268
|
+
onLine: (line: string) => void,
|
|
269
|
+
) => Promise<{ code: number; stderr: string }>;
|
|
270
|
+
|
|
271
|
+
const runLines: RunLines = (binary, args, onLine) =>
|
|
272
|
+
new Promise((resolve, reject) => {
|
|
273
|
+
const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
274
|
+
let buffer = "";
|
|
275
|
+
let stderr = "";
|
|
276
|
+
child.stdout.setEncoding("utf8");
|
|
277
|
+
child.stdout.on("data", (chunk: string) => {
|
|
278
|
+
buffer += chunk;
|
|
279
|
+
let at = buffer.indexOf("\n");
|
|
280
|
+
while (at >= 0) {
|
|
281
|
+
onLine(buffer.slice(0, at).replace(/\r$/, ""));
|
|
282
|
+
buffer = buffer.slice(at + 1);
|
|
283
|
+
at = buffer.indexOf("\n");
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
child.stderr.setEncoding("utf8");
|
|
287
|
+
child.stderr.on("data", (chunk: string) => {
|
|
288
|
+
// The tail is the part that says why; a runaway stderr must not grow without bound.
|
|
289
|
+
stderr = (stderr + chunk).slice(-16_384);
|
|
290
|
+
});
|
|
291
|
+
child.on("error", reject);
|
|
292
|
+
child.on("close", (code) => {
|
|
293
|
+
if (buffer) onLine(buffer);
|
|
294
|
+
resolve({ code: code ?? 1, stderr });
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Put the newest standalone build at `binary`. Written beside it and renamed into place, so a
|
|
300
|
+
* download that dies halfway never leaves a truncated executable where a fetcher looks.
|
|
301
|
+
*/
|
|
302
|
+
export async function installYtDlp(binary: string, fetchImpl: typeof fetch = fetch): Promise<void> {
|
|
303
|
+
const res = await fetchImpl(YTDLP_RELEASE_URL, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
|
|
304
|
+
if (!res.ok) throw new Error(`GitHub answered ${res.status} for ${YTDLP_RELEASE_URL}`);
|
|
305
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
306
|
+
// The standalone build is a Mach-O executable; anything small is an error page.
|
|
307
|
+
if (bytes.length < 1_000_000) throw new Error(`the download was ${bytes.length} bytes, not a yt-dlp build`);
|
|
308
|
+
await mkdir(dirname(binary), { recursive: true });
|
|
309
|
+
const partial = `${binary}.partial`;
|
|
310
|
+
await writeFile(partial, bytes);
|
|
311
|
+
await chmod(partial, 0o755);
|
|
312
|
+
await rename(partial, binary);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** `yt-dlp -U` — the standalone build updates itself in place. Resolves to what it printed. */
|
|
316
|
+
export async function updateYtDlp(binary: string, run: RunLines = runLines): Promise<string> {
|
|
317
|
+
const lines: string[] = [];
|
|
318
|
+
const { code, stderr } = await run(binary, ["-U"], (line) => lines.push(line));
|
|
319
|
+
const said = [...lines, ...stderr.split("\n")].map((l) => l.trim()).filter(Boolean);
|
|
320
|
+
if (code !== 0) throw new Error(said.at(-1) ?? `yt-dlp -U exited ${code}`);
|
|
321
|
+
return said.at(-1) ?? "";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Install yt-dlp when it is missing, else update it — the body of each app's `bun run yt-dlp`.
|
|
326
|
+
* Resolves to the sentence to print.
|
|
327
|
+
*/
|
|
328
|
+
export async function installOrUpdateYtDlp(binary: string): Promise<string> {
|
|
329
|
+
if (!(await isFile(binary))) {
|
|
330
|
+
await installYtDlp(binary);
|
|
331
|
+
return `installed the latest release at ${binary}`;
|
|
332
|
+
}
|
|
333
|
+
return updateYtDlp(binary);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const isFile = async (path: string): Promise<boolean> => {
|
|
337
|
+
try {
|
|
338
|
+
return (await stat(path)).isFile();
|
|
339
|
+
} catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
export interface YtDlpOptions {
|
|
345
|
+
binary: string;
|
|
346
|
+
profile: YtDlpProfile;
|
|
347
|
+
/** The ONE command, runnable from any directory, that installs or updates yt-dlp for this app. */
|
|
348
|
+
fixCommand: string;
|
|
349
|
+
/** The prefix of every log line — `[music]`, `[collections]`. */
|
|
350
|
+
label?: string;
|
|
351
|
+
cacheDir?: string;
|
|
352
|
+
/** The Bun binary yt-dlp may use for YouTube's JS challenges. Defaults to this process's. */
|
|
353
|
+
jsRuntime?: string;
|
|
354
|
+
/** Where each download's temporary directory is made. Defaults to the OS temp dir. */
|
|
355
|
+
tmpRoot?: string;
|
|
356
|
+
/** Injected in tests — the process, the installer, the updater, the clock. */
|
|
357
|
+
run?: RunLines;
|
|
358
|
+
install?: (binary: string) => Promise<void>;
|
|
359
|
+
update?: (binary: string) => Promise<string>;
|
|
360
|
+
now?: () => number;
|
|
361
|
+
log?: (line: string) => void;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The fetcher an app's download agent is handed. See the header for what it heals by itself
|
|
366
|
+
* and what it leaves as a sentence.
|
|
367
|
+
*/
|
|
368
|
+
export function createYtDlp(options: YtDlpOptions): YtDlpFetch {
|
|
369
|
+
const run = options.run ?? runLines;
|
|
370
|
+
const install = options.install ?? ((binary: string) => installYtDlp(binary));
|
|
371
|
+
const update = options.update ?? ((binary: string) => updateYtDlp(binary, run));
|
|
372
|
+
const now = options.now ?? Date.now;
|
|
373
|
+
const label = options.label ?? "[yt-dlp]";
|
|
374
|
+
const jsRuntime = options.jsRuntime ?? (process.versions.bun ? process.execPath : undefined);
|
|
375
|
+
let lastUpdate = Number.NEGATIVE_INFINITY;
|
|
376
|
+
const describe = (kind: YtDlpFailure, detail: string, updated: boolean): string =>
|
|
377
|
+
describeYtDlpFailure(kind, detail, { updated, fixCommand: options.fixCommand, profile: options.profile });
|
|
378
|
+
|
|
379
|
+
const ensureInstalled = async (): Promise<void> => {
|
|
380
|
+
if (await isFile(options.binary)) return;
|
|
381
|
+
options.log?.(`${label} yt-dlp: not at ${options.binary} — installing the latest release`);
|
|
382
|
+
try {
|
|
383
|
+
await install(options.binary);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
386
|
+
throw new YtDlpError(describe("missing", detail, false), "missing");
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/** One yt-dlp run into a fresh directory. Throws a classified error on failure. */
|
|
391
|
+
const attempt = async (videoId: string, onProgress?: (fraction: number) => void): Promise<YtDownload> => {
|
|
392
|
+
const dir = await mkdtemp(join(options.tmpRoot ?? tmpdir(), "ytdlp-"));
|
|
393
|
+
const dispose = () => rm(dir, { recursive: true, force: true });
|
|
394
|
+
try {
|
|
395
|
+
let info: YtInfo | null = null;
|
|
396
|
+
const { code, stderr } = await run(
|
|
397
|
+
options.binary,
|
|
398
|
+
ytDlpArgs(videoId, dir, options.profile, {
|
|
399
|
+
...(options.cacheDir ? { cacheDir: options.cacheDir } : {}),
|
|
400
|
+
...(jsRuntime ? { jsRuntime } : {}),
|
|
401
|
+
}),
|
|
402
|
+
(line) => {
|
|
403
|
+
const fraction = parseYtProgress(line);
|
|
404
|
+
if (fraction !== null) onProgress?.(fraction);
|
|
405
|
+
info = parseYtInfo(line) ?? info;
|
|
406
|
+
},
|
|
407
|
+
);
|
|
408
|
+
const found = info as YtInfo | null;
|
|
409
|
+
if (code !== 0 || !found?.filepath) {
|
|
410
|
+
const kind = code === 0 ? "failed" : classifyYtDlpFailure(stderr);
|
|
411
|
+
throw new YtDlpError(code === 0 ? "yt-dlp finished without saying where the file went" : lastYtDlpError(stderr), kind);
|
|
412
|
+
}
|
|
413
|
+
if (!(await isFile(found.filepath))) throw new YtDlpError(`yt-dlp named ${found.filepath} and it is not there`, "failed");
|
|
414
|
+
const extension = (found.ext || found.filepath.split(".").at(-1) || "bin").toLowerCase();
|
|
415
|
+
return { path: found.filepath, extension, info: found, dir, dispose };
|
|
416
|
+
} catch (error) {
|
|
417
|
+
await dispose();
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
return async (videoId, onProgress) => {
|
|
423
|
+
await ensureInstalled();
|
|
424
|
+
let updated = false;
|
|
425
|
+
let last: YtDlpError | null = null;
|
|
426
|
+
for (let tries = 1; tries <= ATTEMPTS + 1; tries++) {
|
|
427
|
+
try {
|
|
428
|
+
return await attempt(videoId, onProgress);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
last =
|
|
431
|
+
error instanceof YtDlpError ? error : new YtDlpError(error instanceof Error ? error.message : String(error), "failed");
|
|
432
|
+
if (isFinalYtDlpFailure(last.kind)) break;
|
|
433
|
+
if (tries < ATTEMPTS) continue;
|
|
434
|
+
// Out of plain retries. One more, after an update, if it reads as out of date.
|
|
435
|
+
if (last.kind === "outdated" && !updated && now() - lastUpdate >= SELF_UPDATE_EVERY_MS) {
|
|
436
|
+
lastUpdate = now();
|
|
437
|
+
try {
|
|
438
|
+
const said = await update(options.binary);
|
|
439
|
+
updated = true;
|
|
440
|
+
options.log?.(`${label} yt-dlp: updated after "${last.message}" — ${said}`);
|
|
441
|
+
continue;
|
|
442
|
+
} catch (updateError) {
|
|
443
|
+
options.log?.(
|
|
444
|
+
`${label} yt-dlp: update failed — ${updateError instanceof Error ? updateError.message : String(updateError)}`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const failure = last ?? new YtDlpError("no attempt ran", "failed");
|
|
452
|
+
throw new YtDlpError(describe(failure.kind, failure.message, updated), failure.kind);
|
|
453
|
+
};
|
|
454
|
+
}
|