@gentbajko/slopify 3.2.9 → 3.3.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.
Files changed (33) hide show
  1. package/dist/edge/http/projects.js +8 -0
  2. package/dist/edge/http/studio.js +282 -58
  3. package/dist/extension/slopify-studio-chrome.zip +0 -0
  4. package/dist/extension/slopify-studio-firefox.zip +0 -0
  5. package/dist/kernel/db/migrations/0046-youtube-upload-state.sql +6 -0
  6. package/dist/kernel/db/migrations/0047-studio-autopilot.sql +39 -0
  7. package/dist/patch-notes/3.3.0.md +40 -0
  8. package/dist/patch-notes/3.3.1.md +7 -0
  9. package/dist/patch-notes/index.json +12 -0
  10. package/dist/slices/article/segments.js +2 -1
  11. package/dist/slices/rebuild/recipe-document.js +2 -2
  12. package/dist/slices/rebuild/recipe-exports.js +2 -1
  13. package/dist/slices/rebuild/recipe-visual.js +2 -2
  14. package/dist/slices/rebuild/recipe-youtube.js +2 -1
  15. package/dist/slices/revisions/subject.js +2 -3
  16. package/dist/slices/storage/backup-format.js +4 -0
  17. package/dist/slices/storage/portable.js +12 -1
  18. package/dist/slices/studio/backfill.js +34 -0
  19. package/dist/slices/studio/model.js +2 -0
  20. package/dist/slices/studio/pack.js +41 -2
  21. package/dist/slices/studio/pick.js +5 -4
  22. package/dist/slices/studio/plan.js +190 -0
  23. package/dist/slices/studio/queue.js +7 -0
  24. package/dist/slices/studio/settings.js +2 -0
  25. package/dist/slices/studio/stats.js +71 -0
  26. package/dist/slices/studio/videos.js +45 -11
  27. package/dist/slices/youtube/titles.js +2 -2
  28. package/dist/web/assets/index-6wMtRXOo.css +1 -0
  29. package/dist/web/assets/{index-hUEQRe6s.js → index-DmLX0oKs.js} +131 -130
  30. package/dist/web/assets/{pdf-C1Iq6van.js → pdf-BI8cHr9G.js} +1 -1
  31. package/dist/web/index.html +2 -2
  32. package/package.json +1 -1
  33. package/dist/web/assets/index-DaTBqaK8.css +0 -1
@@ -123,7 +123,7 @@ appearance) {
123
123
  // Only for a short, so every long video keeps its fingerprint: the word-by-word
124
124
  // captions are drawn from the word timing in the caption font, under the title.
125
125
  ...(short
126
- ? [["short-v1", config.subtitles?.fontId ?? "default", config.title, timing]]
126
+ ? [["short-v1", config.subtitles?.fontId ?? "default", subjectOf(config), timing]]
127
127
  : []),
128
128
  // Only while the video has an ambient bed, so every video without one keeps the
129
129
  // fingerprint it always had.
@@ -213,7 +213,7 @@ appearance) {
213
213
  kind: "provided",
214
214
  version: 1,
215
215
  assetId: content.provided.thumbnail ?? null,
216
- semantic: [config.title, config.format],
216
+ semantic: [subjectOf(config), config.format],
217
217
  }, [], { unresolved: content.provided.thumbnail === undefined }),
218
218
  ];
219
219
  const promptRecipe = textRecipes.find((value) => value.key === "thumbnail:prompt");
@@ -1,3 +1,4 @@
1
+ import { subjectOf } from "../admission/model.js";
1
2
  import { usesYoutubeDescription } from "../admission/rules.js";
2
3
  import { recipe, resourceIdentity, } from "./recipe-model.js";
3
4
  import { renderedPrompt } from "./recipe-text.js";
@@ -24,7 +25,7 @@ export function youtubeRecipes(context, exports) {
24
25
  // Blank means the built-in prompt; the step fills it in, so a new built-in wording
25
26
  // ships as a new operation version rather than a silent change here.
26
27
  config.descriptionPrompt?.trim() ? renderedPrompt(context, "description") : "",
27
- config.title,
28
+ subjectOf(config),
28
29
  // The project language, only when it is not English, so English keeps its fingerprint.
29
30
  ...(config.language === undefined || config.language === "en" ? [] : [config.language]),
30
31
  ],
