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/dist/server.js CHANGED
@@ -39,7 +39,7 @@ import { Durable } from "./durable.js";
39
39
  import { notifyAll, resendEmail, webPush } from "./notify.js";
40
40
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
41
41
  import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
42
- import { isRemote, playsInBrowser } from "./sources.js";
42
+ import { isRemote, playsInBrowser, sourceLabel } from "./sources.js";
43
43
  import { codecsOf, videoArgs } from "./audio.js";
44
44
  import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
45
45
  import { extname, join, normalize, resolve, sep } from "node:path";
@@ -292,7 +292,11 @@ 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 } : {}),
297
+ // Only for what was added; the library's own tracks say nothing, which is
298
+ // how a client knows they are the library.
299
+ ...(t.group ? { group: t.group } : {}),
296
300
  }));
297
301
  }
298
302
  /** Video containers, as opposed to the songs that are most of a library. */
@@ -301,6 +305,24 @@ export function hasPicture(path) {
301
305
  const dot = path.lastIndexOf(".");
302
306
  return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
303
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
+ }
304
326
  /**
305
327
  * The headless player: the terminal app's engine without the terminal.
306
328
  * One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
@@ -501,15 +523,89 @@ export class PlayerEngine {
501
523
  this.state.note = "";
502
524
  this.push(true);
503
525
  }
526
+ /**
527
+ * Load something as well as what is already here.
528
+ *
529
+ * Adding a folder used to be `replace`, so pointing a server at an album on
530
+ * the web threw away the music on its disk: the playlist you were looking at
531
+ * turned into somebody else's twenty-eight tracks, and clicking your own
532
+ * files played theirs. Nothing about playback changes here -- whatever was
533
+ * playing keeps playing, at the same index, because the new tracks go on the
534
+ * end.
535
+ *
536
+ * Paths already loaded are skipped, so adding the same album twice is not
537
+ * two copies of it.
538
+ */
539
+ add(tracks, from) {
540
+ const group = sourceLabel(from);
541
+ const known = new Set(this.tracks.map((track) => track.path));
542
+ const fresh = tracks.filter((track) => !known.has(track.path)).map((track) => ({ ...track, group }));
543
+ if (fresh.length === 0)
544
+ return 0;
545
+ this.tracks = [...this.tracks, ...fresh];
546
+ // The list itself changed, so it has to ride this frame; a count nobody
547
+ // can index into is worse than no news at all.
548
+ this.push(true);
549
+ return fresh.length;
550
+ }
551
+ /**
552
+ * Take an added source back out.
553
+ *
554
+ * The track that is playing is followed rather than an index: removing an
555
+ * album from above the current track would otherwise slide the playlist out
556
+ * from under a listener mid-song. If the playing track is itself in what is
557
+ * being removed, playback stops -- there is nothing to keep playing.
558
+ */
559
+ drop(group) {
560
+ if (group === "")
561
+ return 0;
562
+ const playingPath = this.tracks[this.state.index]?.path;
563
+ const kept = this.tracks.filter((track) => track.group !== group);
564
+ const removed = this.tracks.length - kept.length;
565
+ if (removed === 0)
566
+ return 0;
567
+ this.tracks = kept;
568
+ const stillThere = kept.findIndex((track) => track.path === playingPath);
569
+ if (stillThere === -1) {
570
+ this.halt();
571
+ this.state.index = this.clamp(this.state.index);
572
+ }
573
+ else {
574
+ this.state.index = stillThere;
575
+ }
576
+ this.push(true);
577
+ return removed;
578
+ }
579
+ groups() {
580
+ const seen = [];
581
+ for (const track of this.tracks) {
582
+ if (track.group && !seen.includes(track.group))
583
+ seen.push(track.group);
584
+ }
585
+ return seen;
586
+ }
504
587
  retag(tracks, root) {
505
- // Dropped rather than applied if the library moved underneath: somebody
506
- // re-streamed while the tagging was still running, and these tags describe
507
- // something nobody is playing any more.
508
- if (root !== this.root || tracks.length !== this.tracks.length)
509
- return;
510
- if (tracks.some((track, at) => track.path !== this.tracks[at]?.path))
588
+ // Matched by path rather than by position, because the list is no longer
589
+ // required to be the one that was sent for tagging: somebody can add an
590
+ // album while a library's tags are still being read, and an exact-shape
591
+ // check would throw away every tag for it. Tags that describe tracks which
592
+ // are no longer here simply match nothing, which is the same protection
593
+ // the shape check was giving.
594
+ void root;
595
+ const byPath = new Map(tracks.map((track) => [track.path, track]));
596
+ let changed = false;
597
+ const merged = this.tracks.map((track) => {
598
+ const tagged = byPath.get(track.path);
599
+ if (!tagged || tagged === track)
600
+ return track;
601
+ changed = true;
602
+ // The group is ours, not the tagger's: it knows what a track is called,
603
+ // not which pile it is in.
604
+ return { ...tagged, ...(track.group ? { group: track.group } : {}) };
605
+ });
606
+ if (!changed)
511
607
  return;
512
- this.tracks = tracks;
608
+ this.tracks = merged;
513
609
  // No stop, no index reset: the only thing that changes is what the titles
514
610
  // say, and every remote finds out because a snapshot goes out -- carrying
515
611
  // the list, since the titles are the whole point of this one.
@@ -534,6 +630,15 @@ export class EmptyEngine {
534
630
  return undefined;
535
631
  }
536
632
  replace() { }
633
+ add() {
634
+ return 0;
635
+ }
636
+ drop() {
637
+ return 0;
638
+ }
639
+ groups() {
640
+ return [];
641
+ }
537
642
  retag() { }
538
643
  stop() { }
539
644
  }
