@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.
- package/dist/edge/http/projects.js +8 -0
- package/dist/edge/http/studio.js +282 -58
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0046-youtube-upload-state.sql +6 -0
- package/dist/kernel/db/migrations/0047-studio-autopilot.sql +39 -0
- package/dist/patch-notes/3.3.0.md +40 -0
- package/dist/patch-notes/3.3.1.md +7 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/article/segments.js +2 -1
- package/dist/slices/rebuild/recipe-document.js +2 -2
- package/dist/slices/rebuild/recipe-exports.js +2 -1
- package/dist/slices/rebuild/recipe-visual.js +2 -2
- package/dist/slices/rebuild/recipe-youtube.js +2 -1
- package/dist/slices/revisions/subject.js +2 -3
- package/dist/slices/storage/backup-format.js +4 -0
- package/dist/slices/storage/portable.js +12 -1
- package/dist/slices/studio/backfill.js +34 -0
- package/dist/slices/studio/model.js +2 -0
- package/dist/slices/studio/pack.js +41 -2
- package/dist/slices/studio/pick.js +5 -4
- package/dist/slices/studio/plan.js +190 -0
- package/dist/slices/studio/queue.js +7 -0
- package/dist/slices/studio/settings.js +2 -0
- package/dist/slices/studio/stats.js +71 -0
- package/dist/slices/studio/videos.js +45 -11
- package/dist/slices/youtube/titles.js +2 -2
- package/dist/web/assets/index-6wMtRXOo.css +1 -0
- package/dist/web/assets/{index-hUEQRe6s.js → index-DmLX0oKs.js} +131 -130
- package/dist/web/assets/{pdf-C1Iq6van.js → pdf-BI8cHr9G.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- 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
|
});
|
package/dist/edge/http/studio.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
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
|
|
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
|
|
67
|
-
|
|
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
|
-
|
|
115
|
-
//
|
|
116
|
-
const
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
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
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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({
|
|
533
|
+
return c.json({
|
|
534
|
+
finish: withItems(waitingTasks(deps.db, "finish")),
|
|
535
|
+
comments: withItems(waitingTasks(deps.db, "comment")),
|
|
536
|
+
});
|
|
393
537
|
})
|
|
394
|
-
.post("/ext/
|
|
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
|
-
|
|
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
|
|
408
|
-
// Studio's upload dialog
|
|
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)
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|