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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { Subprocess } from 'bun';
|
|
2
3
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
3
4
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
5
|
import { tmpdir } from 'node:os';
|
|
@@ -18,11 +19,16 @@ const BS_PRESENT = BS_DIR !== '' && existsSync(path.join(BS_DIR, 'src', 'index.t
|
|
|
18
19
|
|
|
19
20
|
// SKIPPED until a real binary-server checkout is present: point BINARY_SERVER_DIR at one.
|
|
20
21
|
// There is deliberately no default — a published package must not go looking in one machine's
|
|
21
|
-
// directory layout
|
|
22
|
+
// directory layout. In this generation that checkout is `$FORGE/apps/binary-server`.
|
|
23
|
+
// BS_ALLOW_NEW_STORE=1: the spawned server refuses to invent a store it was not given, and this
|
|
24
|
+
// throwaway one is exactly a store it should create.
|
|
25
|
+
// BS_SECRETS_FILE=/dev/null: otherwise it folds in this machine's binary-server.env — the real R2
|
|
26
|
+
// keys and passcode — and a throwaway store has no business holding them.
|
|
22
27
|
describe.skipIf(!BS_PRESENT)('transcode API → real binary-server (integration)', () => {
|
|
23
28
|
let port: number;
|
|
24
29
|
let storeDir: string;
|
|
25
30
|
let server: TestServer | null = null;
|
|
31
|
+
let worker: Subprocess | null = null;
|
|
26
32
|
let tmpData: string;
|
|
27
33
|
const INTERNAL_SECRET = 'test-transcode-secret';
|
|
28
34
|
|
|
@@ -32,24 +38,37 @@ describe.skipIf(!BS_PRESENT)('transcode API → real binary-server (integration)
|
|
|
32
38
|
mkdirSync(storeDir, { recursive: true });
|
|
33
39
|
|
|
34
40
|
port = await pickFreePort();
|
|
41
|
+
const env = {
|
|
42
|
+
PORT: String(port),
|
|
43
|
+
HOST: '127.0.0.1',
|
|
44
|
+
STORAGE_ROOT: storeDir,
|
|
45
|
+
DB_PATH: path.join(tmpData, 'test.sqlite'),
|
|
46
|
+
BS_ALLOW_NEW_STORE: '1',
|
|
47
|
+
BS_SECRETS_FILE: '/dev/null',
|
|
48
|
+
INTERNAL_SECRET,
|
|
49
|
+
NODE_ENV: 'test',
|
|
50
|
+
};
|
|
35
51
|
server = await startTestServer({
|
|
36
52
|
cwd: BS_DIR,
|
|
37
53
|
cmd: ['bun', path.join(BS_DIR, 'src', 'index.ts')],
|
|
38
54
|
port,
|
|
39
|
-
env
|
|
40
|
-
PORT: String(port),
|
|
41
|
-
HOST: '127.0.0.1',
|
|
42
|
-
STORAGE_ROOT: storeDir,
|
|
43
|
-
DB_PATH: path.join(tmpData, 'test.sqlite'),
|
|
44
|
-
INTERNAL_SECRET,
|
|
45
|
-
NODE_ENV: 'test',
|
|
46
|
-
},
|
|
55
|
+
env,
|
|
47
56
|
healthPath: '/healthz',
|
|
48
57
|
timeoutMs: 90_000,
|
|
49
58
|
});
|
|
59
|
+
// The store only ENQUEUES a job; binary-server's processing worker (its own process since
|
|
60
|
+
// bs task 2694) leases and runs it. Without one, every job stays `pending` forever.
|
|
61
|
+
worker = Bun.spawn(['bun', path.join(BS_DIR, 'src', 'processingWorker.ts')], {
|
|
62
|
+
cwd: BS_DIR,
|
|
63
|
+
env: { ...process.env, ...env },
|
|
64
|
+
stdout: 'inherit',
|
|
65
|
+
stderr: 'inherit',
|
|
66
|
+
});
|
|
50
67
|
}, 90_000);
|
|
51
68
|
|
|
52
69
|
afterAll(async () => {
|
|
70
|
+
worker?.kill();
|
|
71
|
+
await worker?.exited;
|
|
53
72
|
await server?.stop();
|
|
54
73
|
await rm(tmpData, { recursive: true, force: true }).catch(() => void 0);
|
|
55
74
|
});
|
|
@@ -118,7 +137,9 @@ describe.skipIf(!BS_PRESENT)('transcode API → real binary-server (integration)
|
|
|
118
137
|
await Bun.sleep(100);
|
|
119
138
|
}
|
|
120
139
|
expect(status).toBe('failed');
|
|
121
|
-
|
|
140
|
+
// Past bun's 5 s default: a timeout kills the spawned server, and the tests after this one
|
|
141
|
+
// then fail at `fetch` instead of here, naming the wrong thing.
|
|
142
|
+
}, 15_000);
|
|
122
143
|
|
|
123
144
|
test('GET /api/transcode/:jobId returns 404 for unknown job', async () => {
|
|
124
145
|
const res = await fetch(`http://localhost:${port}/api/transcode/nonexistent-job-id`, {
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|