nixamp 0.7.4 → 0.7.6

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/README.md CHANGED
@@ -354,8 +354,14 @@ anywhere else with `--url` and `--key`.
354
354
  ╰───────────────────────────────────────────────────────────────────────╯
355
355
  ```
356
356
 
357
- Press `r` to re-stream: hand the running server a different URL or path and the
358
- listeners stay connected while what they are hearing changes under them.
357
+ Press `a` to add: hand the running server a folder, an album URL or a file and
358
+ it joins the playlist under its own heading, with the library still there and
359
+ the listeners still connected. Press `r` to replace instead, which is the
360
+ bigger thing — this server now serves that, and the library it had is gone
361
+ until you restart it.
362
+
363
+ An added block can be taken back out from the playlist itself: its heading
364
+ carries an `×`.
359
365
 
360
366
  ## How it works
361
367
 
package/dist/admin.d.ts CHANGED
@@ -68,6 +68,8 @@ export interface View {
68
68
  error: string;
69
69
  typing: boolean;
70
70
  restreaming: string;
71
+ /** Whether what is being typed replaces the playlist rather than joining it. */
72
+ replacing?: boolean;
71
73
  /** Labelled addresses, and the key that makes them work. */
72
74
  links: {
73
75
  label: string;
package/dist/admin.js CHANGED
@@ -110,6 +110,9 @@ export async function admin(argv) {
110
110
  let snapshot = null;
111
111
  let error = "";
112
112
  let restreaming = "";
113
+ // Which of the two things typing a source means. Adding is the ordinary one
114
+ // and has its own key; replacing throws the library away, so it has another.
115
+ let replacing = false;
113
116
  let typing = false;
114
117
  const app = await createApp({ theme: themes.matrix, title: "nixamp admin", quitKeys: ["ctrl+c"] });
115
118
  const refresh = async () => {
@@ -129,13 +132,16 @@ export async function admin(argv) {
129
132
  if (key === "escape") {
130
133
  typing = false;
131
134
  restreaming = "";
135
+ replacing = false;
132
136
  }
133
137
  else if (key === "enter") {
134
138
  const url = restreaming.trim();
139
+ const asReplacement = replacing;
135
140
  typing = false;
136
141
  restreaming = "";
142
+ replacing = false;
137
143
  if (url)
138
- void restream(target, headers, url).then(() => refresh());
144
+ void restream(target, headers, url, asReplacement).then(() => refresh());
139
145
  }
140
146
  else if (key === "backspace")
141
147
  restreaming = restreaming.slice(0, -1);
@@ -148,26 +154,38 @@ export async function admin(argv) {
148
154
  app.quit();
149
155
  return;
150
156
  }
157
+ if (key === "a") {
158
+ typing = true;
159
+ replacing = false;
160
+ app.invalidate();
161
+ }
151
162
  if (key === "r") {
152
163
  typing = true;
164
+ replacing = true;
153
165
  app.invalidate();
154
166
  }
155
167
  });
156
168
  app.on("exit", () => clearInterval(timer));
157
169
  app.render(({ ui, theme }) => draw(ui, theme, {
158
- url: target.url, report, snapshot, error, typing, restreaming,
170
+ url: target.url, report, snapshot, error, typing, restreaming, replacing,
159
171
  links: target.links, key: target.key, source: target.source,
160
172
  }));
161
173
  await app.start();
162
174
  clearInterval(timer);
163
175
  }
164
- /** Ask the server to play something else, which is what re-streaming is. */
165
- async function restream(target, headers, url) {
176
+ /**
177
+ * Hand the server something else to play.
178
+ *
179
+ * Two different asks down one route: adding puts an album on the end of the
180
+ * playlist, replacing points the server somewhere else entirely. The server
181
+ * adds unless told otherwise, so only the second one says anything.
182
+ */
183
+ async function restream(target, headers, url, replacing = false) {
166
184
  try {
167
185
  await fetch(`${target.url}/api/source`, {
168
186
  method: "POST",
169
187
  headers: { ...headers, "content-type": "application/json" },
170
- body: JSON.stringify({ source: url }),
188
+ body: JSON.stringify({ source: url, ...(replacing ? { replace: true } : {}) }),
171
189
  });
172
190
  }
173
191
  catch {
@@ -249,14 +267,20 @@ export function draw(ui, theme, view) {
249
267
  });
250
268
  });
251
269
  if (view.typing) {
252
- ui.panel({ title: "Re-stream a URL or a path", size: 4 }, (p) => {
270
+ ui.panel({
271
+ title: view.replacing ? "Replace the playlist with a URL or a path" : "Add a URL or a path",
272
+ size: 4,
273
+ }, (p) => {
253
274
  p.text(`${view.restreaming}_`, { fg: theme.accent });
254
- p.label("Enter plays it here. Escape forgets it.");
275
+ p.label(view.replacing
276
+ ? "Enter drops this library and serves that instead. Escape forgets it."
277
+ : "Enter adds it to the playlist. Escape forgets it.");
255
278
  });
256
279
  }
257
280
  ui.statusBar({
258
281
  items: [
259
- { key: "r", label: "Re-stream" },
282
+ { key: "a", label: "Add" },
283
+ { key: "r", label: "Replace" },
260
284
  { key: "q", label: "Quit" },
261
285
  ],
262
286
  right: [{ key: "", label: report ? `${report.connections.length} seen` : "connecting" }],
package/dist/audio.d.ts CHANGED
@@ -78,6 +78,17 @@ export interface Codecs {
78
78
  video: string;
79
79
  /** e.g. "aac", "ac3", "dts". Empty when there is no audio stream. */
80
80
  audio: string;
81
+ /**
82
+ * What is wrapped around them: "mpegts", "matroska,webm", "mov,mp4,...".
83
+ *
84
+ * It matters for one reason. A transport stream -- which is what every IPTV
85
+ * channel is -- frames its AAC as ADTS, and copying that into MP4 needs a
86
+ * bitstream filter or ffmpeg refuses the whole muxing and writes nothing.
87
+ * They also tend to carry several audio tracks, so the one ffmpeg picks can
88
+ * be AC-3 on the same URL that offered AAC a minute earlier, and AC-3 in MP4
89
+ * is a track no browser will play.
90
+ */
91
+ container: string;
81
92
  }
82
93
  /**
83
94
  * Ask ffprobe what the streams are, without holding the event loop.
package/dist/audio.js CHANGED
@@ -284,7 +284,7 @@ function readTags(stdout, fallback) {
284
284
  */
285
285
  export async function codecsOf(tools, path) {
286
286
  const [cmd, ...rest] = tools.ffprobe;
287
- const empty = { video: "", audio: "" };
287
+ const empty = { video: "", audio: "", container: "" };
288
288
  if (!cmd)
289
289
  return empty;
290
290
  return new Promise((done) => {
@@ -292,7 +292,7 @@ export async function codecsOf(tools, path) {
292
292
  ...rest,
293
293
  "-v", "quiet",
294
294
  "-print_format", "json",
295
- "-show_entries", "stream=codec_type,codec_name",
295
+ "-show_entries", "format=format_name:stream=codec_type,codec_name",
296
296
  path,
297
297
  ], { stdio: ["ignore", "pipe", "ignore"] });
298
298
  let out = "";
@@ -307,6 +307,7 @@ export async function codecsOf(tools, path) {
307
307
  return done({
308
308
  video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
309
309
  audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
310
+ container: parsed.format?.format_name ?? "",
310
311
  });
311
312
  }
312
313
  catch {
@@ -331,7 +332,13 @@ export function videoArgs(codecs, capKbps = 0) {
331
332
  return cappedArgs(capKbps);
332
333
  // What a browser can play inside MP4 without help.
333
334
  const keepVideo = codecs.video === "h264";
334
- const keepAudio = codecs.audio === "aac" || codecs.audio === "mp3";
335
+ // A transport stream's audio is never copied. Its AAC is ADTS-framed, which
336
+ // MP4 refuses without a bitstream filter -- ffmpeg writes nothing at all and
337
+ // says "Malformed AAC bitstream detected" -- and the track ffmpeg picks off
338
+ // a channel with several of them can be AC-3, which that filter rejects and
339
+ // no browser plays. Re-encoding audio is cheap; this failing is total.
340
+ const transportStream = codecs.container.includes("mpegts");
341
+ const keepAudio = !transportStream && (codecs.audio === "aac" || codecs.audio === "mp3");
335
342
  return [
336
343
  "-c:v", keepVideo ? "copy" : "libx264",
337
344
  ...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
package/dist/daemon.d.ts CHANGED
@@ -27,6 +27,15 @@ export interface DaemonState {
27
27
  * interface, so it names the router and not this port.
28
28
  */
29
29
  guessedPublic?: boolean;
30
+ /**
31
+ * What it was started with, so it can be started that way again.
32
+ *
33
+ * A daemon serving TLS on a public name is six flags, and restarting it
34
+ * meant finding them again -- from shell history, or from `ps`, or not at
35
+ * all. Absent on a state file written by an older nixamp, which is why
36
+ * `restart` says so rather than starting something different.
37
+ */
38
+ argv?: string[];
30
39
  }
31
40
  /** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
32
41
  export declare function stateDir(): string;
@@ -75,5 +84,16 @@ export declare function daemonLines(state: DaemonState, uptimeMs?: number): stri
75
84
  * log file is the thing this is meant to avoid.
76
85
  */
77
86
  export declare function start(argv: string[], entry: string): Promise<DaemonState>;
87
+ /**
88
+ * Stop it and start it again, the way it was started.
89
+ *
90
+ * The flags are replayed from the state file rather than retyped, because the
91
+ * interesting daemons are the ones with the most flags: a certificate, a key,
92
+ * a public URL. Given arguments of its own it uses those instead, which is how
93
+ * you change one thing without stopping and starting by hand.
94
+ */
95
+ export declare function restart(argv: string[], entry: string,
96
+ /** Injected so a test can see which arguments would be replayed. */
97
+ starter?: typeof start): Promise<DaemonState>;
78
98
  /** Stop it, and wait for it to actually be gone. */
79
99
  export declare function stop(timeoutMs?: number): Promise<boolean>;
package/dist/daemon.js CHANGED
@@ -178,10 +178,30 @@ export async function start(argv, entry) {
178
178
  }
179
179
  throw new Error(`nixamp: the daemon did not start. See ${log}`);
180
180
  }
181
- const state = { ...announced, pid: child.pid, startedAt: Date.now(), log };
181
+ const state = { ...announced, pid: child.pid, startedAt: Date.now(), log, argv };
182
182
  writeState(state);
183
183
  return state;
184
184
  }
185
+ /**
186
+ * Stop it and start it again, the way it was started.
187
+ *
188
+ * The flags are replayed from the state file rather than retyped, because the
189
+ * interesting daemons are the ones with the most flags: a certificate, a key,
190
+ * a public URL. Given arguments of its own it uses those instead, which is how
191
+ * you change one thing without stopping and starting by hand.
192
+ */
193
+ export async function restart(argv, entry,
194
+ /** Injected so a test can see which arguments would be replayed. */
195
+ starter = start) {
196
+ const { state } = status();
197
+ const before = state?.argv;
198
+ if (argv.length === 0 && before === undefined && state !== null) {
199
+ throw new Error("nixamp: this daemon was started by an older nixamp, which did not record its flags. " +
200
+ "Stop it and start it again with the flags you want.");
201
+ }
202
+ await stop();
203
+ return starter(argv.length > 0 ? argv : (before ?? []), entry);
204
+ }
185
205
  /** Poll the log for the announce line. */
186
206
  async function waitForAnnounce(log, timeoutMs) {
187
207
  const deadline = Date.now() + timeoutMs;
package/dist/invite.d.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  * Asking somebody to watch, when that somebody is not technical.
3
3
  *
4
4
  * A share link is a URL with a key in it, which is fine for the person who
5
- * runs the server and useless as a thing to text your mother. An invite is the
6
- * three ways in, written as a sentence: a link that opens a player, a phone
7
- * number, and the code to key once it answers.
5
+ * runs the server and useless as a thing to text your mother. An invite is two
6
+ * things written as a sentence: a link that opens a player, and a phone number
7
+ * with a code, which is the line where everyone watching talks to each other.
8
+ * The phone is not another way to hear the stream -- it is the 800 number
9
+ * beside a podcast. The show is on the screen; the call is the company.
8
10
  *
9
11
  * The sender is signed in, because sending is an action with a cost: a text
10
12
  * message is money and somebody's phone. The recipient signs in too, but only
@@ -21,7 +23,7 @@ export interface Invite {
21
23
  link: string;
22
24
  /** The phone number, when this stream is one the line knows about. */
23
25
  phone: string;
24
- /** The six digits that reach this stream, when it has been published. */
26
+ /** The six digits that reach this stream's room, once it has been published. */
25
27
  code: string;
26
28
  }
27
29
  /** Looks like a phone number rather than an address. */
package/dist/invite.js CHANGED
@@ -2,9 +2,11 @@
2
2
  * Asking somebody to watch, when that somebody is not technical.
3
3
  *
4
4
  * A share link is a URL with a key in it, which is fine for the person who
5
- * runs the server and useless as a thing to text your mother. An invite is the
6
- * three ways in, written as a sentence: a link that opens a player, a phone
7
- * number, and the code to key once it answers.
5
+ * runs the server and useless as a thing to text your mother. An invite is two
6
+ * things written as a sentence: a link that opens a player, and a phone number
7
+ * with a code, which is the line where everyone watching talks to each other.
8
+ * The phone is not another way to hear the stream -- it is the 800 number
9
+ * beside a podcast. The show is on the screen; the call is the company.
8
10
  *
9
11
  * The sender is signed in, because sending is an action with a cost: a text
10
12
  * message is money and somebody's phone. The recipient signs in too, but only
@@ -32,7 +34,10 @@ export function isEmail(value) {
32
34
  export function inviteText(invite) {
33
35
  const lines = [`${invite.name} is streaming.`, "", `Watch: ${invite.link}`];
34
36
  if (invite.phone && invite.code) {
35
- lines.push("", `Or call ${invite.phone} and key ${invite.code} to listen.`);
37
+ // "to talk about it", not "to listen": the line is a room full of the
38
+ // other people watching, and telling somebody they will hear the stream
39
+ // down the phone is telling them something that is not true.
40
+ lines.push("", `To talk about it: call ${invite.phone} and key ${invite.code}.`);
36
41
  }
37
42
  return lines.join("\n");
38
43
  }
package/dist/main.js CHANGED
@@ -45,7 +45,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
45
45
 
46
46
  nixamp [source] play it in the terminal
47
47
  nixamp serve [source] [options] play here, and hand out a browser remote
48
- nixamp daemon start|stop|status serve in the background, and let go of it
48
+ nixamp daemon start|restart|stop|status serve in the background, and let go of it
49
49
  nixamp attach put the player back in front of the daemon
50
50
  nixamp admin [--url U] [--key K] who is connected, and re-stream to them
51
51
  nixamp login [--with github] sign in to nixamp.com, in a browser or here
@@ -162,9 +162,13 @@ Signing out does not touch it: that is what it is for.
162
162
  daemon: `nixamp daemon — a nixamp that outlives the terminal that started it.
