shorts-maker 0.3.0 → 0.4.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/dist/index.html CHANGED
@@ -10,7 +10,7 @@
10
10
  type="image/svg+xml"
11
11
  href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%237c5cff'/%3E%3Cstop offset='1' stop-color='%232dd4bf'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cg transform='translate(6.5 6.5) scale(0.79)' fill='none' stroke='white' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='6' cy='6' r='3'/%3E%3Ccircle cx='6' cy='18' r='3'/%3E%3Cline x1='20' y1='4' x2='8.12' y2='15.88'/%3E%3Cline x1='14.47' y1='14.48' x2='20' y2='20'/%3E%3Cline x1='8.12' y1='8.12' x2='12' y2='12'/%3E%3C/g%3E%3C/svg%3E"
12
12
  />
13
- <link rel="stylesheet" crossorigin href="/chunk-wt9saz7q.css"><script type="module" crossorigin src="/chunk-27wzf6kd.js"></script></head>
13
+ <link rel="stylesheet" crossorigin href="/chunk-1w58sy4p.css"><script type="module" crossorigin src="/chunk-23ted1q8.js"></script></head>
14
14
  <body>
15
15
  <div id="root"></div>
16
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shorts-maker",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Split a video into consecutive shorts titled from its name, add hashtags, and publish them to Facebook — runs locally in your browser.",
5
5
  "type": "module",
6
6
  "module": "src/server.ts",
@@ -90,7 +90,11 @@ export interface PublishInfo {
90
90
  /** Upload progress while `pending`: bytes transferred so far, and the total. */
91
91
  sent?: number;
92
92
  total?: number;
93
- /** Id of this post's "All parts" index comment, so it can be edited as parts land. */
93
+ /**
94
+ * Id of the Page comment written on this post from the job page's comment dialog, so a
95
+ * later run edits it in place instead of adding a second one. (Named for the "All parts"
96
+ * index it used to serve, which the dialog replaced.)
97
+ */
94
98
  indexCommentId?: string;
95
99
  /** When this state was last set (ms epoch). */
96
100
  at: number;
package/src/server.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  import { join, basename, extname, dirname } from "node:path";
5
5
  import { readdir, mkdir, rm } from "node:fs/promises";
6
6
  import { existsSync } from "node:fs";
7
- import { hashtagPool } from "./pipeline/pipeline.ts";
7
+ import { hashtagPool, type PublishInfo } from "./pipeline/pipeline.ts";
8
8
  import { isOverlayPosition, OVERLAY_POSITIONS } from "./pipeline/overlay.ts";
9
9
  import * as store from "./jobs/store.ts";
10
10
  import * as social from "./social/config.ts";
@@ -138,72 +138,11 @@ async function handleFbTest(req: Request): Promise<Response> {
138
138
  }
139
139
  }
140
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
141
  async function handlePublish(req: Request): Promise<Response> {
201
142
  const body = (await req.json().catch(() => null)) as {
202
143
  jobId?: string;
203
144
  file?: string;
204
145
  platform?: string;
205
- /** Maintain an "All parts" index comment on every posted part of this series. */
206
- linkAll?: boolean;
207
146
  } | null;
208
147
  const jobId = body?.jobId ?? "";
209
148
  const file = body?.file ?? "";
@@ -242,24 +181,7 @@ async function handlePublish(req: Request): Promise<Response> {
242
181
  });
243
182
  const info = { status: "done" as const, id: res.id, url: res.url, at: Date.now() };
244
183
  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
- });
184
+ return json({ ok: true, platform, ...info });
263
185
  } catch (err) {
264
186
  const msg = (err as Error).message;
265
187
  store.setPublishState(jobId, file, platform, { status: "error", error: msg, at: Date.now() });
@@ -267,6 +189,163 @@ async function handlePublish(req: Request): Promise<Response> {
267
189
  }
268
190
  }
269
191
 
