@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.
@@ -5,45 +5,13 @@ import { readSetting, writeSetting } from "../settings/repo.js";
5
5
  // upload is prepared; each of its shorts goes out at the first time its day and hour come round
6
6
  // after its own long video, so a plan never needs "next week" notes. The extension types the
7
7
  // times into Studio's schedule, in the browser's own time zone.
8
- export const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
9
- const slotSchema = z.object({
10
- day: z.number().int().min(0).max(6),
11
- time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
12
- });
13
- const rowSchema = z.object({
14
- name: z.string().trim().min(1).max(20),
15
- long: slotSchema,
16
- shorts: z.array(slotSchema).max(10),
17
- });
18
- export const postingPlanSchema = z.object({
19
- timeZone: z.string().min(1).max(100),
20
- rows: z.array(rowSchema).max(14),
21
- });
8
+ export { postingPlanSchema, weekdays, } from "./plan-model.js";
9
+ import { postingPlanSchema } from "./plan-model.js";
22
10
  export const postingPlanKey = "studio.postingPlan";
23
11
  const key = postingPlanKey;
24
- // Three long videos a week, five shorts each, the channel's own times.
25
- export function defaultPlan(timeZone) {
26
- const at = (day, time) => ({ day, time });
27
- return {
28
- timeZone,
29
- rows: [
30
- {
31
- name: "A",
32
- long: at(0, "20:00"),
33
- shorts: [at(1, "00:00"), at(1, "17:00"), at(2, "00:00"), at(2, "17:00"), at(3, "17:00")],
34
- },
35
- {
36
- name: "B",
37
- long: at(2, "20:00"),
38
- shorts: [at(3, "00:00"), at(4, "00:00"), at(4, "17:00"), at(5, "17:00"), at(6, "17:00")],
39
- },
40
- {
41
- name: "C",
42
- long: at(4, "20:00"),
43
- shorts: [at(5, "00:00"), at(6, "00:00"), at(6, "21:00"), at(0, "00:00"), at(0, "17:00")],
44
- },
45
- ],
46
- };
12
+ // No plan until one is made: nothing is scheduled, and Prepare upload says so.
13
+ export function emptyPlan(timeZone) {
14
+ return { timeZone, rows: [] };
47
15
  }
48
16
  export function localTimeZone() {
49
17
  return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
@@ -57,9 +25,9 @@ export function readPlan(db) {
57
25
  return parsed.data;
58
26
  }
59
27
  catch {
60
- // A broken setting reads as the default plan.
28
+ // A broken setting reads as no plan.
61
29
  }
62
- return defaultPlan(localTimeZone());
30
+ return emptyPlan(localTimeZone());
63
31
  }
64
32
  export function writePlan(db, plan) {
65
33
  writeSetting(db, key, JSON.stringify(postingPlanSchema.parse(plan)));
@@ -123,68 +91,17 @@ export function nextOccurrence(timeZone, slot, after) {
123
91
  }
124
92
  return new Date(after.getTime() + 7 * 24 * 60 * 60 * 1000);
125
93
  }
126
- // The coming long-video slots in time order, from `now` (a slot starting within the hour is
127
- // too close to prepare), over `weeks` weeks.
128
- export function comingSlots(plan, now, weeks = 8) {
129
- const soonest = now.getTime() + 60 * 60 * 1000;
130
- const start = localDate(plan.timeZone, now);
131
- const slots = [];
132
- for (let day = 0; day < weeks * 7; day += 1) {
133
- const date = addDays(start, day);
134
- for (const row of plan.rows) {
135
- if (row.long.day !== date.weekday)
136
- continue;
137
- const at = zonedInstant(plan.timeZone, date, row.long.time);
138
- if (at.getTime() >= soonest)
139
- slots.push({ row: row.name, at });
140
- }
94
+ // Every time the slot's day and hour come round from `from` (inclusive) to `until`.
95
+ export function zonedTimes(timeZone, slot, from, until) {
96
+ const times = [];
97
+ let date = localDate(timeZone, new Date(from.getTime() - 24 * 60 * 60 * 1000));
98
+ for (;; date = addDays(date, 1)) {
99
+ if (date.weekday !== slot.day)
100
+ continue;
101
+ const at = zonedInstant(timeZone, date, slot.time);
102
+ if (at.getTime() >= until.getTime())
103
+ return times;
104
+ if (at.getTime() >= from.getTime())
105
+ times.push(at);
141
106
  }
142
- return slots
143
- .toSorted((left, right) => left.at.getTime() - right.at.getTime())
144
- .map((slot) => ({ row: slot.row, longAt: slot.at.toISOString() }));
145
- }
146
- export function scheduleOf(plan, slot) {
147
- const row = plan.rows.find((one) => one.name === slot.row);
148
- const longAt = new Date(slot.longAt);
149
- return {
150
- ...slot,
151
- shortsAt: (row?.shorts ?? []).map((short) => nextOccurrence(plan.timeZone, short, longAt).toISOString()),
152
- };
153
- }
154
- // The project's slot: the one it was given, else the next free one, kept from now on. A project
155
- // set to "Not scheduled" keeps an empty row, so it isn't given one again.
156
- export function assignedSlot(db, plan, projectId, now) {
157
- const row = db
158
- .prepare("SELECT row_name, long_at FROM upload_slots WHERE project_id=?")
159
- .get(projectId);
160
- if (row !== undefined)
161
- return String(row.long_at) === ""
162
- ? undefined
163
- : { row: String(row.row_name), longAt: String(row.long_at) };
164
- const free = freeSlots(db, plan, now, 1)[0];
165
- if (free === undefined)
166
- return undefined;
167
- setSlot(db, projectId, free, now);
168
- return free;
169
- }
170
- export function freeSlots(db, plan, now, count) {
171
- const taken = new Set(db
172
- .prepare("SELECT long_at FROM upload_slots")
173
- .all()
174
- .map((row) => String(row.long_at)));
175
- return comingSlots(plan, now)
176
- .filter((slot) => !taken.has(slot.longAt))
177
- .slice(0, count);
178
- }
179
- export function setSlot(db, projectId, slot, now) {
180
- db.prepare(`INSERT INTO upload_slots(project_id,row_name,long_at,assigned_at) VALUES (?,?,?,?)
181
- ON CONFLICT(project_id) DO UPDATE SET row_name=excluded.row_name, long_at=excluded.long_at,
182
- assigned_at=excluded.assigned_at`).run(projectId, slot.row, slot.longAt, now.toISOString());
183
- }
184
- // "Not scheduled": the project takes no slot until one is chosen for it.
185
- export function unschedule(db, projectId, now) {
186
- setSlot(db, projectId, { row: "", longAt: "" }, now);
187
- }
188
- export function clearSlot(db, projectId) {
189
- db.prepare("DELETE FROM upload_slots WHERE project_id=?").run(projectId);
190
107
  }
@@ -0,0 +1,140 @@
1
+ import { readSetting, writeSetting } from "../settings/repo.js";
2
+ import { zonedTimes } from "./plan.js";
3
+ import { leadHoursDefault, leadHoursMax } from "./plan-model.js";
4
+ const hourMs = 60 * 60 * 1000;
5
+ export const leadHoursKey = "studio.leadHours";
6
+ const leadKey = leadHoursKey;
7
+ export function readLeadHours(db) {
8
+ const stored = Number(readSetting(db, leadKey));
9
+ return Number.isInteger(stored) && stored >= 1 && stored <= leadHoursMax
10
+ ? stored
11
+ : leadHoursDefault;
12
+ }
13
+ export function writeLeadHours(db, hours) {
14
+ writeSetting(db, leadKey, String(hours));
15
+ }
16
+ export function releasesOf(db, projectId) {
17
+ return db
18
+ .prepare("SELECT short, release_at, line, by FROM releases WHERE project_id=? ORDER BY short")
19
+ .all(projectId)
20
+ .map((row) => ({
21
+ short: Number(row.short),
22
+ at: String(row.release_at),
23
+ line: row.line === null ? null : String(row.line),
24
+ by: row.by === "person" ? "person" : "plan",
25
+ }));
26
+ }
27
+ export function allReleases(db) {
28
+ return db
29
+ .prepare("SELECT project_id, short, release_at, line, by FROM releases WHERE release_at != '' ORDER BY release_at")
30
+ .all()
31
+ .map((row) => ({
32
+ projectId: String(row.project_id),
33
+ short: Number(row.short),
34
+ at: String(row.release_at),
35
+ line: row.line === null ? null : String(row.line),
36
+ by: row.by === "person" ? "person" : "plan",
37
+ }));
38
+ }
39
+ function write(db, projectId, release, now) {
40
+ db.prepare(`INSERT INTO releases(project_id,short,release_at,line,by,set_at) VALUES (?,?,?,?,?,?)
41
+ ON CONFLICT(project_id,short) DO UPDATE SET release_at=excluded.release_at,
42
+ line=excluded.line, by=excluded.by, set_at=excluded.set_at`).run(projectId, release.short, release.at, release.line, release.by, now.toISOString());
43
+ }
44
+ // The hours other releases already take: two never go out in the same hour.
45
+ function takenHours(db, exceptProject) {
46
+ return new Set(allReleases(db)
47
+ .filter((release) => release.projectId !== exceptProject)
48
+ .map((release) => Math.floor(Date.parse(release.at) / hourMs)));
49
+ }
50
+ const fits = (line, series) => line.series === "" || line.series === series;
51
+ // The plan's long-video times from `from` to `until`, in order, each with its line.
52
+ export function lineSlots(plan, from, until) {
53
+ return plan.rows
54
+ .flatMap((line) => zonedTimes(plan.timeZone, line.long, from, until).map((at) => ({
55
+ row: line.name,
56
+ series: line.series,
57
+ longAt: at.toISOString(),
58
+ })))
59
+ .toSorted((left, right) => Date.parse(left.longAt) - Date.parse(right.longAt));
60
+ }
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) {
64
+ const taken = takenHours(db, exceptProject);
65
+ const from = new Date(now.getTime() + readLeadHours(db) * hourMs);
66
+ return lineSlots(plan, from, new Date(from.getTime() + 8 * 7 * 24 * hourMs))
67
+ .filter((slot) => {
68
+ const line = plan.rows.find((one) => one.name === slot.row);
69
+ return (line !== undefined &&
70
+ fits(line, series) &&
71
+ !taken.has(Math.floor(Date.parse(slot.longAt) / hourMs)));
72
+ })
73
+ .slice(0, count)
74
+ .map(({ row, longAt }) => ({ row, longAt }));
75
+ }
76
+ // Gives a finished project its release times where it has none: the long video the next free
77
+ // slot of its series, each short the line's first short time after the one before it in an
78
+ // hour no other release has. A project set to "not scheduled" is left so.
79
+ export function planReleases(db, plan, project, now) {
80
+ let releases = releasesOf(db, project.id);
81
+ let long = releases.find((release) => release.short === 0);
82
+ if (long === undefined) {
83
+ const slot = freeSlots(db, plan, project.series, now, 1, project.id)[0];
84
+ if (slot === undefined)
85
+ return;
86
+ long = { short: 0, at: slot.longAt, line: slot.row, by: "plan" };
87
+ write(db, project.id, long, now);
88
+ releases = releasesOf(db, project.id);
89
+ }
90
+ if (long.at === "")
91
+ return;
92
+ const line = plan.rows.find((one) => one.name === long?.line);
93
+ if (line === undefined || line.shorts.length === 0)
94
+ return;
95
+ const taken = takenHours(db, project.id);
96
+ for (const release of releases)
97
+ if (release.at !== "")
98
+ taken.add(Math.floor(Date.parse(release.at) / hourMs));
99
+ const longAt = new Date(long.at);
100
+ const times = line.shorts
101
+ .flatMap((slot) => zonedTimes(plan.timeZone, slot, longAt, new Date(longAt.getTime() + 5 * 7 * 24 * hourMs)))
102
+ .toSorted((left, right) => left.getTime() - right.getTime());
103
+ let after = longAt.getTime();
104
+ for (let short = 1; short <= project.shorts; short += 1) {
105
+ const kept = releases.find((release) => release.short === short);
106
+ if (kept !== undefined) {
107
+ if (kept.at !== "")
108
+ after = Math.max(after, Date.parse(kept.at));
109
+ continue;
110
+ }
111
+ const at = times.find((time) => time.getTime() > after && !taken.has(Math.floor(time.getTime() / hourMs)));
112
+ if (at === undefined)
113
+ return;
114
+ taken.add(Math.floor(at.getTime() / hourMs));
115
+ after = at.getTime();
116
+ write(db, project.id, { short, at: at.toISOString(), line: line.name, by: "plan" }, now);
117
+ }
118
+ }
119
+ // The person sets one release: an ISO time, or "" for "not scheduled". Moving the long video
120
+ // drops the shorts' plan-placed times, so they are planned again after it.
121
+ export function setRelease(db, projectId, short, at, line, now) {
122
+ write(db, projectId, { short, at, line, by: "person" }, now);
123
+ if (short === 0)
124
+ db.prepare("DELETE FROM releases WHERE project_id=? AND short>0 AND by='plan'").run(projectId);
125
+ }
126
+ export function scheduleOf(db, projectId) {
127
+ const releases = releasesOf(db, projectId);
128
+ const long = releases.find((release) => release.short === 0);
129
+ if (long === undefined || long.at === "")
130
+ return undefined;
131
+ const last = Math.max(0, ...releases.map((release) => release.short));
132
+ return {
133
+ row: long.line ?? "",
134
+ longAt: long.at,
135
+ shortsAt: Array.from({ length: last }, (_, index) => {
136
+ const at = releases.find((release) => release.short === index + 1)?.at;
137
+ return at === undefined || at === "" ? null : at;
138
+ }),
139
+ };
140
+ }
@@ -26,6 +26,7 @@ function rowOf(row) {
26
26
  commentMessage: typeof row.comment_message === "string" ? row.comment_message : null,
27
27
  abMessage: typeof row.ab_message === "string" ? row.ab_message : null,
28
28
  abAt: typeof row.ab_at === "string" ? row.ab_at : null,
29
+ checks: typeof row.checks === "string" ? row.checks : null,
29
30
  };
30
31
  }
31
32
  // Records (or replaces) the video an upload became: "filled" from the upload dialog, "done"
@@ -92,3 +93,7 @@ export function previousLongVideo(db, projectId) {
92
93
  .get(projectId, own?.recorded_at ?? null, own?.recorded_at ?? null);
93
94
  return typeof row?.video_id === "string" ? row.video_id : undefined;
94
95
  }
96
+ // Studio's word on a known video's checks, read from its Content list row.
97
+ export function setChecks(db, videoId, checks) {
98
+ db.prepare("UPDATE youtube_videos SET checks=? WHERE video_id=?").run(checks, videoId);
99
+ }