Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
executable file
·2691 lines (2599 loc) · 130 KB
/
Copy pathserver.js
File metadata and controls
executable file
·2691 lines (2599 loc) · 130 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
// Static server for the web version, PLUS (new) a native rendering backend.
//
// Run: node server.js
// Then open http://localhost:8123
// Everything (TTS engine, video engine, UI) is served from the web/ folder.
//
// A real HTTP server (not a file:// open) is required because the app needs
// COOP/COEP headers for cross-origin isolation (SharedArrayBuffer, threaded
// wasm) — browsers won't grant that to files opened directly from disk.
//
// This file used to be pure static serving. It now also exposes a render
// backend that shells out to the user's own installed `ffmpeg` instead of
// ffmpeg.wasm — real native rendering is faster and doesn't hang the way the
// WASM multi-thread core occasionally does. This is purely additive: the
// deployed GitHub Pages build has no server, so it always falls back to the
// original WASM path automatically (see web/app.js's native-probe-then-
// fallback logic) — nothing here changes what that build does.
//
// Deliberately zero npm dependencies itself, matching the rest of this repo
// — the render path reuses web/lib/ffmpeg-filters.js's pure filter-graph
// builders directly via require() (already Node-compatible; that's how its
// own tests run) and only Node builtins (http/fs/path/child_process/crypto)
// otherwise. Also required (not just run as a CLI entrypoint) by
// electron/main.js, which starts this exact same backend in-process for the
// standalone app — everything below runs identically either way.
const http = require("http");
const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
const { execFile, spawn } = require("child_process");
// A GUI-launched macOS app (double-clicked, or `open`ed — as opposed to run
// from an interactive terminal) does NOT inherit the PATH additions from
// the user's shell profile (.zshrc/.zprofile) — it gets whatever launchd's
// own cached session PATH is, which on a freshly-updated or freshly-
// installed-Homebrew machine can be missing /opt/homebrew/bin (or
// /usr/local/bin on Intel) entirely until the next full login/reboot.
// Confirmed live: this exact process's real inherited PATH was just
// "/usr/bin:/bin:/usr/sbin:/sbin" — no Homebrew — causing every
// execFile("ffmpeg"/"whisper", ...) call below to fail with ENOENT even
// though `ffmpeg` and `whisper` were genuinely installed and on PATH for
// every interactive shell on the same machine. child_process.execFile/spawn
// inherit process.env by default, so augmenting it once here (rather than
// passing a custom env to every individual call) fixes every native-backend
// check/spawn in this file at once.
function fixChildProcessPath() {
const commonBinDirs = [
"/opt/homebrew/bin", "/opt/homebrew/sbin", // Homebrew, Apple Silicon
"/usr/local/bin", "/usr/local/sbin", // Homebrew, Intel; also where many manual installs land
"/opt/local/bin", // MacPorts
];
const existing = (process.env.PATH || "").split(path.delimiter);
const additions = commonBinDirs.filter(d => !existing.includes(d) && fs.existsSync(d));
if (additions.length) process.env.PATH = existing.concat(additions).join(path.delimiter);
}
fixChildProcessPath();
// CAST_PORT lets a second instance run alongside a live Electron app
// (which binds the default 8123 the same way `node server.js` does) — handy
// for testing a server.js change without quitting the real app first.
const PORT = parseInt(process.env.CAST_PORT, 10) || 8123;
// CAST_DEBUG logs the real ffmpeg/whisper spawn args and per-request
// status/timing — off by default so normal runs stay quiet.
const DEBUG = !!process.env.CAST_DEBUG;
const ROOT = path.join(__dirname, "web");
const FONTS_DIR = path.join(ROOT, "vendor", "fonts");
// ---------- Background/music asset cache ----------
// A bulk batch frequently reuses the exact same background video (or music
// track) across many jobs — "same video for all", or the numbered picker
// cycling through fewer picks than the story count. Without this, every job
// independently re-uploads identical bytes to /render. The client hashes
// each asset (SHA-256, Web Crypto) once per distinct File and only sends
// the full bytes for the first job that needs a given hash; every other job
// just references the hash (meta.bgCached/meta.musicCached) and this server
// copies the already-cached file instead of expecting a fresh upload.
// Wiped on every server start so it can never grow unbounded across app
// launches — within one running session (including across multiple batches)
// it persists and keeps paying off.
const BG_CACHE_DIR = path.join(os.tmpdir(), "cast-bg-cache");
fs.rmSync(BG_CACHE_DIR, { recursive: true, force: true });
fs.mkdirSync(BG_CACHE_DIR, { recursive: true });
// A real SHA-256 hex digest is always exactly 64 hex chars — the 16..128
// range just gives a little slack. Shared between /cache-asset (which
// already validated this) and writeAssetFile below, which previously
// trusted meta.bgHash/meta.musicHash straight from the client's /render
// request body with no validation at all — an attacker-controlled hash
// there could read arbitrary files back into a render (cached:true, hash
// pointing outside BG_CACHE_DIR via "..") or write the uploaded bytes to an
// arbitrary path (cached:false). Both are only reachable by something that
// can already reach this localhost-bound server, but validating a value
// this cheap to check is worth it regardless.
const ASSET_HASH_RE = /^[0-9a-f]{16,128}$/;
// Writes `filename` into the job's temp `dir`, either by copying a
// previously-cached asset (when `cached` is true — the client is telling us
// it already uploaded these exact bytes for an earlier job in this batch)
// or by writing the freshly-uploaded `bytes` and best-effort persisting a
// copy into the cache keyed by `hash` for any later job that references it.
function writeAssetFile(dir, filename, bytes, hash, cached) {
const validHash = typeof hash === "string" && ASSET_HASH_RE.test(hash) ? hash : null;
const cachePath = validHash ? path.join(BG_CACHE_DIR, validHash) : null;
if (cached) {
if (!cachePath || !fs.existsSync(cachePath)) {
throw new Error(`Missing cached asset for hash ${hash} — retry this job to re-upload it.`);
}
fs.copyFileSync(cachePath, path.join(dir, filename));
return;
}
fs.writeFileSync(path.join(dir, filename), bytes);
if (cachePath) {
try { fs.copyFileSync(path.join(dir, filename), cachePath); } catch (e) { /* best-effort — a failed cache write just means the next job re-uploads */ }
}
}
const {
safeColor, buildCaptionCues, buildKaraokeCues, buildDrawtextFilterChain,
buildAudioFilterChain, buildTitleCardOverlay, parseWavDurationSec,
} = require(path.join(ROOT, "lib", "ffmpeg-filters.js"));
const { CAPTION_FONTS } = require(path.join(ROOT, "lib", "caption-presets.js"));
// ---------- YouTube upload integration ----------
// Persisted to the user's home directory rather than BG_CACHE_DIR (which is
// deliberately wiped on every server start) or Electron's app.getPath
// ("userData") — this file has no Electron dependency (it's also required
// by a plain `node server.js` with no Electron involved at all) and needs to
// survive across restarts, unlike everything else this server persists.
// First piece of secret material this app stores outside the browser's own
// localStorage (where every API key already lives, unencrypted, today) —
//0600 perms are a best-effort improvement on that, not OS-keychain-grade.
// One-time migration from the old "~/.slopdaddy" app-data directory to
// "~/.cast" (Slopdaddy->CAST rebrand) — a single atomic directory rename
// carries every file inside forward (youtube-accounts.json and anything
// else ever stored here), so a returning user's connected YouTube accounts
// survive the rebrand instead of silently requiring re-auth. Runs once, at
// module load, before YT_STORE_PATH is ever read.
(function migrateLegacyAppDataDir() {
const oldDir = path.join(os.homedir(), ".slopdaddy");
const newDir = path.join(os.homedir(), ".cast");
try {
if (!fs.existsSync(newDir) && fs.existsSync(oldDir)) {
fs.renameSync(oldDir, newDir);
}
} catch (e) { /* best-effort — a failed migration just means a fresh ~/.cast/ gets created below */ }
})();
const YT_STORE_PATH = path.join(os.homedir(), ".cast", "youtube-accounts.json");
function loadYoutubeStore() {
try {
const raw = fs.readFileSync(YT_STORE_PATH, "utf8");
const data = JSON.parse(raw);
if (!data.accounts) data.accounts = [];
// Migrate the old single-oauthClient/top-level-uploadLog shape (every
// store on disk before multi-project support) into the new
// oauthClients list — additive only, never drops an existing account.
// The migrated client keeps the old top-level uploadLog so today's
// upload count doesn't reset to zero mid-migration, and every existing
// account gets tagged with the migrated client's id (they were all
// necessarily connected under that one client, since there was no
// other option before).
if (!data.oauthClients) {
data.oauthClients = [];
if (data.oauthClient && data.oauthClient.clientId) {
const migratedId = crypto.randomUUID();
data.oauthClients.push({
id: migratedId,
label: "Default",
clientId: data.oauthClient.clientId,
clientSecret: data.oauthClient.clientSecret,
uploadLog: Array.isArray(data.uploadLog) ? data.uploadLog : [],
});
for (const account of data.accounts) {
if (!account.oauthClientId) account.oauthClientId = migratedId;
}
}
}
delete data.oauthClient;
delete data.uploadLog;
return data;
} catch (e) {
// ENOENT (first run, nothing saved yet) is expected and silent — any
// other failure means the file EXISTS but is corrupt (e.g. a truncated
// write from an app kill/crash mid-save, before saveYoutubeStore()
// wrote atomically) and every connected account is about to silently
// vanish from the user's perspective with zero indication why. Loud on
// purpose: there's no way to recover the data at this point, but a
// silent, invisible wipe is strictly worse than a visible warning.
if (e.code !== "ENOENT") {
console.error(`YouTube account store at ${YT_STORE_PATH} exists but failed to load (${e.message}) — treating as empty. Any connected channels will need to be reconnected.`);
}
return { oauthClients: [], accounts: [] };
}
}
// YouTube exposes no real quota-usage API — this is a local, best-effort
// proxy: a timestamp per successful upload, trimmed to the last 2 days
// (only "today" is ever displayed) so the store doesn't grow unbounded
// across months of use. Per-client (per Google Cloud project), not global —
// quota is a property of the PROJECT, not the app as a whole, which is the
// entire point of supporting multiple projects.
function recordYoutubeUpload(store, oauthClientId) {
const client = store.oauthClients.find(c => c.id === oauthClientId);
// Reachable if the project was deleted mid-upload (a race: DELETE
// /youtube-oauth-clients/:id vs. an in-flight upload whose access token
// was still valid, so ensureFreshAccessToken never re-checked the
// client exists) — the upload still succeeds on YouTube's side, but
// there's no client left to record quota against. Log it rather than
// silently dropping the record with zero diagnostic anywhere.
if (!client) {
console.warn(`recordYoutubeUpload: no oauthClient ${oauthClientId} — upload succeeded but wasn't counted against any project's quota.`);
return;
}
const cutoff = Date.now() - 2 * 24 * 60 * 60 * 1000;
client.uploadLog = (client.uploadLog || []).filter(t => t > cutoff);
client.uploadLog.push(Date.now());
saveYoutubeStore(store);
}
function countYoutubeUploadsToday(store, oauthClientId) {
const client = store.oauthClients.find(c => c.id === oauthClientId);
if (!client) return 0;
const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
return (client.uploadLog || []).filter(t => t >= startOfDay.getTime()).length;
}
// YouTube's default per-project quota (10,000 units/day ÷ 1600 per upload)
// allows roughly 6 uploads/day — used here as a soft, LOCAL heuristic (not
// an authoritative check; Google's real quota could differ) for deciding
// when to prefer a different connection to the same channel under a less-
// used project, before a real 403 quotaExceeded forces the issue.
const YOUTUBE_QUOTA_SOFT_LIMIT = 6;
// The requested account is used as-is unless its project looks to be at
// today's soft limit AND the same real channel (channelId) is ALSO
// connected under a different, less-used project — in which case that
// connection is used instead, silently keeping "upload to channel X"
// working across a quota boundary. Falls back to the originally requested
// account if no better option exists, even if it's over the soft limit —
// a real 403 from Google is a clearer signal than this app refusing
// pre-emptively on its own guess.
function pickAccountForUpload(store, accountId) {
const requested = store.accounts.find(a => a.id === accountId);
if (!requested) return null;
// Resolve each account's client once, shared by both the requested-
// account check and the alt search below (was two separate lookups
// per candidate — a bare existence .some() plus countYoutubeUploadsToday's
// own internal find). Also used to detect an orphaned connection (its
// project was deleted — DELETE /youtube-oauth-clients/:id deliberately
// leaves these accounts in place, see that route's comment):
// countYoutubeUploadsToday returns 0 for a missing client, which would
// otherwise make a dead connection look like the BEST candidate (or, if
// it's the REQUESTED account itself, get returned outright before the
// alt search even runs) and redirect a normally-working upload into a
// guaranteed "project no longer configured" failure.
const clientById = new Map(store.oauthClients.map(c => [c.id, c]));
const hasQuota = (a) => clientById.has(a.oauthClientId) && countYoutubeUploadsToday(store, a.oauthClientId) < YOUTUBE_QUOTA_SOFT_LIMIT;
if (hasQuota(requested)) return requested;
const alt = store.accounts.find(a => a.id !== requested.id && a.channelId === requested.channelId && hasQuota(a));
return alt || requested;
}
function saveYoutubeStore(data) {
fs.mkdirSync(path.dirname(YT_STORE_PATH), { recursive: true });
// Write to a temp file then rename over the real path, rather than
// writing YT_STORE_PATH directly — fs.rename is atomic on both POSIX and
// NTFS, so a process kill/crash mid-save can only ever leave the OLD file
// intact or the NEW one fully written, never a half-written/truncated
// file in between. Without this, an interrupted write (confirmed real:
// this session killed the app with `pkill -9` many times while debugging
// OAuth) could corrupt the store, and loadYoutubeStore() would then
// silently treat every connected account as if it never existed.
const tmpPath = YT_STORE_PATH + ".tmp";
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 });
fs.renameSync(tmpPath, YT_STORE_PATH);
try { fs.chmodSync(YT_STORE_PATH, 0o600); } catch (e) { /* best-effort — no-op on platforms without POSIX perms (Windows) */ }
}
// Channel thumbnails are cached locally (not just linked by their remote
// Google CDN URL) so the client can safely draw one into a <canvas> for the
// title card — a cross-origin <img> without CORS headers taints the canvas,
// permanently blocking toDataURL()/toBlob() with a SecurityError. Serving
// the SAME bytes from this app's own origin (localhost) sidesteps that
// entirely. Best-effort: a failed download just means no synced picture,
// same UX as never having uploaded a custom one.
const YT_THUMB_DIR = path.join(path.dirname(YT_STORE_PATH), "thumbnails");
async function downloadYoutubeChannelThumbnail(accountId, url) {
if (!url) return;
try {
const resp = await fetch(url);
if (!resp.ok) return;
const buf = Buffer.from(await resp.arrayBuffer());
fs.mkdirSync(YT_THUMB_DIR, { recursive: true });
fs.writeFileSync(path.join(YT_THUMB_DIR, accountId + ".jpg"), buf);
} catch (e) { /* best-effort — cosmetic only */ }
}
// PKCE (RFC 7636) — generated server-side with Node's own crypto so the
// code verifier never has to round-trip through the browser. state ->
// verifier, cleared once the callback consumes it (or left to expire
// naturally if the user abandons the consent flow — a small, self-bounded
// in-memory map, not worth a TTL sweep for a single-user local tool).
const pkceChallenges = new Map();
function base64url(buf) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function generatePkcePair() {
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
return { verifier, challenge };
}
const YOUTUBE_SCOPES = "https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonly";
// Google's "Desktop app" OAuth client type uses the loopback IP redirect
// flow (RFC 8252) — confirmed live that its automatic redirect-URI matching
// only recognizes the literal "127.0.0.1", not the "localhost" hostname
// (Google returned Error 400: redirect_uri_mismatch for an otherwise
// correctly-formed request using localhost). Same effective address, but
// Google's matcher is stricter than that.
const YOUTUBE_REDIRECT_URI = `http://127.0.0.1:${PORT}/youtube-oauth-callback`;
// Refresh a bit before the real expiry so a request never races a token
// that's valid when checked but expired by the time it reaches Google.
const TOKEN_REFRESH_SKEW_MS = 60 * 1000;
async function exchangeYoutubeCode(oauthClient, code, verifier, redirectUri) {
const resp = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: oauthClient.clientId,
client_secret: oauthClient.clientSecret,
code, redirect_uri: redirectUri,
code_verifier: verifier,
grant_type: "authorization_code",
}),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error_description || data.error || `Token exchange failed (${resp.status})`);
return data; // {access_token, refresh_token, expires_in, ...}
}
async function refreshYoutubeToken(oauthClient, refreshToken) {
const resp = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: oauthClient.clientId,
client_secret: oauthClient.clientSecret,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error_description || data.error || `Token refresh failed (${resp.status})`);
return data; // {access_token, expires_in, ...} — no new refresh_token on a plain refresh
}
// Ensures `account` (an entry from store.accounts) has a currently-valid
// access token, refreshing and persisting it first if needed. Mutates the
// passed-in store's matching account object and returns the access token —
// callers must saveYoutubeStore(store) themselves once they're done using
// it, same as every other mutate-then-save flow in this file.
async function ensureFreshAccessToken(store, account) {
if (account.accessToken && account.accessTokenExpiresAt > Date.now() + TOKEN_REFRESH_SKEW_MS) {
return account.accessToken;
}
const client = store.oauthClients.find(c => c.id === account.oauthClientId);
if (!client) throw new Error("This account's OAuth project is no longer configured — reconnect it from Settings.");
const refreshed = await refreshYoutubeToken(client, account.refreshToken);
account.accessToken = refreshed.access_token;
account.accessTokenExpiresAt = Date.now() + (refreshed.expires_in || 3600) * 1000;
saveYoutubeStore(store);
return account.accessToken;
}
async function fetchYoutubeChannel(accessToken) {
const resp = await fetch("https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true", {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await resp.json();
if (!resp.ok) throw new Error((data.error && data.error.message) || `Channel lookup failed (${resp.status})`);
const channel = data.items && data.items[0];
if (!channel) throw new Error("No YouTube channel found on this Google account.");
return {
channelId: channel.id,
channelTitle: channel.snippet.title,
channelThumbnail: (channel.snippet.thumbnails && (channel.snippet.thumbnails.default || {}).url) || null,
};
}
function stripTokens(account, store) {
const { accessToken, refreshToken, ...rest } = account;
// Always report the locally-cached, same-origin copy (see
// downloadYoutubeChannelThumbnail) instead of account.channelThumbnail's
// raw Google CDN URL — that field is kept on the account only for
// reference/debugging, never sent to the client.
const cachedPath = path.join(YT_THUMB_DIR, account.id + ".jpg");
rest.channelThumbnail = fs.existsSync(cachedPath) ? `/youtube-account-thumbnail/${account.id}` : null;
// Lets the client tell apart two connections to the same channel under
// different projects (see pickAccountForUpload) in the account picker —
// "My Channel (Project 2)" instead of two identical-looking rows.
if (store) {
const client = store.oauthClients.find(c => c.id === account.oauthClientId);
rest.oauthClientLabel = client ? client.label : null;
}
return rest;
}
function oauthCallbackPage(message, ok) {
return `<!doctype html><html><head><title>CAST</title></head><body style="font-family:sans-serif;text-align:center;padding-top:80px;">
<h2>${ok ? "✓ Signed in" : "✗ Sign-in failed"}</h2>
<p>${message}</p>
<p style="color:#888;">You can close this tab and go back to CAST.</p>
</body></html>`;
}
// ---------- ffmpeg availability ----------
// Not just "does `ffmpeg` exist" — captions are burned in via the drawtext
// filter, which needs ffmpeg built with libfreetype. Plenty of real ffmpeg
// installs (confirmed on this exact machine's homebrew build) omit it, so
// checking `-version` alone would report "available" and then fail every
// single render with an opaque "No such filter" error. Treat "no drawtext"
// the same as "no ffmpeg" — /render-capability reports unavailable either
// way, and the client falls back to the WASM path automatically, which
// bundles its own drawtext-capable ffmpeg core.
let ffmpegAvailable = null; // null = not checked yet, else boolean
function checkFfmpeg(cb) {
if (ffmpegAvailable !== null) { cb(ffmpegAvailable); return; }
// A timeout, not just error handling — execFile's callback never fires at
// all if the child process hangs (e.g. a broken install stuck prompting
// for input), which would otherwise leave every route gated behind this
// check hanging indefinitely for that request.
execFile("ffmpeg", ["-filters"], { timeout: 5000 }, (err, stdout) => {
// A timeout (err.killed) just means THIS check didn't finish in time —
// e.g. the machine was under heavy load right as the process started —
// it says nothing about whether ffmpeg itself is actually usable.
// Caching that as a permanent `false` (as this used to) silently hid the
// whole Performance section and native rendering for the rest of the
// server's lifetime, with no self-healing short of a manual Debug-tab
// Recheck or an app restart. Only a check that actually RAN — and either
// errored for a real reason (e.g. ENOENT, ffmpeg not installed) or
// completed without drawtext support — represents a stable fact worth
// caching.
if (err && err.killed) { cb(false); return; }
ffmpegAvailable = !err && /drawtext/.test(stdout || "");
cb(ffmpegAvailable);
});
}
// ---------- whisper availability ----------
// Same shape as checkFfmpeg — cached, and checks for a specific capability
// (word_timestamps support showing up in --help) rather than just "does a
// binary named `whisper` exist", so an ancient/incompatible install reports
// unavailable instead of failing every single transcription request.
let whisperAvailable = null; // null = not checked yet, else boolean
function checkWhisper(cb) {
if (whisperAvailable !== null) { cb(whisperAvailable); return; }
execFile("whisper", ["--help"], { timeout: 5000 }, (err, stdout) => {
// See checkFfmpeg's matching comment — a timeout is transient (system
// load, not a real "not installed"/"too old" answer), so it isn't
// cached, letting the next call re-check for real.
if (err && err.killed) { cb(false); return; }
whisperAvailable = !err && /word_timestamps/.test(stdout || "");
cb(whisperAvailable);
});
}
// ---------- PocketTTS availability ----------
// Kyutai's Pocket TTS (https://github.com/kyutai-labs/pocket-tts) — a small
// (100M param) CPU-only TTS model, no browser build, so it's a native
// backend like whisper rather than an in-browser engine like Piper/Kokoro.
// Shelled out via `uvx pocket-tts` (not a bare `pocket-tts` binary) since
// that's the package's own documented zero-install invocation via uv —
// confirmed live: `uvx pocket-tts --help` downloads/caches the package on
// first run (a few seconds) and is near-instant on every run after.
let pocketTtsAvailable = null; // null = not checked yet, else boolean
function checkPocketTts(cb) {
if (pocketTtsAvailable !== null) { cb(pocketTtsAvailable); return; }
// Longer timeout than checkFfmpeg/checkWhisper — uvx downloads/caches the
// package on first run, which genuinely takes a few real seconds.
execFile("uvx", ["pocket-tts", "--help"], { timeout: 20000 }, (err, stdout) => {
// See checkFfmpeg's matching comment — don't let a transient timeout
// (system load, or a slow first-run package download taking a bit
// longer than usual) get cached as a permanent "not installed".
if (err && err.killed) { cb(false); return; }
pocketTtsAvailable = !err && /generate/.test(stdout || "");
cb(pocketTtsAvailable);
});
}
// ---------- native Kokoro (Python) availability ----------
// The browser's Kokoro (kokoro-js/ONNX, see web/lib/tts-engines.js) never
// returns real per-word timestamps — every generation falls through to the
// VAD-estimate/native-Whisper caption-sync cascade instead. The official
// Python `kokoro` package (hexgrad/Kokoro-82M) DOES expose real per-token
// start_ts/end_ts derived straight from the model's own synthesis, via its
// KPipeline — confirmed live (see scripts/kokoro_native.py). No official CLI
// exists, so this shells out to a small bundled Python script via `uvx`
// instead, same "no native app dependency baked in" shape as PocketTTS.
// Checking availability just imports the package — doesn't trigger a model
// download, which only happens lazily on the first real KPipeline() call.
let kokoroNativeAvailable = null; // null = not checked yet, else boolean
// Shared with runNativeKokoro's own spawn args below — one place to update
// if scripts/kokoro_native.py's real dependencies ever change, instead of
// two independently-maintained copies drifting apart.
const KOKORO_NATIVE_WITH_ARGS = ["--with", "kokoro", "--with", "soundfile"];
function checkKokoroNative(cb) {
if (kokoroNativeAvailable !== null) { cb(kokoroNativeAvailable); return; }
// Long timeout — uvx resolves/installs kokoro + its dependencies (torch,
// spacy, transformers, ...) on first run, which is genuinely slow.
// Imports soundfile/numpy too, not just kokoro — scripts/kokoro_native.py
// needs all three at real invocation time (soundfile especially needs a
// real libsndfile binary dependency on some platforms), so a check that
// only exercised "import kokoro" could report available:true while a
// real generation still fails on a missing soundfile install.
execFile("uvx", [...KOKORO_NATIVE_WITH_ARGS, "python3", "-c", "import kokoro, soundfile, numpy"], { timeout: 60000 }, (err) => {
if (err && err.killed) { cb(false); return; }
kokoroNativeAvailable = !err;
cb(kokoroNativeAvailable);
});
}
// ---------- concurrency limiter ----------
// Mirrors FFmpegWorkerPool's job (web/worker-pool.js) for native processes —
// batch "Generate All" can fire off several renders (and now transcriptions)
// at once; cap how many real subprocesses of a given kind run concurrently.
// Rendering (ffmpeg) and transcription (whisper, CPU/PyTorch-heavy) are
// different resource profiles, so each kind gets its own independent limiter
// rather than sharing one pool — a render and a transcribe can run at once
// without either starving the other's queue.
//
// max is mutable (setMax) rather than fixed at construction — the client can
// raise or lower it at runtime via POST /performance-settings (Settings ->
// Performance), so a batch already queued respects a change immediately
// rather than needing a server restart.
// Every runNative* function below spawns a subprocess and wires both
// proc.on("error") and proc.on("close") to send an HTTP response — but a
// genuine spawn failure (ENOENT, EACCES, EMFILE) fires BOTH events for the
// same process, and calling respond() twice throws ERR_HTTP_HEADERS_SENT on
// the second call. Wraps one or more response functions so only the first
// call across all of them actually fires — pass every response function a
// given route can call (e.g. runNativeRender's respond AND respondFile,
// which represent the same "have we replied yet" state and must share one
// guard) and use the returned wrappers in place of the originals.
function onceAcrossAll(...fns) {
let called = false;
return fns.map(fn => (...args) => {
if (called) return;
called = true;
return fn(...args);
});
}
function makeSlotLimiter(max) {
let active = 0;
const queue = [];
const state = { max };
return {
acquire() {
return new Promise((resolve) => {
const tryAcquire = () => {
if (active < state.max) { active++; resolve(); }
else queue.push(tryAcquire);
};
tryAcquire();
});
},
release() {
active--;
const next = queue.shift();
if (next) next();
},
setMax(n) {
state.max = n;
// A raised cap may free up waiters immediately.
while (active < state.max && queue.length) queue.shift()();
},
getMax() { return state.max; },
getActive() { return active; },
};
}
// Default to every core — the user explicitly wants max resource usage by
// default; Settings -> Performance lets them dial back to leave headroom for
// the OS/UI if a full-throttle batch render makes the machine unresponsive.
const CPU_COUNT = os.cpus().length;
const renderLimiter = makeSlotLimiter(CPU_COUNT);
const transcribeLimiter = makeSlotLimiter(CPU_COUNT);
const pocketTtsLimiter = makeSlotLimiter(CPU_COUNT);
const kokoroNativeLimiter = makeSlotLimiter(CPU_COUNT);
// Deliberately small and NOT tied to CPU_COUNT like the others — this limits
// concurrent HTTP upload requests, not CPU-bound local work, and YouTube's
// default quota (10,000 units/day, 1600 per upload) allows roughly 6
// uploads/day regardless — more concurrency here wouldn't help against a
// fixed daily budget, it'd just make hitting quotaExceeded happen faster.
const youtubeUploadLimiter = makeSlotLimiter(2);
function clampConcurrency(n) {
n = parseInt(n, 10);
if (!Number.isFinite(n)) return null;
return Math.max(1, Math.min(CPU_COUNT, n));
}
// Per-job thread budget: divide the core count across however many jobs of
// that kind are currently allowed to run at once, so N concurrent renders
// together target ~100% of cores instead of each auto-detecting and
// fighting the others for every core (ffmpeg/whisper both default to
// "use all cores" when no thread count is given).
function threadBudget(limiter) {
return Math.max(1, Math.floor(CPU_COUNT / limiter.getMax()));
}
// ---------- SSE progress channels ----------
// Keyed by a client-generated render id: the client opens an SSE connection
// on /render-progress/:id BEFORE posting the binary payload to /render, so
// the id correlates the two requests. Plain Node http, no ws/sse library.
const progressChannels = new Map();
function sendProgress(id, data) {
const res = progressChannels.get(id);
if (res) res.write(`data: ${JSON.stringify(data)}\n\n`);
}
function closeProgressChannel(id) {
const res = progressChannels.get(id);
if (res) { res.end(); progressChannels.delete(id); }
}
// Caps how much of a request body this server will buffer into memory
// before giving up — this app legitimately deals in large video files, so
// the limits are generous, not restrictive for real use; they exist so a
// misbehaving/unbounded client stream can't grow the process's memory
// without limit. Destroys the connection and responds 413 once exceeded,
// rather than continuing to buffer.
function readBodyWithLimit(req, res, maxBytes, cb) {
const chunks = [];
let total = 0;
let rejected = false;
req.on("data", (c) => {
if (rejected) return;
total += c.length;
if (total > maxBytes) {
rejected = true;
res.writeHead(413, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `Request body exceeds the ${maxBytes}-byte limit for this route` }));
req.destroy();
return;
}
chunks.push(c);
});
req.on("end", () => { if (!rejected) cb(Buffer.concat(chunks)); });
}
// ---------- binary request framing ----------
// No multipart parser (zero dependencies) — a simple length-prefixed frame
// instead: [4 bytes LE uint32: JSON metadata length][JSON metadata][bg
// bytes][audio bytes][music bytes if meta.hasMusic][title-card PNG bytes if
// meta.hasTitleCard]. Metadata carries each segment's byte length plus every
// non-binary field runJob already sends to the WASM worker (subs, style,
// w/h/fps, bgW/bgH, musicVolume, titleCard timing). Mirrors web/app.js's
// renderVideoNatively(), which builds this exact frame client-side.
function parseRenderBody(body) {
if (body.length < 4) throw new Error("Body too short to contain a metadata length header");
const metaLen = body.readUInt32LE(0);
if (4 + metaLen > body.length) throw new Error("Metadata length exceeds body size");
const meta = JSON.parse(body.subarray(4, 4 + metaLen).toString("utf8"));
let offset = 4 + metaLen;
// Buffer.subarray clamps out-of-range indices instead of throwing, so a
// mismatched *Len field would otherwise silently hand buildRenderArgs a
// truncated/empty segment instead of a clear error here — check the
// expected total length up front instead.
const expectedLen = offset + (meta.bgLen || 0) + (meta.audioLen || 0)
+ (meta.hasMusic ? (meta.musicLen || 0) : 0)
+ (meta.hasTitleCard ? (meta.titleCardImageLen || 0) : 0);
if (!Number.isFinite(expectedLen) || expectedLen > body.length) {
throw new Error("Segment lengths in metadata don't match the request body size");
}
const bg = body.subarray(offset, offset + meta.bgLen); offset += meta.bgLen;
const audio = body.subarray(offset, offset + meta.audioLen); offset += meta.audioLen;
let music = null, titleCardImage = null;
if (meta.hasMusic) { music = body.subarray(offset, offset + meta.musicLen); offset += meta.musicLen; }
if (meta.hasTitleCard) { titleCardImage = body.subarray(offset, offset + meta.titleCardImageLen); offset += meta.titleCardImageLen; }
return { meta, bg, audio, music, titleCardImage };
}
// Same length-prefixed framing as parseRenderBody, simpler shape: [4 bytes
// LE uint32: JSON metadata length][JSON metadata][video bytes][thumbnail
// bytes if meta.hasThumbnail].
function parseYoutubeUploadBody(body) {
if (body.length < 4) throw new Error("Body too short to contain a metadata length header");
const metaLen = body.readUInt32LE(0);
if (4 + metaLen > body.length) throw new Error("Metadata length exceeds body size");
const meta = JSON.parse(body.subarray(4, 4 + metaLen).toString("utf8"));
let offset = 4 + metaLen;
const expectedLen = offset + (meta.videoLen || 0) + (meta.hasThumbnail ? (meta.thumbnailLen || 0) : 0);
if (!Number.isFinite(expectedLen) || expectedLen > body.length) {
throw new Error("Segment lengths in metadata don't match the request body size");
}
const video = body.subarray(offset, offset + meta.videoLen); offset += meta.videoLen;
let thumbnail = null;
if (meta.hasThumbnail) { thumbnail = body.subarray(offset, offset + meta.thumbnailLen); offset += meta.thumbnailLen; }
return { meta, video, thumbnail };
}
// Asks YouTube where an interrupted resumable upload actually left off
// (a status-check PUT with no body and an open-ended Content-Range) rather
// than blindly resuming from wherever this process last thought it was —
// the two can disagree if a chunk's response was lost after Google already
// received it.
async function queryYoutubeResumeOffset(uploadUrl, total) {
const resp = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Range": `bytes */${total}` } });
if (resp.status === 308) {
const range = resp.headers.get("range"); // "bytes=0-8388607"
return range ? parseInt(range.split("-")[1], 10) + 1 : 0;
}
throw new Error(`Could not determine resume offset after a dropped connection (status ${resp.status})`);
}
// Resumable upload per YouTube's documented protocol: an init POST to get a
// per-upload URL, then chunked PUTs with Content-Range headers. A dropped
// connection mid-chunk queries Google for the real byte offset instead of
// restarting the whole upload. Scheduling note: `status.publishAt` requires
// `status.privacyStatus: "private"` per YouTube's own API rules — a
// scheduled video literally can't be unlisted/public until it publishes.
async function runYoutubeUpload(id, store, account, video, thumbnail, meta, respond) {
try {
const accessToken = await ensureFreshAccessToken(store, account);
const isScheduled = !!meta.scheduledAt;
const snippet = {
title: meta.title || "Untitled", description: meta.description || "",
tags: Array.isArray(meta.tags) ? meta.tags : [], categoryId: meta.categoryId || "24",
};
const status = isScheduled
? { privacyStatus: "private", publishAt: meta.scheduledAt, selfDeclaredMadeForKids: false }
: { privacyStatus: meta.privacyStatus || "private", selfDeclaredMadeForKids: false };
sendProgress(id, { phase: "initializing", pct: 0 });
const initResp = await fetch("https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json; charset=UTF-8",
"X-Upload-Content-Type": "video/mp4",
"X-Upload-Content-Length": String(video.length),
},
body: JSON.stringify({ snippet, status }),
});
if (!initResp.ok) {
const errData = await initResp.json().catch(() => ({}));
throw new Error((errData.error && errData.error.message) || `Upload init failed (${initResp.status})`);
}
const uploadUrl = initResp.headers.get("location");
if (!uploadUrl) throw new Error("YouTube didn't return a resumable upload URL.");
const total = video.length;
const CHUNK_SIZE = 8 * 1024 * 1024;
let offset = 0;
let videoId = null;
while (offset < total) {
const end = Math.min(offset + CHUNK_SIZE, total) - 1;
let putResp;
try {
putResp = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Length": String(end - offset + 1), "Content-Range": `bytes ${offset}-${end}/${total}` },
body: video.subarray(offset, end + 1),
});
} catch (netErr) {
offset = await queryYoutubeResumeOffset(uploadUrl, total);
sendProgress(id, { phase: "uploading", pct: Math.round((offset / total) * 100) });
continue;
}
if (putResp.status === 308) {
const range = putResp.headers.get("range");
offset = range ? parseInt(range.split("-")[1], 10) + 1 : end + 1;
sendProgress(id, { phase: "uploading", pct: Math.round((offset / total) * 100) });
continue;
}
if (putResp.status === 200 || putResp.status === 201) {
const data = await putResp.json();
videoId = data.id;
sendProgress(id, { phase: "uploading", pct: 100 });
break;
}
const errBody = await putResp.text().catch(() => "");
throw new Error(`Upload chunk failed (${putResp.status}): ${errBody.slice(0, 300)}`);
}
if (!videoId) throw new Error("Upload finished without a video id.");
if (thumbnail && thumbnail.length) {
sendProgress(id, { phase: "thumbnail", pct: 100 });
try {
const thumbResp = await fetch(`https://www.googleapis.com/upload/youtube/v3/thumbnails/set?videoId=${videoId}`, {
method: "POST",
// Must match the actual bytes — a Regenerate is always our own
// canvas.toBlob() PNG, but "Upload Custom..." can be a JPEG.
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": meta.thumbnailMimeType || "image/png" },
body: thumbnail,
});
// Non-fatal — the video itself uploaded fine either way.
if (!thumbResp.ok) console.warn("YouTube thumbnail upload failed:", thumbResp.status);
} catch (e) { console.warn("YouTube thumbnail upload error:", e.message); }
}
recordYoutubeUpload(store, account.oauthClientId);
sendProgress(id, { phase: "done", pct: 100, videoId });
// account may differ from the account meta.accountId originally
// requested — pickAccountForUpload silently substitutes a different
// project's connection to the same channel when the requested one is
// near today's soft quota limit. Surface that here so the client can
// tell the user what actually happened instead of it being invisible.
const usedClient = store.oauthClients.find(c => c.id === account.oauthClientId);
respond(200, {
videoId, status: isScheduled ? "scheduled" : "uploaded",
switchedProject: account.id !== meta.accountId,
oauthClientLabel: usedClient ? usedClient.label : null,
});
} catch (e) {
sendProgress(id, { phase: "error", error: e.message });
respond(500, { error: e.message });
} finally {
closeProgressChannel(id);
}
}
// ---------- TikTok Content Posting integration ----------
// Deliberately NOT structured like the YouTube section above: TikTok's
// per-user rate limit (video.publish scope, ~15-25 posts/day) is scoped to
// each user's own access token, not a shared per-app quota the way
// YouTube's 10,000-units/day is scoped to the OAuth CLIENT (Google Cloud
// project) — so there's no need for YouTube's oauthClients list / multi-
// project quota-switching machinery here. Exactly one client key/secret
// pair per install is enough; still user-supplied (not baked into this
// open-source app's distributed code) for the same reason YouTube's is.
const TIKTOK_STORE_PATH = path.join(os.homedir(), ".cast", "tiktok-accounts.json");
function loadTiktokStore() {
try {
const raw = fs.readFileSync(TIKTOK_STORE_PATH, "utf8");
const data = JSON.parse(raw);
if (!data.accounts) data.accounts = [];
if (data.clientKey === undefined) data.clientKey = null;
if (data.clientSecret === undefined) data.clientSecret = null;
return data;
} catch (e) {
if (e.code !== "ENOENT") {
console.error(`TikTok account store at ${TIKTOK_STORE_PATH} exists but failed to load (${e.message}) — treating as empty. Any connected account will need to be reconnected.`);
}
return { clientKey: null, clientSecret: null, accounts: [] };
}
}
function saveTiktokStore(data) {
fs.mkdirSync(path.dirname(TIKTOK_STORE_PATH), { recursive: true });
const tmpPath = TIKTOK_STORE_PATH + ".tmp";
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 });
fs.renameSync(tmpPath, TIKTOK_STORE_PATH);
try { fs.chmodSync(TIKTOK_STORE_PATH, 0o600); } catch (e) { /* best-effort — no-op on platforms without POSIX perms (Windows) */ }
}
// TikTok's PKCE (RFC 7636) implementation is hex-encoded SHA-256, NOT the
// base64url encoding generatePkcePair() above uses for Google — confirmed
// against TikTok's own docs. A naive reuse of that helper would silently
// produce an incompatible code_challenge, so this stays its own function
// rather than a parameterized shared one.
function generateTiktokPkcePair() {
const verifier = crypto.randomBytes(32).toString("hex");
const challenge = crypto.createHash("sha256").update(verifier).digest("hex");
return { verifier, challenge };
}
const TIKTOK_SCOPES = "user.info.basic,video.publish"; // comma-separated per TikTok's format, unlike Google's space-separated scope string
// TikTok's "Desktop" app type explicitly requires the redirect_uri host to
// be exactly "localhost" or "127.0.0.1" with a port — confirmed directly
// from TikTok's own developer-portal validation message, not just their
// docs. Same effective address family as YOUTUBE_REDIRECT_URI, registered
// under "Desktop" in TikTok's developer portal (see the Settings -> Publish
// setup instructions).
const TIKTOK_REDIRECT_URI = `http://127.0.0.1:${PORT}/tiktok-oauth-callback`;
async function exchangeTiktokCode(clientKey, clientSecret, code, verifier, redirectUri) {
const resp = await fetch("https://open.tiktokapis.com/v2/oauth/token/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", "Cache-Control": "no-cache" },
body: new URLSearchParams({
client_key: clientKey, client_secret: clientSecret,
code, redirect_uri: redirectUri, code_verifier: verifier,
grant_type: "authorization_code",
}),
});
const data = await resp.json();
if (!resp.ok || data.error) throw new Error(data.error_description || data.error || `Token exchange failed (${resp.status})`);
return data; // {access_token, refresh_token, expires_in, refresh_expires_in, open_id, ...}
}
async function refreshTiktokToken(clientKey, clientSecret, refreshToken) {
const resp = await fetch("https://open.tiktokapis.com/v2/oauth/token/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", "Cache-Control": "no-cache" },
body: new URLSearchParams({
client_key: clientKey, client_secret: clientSecret,
refresh_token: refreshToken, grant_type: "refresh_token",
}),
});
const data = await resp.json();
if (!resp.ok || data.error) throw new Error(data.error_description || data.error || `Token refresh failed (${resp.status})`);
return data; // {access_token, expires_in, refresh_token, refresh_expires_in, ...}
}
// Same shape as ensureFreshAccessToken (YouTube) — mutates `account`,
// caller must saveTiktokStore(store) once done. TikTok access tokens last
// 24h (vs Google's 1h) and refresh tokens last 365 days, but the check
// logic is identical either way, just against different real lifetimes.
async function ensureFreshTiktokToken(store, account) {
if (account.accessToken && account.accessTokenExpiresAt > Date.now() + TOKEN_REFRESH_SKEW_MS) {
return account.accessToken;
}
if (!store.clientKey || !store.clientSecret) throw new Error("TikTok app credentials are no longer configured — reconnect from Settings.");
const refreshed = await refreshTiktokToken(store.clientKey, store.clientSecret, account.refreshToken);
account.accessToken = refreshed.access_token;
account.accessTokenExpiresAt = Date.now() + (refreshed.expires_in || 86400) * 1000;
if (refreshed.refresh_token) account.refreshToken = refreshed.refresh_token;
saveTiktokStore(store);
return account.accessToken;
}
async function fetchTiktokUserInfo(accessToken) {
const resp = await fetch("https://open.tiktokapis.com/v2/user/info/?fields=open_id,display_name,avatar_url", {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await resp.json();
if (!resp.ok || (data.error && data.error.code !== "ok")) throw new Error((data.error && data.error.message) || `User info lookup failed (${resp.status})`);
const user = data.data && data.data.user;
if (!user) throw new Error("TikTok didn't return account info.");
return { openId: user.open_id, displayName: user.display_name, avatarUrl: user.avatar_url };
}
function stripTiktokTokens(account) {
const { accessToken, refreshToken, ...rest } = account;
return rest;
}
// TikTok's Content Posting API audit requires the app to show the real
// creator identity (nickname/avatar) and the ACTUAL privacy-level options
// TikTok is willing to grant this client before every post, not a hardcoded
// guess — for an unaudited client that's just SELF_ONLY today, but calling
// this for real (instead of hardcoding "SELF_ONLY" client-side) means the
// UI automatically offers the full option set the moment this app passes
// review, with no further code change needed. Also surfaces
// comment/duet/stitch-disabled flags and the max post duration, all of
// which the Content Posting API docs list as required pre-post display data.
async function fetchTiktokCreatorInfo(accessToken) {
const resp = await fetch("https://open.tiktokapis.com/v2/post/publish/creator_info/query/", {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json; charset=UTF-8" },
});
const data = await resp.json();
if (!resp.ok || (data.error && data.error.code !== "ok")) {
throw new Error((data.error && data.error.message) || `Creator info lookup failed (${resp.status})`);
}
const d = data.data || {};
return {
nickname: d.creator_nickname || "",
avatarUrl: d.creator_avatar_url || "",
username: d.creator_username || "",
privacyLevelOptions: d.privacy_level_options || [],
commentDisabled: !!d.comment_disabled,
duetDisabled: !!d.duet_disabled,
stitchDisabled: !!d.stitch_disabled,
maxVideoPostDurationSec: d.max_video_post_duration_sec || null,
};
}
// TikTok's chunk rules (confirmed from their Content Posting API docs, not
// guessed): a video up to 64MB goes as a single chunk, with chunk_size set
// to the video's own real size (TikTok rejects a single-chunk upload whose
// declared chunk_size doesn't match total_size) — NOT "under 5MB", which
// was this function's original (wrong) threshold and silently declared a
// hardcoded 64MB chunk_size for every video between 5MB and 64MB even
// though the actual PUT only ever sent that video's real, smaller byte
// count, producing TikTok's "chunk size is invalid" error on init. Only a
// video over 64MB needs to be split into multiple 5MB-64MB chunks, with
// the LAST chunk allowed up to 128MB to absorb any remainder rather than
// leaving an awkward tiny final chunk.
const TIKTOK_MIN_CHUNK = 5 * 1024 * 1024;
const TIKTOK_MAX_CHUNK = 64 * 1024 * 1024;
function tiktokChunkPlan(totalBytes) {
if (totalBytes <= TIKTOK_MAX_CHUNK) {
return { chunkSize: totalBytes, totalChunks: 1 };
}
const chunkSize = TIKTOK_MAX_CHUNK;
// TikTok's own docs: total_chunk_count = floor(video_size / chunk_size),
// NOT ceil — the final chunk absorbs whatever remainder is left over
// (which is exactly what the PUT loop's `Math.min(start + chunkSize,
// video.length)` already does), rather than getting its own extra,
// smaller final chunk. Using ceil here produced a total_chunk_count one
// too high, which TikTok rejects outright ("the total chunk count is
// invalid") before ever looking at the actual PUT bytes.
const totalChunks = Math.floor(totalBytes / chunkSize);
return { chunkSize, totalChunks };
}
const TIKTOK_STATUS_POLL_INTERVAL_MS = 2000;
const TIKTOK_STATUS_POLL_TIMEOUT_MS = 2 * 60 * 1000;
// The only privacy_level values TikTok's API itself defines — used only to
// reject an obviously-malformed request early; the real constraint (which
// of these an unaudited client may actually use) is enforced by TikTok's
// own API, driven by whatever fetchTiktokCreatorInfo() reported to the
// client as this account's privacyLevelOptions.
const TIKTOK_VALID_PRIVACY_LEVELS = ["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"];