@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.
- package/dist/edge/http/projects.js +8 -0
- package/dist/edge/http/studio.js +282 -58
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0046-youtube-upload-state.sql +6 -0
- package/dist/kernel/db/migrations/0047-studio-autopilot.sql +39 -0
- package/dist/patch-notes/3.3.0.md +40 -0
- package/dist/patch-notes/3.3.1.md +7 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/article/segments.js +2 -1
- package/dist/slices/rebuild/recipe-document.js +2 -2
- package/dist/slices/rebuild/recipe-exports.js +2 -1
- package/dist/slices/rebuild/recipe-visual.js +2 -2
- package/dist/slices/rebuild/recipe-youtube.js +2 -1
- package/dist/slices/revisions/subject.js +2 -3
- package/dist/slices/storage/backup-format.js +4 -0
- package/dist/slices/storage/portable.js +12 -1
- package/dist/slices/studio/backfill.js +34 -0
- package/dist/slices/studio/model.js +2 -0
- package/dist/slices/studio/pack.js +41 -2
- package/dist/slices/studio/pick.js +5 -4
- package/dist/slices/studio/plan.js +190 -0
- package/dist/slices/studio/queue.js +7 -0
- package/dist/slices/studio/settings.js +2 -0
- package/dist/slices/studio/stats.js +71 -0
- package/dist/slices/studio/videos.js +45 -11
- package/dist/slices/youtube/titles.js +2 -2
- package/dist/web/assets/index-6wMtRXOo.css +1 -0
- package/dist/web/assets/{index-hUEQRe6s.js → index-DmLX0oKs.js} +131 -130
- package/dist/web/assets/{pdf-C1Iq6van.js → pdf-BI8cHr9G.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- 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
|
|
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
|
|
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
|
|
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`,
|
|
3
|
-
//
|
|
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 {
|
|
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
|
+
}
|
|
@@ -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(
|
|
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
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
69
|
+
export function waitingTasks(db, task) {
|
|
70
|
+
const column = task === "finish" ? "finish_state" : "comment_state";
|
|
56
71
|
return db
|
|
57
|
-
.prepare(
|
|
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}} |
|
|
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 ("
|
|
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.
|