@gentbajko/slopify 3.7.0 → 3.7.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.
@@ -5,6 +5,7 @@ import { zValidator } from "@hono/zod-validator";
5
5
  import { Hono } from "hono";
6
6
  import { bodyLimit } from "hono/body-limit";
7
7
  import { z } from "zod";
8
+ import { transact } from "../../kernel/db/tx.js";
8
9
  import { derive } from "../../kernel/runner/graph.js";
9
10
  import { listProjects, projectById, stagesOf } from "../../slices/admission/repo.js";
10
11
  import { channelById } from "../../slices/channels/repo.js";
@@ -18,8 +19,8 @@ import { channelPerformance } from "../../slices/studio/performance.js";
18
19
  import { writeUploadPick } from "../../slices/studio/pick.js";
19
20
  import { postingPlanSchema, readPlan, writePlan } from "../../slices/studio/plan.js";
20
21
  import { leadHoursMax, seriesOf } from "../../slices/studio/plan-model.js";
21
- import { enqueueFill, fillNow, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
22
- import { allReleases, freeSlots, planReleases, readLeadHours, releasesOf, setRelease, writeLeadHours, } from "../../slices/studio/releases.js";
22
+ import { enqueueFill, fillOnly, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
23
+ import { allReleases, freeSlots, planReleases, readLeadHours, releasesOf, setRelease, swapReleases, writeLeadHours, } from "../../slices/studio/releases.js";
23
24
  import { parseStudioExport, readReport, saveReport } from "../../slices/studio/report.js";
24
25
  import { autoCommentKey, bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
25
26
  import { abResults, abVariantSchema, projectStats, saveAbResult, saveStats, } from "../../slices/studio/stats.js";
@@ -254,6 +255,52 @@ export function studioRoutes(deps) {
254
255
  weeks: c.req.valid("query").weeks ?? 2,
255
256
  candidates: finished.map((project) => project.id),
256
257
  }));
258
+ })
259
+ // One project's release times (its long video and each short, with their upload-by), the
260
+ // plan's free times for its series, for the project page's Release block.
261
+ .get("/releases/:projectId", zValidator("param", projectParam, onInvalid), (c) => {
262
+ const { projectId } = c.req.valid("param");
263
+ planned(projectId);
264
+ const pack = uploadPack(deps, projectId);
265
+ if (!pack.ok)
266
+ return unknownProject(c);
267
+ const leadMs = readLeadHours(deps.db) * 3_600_000;
268
+ const releases = releasesOf(deps.db, projectId);
269
+ return c.json({
270
+ leadHours: readLeadHours(deps.db),
271
+ items: pack.pack.items
272
+ .filter((item) => item.video !== null || item.kind === "video")
273
+ .map((item) => {
274
+ const short = item.short ?? 0;
275
+ const at = releases.find((release) => release.short === short)?.at ?? "";
276
+ return {
277
+ short,
278
+ title: item.title,
279
+ at: at === "" ? null : at,
280
+ uploadBy: at === "" ? null : new Date(Date.parse(at) - leadMs).toISOString(),
281
+ onYoutube: videoOf(deps.db, projectId, item.short ?? null)?.uploadState === "done",
282
+ };
283
+ }),
284
+ free: slotChoices(projectId, pack.pack.series),
285
+ });
286
+ })
287
+ // Swap with…: two videos trade their release times; their shorts follow each.
288
+ .post("/releases/:projectId/swap", zValidator("param", projectParam, onInvalid), zValidator("json", z.object({ with: id }).strict(), onInvalid), (c) => {
289
+ const denied = samePage(c);
290
+ if (denied !== undefined)
291
+ return denied;
292
+ const { projectId } = c.req.valid("param");
293
+ const other = c.req.valid("json").with;
294
+ const swapped = transact(deps.db, () => swapReleases(deps.db, projectId, other, deps.clock.now()));
295
+ if (!swapped)
296
+ return problem(c, {
297
+ status: 409,
298
+ title: titleOf(409),
299
+ detail: "Both videos need a release time to swap. Give this one a time first, or reload the page and pick another video.",
300
+ });
301
+ planned(projectId);
302
+ planned(other);
303
+ return c.json({ releases: releasesOf(deps.db, projectId) });
257
304
  })
258
305
  // Moves one release (a time), sets it to "not scheduled" (null), or puts a project into a
259
306
  // free time of the plan (short 0 with its line).
@@ -592,7 +639,7 @@ export function studioRoutes(deps) {
592
639
  });
593
640
  return c.json({ projects });
594
641
  })
595
- // The popup's click: that upload goes first in line, and the extension opens Studio's
642
+ // The popup's click: only that upload waits, and the extension opens Studio's
596
643
  // upload page, where the dialog takes it.
597
644
  .post("/ext/upload", zValidator("json", queueItemBody, onInvalid), (c) => {
598
645
  allowOrigin(c, false);
@@ -607,7 +654,7 @@ export function studioRoutes(deps) {
607
654
  detail: "That upload isn't in the project any more. Open the popup again.",
608
655
  });
609
656
  planned(projectId);
610
- fillNow(deps.db, projectId, short ?? null, deps.clock.now());
657
+ fillOnly(deps.db, [{ projectId, short: short ?? null }], deps.clock.now());
611
658
  return c.json({ url: studioUploadUrl });
612
659
  })
