@gentbajko/slopify 3.8.1 → 3.8.3

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,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.
@@ -0,0 +1,5 @@
1
+ # Slopify 3.8.3
2
+
3
+ Released 4 October 2026.
4
+
5
+ - **A narration chunk Slopify records again by itself really does get recorded.** If its first try found the project busy, Slopify kept "busy" as that try's answer and gave it back every time after, even across restarts, so the video stayed failed and the update waited on it forever. A busy answer is no longer kept: the next look asks again and records the chunk. Retries stuck this way before the update start on their own once it is installed.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.8.3",
4
+ "title": "Slopify 3.8.3",
5
+ "version": "3.8.3",
6
+ "date": "2026-10-04"
7
+ },
8
+ {
9
+ "id": "3.8.2",
10
+ "title": "Slopify 3.8.2",
11
+ "version": "3.8.2",
12
+ "date": "2026-10-04"
13
+ },
2
14
  {
3
15
  "id": "3.8.1",
4
16
  "title": "Slopify 3.8.1",
@@ -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(" ");
@@ -37,8 +37,16 @@ export function readRecovery(deps, projectId, input) {
37
37
  const row = rowSchema.parse(raw);
38
38
  if (row.request_hash !== requestHash("direct-recovery", input.baseRevisionId, input.action))
39
39
  return { ok: false, reason: "idempotency-conflict" };
40
- if (row.response_json !== null)
41
- return recoveryResultSchema.parse(JSON.parse(row.response_json));
40
+ if (row.response_json !== null) {
41
+ const saved = recoveryResultSchema.parse(JSON.parse(row.response_json));
42
+ // "Busy" was the moment's answer, not the request's outcome: one kept from before this was
43
+ // forgotten is dropped, so the same request asks again instead of replaying it forever.
44
+ if (busy(saved)) {
45
+ forget(deps, projectId, input);
46
+ return undefined;
47
+ }
48
+ return saved;
49
+ }
42
50
  return {
43
51
  pending: true,
44
52
  authority: row.authority_stamp,
@@ -57,11 +65,24 @@ export function reserveRecovery(deps, projectId, input, edit) {
57
65
  }
58
66
  export function rememberRecovery(deps, projectId, input, result) {
59
67
  const response = recoveryResultSchema.parse(result);
68
+ // Refused because the project was busy: nothing was done, so the same request may come again
69
+ // (an automatic narration retry keeps its request id) and must be tried again, not replayed.
70
+ if (busy(response)) {
71
+ forget(deps, projectId, input);
72
+ return response;
73
+ }
60
74
  deps.db
61
75
  .prepare("UPDATE project_recovery_requests SET response_json=? WHERE project_id=? AND idempotency_key=?")
62
76
  .run(JSON.stringify(response), projectId, input.idempotencyKey);
63
77
  return response;
64
78
  }
79
+ // Busy before anything was saved (a refusal after the revision was saved keeps its answer).
80
+ const busy = (result) => !result.ok && result.reason === "running" && result.intentRevisionId === undefined;
81
+ function forget(deps, projectId, input) {
82
+ deps.db
83
+ .prepare("DELETE FROM project_recovery_requests WHERE project_id=? AND idempotency_key=?")
84
+ .run(projectId, input.idempotencyKey);
85
+ }
65
86
  // The head has unfinished stages but nothing that will move them on its own: no call in
66
87
  // flight and no admitted, dispatchable work. A saved rerun or edit is admitted by no one
67
88
  // until Resume, and work held by a pause, a restart or a failed sibling of its admission
@@ -84,6 +84,14 @@ export function freeSlots(db, plan, who, now, count, exceptProject) {
84
84
  export function planReleases(db, plan, project, now) {
85
85
  let releases = releasesOf(db, project.id);
86
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
+ }
87
95
  if (long === undefined) {
88
96
  // The line of the day its schedule ran comes first, then any line that takes it.
89
97
  const slots = freeSlots(db, plan, project, now, 30, project.id);
@@ -108,10 +116,12 @@ export function planReleases(db, plan, project, now) {
108
116
  if (release.at !== "")
109
117
  taken.add(Math.floor(Date.parse(release.at) / hourMs));
110
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);
111
121
  const times = line.shorts
112
- .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)))
113
123
  .toSorted((left, right) => left.getTime() - right.getTime());
114
- let after = longAt.getTime();
124
+ let after = start;
115
125
  for (let short = 1; short <= project.shorts; short += 1) {
116
126
  const kept = releases.find((release) => release.short === short);
117
127
  if (kept !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gentbajko/slopify",
3
- "version": "3.8.1",
3
+ "version": "3.8.3",
4
4
  "description": "Turn a prompt into a finished video, locally, with your own provider keys",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Gent Bajko",