shorts-maker 0.3.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.
@@ -0,0 +1,390 @@
1
+ // Job registry: an in-memory working set (for live SSE) durably backed by SQLite.
2
+
3
+ import { join } from "node:path";
4
+ import { mkdirSync } from "node:fs";
5
+ import { rm, mkdir, rename } from "node:fs/promises";
6
+ import { Database } from "bun:sqlite";
7
+ import { run, type PartRow, type PublishInfo, type RunOptions } from "../pipeline/pipeline.ts";
8
+ import { isOverlayPosition, type OverlayPosition } from "../pipeline/overlay.ts";
9
+ import { downloadVideo } from "../pipeline/download.ts";
10
+ import { OUTPUT_DIR, UPLOADS_DIR } from "../paths.ts";
11
+ import { uniqueName } from "../files.ts";
12
+
13
+ /**
14
+ * Where an in-progress URL download is staged. Dot-prefixed and a directory, so it never
15
+ * shows up in the uploads library listing (which lists plain video files only).
16
+ */
17
+ export const stagingDir = (jobId: string): string => join(UPLOADS_DIR, `.dl_${jobId}`);
18
+
19
+ export type JobStatus = "running" | "done" | "error";
20
+
21
+ export interface Job {
22
+ id: string;
23
+ video: string; // absolute-ish path to source (empty until a URL job downloads it)
24
+ /** When set, the source is fetched from this link (via yt-dlp) before splitting. */
25
+ sourceUrl?: string;
26
+ videoName: string;
27
+ title?: string;
28
+ partLen: number;
29
+ numHashtags: number;
30
+ hashtags?: string[];
31
+ /** Where the burned-in "PART 2" badge sits, or undefined when the job has no overlay. */
32
+ overlay?: OverlayPosition;
33
+ status: JobStatus;
34
+ createdAt: number;
35
+ finishedAt?: number;
36
+ outDir: string;
37
+ logs: string[];
38
+ error?: string;
39
+ parts: PartRow[];
40
+ }
41
+
42
+ /** What a browser sees — same as Job but logs are streamed separately, kept light here. */
43
+ export type JobSummary = Omit<Job, "logs" | "hashtags">;
44
+
45
+ type Sub = (ev: JobEvent) => void;
46
+ export type JobEvent =
47
+ | { type: "log"; line: string }
48
+ | { type: "status"; status: JobStatus; error?: string; parts?: PartRow[] };
49
+
50
+ const DB_FILE = join(OUTPUT_DIR, "jobs.db");
51
+ const LEGACY_JSON = join(OUTPUT_DIR, ".jobs.json");
52
+
53
+ // ---- SQLite persistence --------------------------------------------------
54
+
55
+ mkdirSync(OUTPUT_DIR, { recursive: true });
56
+ const db = new Database(DB_FILE, { create: true });
57
+ db.exec("PRAGMA journal_mode = WAL"); // faster concurrent reads/writes
58
+ db.exec(`
59
+ CREATE TABLE IF NOT EXISTS jobs (
60
+ id TEXT PRIMARY KEY,
61
+ video TEXT NOT NULL,
62
+ videoName TEXT NOT NULL,
63
+ title TEXT,
64
+ partLen INTEGER NOT NULL,
65
+ numHashtags INTEGER NOT NULL,
66
+ hashtags TEXT, -- JSON array
67
+ status TEXT NOT NULL,
68
+ createdAt INTEGER NOT NULL,
69
+ finishedAt INTEGER,
70
+ outDir TEXT NOT NULL,
71
+ ephemeral INTEGER, -- 0 | 1
72
+ logs TEXT, -- JSON array
73
+ error TEXT,
74
+ parts TEXT, -- JSON array
75
+ overlay TEXT -- OverlayPosition, NULL when the job has no badge
76
+ )
77
+ `);
78
+
79
+ // Bring a database created before part badges existed up to the current shape.
80
+ const columns = new Set((db.query("PRAGMA table_info(jobs)").all() as { name: string }[]).map((c) => c.name));
81
+ if (!columns.has("overlay")) db.exec("ALTER TABLE jobs ADD COLUMN overlay TEXT");
82
+
83
+ interface Row {
84
+ id: string;
85
+ video: string;
86
+ videoName: string;
87
+ title: string | null;
88
+ partLen: number;
89
+ numHashtags: number;
90
+ hashtags: string | null;
91
+ status: JobStatus;
92
+ createdAt: number;
93
+ finishedAt: number | null;
94
+ outDir: string;
95
+ ephemeral: number; // legacy column, always 0 now
96
+ logs: string | null;
97
+ error: string | null;
98
+ parts: string | null;
99
+ overlay: string | null;
100
+ }
101
+
102
+ const saveStmt = db.query(`
103
+ INSERT OR REPLACE INTO jobs
104
+ (id, video, videoName, title, partLen, numHashtags, hashtags, status,
105
+ createdAt, finishedAt, outDir, ephemeral, logs, error, parts, overlay)
106
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
107
+ `);
108
+ const delStmt = db.query("DELETE FROM jobs WHERE id = ?");
109
+ const allStmt = db.query("SELECT * FROM jobs ORDER BY createdAt DESC");
110
+ const countStmt = db.query("SELECT COUNT(*) AS n FROM jobs");
111
+
112
+ function saveJob(j: Job): void {
113
+ saveStmt.run(
114
+ j.id,
115
+ j.video,
116
+ j.videoName,
117
+ j.title ?? null,
118
+ j.partLen,
119
+ j.numHashtags,
120
+ j.hashtags ? JSON.stringify(j.hashtags) : null,
121
+ j.status,
122
+ j.createdAt,
123
+ j.finishedAt ?? null,
124
+ j.outDir,
125
+ 0, // legacy `ephemeral` column — sources now live on in uploads/
126
+ JSON.stringify(j.logs),
127
+ j.error ?? null,
128
+ JSON.stringify(j.parts),
129
+ j.overlay ?? null,
130
+ );
131
+ }
132
+
133
+ function rowToJob(r: Row): Job {
134
+ return {
135
+ id: r.id,
136
+ video: r.video,
137
+ videoName: r.videoName,
138
+ title: r.title ?? undefined,
139
+ partLen: r.partLen,
140
+ numHashtags: r.numHashtags,
141
+ hashtags: r.hashtags ? (JSON.parse(r.hashtags) as string[]) : undefined,
142
+ overlay: isOverlayPosition(r.overlay) ? r.overlay : undefined,
143
+ status: r.status,
144
+ createdAt: r.createdAt,
145
+ finishedAt: r.finishedAt ?? undefined,
146
+ outDir: r.outDir,
147
+ logs: r.logs ? (JSON.parse(r.logs) as string[]) : [],
148
+ error: r.error ?? undefined,
149
+ parts: r.parts ? (JSON.parse(r.parts) as PartRow[]) : [],
150
+ };
151
+ }
152
+
153
+ // ---- In-memory working set ----------------------------------------------
154
+
155
+ const jobs = new Map<string, Job>();
156
+ const subs = new Map<string, Set<Sub>>();
157
+ /** Abort handles for in-flight jobs, so deleting one can kill its download/split. */
158
+ const running = new Map<string, AbortController>();
159
+ let loaded = false;
160
+
161
+ export async function load(): Promise<void> {
162
+ if (loaded) return;
163
+ loaded = true;
164
+
165
+ // One-time migration from the legacy JSON store, then retire it.
166
+ const dbEmpty = (countStmt.get() as { n: number }).n === 0;
167
+ if (dbEmpty && (await Bun.file(LEGACY_JSON).exists())) {
168
+ try {
169
+ const arr = JSON.parse(await Bun.file(LEGACY_JSON).text()) as Job[];
170
+ db.transaction((rows: Job[]) => rows.forEach(saveJob))(arr);
171
+ await rm(LEGACY_JSON, { force: true }).catch(() => {});
172
+ } catch {
173
+ /* leave a malformed legacy file in place for inspection */
174
+ }
175
+ } else {
176
+ await rm(LEGACY_JSON, { force: true }).catch(() => {});
177
+ }
178
+
179
+ // Hydrate memory; a job that was mid-run when the server stopped is dead.
180
+ for (const r of allStmt.all() as Row[]) {
181
+ const j = rowToJob(r);
182
+ let dirty = false;
183
+ if (j.status === "running") {
184
+ j.status = "error";
185
+ j.error = "Interrupted (server restarted).";
186
+ dirty = true;
187
+ }
188
+ // A publish that was still uploading when the server stopped can never finish.
189
+ for (const part of j.parts) {
190
+ for (const [platform, info] of Object.entries(part.published ?? {})) {
191
+ if (info.status === "pending") {
192
+ part.published![platform] = { status: "error", error: "Interrupted (server restarted).", at: info.at };
193
+ dirty = true;
194
+ }
195
+ }
196
+ }
197
+ if (dirty) saveJob(j);
198
+ jobs.set(j.id, j);
199
+ }
200
+ }
201
+
202
+ function emit(id: string, ev: JobEvent): void {
203
+ // A disconnected SSE subscriber must never throw into (and kill) the running job.
204
+ for (const fn of subs.get(id) ?? []) {
205
+ try {
206
+ fn(ev);
207
+ } catch {
208
+ /* dead stream — ignore */
209
+ }
210
+ }
211
+ }
212
+
213
+ export function subscribe(id: string, fn: Sub): () => void {
214
+ let set = subs.get(id);
215
+ if (!set) subs.set(id, (set = new Set()));
216
+ set.add(fn);
217
+ return () => set!.delete(fn);
218
+ }
219
+
220
+ /** Filesystem-safe timestamp for unique output dirs, e.g. 20260709-153012. */
221
+ export function stamp(): string {
222
+ const d = new Date();
223
+ const p = (n: number) => String(n).padStart(2, "0");
224
+ return (
225
+ `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
226
+ `-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
227
+ );
228
+ }
229
+
230
+ export function summary(j: Job): JobSummary {
231
+ const { logs: _logs, hashtags: _tags, ...rest } = j;
232
+ return rest;
233
+ }
234
+
235
+ export function list(): JobSummary[] {
236
+ return [...jobs.values()].sort((a, b) => b.createdAt - a.createdAt).map(summary);
237
+ }
238
+
239
+ export function get(id: string): Job | undefined {
240
+ return jobs.get(id);
241
+ }
242
+
243
+ /** The publish lifecycle for one platform on one part (undefined = never attempted). */
244
+ export function publishState(jobId: string, file: string, platform: string): PublishInfo | undefined {
245
+ return jobs.get(jobId)?.parts.find((p) => p.file === file)?.published?.[platform];
246
+ }
247
+
248
+ /** Set (and durably persist) a part's publish state for a platform. Returns false if not found. */
249
+ export function setPublishState(jobId: string, file: string, platform: string, info: PublishInfo): boolean {
250
+ const job = jobs.get(jobId);
251
+ if (!job) return false;
252
+ const part = job.parts.find((p) => p.file === file);
253
+ if (!part) return false;
254
+ part.published = { ...(part.published ?? {}), [platform]: info };
255
+ saveJob(job);
256
+ return true;
257
+ }
258
+
259
+ /** Update a part's caption — in memory, in the DB, and in its paste-ready .txt on disk. */
260
+ export async function setCaption(jobId: string, file: string, caption: string): Promise<boolean> {
261
+ const job = jobs.get(jobId);
262
+ const part = job?.parts.find((p) => p.file === file);
263
+ if (!job || !part) return false;
264
+ part.caption = caption;
265
+ saveJob(job);
266
+ const txt = join(job.outDir, "parts", file.replace(/\.[^.]+$/, ".txt"));
267
+ await Bun.write(txt, caption + "\n").catch(() => {});
268
+ return true;
269
+ }
270
+
271
+ /** Forget a part's publish record for a platform (e.g. after deleting the post). */
272
+ export function clearPublishState(jobId: string, file: string, platform: string): boolean {
273
+ const job = jobs.get(jobId);
274
+ const part = job?.parts.find((p) => p.file === file);
275
+ if (!job || !part?.published?.[platform]) return false;
276
+ delete part.published[platform];
277
+ saveJob(job);
278
+ return true;
279
+ }
280
+
281
+ export async function remove(id: string): Promise<boolean> {
282
+ const j = jobs.get(id);
283
+ if (!j) return false;
284
+ // Stop any in-flight download/split first, then forget the job. Deleting from `jobs` here
285
+ // (synchronously, before the aborted task unwinds) is what stops execute() from re-saving it.
286
+ running.get(id)?.abort();
287
+ jobs.delete(id);
288
+ subs.delete(id);
289
+ delStmt.run(id);
290
+ await rm(j.outDir, { recursive: true, force: true }).catch(() => {});
291
+ // The source video stays in uploads/ — it's a reusable library file with its own page.
292
+ // Only the in-progress download staging (if any) gets scrapped.
293
+ await rm(stagingDir(id), { recursive: true, force: true }).catch(() => {});
294
+ return true;
295
+ }
296
+
297
+ export interface CreateOptions {
298
+ /** Local source path (uploads). Mutually exclusive with `url`. */
299
+ video?: string;
300
+ /** Remote source link — downloaded via yt-dlp before splitting. */
301
+ url?: string;
302
+ videoName: string;
303
+ outDir: string;
304
+ title?: string;
305
+ partLen?: number;
306
+ numHashtags?: number;
307
+ hashtags?: string[];
308
+ /** Burn a part badge in at this position. Omit for no overlay (and a much faster cut). */
309
+ overlay?: OverlayPosition;
310
+ }
311
+
312
+ export async function create(opts: CreateOptions): Promise<Job> {
313
+ const job: Job = {
314
+ id: crypto.randomUUID().slice(0, 8),
315
+ video: opts.video ?? "",
316
+ sourceUrl: opts.url,
317
+ videoName: opts.videoName,
318
+ title: opts.title,
319
+ partLen: opts.partLen ?? 900,
320
+ numHashtags: opts.numHashtags ?? 8,
321
+ hashtags: opts.hashtags,
322
+ overlay: opts.overlay,
323
+ status: "running",
324
+ createdAt: Date.now(),
325
+ outDir: opts.outDir,
326
+ logs: [],
327
+ parts: [],
328
+ };
329
+ jobs.set(job.id, job);
330
+ saveJob(job);
331
+ void execute(job);
332
+ return job;
333
+ }
334
+
335
+ async function execute(job: Job): Promise<void> {
336
+ const onLog = (line: string) => {
337
+ job.logs.push(line);
338
+ emit(job.id, { type: "log", line });
339
+ };
340
+ const controller = new AbortController();
341
+ running.set(job.id, controller);
342
+ const dlDir = stagingDir(job.id); // in-progress download only; removed in `finally`
343
+ try {
344
+ // A URL source is fetched first; its progress streams into the same live log. Once it
345
+ // lands we move it out of staging into uploads/, so it joins the reusable library.
346
+ if (job.sourceUrl) {
347
+ const dl = await downloadVideo(job.sourceUrl, dlDir, onLog, controller.signal);
348
+ await mkdir(UPLOADS_DIR, { recursive: true });
349
+ const name = await uniqueName(UPLOADS_DIR, dl.name);
350
+ const dest = join(UPLOADS_DIR, name);
351
+ await rename(dl.path, dest);
352
+ job.video = dest;
353
+ if (!job.title) job.videoName = name; // show the real title in history
354
+ saveJob(job);
355
+ }
356
+
357
+ const manifest = await run({
358
+ video: job.video,
359
+ outDir: job.outDir,
360
+ title: job.title,
361
+ partLen: job.partLen,
362
+ numHashtags: job.numHashtags,
363
+ hashtags: job.hashtags,
364
+ overlay: job.overlay,
365
+ onLog,
366
+ signal: controller.signal,
367
+ });
368
+ job.parts = manifest.parts;
369
+ job.status = "done";
370
+ job.finishedAt = Date.now();
371
+ emit(job.id, { type: "status", status: "done", parts: job.parts });
372
+ } catch (err) {
373
+ // If the job was deleted mid-run it's already gone — the error is just the abort unwinding.
374
+ if (jobs.has(job.id)) {
375
+ const e = err as Error;
376
+ job.status = "error";
377
+ job.error = `${e.name}: ${e.message}`;
378
+ job.finishedAt = Date.now();
379
+ job.logs.push(`✗ ${job.error}`);
380
+ emit(job.id, { type: "log", line: `✗ ${job.error}` });
381
+ emit(job.id, { type: "status", status: "error", error: job.error });
382
+ }
383
+ } finally {
384
+ running.delete(job.id);
385
+ // The source video is kept in uploads/ for reuse — only staging is disposable.
386
+ await rm(dlDir, { recursive: true, force: true }).catch(() => {});
387
+ // Never re-persist a job that was deleted while running (would resurrect it on restart).
388
+ if (jobs.has(job.id)) saveJob(job);
389
+ }
390
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,23 @@
1
+ // Two distinct roots the app pulls from:
2
+ // • PKG_DIR — where the package is installed (read-only assets: the built frontend,
3
+ // the default hashtags list). Anchored to this file's location.
4
+ // • DATA_DIR — where the *user's* stuff is written (jobs, uploads, credentials).
5
+ // Defaults to the folder they run the command in, so `bunx shorts-maker`
6
+ // behaves like a normal project tool (à la vite/next) instead of writing
7
+ // into the global install cache. Override with SHORTS_MAKER_DIR.
8
+
9
+ import { join } from "node:path";
10
+
11
+ export const PKG_DIR = join(import.meta.dir, "..");
12
+ export const DATA_DIR = process.env.SHORTS_MAKER_DIR || process.cwd();
13
+
14
+ export const OUTPUT_DIR = join(DATA_DIR, "output");
15
+ export const UPLOADS_DIR = join(DATA_DIR, "uploads");
16
+ export const CONFIG_DIR = join(DATA_DIR, "config");
17
+
18
+ /** The pre-built frontend served in production (created by `bun run build`). */
19
+ export const DIST_DIR = join(PKG_DIR, "dist");
20
+
21
+ /** Hashtags list: a user-provided one in their folder wins over the packaged default. */
22
+ export const packagedHashtags = join(PKG_DIR, "hashtags.txt");
23
+ export const userHashtags = join(DATA_DIR, "hashtags.txt");
@@ -0,0 +1,201 @@
1
+ // Fetch a source video from a URL via yt-dlp — covers YouTube, Dailymotion, and direct
2
+ // video links (its generic extractor handles plain files too). yt-dlp shells out to ffmpeg
3
+ // to merge best video+audio, which is already a project requirement.
4
+
5
+ import { readdir, mkdir, rm } from "node:fs/promises";
6
+ import { join, extname } from "node:path";
7
+ import { MediaError } from "./media.ts";
8
+
9
+ const VIDEO_EXTS = new Set([".mp4", ".mkv", ".mov", ".webm", ".m4v", ".avi", ".ts", ".flv"]);
10
+
11
+ // Robustness flags applied to every attempt. --extractor-retries covers the "Unable to
12
+ // download webpage: HTTP 429" case; --retries/--fragment-retries cover flaky data transfer.
13
+ const BASE_ARGS = [
14
+ "--no-playlist", // a link inside a playlist grabs just that video
15
+ "--newline", // progress on its own line so it streams cleanly to the log
16
+ "--no-part",
17
+ "-f",
18
+ "bv*+ba/b", // best video+audio, or best single stream
19
+ "--merge-output-format",
20
+ "mp4",
21
+ "--extractor-retries",
22
+ "3",
23
+ "--retries",
24
+ "5",
25
+ "--fragment-retries",
26
+ "10",
27
+ ];
28
+
29
+ // Errors worth escalating to the next strategy for (YouTube bot wall / rate limits).
30
+ // Anything else (bad URL, private video, unsupported site) fails fast — no point retrying.
31
+ const RETRYABLE = /sign in to confirm|not a bot|HTTP Error 429|too many requests|temporarily|rate.?limit/i;
32
+
33
+ export interface Downloaded {
34
+ path: string;
35
+ name: string;
36
+ }
37
+
38
+ /**
39
+ * Wrap `onLog` so yt-dlp's flood of `[download] NN.N%` lines is thinned to at most one per
40
+ * whole percent AND one per ~400ms — a slow download logs roughly per-percent, a fast/small
41
+ * one logs a handful of coarse steps. Every non-progress line (stage changes, warnings)
42
+ * passes through untouched. yt-dlp emits two streams (video then audio); the percent drop
43
+ * between them resets the counter so the second stream is shown too.
44
+ */
45
+ function throttleProgress(onLog: (line: string) => void): (line: string) => void {
46
+ let lastPct = -1;
47
+ let lastAt = 0;
48
+ let prevRaw = -1;
49
+ return (line) => {
50
+ const m = line.match(/^\[download\]\s+([\d.]+)%/);
51
+ if (!m) {
52
+ onLog(line);
53
+ return;
54
+ }
55
+ const raw = parseFloat(m[1]!);
56
+ if (raw + 0.5 < prevRaw) lastPct = -1; // percent went backwards → a new stream started
57
+ prevRaw = raw;
58
+ const pct = Math.floor(raw);
59
+ const now = Date.now();
60
+ if (raw >= 100 || (pct > lastPct && now - lastAt >= 400)) {
61
+ lastPct = pct;
62
+ lastAt = now;
63
+ onLog(line);
64
+ }
65
+ };
66
+ }
67
+
68
+ /** Read a subprocess stream line-by-line, forwarding each line to `onLine`. */
69
+ async function pumpLines(stream: ReadableStream<Uint8Array>, onLine: (line: string) => void): Promise<void> {
70
+ const reader = stream.getReader();
71
+ const dec = new TextDecoder();
72
+ let buf = "";
73
+ for (;;) {
74
+ const { done, value } = await reader.read();
75
+ if (done) break;
76
+ buf += dec.decode(value, { stream: true });
77
+ for (let nl = buf.indexOf("\n"); nl >= 0; nl = buf.indexOf("\n")) {
78
+ const line = buf.slice(0, nl).trimEnd();
79
+ buf = buf.slice(nl + 1);
80
+ if (line) onLine(line);
81
+ }
82
+ }
83
+ if (buf.trim()) onLine(buf.trim());
84
+ }
85
+
86
+ interface Strategy {
87
+ label: string;
88
+ args: string[];
89
+ }
90
+
91
+ /**
92
+ * Ordered attempts. If the user pointed us at browser cookies we try those first (the real
93
+ * fix for YouTube's "confirm you're not a bot" wall). Otherwise we start anonymous and, on a
94
+ * bot/rate-limit failure, escalate through alternate player clients that sometimes slip past.
95
+ */
96
+ function strategies(): Strategy[] {
97
+ const list: Strategy[] = [];
98
+ const browser = process.env.SHORTS_MAKER_COOKIES_FROM_BROWSER?.trim();
99
+ const cookieFile = process.env.SHORTS_MAKER_COOKIES?.trim();
100
+ if (browser) list.push({ label: `${browser} cookies`, args: ["--cookies-from-browser", browser] });
101
+ else if (cookieFile) list.push({ label: "cookies file", args: ["--cookies", cookieFile] });
102
+ list.push({ label: "default", args: [] });
103
+ list.push({ label: "tv client", args: ["--extractor-args", "youtube:player_client=tv"] });
104
+ list.push({ label: "ios client", args: ["--extractor-args", "youtube:player_client=ios"] });
105
+ return list;
106
+ }
107
+
108
+ /** Run one yt-dlp attempt, streaming progress; return its exit code + last stderr lines. */
109
+ async function attempt(
110
+ args: string[],
111
+ onLog: (line: string) => void,
112
+ signal?: AbortSignal,
113
+ ): Promise<{ code: number; errTail: string[] }> {
114
+ const proc = Bun.spawn(["yt-dlp", ...args], { stdout: "pipe", stderr: "pipe" });
115
+ const onAbort = () => proc.kill(); // deleting the job kills the download
116
+ if (signal?.aborted) proc.kill();
117
+ else signal?.addEventListener("abort", onAbort, { once: true });
118
+ const errTail: string[] = [];
119
+ const emit = throttleProgress(onLog);
120
+ try {
121
+ await Promise.all([
122
+ pumpLines(proc.stdout, emit),
123
+ pumpLines(proc.stderr, (line) => {
124
+ errTail.push(line); // keep raw (unthrottled) stderr for error diagnostics
125
+ if (errTail.length > 8) errTail.shift();
126
+ emit(line);
127
+ }),
128
+ ]);
129
+ return { code: await proc.exited, errTail };
130
+ } finally {
131
+ signal?.removeEventListener("abort", onAbort);
132
+ }
133
+ }
134
+
135
+ /** Turn a failed run's stderr into a message that points at the actual fix. */
136
+ function failureMessage(errTail: string[]): string {
137
+ const joined = errTail.join("\n");
138
+ let msg = `Download failed:\n${joined || "no output"}`;
139
+ if (/sign in to confirm|not a bot/i.test(joined)) {
140
+ msg +=
141
+ "\n\nYouTube is blocking anonymous downloads for this video. Use your browser's login: " +
142
+ "start the app with SHORTS_MAKER_COOKIES_FROM_BROWSER set to your browser " +
143
+ "(chrome, safari, firefox, edge, or brave), or serve with " +
144
+ "`--cookies-from-browser <browser>`, then retry.";
145
+ } else if (/429|too many requests/i.test(joined)) {
146
+ msg += "\n\nYouTube is rate-limiting you (HTTP 429). Wait a few minutes, then retry.";
147
+ }
148
+ return msg;
149
+ }
150
+
151
+ /**
152
+ * Download `url` into `destDir`, streaming yt-dlp's progress to `onLog`. Tries a sequence of
153
+ * strategies (see `strategies`), retrying only on YouTube's bot-wall / rate-limit errors and
154
+ * failing fast on anything else. Returns the resulting local file.
155
+ */
156
+ export async function downloadVideo(
157
+ url: string,
158
+ destDir: string,
159
+ onLog: (line: string) => void,
160
+ signal?: AbortSignal,
161
+ ): Promise<Downloaded> {
162
+ if (!Bun.which("yt-dlp")) {
163
+ throw new MediaError(
164
+ "`yt-dlp` was not found on PATH — it's needed to import from a link. " +
165
+ "Install it (e.g. `brew install yt-dlp`, or `pip install yt-dlp`), then retry.",
166
+ );
167
+ }
168
+
169
+ const plan = strategies();
170
+ let lastErr: string[] = [];
171
+ for (let i = 0; i < plan.length; i++) {
172
+ if (signal?.aborted) throw new MediaError("Cancelled.");
173
+ const s = plan[i]!;
174
+ // Fresh dir each attempt so a failed run's partial files can't be mistaken for the result.
175
+ await rm(destDir, { recursive: true, force: true }).catch(() => {});
176
+ await mkdir(destDir, { recursive: true });
177
+ onLog(i === 0 ? `⬇ Downloading ${url}` : `↻ Retrying via ${s.label}…`);
178
+
179
+ const { code, errTail } = await attempt(
180
+ [...BASE_ARGS, ...s.args, "-o", join(destDir, "%(title).80B.%(ext)s"), url],
181
+ onLog,
182
+ signal,
183
+ );
184
+
185
+ if (signal?.aborted) throw new MediaError("Cancelled.");
186
+ if (code === 0) {
187
+ const files = await readdir(destDir);
188
+ const name =
189
+ files.find((f) => extname(f).toLowerCase() === ".mp4") ??
190
+ files.find((f) => VIDEO_EXTS.has(extname(f).toLowerCase()));
191
+ if (name) return { path: join(destDir, name), name };
192
+ lastErr = ["yt-dlp reported success but produced no video file."];
193
+ } else {
194
+ lastErr = errTail;
195
+ if (!RETRYABLE.test(errTail.join("\n"))) break; // not a bot/rate error — don't churn
196
+ if (i < plan.length - 1) await Bun.sleep(3000); // brief backoff before escalating
197
+ }
198
+ }
199
+
200
+ throw new MediaError(failureMessage(lastErr));
201
+ }