@gentbajko/slopify 3.2.9 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/edge/http/projects.js +8 -0
  2. package/dist/edge/http/studio.js +282 -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.3.0.md +40 -0
  8. package/dist/patch-notes/3.3.1.md +7 -0
  9. package/dist/patch-notes/index.json +12 -0
  10. package/dist/slices/article/segments.js +2 -1
  11. package/dist/slices/rebuild/recipe-document.js +2 -2
  12. package/dist/slices/rebuild/recipe-exports.js +2 -1
  13. package/dist/slices/rebuild/recipe-visual.js +2 -2
  14. package/dist/slices/rebuild/recipe-youtube.js +2 -1
  15. package/dist/slices/revisions/subject.js +2 -3
  16. package/dist/slices/storage/backup-format.js +4 -0
  17. package/dist/slices/storage/portable.js +12 -1
  18. package/dist/slices/studio/backfill.js +34 -0
  19. package/dist/slices/studio/model.js +2 -0
  20. package/dist/slices/studio/pack.js +41 -2
  21. package/dist/slices/studio/pick.js +5 -4
  22. package/dist/slices/studio/plan.js +190 -0
  23. package/dist/slices/studio/queue.js +7 -0
  24. package/dist/slices/studio/settings.js +2 -0
  25. package/dist/slices/studio/stats.js +71 -0
  26. package/dist/slices/studio/videos.js +45 -11
  27. package/dist/slices/youtube/titles.js +2 -2
  28. package/dist/web/assets/index-6wMtRXOo.css +1 -0
  29. package/dist/web/assets/{index-hUEQRe6s.js → index-DmLX0oKs.js} +131 -130
  30. package/dist/web/assets/{pdf-C1Iq6van.js → pdf-BI8cHr9G.js} +1 -1
  31. package/dist/web/index.html +2 -2
  32. package/package.json +1 -1
  33. 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,15 +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
15
  import { writeUploadPick } from "../../slices/studio/pick.js";
13
- import { enqueueFill, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
14
- import { bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
15
- import { forgetVideo, projectVideos, recordVideo, setAbState, videoIdOf, videoIdPattern, waitingAbTests, } from "../../slices/studio/videos.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";
16
22
  import { onInvalid, problem, titleOf } from "./problem.js";
17
23
  const id = z
18
24
  .string()
@@ -57,14 +63,33 @@ const extVideoBody = z.object({
57
63
  short: shortField,
58
64
  videoId: z.string().regex(videoIdPattern),
59
65
  });
60
- 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"]),
61
72
  projectId: id,
62
73
  short: shortField,
63
74
  ok: z.boolean(),
64
75
  message: z.string().max(2000),
65
76
  });
66
- const videoLinkBody = z.object({ short: shortField, link: z.string().max(500) });
67
- 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
+ });
68
93
  // The extension builds the app ships (`scripts/copy-extension.mjs`), by browser.
69
94
  const extensionFiles = {
70
95
  "chrome.zip": "slopify-studio-chrome.zip",
@@ -111,13 +136,18 @@ export function studioRoutes(deps) {
111
136
  title: "Unauthorized",
112
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.",
113
138
  });
114
- const extAllowed = (c) => studioRequestAllowed(deps.db, bearerToken(c.req.header("authorization")), c.req.header("origin"));
115
- // An upload has an A/B test to run when it has other titles or more than one thumbnail.
116
- 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;
117
144
  const result = uploadPack(deps, projectId);
118
- const item = result.ok ? packItem(result.pack, short ?? undefined) : undefined;
119
- 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());
120
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"));
121
151
  return (new Hono()
122
152
  .get("/settings", (c) => {
123
153
  // The token is read only by the Slopify page, never by another origin's.
@@ -128,7 +158,29 @@ export function studioRoutes(deps) {
128
158
  playlists: readStudioPlaylists(deps.db),
129
159
  channelPlaylists: readChannelPlaylists(deps.db),
130
160
  pairing: studioPairing(deps.db),
161
+ autoComment: readSetting(deps.db, autoCommentKey) === "on",
131
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) });
132
184
  })
133
185
  .put("/settings/playlists", zValidator("json", playlistsBody, onInvalid), (c) => {
134
186
  const denied = samePage(c);
@@ -161,10 +213,36 @@ export function studioRoutes(deps) {
161
213
  return c.json({ pairing: resetStudioPairing(deps.db) });
162
214
  })
163
215
  .get("/packs/:projectId", zValidator("param", projectParam, onInvalid), (c) => {
164
- 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);
165
219
  if (!result.ok)
166
220
  return unknownProject(c);
167
- 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);
168
246
  })
