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.
- package/dist/channels.d.ts +2 -2
- package/dist/channels.js +15 -7
- package/dist/links.d.ts +17 -0
- package/dist/links.js +0 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +28 -11
- package/package.json +1 -1
- package/src/channels.ts +15 -6
- package/src/links.ts +0 -0
- package/src/server.ts +33 -10
- package/web/dist/sw.js +1 -1
package/dist/channels.d.ts
CHANGED
|
@@ -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
|
|
191
|
-
//
|
|
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
|
-
...
|
|
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
|
|
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
|
-
?
|
|
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
|
|
2434
|
-
const
|
|
2435
|
-
|
|
2436
|
-
|
|
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
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
|
|
243
|
-
//
|
|
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
|
-
...
|
|
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,
|
|
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
|
|
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
|
-
?
|
|
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
|
|
2912
|
-
const
|
|
2913
|
-
|
|
2914
|
-
|
|
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