@gentbajko/slopify 3.8.0 → 3.8.2

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.
@@ -162,17 +162,19 @@ export function studioRoutes(deps) {
162
162
  };
163
163
  };
164
164
  // A project ready to upload gets its release times from the posting plan when its upload is
165
- // prepared (`releases.ts`); one whose long video is on YouTube already keeps what it has.
165
+ // prepared (`releases.ts`).
166
+ // A long video already on YouTube keeps when it went up; its shorts still get times.
166
167
  const planned = (projectId) => {
167
- if (videoOf(deps.db, projectId, null)?.uploadState === "done")
168
- return;
168
+ const long = videoOf(deps.db, projectId, null);
169
+ const out = long?.uploadState === "done" ? long.recordedAt : undefined;
169
170
  const result = uploadPack(deps, projectId);
170
- if (!result.ok || result.pack.items[0]?.video == null)
171
+ if (!result.ok || (out === undefined && result.pack.items[0]?.video == null))
171
172
  return;
172
173
  planReleases(deps.db, readPlan(deps.db), {
173
174
  id: projectId,
174
175
  ...whoOf(projectId, result.pack.series),
175
176
  shorts: result.pack.items.filter((item) => item.kind === "short").length,
177
+ ...(out === undefined ? {} : { longOut: out }),
176
178
  }, deps.clock.now());
177
179
  };
178
180
  // Settings shows only the lines saved there; each schedule shows its own.
