@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.
- package/dist/edge/http/projects.js +8 -0
- package/dist/edge/http/studio.js +300 -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.2.9.md +15 -0
- package/dist/patch-notes/3.3.0.md +40 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/model.js +4 -0
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/article/segments.js +2 -1
- package/dist/slices/rebuild/recipe-appearance.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-reference.js +2 -2
- package/dist/slices/rebuild/recipe-scenes.js +3 -3
- package/dist/slices/rebuild/recipe-shorts.js +3 -2
- package/dist/slices/rebuild/recipe-text.js +2 -1
- package/dist/slices/rebuild/recipe-visual.js +4 -4
- package/dist/slices/rebuild/recipe-youtube.js +2 -1
- package/dist/slices/revisions/mutations.js +2 -1
- package/dist/slices/revisions/subject.js +9 -0
- 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 +64 -8
- package/dist/slices/studio/pick.js +33 -0
- 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-BKn7EPwB.js +310 -0
- package/dist/web/assets/{pdf-CqdWRPHL.js → pdf-BPADxYaD.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BQrub4xw.js +0 -309
- 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,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 {
|
|
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
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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
|
|
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
|
|
62
|
-
|
|
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
|
-
|
|
110
|
-
//
|
|
111
|
-
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;
|
|
112
144
|
const result = uploadPack(deps, projectId);
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
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({
|
|
533
|
+
return c.json({
|
|
534
|
+
finish: withItems(waitingTasks(deps.db, "finish")),
|
|
535
|
+
comments: withItems(waitingTasks(deps.db, "comment")),
|
|
536
|
+
});
|
|
375
537
|
})
|
|
376
|
-
.post("/ext/
|
|
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
|
-
|
|
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
|
|
390
|
-
// 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.
|
|
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)
|
|
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,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
|
+
}
|