nixamp 0.7.5 → 0.7.7

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/server.js CHANGED
@@ -292,7 +292,8 @@ export function toRemoteTracks(tracks) {
292
292
  // Said out loud, because a remote cannot see the path and had been sending
293
293
  // every track to the audio element -- a film's soundtrack over a blank
294
294
  // panel, which is exactly what it looked like.
295
- ...(hasPicture(t.path) ? { video: true } : {}),
295
+ // The name when it says something, what ffprobe found when it does not.
296
+ ...(t.picture ?? hasPicture(t.path) ? { video: true } : {}),
296
297
  // Only for what was added; the library's own tracks say nothing, which is
297
298
  // how a client knows they are the library.
298
299
  ...(t.group ? { group: t.group } : {}),
@@ -304,6 +305,24 @@ export function hasPicture(path) {
304
305
  const dot = path.lastIndexOf(".");
305
306
  return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
306
307
  }
308
+ /**
309
+ * Whether the name of a source tells us anything about what is inside it.
310
+ *
311
+ * A remote address with no extension -- an IPTV channel, a stream key, a
312
+ * redirect -- is the case where it does not, and the only way to find out is
313
+ * to look.
314
+ */
315
+ export function nameSaysNothing(path) {
316
+ if (!isRemote(path))
317
+ return false;
318
+ try {
319
+ const last = new URL(path).pathname.split("/").pop() ?? "";
320
+ return !last.includes(".");
321
+ }
322
+ catch {
323
+ return false;
324
+ }
325
+ }
307
326
  /**
308
327
  * The headless player: the terminal app's engine without the terminal.
309
328
  * One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
@@ -565,6 +584,30 @@ export class PlayerEngine {
565
584
  }
566
585
  return seen;
567
586
  }
587
+ /**
588
+ * The library, arriving after the server was already listening.
589
+ *
590
+ * Walking a directory is the slow part of starting -- eighty thousand files
591
+ * under a home directory takes far longer than the fifteen seconds
592
+ * `nixamp daemon start` waits for the server to say it is up, so starting a
593
+ * daemon with no source given looked exactly like hanging and then failed
594
+ * about a server that was working. The port opens first now and this puts
595
+ * the library in behind it.
596
+ *
597
+ * Unlike `replace` it stops nothing and clears no listeners: whoever
598
+ * connected in the first second is still connected, and simply sees the
599
+ * playlist appear.
600
+ */
601
+ fill(tracks, root) {
602
+ // Something is already loaded, so this is a scan that finished after
603
+ // somebody pointed the server elsewhere. Theirs wins.
604
+ if (this.tracks.length > 0)
605
+ return;
606
+ this.tracks = tracks;
607
+ this.root = root;
608
+ this.state.note = tracks.length === 0 ? `No audio files under ${root}.` : "";
609
+ this.push(true);
610
+ }
568
611
  retag(tracks, root) {
569
612
  // Matched by path rather than by position, because the list is no longer
570
613
  // required to be the one that was sent for tagging: somebody can add an
@@ -611,6 +654,7 @@ export class EmptyEngine {
611
654
  return undefined;
612
655
  }
613
656
  replace() { }
657
+ fill() { }
614
658
  add() {
615
659
  return 0;
616
660
  }
@@ -632,6 +676,14 @@ const CORS = {
632
676
  "access-control-allow-headers": "content-type",
633
677
  "access-control-max-age": "86400",
634
678
  };
679
+ /**
680
+ * How many nameless addresses are worth an ffprobe when a source is added.
681
+ *
682
+ * One is the ordinary case -- somebody pasting a channel -- and a directory
683
+ * listing of thousands must not turn into thousands of probes for an answer
684
+ * that only changes which element a browser uses.
685
+ */
686
+ const PROBE_BY_HAND = 8;
635
687
  /** /api/v1/<provider>/oauth/start and .../callback, the house callback shape. */
636
688
  const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
637
689
  /**
@@ -1768,6 +1820,9 @@ export function createHandler(engine, options) {
1768
1820
  active: tracker.active,
1769
1821
  startedAt: started,
1770
1822
  now: Date.now(),
1823
+ // Where to point OBS. Printed at startup since RTMP was added, which
1824
+ // is no use at all to somebody looking at the admin panel a day later.
1825
+ publish: options.publishUrls?.() ?? [],
1771
1826
  });
1772
1827
  return;
1773
1828
  }
@@ -1872,11 +1927,23 @@ export function createHandler(engine, options) {
1872
1927
  return;
1873
1928
  }
1874
1929
  try {
1875
- const tracks = await options.load(source);
1930
+ let tracks = await options.load(source);
1876
1931
  if (tracks.length === 0) {
1877
1932
  json(response, 422, { error: `nothing to play at ${source}` });
1878
1933
  return;
1879
1934
  }
1935
+ // A handful of addresses whose names say nothing get asked what they
1936
+ // are, so a live channel arrives as a picture rather than as its own
1937
+ // soundtrack. Capped, because a playlist of five thousand of them is
1938
+ // five thousand ffprobes and the answer only matters for the few a
1939
+ // person adds by hand.
1940
+ const looked = await Promise.all(tracks.map(async (track, at) => {
1941
+ if (at >= PROBE_BY_HAND || !nameSaysNothing(track.path))
1942
+ return track;
1943
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, track.path);
1944
+ return codecs.video === "" ? track : { ...track, picture: true };
1945
+ }));
1946
+ tracks = looked;
1880
1947
  let added = tracks.length;
1881
1948
  if (replacing) {
1882
1949
  engine.replace(tracks, source);
@@ -1930,17 +1997,21 @@ export function createHandler(engine, options) {
1930
1997
  const capKbps = Number.isFinite(asked) && asked > 0 ? Math.min(20_000, Math.max(200, asked)) : 0;
1931
1998
  if (playsInBrowser(file) && capKbps === 0) {
1932
1999
  sendFile(request, response, file);
2000
+ return;
1933
2001
  }
1934
- else if (hasPicture(file)) {
1935
- // A film. It used to arrive as MP3 with `-vn`, which is to say as a
1936
- // soundtrack over a blank panel; what ffprobe finds inside decides how
1937
- // little work it takes to keep the picture.
2002
+ // A film, or something whose name refuses to say. A live channel at
2003
+ // .../301 used to fall through to the audio branch and arrive as MP3
2004
+ // with `-vn` -- a match you could only listen to.
2005
+ if (hasPicture(file) || nameSaysNothing(file)) {
2006
+ // What ffprobe finds inside decides how little work it takes to keep
2007
+ // the picture, and whether there is a picture to keep at all.
1938
2008
  const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
1939
- pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
1940
- }
1941
- else {
1942
- transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
2009
+ if (codecs.video !== "") {
2010
+ pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
2011
+ return;
2012
+ }
1943
2013
  }
2014
+ transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
1944
2015
  return;
1945
2016
  }
1946
2017
  // Whatever the source is, this comes back as MP3 a browser will play:
@@ -2298,10 +2369,9 @@ export async function serve(argv, version = "0.1.0") {
2298
2369
  //
2299
2370
  // So the filenames are enough to start: the server is up and answering in the
2300
2371
  // time it takes to walk the directory, and the titles fill in behind it.
2301
- const tracks = await loadSource(tools, root, false);
2302
- const engine = tracks.length > 0
2303
- ? new PlayerEngine(tracks, root, tools)
2304
- : new EmptyEngine(`No audio files under ${root}.`);
2372
+ // Empty on purpose. The walk happens below, once the port is open: it is
2373
+ // the slowest part of starting and nothing about it needs to happen first.
2374
+ const engine = new PlayerEngine([], root, tools);
2305
2375
  const web = options.web !== null ? resolve(options.web) : defaultWebDir();
2306
2376
  const key = options.key ? newKey() : null;
2307
2377
  // Minted whether or not it is published, so `nixamp admin` and the operator
@@ -2500,11 +2570,16 @@ export async function serve(argv, version = "0.1.0") {
2500
2570
  }
2501
2571
  })()
2502
2572
  : undefined;
2573
+ // Filled in below, when the RTMP listeners are opened. Read through a
2574
+ // function so the handler sees the list rather than the empty array it was
2575
+ // built with.
2576
+ let publishUrls = [];
2503
2577
  const server = createServer(engine, {
2504
2578
  web,
2505
2579
  media: options.media,
2506
2580
  owner,
2507
2581
  channels,
2582
+ publishUrls: () => publishUrls,
2508
2583
  ...(ingest ? { ingest } : {}),
2509
2584
  broadcaster,
2510
2585
  broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
@@ -2608,19 +2683,30 @@ export async function serve(argv, version = "0.1.0") {
2608
2683
  guessedPublic: guessedPublic !== "",
2609
2684
  }));
2610
2685
  }
2611
- // The other half of "names now, tags later". It runs while the banner is
2612
- // printed and while the publish prompt waits, and it is deliberately not
2613
- // awaited: nothing downstream needs it, and a library that takes a minute to
2614
- // read should cost nobody a minute of silence.
2615
- if (tracks.length > 0 && !isRemote(root)) {
2616
- void loadTagged(tools, root)
2686
+ // Names now, tags later, and both after the door is open. Not awaited:
2687
+ // nothing below needs the library, and a directory that takes a minute to
2688
+ // walk should cost nobody a minute of not being able to connect.
2689
+ void loadSource(tools, root, false)
2690
+ .then((found) => {
2691
+ engine.fill(found, root);
2692
+ if (found.length === 0) {
2693
+ console.log(`nixamp serve — no audio files under ${root}`);
2694
+ return;
2695
+ }
2696
+ console.log(`nixamp serve — ${found.length} tracks under ${root}`);
2697
+ if (isRemote(root))
2698
+ return;
2699
+ return loadTagged(tools, root)
2617
2700
  .then((tagged) => engine.retag(tagged, root))
2618
2701
  .catch(() => {
2619
- // Filenames are a working player. A failure here is worth nothing but
2620
- // titles that stay as they are.
2702
+ // Filenames are a working player. A failure here is worth nothing
2703
+ // but titles that stay as they are.
2621
2704
  });
2622
- }
2623
- console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
2705
+ })
2706
+ .catch((error) => {
2707
+ console.log(`nixamp: could not read ${root}: ${error.message}`);
2708
+ });
2709
+ console.log(`nixamp serve — reading ${root}`);
2624
2710
  console.log("");
2625
2711
  const width = Math.max(...addresses.map((a) => a.label.length));
2626
2712
  for (const { label, url } of addresses) {
@@ -2673,9 +2759,13 @@ export async function serve(argv, version = "0.1.0") {
2673
2759
  }));
2674
2760
  rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
2675
2761
  rtmp.listen(slots);
2762
+ publishUrls = slots.map((slot) => ({
2763
+ id: slot.id,
2764
+ url: `rtmp://${host}:${slot.port}/live/${listenKey ?? "live"}`,
2765
+ }));
2676
2766
  console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
2677
- for (const slot of slots) {
2678
- console.log(` rtmp://${host}:${slot.port}/live/${listenKey ?? "live"} -> "${slot.id}"`);
2767
+ for (const entry of publishUrls) {
2768
+ console.log(` ${entry.url} -> "${entry.id}"`);
2679
2769
  }
2680
2770
  }
2681
2771
  if (destinations.length > 0) {
@@ -2739,7 +2829,9 @@ export async function serve(argv, version = "0.1.0") {
2739
2829
  name: options.name || hostname(),
2740
2830
  url: listen,
2741
2831
  audio,
2742
- tracks: tracks.length,
2832
+ // Asked of the engine rather than a variable, because the library is
2833
+ // now read after the port opens and may still be arriving.
2834
+ tracks: engine.snapshot(false).trackCount,
2743
2835
  // From `nixamp login`. The directory will not list a stream it cannot
2744
2836
  // attribute to somebody, because a listing is now a phone code that
2745
2837
  // costs money to answer.
package/dist/session.js CHANGED
@@ -512,7 +512,9 @@ export async function servers(argv, fetcher = fetch) {
512
512
  const daemon = await import("./daemon.js");
513
513
  const state = daemon.readState();
514
514
  if (state === null) {
515
- console.error("nixamp: no daemon is running here. Start one, or pass a URL.");
515
+ console.error("nixamp: no daemon is running here.\n" +
516
+ " Start one: nixamp daemon start ~/Music\n" +
517
+ " Or give the share link of the one you mean: https://host:4321/s/KEY");
516
518
  return 1;
517
519
  }
518
520
  // The address worth remembering is the one somebody else can open.
package/dist/share.d.ts CHANGED
@@ -45,6 +45,16 @@ export declare function lookupPublicIp(send?: typeof fetch, timeoutMs?: number):
45
45
  * somewhere else can open. It is labelled for what it is, because the key in
46
46
  * the link is then the only thing between a stranger and the library.
47
47
  */
48
+ /**
49
+ * Whether a browser could ever verify a certificate for this address.
50
+ *
51
+ * A certificate is issued for a name. Handed an https link to a bare IP, a
52
+ * browser has nothing to match it against and refuses the connection before it
53
+ * asks anything -- and from the page it looks identical to a machine that is
54
+ * switched off. Offering such a link is offering one that cannot work, so the
55
+ * links are labelled and the ones that can work go first.
56
+ */
57
+ export declare function certifiable(url: string): boolean;
48
58
  export declare function reachableAddresses(host: string, port: number, publicUrl?: string, scheme?: "http" | "https"): {
49
59
  label: string;
50
60
  url: string;
package/dist/share.js CHANGED
@@ -115,6 +115,25 @@ export async function lookupPublicIp(send = fetch, timeoutMs = 2500) {
115
115
  * somewhere else can open. It is labelled for what it is, because the key in
116
116
  * the link is then the only thing between a stranger and the library.
117
117
  */
118
+ /**
119
+ * Whether a browser could ever verify a certificate for this address.
120
+ *
121
+ * A certificate is issued for a name. Handed an https link to a bare IP, a
122
+ * browser has nothing to match it against and refuses the connection before it
123
+ * asks anything -- and from the page it looks identical to a machine that is
124
+ * switched off. Offering such a link is offering one that cannot work, so the
125
+ * links are labelled and the ones that can work go first.
126
+ */
127
+ export function certifiable(url) {
128
+ if (!url.startsWith("https://"))
129
+ return true;
130
+ try {
131
+ return !isIpAddress(new URL(url).hostname.replace(/^\[|\]$/g, ""));
132
+ }
133
+ catch {
134
+ return true;
135
+ }
136
+ }
118
137
  export function reachableAddresses(host, port, publicUrl = "", scheme = "http") {
119
138
  const link = (address) => {
120
139
  // A bare IPv6 address needs brackets before it is a URL.
@@ -143,11 +162,22 @@ export function reachableAddresses(host, port, publicUrl = "", scheme = "http")
143
162
  }
144
163
  const order = { private: 0, cgnat: 1, public: 2 };
145
164
  found.sort((x, y) => order[x.kind] - order[y.kind]);
146
- return [
165
+ const all = [
147
166
  ...told,
148
167
  { label: "here", url: `${scheme}://localhost:${port}` },
149
168
  ...found.map(({ label, url }) => ({ label, url })),
150
169
  ];
170
+ // Serving https, an address that is a bare IP cannot be verified by anyone,
171
+ // so it is said last and said differently rather than handed out as though
172
+ // it were a link somebody could use.
173
+ if (scheme !== "https")
174
+ return all;
175
+ return [
176
+ ...all.filter((entry) => certifiable(entry.url)),
177
+ ...all
178
+ .filter((entry) => !certifiable(entry.url))
179
+ .map((entry) => ({ label: `${entry.label} (no certificate for an IP)`, url: entry.url })),
180
+ ];
151
181
  }
152
182
  /** The full link, key and all. */
153
183
  export function shareLink(base, key) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
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/admin.ts CHANGED
@@ -57,7 +57,17 @@ export function resolveTarget(argv: string[]): AdminOptions {
57
57
 
58
58
  const state = readState();
59
59
  if (state === null) {
60
- throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
60
+ // Named, with an example. "or pass --url" is only an instruction if you
61
+ // already know what belongs after it, and the whole point of this message
62
+ // is that you are looking at a machine you have no handle on.
63
+ throw new Error(
64
+ "nixamp: no daemon is running on this machine.\n" +
65
+ " Start one: nixamp daemon start ~/Music\n" +
66
+ " Or administer another machine, with its share link:\n" +
67
+ " nixamp admin --url https://server1.you.nixamp.com:4321 --key KEY\n" +
68
+ " The URL and key are the two halves of the link that server printed:\n" +
69
+ " https://host:4321/s/KEY",
70
+ );
61
71
  }
62
72
  const url_ = daemonUrl(state);
63
73
  // Talking to our own daemon, whose certificate names somewhere else. Nothing
package/src/audio.ts CHANGED
@@ -314,6 +314,17 @@ export interface Codecs {
314
314
  video: string;
315
315
  /** e.g. "aac", "ac3", "dts". Empty when there is no audio stream. */
316
316
  audio: string;
317
+ /**
318
+ * What is wrapped around them: "mpegts", "matroska,webm", "mov,mp4,...".
319
+ *
320
+ * It matters for one reason. A transport stream -- which is what every IPTV
321
+ * channel is -- frames its AAC as ADTS, and copying that into MP4 needs a
322
+ * bitstream filter or ffmpeg refuses the whole muxing and writes nothing.
323
+ * They also tend to carry several audio tracks, so the one ffmpeg picks can
324
+ * be AC-3 on the same URL that offered AAC a minute earlier, and AC-3 in MP4
325
+ * is a track no browser will play.
326
+ */
327
+ container: string;
317
328
  }
318
329
 
319
330
  /**
@@ -325,7 +336,7 @@ export interface Codecs {
325
336
  */
326
337
  export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
327
338
  const [cmd, ...rest] = tools.ffprobe;
328
- const empty: Codecs = { video: "", audio: "" };
339
+ const empty: Codecs = { video: "", audio: "", container: "" };
329
340
  if (!cmd) return empty;
330
341
 
331
342
  return new Promise<Codecs>((done) => {
@@ -335,7 +346,7 @@ export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
335
346
  ...rest,
336
347
  "-v", "quiet",
337
348
  "-print_format", "json",
338
- "-show_entries", "stream=codec_type,codec_name",
349
+ "-show_entries", "format=format_name:stream=codec_type,codec_name",
339
350
  path,
340
351
  ],
341
352
  { stdio: ["ignore", "pipe", "ignore"] },
@@ -347,11 +358,15 @@ export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
347
358
  child.on("error", () => done(empty));
348
359
  child.on("close", () => {
349
360
  try {
350
- const parsed = JSON.parse(out) as { streams?: { codec_type?: string; codec_name?: string }[] };
361
+ const parsed = JSON.parse(out) as {
362
+ streams?: { codec_type?: string; codec_name?: string }[];
363
+ format?: { format_name?: string };
364
+ };
351
365
  const streams = parsed.streams ?? [];
352
366
  return done({
353
367
  video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
354
368
  audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
369
+ container: parsed.format?.format_name ?? "",
355
370
  });
356
371
  } catch {
357
372
  return done(empty);
@@ -376,7 +391,13 @@ export function videoArgs(codecs: Codecs, capKbps = 0): string[] {
376
391
 
377
392
  // What a browser can play inside MP4 without help.
378
393
  const keepVideo = codecs.video === "h264";
379
- const keepAudio = codecs.audio === "aac" || codecs.audio === "mp3";
394
+ // A transport stream's audio is never copied. Its AAC is ADTS-framed, which
395
+ // MP4 refuses without a bitstream filter -- ffmpeg writes nothing at all and
396
+ // says "Malformed AAC bitstream detected" -- and the track ffmpeg picks off
397
+ // a channel with several of them can be AC-3, which that filter rejects and
398
+ // no browser plays. Re-encoding audio is cheap; this failing is total.
399
+ const transportStream = codecs.container.includes("mpegts");
400
+ const keepAudio = !transportStream && (codecs.audio === "aac" || codecs.audio === "mp3");
380
401
  return [
381
402
  "-c:v", keepVideo ? "copy" : "libx264",
382
403
  ...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
package/src/daemon.ts CHANGED
@@ -38,6 +38,15 @@ export interface DaemonState {
38
38
  * interface, so it names the router and not this port.
39
39
  */
40
40
  guessedPublic?: boolean;
41
+ /**
42
+ * What it was started with, so it can be started that way again.
43
+ *
44
+ * A daemon serving TLS on a public name is six flags, and restarting it
45
+ * meant finding them again -- from shell history, or from `ps`, or not at
46
+ * all. Absent on a state file written by an older nixamp, which is why
47
+ * `restart` says so rather than starting something different.
48
+ */
49
+ argv?: string[];
41
50
  }
42
51
 
43
52
  /** XDG, with the usual fallback. One daemon per user, which is one too few for nobody. */
@@ -234,11 +243,37 @@ export async function start(argv: string[], entry: string): Promise<DaemonState>
234
243
  throw new Error(`nixamp: the daemon did not start. See ${log}`);
235
244
  }
236
245
 
237
- const state: DaemonState = { ...announced, pid: child.pid, startedAt: Date.now(), log };
246
+ const state: DaemonState = { ...announced, pid: child.pid, startedAt: Date.now(), log, argv };
238
247
  writeState(state);
239
248
  return state;
240
249
  }
241
250
 
251
+ /**
252
+ * Stop it and start it again, the way it was started.
253
+ *
254
+ * The flags are replayed from the state file rather than retyped, because the
255
+ * interesting daemons are the ones with the most flags: a certificate, a key,
256
+ * a public URL. Given arguments of its own it uses those instead, which is how
257
+ * you change one thing without stopping and starting by hand.
258
+ */
259
+ export async function restart(
260
+ argv: string[],
261
+ entry: string,
262
+ /** Injected so a test can see which arguments would be replayed. */
263
+ starter: typeof start = start,
264
+ ): Promise<DaemonState> {
265
+ const { state } = status();
266
+ const before = state?.argv;
267
+ if (argv.length === 0 && before === undefined && state !== null) {
268
+ throw new Error(
269
+ "nixamp: this daemon was started by an older nixamp, which did not record its flags. " +
270
+ "Stop it and start it again with the flags you want.",
271
+ );
272
+ }
273
+ await stop();
274
+ return starter(argv.length > 0 ? argv : (before ?? []), entry);
275
+ }
276
+
242
277
  /** Poll the log for the announce line. */
243
278
  async function waitForAnnounce(
244
279
  log: string,
package/src/invite.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
@@ -22,7 +24,7 @@ export interface Invite {
22
24
  link: string;
23
25
  /** The phone number, when this stream is one the line knows about. */
24
26
  phone: string;
25
- /** The six digits that reach this stream, when it has been published. */
27
+ /** The six digits that reach this stream's room, once it has been published. */
26
28
  code: string;
27
29
  }
28
30
 
@@ -47,7 +49,10 @@ export function isEmail(value: string): boolean {
47
49
  export function inviteText(invite: Invite): string {
48
50
  const lines = [`${invite.name} is streaming.`, "", `Watch: ${invite.link}`];
49
51
  if (invite.phone && invite.code) {
50
- lines.push("", `Or call ${invite.phone} and key ${invite.code} to listen.`);
52
+ // "to talk about it", not "to listen": the line is a room full of the
53
+ // other people watching, and telling somebody they will hear the stream
54
+ // down the phone is telling them something that is not true.
55
+ lines.push("", `To talk about it: call ${invite.phone} and key ${invite.code}.`);
51
56
  }
52
57
  return lines.join("\n");
53
58
  }
package/src/main.ts CHANGED
@@ -69,7 +69,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
69
69
 
70
70
  nixamp [source] play it in the terminal
71
71
  nixamp serve [source] [options] play here, and hand out a browser remote
72
- nixamp daemon start|stop|status serve in the background, and let go of it
72
+ nixamp daemon start|restart|stop|status serve in the background, and let go of it
73
73
  nixamp attach put the player back in front of the daemon
74
74
  nixamp admin [--url U] [--key K] who is connected, and re-stream to them
75
75
  nixamp login [--with github] sign in to nixamp.com, in a browser or here
@@ -188,9 +188,13 @@ Signing out does not touch it: that is what it is for.
188
188
  daemon: `nixamp daemon — a nixamp that outlives the terminal that started it.
189
189
 
190
190
  nixamp daemon start [source] [serve options] start it, detached
191
+ nixamp daemon restart [source] [serve options] stop it and start it again
191
192
  nixamp daemon status where it is, and how long
192
193
  nixamp daemon stop stop it
193
194
 
195
+ Restart with no arguments replays the ones it was started with, certificate
196
+ and public URL included, so picking up a new version costs one command.
197
+
194
198
  It is \`nixamp serve\` with nobody holding its terminal, so it keeps playing and
195
199
  keeps serving its browser remote. One per user.
196
200
 
@@ -257,6 +261,17 @@ async function runDaemon(argv: string[]): Promise<number> {
257
261
  return 0;
258
262
  }
259
263
 
264
+ if (action === "restart") {
265
+ try {
266
+ const state = await d.restart(rest, entry);
267
+ for (const line of d.daemonLines(state)) console.log(line);
268
+ return 0;
269
+ } catch (error) {
270
+ console.error((error as Error).message);
271
+ return 1;
272
+ }
273
+ }
274
+
260
275
  if (action === "status") {
261
276
  const { running, state } = d.status();
262
277
  if (!state) {
@@ -283,7 +298,7 @@ async function runDaemon(argv: string[]): Promise<number> {
283
298
  return attach(rest);
284
299
  }
285
300
 
286
- console.error(`nixamp daemon: unknown action ${action}. Try start, stop, status or attach.`);
301
+ console.error(`nixamp daemon: unknown action ${action}. Try start, restart, stop, status or attach.`);
287
302
  return 64;
288
303
  }
289
304
 
@@ -619,7 +634,11 @@ export function view(
619
634
 
620
635
  if (import.meta.main) {
621
636
  main().catch((error) => {
622
- console.error(error);
637
+ // The same rule the installed launcher uses: a message we wrote is one the
638
+ // reader can act on, and printing a stack over it buries the sentence that
639
+ // says what to do.
640
+ const message = error instanceof Error ? error.message : String(error);
641
+ console.error(message.startsWith("nixamp:") ? message : error);
623
642
  process.exit(1);
624
643
  });
625
644
  }