@gentbajko/slopify 3.5.0 → 3.5.2

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.
@@ -407,9 +407,9 @@ tts:
407
407
  keywords:
408
408
  - tts
409
409
  pricing:
410
- perMillionCharacters: 25
411
- note: On-Demand rate before discounts or credits. Requests are split at 10,000
412
- characters for account compatibility.
410
+ perMillionCharacters: 17.5
411
+ note: The rate as charged. Requests are split at 10,000 characters for account
412
+ compatibility.
413
413
  tts:
414
414
  maxCharacters: 10000
415
415
  asyncMaxCharacters: 100000
@@ -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.
@@ -0,0 +1,7 @@
1
+ # Slopify 3.5.2
2
+
3
+ Released 2 October 2026.
4
+
5
+ ## Costs
6
+
7
+ - **Inworld TTS-2 is priced at $17.50 per million characters** (was $25), so narration estimates and spend match what Inworld charges.
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "id": "3.5.2",
4
+ "title": "Slopify 3.5.2",
5
+ "version": "3.5.2",
6
+ "date": "2026-10-02"
7
+ },
8
+ {
9
+ "id": "3.5.1",
10
+ "title": "Slopify 3.5.1",
11
+ "version": "3.5.1",
12
+ "date": "2026-10-02"
13
+ },
2
14
  {
3
15
  "id": "3.5.0",
4
16
  "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
  }