nixamp 0.5.9 → 0.5.10

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/audio.d.ts CHANGED
@@ -58,3 +58,28 @@ export declare function peaks(pcm: Float32Array): [number, number];
58
58
  /** Interleaved stereo down to mono, for the analyser. */
59
59
  export declare function toMono(pcm: Float32Array): Float32Array;
60
60
  export declare function formatTime(seconds: number): string;
61
+ /** What is actually inside a container, as opposed to what the name suggests. */
62
+ export interface Codecs {
63
+ /** e.g. "h264", "hevc", "vp9". Empty when there is no video stream. */
64
+ video: string;
65
+ /** e.g. "aac", "ac3", "dts". Empty when there is no audio stream. */
66
+ audio: string;
67
+ }
68
+ /**
69
+ * Ask ffprobe what the streams are, without holding the event loop.
70
+ *
71
+ * Deliberately not the spawnSync `probe` above: this one runs while a server is
72
+ * answering other requests, and a synchronous probe per media request is how
73
+ * the whole library came to be tagged with the process wedged solid.
74
+ */
75
+ export declare function codecsOf(tools: Tools, path: string): Promise<Codecs>;
76
+ /**
77
+ * How to get this file into a browser, given what is inside it.
78
+ *
79
+ * A container a browser will not open says nothing about the streams within:
80
+ * most Matroska holds H.264, which every browser decodes, and only the wrapper
81
+ * is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
82
+ * would cost a core per viewer and look worse. So the streams decide, one part
83
+ * at a time -- a film can have its video copied and only its DTS re-encoded.
84
+ */
85
+ export declare function videoArgs(codecs: Codecs): string[];
package/dist/audio.js CHANGED
@@ -204,3 +204,68 @@ export function formatTime(seconds) {
204
204
  const s = total % 60;
205
205
  return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
206
206
  }
