cursedbelt-server 4.41.0 → 4.43.0
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/dist/server/d1/http.d.ts +19 -0
- package/dist/server/d1/http.js +37 -6
- 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/package.json +13 -1
- package/src/leafSubpathsImportNothing.spec.ts +18 -0
- package/src/server/d1/http.spec.ts +43 -0
- package/src/server/d1/http.ts +47 -6
- 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,334 @@
|
|
|
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
|
+
/** Where the standalone build is published. `latest` always names the newest release. */
|
|
40
|
+
const YTDLP_RELEASE_URL = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos";
|
|
41
|
+
/** The markers this module asks yt-dlp to print, so its own chatter can never be mistaken for them. */
|
|
42
|
+
const PROGRESS_MARK = "ytprogress ";
|
|
43
|
+
const INFO_MARK = "ytinfo ";
|
|
44
|
+
/**
|
|
45
|
+
* A download that failed and was tried again. yt-dlp answers an intermittent `403` on the
|
|
46
|
+
* media request (measured 2026-09-30: one in three identical runs), and a retry is the fix.
|
|
47
|
+
*/
|
|
48
|
+
const ATTEMPTS = 3;
|
|
49
|
+
/** How often a fetcher may update yt-dlp by itself. A release that does not fix it will not
|
|
50
|
+
* start fixing it on the tenth try. */
|
|
51
|
+
const SELF_UPDATE_EVERY_MS = 60 * 60_000;
|
|
52
|
+
/** `$STATE/bin/yt-dlp` — one binary for every app on the Mac. `state` is the generation's state root. */
|
|
53
|
+
export function ytDlpPath(state) {
|
|
54
|
+
return join(state, "bin", "yt-dlp");
|
|
55
|
+
}
|
|
56
|
+
/** yt-dlp's cache (the player JS it has already solved), kept with the generation's state. */
|
|
57
|
+
export function ytDlpCacheDir(state) {
|
|
58
|
+
return join(state, "cache", "yt-dlp");
|
|
59
|
+
}
|
|
60
|
+
export class YtDlpError extends Error {
|
|
61
|
+
kind;
|
|
62
|
+
constructor(message, kind) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.kind = kind;
|
|
65
|
+
this.name = "YtDlpError";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** A failure no retry and no update can change — the video's own fault, or the ceiling. */
|
|
69
|
+
export const isFinalYtDlpFailure = (kind) => kind === "unavailable" || kind === "too-big";
|
|
70
|
+
// ── Pure: reading what yt-dlp prints ───────────────────────────────────────────────────
|
|
71
|
+
/**
|
|
72
|
+
* One progress line, as a fraction — or `null` for any other line.
|
|
73
|
+
*
|
|
74
|
+
* The template prints `downloaded total estimate`, and yt-dlp writes `NA` for a field it does
|
|
75
|
+
* not know. The exact total wins; the estimate is what a stream without a `content-length`
|
|
76
|
+
* has. With neither there is no honest fraction, so none is claimed.
|
|
77
|
+
*/
|
|
78
|
+
export function parseYtProgress(line) {
|
|
79
|
+
if (!line.startsWith(PROGRESS_MARK))
|
|
80
|
+
return null;
|
|
81
|
+
const [downloaded, total, estimate] = line.slice(PROGRESS_MARK.length).trim().split(/\s+/).map(Number);
|
|
82
|
+
const whole = Number.isFinite(total) && total > 0 ? total : estimate;
|
|
83
|
+
if (!Number.isFinite(downloaded) || !Number.isFinite(whole) || whole <= 0)
|
|
84
|
+
return null;
|
|
85
|
+
return Math.min(Math.max(downloaded / whole, 0), 1);
|
|
86
|
+
}
|
|
87
|
+
/** The info JSON printed after the file is in place, or `null` for any other line. */
|
|
88
|
+
export function parseYtInfo(line) {
|
|
89
|
+
if (!line.startsWith(INFO_MARK))
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
const value = JSON.parse(line.slice(INFO_MARK.length));
|
|
93
|
+
return value && typeof value === "object" && typeof value.id === "string" ? value : null;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** yt-dlp's last `ERROR:` line — the one that says why — or the stderr's last line. */
|
|
100
|
+
function lastYtDlpError(stderr) {
|
|
101
|
+
const lines = stderr
|
|
102
|
+
.split("\n")
|
|
103
|
+
.map((l) => l.trim())
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
const errors = lines.filter((l) => l.startsWith("ERROR:"));
|
|
106
|
+
return (errors.at(-1) ?? lines.at(-1) ?? "").replace(/^ERROR:\s*/, "");
|
|
107
|
+
}
|
|
108
|
+
const UNAVAILABLE = /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;
|
|
109
|
+
const BLOCKED = /confirm you.?re not a bot|sign in to confirm|too many requests|http error 429/i;
|
|
110
|
+
const OUTDATED = /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;
|
|
111
|
+
const TOO_BIG = /larger than max-filesize|max-filesize/i;
|
|
112
|
+
/** Sort a failure by what can be done about it — see {@link YtDlpFailure}. */
|
|
113
|
+
export function classifyYtDlpFailure(stderr) {
|
|
114
|
+
const line = lastYtDlpError(stderr);
|
|
115
|
+
if (TOO_BIG.test(line))
|
|
116
|
+
return "too-big";
|
|
117
|
+
if (UNAVAILABLE.test(line))
|
|
118
|
+
return "unavailable";
|
|
119
|
+
if (BLOCKED.test(line))
|
|
120
|
+
return "blocked";
|
|
121
|
+
if (OUTDATED.test(line))
|
|
122
|
+
return "outdated";
|
|
123
|
+
return "failed";
|
|
124
|
+
}
|
|
125
|
+
/** The sentence a downloads table shows. Every kind that has a remedy NAMES it. */
|
|
126
|
+
function describeYtDlpFailure(kind, detail, context) {
|
|
127
|
+
const fix = context.fixCommand;
|
|
128
|
+
switch (kind) {
|
|
129
|
+
case "missing":
|
|
130
|
+
return `yt-dlp is not installed on the Mac and installing it failed (${detail}). Run: ${fix}`;
|
|
131
|
+
case "outdated":
|
|
132
|
+
return (`yt-dlp could not read YouTube — it is out of date for a change YouTube made (${detail}). ` +
|
|
133
|
+
(context.updated
|
|
134
|
+
? `The agent already updated it and it still fails, so the fix is not released yet; try again in a day, or run: ${fix}`
|
|
135
|
+
: `Run: ${fix}`));
|
|
136
|
+
case "blocked":
|
|
137
|
+
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}`;
|
|
138
|
+
case "unavailable":
|
|
139
|
+
return `YouTube will not play this video to anyone signed out (${detail}), so there is nothing to download.`;
|
|
140
|
+
case "too-big":
|
|
141
|
+
return `${context.profile.noun} is over this app's ${context.profile.maxFilesize}B ceiling for one download.`;
|
|
142
|
+
default:
|
|
143
|
+
return `yt-dlp failed: ${detail || "no reason given"}. If YouTube links keep failing, update it: ${fix}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** The arguments for one download. Exported so a test pins the flags that matter. */
|
|
147
|
+
export function ytDlpArgs(videoId, outDir, profile, options = {}) {
|
|
148
|
+
return [
|
|
149
|
+
...(options.jsRuntime ? ["--js-runtimes", `bun:${options.jsRuntime}`] : []),
|
|
150
|
+
...(options.cacheDir ? ["--cache-dir", options.cacheDir] : []),
|
|
151
|
+
"--no-playlist",
|
|
152
|
+
"-f",
|
|
153
|
+
profile.format,
|
|
154
|
+
...(profile.extraArgs ?? []),
|
|
155
|
+
"--max-filesize",
|
|
156
|
+
profile.maxFilesize,
|
|
157
|
+
"--no-part",
|
|
158
|
+
"--no-mtime",
|
|
159
|
+
"--no-colors",
|
|
160
|
+
"--newline",
|
|
161
|
+
// `--print` implies `--quiet`, which would swallow the progress lines — hence `--progress`.
|
|
162
|
+
"--progress",
|
|
163
|
+
"--progress-template",
|
|
164
|
+
`download:${PROGRESS_MARK}%(progress.downloaded_bytes)s %(progress.total_bytes)s %(progress.total_bytes_estimate)s`,
|
|
165
|
+
"--print",
|
|
166
|
+
`after_move:${INFO_MARK}%()j`,
|
|
167
|
+
"-o",
|
|
168
|
+
join(outDir, "%(id)s.%(ext)s"),
|
|
169
|
+
"--",
|
|
170
|
+
`https://www.youtube.com/watch?v=${videoId}`,
|
|
171
|
+
];
|
|
172
|
+
}
|
|
173
|
+
const runLines = (binary, args, onLine) => new Promise((resolve, reject) => {
|
|
174
|
+
const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
175
|
+
let buffer = "";
|
|
176
|
+
let stderr = "";
|
|
177
|
+
child.stdout.setEncoding("utf8");
|
|
178
|
+
child.stdout.on("data", (chunk) => {
|
|
179
|
+
buffer += chunk;
|
|
180
|
+
let at = buffer.indexOf("\n");
|
|
181
|
+
while (at >= 0) {
|
|
182
|
+
onLine(buffer.slice(0, at).replace(/\r$/, ""));
|
|
183
|
+
buffer = buffer.slice(at + 1);
|
|
184
|
+
at = buffer.indexOf("\n");
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
child.stderr.setEncoding("utf8");
|
|
188
|
+
child.stderr.on("data", (chunk) => {
|
|
189
|
+
// The tail is the part that says why; a runaway stderr must not grow without bound.
|
|
190
|
+
stderr = (stderr + chunk).slice(-16_384);
|
|
191
|
+
});
|
|
192
|
+
child.on("error", reject);
|
|
193
|
+
child.on("close", (code) => {
|
|
194
|
+
if (buffer)
|
|
195
|
+
onLine(buffer);
|
|
196
|
+
resolve({ code: code ?? 1, stderr });
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
/**
|
|
200
|
+
* Put the newest standalone build at `binary`. Written beside it and renamed into place, so a
|
|
201
|
+
* download that dies halfway never leaves a truncated executable where a fetcher looks.
|
|
202
|
+
*/
|
|
203
|
+
export async function installYtDlp(binary, fetchImpl = fetch) {
|
|
204
|
+
const res = await fetchImpl(YTDLP_RELEASE_URL, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
|
|
205
|
+
if (!res.ok)
|
|
206
|
+
throw new Error(`GitHub answered ${res.status} for ${YTDLP_RELEASE_URL}`);
|
|
207
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
208
|
+
// The standalone build is a Mach-O executable; anything small is an error page.
|
|
209
|
+
if (bytes.length < 1_000_000)
|
|
210
|
+
throw new Error(`the download was ${bytes.length} bytes, not a yt-dlp build`);
|
|
211
|
+
await mkdir(dirname(binary), { recursive: true });
|
|
212
|
+
const partial = `${binary}.partial`;
|
|
213
|
+
await writeFile(partial, bytes);
|
|
214
|
+
await chmod(partial, 0o755);
|
|
215
|
+
await rename(partial, binary);
|
|
216
|
+
}
|
|
217
|
+
/** `yt-dlp -U` — the standalone build updates itself in place. Resolves to what it printed. */
|
|
218
|
+
export async function updateYtDlp(binary, run = runLines) {
|
|
219
|
+
const lines = [];
|
|
220
|
+
const { code, stderr } = await run(binary, ["-U"], (line) => lines.push(line));
|
|
221
|
+
const said = [...lines, ...stderr.split("\n")].map((l) => l.trim()).filter(Boolean);
|
|
222
|
+
if (code !== 0)
|
|
223
|
+
throw new Error(said.at(-1) ?? `yt-dlp -U exited ${code}`);
|
|
224
|
+
return said.at(-1) ?? "";
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Install yt-dlp when it is missing, else update it — the body of each app's `bun run yt-dlp`.
|
|
228
|
+
* Resolves to the sentence to print.
|
|
229
|
+
*/
|
|
230
|
+
export async function installOrUpdateYtDlp(binary) {
|
|
231
|
+
if (!(await isFile(binary))) {
|
|
232
|
+
await installYtDlp(binary);
|
|
233
|
+
return `installed the latest release at ${binary}`;
|
|
234
|
+
}
|
|
235
|
+
return updateYtDlp(binary);
|
|
236
|
+
}
|
|
237
|
+
const isFile = async (path) => {
|
|
238
|
+
try {
|
|
239
|
+
return (await stat(path)).isFile();
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* The fetcher an app's download agent is handed. See the header for what it heals by itself
|
|
247
|
+
* and what it leaves as a sentence.
|
|
248
|
+
*/
|
|
249
|
+
export function createYtDlp(options) {
|
|
250
|
+
const run = options.run ?? runLines;
|
|
251
|
+
const install = options.install ?? ((binary) => installYtDlp(binary));
|
|
252
|
+
const update = options.update ?? ((binary) => updateYtDlp(binary, run));
|
|
253
|
+
const now = options.now ?? Date.now;
|
|
254
|
+
const label = options.label ?? "[yt-dlp]";
|
|
255
|
+
const jsRuntime = options.jsRuntime ?? (process.versions.bun ? process.execPath : undefined);
|
|
256
|
+
let lastUpdate = Number.NEGATIVE_INFINITY;
|
|
257
|
+
const describe = (kind, detail, updated) => describeYtDlpFailure(kind, detail, { updated, fixCommand: options.fixCommand, profile: options.profile });
|
|
258
|
+
const ensureInstalled = async () => {
|
|
259
|
+
if (await isFile(options.binary))
|
|
260
|
+
return;
|
|
261
|
+
options.log?.(`${label} yt-dlp: not at ${options.binary} — installing the latest release`);
|
|
262
|
+
try {
|
|
263
|
+
await install(options.binary);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
267
|
+
throw new YtDlpError(describe("missing", detail, false), "missing");
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
/** One yt-dlp run into a fresh directory. Throws a classified error on failure. */
|
|
271
|
+
const attempt = async (videoId, onProgress) => {
|
|
272
|
+
const dir = await mkdtemp(join(options.tmpRoot ?? tmpdir(), "ytdlp-"));
|
|
273
|
+
const dispose = () => rm(dir, { recursive: true, force: true });
|
|
274
|
+
try {
|
|
275
|
+
let info = null;
|
|
276
|
+
const { code, stderr } = await run(options.binary, ytDlpArgs(videoId, dir, options.profile, {
|
|
277
|
+
...(options.cacheDir ? { cacheDir: options.cacheDir } : {}),
|
|
278
|
+
...(jsRuntime ? { jsRuntime } : {}),
|
|
279
|
+
}), (line) => {
|
|
280
|
+
const fraction = parseYtProgress(line);
|
|
281
|
+
if (fraction !== null)
|
|
282
|
+
onProgress?.(fraction);
|
|
283
|
+
info = parseYtInfo(line) ?? info;
|
|
284
|
+
});
|
|
285
|
+
const found = info;
|
|
286
|
+
if (code !== 0 || !found?.filepath) {
|
|
287
|
+
const kind = code === 0 ? "failed" : classifyYtDlpFailure(stderr);
|
|
288
|
+
throw new YtDlpError(code === 0 ? "yt-dlp finished without saying where the file went" : lastYtDlpError(stderr), kind);
|
|
289
|
+
}
|
|
290
|
+
if (!(await isFile(found.filepath)))
|
|
291
|
+
throw new YtDlpError(`yt-dlp named ${found.filepath} and it is not there`, "failed");
|
|
292
|
+
const extension = (found.ext || found.filepath.split(".").at(-1) || "bin").toLowerCase();
|
|
293
|
+
return { path: found.filepath, extension, info: found, dir, dispose };
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
await dispose();
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
return async (videoId, onProgress) => {
|
|
301
|
+
await ensureInstalled();
|
|
302
|
+
let updated = false;
|
|
303
|
+
let last = null;
|
|
304
|
+
for (let tries = 1; tries <= ATTEMPTS + 1; tries++) {
|
|
305
|
+
try {
|
|
306
|
+
return await attempt(videoId, onProgress);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
last =
|
|
310
|
+
error instanceof YtDlpError ? error : new YtDlpError(error instanceof Error ? error.message : String(error), "failed");
|
|
311
|
+
if (isFinalYtDlpFailure(last.kind))
|
|
312
|
+
break;
|
|
313
|
+
if (tries < ATTEMPTS)
|
|
314
|
+
continue;
|
|
315
|
+
// Out of plain retries. One more, after an update, if it reads as out of date.
|
|
316
|
+
if (last.kind === "outdated" && !updated && now() - lastUpdate >= SELF_UPDATE_EVERY_MS) {
|
|
317
|
+
lastUpdate = now();
|
|
318
|
+
try {
|
|
319
|
+
const said = await update(options.binary);
|
|
320
|
+
updated = true;
|
|
321
|
+
options.log?.(`${label} yt-dlp: updated after "${last.message}" — ${said}`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
catch (updateError) {
|
|
325
|
+
options.log?.(`${label} yt-dlp: update failed — ${updateError instanceof Error ? updateError.message : String(updateError)}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const failure = last ?? new YtDlpError("no attempt ran", "failed");
|
|
332
|
+
throw new YtDlpError(describe(failure.kind, failure.message, updated), failure.kind);
|
|
333
|
+
};
|
|
334
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.43.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -343,6 +343,18 @@
|
|
|
343
343
|
"source": "./src/server/sync/index.ts",
|
|
344
344
|
"import": "./dist/server/sync/index.js"
|
|
345
345
|
},
|
|
346
|
+
"./youtube": {
|
|
347
|
+
"types": "./dist/server/youtube/youtubeApi.d.ts",
|
|
348
|
+
"bun": "./src/server/youtube/youtubeApi.ts",
|
|
349
|
+
"source": "./src/server/youtube/youtubeApi.ts",
|
|
350
|
+
"import": "./dist/server/youtube/youtubeApi.js"
|
|
351
|
+
},
|
|
352
|
+
"./youtube/ytdlp": {
|
|
353
|
+
"types": "./dist/server/youtube/ytdlp.d.ts",
|
|
354
|
+
"bun": "./src/server/youtube/ytdlp.ts",
|
|
355
|
+
"source": "./src/server/youtube/ytdlp.ts",
|
|
356
|
+
"import": "./dist/server/youtube/ytdlp.js"
|
|
357
|
+
},
|
|
346
358
|
"./ws": {
|
|
347
359
|
"types": "./dist/server/ws/index.d.ts",
|
|
348
360
|
"bun": "./src/server/ws/index.ts",
|
|
@@ -225,6 +225,24 @@ const LEAVES = [
|
|
|
225
225
|
evaluates: 'argon2Cost',
|
|
226
226
|
siblings: ['src/server/satellite/door.ts'],
|
|
227
227
|
},
|
|
228
|
+
{
|
|
229
|
+
subpath: './youtube',
|
|
230
|
+
/**
|
|
231
|
+
* The YouTube Data API reader (4.43.0, forge task 2349) — out of Music so Collections lists
|
|
232
|
+
* the same playlists. Collections calls it from a Worker, so it may reach nothing but
|
|
233
|
+
* `fetch`; its one import is `import type` of `./google-oauth`'s `FetchLike`, which is erased.
|
|
234
|
+
*/
|
|
235
|
+
evaluates: 'createYouTubeApi',
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
subpath: './youtube/ytdlp',
|
|
239
|
+
/**
|
|
240
|
+
* The yt-dlp runner (4.43.0, forge task 2349) shared by Music's audio and Collections' video
|
|
241
|
+
* agents. Mac-only, and its promise is `node:*` and nothing else — no `cursedops`, so the
|
|
242
|
+
* caller says where the binary is.
|
|
243
|
+
*/
|
|
244
|
+
evaluates: 'createYtDlp',
|
|
245
|
+
},
|
|
228
246
|
] as const;
|
|
229
247
|
|
|
230
248
|
/**
|
|
@@ -124,3 +124,46 @@ describe('createHttpD1', () => {
|
|
|
124
124
|
expect(sent[0]?.body).toEqual({ sql: 'CREATE TABLE u (x); INSERT INTO u VALUES (1); INSERT INTO u VALUES (2)' });
|
|
125
125
|
});
|
|
126
126
|
});
|
|
127
|
+
|
|
128
|
+
describe('🔴 a request that never connected is retried; one that may have run is not', () => {
|
|
129
|
+
const refused = () => Object.assign(new TypeError('Unable to connect. Is the computer able to access the url?'), { code: 'ConnectionRefused' });
|
|
130
|
+
const flaky = (failures: Error[]) => {
|
|
131
|
+
let calls = 0;
|
|
132
|
+
const waits: number[] = [];
|
|
133
|
+
const fetchImpl = (async () => {
|
|
134
|
+
const fail = failures[calls++];
|
|
135
|
+
if (fail) throw fail;
|
|
136
|
+
return new Response(JSON.stringify({ success: true, errors: [], result: [{ results: [{ n: 1 }], success: true }] }));
|
|
137
|
+
}) as unknown as typeof fetch;
|
|
138
|
+
const db = createHttpD1({ accountId: 'a', databaseId: 'd', apiToken: 't', fetch: fetchImpl, sleep: async (ms) => void waits.push(ms) });
|
|
139
|
+
return { db, waits, calls: () => calls };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
test('2026-10-02, family dangling-media: ConnectionRefused on a wake, then the network is back — the read answers', async () => {
|
|
143
|
+
const { db, waits, calls } = flaky([refused(), refused()]);
|
|
144
|
+
expect(await db.prepare('SELECT 1 n').first('n')).toBe(1);
|
|
145
|
+
expect(calls()).toBe(3);
|
|
146
|
+
expect(waits).toEqual([2_000, 10_000]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('still refused after every wait: the original error, not a swallowed one', async () => {
|
|
150
|
+
const { db, waits } = flaky(Array.from({ length: 5 }, refused));
|
|
151
|
+
await expect(db.prepare('SELECT 1').all()).rejects.toThrow('Unable to connect');
|
|
152
|
+
expect(waits).toEqual([2_000, 10_000, 30_000, 60_000]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a reset after connecting may have run the write — thrown at once, never retried', async () => {
|
|
156
|
+
const reset = Object.assign(new TypeError('socket closed'), { code: 'ECONNRESET' });
|
|
157
|
+
const { db, calls } = flaky([reset]);
|
|
158
|
+
await expect(db.prepare('INSERT INTO t VALUES (1)').run()).rejects.toThrow('socket closed');
|
|
159
|
+
expect(calls()).toBe(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('an unresolvable host is a never-connected failure too, and a timeout is not', async () => {
|
|
163
|
+
const dns = Object.assign(new TypeError('getaddrinfo ENOTFOUND api.cloudflare.com'), { code: 'ENOTFOUND' });
|
|
164
|
+
expect(await flaky([dns]).db.prepare('SELECT 1 n').first('n')).toBe(1);
|
|
165
|
+
const timeout = flaky([new DOMException('The operation timed out.', 'TimeoutError')]);
|
|
166
|
+
await expect(timeout.db.prepare('SELECT 1').all()).rejects.toThrow('timed out');
|
|
167
|
+
expect(timeout.calls()).toBe(1);
|
|
168
|
+
});
|
|
169
|
+
});
|
package/src/server/d1/http.ts
CHANGED
|
@@ -53,6 +53,33 @@ export interface HttpD1Options {
|
|
|
53
53
|
fetch?: typeof fetch;
|
|
54
54
|
/** Defaults to `https://api.cloudflare.com/client/v4`. */
|
|
55
55
|
apiBase?: string;
|
|
56
|
+
/**
|
|
57
|
+
* The waits, in ms, before each retry of a request that never CONNECTED — see
|
|
58
|
+
* {@link neverConnected}. Default {@link CONNECT_RETRY_DELAYS_MS}; `[]` fails on the first.
|
|
59
|
+
*/
|
|
60
|
+
connectRetryDelaysMs?: readonly number[];
|
|
61
|
+
/** Injected for tests. Defaults to a `setTimeout` promise. */
|
|
62
|
+
sleep?: (ms: number) => Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* ~1m45s of waiting, which outlasts a Mac's network coming back after a wake. Measured
|
|
67
|
+
* 2026-10-02 16:15Z: family's once-a-day `dangling-media` job fired as the Mac woke, its first
|
|
68
|
+
* D1 read was `ConnectionRefused`, and the job exited 1 — red for a whole day over one blip.
|
|
69
|
+
*/
|
|
70
|
+
export const CONNECT_RETRY_DELAYS_MS: readonly number[] = [2_000, 10_000, 30_000, 60_000];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 🔴 `true` only for a failure that happened BEFORE the request reached Cloudflare — refused,
|
|
74
|
+
* unresolvable, unroutable — so retrying it cannot run a write twice. A reset or a timeout
|
|
75
|
+
* after connecting may have executed the statement, and is thrown as it is.
|
|
76
|
+
*/
|
|
77
|
+
export function neverConnected(err: unknown): boolean {
|
|
78
|
+
const code = (err as { code?: unknown } | null)?.code;
|
|
79
|
+
return (
|
|
80
|
+
typeof code === 'string' &&
|
|
81
|
+
['ConnectionRefused', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'FailedToOpenSocket', 'ENETUNREACH', 'EHOSTUNREACH', 'ENETDOWN'].includes(code)
|
|
82
|
+
);
|
|
56
83
|
}
|
|
57
84
|
|
|
58
85
|
interface WireResult {
|
|
@@ -97,14 +124,28 @@ export function httpD1Binding(options: HttpD1Options): D1BindingLike {
|
|
|
97
124
|
const doFetch = options.fetch ?? fetch;
|
|
98
125
|
const base = `${options.apiBase ?? 'https://api.cloudflare.com/client/v4'}/accounts/${options.accountId}/d1/database/${options.databaseId}`;
|
|
99
126
|
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
127
|
+
const retryDelays = options.connectRetryDelaysMs ?? CONNECT_RETRY_DELAYS_MS;
|
|
128
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
129
|
+
|
|
130
|
+
const send = async (path: '/query' | '/raw', body: unknown): Promise<Response> => {
|
|
131
|
+
for (let attempt = 0; ; attempt++) {
|
|
132
|
+
try {
|
|
133
|
+
return await doFetch(`${base}${path}`, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { authorization: `Bearer ${options.apiToken}`, 'content-type': 'application/json' },
|
|
136
|
+
body: JSON.stringify(body),
|
|
137
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
const wait = retryDelays[attempt];
|
|
141
|
+
if (wait === undefined || !neverConnected(err)) throw err;
|
|
142
|
+
await sleep(wait);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
100
146
|
|
|
101
147
|
const post = async (path: '/query' | '/raw', body: unknown): Promise<WireResult[]> => {
|
|
102
|
-
const response = await
|
|
103
|
-
method: 'POST',
|
|
104
|
-
headers: { authorization: `Bearer ${options.apiToken}`, 'content-type': 'application/json' },
|
|
105
|
-
body: JSON.stringify(body),
|
|
106
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
107
|
-
});
|
|
148
|
+
const response = await send(path, body);
|
|
108
149
|
const envelope = (await response.json().catch(() => null)) as Envelope | null;
|
|
109
150
|
if (!response.ok || !envelope || envelope.success === false) {
|
|
110
151
|
const message =
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The YouTube Data API reader (forge task 2349), against a fake Google — no request leaves.
|
|
3
|
+
*
|
|
4
|
+
* What is worth pinning: a page comes out normalized (no envelope, no tombstones), every page of
|
|
5
|
+
* a playlist is walked and a cap is SAID rather than silently applied, lengths are read in
|
|
6
|
+
* batches of 50, and a refusal carries the fix rather than a bare status.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { collectYouTubePages, createYouTubeApi, youtubeThumbnailUrl } from "./youtubeApi";
|
|
10
|
+
|
|
11
|
+
type Handler = (url: URL) => { status?: number; body: unknown };
|
|
12
|
+
|
|
13
|
+
const fakeGoogle = (handler: Handler) => {
|
|
14
|
+
const seen: URL[] = [];
|
|
15
|
+
const fetchImpl = async (input: string): Promise<Response> => {
|
|
16
|
+
const url = new URL(input);
|
|
17
|
+
seen.push(url);
|
|
18
|
+
const { status = 200, body } = handler(url);
|
|
19
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
20
|
+
};
|
|
21
|
+
return { seen, api: createYouTubeApi(fetchImpl) };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const item = (videoId: string, title = `Video ${videoId}`) => ({
|
|
25
|
+
snippet: { title, videoOwnerChannelTitle: "Chan", thumbnails: {} },
|
|
26
|
+
contentDetails: { videoId },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("createYouTubeApi", () => {
|
|
30
|
+
test("a playlist page is normalized, a tombstone is dropped, and a missing picture falls back to YouTube's", async () => {
|
|
31
|
+
const { api, seen } = fakeGoogle(() => ({
|
|
32
|
+
body: { items: [item("a1"), item("gone", "Deleted video")], nextPageToken: "p2" },
|
|
33
|
+
}));
|
|
34
|
+
const page = await api.playlistItems("tok", "PL1");
|
|
35
|
+
expect(page).toEqual({
|
|
36
|
+
status: "ok",
|
|
37
|
+
value: {
|
|
38
|
+
nextPageToken: "p2",
|
|
39
|
+
items: [
|
|
40
|
+
{
|
|
41
|
+
videoId: "a1",
|
|
42
|
+
title: "Video a1",
|
|
43
|
+
channelTitle: "Chan",
|
|
44
|
+
thumbnail: youtubeThumbnailUrl("a1"),
|
|
45
|
+
url: "https://www.youtube.com/watch?v=a1",
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
expect(seen[0]?.pathname).toBe("/youtube/v3/playlistItems");
|
|
51
|
+
expect(seen[0]?.searchParams.get("playlistId")).toBe("PL1");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("🔴 every page is walked, and a list stopped at the cap SAYS so", async () => {
|
|
55
|
+
const { api } = fakeGoogle((url) => {
|
|
56
|
+
const at = Number(url.searchParams.get("pageToken") ?? "0");
|
|
57
|
+
return { body: { items: [item(`v${at}`)], nextPageToken: String(at + 1) } };
|
|
58
|
+
});
|
|
59
|
+
const capped = await collectYouTubePages((pt) => api.playlistItems("tok", "PL1", pt), 3);
|
|
60
|
+
expect(capped).toMatchObject({ status: "ok", truncated: true });
|
|
61
|
+
expect(capped.status === "ok" ? capped.items.map((v) => v.videoId) : []).toEqual(["v0", "v1", "v2"]);
|
|
62
|
+
|
|
63
|
+
const { api: short } = fakeGoogle(() => ({ body: { items: [item("only")] } }));
|
|
64
|
+
expect(await collectYouTubePages((pt) => short.playlistItems("tok", "PL1", pt))).toMatchObject({
|
|
65
|
+
status: "ok",
|
|
66
|
+
truncated: false,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("lengths are read 50 ids at a time, and an ISO duration becomes seconds", async () => {
|
|
71
|
+
const { api, seen } = fakeGoogle((url) => ({
|
|
72
|
+
body: {
|
|
73
|
+
items: (url.searchParams.get("id") ?? "").split(",").map((id) => ({ id, contentDetails: { duration: "PT1H2M3S" } })),
|
|
74
|
+
},
|
|
75
|
+
}));
|
|
76
|
+
const ids = Array.from({ length: 51 }, (_, i) => `id${i}`);
|
|
77
|
+
const lengths = await api.durations("tok", ids);
|
|
78
|
+
expect(seen).toHaveLength(2);
|
|
79
|
+
expect(lengths.status === "ok" ? lengths.value.get("id50") : null).toBe(3723);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("🔴 a refusal names the fix — a 403 says quota or API-not-enabled, a 401 says reconnect", async () => {
|
|
83
|
+
const forbidden = await fakeGoogle(() => ({ status: 403, body: { error: { message: "quotaExceeded" } } })).api.playlists("tok");
|
|
84
|
+
expect(forbidden).toMatchObject({ status: "error", reason: "YouTube refused the read: quotaExceeded" });
|
|
85
|
+
expect(forbidden.status === "error" ? forbidden.fix : "").toMatch(/quota/);
|
|
86
|
+
|
|
87
|
+
const lapsed = await fakeGoogle(() => ({ status: 401, body: {} })).api.playlists("tok");
|
|
88
|
+
expect(lapsed.status === "error" ? lapsed.fix : "").toMatch(/Connect again/);
|
|
89
|
+
});
|
|
90
|
+
});
|