@gentbajko/slopify 3.2.8 → 3.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.
Files changed (42) hide show
  1. package/dist/edge/http/projects.js +8 -0
  2. package/dist/edge/http/studio.js +300 -58
  3. package/dist/extension/slopify-studio-chrome.zip +0 -0
  4. package/dist/extension/slopify-studio-firefox.zip +0 -0
  5. package/dist/kernel/db/migrations/0046-youtube-upload-state.sql +6 -0
  6. package/dist/kernel/db/migrations/0047-studio-autopilot.sql +39 -0
  7. package/dist/patch-notes/3.2.9.md +15 -0
  8. package/dist/patch-notes/3.3.0.md +40 -0
  9. package/dist/patch-notes/index.json +12 -0
  10. package/dist/slices/admission/model.js +4 -0
  11. package/dist/slices/admission/schema.js +1 -0
  12. package/dist/slices/article/segments.js +2 -1
  13. package/dist/slices/rebuild/recipe-appearance.js +2 -1
  14. package/dist/slices/rebuild/recipe-document.js +2 -2
  15. package/dist/slices/rebuild/recipe-exports.js +2 -1
  16. package/dist/slices/rebuild/recipe-reference.js +2 -2
  17. package/dist/slices/rebuild/recipe-scenes.js +3 -3
  18. package/dist/slices/rebuild/recipe-shorts.js +3 -2
  19. package/dist/slices/rebuild/recipe-text.js +2 -1
  20. package/dist/slices/rebuild/recipe-visual.js +4 -4
  21. package/dist/slices/rebuild/recipe-youtube.js +2 -1
  22. package/dist/slices/revisions/mutations.js +2 -1
  23. package/dist/slices/revisions/subject.js +9 -0
  24. package/dist/slices/storage/backup-format.js +4 -0
  25. package/dist/slices/storage/portable.js +12 -1
  26. package/dist/slices/studio/backfill.js +34 -0
  27. package/dist/slices/studio/model.js +2 -0
  28. package/dist/slices/studio/pack.js +64 -8
  29. package/dist/slices/studio/pick.js +33 -0
  30. package/dist/slices/studio/plan.js +190 -0
  31. package/dist/slices/studio/queue.js +7 -0
  32. package/dist/slices/studio/settings.js +2 -0
  33. package/dist/slices/studio/stats.js +71 -0
  34. package/dist/slices/studio/videos.js +45 -11
  35. package/dist/slices/youtube/titles.js +2 -2
  36. package/dist/web/assets/index-6wMtRXOo.css +1 -0
  37. package/dist/web/assets/index-BKn7EPwB.js +310 -0
  38. package/dist/web/assets/{pdf-CqdWRPHL.js → pdf-BPADxYaD.js} +1 -1
  39. package/dist/web/index.html +2 -2
  40. package/package.json +1 -1
  41. package/dist/web/assets/index-BQrub4xw.js +0 -309
  42. package/dist/web/assets/index-DaTBqaK8.css +0 -1
@@ -13,6 +13,7 @@ import { adoptBaseline } from "../../slices/revisions/adopt.js";
13
13
  import { currentRevisionId } from "../../slices/revisions/repo.js";
14
14
  import { limitWaitsByProject, listingWait } from "../../slices/run-cost/panel.js";
15
15
  import { outputsOf } from "../../slices/storage/repo.js";
16
+ import { longVideoStats } from "../../slices/studio/stats.js";
16
17
  import { trashProject } from "../../slices/trash/service.js";
17
18
  import { setAsideProjects, uploadedProjects } from "../../slices/uploads/repo.js";
18
19
  import { onInvalid, problem, titleOf } from "./problem.js";
