@gentbajko/slopify 3.2.6 → 3.2.8

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.
@@ -11,6 +11,7 @@ import { studioPlaylistMax } from "../../slices/studio/model.js";
11
11
  import { packItem, uploadPack } from "../../slices/studio/pack.js";
12
12
  import { enqueueFill, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
13
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";
14
15
  import { onInvalid, problem, titleOf } from "./problem.js";
15
16
  const id = z
16
17
  .string()
@@ -45,6 +46,20 @@ const projectPlaylistsBody = z.object({
45
46
  playlists: z.array(z.string().max(studioPlaylistMax)).max(studioPlaylistsMax).nullable(),
46
47
  });
47
48
  const realFootageBody = z.object({ realFootage: z.boolean() });
49
+ const shortField = z.number().int().min(1).max(99).nullable().optional();
50
+ const extVideoBody = z.object({
51
+ projectId: id,
52
+ short: shortField,
53
+ videoId: z.string().regex(videoIdPattern),
54
+ });
55
+ const abResultBody = z.object({
56
+ projectId: id,
57
+ short: shortField,
58
+ ok: z.boolean(),
59
+ message: z.string().max(2000),
60
+ });
61
+ const videoLinkBody = z.object({ short: shortField, link: z.string().max(500) });
62
+ const abTestBody = z.object({ short: shortField, start: z.boolean() });
48
63
  // The extension builds the app ships (`scripts/copy-extension.mjs`), by browser.
49
64
  const extensionFiles = {
50
65
  "chrome.zip": "slopify-studio-chrome.zip",
@@ -91,6 +106,13 @@ export function studioRoutes(deps) {
91
106
  title: "Unauthorized",
92
107
  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.",
93
108
  });
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) => {
112
+ 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);
115
+ };
94
116
  return (new Hono()
95
117
  .get("/settings", (c) => {
96
118
  // The token is read only by the Slopify page, never by another origin's.
@@ -182,6 +204,52 @@ export function studioRoutes(deps) {
182
204
  return c.json({ chosen: { projectId, short: short ?? null }, queue: queueView(queue) });
183
205
  })
184
206
  // What waits for the extension, oldest first: each new upload dialog takes the first.
207
+ // The YouTube videos a project's uploads became, and their A/B tests.
208
+ .get("/videos/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ videos: projectVideos(deps.db, c.req.valid("param").projectId) }))
209
+ // A video's link pasted in Slopify, for an upload made without the extension; an empty
210
+ // link forgets it.
211
+ .put("/videos/:projectId", zValidator("param", projectParam, onInvalid), zValidator("json", videoLinkBody, onInvalid), (c) => {
212
+ const wrong = samePage(c);
213
+ if (wrong !== undefined)
214
+ return wrong;
215
+ const { projectId } = c.req.valid("param");
216
+ const { short, link } = c.req.valid("json");
217
+ if (link.trim() === "") {
218
+ forgetVideo(deps.db, projectId, short ?? null);
219
+ return c.json({ videos: projectVideos(deps.db, projectId) });
220
+ }
221
+ const videoId = videoIdOf(link);
222
+ if (videoId === undefined)
223
+ return problem(c, {
224
+ status: 400,
225
+ title: titleOf(400),
226
+ detail: "That isn't a YouTube video link. Copy the video's link from YouTube Studio (Details → Video link, like https://youtu.be/…) and paste it again.",
227
+ });
228
+ recordVideo(deps.db, projectId, short ?? null, videoId, deps.clock.now().toISOString());
229
+ 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
+ })
185
253
  .get("/queue", (c) => {
186
254
  const denied = samePage(c);
187
255
  if (denied !== undefined)
@@ -275,22 +343,61 @@ export function studioRoutes(deps) {
275
343
  const { projectId, short } = c.req.valid("json");
276
344
  const left = removeFill(deps.db, projectId, short ?? null, deps.clock.now());
277
345
  return c.json({ waiting: left.length });
346
+ })
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) => {
351
+ allowOrigin(c, false);
352
+ if (!extAllowed(c))
353
+ 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) => {
364
+ allowOrigin(c, false);
365
+ if (!extAllowed(c))
366
+ return refused(c);
367
+ const tests = waitingAbTests(deps.db).flatMap((video) => {
368
+ const result = uploadPack(deps, video.projectId);
369
+ const item = result.ok ? packItem(result.pack, video.short ?? undefined) : undefined;
370
+ return item === undefined
371
+ ? []
372
+ : [{ projectId: video.projectId, short: video.short, videoId: video.videoId, item }];
373
+ });
374
+ return c.json({ tests });
375
+ })
376
+ .post("/ext/ab-tests/result", zValidator("json", abResultBody, onInvalid), (c) => {
377
+ allowOrigin(c, false);
378
+ if (!extAllowed(c))
379
+ 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());
382
+ return c.json({ ok: true });
278
383
  })
279
384
  .get("/ext/files/:projectId/:asset", zValidator("param", fileParam, onInvalid), (c) => {
280
385
  allowOrigin(c, false);
281
386
  if (!studioRequestAllowed(deps.db, bearerToken(c.req.header("authorization")), c.req.header("origin")))
282
387
  return refused(c);
283
388
  const { projectId, asset } = c.req.valid("param");
284
- // Only the thumbnails of a pack: the extension needs nothing else from the disk.
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.
285
391
  const result = uploadPack(deps, projectId);
286
392
  const listed = result.ok &&
287
- result.pack.items.some((item) => item.thumbnails.some((thumbnail) => thumbnail.asset === asset));
393
+ result.pack.items.some((item) => item.video?.asset === asset ||
394
+ item.thumbnails.some((thumbnail) => thumbnail.asset === asset));
288
395
  const found = listed ? findDownload(deps, projectId, asset) : undefined;
289
396
  if (found === undefined || !found.ok)
290
397
  return problem(c, {
291
398
  status: 404,
292
399
  title: titleOf(404),
293
- detail: "That thumbnail isn't in the project's upload pack any more. Reload the project in Slopify and press Fill in YouTube Studio again.",
400
+ detail: "That file isn't in the project's upload pack any more. Reload the project in Slopify and press Fill in YouTube Studio again.",
294
401
  });
295
402
  return c.body(Readable.toWeb(createReadStream(found.download.path)), 200, {
296
403
  "content-type": found.download.contentType,
@@ -0,0 +1,15 @@
1
+ -- The YouTube video each upload became (the long video, short 0, or short N), as the Slopify
2
+ -- Studio extension read it from Studio's upload dialog or the person pasted its link. With it,
3
+ -- the video's A/B test (its other titles and thumbnails) can wait until the video is public:
4
+ -- a scheduled video is private until then, and Studio tests only public ones. The extension
5
+ -- checks the waiting tests, starts each once its video is public, and reports back.
6
+ CREATE TABLE youtube_videos (
7
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
8
+ short INTEGER NOT NULL CHECK (short >= 0),
9
+ video_id TEXT NOT NULL,
10
+ recorded_at TEXT NOT NULL,
11
+ ab_state TEXT NOT NULL DEFAULT 'none' CHECK (ab_state IN ('none', 'waiting', 'started', 'failed')),
12
+ ab_message TEXT,
13
+ ab_at TEXT,
14
+ PRIMARY KEY (project_id, short)
15
+ );
@@ -0,0 +1,15 @@
1
+ # Slopify 3.2.7
2
+
3
+ Released 1 October 2026.
4
+
5
+ 3.2.7 lets the Slopify Studio extension put the video into YouTube Studio's upload dialog itself.
6
+
7
+ ## YouTube Studio
8
+
9
+ - After **Fill in YouTube Studio**, the extension adds the video file to Studio's upload dialog as soon as it opens on Select files, then fills the details as before. Studio uploads it as a private draft, exactly as a file you drop in; nothing is published, and you still check everything and press Publish yourself.
10
+ - If the video can't be added (Slopify isn't running, or Studio's dialog changed), the extension says so: drop the file in yourself from Open folder in Prepare upload.
11
+
12
+ ## Upgrading from 3.2.6
13
+
14
+ - **Download the extension again** (Settings → YouTube Studio → Download) and replace the old one; it is version 0.2.0. Pairing carries over when you update it in place.
15
+ - **Nothing becomes outdated.**
@@ -0,0 +1,20 @@
1
+ # Slopify 3.2.8
2
+
3
+ Released 2 October 2026.
4
+
5
+ 3.2.8 starts your A/B tests once a scheduled video goes public, tests the other titles too, and lets the pinned comment and other titles be written again.
6
+
7
+ ## A/B tests after publishing
8
+
9
+ - YouTube only A/B tests public videos, and a scheduled video is private until its time. The Slopify Studio extension now keeps each upload's YouTube link and starts its A/B test by itself once the video is public: it opens the video in a background tab, fills A/B Testing and presses **Set test**. Chrome has to be open around that time; otherwise the test starts the next time Chrome opens.
10
+ - A/B Testing now tests the **other titles** too: **Title and thumbnail** with two or three thumbnails, **Title only** with one.
11
+ - The project's YouTube section has **On YouTube**: each upload's link (paste one for an upload you made by hand), its A/B test's state, and **Start A/B test** to test any video after the fact.
12
+
13
+ ## YouTube section
14
+
15
+ - **Write again** is on the pinned comment and the other titles too, not only the description. It still writes every part anew in one AI call; a part you edited keeps your text and offers the new one beside it.
16
+
17
+ ## Upgrading from 3.2.7
18
+
19
+ - **Download the extension again** (Settings → YouTube Studio → Download), unzip it over the old one and press reload in `chrome://extensions`. It is version 0.3.0, and Chrome asks once to allow its new permissions (a 15-minute timer, and YouTube's public video check).
20
+ - **Nothing becomes outdated.**
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.2.8",
4
+ "title": "Slopify 3.2.8",
5
+ "version": "3.2.8",
6
+ "date": "2026-10-02"
7
+ },
8
+ {
9
+ "id": "3.2.7",
10
+ "title": "Slopify 3.2.7",
11
+ "version": "3.2.7",
12
+ "date": "2026-10-01"
13
+ },
2
14
  {
3
15
  "id": "3.2.6",
4
16
  "title": "Slopify 3.2.6",
@@ -55,6 +55,8 @@ export const projectTables = [
55
55
  "project_recovery_requests",
56
56
  "youtube_description_edits",
57
57
  "project_uploads",
58
+ // Since 3.2.8: the YouTube video each upload became, and its A/B test.
59
+ "youtube_videos",
58
60
  // Since 2.5.0. Left out on purpose: plan_limit_waits and plan_limit_waiters (an account's
59
61
  // current wait, a lease of this install) and prompt_softening (a pending one-off request).
60
62
  // Since 3.0.8, narration_retries is left out too: the tries of a run, not of the project.
@@ -0,0 +1,60 @@
1
+ // A YouTube video id: 11 letters, digits, `-` and `_`.
2
+ export const videoIdPattern = /^[A-Za-z0-9_-]{11}$/;
3
+ // The id inside a link as Studio and YouTube show it (youtu.be/ID, watch?v=ID, /shorts/ID,
4
+ // studio's /video/ID/edit), or the bare id.
5
+ export function videoIdOf(text) {
6
+ const trimmed = text.trim();
7
+ if (videoIdPattern.test(trimmed))
8
+ return trimmed;
9
+ const match = /(?:youtu\.be\/|[?&]v=|\/shorts\/|\/video\/|\/live\/|\/embed\/)([A-Za-z0-9_-]{11})(?![A-Za-z0-9_-])/.exec(trimmed);
10
+ return match?.[1];
11
+ }
12
+ const slot = (short) => short ?? 0;
13
+ function rowOf(row) {
14
+ const short = Number(row.short);
15
+ return {
16
+ projectId: String(row.project_id),
17
+ short: short === 0 ? null : short,
18
+ videoId: String(row.video_id),
19
+ recordedAt: String(row.recorded_at),
20
+ abState: String(row.ab_state),
21
+ abMessage: typeof row.ab_message === "string" ? row.ab_message : null,
22
+ abAt: typeof row.ab_at === "string" ? row.ab_at : null,
23
+ };
24
+ }
25
+ // Records (or replaces) the video an upload became. A new video id starts its A/B test over.
26
+ export function recordVideo(db, projectId, short, videoId, at) {
27
+ db.prepare(`INSERT INTO youtube_videos(project_id,short,video_id,recorded_at) VALUES (?,?,?,?)
28
+ ON CONFLICT(project_id,short) DO UPDATE SET
29
+ ab_state=CASE WHEN video_id=excluded.video_id THEN ab_state ELSE 'none' END,
30
+ ab_message=CASE WHEN video_id=excluded.video_id THEN ab_message ELSE NULL END,
31
+ ab_at=CASE WHEN video_id=excluded.video_id THEN ab_at ELSE NULL END,
32
+ video_id=excluded.video_id, recorded_at=excluded.recorded_at`).run(projectId, slot(short), videoId, at);
33
+ }
34
+ export function forgetVideo(db, projectId, short) {
35
+ db.prepare("DELETE FROM youtube_videos WHERE project_id=? AND short=?").run(projectId, slot(short));
36
+ }
37
+ export function projectVideos(db, projectId) {
38
+ return db
39
+ .prepare("SELECT * FROM youtube_videos WHERE project_id=? ORDER BY short")
40
+ .all(projectId)
41
+ .map(rowOf);
42
+ }
43
+ export function videoOf(db, projectId, short) {
44
+ const row = db
45
+ .prepare("SELECT * FROM youtube_videos WHERE project_id=? AND short=?")
46
+ .get(projectId, slot(short));
47
+ return row === undefined ? undefined : rowOf(row);
48
+ }
49
+ // Sets an A/B test's state; false when the project has no video recorded for that upload.
50
+ export function setAbState(db, projectId, short, state, message, at) {
51
+ return (db
52
+ .prepare("UPDATE youtube_videos SET ab_state=?, ab_message=?, ab_at=? WHERE project_id=? AND short=?")
53
+ .run(state, message, at, projectId, slot(short)).changes > 0);
54
+ }
55
+ export function waitingAbTests(db) {
56
+ return db
57
+ .prepare("SELECT * FROM youtube_videos WHERE ab_state='waiting' ORDER BY recorded_at")
58
+ .all()
59
+ .map(rowOf);
60
+ }