@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
|
@@ -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
|
});
|
package/dist/edge/http/studio.js
CHANGED
|
@@ -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 {
|
|
26
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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,
|
|
Binary file
|
|
Binary file
|
|
@@ -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.
|
|
@@ -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,
|