@gentbajko/slopify 3.7.2 → 3.8.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.
@@ -9,6 +9,7 @@ import { transact } from "../../kernel/db/tx.js";
9
9
  import { derive } from "../../kernel/runner/graph.js";
10
10
  import { listProjects, projectById, stagesOf } from "../../slices/admission/repo.js";
11
11
  import { channelById } from "../../slices/channels/repo.js";
12
+ import { scheduleById, scheduleRunOfProject } from "../../slices/schedules/repo.js";
12
13
  import { readSetting, writeSetting } from "../../slices/settings/repo.js";
13
14
  import { findDownload } from "../../slices/storage/downloads.js";
14
15
  import { backfillVideos } from "../../slices/studio/backfill.js";
@@ -17,7 +18,7 @@ import { studioPlaylistMax, studioUploadUrl, } from "../../slices/studio/model.j
17
18
  import { packItem, uploadPack } from "../../slices/studio/pack.js";
18
19
  import { channelPerformance } from "../../slices/studio/performance.js";
19
20
  import { writeUploadPick } from "../../slices/studio/pick.js";
20
- import { postingPlanSchema, readPlan, writePlan } from "../../slices/studio/plan.js";
21
+ import { postingPlanSchema, readPlan, readStoredPlan, weekdayIn, writePlan, } from "../../slices/studio/plan.js";
21
22
  import { leadHoursMax, seriesOf } from "../../slices/studio/plan-model.js";
22
23
  import { enqueueFill, fillOnly, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
23
24
  import { allReleases, freeSlots, planReleases, readLeadHours, releasesOf, setRelease, swapReleases, writeLeadHours, } from "../../slices/studio/releases.js";
@@ -148,6 +149,18 @@ export function studioRoutes(deps) {
148
149
  title: "Unauthorized",
149
150
  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.",
150
151
  });
152
+ // A project's place in the plan: its series, and the schedule and run day that made it.
153
+ const whoOf = (projectId, series) => {
154
+ const run = scheduleRunOfProject(deps.db, projectId);
155
+ if (run === undefined)
156
+ return { series };
157
+ const schedule = scheduleById(deps.db, run.scheduleId);
158
+ return {
159
+ series,
160
+ schedule: run.scheduleId,
161
+ runDay: weekdayIn(schedule?.timezone ?? "UTC", new Date(run.scheduledFor)),
162
+ };
163
+ };
151
164
  // A project ready to upload gets its release times from the posting plan when its upload is
152
165
  // prepared (`releases.ts`); one whose long video is on YouTube already keeps what it has.
153
166
  const planned = (projectId) => {
@@ -158,12 +171,13 @@ export function studioRoutes(deps) {
158
171
  return;
159
172
  planReleases(deps.db, readPlan(deps.db), {
160
173
  id: projectId,
161
- series: result.pack.series,
174
+ ...whoOf(projectId, result.pack.series),
162
175
  shorts: result.pack.items.filter((item) => item.kind === "short").length,
163
176
  }, deps.clock.now());
164
177
  };
178
+ // Settings shows only the lines saved there; each schedule shows its own.
165
179
  const planBody = () => ({
166
- plan: readPlan(deps.db),
180
+ plan: readStoredPlan(deps.db),
167
181
  leadHours: readLeadHours(deps.db),
168
182
  series: [...new Set(listProjects(deps.db).map((project) => seriesOf(project.config)))]
169
183
  .filter((one) => one !== "")
@@ -186,7 +200,7 @@ export function studioRoutes(deps) {
186
200
  })),
187
201
  };
188
202
  };
189
- const slotChoices = (projectId, series) => freeSlots(deps.db, readPlan(deps.db), series, deps.clock.now(), 9);
203
+ const slotChoices = (projectId, series) => freeSlots(deps.db, readPlan(deps.db), whoOf(projectId, series), deps.clock.now(), 9);
190
204
  // Finished projects not marked uploaded: the popup's list and the calendar's candidates.