169
247
  // Prepare upload's AI use tick: the project's uploaded clips are real footage.
170
248
  .put("/packs/:projectId/real-footage", zValidator("param", projectParam, onInvalid), zValidator("json", realFootageBody, onInvalid), (c) => {
@@ -222,6 +300,9 @@ export function studioRoutes(deps) {
222
300
  return c.json({ chosen: { projectId, short: short ?? null }, queue: queueView(queue) });
223
301
  })
224
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) }))
225
306
  // The YouTube videos a project's uploads became, and their A/B tests.
226
307
  .get("/videos/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ videos: projectVideos(deps.db, c.req.valid("param").projectId) }))
227
308
  // A video's link pasted in Slopify, for an upload made without the extension; an empty
@@ -245,28 +326,6 @@ export function studioRoutes(deps) {
245
326
  });
246
327
  recordVideo(deps.db, projectId, short ?? null, videoId, deps.clock.now().toISOString());
247
328
  return c.json({ videos: projectVideos(deps.db, projectId) });
248
- })
249
- // Start A/B test (or Cancel): the extension starts it once the video is public.
250
- .post("/videos/:projectId/ab-test", zValidator("param", projectParam, onInvalid), zValidator("json", abTestBody, onInvalid), (c) => {
251
- const wrong = samePage(c);
252
- if (wrong !== undefined)
253
- return wrong;
254
- const { projectId } = c.req.valid("param");
255
- const { short, start } = c.req.valid("json");
256
- if (start && !abTestable(projectId, short ?? null))
257
- return problem(c, {
258
- status: 409,
259
- title: titleOf(409),
260
- 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.",
261
- });
262
- const set = setAbState(deps.db, projectId, short ?? null, start ? "waiting" : "none", null, deps.clock.now().toISOString());
263
- if (!set)
264
- return problem(c, {
265
- status: 409,
266
- title: titleOf(409),
267
- detail: "Slopify doesn't know this upload's YouTube video yet. Paste its link (from YouTube Studio → Details → Video link) first.",
268
- });
269
- return c.json({ videos: projectVideos(deps.db, projectId) });
270
329
  })
271
330
  .get("/queue", (c) => {
272
331
  const denied = samePage(c);
@@ -362,53 +421,218 @@ export function studioRoutes(deps) {
362
421
  const left = removeFill(deps.db, projectId, short ?? null, deps.clock.now());
363
422
  return c.json({ waiting: left.length });
364
423
  })
365
- // The extension read the new video's id from Studio's upload dialog. An upload with an
366
- // A/B test to run (other titles, or several thumbnails) waits for its video to be public:
367
- // scheduled videos are private until then, and Studio tests only public ones.
368
- .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) => {
369
427
  allowOrigin(c, false);
370
428
  if (!extAllowed(c))
371
429
  return refused(c);
372
- const { projectId, short, videoId } = c.req.valid("json");
373
- recordVideo(deps.db, projectId, short ?? null, videoId, deps.clock.now().toISOString());
374
- const testable = abTestable(projectId, short ?? null);
375
- if (testable)
376
- setAbState(deps.db, projectId, short ?? null, "waiting", null, deps.clock.now().toISOString());
377
- return c.json({ abTest: testable });
378
- })
379
- // The A/B tests waiting for their videos to be public, each with its upload item (titles
380
- // and thumbnails). One whose project or item is gone is dropped.
381
- .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) => {
382
478
  allowOrigin(c, false);
383
479
  if (!extAllowed(c))
384
480
  return refused(c);
385
- const tests = waitingAbTests(deps.db).flatMap((video) => {
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) => {
514
+ allowOrigin(c, false);
515
+ if (!extAllowed(c))
516
+ return refused(c);
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) => {
386
527
  const result = uploadPack(deps, video.projectId);
387
528
  const item = result.ok ? packItem(result.pack, video.short ?? undefined) : undefined;
388
529
  return item === undefined
389
530
  ? []
390
531
  : [{ projectId: video.projectId, short: video.short, videoId: video.videoId, item }];
391
532
  });
392
- return c.json({ tests });
533
+ return c.json({
534
+ finish: withItems(waitingTasks(deps.db, "finish")),
535
+ comments: withItems(waitingTasks(deps.db, "comment")),
536
+ });
393
537
  })
