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.
Files changed (78) hide show
  1. package/dist/audio.js +3 -0
  2. package/dist/channels.d.ts +14 -0
  3. package/dist/channels.js +20 -4
  4. package/dist/decoder-policy.d.ts +20 -0
  5. package/dist/decoder-policy.js +34 -0
  6. package/dist/destinations.d.ts +5 -0
  7. package/dist/destinations.js +82 -0
  8. package/dist/directory.d.ts +3 -1
  9. package/dist/directory.js +8 -2
  10. package/dist/library.d.ts +2 -0
  11. package/dist/library.js +4 -1
  12. package/dist/links.d.ts +4 -0
  13. package/dist/links.js +20 -2
  14. package/dist/main.d.ts +0 -8
  15. package/dist/main.js +9 -0
  16. package/dist/playlist-input.d.ts +46 -0
  17. package/dist/playlist-input.js +158 -54
  18. package/dist/publish-choice.d.ts +21 -0
  19. package/dist/publish-choice.js +17 -0
  20. package/dist/server.d.ts +27 -1
  21. package/dist/server.js +228 -17
  22. package/dist/share.d.ts +1 -1
  23. package/dist/share.js +5 -4
  24. package/dist/station-cli.d.ts +3 -0
  25. package/dist/station-cli.js +201 -0
  26. package/dist/station-page.d.ts +5 -0
  27. package/dist/station-page.js +137 -0
  28. package/dist/station-routes.d.ts +60 -0
  29. package/dist/station-routes.js +249 -0
  30. package/dist/station-runner.d.ts +67 -0
  31. package/dist/station-runner.js +219 -0
  32. package/dist/station-usage.d.ts +2 -0
  33. package/dist/station-usage.js +20 -0
  34. package/dist/stations.d.ts +148 -0
  35. package/dist/stations.js +331 -0
  36. package/package.json +1 -1
  37. package/src/audio.ts +3 -0
  38. package/src/channels.ts +30 -5
  39. package/src/decoder-policy.ts +33 -0
  40. package/src/destinations.ts +78 -0
  41. package/src/directory.ts +10 -2
  42. package/src/library.ts +6 -1
  43. package/src/links.ts +23 -4
  44. package/src/main.ts +8 -0
  45. package/src/playlist-input.ts +172 -50
  46. package/src/publish-choice.ts +35 -0
  47. package/src/server.ts +227 -18
  48. package/src/share.ts +5 -4
  49. package/src/station-cli.ts +195 -0
  50. package/src/station-page.ts +144 -0
  51. package/src/station-routes.ts +301 -0
  52. package/src/station-runner.ts +244 -0
  53. package/src/station-usage.ts +20 -0
  54. package/src/stations.ts +407 -0
  55. package/web/dist/assets/{ar-8fDonUoB.js → ar-CTUDxy1J.js} +1 -1
  56. package/web/dist/assets/{cs-DCClolTy.js → cs-m4JTaO3o.js} +1 -1
  57. package/web/dist/assets/{da-C3RPhXUd.js → da-DHnJElYB.js} +1 -1
  58. package/web/dist/assets/{de-CYBoEomk.js → de-DkxPohnf.js} +1 -1
  59. package/web/dist/assets/{es-DS3ls4xT.js → es-oY_G83d7.js} +1 -1
  60. package/web/dist/assets/{fi-DbCZpO89.js → fi-fCO8XRdg.js} +1 -1
  61. package/web/dist/assets/{fr-D0z3UpdL.js → fr-CSupf-IN.js} +1 -1
  62. package/web/dist/assets/{hi-BTU3sRlQ.js → hi-Dve7PJsC.js} +1 -1
  63. package/web/dist/assets/{hls-3VKVEQE3-CQXTVHn_.js → hls-3VKVEQE3-D5-AkKSR.js} +1 -1
  64. package/web/dist/assets/{hu-B37Q8ldT.js → hu-D1KYamzV.js} +1 -1
  65. package/web/dist/assets/{id-4pdXNa4G.js → id-Cofp_4aQ.js} +1 -1
  66. package/web/dist/assets/index-7mCQZGNF.js +22 -0
  67. package/web/dist/assets/{it-CTe69-UY.js → it-62iXQWQD.js} +1 -1
  68. package/web/dist/assets/{mpegts-BBmkDppm.js → mpegts-BT5ESacM.js} +1 -1
  69. package/web/dist/assets/{mpegts-LO6RVLD6-CUVdjYFi.js → mpegts-LO6RVLD6-BgHd8PFT.js} +1 -1
  70. package/web/dist/assets/{nl-CxrmHrMn.js → nl-CzRRG-CN.js} +1 -1
  71. package/web/dist/assets/{ru-zjVCfIKq.js → ru-OykC1LJQ.js} +1 -1
  72. package/web/dist/assets/{sv-CEoiT2Lr.js → sv-B8FXBDHm.js} +1 -1
  73. package/web/dist/assets/{uk-ZC6JzQvD.js → uk-DhAJBjwH.js} +1 -1
  74. package/web/dist/assets/{vi-D9anF-cl.js → vi-CdBSRReC.js} +1 -1
  75. package/web/dist/assets/{zh-BeLYdC0I.js → zh-wGWNyBEZ.js} +1 -1
  76. package/web/dist/index.html +1 -1
  77. package/web/dist/sw.js +22 -22
  78. package/web/dist/assets/index-BDBbN5Qy.js +0 -22
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.28.45",
3
+ "version": "0.30.1",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- .filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id))
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 { ...(typeof record["library"] === "string" ? { library: record["library"] } : {}) };
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
  }