@@ -1,7 +1,6 @@
1
1
  // A renamed project keeps what it is about (`subjectOf`): on the first rename the title it was
2
- // made with is kept as `subjectTitle`, so the scenes, looks, cast and shorts made from it keep
3
- // their fingerprints and nothing is drawn again. What shows the title (YouTube's text, the
4
- // PDF, the files' tags) follows the new name. An edit can't drop a kept subject either.
2
+ // made with is kept as `subjectTitle`, and every step's fingerprint reads it, so a rename runs
3
+ // nothing. An edit can't drop a kept subject either.
5
4
  export function keptSubject(base, edit) {
6
5
  const subject = base.subjectTitle ?? (edit.config.title !== base.title ? base.title : undefined);
7
6
  if (subject === undefined || edit.config.subjectTitle === subject)
@@ -57,6 +57,10 @@ 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",
62
+ "video_stats",
63
+ "ab_results",
60
64
  // Since 2.5.0. Left out on purpose: plan_limit_waits and plan_limit_waiters (an account's
61
65
  // current wait, a lease of this install) and prompt_softening (a pending one-off request).
62
66
  // Since 3.0.8, narration_retries is left out too: the tries of a run, not of the project.
@@ -23,7 +23,9 @@ import { appearances, providerById, providerIds } from "../settings/model.js";
23
23
  import { listVoices } from "../settings/repo.js";
24
24
  import { realPersonVoicesKey, voiceIdMax, voiceNameMax } from "../settings/voices.js";
25
25
  import { whatsNewSeenKey } from "../settings/whats-new.js";
26
- import { storedPlaylistsSchema, storedProjectPlaylistsSchema, studioChannelPlaylistPrefix, studioFillQueuePrefix, studioPairingKey, studioPlaylistKey, studioProjectPlaylistsKey, studioRealFootageKey, } from "../studio/settings.js";
26
+ import { uploadPickPrefix, uploadPickSchema } from "../studio/pick.js";
27
+ import { postingPlanKey, postingPlanSchema } from "../studio/plan.js";
28
+ import { autoCommentKey, storedPlaylistsSchema, storedProjectPlaylistsSchema, studioChannelPlaylistPrefix, studioFillQueuePrefix, studioPairingKey, studioPlaylistKey, studioProjectPlaylistsKey, studioRealFootageKey, } from "../studio/settings.js";
27
29
  import { channelLinksKey } from "../youtube/edits-repo.js";
28
30
  import { defaultBackupsDir, projectDir, stagingPath } from "./layout.js";
29
31
  import { stageKinds } from "./model.js";
@@ -379,6 +381,11 @@ function portableSettings(settings) {
379
381
  key === whatsNewSeenKey ||
380
382
  key === patchNotesSeenKey)
381
383
  continue;
384
+ if (key === autoCommentKey) {
385
+ z.enum(["on", "off"]).parse(value);
386
+ portable[key] = value;
387
+ continue;
388
+ }
382
389
  const parsed = storedJson(value);
383
390
  if (key === "silenceGapSeconds")
384
391
  storedSilenceGap.parse(parsed);
@@ -388,6 +395,10 @@ function portableSettings(settings) {
388
395
  storedLoudness.parse(parsed);
389
396
  else if (key === studioPlaylistKey || key.startsWith(studioChannelPlaylistPrefix))
390
397
  storedPlaylistsSchema.parse(parsed);
398
+ else if (key === postingPlanKey)
399
+ postingPlanSchema.parse(parsed);
400
+ else if (key.startsWith(uploadPickPrefix))
401
+ uploadPickSchema.parse(parsed);
391
402
  else if (key === studioProjectPlaylistsKey)
392
403
  storedProjectPlaylistsSchema.parse(parsed);
393
404
  else if (key === channelLinksKey)
@@ -0,0 +1,34 @@
1
+ import { listProjects } from "../admission/repo.js";
2
+ import { uploadPack } from "./pack.js";
3
+ import { clearSlot } from "./plan.js";
4
+ import { projectVideos, recordVideo } from "./videos.js";
5
+ const norm = (title) => title.trim().replace(/\s+/g, " ").toLowerCase();
6
+ export function backfillVideos(deps, rows, at) {
7
+ const byTitle = new Map();
8
+ for (const row of rows)
9
+ byTitle.set(norm(row.title), row.videoId);
10
+ if (byTitle.size === 0)
11
+ return 0;
12
+ let found = 0;
13
+ for (const project of listProjects(deps.db)) {
14
+ const known = projectVideos(deps.db, project.id).filter((one) => one.uploadState === "done");
15
+ const result = uploadPack(deps, project.id);
16
+ if (!result.ok)
17
+ continue;
18
+ for (const item of result.pack.items) {
19
+ const short = item.short ?? null;
20
+ if (known.some((one) => one.short === short))
21
+ continue;
22
+ const titles = item.pickable?.titles ?? [item.title, ...item.titles];
23
+ const videoId = titles.map((one) => byTitle.get(norm(one))).find((one) => one !== undefined);
24
+ if (videoId === undefined)
25
+ continue;
26
+ 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
+ found += 1;
31
+ }
32
+ }
33
+ return found;
34
+ }
@@ -21,6 +21,8 @@ export const studioSteps = [
21
21
  "audience",
22
22
  "altered",
23
23
  "tags",
24
+ // Studio's Visibility step: when it goes out, from the posting plan.
25
+ "schedule",
24
26
  ];
25
27
  // Tags as Studio's field takes them: comma-separated.
26
28
  export function tagsLine(tags) {
@@ -14,11 +14,14 @@ import { outputPath } from "../storage/layout.js";
14
14
  import { outputsOf } from "../storage/repo.js";
15
15
  import { videoEditOf } from "../video/edit-settings.js";
16
16
  import { usesVoices } from "../voices/model.js";
17
- import { effectiveDescription } from "../youtube/edits-repo.js";
17
+ import { effectiveDescription, readDescriptionEdits } from "../youtube/edits-repo.js";
18
+ import { linkKey, previousVideoLink } from "../youtube/placeholders.js";
18
19
  import { aiDisclosureOf } from "./disclosure.js";
19
20
  import { studioAudience, studioTitleMax, } from "./model.js";
20
21
  import { picked, readUploadPick } from "./pick.js";
22
+ import { readPlan, scheduleOf } from "./plan.js";
21
23
  import { projectPlaylists, readRealFootage } from "./settings.js";
24
+ import { previousLongVideo, videoIdOf, videoOf } from "./videos.js";
22
25
  // What `shorts.json` holds for each picked clip; only what the pack uses.
23
26
  const clipsSchema = z.object({
24
27
  shorts: z.array(z.object({
@@ -88,6 +91,7 @@ export function uploadPack(deps, projectId) {
88
91
  description: written,
89
92
  tags: writtenTags ?? "",
90
93
  titles: text(outputs.find((output) => output.role === "youtube_titles")),
94
+ pinnedComment: text(outputs.find((output) => output.role === "youtube_pinned_comment")),
91
95
  // The last chapter is checked against the video's own length when it is known.
92
96
  durationSeconds: video?.durationMs === null || video?.durationMs === undefined
93
97
  ? undefined
@@ -125,6 +129,22 @@ export function uploadPack(deps, projectId) {
125
129
  ];
126
130
  const allThumbnails = thumbnails.map(file);
127
131
  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) });
141
+ // The long video's captions, its end screen's video (the project's Previous video, else the
142
+ // long video uploaded before it) and the comment to pin; a short's related video is the
143
+ // long video, once it is on YouTube.
144
+ const captions = outputs.find((output) => output.role === "subtitles_srt");
145
+ const previous = edited === undefined ? undefined : readPreviousVideo(deps.db, projectId);
146
+ const endScreenVideoId = previous ?? previousLongVideo(deps.db, projectId);
147
+ const pinnedComment = edited?.pinnedComment.trim() ?? "";
128
148
  const arranged = picked(allTitles, allThumbnails, pick);
129
149
  const items = [
130
150
  {
@@ -146,8 +166,17 @@ export function uploadPack(deps, projectId) {
146
166
  playlists,
147
167
  playlist,
148
168
  ...(edited?.chapterNotice === undefined ? {} : { chapterNotice: edited.chapterNotice }),
169
+ ...(schedule === undefined ? {} : { scheduleAt: schedule.longAt }),
170
+ ...(captions === undefined ? {} : { captions: file(captions) }),
171
+ ...(endScreenVideoId === undefined ? {} : { endScreenVideoId }),
172
+ ...(pinnedComment === "" ? {} : { pinnedComment }),
149
173
  },
150
174
  ];
175
+ // The shorts link the long video: the link set in the project, else the one Slopify knows
176
+ // the video by on YouTube (`videos.ts`).
177
+ const longVideo = videoOf(deps.db, projectId, null);
178
+ const fullVideoLink = config.shorts?.fullVideoLink ??
179
+ (longVideo?.uploadState === "done" ? `https://youtu.be/${longVideo.videoId}` : undefined);
151
180
  const clips = clipsOf(text(outputs.find((output) => output.role === "shorts")));
152
181
  const shortVideos = outputs.filter((output) => output.role === "short_video");
153
182
  for (const clip of clips) {
@@ -168,7 +197,7 @@ export function uploadPack(deps, projectId) {
168
197
  titles: [],
169
198
  description: [
170
199
  clip.description,
171
- fullVideoLine(config.shorts?.fullVideoLink),
200
+ fullVideoLine(fullVideoLink),
172
201
  "",
173
202
  clip.hashtags.join(" "),
174
203
  ].join("\n"),
@@ -180,6 +209,10 @@ export function uploadPack(deps, projectId) {
180
209
  alteredContent: disclosure("short"),
181
210
  playlists,
182
211
  playlist,
212
+ ...(schedule?.shortsAt[clip.number - 1] === undefined
213
+ ? {}
214
+ : { scheduleAt: schedule.shortsAt[clip.number - 1] }),
215
+ ...(longVideo?.uploadState === "done" ? { relatedVideoId: longVideo.videoId } : {}),
183
216
  });
184
217
  }
185
218
  return {
@@ -188,6 +221,7 @@ export function uploadPack(deps, projectId) {
188
221
  projectId,
189
222
  projectTitle: project.title,
190
223
  items,
224
+ ...(schedule === undefined ? {} : { schedule }),
191
225
  missing,
192
226
  playlistChoices,
193
227
  ...(uploadedClips > 0 ? { footage: { clips: uploadedClips, real: realFootage } } : {}),
@@ -237,3 +271,8 @@ function available(deps, projectId, output) {
237
271
  throwIfNoEntry: false,
238
272
  })?.isFile() === true);
239
273
  }
274
+ // The project's Previous video link (YouTube section), as a video id.
275
+ function readPreviousVideo(db, projectId) {
276
+ const link = readDescriptionEdits(db, projectId).links.find((one) => linkKey(one.name) === linkKey(previousVideoLink))?.url;
277
+ return link === undefined ? undefined : videoIdOf(link);
278
+ }
@@ -1,16 +1,17 @@
1
1
  import { z } from "zod";
2
2
  import { readSetting, writeSetting } from "../settings/repo.js";
3
- const pickSchema = z.object({
3
+ export const uploadPickSchema = z.object({
4
4
  title: z.number().int().min(0).max(9),
5
5
  thumbnail: z.number().int().min(0).max(9),
6
6
  });
7
- const keyOf = (projectId) => `studio.uploadPick.${projectId}`;
7
+ export const uploadPickPrefix = "studio.uploadPick.";
8
+ const keyOf = (projectId) => `${uploadPickPrefix}${projectId}`;
8
9
  export function readUploadPick(db, projectId) {
9
10
  const stored = readSetting(db, keyOf(projectId));
10
11
  if (stored === undefined)
11
12
  return { title: 0, thumbnail: 0 };
12
13
  try {
13
- const parsed = pickSchema.safeParse(JSON.parse(stored));
14
+ const parsed = uploadPickSchema.safeParse(JSON.parse(stored));
14
15
  return parsed.success ? parsed.data : { title: 0, thumbnail: 0 };
15
16
  }
16
17
  catch {
@@ -18,7 +19,7 @@ export function readUploadPick(db, projectId) {
18
19
  }
19
20
  }
20
21
  export function writeUploadPick(db, projectId, pick) {
21
- writeSetting(db, keyOf(projectId), JSON.stringify(pickSchema.parse(pick)));
22
+ writeSetting(db, keyOf(projectId), JSON.stringify(uploadPickSchema.parse(pick)));
22
23
  }
23
24
  // The chosen title first, the others after it in their order; the same for the thumbnails. A
24
25
  // pick past the end (a title list rewritten shorter) falls back to the first.
@@ -0,0 +1,190 @@
1
+ import { z } from "zod";
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
5
+ // upload is prepared; each of its shorts goes out at the first time its day and hour come round
6
+ // after its own long video, so a plan never needs "next week" notes. The extension types the
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
+ });
22
+ export const postingPlanKey = "studio.postingPlan";
23
+ 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
+ };
47
+ }
48
+ export function localTimeZone() {
49
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
50
+ }
51
+ export function readPlan(db) {
52
+ const stored = readSetting(db, key);
53
+ if (stored !== undefined)
54
+ try {
55
+ const parsed = postingPlanSchema.safeParse(JSON.parse(stored));
56
+ if (parsed.success)
57
+ return parsed.data;
58
+ }
59
+ catch {
60
+ // A broken setting reads as the default plan.
61
+ }
62
+ return defaultPlan(localTimeZone());
63
+ }
64
+ export function writePlan(db, plan) {
65
+ writeSetting(db, key, JSON.stringify(postingPlanSchema.parse(plan)));
66
+ }
67
+ function partsIn(timeZone, at) {
68
+ const format = new Intl.DateTimeFormat("en-US", {
69
+ timeZone,
70
+ hourCycle: "h23",
71
+ year: "numeric",
72
+ month: "numeric",
73
+ day: "numeric",
74
+ hour: "numeric",
75
+ minute: "numeric",
76
+ second: "numeric",
77
+ });
78
+ const out = {};
79
+ for (const part of format.formatToParts(at))
80
+ if (part.type !== "literal")
81
+ out[part.type] = Number(part.value);
82
+ return out;
83
+ }
84
+ // How far the zone is ahead of UTC at that instant, in milliseconds.
85
+ function offsetAt(timeZone, at) {
86
+ const p = partsIn(timeZone, at);
87
+ const local = Date.UTC(p.year ?? 0, (p.month ?? 1) - 1, p.day ?? 1, p.hour ?? 0, p.minute ?? 0, p.second ?? 0);
88
+ return local - Math.floor(at.getTime() / 1000) * 1000;
89
+ }
90
+ // The instant a wall-clock time names in the zone (the later reading when a clock change
91
+ // makes it ambiguous, the hour after when it skips it).
92
+ export function zonedInstant(timeZone, date, time) {
93
+ const [hour, minute] = time.split(":").map(Number);
94
+ const wall = Date.UTC(date.year, date.month - 1, date.day, hour ?? 0, minute ?? 0);
95
+ const first = wall - offsetAt(timeZone, new Date(wall));
96
+ return new Date(wall - offsetAt(timeZone, new Date(first)));
97
+ }
98
+ function localDate(timeZone, at) {
99
+ const p = partsIn(timeZone, at);
100
+ const year = p.year ?? 1970;
101
+ const month = p.month ?? 1;
102
+ const day = p.day ?? 1;
103
+ return { year, month, day, weekday: new Date(Date.UTC(year, month - 1, day)).getUTCDay() };
104
+ }
105
+ function addDays(date, days) {
106
+ const next = new Date(Date.UTC(date.year, date.month - 1, date.day + days));
107
+ return {
108
+ year: next.getUTCFullYear(),
109
+ month: next.getUTCMonth() + 1,
110
+ day: next.getUTCDate(),
111
+ weekday: next.getUTCDay(),
112
+ };
113
+ }
114
+ // The first time the slot's day and hour come round after `after`.
115
+ export function nextOccurrence(timeZone, slot, after) {
116
+ let date = localDate(timeZone, after);
117
+ for (let step = 0; step < 15; step += 1, date = addDays(date, 1)) {
118
+ if (date.weekday !== slot.day)
119
+ continue;
120
+ const at = zonedInstant(timeZone, date, slot.time);
121
+ if (at.getTime() > after.getTime())
122
+ return at;
123
+ }
124
+ return new Date(after.getTime() + 7 * 24 * 60 * 60 * 1000);
125
+ }
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
+ }
141
+ }
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
+ }
@@ -67,3 +67,10 @@ export function removeFill(db, projectId, short, now) {
67
67
  write(db, queueKey(db), next);
68
68
  return next;
69
69
  }
70
+ // The extension popup's pick: this item goes first, so the upload dialog it opens gets it.
71
+ export function fillNow(db, projectId, short, now) {
72
+ const others = readFillQueue(db, now).filter((entry) => !same(entry, projectId, short));
73
+ const next = [{ projectId, short, at: now.toISOString() }, ...others].slice(0, fillQueueMax);
74
+ write(db, queueKey(db), next);
75
+ return next;
76
+ }
@@ -140,6 +140,8 @@ export function saveProjectPlaylists(db, projectId, names) {
140
140
  // project's Prepare upload, under AI use. One row holding their ids; YouTube's second AI use
141
141
  // case is footage of a real event or place altered by AI (`disclosure.ts`).
142
142
  export const studioRealFootageKey = "studio.realFootage";
143
+ // "on" or "off": whether the extension posts and pins each video's comment once it is public.
144
+ export const autoCommentKey = "studio.autoComment";
143
145
  function realFootageIds(db) {
144
146
  const stored = readSetting(db, studioRealFootageKey);
145
147
  if (stored === undefined)
@@ -0,0 +1,71 @@
1
+ import { z } from "zod";
2
+ export const abVariantSchema = z.object({
3
+ title: z.string().max(200).nullable(),
4
+ // Which thumbnail (1-3) the variant showed, when Studio says.
5
+ thumbnail: z.number().int().min(1).max(3).nullable(),
6
+ // Its share of watch time, percent.
7
+ share: z.number().min(0).max(100).nullable(),
8
+ winner: z.boolean(),
9
+ });
10
+ const slot = (short) => short ?? 0;
11
+ const num = (value) => (typeof value === "number" ? value : null);
12
+ export function saveStats(db, stats) {
13
+ db.prepare(`INSERT INTO video_stats(project_id,short,video_id,read_at,impressions,ctr,views,average_view_seconds,watch_hours)
14
+ VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(project_id,short) DO UPDATE SET video_id=excluded.video_id,
15
+ read_at=excluded.read_at, impressions=excluded.impressions, ctr=excluded.ctr, views=excluded.views,
16
+ average_view_seconds=excluded.average_view_seconds, watch_hours=excluded.watch_hours`).run(stats.projectId, slot(stats.short), stats.videoId, stats.readAt, stats.impressions, stats.ctr, stats.views, stats.averageViewSeconds, stats.watchHours);
17
+ }
18
+ function statsOf(row) {
19
+ const short = Number(row.short);
20
+ return {
21
+ projectId: String(row.project_id),
22
+ short: short === 0 ? null : short,
23
+ videoId: String(row.video_id),
24
+ readAt: String(row.read_at),
25
+ impressions: num(row.impressions),
26
+ ctr: num(row.ctr),
27
+ views: num(row.views),
28
+ averageViewSeconds: num(row.average_view_seconds),
29
+ watchHours: num(row.watch_hours),
30
+ };
31
+ }
32
+ export function projectStats(db, projectId) {
33
+ return db
34
+ .prepare("SELECT * FROM video_stats WHERE project_id=? ORDER BY short")
35
+ .all(projectId)
36
+ .map(statsOf);
37
+ }
38
+ // Each project's long video's numbers, for the Projects list.
39
+ export function longVideoStats(db) {
40
+ return new Map(db
41
+ .prepare("SELECT * FROM video_stats WHERE short=0")
42
+ .all()
43
+ .map((row) => [String(row.project_id), statsOf(row)]));
44
+ }
45
+ export function saveAbResult(db, result) {
46
+ db.prepare(`INSERT INTO ab_results(project_id,short,video_id,read_at,variants) VALUES (?,?,?,?,?)
47
+ ON CONFLICT(project_id,short) DO UPDATE SET video_id=excluded.video_id, read_at=excluded.read_at,
48
+ variants=excluded.variants`).run(result.projectId, slot(result.short), result.videoId, result.readAt, JSON.stringify(result.variants));
49
+ }
50
+ export function abResults(db) {
51
+ return db
52
+ .prepare(`SELECT r.*, p.title AS project_title FROM ab_results r JOIN projects p ON p.id=r.project_id
53
+ ORDER BY r.read_at DESC`)
54
+ .all()
55
+ .flatMap((row) => {
56
+ const parsed = z.array(abVariantSchema).safeParse(JSON.parse(String(row.variants)));
57
+ if (!parsed.success)
58
+ return [];
59
+ const short = Number(row.short);
60
+ return [
61
+ {
62
+ projectId: String(row.project_id),
63
+ projectTitle: String(row.project_title),
64
+ short: short === 0 ? null : short,
65
+ videoId: String(row.video_id),
66
+ readAt: String(row.read_at),
67
+ variants: parsed.data,
68
+ },
69
+ ];
70
+ });
71
+ }
@@ -1,3 +1,4 @@
1
+ const taskState = (value) => value === "waiting" || value === "done" || value === "failed" ? value : "none";
1
2
  // A YouTube video id: 11 letters, digits, `-` and `_`.
2
3
  export const videoIdPattern = /^[A-Za-z0-9_-]{11}$/;
3
4
  // The id inside a link as Studio and YouTube show it (youtu.be/ID, watch?v=ID, /shorts/ID,
@@ -17,19 +18,34 @@ function rowOf(row) {
17
18
  short: short === 0 ? null : short,
18
19
  videoId: String(row.video_id),
19
20
  recordedAt: String(row.recorded_at),
21
+ uploadState: row.upload_state === "filled" ? "filled" : "done",
20
22
  abState: String(row.ab_state),
23
+ finishState: taskState(row.finish_state),
24
+ finishMessage: typeof row.finish_message === "string" ? row.finish_message : null,
25
+ commentState: taskState(row.comment_state),
26
+ commentMessage: typeof row.comment_message === "string" ? row.comment_message : null,
21
27
  abMessage: typeof row.ab_message === "string" ? row.ab_message : null,
22
28
  abAt: typeof row.ab_at === "string" ? row.ab_at : null,
23
29
  };
24
30
  }
25
- // Records (or replaces) the video an upload became. A new video id starts its A/B test over.
26
- export function recordVideo(db, projectId, short, videoId, at) {
27
- db.prepare(`INSERT INTO youtube_videos(project_id,short,video_id,recorded_at) VALUES (?,?,?,?)
31
+ // Records (or replaces) the video an upload became: "filled" from the upload dialog, "done"
32
+ // once confirmed or pasted. A new video id starts its A/B test over; a confirmed video isn't
33
+ // taken back to "filled" by filling it again.
34
+ export function recordVideo(db, projectId, short, videoId, at, state = "done") {
35
+ db.prepare(`INSERT INTO youtube_videos(project_id,short,video_id,recorded_at,upload_state) VALUES (?,?,?,?,?)
28
36
  ON CONFLICT(project_id,short) DO UPDATE SET
29
37
  ab_state=CASE WHEN video_id=excluded.video_id THEN ab_state ELSE 'none' END,
30
38
  ab_message=CASE WHEN video_id=excluded.video_id THEN ab_message ELSE NULL END,
31
39
  ab_at=CASE WHEN video_id=excluded.video_id THEN ab_at ELSE NULL END,
32
- video_id=excluded.video_id, recorded_at=excluded.recorded_at`).run(projectId, slot(short), videoId, at);
40
+ upload_state=CASE WHEN video_id=excluded.video_id AND upload_state='done' THEN 'done'
41
+ ELSE excluded.upload_state END,
42
+ video_id=excluded.video_id, recorded_at=excluded.recorded_at`).run(projectId, slot(short), videoId, at, state);
43
+ }
44
+ // Studio said the upload was scheduled or published. False when no such upload was filled.
45
+ export function confirmUpload(db, projectId, short, videoId) {
46
+ return (db
47
+ .prepare("UPDATE youtube_videos SET upload_state='done' WHERE project_id=? AND short=? AND video_id=?")
48
+ .run(projectId, slot(short), videoId).changes > 0);
33
49
  }
34
50
  export function forgetVideo(db, projectId, short) {
35
51
  db.prepare("DELETE FROM youtube_videos WHERE project_id=? AND short=?").run(projectId, slot(short));
@@ -46,15 +62,33 @@ export function videoOf(db, projectId, short) {
46
62
  .get(projectId, slot(short));
47
63
  return row === undefined ? undefined : rowOf(row);
48
64
  }
49
- // Sets an A/B test's state; false when the project has no video recorded for that upload.
50
- export function setAbState(db, projectId, short, state, message, at) {
51
- return (db
52
- .prepare("UPDATE youtube_videos SET ab_state=?, ab_message=?, ab_at=? WHERE project_id=? AND short=?")
53
- .run(state, message, at, projectId, slot(short)).changes > 0);
65
+ export function setTaskState(db, task, projectId, short, state, message) {
66
+ const column = task === "finish" ? "finish" : "comment";
67
+ db.prepare(`UPDATE youtube_videos SET ${column}_state=?, ${column}_message=? WHERE project_id=? AND short=?`).run(state, message, projectId, slot(short));
54
68
  }
55
- export function waitingAbTests(db) {
69
+ export function waitingTasks(db, task) {
70
+ const column = task === "finish" ? "finish_state" : "comment_state";
56
71
  return db
57
- .prepare("SELECT * FROM youtube_videos WHERE ab_state='waiting' ORDER BY recorded_at")
72
+ .prepare(`SELECT * FROM youtube_videos WHERE ${column}='waiting' ORDER BY recorded_at`)
58
73
  .all()
59
74
  .map(rowOf);
60
75
  }
76
+ // Every video on YouTube, for the numbers the extension reads from Studio.
77
+ export function doneVideos(db) {
78
+ return db
79
+ .prepare("SELECT * FROM youtube_videos WHERE upload_state='done' ORDER BY recorded_at DESC")
80
+ .all()
81
+ .map(rowOf);
82
+ }
83
+ // The long video uploaded last before this project's, for its end screen ("the previous
84
+ // episode"), when the project names none.
85
+ export function previousLongVideo(db, projectId) {
86
+ const own = db
87
+ .prepare("SELECT recorded_at FROM youtube_videos WHERE project_id=? AND short=0")
88
+ .get(projectId);
89
+ const row = db
90
+ .prepare(`SELECT video_id FROM youtube_videos WHERE short=0 AND project_id<>? AND upload_state='done'
91
+ AND (? IS NULL OR recorded_at < ?) ORDER BY recorded_at DESC LIMIT 1`)
92
+ .get(projectId, own?.recorded_at ?? null, own?.recorded_at ?? null);
93
+ return typeof row?.video_id === "string" ? row.video_id : undefined;
94
+ }
@@ -1,6 +1,6 @@
1
- // A title made from a pattern with keywords ("{{Topic}} | D&D Lore To Sleep To") keeps its
1
+ // A title made from a pattern with keywords ("{{Topic}} | Stories To Sleep To") keeps its
2
2
  // fixed wording in YouTube's title A/B test: the other titles change only what the keywords
3
- // stand for ("Tiamat, the Five-Headed Dragon Queen | D&D Lore To Sleep To"). A title without
3
+ // stand for ("The Lighthouse Keeper | Stories To Sleep To"). A title without
4
4
  // keywords has nothing fixed, and its other titles are free.
5
5
  const slot = /\{\{([^{}\n]*)\}\}/g;
6
6
  // Undefined when the project kept no pattern, or the pattern has no keyword.