nixamp 0.29.1 → 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/channels.js +4 -1
- package/dist/directory.d.ts +3 -1
- package/dist/directory.js +8 -2
- package/dist/main.d.ts +0 -8
- package/dist/main.js +9 -0
- package/dist/server.d.ts +27 -1
- package/dist/server.js +162 -6
- 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/channels.ts +4 -1
- package/src/directory.ts +10 -2
- package/src/main.ts +8 -0
- package/src/server.ts +169 -7
- 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-D6_ZFDan.js → ar-CTUDxy1J.js} +1 -1
- package/web/dist/assets/{cs-DEPGOGEV.js → cs-m4JTaO3o.js} +1 -1
- package/web/dist/assets/{da-CQw7SPC9.js → da-DHnJElYB.js} +1 -1
- package/web/dist/assets/{de-OWnobbah.js → de-DkxPohnf.js} +1 -1
- package/web/dist/assets/{es-qMpyH9bM.js → es-oY_G83d7.js} +1 -1
- package/web/dist/assets/{fi-DpqqwJTD.js → fi-fCO8XRdg.js} +1 -1
- package/web/dist/assets/{fr-BiO_ceen.js → fr-CSupf-IN.js} +1 -1
- package/web/dist/assets/{hi-D-q5wZuY.js → hi-Dve7PJsC.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-D2W4K8VR.js → hls-3VKVEQE3-D5-AkKSR.js} +1 -1
- package/web/dist/assets/{hu-FIjHyhVC.js → hu-D1KYamzV.js} +1 -1
- package/web/dist/assets/{id-gmB6lu56.js → id-Cofp_4aQ.js} +1 -1
- package/web/dist/assets/{index-UmGvzuun.js → index-7mCQZGNF.js} +2 -2
- package/web/dist/assets/{it-Dxfo713A.js → it-62iXQWQD.js} +1 -1
- package/web/dist/assets/{mpegts-BT4XGr_E.js → mpegts-BT5ESacM.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CpZnlq0W.js → mpegts-LO6RVLD6-BgHd8PFT.js} +1 -1
- package/web/dist/assets/{nl-mk5eN3Io.js → nl-CzRRG-CN.js} +1 -1
- package/web/dist/assets/{ru-B2psU0kN.js → ru-OykC1LJQ.js} +1 -1
- package/web/dist/assets/{sv-Cg6waoNZ.js → sv-B8FXBDHm.js} +1 -1
- package/web/dist/assets/{uk-BcFkukLd.js → uk-DhAJBjwH.js} +1 -1
- package/web/dist/assets/{vi-CnqIC1Sz.js → vi-CdBSRReC.js} +1 -1
- package/web/dist/assets/{zh-CvnXIFvN.js → zh-wGWNyBEZ.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -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/channels.ts
CHANGED
|
@@ -1456,7 +1456,10 @@ export function rememberedChannels(dir: string, port: number): RememberedChannel
|
|
|
1456
1456
|
*/
|
|
1457
1457
|
export function rememberedNow(channels: Channels): RememberedChannel[] {
|
|
1458
1458
|
return channels.list()
|
|
1459
|
-
|
|
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-"))
|
|
1460
1463
|
.map((one) => {
|
|
1461
1464
|
const kept: RememberedChannel = { id: one.id, name: one.name, source: one.source as string };
|
|
1462
1465
|
if (one.kind) kept.kind = one.kind;
|
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/main.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.ts"
|
|
|
7
7
|
*
|
|
8
8
|
* ffmpeg decodes; we read every sample on its way to the speakers and draw it.
|
|
9
9
|
*/
|
|
10
|
+
import { USAGE as STATION_USAGE } from "./station-usage.ts";
|
|
10
11
|
import { createApp, themes, type BrailleCanvas, type Container, type KeyEvent, type Theme } from "@profullstack/hqtui";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { resolve } from "node:path";
|
|
@@ -232,6 +233,7 @@ keeps serving its browser remote. One per user.
|
|
|
232
233
|
|
|
233
234
|
nixamp attach put the player back in front of it
|
|
234
235
|
nixamp admin who is connected, and re-stream to them
|
|
236
|
+
nixamp station make a folder into a station others can join (see: nixamp station help)
|
|
235
237
|
|
|
236
238
|
From inside the player, d hands the music to a daemon without stopping it.
|
|
237
239
|
`,
|
|
@@ -394,6 +396,7 @@ q or d leaves; neither stops anything. \`nixamp daemon stop\` is what stops it.
|
|
|
394
396
|
};
|
|
395
397
|
|
|
396
398
|
export function helpFor(topic: string | undefined): string {
|
|
399
|
+
if (topic === "station" || topic === "stations") return STATION_USAGE;
|
|
397
400
|
return (topic ? TOPICS[topic] : undefined) ?? HELP;
|
|
398
401
|
}
|
|
399
402
|
|
|
@@ -553,6 +556,11 @@ export async function main(): Promise<void> {
|
|
|
553
556
|
process.exitCode = await runDaemon(rest);
|
|
554
557
|
return;
|
|
555
558
|
}
|
|
559
|
+
if (first === "station" || first === "stations") {
|
|
560
|
+
const { station } = await import("./station-cli.ts");
|
|
561
|
+
process.exitCode = await station(rest);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
556
564
|
if (first === "admin") {
|
|
557
565
|
const { admin } = await import("./admin.ts");
|
|
558
566
|
await admin(rest);
|