@@ -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.
@@ -0,0 +1,6 @@
1
+ # Slopify 3.8.2
2
+
3
+ Released 4 October 2026.
4
+
5
+ - **Shorts of a video already on YouTube get release times.** A project whose long video went up before it had a release time left its shorts with none, so the Studio extension had no schedule to type in. Now the long video keeps the time it went up as its release, and its shorts take the release line's short times from the upload lead on (never due sooner than you could upload them).
6
+ - **A narration chunk recorded again by itself no longer gets stuck.** When the voice garbled words, Slopify promised to record the chunk again and carry on, but if the project's last other step was just finishing, the retry waited for a step that never came, and the video stayed failed. It now looks again every 15 seconds until the project is free.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.8.2",
4
+ "title": "Slopify 3.8.2",
5
+ "version": "3.8.2",
6
+ "date": "2026-10-04"
7
+ },
8
+ {
9
+ "id": "3.8.1",
10
+ "title": "Slopify 3.8.1",
11
+ "version": "3.8.1",
12
+ "date": "2026-10-04"
13
+ },
2
14
  {
3
15
  "id": "3.8.0",
4
16
  "title": "Slopify 3.8.0",
@@ -61,6 +61,13 @@ export function createNarrationRetries() {
61
61
  continue;
62
62
  running.add(id);
63
63
  void recordAgain(deps, retry)
64
+ .then((waiting) => {
65
+ // The step that just finished may still count as running when it kicks this (the
66
+ // runner lets go of it after its `onFinished`), and nothing else finishes to kick
67
+ // again: look again shortly until the project is free.
68
+ if (waiting)
69
+ setTimeout(() => kick(retry.projectId), retryWaitMs).unref?.();
70
+ })
64
71
  .catch((error) => {
65
72
  deps.log.write("error", "narration.retry", {
66
73
  projectId: retry.projectId,
@@ -86,11 +93,16 @@ export function createNarrationRetries() {
86
93
  kick,
87
94
  };
88
95
  }
96
+ // How long a retry waiting for the project's other steps waits before it looks again.
97
+ export const retryWaitMs = 15_000;
98
+ // The waits already logged, so looking again doesn't log the same line every 15 seconds.
99
+ const told = new Set();
100
+ // True when the project's other steps are still going and the retry waits for them.
89
101
  async function recordAgain(deps, retry) {
90
102
  const base = currentRevisionId(deps.db, retry.projectId);
91
103
  if (base === undefined) {
92
104
  settle(deps.db, retry, "failed", "no project", deps.clock.now().toISOString());
93
- return;
105
+ return false;
94
106
  }
95
107
  const result = await recoverProject(deps, retry.projectId, {
96
108
  baseRevisionId: base,
@@ -106,6 +118,10 @@ async function recordAgain(deps, retry) {
106
118
  });
107
119
  }
108
120
  else if (result.reason === "running") {
121
+ const key = retryKey(retry);
122
+ if (told.has(key))
123
+ return true;
124
+ told.add(key);
109
125
  // Another step of the project is still going (its PDF, say): the retry waits for it and
110
126
  // starts when that step finishes, which kicks this again (`onFinished`). Giving up here
111
127
  // left the video failed with a message promising a retry that never came.
@@ -113,7 +129,7 @@ async function recordAgain(deps, retry) {
113
129
  projectId: retry.projectId,
114
130
  detail: `Recording ${retry.chunkKey} again once the project's running steps finish.`,
115
131
  });
116
- return;
132
+ return true;
117
133
  }
118
134
  else {
119
135
  settle(deps.db, retry, "failed", refusal(result), now);
@@ -123,6 +139,7 @@ async function recordAgain(deps, retry) {
123
139
  });
124
140
  }
125
141
  deps.emit(retry.projectId, { type: "project.updated", projectId: retry.projectId });
142
+ return false;
126
143
  }
127
144
  function refusal(result) {
128
145
  const detail = result.fields?.map((field) => field.message).join(" ");
@@ -12,9 +12,10 @@ const rowSchema = z.object({
12
12
  series: z.string().trim().max(100).default(""),
13
13
  long: slotSchema,
14
14
  shorts: z.array(slotSchema).max(10),
15
- // A line from a schedule's release times (never stored here): it takes only that schedule's
16
- // projects, preferring the ones its run day (`runDay`) made, in the schedule's time zone, and
17
- // the calendar shows the schedule's name (`label`).
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`).
18
19
  schedule: z.string().optional(),
19
20
  runDay: z.number().int().min(0).max(6).optional(),
20
21
  timeZone: z.string().optional(),
@@ -1,3 +1,4 @@
1
+ import { templateById } from "../project-templates/repo.js";
1
2
  import { scheduleRows } from "../schedules/repo.js";
2
3
  import { readSetting, writeSetting } from "../settings/repo.js";
3
4
  // The posting plan: a week of long-video slots, each with its shorts. Each schedule sets its
@@ -7,7 +8,7 @@ import { readSetting, writeSetting } from "../settings/repo.js";
7
8
  // after its own long video, so a plan never needs "next week" notes. The extension types the
8
9
  // times into Studio's schedule, in the browser's own time zone.
9
10
  export { postingPlanSchema, weekdays, } from "./plan-model.js";
10
- import { postingPlanSchema } from "./plan-model.js";
11
+ import { postingPlanSchema, seriesOf } from "./plan-model.js";
11
12
  export const postingPlanKey = "studio.postingPlan";
12
13
  const key = postingPlanKey;
13
14
  // No plan until one is made: nothing is scheduled, and Prepare upload says so.
@@ -34,6 +35,17 @@ export function readStoredPlan(db) {
34
35
  }
35
36
  return emptyPlan(localTimeZone());
36
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
+ }
37
49
  // The lines every release is planned from: each schedule's release times (one line for each
38
50
  // day it runs), then the stored lines.
39
51
  export function readPlan(db) {
@@ -42,7 +54,7 @@ export function readPlan(db) {
42
54
  .filter((schedule) => schedule.deletedAt === null && schedule.status !== "canceled")
43
55
  .flatMap((schedule) => schedule.releases.map((release) => ({
44
56
  name: `s${schedule.id.slice(0, 8)}${String(release.day)}`,
45
- series: "",
57
+ series: templateSeries(db, schedule.templateId),
46
58
  long: release.long,
47
59
  shorts: [...release.shorts],
48
60
  schedule: schedule.id,
@@ -47,9 +47,10 @@ 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
- // A schedule's line takes only that schedule's projects; a stored line takes its series (or any).
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.
51
52
  const fits = (line, who) => line.schedule !== undefined
52
- ? line.schedule === who.schedule
53
+ ? line.schedule === who.schedule || (line.series !== "" && line.series === who.series)
53
54
  : line.series === "" || line.series === who.series;
54
55
  const zoneOf = (plan, line) => line.timeZone ?? plan.timeZone;
55
56
  // The plan's long-video times from `from` to `until`, in order, each with its line.
@@ -83,6 +84,14 @@ export function freeSlots(db, plan, who, now, count, exceptProject) {
83
84
  export function planReleases(db, plan, project, now) {
84
85
  let releases = releasesOf(db, project.id);
85
86
  let long = releases.find((release) => release.short === 0);
87
+ if (long === undefined && project.longOut !== undefined) {
88
+ const line = plan.rows.find((one) => one.schedule !== undefined && fits(one, project) && one.runDay === project.runDay) ?? plan.rows.find((one) => fits(one, project));
89
+ if (line === undefined)
90
+ return;
91
+ long = { short: 0, at: project.longOut, line: line.name, by: "plan" };
92
+ write(db, project.id, long, now);
93
+ releases = releasesOf(db, project.id);
94
+ }
86
95
  if (long === undefined) {
87
96
  // The line of the day its schedule ran comes first, then any line that takes it.
88
97
  const slots = freeSlots(db, plan, project, now, 30, project.id);
@@ -107,10 +116,12 @@ export function planReleases(db, plan, project, now) {
107
116
  if (release.at !== "")
108
117
  taken.add(Math.floor(Date.parse(release.at) / hourMs));
109
118
  const longAt = new Date(long.at);
119
+ // A short is never due before it could be uploaded in time.
120
+ const start = Math.max(longAt.getTime(), now.getTime() + readLeadHours(db) * hourMs);
110
121
  const times = line.shorts
111
- .flatMap((slot) => zonedTimes(zoneOf(plan, line), slot, longAt, new Date(longAt.getTime() + 5 * 7 * 24 * hourMs)))
122
+ .flatMap((slot) => zonedTimes(zoneOf(plan, line), slot, new Date(start), new Date(start + 5 * 7 * 24 * hourMs)))
112
123
  .toSorted((left, right) => left.getTime() - right.getTime());
113
- let after = longAt.getTime();
124
+ let after = start;
114
125
  for (let short = 1; short <= project.shorts; short += 1) {
115
126
  const kept = releases.find((release) => release.short === short);
116
127
  if (kept !== undefined) {