nixamp 0.29.1 → 0.30.3
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.d.ts +12 -0
- package/dist/channels.js +12 -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/mcp.d.ts +3 -0
- package/dist/mcp.js +7 -1
- package/dist/playlist-input.d.ts +10 -0
- package/dist/playlist-input.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-mcp.d.ts +294 -0
- package/dist/station-mcp.js +173 -0
- package/dist/station-page.d.ts +5 -0
- package/dist/station-page.js +138 -0
- package/dist/station-routes.d.ts +60 -0
- package/dist/station-routes.js +250 -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 +14 -1
- package/src/directory.ts +10 -2
- package/src/main.ts +8 -0
- package/src/mcp.ts +9 -1
- package/src/playlist-input.ts +9 -0
- package/src/server.ts +169 -7
- package/src/station-cli.ts +195 -0
- package/src/station-mcp.ts +183 -0
- package/src/station-page.ts +145 -0
- package/src/station-routes.ts +302 -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-ClCIWtVb.js} +1 -1
- package/web/dist/assets/{cs-DEPGOGEV.js → cs-CVG4U823.js} +1 -1
- package/web/dist/assets/{da-CQw7SPC9.js → da-igdiQUk8.js} +1 -1
- package/web/dist/assets/{de-OWnobbah.js → de-CHPsc6lc.js} +1 -1
- package/web/dist/assets/{es-qMpyH9bM.js → es-AvbMspQX.js} +1 -1
- package/web/dist/assets/{fi-DpqqwJTD.js → fi-5JGUOm3W.js} +1 -1
- package/web/dist/assets/{fr-BiO_ceen.js → fr-YfMwk1FR.js} +1 -1
- package/web/dist/assets/{hi-D-q5wZuY.js → hi-BUZuQTkp.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-D2W4K8VR.js → hls-3VKVEQE3-BCXawNaW.js} +1 -1
- package/web/dist/assets/{hu-FIjHyhVC.js → hu-C7on-yUl.js} +1 -1
- package/web/dist/assets/{id-gmB6lu56.js → id-pIrzVeOi.js} +1 -1
- package/web/dist/assets/{index-UmGvzuun.js → index-CDBIr8Tz.js} +2 -2
- package/web/dist/assets/{it-Dxfo713A.js → it-Bl9xk5B4.js} +1 -1
- package/web/dist/assets/{mpegts-BT4XGr_E.js → mpegts-63ylMmge.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CpZnlq0W.js → mpegts-LO6RVLD6-DBakOXU5.js} +1 -1
- package/web/dist/assets/{nl-mk5eN3Io.js → nl-b5BUih3i.js} +1 -1
- package/web/dist/assets/{ru-B2psU0kN.js → ru-BlAkbEPp.js} +1 -1
- package/web/dist/assets/{sv-Cg6waoNZ.js → sv-BEKvqELG.js} +1 -1
- package/web/dist/assets/{uk-BcFkukLd.js → uk-JY8Dh56p.js} +1 -1
- package/web/dist/assets/{vi-CnqIC1Sz.js → vi-DbtxuQPL.js} +1 -1
- package/web/dist/assets/{zh-CvnXIFvN.js → zh-4x0DI5jO.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
|
@@ -299,6 +299,11 @@ export class Channel {
|
|
|
299
299
|
skip(): boolean {
|
|
300
300
|
return this.playlistInput?.skip() ?? false;
|
|
301
301
|
}
|
|
302
|
+
|
|
303
|
+
/** An entry's tags, as read when it was put on. */
|
|
304
|
+
tagsOf(file: string): { title: string; artist: string; album: string } | undefined {
|
|
305
|
+
return this.playlistInput?.tags.get(file);
|
|
306
|
+
}
|
|
302
307
|
private failures = 0;
|
|
303
308
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
304
309
|
/** Fires when a pulled source has said nothing for STALL. */
|
|
@@ -1341,6 +1346,11 @@ export class Channels {
|
|
|
1341
1346
|
return this.open.get(id)?.info.kind;
|
|
1342
1347
|
}
|
|
1343
1348
|
|
|
1349
|
+
/** An entry's tags on a channel playing a list of files. */
|
|
1350
|
+
tagsOf(id: string, file: string): { title: string; artist: string; album: string } | undefined {
|
|
1351
|
+
return this.open.get(id)?.tagsOf(file);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1344
1354
|
/** The next entry, now, on a channel playing a list of files. */
|
|
1345
1355
|
skip(id: string): boolean {
|
|
1346
1356
|
return this.open.get(id)?.skip() ?? false;
|
|
@@ -1456,7 +1466,10 @@ export function rememberedChannels(dir: string, port: number): RememberedChannel
|
|
|
1456
1466
|
*/
|
|
1457
1467
|
export function rememberedNow(channels: Channels): RememberedChannel[] {
|
|
1458
1468
|
return channels.list()
|
|
1459
|
-
|
|
1469
|
+
// A station's channel is put back by its station, which knows whether it
|
|
1470
|
+
// is still wanted; remembering it here too would bring back one its
|
|
1471
|
+
// owner had stopped.
|
|
1472
|
+
.filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id) && !one.id.startsWith("station-"))
|
|
1460
1473
|
.map((one) => {
|
|
1461
1474
|
const kept: RememberedChannel = { id: one.id, name: one.name, source: one.source as string };
|
|
1462
1475
|
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);
|
package/src/mcp.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { describeRecord } from "./hash.ts";
|
|
|
30
30
|
import { describeFile, keepFile, type Described } from "./media-local.ts";
|
|
31
31
|
import { fileFingerprint, idFrom, languageCode, mediaOfUrl, transcriptIdOf } from "./transcripts.ts";
|
|
32
32
|
import { personaLines, readPersona, readVoices, writePersona } from "./profile.ts";
|
|
33
|
+
import { STATION_TOOLS, callStationTool, isStationTool, type StationToolOptions } from "./station-mcp.ts";
|
|
33
34
|
|
|
34
35
|
export const PROTOCOL_VERSION = "2025-06-18";
|
|
35
36
|
|
|
@@ -242,6 +243,7 @@ export const TOOLS: ToolDefinition[] = [
|
|
|
242
243
|
required: ["server"],
|
|
243
244
|
},
|
|
244
245
|
},
|
|
246
|
+
...STATION_TOOLS,
|
|
245
247
|
];
|
|
246
248
|
|
|
247
249
|
interface TrollboxLine {
|
|
@@ -257,6 +259,8 @@ function said(line: TrollboxLine): string {
|
|
|
257
259
|
|
|
258
260
|
export interface McpOptions {
|
|
259
261
|
fetcher?: typeof fetch;
|
|
262
|
+
/** Where stations are asked; the tests hand in a local daemon. */
|
|
263
|
+
stations?: StationToolOptions;
|
|
260
264
|
/** Injected by the tests; the session on disk otherwise. */
|
|
261
265
|
session?: { site: string; token: string } | null;
|
|
262
266
|
/** How a recording becomes a WAV; the tests hand in a fake. */
|
|
@@ -309,6 +313,10 @@ function describe(row: PartyRow): string {
|
|
|
309
313
|
* pipe, and so the same call is reachable from anywhere else that wants it.
|
|
310
314
|
*/
|
|
311
315
|
export async function callTool(name: string, args: Record<string, unknown>, options: McpOptions = {}): Promise<ToolResult> {
|
|
316
|
+
// Stations first: on this machine they need the daemon, not a sign-in.
|
|
317
|
+
if (isStationTool(name)) {
|
|
318
|
+
return callStationTool(name, args, { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) });
|
|
319
|
+
}
|
|
312
320
|
const session = options.session === undefined ? readSession() : options.session;
|
|
313
321
|
if (!session) {
|
|
314
322
|
return failed("This machine is not signed in to nixamp. Run `nixamp login`, or set NIXAMP_TOKEN.");
|
|
@@ -592,7 +600,7 @@ export async function handleMessage(message: Request, options: McpOptions = {}):
|
|
|
592
600
|
return reply({
|
|
593
601
|
protocolVersion: PROTOCOL_VERSION,
|
|
594
602
|
capabilities: { tools: { listChanged: false } },
|
|
595
|
-
serverInfo: { name: "nixamp", title: "nixamp: watch parties, rooms and transcripts", version: "2" },
|
|
603
|
+
serverInfo: { name: "nixamp", title: "nixamp: stations, watch parties, rooms and transcripts", version: "2" },
|
|
596
604
|
instructions:
|
|
597
605
|
"Watch parties on nixamp: a party lives on the site hosting the film and is bridged here as a room every nixamp client can join; codes are the ones that site shows, positions are seconds into the film. Rooms: say and read trollbox lines, hear a recording. Transcripts: a file, a link or a live is written down once by nixamp.com's own ear and kept under what it is; ask for it in another language and it is translated once and kept too.",
|
|
598
606
|
});
|
package/src/playlist-input.ts
CHANGED
|
@@ -53,6 +53,12 @@ export class PlaylistInputStream {
|
|
|
53
53
|
private entries: { at: number; file: string; start: number; position: number }[] = [];
|
|
54
54
|
error: Error | null = null;
|
|
55
55
|
complete = false;
|
|
56
|
+
/**
|
|
57
|
+
* What each entry's own tags say, read when it was probed to be played --
|
|
58
|
+
* so a station can say what is on before the library has got round to
|
|
59
|
+
* reading the file, which on a big library is a while.
|
|
60
|
+
*/
|
|
61
|
+
readonly tags = new Map<string, { title: string; artist: string; album: string }>();
|
|
56
62
|
constructor(private readonly options: PlaylistInput) {}
|
|
57
63
|
|
|
58
64
|
/** Every entry that has started, in the order it started, with where it sits in the stream. */
|
|
@@ -134,6 +140,9 @@ export class PlaylistInputStream {
|
|
|
134
140
|
const codecs = await codecsOf({ ffmpeg: o.ffmpeg, ffprobe: o.ffprobe, play: null }, file, [], AbortSignal.any([signal, AbortSignal.timeout(10_000)]));
|
|
135
141
|
if (signal.aborted) return;
|
|
136
142
|
if (!codecs.audio && !codecs.video) throw new Error(`Cannot decode playlist entry ${at + 1}`);
|
|
143
|
+
if (codecs.tags && (codecs.tags.title || codecs.tags.artist)) {
|
|
144
|
+
this.tags.set(file, { title: codecs.tags.title ?? "", artist: codecs.tags.artist ?? "", album: codecs.tags.album ?? "" });
|
|
145
|
+
}
|
|
137
146
|
const duration = (codecs.duration ?? 0) - position;
|
|
138
147
|
if (duration <= 0) throw new Error(`No playable duration for playlist entry ${at + 1}`);
|
|
139
148
|
this.entries.push({ at, file, start: offset, position });
|