nixamp 0.28.45 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio.js +3 -0
- package/dist/channels.d.ts +14 -0
- package/dist/channels.js +20 -4
- package/dist/decoder-policy.d.ts +20 -0
- package/dist/decoder-policy.js +34 -0
- package/dist/destinations.d.ts +5 -0
- package/dist/destinations.js +82 -0
- package/dist/directory.d.ts +3 -1
- package/dist/directory.js +8 -2
- package/dist/library.d.ts +2 -0
- package/dist/library.js +4 -1
- package/dist/links.d.ts +4 -0
- package/dist/links.js +20 -2
- package/dist/main.d.ts +0 -8
- package/dist/main.js +9 -0
- package/dist/playlist-input.d.ts +46 -0
- package/dist/playlist-input.js +158 -54
- package/dist/publish-choice.d.ts +21 -0
- package/dist/publish-choice.js +17 -0
- package/dist/server.d.ts +27 -1
- package/dist/server.js +228 -17
- package/dist/share.d.ts +1 -1
- package/dist/share.js +5 -4
- package/dist/station-cli.d.ts +3 -0
- package/dist/station-cli.js +201 -0
- package/dist/station-page.d.ts +5 -0
- package/dist/station-page.js +137 -0
- package/dist/station-routes.d.ts +60 -0
- package/dist/station-routes.js +249 -0
- package/dist/station-runner.d.ts +67 -0
- package/dist/station-runner.js +219 -0
- package/dist/station-usage.d.ts +2 -0
- package/dist/station-usage.js +20 -0
- package/dist/stations.d.ts +148 -0
- package/dist/stations.js +331 -0
- package/package.json +1 -1
- package/src/audio.ts +3 -0
- package/src/channels.ts +30 -5
- package/src/decoder-policy.ts +33 -0
- package/src/destinations.ts +78 -0
- package/src/directory.ts +10 -2
- package/src/library.ts +6 -1
- package/src/links.ts +23 -4
- package/src/main.ts +8 -0
- package/src/playlist-input.ts +172 -50
- package/src/publish-choice.ts +35 -0
- package/src/server.ts +227 -18
- package/src/share.ts +5 -4
- package/src/station-cli.ts +195 -0
- package/src/station-page.ts +144 -0
- package/src/station-routes.ts +301 -0
- package/src/station-runner.ts +244 -0
- package/src/station-usage.ts +20 -0
- package/src/stations.ts +407 -0
- package/web/dist/assets/{ar-8fDonUoB.js → ar-CTUDxy1J.js} +1 -1
- package/web/dist/assets/{cs-DCClolTy.js → cs-m4JTaO3o.js} +1 -1
- package/web/dist/assets/{da-C3RPhXUd.js → da-DHnJElYB.js} +1 -1
- package/web/dist/assets/{de-CYBoEomk.js → de-DkxPohnf.js} +1 -1
- package/web/dist/assets/{es-DS3ls4xT.js → es-oY_G83d7.js} +1 -1
- package/web/dist/assets/{fi-DbCZpO89.js → fi-fCO8XRdg.js} +1 -1
- package/web/dist/assets/{fr-D0z3UpdL.js → fr-CSupf-IN.js} +1 -1
- package/web/dist/assets/{hi-BTU3sRlQ.js → hi-Dve7PJsC.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-CQXTVHn_.js → hls-3VKVEQE3-D5-AkKSR.js} +1 -1
- package/web/dist/assets/{hu-B37Q8ldT.js → hu-D1KYamzV.js} +1 -1
- package/web/dist/assets/{id-4pdXNa4G.js → id-Cofp_4aQ.js} +1 -1
- package/web/dist/assets/index-7mCQZGNF.js +22 -0
- package/web/dist/assets/{it-CTe69-UY.js → it-62iXQWQD.js} +1 -1
- package/web/dist/assets/{mpegts-BBmkDppm.js → mpegts-BT5ESacM.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CUVdjYFi.js → mpegts-LO6RVLD6-BgHd8PFT.js} +1 -1
- package/web/dist/assets/{nl-CxrmHrMn.js → nl-CzRRG-CN.js} +1 -1
- package/web/dist/assets/{ru-zjVCfIKq.js → ru-OykC1LJQ.js} +1 -1
- package/web/dist/assets/{sv-CEoiT2Lr.js → sv-B8FXBDHm.js} +1 -1
- package/web/dist/assets/{uk-ZC6JzQvD.js → uk-DhAJBjwH.js} +1 -1
- package/web/dist/assets/{vi-D9anF-cl.js → vi-CdBSRReC.js} +1 -1
- package/web/dist/assets/{zh-BeLYdC0I.js → zh-wGWNyBEZ.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
- package/web/dist/assets/index-BDBbN5Qy.js +0 -22
package/dist/stations.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stations: a collection turned into a program that keeps playing.
|
|
3
|
+
*
|
|
4
|
+
* A channel is a running decoder, named for what it plays, and gone when it
|
|
5
|
+
* stops. A station is the thing a person made and named: which files, in what
|
|
6
|
+
* rotation, who may hear it, and whether it should be on. Its id is random
|
|
7
|
+
* and never changes, so its link survives a rename, a restart, a moved file
|
|
8
|
+
* and a renderer that died; the channel it plays through (`station-<id>`) is
|
|
9
|
+
* only how it happens to be delivered right now.
|
|
10
|
+
*
|
|
11
|
+
* Two states, kept apart. `desired` is what the owner asked for and is
|
|
12
|
+
* written down; a station deliberately stopped stays stopped after a restart,
|
|
13
|
+
* and one left running comes back. `observed` is what is actually happening
|
|
14
|
+
* and is never written down, because it is only true now.
|
|
15
|
+
*
|
|
16
|
+
* Who may listen is the station's own business, not the server's. A new
|
|
17
|
+
* station is private, stopped and unlisted. Its listen token opens its stream,
|
|
18
|
+
* its now-playing and its picture, and nothing of the library behind it.
|
|
19
|
+
* Listing it in the public directory is a separate choice from making it
|
|
20
|
+
* public, and making it public asks first on what basis it may be broadcast.
|
|
21
|
+
*/
|
|
22
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
23
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join, sep } from "node:path";
|
|
25
|
+
export const RIGHTS_BASES = ["owned", "permission", "licensed", "public-domain"];
|
|
26
|
+
/** Times an item may fail in a row before rotation leaves it out. */
|
|
27
|
+
export const EXCLUDE_AFTER = 3;
|
|
28
|
+
export const DEFAULT_BITRATE = 128;
|
|
29
|
+
export const BITRATES = [64, 96, 128, 160, 192, 256, 320];
|
|
30
|
+
const MAX_ITEMS = 20_000;
|
|
31
|
+
const MAX_NAME = 120;
|
|
32
|
+
const MAX_DESCRIPTION = 500;
|
|
33
|
+
/** A station's channel id. */
|
|
34
|
+
export function channelOf(id) {
|
|
35
|
+
return `station-${id}`;
|
|
36
|
+
}
|
|
37
|
+
/** The station a channel id belongs to, or "" for any other channel. */
|
|
38
|
+
export function stationOfChannel(channelId) {
|
|
39
|
+
return channelId.startsWith("station-") ? channelId.slice("station-".length) : "";
|
|
40
|
+
}
|
|
41
|
+
function newId() {
|
|
42
|
+
// Ten characters of base32: short enough to read out, far too many to guess.
|
|
43
|
+
const alphabet = "abcdefghijkmnpqrstuvwxyz23456789";
|
|
44
|
+
return [...randomBytes(10)].map((byte) => alphabet[byte % alphabet.length]).join("");
|
|
45
|
+
}
|
|
46
|
+
function newToken() {
|
|
47
|
+
return randomBytes(16).toString("base64url");
|
|
48
|
+
}
|
|
49
|
+
export function tokensMatch(offered, held) {
|
|
50
|
+
const a = Buffer.from(offered);
|
|
51
|
+
const b = Buffer.from(held);
|
|
52
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
53
|
+
}
|
|
54
|
+
/** A path inside one of these roots, after symlinks: never outside the library. */
|
|
55
|
+
export function insideRoots(path, roots) {
|
|
56
|
+
let real;
|
|
57
|
+
try {
|
|
58
|
+
real = realpathSync(path);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return roots.some((root) => {
|
|
64
|
+
let base;
|
|
65
|
+
try {
|
|
66
|
+
base = realpathSync(root);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return real === base || real.startsWith(base.endsWith(sep) ? base : base + sep);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
export class StationError extends Error {
|
|
75
|
+
code;
|
|
76
|
+
status;
|
|
77
|
+
constructor(code, message, status = 400) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.code = code;
|
|
80
|
+
this.status = status;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function text(value, max, field) {
|
|
84
|
+
if (typeof value !== "string")
|
|
85
|
+
throw new StationError("invalid_" + field, `${field} must be text`);
|
|
86
|
+
return value.trim().replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, max);
|
|
87
|
+
}
|
|
88
|
+
/** The stations of one server, kept in one file beside its keys. */
|
|
89
|
+
export class StationStore {
|
|
90
|
+
file;
|
|
91
|
+
io;
|
|
92
|
+
stations = new Map();
|
|
93
|
+
constructor(file, io = {
|
|
94
|
+
read: (path) => { try {
|
|
95
|
+
return readFileSync(path, "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
} },
|
|
100
|
+
write: (path, body) => {
|
|
101
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
102
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
103
|
+
writeFileSync(temporary, body, { mode: 0o600 });
|
|
104
|
+
renameSync(temporary, path);
|
|
105
|
+
},
|
|
106
|
+
}) {
|
|
107
|
+
this.file = file;
|
|
108
|
+
this.io = io;
|
|
109
|
+
const body = this.io.read(file);
|
|
110
|
+
if (body === null)
|
|
111
|
+
return;
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(body);
|
|
114
|
+
for (const one of parsed.stations ?? []) {
|
|
115
|
+
if (one && typeof one.id === "string" && Array.isArray(one.items)) {
|
|
116
|
+
this.stations.set(one.id, {
|
|
117
|
+
...one,
|
|
118
|
+
failures: one.failures ?? {},
|
|
119
|
+
bitrate: one.bitrate ?? DEFAULT_BITRATE,
|
|
120
|
+
rights: one.rights ?? null,
|
|
121
|
+
owner: one.owner ?? "",
|
|
122
|
+
description: one.description ?? "",
|
|
123
|
+
idempotencyKey: one.idempotencyKey ?? "",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// A file that cannot be read is not a reason to refuse to serve. It is
|
|
130
|
+
// left where it is, and the next write replaces it.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
static fileFor(stateDir, port) {
|
|
134
|
+
return join(stateDir, `stations-${port}.json`);
|
|
135
|
+
}
|
|
136
|
+
save() {
|
|
137
|
+
this.io.write(this.file, `${JSON.stringify({ version: 1, stations: [...this.stations.values()] }, null, 2)}\n`);
|
|
138
|
+
}
|
|
139
|
+
list() {
|
|
140
|
+
return [...this.stations.values()].sort((a, b) => a.createdAt - b.createdAt);
|
|
141
|
+
}
|
|
142
|
+
get(id) {
|
|
143
|
+
return this.stations.get(id);
|
|
144
|
+
}
|
|
145
|
+
/** New, and always private, stopped and unlisted (RAD-02). */
|
|
146
|
+
/** The station a create with this key already made. */
|
|
147
|
+
byIdempotencyKey(key) {
|
|
148
|
+
if (key === "")
|
|
149
|
+
return undefined;
|
|
150
|
+
return [...this.stations.values()].find((one) => one.idempotencyKey === key);
|
|
151
|
+
}
|
|
152
|
+
create(input, items, owner, now = Date.now(), idempotencyKey = "") {
|
|
153
|
+
const name = text(input.name ?? "", MAX_NAME, "name");
|
|
154
|
+
if (name === "")
|
|
155
|
+
throw new StationError("name_required", "a station needs a name");
|
|
156
|
+
if (items.length === 0)
|
|
157
|
+
throw new StationError("empty_station", "choose at least one file that plays");
|
|
158
|
+
if (items.length > MAX_ITEMS)
|
|
159
|
+
throw new StationError("too_many_items", `a station holds at most ${MAX_ITEMS} files`);
|
|
160
|
+
const station = {
|
|
161
|
+
id: newId(),
|
|
162
|
+
name,
|
|
163
|
+
description: input.description === undefined ? "" : text(input.description, MAX_DESCRIPTION, "description"),
|
|
164
|
+
items,
|
|
165
|
+
shuffle: input.shuffle === true,
|
|
166
|
+
repeat: input.repeat !== false,
|
|
167
|
+
visibility: "private",
|
|
168
|
+
listed: false,
|
|
169
|
+
desired: "stopped",
|
|
170
|
+
listenToken: newToken(),
|
|
171
|
+
bitrate: this.bitrateFrom(input.bitrate),
|
|
172
|
+
rights: null,
|
|
173
|
+
owner,
|
|
174
|
+
failures: {},
|
|
175
|
+
idempotencyKey: idempotencyKey.slice(0, 200),
|
|
176
|
+
createdAt: now,
|
|
177
|
+
updatedAt: now,
|
|
178
|
+
};
|
|
179
|
+
this.stations.set(station.id, station);
|
|
180
|
+
this.save();
|
|
181
|
+
return station;
|
|
182
|
+
}
|
|
183
|
+
bitrateFrom(value) {
|
|
184
|
+
if (value === undefined)
|
|
185
|
+
return DEFAULT_BITRATE;
|
|
186
|
+
if (typeof value !== "number" || !BITRATES.includes(value)) {
|
|
187
|
+
throw new StationError("invalid_bitrate", `bitrate is one of ${BITRATES.join(", ")} kbps`);
|
|
188
|
+
}
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
/** Name, words, files and rotation. Who may listen is changed by publish/unpublish. */
|
|
192
|
+
update(id, input, items, now = Date.now()) {
|
|
193
|
+
const station = this.require(id);
|
|
194
|
+
if (input.name !== undefined) {
|
|
195
|
+
const name = text(input.name, MAX_NAME, "name");
|
|
196
|
+
if (name === "")
|
|
197
|
+
throw new StationError("name_required", "a station needs a name");
|
|
198
|
+
station.name = name;
|
|
199
|
+
}
|
|
200
|
+
if (input.description !== undefined)
|
|
201
|
+
station.description = text(input.description, MAX_DESCRIPTION, "description");
|
|
202
|
+
if (input.shuffle !== undefined)
|
|
203
|
+
station.shuffle = input.shuffle === true;
|
|
204
|
+
if (input.repeat !== undefined)
|
|
205
|
+
station.repeat = input.repeat !== false;
|
|
206
|
+
if (input.bitrate !== undefined)
|
|
207
|
+
station.bitrate = this.bitrateFrom(input.bitrate);
|
|
208
|
+
if (items !== null) {
|
|
209
|
+
if (items.length === 0)
|
|
210
|
+
throw new StationError("empty_station", "a station needs at least one file that plays");
|
|
211
|
+
if (items.length > MAX_ITEMS)
|
|
212
|
+
throw new StationError("too_many_items", `a station holds at most ${MAX_ITEMS} files`);
|
|
213
|
+
station.items = items;
|
|
214
|
+
// A file taken out and put back is given another chance.
|
|
215
|
+
station.failures = Object.fromEntries(Object.entries(station.failures).filter(([file]) => items.includes(file)));
|
|
216
|
+
}
|
|
217
|
+
station.updatedAt = now;
|
|
218
|
+
this.save();
|
|
219
|
+
return station;
|
|
220
|
+
}
|
|
221
|
+
remove(id) {
|
|
222
|
+
const had = this.stations.delete(id);
|
|
223
|
+
if (had)
|
|
224
|
+
this.save();
|
|
225
|
+
return had;
|
|
226
|
+
}
|
|
227
|
+
setDesired(id, desired, now = Date.now()) {
|
|
228
|
+
const station = this.require(id);
|
|
229
|
+
station.desired = desired;
|
|
230
|
+
station.updatedAt = now;
|
|
231
|
+
this.save();
|
|
232
|
+
return station;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Who may hear it, and whether the directory lists it. Public needs a
|
|
236
|
+
* declared basis; listed needs public. Going private mints a new token, so
|
|
237
|
+
* every link handed out before stops working, and the caller drops the
|
|
238
|
+
* listeners who used one.
|
|
239
|
+
*/
|
|
240
|
+
setAccess(id, access, by, now = Date.now()) {
|
|
241
|
+
const station = this.require(id);
|
|
242
|
+
const visibility = access.visibility === undefined ? station.visibility : access.visibility;
|
|
243
|
+
if (visibility !== "private" && visibility !== "unlisted" && visibility !== "public") {
|
|
244
|
+
throw new StationError("invalid_visibility", "visibility is private, unlisted or public");
|
|
245
|
+
}
|
|
246
|
+
let listed = access.listed === undefined ? station.listed : access.listed === true;
|
|
247
|
+
if (visibility !== "public")
|
|
248
|
+
listed = false;
|
|
249
|
+
if (access.rights) {
|
|
250
|
+
const basis = access.rights.basis;
|
|
251
|
+
if (typeof basis !== "string" || !RIGHTS_BASES.includes(basis)) {
|
|
252
|
+
throw new StationError("invalid_rights_basis", `the basis is one of ${RIGHTS_BASES.join(", ")}`);
|
|
253
|
+
}
|
|
254
|
+
station.rights = {
|
|
255
|
+
basis: basis,
|
|
256
|
+
note: access.rights.note === undefined ? "" : text(access.rights.note, MAX_DESCRIPTION, "note"),
|
|
257
|
+
declaredAt: now,
|
|
258
|
+
declaredBy: by,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (visibility === "public" && station.rights === null) {
|
|
262
|
+
throw new StationError("rights_required", "before a station is public, say on what basis it may be broadcast: owned, permission, licensed or public-domain", 409);
|
|
263
|
+
}
|
|
264
|
+
const revoked = visibility === "private" && station.visibility !== "private";
|
|
265
|
+
station.visibility = visibility;
|
|
266
|
+
station.listed = listed;
|
|
267
|
+
if (revoked)
|
|
268
|
+
station.listenToken = newToken();
|
|
269
|
+
station.updatedAt = now;
|
|
270
|
+
this.save();
|
|
271
|
+
return { station, revoked };
|
|
272
|
+
}
|
|
273
|
+
/** A new listen link; every earlier one stops working. */
|
|
274
|
+
rotateToken(id, now = Date.now()) {
|
|
275
|
+
const station = this.require(id);
|
|
276
|
+
station.listenToken = newToken();
|
|
277
|
+
station.updatedAt = now;
|
|
278
|
+
this.save();
|
|
279
|
+
return station;
|
|
280
|
+
}
|
|
281
|
+
/** One more failure in a row for this file; true when it is now left out. */
|
|
282
|
+
recordFailure(id, file) {
|
|
283
|
+
const station = this.stations.get(id);
|
|
284
|
+
if (!station)
|
|
285
|
+
return false;
|
|
286
|
+
const count = (station.failures[file] ?? 0) + 1;
|
|
287
|
+
station.failures[file] = count;
|
|
288
|
+
this.save();
|
|
289
|
+
return count === EXCLUDE_AFTER;
|
|
290
|
+
}
|
|
291
|
+
/** Played through: its failures are forgotten. */
|
|
292
|
+
recordSuccess(id, file) {
|
|
293
|
+
const station = this.stations.get(id);
|
|
294
|
+
if (!station || station.failures[file] === undefined)
|
|
295
|
+
return;
|
|
296
|
+
delete station.failures[file];
|
|
297
|
+
this.save();
|
|
298
|
+
}
|
|
299
|
+
/** Every left-out file tried again (the owner's "retry"). */
|
|
300
|
+
clearFailures(id) {
|
|
301
|
+
const station = this.require(id);
|
|
302
|
+
station.failures = {};
|
|
303
|
+
this.save();
|
|
304
|
+
return station;
|
|
305
|
+
}
|
|
306
|
+
isExcluded(id, file) {
|
|
307
|
+
return (this.stations.get(id)?.failures[file] ?? 0) >= EXCLUDE_AFTER;
|
|
308
|
+
}
|
|
309
|
+
require(id) {
|
|
310
|
+
const station = this.stations.get(id);
|
|
311
|
+
if (!station)
|
|
312
|
+
throw new StationError("not_found", "no such station", 404);
|
|
313
|
+
return station;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** A station-wide shuffle: one order for everybody, Fisher-Yates. */
|
|
317
|
+
export function shuffled(length, random = Math.random) {
|
|
318
|
+
const order = [...Array(length).keys()];
|
|
319
|
+
for (let index = order.length - 1; index > 0; index--) {
|
|
320
|
+
const other = Math.floor(random() * (index + 1));
|
|
321
|
+
[order[index], order[other]] = [order[other], order[index]];
|
|
322
|
+
}
|
|
323
|
+
return order;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Restart after an unexpected end: 2 s, doubling, at most a minute apart.
|
|
327
|
+
* After the last of these the station is offline but still wanted, and is
|
|
328
|
+
* tried again every OFFLINE_RETRY_MS so a source that comes back is found.
|
|
329
|
+
*/
|
|
330
|
+
export const RECOVERY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 32_000, 60_000];
|
|
331
|
+
export const OFFLINE_RETRY_MS = 5 * 60_000;
|
package/package.json
CHANGED
package/src/audio.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* same bytes to the output process. Running two decoders instead would drift
|
|
7
7
|
* apart within seconds and the bars would stop matching what you hear.
|
|
8
8
|
*/
|
|
9
|
+
import { protocolArgs } from "./decoder-policy.ts";
|
|
9
10
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
10
11
|
import { readdirSync } from "node:fs";
|
|
11
12
|
import { homedir } from "node:os";
|
|
@@ -136,6 +137,7 @@ export function probe(tools: Tools, path: string): Track {
|
|
|
136
137
|
...rest,
|
|
137
138
|
"-v", "quiet", "-print_format", "json",
|
|
138
139
|
"-show_format", "-show_entries", "format_tags=title,artist,album",
|
|
140
|
+
...protocolArgs(path),
|
|
139
141
|
path,
|
|
140
142
|
], { encoding: "utf8", timeout: 20_000, maxBuffer: 4 * 1024 * 1024 });
|
|
141
143
|
if (result.error || result.status !== 0) return fallback;
|
|
@@ -224,6 +226,7 @@ export class Stream {
|
|
|
224
226
|
// being on disk. Measured with that condition in place: still 13.8x.
|
|
225
227
|
"-re",
|
|
226
228
|
...(from > 0 ? ["-ss", String(from)] : []),
|
|
229
|
+
...protocolArgs(track.path),
|
|
227
230
|
"-i", track.path,
|
|
228
231
|
"-f", "f32le", "-ac", String(CHANNELS), "-ar", String(RATE), "-",
|
|
229
232
|
], { stdio: ["ignore", "pipe", "pipe"] });
|
package/src/channels.ts
CHANGED
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
* what live means. MP3 frames are self-describing, so a player finds the next
|
|
15
15
|
* frame boundary and carries on; there is nothing to catch up on.
|
|
16
16
|
*/
|
|
17
|
+
import { protocolArgs } from "./decoder-policy.ts";
|
|
17
18
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
18
19
|
import { randomBytes } from "node:crypto";
|
|
19
20
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
21
|
import { dirname, join } from "node:path";
|
|
21
|
-
import { PlaylistInputStream } from "./playlist-input.ts";
|
|
22
|
+
import { PlaylistInputStream, type PlaylistInput } from "./playlist-input.ts";
|
|
22
23
|
import type { Readable } from "node:stream";
|
|
23
24
|
import { FragmentClock, Fragments, isOpening } from "./fragments.ts";
|
|
24
25
|
|
|
@@ -122,6 +123,13 @@ export interface PullResume {
|
|
|
122
123
|
/** For a list of things rather than one: every entry, in order. */
|
|
123
124
|
playlist?: string[];
|
|
124
125
|
playlistAt?: number;
|
|
126
|
+
/**
|
|
127
|
+
* How a station plays its list: round and round, in a shuffled order, with
|
|
128
|
+
* edits landing on the next track and broken entries passed over. Only for
|
|
129
|
+
* lists of files on this machine; not remembered, because whoever owns the
|
|
130
|
+
* rotation (the station) puts it back.
|
|
131
|
+
*/
|
|
132
|
+
rotation?: Pick<PlaylistInput, "repeat" | "order" | "current" | "excluded" | "onFailure" | "passOver">;
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
/**
|
|
@@ -284,6 +292,13 @@ export class Channel {
|
|
|
284
292
|
private redial: (() => void) | null = null;
|
|
285
293
|
/** For a channel playing a list: move to the next entry. Null when there is no list. */
|
|
286
294
|
private advance: (() => boolean) | null = null;
|
|
295
|
+
/** The list being played entry by entry, when it is one; what skip() moves on. */
|
|
296
|
+
private playlistInput: PlaylistInputStream | null = null;
|
|
297
|
+
|
|
298
|
+
/** On to the next entry of a list of files. False when this is not one. */
|
|
299
|
+
skip(): boolean {
|
|
300
|
+
return this.playlistInput?.skip() ?? false;
|
|
301
|
+
}
|
|
287
302
|
private failures = 0;
|
|
288
303
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
289
304
|
/** Fires when a pulled source has said nothing for STALL. */
|
|
@@ -400,7 +415,7 @@ export class Channel {
|
|
|
400
415
|
// A list plays entry by entry: the one that is on is what gets dialled,
|
|
401
416
|
// and an entry that ended is followed by the next, at once.
|
|
402
417
|
const list = resume.playlist && resume.playlist.length > 0 ? resume.playlist : null;
|
|
403
|
-
const localPlaylist = list && list.length > 1 && list.every(one => !/^https?:\/\//i.test(one)) ? list : null;
|
|
418
|
+
const localPlaylist = list && (list.length > 1 || resume.rotation) && list.every(one => !/^https?:\/\//i.test(one)) ? list : null;
|
|
404
419
|
let at = 0;
|
|
405
420
|
if (list) {
|
|
406
421
|
this.info.playlist = list;
|
|
@@ -450,7 +465,9 @@ export class Channel {
|
|
|
450
465
|
ffprobe: this.options.ffprobe ?? this.options.ffmpeg.map(one => one.replace(/ffmpeg(?=(?:\.exe)?$)/, "ffprobe")),
|
|
451
466
|
files: localPlaylist, at: this.info.playlistAt ?? 0, position: from,
|
|
452
467
|
video: this.info.kind === "video", width: this.info.codecs?.width, height: this.info.codecs?.height,
|
|
468
|
+
...resume.rotation,
|
|
453
469
|
}) : null;
|
|
470
|
+
this.playlistInput = playlistInput;
|
|
454
471
|
const through = this.outroOn ? null : playlistInput
|
|
455
472
|
? { format: "mpegts", open: async (signal: AbortSignal) => playlistInput.open(signal) }
|
|
456
473
|
: this.options.through?.(this.info, from, input, audio) ?? null;
|
|
@@ -503,10 +520,10 @@ export class Channel {
|
|
|
503
520
|
// What the source's site expects on the request: a user agent, a
|
|
504
521
|
// referer, a cookie. A link resolved by yt-dlp comes with these,
|
|
505
522
|
// and a CDN that got them from yt-dlp and not from us answers 403.
|
|
506
|
-
...input, ...seek, "-i", current,
|
|
523
|
+
...input, ...seek, ...protocolArgs(current), "-i", current,
|
|
507
524
|
// The sound, when the site keeps it apart from the picture: a
|
|
508
525
|
// second input, dialled the same way, that the encode maps in.
|
|
509
|
-
...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
|
|
526
|
+
...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, ...protocolArgs(audio), "-i", audio] : []),
|
|
510
527
|
]),
|
|
511
528
|
...(playlistEncode ?? encode),
|
|
512
529
|
"pipe:1",
|
|
@@ -1324,6 +1341,11 @@ export class Channels {
|
|
|
1324
1341
|
return this.open.get(id)?.info.kind;
|
|
1325
1342
|
}
|
|
1326
1343
|
|
|
1344
|
+
/** The next entry, now, on a channel playing a list of files. */
|
|
1345
|
+
skip(id: string): boolean {
|
|
1346
|
+
return this.open.get(id)?.skip() ?? false;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1327
1349
|
stop(id: string): boolean {
|
|
1328
1350
|
const channel = this.open.get(id);
|
|
1329
1351
|
if (!channel) return false;
|
|
@@ -1434,7 +1456,10 @@ export function rememberedChannels(dir: string, port: number): RememberedChannel
|
|
|
1434
1456
|
*/
|
|
1435
1457
|
export function rememberedNow(channels: Channels): RememberedChannel[] {
|
|
1436
1458
|
return channels.list()
|
|
1437
|
-
|
|
1459
|
+
// A station's channel is put back by its station, which knows whether it
|
|
1460
|
+
// is still wanted; remembering it here too would bring back one its
|
|
1461
|
+
// owner had stopped.
|
|
1462
|
+
.filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id) && !one.id.startsWith("station-"))
|
|
1438
1463
|
.map((one) => {
|
|
1439
1464
|
const kept: RememberedChannel = { id: one.id, name: one.name, source: one.source as string };
|
|
1440
1465
|
if (one.kind) kept.kind = one.kind;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a decoder may open while reading one source.
|
|
3
|
+
*
|
|
4
|
+
* ffmpeg follows references inside what it reads: an .m3u8 names segments, a
|
|
5
|
+
* concat list names files, a playlist on disk can name a URL. A file in
|
|
6
|
+
* somebody's library is untrusted input, and without a list of protocols it
|
|
7
|
+
* can make this machine fetch any address it likes, on the LAN included. So
|
|
8
|
+
* a file on disk may open files and pipes and nothing else, and a link may
|
|
9
|
+
* open the network protocols a stream needs and not the local file system.
|
|
10
|
+
*
|
|
11
|
+
* Sources this does not recognise (an ingest URL, a lavfi graph) are left
|
|
12
|
+
* alone rather than broken: the list is for what comes out of a library or a
|
|
13
|
+
* pasted link, which is where untrusted references come from.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A file in a library: itself, and the pipes the pipeline is built from. */
|
|
17
|
+
export const LOCAL_PROTOCOLS = "file,pipe,fd";
|
|
18
|
+
|
|
19
|
+
/** A link: the network, as streams use it, and never `file`. */
|
|
20
|
+
export const REMOTE_PROTOCOLS = [
|
|
21
|
+
"http", "https", "tcp", "tls", "crypto", "data", "httpproxy",
|
|
22
|
+
"rtmp", "rtmps", "rtmpt", "rtmpts", "rtmpe", "rtmpte", "ffrtmpcrypt", "ffrtmphttp",
|
|
23
|
+
].join(",");
|
|
24
|
+
|
|
25
|
+
/** The `-protocol_whitelist` to put before this source's `-i`, or nothing. */
|
|
26
|
+
export function protocolArgs(source: string): string[] {
|
|
27
|
+
if (source === "-" || source.startsWith("pipe:")) return ["-protocol_whitelist", LOCAL_PROTOCOLS];
|
|
28
|
+
if (/^(https?|rtmp[a-z]*):\/\//i.test(source)) return ["-protocol_whitelist", REMOTE_PROTOCOLS];
|
|
29
|
+
if (/^file:/i.test(source)) return ["-protocol_whitelist", LOCAL_PROTOCOLS];
|
|
30
|
+
// Any other scheme (two letters or more, so `C:\` is still a path).
|
|
31
|
+
if (/^[a-z][a-z0-9+.-]+:/i.test(source)) return [];
|
|
32
|
+
return ["-protocol_whitelist", LOCAL_PROTOCOLS];
|
|
33
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a link somebody else pasted may be fetched from this machine.
|
|
3
|
+
*
|
|
4
|
+
* A member -- any nixamp.com account -- may put a link on the air on a server
|
|
5
|
+
* that is not theirs. Fetching it is a request from inside the owner's
|
|
6
|
+
* network, so a link to the router, the cloud metadata address or a service
|
|
7
|
+
* on localhost would be the member reaching things only the owner can. The
|
|
8
|
+
* owner may fetch what they like: it is their network. A member's link has to
|
|
9
|
+
* resolve to a public address, every address it resolves to, at every hop
|
|
10
|
+
* this code follows.
|
|
11
|
+
*
|
|
12
|
+
* What it cannot see: a redirect yt-dlp or ffmpeg follows on its own, and a
|
|
13
|
+
* name that resolves differently a second later. The decoder's protocol list
|
|
14
|
+
* (decoder-policy.ts) keeps those off the file system; keeping them off the
|
|
15
|
+
* LAN entirely needs the fetch to happen here, which is later work.
|
|
16
|
+
*/
|
|
17
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
18
|
+
import { isIP } from "node:net";
|
|
19
|
+
|
|
20
|
+
export type Lookup = (host: string) => Promise<string[]>;
|
|
21
|
+
|
|
22
|
+
const defaultLookup: Lookup = async (host) =>
|
|
23
|
+
(await dnsLookup(host, { all: true, verbatim: true })).map((one) => one.address);
|
|
24
|
+
|
|
25
|
+
function v4Number(address: string): number {
|
|
26
|
+
return address.split(".").reduce((sum, part) => sum * 256 + Number(part), 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function inV4(address: string, base: string, bits: number): boolean {
|
|
30
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
31
|
+
return ((v4Number(address) & mask) >>> 0) === ((v4Number(base) & mask) >>> 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PRIVATE_V4: Array<[string, number]> = [
|
|
35
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
|
|
36
|
+
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
|
|
37
|
+
["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24],
|
|
38
|
+
["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** Not somewhere on the public internet: loopback, private, link-local, reserved. */
|
|
42
|
+
export function isPrivateAddress(address: string): boolean {
|
|
43
|
+
const kind = isIP(address);
|
|
44
|
+
if (kind === 4) return PRIVATE_V4.some(([base, bits]) => inV4(address, base, bits));
|
|
45
|
+
if (kind !== 6) return true;
|
|
46
|
+
const lower = address.toLowerCase();
|
|
47
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
|
|
48
|
+
if (mapped) return isPrivateAddress(mapped[1]!);
|
|
49
|
+
if (lower === "::" || lower === "::1") return true;
|
|
50
|
+
const first = parseInt(lower.split(":")[0] || "0", 16);
|
|
51
|
+
// fc00::/7 unique local, fe80::/10 link-local, ff00::/8 multicast.
|
|
52
|
+
if ((first & 0xfe00) === 0xfc00) return true;
|
|
53
|
+
if ((first & 0xffc0) === 0xfe80) return true;
|
|
54
|
+
if ((first & 0xff00) === 0xff00) return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** "" when this link may be fetched for a member, else why not. */
|
|
59
|
+
export async function refusedDestination(link: string, lookup: Lookup = defaultLookup): Promise<string> {
|
|
60
|
+
let url: URL;
|
|
61
|
+
try {
|
|
62
|
+
url = new URL(link);
|
|
63
|
+
} catch {
|
|
64
|
+
return "that is not a link";
|
|
65
|
+
}
|
|
66
|
+
if (!/^(https?|rtmps?):$/i.test(url.protocol)) return "only http, https and rtmp links can be played here";
|
|
67
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
68
|
+
if (host === "" || /^localhost$/i.test(host) || /\.localhost$/i.test(host)) return "that link points at this machine";
|
|
69
|
+
let addresses: string[];
|
|
70
|
+
try {
|
|
71
|
+
addresses = isIP(host) ? [host] : await lookup(host);
|
|
72
|
+
} catch {
|
|
73
|
+
return "that link's host does not resolve";
|
|
74
|
+
}
|
|
75
|
+
if (addresses.length === 0) return "that link's host does not resolve";
|
|
76
|
+
if (addresses.some(isPrivateAddress)) return "that link points inside a private network, which only this server's owner may play";
|
|
77
|
+
return "";
|
|
78
|
+
}
|
package/src/directory.ts
CHANGED
|
@@ -154,6 +154,8 @@ export interface LineupEntry {
|
|
|
154
154
|
kind: "audio" | "video";
|
|
155
155
|
art: string;
|
|
156
156
|
about: string;
|
|
157
|
+
/** A station's own listening page, on the same server; only for a station its owner listed. */
|
|
158
|
+
listen?: string;
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
const MAX_NAME = 60;
|
|
@@ -233,13 +235,13 @@ export function parseAnnouncement(input: unknown): Announcement | null {
|
|
|
233
235
|
}
|
|
234
236
|
: {}),
|
|
235
237
|
...(Array.isArray(record["lineup"])
|
|
236
|
-
? { lineup: (record["lineup"] as unknown[]).map(parseLineupEntry).filter((one): one is LineupEntry => one !== null).slice(0, MAX_CHANNELS) }
|
|
238
|
+
? { lineup: (record["lineup"] as unknown[]).map((one) => parseLineupEntry(one, listen.origin)).filter((one): one is LineupEntry => one !== null).slice(0, MAX_CHANNELS) }
|
|
237
239
|
: {}),
|
|
238
240
|
};
|
|
239
241
|
}
|
|
240
242
|
|
|
241
243
|
/** One lineup entry as a publisher sent it, cleaned, or null when it is not one. */
|
|
242
|
-
export function parseLineupEntry(input: unknown): LineupEntry | null {
|
|
244
|
+
export function parseLineupEntry(input: unknown, origin = ""): LineupEntry | null {
|
|
243
245
|
if (typeof input !== "object" || input === null) return null;
|
|
244
246
|
const record = input as Record<string, unknown>;
|
|
245
247
|
const id = clean(record["id"], MAX_ID);
|
|
@@ -257,6 +259,12 @@ export function parseLineupEntry(input: unknown): LineupEntry | null {
|
|
|
257
259
|
// page in a tag, and javascript: is a page in a tag that runs.
|
|
258
260
|
art: publishable(art) !== null ? art : "",
|
|
259
261
|
about: clean(record["about"], MAX_ABOUT),
|
|
262
|
+
// The same server as the listing, or nothing, like the audio address.
|
|
263
|
+
...(() => {
|
|
264
|
+
const offered = typeof record["listen"] === "string" ? record["listen"].slice(0, MAX_ART) : "";
|
|
265
|
+
const parsed = offered && origin ? publishable(offered) : null;
|
|
266
|
+
return parsed !== null && parsed.origin === origin ? { listen: offered } : {};
|
|
267
|
+
})(),
|
|
260
268
|
};
|
|
261
269
|
}
|
|
262
270
|
|
package/src/library.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { stateDir } from "./daemon.ts";
|
|
|
23
23
|
export interface Config {
|
|
24
24
|
/** The folder the daemon serves when it is not told which. */
|
|
25
25
|
library?: string;
|
|
26
|
+
/** Whether this machine is listed in the public directory; unset until chosen. */
|
|
27
|
+
publish?: boolean;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export function configPath(): string {
|
|
@@ -34,7 +36,10 @@ export function readConfig(): Config {
|
|
|
34
36
|
const parsed = JSON.parse(readFileSync(configPath(), "utf8")) as unknown;
|
|
35
37
|
if (typeof parsed !== "object" || parsed === null) return {};
|
|
36
38
|
const record = parsed as Record<string, unknown>;
|
|
37
|
-
return {
|
|
39
|
+
return {
|
|
40
|
+
...(typeof record["library"] === "string" ? { library: record["library"] } : {}),
|
|
41
|
+
...(typeof record["publish"] === "boolean" ? { publish: record["publish"] } : {}),
|
|
42
|
+
};
|
|
38
43
|
} catch {
|
|
39
44
|
return {};
|
|
40
45
|
}
|