nixamp 0.56.0 → 0.57.0
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/channels.d.ts +20 -0
- package/dist/channels.js +24 -0
- package/dist/library.d.ts +6 -0
- package/dist/library.js +11 -0
- package/dist/playlist-input.d.ts +32 -7
- package/dist/playlist-input.js +20 -2
- package/dist/server.js +9 -1
- package/dist/station-routes.d.ts +1 -0
- package/dist/station-routes.js +23 -5
- package/dist/station-runner.d.ts +48 -0
- package/dist/station-runner.js +208 -4
- package/dist/stations.d.ts +14 -0
- package/dist/stations.js +11 -0
- package/package.json +1 -1
- package/src/channels.ts +30 -0
- package/src/library.ts +14 -0
- package/src/playlist-input.ts +46 -5
- package/src/server.ts +10 -1
- package/src/station-routes.ts +23 -6
- package/src/station-runner.ts +205 -4
- package/src/stations.ts +15 -0
- package/web/dist/assets/{ar-CwZJb_gN.js → ar-d05d_7Df.js} +1 -1
- package/web/dist/assets/{cs-BQeUHU5U.js → cs-CclrNwa7.js} +1 -1
- package/web/dist/assets/{da-B9gLGX-j.js → da-C30KTGl1.js} +1 -1
- package/web/dist/assets/{de-BtKJqMyc.js → de-D2A2RQwi.js} +1 -1
- package/web/dist/assets/{es-xCZPNIQ8.js → es-BXFx0WZ5.js} +1 -1
- package/web/dist/assets/{fi-Bs6hJxj9.js → fi-DPA3S8og.js} +1 -1
- package/web/dist/assets/{fr-Db3viYEV.js → fr-D_0wnr4h.js} +1 -1
- package/web/dist/assets/{hi-BEO0uXMK.js → hi-EfiCQnb9.js} +1 -1
- package/web/dist/assets/{hu-DddOFPOR.js → hu-CK5rnxKO.js} +1 -1
- package/web/dist/assets/{id-Ca4dDDtC.js → id-BgTV_4UK.js} +1 -1
- package/web/dist/assets/{it-B9cFkgz7.js → it-DGefapXn.js} +1 -1
- package/web/dist/assets/{main-CHubaPAu.js → main-C-tWhTWo.js} +2 -2
- package/web/dist/assets/{nl-kUxjpAwG.js → nl-Cyst29Bp.js} +1 -1
- package/web/dist/assets/{ru-BE9Sxy3t.js → ru-D8G6Wynm.js} +1 -1
- package/web/dist/assets/{station-DxBlD6Yl.js → station-D6sd4DqP.js} +1 -1
- package/web/dist/assets/{sv-Cp6BIRXI.js → sv-aYnDAFwz.js} +1 -1
- package/web/dist/assets/{uk-DavLH9cs.js → uk-BDtew7z3.js} +1 -1
- package/web/dist/assets/{vi-eAlsSX6P.js → vi-CRlDn2Xh.js} +1 -1
- package/web/dist/assets/{zh-D2Y8KIQG.js → zh-BCAIjeQq.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +1 -1
- package/web/dist/sw.js +20 -20
package/dist/channels.d.ts
CHANGED
|
@@ -247,6 +247,16 @@ export declare class Channel {
|
|
|
247
247
|
* failure, so it does not count as a redial.
|
|
248
248
|
*/
|
|
249
249
|
skip(): boolean;
|
|
250
|
+
/** How far listeners have got into this dial of a list, by the encoder's progress. */
|
|
251
|
+
private heard;
|
|
252
|
+
/** Where a list would pick up after what listeners hear now (PlaylistInputStream.resumePoint). */
|
|
253
|
+
resumePoint(): {
|
|
254
|
+
file: string;
|
|
255
|
+
upcoming: string[];
|
|
256
|
+
sinceBreak: number;
|
|
257
|
+
} | null;
|
|
258
|
+
/** Whether listeners would get sound (and, for a picture, its opening) if they joined now. */
|
|
259
|
+
get flowing(): boolean;
|
|
250
260
|
/** An entry's tags, as read when it was put on. */
|
|
251
261
|
tagsOf(file: string): {
|
|
252
262
|
title: string;
|
|
@@ -539,6 +549,16 @@ export declare class Channels {
|
|
|
539
549
|
artist: string;
|
|
540
550
|
album: string;
|
|
541
551
|
} | undefined;
|
|
552
|
+
/** Where a channel playing a list would pick up after what is on now; null when it is not one. */
|
|
553
|
+
resumePoint(id: string): {
|
|
554
|
+
file: string;
|
|
555
|
+
upcoming: string[];
|
|
556
|
+
sinceBreak: number;
|
|
557
|
+
} | null;
|
|
558
|
+
/** Whether a channel is up and has started sending. */
|
|
559
|
+
flowing(id: string): boolean;
|
|
560
|
+
/** Who is attached to one channel: listeners, relays, packagers. */
|
|
561
|
+
listenersOf(id: string): number;
|
|
542
562
|
/** The next entry, now, on a channel playing a list of files. */
|
|
543
563
|
skip(id: string): boolean;
|
|
544
564
|
stop(id: string): boolean;
|
package/dist/channels.js
CHANGED
|
@@ -169,6 +169,16 @@ export class Channel {
|
|
|
169
169
|
this.info.redials = redials;
|
|
170
170
|
return done;
|
|
171
171
|
}
|
|
172
|
+
/** How far listeners have got into this dial of a list, by the encoder's progress. */
|
|
173
|
+
heard = 0;
|
|
174
|
+
/** Where a list would pick up after what listeners hear now (PlaylistInputStream.resumePoint). */
|
|
175
|
+
resumePoint() {
|
|
176
|
+
return this.playlistInput?.resumePoint(this.heard) ?? null;
|
|
177
|
+
}
|
|
178
|
+
/** Whether listeners would get sound (and, for a picture, its opening) if they joined now. */
|
|
179
|
+
get flowing() {
|
|
180
|
+
return this.info.bytes > 0 && (!this.fragments || this.fragments.ready);
|
|
181
|
+
}
|
|
172
182
|
/** An entry's tags, as read when it was put on. */
|
|
173
183
|
tagsOf(file) {
|
|
174
184
|
return this.playlistInput?.tags.get(file);
|
|
@@ -334,6 +344,7 @@ export class Channel {
|
|
|
334
344
|
...resume.rotation,
|
|
335
345
|
}) : null;
|
|
336
346
|
this.playlistInput = playlistInput;
|
|
347
|
+
this.heard = 0;
|
|
337
348
|
const through = this.outroOn ? null : playlistInput
|
|
338
349
|
? { format: "mpegts", open: async (signal) => playlistInput.open(signal) }
|
|
339
350
|
: this.options.through?.(this.info, from, input, audio) ?? null;
|
|
@@ -413,6 +424,7 @@ export class Channel {
|
|
|
413
424
|
if (match) {
|
|
414
425
|
const seconds = Number(match[1]) / 1e6;
|
|
415
426
|
if (playlistInput) {
|
|
427
|
+
this.heard = seconds;
|
|
416
428
|
const place = playlistInput.place(seconds);
|
|
417
429
|
// A break (-1) belongs to no list; a track's index belongs to its own.
|
|
418
430
|
if (place.at >= 0 && this.info.playlist !== place.list)
|
|
@@ -1283,6 +1295,18 @@ export class Channels {
|
|
|
1283
1295
|
tagsOf(id, file) {
|
|
1284
1296
|
return this.open.get(id)?.tagsOf(file);
|
|
1285
1297
|
}
|
|
1298
|
+
/** Where a channel playing a list would pick up after what is on now; null when it is not one. */
|
|
1299
|
+
resumePoint(id) {
|
|
1300
|
+
return this.open.get(id)?.resumePoint() ?? null;
|
|
1301
|
+
}
|
|
1302
|
+
/** Whether a channel is up and has started sending. */
|
|
1303
|
+
flowing(id) {
|
|
1304
|
+
return this.open.get(id)?.flowing ?? false;
|
|
1305
|
+
}
|
|
1306
|
+
/** Who is attached to one channel: listeners, relays, packagers. */
|
|
1307
|
+
listenersOf(id) {
|
|
1308
|
+
return this.open.get(id)?.listeners.size ?? 0;
|
|
1309
|
+
}
|
|
1286
1310
|
/** The next entry, now, on a channel playing a list of files. */
|
|
1287
1311
|
skip(id) {
|
|
1288
1312
|
return this.open.get(id)?.skip() ?? false;
|
package/dist/library.d.ts
CHANGED
|
@@ -21,7 +21,13 @@ export interface Config {
|
|
|
21
21
|
limits?: Record<string, unknown>;
|
|
22
22
|
/** Proxies whose X-Forwarded-For is believed for listener shares (`nixamp station shares trust ADDRESS`). */
|
|
23
23
|
trustedProxies?: string[];
|
|
24
|
+
/** Seconds with nobody listening before a station sleeps; 0 never. 60 when unset. */
|
|
25
|
+
stationIdleSeconds?: number;
|
|
24
26
|
}
|
|
27
|
+
/** A station with nobody listening sleeps after this many seconds, unless config or NIXAMP_STATION_IDLE says otherwise. */
|
|
28
|
+
export declare const DEFAULT_STATION_IDLE_SECONDS = 60;
|
|
29
|
+
/** How long a station waits with nobody listening before it sleeps: env, then config, then 60; 0 is never. */
|
|
30
|
+
export declare function stationIdleSeconds(env: Record<string, string | undefined>, config: Config): number;
|
|
25
31
|
export declare function configPath(): string;
|
|
26
32
|
export declare function readConfig(): Config;
|
|
27
33
|
export declare function writeConfig(patch: Partial<Config>): void;
|
package/dist/library.js
CHANGED
|
@@ -19,6 +19,16 @@ import { homedir } from "node:os";
|
|
|
19
19
|
import { join, resolve } from "node:path";
|
|
20
20
|
import { createInterface } from "node:readline/promises";
|
|
21
21
|
import { stateDir } from "./daemon.js";
|
|
22
|
+
/** A station with nobody listening sleeps after this many seconds, unless config or NIXAMP_STATION_IDLE says otherwise. */
|
|
23
|
+
export const DEFAULT_STATION_IDLE_SECONDS = 60;
|
|
24
|
+
/** How long a station waits with nobody listening before it sleeps: env, then config, then 60; 0 is never. */
|
|
25
|
+
export function stationIdleSeconds(env, config) {
|
|
26
|
+
const raw = env["NIXAMP_STATION_IDLE"];
|
|
27
|
+
if (raw !== undefined && raw.trim() !== "" && Number.isFinite(Number(raw)) && Number(raw) >= 0)
|
|
28
|
+
return Number(raw);
|
|
29
|
+
const set = config.stationIdleSeconds;
|
|
30
|
+
return typeof set === "number" && Number.isFinite(set) && set >= 0 ? set : DEFAULT_STATION_IDLE_SECONDS;
|
|
31
|
+
}
|
|
22
32
|
export function configPath() {
|
|
23
33
|
return join(stateDir(), "config.json");
|
|
24
34
|
}
|
|
@@ -33,6 +43,7 @@ export function readConfig() {
|
|
|
33
43
|
...(typeof record["publish"] === "boolean" ? { publish: record["publish"] } : {}),
|
|
34
44
|
...(typeof record["autoStations"] === "object" && record["autoStations"] !== null ? { autoStations: record["autoStations"] } : {}),
|
|
35
45
|
...(typeof record["limits"] === "object" && record["limits"] !== null ? { limits: record["limits"] } : {}),
|
|
46
|
+
...(typeof record["stationIdleSeconds"] === "number" ? { stationIdleSeconds: record["stationIdleSeconds"] } : {}),
|
|
36
47
|
...(Array.isArray(record["trustedProxies"]) ? { trustedProxies: record["trustedProxies"].filter((one) => typeof one === "string") } : {}),
|
|
37
48
|
...(Array.isArray(record["guideHosts"]) ? { guideHosts: record["guideHosts"].filter((one) => typeof one === "string") } : {}),
|
|
38
49
|
...(typeof record["guideAds"] === "object" && record["guideAds"] !== null ? { guideAds: record["guideAds"] } : {}),
|
package/dist/playlist-input.d.ts
CHANGED
|
@@ -37,6 +37,12 @@ export interface PlaylistInput {
|
|
|
37
37
|
breaks?: {
|
|
38
38
|
everySeconds: number | (() => number);
|
|
39
39
|
next: () => Promise<string | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Seconds of airtime since the last break when this stream opens, asked
|
|
42
|
+
* once per open: a station that slept with nobody listening carries its
|
|
43
|
+
* clock across, so breaks come every so often of time on the air.
|
|
44
|
+
*/
|
|
45
|
+
sinceBreak?: () => number;
|
|
40
46
|
};
|
|
41
47
|
/** Told when an entry is passed over because it would not play. */
|
|
42
48
|
onFailure?: (event: {
|
|
@@ -51,6 +57,19 @@ export interface PlaylistInput {
|
|
|
51
57
|
* not a loop.
|
|
52
58
|
*/
|
|
53
59
|
export declare const MAX_FAILURES_IN_A_ROW = 5;
|
|
60
|
+
/** An entry that started: where it sits in the stream and in its pass. */
|
|
61
|
+
interface Entry {
|
|
62
|
+
at: number;
|
|
63
|
+
file: string;
|
|
64
|
+
start: number;
|
|
65
|
+
position: number;
|
|
66
|
+
list: readonly string[];
|
|
67
|
+
/** The pass's order and how far into it the next entry is, for resumePoint. */
|
|
68
|
+
order: readonly number[];
|
|
69
|
+
step: number;
|
|
70
|
+
/** Where the break clock stood when it started. */
|
|
71
|
+
lastBreak: number;
|
|
72
|
+
}
|
|
54
73
|
/** Decode each file independently; feed one persistent muxer a fixed stream
|
|
55
74
|
* profile and continuous timestamps. A concat demuxer cannot decode a list
|
|
56
75
|
* whose input codecs differ. Only one entry is encoded at a time, with pipe
|
|
@@ -72,13 +91,7 @@ export declare class PlaylistInputStream {
|
|
|
72
91
|
}>;
|
|
73
92
|
constructor(options: PlaylistInput);
|
|
74
93
|
/** Every entry that has started, in the order it started, with where it sits in the stream. */
|
|
75
|
-
get history(): ReadonlyArray<
|
|
76
|
-
at: number;
|
|
77
|
-
file: string;
|
|
78
|
-
start: number;
|
|
79
|
-
position: number;
|
|
80
|
-
list: readonly string[];
|
|
81
|
-
}>;
|
|
94
|
+
get history(): ReadonlyArray<Entry>;
|
|
82
95
|
/**
|
|
83
96
|
* What is on at this point of the stream. `at` indexes `list`, the list as
|
|
84
97
|
* it was when that entry started: a station re-reads its list at every
|
|
@@ -90,6 +103,17 @@ export declare class PlaylistInputStream {
|
|
|
90
103
|
position: number;
|
|
91
104
|
list: readonly string[];
|
|
92
105
|
};
|
|
106
|
+
/**
|
|
107
|
+
* Where to pick up after this point of the stream, for a station that
|
|
108
|
+
* sleeps with nobody listening: the file that is on ("" in a break), the
|
|
109
|
+
* rest of this pass in the order it was going to play, and the seconds
|
|
110
|
+
* of airtime since the last break.
|
|
111
|
+
*/
|
|
112
|
+
resumePoint(seconds: number): {
|
|
113
|
+
file: string;
|
|
114
|
+
upcoming: string[];
|
|
115
|
+
sinceBreak: number;
|
|
116
|
+
} | null;
|
|
93
117
|
/** The decoder for the entry that is on, so skip() can end it early. */
|
|
94
118
|
private stopCurrent;
|
|
95
119
|
private skipping;
|
|
@@ -97,3 +121,4 @@ export declare class PlaylistInputStream {
|
|
|
97
121
|
skip(): boolean;
|
|
98
122
|
open(signal: AbortSignal): AsyncGenerator<Buffer>;
|
|
99
123
|
}
|
|
124
|
+
export {};
|
package/dist/playlist-input.js
CHANGED
|
@@ -40,6 +40,22 @@ export class PlaylistInputStream {
|
|
|
40
40
|
return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position), list: entry.list }
|
|
41
41
|
: { at: this.options.at, position: this.options.position, list: this.options.files };
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Where to pick up after this point of the stream, for a station that
|
|
45
|
+
* sleeps with nobody listening: the file that is on ("" in a break), the
|
|
46
|
+
* rest of this pass in the order it was going to play, and the seconds
|
|
47
|
+
* of airtime since the last break.
|
|
48
|
+
*/
|
|
49
|
+
resumePoint(seconds) {
|
|
50
|
+
const entry = this.entries.findLast(one => seconds >= one.start) ?? this.entries[0];
|
|
51
|
+
if (!entry)
|
|
52
|
+
return null;
|
|
53
|
+
return {
|
|
54
|
+
file: entry.at >= 0 ? entry.file : "",
|
|
55
|
+
upcoming: entry.order.slice(entry.step).map((index) => entry.list[index]).filter((one) => typeof one === "string"),
|
|
56
|
+
sinceBreak: entry.at < 0 ? 0 : Math.max(0, seconds - entry.lastBreak),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
43
59
|
/** The decoder for the entry that is on, so skip() can end it early. */
|
|
44
60
|
stopCurrent = null;
|
|
45
61
|
skipping = false;
|
|
@@ -69,7 +85,9 @@ export class PlaylistInputStream {
|
|
|
69
85
|
let step = Math.min(Math.max(0, o.at), order.length);
|
|
70
86
|
let played = 0;
|
|
71
87
|
let last = "";
|
|
72
|
-
|
|
88
|
+
// Counted back from 0 by however long it had been, so a clock carried
|
|
89
|
+
// across a sleep comes due when it would have on the air.
|
|
90
|
+
let lastBreak = -Math.max(0, o.breaks?.sinceBreak?.() ?? 0);
|
|
73
91
|
// A film cut for a break, to be picked up where it stopped.
|
|
74
92
|
let resume = null;
|
|
75
93
|
for (;;) {
|
|
@@ -146,7 +164,7 @@ export class PlaylistInputStream {
|
|
|
146
164
|
const room = o.video && !isBreak && o.breaks && every > 0 ? every - (offset - lastBreak) : Infinity;
|
|
147
165
|
const cut = room >= 1 && full - room > 5;
|
|
148
166
|
const duration = cut ? room : full;
|
|
149
|
-
this.entries.push({ at, file, start: offset, position, list: files });
|
|
167
|
+
this.entries.push({ at, file, start: offset, position, list: files, order, step, lastBreak });
|
|
150
168
|
const hasVideo = Boolean(codecs.video);
|
|
151
169
|
const extra = o.video && !hasVideo
|
|
152
170
|
? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
|
package/dist/server.js
CHANGED
|
@@ -98,7 +98,7 @@ import { Porkbun, isIPv4, isIPv6 } from "./dns.js";
|
|
|
98
98
|
import { NameError, Names } from "./names.js";
|
|
99
99
|
import { AcmeIssuer, Certs } from "./certs.js";
|
|
100
100
|
import { claimName, fetchCert, humanizeSource, labelFor, readCertFiles, writeCertFiles } from "./naming.js";
|
|
101
|
-
import { forbiddenLibrary, readConfig, readLibrary, writeConfig } from "./library.js";
|
|
101
|
+
import { forbiddenLibrary, readConfig, readLibrary, stationIdleSeconds, writeConfig } from "./library.js";
|
|
102
102
|
import { createThrottle, presentedCredential } from "@profullstack/throttle";
|
|
103
103
|
import { Durable } from "./durable.js";
|
|
104
104
|
import { notifyAll, resendEmail, webPush } from "./notify.js";
|
|
@@ -5501,6 +5501,11 @@ export function createHandler(engine, options) {
|
|
|
5501
5501
|
response.end(request.method === "HEAD" ? undefined : bytes);
|
|
5502
5502
|
return;
|
|
5503
5503
|
}
|
|
5504
|
+
// A station asleep with nobody listening is woken by whoever asks for
|
|
5505
|
+
// its channel here too, and they are held for its first bytes.
|
|
5506
|
+
if ((action === "hls" || action === undefined) && request.method === "GET" && !channels.has(id) && stationOfChannel(id) && options.stations?.runner.isSleeping(stationOfChannel(id))) {
|
|
5507
|
+
await options.stations.runner.awake(stationOfChannel(id));
|
|
5508
|
+
}
|
|
5504
5509
|
// The same channel as HLS: a playlist of short files, which is what
|
|
5505
5510
|
// Safari on an iPhone plays live -- it will not take the endless MP4
|
|
5506
5511
|
// below, and spun on it a few times before giving up. Packaged on
|
|
@@ -6956,6 +6961,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6956
6961
|
onRestart: (id) => stationUsage.restarted(id),
|
|
6957
6962
|
onFailure: (id) => stationUsage.failed(id),
|
|
6958
6963
|
artistOf: (file) => trackFor(engine, file)?.artist ?? "",
|
|
6964
|
+
// Nobody listening for this long and a station sleeps until somebody does.
|
|
6965
|
+
idleSeconds: () => stationIdleSeconds(process.env, readConfig()),
|
|
6959
6966
|
store: stationStore,
|
|
6960
6967
|
channels,
|
|
6961
6968
|
pull: async (channelId, name, files, known) => Boolean(await pullChannel(channels, tools.ffprobe, channelId, name, files[0], [], "", {
|
|
@@ -7245,6 +7252,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
7245
7252
|
});
|
|
7246
7253
|
}
|
|
7247
7254
|
stationRunner.restore();
|
|
7255
|
+
stationRunner.watchIdle();
|
|
7248
7256
|
// Where each film has got to, written down every so often, so a restart
|
|
7249
7257
|
// picks it up from about there rather than from the start. Only when it
|
|
7250
7258
|
// has changed: a server carrying nothing but live television writes
|
package/dist/station-routes.d.ts
CHANGED
|
@@ -112,6 +112,7 @@ export declare function guideEntries(ctx: StationContext): Array<PublicStation &
|
|
|
112
112
|
export declare function ownerView(ctx: StationContext, station: Station): OwnerStation & {
|
|
113
113
|
links: ReturnType<typeof links>;
|
|
114
114
|
nowPlaying: string;
|
|
115
|
+
sleeping: boolean;
|
|
115
116
|
};
|
|
116
117
|
/** Answers anything under /api/v1/stations; false when the path is not one. */
|
|
117
118
|
export declare function handleStations(request: IncomingMessage, response: ServerResponse, path: string, url: URL, ctx: StationContext): Promise<boolean>;
|
package/dist/station-routes.js
CHANGED
|
@@ -37,14 +37,16 @@ function nowPlayingFile(ctx, station) {
|
|
|
37
37
|
}
|
|
38
38
|
export function publicView(ctx, station) {
|
|
39
39
|
const state = ctx.runner.observed(station.id);
|
|
40
|
-
|
|
40
|
+
// Asleep, what it will wake on: the first listener hears that next.
|
|
41
|
+
const asleep = ctx.runner.isSleeping?.(station.id) ?? false;
|
|
42
|
+
const file = asleep ? ctx.runner.nextUp(station.id) : state === "live" || state === "recovering" ? nowPlayingFile(ctx, station) : "";
|
|
41
43
|
// The tags the player read when it put this file on, else the library's.
|
|
42
44
|
const track = file ? (ctx.channels.tagsOf(channelOf(station.id), file) ?? ctx.trackFor(file)) : null;
|
|
43
45
|
// What the channel is carrying wins over the flag: a station made by hand
|
|
44
46
|
// from films streams video whether or not anyone said so.
|
|
45
47
|
const video = station.video || ctx.channels.kindOf(channelOf(station.id)) === "video";
|
|
46
48
|
// How far into the song (or film) on the air, by the encoder's clock, and how long it is.
|
|
47
|
-
const position = file ? ctx.channels.info(channelOf(station.id))?.position : undefined;
|
|
49
|
+
const position = file && !asleep ? ctx.channels.info(channelOf(station.id))?.position : undefined;
|
|
48
50
|
const duration = file && ctx.durationOf ? ctx.durationOf(file) : 0;
|
|
49
51
|
const progress = typeof position === "number" && duration > 0 ? { position: Math.round(Math.min(position, duration) * 10) / 10, duration: Math.round(duration * 10) / 10 } : null;
|
|
50
52
|
return {
|
|
@@ -86,6 +88,8 @@ export function ownerView(ctx, station) {
|
|
|
86
88
|
return {
|
|
87
89
|
...rest,
|
|
88
90
|
state: ctx.runner.observed(station.id),
|
|
91
|
+
// On the air with nobody listening: no encoder until somebody does.
|
|
92
|
+
sleeping: ctx.runner.isSleeping?.(station.id) ?? false,
|
|
89
93
|
excluded: Object.entries(failures).filter(([, count]) => count >= 3).map(([file]) => file),
|
|
90
94
|
listeners: ctx.runner.listeners(station.id),
|
|
91
95
|
error: ctx.runner.error(station.id),
|
|
@@ -264,18 +268,32 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
264
268
|
return true;
|
|
265
269
|
}
|
|
266
270
|
const channelId = channelOf(station.id);
|
|
267
|
-
|
|
268
|
-
|
|
271
|
+
// Asleep, a HEAD is told what it would get without waking it.
|
|
272
|
+
if (method === "HEAD" && ctx.runner.isSleeping?.(station.id)) {
|
|
273
|
+
response.writeHead(200, { ...ctx.headers, "content-type": station.video ? "video/mp4" : "audio/mpeg", "cache-control": "no-store" });
|
|
274
|
+
response.end();
|
|
269
275
|
return true;
|
|
270
276
|
}
|
|
271
277
|
// Admitted only within the station's and the server's limits, and the
|
|
272
|
-
// month's allowance; refused with what to do about it.
|
|
278
|
+
// month's allowance; refused with what to do about it. Before waking it:
|
|
279
|
+
// a listener turned away is no reason to start the encoder.
|
|
273
280
|
const refused = method === "GET" && ctx.usage && ctx.limits ? ctx.usage.admit(station.id, ctx.limits) : null;
|
|
274
281
|
if (refused) {
|
|
275
282
|
response.writeHead(refused.status, { ...ctx.headers, "content-type": "application/json; charset=utf-8", "retry-after": String(refused.retryAfter) });
|
|
276
283
|
response.end(JSON.stringify({ error: refused.error, code: refused.code, retryAfter: refused.retryAfter }));
|
|
277
284
|
return true;
|
|
278
285
|
}
|
|
286
|
+
// The first listener wakes a sleeping station, and is held until it
|
|
287
|
+
// starts sending, so they hear it begin rather than a stream that ends.
|
|
288
|
+
let closed = false;
|
|
289
|
+
response.once("close", () => { closed = true; });
|
|
290
|
+
await ctx.runner.awake?.(station.id, undefined, () => closed);
|
|
291
|
+
if (closed)
|
|
292
|
+
return true;
|
|
293
|
+
if (!ctx.channels.has(channelId)) {
|
|
294
|
+
ctx.json(response, 503, { error: "the station is not on the air", code: "not_live", state: ctx.runner.observed(station.id) });
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
279
297
|
// Signed in at nixamp.com: no breaks, starting a little behind live so
|
|
280
298
|
// they can be left out without a gap. Radio only (Channel.listenWithoutBreaks).
|
|
281
299
|
const pass = url.searchParams.get(PASS_PARAM) ?? "";
|
package/dist/station-runner.d.ts
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* long as it is still wanted, so a disk that comes back or a file put back in
|
|
9
9
|
* place brings it back under the same id and link. A station its owner
|
|
10
10
|
* stopped is never restarted by anything here.
|
|
11
|
+
*
|
|
12
|
+
* A station nobody is listening to sleeps: after `idleSeconds` with nobody
|
|
13
|
+
* attached to its channel, the channel (and its ffmpeg) is stopped, but the
|
|
14
|
+
* station stays wanted on and is still on the air to the guide. The first
|
|
15
|
+
* listener wakes it, and it carries on from the track after the one it slept
|
|
16
|
+
* on, with its break clock where it was. A sleep is not a failure: no
|
|
17
|
+
* recovery, no restart counted.
|
|
11
18
|
*/
|
|
12
19
|
import type { Channels, ChannelInfo, PullResume } from "./channels.ts";
|
|
13
20
|
import { type Observed, type Station, type StationStore } from "./stations.ts";
|
|
@@ -30,7 +37,11 @@ export interface RunnerDeps {
|
|
|
30
37
|
breaks?: (video: boolean) => Promise<string | null>;
|
|
31
38
|
/** Who a file is by, from the library's tags, for a shuffle that spreads artists out; "" when unknown. */
|
|
32
39
|
artistOf?: (file: string) => string;
|
|
40
|
+
/** Seconds with nobody listening before a station sleeps; 0 (or unset) never. Asked at every sweep. */
|
|
41
|
+
idleSeconds?: () => number;
|
|
33
42
|
}
|
|
43
|
+
/** How long a listener waking a station is held for its first bytes. */
|
|
44
|
+
export declare const WAKE_WAIT_MS = 10000;
|
|
34
45
|
/**
|
|
35
46
|
* Who a file is by when the tags do not say: "Artist - Album/track" names
|
|
36
47
|
* them in the folder, and "Artist/Album/track" one folder up.
|
|
@@ -53,6 +64,14 @@ export declare class StationRunner {
|
|
|
53
64
|
private down;
|
|
54
65
|
/** The server is going down: its channels end because it is, not because a station failed. */
|
|
55
66
|
private closing;
|
|
67
|
+
/** Wanted on, with nobody listening: no channel until somebody does. */
|
|
68
|
+
private asleep;
|
|
69
|
+
/** For a station asleep: the rest of the pass it was playing, in order. */
|
|
70
|
+
private parked;
|
|
71
|
+
/** Since when a station's channel has had nobody on it. */
|
|
72
|
+
private quietSince;
|
|
73
|
+
private waking;
|
|
74
|
+
private idleTimer;
|
|
56
75
|
constructor(deps: RunnerDeps);
|
|
57
76
|
/**
|
|
58
77
|
* Called before the server stops every channel on its way out. Without it,
|
|
@@ -74,6 +93,35 @@ export declare class StationRunner {
|
|
|
74
93
|
remove(id: string): void;
|
|
75
94
|
/** Put back every station that was running when this server last stopped. */
|
|
76
95
|
restore(): void;
|
|
96
|
+
/** Whether a station is asleep: wanted on, on the air to the guide, with no channel until somebody listens. */
|
|
97
|
+
isSleeping(id: string): boolean;
|
|
98
|
+
/** The file a sleeping station would wake on, "" when it cannot say. */
|
|
99
|
+
nextUp(id: string): string;
|
|
100
|
+
/** Look every so often for stations nobody is listening to. */
|
|
101
|
+
watchIdle(everyMs?: number): void;
|
|
102
|
+
/**
|
|
103
|
+
* Put to sleep every station whose channel has had nobody on it for the
|
|
104
|
+
* idle time. Anybody attached counts: a listener on the link or /radio, a
|
|
105
|
+
* class or room relaying it, an HLS packager, a peer pulling it. The
|
|
106
|
+
* now-playing events alone do not.
|
|
107
|
+
*/
|
|
108
|
+
sweep(): void;
|
|
109
|
+
/** Off the air for now, still wanted on: remembers where it was, and is not a failure. */
|
|
110
|
+
sleep(id: string): void;
|
|
111
|
+
/** On again for a listener, if it was asleep. True when its channel is up (or coming up). */
|
|
112
|
+
wake(id: string): Promise<boolean>;
|
|
113
|
+
/**
|
|
114
|
+
* Wake it if it is asleep, and wait (up to `timeoutMs`) for its first
|
|
115
|
+
* bytes, so a listener hears it start rather than a stream that ends.
|
|
116
|
+
* True when the channel is up.
|
|
117
|
+
*/
|
|
118
|
+
awake(id: string, timeoutMs?: number, gone?: () => boolean): Promise<boolean>;
|
|
119
|
+
/**
|
|
120
|
+
* Where a launch picks up: the rest of the pass it slept in, or (after the
|
|
121
|
+
* server restarted) the files after the one it was on; and the break
|
|
122
|
+
* clock. Taken once, by the next launch.
|
|
123
|
+
*/
|
|
124
|
+
private takeResume;
|
|
77
125
|
private launch;
|
|
78
126
|
/** Told by the server when any channel ends. */
|
|
79
127
|
onChannelEnd(info: ChannelInfo): void;
|