192
+ /** A comment Facebook no longer has — editing it must fall back to writing a fresh one. */
193
+ const commentGone = (msg: string) => /does not exist|cannot be loaded|unsupported get request|missing/i.test(msg);
194
+
195
+ /**
196
+ * Comment on several of a job's published parts at once.
197
+ *
198
+ * A part whose post already carries this app's comment (`indexCommentId`) has it edited in
199
+ * place; otherwise a fresh comment is written and its id stored, so the next run edits that
200
+ * one instead of piling up copies.
201
+ *
202
+ * Each part is attempted independently: one Page/rate-limit failure must not hide the
203
+ * comments that did land, so every outcome comes back per-part rather than as a single throw.
204
+ */
205
+ async function handleComment(req: Request): Promise<Response> {
206
+ const body = (await req.json().catch(() => null)) as {
207
+ jobId?: string;
208
+ files?: string[];
209
+ platform?: string;
210
+ message?: string;
211
+ } | null;
212
+ const platform = body?.platform ?? "facebook";
213
+ if (platform !== "facebook") return json({ error: `Unsupported platform: ${platform}` }, 400);
214
+
215
+ const message = (body?.message ?? "").trim();
216
+ if (!message) return json({ error: "A comment message is required." }, 400);
217
+
218
+ const job = store.get(body?.jobId ?? "");
219
+ if (!job) return json({ error: "No such job." }, 404);
220
+
221
+ const files = Array.isArray(body?.files) ? body.files.map(String) : [];
222
+ if (!files.length) return json({ error: "Pick at least one published part." }, 400);
223
+
224
+ const fb = await social.getFacebook();
225
+ if (!fb?.pageId || !fb?.token) {
226
+ return json({ error: "Facebook isn’t configured. Add your Page in Settings." }, 400);
227
+ }
228
+
229
+ type Result = { file: string; part?: number; ok: boolean; id?: string; action?: "created" | "updated"; error?: string };
230
+ const results: Result[] = [];
231
+
232
+ /** Write a new comment on the post and remember its id, so the next run edits it. */
233
+ const create = async (file: string, postId: string, pub: PublishInfo): Promise<Result> => {
234
+ const c = await facebook.postComment(postId, message, fb.token);
235
+ store.setPublishState(job.id, file, platform, { ...pub, indexCommentId: c.id });
236
+ return { file, ok: true, id: c.id, action: "created" };
237
+ };
238
+
239
+ for (const file of files) {
240
+ const part = job.parts.find((p) => p.file === file);
241
+ const pub = part?.published?.[platform];
242
+ if (!part || pub?.status !== "done" || !pub.id) {
243
+ results.push({ file, part: part?.part, ok: false, error: "This part isn’t posted." });
244
+ continue;
245
+ }
246
+ try {
247
+ if (pub.indexCommentId) {
248
+ try {
249
+ await facebook.updateComment(pub.indexCommentId, message, fb.token);
250
+ results.push({ file, part: part.part, ok: true, id: pub.indexCommentId, action: "updated" });
251
+ } catch (err) {
252
+ // Someone deleted it on Facebook's side — write a fresh one and re-track it.
253
+ if (!commentGone((err as Error).message)) throw err;
254
+ results.push({ ...(await create(file, pub.id, pub)), part: part.part });
255
+ }
256
+ } else {
257
+ results.push({ ...(await create(file, pub.id, pub)), part: part.part });
258
+ }
259
+ } catch (err) {
260
+ results.push({ file, part: part.part, ok: false, error: (err as Error).message });
261
+ }
262
+ }
263
+ const posted = results.filter((r) => r.ok).length;
264
+ return json({ ok: posted === results.length, posted, results });
265
+ }
266
+
267
+ /**
268
+ * Reconcile the tracked comment ids against what's actually on Facebook, without writing
269
+ * anything to the Page. For each published part we list the comments its post carries that
270
+ * were authored by our Page, then:
271
+ *
272
+ * - the tracked comment is still there → "tracked" (nothing to do)
273
+ * - it's gone, or we never had one, and exactly
274
+ * one Page comment exists → "linked" (adopt it)
275
+ * - several Page comments exist and none is
276
+ * tracked → "ambiguous" (adopt nothing, list them)
277
+ * - the Page has commented nothing → "none"
278
+ *
279
+ * The ambiguous case is deliberately left alone: guessing wrong would mean the next comment
280
+ * silently overwrites the wrong one. The candidates come back so the user can decide.
281
+ */
282
+ async function handleSyncComments(req: Request): Promise<Response> {
283
+ const body = (await req.json().catch(() => null)) as { jobId?: string; platform?: string } | null;
284
+ const platform = body?.platform ?? "facebook";
285
+ if (platform !== "facebook") return json({ error: `Unsupported platform: ${platform}` }, 400);
286
+
287
+ const job = store.get(body?.jobId ?? "");
288
+ if (!job) return json({ error: "No such job." }, 404);
289
+
290
+ const fb = await social.getFacebook();
291
+ if (!fb?.pageId || !fb?.token) {
292
+ return json({ error: "Facebook isn’t configured. Add your Page in Settings." }, 400);
293
+ }
294
+
295
+ type Status = "tracked" | "linked" | "ambiguous" | "none";
296
+ const results: {
297
+ file: string;
298
+ part: number;
299
+ status?: Status;
300
+ ok: boolean;
301
+ commentId?: string;
302
+ candidates?: facebook.PageComment[];
303
+ error?: string;
304
+ }[] = [];
305
+
306
+ for (const part of job.parts) {
307
+ const pub = part.published?.[platform];
308
+ if (pub?.status !== "done" || !pub.id) continue; // not posted — nothing to reconcile
309
+
310
+ try {
311
+ const mine = await facebook.listPageComments(pub.id, fb.pageId, fb.token);
312
+ const tracked = pub.indexCommentId && mine.some((c) => c.id === pub.indexCommentId);
313
+
314
+ let status: Status;
315
+ let commentId: string | undefined;
316
+ if (tracked) {
317
+ status = "tracked";
318
+ commentId = pub.indexCommentId;
319
+ } else if (mine.length === 1) {
320
+ status = "linked";
321
+ commentId = mine[0]!.id;
322
+ } else if (mine.length > 1) {
323
+ status = "ambiguous";
324
+ } else {
325
+ status = "none";
326
+ }
327
+
328
+ // Adopt the found comment; drop a tracked id that no longer exists on the Page.
329
+ if (commentId !== pub.indexCommentId) {
330
+ store.setPublishState(job.id, part.file, platform, { ...pub, indexCommentId: commentId });
331
+ }
332
+ results.push({
333
+ file: part.file,
334
+ part: part.part,
335
+ ok: true,
336
+ status,
337
+ commentId,
338
+ ...(status === "ambiguous" ? { candidates: mine } : {}),
339
+ });
340
+ } catch (err) {
341
+ results.push({ file: part.file, part: part.part, ok: false, error: (err as Error).message });
342
+ }
343
+ }
344
+
345
+ if (!results.length) return json({ error: "No published parts to sync." }, 400);
346
+ return json({ ok: results.every((r) => r.ok), results });
347
+ }
348
+
270
349
  /** Delete the Facebook post for a part, then forget the local publish record. */
