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.
@@ -0,0 +1,259 @@
1
+ /**
2
+ * The shared yt-dlp runner (forge task 2349 — these specs came over from Music's own runner), with
3
+ * yt-dlp itself injected — nothing here reaches YouTube.
4
+ *
5
+ * What is worth pinning: that progress is read from the lines this file asked for and from
6
+ * nothing else, that a failure is sorted by what fixes it, and that the two self-healing paths
7
+ * (install when missing, update when out of date) run exactly when they should and say so
8
+ * when they did not help.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+ import { chmod, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import {
15
+ classifyYtDlpFailure,
16
+ createYtDlp,
17
+ installOrUpdateYtDlp,
18
+ parseYtInfo,
19
+ parseYtProgress,
20
+ type RunLines,
21
+ YtDlpError,
22
+ type YtDlpProfile,
23
+ ytDlpArgs,
24
+ ytDlpPath,
25
+ } from "./ytdlp";
26
+
27
+ /** Music's profile, as Music hands it in — audio as YouTube serves it. */
28
+ const AUDIO: YtDlpProfile = {
29
+ format: "bestaudio[ext=webm][acodec=opus]/bestaudio[ext=m4a]",
30
+ maxFilesize: "200M",
31
+ noun: "That video's audio",
32
+ };
33
+ /** Collections' — H.264 + AAC merged into MP4. */
34
+ const VIDEO: YtDlpProfile = {
35
+ format: "bv*[vcodec^=avc1][height<=1080]+ba[ext=m4a]/b[ext=mp4]",
36
+ extraArgs: ["--merge-output-format", "mp4"],
37
+ maxFilesize: "8G",
38
+ noun: "That video",
39
+ };
40
+ const FIX = "cd /somewhere && bun run yt-dlp";
41
+
42
+ describe("reading what yt-dlp prints", () => {
43
+ test("a progress line is a fraction of the exact total", () => {
44
+ expect(parseYtProgress("ytprogress 2551 10202 NA")).toBeCloseTo(0.25, 3);
45
+ });
46
+
47
+ test("the estimate stands in when there is no exact total", () => {
48
+ expect(parseYtProgress("ytprogress 50 NA 200")).toBe(0.25);
49
+ });
50
+
51
+ test("🔴 no total and no estimate is no progress, never a made-up one", () => {
52
+ expect(parseYtProgress("ytprogress 50 NA NA")).toBeNull();
53
+ });
54
+
55
+ test("yt-dlp's own chatter is not progress", () => {
56
+ expect(parseYtProgress("[download] 41.1% of 9.73MiB")).toBeNull();
57
+ expect(parseYtInfo("[info] aqz: Downloading 1 format(s): 251")).toBeNull();
58
+ });
59
+
60
+ test("the info line carries the file's path and what it is", () => {
61
+ expect(parseYtInfo('ytinfo {"id":"abc","ext":"webm","filepath":"/t/abc.webm"}')).toMatchObject({
62
+ id: "abc",
63
+ ext: "webm",
64
+ filepath: "/t/abc.webm",
65
+ });
66
+ expect(parseYtInfo("ytinfo {not json")).toBeNull();
67
+ });
68
+
69
+ test("🔴 the profile's format and extra args are what yt-dlp is given, and the video is one video", () => {
70
+ const args = ytDlpArgs("abc", "/out", VIDEO, { jsRuntime: "/bin/bun" });
71
+ expect(args[args.indexOf("-f") + 1]).toBe(VIDEO.format);
72
+ expect(args.slice(args.indexOf("-f") + 2, args.indexOf("-f") + 4)).toEqual(["--merge-output-format", "mp4"]);
73
+ expect(args[args.indexOf("--max-filesize") + 1]).toBe("8G");
74
+ expect(args).toContain("--no-playlist");
75
+ expect(args.slice(0, 2)).toEqual(["--js-runtimes", "bun:/bin/bun"]);
76
+ expect(args.at(-1)).toBe("https://www.youtube.com/watch?v=abc");
77
+ expect(args.at(-2)).toBe("--");
78
+ expect(ytDlpArgs("abc", "/out", AUDIO)).not.toContain("--merge-output-format");
79
+ });
80
+
81
+ test("the binary is one per generation, under its state root", () => {
82
+ expect(ytDlpPath("/state")).toBe("/state/bin/yt-dlp");
83
+ });
84
+ });
85
+
86
+ describe("sorting a failure by what fixes it", () => {
87
+ const err = (line: string) => `WARNING: something\nERROR: ${line}\n`;
88
+ test.each([
89
+ ["[youtube] abc: Video unavailable. This video is private", "unavailable"],
90
+ ["[youtube] abc: Sign in to confirm your age", "unavailable"],
91
+ ["[youtube] abc: Sign in to confirm you're not a bot", "blocked"],
92
+ ["unable to download video data: HTTP Error 403: Forbidden", "outdated"],
93
+ ["[youtube] abc: Unable to extract nsig function code", "outdated"],
94
+ ["[youtube] abc: Requested format is not available", "outdated"],
95
+ ["File is larger than max-filesize (300000000 bytes > 209715200 bytes). Aborting.", "too-big"],
96
+ ["something nobody has seen before", "failed"],
97
+ ] as const)("%s → %s", (line, kind) => {
98
+ expect(classifyYtDlpFailure(err(line))).toBe(kind);
99
+ });
100
+ });
101
+
102
+ describe("the fetcher", () => {
103
+ let dir: string;
104
+ let binary: string;
105
+ let runs: string[][];
106
+ let updates: number;
107
+ let installs: number;
108
+
109
+ beforeEach(async () => {
110
+ dir = await mkdtemp(join(tmpdir(), "ytdlp-test-"));
111
+ binary = join(dir, "yt-dlp");
112
+ await writeFile(binary, "#!/bin/sh\n");
113
+ runs = [];
114
+ updates = 0;
115
+ installs = 0;
116
+ });
117
+ afterEach(async () => {
118
+ await rm(dir, { recursive: true, force: true });
119
+ });
120
+
121
+ /** A yt-dlp that answers each run from the script, in order; the last answer repeats. */
122
+ const scripted =
123
+ (...answers: ("ok" | string)[]): RunLines =>
124
+ async (_bin, args, onLine) => {
125
+ runs.push(args);
126
+ const answer = answers[Math.min(runs.length - 1, answers.length - 1)] as string;
127
+ if (answer !== "ok") return { code: 1, stderr: `ERROR: ${answer}\n` };
128
+ const out = args[args.indexOf("-o") + 1] as string;
129
+ const file = out.replace("%(id)s.%(ext)s", "abc.webm");
130
+ await writeFile(file, new Uint8Array([0x1a, 0x45, 0xdf, 0xa3, 1, 2, 3]));
131
+ onLine("ytprogress 1 4 NA");
132
+ onLine("[download] 100%");
133
+ onLine(`ytinfo ${JSON.stringify({ id: "abc", title: "T", ext: "webm", filepath: file })}`);
134
+ return { code: 0, stderr: "" };
135
+ };
136
+
137
+ const make = (run: RunLines, profile: YtDlpProfile = AUDIO) =>
138
+ createYtDlp({
139
+ binary,
140
+ profile,
141
+ fixCommand: FIX,
142
+ tmpRoot: dir,
143
+ jsRuntime: "/bin/bun",
144
+ run,
145
+ install: async () => {
146
+ installs++;
147
+ },
148
+ update: async () => {
149
+ updates++;
150
+ return "Updated yt-dlp to stable@2099.01.01";
151
+ },
152
+ });
153
+
154
+ test("a download hands back the file, the extension and the info, and reports progress", async () => {
155
+ const fractions: number[] = [];
156
+ const audio = await make(scripted("ok"))("abc", (f) => fractions.push(f));
157
+ expect(audio.extension).toBe("webm");
158
+ expect(audio.info.title).toBe("T");
159
+ expect(new Uint8Array(await readFile(audio.path)).slice(0, 4)).toEqual(new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]));
160
+ expect(fractions).toEqual([0.25]);
161
+ expect(installs).toBe(0);
162
+ // 🔴 The file is the caller's until it disposes — then it and its directory are gone.
163
+ await audio.dispose();
164
+ expect(await stat(audio.dir).catch(() => null)).toBeNull();
165
+ });
166
+
167
+ test("🔴 a failed run leaves no temporary directory behind", async () => {
168
+ await make(scripted("[youtube] abc: Private video"))("abc").catch(() => undefined);
169
+ expect((await readdir(dir)).filter((name) => name.startsWith("ytdlp-"))).toEqual([]);
170
+ });
171
+
172
+ test("the ceiling sentence uses the profile's own noun and size", async () => {
173
+ const failure = await make(scripted("File is larger than max-filesize (9 > 8). Aborting."), VIDEO)("abc").then(
174
+ () => {
175
+ throw new Error("expected a failure");
176
+ },
177
+ (e: unknown) => e as YtDlpError,
178
+ );
179
+ expect(failure.kind).toBe("too-big");
180
+ expect(failure.message).toBe("That video is over this app's 8GB ceiling for one download.");
181
+ });
182
+
183
+ test("an intermittent 403 is retried, and a retry that works is simply the answer", async () => {
184
+ const audio = await make(scripted("HTTP Error 403: Forbidden", "ok"))("abc");
185
+ expect(audio.info.id).toBe("abc");
186
+ expect(runs).toHaveLength(2);
187
+ expect(updates).toBe(0);
188
+ });
189
+
190
+ test("🔴 out of date: retried, then updated ONCE, then tried again — and the update is what fixed it", async () => {
191
+ const audio = await make(scripted("Unable to extract nsig", "Unable to extract nsig", "Unable to extract nsig", "ok"))("abc");
192
+ expect(audio.info.id).toBe("abc");
193
+ expect(updates).toBe(1);
194
+ expect(runs).toHaveLength(4);
195
+ });
196
+
197
+ test("🔴 out of date and the update did not help: the sentence names the command to run", async () => {
198
+ const failure = await make(scripted("Unable to extract nsig"))("abc").catch((e: unknown) => e);
199
+ expect(failure).toBeInstanceOf(YtDlpError);
200
+ expect((failure as YtDlpError).kind).toBe("outdated");
201
+ expect((failure as YtDlpError).message).toMatch(/out of date/);
202
+ expect((failure as YtDlpError).message).toMatch(/already updated it/);
203
+ expect((failure as YtDlpError).message).toMatch(/run: cd \/somewhere && bun run yt-dlp$/);
204
+ expect(updates).toBe(1);
205
+ });
206
+
207
+ test("the self-update runs at most once an hour, however many videos fail", async () => {
208
+ const fetchAudio = make(scripted("Unable to extract nsig"));
209
+ await fetchAudio("abc").catch(() => undefined);
210
+ const second = await fetchAudio("abc").then(() => { throw new Error("expected a failure"); }, (e: unknown) => e as YtDlpError);
211
+ expect(updates).toBe(1);
212
+ // Not updated THIS time, so the sentence tells the owner to do it rather than claiming it was done.
213
+ expect(second.message).toMatch(/Run: cd /);
214
+ });
215
+
216
+ test("a private video is not retried and not a reason to update", async () => {
217
+ const failure = await make(scripted("[youtube] abc: Private video"))("abc").then(() => { throw new Error("expected a failure"); }, (e: unknown) => e as YtDlpError);
218
+ expect(failure.kind).toBe("unavailable");
219
+ expect(runs).toHaveLength(1);
220
+ expect(updates).toBe(0);
221
+ });
222
+
223
+ test("🔴 a missing binary is installed before the first run", async () => {
224
+ await rm(binary);
225
+ await make(scripted("ok"))("abc");
226
+ expect(installs).toBe(1);
227
+ });
228
+
229
+ test("🔴 an install that fails says so and names the command", async () => {
230
+ await rm(binary);
231
+ const failure = await createYtDlp({
232
+ binary,
233
+ profile: AUDIO,
234
+ fixCommand: FIX,
235
+ tmpRoot: dir,
236
+ run: scripted("ok"),
237
+ install: async () => {
238
+ throw new Error("GitHub answered 503");
239
+ },
240
+ })("abc").then(() => { throw new Error("expected a failure"); }, (e: unknown) => e as YtDlpError);
241
+ expect(failure.kind).toBe("missing");
242
+ expect(failure.message).toMatch(/GitHub answered 503.*bun run yt-dlp/);
243
+ expect(runs).toHaveLength(0);
244
+ });
245
+ });
246
+
247
+ describe("bun run yt-dlp", () => {
248
+ test("🔴 an installed binary is UPDATED, never re-downloaded over itself", async () => {
249
+ const dir = await mkdtemp(join(tmpdir(), "ytdlp-cli-"));
250
+ try {
251
+ const binary = join(dir, "yt-dlp");
252
+ await writeFile(binary, "#!/bin/sh\necho 'yt-dlp is up to date (stable@2099.01.01)'\n");
253
+ await chmod(binary, 0o755);
254
+ expect(await installOrUpdateYtDlp(binary)).toBe("yt-dlp is up to date (stable@2099.01.01)");
255
+ } finally {
256
+ await rm(dir, { recursive: true, force: true });
257
+ }
258
+ });
259
+ });