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.
- package/dist/ai-label.d.ts +38 -3
- package/dist/ai-label.js +96 -5
- package/dist/channels.d.ts +20 -0
- package/dist/channels.js +24 -0
- package/dist/label-profiles.d.ts +60 -0
- package/dist/label-profiles.js +190 -0
- package/dist/label-routes.js +29 -2
- 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/ai-label.ts +95 -8
- package/src/channels.ts +30 -0
- package/src/label-profiles.ts +212 -0
- package/src/label-routes.ts +30 -2
- 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-DDphLo2L.js → ar-d05d_7Df.js} +1 -1
- package/web/dist/assets/{cs-CMKLJTnw.js → cs-CclrNwa7.js} +1 -1
- package/web/dist/assets/{da-CibDEkHw.js → da-C30KTGl1.js} +1 -1
- package/web/dist/assets/{de-D1BL2QsJ.js → de-D2A2RQwi.js} +1 -1
- package/web/dist/assets/{es-Cj9qr9jo.js → es-BXFx0WZ5.js} +1 -1
- package/web/dist/assets/{fi-DqCO5i_7.js → fi-DPA3S8og.js} +1 -1
- package/web/dist/assets/{fr-DUk14AWf.js → fr-D_0wnr4h.js} +1 -1
- package/web/dist/assets/{hi-U_jzSO9-.js → hi-EfiCQnb9.js} +1 -1
- package/web/dist/assets/{hu-B28enhWa.js → hu-CK5rnxKO.js} +1 -1
- package/web/dist/assets/{id-DlGfGd_6.js → id-BgTV_4UK.js} +1 -1
- package/web/dist/assets/{it-D3HMQpf5.js → it-DGefapXn.js} +1 -1
- package/web/dist/assets/{main-D0eqtyhi.js → main-C-tWhTWo.js} +2 -2
- package/web/dist/assets/{nl-i1Y9R3xr.js → nl-Cyst29Bp.js} +1 -1
- package/web/dist/assets/{ru-DjRg1y2P.js → ru-D8G6Wynm.js} +1 -1
- package/web/dist/assets/{station-DxBlD6Yl.js → station-D6sd4DqP.js} +1 -1
- package/web/dist/assets/{sv-Dsj-93a2.js → sv-aYnDAFwz.js} +1 -1
- package/web/dist/assets/{uk-COWBdTZb.js → uk-BDtew7z3.js} +1 -1
- package/web/dist/assets/{vi-CgEMtDCA.js → vi-CRlDn2Xh.js} +1 -1
- package/web/dist/assets/{zh-CBa5mJ__.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/label-routes.js
CHANGED
|
@@ -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"] } : {}),
|
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;
|