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.
@@ -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 — and `binary-server` has not been re-homed here, so this skips on every host today.
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
+ }