Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
1529 lines (1454 loc) · 61.4 KB
/
Copy pathserver.js
File metadata and controls
1529 lines (1454 loc) · 61.4 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
/* cable-82 server. Dependency-free: serves the static app and proxies
ONLY the feeds listed in config.json. The browser asks for
/api/feed/<id>; the URL is looked up server-side, so no URL ever
crosses the wire and the proxy cannot be pointed anywhere else.
The control room reads and writes config.json through /api/config.
Node >= 18 (built-in fetch). */
"use strict";
// Node older than 18 cannot load this file at all: it has no `node:` module
// prefix (Node 12) or no built-in fetch (Node 16), and the error it dies
// with ("Cannot find module 'node:http'") tells nobody what went wrong.
// Raspberry Pi OS Bullseye ships Node 12 from apt. Say it in plain words,
// before anything else runs. Keep this block free of syntax Node 10 or 12
// cannot parse, or they crash before reaching it.
const NODE_MAJOR = Number(String(process.versions.node).split(".")[0]);
if (NODE_MAJOR < 18) {
process.stderr.write(
"\n" +
"CABLE 82 needs Node.js 18 or newer. This is Node v" + process.versions.node + ".\n" +
"\n" +
"On a Raspberry Pi (or any Debian/Ubuntu machine), install a current Node.js:\n" +
"\n" +
" curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -\n" +
" sudo apt-get install -y nodejs\n" +
"\n" +
"Then check with `node -v` and run `node server.js` again.\n" +
"\n"
);
process.exit(1);
}
const http = require("node:http");
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
const { pipeline } = require("node:stream");
const { execFile, execFileSync } = require("node:child_process");
const SCHEMA = require("./config-schema.js");
const { readMp4Title } = require("./media-meta.js");
const ROOT = __dirname;
// The display's build: a hash of the files the kiosk runs, taken once at
// startup. After `git pull` and a service restart the display reconnects to
// the bus, sees a different token in the hello, and reloads onto the new
// files by itself. Nobody has to touch the TV.
const DISPLAY_FILES = [
"index.html", "style.css", "config-schema.js",
"dial.js", "board.js", "guide.js", "video.js", "set.js", "tuner.js", "jarvis.js", "app.js",
];
function displayBuild() {
const h = crypto.createHash("sha1");
for (const f of DISPLAY_FILES) {
try {
h.update(fs.readFileSync(path.join(ROOT, f)));
} catch (e) {
h.update(f);
}
}
return h.digest("hex").slice(0, 12);
}
const DISPLAY_BUILD = displayBuild();
const REPO_URL = "https://github.com/nothans/cable-82";
// What release is this? `git describe` names the tag when the checkout sits on
// one (v0.4.0), and says how far past it we are otherwise
// (v0.4.0-3-gabc1234, plus -dirty for local edits). A zip download has no .git
// and no answer, so the control room says "unknown version" rather than
// pretending. Read once at startup: it cannot change without a restart, and
// the release check on the roadmap wants the same fact.
function releaseVersion() {
const git = (args) =>
execFileSync("git", args, { cwd: ROOT, encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] }).trim();
try {
// Git searches upwards, so a copy of this folder sitting inside somebody
// else's repo would answer with that repo's tags. Only trust the answer
// when the checkout root is this folder.
const top = path.resolve(git(["rev-parse", "--show-toplevel"]));
if (top.toLowerCase() !== path.resolve(ROOT).toLowerCase()) return null;
return git(["describe", "--tags", "--dirty", "--always"]) || null;
} catch (e) {
return null;
}
}
const VERSION = releaseVersion();
// Is this a Raspberry Pi, and may we power it down without a password? Both
// have to be true before the control room offers the buttons: a machine that
// cannot answer for itself should not be told to switch off.
function hostModel() {
for (const f of ["/proc/device-tree/model", "/sys/firmware/devicetree/base/model"]) {
try {
// The device tree NUL-terminates the string.
const raw = fs.readFileSync(f, "utf8").replace(/\0/g, "").trim();
if (raw) return raw;
} catch (e) {
/* not a device-tree machine */
}
}
return null;
}
const HOST_MODEL = hostModel();
const IS_PI = /raspberry pi/i.test(HOST_MODEL || "");
// Asked once, the first time anyone needs the answer: sudo rights do not
// come and go while the station is on the air, and spawning sudo on every
// request would be silly. A change needs a restart, like the port does.
let powerAllowed = null;
function canPowerOff() {
if (powerAllowed === null) {
powerAllowed = false;
if (IS_PI && process.platform === "linux") {
try {
execFileSync("sudo", ["-n", "true"], { timeout: 2000, stdio: "ignore" });
powerAllowed = true;
} catch (e) {
/* no passwordless sudo: the buttons stay hidden */
}
}
}
return powerAllowed;
}
// A clean checkout of a release: safe to link straight at that release's page.
const RELEASE_TAG = VERSION && /^v\d+\.\d+\.\d+$/.test(VERSION) ? VERSION : null;
const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".webmanifest": "application/manifest+json; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".md": "text/plain; charset=utf-8",
".xml": "application/xml; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
".png": "image/png",
".ico": "image/x-icon",
".svg": "image/svg+xml",
".mp3": "audio/mpeg",
".m4a": "audio/mp4",
".aac": "audio/aac",
".ogg": "audio/ogg",
".oga": "audio/ogg",
".opus": "audio/ogg",
".wav": "audio/wav",
".flac": "audio/flac",
".mp4": "video/mp4",
".m4v": "video/mp4",
".webm": "video/webm",
".ogv": "video/ogg",
".mov": "video/quicktime",
};
const AUDIO_RE = /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac)$/i;
const VIDEO_RE = /\.(mp4|m4v|webm|ogv|mov)$/i;
// The running order of a folder is the schema's natural sort: the display
// sorts with the same function, so both sides agree on what airs first.
const naturalCompare = SCHEMA.naturalCompare;
// RFC 9110: a 405 names the methods that would have worked.
function send405(res, allow) {
res.writeHead(405, { "content-type": "text/plain; charset=utf-8", allow });
res.end("METHOD NOT ALLOWED");
}
function send(res, code, text) {
if (res.headersSent) return;
res.writeHead(code, { "content-type": "text/plain; charset=utf-8" });
res.end(text || String(code));
}
// Where a JSON parse failed, as a line and column: what a person with the
// file open in an editor needs. V8 words its errors three ways: with a
// position ("... at position 812"), with a snippet of the text around the
// fault ('Unexpected token ',', ..."tagline": ,\n}" is not valid JSON'), or
// with neither ("Unexpected end of JSON input"). The first two can be turned
// into a line and column; the third is already the whole story.
function describeJsonError(text, e) {
const lineCol = (pos) => {
const before = text.slice(0, pos);
return "line " + before.split("\n").length + ", column " + (pos - before.lastIndexOf("\n"));
};
const msg = e.message;
const byPosition = /position (\d+)/.exec(msg);
if (byPosition) return msg.replace(/ in JSON at position \d+.*$/, "") + " at " + lineCol(Number(byPosition[1]));
const bySnippet = /^Unexpected token (.+?), (\.\.\.)?"([\s\S]*)"(\.\.\.)? is not valid JSON$/.exec(msg);
if (bySnippet) {
const token = bySnippet[1].replace(/^'|'$/g, "");
const snippet = bySnippet[3].replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
const at = text.indexOf(snippet);
if (at >= 0) {
// the token V8 tripped on is the last one in the snippet's leading half
const inSnippet = snippet.indexOf(token, Math.max(0, Math.min(snippet.length - 1, 10) - token.length));
const pos = at + (inSnippet >= 0 ? inSnippet : 0);
return "unexpected " + JSON.stringify(token) + " at " + lineCol(pos);
}
}
return msg;
}
// Re-read config.json when its mtime changes; keep the last good config if
// the new file is broken so the channel stays on the air. The parsed config
// is run through the shared schema, so the display and the server always
// agree on what the config means. A broken file is remembered as an error
// (`loadConfig.error`) so the display and the control room can say so,
// rather than the log being the only place that knows.
function makeConfigLoader(configPath) {
let cache = { key: "", cfg: null };
function loadConfig() {
let key;
try {
const st = fs.statSync(configPath);
key = st.mtimeMs + ":" + st.size; // size too: mtime granularity can miss quick saves
} catch (e) {
return cache.cfg;
}
if (key !== cache.key) {
const text = fs.readFileSync(configPath, "utf8");
try {
const raw = JSON.parse(text);
const { ok, cfg, errors } = SCHEMA.validateConfig(raw);
if (!ok) throw new Error(errors.join("; "));
cache = { key, cfg };
loadConfig.error = null;
} catch (e) {
const detail = e instanceof SyntaxError ? describeJsonError(text, e) : e.message;
loadConfig.error = { message: "config.json could not be read: " + detail, since: new Date().toISOString() };
console.error("[cable-82] " + loadConfig.error.message + (cache.cfg ? " (keeping the last good config on the air)" : ""));
cache.key = key; // do not retry until the file changes again
}
}
return cache.cfg;
}
loadConfig.error = null;
return loadConfig;
}
// Read the request body with a hard size cap, so a giant POST can't exhaust
// memory before we reject it.
function readBody(req, maxBytes) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on("data", (c) => {
size += c.length;
if (size > maxBytes) {
req.destroy();
reject(new Error("body exceeds " + maxBytes + " byte cap"));
return;
}
chunks.push(c);
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
req.on("error", reject);
});
}
async function fetchWithCap(url, timeoutMs, maxBytes) {
const r = await fetch(url, {
redirect: "follow",
signal: AbortSignal.timeout(timeoutMs),
headers: {
"user-agent": "cable-82/1.0 (community bulletin board channel)",
accept: "application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.1",
},
});
if (!r.ok) throw new Error("upstream HTTP " + r.status);
const reader = r.body.getReader();
const chunks = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
try { await reader.cancel(); } catch (e) { /* ignore */ }
throw new Error("feed exceeds " + maxBytes + " byte cap");
}
chunks.push(Buffer.from(value));
}
return { body: Buffer.concat(chunks), type: r.headers.get("content-type") || "application/xml" };
}
let mockCounter = 0;
function mockBody(id) {
const dir = path.join(ROOT, "test", "mock-feeds");
let xml;
try {
xml = fs.readFileSync(path.join(dir, id + ".xml"), "utf8");
} catch (e) {
xml = fs.readFileSync(path.join(dir, "news.xml"), "utf8");
}
// Inject a fresh item each serve so refresh cycles see new content.
mockCounter++;
if (xml.includes("</channel>")) {
xml = xml.replace("</channel>", "<item><title>MOCK UPDATE " + mockCounter + "</title></item></channel>");
} else if (xml.includes("</feed>")) {
xml = xml.replace("</feed>", "<entry><title>MOCK UPDATE " + mockCounter + "</title></entry></feed>");
}
return Buffer.from(xml);
}
function createApp(opts = {}) {
const o = Object.assign(
{
mock: false,
chaos: false,
configPath: path.join(ROOT, "config.json"),
musicDir: path.join(ROOT, "music"),
channelsDir: path.join(ROOT, "channels"),
// Extra places to look for a channels folder (--media), on top of the
// drives found by the scan below.
mediaDirs: [],
// Where drives turn up: Linux mounts them under /media or /mnt, macOS
// under /Volumes. Overridable so a test can point the scan at a
// fixture instead of the machine's real drives; empty means do not look.
mediaScanDirs: ["/media", "/mnt", "/Volumes"],
// Set by tests to capture the power commands instead of running them.
powerRun: null,
upstreamTimeoutMs: 10000,
maxFeedBytes: 1024 * 1024,
maxConfigBytes: 512 * 1024,
// Open-Meteo: free, no key, no account. Overridable so tests can point
// at a local mock instead of the real service.
weatherApiBase: "https://api.open-meteo.com/v1/forecast",
geocodeApiBase: "https://geocoding-api.open-meteo.com/v1/search",
// CheerLights (cheerlights.com): the latest global color, one JSON fetch.
cheerlightsApiBase: "https://api.thingspeak.com/channels/1417/feeds/last.json",
// Jarvis speaks through espeak-ng when it is installed. null, or a
// program that is not there, leaves Jarvis on screen without a voice.
espeak: "espeak-ng",
// Set by tests to stand in for espeak-ng: (line, jarvisConfig) ->
// Promise of a WAV buffer, or of null for no voice.
speak: null,
},
opts
);
// Seed config.json from the schema defaults on a fresh clone so the
// channel and the control room both have something to read.
try {
fs.statSync(o.configPath);
} catch (e) {
try {
const seed = SCHEMA.validateConfig(SCHEMA.DEFAULT_CONFIG).cfg;
fs.writeFileSync(o.configPath, JSON.stringify(seed, null, 2) + "\n");
} catch (e2) {
console.error("[cable-82] could not seed config.json: " + e2.message);
}
}
const rawLoadConfig = makeConfigLoader(o.configPath);
const lastGood = new Map(); // feed id -> { body, type }
let weatherCache = null; // last good normalized weather, so a blip keeps the card up
let cheerCache = null; // last good CheerLights color, same idea
// A cheap version token (the file mtime) the display polls to notice a
// control-room save and reload itself.
function configVersion() {
try {
return fs.statSync(o.configPath).mtimeMs;
} catch (e) {
return 0;
}
}
// Validate then atomically replace config.json. Returns { ok, cfg,
// warnings } or { ok:false, errors }. The write is temp-file + rename so a
// crash mid-write never leaves a truncated config on the air.
function writeConfig(raw) {
const { ok, cfg, errors } = SCHEMA.validateConfig(raw);
if (!ok) return { ok: false, errors };
const text = JSON.stringify(cfg, null, 2) + "\n";
const tmp = o.configPath + ".tmp";
try {
fs.writeFileSync(tmp, text);
fs.renameSync(tmp, o.configPath);
} catch (e) {
// Some filesystems refuse rename-over-existing; fall back to a direct
// write so a save still lands.
try {
fs.writeFileSync(o.configPath, text);
try { fs.unlinkSync(tmp); } catch (e3) { /* tmp may not exist */ }
} catch (e2) {
return { ok: false, errors: ["WRITE FAILED: " + e2.message] };
}
}
return { ok: true, cfg, warnings: errors };
}
function loadConfig() {
const cfg = rawLoadConfig();
if (cfg && Array.isArray(cfg.feeds)) {
const ids = new Set(cfg.feeds.map((f) => f && f.id));
for (const k of lastGood.keys()) if (!ids.has(k)) lastGood.delete(k);
}
return cfg;
}
async function handleFeed(id, res) {
const cfg = loadConfig();
if (!cfg || !Array.isArray(cfg.feeds)) return send(res, 500, "CONFIG UNAVAILABLE");
const feed = cfg.feeds.find((f) => f && f.id === id);
if (!feed) return send(res, 404, "UNKNOWN FEED ID");
if (o.mock) {
if (o.chaos) {
const roll = Math.random();
if (roll < 0.25) return send(res, 502, "CHAOS 502");
if (roll < 0.4) {
setTimeout(() => send(res, 502, "CHAOS SLOW 502"), 15000);
return;
}
}
res.writeHead(200, { "content-type": "application/xml; charset=utf-8" });
return res.end(mockBody(id));
}
try {
const got = await fetchWithCap(feed.url, o.upstreamTimeoutMs, o.maxFeedBytes);
lastGood.set(id, got);
res.writeHead(200, { "content-type": got.type });
res.end(got.body);
} catch (e) {
const cached = lastGood.get(id);
if (cached) {
res.writeHead(200, { "content-type": cached.type, "x-cable82-stale": "1" });
return res.end(cached.body);
}
send(res, 502, "FEED UNAVAILABLE: " + e.message);
}
}
function handleConfig(req, res) {
if (req.method === "GET") {
const cfg = loadConfig();
const problem = rawLoadConfig.error;
if (!cfg) {
// Nothing good to serve. Say why in a shape the display and the
// control room can show, so a broken hand edit is not mistaken for
// a server that is down.
res.writeHead(500, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
return res.end(JSON.stringify({ ok: false, error: problem ? problem.message : "config.json could not be read", since: problem && problem.since }));
}
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
const out = { version: String(configVersion()), config: cfg };
// The file on disk is broken but the last good settings are on the
// air: the room shows this and a Save writes a good file over it.
if (problem) out.warning = problem.message + ". The last good settings are on the air; Save from here to write a good file over it.";
return res.end(JSON.stringify(out));
}
if (req.method === "POST") {
// A custom header a same-origin fetch must set; browsers won't attach
// it to a cross-site request without a CORS preflight we never grant,
// so a drive-by page can't rewrite the channel.
if (req.headers["x-cable82-config"] !== "1") return send(res, 403, "MISSING CONFIG HEADER");
// Optional optimistic lock: if the control room says which version it
// loaded and the file has moved since (a hand edit, another tab), refuse
// the stale save instead of silently clobbering the newer config.
const saidVersion = req.headers["x-cable82-config-version"];
if (saidVersion && saidVersion !== String(configVersion())) {
res.writeHead(409, { "content-type": "application/json; charset=utf-8" });
return res.end(JSON.stringify({ ok: false, conflict: true, version: String(configVersion()) }));
}
readBody(req, o.maxConfigBytes)
.then((body) => {
let raw;
try {
raw = JSON.parse(body);
} catch (e) {
return send(res, 400, "INVALID JSON");
}
const result = writeConfig(raw);
if (!result.ok) {
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
return res.end(JSON.stringify({ ok: false, errors: result.errors }));
}
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ ok: true, version: String(configVersion()), config: result.cfg, warnings: result.warnings }));
// Tell every connected display immediately; the poll stays as the
// fallback for a display that connected before SSE existed.
sseBroadcast("config", { version: String(configVersion()) });
})
.catch(() => send(res, 413, "CONFIG BODY TOO LARGE"));
return;
}
return send405(res, "GET, POST");
}
// Geocode a place name into coordinates + timezone for the control room.
// Only the search string varies; the host and path are fixed here, so this
// can't be pointed anywhere else.
async function handleGeocode(query, res) {
const name = String(query || "").trim().slice(0, 80);
if (!name) return send(res, 400, "MISSING SEARCH");
try {
const url = o.geocodeApiBase + "?name=" + encodeURIComponent(name) + "&count=5&language=en&format=json";
const got = await fetchWithCap(url, o.upstreamTimeoutMs, o.maxFeedBytes);
const data = JSON.parse(got.body.toString("utf8"));
const results = (Array.isArray(data.results) ? data.results : []).map((r) => ({
name: r.name,
admin1: r.admin1 || "",
country: r.country || "",
latitude: r.latitude,
longitude: r.longitude,
timezone: r.timezone || "auto",
}));
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
res.end(JSON.stringify({ results }));
} catch (e) {
send(res, 502, "GEOCODE UNAVAILABLE: " + e.message);
}
}
// Fetch current conditions + today's hi/lo/sun for the configured location,
// normalized to a small shape the display renders. Serves the last good
// copy if a refresh fails, same as the feed proxy.
// The current weather for the configured place, or a cached copy when the
// service is down. Throws with .status 503 when no place is set and 502
// when nothing is available at all.
async function currentWeather() {
const cfg = loadConfig();
const w = cfg && cfg.weather;
const loc = w && w.location;
if (!loc || !Number.isFinite(loc.latitude) || !Number.isFinite(loc.longitude)) {
throw Object.assign(new Error("NO WEATHER LOCATION SET"), { status: 503 });
}
const tempParam = w.tempUnit === "C" ? "celsius" : "fahrenheit";
const windParam = w.windUnit === "kmh" ? "kmh" : "mph";
const url =
o.weatherApiBase +
"?latitude=" + encodeURIComponent(loc.latitude) +
"&longitude=" + encodeURIComponent(loc.longitude) +
"¤t=temperature_2m,weather_code,wind_speed_10m" +
"&daily=temperature_2m_max,temperature_2m_min,sunrise,sunset" +
"&temperature_unit=" + tempParam +
"&wind_speed_unit=" + windParam +
"&timezone=" + encodeURIComponent(loc.timezone || "auto") +
"&forecast_days=1";
try {
const got = await fetchWithCap(url, o.upstreamTimeoutMs, o.maxFeedBytes);
const data = JSON.parse(got.body.toString("utf8"));
const cur = data.current || {};
const daily = data.daily || {};
const first = (arr) => (Array.isArray(arr) && arr.length ? arr[0] : null);
const num = (v) => (Number.isFinite(Number(v)) ? Math.round(Number(v)) : null);
const out = {
name: loc.name || "",
tempNow: num(cur.temperature_2m),
tempUnit: w.tempUnit === "C" ? "C" : "F",
code: cur.weather_code,
condition: SCHEMA.weatherText(cur.weather_code),
wind: num(cur.wind_speed_10m),
windUnit: w.windUnit === "kmh" ? "KM/H" : "MPH",
tempHi: num(first(daily.temperature_2m_max)),
tempLo: num(first(daily.temperature_2m_min)),
sunrise: first(daily.sunrise),
sunset: first(daily.sunset),
};
weatherCache = out;
return { data: out, stale: false };
} catch (e) {
if (weatherCache) return { data: weatherCache, stale: true };
throw Object.assign(new Error("WEATHER UNAVAILABLE: " + e.message), { status: 502 });
}
}
async function handleWeather(res) {
try {
const { data, stale } = await currentWeather();
res.writeHead(200, Object.assign(
{ "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
stale ? { "x-cable82-stale": "1" } : {}
));
res.end(JSON.stringify(data));
} catch (e) {
send(res, e.status || 502, e.message);
}
}
// The latest CheerLights color (cheerlights.com): one global color anyone
// in the world can set. Fetched server-side like everything else, last
// good copy kept so a blip never blanks the ticker item.
async function currentCheerlights() {
const cfg = loadConfig();
if (!cfg || !cfg.cheerlights || !cfg.cheerlights.enabled) {
throw Object.assign(new Error("CHEERLIGHTS NOT ENABLED"), { status: 503 });
}
try {
const got = await fetchWithCap(o.cheerlightsApiBase, o.upstreamTimeoutMs, o.maxFeedBytes);
const data = JSON.parse(got.body.toString("utf8"));
const color = String(data.field1 || "").trim().toLowerCase().slice(0, 24);
if (!color || !/^[a-z]+$/.test(color)) throw new Error("no color in feed");
// The name is all the crawl uses: it scrolls the word, not a swatch.
const out = { color };
cheerCache = out;
return { data: out, stale: false };
} catch (e) {
if (cheerCache) return { data: cheerCache, stale: true };
throw Object.assign(new Error("CHEERLIGHTS UNAVAILABLE: " + e.message), { status: 502 });
}
}
async function handleCheerlights(res) {
try {
const { data, stale } = await currentCheerlights();
res.writeHead(200, Object.assign(
{ "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
stale ? { "x-cable82-stale": "1" } : {}
));
res.end(JSON.stringify(data));
} catch (e) {
send(res, e.status || 502, e.message);
}
}
// ---------------- channels (the dial's video folders)
// Per-folder duration cache, kept beside the videos as .durations.json.
// The server never probes media itself (dependency-free); the display
// learns each file's duration once from the <video> metadata and posts it
// back, so the cache fills itself on first play and then stays.
// ---------------------------------------------------------- where channels live
//
// A channel is a folder of video. The bundled one is channels/ next to the
// server, but a 16 GB card fills up fast and a library does not have to live
// on it: plug in a drive, put a channels/ folder at the top of it, and its
// folders join the same pool under the same names. Removable media is
// scanned (Linux mounts them under /media or /mnt, macOS under /Volumes),
// and --media adds any other path, a NAS mount included.
let rootsCache = { at: 0, roots: [] };
function mediaCandidates() {
const out = [];
for (const base of o.mediaScanDirs) {
let entries = [];
try {
entries = fs.readdirSync(base);
} catch (e) {
continue; // that base does not exist on this machine
}
for (const name of entries) {
const dir = path.join(base, name);
out.push(dir);
// Linux desktops mount per user: /media/<user>/<label>.
try {
if (fs.statSync(dir).isDirectory()) {
for (const sub of fs.readdirSync(dir)) out.push(path.join(dir, sub));
}
} catch (e) {
/* unreadable mount point, skip it */
}
}
}
return out;
}
// Every root that actually holds a channels folder right now, the built-in
// one first so a local folder always wins a name it shares with a drive.
// Cached for a few seconds: a drive can come and go, but not that fast, and
// this runs on every listing.
function channelRoots() {
const now = Date.now();
if (now - rootsCache.at < 5000) return rootsCache.roots;
const roots = [{ dir: o.channelsDir, volume: "" }];
const seen = new Set([path.resolve(o.channelsDir)]);
const add = (dir, volume) => {
const abs = path.resolve(dir);
if (seen.has(abs)) return false;
try {
if (!fs.statSync(abs).isDirectory()) return false;
} catch (e) {
return false;
}
seen.add(abs);
roots.push({ dir: abs, volume });
return true;
};
// A drive earns a place by carrying a channels folder, and nothing else:
// every mount point is a directory, so without that rule a plugged-in
// drive would offer its whole contents as channels.
for (const mount of mediaCandidates()) add(path.join(mount, "channels"), path.basename(mount));
// A path named with --media is a deliberate choice, so it may be the
// channels folder itself.
for (const dir of o.mediaDirs || []) {
if (!add(path.join(dir, "channels"), path.basename(dir))) add(dir, path.basename(dir));
}
rootsCache = { at: now, roots };
return roots;
}
// The directory a folder name resolves to, and which volume it came from.
// Names stay single safe segments, so a config written before any of this
// means exactly what it always did.
function resolveFolder(name) {
if (!safeFolder(name)) return null;
for (const root of channelRoots()) {
const dir = path.join(root.dir, name);
if (!dir.startsWith(root.dir + path.sep)) continue; // belt and braces
try {
if (fs.statSync(dir).isDirectory()) return { dir, volume: root.volume };
} catch (e) {
/* not on this root */
}
}
return null;
}
function readDurations(dir) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(dir, ".durations.json"), "utf8"));
return raw && typeof raw === "object" ? raw : {};
} catch (e) {
return {};
}
}
// One safe path segment under channels/ - the schema's rule, not a copy
// of it, so the two can never drift apart.
function safeFolder(name) {
return typeof name === "string" && SCHEMA.FOLDER_RE.test(name) && !name.includes("..");
}
// The titles written inside a folder's files, read once each and kept
// beside the videos as .titles.json (null for a file with none). Only a
// channel set to show titles from its files asks; a folder of two hundred
// files is walked once, header by header, and never again.
function readTitles(dir, names) {
const cachePath = path.join(dir, ".titles.json");
let cache = {};
try {
const raw = JSON.parse(fs.readFileSync(cachePath, "utf8"));
if (raw && typeof raw === "object") cache = raw;
} catch (e) {
/* no cache yet */
}
let learned = 0;
for (const f of names) {
if (f in cache) continue;
cache[f] = /\.(mp4|m4v|mov)$/i.test(f) ? readMp4Title(path.join(dir, f)) : null;
learned++;
}
if (learned) {
try {
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2) + "\n");
} catch (e) {
/* a read-only drive: the titles are still served, just re-read next time */
}
}
return cache;
}
// The playlist of one folder that has already been resolved to a
// directory: the files in airing order, each with its cached duration,
// and the title inside it when the channel wants those.
function listFiles(folder, dir, withTitles) {
const durations = readDurations(dir);
let names = [];
try {
names = fs.readdirSync(dir).filter((f) => VIDEO_RE.test(f)).sort(naturalCompare);
} catch (e) {
return null;
}
const titles = withTitles ? readTitles(dir, names) : null;
return names.map((f) => {
const entry = {
file: f,
url: "channels/" + encodeURIComponent(folder) + "/" + encodeURIComponent(f),
duration: Number.isFinite(durations[f]) ? durations[f] : null,
};
if (titles) entry.title = typeof titles[f] === "string" ? titles[f] : null;
return entry;
});
}
// The same for a folder named in the config, wherever it lives.
function listChannelFiles(folder, withTitles) {
const found = resolveFolder(folder);
return found ? listFiles(folder, found.dir, withTitles) : null; // null: missing on every root
}
// GET /api/channels: every folder under channels/ (for the control room's
// picker) plus the playlist for each configured video channel (for the
// display). One endpoint, one scan, both consumers.
function handleChannels(res) {
const folders = [];
const listed = new Set();
for (const root of channelRoots()) {
let names = [];
try {
names = fs.readdirSync(root.dir).sort(naturalCompare);
} catch (e) {
continue; // no channels folder on this root: nothing to add
}
for (const name of names) {
if (!safeFolder(name) || listed.has(name)) continue;
const dir = path.join(root.dir, name);
try {
if (!fs.statSync(dir).isDirectory()) continue;
} catch (e) {
continue;
}
listed.add(name);
const files = listFiles(name, dir) || [];
const known = files.filter((f) => f.duration != null);
folders.push({
folder: name,
volume: root.volume,
files: files.length,
seconds: Math.round(known.reduce((a, f) => a + f.duration, 0)),
probed: known.length,
});
}
}
folders.sort((a, b) => naturalCompare(a.folder, b.folder));
const cfg = loadConfig();
const channels = [];
for (const ch of (cfg && cfg.channels) || []) {
if (ch.type !== "video") continue;
const entry = { number: ch.number, folder: ch.folder, files: listChannelFiles(ch.folder, ch.titles === "metadata") || [] };
// A programmed channel carries its spots too: one fetch gives the
// display everything the composite timeline is built from.
if (ch.breaks) entry.breaks = { folder: ch.breaks.folder, files: listChannelFiles(ch.breaks.folder) || [] };
channels.push(entry);
}
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
res.end(JSON.stringify({ folders, channels }));
}
// POST /api/channels/durations {folder, durations:{file:seconds}} - the
// display reporting what it measured. Guarded like a config write, and
// only files that actually exist in the folder are recorded.
function handleDurations(req, res) {
if (req.method !== "POST") return send405(res, "POST");
if (req.headers["x-cable82-config"] !== "1") return send(res, 403, "MISSING CONFIG HEADER");
readBody(req, 256 * 1024)
.then((body) => {
let raw;
try {
raw = JSON.parse(body);
} catch (e) {
return send(res, 400, "INVALID JSON");
}
const folder = raw && raw.folder;
if (!safeFolder(folder)) return send(res, 400, "BAD FOLDER");
const found = resolveFolder(folder);
if (!found) return send(res, 404, "UNKNOWN FOLDER");
const dir = found.dir;
let names;
try {
names = new Set(fs.readdirSync(dir).filter((f) => VIDEO_RE.test(f)));
} catch (e) {
return send(res, 404, "UNKNOWN FOLDER");
}
const cache = readDurations(dir);
let wrote = 0;
const entries = raw.durations && typeof raw.durations === "object" ? raw.durations : {};
for (const [file, dur] of Object.entries(entries)) {
const d = Number(dur);
if (!names.has(file) || !Number.isFinite(d) || d <= 0 || d > 24 * 3600) continue;
cache[file] = Math.round(d * 100) / 100;
wrote++;
}
try {
fs.writeFileSync(path.join(dir, ".durations.json"), JSON.stringify(cache, null, 2) + "\n");
} catch (e) {
return send(res, 500, "CACHE WRITE FAILED: " + e.message);
}
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ ok: true, wrote }));
})
.catch(() => send(res, 413, "BODY TOO LARGE"));
}
// ---------------- the tuner bus (events, not state)
// Sources send events - up, down, set - and the display applies each one
// once, tracked by sequence number. The server never asserts a current
// channel, so an HTTP source can never fight the gamepad. Learned the hard
// way: a source that re-declares "the channel is X" overrides every local
// tune a second later.
const sseClients = new Set();
let tuneSeq = 0;
let lastTune = null;
// The four keys of a 1960s remote plus a direct dial: up/down step the
// dial, set jumps to a number, volume steps the sound (loud, off, soft,
// medium, loud), power toggles the picture off and on. volumeUp and
// volumeDown move the sound one cell of the meter, for a source that has
// two directions to give. Each is an event the display interprets; the
// server never holds a channel or a level.
const TUNE_CMDS = ["up", "down", "set", "volume", "volumeUp", "volumeDown", "power"];
function sseBroadcast(event, data) {
const msg = "event: " + event + "\ndata: " + JSON.stringify(data) + "\n\n";
for (const client of sseClients) {
try {
client.write(msg);
} catch (e) {
sseClients.delete(client);
}
}
}
function handleTune(req, res) {
const cfg = loadConfig();
if (!cfg || !cfg.tuner.sources.http) return send(res, 403, "HTTP TUNING IS DISABLED");
if (req.method === "GET") {
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
return res.end(JSON.stringify({ seq: tuneSeq, last: lastTune, listeners: sseClients.size }));
}
if (req.method !== "POST") return send405(res, "GET, POST");
readBody(req, 4096)
.then((body) => {
let raw;
try {
raw = JSON.parse(body);
} catch (e) {
return send(res, 400, "INVALID JSON");
}
const cmd = raw && raw.cmd;
if (!TUNE_CMDS.includes(cmd)) return send(res, 400, "CMD MUST BE up, down, set, volume, volumeUp, volumeDown, OR power");
const evt = { seq: ++tuneSeq, cmd };
if (cmd === "set") {
const n = Math.round(Number(raw.channel));
if (!Number.isFinite(n) || n < 0 || n > 999) return send(res, 400, "SET NEEDS A CHANNEL NUMBER (0-999)");
evt.channel = n;
}
lastTune = evt;
sseBroadcast("tune", evt);
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ ok: true, seq: evt.seq, listeners: sseClients.size }));
})
.catch(() => send(res, 413, "BODY TOO LARGE"));
}
// ---------------- Jarvis (the on-screen host)
// POST /api/jarvis puts Jarvis on the screen to say one line: either the
// words given ({text}) or an answer the station writes from what it
// already knows ({ask: "weather" | "cheerlights"}). The line rides the
// bus as a `jarvis` event; when espeak-ng is installed the server also
// renders it to a WAV the display plays and moves the mouth to. Only the
// last few clips are kept, in memory. `voice` in the reply says a voice
// was asked for; the clip itself 404s if it could not be made, and the
// display then speaks with the drawn mouth instead.
let jarvisSeq = 0;
const jarvisClips = new Map();
// The questions Jarvis answers from what the station already knows.
const JARVIS_ASKS = ["weather", "cheerlights", "time", "joke"];
const JARVIS_MAX = 240;
function jarvisLine(text) {
return String(text).replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, JARVIS_MAX);
}
async function jarvisAnswer(ask) {
if (ask === "weather") {
const { data: w } = await currentWeather();
const where = w.name ? " in " + w.name : "";
const unit = w.tempUnit === "C" ? "Celsius" : "degrees";
let line = "It's " + w.tempNow + " " + unit + " and " + String(w.condition || "").toLowerCase() + where + ".";
if (Number.isFinite(w.tempHi) && Number.isFinite(w.tempLo)) line += " High of " + w.tempHi + ", low of " + w.tempLo + ".";
return line;
}
if (ask === "cheerlights") {
const { data: c } = await currentCheerlights();
return "CheerLights is " + c.color + " right now.";
}
const cfg = loadConfig() || {};
if (ask === "time") {
// In the station's own clock format, and the weather place's time
// zone when one is set (the set's clock follows the machine's).
const zone = cfg.weather && cfg.weather.location && cfg.weather.location.timezone;
const fmt = (tz) =>
new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: cfg.timeFormat !== "24h", ...(tz ? { timeZone: tz } : {}) });
let now;
try {
now = fmt(zone);
} catch (e) {
now = fmt(null);
}
return "It's " + now + ".";
}
// joke: one from the board's own deck.
const deck = Array.isArray(cfg.dadJokes) ? cfg.dadJokes : [];
if (!deck.length) throw Object.assign(new Error("NO JOKES IN THE DECK"), { status: 503 });
return deck[Math.floor(Math.random() * deck.length)];
}
// Whether espeak-ng is installed: asked once, and again at most once a
// minute while it is missing, so installing it needs no restart.
let espeakFound = null;
let espeakAskedAt = 0;
function espeakInstalled() {
if (!o.espeak) return false;