@gentbajko/slopify 3.3.1 → 3.4.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.
@@ -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 { assignedSlot, freeSlots, postingPlanSchema, readPlan, setSlot, unschedule, writePlan, } from "../../slices/studio/plan.js";
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({ title: z.string().max(200), videoId: z.string().regex(videoIdPattern) }))
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 takes the plan's next free slot when its upload is prepared;
140
- // one whose long video is on YouTube already doesn't.
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
- assignedSlot(deps.db, readPlan(deps.db), projectId, deps.clock.now());
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 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
- })
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
- const plan = readPlan(deps.db);
183
- return c.json({ plan, free: freeSlots(deps.db, plan, deps.clock.now(), 9) });
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
- unschedule(deps.db, projectId, deps.clock.now());
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
- setSlot(deps.db, projectId, wanted, deps.clock.now());
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 found = backfillVideos(deps, c.req.valid("json").videos, deps.clock.now().toISOString());
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 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 [];
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 ? {} : { scheduleAt: item.scheduleAt }),
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) => {
@@ -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.
@@ -0,0 +1,7 @@
1
+ # Slopify 3.4.1
2
+
3
+ Released 2 October 2026.
4
+
5
+ ## Calendar → Releases
6
+
7
+ - **A free time offers only projects with their video made.** Finished projects with no video (a thumbnails-only or audio-only project) no longer show in its list.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.4.1",
4
+ "title": "Slopify 3.4.1",
5
+ "version": "3.4.1",
6
+ "date": "2026-10-02"
7
+ },
8
+ {
9
+ "id": "3.4.0",
10
+ "title": "Slopify 3.4.0",
11
+ "version": "3.4.0",
12
+ "date": "2026-10-02"
13
+ },
2
14
  {
3
15
  "id": "3.3.1",
4
16
  "title": "Slopify 3.3.1",
@@ -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: each project's posting-plan slot, Studio's numbers and its A/B results.
61
- "upload_slots",
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,89 @@
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
+ // Only a project with its video made can take a time.
79
+ candidates: options.candidates.flatMap((id) => {
80
+ const project = projects.get(id);
81
+ if (project === undefined || scheduled.has(id))
82
+ return [];
83
+ const pack = uploadPack(deps, id);
84
+ if (!pack.ok || pack.pack.items[0]?.video == null)
85
+ return [];
86
+ return [{ id, title: project.title, series: seriesOf(project.config) }];
87
+ }),
88
+ };
89
+ }
@@ -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 { readPlan, scheduleOf } from "./plan.js";
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 posting plan's slot (`plan.ts`), once the project has
133
- // one: the long video at the slot, each short at its own time after it.
134
- const plan = readPlan(deps.db);
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] === undefined
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
+ }