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.
- package/README.md +166 -0
- package/bunfig.toml +3 -0
- package/dist/chunk-27wzf6kd.js +240 -0
- package/dist/chunk-wt9saz7q.css +1 -0
- package/dist/index.html +18 -0
- package/hashtags.txt +40 -0
- package/package.json +59 -0
- package/src/build.ts +30 -0
- package/src/cli.ts +70 -0
- package/src/files.ts +24 -0
- package/src/jobs/store.ts +390 -0
- package/src/paths.ts +23 -0
- package/src/pipeline/download.ts +201 -0
- package/src/pipeline/media.ts +312 -0
- package/src/pipeline/overlay.ts +138 -0
- package/src/pipeline/parts.ts +78 -0
- package/src/pipeline/pipeline.ts +217 -0
- package/src/server.ts +645 -0
- package/src/social/config.ts +72 -0
- package/src/social/facebook.ts +232 -0
- package/tsconfig.json +16 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Web UI server: pick a video + options, watch live logs, browse/delete past jobs.
|
|
3
|
+
|
|
4
|
+
import { join, basename, extname, dirname } from "node:path";
|
|
5
|
+
import { readdir, mkdir, rm } from "node:fs/promises";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { hashtagPool } from "./pipeline/pipeline.ts";
|
|
8
|
+
import { isOverlayPosition, OVERLAY_POSITIONS } from "./pipeline/overlay.ts";
|
|
9
|
+
import * as store from "./jobs/store.ts";
|
|
10
|
+
import * as social from "./social/config.ts";
|
|
11
|
+
import * as facebook from "./social/facebook.ts";
|
|
12
|
+
import { UPLOADS_DIR, OUTPUT_DIR, DIST_DIR } from "./paths.ts";
|
|
13
|
+
import { VIDEO_EXTS, isVideo, safeJoin, uniqueName } from "./files.ts";
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
// Serve the pre-built frontend (dist/, shipped in the published package or made by
|
|
17
|
+
// `bun run build`) when it exists; otherwise fall back to Bun's live bundler for local
|
|
18
|
+
// dev. Explicit NODE_ENV=development forces the dev path even when a build is present.
|
|
19
|
+
const HAS_DIST = existsSync(join(DIST_DIR, "index.html"));
|
|
20
|
+
const DEV = process.env.NODE_ENV === "development" || !HAS_DIST;
|
|
21
|
+
// Static import would trigger Bun's bundler (and its Tailwind plugin) at load time, which
|
|
22
|
+
// isn't available once installed — so only pull in the HTML entry on the dev path.
|
|
23
|
+
const homepage = DEV ? (await import("../frontend/index.html")).default : null;
|
|
24
|
+
|
|
25
|
+
const json = (data: unknown, status = 200) =>
|
|
26
|
+
new Response(JSON.stringify(data), {
|
|
27
|
+
status,
|
|
28
|
+
headers: { "content-type": "application/json" },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function slugStem(name: string): string {
|
|
32
|
+
return (
|
|
33
|
+
basename(name)
|
|
34
|
+
.replace(/\.[^.]+$/, "")
|
|
35
|
+
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
36
|
+
.replace(/^-+|-+$/g, "")
|
|
37
|
+
.toLowerCase() || "video"
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const contentType = (file: string): string => {
|
|
42
|
+
const ext = extname(file).toLowerCase();
|
|
43
|
+
return (
|
|
44
|
+
{
|
|
45
|
+
".mp4": "video/mp4",
|
|
46
|
+
".txt": "text/plain; charset=utf-8",
|
|
47
|
+
".json": "application/json",
|
|
48
|
+
".html": "text/html; charset=utf-8",
|
|
49
|
+
}[ext] ?? "application/octet-stream"
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Sanitize an uploaded filename to a bare, safe basename within videos/. */
|
|
54
|
+
function safeName(raw: string): string {
|
|
55
|
+
const base = basename(raw).replace(/[/\\]/g, "").replace(/[\x00-\x1f]/g, "").trim();
|
|
56
|
+
return base || "upload.mp4";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Stream a raw request body into the uploads/ staging dir, without buffering it in memory. */
|
|
60
|
+
async function handleUpload(req: Request, url: URL): Promise<Response> {
|
|
61
|
+
const raw = url.searchParams.get("name") || req.headers.get("x-filename") || "";
|
|
62
|
+
const wanted = safeName(decodeURIComponent(raw));
|
|
63
|
+
if (!VIDEO_EXTS.has(extname(wanted).toLowerCase())) {
|
|
64
|
+
return json({ error: `Unsupported video type: ${extname(wanted) || "(none)"}` }, 400);
|
|
65
|
+
}
|
|
66
|
+
if (!req.body) return json({ error: "Empty upload." }, 400);
|
|
67
|
+
|
|
68
|
+
await mkdir(UPLOADS_DIR, { recursive: true });
|
|
69
|
+
const name = await uniqueName(UPLOADS_DIR, wanted);
|
|
70
|
+
const path = join(UPLOADS_DIR, name);
|
|
71
|
+
const sink = Bun.file(path).writer();
|
|
72
|
+
try {
|
|
73
|
+
const reader = req.body.getReader();
|
|
74
|
+
for (;;) {
|
|
75
|
+
const { done, value } = await reader.read();
|
|
76
|
+
if (done) break;
|
|
77
|
+
if (value) sink.write(value);
|
|
78
|
+
}
|
|
79
|
+
await sink.end();
|
|
80
|
+
} catch (err) {
|
|
81
|
+
await Promise.resolve(sink.end()).catch(() => {});
|
|
82
|
+
await rm(path, { force: true }).catch(() => {});
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
return json({ name, size: Bun.file(path).size }, 201);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- Social settings + publishing -------------------------------------------
|
|
89
|
+
|
|
90
|
+
async function handleGetSettings(): Promise<Response> {
|
|
91
|
+
const fb = await social.getFacebook();
|
|
92
|
+
return json({
|
|
93
|
+
facebook: {
|
|
94
|
+
pageId: fb?.pageId ?? "",
|
|
95
|
+
pageName: fb?.pageName,
|
|
96
|
+
hasToken: !!fb?.token,
|
|
97
|
+
connected: !!(fb?.pageId && fb?.token),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function handleSaveSettings(req: Request): Promise<Response> {
|
|
103
|
+
const body = (await req.json().catch(() => null)) as {
|
|
104
|
+
facebook?: { pageId?: string; token?: string };
|
|
105
|
+
} | null;
|
|
106
|
+
const fbIn = body?.facebook;
|
|
107
|
+
if (!fbIn?.pageId?.trim()) return json({ error: "A Facebook Page ID is required." }, 400);
|
|
108
|
+
|
|
109
|
+
const saved = await social.getFacebook();
|
|
110
|
+
const pageId = fbIn.pageId.trim();
|
|
111
|
+
const token = fbIn.token?.trim() ? social.cleanToken(fbIn.token) : saved?.token || "";
|
|
112
|
+
if (!token) return json({ error: "An access token is required." }, 400);
|
|
113
|
+
|
|
114
|
+
// Verify before persisting — only store credentials that actually work.
|
|
115
|
+
try {
|
|
116
|
+
const { pageName } = await facebook.verify(pageId, token);
|
|
117
|
+
await social.saveFacebook({ pageId, token: fbIn.token });
|
|
118
|
+
await social.setFacebookPageName(pageName);
|
|
119
|
+
return handleGetSettings();
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return json({ error: (err as Error).message }, 400);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function handleFbTest(req: Request): Promise<Response> {
|
|
126
|
+
// Verify only — never writes to disk. Uses the form's values, falling back to whatever
|
|
127
|
+
// is already saved (so you can re-test a stored config with the token field left blank).
|
|
128
|
+
const body = (await req.json().catch(() => ({}))) as { pageId?: string; token?: string };
|
|
129
|
+
const saved = await social.getFacebook();
|
|
130
|
+
const pageId = body?.pageId?.trim() || saved?.pageId || "";
|
|
131
|
+
const token = body?.token?.trim() ? social.cleanToken(body.token) : saved?.token || "";
|
|
132
|
+
if (!pageId || !token) return json({ error: "Enter a Page ID and access token first." }, 400);
|
|
133
|
+
try {
|
|
134
|
+
const { pageName, missing } = await facebook.verify(pageId, token);
|
|
135
|
+
return json({ ok: true, pageName, missing });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return json({ error: (err as Error).message }, 400);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Keep one "All parts" index comment on every posted part, edited in place as parts are
|
|
143
|
+
* published or removed. With fewer than two posts an index is noise, so any leftover
|
|
144
|
+
* index comment is deleted instead.
|
|
145
|
+
*/
|
|
146
|
+
async function syncPartsIndex(
|
|
147
|
+
job: store.Job,
|
|
148
|
+
platform: string,
|
|
149
|
+
token: string,
|
|
150
|
+
): Promise<{ updated: number; warnings: string[] }> {
|
|
151
|
+
const published = job.parts
|
|
152
|
+
.filter((p) => p.published?.[platform]?.status === "done" && p.published[platform]!.id)
|
|
153
|
+
.sort((a, b) => a.part - b.part);
|
|
154
|
+
const warnings: string[] = [];
|
|
155
|
+
let updated = 0;
|
|
156
|
+
|
|
157
|
+
if (published.length < 2) {
|
|
158
|
+
for (const p of published) {
|
|
159
|
+
const pub = p.published![platform]!;
|
|
160
|
+
if (!pub.indexCommentId) continue;
|
|
161
|
+
await facebook.deleteComment(pub.indexCommentId, token).catch(() => {});
|
|
162
|
+
store.setPublishState(job.id, p.file, platform, { ...pub, indexCommentId: undefined });
|
|
163
|
+
}
|
|
164
|
+
return { updated, warnings };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Footer flips automatically once the final part lands, since comments are re-edited
|
|
168
|
+
// on every sync.
|
|
169
|
+
const complete = published.length === job.parts.length;
|
|
170
|
+
const footer = complete
|
|
171
|
+
? "❤️ Enjoyed the series? Follow us for more content!"
|
|
172
|
+
: "🔔 Follow us — more parts coming soon!";
|
|
173
|
+
const message =
|
|
174
|
+
"📺 All parts:\n" +
|
|
175
|
+
published
|
|
176
|
+
.map((p) => {
|
|
177
|
+
const pub = p.published![platform]!;
|
|
178
|
+
return `▶ Part ${p.part}: ${pub.url ?? `https://www.facebook.com/${pub.id}`}`;
|
|
179
|
+
})
|
|
180
|
+
.join("\n") +
|
|
181
|
+
`\n\n${footer}`;
|
|
182
|
+
|
|
183
|
+
for (const p of published) {
|
|
184
|
+
const pub = p.published![platform]!;
|
|
185
|
+
try {
|
|
186
|
+
if (pub.indexCommentId) {
|
|
187
|
+
await facebook.updateComment(pub.indexCommentId, message, token);
|
|
188
|
+
} else {
|
|
189
|
+
const c = await facebook.postComment(pub.id!, message, token);
|
|
190
|
+
store.setPublishState(job.id, p.file, platform, { ...pub, indexCommentId: c.id });
|
|
191
|
+
}
|
|
192
|
+
updated++;
|
|
193
|
+
} catch (err) {
|
|
194
|
+
warnings.push(`Part ${p.part}: ${(err as Error).message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { updated, warnings };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function handlePublish(req: Request): Promise<Response> {
|
|
201
|
+
const body = (await req.json().catch(() => null)) as {
|
|
202
|
+
jobId?: string;
|
|
203
|
+
file?: string;
|
|
204
|
+
platform?: string;
|
|
205
|
+
/** Maintain an "All parts" index comment on every posted part of this series. */
|
|
206
|
+
linkAll?: boolean;
|
|
207
|
+
} | null;
|
|
208
|
+
const jobId = body?.jobId ?? "";
|
|
209
|
+
const file = body?.file ?? "";
|
|
210
|
+
const platform = body?.platform ?? "facebook";
|
|
211
|
+
if (platform !== "facebook") return json({ error: `Unsupported platform: ${platform}` }, 400);
|
|
212
|
+
|
|
213
|
+
const job = store.get(jobId);
|
|
214
|
+
if (!job) return json({ error: "No such job." }, 404);
|
|
215
|
+
const part = job.parts.find((p) => p.file === file);
|
|
216
|
+
if (!part) return json({ error: "No such part." }, 404);
|
|
217
|
+
const path = safeJoin(join(job.outDir, "parts"), file);
|
|
218
|
+
if (!path || !(await Bun.file(path).exists())) return json({ error: "Clip file is missing." }, 404);
|
|
219
|
+
|
|
220
|
+
const fb = await social.getFacebook();
|
|
221
|
+
if (!fb?.pageId || !fb?.token) {
|
|
222
|
+
return json({ error: "Facebook isn’t configured. Add your Page in Settings." }, 400);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Guard against double-publishing: already done, or another upload in flight.
|
|
226
|
+
const existing = store.publishState(jobId, file, platform);
|
|
227
|
+
if (existing?.status === "done") return json({ ok: true, platform, ...existing });
|
|
228
|
+
if (existing?.status === "pending") return json({ error: "This part is already being published." }, 409);
|
|
229
|
+
|
|
230
|
+
// Persist the in-flight state first, so a page refresh (or a second click) sees it.
|
|
231
|
+
store.setPublishState(jobId, file, platform, { status: "pending", at: Date.now() });
|
|
232
|
+
try {
|
|
233
|
+
const res = await facebook.uploadVideo({
|
|
234
|
+
pageId: fb.pageId,
|
|
235
|
+
token: fb.token,
|
|
236
|
+
filePath: path,
|
|
237
|
+
title: part.title,
|
|
238
|
+
description: part.caption,
|
|
239
|
+
// Persist byte progress so the frontend's poll can render a live bar.
|
|
240
|
+
onProgress: (sent, total) =>
|
|
241
|
+
store.setPublishState(jobId, file, platform, { status: "pending", sent, total, at: Date.now() }),
|
|
242
|
+
});
|
|
243
|
+
const info = { status: "done" as const, id: res.id, url: res.url, at: Date.now() };
|
|
244
|
+
store.setPublishState(jobId, file, platform, info);
|
|
245
|
+
|
|
246
|
+
// Series index: refresh the "All parts" comment across every posted part.
|
|
247
|
+
// Best-effort — the publish itself already succeeded, so index trouble is reported
|
|
248
|
+
// as a warning, never as a failed publish.
|
|
249
|
+
let indexed: number | undefined;
|
|
250
|
+
let linkWarning: string | undefined;
|
|
251
|
+
if (body?.linkAll) {
|
|
252
|
+
const sync = await syncPartsIndex(job, platform, fb.token);
|
|
253
|
+
indexed = sync.updated;
|
|
254
|
+
if (sync.warnings.length) linkWarning = `Index comment issues — ${sync.warnings.join("; ")}`;
|
|
255
|
+
}
|
|
256
|
+
return json({
|
|
257
|
+
ok: true,
|
|
258
|
+
platform,
|
|
259
|
+
...info,
|
|
260
|
+
...(indexed !== undefined ? { indexed } : {}),
|
|
261
|
+
...(linkWarning ? { linkWarning } : {}),
|
|
262
|
+
});
|
|
263
|
+
} catch (err) {
|
|
264
|
+
const msg = (err as Error).message;
|
|
265
|
+
store.setPublishState(jobId, file, platform, { status: "error", error: msg, at: Date.now() });
|
|
266
|
+
return json({ error: msg }, 502);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Delete the Facebook post for a part, then forget the local publish record. */
|
|
271
|
+
async function handleUnpublish(req: Request): Promise<Response> {
|
|
272
|
+
const body = (await req.json().catch(() => null)) as { jobId?: string; file?: string; platform?: string } | null;
|
|
273
|
+
const jobId = body?.jobId ?? "";
|
|
274
|
+
const file = body?.file ?? "";
|
|
275
|
+
const platform = body?.platform ?? "facebook";
|
|
276
|
+
if (platform !== "facebook") return json({ error: `Unsupported platform: ${platform}` }, 400);
|
|
277
|
+
|
|
278
|
+
const pub = store.publishState(jobId, file, platform);
|
|
279
|
+
if (!pub?.id) return json({ error: "This part isn’t posted." }, 404);
|
|
280
|
+
|
|
281
|
+
const fb = await social.getFacebook();
|
|
282
|
+
if (!fb?.token) return json({ error: "Facebook isn’t configured. Add your Page in Settings." }, 400);
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
await facebook.deleteVideo(pub.id, fb.token);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
// If the post is already gone on Facebook's side, just forget it locally.
|
|
288
|
+
const msg = (err as Error).message;
|
|
289
|
+
if (!/does not exist|unsupported|cannot be loaded|missing/i.test(msg)) {
|
|
290
|
+
return json({ error: msg }, 502);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
store.clearPublishState(jobId, file, platform);
|
|
294
|
+
|
|
295
|
+
// If this series maintains "All parts" index comments, scrub the dead link from the
|
|
296
|
+
// remaining posts (best-effort — the removal itself already succeeded).
|
|
297
|
+
const job = store.get(jobId);
|
|
298
|
+
if (job && job.parts.some((p) => p.published?.[platform]?.indexCommentId)) {
|
|
299
|
+
await syncPartsIndex(job, platform, fb.token).catch(() => {});
|
|
300
|
+
}
|
|
301
|
+
return json({ ok: true });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- Reveal a clip in the OS file manager (local machine only) --------------
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* True only when the request comes from a browser on this same machine. Behind a
|
|
308
|
+
* tunnel/proxy (e.g. cloudflared) the socket is loopback but forwarding headers are set,
|
|
309
|
+
* so those requests are treated as remote — a remote user must not pop Finder on the host.
|
|
310
|
+
*/
|
|
311
|
+
function isLocalRequest(req: Request, server: Bun.Server<undefined>): boolean {
|
|
312
|
+
if (req.headers.get("x-forwarded-for") || req.headers.get("x-forwarded-proto") || req.headers.get("x-forwarded-host")) {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
const ip = server.requestIP(req)?.address ?? "";
|
|
316
|
+
return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Open the OS file manager with the clip selected (macOS Finder / Windows Explorer / Linux). */
|
|
320
|
+
function revealInFileManager(filePath: string): void {
|
|
321
|
+
const cmd =
|
|
322
|
+
process.platform === "darwin"
|
|
323
|
+
? ["open", "-R", filePath]
|
|
324
|
+
: process.platform === "win32"
|
|
325
|
+
? ["explorer", `/select,${filePath}`]
|
|
326
|
+
: ["xdg-open", dirname(filePath)]; // Linux has no universal "select" — open the folder
|
|
327
|
+
Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function handleReveal(req: Request, server: Bun.Server<undefined>): Promise<Response> {
|
|
331
|
+
if (!isLocalRequest(req, server)) {
|
|
332
|
+
return json({ error: "“Reveal” only works when ShortsMaker is open on the same computer as the server." }, 403);
|
|
333
|
+
}
|
|
334
|
+
const body = (await req.json().catch(() => null)) as { jobId?: string; file?: string } | null;
|
|
335
|
+
const job = store.get(body?.jobId ?? "");
|
|
336
|
+
if (!job) return json({ error: "No such job." }, 404);
|
|
337
|
+
const path = safeJoin(join(job.outDir, "parts"), body?.file ?? "");
|
|
338
|
+
if (!path || !(await Bun.file(path).exists())) return json({ error: "Clip file is missing." }, 404);
|
|
339
|
+
try {
|
|
340
|
+
revealInFileManager(path);
|
|
341
|
+
return json({ ok: true });
|
|
342
|
+
} catch (err) {
|
|
343
|
+
return json({ error: (err as Error).message }, 500);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function handleCreate(req: Request): Promise<Response> {
|
|
348
|
+
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
|
349
|
+
if (!body) return json({ error: "Invalid JSON body." }, 400);
|
|
350
|
+
|
|
351
|
+
const title = body.title ? String(body.title).trim() || undefined : undefined;
|
|
352
|
+
const partLen = body.partLen != null ? Math.max(30, Number(body.partLen)) : undefined;
|
|
353
|
+
const numHashtags = body.numHashtags != null ? Math.max(0, Number(body.numHashtags)) : undefined;
|
|
354
|
+
const hashtags = Array.isArray(body.hashtags)
|
|
355
|
+
? (body.hashtags as unknown[]).map(String)
|
|
356
|
+
: typeof body.hashtags === "string"
|
|
357
|
+
? (body.hashtags as string).split(/[\n,]/)
|
|
358
|
+
: undefined;
|
|
359
|
+
|
|
360
|
+
// The badge is opt-in; anything but a known position (including null/absent) means off.
|
|
361
|
+
let overlay: store.CreateOptions["overlay"];
|
|
362
|
+
if (body.overlay != null && body.overlay !== false && body.overlay !== "") {
|
|
363
|
+
if (!isOverlayPosition(body.overlay)) {
|
|
364
|
+
return json({ error: `Overlay position must be one of: ${OVERLAY_POSITIONS.join(", ")}` }, 400);
|
|
365
|
+
}
|
|
366
|
+
overlay = body.overlay;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Two source modes: a staged upload (`video`) or a remote link (`url`, fetched by yt-dlp).
|
|
370
|
+
const rawUrl = typeof body.url === "string" ? body.url.trim() : "";
|
|
371
|
+
let source: { video?: string; url?: string; videoName: string };
|
|
372
|
+
if (rawUrl) {
|
|
373
|
+
let parsed: URL;
|
|
374
|
+
try {
|
|
375
|
+
parsed = new URL(rawUrl);
|
|
376
|
+
} catch {
|
|
377
|
+
return json({ error: "That doesn’t look like a valid URL." }, 400);
|
|
378
|
+
}
|
|
379
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
380
|
+
return json({ error: "Only http/https links are supported." }, 400);
|
|
381
|
+
}
|
|
382
|
+
// Provisional label until the real title is known post-download.
|
|
383
|
+
source = { url: rawUrl, videoName: parsed.hostname.replace(/^www\./, "") };
|
|
384
|
+
} else {
|
|
385
|
+
const videoName = String(body.video ?? "");
|
|
386
|
+
// The source is the staged upload in uploads/ — deleted once the job finishes.
|
|
387
|
+
const src = safeJoin(UPLOADS_DIR, videoName);
|
|
388
|
+
if (!videoName || !src || !(await Bun.file(src).exists())) {
|
|
389
|
+
return json({ error: `Uploaded video not found: ${videoName}` }, 400);
|
|
390
|
+
}
|
|
391
|
+
source = { video: src, videoName };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const outDir = join(OUTPUT_DIR, `${slugStem(source.videoName)}__${store.stamp()}`);
|
|
395
|
+
const job = await store.create({
|
|
396
|
+
...source,
|
|
397
|
+
outDir,
|
|
398
|
+
title,
|
|
399
|
+
partLen,
|
|
400
|
+
numHashtags,
|
|
401
|
+
hashtags,
|
|
402
|
+
overlay,
|
|
403
|
+
});
|
|
404
|
+
return json(store.summary(job), 201);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---- Uploads library --------------------------------------------------------
|
|
408
|
+
// uploads/ holds every source video (browser uploads + finished URL downloads). Files
|
|
409
|
+
// persist across jobs and restarts; the New split page manages them.
|
|
410
|
+
|
|
411
|
+
async function handleListUploads(): Promise<Response> {
|
|
412
|
+
try {
|
|
413
|
+
const entries = await readdir(UPLOADS_DIR, { withFileTypes: true });
|
|
414
|
+
const files = await Promise.all(
|
|
415
|
+
entries
|
|
416
|
+
.filter((e) => e.isFile() && isVideo(e.name))
|
|
417
|
+
.map(async (e) => {
|
|
418
|
+
const f = Bun.file(join(UPLOADS_DIR, e.name));
|
|
419
|
+
return { name: e.name, size: f.size, mtime: (await f.stat()).mtimeMs };
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
422
|
+
files.sort((a, b) => b.mtime - a.mtime);
|
|
423
|
+
return json(files);
|
|
424
|
+
} catch {
|
|
425
|
+
return json([]); // uploads/ may not exist yet
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function handleDeleteUpload(name: string): Promise<Response> {
|
|
430
|
+
const path = safeJoin(UPLOADS_DIR, decodeURIComponent(name));
|
|
431
|
+
if (!path || !isVideo(path)) return json({ error: "No such upload." }, 404);
|
|
432
|
+
if (!(await Bun.file(path).exists())) return json({ error: "No such upload." }, 404);
|
|
433
|
+
await rm(path, { force: true });
|
|
434
|
+
return json({ ok: true });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Delete every source video. Staging dirs and non-video files are left alone. */
|
|
438
|
+
async function handleClearUploads(): Promise<Response> {
|
|
439
|
+
try {
|
|
440
|
+
const entries = await readdir(UPLOADS_DIR, { withFileTypes: true });
|
|
441
|
+
const videos = entries.filter((e) => e.isFile() && isVideo(e.name));
|
|
442
|
+
await Promise.all(videos.map((e) => rm(join(UPLOADS_DIR, e.name), { force: true }).catch(() => {})));
|
|
443
|
+
return json({ ok: true, removed: videos.length });
|
|
444
|
+
} catch {
|
|
445
|
+
return json({ ok: true, removed: 0 }); // uploads/ may not exist yet
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function streamJob(id: string): Response {
|
|
450
|
+
const job = store.get(id);
|
|
451
|
+
if (!job) return json({ error: "No such job." }, 404);
|
|
452
|
+
|
|
453
|
+
let unsub: (() => void) | null = null;
|
|
454
|
+
let closed = false;
|
|
455
|
+
|
|
456
|
+
const stream = new ReadableStream({
|
|
457
|
+
start(controller) {
|
|
458
|
+
const enc = new TextEncoder();
|
|
459
|
+
const send = (ev: store.JobEvent) => {
|
|
460
|
+
if (closed) return;
|
|
461
|
+
try {
|
|
462
|
+
controller.enqueue(enc.encode(`data: ${JSON.stringify(ev)}\n\n`));
|
|
463
|
+
} catch {
|
|
464
|
+
// Client went away — stop and detach so the job keeps running untouched.
|
|
465
|
+
closed = true;
|
|
466
|
+
unsub?.();
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// Replay what's already happened, then go live.
|
|
471
|
+
for (const line of job.logs) send({ type: "log", line });
|
|
472
|
+
if (job.status !== "running") {
|
|
473
|
+
send({ type: "status", status: job.status, error: job.error, parts: job.parts });
|
|
474
|
+
closed = true;
|
|
475
|
+
try {
|
|
476
|
+
controller.close();
|
|
477
|
+
} catch {
|
|
478
|
+
/* already closed */
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
unsub = store.subscribe(id, (ev) => {
|
|
483
|
+
send(ev);
|
|
484
|
+
if (ev.type === "status" && ev.status !== "running") {
|
|
485
|
+
closed = true;
|
|
486
|
+
unsub?.();
|
|
487
|
+
try {
|
|
488
|
+
controller.close();
|
|
489
|
+
} catch {
|
|
490
|
+
/* already closed */
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
},
|
|
495
|
+
cancel() {
|
|
496
|
+
// Browser closed the EventSource — unsubscribe so we stop writing.
|
|
497
|
+
closed = true;
|
|
498
|
+
unsub?.();
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
return new Response(stream, {
|
|
503
|
+
headers: {
|
|
504
|
+
"content-type": "text/event-stream",
|
|
505
|
+
"cache-control": "no-cache",
|
|
506
|
+
connection: "keep-alive",
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Clear download staging dirs left behind by a crash/restart. The uploaded/downloaded
|
|
513
|
+
* videos themselves are a persistent library now — never swept.
|
|
514
|
+
*/
|
|
515
|
+
async function sweepStaging(): Promise<void> {
|
|
516
|
+
try {
|
|
517
|
+
const entries = await readdir(UPLOADS_DIR, { withFileTypes: true });
|
|
518
|
+
await Promise.all(
|
|
519
|
+
entries
|
|
520
|
+
.filter((e) => e.isDirectory() && e.name.startsWith(".dl_"))
|
|
521
|
+
.map((e) => rm(join(UPLOADS_DIR, e.name), { recursive: true, force: true }).catch(() => {})),
|
|
522
|
+
);
|
|
523
|
+
} catch {
|
|
524
|
+
/* uploads/ may not exist yet */
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Serve the pre-built SPA from dist/: a real file if it exists, else the app shell so
|
|
529
|
+
* client routes (/uploads, /jobs/x, …) and hard refreshes resolve. Prod path only. */
|
|
530
|
+
async function serveStatic(pathname: string): Promise<Response> {
|
|
531
|
+
const rel = pathname === "/" ? "index.html" : pathname.slice(1);
|
|
532
|
+
const filePath = safeJoin(DIST_DIR, rel);
|
|
533
|
+
if (filePath) {
|
|
534
|
+
const f = Bun.file(filePath);
|
|
535
|
+
if (await f.exists()) return new Response(f);
|
|
536
|
+
}
|
|
537
|
+
// A missing asset (has an extension) is a genuine 404; a client route gets the shell.
|
|
538
|
+
if (extname(pathname)) return new Response("Not found", { status: 404 });
|
|
539
|
+
return new Response(Bun.file(join(DIST_DIR, "index.html")), {
|
|
540
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
await store.load();
|
|
545
|
+
await sweepStaging();
|
|
546
|
+
|
|
547
|
+
// In dev, Bun bundles the React app from this HTML entry (with HMR); every client-side
|
|
548
|
+
// route serves the same shell so deep links / hard refreshes work. In prod these are
|
|
549
|
+
// served from dist/ by serveStatic() instead.
|
|
550
|
+
const spaRoutes = homepage
|
|
551
|
+
? {
|
|
552
|
+
"/": homepage,
|
|
553
|
+
"/settings": homepage,
|
|
554
|
+
"/settings/*": homepage,
|
|
555
|
+
"/jobs/*": homepage,
|
|
556
|
+
}
|
|
557
|
+
: undefined;
|
|
558
|
+
|
|
559
|
+
const server = Bun.serve({
|
|
560
|
+
port: Number(process.env.PORT ?? 4321),
|
|
561
|
+
idleTimeout: 0, // keep SSE connections open + allow long uploads
|
|
562
|
+
maxRequestBodySize: 64 * 1024 ** 3, // 64 GB — video uploads (default is only 128 MB)
|
|
563
|
+
...(spaRoutes ? { routes: spaRoutes } : {}),
|
|
564
|
+
development: DEV ? { hmr: true, console: true } : false,
|
|
565
|
+
async fetch(req, server) {
|
|
566
|
+
const url = new URL(req.url);
|
|
567
|
+
const { pathname } = url;
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
if (pathname === "/api/hashtags") return json({ hashtags: await hashtagPool() });
|
|
571
|
+
if (pathname === "/api/env") return json({ platform: process.platform, local: isLocalRequest(req, server) });
|
|
572
|
+
if (pathname === "/api/upload" && req.method === "PUT") return handleUpload(req, url);
|
|
573
|
+
if (pathname === "/api/reveal" && req.method === "POST") return handleReveal(req, server);
|
|
574
|
+
|
|
575
|
+
if (pathname === "/api/settings") {
|
|
576
|
+
if (req.method === "PUT") return handleSaveSettings(req);
|
|
577
|
+
return handleGetSettings();
|
|
578
|
+
}
|
|
579
|
+
if (pathname === "/api/settings/facebook/test" && req.method === "POST") return handleFbTest(req);
|
|
580
|
+
if (pathname === "/api/settings/facebook" && req.method === "DELETE") {
|
|
581
|
+
await social.clearFacebook();
|
|
582
|
+
return handleGetSettings();
|
|
583
|
+
}
|
|
584
|
+
if (pathname === "/api/publish" && req.method === "POST") return handlePublish(req);
|
|
585
|
+
if (pathname === "/api/publish" && req.method === "DELETE") return handleUnpublish(req);
|
|
586
|
+
|
|
587
|
+
if (pathname === "/api/uploads" && req.method === "GET") return handleListUploads();
|
|
588
|
+
if (pathname === "/api/uploads" && req.method === "DELETE") return handleClearUploads();
|
|
589
|
+
const up = pathname.match(/^\/api\/uploads\/(.+)$/);
|
|
590
|
+
if (up && req.method === "DELETE") return handleDeleteUpload(up[1]!);
|
|
591
|
+
|
|
592
|
+
if (pathname === "/api/jobs") {
|
|
593
|
+
if (req.method === "POST") return handleCreate(req);
|
|
594
|
+
return json(store.list());
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const m = pathname.match(/^\/api\/jobs\/([^/]+)(\/(stream|parts))?(\/(.+))?$/);
|
|
598
|
+
if (m) {
|
|
599
|
+
const id = m[1]!;
|
|
600
|
+
const kind = m[3];
|
|
601
|
+
if (!kind) {
|
|
602
|
+
if (req.method === "DELETE") {
|
|
603
|
+
const ok = await store.remove(id);
|
|
604
|
+
return ok ? json({ ok }) : json({ error: "No such job." }, 404);
|
|
605
|
+
}
|
|
606
|
+
const job = store.get(id);
|
|
607
|
+
return job ? json(job) : json({ error: "No such job." }, 404);
|
|
608
|
+
}
|
|
609
|
+
if (kind === "stream") return streamJob(id);
|
|
610
|
+
if (kind === "parts") {
|
|
611
|
+
const job = store.get(id);
|
|
612
|
+
const rel = m[5] ?? "";
|
|
613
|
+
if (!job || !rel) return json({ error: "Not found." }, 404);
|
|
614
|
+
if (req.method === "PATCH") {
|
|
615
|
+
const body = (await req.json().catch(() => null)) as { caption?: string } | null;
|
|
616
|
+
if (typeof body?.caption !== "string") return json({ error: "A caption string is required." }, 400);
|
|
617
|
+
const ok = await store.setCaption(id, rel, body.caption);
|
|
618
|
+
return ok ? json({ ok: true, caption: body.caption }) : json({ error: "No such part." }, 404);
|
|
619
|
+
}
|
|
620
|
+
const path = safeJoin(join(job.outDir, "parts"), rel);
|
|
621
|
+
if (!path || !(await Bun.file(path).exists())) {
|
|
622
|
+
return json({ error: "Not found." }, 404);
|
|
623
|
+
}
|
|
624
|
+
const dl = url.searchParams.has("download");
|
|
625
|
+
return new Response(Bun.file(path), {
|
|
626
|
+
headers: {
|
|
627
|
+
"content-type": contentType(path),
|
|
628
|
+
...(dl ? { "content-disposition": `attachment; filename="${basename(path)}"` } : {}),
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// In prod, serve the built SPA + assets from dist/. In dev, `routes` above already
|
|
635
|
+
// handled the shell and Bun serves the bundled assets, so anything here is a 404.
|
|
636
|
+
if (!DEV) return serveStatic(pathname);
|
|
637
|
+
return new Response("Not found", { status: 404 });
|
|
638
|
+
} catch (err) {
|
|
639
|
+
const e = err as Error;
|
|
640
|
+
return json({ error: `${e.name}: ${e.message}` }, 500);
|
|
641
|
+
}
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
console.log(`\n shorts-maker ▸ http://localhost:${server.port}\n`);
|