@@ -546,6 +651,14 @@ const CORS = {
546
651
  "access-control-allow-headers": "content-type",
547
652
  "access-control-max-age": "86400",
548
653
  };
654
+ /**
655
+ * How many nameless addresses are worth an ffprobe when a source is added.
656
+ *
657
+ * One is the ordinary case -- somebody pasting a channel -- and a directory
658
+ * listing of thousands must not turn into thousands of probes for an answer
659
+ * that only changes which element a browser uses.
660
+ */
661
+ const PROBE_BY_HAND = 8;
549
662
  /** /api/v1/<provider>/oauth/start and .../callback, the house callback shape. */
550
663
  const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
551
664
  /**
@@ -845,7 +958,18 @@ export function createHandler(engine, options) {
845
958
  path !== "/api/health" &&
846
959
  path !== "/api/directory" &&
847
960
  !isSignInPath(path)) {
848
- const scope = scopeOf(keyFrom(request, url), key, listenKey);
961
+ let scope = scopeOf(keyFrom(request, url), key, listenKey);
962
+ // A key is how somebody who was invited proves it. It is not the only way
963
+ // to be allowed in: the person who owns this server is allowed in whether
964
+ // or not they still have the link, and their nixamp.com session says who
965
+ // they are. Without this, signing in as yourself and opening your own
966
+ // server was refused, and the address of a machine you administer was
967
+ // useless without a link you had to go and find.
968
+ if (scope === null && options.owner) {
969
+ const check = await options.owner.check(false, tokenFrom(request.headers));
970
+ if (check.allowed)
971
+ scope = "control";
972
+ }
849
973
  if (scope === null) {
850
974
  // Counted, not because a 128-bit key falls to guessing, but because
851
975
  // somebody hammering one should stop costing this server anything.
@@ -856,7 +980,9 @@ export function createHandler(engine, options) {
856
980
  response.end(JSON.stringify({ error: "too many attempts; wait a moment" }));
857
981
  return;
858
982
  }
859
- json(response, 401, { error: "this nixamp needs the key from its share link" });
983
+ json(response, 401, {
984
+ error: "this nixamp needs the key from its share link, or sign in as its owner",
985
+ });
860
986
  return;
861
987
  }
862
988
  if (scope === "listen" && !allowedForListening(path)) {
@@ -1719,16 +1845,50 @@ export function createHandler(engine, options) {
1719
1845
  json(response, 200, engine.snapshot());
1720
1846
  return;
1721
1847
  }
1722
- // Re-stream: hand the running server a different source. The listeners
1723
- // stay connected; what they are listening to changes under them.
1848
+ // Take an added source back out of the playlist. The library it was added
1849
+ // to is untouched -- there is no group name that names it.
1850
+ if (path === "/api/source/remove") {
1851
+ if (request.method !== "POST") {
1852
+ json(response, 405, { error: "POST only" });
1853
+ return;
1854
+ }
1855
+ let group = "";
1856
+ try {
1857
+ group = String(JSON.parse(await readBody(request)).group ?? "");
1858
+ }
1859
+ catch {
1860
+ json(response, 400, { error: "bad JSON" });
1861
+ return;
1862
+ }
1863
+ if (!group) {
1864
+ json(response, 400, { error: "no group given" });
1865
+ return;
1866
+ }
1867
+ const removed = engine.drop(group);
1868
+ if (removed === 0) {
1869
+ json(response, 404, { error: `nothing here came from ${group}` });
1870
+ return;
1871
+ }
1872
+ json(response, 200, { ...engine.snapshot(), removed, groups: engine.groups() });
1873
+ return;
1874
+ }
1875
+ // Hand the running server another source. The listeners stay connected;
1876
+ // by default they get more to listen to, and only an explicit `replace`
1877
+ // swaps what this server is for something else.
1724
1878
  if (path === "/api/source") {
1725
1879
  if (request.method !== "POST") {
1726
1880
  json(response, 405, { error: "POST only" });
1727
1881
  return;
1728
1882
  }
1729
1883
  let source = "";
1884
+ let replacing = false;
1730
1885
  try {
1731
- source = String(JSON.parse(await readBody(request)).source ?? "");
1886
+ const body = JSON.parse(await readBody(request));
1887
+ source = String(body.source ?? "");
1888
+ // Adding is what somebody means by putting a folder in a box, so it is
1889
+ // the default. Replacing is the much larger claim that this server now
1890
+ // serves that instead, so it is the one you have to ask for.
1891
+ replacing = body.replace === true;
1732
1892
  }
1733
1893
  catch {
1734
1894
  json(response, 400, { error: "bad JSON" });
@@ -1739,13 +1899,38 @@ export function createHandler(engine, options) {
1739
1899
  return;
1740
1900
  }
1741
1901
  try {
1742
- const tracks = await options.load(source);
1902
+ let tracks = await options.load(source);
1743
1903
  if (tracks.length === 0) {
1744
1904
  json(response, 422, { error: `nothing to play at ${source}` });
1745
1905
  return;
1746
1906
  }
1747
- engine.replace(tracks, source);
1748
- // Names now, tags later, here as much as at startup: re-streaming a
1907
+ // A handful of addresses whose names say nothing get asked what they
1908
+ // are, so a live channel arrives as a picture rather than as its own
1909
+ // soundtrack. Capped, because a playlist of five thousand of them is
1910
+ // five thousand ffprobes and the answer only matters for the few a
1911
+ // person adds by hand.
1912
+ const looked = await Promise.all(tracks.map(async (track, at) => {
1913
+ if (at >= PROBE_BY_HAND || !nameSaysNothing(track.path))
1914
+ return track;
1915
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, track.path);
1916
+ return codecs.video === "" ? track : { ...track, picture: true };
1917
+ }));
1918
+ tracks = looked;
1919
+ let added = tracks.length;
1920
+ if (replacing) {
1921
+ engine.replace(tracks, source);
1922
+ }
1923
+ else {
1924
+ added = engine.add(tracks, source);
1925
+ if (added === 0) {
1926
+ // Everything there was already here. Not an error -- the playlist
1927
+ // is exactly what the caller asked for -- but worth saying, so a
1928
+ // client can tell that apart from having added an album.
1929
+ json(response, 200, { ...engine.snapshot(), added: 0, groups: engine.groups() });
1930
+ return;
1931
+ }
1932
+ }
1933
+ // Names now, tags later, here as much as at startup: loading a
1749
1934
  // directory of five thousand files used to read every tag before it
1750
1935
  // answered, with the event loop held the whole time.
1751
1936
  if (options.tag) {
@@ -1754,7 +1939,7 @@ export function createHandler(engine, options) {
1754
1939
  .then((tagged) => engine.retag(tagged, source))
1755
1940
  .catch(() => { });
1756
1941
  }
1757
- json(response, 200, engine.snapshot());
1942
+ json(response, 200, { ...engine.snapshot(), added, replaced: replacing, groups: engine.groups() });
1758
1943
  }
1759
1944
  catch (error) {
1760
1945
  json(response, 422, { error: error.message.replace(/^nixamp: /, "") });
@@ -1784,17 +1969,21 @@ export function createHandler(engine, options) {
1784
1969
  const capKbps = Number.isFinite(asked) && asked > 0 ? Math.min(20_000, Math.max(200, asked)) : 0;
1785
1970
  if (playsInBrowser(file) && capKbps === 0) {
1786
1971
  sendFile(request, response, file);
1972
+ return;
1787
1973
  }
1788
- else if (hasPicture(file)) {
1789
- // A film. It used to arrive as MP3 with `-vn`, which is to say as a
1790
- // soundtrack over a blank panel; what ffprobe finds inside decides how
1791
- // little work it takes to keep the picture.
1974
+ // A film, or something whose name refuses to say. A live channel at
1975
+ // .../301 used to fall through to the audio branch and arrive as MP3
1976
+ // with `-vn` -- a match you could only listen to.
1977
+ if (hasPicture(file) || nameSaysNothing(file)) {
1978
+ // What ffprobe finds inside decides how little work it takes to keep
1979
+ // the picture, and whether there is a picture to keep at all.
1792
1980
  const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
1793
- pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
1794
- }
1795
- else {
1796
- transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
1981
+ if (codecs.video !== "") {
1982
+ pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
1983
+ return;
1984
+ }
1797
1985
  }
1986
+ transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
1798
1987
  return;
1799
1988
  }
1800
1989
  // Whatever the source is, this comes back as MP3 a browser will play:
package/dist/share.js CHANGED
@@ -180,7 +180,12 @@ export function scopeOf(offered, control, listen) {
180
180
  }
181
181
  /** Paths a listen key may have. Everything else needs the control key. */
