@gentbajko/slopify 3.15.0 → 3.17.0

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.
@@ -15,7 +15,6 @@ import { adoptBaseline } from "../../slices/revisions/adopt.js";
15
15
  import { currentRevisionId } from "../../slices/revisions/repo.js";
16
16
  import { limitWaitsByProject, listingWait } from "../../slices/run-cost/panel.js";
17
17
  import { outputsOf } from "../../slices/storage/repo.js";
18
- import { longVideoStats } from "../../slices/studio/stats.js";
19
18
  import { trashProject } from "../../slices/trash/service.js";
20
19
  import { setAsideProjects, uploadedAndSettled, uploadedProjects, } from "../../slices/uploads/repo.js";
21
20
  import { onInvalid, problem, titleOf } from "./problem.js";
@@ -68,7 +67,6 @@ export function projectRoutes(deps) {
68
67
  const uploads = uploadedProjects(deps.db);
69
68
  const aside = setAsideProjects(deps.db);
70
69
  const settled = uploadedAndSettled(deps.db);
71
- const numbers = longVideoStats(deps.db);
72
70
  const waits = limitWaitsByProject(deps.db);
73
71
  const projects = listProjectHeads(deps.db).map((project) => {
74
72
  const stages = standings.get(project.id) ?? [];
@@ -80,12 +78,6 @@ export function projectRoutes(deps) {
80
78
  channelId: channels.get(project.id) ?? defaultChannelId,
81
79
  uploadedAt: uploads.get(project.id) ?? null,
82
80
  ...(aside.has(project.id) ? { setAside: true } : {}),
83
- ...(numbers.get(project.id)?.views == null
84
- ? {}
85
- : { views: numbers.get(project.id)?.views ?? 0 }),
86
- ...(numbers.get(project.id)?.ctr == null
87
- ? {}
88
- : { ctr: numbers.get(project.id)?.ctr ?? 0 }),
89
81
  ...(waiting === undefined ? {} : { limitWaits: waiting.map(listingWait) }),
90
82
  };
91
83
  });
@@ -6,7 +6,7 @@ import { BackupBusyError, planBackup, streamBackup, } from "../../slices/storage
6
6
  import { BackupImportRefused, importBackup } from "../../slices/storage/backup-import.js";
7
7
  import { importPortable, portableMaxArchiveBytes, storageUsage, } from "../../slices/storage/portable.js";
8
8
  import { reconcileStorage } from "../../slices/storage/reconcile.js";
9
- import { keepOutputsOnly, projectStorage } from "../../slices/storage/trim.js";
9
+ import { cleanUpAll, deleteAllOldVersions, deleteOldVersions, keepOutputsOnly, projectStorage, } from "../../slices/storage/trim.js";
10
10
  import { onInvalid, problem, titleOf } from "./problem.js";
11
11
  const idParam = z.object({
12
12
  id: z
@@ -57,6 +57,31 @@ export function storageRoutes(deps) {
57
57
  detail: "Only a finished project can drop its working files, and this one is running, waiting or has unfinished steps. Let it finish (or cancel it on its project page), then drop the working files again from the project page or Settings → Storage.",
58
58
  });
59
59
  })
60
+ // Delete old versions: one project's History files (`slices/storage/trim.ts`).
61
+ .post("/projects/:id/old-versions/delete", zValidator("param", idParam, onInvalid), (c) => {
62
+ const result = deleteOldVersions({ db: deps.db, paths: deps.paths, hasInflight: deps.runner.hasInflight }, c.req.valid("param").id);
63
+ if (result.ok)
64
+ return c.json(result);
65
+ return result.reason === "no-project"
66
+ ? problem(c, {
67
+ status: 404,
68
+ title: titleOf(404),
69
+ detail: "This project no longer exists. Reload Settings → Backup & storage to see the current list.",
70
+ })
71
+ : problem(c, {
72
+ status: 409,
73
+ title: titleOf(409),
74
+ detail: "This project is running or has steps waiting to run, and its files stay where they are until then. Let it finish, or cancel its run on its project page, then press Delete old versions again.",
75
+ });
76
+ })
77
+ // Every project's old versions; ones that are running are left for later.
78
+ .post("/old-versions/delete", (c) => c.json(deleteAllOldVersions({
79
+ db: deps.db,
80
+ paths: deps.paths,
81
+ hasInflight: deps.runner.hasInflight,
82
+ })))
83
+ // Clean up: old versions everywhere and working files of every finished project.
84
+ .post("/cleanup", (c) => c.json(cleanUpAll({ db: deps.db, paths: deps.paths, hasInflight: deps.runner.hasInflight })))
60
85
  // What Export everything would write, asked before the download starts: a download