207
+ /**
208
+ * Ask ffprobe what the streams are, without holding the event loop.
209
+ *
210
+ * Deliberately not the spawnSync `probe` above: this one runs while a server is
211
+ * answering other requests, and a synchronous probe per media request is how
212
+ * the whole library came to be tagged with the process wedged solid.
213
+ */
214
+ export async function codecsOf(tools, path) {
215
+ const [cmd, ...rest] = tools.ffprobe;
216
+ const empty = { video: "", audio: "" };
217
+ if (!cmd)
218
+ return empty;
219
+ return new Promise((done) => {
220
+ const child = spawn(cmd, [
221
+ ...rest,
222
+ "-v", "quiet",
223
+ "-print_format", "json",
224
+ "-show_entries", "stream=codec_type,codec_name",
225
+ path,
226
+ ], { stdio: ["ignore", "pipe", "ignore"] });
227
+ let out = "";
228
+ child.stdout.on("data", (chunk) => {
229
+ out += chunk.toString("utf8");
230
+ });
231
+ child.on("error", () => done(empty));
232
+ child.on("close", () => {
233
+ try {
234
+ const parsed = JSON.parse(out);
235
+ const streams = parsed.streams ?? [];
236
+ return done({
237
+ video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
238
+ audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
239
+ });
240
+ }
241
+ catch {
242
+ return done(empty);
243
+ }
244
+ });
245
+ });
246
+ }
247
+ /**
248
+ * How to get this file into a browser, given what is inside it.
249
+ *
250
+ * A container a browser will not open says nothing about the streams within:
251
+ * most Matroska holds H.264, which every browser decodes, and only the wrapper
252
+ * is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
253
+ * would cost a core per viewer and look worse. So the streams decide, one part
254
+ * at a time -- a film can have its video copied and only its DTS re-encoded.
255
+ */
256
+ export function videoArgs(codecs) {
257
+ // What a browser can play inside MP4 without help.
258
+ const keepVideo = codecs.video === "h264";
259
+ const keepAudio = codecs.audio === "aac" || codecs.audio === "mp3";
260
+ return [
261
+ "-c:v", keepVideo ? "copy" : "libx264",
262
+ ...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
263
+ "-c:a", keepAudio ? "copy" : "aac",
264
+ ...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]),
265
+ "-f", "mp4",
266
+ // Fragmented, because this is a pipe: a normal MP4 writes its index at the
267
+ // end, which for a stream never arrives and for a browser means nothing
268
+ // plays at all.
269
+ "-movflags", "frag_keyframe+empty_moov+default_base_moof",
270
+ ];
271
+ }
package/dist/server.d.ts CHANGED
@@ -205,6 +205,8 @@ export interface HandlerOptions {
205
205
  listenKey?: string | null;
206
206
  /** How to run ffmpeg, for the sources a browser cannot play by itself. */
207
207
  ffmpeg?: string[];
208
+ /** Where ffprobe is, for asking what is inside a file before re-encoding it. */
209
+ ffprobe?: string[];
208
210
  /** Who is listening, for the admin view. */
209
211
  connections?: Connections;
210
212
  /**
package/dist/server.js CHANGED
@@ -35,6 +35,7 @@ import { notifyAll, resendEmail, webPush } from "./notify.js";
35
35
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
36
36
  import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
37
37
  import { isRemote, playsInBrowser } from "./sources.js";
38
+ import { codecsOf, videoArgs } from "./audio.js";
38
39
  import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
39
40
  import { extname, join, normalize, resolve, sep } from "node:path";
40
41
  import { fileURLToPath } from "node:url";
@@ -1544,10 +1545,19 @@ export function createHandler(engine, options) {
1544
1545
  // to it raw is bytes it cannot play. Seeking is what this route is for
1545
1546
  // and transcoding gives it up, but an unseekable film beats a silent
1546
1547
  // one -- and the seekable formats are untouched.
1547
- if (playsInBrowser(file))
1548
+ if (playsInBrowser(file)) {
1548
1549
  sendFile(request, response, file);
1549
- else
1550
+ }
1551
+ else if (hasPicture(file)) {
1552
+ // A film. It used to arrive as MP3 with `-vn`, which is to say as a
1553
+ // soundtrack over a blank panel; what ffprobe finds inside decides how
1554
+ // little work it takes to keep the picture.
1555
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
1556
+ pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
1557
+ }
1558
+ else {
1550
1559
  transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
1560
+ }
1551
1561
  return;
1552
1562
  }
1553
1563
  // Whatever the source is, this comes back as MP3 a browser will play:
@@ -1748,7 +1758,18 @@ function liveAudio(request, response, engine, ffmpeg) {
1748
1758
  * player falls back to /api/media for a local file it can seek, and uses this
1749
1759
  * for everything else.
1750
1760
  */
1761
+ /** Audio, from whatever this is: the shape every non-browser source took. */
1751
1762
  function transcode(request, response, source, ffmpeg) {
1763
+ pipeFfmpeg(request, response, source, ffmpeg, ["-vn", "-f", "mp3", "-b:a", "192k"], "audio/mpeg");
1764
+ }
1765
+ /**
1766
+ * Run ffmpeg and hand its output straight to the caller.
1767
+ *
1768
+ * The output arguments belong to the caller, because the same plumbing carries
1769
+ * a film and a song and the only difference is what ffmpeg is asked to write --
1770
+ * which for a film is decided by what ffprobe found inside it.
1771
+ */
1772
+ function pipeFfmpeg(request, response, source, ffmpeg, outputArgs, contentType) {
1752
1773
  const [command, ...prefix] = ffmpeg;
1753
1774
  const child = spawn(command, [
1754
1775
  ...prefix,
@@ -1759,9 +1780,7 @@ function transcode(request, response, source, ffmpeg) {
1759
1780
  // when they are handed to it for a file on disk.
1760
1781
  ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
1761
1782
  "-i", source,
1762
- "-vn",
1763
- "-f", "mp3",
1764
- "-b:a", "192k",
1783
+ ...outputArgs,
1765
1784
  "-",
1766
1785
  ], { stdio: ["ignore", "pipe", "pipe"] });
1767
1786
  let failed = "";
@@ -1776,7 +1795,7 @@ function transcode(request, response, source, ffmpeg) {
1776
1795
  started = true;
1777
1796
  response.writeHead(200, {
1778
1797
  ...CORS,
1779
- "content-type": "audio/mpeg",
1798
+ "content-type": contentType,
1780
1799
  "cache-control": "no-store",
1781
1800
  // Length is unknowable up front, and a browser is happy without it.
1782
1801
  "transfer-encoding": "chunked",
@@ -2094,6 +2113,7 @@ export async function serve(argv, version = "0.1.0") {
2094
2113
  connections,
2095
2114
  paywall,
2096
2115
  ffmpeg: tools.ffmpeg,
2116
+ ffprobe: tools.ffprobe,
2097
2117
  load: (next) => loadSource(tools, next),
2098
2118
  ...(directory ? { directory } : {}),
2099
2119
  ...(follows ? { follows, vapidPublicKey } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
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
@@ -231,3 +231,81 @@ export function formatTime(seconds: number): string {
231
231
  const s = total % 60;
232
232
  return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
233
233
  }
234
+
235
+ /** What is actually inside a container, as opposed to what the name suggests. */
236
+ export interface Codecs {
237
+ /** e.g. "h264", "hevc", "vp9". Empty when there is no video stream. */
238
+ video: string;
239
+ /** e.g. "aac", "ac3", "dts". Empty when there is no audio stream. */
240
+ audio: string;
241
+ }
242
+
243
+ /**
244
+ * Ask ffprobe what the streams are, without holding the event loop.
245
+ *
246
+ * Deliberately not the spawnSync `probe` above: this one runs while a server is
247
+ * answering other requests, and a synchronous probe per media request is how
248
+ * the whole library came to be tagged with the process wedged solid.
249
+ */
250
+ export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
251
+ const [cmd, ...rest] = tools.ffprobe;
252
+ const empty: Codecs = { video: "", audio: "" };
253
+ if (!cmd) return empty;
254
+
255
+ return new Promise<Codecs>((done) => {
256
+ const child = spawn(
257
+ cmd,
258
+ [
259
+ ...rest,
260
+ "-v", "quiet",
261
+ "-print_format", "json",
262
+ "-show_entries", "stream=codec_type,codec_name",
263
+ path,
264
+ ],
265
+ { stdio: ["ignore", "pipe", "ignore"] },
266
+ );
267
+ let out = "";
268
+ child.stdout.on("data", (chunk: Buffer) => {
269
+ out += chunk.toString("utf8");
270
+ });
271
+ child.on("error", () => done(empty));
272
+ child.on("close", () => {
273
+ try {
274
+ const parsed = JSON.parse(out) as { streams?: { codec_type?: string; codec_name?: string }[] };
275
+ const streams = parsed.streams ?? [];
276
+ return done({
277
+ video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
278
+ audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
279
+ });
280
+ } catch {
281
+ return done(empty);
282
+ }
283
+ });
284
+ });
285
+ }
286
+
287
+ /**
288
+ * How to get this file into a browser, given what is inside it.
289
+ *
290
+ * A container a browser will not open says nothing about the streams within:
291
+ * most Matroska holds H.264, which every browser decodes, and only the wrapper
292
+ * is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
293
+ * would cost a core per viewer and look worse. So the streams decide, one part
294
+ * at a time -- a film can have its video copied and only its DTS re-encoded.
295
+ */
296
+ export function videoArgs(codecs: Codecs): string[] {
297
+ // What a browser can play inside MP4 without help.
298
+ const keepVideo = codecs.video === "h264";
299
+ const keepAudio = codecs.audio === "aac" || codecs.audio === "mp3";
300
+ return [
301
+ "-c:v", keepVideo ? "copy" : "libx264",
302
+ ...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
303
+ "-c:a", keepAudio ? "copy" : "aac",
304
+ ...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]),
305
+ "-f", "mp4",
306
+ // Fragmented, because this is a pipe: a normal MP4 writes its index at the
307
+ // end, which for a stream never arrives and for a browser means nothing
308
+ // plays at all.
309
+ "-movflags", "frag_keyframe+empty_moov+default_base_moof",
310
+ ];
311
+ }
package/src/server.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  paywallFromEnv,
56
56
  } from "./paywall.ts";
57
57
  import { isRemote, playsInBrowser } from "./sources.ts";
58
+ import { codecsOf, videoArgs } from "./audio.ts";
58
59
  import {
59
60
  allowedForListening,
60
61
  elevate,
@@ -693,6 +694,8 @@ export interface HandlerOptions {
693
694
  listenKey?: string | null;
694
695
  /** How to run ffmpeg, for the sources a browser cannot play by itself. */
695
696
  ffmpeg?: string[];
697
+ /** Where ffprobe is, for asking what is inside a file before re-encoding it. */
698
+ ffprobe?: string[];
696
699
  /** Who is listening, for the admin view. */
697
700
  connections?: Connections;
698
701
  /**
@@ -1813,8 +1816,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1813
1816
  // to it raw is bytes it cannot play. Seeking is what this route is for
1814
1817
  // and transcoding gives it up, but an unseekable film beats a silent
1815
1818
  // one -- and the seekable formats are untouched.
1816
- if (playsInBrowser(file)) sendFile(request, response, file);
1817
- else transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
1819
+ if (playsInBrowser(file)) {
1820
+ sendFile(request, response, file);
1821
+ } else if (hasPicture(file)) {
1822
+ // A film. It used to arrive as MP3 with `-vn`, which is to say as a
1823
+ // soundtrack over a blank panel; what ffprobe finds inside decides how
1824
+ // little work it takes to keep the picture.
1825
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
1826
+ pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
1827
+ } else {
1828
+ transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
1829
+ }
1818
1830
  return;
1819
1831
  }
1820
1832
 
@@ -2029,11 +2041,30 @@ function liveAudio(
2029
2041
  * player falls back to /api/media for a local file it can seek, and uses this
2030
2042
  * for everything else.
2031
2043
  */
2044
+ /** Audio, from whatever this is: the shape every non-browser source took. */
2032
2045
  function transcode(
2033
2046
  request: IncomingMessage,
2034
2047
  response: ServerResponse,
2035
2048
  source: string,
2036
2049
  ffmpeg: string[],
2050
+ ): void {
2051
+ pipeFfmpeg(request, response, source, ffmpeg, ["-vn", "-f", "mp3", "-b:a", "192k"], "audio/mpeg");
2052
+ }
2053
+
2054
+ /**
2055
+ * Run ffmpeg and hand its output straight to the caller.
2056
+ *
2057
+ * The output arguments belong to the caller, because the same plumbing carries
2058
+ * a film and a song and the only difference is what ffmpeg is asked to write --
2059
+ * which for a film is decided by what ffprobe found inside it.
2060
+ */
2061
+ function pipeFfmpeg(
2062
+ request: IncomingMessage,
2063
+ response: ServerResponse,
2064
+ source: string,
2065
+ ffmpeg: string[],
2066
+ outputArgs: string[],
2067
+ contentType: string,
2037
2068
  ): void {
2038
2069
  const [command, ...prefix] = ffmpeg as [string, ...string[]];
2039
2070
  const child = spawn(
@@ -2047,9 +2078,7 @@ function transcode(
2047
2078
  // when they are handed to it for a file on disk.
2048
2079
  ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
2049
2080
  "-i", source,
2050
- "-vn",
2051
- "-f", "mp3",
2052
- "-b:a", "192k",
2081
+ ...outputArgs,
2053
2082
  "-",
2054
2083
  ],
2055
2084
  { stdio: ["ignore", "pipe", "pipe"] },
@@ -2067,7 +2096,7 @@ function transcode(
2067
2096
  started = true;
2068
2097
  response.writeHead(200, {
2069
2098
  ...CORS,
2070
- "content-type": "audio/mpeg",
2099
+ "content-type": contentType,
2071
2100
  "cache-control": "no-store",
2072
2101
  // Length is unknowable up front, and a browser is happy without it.
2073
2102
  "transfer-encoding": "chunked",
@@ -2404,6 +2433,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2404
2433
  connections,
2405
2434
  paywall,
2406
2435
  ffmpeg: tools.ffmpeg,
2436
+ ffprobe: tools.ffprobe,
2407
2437
  load: (next) => loadSource(tools, next),
2408
2438
  ...(directory ? { directory } : {}),
2409
2439
  ...(follows ? { follows, vapidPublicKey } : {}),
package/web/dist/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1788941457384";
2
+ const CACHE = "nixamp-1788942007074";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",