@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.
- package/dist/edge/http/studio.js +81 -13
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/patch-notes/3.9.0.md +17 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/studio/report-stats.js +39 -0
- package/dist/slices/studio/report.js +10 -0
- package/dist/slices/studio/settings.js +35 -0
- package/dist/web/assets/{index-CAkfWk98.js → index-IWb_SctN.js} +24 -24
- package/dist/web/assets/{index-D4rGFI9z.css → index-_D68Tk8q.css} +1 -1
- package/dist/web/assets/{pdf-DlfMpxou.js → pdf-D04vvdOD.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
package/dist/edge/http/studio.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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,
|
|
Binary file
|
|
Binary file
|
|
@@ -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.
|
|
@@ -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
|
+
}
|