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 CHANGED
@@ -81,7 +81,8 @@ after typing it (task 2138). `src/server/master-lock/sessions.ts` has the why.
81
81
 
82
82
  ## Standards
83
83
 
84
- `docs/retention.md`, `docs/activity.md`, `docs/notifications.md`, `docs/engagement.md` — the
84
+ `docs/retention.md`, `docs/activity.md`, `docs/notifications.md`, `docs/engagement.md`,
85
+ `docs/ctgr.md` (the GET-tunnel, getOnly and CSRF — register routes through `buildRoutes`) — the
85
86
  contracts every app that uses those modules is agreeing to. Cite them from an app with the
86
87
  package prefix (`cursedbelt-server/docs/retention.md`).
87
88
 
@@ -0,0 +1,93 @@
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
+ /** YouTube's own still for a video — the fallback when a playlist row carries no thumbnail. */
24
+ export declare const youtubeThumbnailUrl: (videoId: string) => string;
25
+ /** Read-only, and nothing else. See the header. */
26
+ export declare const YOUTUBE_READONLY_SCOPE = "https://www.googleapis.com/auth/youtube.readonly";
27
+ export interface TubeApiVideo {
28
+ videoId: string;
29
+ title: string;
30
+ channelTitle: string;
31
+ thumbnail: string;
32
+ url: string;
33
+ }
34
+ export interface TubeApiPlaylist {
35
+ id: string;
36
+ title: string;
37
+ description: string;
38
+ thumbnail: string;
39
+ itemCount: number;
40
+ }
41
+ export interface TubeApiChannel {
42
+ id: string;
43
+ title: string;
44
+ thumbnail: string;
45
+ }
46
+ export interface TubePage<T> {
47
+ items: T[];
48
+ nextPageToken?: string;
49
+ }
50
+ /** A read that failed says why and what to do — the same law the token store runs on. */
51
+ export type TubeApiResult<T> = {
52
+ status: "ok";
53
+ value: T;
54
+ } | {
55
+ status: "error";
56
+ reason: string;
57
+ fix: string;
58
+ };
59
+ export declare function createYouTubeApi(fetchImpl?: FetchLike): {
60
+ /** Who this grant belongs to — the name shown beside "Connected". */
61
+ channel(accessToken: string): Promise<TubeApiResult<TubeApiChannel | null>>;
62
+ playlists(accessToken: string, pageToken?: string): Promise<TubeApiResult<TubePage<TubeApiPlaylist>>>;
63
+ playlistItems(accessToken: string, playlistId: string, pageToken?: string): Promise<TubeApiResult<TubePage<TubeApiVideo>>>;
64
+ /**
65
+ * How long each video is, in seconds — `playlistItems` does not say. Up to 50 ids per
66
+ * call (1 quota unit each call), so a 200-video playlist costs four. A video YouTube no
67
+ * longer returns (private since, deleted) is simply absent from the map.
68
+ */
69
+ durations(accessToken: string, videoIds: readonly string[]): Promise<TubeApiResult<Map<string, number>>>;
70
+ /**
71
+ * Liked videos. Not a playlist read: the "LL" pseudo-playlist is not reliably
72
+ * listable through `playlistItems`, whereas `videos?myRating=like` is the documented
73
+ * way to ask and costs the same.
74
+ */
75
+ liked(accessToken: string, pageToken?: string): Promise<TubeApiResult<TubePage<TubeApiVideo>>>;
76
+ };
77
+ export type YouTubeApi = ReturnType<typeof createYouTubeApi>;
78
+ /**
79
+ * Walk every page of a paginated read, stopping at `maxPages` and SAYING SO (`truncated`).
80
+ *
81
+ * A list that quietly stops at the cap is indistinguishable from one that is exactly that long,
82
+ * so the cap is reported. 40 pages of 50 is 2,000 videos — larger than any real playlist, and
83
+ * small enough that a pathological account cannot turn one button into a thousand API calls.
84
+ */
85
+ export declare function collectYouTubePages<T>(fetchPage: (pageToken?: string) => Promise<TubeApiResult<TubePage<T>>>, maxPages?: number): Promise<{
86
+ status: "ok";
87
+ items: T[];
88
+ truncated: boolean;
89
+ } | {
90
+ status: "error";
91
+ reason: string;
92
+ fix: string;
93
+ }>;
@@ -0,0 +1,219 @@
1
+ const API_BASE = "https://www.googleapis.com/youtube/v3";
2
+ /** YouTube's own still for a video — the fallback when a playlist row carries no thumbnail. */
3
+ export const youtubeThumbnailUrl = (videoId) => videoId ? `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg` : "";
4
+ /** Read-only, and nothing else. See the header. */
5
+ export const YOUTUBE_READONLY_SCOPE = "https://www.googleapis.com/auth/youtube.readonly";
6
+ /**
7
+ * `medium` first, deliberately. `default` is 120×90 and looks like a thumbnail from 2009 on
8
+ * a retina phone; `maxres` does not exist for most videos, so preferring it would leave
9
+ * half a list with no picture at all.
10
+ */
11
+ const pickThumb = (thumbs) => thumbs?.medium?.url || thumbs?.high?.url || thumbs?.standard?.url || thumbs?.default?.url || "";
12
+ const watchUrl = (videoId) => `https://www.youtube.com/watch?v=${videoId}`;
13
+ /**
14
+ * A playlist keeps a tombstone for a video the uploader deleted or made private. It has a
15
+ * title and a thumbnail and NOTHING that will ever play, so importing it would put dead
16
+ * rows in the owner's list that look exactly like live ones until pressed.
17
+ */
18
+ const UNPLAYABLE_TITLES = new Set(["Private video", "Deleted video"]);
19
+ async function get(accessToken, path, params, fetchImpl) {
20
+ let res;
21
+ try {
22
+ res = await fetchImpl(`${API_BASE}/${path}?${new URLSearchParams(params).toString()}`, {
23
+ headers: { authorization: `Bearer ${accessToken}` },
24
+ signal: AbortSignal.timeout(30_000),
25
+ });
26
+ }
27
+ catch (err) {
28
+ return {
29
+ status: "error",
30
+ reason: `could not reach YouTube: ${err instanceof Error ? err.message : String(err)}`,
31
+ fix: "check this machine's internet connection and try again",
32
+ };
33
+ }
34
+ const body = (await res.json().catch(() => ({})));
35
+ if (!res.ok) {
36
+ const detail = body.error?.message || `HTTP ${res.status}`;
37
+ // 403 is overwhelmingly one of two things and they have different fixes, so the
38
+ // message says both rather than making the owner guess which.
39
+ if (res.status === 403) {
40
+ return {
41
+ status: "error",
42
+ reason: `YouTube refused the read: ${detail}`,
43
+ 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",
44
+ };
45
+ }
46
+ return {
47
+ status: "error",
48
+ reason: `YouTube refused the read: ${detail}`,
49
+ fix: res.status === 401
50
+ ? "the connection has lapsed — press Disconnect and Connect again"
51
+ : "try again; if it persists, check the YouTube API status",
52
+ };
53
+ }
54
+ return { status: "ok", value: body };
55
+ }
56
+ const rows = (body) => (Array.isArray(body.items) ? body.items : []);
57
+ const nextToken = (body) => typeof body.nextPageToken === "string" ? { nextPageToken: body.nextPageToken } : {};
58
+ /** `PT1H2M3S` → 3723. YouTube's ISO 8601 durations never carry years or months. */
59
+ function parseIsoDuration(value) {
60
+ const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(value);
61
+ if (!m || value === "P" || value.endsWith("T"))
62
+ return null;
63
+ const [, d, h, min, s] = m.map((x) => Number(x ?? 0));
64
+ return d * 86_400 + h * 3600 + min * 60 + s;
65
+ }
66
+ export function createYouTubeApi(fetchImpl = fetch) {
67
+ return {
68
+ /** Who this grant belongs to — the name shown beside "Connected". */
69
+ async channel(accessToken) {
70
+ const res = await get(accessToken, "channels", { part: "snippet", mine: "true" }, fetchImpl);
71
+ if (res.status !== "ok")
72
+ return res;
73
+ const first = rows(res.value)[0];
74
+ if (!first)
75
+ return { status: "ok", value: null };
76
+ const snippet = first.snippet ?? {};
77
+ return {
78
+ status: "ok",
79
+ value: {
80
+ id: String(first.id ?? ""),
81
+ title: String(snippet.title ?? ""),
82
+ thumbnail: pickThumb(snippet.thumbnails),
83
+ },
84
+ };
85
+ },
86
+ async playlists(accessToken, pageToken) {
87
+ const res = await get(accessToken, "playlists", {
88
+ part: "snippet,contentDetails",
89
+ mine: "true",
90
+ maxResults: "50",
91
+ ...(pageToken ? { pageToken } : {}),
92
+ }, fetchImpl);
93
+ if (res.status !== "ok")
94
+ return res;
95
+ return {
96
+ status: "ok",
97
+ value: {
98
+ ...nextToken(res.value),
99
+ items: rows(res.value).map((p) => {
100
+ const snippet = p.snippet ?? {};
101
+ const details = p.contentDetails ?? {};
102
+ return {
103
+ id: String(p.id ?? ""),
104
+ title: String(snippet.title ?? ""),
105
+ description: String(snippet.description ?? ""),
106
+ thumbnail: pickThumb(snippet.thumbnails),
107
+ itemCount: typeof details.itemCount === "number" ? details.itemCount : 0,
108
+ };
109
+ }),
110
+ },
111
+ };
112
+ },
113
+ async playlistItems(accessToken, playlistId, pageToken) {
114
+ const res = await get(accessToken, "playlistItems", {
115
+ part: "snippet,contentDetails",
116
+ playlistId,
117
+ maxResults: "50",
118
+ ...(pageToken ? { pageToken } : {}),
119
+ }, fetchImpl);
120
+ if (res.status !== "ok")
121
+ return res;
122
+ return {
123
+ status: "ok",
124
+ value: {
125
+ ...nextToken(res.value),
126
+ items: rows(res.value)
127
+ .map((item) => {
128
+ const snippet = item.snippet ?? {};
129
+ const details = item.contentDetails ?? {};
130
+ const resource = snippet.resourceId;
131
+ const videoId = String(details.videoId ?? resource?.videoId ?? "");
132
+ return {
133
+ videoId,
134
+ title: String(snippet.title ?? ""),
135
+ channelTitle: String(snippet.videoOwnerChannelTitle ?? ""),
136
+ thumbnail: pickThumb(snippet.thumbnails) || youtubeThumbnailUrl(videoId),
137
+ url: watchUrl(videoId),
138
+ };
139
+ })
140
+ .filter((v) => v.videoId && !UNPLAYABLE_TITLES.has(v.title)),
141
+ },
142
+ };
143
+ },
144
+ /**
145
+ * How long each video is, in seconds — `playlistItems` does not say. Up to 50 ids per
146
+ * call (1 quota unit each call), so a 200-video playlist costs four. A video YouTube no
147
+ * longer returns (private since, deleted) is simply absent from the map.
148
+ */
149
+ async durations(accessToken, videoIds) {
150
+ const out = new Map();
151
+ for (let at = 0; at < videoIds.length; at += 50) {
152
+ const res = await get(accessToken, "videos", { part: "contentDetails", id: videoIds.slice(at, at + 50).join(","), maxResults: "50" }, fetchImpl);
153
+ if (res.status !== "ok")
154
+ return res;
155
+ for (const v of rows(res.value)) {
156
+ const seconds = parseIsoDuration(String(v.contentDetails?.duration ?? ""));
157
+ if (seconds !== null)
158
+ out.set(String(v.id ?? ""), seconds);
159
+ }
160
+ }
161
+ return { status: "ok", value: out };
162
+ },
163
+ /**
164
+ * Liked videos. Not a playlist read: the "LL" pseudo-playlist is not reliably
165
+ * listable through `playlistItems`, whereas `videos?myRating=like` is the documented
166
+ * way to ask and costs the same.
167
+ */
168
+ async liked(accessToken, pageToken) {
169
+ const res = await get(accessToken, "videos", {
170
+ part: "snippet",
171
+ myRating: "like",
172
+ maxResults: "50",
173
+ ...(pageToken ? { pageToken } : {}),
174
+ }, fetchImpl);
175
+ if (res.status !== "ok")
176
+ return res;
177
+ return {
178
+ status: "ok",
179
+ value: {
180
+ ...nextToken(res.value),
181
+ items: rows(res.value)
182
+ .map((v) => {
183
+ const snippet = v.snippet ?? {};
184
+ const videoId = String(v.id ?? "");
185
+ return {
186
+ videoId,
187
+ title: String(snippet.title ?? ""),
188
+ channelTitle: String(snippet.channelTitle ?? ""),
189
+ thumbnail: pickThumb(snippet.thumbnails) || youtubeThumbnailUrl(videoId),
190
+ url: watchUrl(videoId),
191
+ };
192
+ })
193
+ .filter((v) => v.videoId),
194
+ },
195
+ };
196
+ },
197
+ };
198
+ }
199
+ /**
200
+ * Walk every page of a paginated read, stopping at `maxPages` and SAYING SO (`truncated`).
201
+ *
202
+ * A list that quietly stops at the cap is indistinguishable from one that is exactly that long,
203
+ * so the cap is reported. 40 pages of 50 is 2,000 videos — larger than any real playlist, and
204
+ * small enough that a pathological account cannot turn one button into a thousand API calls.
205
+ */
206
+ export async function collectYouTubePages(fetchPage, maxPages = 40) {
207
+ const items = [];
208
+ let pageToken;
209
+ let pages = 0;
210
+ do {
211
+ const page = await fetchPage(pageToken);
212
+ if (page.status !== "ok")
213
+ return page;
214
+ items.push(...page.value.items);
215
+ pageToken = page.value.nextPageToken;
216
+ pages += 1;
217
+ } while (pageToken && pages < maxPages);
218
+ return { status: "ok", items, truncated: Boolean(pageToken) };
219
+ }
@@ -0,0 +1,129 @@
1
+ /** `$STATE/bin/yt-dlp` — one binary for every app on the Mac. `state` is the generation's state root. */
2
+ export declare function ytDlpPath(state: string): string;
3
+ /** yt-dlp's cache (the player JS it has already solved), kept with the generation's state. */
4
+ export declare function ytDlpCacheDir(state: string): string;
5
+ /**
6
+ * What one app downloads. Music's is audio as YouTube serves it; Collections' is H.264 + AAC
7
+ * in MP4. Everything else about a run is the same.
8
+ */
9
+ export interface YtDlpProfile {
10
+ /** yt-dlp's `-f` selector. */
11
+ format: string;
12
+ /** Arguments placed after the format — e.g. `--merge-output-format mp4`. */
13
+ extraArgs?: readonly string[];
14
+ /** `--max-filesize`, in yt-dlp's own notation (`200M`, `4G`). */
15
+ maxFilesize: string;
16
+ /** What a file is called in a sentence: "That video's audio …", "That video …". */
17
+ noun: string;
18
+ }
19
+ /** What yt-dlp says about a video — the fields an app reads. */
20
+ export interface YtInfo {
21
+ id: string;
22
+ title?: string;
23
+ /** Set for YouTube Music tracks (the "Topic" channels): the song, not the video title. */
24
+ track?: string;
25
+ artist?: string;
26
+ creator?: string;
27
+ channel?: string;
28
+ uploader?: string;
29
+ album?: string;
30
+ release_year?: number;
31
+ /** `YYYYMMDD`. */
32
+ upload_date?: string;
33
+ duration?: number;
34
+ thumbnail?: string;
35
+ ext?: string;
36
+ filepath?: string;
37
+ /** The chosen video stream's codec — `avc1.640028`, `vp09…`, `none` for audio. */
38
+ vcodec?: string;
39
+ acodec?: string;
40
+ width?: number;
41
+ height?: number;
42
+ }
43
+ /** One finished download. `path` is inside `dir`; {@link dispose} removes both. */
44
+ export interface YtDownload {
45
+ path: string;
46
+ /** `webm`, `m4a`, `mp4` — what the file IS, for its name and its MIME type. */
47
+ extension: string;
48
+ info: YtInfo;
49
+ /** The temporary directory the file is in — a caller may write a re-encode beside it. */
50
+ dir: string;
51
+ dispose(): Promise<void>;
52
+ }
53
+ /** Fetch one video. `onProgress` gets the fraction of the file received, 0–1. */
54
+ export type YtDlpFetch = (videoId: string, onProgress?: (fraction: number) => void) => Promise<YtDownload>;
55
+ /**
56
+ * Why a download failed, sorted by what the owner can do about it.
57
+ *
58
+ * · `missing` — no binary, and installing one failed.
59
+ * · `outdated` — YouTube changed and this yt-dlp has not caught up. Update fixes it.
60
+ * · `blocked` — YouTube is refusing this Mac as a bot. Time fixes it, usually.
61
+ * · `unavailable` — the video itself: private, deleted, region-locked, members-only.
62
+ * · `too-big` — over the one-download ceiling.
63
+ * · `failed` — anything else, with yt-dlp's own last error line.
64
+ */
65
+ export type YtDlpFailure = "missing" | "outdated" | "blocked" | "unavailable" | "too-big" | "failed";
66
+ export declare class YtDlpError extends Error {
67
+ readonly kind: YtDlpFailure;
68
+ constructor(message: string, kind: YtDlpFailure);
69
+ }
70
+ /** A failure no retry and no update can change — the video's own fault, or the ceiling. */
71
+ export declare const isFinalYtDlpFailure: (kind: YtDlpFailure) => boolean;
72
+ /**
73
+ * One progress line, as a fraction — or `null` for any other line.
74
+ *
75
+ * The template prints `downloaded total estimate`, and yt-dlp writes `NA` for a field it does
76
+ * not know. The exact total wins; the estimate is what a stream without a `content-length`
77
+ * has. With neither there is no honest fraction, so none is claimed.
78
+ */
79
+ export declare function parseYtProgress(line: string): number | null;
80
+ /** The info JSON printed after the file is in place, or `null` for any other line. */
81
+ export declare function parseYtInfo(line: string): YtInfo | null;
82
+ /** Sort a failure by what can be done about it — see {@link YtDlpFailure}. */
83
+ export declare function classifyYtDlpFailure(stderr: string): YtDlpFailure;
84
+ /** The arguments for one download. Exported so a test pins the flags that matter. */
85
+ export declare function ytDlpArgs(videoId: string, outDir: string, profile: Pick<YtDlpProfile, "format" | "extraArgs" | "maxFilesize">, options?: {
86
+ cacheDir?: string;
87
+ jsRuntime?: string;
88
+ }): string[];
89
+ /** Run a program, handing each stdout line over as it arrives. Injected in tests. */
90
+ export type RunLines = (binary: string, args: string[], onLine: (line: string) => void) => Promise<{
91
+ code: number;
92
+ stderr: string;
93
+ }>;
94
+ /**
95
+ * Put the newest standalone build at `binary`. Written beside it and renamed into place, so a
96
+ * download that dies halfway never leaves a truncated executable where a fetcher looks.
97
+ */
98
+ export declare function installYtDlp(binary: string, fetchImpl?: typeof fetch): Promise<void>;
99
+ /** `yt-dlp -U` — the standalone build updates itself in place. Resolves to what it printed. */
100
+ export declare function updateYtDlp(binary: string, run?: RunLines): Promise<string>;
101
+ /**
102
+ * Install yt-dlp when it is missing, else update it — the body of each app's `bun run yt-dlp`.
103
+ * Resolves to the sentence to print.
104
+ */
105
+ export declare function installOrUpdateYtDlp(binary: string): Promise<string>;
106
+ export interface YtDlpOptions {
107
+ binary: string;
108
+ profile: YtDlpProfile;
109
+ /** The ONE command, runnable from any directory, that installs or updates yt-dlp for this app. */
110
+ fixCommand: string;
111
+ /** The prefix of every log line — `[music]`, `[collections]`. */
112
+ label?: string;
113
+ cacheDir?: string;
114
+ /** The Bun binary yt-dlp may use for YouTube's JS challenges. Defaults to this process's. */
115
+ jsRuntime?: string;
116
+ /** Where each download's temporary directory is made. Defaults to the OS temp dir. */
117
+ tmpRoot?: string;
118
+ /** Injected in tests — the process, the installer, the updater, the clock. */
119
+ run?: RunLines;
120
+ install?: (binary: string) => Promise<void>;
121
+ update?: (binary: string) => Promise<string>;
122
+ now?: () => number;
123
+ log?: (line: string) => void;
124
+ }
125
+ /**
126
+ * The fetcher an app's download agent is handed. See the header for what it heals by itself
127
+ * and what it leaves as a sentence.
128
+ */
129
+ export declare function createYtDlp(options: YtDlpOptions): YtDlpFetch;