nixamp 0.55.2 → 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 (51) hide show
  1. package/dist/ai-label.d.ts +38 -3
  2. package/dist/ai-label.js +96 -5
  3. package/dist/channels.d.ts +20 -0
  4. package/dist/channels.js +24 -0
  5. package/dist/label-profiles.d.ts +60 -0
  6. package/dist/label-profiles.js +190 -0
  7. package/dist/label-routes.js +29 -2
  8. package/dist/library.d.ts +6 -0
  9. package/dist/library.js +11 -0
  10. package/dist/playlist-input.d.ts +32 -7
  11. package/dist/playlist-input.js +20 -2
  12. package/dist/server.js +9 -1
  13. package/dist/station-routes.d.ts +1 -0
  14. package/dist/station-routes.js +23 -5
  15. package/dist/station-runner.d.ts +48 -0
  16. package/dist/station-runner.js +208 -4
  17. package/dist/stations.d.ts +14 -0
  18. package/dist/stations.js +11 -0
  19. package/package.json +1 -1
  20. package/src/ai-label.ts +95 -8
  21. package/src/channels.ts +30 -0
  22. package/src/label-profiles.ts +212 -0
  23. package/src/label-routes.ts +30 -2
  24. package/src/library.ts +14 -0
  25. package/src/playlist-input.ts +46 -5
  26. package/src/server.ts +10 -1
  27. package/src/station-routes.ts +23 -6
  28. package/src/station-runner.ts +205 -4
  29. package/src/stations.ts +15 -0
  30. package/web/dist/assets/{ar-DDphLo2L.js → ar-d05d_7Df.js} +1 -1
  31. package/web/dist/assets/{cs-CMKLJTnw.js → cs-CclrNwa7.js} +1 -1
  32. package/web/dist/assets/{da-CibDEkHw.js → da-C30KTGl1.js} +1 -1
  33. package/web/dist/assets/{de-D1BL2QsJ.js → de-D2A2RQwi.js} +1 -1
  34. package/web/dist/assets/{es-Cj9qr9jo.js → es-BXFx0WZ5.js} +1 -1
  35. package/web/dist/assets/{fi-DqCO5i_7.js → fi-DPA3S8og.js} +1 -1
  36. package/web/dist/assets/{fr-DUk14AWf.js → fr-D_0wnr4h.js} +1 -1
  37. package/web/dist/assets/{hi-U_jzSO9-.js → hi-EfiCQnb9.js} +1 -1
  38. package/web/dist/assets/{hu-B28enhWa.js → hu-CK5rnxKO.js} +1 -1
  39. package/web/dist/assets/{id-DlGfGd_6.js → id-BgTV_4UK.js} +1 -1
  40. package/web/dist/assets/{it-D3HMQpf5.js → it-DGefapXn.js} +1 -1
  41. package/web/dist/assets/{main-D0eqtyhi.js → main-C-tWhTWo.js} +2 -2
  42. package/web/dist/assets/{nl-i1Y9R3xr.js → nl-Cyst29Bp.js} +1 -1
  43. package/web/dist/assets/{ru-DjRg1y2P.js → ru-D8G6Wynm.js} +1 -1
  44. package/web/dist/assets/{station-DxBlD6Yl.js → station-D6sd4DqP.js} +1 -1
  45. package/web/dist/assets/{sv-Dsj-93a2.js → sv-aYnDAFwz.js} +1 -1
  46. package/web/dist/assets/{uk-COWBdTZb.js → uk-BDtew7z3.js} +1 -1
  47. package/web/dist/assets/{vi-CgEMtDCA.js → vi-CRlDn2Xh.js} +1 -1
  48. package/web/dist/assets/{zh-CBa5mJ__.js → zh-BCAIjeQq.js} +1 -1
  49. package/web/dist/index.html +1 -1
  50. package/web/dist/station.html +1 -1
  51. package/web/dist/sw.js +20 -20
@@ -17,9 +17,11 @@ import { adCsp, withPageAd } from "./station-guide.js";
17
17
  // files are sold, so only the words and the cover are public here, and an
18
18
  // MP3 or a zip needs a grant from nixamp.com's store (music-store.ts).
19
19
  const FILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/([a-f0-9]{8})\/(\d{1,3}\.(?:txt|mp3)|cover\.jpg)$/;
20
+ /** An artist's OpenProfile pages and portraits: artist.md, members/<slug>.md, members/<slug>.jpg. */
21
+ const PROFILE = /^\/api\/v1\/label\/([a-f0-9]{10})\/(artist\.md|members\/[a-z0-9-]{1,40}\.(?:md|jpg))$/;
20
22
  /** Whether this path is the label's public face (an MP3 or the zip still asks for its grant). */
21
23
  export function isPublicLabelPath(path) {
22
- return path === "/label" || path === "/api/v1/label" || path === "/api/v1/label/zip" || FILE.test(path);
24
+ return path === "/label" || path === "/api/v1/label" || path === "/api/v1/label/zip" || FILE.test(path) || PROFILE.test(path);
23
25
  }
