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,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/youtube` — the YouTube Data API v3, READ half only, normalized.
|
|
3
|
+
*
|
|
4
|
+
* Arrived in 4.43.0 (forge task 2349) from Music (`apps/music/src/server/tube/youtubeApi.ts`),
|
|
5
|
+
* where it was already catalog-agnostic, so Collections could list the same owner's playlists
|
|
6
|
+
* without a second copy. Worker-safe: it is `fetch` and nothing else.
|
|
7
|
+
*
|
|
8
|
+
* 🔴 **It never writes to YouTube.** The scope a consumer asks for is
|
|
9
|
+
* {@link YOUTUBE_READONLY_SCOPE}, and there is no code path here that could create, reorder or
|
|
10
|
+
* delete anything in the owner's account. The failure mode of a write-back mistake is
|
|
11
|
+
* destroying a playlist on a service no app here owns.
|
|
12
|
+
*
|
|
13
|
+
* ── Quota ────────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
* Every call costs Data API quota (10,000 units/day by default; a `playlistItems` page costs 1,
|
|
15
|
+
* a `search` 100 — which is why there is no search). Playback is not the Data API, so a
|
|
16
|
+
* consumer reads once, when the owner asks, and never on a timer.
|
|
17
|
+
*
|
|
18
|
+
* Every read that fails says why AND what to do ({@link TubeApiResult}) — the same law
|
|
19
|
+
* `./google-oauth` runs on. Normalized shapes come out, so no route and no browser ever touches
|
|
20
|
+
* a Google response envelope.
|
|
21
|
+
*/
|
|
22
|
+
import type { FetchLike } from "../google-oauth/googleOAuth";
|
|
23
|
+
|
|
24
|
+
const API_BASE = "https://www.googleapis.com/youtube/v3";
|
|
25
|
+
|
|
26
|
+
/** YouTube's own still for a video — the fallback when a playlist row carries no thumbnail. */
|
|
27
|
+
export const youtubeThumbnailUrl = (videoId: string): string =>
|
|
28
|
+
videoId ? `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg` : "";
|
|
29
|
+
|
|
30
|
+
/** Read-only, and nothing else. See the header. */
|
|
31
|
+
export const YOUTUBE_READONLY_SCOPE = "https://www.googleapis.com/auth/youtube.readonly";
|
|
32
|
+
|
|
33
|
+
export interface TubeApiVideo {
|
|
34
|
+
videoId: string;
|
|
35
|
+
title: string;
|
|
36
|
+
channelTitle: string;
|
|
37
|
+
thumbnail: string;
|
|
38
|
+
url: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TubeApiPlaylist {
|
|
42
|
+
id: string;
|
|
43
|
+
title: string;
|
|
44
|
+
description: string;
|
|
45
|
+
thumbnail: string;
|
|
46
|
+
itemCount: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TubeApiChannel {
|
|
50
|
+
id: string;
|
|
51
|
+
title: string;
|
|
52
|
+
thumbnail: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TubePage<T> {
|
|
56
|
+
items: T[];
|
|
57
|
+
nextPageToken?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A read that failed says why and what to do — the same law the token store runs on. */
|
|
61
|
+
export type TubeApiResult<T> =
|
|
62
|
+
| { status: "ok"; value: T }
|
|
63
|
+
| { status: "error"; reason: string; fix: string };
|
|
64
|
+
|
|
65
|
+
type Json = Record<string, unknown>;
|
|
66
|
+
type Thumbs = Record<string, { url?: string } | undefined> | undefined;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `medium` first, deliberately. `default` is 120×90 and looks like a thumbnail from 2009 on
|
|
70
|
+
* a retina phone; `maxres` does not exist for most videos, so preferring it would leave
|
|
71
|
+
* half a list with no picture at all.
|
|
72
|
+
*/
|
|
73
|
+
const pickThumb = (thumbs: Thumbs): string =>
|
|
74
|
+
thumbs?.medium?.url || thumbs?.high?.url || thumbs?.standard?.url || thumbs?.default?.url || "";
|
|
75
|
+
|
|
76
|
+
const watchUrl = (videoId: string): string => `https://www.youtube.com/watch?v=${videoId}`;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A playlist keeps a tombstone for a video the uploader deleted or made private. It has a
|
|
80
|
+
* title and a thumbnail and NOTHING that will ever play, so importing it would put dead
|
|
81
|
+
* rows in the owner's list that look exactly like live ones until pressed.
|
|
82
|
+
*/
|
|
83
|
+
const UNPLAYABLE_TITLES = new Set(["Private video", "Deleted video"]);
|
|
84
|
+
|
|
85
|
+
async function get(
|
|
86
|
+
accessToken: string,
|
|
87
|
+
path: string,
|
|
88
|
+
params: Record<string, string>,
|
|
89
|
+
fetchImpl: FetchLike,
|
|
90
|
+
): Promise<TubeApiResult<Json>> {
|
|
91
|
+
let res: Response;
|
|
92
|
+
try {
|
|
93
|
+
res = await fetchImpl(`${API_BASE}/${path}?${new URLSearchParams(params).toString()}`, {
|
|
94
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
95
|
+
signal: AbortSignal.timeout(30_000),
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return {
|
|
99
|
+
status: "error",
|
|
100
|
+
reason: `could not reach YouTube: ${err instanceof Error ? err.message : String(err)}`,
|
|
101
|
+
fix: "check this machine's internet connection and try again",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const body = (await res.json().catch(() => ({}))) as Json;
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
const detail = ((body.error as Json | undefined)?.message as string) || `HTTP ${res.status}`;
|
|
107
|
+
// 403 is overwhelmingly one of two things and they have different fixes, so the
|
|
108
|
+
// message says both rather than making the owner guess which.
|
|
109
|
+
if (res.status === 403) {
|
|
110
|
+
return {
|
|
111
|
+
status: "error",
|
|
112
|
+
reason: `YouTube refused the read: ${detail}`,
|
|
113
|
+
fix: "this is either the daily Data API quota (it resets at midnight Pacific) or the YouTube Data API v3 not being enabled for this Google Cloud project",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
status: "error",
|
|
118
|
+
reason: `YouTube refused the read: ${detail}`,
|
|
119
|
+
fix:
|
|
120
|
+
res.status === 401
|
|
121
|
+
? "the connection has lapsed — press Disconnect and Connect again"
|
|
122
|
+
: "try again; if it persists, check the YouTube API status",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return { status: "ok", value: body };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const rows = (body: Json): Json[] => (Array.isArray(body.items) ? (body.items as Json[]) : []);
|
|
129
|
+
const nextToken = (body: Json): { nextPageToken?: string } =>
|
|
130
|
+
typeof body.nextPageToken === "string" ? { nextPageToken: body.nextPageToken } : {};
|
|
131
|
+
|
|
132
|
+
/** `PT1H2M3S` → 3723. YouTube's ISO 8601 durations never carry years or months. */
|
|
133
|
+
function parseIsoDuration(value: string): number | null {
|
|
134
|
+
const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(value);
|
|
135
|
+
if (!m || value === "P" || value.endsWith("T")) return null;
|
|
136
|
+
const [, d, h, min, s] = m.map((x) => Number(x ?? 0));
|
|
137
|
+
return (d as number) * 86_400 + (h as number) * 3600 + (min as number) * 60 + (s as number);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createYouTubeApi(fetchImpl: FetchLike = fetch) {
|
|
141
|
+
return {
|
|
142
|
+
/** Who this grant belongs to — the name shown beside "Connected". */
|
|
143
|
+
async channel(accessToken: string): Promise<TubeApiResult<TubeApiChannel | null>> {
|
|
144
|
+
const res = await get(accessToken, "channels", { part: "snippet", mine: "true" }, fetchImpl);
|
|
145
|
+
if (res.status !== "ok") return res;
|
|
146
|
+
const first = rows(res.value)[0];
|
|
147
|
+
if (!first) return { status: "ok", value: null };
|
|
148
|
+
const snippet = (first.snippet as Json) ?? {};
|
|
149
|
+
return {
|
|
150
|
+
status: "ok",
|
|
151
|
+
value: {
|
|
152
|
+
id: String(first.id ?? ""),
|
|
153
|
+
title: String(snippet.title ?? ""),
|
|
154
|
+
thumbnail: pickThumb(snippet.thumbnails as Thumbs),
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async playlists(
|
|
160
|
+
accessToken: string,
|
|
161
|
+
pageToken?: string,
|
|
162
|
+
): Promise<TubeApiResult<TubePage<TubeApiPlaylist>>> {
|
|
163
|
+
const res = await get(
|
|
164
|
+
accessToken,
|
|
165
|
+
"playlists",
|
|
166
|
+
{
|
|
167
|
+
part: "snippet,contentDetails",
|
|
168
|
+
mine: "true",
|
|
169
|
+
maxResults: "50",
|
|
170
|
+
...(pageToken ? { pageToken } : {}),
|
|
171
|
+
},
|
|
172
|
+
fetchImpl,
|
|
173
|
+
);
|
|
174
|
+
if (res.status !== "ok") return res;
|
|
175
|
+
return {
|
|
176
|
+
status: "ok",
|
|
177
|
+
value: {
|
|
178
|
+
...nextToken(res.value),
|
|
179
|
+
items: rows(res.value).map((p) => {
|
|
180
|
+
const snippet = (p.snippet as Json) ?? {};
|
|
181
|
+
const details = (p.contentDetails as Json) ?? {};
|
|
182
|
+
return {
|
|
183
|
+
id: String(p.id ?? ""),
|
|
184
|
+
title: String(snippet.title ?? ""),
|
|
185
|
+
description: String(snippet.description ?? ""),
|
|
186
|
+
thumbnail: pickThumb(snippet.thumbnails as Thumbs),
|
|
187
|
+
itemCount: typeof details.itemCount === "number" ? details.itemCount : 0,
|
|
188
|
+
};
|
|
189
|
+
}),
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async playlistItems(
|
|
195
|
+
accessToken: string,
|
|
196
|
+
playlistId: string,
|
|
197
|
+
pageToken?: string,
|
|
198
|
+
): Promise<TubeApiResult<TubePage<TubeApiVideo>>> {
|
|
199
|
+
const res = await get(
|
|
200
|
+
accessToken,
|
|
201
|
+
"playlistItems",
|
|
202
|
+
{
|
|
203
|
+
part: "snippet,contentDetails",
|
|
204
|
+
playlistId,
|
|
205
|
+
maxResults: "50",
|
|
206
|
+
...(pageToken ? { pageToken } : {}),
|
|
207
|
+
},
|
|
208
|
+
fetchImpl,
|
|
209
|
+
);
|
|
210
|
+
if (res.status !== "ok") return res;
|
|
211
|
+
return {
|
|
212
|
+
status: "ok",
|
|
213
|
+
value: {
|
|
214
|
+
...nextToken(res.value),
|
|
215
|
+
items: rows(res.value)
|
|
216
|
+
.map((item): TubeApiVideo => {
|
|
217
|
+
const snippet = (item.snippet as Json) ?? {};
|
|
218
|
+
const details = (item.contentDetails as Json) ?? {};
|
|
219
|
+
const resource = snippet.resourceId as Json | undefined;
|
|
220
|
+
const videoId = String(details.videoId ?? resource?.videoId ?? "");
|
|
221
|
+
return {
|
|
222
|
+
videoId,
|
|
223
|
+
title: String(snippet.title ?? ""),
|
|
224
|
+
channelTitle: String(snippet.videoOwnerChannelTitle ?? ""),
|
|
225
|
+
thumbnail: pickThumb(snippet.thumbnails as Thumbs) || youtubeThumbnailUrl(videoId),
|
|
226
|
+
url: watchUrl(videoId),
|
|
227
|
+
};
|
|
228
|
+
})
|
|
229
|
+
.filter((v) => v.videoId && !UNPLAYABLE_TITLES.has(v.title)),
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* How long each video is, in seconds — `playlistItems` does not say. Up to 50 ids per
|
|
236
|
+
* call (1 quota unit each call), so a 200-video playlist costs four. A video YouTube no
|
|
237
|
+
* longer returns (private since, deleted) is simply absent from the map.
|
|
238
|
+
*/
|
|
239
|
+
async durations(accessToken: string, videoIds: readonly string[]): Promise<TubeApiResult<Map<string, number>>> {
|
|
240
|
+
const out = new Map<string, number>();
|
|
241
|
+
for (let at = 0; at < videoIds.length; at += 50) {
|
|
242
|
+
const res = await get(
|
|
243
|
+
accessToken,
|
|
244
|
+
"videos",
|
|
245
|
+
{ part: "contentDetails", id: videoIds.slice(at, at + 50).join(","), maxResults: "50" },
|
|
246
|
+
fetchImpl,
|
|
247
|
+
);
|
|
248
|
+
if (res.status !== "ok") return res;
|
|
249
|
+
for (const v of rows(res.value)) {
|
|
250
|
+
const seconds = parseIsoDuration(String((v.contentDetails as Json)?.duration ?? ""));
|
|
251
|
+
if (seconds !== null) out.set(String(v.id ?? ""), seconds);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { status: "ok", value: out };
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Liked videos. Not a playlist read: the "LL" pseudo-playlist is not reliably
|
|
259
|
+
* listable through `playlistItems`, whereas `videos?myRating=like` is the documented
|
|
260
|
+
* way to ask and costs the same.
|
|
261
|
+
*/
|
|
262
|
+
async liked(
|
|
263
|
+
accessToken: string,
|
|
264
|
+
pageToken?: string,
|
|
265
|
+
): Promise<TubeApiResult<TubePage<TubeApiVideo>>> {
|
|
266
|
+
const res = await get(
|
|
267
|
+
accessToken,
|
|
268
|
+
"videos",
|
|
269
|
+
{
|
|
270
|
+
part: "snippet",
|
|
271
|
+
myRating: "like",
|
|
272
|
+
maxResults: "50",
|
|
273
|
+
...(pageToken ? { pageToken } : {}),
|
|
274
|
+
},
|
|
275
|
+
fetchImpl,
|
|
276
|
+
);
|
|
277
|
+
if (res.status !== "ok") return res;
|
|
278
|
+
return {
|
|
279
|
+
status: "ok",
|
|
280
|
+
value: {
|
|
281
|
+
...nextToken(res.value),
|
|
282
|
+
items: rows(res.value)
|
|
283
|
+
.map((v): TubeApiVideo => {
|
|
284
|
+
const snippet = (v.snippet as Json) ?? {};
|
|
285
|
+
const videoId = String(v.id ?? "");
|
|
286
|
+
return {
|
|
287
|
+
videoId,
|
|
288
|
+
title: String(snippet.title ?? ""),
|
|
289
|
+
channelTitle: String(snippet.channelTitle ?? ""),
|
|
290
|
+
thumbnail: pickThumb(snippet.thumbnails as Thumbs) || youtubeThumbnailUrl(videoId),
|
|
291
|
+
url: watchUrl(videoId),
|
|
292
|
+
};
|
|
293
|
+
})
|
|
294
|
+
.filter((v) => v.videoId),
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export type YouTubeApi = ReturnType<typeof createYouTubeApi>;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Walk every page of a paginated read, stopping at `maxPages` and SAYING SO (`truncated`).
|
|
305
|
+
*
|
|
306
|
+
* A list that quietly stops at the cap is indistinguishable from one that is exactly that long,
|
|
307
|
+
* so the cap is reported. 40 pages of 50 is 2,000 videos — larger than any real playlist, and
|
|
308
|
+
* small enough that a pathological account cannot turn one button into a thousand API calls.
|
|
309
|
+
*/
|
|
310
|
+
export async function collectYouTubePages<T>(
|
|
311
|
+
fetchPage: (pageToken?: string) => Promise<TubeApiResult<TubePage<T>>>,
|
|
312
|
+
maxPages = 40,
|
|
313
|
+
): Promise<{ status: "ok"; items: T[]; truncated: boolean } | { status: "error"; reason: string; fix: string }> {
|
|
314
|
+
const items: T[] = [];
|
|
315
|
+
let pageToken: string | undefined;
|
|
316
|
+
let pages = 0;
|
|
317
|
+
do {
|
|
318
|
+
const page = await fetchPage(pageToken);
|
|
319
|
+
if (page.status !== "ok") return page;
|
|
320
|
+
items.push(...page.value.items);
|
|
321
|
+
pageToken = page.value.nextPageToken;
|
|
322
|
+
pages += 1;
|
|
323
|
+
} while (pageToken && pages < maxPages);
|
|
324
|
+
return { status: "ok", items, truncated: Boolean(pageToken) };
|
|
325
|
+
}
|
|
@@ -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
|
+
});
|