163
163
 
164
164
  nixamp daemon start [source] [serve options] start it, detached
165
+ nixamp daemon restart [source] [serve options] stop it and start it again
165
166
  nixamp daemon status where it is, and how long
166
167
  nixamp daemon stop stop it
167
168
 
169
+ Restart with no arguments replays the ones it was started with, certificate
170
+ and public URL included, so picking up a new version costs one command.
171
+
168
172
  It is \`nixamp serve\` with nobody holding its terminal, so it keeps playing and
169
173
  keeps serving its browser remote. One per user.
170
174
 
@@ -228,6 +232,18 @@ async function runDaemon(argv) {
228
232
  console.log(stopped ? "nixamp daemon stopped" : "nixamp: no daemon was running");
229
233
  return 0;
230
234
  }
235
+ if (action === "restart") {
236
+ try {
237
+ const state = await d.restart(rest, entry);
238
+ for (const line of d.daemonLines(state))
239
+ console.log(line);
240
+ return 0;
241
+ }
242
+ catch (error) {
243
+ console.error(error.message);
244
+ return 1;
245
+ }
246
+ }
231
247
  if (action === "status") {
232
248
  const { running, state } = d.status();
233
249
  if (!state) {
@@ -253,7 +269,7 @@ async function runDaemon(argv) {
253
269
  const { attach } = await import("./attach.js");
254
270
  return attach(rest);
255
271
  }
256
- console.error(`nixamp daemon: unknown action ${action}. Try start, stop, status or attach.`);
272
+ console.error(`nixamp daemon: unknown action ${action}. Try start, restart, stop, status or attach.`);
257
273
  return 64;
258
274
  }
259
275
  /**
@@ -134,15 +134,6 @@ export declare class PartyLine {
134
134
  add: (code: string, phone: string) => void;
135
135
  take: (code: string) => Promise<string[]>;
136
136
  }, waiting?: ReadonlyMap<string, ReadonlySet<string>>): void;
137
- /**
138
- * Legs listening to a stream, by its code.
139
- *
140
- * Separate from the rooms because a stream listener is not in a conference:
141
- * they are a leg with an MP3 playing into it. Nothing else was counting
142
- * them, so the directory had no way to say how many people were on the
143
- * phone for a broadcast.
144
- */
145
- private readonly streamLegs;
146
137
  private readonly key;
147
138
  private readonly fetch;
148
139
  private readonly now;
@@ -199,6 +190,14 @@ export declare class PartyLine {
199
190
  * room code still works: this line was a party line before it was a way into
200
191
  * a broadcast, and a code that means nothing to the directory should still
201
192
  * mean a room.
193
+ *
194
+ * Keying a stream's code puts you in a room with the other people watching
195
+ * it. It does not play the stream at you, which is what it used to do: this
196
+ * is the phone line beside a broadcast, the way a podcast has an 800 number
197
+ * -- the show is on your screen and the phone is where you talk about it.
198
+ * Playing the audio down the phone was both the worse half of the idea and
199
+ * the one that kept failing, because a share link answers a 302 and a cookie
200
+ * rather than an MP3.
202
201
  */
203
202
  private stream;
204
203
  /** Whether the caller took the reminder that was offered. */
@@ -220,7 +219,13 @@ export declare class PartyLine {
220
219
  /** Put a leg into a room, making the conference if it is the first one there. */
221
220
  private join;
222
221
  private enter;
223
- /** How many people are listening to a stream by phone. */
222
+ /**
223
+ * How many people are on the phone for a stream.
224
+ *
225
+ * The room's own count, now that a stream's code is a room like any other.
226
+ * It used to count legs with an MP3 playing into them, which is a thing that
227
+ * no longer happens.
228
+ */
224
229
  listenersOn(code: string): number;
225
230
  /** A leg that hung up or was dropped, wherever it was. */
226
231
  private release;
package/dist/partyline.js CHANGED
@@ -155,15 +155,6 @@ export class PartyLine {
155
155
  this.reminders.set(code, set);
156
156
  }
157
157
  }
158
- /**
159
- * Legs listening to a stream, by its code.
160
- *
161
- * Separate from the rooms because a stream listener is not in a conference:
162
- * they are a leg with an MP3 playing into it. Nothing else was counting
163
- * them, so the directory had no way to say how many people were on the
164
- * phone for a broadcast.
165
- */
166
- streamLegs = new Map();
167
158
  key;
168
159
  fetch;
169
160
  now;
@@ -318,6 +309,14 @@ export class PartyLine {
318
309
  * room code still works: this line was a party line before it was a way into
319
310
  * a broadcast, and a code that means nothing to the directory should still
320
311
  * mean a room.
312
+ *
313
+ * Keying a stream's code puts you in a room with the other people watching
314
+ * it. It does not play the stream at you, which is what it used to do: this
315
+ * is the phone line beside a broadcast, the way a podcast has an 800 number
316
+ * -- the show is on your screen and the phone is where you talk about it.
317
+ * Playing the audio down the phone was both the worse half of the idea and
318
+ * the one that kept failing, because a share link answers a 302 and a cookie
319
+ * rather than an MP3.
321
320
  */
322
321
  async stream(leg, code) {
323
322
  const streams = this.options.streams;
@@ -326,39 +325,12 @@ export class PartyLine {
326
325
  const live = streams.liveByCode(code);
327
326
  if (live !== undefined) {
328
327
  const what = live.nowPlaying ? ` of ${live.nowPlaying}` : "";
329
- // The share link is not playable. It answers 302 with a cookie and sends
330
- // a browser to the player page; Telnyx fetches once with no cookie jar
331
- // and gets a 401 in JSON. Playing it means a caller who is told "here it
332
- // is" and then hears nothing at all, which is how this was found. Say
333
- // what is true instead, and hang up rather than bill for silence.
334
- if (!live.audio) {
335
- await this.command(leg, "speak", {
336
- payload: `${live.name} is live right now${what}, but this stream cannot be played over the phone. ` +
337
- "You can listen to it at nixamp dot com slash directory. Goodbye.",
338
- voice: this.voice,
339
- });
340
- await this.command(leg, "hangup", {});
341
- this.options.onEvent?.(` ${code} is live but announced no audio address; nothing to play.`);
342
- return true;
343
- }
344
328
  await this.command(leg, "speak", {
345
- payload: `Welcome to ${live.name}'s live stream${what}. It started at ${pacificTime(live.startedAt)}. Here it is.`,
329
+ payload: `You're on the line for ${live.name}${what}. ` +
330
+ "Everyone here is watching it too. Say hello.",
346
331
  voice: this.voice,