61
86
  // link cannot show a refusal, the browser would save it as the file.
62
87
  .get("/export/summary", (c) => {
@@ -3,7 +3,6 @@ import { join } from "node:path";
3
3
  import { Readable } from "node:stream";
4
4
  import { zValidator } from "@hono/zod-validator";
5
5
  import { Hono } from "hono";
6
- import { bodyLimit } from "hono/body-limit";
7
6
  import { z } from "zod";
8
7
  import { transact } from "../../kernel/db/tx.js";
9
8
  import { derive } from "../../kernel/runner/graph.js";
@@ -16,16 +15,13 @@ import { backfillVideos } from "../../slices/studio/backfill.js";
16
15
  import { releaseCalendar } from "../../slices/studio/calendar.js";
17
16
  import { playlistUses, studioPlaylistMax, studioUploadUrl, } from "../../slices/studio/model.js";
18
17
  import { packItem, uploadPack } from "../../slices/studio/pack.js";
19
- import { channelPerformance } from "../../slices/studio/performance.js";
20
18
  import { writeUploadPick } from "../../slices/studio/pick.js";
21
19
  import { postingPlanSchema, readPlan, readStoredPlan, weekdayIn, writePlan, } from "../../slices/studio/plan.js";
22
20
  import { leadHoursMax, seriesOf } from "../../slices/studio/plan-model.js";
23
21
  import { enqueueFill, fillOnly, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
24
22
  import { allReleases, freeSlots, planReleases, readLeadHours, releasesOf, setRelease, swapReleases, writeLeadHours, } from "../../slices/studio/releases.js";
25
- import { parseStudioExport, readReport, saveReport } from "../../slices/studio/report.js";
26
- import { statsFromReport } from "../../slices/studio/report-stats.js";
27
- import { autoCommentKey, bearerToken, exportViewProblem, exportViews, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readExportView, readStudioPlaylists, resetStudioPairing, saveExportView, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
28
- import { abResults, abVariantSchema, projectStats, saveAbResult, saveStats, } from "../../slices/studio/stats.js";
23
+ import { autoCommentKey, bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
24
+ import { abResults, abVariantSchema, saveAbResult } from "../../slices/studio/stats.js";
29
25
  import { confirmUpload, doneVideos, forgetVideo, projectVideos, recordedVideos, recordVideo, setChecks, setTaskState, videoIdOf, videoIdPattern, videoOf, waitingTasks, } from "../../slices/studio/videos.js";
30
26
  import { setAsideProjects, uploadedProjects } from "../../slices/uploads/repo.js";
31
27
  import { onInvalid, problem, titleOf } from "./problem.js";
@@ -89,15 +85,11 @@ const taskResultBody = z.object({
89
85
  ok: z.boolean(),
90
86
  message: z.string().max(2000),
91
87
  });
88
+ // An A/B test's result as Studio shows it; older extensions also send numbers, which are ignored.
92
89
  const statsBody = z.object({
93
90
  projectId: id,
94
91
  short: shortField,
95
92
  videoId: z.string().regex(videoIdPattern),
96
- impressions: z.number().nonnegative().optional(),
97
- ctr: z.number().min(0).max(100).optional(),
98
- views: z.number().nonnegative().optional(),
99
- averageViewSeconds: z.number().nonnegative().optional(),
100
- watchHours: z.number().nonnegative().optional(),
101
93
  abVariants: z.array(abVariantSchema).max(3).optional(),
102
94
  });
103
95
  const backfillBody = z.object({
@@ -192,24 +184,6 @@ export function studioRoutes(deps) {
192
184
  .filter((one) => one !== "")
193
185
  .toSorted(),
194
186
  });
195
- // A report and, for each of its videos Slopify made, the project and which upload it is.
196
- const reportBody = (channelId) => {
197
- const report = readReport(deps.db, channelId);
198
- const known = new Map(listProjects(deps.db).flatMap((project) => projectVideos(deps.db, project.id).map((video) => [
199
- video.videoId,
200
- { projectId: project.id, short: video.short, projectTitle: project.title },
201
- ])));
202
- return {
203
- exportView: readExportView(deps.db, channelId),
204
- report,
205
- projects: report === null
206
- ? {}
207
- : Object.fromEntries(report.rows.flatMap((row) => {
208
- const found = known.get(row.videoId);
209
- return found === undefined ? [] : [[row.videoId, found]];
210
- })),
211
- };
212
- };
213
187
  const slotChoices = (projectId, series) => freeSlots(deps.db, readPlan(deps.db), whoOf(projectId, series), deps.clock.now(), 9);
214
188
  // Finished projects not marked uploaded: the popup's list and the calendar's candidates.
215
189
  const finishedProjects = () => {
@@ -469,86 +443,8 @@ export function studioRoutes(deps) {
469
443
  return c.json({ chosen: { projectId, short: short ?? null }, queue: queueView(queue) });
470
444
  })
471
445
  // What waits for the extension, oldest first: each new upload dialog takes the first.
472
- // Studio's numbers for a project's videos, and every finished A/B test (Library).
473
- .get("/stats/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ stats: projectStats(deps.db, c.req.valid("param").projectId) }))
446
+ // Every finished A/B test (Library).
474
447
  .get("/ab-results", (c) => c.json({ results: abResults(deps.db) }))
475
- // Channels → YouTube numbers: Studio's Advanced-mode export, imported as the zip Studio
476
- // downloads, and which project each of its videos is.
477
- .get("/channels/:channelId/report", zValidator("param", z.object({ channelId: id }), onInvalid), (c) => c.json(reportBody(c.req.valid("param").channelId)))
478
- .post("/channels/:channelId/report", zValidator("param", z.object({ channelId: id }), onInvalid), bodyLimit({
479
- maxSize: 30 * 1024 * 1024,
480
- onError: (c) => problem(c, {
481
- status: 413,
482
- title: titleOf(413),
483
- detail: "That file is larger than 30 MB, which a Studio export never is. Export again from Studio's Advanced mode and import the zip it downloads.",
484
- }),
485
- }), async (c) => {
486
- const denied = samePage(c);
487
- if (denied !== undefined)
488
- return denied;
489
- const { channelId } = c.req.valid("param");
490
- const parsed = parseStudioExport(new Uint8Array(await c.req.arrayBuffer()), deps.clock.now().toISOString());
491
- if (!parsed.ok)
492
- return problem(c, { status: 400, title: titleOf(400), detail: parsed.message });
493
- saveReport(deps.db, channelId, parsed.report);
494
- statsFromReport(deps.db, parsed.report, parsed.report.importedAt);
495
- return c.json(reportBody(channelId));
496
- })
497
- // The Analytics view the extension exports every day for this channel; null forgets it.
498
- .put("/channels/:channelId/export-view", zValidator("param", z.object({ channelId: id }), onInvalid), zValidator("json", z.object({ url: z.string().max(4000).nullable() }), onInvalid), (c) => {
499
- const denied = samePage(c);
500
- if (denied !== undefined)
501
- return denied;
502
- const { channelId } = c.req.valid("param");
503
- const { url } = c.req.valid("json");
504
- const wrong = url === null || url.trim() === "" ? undefined : exportViewProblem(url);
505
- if (wrong !== undefined)
506
- return problem(c, {
507
- status: 400,
508
- title: titleOf(400),
509
- detail: wrong,
510
- extensions: { fields: [{ field: "url", message: wrong }] },
511
- });
512
- saveExportView(deps.db, channelId, url);
513
- return c.json(reportBody(channelId));
514
- })
515
- // The extension: each channel's Analytics view to export.
516
- .get("/ext/export-views", (c) => {
517
- allowOrigin(c, false);
518
- if (!extAllowed(c))
519
- return refused(c);
520
- return c.json({ views: exportViews(deps.db) });
521
- })
522
- // The extension exported a channel's view: the zip Studio's Export makes, as Studio sends
523
- // it (base64). Saved as an import would be, and each known video's numbers with it.
524
- .post("/ext/report", bodyLimit({
525
- maxSize: 40 * 1024 * 1024,
526
- onError: (c) => problem(c, {
527
- status: 413,
528
- title: titleOf(413),
529
- detail: "Studio's export was larger than Slopify takes (40 MB).",
530
- }),
531
- }), zValidator("json", z.object({
532
- channelId: id,
533
- zippedData: z
534
- .string()
535
- .min(1)
536
- .max(40 * 1024 * 1024),
537
- }), onInvalid), (c) => {
538
- allowOrigin(c, false);
539
- if (!extAllowed(c))
540
- return refused(c);
541
- const { channelId, zippedData } = c.req.valid("json");
542
- const now = deps.clock.now().toISOString();
543
- const parsed = parseStudioExport(new Uint8Array(Buffer.from(zippedData, "base64url")), now);
544
- if (!parsed.ok)
545
- return problem(c, { status: 400, title: titleOf(400), detail: parsed.message });
546
- saveReport(deps.db, channelId, parsed.report);
547
- const videos = statsFromReport(deps.db, parsed.report, now);
548
- return c.json({ rows: parsed.report.rows.length, videos });
549
- })
550
- // Channels → YouTube: the channel's videos on YouTube with Studio's numbers and totals.
551
- .get("/channels/:channelId/performance", zValidator("param", z.object({ channelId: id }), onInvalid), (c) => c.json(channelPerformance(deps, c.req.valid("param").channelId)))
552
448
  // The YouTube videos a project's uploads became, and their A/B tests.
553
449
  .get("/videos/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ videos: projectVideos(deps.db, c.req.valid("param").projectId) }))
554
450
  // A video's link pasted in Slopify, for an upload made without the extension; an empty
@@ -797,13 +693,12 @@ export function studioRoutes(deps) {
797
693
  setTaskState(deps.db, task, projectId, short ?? null, ok ? "done" : "failed", message);
798
694
  return c.json({ ok: true });
799
695
  })
800
- // Every video on YouTube, for the numbers the extension reads from Studio.
696
+ // The long videos whose A/B test is running, for the extension to read each result.
801
697
  .get("/ext/known-videos", (c) => {
802
698
  allowOrigin(c, false);
803
699
  if (!extAllowed(c))
804
700
  return refused(c);
805
701
  return c.json({
806
- // The export brings every video's numbers; only an A/B test's result needs a visit.
807
702
  videos: doneVideos(deps.db)
808
703
  .filter((video) => video.short === null && video.abState === "started")
809
704
  .map((video) => ({
@@ -864,26 +759,6 @@ export function studioRoutes(deps) {
864
759
  return refused(c);
865
760
  const body = c.req.valid("json");
866
761
  const readAt = deps.clock.now().toISOString();
867
- // A visit for the A/B result alone brings no numbers: the export's are kept.
868
- const numbers = [
869
- body.impressions,
870
- body.ctr,
871
- body.views,
872
- body.averageViewSeconds,
873
- body.watchHours,
874
- ].some((one) => one !== undefined);
875
- if (numbers)
876
- saveStats(deps.db, {
877
- projectId: body.projectId,
878
- short: body.short ?? null,
879
- videoId: body.videoId,
880
- readAt,
881
- impressions: body.impressions ?? null,
882
- ctr: body.ctr ?? null,
883
- views: body.views ?? null,
884
- averageViewSeconds: body.averageViewSeconds ?? null,
885
- watchHours: body.watchHours ?? null,
886
- });
887
762
  if (body.abVariants !== undefined && body.abVariants.length > 1)
888
763
  saveAbResult(deps.db, {
889
764
  projectId: body.projectId,
@@ -0,0 +1,10 @@
1
+ # Slopify 3.16.0
2
+
3
+ Released 9 October 2026.
4
+
5
+ - **Free space from inside Slopify.** Settings → Backup & storage → Disk space now shows each project's old versions (its History folder) next to its outputs and working files, and offers:
6
+ - **Delete old versions** on each project, and on a finished project's page under Free space: removes earlier renders and narration, and keeps the current version.
7
+ - **Delete all old versions**, for every project at once.
8
+ - **Clean up everything**: old versions for every project, and the working files of every finished one (Keep outputs only). Projects that are running or not finished keep everything.
9
+
10
+ Each asks first and says how much it frees. Your uploads and anything you put in History yourself are never removed. Going back to an old version, or changing a project later, makes those files again.
@@ -0,0 +1,10 @@
1
+ # Slopify 3.17.0
2
+
3
+ Released 10 October 2026.
4
+
5
+ - **YouTube numbers are gone.** Studio's export only carried daily figures for the few videos its chart showed, so the numbers were incomplete. The **YouTube numbers** tab in Channels is removed. So are the views and CTR on the Projects list and the numbers line under each video in **On YouTube**. The extension no longer exports the Analytics view every day or visits each video's Analytics, and the popup's **Read Studio now** is gone. **Library → A/B results** stays: once a day the extension still reads a running A/B test's result from the long video's Details page. (Extension 1.4.0.)
6
+
7
+ ## Upgrading from 3.16.0
8
+
9
+ - **Nothing becomes outdated.**
10
+ - The extension loaded from Slopify's own folder switches to 1.4.0 by itself. The store version updates through the store.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.17.0",
4
+ "title": "Slopify 3.17.0",
5
+ "version": "3.17.0",
6
+ "date": "2026-10-10"
7
+ },
8
+ {
9
+ "id": "3.16.0",
10
+ "title": "Slopify 3.16.0",
11
+ "version": "3.16.0",
12
+ "date": "2026-10-09"
13
+ },
2
14
  {
3
15
  "id": "3.15.0",
4
16
  "title": "Slopify 3.15.0",
@@ -1,7 +1,8 @@
1
- import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
1
+ import { existsSync, readdirSync, rmdirSync, statSync, unlinkSync } from "node:fs";
2
2
  import { join, relative, sep } from "node:path";
3
3
  import { derive } from "../../kernel/runner/graph.js";
4
- import { projectExists, projectPaused, stagesOf } from "../admission/repo.js";
4
+ import { liveProject, projectExists, projectPaused, stagesOf } from "../admission/repo.js";
5
+ import { projectSettled } from "./arrange.js";
5
6
  import { backupsFolderName, outputPath, projectDir } from "./layout.js";
6
7
  import { pieceFile } from "./reconcile.js";
7
8
  // Keep outputs only: a finished project drops the working files it was made from (images,
@@ -37,7 +38,14 @@ export function projectStorage(deps, projectId) {
37
38
  let workingBytes = 0;
38
39
  let removableFiles = 0;
39
40
  let removableBytes = 0;
41
+ const history = historyPaths(deps, projectId);
42
+ let historyFiles = 0;
43
+ let historyBytes = 0;
40
44
  for (const [path, bytes] of files) {
45
+ if (history.has(path)) {
46
+ historyFiles += 1;
47
+ historyBytes += bytes;
48
+ }
41
49
  if (outputs.has(path))
42
50
  outputsBytes += bytes;
43
51
  else
@@ -52,9 +60,110 @@ export function projectStorage(deps, projectId) {
52
60
  workingBytes,
53
61
  removableFiles,
54
62
  removableBytes,
63
+ historyFiles,
64
+ historyBytes,
55
65
  finished: finished(deps, projectId),
56
66
  };
57
67
  }
68
+ // Delete old versions: the files of older versions that arranging moved to History/, by their
69
+ // stored paths. Never a file the user supplied, nor one they put in History themselves. Going
70
+ // back to such a version makes its files again.
71
+ function historyPaths(deps, projectId) {
72
+ const supplied = suppliedPaths(deps.db, projectId);
73
+ const out = new Set();
74
+ for (const [path, place] of placedFiles(deps.paths, projectId))
75
+ if (place.startsWith("History/") && !supplied.has(path))
76
+ out.add(path);
77
+ return out;
78
+ }
79
+ function placedFiles(paths, projectId) {
80
+ const places = paths.places;
81
+ return places !== undefined && "placesOf" in places
82
+ ? places.placesOf(projectId)
83
+ : new Map();
84
+ }
85
+ export function deleteOldVersions(deps, projectId) {
86
+ if (!projectExists(deps.db, projectId))
87
+ return { ok: false, reason: "no-project" };
88
+ if (deps.hasInflight?.(projectId) === true || !projectSettled(deps.db, projectId))
89
+ return { ok: false, reason: "busy" };
90
+ const files = filesOf(deps.paths, projectId);
91
+ let count = 0;
92
+ let bytesFreed = 0;
93
+ for (const path of historyPaths(deps, projectId)) {
94
+ const bytes = files.get(path);
95
+ if (bytes === undefined)
96
+ continue;
97
+ unlinkSync(outputPath(deps.paths, projectId, path));
98
+ count += 1;
99
+ bytesFreed += bytes;
100
+ }
101
+ removeEmptyUnder(join(projectDir(deps.paths, projectId), "History"));
102
+ return { ok: true, files: count, bytesFreed };
103
+ }
104
+ // Every project's old versions, skipping any that runs or waits to run.
105
+ export function deleteAllOldVersions(deps) {
106
+ const busyIds = [];
107
+ let projects = 0;
108
+ let files = 0;
109
+ let bytesFreed = 0;
110
+ let busy = 0;
111
+ for (const row of deps.db.prepare(`SELECT id FROM projects WHERE ${liveProject()}`).all()) {
112
+ const id = String(row.id);
113
+ if (placedFiles(deps.paths, id).size === 0)
114
+ continue;
115
+ const result = deleteOldVersions(deps, id);
116
+ if (!result.ok) {
117
+ if (result.reason === "busy") {
118
+ busy += 1;
119
+ busyIds.push(id);
120
+ }
121
+ continue;
122
+ }
123
+ if (result.files > 0)
124
+ projects += 1;
125
+ files += result.files;
126
+ bytesFreed += result.bytesFreed;
127
+ }
128
+ return { projects, files, bytesFreed, busy, busyIds };
129
+ }
130
+ // Clean up: every project's old versions, and the working files of every finished one (Keep
131
+ // outputs only). What runs or waits to run is left as it is.
132
+ export function cleanUpAll(deps) {
133
+ const old = deleteAllOldVersions(deps);
134
+ let files = old.files;
135
+ let bytesFreed = old.bytesFreed;
136
+ let skipped = old.busy;
137
+ for (const row of deps.db.prepare(`SELECT id FROM projects WHERE ${liveProject()}`).all()) {
138
+ const id = String(row.id);
139
+ const result = keepOutputsOnly(deps, id);
140
+ if (result.ok) {
141
+ files += result.files;
142
+ bytesFreed += result.bytesFreed;
143
+ }
144
+ else if (result.reason === "not-finished" && !old.busyIds.includes(id))
145
+ skipped += 1;
146
+ }
147
+ return { files, bytesFreed, skipped };
148
+ }
149
+ // The folders under `root` left empty, deepest first, and `root` itself if it is empty then.
150
+ function removeEmptyUnder(root) {
151
+ if (!existsSync(root))
152
+ return;
153
+ const walk = (dir) => {
154
+ let empty = true;
155
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
156
+ if (entry.isDirectory() && walk(join(dir, entry.name))) {
157
+ rmdirSync(join(dir, entry.name));
158
+ continue;
159
+ }
160
+ empty = false;
161
+ }
162
+ return empty;
163
+ };
164
+ if (walk(root))
165
+ rmdirSync(root);
166
+ }
58
167
  export function keepOutputsOnly(deps, projectId) {
59
168
  if (!projectExists(deps.db, projectId))
60
169
  return { ok: false, reason: "no-project" };
@@ -114,7 +223,11 @@ function outputPaths(db, projectId) {
114
223
  // The outputs, and every file the user supplied: an asset any saved revision or the project's
115
224
  // settings name, or a legacy output row that came from an upload.
116
225
  function keptPaths(db, projectId) {
117
- const kept = new Set(outputPaths(db, projectId));
226
+ return new Set([...outputPaths(db, projectId), ...suppliedPaths(db, projectId)]);
227
+ }
228
+ // The files the user supplied: nothing could make them again, so nothing removes them.
229
+ function suppliedPaths(db, projectId) {
230
+ const kept = new Set();
118
231
  for (const path of pathsOf(db
119
232
  .prepare(`SELECT a.path FROM project_assets a WHERE a.project_id=? AND (
120
233
  EXISTS(SELECT 1 FROM project_revisions r WHERE r.project_id=a.project_id
@@ -242,38 +242,3 @@ export function studioRequestAllowed(db, token, origin) {
242
242
  return true;
243
243
  return pairing.origin !== null && origin === pairing.origin;
244
244
  }
245
- // Each channel's Studio Analytics view (Advanced mode's Explore link with its chosen columns),
246
- // which the extension opens every day to export into Channels → YouTube. The start stays as
247
- // saved; the extension moves the end to today.
248
- export const studioExportViewPrefix = "studio.exportView.";
249
- export function exportViewProblem(url) {
250
- let parsed;
251
- try {
252
- parsed = new URL(url.trim());
253
- }
254
- catch {
255
- return "That isn't a link. In YouTube Studio, open Analytics → Advanced mode, set it up, and copy the address from the browser's address bar.";
256
- }
257
- if (parsed.origin !== "https://studio.youtube.com" ||
258
- !/^\/channel\/UC[A-Za-z0-9_-]+\/analytics\/.+\/explore$/.test(parsed.pathname))
259
- return "That isn't a Studio Analytics Advanced mode link. In YouTube Studio, open Analytics → Advanced mode, set it up, and copy the address from the browser's address bar.";
260
- if (!parsed.searchParams.has("time_period"))
261
- return "That link has no date range. In Advanced mode, pick the dates (the start stays as you set it; the end follows today), then copy the address again.";
262
- return undefined;
263
- }
264
- export function readExportView(db, channelId) {
265
- return readSetting(db, `${studioExportViewPrefix}${channelId}`) ?? null;
266
- }
267
- export function saveExportView(db, channelId, url) {
268
- const key = `${studioExportViewPrefix}${channelId}`;
269
- if (url === null || url.trim() === "")
270
- db.prepare("DELETE FROM settings WHERE key = ?").run(key);
271
- else
272
- writeSetting(db, key, url.trim());
273
- }
274
- export function exportViews(db) {
275
- const prefix = studioExportViewPrefix;
276
- return db
277
- .prepare("SELECT key, value FROM settings WHERE substr(key, 1, ?) = ? ORDER BY key")
278
- .all(prefix.length, prefix).map((row) => ({ channelId: row.key.slice(prefix.length), url: row.value }));
279
- }
@@ -1,4 +1,6 @@
1
1
  import { z } from "zod";
2
+ // Finished A/B tests as Studio shows them, read by the extension from the video's Analytics.
3
+ // Slopify never asks YouTube's API: these are what Studio showed when the extension last looked.
2
4
  export const abVariantSchema = z.object({
3
5
  title: z.string().max(200).nullable(),
4
6
  // Which thumbnail (1-3) the variant showed, when Studio says.
@@ -8,40 +10,6 @@ export const abVariantSchema = z.object({
8
10
  winner: z.boolean(),
9
11
  });
10
12
  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
13
  export function saveAbResult(db, result) {
46
14
  db.prepare(`INSERT INTO ab_results(project_id,short,video_id,read_at,variants) VALUES (?,?,?,?,?)
47
15
  ON CONFLICT(project_id,short) DO UPDATE SET video_id=excluded.video_id, read_at=excluded.read_at,
@@ -142,6 +142,16 @@ If you change that project later (edit an image, a caption style or the narratio
142
142
 
143
143
  A finished project's own page offers the same in its right rail: **Free space** with **Free 1.2 GB: keep the outputs, drop the working files** (see [Project Page](Project-Page#free-space)).
144
144
 
145
+ ### Delete old versions and Clean up everything
146
+
147
+ **Delete old versions** removes a project's `History/` folder: the files of earlier versions, such as a previous render or the narration before a voice change. The current version in `Upload/` and `Working/`, your uploads and anything you put in History yourself stay. Going back to one of those versions makes its files again, which takes time and provider credits.
148
+
149
+ - **One project:** the **Delete old versions** button on its row in **Disk space**, or under **Free space** on its project page. It says how much it frees.
150
+ - **Every project:** **Delete all old versions** above the **Disk space** list.
151
+ - **Clean up everything** does Delete old versions for every project and **Keep outputs only** for every finished one. Projects that are running or not finished keep everything.
152
+
153
+ Each asks first. Deleting the files in your file manager works the same way.
154
+
145
155
  ### Clear leftover files
146
156
 
147
157
  **Clear leftover files** deletes files in the projects folder that no project records any more (never anything in a project's `Upload/`, `Working/` or `History/`, and never a folder Slopify did not make), and uploaded files that no draft, template or project uses. Your projects, outputs and library are never touched. Slopify also does this each time it starts; use it after a crash or to free space now.