@gentbajko/slopify 3.8.7 → 3.9.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.
@@ -23,7 +23,8 @@ import { leadHoursMax, seriesOf } from "../../slices/studio/plan-model.js";
23
23
  import { enqueueFill, fillOnly, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
24
24
  import { allReleases, freeSlots, planReleases, readLeadHours, releasesOf, setRelease, swapReleases, writeLeadHours, } from "../../slices/studio/releases.js";
25
25
  import { parseStudioExport, readReport, saveReport } from "../../slices/studio/report.js";
26
- import { autoCommentKey, bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.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";
27
28
  import { abResults, abVariantSchema, projectStats, saveAbResult, saveStats, } from "../../slices/studio/stats.js";
28
29
  import { confirmUpload, doneVideos, forgetVideo, projectVideos, recordVideo, setChecks, setTaskState, videoIdOf, videoIdPattern, videoOf, waitingTasks, } from "../../slices/studio/videos.js";
29
30
  import { setAsideProjects, uploadedProjects } from "../../slices/uploads/repo.js";
@@ -195,6 +196,7 @@ export function studioRoutes(deps) {
195
196
  { projectId: project.id, short: video.short, projectTitle: project.title },
196
197
  ])));
197
198
  return {
199
+ exportView: readExportView(deps.db, channelId),
198
200
  report,
199
201
  projects: report === null
200
202
  ? {}
@@ -484,7 +486,61 @@ export function studioRoutes(deps) {
484
486
  if (!parsed.ok)
485
487
  return problem(c, { status: 400, title: titleOf(400), detail: parsed.message });
486
488
  saveReport(deps.db, channelId, parsed.report);
489
+ statsFromReport(deps.db, parsed.report, parsed.report.importedAt);
487
490
  return c.json(reportBody(channelId));
491
+ })
492
+ // The Analytics view the extension exports every day for this channel; null forgets it.
493
+ .put("/channels/:channelId/export-view", zValidator("param", z.object({ channelId: id }), onInvalid), zValidator("json", z.object({ url: z.string().max(4000).nullable() }), onInvalid), (c) => {
494
+ const denied = samePage(c);
495
+ if (denied !== undefined)
496
+ return denied;
497
+ const { channelId } = c.req.valid("param");
498
+ const { url } = c.req.valid("json");
499
+ const wrong = url === null || url.trim() === "" ? undefined : exportViewProblem(url);
500
+ if (wrong !== undefined)
501
+ return problem(c, {
502
+ status: 400,
503
+ title: titleOf(400),
504
+ detail: wrong,
505
+ extensions: { fields: [{ field: "url", message: wrong }] },
506
+ });
507
+ saveExportView(deps.db, channelId, url);
508
+ return c.json(reportBody(channelId));
509
+ })
510
+ // The extension: each channel's Analytics view to export.
511
+ .get("/ext/export-views", (c) => {
512
+ allowOrigin(c, false);
513
+ if (!extAllowed(c))
514
+ return refused(c);
515
+ return c.json({ views: exportViews(deps.db) });
516
+ })
517
+ // The extension exported a channel's view: the zip Studio's Export makes, as Studio sends
518
+ // it (base64). Saved as an import would be, and each known video's numbers with it.
519
+ .post("/ext/report", bodyLimit({
520
+ maxSize: 40 * 1024 * 1024,
521
+ onError: (c) => problem(c, {
522
+ status: 413,
523
+ title: titleOf(413),
524
+ detail: "Studio's export was larger than Slopify takes (40 MB).",
525
+ }),
526
+ }), zValidator("json", z.object({
527
+ channelId: id,
528
+ zippedData: z
529
+ .string()
530
+ .min(1)
531
+ .max(40 * 1024 * 1024),
532
+ }), onInvalid), (c) => {
533
+ allowOrigin(c, false);
534
+ if (!extAllowed(c))
535
+ return refused(c);
536
+ const { channelId, zippedData } = c.req.valid("json");
537
+ const now = deps.clock.now().toISOString();
538
+ const parsed = parseStudioExport(new Uint8Array(Buffer.from(zippedData, "base64url")), now);
539
+ if (!parsed.ok)
540
+ return problem(c, { status: 400, title: titleOf(400), detail: parsed.message });
541
+ saveReport(deps.db, channelId, parsed.report);
542
+ const videos = statsFromReport(deps.db, parsed.report, now);
543
+ return c.json({ rows: parsed.report.rows.length, videos });
488
544
  })
