@gentbajko/slopify 3.16.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.
- package/dist/edge/http/projects.js +0 -8
- package/dist/edge/http/studio.js +5 -130
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/patch-notes/3.17.0.md +10 -0
- package/dist/patch-notes/index.json +6 -0
- package/dist/slices/studio/settings.js +0 -35
- package/dist/slices/studio/stats.js +2 -34
- package/dist/web/assets/index-CbmBh8V-.js +325 -0
- package/dist/web/assets/{index-VbpUYLAp.css → index-DpdXRssU.css} +1 -1
- package/dist/web/assets/{pdf-C6KhxeKQ.js → pdf-B42WW32R.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/slices/studio/performance.js +0 -83
- package/dist/slices/studio/report-stats.js +0 -39
- package/dist/slices/studio/report.js +0 -172
- package/dist/web/assets/index-Dvz8bsiD.js +0 -325
package/dist/web/index.html
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
7
|
<meta name="color-scheme" content="dark light" />
|
|
8
8
|
<title>Slopify</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-CbmBh8V-.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DpdXRssU.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { listProjects } from "../admission/repo.js";
|
|
2
|
-
import { defaultChannelId } from "../channels/model.js";
|
|
3
|
-
import { projectChannels } from "../channels/repo.js";
|
|
4
|
-
import { uploadPack } from "./pack.js";
|
|
5
|
-
import { releasesOf } from "./releases.js";
|
|
6
|
-
import { projectStats } from "./stats.js";
|
|
7
|
-
import { projectVideos } from "./videos.js";
|
|
8
|
-
function totals(rows) {
|
|
9
|
-
const read = rows.flatMap((row) => (row.stats === null ? [] : [row.stats]));
|
|
10
|
-
const sum = (pick) => read.reduce((total, stats) => total + (pick(stats) ?? 0), 0);
|
|
11
|
-
const impressions = sum((stats) => stats.impressions);
|
|
12
|
-
const clicks = read.reduce((total, stats) => total +
|
|
13
|
-
(stats.impressions !== null && stats.ctr !== null
|
|
14
|
-
? (stats.impressions * stats.ctr) / 100
|
|
15
|
-
: 0), 0);
|
|
16
|
-
const viewed = read.filter((stats) => stats.views !== null && stats.averageViewSeconds !== null);
|
|
17
|
-
const viewWeight = viewed.reduce((total, stats) => total + (stats.views ?? 0), 0);
|
|
18
|
-
return {
|
|
19
|
-
videos: rows.length,
|
|
20
|
-
views: sum((stats) => stats.views),
|
|
21
|
-
impressions: read.some((stats) => stats.impressions !== null) ? impressions : null,
|
|
22
|
-
ctr: impressions > 0 ? Math.round((clicks / impressions) * 1000) / 10 : null,
|
|
23
|
-
averageViewSeconds: viewWeight > 0
|
|
24
|
-
? Math.round(viewed.reduce((total, stats) => total + (stats.views ?? 0) * (stats.averageViewSeconds ?? 0), 0) / viewWeight)
|
|
25
|
-
: null,
|
|
26
|
-
watchHours: Math.round(sum((stats) => stats.watchHours) * 10) / 10,
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
export function channelPerformance(deps, channelId) {
|
|
30
|
-
const channels = projectChannels(deps.db);
|
|
31
|
-
const projects = [];
|
|
32
|
-
for (const project of listProjects(deps.db)) {
|
|
33
|
-
if ((channels.get(project.id) ?? defaultChannelId) !== channelId)
|
|
34
|
-
continue;
|
|
35
|
-
const videos = projectVideos(deps.db, project.id).filter((one) => one.uploadState === "done");
|
|
36
|
-
const stats = projectStats(deps.db, project.id);
|
|
37
|
-
if (videos.length === 0 && stats.length === 0)
|
|
38
|
-
continue;
|
|
39
|
-
const releases = releasesOf(deps.db, project.id);
|
|
40
|
-
const pack = uploadPack(deps, project.id);
|
|
41
|
-
const items = pack.ok ? pack.pack.items : [];
|
|
42
|
-
const row = (short) => {
|
|
43
|
-
const video = videos.find((one) => one.short === short);
|
|
44
|
-
const numbers = stats.find((one) => one.short === short) ?? null;
|
|
45
|
-
if (video === undefined && numbers === null)
|
|
46
|
-
return null;
|
|
47
|
-
const release = releases.find((one) => one.short === (short ?? 0))?.at;
|
|
48
|
-
const item = items.find((one) => (one.short ?? null) === short);
|
|
49
|
-
return {
|
|
50
|
-
short,
|
|
51
|
-
title: item?.title ?? (short === null ? project.title : `Short ${String(short)}`),
|
|
52
|
-
videoId: video?.videoId ?? numbers?.videoId ?? null,
|
|
53
|
-
releasedAt: release !== undefined && release !== "" ? release : null,
|
|
54
|
-
stats: numbers,
|
|
55
|
-
};
|
|
56
|
-
};
|
|
57
|
-
const shortNumbers = [
|
|
58
|
-
...new Set([
|
|
59
|
-
...videos.flatMap((one) => (one.short === null ? [] : [one.short])),
|
|
60
|
-
...stats.flatMap((one) => (one.short === null ? [] : [one.short])),
|
|
61
|
-
]),
|
|
62
|
-
].toSorted((left, right) => left - right);
|
|
63
|
-
projects.push({
|
|
64
|
-
projectId: project.id,
|
|
65
|
-
title: project.title,
|
|
66
|
-
long: row(null),
|
|
67
|
-
shorts: shortNumbers.flatMap((short) => {
|
|
68
|
-
const one = row(short);
|
|
69
|
-
return one === null ? [] : [one];
|
|
70
|
-
}),
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
const longs = projects.flatMap((one) => (one.long === null ? [] : [one.long]));
|
|
74
|
-
const shorts = projects.flatMap((one) => one.shorts);
|
|
75
|
-
const reads = [...longs, ...shorts].flatMap((one) => one.stats === null ? [] : [one.stats.readAt]);
|
|
76
|
-
return {
|
|
77
|
-
projects: projects.toSorted((left, right) => Date.parse(right.long?.releasedAt ?? right.shorts[0]?.releasedAt ?? "") -
|
|
78
|
-
Date.parse(left.long?.releasedAt ?? left.shorts[0]?.releasedAt ?? "") || 0),
|
|
79
|
-
long: totals(longs),
|
|
80
|
-
shorts: totals(shorts),
|
|
81
|
-
readAt: reads.toSorted().at(-1) ?? null,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,172 +0,0 @@
|
|
|
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
|
-
// 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
|
-
}
|
|
131
|
-
return {
|
|
132
|
-
ok: true,
|
|
133
|
-
report: {
|
|
134
|
-
importedAt,
|
|
135
|
-
...(totals.length > 0 && days.length > 0 ? { dailyTotals } : {}),
|
|
136
|
-
chartMetric: chart.length > 1 ? chartMetric : null,
|
|
137
|
-
from: days[0] ?? null,
|
|
138
|
-
to: days.at(-1) ?? null,
|
|
139
|
-
columns,
|
|
140
|
-
totals: totalRow === undefined ? columns.map(() => null) : valuesOf(totalRow),
|
|
141
|
-
rows: videoRows.map((row) => {
|
|
142
|
-
const id = (row[0] ?? "").trim();
|
|
143
|
-
return {
|
|
144
|
-
videoId: id,
|
|
145
|
-
title: (row[1] ?? "").trim(),
|
|
146
|
-
published: (row[2] ?? "").trim() || null,
|
|
147
|
-
duration: (() => {
|
|
148
|
-
const value = Number((row[3] ?? "").trim());
|
|
149
|
-
return Number.isFinite(value) && (row[3] ?? "").trim() !== "" ? value : null;
|
|
150
|
-
})(),
|
|
151
|
-
values: valuesOf(row),
|
|
152
|
-
daily: daily.get(id) ?? [],
|
|
153
|
-
};
|
|
154
|
-
}),
|
|
155
|
-
},
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
export function saveReport(db, channelId, report) {
|
|
159
|
-
db.prepare(`INSERT INTO studio_reports(channel_id, imported_at, report) VALUES (?,?,?)
|
|
160
|
-
ON CONFLICT(channel_id) DO UPDATE SET imported_at=excluded.imported_at, report=excluded.report`).run(channelId, report.importedAt, JSON.stringify(report));
|
|
161
|
-
}
|
|
162
|
-
export function readReport(db, channelId) {
|
|
163
|
-
const row = db.prepare("SELECT report FROM studio_reports WHERE channel_id=?").get(channelId);
|
|
164
|
-
if (row === undefined)
|
|
165
|
-
return null;
|
|
166
|
-
try {
|
|
167
|
-
return JSON.parse(String(row.report));
|
|
168
|
-
}
|
|
169
|
-
catch {
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
}
|