271
350
  async function handleUnpublish(req: Request): Promise<Response> {
272
351
  const body = (await req.json().catch(() => null)) as { jobId?: string; file?: string; platform?: string } | null;
@@ -290,14 +369,10 @@ async function handleUnpublish(req: Request): Promise<Response> {
290
369
  return json({ error: msg }, 502);
291
370
  }
292
371
  }
372
+ // Deleting the post takes its comment with it, so the local record (comment id included)
373
+ // is simply forgotten. Comments on the *other* posts may now list a dead link — reposting
374
+ // from the job page's comment dialog rewrites them with the current parts.
293
375
  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
376
  return json({ ok: true });
302
377
  }
303
378
 
@@ -583,6 +658,8 @@ const server = Bun.serve({
583
658
  }
584
659
  if (pathname === "/api/publish" && req.method === "POST") return handlePublish(req);
585
660
  if (pathname === "/api/publish" && req.method === "DELETE") return handleUnpublish(req);
661
+ if (pathname === "/api/comment" && req.method === "POST") return handleComment(req);
662
+ if (pathname === "/api/comment/sync" && req.method === "POST") return handleSyncComments(req);
586
663
 
587
664
  if (pathname === "/api/uploads" && req.method === "GET") return handleListUploads();
588
665
  if (pathname === "/api/uploads" && req.method === "DELETE") return handleClearUploads();
@@ -107,6 +107,46 @@ export async function postComment(objectId: string, message: string, token: stri
107
107
  return { id: data.id };
108
108
  }
109
109
 
110
+ export interface PageComment {
111
+ id: string;
112
+ message: string;
113
+ createdTime?: string;
114
+ }
115
+
116
+ interface CommentNode {
117
+ id: string;
118
+ message?: string;
119
+ created_time?: string;
120
+ from?: { id?: string };
121
+ }
122
+
123
+ /**
124
+ * Every comment on `objectId` authored by the Page itself, oldest first. Read-only.
125
+ *
126
+ * `from` is only populated for comments the token's Page owns, so a comment with no `from`
127
+ * is somebody else's and is skipped — we must never adopt a viewer's comment as our own.
128
+ * Paging is capped: a post with thousands of comments isn't worth walking, and our comment
129
+ * (posted by us, right after publishing) sits near the front.
130
+ */
131
+ export async function listPageComments(objectId: string, pageId: string, token: string): Promise<PageComment[]> {
132
+ let url =
133
+ `${GRAPH}/${encodeURIComponent(objectId)}/comments` +
134
+ `?fields=id,message,created_time,from&order=chronological&limit=100&access_token=${encodeURIComponent(token)}`;
135
+ const found: PageComment[] = [];
136
+
137
+ for (let page = 0; page < 5 && url; page++) {
138
+ const res = await fetch(url);
139
+ const data = await readJson<GraphError & { data?: CommentNode[]; paging?: { next?: string } }>(res);
140
+ if (data.error) throw new FacebookError(fbMessage(data.error, res.status));
141
+ for (const c of data.data ?? []) {
142
+ if (c.from?.id !== pageId) continue;
143
+ found.push({ id: c.id, message: c.message ?? "", createdTime: c.created_time });
144
+ }
145
+ url = data.paging?.next ?? "";
146
+ }
147
+ return found;
148
+ }
149
+
110
150
  /** Edit an existing Page comment in place. */
111
151
  export async function updateComment(commentId: string, message: string, token: string): Promise<void> {
112
152
  const form = new FormData();