489
545
  // Channels → YouTube: the channel's videos on YouTube with Studio's numbers and totals.
490
546
  .get("/channels/:channelId/performance", zValidator("param", z.object({ channelId: id }), onInvalid), (c) => c.json(channelPerformance(deps, c.req.valid("param").channelId)))
@@ -742,7 +798,10 @@ export function studioRoutes(deps) {
742
798
  if (!extAllowed(c))
743
799
  return refused(c);
744
800
  return c.json({
745
- videos: doneVideos(deps.db).map((video) => ({
801
+ // The export brings every video's numbers; only an A/B test's result needs a visit.
802
+ videos: doneVideos(deps.db)
803
+ .filter((video) => video.short === null && video.abState === "started")
804
+ .map((video) => ({
746
805
  projectId: video.projectId,
747
806
  short: video.short,
748
807
  videoId: video.videoId,
@@ -755,17 +814,26 @@ export function studioRoutes(deps) {
755
814
  return refused(c);
756
815
  const body = c.req.valid("json");
757
816
  const readAt = deps.clock.now().toISOString();
758
- saveStats(deps.db, {
759
- projectId: body.projectId,
760
- short: body.short ?? null,
761
- videoId: body.videoId,
762
- readAt,
763
- impressions: body.impressions ?? null,
764
- ctr: body.ctr ?? null,
765
- views: body.views ?? null,
766
- averageViewSeconds: body.averageViewSeconds ?? null,
767
- watchHours: body.watchHours ?? null,
768
- });
817
+ // A visit for the A/B result alone brings no numbers: the export's are kept.
818
+ const numbers = [
819
+ body.impressions,
820
+ body.ctr,
821
+ body.views,
822
+ body.averageViewSeconds,
823
+ body.watchHours,
824
+ ].some((one) => one !== undefined);
825
+ if (numbers)
826
+ saveStats(deps.db, {
827
+ projectId: body.projectId,
828
+ short: body.short ?? null,
829
+ videoId: body.videoId,
830
+ readAt,
831
+ impressions: body.impressions ?? null,
832
+ ctr: body.ctr ?? null,
833
+ views: body.views ?? null,
834
+ averageViewSeconds: body.averageViewSeconds ?? null,
835
+ watchHours: body.watchHours ?? null,
836
+ });
769
837
  if (body.abVariants !== undefined && body.abVariants.length > 1)
770
838
  saveAbResult(deps.db, {
771
839
  projectId: body.projectId,
@@ -0,0 +1,17 @@
1
+ # Slopify 3.9.0
2
+
3
+ Released 4 October 2026.
4
+
5
+ ## Studio's numbers, every day, by themselves
6
+
7
+ - **The extension exports your Analytics view daily.** Paste the address of your YouTube Studio Analytics → Advanced mode view (with the columns you chose) into Channels → YouTube → From Studio's export, and press Save view. Once a day the extension opens it in the background with the end date moved to today (the start stays as you set it), presses Export → Comma-separated values, and brings Studio's zip straight into Slopify: no file lands in your Downloads, and no import by hand.
8
+ - **Every video's numbers come from that one export.** Each video you uploaded with Slopify gets its views, impressions, click-through rate, average view and watch hours from it, on its project page and in Channels → YouTube. The extension no longer opens each video's Analytics; it only opens a long video whose A/B test is running, to read its result.
9
+ - **The channel's total.** The export's third file (Totals.csv) is read too: tick Channel total above the chart to draw the whole view's daily figure.
10
+
11
+ ## Channels → YouTube
12
+
13
+ - **One video's chart opens under its row.** Press the arrow beside a video in the From Studio's export table to see its own chart; the grid of every video's chart is gone.
14
+
15
+ ## YouTube Studio extension (1.2.0)
16
+
17
+ Download the extension again from Settings → YouTube Studio and reload it.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "id": "3.9.0",
4
+ "title": "Slopify 3.9.0",
5
+ "version": "3.9.0",
6
+ "date": "2026-10-04"
7
+ },
2
8
  {
3
9
  "id": "3.8.7",
4
10
  "title": "Slopify 3.8.7",
@@ -0,0 +1,39 @@
1
+ import { saveStats } from "./stats.js";
2
+ import { doneVideos } from "./videos.js";
3
+ // Studio's export holds every video's numbers: each one Slopify uploaded gets them as its
4
+ // project's numbers (the project page and Channels → YouTube), so the extension needn't open
5
+ // each video's Analytics. A column the export doesn't have is left empty.
6
+ const find = (report, test, not) => report.columns.findIndex((column) => test.test(column.label) && !not?.test(column.label));
7
+ export function statsFromReport(db, report, readAt) {
8
+ const at = {
9
+ impressions: find(report, /impressions/i, /rate|ctr/i),
10
+ ctr: find(report, /click-through rate|ctr/i),
11
+ views: find(report, /^views$/i),
12
+ averageViewSeconds: find(report, /^average view duration$/i),
13
+ watchHours: find(report, /^watch time \(hours\)$/i),
14
+ };
15
+ const value = (row, index) => {
16
+ const one = index < 0 ? null : row.values[index];
17
+ return typeof one === "number" ? one : null;
18
+ };
19
+ const rows = new Map(report.rows.map((row) => [row.videoId, row]));
20
+ let saved = 0;
21
+ for (const video of doneVideos(db)) {
22
+ const row = rows.get(video.videoId);
23
+ if (row === undefined)
24
+ continue;
25
+ saveStats(db, {
26
+ projectId: video.projectId,
27
+ short: video.short,
28
+ videoId: video.videoId,
29
+ readAt,
30
+ impressions: value(row, at.impressions),
31
+ ctr: value(row, at.ctr),
32
+ views: value(row, at.views),
33
+ averageViewSeconds: value(row, at.averageViewSeconds),
34
+ watchHours: value(row, at.watchHours),
35
+ });
36
+ saved += 1;
37
+ }
38
+ return saved;
39
+ }
@@ -119,10 +119,20 @@ export function parseStudioExport(bytes, importedAt) {
119
119
  series[at] = Number(row.at(-1)) || 0;
120
120
  daily.set(id, series);
121
121
  }
122
+ // Totals.csv: Date, then the metric; the whole view's figure per day.
123
+ const totalsBytes = named("totals");
124
+ const totals = totalsBytes === undefined ? [] : parseCsv(strFromU8(totalsBytes)).slice(1);
125
+ const dailyTotals = Array.from({ length: days.length }, () => 0);
126
+ for (const row of totals) {
127
+ const at = dayIndex.get(row[0] ?? "");
128
+ if (at !== undefined)
129
+ dailyTotals[at] = Number(row.at(-1)) || 0;
130
+ }
122
131
  return {
123
132
  ok: true,
124
133
  report: {
125
134
  importedAt,
135
+ ...(totals.length > 0 && days.length > 0 ? { dailyTotals } : {}),
126
136
  chartMetric: chart.length > 1 ? chartMetric : null,
127
137
  from: days[0] ?? null,
128
138
  to: days.at(-1) ?? null,
@@ -242,3 +242,38 @@ 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
+ }