182
182
  export function allowedForListening(path) {
183
- if (path === "/api/command" || path === "/api/source")
183
+ if (path === "/api/command")
184
+ return false;
185
+ // Prefix, not equality: everything under this changes what the server plays,
186
+ // and an exact check let a listen key reach /api/source/remove and delete an
187
+ // album out of somebody else's playlist.
188
+ if (path === "/api/source" || path.startsWith("/api/source/"))
184
189
  return false;
185
190
  return true;
186
191
  }
package/dist/sources.d.ts CHANGED
@@ -33,5 +33,14 @@ export declare function parseM3u(text: string, base: string): Entry[];
33
33
  /** A .pls, which Shoutcast and Icecast hand out as often as an .m3u. */
34
34
  export declare function parsePls(text: string, base: string): Entry[];
35
35
  /** The last useful part of a path or URL, for when nothing named the track. */
36
+ /**
37
+ * What to call a whole source, as a heading over the tracks it brought.
38
+ *
39
+ * `nameOf` answers for a file; this answers for the thing a person added --
40
+ * usually the last segment either way, but a URL that is only a host has no
41
+ * segment to take, and "the album at that address" reads better as the host
42
+ * than as the whole URL repeated over every row.
43
+ */
44
+ export declare function sourceLabel(source: string): string;
36
45
  export declare function nameOf(source: string): string;
