@gentbajko/slopify 3.7.2 → 3.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/edge/http/studio.js +18 -4
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0051-schedule-releases.sql +4 -0
- package/dist/patch-notes/3.8.0.md +9 -0
- package/dist/patch-notes/3.8.1.md +7 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/schedules/repo.js +21 -4
- package/dist/slices/schedules/schema.js +21 -0
- package/dist/slices/schedules/service.js +1 -0
- package/dist/slices/studio/calendar.js +5 -1
- package/dist/slices/studio/plan-model.js +8 -0
- package/dist/slices/studio/plan.js +47 -7
- package/dist/slices/studio/releases.js +19 -8
- package/dist/web/assets/{index-DWA0T3Ai.css → index-BLtujGrO.css} +1 -1
- package/dist/web/assets/{index-BB3oaUR6.js → index-DzehNHWU.js} +120 -120
- package/dist/web/assets/{pdf-DduTO1_g.js → pdf-BPNXDDg1.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
package/dist/edge/http/studio.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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);
|
|
Binary file
|
|
Binary file
|
|
@@ -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.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Slopify 3.8.1
|
|
2
|
+
|
|
3
|
+
Released 4 October 2026.
|
|
4
|
+
|
|
5
|
+
- **A schedule's release times also take its series' other projects.** A project queued by hand from the schedule's topics (or any project whose title ends with the same series as the schedule's template, the part after "|") takes the schedule's release times too, not only the projects the schedule ran itself.
|
|
6
|
+
- **Deleted on YouTube.** Each upload in the project's On YouTube list has a Deleted on YouTube button: press it after deleting the video in Studio, and Slopify forgets it, so the extension offers the upload again.
|
|
7
|
+
- **No upload deadline to watch.** The release calendar marks each time "goes public", and a video that is ready says "Ready to upload": upload and schedule it whenever you like. Only a video still not uploaded in the last day before it goes public is flagged, "Late · upload now", since YouTube's checks may not finish in time.
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "3.8.1",
|
|
4
|
+
"title": "Slopify 3.8.1",
|
|
5
|
+
"version": "3.8.1",
|
|
6
|
+
"date": "2026-10-04"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "3.8.0",
|
|
10
|
+
"title": "Slopify 3.8.0",
|
|
11
|
+
"version": "3.8.0",
|
|
12
|
+
"date": "2026-10-04"
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"id": "3.7.2",
|
|
4
16
|
"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
|
-
|
|
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,
|
|
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
|
-
|
|
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,14 @@ 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 that schedule's
|
|
16
|
+
// projects and any other of its template's series (`series`), preferring the ones its run day
|
|
17
|
+
// (`runDay`) made, in the schedule's time zone; the calendar shows the schedule's name
|
|
18
|
+
// (`label`).
|
|
19
|
+
schedule: z.string().optional(),
|
|
20
|
+
runDay: z.number().int().min(0).max(6).optional(),
|
|
21
|
+
timeZone: z.string().optional(),
|
|
22
|
+
label: z.string().optional(),
|
|
15
23
|
});
|
|
16
24
|
export const postingPlanSchema = z.object({
|
|
17
25
|
timeZone: z.string().min(1).max(100),
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { templateById } from "../project-templates/repo.js";
|
|
2
|
+
import { scheduleRows } from "../schedules/repo.js";
|
|
2
3
|
import { readSetting, writeSetting } from "../settings/repo.js";
|
|
3
|
-
// The posting plan
|
|
4
|
-
//
|
|
4
|
+
// The posting plan: a week of long-video slots, each with its shorts. Each schedule sets its
|
|
5
|
+
// own (its release times, one line per day it runs, in its time zone); lines saved in Settings
|
|
6
|
+
// before that are kept in the plan's time zone. A finished project takes the next free slot when its
|
|
5
7
|
// upload is prepared; each of its shorts goes out at the first time its day and hour come round
|
|
6
8
|
// after its own long video, so a plan never needs "next week" notes. The extension types the
|
|
7
9
|
// times into Studio's schedule, in the browser's own time zone.
|
|
8
10
|
export { postingPlanSchema, weekdays, } from "./plan-model.js";
|
|
9
|
-
import { postingPlanSchema } from "./plan-model.js";
|
|
11
|
+
import { postingPlanSchema, seriesOf } from "./plan-model.js";
|
|
10
12
|
export const postingPlanKey = "studio.postingPlan";
|
|
11
13
|
const key = postingPlanKey;
|
|
12
14
|
// No plan until one is made: nothing is scheduled, and Prepare upload says so.
|
|
@@ -16,21 +18,55 @@ export function emptyPlan(timeZone) {
|
|
|
16
18
|
export function localTimeZone() {
|
|
17
19
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
18
20
|
}
|
|
19
|
-
|
|
21
|
+
// The plan as stored in Settings: the lines made before schedules had release times.
|
|
22
|
+
export function readStoredPlan(db) {
|
|
20
23
|
const stored = readSetting(db, key);
|
|
21
24
|
if (stored !== undefined)
|
|
22
25
|
try {
|
|
23
26
|
const parsed = postingPlanSchema.safeParse(JSON.parse(stored));
|
|
24
27
|
if (parsed.success)
|
|
25
|
-
return
|
|
28
|
+
return {
|
|
29
|
+
...parsed.data,
|
|
30
|
+
rows: parsed.data.rows.filter((row) => row.schedule === undefined),
|
|
31
|
+
};
|
|
26
32
|
}
|
|
27
33
|
catch {
|
|
28
34
|
// A broken setting reads as no plan.
|
|
29
35
|
}
|
|
30
36
|
return emptyPlan(localTimeZone());
|
|
31
37
|
}
|
|
38
|
+
// The series of a schedule's template, from its project title ("{{Topic}} | Stories" →
|
|
39
|
+
// "Stories"); "" when the template is gone or its title names none.
|
|
40
|
+
function templateSeries(db, templateId) {
|
|
41
|
+
try {
|
|
42
|
+
const title = templateById(db, templateId)?.document.form.title;
|
|
43
|
+
return title === undefined ? "" : seriesOf({ title });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// The lines every release is planned from: each schedule's release times (one line for each
|
|
50
|
+
// day it runs), then the stored lines.
|
|
51
|
+
export function readPlan(db) {
|
|
52
|
+
const stored = readStoredPlan(db);
|
|
53
|
+
const fromSchedules = scheduleRows(db)
|
|
54
|
+
.filter((schedule) => schedule.deletedAt === null && schedule.status !== "canceled")
|
|
55
|
+
.flatMap((schedule) => schedule.releases.map((release) => ({
|
|
56
|
+
name: `s${schedule.id.slice(0, 8)}${String(release.day)}`,
|
|
57
|
+
series: templateSeries(db, schedule.templateId),
|
|
58
|
+
long: release.long,
|
|
59
|
+
shorts: [...release.shorts],
|
|
60
|
+
schedule: schedule.id,
|
|
61
|
+
runDay: release.day,
|
|
62
|
+
timeZone: schedule.timezone,
|
|
63
|
+
label: schedule.name,
|
|
64
|
+
})));
|
|
65
|
+
return { ...stored, rows: [...fromSchedules, ...stored.rows] };
|
|
66
|
+
}
|
|
32
67
|
export function writePlan(db, plan) {
|
|
33
|
-
|
|
68
|
+
const parsed = postingPlanSchema.parse(plan);
|
|
69
|
+
writeSetting(db, key, JSON.stringify({ ...parsed, rows: parsed.rows.filter((row) => row.schedule === undefined) }));
|
|
34
70
|
}
|
|
35
71
|
function partsIn(timeZone, at) {
|
|
36
72
|
const format = new Intl.DateTimeFormat("en-US", {
|
|
@@ -79,6 +115,10 @@ function addDays(date, days) {
|
|
|
79
115
|
weekday: next.getUTCDay(),
|
|
80
116
|
};
|
|
81
117
|
}
|
|
118
|
+
// The weekday (0 is Sunday) an instant falls on in the zone.
|
|
119
|
+
export function weekdayIn(timeZone, at) {
|
|
120
|
+
return localDate(timeZone, at).weekday;
|
|
121
|
+
}
|
|
82
122
|
// The first time the slot's day and hour come round after `after`.
|
|
83
123
|
export function nextOccurrence(timeZone, slot, after) {
|
|
84
124
|
let date = localDate(timeZone, after);
|
|
@@ -47,27 +47,32 @@ 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
|
-
|
|
50
|
+
// A schedule's line takes that schedule's projects and any other of its template's series (a
|
|
51
|
+
// project queued by hand from its topics); a stored line takes its series, or any.
|
|
52
|
+
const fits = (line, who) => line.schedule !== undefined
|
|
53
|
+
? line.schedule === who.schedule || (line.series !== "" && line.series === who.series)
|
|
54
|
+
: line.series === "" || line.series === who.series;
|
|
55
|
+
const zoneOf = (plan, line) => line.timeZone ?? plan.timeZone;
|
|
51
56
|
// The plan's long-video times from `from` to `until`, in order, each with its line.
|
|
52
57
|
export function lineSlots(plan, from, until) {
|
|
53
58
|
return plan.rows
|
|
54
|
-
.flatMap((line) => zonedTimes(plan
|
|
59
|
+
.flatMap((line) => zonedTimes(zoneOf(plan, line), line.long, from, until).map((at) => ({
|
|
55
60
|
row: line.name,
|
|
56
61
|
series: line.series,
|
|
57
62
|
longAt: at.toISOString(),
|
|
58
63
|
})))
|
|
59
64
|
.toSorted((left, right) => Date.parse(left.longAt) - Date.parse(right.longAt));
|
|
60
65
|
}
|
|
61
|
-
// The coming long-video times a project
|
|
62
|
-
//
|
|
63
|
-
export function freeSlots(db, plan,
|
|
66
|
+
// The coming long-video times a project may take: far enough ahead to upload in time, of a
|
|
67
|
+
// line that takes it, in an hour no other release has.
|
|
68
|
+
export function freeSlots(db, plan, who, now, count, exceptProject) {
|
|
64
69
|
const taken = takenHours(db, exceptProject);
|
|
65
70
|
const from = new Date(now.getTime() + readLeadHours(db) * hourMs);
|
|
66
71
|
return lineSlots(plan, from, new Date(from.getTime() + 8 * 7 * 24 * hourMs))
|
|
67
72
|
.filter((slot) => {
|
|
68
73
|
const line = plan.rows.find((one) => one.name === slot.row);
|
|
69
74
|
return (line !== undefined &&
|
|
70
|
-
fits(line,
|
|
75
|
+
fits(line, who) &&
|
|
71
76
|
!taken.has(Math.floor(Date.parse(slot.longAt) / hourMs)));
|
|
72
77
|
})
|
|
73
78
|
.slice(0, count)
|
|
@@ -80,7 +85,13 @@ export function planReleases(db, plan, project, now) {
|
|
|
80
85
|
let releases = releasesOf(db, project.id);
|
|
81
86
|
let long = releases.find((release) => release.short === 0);
|
|
82
87
|
if (long === undefined) {
|
|
83
|
-
|
|
88
|
+
// The line of the day its schedule ran comes first, then any line that takes it.
|
|
89
|
+
const slots = freeSlots(db, plan, project, now, 30, project.id);
|
|
90
|
+
const own = (row) => {
|
|
91
|
+
const line = plan.rows.find((one) => one.name === row);
|
|
92
|
+
return line?.schedule !== undefined && line.runDay === project.runDay;
|
|
93
|
+
};
|
|
94
|
+
const slot = slots.find((one) => own(one.row)) ?? slots[0];
|
|
84
95
|
if (slot === undefined)
|
|
85
96
|
return;
|
|
86
97
|
long = { short: 0, at: slot.longAt, line: slot.row, by: "plan" };
|
|
@@ -98,7 +109,7 @@ export function planReleases(db, plan, project, now) {
|
|
|
98
109
|
taken.add(Math.floor(Date.parse(release.at) / hourMs));
|
|
99
110
|
const longAt = new Date(long.at);
|
|
100
111
|
const times = line.shorts
|
|
101
|
-
.flatMap((slot) => zonedTimes(plan
|
|
112
|
+
.flatMap((slot) => zonedTimes(zoneOf(plan, line), slot, longAt, new Date(longAt.getTime() + 5 * 7 * 24 * hourMs)))
|
|
102
113
|
.toSorted((left, right) => left.getTime() - right.getTime());
|
|
103
114
|
let after = longAt.getTime();
|
|
104
115
|
for (let short = 1; short <= project.shorts; short += 1) {
|