@gentbajko/slopify 3.5.0 → 3.5.1

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.
@@ -13,6 +13,7 @@ import { backfillVideos } from "../../slices/studio/backfill.js";
13
13
  import { releaseCalendar } from "../../slices/studio/calendar.js";
14
14
  import { studioPlaylistMax, studioUploadUrl, } from "../../slices/studio/model.js";
15
15
  import { packItem, uploadPack } from "../../slices/studio/pack.js";
16
+ import { channelPerformance } from "../../slices/studio/performance.js";
16
17
  import { writeUploadPick } from "../../slices/studio/pick.js";
17
18
  import { postingPlanSchema, readPlan, writePlan } from "../../slices/studio/plan.js";
18
19
  import { leadHoursMax, seriesOf } from "../../slices/studio/plan-model.js";
@@ -380,6 +381,8 @@ export function studioRoutes(deps) {
380
381
  // Studio's numbers for a project's videos, and every finished A/B test (Library).
381
382
  .get("/stats/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ stats: projectStats(deps.db, c.req.valid("param").projectId) }))
382
383
  .get("/ab-results", (c) => c.json({ results: abResults(deps.db) }))
384
+ // Channels → YouTube: the channel's videos on YouTube with Studio's numbers and totals.
385
+ .get("/channels/:channelId/performance", zValidator("param", z.object({ channelId: id }), onInvalid), (c) => c.json(channelPerformance(deps, c.req.valid("param").channelId)))
383
386
  // The YouTube videos a project's uploads became, and their A/B tests.
384
387
  .get("/videos/:projectId", zValidator("param", projectParam, onInvalid), (c) => c.json({ videos: projectVideos(deps.db, c.req.valid("param").projectId) }))
385
388
  // A video's link pasted in Slopify, for an upload made without the extension; an empty
@@ -0,0 +1,12 @@
1
+ # Slopify 3.5.1
2
+
3
+ Released 2 October 2026.
4
+
5
+ ## Channels → YouTube numbers
6
+
7
+ - **A channel's YouTube numbers in one place.** A new **YouTube numbers** tab on each channel shows its long videos' and its shorts' totals (views, impressions, click-through rate, average view duration, watch hours), then every video with its shorts under it, ordered newest first, by views, by click-through rate or by average view.
8
+ - The numbers are what Studio showed when the Slopify Studio extension last read them: daily while Chrome is open, or at once with **Read Studio numbers now** in its popup.
9
+
10
+ ## Calendar → Releases
11
+
12
+ - **"Checks clear" for real.** Studio shows a clear video as "No notices · reaching viewers and earning"; Slopify now reads that as clear instead of as a problem.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "id": "3.5.1",
4
+ "title": "Slopify 3.5.1",
5
+ "version": "3.5.1",
6
+ "date": "2026-10-02"
7
+ },
2
8
  {
3
9
  "id": "3.5.0",
4
10
  "title": "Slopify 3.5.0",
@@ -0,0 +1,83 @@
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
+ }
@@ -26,7 +26,7 @@ function rowOf(row) {
26
26
  commentMessage: typeof row.comment_message === "string" ? row.comment_message : null,
27
27
  abMessage: typeof row.ab_message === "string" ? row.ab_message : null,
28
28
  abAt: typeof row.ab_at === "string" ? row.ab_at : null,
29
- checks: typeof row.checks === "string" ? row.checks : null,
29
+ checks: typeof row.checks === "string" ? checksWord(row.checks) : null,
30
30
  };
31
31
  }
32
32
  // Records (or replaces) the video an upload became: "filled" from the upload dialog, "done"
@@ -93,7 +93,13 @@ export function previousLongVideo(db, projectId) {
93
93
  .get(projectId, own?.recorded_at ?? null, own?.recorded_at ?? null);
94
94
  return typeof row?.video_id === "string" ? row.video_id : undefined;
95
95
  }
96
- // Studio's word on a known video's checks, read from its Content list row.
96
+ // Studio's word on a known video's checks, read from its Content list row. Studio shows a clear
97
+ // video's Restrictions as "None", or as "—" with "No notices… reaching viewers and earning";
98
+ // both are kept as "ok", anything else as Studio's words (a claim, a limit).
99
+ export function checksWord(text) {
100
+ const plain = text.replace(/\s+/g, " ").trim();
101
+ return /^(none|[—–-]?\s*no notices.*)$/i.test(plain) || /^[—–-]$/.test(plain) ? "ok" : plain;
102
+ }
97
103
  export function setChecks(db, videoId, checks) {
98
- db.prepare("UPDATE youtube_videos SET checks=? WHERE video_id=?").run(checks, videoId);
104
+ db.prepare("UPDATE youtube_videos SET checks=? WHERE video_id=?").run(checksWord(checks), videoId);
99
105
  }