37
46
  export declare function playsInBrowser(source: string): boolean;
package/dist/sources.js CHANGED
@@ -94,6 +94,30 @@ export function parsePls(text, base) {
94
94
  });
95
95
  }
96
96
  /** The last useful part of a path or URL, for when nothing named the track. */
97
+ /**
98
+ * What to call a whole source, as a heading over the tracks it brought.
99
+ *
100
+ * `nameOf` answers for a file; this answers for the thing a person added --
101
+ * usually the last segment either way, but a URL that is only a host has no
102
+ * segment to take, and "the album at that address" reads better as the host
103
+ * than as the whole URL repeated over every row.
104
+ */
105
+ export function sourceLabel(source) {
106
+ const trimmed = source.replace(/\/+$/, "");
107
+ if (trimmed === "")
108
+ return source;
109
+ const named = nameOf(trimmed);
110
+ if (named !== trimmed && named !== "")
111
+ return named;
112
+ if (!isRemote(trimmed))
113
+ return trimmed;
114
+ try {
115
+ return new URL(trimmed).host;
116
+ }
117
+ catch {
118
+ return trimmed;
119
+ }
120
+ }
97
121
  export function nameOf(source) {
98
122
  const remote = isRemote(source);
99
123
  const path = remote ? new URL(source).pathname : source;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
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
@@ -146,6 +146,9 @@ export async function admin(argv: string[]): Promise<void> {
146
146
  let snapshot: Snapshot | null = null;
147
147
  let error = "";
148
148
  let restreaming = "";
149
+ // Which of the two things typing a source means. Adding is the ordinary one
150
+ // and has its own key; replacing throws the library away, so it has another.
151
+ let replacing = false;
149
152
  let typing = false;
150
153
 
151
154
  const app = await createApp({ theme: themes.matrix, title: "nixamp admin", quitKeys: ["ctrl+c"] });
@@ -164,24 +167,27 @@ export async function admin(argv: string[]): Promise<void> {
164
167
  app.on("key", (event: KeyEvent) => {
165
168
  const key = event.key;
166
169
  if (typing) {
167
- if (key === "escape") { typing = false; restreaming = ""; }
170
+ if (key === "escape") { typing = false; restreaming = ""; replacing = false; }
168
171
  else if (key === "enter") {
169
172
  const url = restreaming.trim();
173
+ const asReplacement = replacing;
170
174
  typing = false;
171
175
  restreaming = "";
172
- if (url) void restream(target, headers, url).then(() => refresh());
176
+ replacing = false;
177
+ if (url) void restream(target, headers, url, asReplacement).then(() => refresh());
173
178
  } else if (key === "backspace") restreaming = restreaming.slice(0, -1);
174
179
  else restreaming += typed(key);
175
180
  app.invalidate();
176
181
  return;
177
182
  }
178
183
  if (key === "q") { app.quit(); return; }
179
- if (key === "r") { typing = true; app.invalidate(); }
184
+ if (key === "a") { typing = true; replacing = false; app.invalidate(); }
185
+ if (key === "r") { typing = true; replacing = true; app.invalidate(); }
180
186
  });
181
187
 
182
188
  app.on("exit", () => clearInterval(timer));
183
189
  app.render(({ ui, theme }) => draw(ui, theme, {
184
- url: target.url, report, snapshot, error, typing, restreaming,
190
+ url: target.url, report, snapshot, error, typing, restreaming, replacing,
185
191
  links: target.links, key: target.key, source: target.source,
186
192
  }));
187
193
 
@@ -189,13 +195,24 @@ export async function admin(argv: string[]): Promise<void> {
189
195
  clearInterval(timer);
190
196
  }
191
197
 
192
- /** Ask the server to play something else, which is what re-streaming is. */
193
- async function restream(target: AdminOptions, headers: Record<string, string>, url: string): Promise<void> {
198
+ /**
199
+ * Hand the server something else to play.
200
+ *
201
+ * Two different asks down one route: adding puts an album on the end of the
202
+ * playlist, replacing points the server somewhere else entirely. The server
203
+ * adds unless told otherwise, so only the second one says anything.
204
+ */
205
+ async function restream(
206
+ target: AdminOptions,
207
+ headers: Record<string, string>,
208
+ url: string,
209
+ replacing = false,
210
+ ): Promise<void> {
194
211
  try {
195
212
  await fetch(`${target.url}/api/source`, {
196
213
  method: "POST",
197
214
  headers: { ...headers, "content-type": "application/json" },
198
- body: JSON.stringify({ source: url }),
215
+ body: JSON.stringify({ source: url, ...(replacing ? { replace: true } : {}) }),
199
216
  });
200
217
  } catch {
201
218
  // The next refresh reports the server being unreachable; this is not the
@@ -210,6 +227,8 @@ export interface View {
210
227
  error: string;
211
228
  typing: boolean;
212
229
  restreaming: string;
230
+ /** Whether what is being typed replaces the playlist rather than joining it. */
231
+ replacing?: boolean;
213
232
  /** Labelled addresses, and the key that makes them work. */
214
233
  links: { label: string; url: string }[];
215
234
  key: string | null;
@@ -296,15 +315,21 @@ export function draw(ui: Container, theme: Theme, view: View): void {
296
315
  });
297
316
 
298
317
  if (view.typing) {
299
- ui.panel({ title: "Re-stream a URL or a path", size: 4 }, (p) => {
318
+ ui.panel({
319
+ title: view.replacing ? "Replace the playlist with a URL or a path" : "Add a URL or a path",
320
+ size: 4,
321
+ }, (p) => {
300
322
  p.text(`${view.restreaming}_`, { fg: theme.accent });
301
- p.label("Enter plays it here. Escape forgets it.");
323
+ p.label(view.replacing
324
+ ? "Enter drops this library and serves that instead. Escape forgets it."
325
+ : "Enter adds it to the playlist. Escape forgets it.");
302
326
  });
303
327
  }
304
328
 
305
329
  ui.statusBar({
306
330
  items: [
307
- { key: "r", label: "Re-stream" },
331
+ { key: "a", label: "Add" },
332
+ { key: "r", label: "Replace" },
308
333
  { key: "q", label: "Quit" },
309
334
  ],
310
335
  right: [{ key: "", label: report ? `${report.connections.length} seen` : "connecting" }],
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,