@gentbajko/slopify 3.3.1 → 3.4.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/studio.js +119 -32
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0048-releases.sql +20 -0
- package/dist/patch-notes/3.4.0.md +28 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/storage/backup-format.js +34 -3
- package/dist/slices/storage/portable.js +4 -0
- package/dist/slices/studio/backfill.js +0 -4
- package/dist/slices/studio/calendar.js +85 -0
- package/dist/slices/studio/pack.js +7 -13
- package/dist/slices/studio/plan-model.js +37 -0
- package/dist/slices/studio/plan.js +19 -102
- package/dist/slices/studio/releases.js +140 -0
- package/dist/slices/studio/videos.js +5 -0
- package/dist/web/assets/index-B4HZ24q0.js +310 -0
- package/dist/web/assets/index-GnlzADIB.css +1 -0
- package/dist/web/assets/{pdf-BI8cHr9G.js → pdf-Dsbuwxiz.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-6wMtRXOo.css +0 -1
- package/dist/web/assets/index-DmLX0oKs.js +0 -310
package/dist/edge/http/studio.js
CHANGED
|
@@ -10,14 +10,17 @@ import { channelById } from "../../slices/channels/repo.js";
|
|
|
10
10
|
import { readSetting, writeSetting } from "../../slices/settings/repo.js";
|
|
11
11
|
import { findDownload } from "../../slices/storage/downloads.js";
|
|
12
12
|
import { backfillVideos } from "../../slices/studio/backfill.js";
|
|
13
|
+
import { releaseCalendar } from "../../slices/studio/calendar.js";
|
|
13
14
|
import { studioPlaylistMax, studioUploadUrl, } from "../../slices/studio/model.js";
|
|
14
15
|
import { packItem, uploadPack } from "../../slices/studio/pack.js";
|
|
15
16
|
import { writeUploadPick } from "../../slices/studio/pick.js";
|
|
16
|
-
import {
|
|
17
|
+
import { postingPlanSchema, readPlan, writePlan } from "../../slices/studio/plan.js";
|
|
18
|
+
import { leadHoursMax, seriesOf } from "../../slices/studio/plan-model.js";
|
|
17
19
|
import { enqueueFill, fillNow, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
|
|
20
|
+
import { allReleases, freeSlots, planReleases, readLeadHours, releasesOf, setRelease, writeLeadHours, } from "../../slices/studio/releases.js";
|
|
18
21
|
import { autoCommentKey, bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
|
|
19
22
|
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";
|
|
23
|
+
import { confirmUpload, doneVideos, forgetVideo, projectVideos, recordVideo, setChecks, setTaskState, videoIdOf, videoIdPattern, videoOf, waitingTasks, } from "../../slices/studio/videos.js";
|
|
21
24
|
import { setAsideProjects, uploadedProjects } from "../../slices/uploads/repo.js";
|
|
22
25
|
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
23
26
|
const id = z
|
|
@@ -87,8 +90,13 @@ const statsBody = z.object({
|
|
|
87
90
|
});
|
|
88
91
|
const backfillBody = z.object({
|
|
89
92
|
videos: z
|
|
90
|
-
.array(z.object({
|
|
93
|
+
.array(z.object({
|
|
94
|
+
title: z.string().max(200),
|
|
95
|
+
videoId: z.string().regex(videoIdPattern),
|
|
96
|
+
checks: z.string().max(100).optional(),
|
|
97
|
+
}))
|
|
91
98
|
.max(200),
|
|
99
|
+
close: z.boolean().optional(),
|
|
92
100
|
});
|
|
93
101
|
// The extension builds the app ships (`scripts/copy-extension.mjs`), by browser.
|
|
94
102
|
const extensionFiles = {
|
|
@@ -136,17 +144,39 @@ export function studioRoutes(deps) {
|
|
|
136
144
|
title: "Unauthorized",
|
|
137
145
|
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.",
|
|
138
146
|
});
|
|
139
|
-
// A project ready to upload
|
|
140
|
-
// one whose long video is on YouTube already
|
|
147
|
+
// A project ready to upload gets its release times from the posting plan when its upload is
|
|
148
|
+
// prepared (`releases.ts`); one whose long video is on YouTube already keeps what it has.
|
|
141
149
|
const planned = (projectId) => {
|
|
142
150
|
if (videoOf(deps.db, projectId, null)?.uploadState === "done")
|
|
143
151
|
return;
|
|
144
152
|
const result = uploadPack(deps, projectId);
|
|
145
153
|
if (!result.ok || result.pack.items[0]?.video == null)
|
|
146
154
|
return;
|
|
147
|
-
|
|
155
|
+
planReleases(deps.db, readPlan(deps.db), {
|
|
156
|
+
id: projectId,
|
|
157
|
+
series: result.pack.series,
|
|
158
|
+
shorts: result.pack.items.filter((item) => item.kind === "short").length,
|
|
159
|
+
}, deps.clock.now());
|
|
160
|
+
};
|
|
161
|
+
const planBody = () => ({
|
|
162
|
+
plan: readPlan(deps.db),
|
|
163
|
+
leadHours: readLeadHours(deps.db),
|
|
164
|
+
series: [...new Set(listProjects(deps.db).map((project) => seriesOf(project.config)))]
|
|
165
|
+
.filter((one) => one !== "")
|
|
166
|
+
.toSorted(),
|
|
167
|
+
});
|
|
168
|
+
const slotChoices = (projectId, series) => freeSlots(deps.db, readPlan(deps.db), series, deps.clock.now(), 9);
|
|
169
|
+
// Finished projects not marked uploaded: the popup's list and the calendar's candidates.
|
|
170
|
+
const finishedProjects = () => {
|
|
171
|
+
const uploads = uploadedProjects(deps.db);
|
|
172
|
+
const aside = setAsideProjects(deps.db);
|
|
173
|
+
return listProjects(deps.db).filter((project) => {
|
|
174
|
+
if (uploads.has(project.id))
|
|
175
|
+
return false;
|
|
176
|
+
const state = derive(stagesOf(deps.db, project.id), project.paused === true);
|
|
177
|
+
return (state === "done" || state === "partial" || (state === "pending" && aside.has(project.id)));
|
|
178
|
+
});
|
|
148
179
|
};
|
|
149
|
-
const slotChoices = () => freeSlots(deps.db, readPlan(deps.db), deps.clock.now(), 9);
|
|
150
180
|
const extAllowed = (c) => studioRequestAllowed(deps.db, bearerToken(c.req.header("authorization")), c.req.header("origin"));
|
|
151
181
|
return (new Hono()
|
|
152
182
|
.get("/settings", (c) => {
|
|
@@ -169,18 +199,61 @@ export function studioRoutes(deps) {
|
|
|
169
199
|
writeSetting(deps.db, autoCommentKey, c.req.valid("json").on ? "on" : "off");
|
|
170
200
|
return c.json({ autoComment: c.req.valid("json").on });
|
|
171
201
|
})
|
|
172
|
-
// The posting plan and the
|
|
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
|
-
})
|
|
202
|
+
// The posting plan, its lead time, and the series the projects use (for the lines' picker).
|
|
203
|
+
.get("/plan", (c) => c.json(planBody()))
|
|
177
204
|
.put("/plan", zValidator("json", postingPlanSchema, onInvalid), (c) => {
|
|
178
205
|
const denied = samePage(c);
|
|
179
206
|
if (denied !== undefined)
|
|
180
207
|
return denied;
|
|
181
208
|
writePlan(deps.db, c.req.valid("json"));
|
|
182
|
-
|
|
183
|
-
|
|
209
|
+
return c.json(planBody());
|
|
210
|
+
})
|
|
211
|
+
.put("/settings/lead-hours", zValidator("json", z.object({ hours: z.number().int().min(1).max(leadHoursMax) }).strict(), onInvalid), (c) => {
|
|
212
|
+
const denied = samePage(c);
|
|
213
|
+
if (denied !== undefined)
|
|
214
|
+
return denied;
|
|
215
|
+
writeLeadHours(deps.db, c.req.valid("json").hours);
|
|
216
|
+
return c.json(planBody());
|
|
217
|
+
})
|
|
218
|
+
// Calendar → Releases: the coming weeks' long videos and shorts with their times and
|
|
219
|
+
// states, the plan's free times, and the finished projects that could fill them.
|
|
220
|
+
.get("/releases", zValidator("query", z.object({ weeks: z.coerce.number().int().min(1).max(8).optional() })), (c) => {
|
|
221
|
+
const denied = samePage(c);
|
|
222
|
+
if (denied !== undefined)
|
|
223
|
+
return denied;
|
|
224
|
+
const finished = finishedProjects();
|
|
225
|
+
// Every finished project gets its times, and so does any project already holding a
|
|
226
|
+
// release (its shorts may still need theirs).
|
|
227
|
+
for (const id of new Set([
|
|
228
|
+
...finished.map((project) => project.id),
|
|
229
|
+
...allReleases(deps.db).map((release) => release.projectId),
|
|
230
|
+
]))
|
|
231
|
+
planned(id);
|
|
232
|
+
return c.json(releaseCalendar(deps, {
|
|
233
|
+
now: deps.clock.now(),
|
|
234
|
+
weeks: c.req.valid("query").weeks ?? 2,
|
|
235
|
+
candidates: finished.map((project) => project.id),
|
|
236
|
+
}));
|
|
237
|
+
})
|
|
238
|
+
// Moves one release (a time), sets it to "not scheduled" (null), or puts a project into a
|
|
239
|
+
// free time of the plan (short 0 with its line).
|
|
240
|
+
.put("/releases/:projectId", zValidator("param", projectParam, onInvalid), zValidator("json", z
|
|
241
|
+
.object({
|
|
242
|
+
short: z.number().int().min(0).max(99),
|
|
243
|
+
at: z.iso.datetime().nullable(),
|
|
244
|
+
line: z.string().min(1).max(20).optional(),
|
|
245
|
+
})
|
|
246
|
+
.strict(), onInvalid), (c) => {
|
|
247
|
+
const denied = samePage(c);
|
|
248
|
+
if (denied !== undefined)
|
|
249
|
+
return denied;
|
|
250
|
+
const { projectId } = c.req.valid("param");
|
|
251
|
+
if (uploadPack(deps, projectId).ok === false)
|
|
252
|
+
return unknownProject(c);
|
|
253
|
+
const { short, at, line } = c.req.valid("json");
|
|
254
|
+
setRelease(deps.db, projectId, short, at ?? "", line ?? null, deps.clock.now());
|
|
255
|
+
planned(projectId);
|
|
256
|
+
return c.json({ releases: releasesOf(deps.db, projectId) });
|
|
184
257
|
})
|
|
185
258
|
.put("/settings/playlists", zValidator("json", playlistsBody, onInvalid), (c) => {
|
|
186
259
|
const denied = samePage(c);
|
|
@@ -218,7 +291,7 @@ export function studioRoutes(deps) {
|
|
|
218
291
|
const result = uploadPack(deps, projectId);
|
|
219
292
|
if (!result.ok)
|
|
220
293
|
return unknownProject(c);
|
|
221
|
-
return c.json({ ...result.pack, slotChoices: slotChoices() });
|
|
294
|
+
return c.json({ ...result.pack, slotChoices: slotChoices(projectId, result.pack.series) });
|
|
222
295
|
})
|
|
223
296
|
// Prepare upload's slot picker: another free slot of the plan, or none.
|
|
224
297
|
.put("/packs/:projectId/slot", zValidator("param", projectParam, onInvalid), zValidator("json", slotBody, onInvalid), (c) => {
|
|
@@ -227,21 +300,25 @@ export function studioRoutes(deps) {
|
|
|
227
300
|
return denied;
|
|
228
301
|
const { projectId } = c.req.valid("param");
|
|
229
302
|
const wanted = c.req.valid("json").slot;
|
|
303
|
+
const before = uploadPack(deps, projectId);
|
|
304
|
+
if (!before.ok)
|
|
305
|
+
return unknownProject(c);
|
|
230
306
|
if (wanted === null)
|
|
231
|
-
|
|
307
|
+
setRelease(deps.db, projectId, 0, "", null, deps.clock.now());
|
|
232
308
|
else {
|
|
233
|
-
const free = slotChoices().some((one) => one.row === wanted.row && one.longAt === wanted.longAt);
|
|
309
|
+
const free = slotChoices(projectId, before.pack.series).some((one) => one.row === wanted.row && one.longAt === wanted.longAt);
|
|
234
310
|
if (!free)
|
|
235
311
|
return problem(c, {
|
|
236
312
|
status: 409,
|
|
237
313
|
title: titleOf(409),
|
|
238
314
|
detail: "That slot is taken or past. Reload Prepare upload and choose one of the slots it lists.",
|
|
239
315
|
});
|
|
240
|
-
|
|
316
|
+
setRelease(deps.db, projectId, 0, wanted.longAt, wanted.row, deps.clock.now());
|
|
241
317
|
}
|
|
318
|
+
planned(projectId);
|
|
242
319
|
const result = uploadPack(deps, projectId);
|
|
243
320
|
return result.ok
|
|
244
|
-
? c.json({ ...result.pack, slotChoices: slotChoices() })
|
|
321
|
+
? c.json({ ...result.pack, slotChoices: slotChoices(projectId, result.pack.series) })
|
|
245
322
|
: unknownProject(c);
|
|
246
323
|
})
|
|
247
324
|
// Prepare upload's AI use tick: the project's uploaded clips are real footage.
|
|
@@ -427,7 +504,11 @@ export function studioRoutes(deps) {
|
|
|
427
504
|
allowOrigin(c, false);
|
|
428
505
|
if (!extAllowed(c))
|
|
429
506
|
return refused(c);
|
|
430
|
-
const
|
|
507
|
+
const { videos } = c.req.valid("json");
|
|
508
|
+
const found = backfillVideos(deps, videos, deps.clock.now().toISOString());
|
|
509
|
+
for (const video of videos)
|
|
510
|
+
if (video.checks !== undefined)
|
|
511
|
+
setChecks(deps.db, video.videoId, video.checks);
|
|
431
512
|
return c.json({ found });
|
|
432
513
|
})
|
|
433
514
|
// The extension popup: finished projects not marked uploaded, each with its video and
|
|
@@ -436,17 +517,7 @@ export function studioRoutes(deps) {
|
|
|
436
517
|
allowOrigin(c, false);
|
|
437
518
|
if (!extAllowed(c))
|
|
438
519
|
return refused(c);
|
|
439
|
-
const
|
|
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 [];
|
|
520
|
+
const projects = finishedProjects().flatMap((project) => {
|
|
450
521
|
planned(project.id);
|
|
451
522
|
const result = uploadPack(deps, project.id);
|
|
452
523
|
if (!result.ok || result.pack.items[0]?.video == null)
|
|
@@ -465,7 +536,13 @@ export function studioRoutes(deps) {
|
|
|
465
536
|
...(videoOf(deps.db, project.id, item.short ?? null) === undefined
|
|
466
537
|
? {}
|
|
467
538
|
: { videoId: videoOf(deps.db, project.id, item.short ?? null)?.videoId }),
|
|
468
|
-
...(item.scheduleAt === undefined
|
|
539
|
+
...(item.scheduleAt === undefined
|
|
540
|
+
? {}
|
|
541
|
+
: {
|
|
542
|
+
scheduleAt: item.scheduleAt,
|
|
543
|
+
// When it must be scheduled by, so the checks finish before release.
|
|
544
|
+
uploadBy: new Date(Date.parse(item.scheduleAt) - readLeadHours(deps.db) * 3_600_000).toISOString(),
|
|
545
|
+
}),
|
|
469
546
|
})),
|
|
470
547
|
},
|
|
471
548
|
];
|
|
@@ -530,9 +607,19 @@ export function studioRoutes(deps) {
|
|
|
530
607
|
? []
|
|
531
608
|
: [{ projectId: video.projectId, short: video.short, videoId: video.videoId, item }];
|
|
532
609
|
});
|
|
610
|
+
// A video on YouTube, due in the coming weeks, whose checks Studio hasn't cleared (or
|
|
611
|
+
// the extension hasn't read): the extension reads the Content list for them.
|
|
612
|
+
const soon = deps.clock.now().getTime();
|
|
613
|
+
const checks = doneVideos(deps.db).some((video) => {
|
|
614
|
+
if (video.checks === "ok")
|
|
615
|
+
return false;
|
|
616
|
+
const at = releasesOf(deps.db, video.projectId).find((release) => release.short === (video.short ?? 0))?.at;
|
|
617
|
+
return at !== undefined && at !== "" && Date.parse(at) > soon;
|
|
618
|
+
});
|
|
533
619
|
return c.json({
|
|
534
620
|
finish: withItems(waitingTasks(deps.db, "finish")),
|
|
535
621
|
comments: withItems(waitingTasks(deps.db, "comment")),
|
|
622
|
+
checks,
|
|
536
623
|
});
|
|
537
624
|
})
|
|
538
625
|
.post("/ext/task-result", zValidator("json", taskResultBody, onInvalid), (c) => {
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- Release calendar (3.4.0): one release time for each long video and each short, in place of
|
|
2
|
+
-- one posting-plan slot per project. short 0 is the long video. release_at '' is "not
|
|
3
|
+
-- scheduled". line is the posting-plan line the time came from (null when picked by hand);
|
|
4
|
+
-- by says whether the plan or the person set it, so moving a long video re-plans only the
|
|
5
|
+
-- shorts the plan placed.
|
|
6
|
+
CREATE TABLE releases (
|
|
7
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
8
|
+
short INTEGER NOT NULL CHECK (short >= 0),
|
|
9
|
+
release_at TEXT NOT NULL,
|
|
10
|
+
line TEXT,
|
|
11
|
+
by TEXT NOT NULL DEFAULT 'plan' CHECK (by IN ('plan', 'person')),
|
|
12
|
+
set_at TEXT NOT NULL,
|
|
13
|
+
PRIMARY KEY (project_id, short)
|
|
14
|
+
);
|
|
15
|
+
INSERT INTO releases (project_id, short, release_at, line, by, set_at)
|
|
16
|
+
SELECT project_id, 0, long_at, NULLIF(row_name, ''), 'plan', assigned_at FROM upload_slots;
|
|
17
|
+
DROP TABLE upload_slots;
|
|
18
|
+
-- What Studio's Content list says about a video's checks (copyright, ad suitability), as the
|
|
19
|
+
-- extension last read it: null until read.
|
|
20
|
+
ALTER TABLE youtube_videos ADD COLUMN checks TEXT;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Slopify 3.4.0
|
|
2
|
+
|
|
3
|
+
Released 2 October 2026.
|
|
4
|
+
|
|
5
|
+
## Calendar → Releases
|
|
6
|
+
|
|
7
|
+
- **Every video and every short has its own release time**, shown week by week in Calendar → **Releases**: the long video on one line, its shorts beside it, each with the time it must be uploaded and scheduled by.
|
|
8
|
+
- **Upload in time for the checks.** Each item is due a set number of hours before its release (24 by default; change it in Settings → YouTube Studio), so YouTube's copyright and ad checks finish while it is still private. An item past that time and not on YouTube shows **Late**.
|
|
9
|
+
- **See where each one stands:** not rendered, upload by…, filled in Studio but not scheduled, or scheduled, with **checks clear** once Studio's Content list shows no restrictions.
|
|
10
|
+
- **Move anything.** Click a time to change it; a long video can also be set to not scheduled. A free time of your plan offers the finished projects that fit it: pick one and press **Put it here**.
|
|
11
|
+
|
|
12
|
+
## Posting plan
|
|
13
|
+
|
|
14
|
+
- **Starts empty.** Nothing is scheduled until you add a long video in Settings → YouTube Studio → Posting plan.
|
|
15
|
+
- **No more A, B, C.** One line per long video, each with its own day and time and as many shorts as you post for it (**+ Short**, and × to remove one).
|
|
16
|
+
- **Series.** Each line can take any project or only one series, read from the project's titles (the part after "|"), so a week of three of one series and one of another fills itself the right way.
|
|
17
|
+
- **Shorts never share an hour** with another release; one that would takes the next time of its line.
|
|
18
|
+
|
|
19
|
+
## YouTube Studio extension (1.1.0)
|
|
20
|
+
|
|
21
|
+
- **Upload all shorts, one at a time.** The next short opens only after the previous file has fully uploaded, so leaving the page can't stop an upload.
|
|
22
|
+
- **The popup lists what's due first**, with each upload's "by" time, or **late**.
|
|
23
|
+
- **Reads the checks.** While a scheduled video's checks aren't clear, the extension reads Studio's Content list in the background every two hours.
|
|
24
|
+
|
|
25
|
+
## Upgrading from 3.3.1
|
|
26
|
+
|
|
27
|
+
- **Your plan and your projects' times carry over.** A project that already had a slot keeps it; its shorts get their times the first time you open Calendar → Releases.
|
|
28
|
+
- **Update the extension** (Settings → YouTube Studio → Download, then reload it in `chrome://extensions`), or let the Chrome Web Store update it once version 1.1.0 is approved.
|
|
@@ -57,8 +57,9 @@ export const projectTables = [
|
|
|
57
57
|
"project_uploads",
|
|
58
58
|
// Since 3.2.8: the YouTube video each upload became, and its A/B test.
|
|
59
59
|
"youtube_videos",
|
|
60
|
-
// Since 3.3.0:
|
|
61
|
-
|
|
60
|
+
// Since 3.3.0: Studio's numbers and its A/B results; since 3.4.0 each release time
|
|
61
|
+
// (`releases`, which took the place of 3.3's `upload_slots`: see `fromUploadSlots`).
|
|
62
|
+
"releases",
|
|
62
63
|
"video_stats",
|
|
63
64
|
"ab_results",
|
|
64
65
|
// Since 2.5.0. Left out on purpose: plan_limit_waits and plan_limit_waiters (an account's
|
|
@@ -183,13 +184,43 @@ export const libraryPartSchema = z
|
|
|
183
184
|
})
|
|
184
185
|
.strict();
|
|
185
186
|
export const usagePartSchema = z.object({ tables: tablesOf(usageTables) }).strict();
|
|
187
|
+
// A 3.3 backup keeps each project's posting-plan slot in `upload_slots`; it is read as the
|
|
188
|
+
// long video's release, as migration 0048 does with the table itself.
|
|
189
|
+
function fromUploadSlots(value) {
|
|
190
|
+
if (typeof value !== "object" || value === null)
|
|
191
|
+
return value;
|
|
192
|
+
const tables = value.tables;
|
|
193
|
+
if (typeof tables !== "object" || tables === null || !("upload_slots" in tables))
|
|
194
|
+
return value;
|
|
195
|
+
const { upload_slots: slots, ...rest } = tables;
|
|
196
|
+
const releases = Array.isArray(slots)
|
|
197
|
+
? slots.map((slot) => ({
|
|
198
|
+
project_id: slot.project_id ?? null,
|
|
199
|
+
short: 0,
|
|
200
|
+
release_at: slot.long_at ?? "",
|
|
201
|
+
line: slot.row_name === "" || slot.row_name === undefined ? null : slot.row_name,
|
|
202
|
+
by: "plan",
|
|
203
|
+
set_at: slot.assigned_at ?? "",
|
|
204
|
+
}))
|
|
205
|
+
: slots;
|
|
206
|
+
return {
|
|
207
|
+
...value,
|
|
208
|
+
tables: {
|
|
209
|
+
...rest,
|
|
210
|
+
...(releases === undefined || (Array.isArray(releases) && releases.length === 0)
|
|
211
|
+
? {}
|
|
212
|
+
: { releases }),
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
186
216
|
export const projectPartSchema = z
|
|
217
|
+
.preprocess(fromUploadSlots, z
|
|
187
218
|
.object({
|
|
188
219
|
id: backupId,
|
|
189
220
|
tables: tablesOf(projectTables),
|
|
190
221
|
files: z.array(fileEntry).max(maxFilesPerProject),
|
|
191
222
|
})
|
|
192
|
-
.strict()
|
|
223
|
+
.strict())
|
|
193
224
|
.refine((part) => part.tables.projects?.length === 1 && part.tables.projects[0]?.id === part.id, "A project part must hold exactly its own project row.");
|
|
194
225
|
export const checksumsSchema = z
|
|
195
226
|
.object({
|
|
@@ -25,6 +25,8 @@ import { realPersonVoicesKey, voiceIdMax, voiceNameMax } from "../settings/voice
|
|
|
25
25
|
import { whatsNewSeenKey } from "../settings/whats-new.js";
|
|
26
26
|
import { uploadPickPrefix, uploadPickSchema } from "../studio/pick.js";
|
|
27
27
|
import { postingPlanKey, postingPlanSchema } from "../studio/plan.js";
|
|
28
|
+
import { leadHoursMax } from "../studio/plan-model.js";
|
|
29
|
+
import { leadHoursKey } from "../studio/releases.js";
|
|
28
30
|
import { autoCommentKey, storedPlaylistsSchema, storedProjectPlaylistsSchema, studioChannelPlaylistPrefix, studioFillQueuePrefix, studioPairingKey, studioPlaylistKey, studioProjectPlaylistsKey, studioRealFootageKey, } from "../studio/settings.js";
|
|
29
31
|
import { channelLinksKey } from "../youtube/edits-repo.js";
|
|
30
32
|
import { defaultBackupsDir, projectDir, stagingPath } from "./layout.js";
|
|
@@ -397,6 +399,8 @@ function portableSettings(settings) {
|
|
|
397
399
|
storedPlaylistsSchema.parse(parsed);
|
|
398
400
|
else if (key === postingPlanKey)
|
|
399
401
|
postingPlanSchema.parse(parsed);
|
|
402
|
+
else if (key === leadHoursKey)
|
|
403
|
+
z.number().int().min(1).max(leadHoursMax).parse(parsed);
|
|
400
404
|
else if (key.startsWith(uploadPickPrefix))
|
|
401
405
|
uploadPickSchema.parse(parsed);
|
|
402
406
|
else if (key === studioProjectPlaylistsKey)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { listProjects } from "../admission/repo.js";
|
|
2
2
|
import { uploadPack } from "./pack.js";
|
|
3
|
-
import { clearSlot } from "./plan.js";
|
|
4
3
|
import { projectVideos, recordVideo } from "./videos.js";
|
|
5
4
|
const norm = (title) => title.trim().replace(/\s+/g, " ").toLowerCase();
|
|
6
5
|
export function backfillVideos(deps, rows, at) {
|
|
@@ -24,9 +23,6 @@ export function backfillVideos(deps, rows, at) {
|
|
|
24
23
|
if (videoId === undefined)
|
|
25
24
|
continue;
|
|
26
25
|
recordVideo(deps.db, project.id, short, videoId, at);
|
|
27
|
-
// Uploaded by hand before: its long video holds no slot of the posting plan.
|
|
28
|
-
if (short === null)
|
|
29
|
-
clearSlot(deps.db, project.id);
|
|
30
26
|
found += 1;
|
|
31
27
|
}
|
|
32
28
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { listProjects } from "../admission/repo.js";
|
|
2
|
+
import { uploadPack } from "./pack.js";
|
|
3
|
+
import { readPlan } from "./plan.js";
|
|
4
|
+
import { seriesOf } from "./plan-model.js";
|
|
5
|
+
import { allReleases, lineSlots, readLeadHours, releasesOf } from "./releases.js";
|
|
6
|
+
import { videoOf } from "./videos.js";
|
|
7
|
+
const hourMs = 60 * 60 * 1000;
|
|
8
|
+
export function releaseCalendar(deps, options) {
|
|
9
|
+
const { now } = options;
|
|
10
|
+
const plan = readPlan(deps.db);
|
|
11
|
+
const leadMs = readLeadHours(deps.db) * hourMs;
|
|
12
|
+
const from = new Date(now.getTime() - 24 * hourMs);
|
|
13
|
+
const until = new Date(now.getTime() + options.weeks * 7 * 24 * hourMs);
|
|
14
|
+
const inWindow = (iso) => {
|
|
15
|
+
const at = Date.parse(iso);
|
|
16
|
+
return at >= from.getTime() && at < until.getTime();
|
|
17
|
+
};
|
|
18
|
+
const projects = new Map(listProjects(deps.db).map((project) => [project.id, project]));
|
|
19
|
+
const longs = allReleases(deps.db).filter((release) => release.short === 0 && projects.has(release.projectId) && inWindow(release.at));
|
|
20
|
+
const seriesOfLine = (line) => plan.rows.find((row) => row.name === line)?.series ?? "";
|
|
21
|
+
const entries = longs.map((long) => {
|
|
22
|
+
const project = projects.get(long.projectId);
|
|
23
|
+
const pack = uploadPack(deps, long.projectId);
|
|
24
|
+
const releases = releasesOf(deps.db, long.projectId);
|
|
25
|
+
const items = pack.ok ? pack.pack.items : [];
|
|
26
|
+
return {
|
|
27
|
+
at: long.at,
|
|
28
|
+
line: long.line,
|
|
29
|
+
series: project === undefined ? "" : seriesOf(project.config),
|
|
30
|
+
project: { id: long.projectId, title: project?.title ?? "" },
|
|
31
|
+
items: items.map((item) => {
|
|
32
|
+
const short = item.short ?? 0;
|
|
33
|
+
const at = releases.find((release) => release.short === short)?.at;
|
|
34
|
+
const release = at === undefined || at === "" ? null : at;
|
|
35
|
+
const uploadBy = release === null ? null : new Date(Date.parse(release) - leadMs);
|
|
36
|
+
const video = videoOf(deps.db, long.projectId, item.short ?? null);
|
|
37
|
+
const state = video?.uploadState === "done"
|
|
38
|
+
? "scheduled"
|
|
39
|
+
: video?.uploadState === "filled"
|
|
40
|
+
? "filled"
|
|
41
|
+
: item.video === null
|
|
42
|
+
? "not-ready"
|
|
43
|
+
: uploadBy !== null && now.getTime() > uploadBy.getTime()
|
|
44
|
+
? "late"
|
|
45
|
+
: "ready";
|
|
46
|
+
return {
|
|
47
|
+
short,
|
|
48
|
+
title: item.title,
|
|
49
|
+
at: release,
|
|
50
|
+
uploadBy: uploadBy?.toISOString() ?? null,
|
|
51
|
+
state,
|
|
52
|
+
checks: video?.checks ?? null,
|
|
53
|
+
videoId: video?.videoId ?? null,
|
|
54
|
+
};
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
// The plan's long-video times no project holds (the hour is free), from now on.
|
|
59
|
+
const takenHours = new Set(allReleases(deps.db).map((release) => Math.floor(Date.parse(release.at) / hourMs)));
|
|
60
|
+
for (const slot of lineSlots(plan, now, until))
|
|
61
|
+
if (!takenHours.has(Math.floor(Date.parse(slot.longAt) / hourMs)))
|
|
62
|
+
entries.push({
|
|
63
|
+
at: slot.longAt,
|
|
64
|
+
line: slot.row,
|
|
65
|
+
series: seriesOfLine(slot.row),
|
|
66
|
+
project: null,
|
|
67
|
+
items: [],
|
|
68
|
+
});
|
|
69
|
+
const scheduled = new Set(allReleases(deps.db)
|
|
70
|
+
.filter((release) => release.short === 0)
|
|
71
|
+
.map((release) => release.projectId));
|
|
72
|
+
return {
|
|
73
|
+
leadHours: readLeadHours(deps.db),
|
|
74
|
+
timeZone: plan.timeZone,
|
|
75
|
+
from: from.toISOString(),
|
|
76
|
+
until: until.toISOString(),
|
|
77
|
+
entries: entries.toSorted((left, right) => Date.parse(left.at) - Date.parse(right.at)),
|
|
78
|
+
candidates: options.candidates.flatMap((id) => {
|
|
79
|
+
const project = projects.get(id);
|
|
80
|
+
if (project === undefined || scheduled.has(id))
|
|
81
|
+
return [];
|
|
82
|
+
return [{ id, title: project.title, series: seriesOf(project.config) }];
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -19,7 +19,8 @@ import { linkKey, previousVideoLink } from "../youtube/placeholders.js";
|
|
|
19
19
|
import { aiDisclosureOf } from "./disclosure.js";
|
|
20
20
|
import { studioAudience, studioTitleMax, } from "./model.js";
|
|
21
21
|
import { picked, readUploadPick } from "./pick.js";
|
|
22
|
-
import {
|
|
22
|
+
import { seriesOf } from "./plan-model.js";
|
|
23
|
+
import { scheduleOf } from "./releases.js";
|
|
23
24
|
import { projectPlaylists, readRealFootage } from "./settings.js";
|
|
24
25
|
import { previousLongVideo, videoIdOf, videoOf } from "./videos.js";
|
|
25
26
|
// What `shorts.json` holds for each picked clip; only what the pack uses.
|
|
@@ -129,15 +130,9 @@ export function uploadPack(deps, projectId) {
|
|
|
129
130
|
];
|
|
130
131
|
const allThumbnails = thumbnails.map(file);
|
|
131
132
|
const pick = readUploadPick(deps.db, projectId);
|
|
132
|
-
// When each upload goes out, from the
|
|
133
|
-
//
|
|
134
|
-
const
|
|
135
|
-
const slotRow = deps.db
|
|
136
|
-
.prepare("SELECT row_name, long_at FROM upload_slots WHERE project_id=?")
|
|
137
|
-
.get(projectId);
|
|
138
|
-
const schedule = slotRow === undefined || String(slotRow.long_at) === ""
|
|
139
|
-
? undefined
|
|
140
|
-
: scheduleOf(plan, { row: String(slotRow.row_name), longAt: String(slotRow.long_at) });
|
|
133
|
+
// When each upload goes out, from the release calendar (`releases.ts`): the long video's
|
|
134
|
+
// release and each short's, once the project has them.
|
|
135
|
+
const schedule = scheduleOf(deps.db, projectId);
|
|
141
136
|
// The long video's captions, its end screen's video (the project's Previous video, else the
|
|
142
137
|
// long video uploaded before it) and the comment to pin; a short's related video is the
|
|
143
138
|
// long video, once it is on YouTube.
|
|
@@ -209,9 +204,7 @@ export function uploadPack(deps, projectId) {
|
|
|
209
204
|
alteredContent: disclosure("short"),
|
|
210
205
|
playlists,
|
|
211
206
|
playlist,
|
|
212
|
-
...(schedule?.shortsAt[clip.number - 1]
|
|
213
|
-
? {}
|
|
214
|
-
: { scheduleAt: schedule.shortsAt[clip.number - 1] }),
|
|
207
|
+
...((at) => (at == null ? {} : { scheduleAt: at }))(schedule?.shortsAt[clip.number - 1]),
|
|
215
208
|
...(longVideo?.uploadState === "done" ? { relatedVideoId: longVideo.videoId } : {}),
|
|
216
209
|
});
|
|
217
210
|
}
|
|
@@ -220,6 +213,7 @@ export function uploadPack(deps, projectId) {
|
|
|
220
213
|
pack: {
|
|
221
214
|
projectId,
|
|
222
215
|
projectTitle: project.title,
|
|
216
|
+
series: seriesOf(config),
|
|
223
217
|
items,
|
|
224
218
|
...(schedule === undefined ? {} : { schedule }),
|
|
225
219
|
missing,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
// The posting plan's shapes, with nothing from the server, so the web app can import them.
|
|
3
|
+
export const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
4
|
+
const slotSchema = z.object({
|
|
5
|
+
day: z.number().int().min(0).max(6),
|
|
6
|
+
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
|
7
|
+
});
|
|
8
|
+
const rowSchema = z.object({
|
|
9
|
+
// An inner id, never shown: a release keeps the line it came from.
|
|
10
|
+
name: z.string().trim().min(1).max(20),
|
|
11
|
+
// Which projects the line takes: "" takes any, else only a project of that series.
|
|
12
|
+
series: z.string().trim().max(100).default(""),
|
|
13
|
+
long: slotSchema,
|
|
14
|
+
shorts: z.array(slotSchema).max(10),
|
|
15
|
+
});
|
|
16
|
+
export const postingPlanSchema = z.object({
|
|
17
|
+
timeZone: z.string().min(1).max(100),
|
|
18
|
+
rows: z.array(rowSchema).max(14),
|
|
19
|
+
});
|
|
20
|
+
// How long before its release a video must be uploaded and scheduled, so YouTube's copyright
|
|
21
|
+
// and ad-suitability checks finish while it is still private.
|
|
22
|
+
export const leadHoursDefault = 24;
|
|
23
|
+
export const leadHoursMax = 168;
|
|
24
|
+
// A project's series: what its titles keep after the topic, read from the title pattern (else
|
|
25
|
+
// the title) after its last "|": "{{Topic}} | Stories To Sleep To" → "Stories To Sleep To".
|
|
26
|
+
// A title without one has no series and fits only a line that takes any.
|
|
27
|
+
export function seriesOf(config) {
|
|
28
|
+
const source = config.titlePattern ?? config.title;
|
|
29
|
+
const at = source.lastIndexOf("|");
|
|
30
|
+
if (at < 0)
|
|
31
|
+
return "";
|
|
32
|
+
return source
|
|
33
|
+
.slice(at + 1)
|
|
34
|
+
.replace(/\{\{[^{}]*\}\}/g, "")
|
|
35
|
+
.replace(/\s+/g, " ")
|
|
36
|
+
.trim();
|
|
37
|
+
}
|