347
332
  });
348
- // A nixamp stream is an MP3 over HTTP and Telnyx will play a URL into a
349
- // call, so listening by phone costs no audio handling here at all.
350
- const playing = await this.command(leg, "playback_start", {
351
- audio_url: live.audio,
352
- loop: "infinity",
353
- });
354
- // Counted only once the audio is actually going. A leg we failed to
355
- // start is not somebody listening, and the directory would be saying so.
356
- if (playing) {
357
- const legs = this.streamLegs.get(code) ?? new Set();
358
- legs.add(leg);
359
- this.streamLegs.set(code, legs);
360
- this.options.onEvent?.(` a caller is listening to ${code} (${legs.size} on the phone).`);
361
- }
333
+ await this.join(leg, code);
362
334
  return true;
363
335
  }
364
336
  const ended = streams.endedByCode(code);
@@ -490,16 +462,18 @@ export class PartyLine {
490
462
  room.callers = room.legs.size;
491
463
  this.options.onEvent?.(` a caller joined a room (${room.callers} on the line).`);
492
464
  }
493
- /** How many people are listening to a stream by phone. */
465
+ /**
466
+ * How many people are on the phone for a stream.
467
+ *
468
+ * The room's own count, now that a stream's code is a room like any other.
469
+ * It used to count legs with an MP3 playing into them, which is a thing that
470
+ * no longer happens.
471
+ */
494
472
  listenersOn(code) {
495
- return this.streamLegs.get(code)?.size ?? 0;
473
+ return this.rooms.get(code)?.callers ?? 0;
496
474
  }
