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.
Files changed (43) hide show
  1. package/dist/channels.d.ts +20 -0
  2. package/dist/channels.js +24 -0
  3. package/dist/library.d.ts +6 -0
  4. package/dist/library.js +11 -0
  5. package/dist/playlist-input.d.ts +32 -7
  6. package/dist/playlist-input.js +20 -2
  7. package/dist/server.js +9 -1
  8. package/dist/station-routes.d.ts +1 -0
  9. package/dist/station-routes.js +23 -5
  10. package/dist/station-runner.d.ts +48 -0
  11. package/dist/station-runner.js +208 -4
  12. package/dist/stations.d.ts +14 -0
  13. package/dist/stations.js +11 -0
  14. package/package.json +1 -1
  15. package/src/channels.ts +30 -0
  16. package/src/library.ts +14 -0
  17. package/src/playlist-input.ts +46 -5
  18. package/src/server.ts +10 -1
  19. package/src/station-routes.ts +23 -6
  20. package/src/station-runner.ts +205 -4
  21. package/src/stations.ts +15 -0
  22. package/web/dist/assets/{ar-CwZJb_gN.js → ar-d05d_7Df.js} +1 -1
  23. package/web/dist/assets/{cs-BQeUHU5U.js → cs-CclrNwa7.js} +1 -1
  24. package/web/dist/assets/{da-B9gLGX-j.js → da-C30KTGl1.js} +1 -1
  25. package/web/dist/assets/{de-BtKJqMyc.js → de-D2A2RQwi.js} +1 -1
  26. package/web/dist/assets/{es-xCZPNIQ8.js → es-BXFx0WZ5.js} +1 -1
  27. package/web/dist/assets/{fi-Bs6hJxj9.js → fi-DPA3S8og.js} +1 -1
  28. package/web/dist/assets/{fr-Db3viYEV.js → fr-D_0wnr4h.js} +1 -1
  29. package/web/dist/assets/{hi-BEO0uXMK.js → hi-EfiCQnb9.js} +1 -1
  30. package/web/dist/assets/{hu-DddOFPOR.js → hu-CK5rnxKO.js} +1 -1
  31. package/web/dist/assets/{id-Ca4dDDtC.js → id-BgTV_4UK.js} +1 -1
  32. package/web/dist/assets/{it-B9cFkgz7.js → it-DGefapXn.js} +1 -1
  33. package/web/dist/assets/{main-CHubaPAu.js → main-C-tWhTWo.js} +2 -2
  34. package/web/dist/assets/{nl-kUxjpAwG.js → nl-Cyst29Bp.js} +1 -1
  35. package/web/dist/assets/{ru-BE9Sxy3t.js → ru-D8G6Wynm.js} +1 -1
  36. package/web/dist/assets/{station-DxBlD6Yl.js → station-D6sd4DqP.js} +1 -1
  37. package/web/dist/assets/{sv-Cp6BIRXI.js → sv-aYnDAFwz.js} +1 -1
  38. package/web/dist/assets/{uk-DavLH9cs.js → uk-BDtew7z3.js} +1 -1
  39. package/web/dist/assets/{vi-eAlsSX6P.js → vi-CRlDn2Xh.js} +1 -1
  40. package/web/dist/assets/{zh-D2Y8KIQG.js → zh-BCAIjeQq.js} +1 -1
  41. package/web/dist/index.html +1 -1
  42. package/web/dist/station.html +1 -1
  43. package/web/dist/sw.js +20 -20
@@ -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"] } : {}),
@@ -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 {};
@@ -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
- let lastBreak = 0;
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
@@ -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>;
@@ -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
- const file = state === "live" || state === "recovering" ? nowPlayingFile(ctx, station) : "";
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
- if (!ctx.channels.has(channelId)) {
268
- ctx.json(response, 503, { error: "the station is not on the air", code: "not_live", state: ctx.runner.observed(station.id) });
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) ?? "";
@@ -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;