24
26
  /** nixamp.com's page that sells this: a song, an album or everything by the artist. */
25
27
  export function buyLink(site, server, kind, artist, album = "", n = 0) {
@@ -126,6 +128,16 @@ export async function handleLabelRoutes(request, response, path, ctx) {
126
128
  response.end();
127
129
  return true;
128
130
  }
131
+ const profile = PROFILE.exec(path);
132
+ if (profile) {
133
+ const found = label.profileFile(profile[1], profile[2]);
134
+ if (!found) {
135
+ ctx.json(response, 404, { error: "no such profile", code: "not_found" });
136
+ return true;
137
+ }
138
+ sendFile(request, response, found.path, found.type, "");
139
+ return true;
140
+ }
129
141
  const [, artist, album, what] = FILE.exec(path);
130
142
  if (what.endsWith(".mp3")) {
131
143
  const scope = ctx.grants && grant ? await ctx.grants.check(grant) : null;
@@ -253,6 +265,11 @@ audio{width:100%;max-width:360px;height:36px}
253
265
  details{margin-top:4px}summary{cursor:pointer;color:var(--muted);font-size:.9rem}
254
266
  pre.lyrics{white-space:pre-wrap;font:inherit;font-size:.95rem;margin:8px 0 0;padding:12px;border-radius:8px;background:var(--bg)}
255
267
  .dl{font-size:.85rem}.soon{color:var(--muted);font-size:.85rem}
268
+ ul.members{list-style:none;margin:12px 0 4px;padding:0;display:flex;flex-wrap:wrap;gap:10px}
269
+ ul.members a{display:flex;gap:8px;align-items:center;color:var(--fg);text-decoration:none;border:1px solid var(--line);border-radius:999px;padding:4px 12px 4px 4px}
270
+ ul.members a:hover,ul.members a:focus-visible{border-color:var(--accent)}
271
+ ul.members img{width:40px;height:40px;border-radius:50%;object-fit:cover;background:var(--line)}
272
+ ul.members span{display:grid;line-height:1.2}ul.members .role{color:var(--muted);font-size:.8rem}
256
273
  a.buy{display:inline-block;font-weight:700;text-decoration:none;border:1px solid var(--accent);border-radius:999px;padding:2px 10px}
257
274
  footer{margin-top:48px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
258
275
  `;
@@ -263,6 +280,16 @@ export const LABEL_CSP = [
263
280
  "default-src 'none'", `style-src ${hash(STYLE)}`, "img-src 'self'",
264
281
  "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
265
282
  ].join("; ");
283
+ /** The band: each member's portrait, name and role, linking to their OpenProfile page. */
284
+ function renderMembers(artist) {
285
+ if (!artist.members?.length)
286
+ return "";
287
+ const base = `/api/v1/label/${artist.key}`;
288
+ return `<ul class="members">${artist.members.map((member) => `<li><a href="${base}/members/${escape(member.slug)}.md" title="${escape(member.bio)}">`
289
+ + `<img src="${base}/members/${escape(member.slug)}.jpg" alt="" width="56" height="56" loading="lazy">`
290
+ + `<span><strong>${escape(member.name)}</strong><span class="role">${escape(member.role)}</span></span></a></li>`).join("")}</ul>`
291
+ + `<p class="dl"><a href="${base}/artist.md">${escape(artist.name)}'s profile</a></p>`;
292
+ }
266
293
  function price(cents) {
267
294
  return cents < 100 ? `${cents}¢` : `$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`;
268
295
  }
@@ -289,7 +316,7 @@ export function renderLabel(view, shop) {
289
316
  const nav = view.genres.length > 1 ? `<nav class="genres" aria-label="Genres">${view.genres.map((one) => `<a href="#${escape(one.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">${escape(one.genre)}</a>`).join("")}</nav>` : "";
290
317
  const sections = view.genres.map((genre) => `<section class="genre" id="${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}" aria-labelledby="g-${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">`
291
318
  + `<h2 id="g-${escape(genre.genre.toLowerCase().replace(/[^a-z0-9]+/g, "-"))}">${escape(genre.genre)}${genre.station ? ` <a href="/watch/${escape(genre.station)}">Listen live, free →</a>` : ""}</h2>`
292
- + genre.artists.map((artist) => `<article class="artist"><h3>${escape(artist.name)}</h3>${artist.bio ? `<p class="bio">${escape(artist.bio)}</p>` : ""}`
319
+ + genre.artists.map((artist) => `<article class="artist"><h3>${escape(artist.name)}</h3>${artist.bio ? `<p class="bio">${escape(artist.bio)}</p>` : ""}${renderMembers(artist)}`
293
320
  + `${shop ? `<p>${buy(shop, "artist", artist.key, "", 0, "everything by them")}</p>` : ""}`
294
321
  + `${artist.albums.map((album) => renderAlbum(artist, album, shop)).join("")}</article>`).join("")
295
322
  + `</section>`).join("");
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;