497
475
  /** A leg that hung up or was dropped, wherever it was. */
498
476
  release(leg) {
499
- for (const [code, legs] of this.streamLegs) {
500
- if (legs.delete(leg) && legs.size === 0)
501
- this.streamLegs.delete(code);
502
- }
503
477
  const code = this.legRoom.get(leg);
504
478
  this.legRoom.delete(leg);
505
479
  if (code === undefined)
@@ -20,6 +20,14 @@ export interface RemoteTrack {
20
20
  * a browser could show played its soundtrack over a blank panel.
21
21
  */
22
22
  video?: boolean;
23
+ /**
24
+ * The source this track came in with, when it was not part of the library.
25
+ *
26
+ * Absent means it belongs to whatever this server was started on. Present
27
+ * means somebody added a folder or an album afterwards, and the name is what
28
+ * a client puts at the top of that block so the two are not one soup.
29
+ */
30
+ group?: string;
23
31
  }
24
32
  /** Everything a remote needs to draw the player. */
25
33
  export interface Snapshot {
package/dist/server.d.ts CHANGED
@@ -124,6 +124,27 @@ export declare function parseRange(header: string | undefined, size: number): By
124
124
  * `..` in a request path is the oldest bug in static file serving.
125
125
  */
126
126
  export declare function safeJoin(rootDir: string, urlPath: string): string | null;
127
+ /**
128
+ * A track and the source it arrived with.
129
+ *
130
+ * The library a server was started on has no group: it is simply what this
131
+ * machine has. Anything added afterwards carries the name of the folder or
132
+ * album it came from, which is what lets a client draw the two apart instead
133
+ * of running them together.
134
+ */
135
+ export type Loaded = Track & {
136
+ group?: string;
137
+ /**
138
+ * Whether this has a picture, when the name could not say.
139
+ *
140
+ * A file on disk is named `film.mkv` and that is answer enough. A live
141
+ * stream is `http://host/tipoffsport/KEY/301`, which says nothing at all --
142
+ * so it was treated as audio, transcoded with `-vn`, and arrived as a
143
+ * football match somebody could only listen to. Asked of ffprobe once, when
144
+ * the source is added, rather than guessed from a URL that has no opinion.
145
+ */
146
+ picture?: boolean;
147
+ };
127
148
  /** What the HTTP layer needs from a player. Tests hand it a fake. */
128
149
  export interface Engine {
129
150
  /** `withTracks` false leaves the library out, for a frame that is only motion. */
@@ -133,11 +154,29 @@ export interface Engine {
133
154
  /** Absolute path of a track, or undefined when the index is not one. */
134
155
  trackPath(index: number): string | undefined;
135
156
  /**
136
- * Play something else instead. Re-streaming is the whole reason the admin
137
- * view exists: point a running server at a URL without restarting it and
138
- * dropping every listener.
157
+ * Play something else instead of everything here.
158
+ *
159
+ * The big hammer, and no longer what adding a folder does: this is "point
160
+ * this server somewhere else", which throws the library away on purpose.
139
161
  */
140
162
  replace(tracks: Track[], root: string): void;
163
+ /**
164
+ * Play something as well as everything here.
165
+ *
166
+ * What somebody means by putting a folder in a box: the album shows up at
167
+ * the bottom of the playlist under its own name, and the music that was
168
+ * already there is still there. Answers how many tracks were new.
169
+ */
170
+ add(tracks: Track[], from: string): number;
171
+ /**
172
+ * Take an added source back out again, by the name `add` gave it.
173
+ *
174
+ * Nothing that came with the library can be dropped this way; the library is
175
+ * what the server is, and there is a command line for changing that.
176
+ */
177
+ drop(group: string): number;
178
+ /** Every added source, in the order they were added. */
179
+ groups(): string[];
141
180
  /**
142
181
  * The same tracks, now with their tags.
143
182
  *
@@ -150,8 +189,16 @@ export interface Engine {
150
189
  retag(tracks: Track[], root: string): void;
151
190
  stop(): void;
152
191
  }
153
- export declare function toRemoteTracks(tracks: Track[]): RemoteTrack[];
192
+ export declare function toRemoteTracks(tracks: Loaded[]): RemoteTrack[];
154
193
  export declare function hasPicture(path: string): boolean;
194
+ /**
195
+ * Whether the name of a source tells us anything about what is inside it.
196
+ *
197
+ * A remote address with no extension -- an IPTV channel, a stream key, a
198
+ * redirect -- is the case where it does not, and the only way to find out is
199
+ * to look.
200
+ */
201
+ export declare function nameSaysNothing(path: string): boolean;
155
202
  /**
156
203
  * The headless player: the terminal app's engine without the terminal.
157
204
  * One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
@@ -172,7 +219,7 @@ export declare class PlayerEngine implements Engine {
172
219
  private timer;
173
220
  private dirty;
174
221
  private state;
175
- constructor(tracks: Track[], root: string, tools: Tools,
222
+ constructor(tracks: Loaded[], root: string, tools: Tools,
176
223
  /** Frames a second pushed to remotes. */
177
224
  fps?: number);
178
225
  private readonly silent;
@@ -199,6 +246,30 @@ export declare class PlayerEngine implements Engine {
199
246
  private push;
200
247
  stop(): void;
201
248
  replace(tracks: Track[], root: string): void;
249
+ /**
250
+ * Load something as well as what is already here.
251
+ *
252
+ * Adding a folder used to be `replace`, so pointing a server at an album on
253
+ * the web threw away the music on its disk: the playlist you were looking at
254
+ * turned into somebody else's twenty-eight tracks, and clicking your own
255
+ * files played theirs. Nothing about playback changes here -- whatever was
256
+ * playing keeps playing, at the same index, because the new tracks go on the
257
+ * end.
258
+ *
259
+ * Paths already loaded are skipped, so adding the same album twice is not
260
+ * two copies of it.
261
+ */
262
+ add(tracks: Track[], from: string): number;
263
+ /**
264
+ * Take an added source back out.
265
+ *
266
+ * The track that is playing is followed rather than an index: removing an
267
+ * album from above the current track would otherwise slide the playlist out
268
+ * from under a listener mid-song. If the playing track is itself in what is
269
+ * being removed, playback stops -- there is nothing to keep playing.
270
+ */
271
+ drop(group: string): number;
272
+ groups(): string[];
202
273
  retag(tracks: Track[], root: string): void;
203
274
  }
204
275
  /** An engine with no library behind it, for the hosted PWA. */
@@ -210,6 +281,9 @@ export declare class EmptyEngine implements Engine {
210
281
  subscribe(listener: (snapshot: Snapshot) => void): () => void;
211
282
  trackPath(): undefined;
212
283
  replace(): void;
284
+ add(): number;
285
+ drop(): number;
286
+ groups(): string[];
213
287
  retag(): void;
214
288
  stop(): void;
215
289
  }