191
205
  const finishedProjects = () => {
192
206
  const uploads = uploadedProjects(deps.db);
@@ -0,0 +1,4 @@
1
+ -- Release times on the schedule (3.8.0): for each day the schedule runs, when the project it
2
+ -- makes goes out on YouTube (the long video and each short, a weekday and a time in the
3
+ -- schedule's time zone). Null: the schedule sets none.
4
+ ALTER TABLE schedules ADD COLUMN releases_json TEXT;
@@ -0,0 +1,9 @@
1
+ # Slopify 3.8.0
2
+
3
+ Released 4 October 2026.
4
+
5
+ ## Release times live in the schedule
6
+
7
+ - **Each schedule sets its own release times.** Edit a schedule (Calendar → Schedules → Edit) and press **Set release times**: for each day it runs there is a line with the long video's weekday and time and one for every short its template makes. The number of shorts comes from the template, so there is nothing to add or remove by hand. Times are in the schedule's time zone.
8
+ - **A project goes out on its run's line.** A project a schedule made takes the next free time of the line for the day it ran (else another of that schedule's lines), and its shorts follow it, never in the same hour as another release. Calendar → Releases shows each line under its schedule's name.
9
+ - **Settings → YouTube Studio no longer has a posting plan editor.** It points to the schedules and keeps the upload lead time and the pinned comment. A posting plan saved there before still applies (to projects no schedule made, and when a schedule's own times are taken) until you press **Remove the old plan**. Release times already given don't change.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "id": "3.8.0",
4
+ "title": "Slopify 3.8.0",
5
+ "version": "3.8.0",
6
+ "date": "2026-10-04"
7
+ },
2
8
  {
3
9
  "id": "3.7.2",
4
10
  "title": "Slopify 3.7.2",
@@ -32,12 +32,13 @@ const rowSchema = z.object({
32
32
  topics_generated_at: z.string().nullable(),
33
33
  topics_failed_at: z.string().nullable(),
34
34
  topics_error: z.string().nullable(),
35
+ releases_json: z.string().nullable(),
35
36
  held_topics: z.number(),
36
37
  });
37
38
  const scheduleColumns = `id,name,template_id,template_version,cadence_json,timezone,missed_policy,
38
39
  overlap_policy,spend_limit_cents,items_json,topic_keyword,values_json,status,version,next_run_at,
39
40
  created_at,updated_at,deleted_at,brief,topic_mode,topic_min,topic_llm_json,topics_generating_at,
40
- topics_generated_at,topics_failed_at,topics_error,
41
+ topics_generated_at,topics_failed_at,topics_error,releases_json,
41
42
  (SELECT count(*) FROM schedule_topics AS held
42
43
  WHERE held.schedule_id=schedules.id AND held.state='held') AS held_topics`;
43
44
  export function scheduleById(db, id) {
@@ -67,8 +68,9 @@ export function insertSchedule(db, schedule, creationHash) {
67
68
  db.prepare(`INSERT INTO schedules
68
69
  (id,name,template_id,template_version,cadence_json,timezone,missed_policy,overlap_policy,
69
70
  spend_limit_cents,items_json,topic_keyword,values_json,status,version,creation_hash,
70
- next_run_at,created_at,updated_at,deleted_at,brief,topic_mode,topic_min,topic_llm_json)
71
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(schedule.id, schedule.name, schedule.templateId, schedule.templateVersion, JSON.stringify(schedule.cadence), schedule.timezone, schedule.missedPolicy, schedule.overlapPolicy, schedule.spendLimitCents, JSON.stringify(schedule.items), schedule.topicKeyword, JSON.stringify(schedule.values), schedule.status, schedule.version, creationHash, schedule.nextRunAt, schedule.createdAt, schedule.updatedAt, schedule.deletedAt, ...generationColumns(schedule));
71
+ next_run_at,created_at,updated_at,deleted_at,brief,topic_mode,topic_min,topic_llm_json,
72
+ releases_json)
73
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(schedule.id, schedule.name, schedule.templateId, schedule.templateVersion, JSON.stringify(schedule.cadence), schedule.timezone, schedule.missedPolicy, schedule.overlapPolicy, schedule.spendLimitCents, JSON.stringify(schedule.items), schedule.topicKeyword, JSON.stringify(schedule.values), schedule.status, schedule.version, creationHash, schedule.nextRunAt, schedule.createdAt, schedule.updatedAt, schedule.deletedAt, ...generationColumns(schedule));
72
74
  }
73
75
  function generationColumns(schedule) {
74
76
  const generation = schedule.topicGeneration;
@@ -77,6 +79,7 @@ function generationColumns(schedule) {
77
79
  generation.mode,
78
80
  generation.keepAtLeast,
79
81
  generation.llm === null ? null : JSON.stringify(generation.llm),
82
+ schedule.releases.length === 0 ? null : JSON.stringify(schedule.releases),
80
83
  ];
81
84
  }
82
85
  export function updateScheduleRow(db, schedule, mutationId, mutationHash, baseVersion) {
@@ -85,7 +88,8 @@ export function updateScheduleRow(db, schedule, mutationId, mutationHash, baseVe
85
88
  missed_policy=?,overlap_policy=?,spend_limit_cents=?,items_json=?,topic_keyword=?,
86
89
  values_json=?,status=?,version=?,
87
90
  next_run_at=?,updated_at=?,mutation_id=?,mutation_hash=?,
88
- brief=?,topic_mode=?,topic_min=?,topic_llm_json=?,topics_failed_at=NULL,topics_error=NULL
91
+ brief=?,topic_mode=?,topic_min=?,topic_llm_json=?,releases_json=?,topics_failed_at=NULL,
92
+ topics_error=NULL
89
93
  WHERE id=? AND version=? AND deleted_at IS NULL`)
90
94
  .run(schedule.name, schedule.templateId, schedule.templateVersion, JSON.stringify(schedule.cadence), schedule.timezone, schedule.missedPolicy, schedule.overlapPolicy, schedule.spendLimitCents, JSON.stringify(schedule.items), schedule.topicKeyword, JSON.stringify(schedule.values), schedule.status, schedule.version, schedule.nextRunAt, schedule.updatedAt, mutationId, mutationHash, ...generationColumns(schedule), schedule.id, baseVersion);
91
95
  return Number(changed.changes) === 1;
@@ -300,6 +304,7 @@ function parseSchedule(row) {
300
304
  keepAtLeast: value.topic_min,
301
305
  llm: value.topic_llm_json === null ? null : JSON.parse(value.topic_llm_json),
302
306
  },
307
+ releases: value.releases_json === null ? [] : JSON.parse(value.releases_json),
303
308
  topics: {
304
309
  held: value.held_topics,
305
310
  generatingSince: value.topics_generating_at,
@@ -363,3 +368,15 @@ const runErrors = {
363
368
  function runErrorText(error) {
364
369
  return Object.hasOwn(runErrors, error) ? (runErrors[error] ?? error) : error;
365
370
  }
371
+ // The schedule run that made a project, and the time it was due; undefined for a project made
372
+ // by hand.
373
+ export function scheduleRunOfProject(db, projectId) {
374
+ const row = db
375
+ .prepare(`SELECT schedule_runs.schedule_id, schedule_runs.scheduled_for
376
+ FROM schedule_runs, json_each(schedule_runs.project_ids_json) AS admitted
377
+ WHERE admitted.value=? ORDER BY schedule_runs.started_at DESC LIMIT 1`)
378
+ .get(projectId);
379
+ if (row === undefined)
380
+ return undefined;
381
+ return { scheduleId: String(row.schedule_id), scheduledFor: String(row.scheduled_for) };
382
+ }
@@ -66,6 +66,25 @@ export const topicStateIdle = {
66
66
  failedAt: null,
67
67
  error: null,
68
68
  };
69
+ // When the project a run makes goes out on YouTube: for one day the schedule runs (`day`, 0 is
70
+ // Sunday), the long video's weekday and time and each short's, in the schedule's time zone.
71
+ // Each goes out the first time its day and hour come round after the one before it.
72
+ const releaseTime = z
73
+ .object({
74
+ day: z.number().int().min(0).max(6),
75
+ time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
76
+ })
77
+ .strict()
78
+ .readonly();
79
+ export const scheduleReleaseSchema = z
80
+ .object({
81
+ day: z.number().int().min(0).max(6),
82
+ long: releaseTime,
83
+ shorts: z.array(releaseTime).max(10).readonly(),
84
+ })
85
+ .strict()
86
+ .readonly();
87
+ const releases = z.array(scheduleReleaseSchema).max(7).readonly();
69
88
  // Enough for a year of daily runs, pasted as one list.
70
89
  export const queueMax = 500;
71
90
  export const scheduleCreateSchema = z
@@ -92,6 +111,7 @@ export const scheduleCreateSchema = z
92
111
  // generation reads it.
93
112
  brief: z.string().trim().max(briefMax).nullable().default(null),
94
113
  topicGeneration: topicGenerationSchema.default(topicGenerationOff),
114
+ releases: releases.default([]),
95
115
  })
96
116
  .strict()
97
117
  .readonly();
@@ -124,6 +144,7 @@ export const scheduleSummarySchema = z
124
144
  values: values.default({}),
125
145
  brief: z.string().nullable().default(null),
126
146
  topicGeneration: topicGenerationSchema.default(topicGenerationOff),
147
+ releases: releases.default([]),
127
148
  // The generation's own state: how many topics wait for approval, whether an LLM is being
128
149
  // asked now, and the last failure's plain reason (cleared by the next success).
129
150
  topics: topicState.default(topicStateIdle),
@@ -216,6 +216,7 @@ function summary(input, nextRunAt, now, status = "active", version = 1, topics =
216
216
  values: input.values,
217
217
  brief: input.brief === null || input.brief === "" ? null : input.brief,
218
218
  topicGeneration: input.topicGeneration,
219
+ releases: input.releases,
219
220
  topics,
220
221
  status,
221
222
  version,
@@ -17,7 +17,11 @@ export function releaseCalendar(deps, options) {
17
17
  };
18
18
  const projects = new Map(listProjects(deps.db).map((project) => [project.id, project]));
19
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 ?? "";
20
+ // A schedule's line shows the schedule's name; a stored line its series.
21
+ const seriesOfLine = (line) => {
22
+ const row = plan.rows.find((one) => one.name === line);
23
+ return row?.label ?? row?.series ?? "";
24
+ };
21
25
  const entries = longs.map((long) => {
22
26
  const project = projects.get(long.projectId);
23
27
  const pack = uploadPack(deps, long.projectId);
@@ -12,6 +12,13 @@ const rowSchema = z.object({
12
12
  series: z.string().trim().max(100).default(""),
13
13
  long: slotSchema,
14
14
  shorts: z.array(slotSchema).max(10),
15
+ // A line from a schedule's release times (never stored here): it takes only that schedule's
16
+ // projects, preferring the ones its run day (`runDay`) made, in the schedule's time zone, and
17
+ // the calendar shows the schedule's name (`label`).
18
+ schedule: z.string().optional(),
19
+ runDay: z.number().int().min(0).max(6).optional(),
20
+ timeZone: z.string().optional(),
21
+ label: z.string().optional(),
15
22
  });
16
23
  export const postingPlanSchema = z.object({
17
24
  timeZone: z.string().min(1).max(100),
@@ -1,7 +1,8 @@
1
- import { z } from "zod";
1
+ import { scheduleRows } from "../schedules/repo.js";
2
2
  import { readSetting, writeSetting } from "../settings/repo.js";
3
- // The posting plan (Settings → YouTube Studio → Posting plan): a week of long-video slots, each
4
- // with its shorts, kept in one time zone. A finished project takes the next free slot when its
3
+ // The posting plan: a week of long-video slots, each with its shorts. Each schedule sets its
4
+ // own (its release times, one line per day it runs, in its time zone); lines saved in Settings
5
+ // before that are kept in the plan's time zone. A finished project takes the next free slot when its
5
6
  // upload is prepared; each of its shorts goes out at the first time its day and hour come round
6
7
  // after its own long video, so a plan never needs "next week" notes. The extension types the
7
8
  // times into Studio's schedule, in the browser's own time zone.
@@ -16,21 +17,44 @@ export function emptyPlan(timeZone) {
16
17
  export function localTimeZone() {
17
18
  return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
18
19
  }
19
- export function readPlan(db) {
20
+ // The plan as stored in Settings: the lines made before schedules had release times.
21
+ export function readStoredPlan(db) {
20
22
  const stored = readSetting(db, key);
21
23
  if (stored !== undefined)
22
24
  try {
23
25
  const parsed = postingPlanSchema.safeParse(JSON.parse(stored));
24
26
  if (parsed.success)
25
- return parsed.data;
27
+ return {
28
+ ...parsed.data,
29
+ rows: parsed.data.rows.filter((row) => row.schedule === undefined),
30
+ };
26
31
  }
27
32
  catch {
28
33
  // A broken setting reads as no plan.
29
34
  }
30
35
  return emptyPlan(localTimeZone());
31
36
  }
37
+ // The lines every release is planned from: each schedule's release times (one line for each
38
+ // day it runs), then the stored lines.
39
+ export function readPlan(db) {
40
+ const stored = readStoredPlan(db);
41
+ const fromSchedules = scheduleRows(db)
42
+ .filter((schedule) => schedule.deletedAt === null && schedule.status !== "canceled")
43
+ .flatMap((schedule) => schedule.releases.map((release) => ({
44
+ name: `s${schedule.id.slice(0, 8)}${String(release.day)}`,
45
+ series: "",
46
+ long: release.long,
47
+ shorts: [...release.shorts],
48
+ schedule: schedule.id,
49
+ runDay: release.day,
50
+ timeZone: schedule.timezone,
51
+ label: schedule.name,
52
+ })));
53
+ return { ...stored, rows: [...fromSchedules, ...stored.rows] };
54
+ }
32
55
  export function writePlan(db, plan) {
33
- writeSetting(db, key, JSON.stringify(postingPlanSchema.parse(plan)));
56
+ const parsed = postingPlanSchema.parse(plan);
57
+ writeSetting(db, key, JSON.stringify({ ...parsed, rows: parsed.rows.filter((row) => row.schedule === undefined) }));
34
58
  }
35
59
  function partsIn(timeZone, at) {
36
60
  const format = new Intl.DateTimeFormat("en-US", {
@@ -79,6 +103,10 @@ function addDays(date, days) {
79
103
  weekday: next.getUTCDay(),
80
104
  };
81
105
  }
106
+ // The weekday (0 is Sunday) an instant falls on in the zone.
107
+ export function weekdayIn(timeZone, at) {
108
+ return localDate(timeZone, at).weekday;
109
+ }
82
110
  // The first time the slot's day and hour come round after `after`.
83
111
  export function nextOccurrence(timeZone, slot, after) {
84
112
  let date = localDate(timeZone, after);
@@ -47,27 +47,31 @@ function takenHours(db, exceptProject) {
47
47
  .filter((release) => release.projectId !== exceptProject)
48
48
  .map((release) => Math.floor(Date.parse(release.at) / hourMs)));
49
49
  }
50
- const fits = (line, series) => line.series === "" || line.series === series;
50
+ // A schedule's line takes only that schedule's projects; a stored line takes its series (or any).
51
+ const fits = (line, who) => line.schedule !== undefined
52
+ ? line.schedule === who.schedule
53
+ : line.series === "" || line.series === who.series;
54
+ const zoneOf = (plan, line) => line.timeZone ?? plan.timeZone;
51
55
  // The plan's long-video times from `from` to `until`, in order, each with its line.
52
56
  export function lineSlots(plan, from, until) {
53
57
  return plan.rows
54
- .flatMap((line) => zonedTimes(plan.timeZone, line.long, from, until).map((at) => ({
58
+ .flatMap((line) => zonedTimes(zoneOf(plan, line), line.long, from, until).map((at) => ({
55
59
  row: line.name,
56
60
  series: line.series,
57
61
  longAt: at.toISOString(),
58
62
  })))
59
63
  .toSorted((left, right) => Date.parse(left.longAt) - Date.parse(right.longAt));
60
64
  }
61
- // The coming long-video times a project of `series` may take: far enough ahead to upload in
62
- // time, of a line that takes its series, in an hour no other release has.
63
- export function freeSlots(db, plan, series, now, count, exceptProject) {
65
+ // The coming long-video times a project may take: far enough ahead to upload in time, of a
66
+ // line that takes it, in an hour no other release has.
67
+ export function freeSlots(db, plan, who, now, count, exceptProject) {
64
68
  const taken = takenHours(db, exceptProject);
65
69
  const from = new Date(now.getTime() + readLeadHours(db) * hourMs);
66
70
  return lineSlots(plan, from, new Date(from.getTime() + 8 * 7 * 24 * hourMs))
67
71
  .filter((slot) => {
68
72
  const line = plan.rows.find((one) => one.name === slot.row);
69
73
  return (line !== undefined &&
70
- fits(line, series) &&
74
+ fits(line, who) &&
71
75
  !taken.has(Math.floor(Date.parse(slot.longAt) / hourMs)));
72
76
  })
73
77
  .slice(0, count)
@@ -80,7 +84,13 @@ export function planReleases(db, plan, project, now) {
80
84
  let releases = releasesOf(db, project.id);
81
85
  let long = releases.find((release) => release.short === 0);
82
86
  if (long === undefined) {
83
- const slot = freeSlots(db, plan, project.series, now, 1, project.id)[0];
87
+ // The line of the day its schedule ran comes first, then any line that takes it.
88
+ const slots = freeSlots(db, plan, project, now, 30, project.id);
89
+ const own = (row) => {
90
+ const line = plan.rows.find((one) => one.name === row);
91
+ return line?.schedule !== undefined && line.runDay === project.runDay;
92
+ };
93
+ const slot = slots.find((one) => own(one.row)) ?? slots[0];
84
94
  if (slot === undefined)
85
95
  return;
86
96
  long = { short: 0, at: slot.longAt, line: slot.row, by: "plan" };
@@ -98,7 +108,7 @@ export function planReleases(db, plan, project, now) {
98
108
  taken.add(Math.floor(Date.parse(release.at) / hourMs));
99
109
  const longAt = new Date(long.at);
100
110
  const times = line.shorts
101
- .flatMap((slot) => zonedTimes(plan.timeZone, slot, longAt, new Date(longAt.getTime() + 5 * 7 * 24 * hourMs)))
111
+ .flatMap((slot) => zonedTimes(zoneOf(plan, line), slot, longAt, new Date(longAt.getTime() + 5 * 7 * 24 * hourMs)))
102
112
  .toSorted((left, right) => left.getTime() - right.getTime());
103
113
  let after = longAt.getTime();
104
114
  for (let short = 1; short <= project.shorts; short += 1) {