@@ -60,6 +61,7 @@ export function projectRoutes(deps) {
60
61
  const channels = projectChannels(deps.db);
61
62
  const uploads = uploadedProjects(deps.db);
62
63
  const aside = setAsideProjects(deps.db);
64
+ const numbers = longVideoStats(deps.db);
63
65
  const waits = limitWaitsByProject(deps.db);
64
66
  const projects = listProjects(deps.db).map((project) => {
65
67
  const stages = standings.get(project.id) ?? [];
@@ -71,6 +73,12 @@ export function projectRoutes(deps) {
71
73
  channelId: channels.get(project.id) ?? defaultChannelId,
72
74
  uploadedAt: uploads.get(project.id) ?? null,
73
75
  ...(aside.has(project.id) ? { setAside: true } : {}),
76
+ ...(numbers.get(project.id)?.views == null
77
+ ? {}
78
+ : { views: numbers.get(project.id)?.views ?? 0 }),
79
+ ...(numbers.get(project.id)?.ctr == null
80
+ ? {}
81
+ : { ctr: numbers.get(project.id)?.ctr ?? 0 }),
74
82
  ...(waiting === undefined ? {} : { limitWaits: waiting.map(listingWait) }),
75
83
  };
76
84
  });
@@ -4,14 +4,21 @@ import { Readable } from "node:stream";
4
4
  import { zValidator } from "@hono/zod-validator";
5
5
  import { Hono } from "hono";
6
6
  import { z } from "zod";
7
- import { projectById } from "../../slices/admission/repo.js";
7
+ import { derive } from "../../kernel/runner/graph.js";
8
+ import { listProjects, projectById, stagesOf } from "../../slices/admission/repo.js";
8
9
  import { channelById } from "../../slices/channels/repo.js";
10
+ import { readSetting, writeSetting } from "../../slices/settings/repo.js";
9
11
  import { findDownload } from "../../slices/storage/downloads.js";
10
- import { studioPlaylistMax } from "../../slices/studio/model.js";
12
+ import { backfillVideos } from "../../slices/studio/backfill.js";
13
+ import { studioPlaylistMax, studioUploadUrl, } from "../../slices/studio/model.js";
11
14
  import { packItem, uploadPack } from "../../slices/studio/pack.js";
12
- import { enqueueFill, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
13
- import { bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
14
- import { forgetVideo, projectVideos, recordVideo, setAbState, videoIdOf, videoIdPattern, waitingAbTests, } from "../../slices/studio/videos.js";
15
+ import { writeUploadPick } from "../../slices/studio/pick.js";
16
+ import { assignedSlot, freeSlots, postingPlanSchema, readPlan, setSlot, unschedule, writePlan, } from "../../slices/studio/plan.js";
17
+ import { enqueueFill, fillNow, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
18
+ import { autoCommentKey, bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
19
+ import { abResults, abVariantSchema, projectStats, saveAbResult, saveStats, } from "../../slices/studio/stats.js";
20
+ import { confirmUpload, doneVideos, forgetVideo, projectVideos, recordVideo, setTaskState, videoIdOf, videoIdPattern, videoOf, waitingTasks, } from "../../slices/studio/videos.js";
21
+ import { setAsideProjects, uploadedProjects } from "../../slices/uploads/repo.js";
15
22
  import { onInvalid, problem, titleOf } from "./problem.js";
16
23
  const id = z
17
24
  .string()
@@ -46,20 +53,43 @@ const projectPlaylistsBody = z.object({
46
53
  playlists: z.array(z.string().max(studioPlaylistMax)).max(studioPlaylistsMax).nullable(),
47
54
  });
48
55
  const realFootageBody = z.object({ realFootage: z.boolean() });
56
+ const pickBody = z.object({
57
+ title: z.number().int().min(0).max(9),
58
+ thumbnail: z.number().int().min(0).max(9),
59
+ });
49
60
  const shortField = z.number().int().min(1).max(99).nullable().optional();
50
61
  const extVideoBody = z.object({
51
62
  projectId: id,
52
63
  short: shortField,
53
64
  videoId: z.string().regex(videoIdPattern),
54
65
  });
55
- const abResultBody = z.object({
66
+ const videoLinkBody = z.object({ short: shortField, link: z.string().max(500) });
67
+ const slotBody = z.object({
68
+ slot: z.object({ row: z.string().min(1).max(20), longAt: z.string().max(40) }).nullable(),
69
+ });
70
+ const taskResultBody = z.object({
71
+ task: z.enum(["finish", "comment"]),
56
72
  projectId: id,
57
73
  short: shortField,
58
74
  ok: z.boolean(),
59
75
  message: z.string().max(2000),
60
76
  });
61
- const videoLinkBody = z.object({ short: shortField, link: z.string().max(500) });
62
- const abTestBody = z.object({ short: shortField, start: z.boolean() });
77
+ const statsBody = z.object({
78
+ projectId: id,
79
+ short: shortField,
80
+ videoId: z.string().regex(videoIdPattern),
81
+ impressions: z.number().nonnegative().optional(),
82
+ ctr: z.number().min(0).max(100).optional(),
83
+ views: z.number().nonnegative().optional(),
84
+ averageViewSeconds: z.number().nonnegative().optional(),
85
+ watchHours: z.number().nonnegative().optional(),
86
+ abVariants: z.array(abVariantSchema).max(3).optional(),
87
+ });
88
+ const backfillBody = z.object({
89
+ videos: z
90
+ .array(z.object({ title: z.string().max(200), videoId: z.string().regex(videoIdPattern) }))
91
+ .max(200),
92
+ });
63
93
  // The extension builds the app ships (`scripts/copy-extension.mjs`), by browser.
64
94
  const extensionFiles = {
65
95
  "chrome.zip": "slopify-studio-chrome.zip",
@@ -106,13 +136,18 @@ export function studioRoutes(deps) {
106
136
  title: "Unauthorized",
107
137
  detail: "The Slopify Studio extension isn't paired with this Slopify. Copy the pairing token from Slopify's Settings → YouTube Studio into the extension's options and press Pair.",
108
138
  });
109
- const extAllowed = (c) => studioRequestAllowed(deps.db, bearerToken(c.req.header("authorization")), c.req.header("origin"));
110
- // An upload has an A/B test to run when it has other titles or more than one thumbnail.
111
- const abTestable = (projectId, short) => {
139
+ // A project ready to upload takes the plan's next free slot when its upload is prepared;
140
+ // one whose long video is on YouTube already doesn't.
141
+ const planned = (projectId) => {
142
+ if (videoOf(deps.db, projectId, null)?.uploadState === "done")
143
+ return;
112
144
  const result = uploadPack(deps, projectId);
113
- const item = result.ok ? packItem(result.pack, short ?? undefined) : undefined;
114
- return item !== undefined && (item.titles.length > 0 || item.thumbnails.length > 1);
145
+ if (!result.ok || result.pack.items[0]?.video == null)
146
+ return;
147
+ assignedSlot(deps.db, readPlan(deps.db), projectId, deps.clock.now());
115
148
  };
149
+ const slotChoices = () => freeSlots(deps.db, readPlan(deps.db), deps.clock.now(), 9);
150
+ const extAllowed = (c) => studioRequestAllowed(deps.db, bearerToken(c.req.header("authorization")), c.req.header("origin"));
116
151
  return (new Hono()
117
152
  .get("/settings", (c) => {
118
153
  // The token is read only by the Slopify page, never by another origin's.
@@ -123,7 +158,29 @@ export function studioRoutes(deps) {
123
158
  playlists: readStudioPlaylists(deps.db),
124
159
  channelPlaylists: readChannelPlaylists(deps.db),
125
160
  pairing: studioPairing(deps.db),
161
+ autoComment: readSetting(deps.db, autoCommentKey) === "on",
126
162
  });
163
+ })
164
+ // Post and pin each video's comment once it is public (opt-in: it posts in your name).
165
+ .put("/settings/auto-comment", zValidator("json", z.object({ on: z.boolean() }), onInvalid), (c) => {
166
+ const denied = samePage(c);
167
+ if (denied !== undefined)
168
+ return denied;
169
+ writeSetting(deps.db, autoCommentKey, c.req.valid("json").on ? "on" : "off");
170
+ return c.json({ autoComment: c.req.valid("json").on });
171
+ })
172
+ // The posting plan and the coming free slots.
173
+ .get("/plan", (c) => {
174
+ const plan = readPlan(deps.db);
175
+ return c.json({ plan, free: freeSlots(deps.db, plan, deps.clock.now(), 9) });
176
+ })
177
+ .put("/plan", zValidator("json", postingPlanSchema, onInvalid), (c) => {
178
+ const denied = samePage(c);
179
+ if (denied !== undefined)
180
+ return denied;
181
+ writePlan(deps.db, c.req.valid("json"));
182
+ const plan = readPlan(deps.db);
183
+ return c.json({ plan, free: freeSlots(deps.db, plan, deps.clock.now(), 9) });
127
184
  })
128
185
  .put("/settings/playlists", zValidator("json", playlistsBody, onInvalid), (c) => {
129
186
  const denied = samePage(c);
@@ -156,10 +213,36 @@ export function studioRoutes(deps) {
156
213
  return c.json({ pairing: resetStudioPairing(deps.db) });
157
214
  })
158
215
  .get("/packs/:projectId", zValidator("param", projectParam, onInvalid), (c) => {
159
- const result = uploadPack(deps, c.req.valid("param").projectId);
216
+ const { projectId } = c.req.valid("param");
217
+ planned(projectId);
218
+ const result = uploadPack(deps, projectId);
160
219
  if (!result.ok)
161
220
  return unknownProject(c);
162
- return c.json(result.pack);
221
+ return c.json({ ...result.pack, slotChoices: slotChoices() });
222
+ })
223
+ // Prepare upload's slot picker: another free slot of the plan, or none.
224
+ .put("/packs/:projectId/slot", zValidator("param", projectParam, onInvalid), zValidator("json", slotBody, onInvalid), (c) => {
225
+ const denied = samePage(c);
226
+ if (denied !== undefined)
227
+ return denied;
228
+ const { projectId } = c.req.valid("param");
229
+ const wanted = c.req.valid("json").slot;
230
+ if (wanted === null)
231
+ unschedule(deps.db, projectId, deps.clock.now());
232
+ else {
233
+ const free = slotChoices().some((one) => one.row === wanted.row && one.longAt === wanted.longAt);
234
+ if (!free)
235
+ return problem(c, {
236
+ status: 409,
237
+ title: titleOf(409),
238
+ detail: "That slot is taken or past. Reload Prepare upload and choose one of the slots it lists.",
239
+ });
240
+ setSlot(deps.db, projectId, wanted, deps.clock.now());
241
+ }
242
+ const result = uploadPack(deps, projectId);
243
+ return result.ok
244
+ ? c.json({ ...result.pack, slotChoices: slotChoices() })
245
+ : unknownProject(c);
163
246
  })
164
247
  // Prepare upload's AI use tick: the project's uploaded clips are real footage.
165
248
  .put("/packs/:projectId/real-footage", zValidator("param", projectParam, onInvalid), zValidator("json", realFootageBody, onInvalid), (c) => {
@@ -172,6 +255,19 @@ export function studioRoutes(deps) {
172
255
  saveRealFootage(deps.db, projectId, c.req.valid("json").realFootage);
173
256
  const result = uploadPack(deps, projectId);
174
257
  return result.ok ? c.json(result.pack) : unknownProject(c);
258
+ })
259
+ // Prepare upload's pick: which title and thumbnail the upload carries; the A/B test tries
260
+ // the others once the video is public.
261
+ .put("/packs/:projectId/pick", zValidator("param", projectParam, onInvalid), zValidator("json", pickBody, onInvalid), (c) => {
262
+ const denied = samePage(c);
263
+ if (denied !== undefined)
264
+ return denied;
265
+ const { projectId } = c.req.valid("param");
266
+ if (!uploadPack(deps, projectId).ok)
267
+ return unknownProject(c);
268
+ writeUploadPick(deps.db, projectId, c.req.valid("json"));
269
+ const result = uploadPack(deps, projectId);
270
+ return result.ok ? c.json(result.pack) : unknownProject(c);
175
271
  })
176
272
  // Prepare upload's playlist ticks: which of the channel's playlists this project goes into.
177
273
  .put("/packs/:projectId/playlists", zValidator("param", projectParam, onInvalid), zValidator("json", projectPlaylistsBody, onInvalid), (c) => {
@@ -204,6 +300,9 @@ export function studioRoutes(deps) {
204
300
  return c.json({ chosen: { projectId, short: short ?? null }, queue: queueView(queue) });
205
301
  })
206
302
  // What waits for the extension, oldest first: each new upload dialog takes the first.
303
+ // Studio's numbers for a project's videos, and every finished A/B test (Library).
304
+ .get("/stats/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ stats: projectStats(deps.db, c.req.valid("param").projectId) }))
305
+ .get("/ab-results", (c) => c.json({ results: abResults(deps.db) }))
207
306
  // The YouTube videos a project's uploads became, and their A/B tests.
208
307
  .get("/videos/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ videos: projectVideos(deps.db, c.req.valid("param").projectId) }))
209
308
  // A video's link pasted in Slopify, for an upload made without the extension; an empty
@@ -227,28 +326,6 @@ export function studioRoutes(deps) {
227
326
  });
228
327
  recordVideo(deps.db, projectId, short ?? null, videoId, deps.clock.now().toISOString());
229
328
  return c.json({ videos: projectVideos(deps.db, projectId) });
230
- })
231
- // Start A/B test (or Cancel): the extension starts it once the video is public.
232
- .post("/videos/:projectId/ab-test", zValidator("param", projectParam, onInvalid), zValidator("json", abTestBody, onInvalid), (c) => {
233
- const wrong = samePage(c);
234
- if (wrong !== undefined)
235
- return wrong;
236
- const { projectId } = c.req.valid("param");
237
- const { short, start } = c.req.valid("json");
238
- if (start && !abTestable(projectId, short ?? null))
239
- return problem(c, {
240
- status: 409,
241
- title: titleOf(409),
242
- detail: "There is nothing to A/B test: this upload has one thumbnail and no other titles. Make more thumbnails (Edit project → Thumbnail) or let the YouTube step write other titles, then try again.",
243
- });
244
- const set = setAbState(deps.db, projectId, short ?? null, start ? "waiting" : "none", null, deps.clock.now().toISOString());
245
- if (!set)
246
- return problem(c, {
247
- status: 409,
248
- title: titleOf(409),
249
- detail: "Slopify doesn't know this upload's YouTube video yet. Paste its link (from YouTube Studio → Details → Video link) first.",
250
- });
251
- return c.json({ videos: projectVideos(deps.db, projectId) });
252
329
  })
253
330
  .get("/queue", (c) => {
254
331
  const denied = samePage(c);
@@ -344,53 +421,218 @@ export function studioRoutes(deps) {
344
421
  const left = removeFill(deps.db, projectId, short ?? null, deps.clock.now());
345
422
  return c.json({ waiting: left.length });
346
423
  })
347
- // The extension read the new video's id from Studio's upload dialog. An upload with an
348
- // A/B test to run (other titles, or several thumbnails) waits for its video to be public:
349
- // scheduled videos are private until then, and Studio tests only public ones.
350
- .post("/ext/video", zValidator("json", extVideoBody, onInvalid), (c) => {
424
+ // Rows of Studio's Content list (title and video id), matched to projects by title so
425
+ // uploads made by hand get their links too (`backfill.ts`).
426
+ .post("/ext/backfill", zValidator("json", backfillBody, onInvalid), (c) => {
351
427
  allowOrigin(c, false);
352
428
  if (!extAllowed(c))
353
429
  return refused(c);
354
- const { projectId, short, videoId } = c.req.valid("json");
355
- recordVideo(deps.db, projectId, short ?? null, videoId, deps.clock.now().toISOString());
356
- const testable = abTestable(projectId, short ?? null);
357
- if (testable)
358
- setAbState(deps.db, projectId, short ?? null, "waiting", null, deps.clock.now().toISOString());
359
- return c.json({ abTest: testable });
360
- })
361
- // The A/B tests waiting for their videos to be public, each with its upload item (titles
362
- // and thumbnails). One whose project or item is gone is dropped.
363
- .get("/ext/ab-tests", (c) => {
430
+ const found = backfillVideos(deps, c.req.valid("json").videos, deps.clock.now().toISOString());
431
+ return c.json({ found });
432
+ })
433
+ // The extension popup: finished projects not marked uploaded, each with its video and
434
+ // shorts, and which of them are on YouTube already (`youtube_videos`).
435
+ .get("/ext/ready", (c) => {
436
+ allowOrigin(c, false);
437
+ if (!extAllowed(c))
438
+ return refused(c);
439
+ const uploads = uploadedProjects(deps.db);
440
+ const aside = setAsideProjects(deps.db);
441
+ const projects = listProjects(deps.db).flatMap((project) => {
442
+ if (uploads.has(project.id))
443
+ return [];
444
+ const state = derive(stagesOf(deps.db, project.id), project.paused === true);
445
+ const finished = state === "done" ||
446
+ state === "partial" ||
447
+ (state === "pending" && aside.has(project.id));
448
+ if (!finished)
449
+ return [];
450
+ planned(project.id);
451
+ const result = uploadPack(deps, project.id);
452
+ if (!result.ok || result.pack.items[0]?.video == null)
453
+ return [];
454
+ return [
455
+ {
456
+ projectId: project.id,
457
+ title: project.title,
458
+ items: result.pack.items.map((item) => ({
459
+ kind: item.kind,
460
+ short: item.short ?? null,
461
+ title: item.title,
462
+ ready: item.video !== null,
463
+ uploaded: videoOf(deps.db, project.id, item.short ?? null)?.uploadState === "done",
464
+ started: videoOf(deps.db, project.id, item.short ?? null)?.uploadState === "filled",
465
+ ...(videoOf(deps.db, project.id, item.short ?? null) === undefined
466
+ ? {}
467
+ : { videoId: videoOf(deps.db, project.id, item.short ?? null)?.videoId }),
468
+ ...(item.scheduleAt === undefined ? {} : { scheduleAt: item.scheduleAt }),
469
+ })),
470
+ },
471
+ ];
472
+ });
473
+ return c.json({ projects });
474
+ })
475
+ // The popup's click: that upload goes first in line, and the extension opens Studio's
476
+ // upload page, where the dialog takes it.
477
+ .post("/ext/upload", zValidator("json", queueItemBody, onInvalid), (c) => {
478
+ allowOrigin(c, false);
479
+ if (!extAllowed(c))
480
+ return refused(c);
481
+ const { projectId, short } = c.req.valid("json");
482
+ const result = uploadPack(deps, projectId);
483
+ if (!result.ok || packItem(result.pack, short ?? undefined) === undefined)
484
+ return problem(c, {
485
+ status: 404,
486
+ title: titleOf(404),
487
+ detail: "That upload isn't in the project any more. Open the popup again.",
488
+ });
489
+ planned(projectId);
490
+ fillNow(deps.db, projectId, short ?? null, deps.clock.now());
491
+ return c.json({ url: studioUploadUrl });
492
+ })
493
+ // The popup's Upload all Shorts: each short not on YouTube yet waits, in order, and the
494
+ // upload dialogs take them one after another.
495
+ .post("/ext/upload-all", zValidator("json", z.object({ projectId: id }), onInvalid), (c) => {
496
+ allowOrigin(c, false);
497
+ if (!extAllowed(c))
498
+ return refused(c);
499
+ const { projectId } = c.req.valid("json");
500
+ planned(projectId);
501
+ const result = uploadPack(deps, projectId);
502
+ if (!result.ok)
503
+ return unknownProject(c);
504
+ const shorts = result.pack.items.filter((item) => item.kind === "short" &&
505
+ item.video !== null &&
506
+ videoOf(deps.db, projectId, item.short ?? null)?.uploadState !== "done");
507
+ // fillNow puts each first, so the last short goes in first and short 1 ends up first.
508
+ for (const item of shorts.toReversed())
509
+ fillNow(deps.db, projectId, item.short ?? null, deps.clock.now());
510
+ return c.json({ url: studioUploadUrl, count: shorts.length });
511
+ })
512
+ // One project's upload pack, for a Studio page opened for one of its uploads.
513
+ .get("/ext/packs/:projectId", zValidator("param", projectParam, onInvalid), (c) => {
364
514
  allowOrigin(c, false);
365
515
  if (!extAllowed(c))
366
516
  return refused(c);
367
- const tests = waitingAbTests(deps.db).flatMap((video) => {
517
+ const result = uploadPack(deps, c.req.valid("param").projectId);
518
+ return result.ok ? c.json(result.pack) : unknownProject(c);
519
+ })
520
+ // What waits for the extension on YouTube's side: the Details touches after an upload,
521
+ // and the comments to pin once public.
522
+ .get("/ext/tasks", (c) => {
523
+ allowOrigin(c, false);
524
+ if (!extAllowed(c))
525
+ return refused(c);
526
+ const withItems = (videos) => videos.flatMap((video) => {
368
527
  const result = uploadPack(deps, video.projectId);
369
528
  const item = result.ok ? packItem(result.pack, video.short ?? undefined) : undefined;
370
529
  return item === undefined
371
530
  ? []
372
531
  : [{ projectId: video.projectId, short: video.short, videoId: video.videoId, item }];
373
532
  });
374
- return c.json({ tests });
533
+ return c.json({
534
+ finish: withItems(waitingTasks(deps.db, "finish")),
535
+ comments: withItems(waitingTasks(deps.db, "comment")),
536
+ });
375
537
  })
376
- .post("/ext/ab-tests/result", zValidator("json", abResultBody, onInvalid), (c) => {
538
+ .post("/ext/task-result", zValidator("json", taskResultBody, onInvalid), (c) => {
377
539
  allowOrigin(c, false);
378
540
  if (!extAllowed(c))
379
541
  return refused(c);
380
- const { projectId, short, ok, message } = c.req.valid("json");
381
- setAbState(deps.db, projectId, short ?? null, ok ? "started" : "failed", message, deps.clock.now().toISOString());
542
+ const { task, projectId, short, ok, message } = c.req.valid("json");
543
+ setTaskState(deps.db, task, projectId, short ?? null, ok ? "done" : "failed", message);
382
544
  return c.json({ ok: true });
545
+ })
546
+ // Every video on YouTube, for the numbers the extension reads from Studio.
547
+ .get("/ext/known-videos", (c) => {
548
+ allowOrigin(c, false);
549
+ if (!extAllowed(c))
550
+ return refused(c);
551
+ return c.json({
552
+ videos: doneVideos(deps.db).map((video) => ({
553
+ projectId: video.projectId,
554
+ short: video.short,
555
+ videoId: video.videoId,
556
+ })),
557
+ });
558
+ })
559
+ .post("/ext/stats", zValidator("json", statsBody, onInvalid), (c) => {
560
+ allowOrigin(c, false);
561
+ if (!extAllowed(c))
562
+ return refused(c);
563
+ const body = c.req.valid("json");
564
+ const readAt = deps.clock.now().toISOString();
565
+ saveStats(deps.db, {
566
+ projectId: body.projectId,
567
+ short: body.short ?? null,
568
+ videoId: body.videoId,
569
+ readAt,
570
+ impressions: body.impressions ?? null,
571
+ ctr: body.ctr ?? null,
572
+ views: body.views ?? null,
573
+ averageViewSeconds: body.averageViewSeconds ?? null,
574
+ watchHours: body.watchHours ?? null,
575
+ });
576
+ if (body.abVariants !== undefined && body.abVariants.length > 1)
577
+ saveAbResult(deps.db, {
578
+ projectId: body.projectId,
579
+ short: body.short ?? null,
580
+ videoId: body.videoId,
581
+ readAt,
582
+ variants: body.abVariants,
583
+ });
584
+ return c.json({ ok: true });
585
+ })
586
+ // The extension read the new video's id from Studio's upload dialog. It is "filled" until
587
+ // Studio says it was scheduled or published (`/ext/video/done`): a cancelled upload isn't
588
+ // on YouTube.
589
+ .post("/ext/video", zValidator("json", extVideoBody, onInvalid), (c) => {
590
+ allowOrigin(c, false);
591
+ if (!extAllowed(c))
592
+ return refused(c);
593
+ const { projectId, short, videoId } = c.req.valid("json");
594
+ recordVideo(deps.db, projectId, short ?? null, videoId, deps.clock.now().toISOString(), "filled");
595
+ return c.json({ recorded: true });
596
+ })
597
+ // Studio showed "Video scheduled" or "Video published". An upload with an A/B test to run
598
+ // (other titles, or several thumbnails) now waits for its video to be public: scheduled
599
+ // videos are private until then, and Studio tests only public ones.
600
+ .post("/ext/video/done", zValidator("json", extVideoBody, onInvalid), (c) => {
601
+ allowOrigin(c, false);
602
+ if (!extAllowed(c))
603
+ return refused(c);
604
+ const { projectId, short, videoId } = c.req.valid("json");
605
+ if (!confirmUpload(deps.db, projectId, short ?? null, videoId))
606
+ recordVideo(deps.db, projectId, short ?? null, videoId, deps.clock.now().toISOString());
607
+ // The Details touches (a short's related video, the long video's end screen and
608
+ // captions) wait for the extension now, and the comment for the video to be public.
609
+ const confirmedPack = uploadPack(deps, projectId);
610
+ const confirmedItem = confirmedPack.ok
611
+ ? packItem(confirmedPack.pack, short ?? undefined)
612
+ : undefined;
613
+ if (confirmedItem !== undefined &&
614
+ (confirmedItem.relatedVideoId !== undefined ||
615
+ confirmedItem.endScreenVideoId !== undefined ||
616
+ confirmedItem.captions !== undefined))
617
+ setTaskState(deps.db, "finish", projectId, short ?? null, "waiting", null);
618
+ if (confirmedItem?.kind === "video" &&
619
+ confirmedItem.pinnedComment !== undefined &&
620
+ readSetting(deps.db, autoCommentKey) === "on")
621
+ setTaskState(deps.db, "comment", projectId, short ?? null, "waiting", null);
622
+ return c.json({ confirmed: true });
383
623
  })
384
624
  .get("/ext/files/:projectId/:asset", zValidator("param", fileParam, onInvalid), (c) => {
385
625
  allowOrigin(c, false);
386
626
  if (!studioRequestAllowed(deps.db, bearerToken(c.req.header("authorization")), c.req.header("origin")))
387
627
  return refused(c);
388
628
  const { projectId, asset } = c.req.valid("param");
389
- // Only a pack's own files, its videos and thumbnails: the extension puts the video into
390
- // Studio's upload dialog and the thumbnails into Details, and needs nothing else.
629
+ // Only a pack's own files, its videos, thumbnails and captions: the extension puts the
630
+ // video into Studio's upload dialog, the thumbnails into Details and the captions file
631
+ // into Subtitles, and needs nothing else.
391
632
  const result = uploadPack(deps, projectId);
392
633
  const listed = result.ok &&
393
634
  result.pack.items.some((item) => item.video?.asset === asset ||
635
+ item.captions?.asset === asset ||
394
636
  item.thumbnails.some((thumbnail) => thumbnail.asset === asset));
395
637
  const found = listed ? findDownload(deps, projectId, asset) : undefined;
396
638
  if (found === undefined || !found.ok)
@@ -0,0 +1,6 @@
1
+ -- Whether an upload reached YouTube: the extension records the video when it fills the Details
2
+ -- ('filled'), and confirms it once Studio says it was scheduled or published ('done'). One
3
+ -- cancelled, or left as a draft, stays 'filled' and is offered for upload again. A pasted link
4
+ -- is 'done', as is every video recorded before this column.
5
+ ALTER TABLE youtube_videos ADD COLUMN upload_state TEXT NOT NULL DEFAULT 'done'
6
+ CHECK (upload_state IN ('filled', 'done'));
@@ -0,0 +1,39 @@
1
+ -- Studio, hands off (3.3.0).
2
+ -- upload_slots: the posting plan's slot each project's upload takes (its long video's time; the
3
+ -- shorts follow from the plan).
4
+ CREATE TABLE upload_slots (
5
+ project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
6
+ row_name TEXT NOT NULL,
7
+ long_at TEXT NOT NULL,
8
+ assigned_at TEXT NOT NULL
9
+ );
10
+ -- What the extension does on a confirmed upload's Details page (the shorts' related video, the
11
+ -- long video's end screen and captions), and the pinned comment once it is public.
12
+ ALTER TABLE youtube_videos ADD COLUMN finish_state TEXT NOT NULL DEFAULT 'none'
13
+ CHECK (finish_state IN ('none', 'waiting', 'done', 'failed'));
14
+ ALTER TABLE youtube_videos ADD COLUMN finish_message TEXT;
15
+ ALTER TABLE youtube_videos ADD COLUMN comment_state TEXT NOT NULL DEFAULT 'none'
16
+ CHECK (comment_state IN ('none', 'waiting', 'done', 'failed'));
17
+ ALTER TABLE youtube_videos ADD COLUMN comment_message TEXT;
18
+ -- Studio's numbers for each known video, as the extension last read them.
19
+ CREATE TABLE video_stats (
20
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
21
+ short INTEGER NOT NULL,
22
+ video_id TEXT NOT NULL,
23
+ read_at TEXT NOT NULL,
24
+ impressions INTEGER,
25
+ ctr REAL,
26
+ views INTEGER,
27
+ average_view_seconds INTEGER,
28
+ watch_hours REAL,
29
+ PRIMARY KEY (project_id, short)
30
+ );
31
+ -- A finished A/B test as Studio shows it: each variant's title, thumbnail number and share.
32
+ CREATE TABLE ab_results (
33
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
34
+ short INTEGER NOT NULL,
35
+ video_id TEXT NOT NULL,
36
+ read_at TEXT NOT NULL,
37
+ variants TEXT NOT NULL CHECK (json_valid(variants)),
38
+ PRIMARY KEY (project_id, short)
39
+ );
@@ -0,0 +1,15 @@
1
+ # Slopify 3.2.9
2
+
3
+ Released 2 October 2026.
4
+
5
+ ## Projects
6
+
7
+ - **Renaming a project no longer makes everything again.** The pictures, scenes, looks and shorts stay as they are: they keep what the project was about. What shows the title follows the new name: the YouTube description and other titles, the PDF and the audio files' tags.
8
+
9
+ ## Prepare upload
10
+
11
+ - **Pick the upload's title and thumbnail.** The A/B test now starts after the video is public, so the upload itself carries one title and one thumbnail: choose the title among the project's and its other titles, and press **Use for upload** on a thumbnail. Fill in YouTube Studio uses your pick, and the A/B test later tries the others beside it.
12
+
13
+ ## Upgrading from 3.2.8
14
+
15
+ - **Nothing becomes outdated.** A project renamed before this version keeps whatever its rename already queued.
@@ -0,0 +1,40 @@
1
+ # Slopify 3.3.0
2
+
3
+ Released 2 October 2026.
4
+
5
+ ## Posting plan
6
+
7
+ - **Plan the week once.** Settings → YouTube Studio → **Posting plan** is a table: one row per long video, with the long video's day and time and each short's day and time, in your time zone.
8
+ - **Each finished project takes the next free row** when you prepare its upload. Each short goes out the first time its day and hour come round after its own video, so a plan never needs "next week".
9
+ - **Prepare upload shows the slot** (a Schedule step) with a picker to choose another free one, and the extension types the date and time into Studio's Schedule. You press Schedule.
10
+
11
+ ## Project page
12
+
13
+ - **Shorts and YouTube are part of Video.** The rail has one Video section; the shorts, the YouTube description and On YouTube sit inside it.
14
+ - **On YouTube shows each video's numbers**: views, click-through rate and average view duration, read from Studio once a day. The Projects list shows views and CTR too.
15
+ - **A/B test only when you ask.** An upload gets the title and thumbnail you picked. Press **A/B test** on On YouTube (titles and thumbnails, titles, or thumbnails) and Studio opens with the test filled in; you check it and press Set test and Save.
16
+
17
+ ## Library
18
+
19
+ - **A/B results**: every finished A/B test the extension read, winner first. **Copy as prompt notes** gives a short summary to paste into the prompts that write titles and thumbnails.
20
+
21
+ ## Projects
22
+
23
+ - **Renaming a project runs nothing.** Every step keeps what the project was made about, so a new name never rewrites the outro, re-records it or renders the video again. Press **Write again** on the YouTube description if you want it written for the new name.
24
+
25
+ ## YouTube Studio extension (1.0.0)
26
+
27
+ - **In the Chrome Web Store**, so it updates by itself (Firefox still loads the zip).
28
+ - **Click the extension's icon** to see your finished projects not marked uploaded, each video and short with its planned time and a ✓ once on YouTube. Click one to upload it: Studio's upload page opens and the extension adds the file and fills in the details.
29
+ - **Upload all shorts in one go**: once Studio confirms one upload, the next upload page opens by itself.
30
+ - **An upload counts only once Studio confirms it** ("Video scheduled", "Video published" or "Video saved"). One you cancel, or close as a draft, stays offered ("Upload again").
31
+ - **Finishing touches after the upload**: the subtitles file goes in as captions, the long video's end screen points at the previous episode, and each short's related video is the long video once it is public. On YouTube shows each one waiting, done or failed, and why.
32
+ - **Pinned comment, if you want it** (off by default; Settings → YouTube Studio): once the video is public, the extension posts the project's pinned comment and pins it.
33
+ - **Links fill themselves in.** Open Studio's Content page and the extension matches its videos and shorts to your projects by title. Shorts then link the full video by themselves.
34
+ - **A/B Testing from the popup** on an uploaded long video: Titles and thumbnails, Titles, or Thumbnails.
35
+
36
+ ## Upgrading from 3.2.9
37
+
38
+ - **Install the extension from the Chrome Web Store** once it's listed (or download it again from Settings → YouTube Studio and reload it in `chrome://extensions`). It is version 1.0.0.
39
+ - **Check the posting plan** in Settings → YouTube Studio; it starts with three long videos a week and their shorts.
40
+ - **Nothing becomes outdated.** A video the extension recorded earlier counts as on YouTube.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.3.0",
4
+ "title": "Slopify 3.3.0",
5
+ "version": "3.3.0",
6
+ "date": "2026-10-02"
7
+ },
8
+ {
9
+ "id": "3.2.9",
10
+ "title": "Slopify 3.2.9",
11
+ "version": "3.2.9",
12
+ "date": "2026-10-02"
13
+ },
2
14
  {
3
15
  "id": "3.2.8",
4
16
  "title": "Slopify 3.2.8",
@@ -45,3 +45,7 @@ export const referenceKey = "reference:image";
45
45
  // mode existed was) or one vertical short of about a minute, made by the same stages with
46
46
  // the Shorts renderer's word-by-word captions (`admission/short-mode.ts`).
47
47
  export const runModes = ["video", "short"];
48
+ // What the project is about, for the steps that only use the title as context.
49
+ export function subjectOf(config) {
50
+ return config.subjectTitle ?? config.title;
51
+ }
@@ -190,4 +190,5 @@ export const runDraftSchema = z.object({
190
190
  export const runConfigSchema = runDraftSchema.extend({
191
191
  rendered: z.record(z.string(), z.string()),
192
192
  titlePattern: z.string().optional(),
193
+ subjectTitle: z.string().optional(),
193
194
  });