nixamp 0.28.45 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio.js +3 -0
- package/dist/channels.d.ts +14 -0
- package/dist/channels.js +20 -4
- package/dist/decoder-policy.d.ts +20 -0
- package/dist/decoder-policy.js +34 -0
- package/dist/destinations.d.ts +5 -0
- package/dist/destinations.js +82 -0
- package/dist/directory.d.ts +3 -1
- package/dist/directory.js +8 -2
- package/dist/library.d.ts +2 -0
- package/dist/library.js +4 -1
- package/dist/links.d.ts +4 -0
- package/dist/links.js +20 -2
- package/dist/main.d.ts +0 -8
- package/dist/main.js +9 -0
- package/dist/playlist-input.d.ts +46 -0
- package/dist/playlist-input.js +158 -54
- package/dist/publish-choice.d.ts +21 -0
- package/dist/publish-choice.js +17 -0
- package/dist/server.d.ts +27 -1
- package/dist/server.js +228 -17
- package/dist/share.d.ts +1 -1
- package/dist/share.js +5 -4
- package/dist/station-cli.d.ts +3 -0
- package/dist/station-cli.js +201 -0
- package/dist/station-page.d.ts +5 -0
- package/dist/station-page.js +137 -0
- package/dist/station-routes.d.ts +60 -0
- package/dist/station-routes.js +249 -0
- package/dist/station-runner.d.ts +67 -0
- package/dist/station-runner.js +219 -0
- package/dist/station-usage.d.ts +2 -0
- package/dist/station-usage.js +20 -0
- package/dist/stations.d.ts +148 -0
- package/dist/stations.js +331 -0
- package/package.json +1 -1
- package/src/audio.ts +3 -0
- package/src/channels.ts +30 -5
- package/src/decoder-policy.ts +33 -0
- package/src/destinations.ts +78 -0
- package/src/directory.ts +10 -2
- package/src/library.ts +6 -1
- package/src/links.ts +23 -4
- package/src/main.ts +8 -0
- package/src/playlist-input.ts +172 -50
- package/src/publish-choice.ts +35 -0
- package/src/server.ts +227 -18
- package/src/share.ts +5 -4
- package/src/station-cli.ts +195 -0
- package/src/station-page.ts +144 -0
- package/src/station-routes.ts +301 -0
- package/src/station-runner.ts +244 -0
- package/src/station-usage.ts +20 -0
- package/src/stations.ts +407 -0
- package/web/dist/assets/{ar-8fDonUoB.js → ar-CTUDxy1J.js} +1 -1
- package/web/dist/assets/{cs-DCClolTy.js → cs-m4JTaO3o.js} +1 -1
- package/web/dist/assets/{da-C3RPhXUd.js → da-DHnJElYB.js} +1 -1
- package/web/dist/assets/{de-CYBoEomk.js → de-DkxPohnf.js} +1 -1
- package/web/dist/assets/{es-DS3ls4xT.js → es-oY_G83d7.js} +1 -1
- package/web/dist/assets/{fi-DbCZpO89.js → fi-fCO8XRdg.js} +1 -1
- package/web/dist/assets/{fr-D0z3UpdL.js → fr-CSupf-IN.js} +1 -1
- package/web/dist/assets/{hi-BTU3sRlQ.js → hi-Dve7PJsC.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-CQXTVHn_.js → hls-3VKVEQE3-D5-AkKSR.js} +1 -1
- package/web/dist/assets/{hu-B37Q8ldT.js → hu-D1KYamzV.js} +1 -1
- package/web/dist/assets/{id-4pdXNa4G.js → id-Cofp_4aQ.js} +1 -1
- package/web/dist/assets/index-7mCQZGNF.js +22 -0
- package/web/dist/assets/{it-CTe69-UY.js → it-62iXQWQD.js} +1 -1
- package/web/dist/assets/{mpegts-BBmkDppm.js → mpegts-BT5ESacM.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CUVdjYFi.js → mpegts-LO6RVLD6-BgHd8PFT.js} +1 -1
- package/web/dist/assets/{nl-CxrmHrMn.js → nl-CzRRG-CN.js} +1 -1
- package/web/dist/assets/{ru-zjVCfIKq.js → ru-OykC1LJQ.js} +1 -1
- package/web/dist/assets/{sv-CEoiT2Lr.js → sv-B8FXBDHm.js} +1 -1
- package/web/dist/assets/{uk-ZC6JzQvD.js → uk-DhAJBjwH.js} +1 -1
- package/web/dist/assets/{vi-D9anF-cl.js → vi-CdBSRReC.js} +1 -1
- package/web/dist/assets/{zh-BeLYdC0I.js → zh-wGWNyBEZ.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
- package/web/dist/assets/index-BDBbN5Qy.js +0 -22
package/dist/playlist-input.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { protocolArgs } from "./decoder-policy.js";
|
|
2
3
|
import { codecsOf } from "./audio.js";
|
|
4
|
+
/**
|
|
5
|
+
* How many entries in a row may fail before the list is given up on. A
|
|
6
|
+
* corrupt file is skipped; a list where nothing plays is an error, said once,
|
|
7
|
+
* not a loop.
|
|
8
|
+
*/
|
|
9
|
+
export const MAX_FAILURES_IN_A_ROW = 5;
|
|
3
10
|
/** Decode each file independently; feed one persistent muxer a fixed stream
|
|
4
11
|
* profile and continuous timestamps. A concat demuxer cannot decode a list
|
|
5
12
|
* whose input codecs differ. Only one entry is encoded at a time, with pipe
|
|
@@ -12,11 +19,26 @@ export class PlaylistInputStream {
|
|
|
12
19
|
constructor(options) {
|
|
13
20
|
this.options = options;
|
|
14
21
|
}
|
|
22
|
+
/** Every entry that has started, in the order it started, with where it sits in the stream. */
|
|
23
|
+
get history() {
|
|
24
|
+
return this.entries;
|
|
25
|
+
}
|
|
15
26
|
place(seconds) {
|
|
16
27
|
const entry = this.entries.findLast(one => seconds >= one.start) ?? this.entries[0];
|
|
17
28
|
return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position) }
|
|
18
29
|
: { at: this.options.at, position: this.options.position };
|
|
19
30
|
}
|
|
31
|
+
/** The decoder for the entry that is on, so skip() can end it early. */
|
|
32
|
+
stopCurrent = null;
|
|
33
|
+
skipping = false;
|
|
34
|
+
/** End the entry that is on and go to the next. False when nothing is on. */
|
|
35
|
+
skip() {
|
|
36
|
+
if (!this.stopCurrent)
|
|
37
|
+
return false;
|
|
38
|
+
this.skipping = true;
|
|
39
|
+
this.stopCurrent();
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
20
42
|
async *open(signal) {
|
|
21
43
|
const o = this.options;
|
|
22
44
|
const [command, ...prefix] = o.ffmpeg;
|
|
@@ -24,66 +46,148 @@ export class PlaylistInputStream {
|
|
|
24
46
|
const width = Math.max(2, Math.floor((o.width || 1280) * ratio / 2) * 2);
|
|
25
47
|
const height = Math.max(2, Math.floor((o.height || 720) * ratio / 2) * 2);
|
|
26
48
|
let offset = 0;
|
|
49
|
+
let failuresInARow = 0;
|
|
50
|
+
let first = true;
|
|
27
51
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
52
|
+
const orderFor = (list) => o.order ? o.order(list.length).filter((one) => one >= 0 && one < list.length) : list.map((_, index) => index);
|
|
53
|
+
let files = o.current?.() ?? o.files;
|
|
54
|
+
if (files.length === 0)
|
|
55
|
+
throw new Error("The playlist is empty");
|
|
56
|
+
let order = orderFor(files);
|
|
57
|
+
let step = Math.min(o.at, order.length);
|
|
58
|
+
let played = 0;
|
|
59
|
+
let last = "";
|
|
60
|
+
for (;;) {
|
|
31
61
|
if (signal.aborted)
|
|
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
|
-
|
|
62
|
+
break;
|
|
63
|
+
// Asked at every boundary, so an edit lands on the next track. The
|
|
64
|
+
// list goes on after the file that just played, where the new list
|
|
65
|
+
// has it; a shuffled list draws a new order.
|
|
66
|
+
if (o.current) {
|
|
67
|
+
const fresh = o.current();
|
|
68
|
+
if (fresh.length !== files.length || fresh.some((one, index) => one !== files[index])) {
|
|
69
|
+
if (fresh.length === 0)
|
|
70
|
+
throw new Error("The playlist is empty");
|
|
71
|
+
files = fresh;
|
|
72
|
+
order = orderFor(files);
|
|
73
|
+
const where = last ? files.indexOf(last) : -1;
|
|
74
|
+
step = o.order ? 0 : where >= 0 ? where + 1 : Math.min(step, files.length);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (step >= order.length) {
|
|
78
|
+
if (!o.repeat)
|
|
79
|
+
break;
|
|
80
|
+
// A pass where everything was passed over is a list with nothing
|
|
81
|
+
// to play, and repeating it would be the loop this exists to avoid.
|
|
82
|
+
if (played === 0)
|
|
83
|
+
throw new Error("Nothing in this playlist would play");
|
|
84
|
+
played = 0;
|
|
85
|
+
order = orderFor(files);
|
|
86
|
+
step = 0;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
{
|
|
90
|
+
const at = order[step];
|
|
91
|
+
step += 1;
|
|
92
|
+
const file = files[at];
|
|
93
|
+
last = file;
|
|
94
|
+
if (o.excluded?.(file))
|
|
95
|
+
continue;
|
|
96
|
+
const position = first && step - 1 === o.at ? o.position : 0;
|
|
97
|
+
first = false;
|
|
98
|
+
let reached = 0;
|
|
99
|
+
try {
|
|
100
|
+
const codecs = await codecsOf({ ffmpeg: o.ffmpeg, ffprobe: o.ffprobe, play: null }, file, [], AbortSignal.any([signal, AbortSignal.timeout(10_000)]));
|
|
69
101
|
if (signal.aborted)
|
|
70
102
|
return;
|
|
71
|
-
|
|
103
|
+
if (!codecs.audio && !codecs.video)
|
|
104
|
+
throw new Error(`Cannot decode playlist entry ${at + 1}`);
|
|
105
|
+
const duration = (codecs.duration ?? 0) - position;
|
|
106
|
+
if (duration <= 0)
|
|
107
|
+
throw new Error(`No playable duration for playlist entry ${at + 1}`);
|
|
108
|
+
this.entries.push({ at, file, start: offset, position });
|
|
109
|
+
const hasVideo = Boolean(codecs.video);
|
|
110
|
+
const extra = o.video && !hasVideo
|
|
111
|
+
? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
|
|
112
|
+
: o.video && !codecs.audio ? ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"] : [];
|
|
113
|
+
const args = [
|
|
114
|
+
...prefix, "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "2",
|
|
115
|
+
// How far this entry got, so an entry cut short -- skipped, or
|
|
116
|
+
// broken halfway -- moves the clock by what was played and the
|
|
117
|
+
// next one starts where this one stopped, not after a gap.
|
|
118
|
+
"-progress", "pipe:3", "-stats_period", "0.5",
|
|
119
|
+
"-threads", "2", ...(position > 0 ? ["-ss", String(position)] : []), ...protocolArgs(file), "-i", file,
|
|
120
|
+
...extra, "-t", String(duration),
|
|
121
|
+
...(o.video ? [
|
|
122
|
+
"-map", hasVideo ? "0:v:0" : "1:v:0", "-map", codecs.audio ? "0:a:0" : "1:a:0",
|
|
123
|
+
"-vf", `fps=30,scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,setpts=PTS-STARTPTS`,
|
|
124
|
+
"-c:v", "libx264", "-threads", "2", "-preset", "veryfast", "-tune", "zerolatency",
|
|
125
|
+
"-crf", "23", "-pix_fmt", "yuv420p", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
|
126
|
+
] : ["-map", "0:a:0", "-vn"]),
|
|
127
|
+
"-af", "aresample=48000,asetpts=PTS-STARTPTS", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
|
|
128
|
+
"-mpegts_flags", "+initial_discontinuity", "-output_ts_offset", String(offset), "-mpegts_copyts", "1", "-muxdelay", "0", "-muxpreload", "0",
|
|
129
|
+
"-f", "mpegts", "pipe:1",
|
|
130
|
+
];
|
|
131
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe", "pipe"] });
|
|
132
|
+
let tail = "";
|
|
133
|
+
child.stderr.on("data", chunk => { tail = (tail + chunk.toString()).slice(-1500); });
|
|
134
|
+
child.stdio[3]?.on("data", (chunk) => {
|
|
135
|
+
const found = /out_time_us=(\d+)/g;
|
|
136
|
+
let match;
|
|
137
|
+
while ((match = found.exec(chunk.toString())) !== null)
|
|
138
|
+
reached = Math.max(reached, Number(match[1]) / 1e6);
|
|
139
|
+
});
|
|
140
|
+
const closed = new Promise((resolve) => {
|
|
141
|
+
child.once("error", error => { tail = error.message; resolve(-1); });
|
|
142
|
+
child.once("close", code => resolve(code));
|
|
143
|
+
});
|
|
144
|
+
const abort = () => { child.kill("SIGKILL"); };
|
|
145
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
146
|
+
this.stopCurrent = abort;
|
|
147
|
+
let skipped = false;
|
|
148
|
+
try {
|
|
149
|
+
for await (const chunk of child.stdout) {
|
|
150
|
+
if (signal.aborted)
|
|
151
|
+
return;
|
|
152
|
+
yield chunk;
|
|
153
|
+
}
|
|
154
|
+
const code = await closed;
|
|
155
|
+
if (signal.aborted)
|
|
156
|
+
return;
|
|
157
|
+
skipped = this.skipping;
|
|
158
|
+
if (code !== 0 && !skipped)
|
|
159
|
+
throw new Error(`Playlist entry ${at + 1} failed: ${tail.trim()}`);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
this.stopCurrent = null;
|
|
163
|
+
this.skipping = false;
|
|
164
|
+
signal.removeEventListener("abort", abort);
|
|
165
|
+
abort();
|
|
166
|
+
await closed;
|
|
167
|
+
}
|
|
168
|
+
failuresInARow = 0;
|
|
169
|
+
played += 1;
|
|
170
|
+
// Match the frame grid used above so rounding cannot accumulate a
|
|
171
|
+
// backwards timestamp over hundreds of short files.
|
|
172
|
+
const length = skipped ? Math.min(duration, reached) : duration;
|
|
173
|
+
offset += o.video ? Math.ceil(length * 30) / 30 : length;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (signal.aborted || !o.passOver)
|
|
177
|
+
throw error;
|
|
178
|
+
// Passed over, not dialled again: the next entry is what a
|
|
179
|
+
// listener should hear, and a file that will not decode now will
|
|
180
|
+
// not decode in two seconds either.
|
|
181
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
182
|
+
o.onFailure?.({ at, file, reason });
|
|
183
|
+
if (reached > 0)
|
|
184
|
+
offset += o.video ? Math.ceil(reached * 30) / 30 : reached;
|
|
185
|
+
failuresInARow += 1;
|
|
186
|
+
if (failuresInARow >= Math.min(MAX_FAILURES_IN_A_ROW, Math.max(1, files.length))) {
|
|
187
|
+
throw new Error(`Nothing in this playlist would play: ${reason}`);
|
|
188
|
+
}
|
|
72
189
|
}
|
|
73
|
-
const code = await closed;
|
|
74
|
-
if (signal.aborted)
|
|
75
|
-
return;
|
|
76
|
-
if (code !== 0)
|
|
77
|
-
throw new Error(`Playlist entry ${at + 1} failed: ${tail.trim()}`);
|
|
78
|
-
}
|
|
79
|
-
finally {
|
|
80
|
-
signal.removeEventListener("abort", abort);
|
|
81
|
-
abort();
|
|
82
|
-
await closed;
|
|
83
190
|
}
|
|
84
|
-
// Match the frame grid used above so rounding cannot accumulate a
|
|
85
|
-
// backwards timestamp over hundreds of short files.
|
|
86
|
-
offset += o.video ? Math.ceil(duration * 30) / 30 : duration;
|
|
87
191
|
}
|
|
88
192
|
this.complete = !signal.aborted;
|
|
89
193
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this server goes in the public directory.
|
|
3
|
+
*
|
|
4
|
+
* A listing is a phone code and a link anybody can open, so it is a thing
|
|
5
|
+
* somebody chose, not a thing that happens because a daemon had no terminal
|
|
6
|
+
* to ask on. The choice is asked once, or taken from a flag, and written into
|
|
7
|
+
* config.json beside the library, and every later start reads it back.
|
|
8
|
+
*
|
|
9
|
+
* Servers that were listed before the choice was written down keep being
|
|
10
|
+
* listed: a daemon without a terminal used to publish by itself, and quietly
|
|
11
|
+
* taking it off the directory on an update would be changing somebody's
|
|
12
|
+
* choice for them. A server new to this machine is not listed until asked.
|
|
13
|
+
*/
|
|
14
|
+
export type PublishFlag = "ask" | "yes" | "no";
|
|
15
|
+
/**
|
|
16
|
+
* "yes" or "no" when the answer is already known, "ask" when there is a
|
|
17
|
+
* person at the terminal to ask.
|
|
18
|
+
*/
|
|
19
|
+
export declare function publishChoice(flag: PublishFlag, recorded: boolean | undefined, interactive: boolean, established: boolean): "yes" | "no" | "ask";
|
|
20
|
+
/** Whether to write the answer down: only a choice nobody had made yet. */
|
|
21
|
+
export declare function shouldRecord(flag: PublishFlag, recorded: boolean | undefined): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "yes" or "no" when the answer is already known, "ask" when there is a
|
|
3
|
+
* person at the terminal to ask.
|
|
4
|
+
*/
|
|
5
|
+
export function publishChoice(flag, recorded, interactive, established) {
|
|
6
|
+
if (flag !== "ask")
|
|
7
|
+
return flag;
|
|
8
|
+
if (recorded !== undefined)
|
|
9
|
+
return recorded ? "yes" : "no";
|
|
10
|
+
if (interactive)
|
|
11
|
+
return "ask";
|
|
12
|
+
return established ? "yes" : "no";
|
|
13
|
+
}
|
|
14
|
+
/** Whether to write the answer down: only a choice nobody had made yet. */
|
|
15
|
+
export function shouldRecord(flag, recorded) {
|
|
16
|
+
return flag === "ask" && recorded === undefined;
|
|
17
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { LiveVoice } from "./live-voice.ts";
|
|
|
5
5
|
import { Connections } from "./connections.ts";
|
|
6
6
|
import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast.ts";
|
|
7
7
|
import { Ingest } from "./ingest.ts";
|
|
8
|
-
import { Channels, type Channel, type ChannelInfo, type RememberedChannel } from "./channels.ts";
|
|
8
|
+
import { Channels, type Channel, type ChannelInfo, type PullResume, type RememberedChannel } from "./channels.ts";
|
|
9
9
|
import { Accounts } from "./accounts.ts";
|
|
10
10
|
import { type ResetMail } from "./password-reset.ts";
|
|
11
11
|
import { Handles } from "./handles.ts";
|
|
@@ -21,6 +21,8 @@ import { Directory, type LineupEntry } from "./directory.ts";
|
|
|
21
21
|
import { ArtCache } from "./art.ts";
|
|
22
22
|
import { PartyLine } from "./partyline.ts";
|
|
23
23
|
import { HlsPackagers } from "./hls.ts";
|
|
24
|
+
import { StationRunner } from "./station-runner.ts";
|
|
25
|
+
import { StationStore } from "./stations.ts";
|
|
24
26
|
import { CompressionService } from "./compression/service.ts";
|
|
25
27
|
import { Enricher } from "./enrich.ts";
|
|
26
28
|
import { Follows } from "./follows.ts";
|
|
@@ -438,7 +440,26 @@ export interface KnownSource {
|
|
|
438
440
|
/** A picture and a line about it, from the site or the list that named it. */
|
|
439
441
|
art?: string;
|
|
440
442
|
about?: string;
|
|
443
|
+
/** A station's rotation (see PullResume.rotation). */
|
|
444
|
+
rotation?: PullResume["rotation"];
|
|
445
|
+
/** kbps for an audio rendition; 192 when unset. */
|
|
446
|
+
audioBitrate?: number;
|
|
441
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* A station's files, chosen by track index (as /api/folders/live takes them)
|
|
450
|
+
* or by a folder under the library; null when neither was given. Only files
|
|
451
|
+
* on this machine and inside the library: a station never reaches past it.
|
|
452
|
+
*/
|
|
453
|
+
export declare function selectStationFiles(engine: Engine, body: {
|
|
454
|
+
items?: unknown;
|
|
455
|
+
folder?: unknown;
|
|
456
|
+
}): string[] | null;
|
|
457
|
+
/** What the library knows about one file: its tags, for a now-playing line. */
|
|
458
|
+
export declare function trackFor(engine: Engine, file: string): {
|
|
459
|
+
title: string;
|
|
460
|
+
artist: string;
|
|
461
|
+
album: string;
|
|
462
|
+
} | null;
|
|
442
463
|
/** How many times a source that answers nothing is asked, and how far apart. */
|
|
443
464
|
export declare const PROBE_TRIES = 3;
|
|
444
465
|
export declare const PROBE_RETRY_MS = 1500;
|
|
@@ -545,6 +566,11 @@ export declare function joinSubject(url: URL, options: Pick<HandlerOptions, "dir
|
|
|
545
566
|
/** The shell, titled for what a join link opens, with its picture on the card. */
|
|
546
567
|
export declare function joinDocument(shell: string, subject: JoinSubject, site: string): string;
|
|
547
568
|
export interface HandlerOptions {
|
|
569
|
+
/** This server's stations and what keeps them on the air. */
|
|
570
|
+
stations?: {
|
|
571
|
+
store: StationStore;
|
|
572
|
+
runner: StationRunner;
|
|
573
|
+
};
|
|
548
574
|
web: string | null;
|
|
549
575
|
/** Branded clients on this same backend, keyed by an explicitly configured host. */
|
|
550
576
|
webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;
|