394
- .post("/ext/ab-tests/result", zValidator("json", abResultBody, onInvalid), (c) => {
538
+ .post("/ext/task-result", zValidator("json", taskResultBody, onInvalid), (c) => {
395
539
  allowOrigin(c, false);
396
540
  if (!extAllowed(c))
397
541
  return refused(c);
398
- const { projectId, short, ok, message } = c.req.valid("json");
399
- 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);
400
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 });
401
623
  })
402
624
  .get("/ext/files/:projectId/:asset", zValidator("param", fileParam, onInvalid), (c) => {
403
625
  allowOrigin(c, false);
404
626
  if (!studioRequestAllowed(deps.db, bearerToken(c.req.header("authorization")), c.req.header("origin")))
405
627
  return refused(c);
406
628
  const { projectId, asset } = c.req.valid("param");
407
- // Only a pack's own files, its videos and thumbnails: the extension puts the video into
408
- // 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.
409
632
  const result = uploadPack(deps, projectId);
410
633
  const listed = result.ok &&
411
634
  result.pack.items.some((item) => item.video?.asset === asset ||
635
+ item.captions?.asset === asset ||
412
636
  item.thumbnails.some((thumbnail) => thumbnail.asset === asset));
413
637
  const found = listed ? findDownload(deps, projectId, asset) : undefined;
414
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,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.
@@ -0,0 +1,7 @@
1
+ # Slopify 3.3.1
2
+
3
+ Released 2 October 2026.
4
+
5
+ ## Notifications
6
+
7
+ - **Run sounds don't go quiet any more.** Each chime now plays on a fresh sound player, so the start and end sounds (and Settings → Notifications → Play start / Play end) keep working after your sound output changes or the computer wakes from sleep.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.3.1",
4
+ "title": "Slopify 3.3.1",
5
+ "version": "3.3.1",
6
+ "date": "2026-10-02"
7
+ },
8
+ {
9
+ "id": "3.3.0",
10
+ "title": "Slopify 3.3.0",
11
+ "version": "3.3.0",
12
+ "date": "2026-10-02"
13
+ },
2
14
  {
3
15
  "id": "3.2.9",
4
16
  "title": "Slopify 3.2.9",
@@ -1,4 +1,5 @@
1
1
  import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
2
+ import { subjectOf } from "../admission/model.js";
2
3
  import { noTokens, plusUsage } from "../telemetry/model.js";
3
4
  export const categories = ["intro", "outro"];
4
5
  export async function writeSegment(providers, choice, config, category, article, sent) {
@@ -43,7 +44,7 @@ export function segmentMessages(body, config, article) {
43
44
  content: [
44
45
  body,
45
46
  "",
46
- `Video title: ${config.title}`,
47
+ `Video title: ${subjectOf(config)}`,
47
48
  "",
48
49
  "Keyword values for this run:",
49
50
  "",
@@ -1,4 +1,4 @@
1
- import { sourceOf } from "../admission/model.js";
1
+ import { sourceOf, subjectOf } from "../admission/model.js";
2
2
  import { resolvedDocumentTheme } from "../document/theme.js";
3
3
  import { recipe, resourceIdentity, } from "./recipe-model.js";
4
4
  // Bumped when the renderer's output changes for the same inputs, so documents made by an
@@ -21,7 +21,7 @@ export function documentRecipes(context, text, thumbnail) {
21
21
  operation: "render-document",
22
22
  values: [
23
23
  renderer,
24
- config.title,
24
+ subjectOf(config),
25
25
  JSON.parse(JSON.stringify(theme)),
26
26
  resourceIdentity(context, text.article),
27
27
  notes === undefined ? null : resourceIdentity(context, notes),
@@ -1,4 +1,5 @@
1
1
  import { projectLanguage } from "../../kernel/ports/languages.js";
2
+ import { subjectOf } from "../admission/model.js";
2
3
  import { usesShorts, usesYoutubeDescription } from "../admission/rules.js";
3
4
  import { usesShortMode } from "../admission/short-mode.js";
4
5
  import { reviewsNarration } from "../reviews/rules.js";
@@ -78,7 +79,7 @@ export function exportRecipes(context, audio) {
78
79
  audio.mediaFingerprint,
79
80
  resourceIdentity(context, timing),
80
81
  (audio.sections ?? []).map((section) => [section.title, section.firstTurn]),
81
- config.title,
82
+ subjectOf(config),
82
83
  ...master.values,
83
84
  ...lines.values,
84
85
  // The book's title and chapter become the files' album and track tags; a project