613
660
  // The popup's Upload all Shorts: each short not on YouTube yet waits, in order, and the
@@ -624,9 +671,7 @@ export function studioRoutes(deps) {
624
671
  const shorts = result.pack.items.filter((item) => item.kind === "short" &&
625
672
  item.video !== null &&
626
673
  videoOf(deps.db, projectId, item.short ?? null)?.uploadState !== "done");
627
- // fillNow puts each first, so the last short goes in first and short 1 ends up first.
628
- for (const item of shorts.toReversed())
629
- fillNow(deps.db, projectId, item.short ?? null, deps.clock.now());
674
+ fillOnly(deps.db, shorts.map((item) => ({ projectId, short: item.short ?? null })), deps.clock.now());
630
675
  return c.json({ url: studioUploadUrl, count: shorts.length });
631
676
  })
632
677
  // One project's upload pack, for a Studio page opened for one of its uploads.
@@ -0,0 +1,16 @@
1
+ # Slopify 3.7.1
2
+
3
+ Released 4 October 2026.
4
+
5
+ ## Release times on the project
6
+
7
+ - **Set release dates in the project itself.** The Video section has a Release block: the long video's and each short's release time, editable, with the time each must be uploaded and scheduled by. The long video can take one of the posting plan's free times, or be set to Not scheduled. They are the same times Calendar → Releases, Prepare upload and the Studio extension use.
8
+ - **Swap with…** in the Release block: two videos trade their release times, and each one's shorts move with it.
9
+
10
+ ## Project page
11
+
12
+ - **A run clock you can see.** While a project runs, its header shows "Working · 1:03:27", counting every second and checked against the server every 20 seconds. A stopped run shows its working time so far.
13
+
14
+ ## YouTube Studio extension (1.1.3)
15
+
16
+ - **One click uploads one video.** Studio rebuilds its file picker while an upload starts; the extension took each rebuild for a new upload dialog and added the same short again (and Upload all seemed to start every short at once). Now each dialog takes one video, and the same upload is never added twice within a minute and a half. Download the extension again from Settings → YouTube Studio and reload it.
@@ -0,0 +1,11 @@
1
+ # Slopify 3.7.2
2
+
3
+ Released 4 October 2026.
4
+
5
+ ## YouTube Studio extension
6
+
7
+ - **Upload one uploads one.** The extension's Upload now leaves only that video waiting, and Upload all Shorts only that project's shorts. Before, anything left waiting from an earlier click stayed in line, and the next upload dialogs took it. Together with extension 1.1.3 (one video per upload dialog), shorts go up strictly one at a time. If you're still on 1.1.2, download the extension again from Settings → YouTube Studio and reload it.
8
+
9
+ ## Layout
10
+
11
+ - **The sidebar always reaches the bottom of the window.** In a short or zoomed window scrolled to the end, the sidebar could stop partway down and leave an empty band.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.7.2",
4
+ "title": "Slopify 3.7.2",
5
+ "version": "3.7.2",
6
+ "date": "2026-10-04"
7
+ },
8
+ {
9
+ "id": "3.7.1",
10
+ "title": "Slopify 3.7.1",
11
+ "version": "3.7.1",
12
+ "date": "2026-10-04"
13
+ },
2
14
  {
3
15
  "id": "3.7.0",
4
16
  "title": "Slopify 3.7.0",
@@ -67,10 +67,12 @@ 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);
70
+ // The popup's Upload or Upload all: only these wait, in this order, and anything left from an
71
+ // earlier pick is dropped, so Studio's upload dialogs never take more than was asked for.
72
+ export function fillOnly(db, items, now) {
73
+ const next = items
74
+ .slice(0, fillQueueMax)
75
+ .map((item) => ({ projectId: item.projectId, short: item.short, at: now.toISOString() }));
74
76
  write(db, queueKey(db), next);
75
77
  return next;
76
78
  }
@@ -123,6 +123,18 @@ export function setRelease(db, projectId, short, at, line, now) {
123
123
  if (short === 0)
124
124
  db.prepare("DELETE FROM releases WHERE project_id=? AND short>0 AND by='plan'").run(projectId);
125
125
  }
126
+ // Two projects trade their long videos' release times (and lines); each one's plan-placed
127
+ // shorts are dropped, to be placed again after its new time. False when either has no time.
128
+ export function swapReleases(db, first, second, now) {
129
+ const longOf = (projectId) => releasesOf(db, projectId).find((release) => release.short === 0 && release.at !== "");
130
+ const a = longOf(first);
131
+ const b = longOf(second);
132
+ if (a === undefined || b === undefined || first === second)
133
+ return false;
134
+ setRelease(db, first, 0, b.at, b.line, now);
135
+ setRelease(db, second, 0, a.at, a.line, now);
136
+ return true;
137
+ }
126
138
  export function scheduleOf(db, projectId) {
127
139
  const releases = releasesOf(db, projectId);
128
140
  const long = releases.find((release) => release.short === 0);