nixamp 0.13.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,7 +116,7 @@ export declare class Channel {
116
116
  * because you looked away, and a room where the picture depends on who is
117
117
  * in it is not a room anybody can be invited to.
118
118
  */
119
- pull(source: string, encode: string[], paced?: boolean, stall?: number, input?: string[]): void;
119
+ pull(source: string, encode: string[], paced?: boolean, stall?: number, input?: string[], audio?: string): void;
120
120
  /**
121
121
  * Start the source over, now.
122
122
  *
@@ -209,7 +209,7 @@ export declare class Channels {
209
209
  * about it is the same too, which is the point -- a re-stream stops being a
210
210
  * special case and becomes one more thing that is on.
211
211
  */
212
- pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean, stall?: number, input?: string[]): Channel | null;
212
+ pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean, stall?: number, input?: string[], audio?: string): Channel | null;
213
213
  /**
214
214
  * Dial a pulled channel's source again, now. False for a channel that is
215
215
  * not there or is not ours to dial: a publisher's stream restarts at the
package/dist/channels.js CHANGED
@@ -168,12 +168,18 @@ export class Channel {
168
168
  * because you looked away, and a room where the picture depends on who is
169
169
  * in it is not a room anybody can be invited to.
170
170
  */
171
- pull(source, encode, paced = true, stall = STALL, input = []) {
171
+ pull(source, encode, paced = true, stall = STALL, input = [], audio = "") {
172
172
  this.stall = stall;
173
173
  if (this.info.kind === "video")
174
174
  this.fragments = new Fragments();
175
175
  const [command, ...prefix] = this.options.ffmpeg;
176
176
  const remote = /^https?:\/\//i.test(source);
177
+ // What every remote input is told: dial again when the CDN drops it, and
178
+ // give up on a socket that has gone quiet. Input options apply to the
179
+ // input that follows them, so a second input is told again.
180
+ const remoteArgs = remote
181
+ ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5", "-rw_timeout", String(stall * 1000)]
182
+ : [];
177
183
  const dial = () => {
178
184
  if (this.closing)
179
185
  return;
@@ -185,12 +191,11 @@ export class Channel {
185
191
  // A dropped source is normal over hours, and a channel that dies
186
192
  // the first time a CDN hiccups is not a channel anybody can rely
187
193
  // on. ffmpeg redials on its own before we have to.
188
- ...(remote ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
189
194
  // A connection that stops answering is an error after this long,
190
- // and an error is a thing the reconnect above knows what to do
191
- // with. Without it a silent socket is waited on for ever. In
195
+ // and an error is a thing the reconnect knows what to do with.
196
+ // Without it a silent socket is waited on for ever. In
192
197
  // microseconds, as ffmpeg wants it.
193
- ...(remote ? ["-rw_timeout", String(stall * 1000)] : []),
198
+ ...remoteArgs,
194
199
  // Real time, always. A file read as fast as the disk allows is an
195
200
  // hour of film in ninety seconds and a room that cannot be in it
196
201
  // together; a live source is already paced and loses nothing.
@@ -200,6 +205,9 @@ export class Channel {
200
205
  // and a CDN that got them from yt-dlp and not from us answers 403.
201
206
  ...input,
202
207
  "-i", source,
208
+ // The sound, when the site keeps it apart from the picture: a
209
+ // second input, dialled the same way, that the encode maps in.
210
+ ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, "-i", audio] : []),
203
211
  ...encode,
204
212
  "pipe:1",
205
213
  ], { stdio: ["ignore", "pipe", "pipe"] });
@@ -560,7 +568,7 @@ export class Channels {
560
568
  * about it is the same too, which is the point -- a re-stream stops being a
561
569
  * special case and becomes one more thing that is on.
562
570
  */
563
- pull(id, name, source, encode, kind, paced = true, stall = STALL, input = []) {
571
+ pull(id, name, source, encode, kind, paced = true, stall = STALL, input = [], audio = "") {
564
572
  if (this.open.has(id))
565
573
  return null;
566
574
  const channel = new Channel({
@@ -575,7 +583,7 @@ export class Channels {
575
583
  source,
576
584
  }, this.options, (gone) => this.open.delete(gone));
577
585
  this.open.set(id, channel);
578
- channel.pull(source, encode, paced, stall, input);
586
+ channel.pull(source, encode, paced, stall, input, audio);
579
587
  return channel;
580
588
  }
581
589
  /**
package/dist/links.d.ts CHANGED
@@ -3,6 +3,15 @@ export interface ResolvedLink {
3
3
  title: string;
4
4
  /** Where the bytes are: what ffmpeg is pointed at. */
5
5
  media: string;
6
+ /**
7
+ * The sound, when it comes separately. YouTube stopped offering a single
8
+ * stream with both picture and sound in it: every format is video alone
9
+ * or audio alone, and a player that insists on one file gets "Requested
10
+ * format is not available" for every link. So the two are asked for as a
11
+ * pair, and ffmpeg reads both and puts them together. "" when `media`
12
+ * already has the sound in it.
13
+ */
14
+ audio: string;
6
15
  /** Whether it is on now, which decides pacing and whether it can be saved. */
7
16
  live: boolean;
8
17
  /** Seconds, when known. */
@@ -41,6 +50,14 @@ export declare function directLink(url: string): ResolvedLink;
41
50
  * second ago. The user agent has its own flag; the rest go as one block.
42
51
  */
43
52
  export declare function inputArgsFor(headers: Record<string, string>): string[];
53
+ /**
54
+ * How ffmpeg is asked to hand over a picture and a sound as one file, down a
55
+ * pipe. yt-dlp merges the two only into a file it can seek in, so a pair
56
+ * cannot go down its pipe; ffmpeg copies both into a fragmented MP4 -- or
57
+ * Matroska when the picture is not MP4, since a VP9 picture in an MP4 is a
58
+ * file few things open -- and writes as it goes.
59
+ */
60
+ export declare function mergeDownloadArgs(link: ResolvedLink): string[];
44
61
  /** A file name for a download: the title, made safe, with the right ending. */
45
62
  export declare function fileNameFor(link: ResolvedLink, audioOnly: boolean): string;
46
63
  /** What a browser should call the bytes, from the file name's ending. */
package/dist/links.js CHANGED
Binary file
package/dist/server.d.ts CHANGED
@@ -370,7 +370,7 @@ export declare const MAX_ON_DEMAND = 4;
370
370
  * ones back, so that both agree on what a source is encoded as. Null when
371
371
  * that channel id is already on.
372
372
  */
373
- export declare function pullChannel(channels: Channels, ffprobe: string[], id: string, name: string, source: string, input?: string[]): Promise<Channel | null>;
373
+ export declare function pullChannel(channels: Channels, ffprobe: string[], id: string, name: string, source: string, input?: string[], audio?: string): Promise<Channel | null>;
374
374
  /**
375
375
  * A Netscape cookies file beside the state, if the operator has put one there.
376
376
  *
package/dist/server.js CHANGED
@@ -36,7 +36,7 @@ import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
36
36
  import { PartyLine, telnyxSms } from "./partyline.js";
37
37
  import { HlsPackagers, withKey } from "./hls.js";
38
38
  import { DEFAULT_SITE as NICHEDB, Enricher, FIXTURE_TTL_MS } from "./enrich.js";
39
- import { contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat, } from "./links.js";
39
+ import { contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, mergeDownloadArgs, playableLink, resolveLink, saveFormat, } from "./links.js";
40
40
  import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.js";
41
41
  import pg from "pg";
42
42
  import { Follows, phoneFrom } from "./follows.js";
@@ -769,15 +769,29 @@ export const MAX_ON_DEMAND = 4;
769
769
  * ones back, so that both agree on what a source is encoded as. Null when
770
770
  * that channel id is already on.
771
771
  */
772
- export async function pullChannel(channels, ffprobe, id, name, source, input = []) {
773
- const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source, input);
772
+ export async function pullChannel(channels, ffprobe, id, name, source, input = [], audio = "") {
773
+ const tools = { ffmpeg: [], ffprobe, play: null };
774
+ // A pair is probed as a pair: the picture's file has no sound in it, and
775
+ // asked alone it would read as a silent film. The sound's codec comes from
776
+ // the sound's file; the picture's, and the container, from the picture's.
777
+ const [picture, sound] = await Promise.all([
778
+ codecsOf(tools, source, input),
779
+ audio ? codecsOf(tools, audio, input) : Promise.resolve(null),
780
+ ]);
781
+ const codecs = sound ? { ...picture, audio: sound.audio } : picture;
774
782
  const kind = codecs.video === "" ? "audio" : "video";
775
783
  const encode = kind === "video"
776
- ? videoArgs(codecs)
784
+ ? [
785
+ // Which streams from which input, when there are two: the picture
786
+ // from the first, the sound from the second. With one input ffmpeg
787
+ // picks for itself, as it always did.
788
+ ...(audio ? ["-map", "0:v:0", "-map", "1:a:0"] : []),
789
+ ...videoArgs(codecs),
790
+ ]
777
791
  // No picture in it, so none is invented: MP3 is the thing every browser
778
792
  // plays and the thing a listener can join halfway through.
779
793
  : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
780
- return channels.pull(id, name, source, encode, kind, true, undefined, input);
794
+ return channels.pull(id, name, source, encode, kind, true, undefined, input, kind === "video" ? audio : "");
781
795
  }
782
796
  /** What a link resolves to, kept so a download can be named without asking twice. */
783
797
  const links = new Map();
@@ -2387,7 +2401,7 @@ export function createHandler(engine, options) {
2387
2401
  }
2388
2402
  links.set(link, resolved);
2389
2403
  if (!options.channels.has(channelId)) {
2390
- const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers));
2404
+ const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers), resolved.audio);
2391
2405
  if (!started) {
2392
2406
  json(response, 409, { error: "that link is already starting" });
2393
2407
  return;
@@ -2430,11 +2444,14 @@ export function createHandler(engine, options) {
2430
2444
  // as the plain MP3 the site also offers, and ".m4a" on an MP3 is a
2431
2445
  // file nothing will open.
2432
2446
  const saved = await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "", format: saveFormat(audioOnly) });
2433
- const fileName = fileNameFor("error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext }, audioOnly);
2434
- const [command, ...prefix] = options.ytdlp;
2435
- const child = spawn(command, [...prefix, ...downloadArgs(link, audioOnly, options.cookies ?? "")], {
2436
- stdio: ["ignore", "pipe", "pipe"],
2437
- });
2447
+ const chosen = "error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext, media: saved.media, audio: saved.audio, headers: saved.headers };
2448
+ const fileName = fileNameFor(chosen, audioOnly);
2449
+ // A picture and a sound kept apart by the site are put together here
2450
+ // by ffmpeg, as they go: yt-dlp only merges into a file it can seek
2451
+ // in, which a pipe is not. Anything else yt-dlp hands over whole.
2452
+ const paired = !audioOnly && chosen.audio !== "";
2453
+ const [command, ...prefix] = (paired ? (options.ffmpeg ?? ["ffmpeg"]) : options.ytdlp);
2454
+ const child = spawn(command, [...prefix, ...(paired ? mergeDownloadArgs(chosen) : downloadArgs(link, audioOnly, options.cookies ?? ""))], { stdio: ["ignore", "pipe", "pipe"] });
2438
2455
  response.writeHead(200, {
2439
2456
  "content-type": contentTypeFor(fileName),
2440
2457
  "content-disposition": `attachment; filename="${fileName.replace(/["\\]/g, "")}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/channels.ts CHANGED
@@ -219,11 +219,17 @@ export class Channel {
219
219
  * because you looked away, and a room where the picture depends on who is
220
220
  * in it is not a room anybody can be invited to.
221
221
  */
222
- pull(source: string, encode: string[], paced = true, stall = STALL, input: string[] = []): void {
222
+ pull(source: string, encode: string[], paced = true, stall = STALL, input: string[] = [], audio = ""): void {
223
223
  this.stall = stall;
224
224
  if (this.info.kind === "video") this.fragments = new Fragments();
225
225
  const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
226
226
  const remote = /^https?:\/\//i.test(source);
227
+ // What every remote input is told: dial again when the CDN drops it, and
228
+ // give up on a socket that has gone quiet. Input options apply to the
229
+ // input that follows them, so a second input is told again.
230
+ const remoteArgs = remote
231
+ ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5", "-rw_timeout", String(stall * 1000)]
232
+ : [];
227
233
 
228
234
  const dial = (): void => {
229
235
  if (this.closing) return;
@@ -237,12 +243,11 @@ export class Channel {
237
243
  // A dropped source is normal over hours, and a channel that dies
238
244
  // the first time a CDN hiccups is not a channel anybody can rely
239
245
  // on. ffmpeg redials on its own before we have to.
240
- ...(remote ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
241
246
  // A connection that stops answering is an error after this long,
242
- // and an error is a thing the reconnect above knows what to do
243
- // with. Without it a silent socket is waited on for ever. In
247
+ // and an error is a thing the reconnect knows what to do with.
248
+ // Without it a silent socket is waited on for ever. In
244
249
  // microseconds, as ffmpeg wants it.
245
- ...(remote ? ["-rw_timeout", String(stall * 1000)] : []),
250
+ ...remoteArgs,
246
251
  // Real time, always. A file read as fast as the disk allows is an
247
252
  // hour of film in ninety seconds and a room that cannot be in it
248
253
  // together; a live source is already paced and loses nothing.
@@ -252,6 +257,9 @@ export class Channel {
252
257
  // and a CDN that got them from yt-dlp and not from us answers 403.
253
258
  ...input,
254
259
  "-i", source,
260
+ // The sound, when the site keeps it apart from the picture: a
261
+ // second input, dialled the same way, that the encode maps in.
262
+ ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, "-i", audio] : []),
255
263
  ...encode,
256
264
  "pipe:1",
257
265
  ],
@@ -617,6 +625,7 @@ export class Channels {
617
625
  paced = true,
618
626
  stall = STALL,
619
627
  input: string[] = [],
628
+ audio = "",
620
629
  ): Channel | null {
621
630
  if (this.open.has(id)) return null;
622
631
  const channel = new Channel(
@@ -635,7 +644,7 @@ export class Channels {
635
644
  (gone) => this.open.delete(gone),
636
645
  );
637
646
  this.open.set(id, channel);
638
- channel.pull(source, encode, paced, stall, input);
647
+ channel.pull(source, encode, paced, stall, input, audio);
639
648
  return channel;
640
649
  }
641
650
 
package/src/links.ts CHANGED
Binary file
package/src/server.ts CHANGED
@@ -54,7 +54,8 @@ import { PartyLine, telnyxSms } from "./partyline.ts";
54
54
  import { HlsPackagers, withKey } from "./hls.ts";
55
55
  import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
56
56
  import {
57
- contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat,
57
+ contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, mergeDownloadArgs, playableLink, resolveLink,
58
+ saveFormat,
58
59
  type ResolvedLink,
59
60
  } from "./links.ts";
60
61
  import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.ts";
@@ -985,15 +986,30 @@ export async function pullChannel(
985
986
  name: string,
986
987
  source: string,
987
988
  input: string[] = [],
989
+ audio = "",
988
990
  ): Promise<Channel | null> {
989
- const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source, input);
991
+ const tools = { ffmpeg: [], ffprobe, play: null };
992
+ // A pair is probed as a pair: the picture's file has no sound in it, and
993
+ // asked alone it would read as a silent film. The sound's codec comes from
994
+ // the sound's file; the picture's, and the container, from the picture's.
995
+ const [picture, sound] = await Promise.all([
996
+ codecsOf(tools, source, input),
997
+ audio ? codecsOf(tools, audio, input) : Promise.resolve(null),
998
+ ]);
999
+ const codecs = sound ? { ...picture, audio: sound.audio } : picture;
990
1000
  const kind = codecs.video === "" ? "audio" : "video";
991
1001
  const encode = kind === "video"
992
- ? videoArgs(codecs)
1002
+ ? [
1003
+ // Which streams from which input, when there are two: the picture
1004
+ // from the first, the sound from the second. With one input ffmpeg
1005
+ // picks for itself, as it always did.
1006
+ ...(audio ? ["-map", "0:v:0", "-map", "1:a:0"] : []),
1007
+ ...videoArgs(codecs),
1008
+ ]
993
1009
  // No picture in it, so none is invented: MP3 is the thing every browser
994
1010
  // plays and the thing a listener can join halfway through.
995
1011
  : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
996
- return channels.pull(id, name, source, encode, kind, true, undefined, input);
1012
+ return channels.pull(id, name, source, encode, kind, true, undefined, input, kind === "video" ? audio : "");
997
1013
  }
998
1014
 
999
1015
  /** What a link resolves to, kept so a download can be named without asking twice. */
@@ -2863,7 +2879,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2863
2879
  if (!options.channels.has(channelId)) {
2864
2880
  const started = await pullChannel(
2865
2881
  options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media,
2866
- inputArgsFor(resolved.headers),
2882
+ inputArgsFor(resolved.headers), resolved.audio,
2867
2883
  );
2868
2884
  if (!started) {
2869
2885
  json(response, 409, { error: "that link is already starting" });
@@ -2908,11 +2924,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2908
2924
  // as the plain MP3 the site also offers, and ".m4a" on an MP3 is a
2909
2925
  // file nothing will open.
2910
2926
  const saved = await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "", format: saveFormat(audioOnly) });
2911
- const fileName = fileNameFor("error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext }, audioOnly);
2912
- const [command, ...prefix] = options.ytdlp as [string, ...string[]];
2913
- const child = spawn(command, [...prefix, ...downloadArgs(link, audioOnly, options.cookies ?? "")], {
2914
- stdio: ["ignore", "pipe", "pipe"],
2915
- });
2927
+ const chosen = "error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext, media: saved.media, audio: saved.audio, headers: saved.headers };
2928
+ const fileName = fileNameFor(chosen, audioOnly);
2929
+ // A picture and a sound kept apart by the site are put together here
2930
+ // by ffmpeg, as they go: yt-dlp only merges into a file it can seek
2931
+ // in, which a pipe is not. Anything else yt-dlp hands over whole.
2932
+ const paired = !audioOnly && chosen.audio !== "";
2933
+ const [command, ...prefix] = (paired ? (options.ffmpeg ?? ["ffmpeg"]) : options.ytdlp) as [string, ...string[]];
2934
+ const child = spawn(
2935
+ command,
2936
+ [...prefix, ...(paired ? mergeDownloadArgs(chosen) : downloadArgs(link, audioOnly, options.cookies ?? ""))],
2937
+ { stdio: ["ignore", "pipe", "pipe"] },
2938
+ );
2916
2939
  response.writeHead(200, {
2917
2940
  "content-type": contentTypeFor(fileName),
2918
2941
  "content-disposition": `attachment; filename="${fileName.replace(/["\\]/g, "")}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
package/web/dist/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1789114815909";
2
+ const CACHE = "nixamp-1789117208278";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",