@gentbajko/slopify 3.5.2 → 3.6.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 +40 -0
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0050-studio-reports.sql +8 -0
- package/dist/patch-notes/3.6.0.md +9 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/studio/report.js +162 -0
- package/dist/web/assets/index-BII4kM5o.js +310 -0
- package/dist/web/assets/{index-BGnKvlNS.css → index-Cg3L24dx.css} +1 -1
- package/dist/web/assets/{pdf-C7lfRlHt.js → pdf-L47MTvvZ.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-ePjxsfy3.js +0 -310
package/dist/edge/http/studio.js
CHANGED
|
@@ -3,6 +3,7 @@ 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";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { derive } from "../../kernel/runner/graph.js";
|
|
8
9
|
import { listProjects, projectById, stagesOf } from "../../slices/admission/repo.js";
|
|
@@ -19,6 +20,7 @@ import { postingPlanSchema, readPlan, writePlan } from "../../slices/studio/plan
|
|
|
19
20
|
import { leadHoursMax, seriesOf } from "../../slices/studio/plan-model.js";
|
|
20
21
|
import { enqueueFill, fillNow, readFillQueue, removeFill, } from "../../slices/studio/queue.js";
|
|
21
22
|
import { allReleases, freeSlots, planReleases, readLeadHours, releasesOf, setRelease, writeLeadHours, } from "../../slices/studio/releases.js";
|
|
23
|
+
import { parseStudioExport, readReport, saveReport } from "../../slices/studio/report.js";
|
|
22
24
|
import { autoCommentKey, bearerToken, isExtensionOrigin, pairStudioExtension, readChannelPlaylists, readStudioPlaylists, resetStudioPairing, saveProjectPlaylists, saveRealFootage, saveStudioPlaylists, studioPairing, studioPlaylistsMax, studioPlaylistsProblem, studioRequestAllowed, } from "../../slices/studio/settings.js";
|
|
23
25
|
import { abResults, abVariantSchema, projectStats, saveAbResult, saveStats, } from "../../slices/studio/stats.js";
|
|
24
26
|
import { confirmUpload, doneVideos, forgetVideo, projectVideos, recordVideo, setChecks, setTaskState, videoIdOf, videoIdPattern, videoOf, waitingTasks, } from "../../slices/studio/videos.js";
|
|
@@ -166,6 +168,23 @@ export function studioRoutes(deps) {
|
|
|
166
168
|
.filter((one) => one !== "")
|
|
167
169
|
.toSorted(),
|
|
168
170
|
});
|
|
171
|
+
// A report and, for each of its videos Slopify made, the project and which upload it is.
|
|
172
|
+
const reportBody = (channelId) => {
|
|
173
|
+
const report = readReport(deps.db, channelId);
|
|
174
|
+
const known = new Map(listProjects(deps.db).flatMap((project) => projectVideos(deps.db, project.id).map((video) => [
|
|
175
|
+
video.videoId,
|
|
176
|
+
{ projectId: project.id, short: video.short, projectTitle: project.title },
|
|
177
|
+
])));
|
|
178
|
+
return {
|
|
179
|
+
report,
|
|
180
|
+
projects: report === null
|
|
181
|
+
? {}
|
|
182
|
+
: Object.fromEntries(report.rows.flatMap((row) => {
|
|
183
|
+
const found = known.get(row.videoId);
|
|
184
|
+
return found === undefined ? [] : [[row.videoId, found]];
|
|
185
|
+
})),
|
|
186
|
+
};
|
|
187
|
+
};
|
|
169
188
|
const slotChoices = (projectId, series) => freeSlots(deps.db, readPlan(deps.db), series, deps.clock.now(), 9);
|
|
170
189
|
// Finished projects not marked uploaded: the popup's list and the calendar's candidates.
|
|
171
190
|
const finishedProjects = () => {
|
|
@@ -381,6 +400,27 @@ export function studioRoutes(deps) {
|
|
|
381
400
|
// Studio's numbers for a project's videos, and every finished A/B test (Library).
|
|
382
401
|
.get("/stats/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ stats: projectStats(deps.db, c.req.valid("param").projectId) }))
|
|
383
402
|
.get("/ab-results", (c) => c.json({ results: abResults(deps.db) }))
|
|
403
|
+
// Channels → YouTube numbers: Studio's Advanced-mode export, imported as the zip Studio
|
|
404
|
+
// downloads, and which project each of its videos is.
|
|
405
|
+
.get("/channels/:channelId/report", zValidator("param", z.object({ channelId: id }), onInvalid), (c) => c.json(reportBody(c.req.valid("param").channelId)))
|
|
406
|
+
.post("/channels/:channelId/report", zValidator("param", z.object({ channelId: id }), onInvalid), bodyLimit({
|
|
407
|
+
maxSize: 30 * 1024 * 1024,
|
|
408
|
+
onError: (c) => problem(c, {
|
|
409
|
+
status: 413,
|
|
410
|
+
title: titleOf(413),
|
|
411
|
+
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.",
|
|
412
|
+
}),
|
|
413
|
+
}), async (c) => {
|
|
414
|
+
const denied = samePage(c);
|
|
415
|
+
if (denied !== undefined)
|
|
416
|
+
return denied;
|
|
417
|
+
const { channelId } = c.req.valid("param");
|
|
418
|
+
const parsed = parseStudioExport(new Uint8Array(await c.req.arrayBuffer()), deps.clock.now().toISOString());
|
|
419
|
+
if (!parsed.ok)
|
|
420
|
+
return problem(c, { status: 400, title: titleOf(400), detail: parsed.message });
|
|
421
|
+
saveReport(deps.db, channelId, parsed.report);
|
|
422
|
+
return c.json(reportBody(channelId));
|
|
423
|
+
})
|
|
384
424
|
// Channels → YouTube: the channel's videos on YouTube with Studio's numbers and totals.
|
|
385
425
|
.get("/channels/:channelId/performance", zValidator("param", z.object({ channelId: id }), onInvalid), (c) => c.json(channelPerformance(deps, c.req.valid("param").channelId)))
|
|
386
426
|
// The YouTube videos a project's uploads became, and their A/B tests.
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- Studio's Advanced-mode export, one per channel (3.6.0): the table's columns and rows (one
|
|
2
|
+
-- per video, every metric the person chose in Studio) and each video's daily figures for the
|
|
3
|
+
-- chart's metric, as the last imported zip held them. A new import replaces the old one.
|
|
4
|
+
CREATE TABLE studio_reports (
|
|
5
|
+
channel_id TEXT PRIMARY KEY,
|
|
6
|
+
imported_at TEXT NOT NULL,
|
|
7
|
+
report TEXT NOT NULL CHECK (json_valid(report))
|
|
8
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Slopify 3.6.0
|
|
2
|
+
|
|
3
|
+
Released 3 October 2026.
|
|
4
|
+
|
|
5
|
+
## Channels → YouTube numbers
|
|
6
|
+
|
|
7
|
+
- **Import from Studio.** Export Studio's Advanced mode (Analytics → Advanced mode, choose your metrics as columns, the Export arrow → Comma-separated values) and import the zip it downloads. Every video becomes a row with every metric you chose: views, average view duration, percentage viewed, RPM, revenue, watch time, subscribers, impressions, click-through rate, and anything else Studio offers.
|
|
8
|
+
- **Compare at a glance.** Order the videos by any column, show or hide columns (Slopify remembers your choice in this browser), and see each video's daily figure for the chart's metric as a small line. Videos Slopify made link to their projects; your older videos are listed too.
|
|
9
|
+
- A new import replaces the old one.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { strFromU8, unzipSync } from "fflate";
|
|
2
|
+
// RFC 4180: commas, quoted fields with "" for a quote, CRLF or LF lines.
|
|
3
|
+
export function parseCsv(text) {
|
|
4
|
+
const rows = [];
|
|
5
|
+
let row = [];
|
|
6
|
+
let field = "";
|
|
7
|
+
let quoted = false;
|
|
8
|
+
const body = text.replace(/^/, "");
|
|
9
|
+
for (let at = 0; at < body.length; at += 1) {
|
|
10
|
+
const char = body[at];
|
|
11
|
+
if (quoted) {
|
|
12
|
+
if (char === '"' && body[at + 1] === '"') {
|
|
13
|
+
field += '"';
|
|
14
|
+
at += 1;
|
|
15
|
+
}
|
|
16
|
+
else if (char === '"')
|
|
17
|
+
quoted = false;
|
|
18
|
+
else
|
|
19
|
+
field += char;
|
|
20
|
+
}
|
|
21
|
+
else if (char === '"')
|
|
22
|
+
quoted = true;
|
|
23
|
+
else if (char === ",") {
|
|
24
|
+
row.push(field);
|
|
25
|
+
field = "";
|
|
26
|
+
}
|
|
27
|
+
else if (char === "\n" || char === "\r") {
|
|
28
|
+
if (char === "\r" && body[at + 1] === "\n")
|
|
29
|
+
at += 1;
|
|
30
|
+
row.push(field);
|
|
31
|
+
rows.push(row);
|
|
32
|
+
row = [];
|
|
33
|
+
field = "";
|
|
34
|
+
}
|
|
35
|
+
else
|
|
36
|
+
field += char;
|
|
37
|
+
}
|
|
38
|
+
if (field !== "" || row.length > 0) {
|
|
39
|
+
row.push(field);
|
|
40
|
+
rows.push(row);
|
|
41
|
+
}
|
|
42
|
+
return rows.filter((one) => one.some((cell) => cell !== ""));
|
|
43
|
+
}
|
|
44
|
+
const durationPattern = /^(\d+):(\d{2})(?::(\d{2}))?$/;
|
|
45
|
+
function seconds(text) {
|
|
46
|
+
const match = durationPattern.exec(text.trim());
|
|
47
|
+
if (match === null)
|
|
48
|
+
return null;
|
|
49
|
+
const [, a, b, c] = match;
|
|
50
|
+
return c === undefined
|
|
51
|
+
? Number(a) * 60 + Number(b)
|
|
52
|
+
: Number(a) * 3600 + Number(b) * 60 + Number(c);
|
|
53
|
+
}
|
|
54
|
+
function cell(text, kind) {
|
|
55
|
+
const plain = text.trim();
|
|
56
|
+
if (plain === "")
|
|
57
|
+
return null;
|
|
58
|
+
if (kind === "duration")
|
|
59
|
+
return seconds(plain);
|
|
60
|
+
if (kind === "number") {
|
|
61
|
+
const value = Number(plain.replace(/,/g, ""));
|
|
62
|
+
return Number.isFinite(value) ? value : null;
|
|
63
|
+
}
|
|
64
|
+
return plain;
|
|
65
|
+
}
|
|
66
|
+
// The columns before the metrics: Content (the video id), Video title, Video publish time and
|
|
67
|
+
// Duration. Studio names them in the account's language; their places are fixed.
|
|
68
|
+
const fixedColumns = 4;
|
|
69
|
+
export function parseStudioExport(bytes, importedAt) {
|
|
70
|
+
let files;
|
|
71
|
+
try {
|
|
72
|
+
files = unzipSync(bytes, { filter: (file) => file.name.toLowerCase().endsWith(".csv") });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
files = { "Table data.csv": bytes };
|
|
76
|
+
}
|
|
77
|
+
const named = (part) => Object.entries(files).find(([name]) => name.toLowerCase().includes(part))?.[1];
|
|
78
|
+
const tableBytes = named("table") ?? (Object.keys(files).length === 1 ? Object.values(files)[0] : undefined);
|
|
79
|
+
if (tableBytes === undefined)
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
message: "That file has no table: export from Studio's Advanced mode (Analytics → Advanced mode → the Export arrow → Comma-separated values) and import the zip it downloads.",
|
|
83
|
+
};
|
|
84
|
+
const table = parseCsv(strFromU8(tableBytes));
|
|
85
|
+
const header = table[0];
|
|
86
|
+
if (header === undefined || header.length <= fixedColumns || table.length < 2)
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
message: "That table has no videos or no metrics. In Studio's Advanced mode, show the Content dimension with at least one metric, then export again.",
|
|
90
|
+
};
|
|
91
|
+
const body = table.slice(1);
|
|
92
|
+
const totalRow = body.find((row) => /^total$/i.test((row[0] ?? "").trim()));
|
|
93
|
+
const videoRows = body.filter((row) => row !== totalRow && /^[A-Za-z0-9_-]{11}$/.test((row[0] ?? "").trim()));
|
|
94
|
+
const columns = header.slice(fixedColumns).map((label, index) => {
|
|
95
|
+
const samples = [totalRow, ...videoRows]
|
|
96
|
+
.map((row) => (row?.[fixedColumns + index] ?? "").trim())
|
|
97
|
+
.filter((value) => value !== "");
|
|
98
|
+
const kind = samples.length > 0 && samples.every((value) => durationPattern.test(value))
|
|
99
|
+
? "duration"
|
|
100
|
+
: samples.every((value) => Number.isFinite(Number(value.replace(/,/g, ""))))
|
|
101
|
+
? "number"
|
|
102
|
+
: "text";
|
|
103
|
+
return { label: label.trim(), kind };
|
|
104
|
+
});
|
|
105
|
+
const valuesOf = (row) => columns.map((column, index) => cell(row[fixedColumns + index] ?? "", column.kind));
|
|
106
|
+
// The chart: Date, Content, …, then the metric; one line per video per day.
|
|
107
|
+
const chartBytes = named("chart");
|
|
108
|
+
const chart = chartBytes === undefined ? [] : parseCsv(strFromU8(chartBytes));
|
|
109
|
+
const chartMetric = chart[0]?.at(-1)?.trim() ?? null;
|
|
110
|
+
const days = [...new Set(chart.slice(1).map((row) => row[0] ?? ""))].filter(Boolean).toSorted();
|
|
111
|
+
const dayIndex = new Map(days.map((day, index) => [day, index]));
|
|
112
|
+
const daily = new Map();
|
|
113
|
+
for (const row of chart.slice(1)) {
|
|
114
|
+
const id = (row[1] ?? "").trim();
|
|
115
|
+
const at = dayIndex.get(row[0] ?? "");
|
|
116
|
+
if (at === undefined || id === "")
|
|
117
|
+
continue;
|
|
118
|
+
const series = daily.get(id) ?? Array.from({ length: days.length }, () => 0);
|
|
119
|
+
series[at] = Number(row.at(-1)) || 0;
|
|
120
|
+
daily.set(id, series);
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
report: {
|
|
125
|
+
importedAt,
|
|
126
|
+
chartMetric: chart.length > 1 ? chartMetric : null,
|
|
127
|
+
from: days[0] ?? null,
|
|
128
|
+
to: days.at(-1) ?? null,
|
|
129
|
+
columns,
|
|
130
|
+
totals: totalRow === undefined ? columns.map(() => null) : valuesOf(totalRow),
|
|
131
|
+
rows: videoRows.map((row) => {
|
|
132
|
+
const id = (row[0] ?? "").trim();
|
|
133
|
+
return {
|
|
134
|
+
videoId: id,
|
|
135
|
+
title: (row[1] ?? "").trim(),
|
|
136
|
+
published: (row[2] ?? "").trim() || null,
|
|
137
|
+
duration: (() => {
|
|
138
|
+
const value = Number((row[3] ?? "").trim());
|
|
139
|
+
return Number.isFinite(value) && (row[3] ?? "").trim() !== "" ? value : null;
|
|
140
|
+
})(),
|
|
141
|
+
values: valuesOf(row),
|
|
142
|
+
daily: daily.get(id) ?? [],
|
|
143
|
+
};
|
|
144
|
+
}),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function saveReport(db, channelId, report) {
|
|
149
|
+
db.prepare(`INSERT INTO studio_reports(channel_id, imported_at, report) VALUES (?,?,?)
|
|
150
|
+
ON CONFLICT(channel_id) DO UPDATE SET imported_at=excluded.imported_at, report=excluded.report`).run(channelId, report.importedAt, JSON.stringify(report));
|
|
151
|
+
}
|
|
152
|
+
export function readReport(db, channelId) {
|
|
153
|
+
const row = db.prepare("SELECT report FROM studio_reports WHERE channel_id=?").get(channelId);
|
|
154
|
+
if (row === undefined)
|
|
155
|
+
return null;
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(String(row.report));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|