nixamp 0.5.8 → 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 +25 -0
- package/dist/audio.js +65 -0
- package/dist/protocol.d.ts +8 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +36 -6
- package/package.json +1 -1
- package/src/audio.ts +78 -0
- package/src/protocol.ts +8 -0
- package/src/server.ts +48 -6
- package/web/dist/assets/hls-3VKVEQE3-Cj5Lwh7A.js +1 -0
- package/web/dist/assets/hls-n74Cnh8A.js +42 -0
- package/web/dist/assets/index-ABOb54bx.js +1 -0
- package/web/dist/assets/mpegts-CKFUPaK9.js +3 -0
- package/web/dist/assets/mpegts-LO6RVLD6-Bj_ZfP5s.js +1 -0
- package/web/dist/assets/native-C7JTKWJH-BUyIoj0P.js +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +7 -2
- package/web/dist/assets/index-BD37tdcP.js +0 -1
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/protocol.d.ts
CHANGED
|
@@ -12,6 +12,14 @@ export interface RemoteTrack {
|
|
|
12
12
|
album: string;
|
|
13
13
|
/** Seconds; 0 when ffprobe could not tell us. */
|
|
14
14
|
duration: number;
|
|
15
|
+
/**
|
|
16
|
+
* Whether this is a film rather than a song.
|
|
17
|
+
*
|
|
18
|
+
* The server knows, because it has the path; a remote had been guessing, and
|
|
19
|
+
* guessing wrong -- every remote track went to the audio element, so a video
|
|
20
|
+
* a browser could show played its soundtrack over a blank panel.
|
|
21
|
+
*/
|
|
22
|
+
video?: boolean;
|
|
15
23
|
}
|
|
16
24
|
/** Everything a remote needs to draw the player. */
|
|
17
25
|
export interface Snapshot {
|
package/dist/server.d.ts
CHANGED
|
@@ -135,6 +135,7 @@ export interface Engine {
|
|
|
135
135
|
stop(): void;
|
|
136
136
|
}
|
|
137
137
|
export declare function toRemoteTracks(tracks: Track[]): RemoteTrack[];
|
|
138
|
+
export declare function hasPicture(path: string): boolean;
|
|
138
139
|
/**
|
|
139
140
|
* The headless player: the terminal app's engine without the terminal.
|
|
140
141
|
* One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
|
|
@@ -204,6 +205,8 @@ export interface HandlerOptions {
|
|
|
204
205
|
listenKey?: string | null;
|
|
205
206
|
/** How to run ffmpeg, for the sources a browser cannot play by itself. */
|
|
206
207
|
ffmpeg?: string[];
|
|
208
|
+
/** Where ffprobe is, for asking what is inside a file before re-encoding it. */
|
|
209
|
+
ffprobe?: string[];
|
|
207
210
|
/** Who is listening, for the admin view. */
|
|
208
211
|
connections?: Connections;
|
|
209
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";
|
|
@@ -269,8 +270,18 @@ export function toRemoteTracks(tracks) {
|
|
|
269
270
|
artist: t.artist,
|
|
270
271
|
album: t.album,
|
|
271
272
|
duration: t.duration,
|
|
273
|
+
// Said out loud, because a remote cannot see the path and had been sending
|
|
274
|
+
// every track to the audio element -- a film's soundtrack over a blank
|
|
275
|
+
// panel, which is exactly what it looked like.
|
|
276
|
+
...(hasPicture(t.path) ? { video: true } : {}),
|
|
272
277
|
}));
|
|
273
278
|
}
|
|
279
|
+
/** Video containers, as opposed to the songs that are most of a library. */
|
|
280
|
+
const PICTURE = new Set([".mp4", ".mkv", ".avi", ".mov", ".m4v", ".webm", ".mpg", ".mpeg", ".wmv", ".flv"]);
|
|
281
|
+
export function hasPicture(path) {
|
|
282
|
+
const dot = path.lastIndexOf(".");
|
|
283
|
+
return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
|
|
284
|
+
}
|
|
274
285
|
/**
|
|
275
286
|
* The headless player: the terminal app's engine without the terminal.
|
|
276
287
|
* One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
|
|
@@ -1534,10 +1545,19 @@ export function createHandler(engine, options) {
|
|
|
1534
1545
|
// to it raw is bytes it cannot play. Seeking is what this route is for
|
|
1535
1546
|
// and transcoding gives it up, but an unseekable film beats a silent
|
|
1536
1547
|
// one -- and the seekable formats are untouched.
|
|
1537
|
-
if (playsInBrowser(file))
|
|
1548
|
+
if (playsInBrowser(file)) {
|
|
1538
1549
|
sendFile(request, response, file);
|
|
1539
|
-
|
|
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 {
|
|
1540
1559
|
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
1560
|
+
}
|
|
1541
1561
|
return;
|
|
1542
1562
|
}
|
|
1543
1563
|
// Whatever the source is, this comes back as MP3 a browser will play:
|
|
@@ -1738,7 +1758,18 @@ function liveAudio(request, response, engine, ffmpeg) {
|
|
|
1738
1758
|
* player falls back to /api/media for a local file it can seek, and uses this
|
|
1739
1759
|
* for everything else.
|
|
1740
1760
|
*/
|
|
1761
|
+
/** Audio, from whatever this is: the shape every non-browser source took. */
|
|
1741
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) {
|
|
1742
1773
|
const [command, ...prefix] = ffmpeg;
|
|
1743
1774
|
const child = spawn(command, [
|
|
1744
1775
|
...prefix,
|
|
@@ -1749,9 +1780,7 @@ function transcode(request, response, source, ffmpeg) {
|
|
|
1749
1780
|
// when they are handed to it for a file on disk.
|
|
1750
1781
|
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
1751
1782
|
"-i", source,
|
|
1752
|
-
|
|
1753
|
-
"-f", "mp3",
|
|
1754
|
-
"-b:a", "192k",
|
|
1783
|
+
...outputArgs,
|
|
1755
1784
|
"-",
|
|
1756
1785
|
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
1757
1786
|
let failed = "";
|
|
@@ -1766,7 +1795,7 @@ function transcode(request, response, source, ffmpeg) {
|
|
|
1766
1795
|
started = true;
|
|
1767
1796
|
response.writeHead(200, {
|
|
1768
1797
|
...CORS,
|
|
1769
|
-
"content-type":
|
|
1798
|
+
"content-type": contentType,
|
|
1770
1799
|
"cache-control": "no-store",
|
|
1771
1800
|
// Length is unknowable up front, and a browser is happy without it.
|
|
1772
1801
|
"transfer-encoding": "chunked",
|
|
@@ -2084,6 +2113,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2084
2113
|
connections,
|
|
2085
2114
|
paywall,
|
|
2086
2115
|
ffmpeg: tools.ffmpeg,
|
|
2116
|
+
ffprobe: tools.ffprobe,
|
|
2087
2117
|
load: (next) => loadSource(tools, next),
|
|
2088
2118
|
...(directory ? { directory } : {}),
|
|
2089
2119
|
...(follows ? { follows, vapidPublicKey } : {}),
|
package/package.json
CHANGED
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/protocol.ts
CHANGED
|
@@ -13,6 +13,14 @@ export interface RemoteTrack {
|
|
|
13
13
|
album: string;
|
|
14
14
|
/** Seconds; 0 when ffprobe could not tell us. */
|
|
15
15
|
duration: number;
|
|
16
|
+
/**
|
|
17
|
+
* Whether this is a film rather than a song.
|
|
18
|
+
*
|
|
19
|
+
* The server knows, because it has the path; a remote had been guessing, and
|
|
20
|
+
* guessing wrong -- every remote track went to the audio element, so a video
|
|
21
|
+
* a browser could show played its soundtrack over a blank panel.
|
|
22
|
+
*/
|
|
23
|
+
video?: boolean;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
/** Everything a remote needs to draw the player. */
|
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,
|
|
@@ -391,9 +392,21 @@ export function toRemoteTracks(tracks: Track[]): RemoteTrack[] {
|
|
|
391
392
|
artist: t.artist,
|
|
392
393
|
album: t.album,
|
|
393
394
|
duration: t.duration,
|
|
395
|
+
// Said out loud, because a remote cannot see the path and had been sending
|
|
396
|
+
// every track to the audio element -- a film's soundtrack over a blank
|
|
397
|
+
// panel, which is exactly what it looked like.
|
|
398
|
+
...(hasPicture(t.path) ? { video: true } : {}),
|
|
394
399
|
}));
|
|
395
400
|
}
|
|
396
401
|
|
|
402
|
+
/** Video containers, as opposed to the songs that are most of a library. */
|
|
403
|
+
const PICTURE = new Set([".mp4", ".mkv", ".avi", ".mov", ".m4v", ".webm", ".mpg", ".mpeg", ".wmv", ".flv"]);
|
|
404
|
+
|
|
405
|
+
export function hasPicture(path: string): boolean {
|
|
406
|
+
const dot = path.lastIndexOf(".");
|
|
407
|
+
return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
|
|
408
|
+
}
|
|
409
|
+
|
|
397
410
|
/**
|
|
398
411
|
* The headless player: the terminal app's engine without the terminal.
|
|
399
412
|
* One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
|
|
@@ -681,6 +694,8 @@ export interface HandlerOptions {
|
|
|
681
694
|
listenKey?: string | null;
|
|
682
695
|
/** How to run ffmpeg, for the sources a browser cannot play by itself. */
|
|
683
696
|
ffmpeg?: string[];
|
|
697
|
+
/** Where ffprobe is, for asking what is inside a file before re-encoding it. */
|
|
698
|
+
ffprobe?: string[];
|
|
684
699
|
/** Who is listening, for the admin view. */
|
|
685
700
|
connections?: Connections;
|
|
686
701
|
/**
|
|
@@ -1801,8 +1816,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1801
1816
|
// to it raw is bytes it cannot play. Seeking is what this route is for
|
|
1802
1817
|
// and transcoding gives it up, but an unseekable film beats a silent
|
|
1803
1818
|
// one -- and the seekable formats are untouched.
|
|
1804
|
-
if (playsInBrowser(file))
|
|
1805
|
-
|
|
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
|
+
}
|
|
1806
1830
|
return;
|
|
1807
1831
|
}
|
|
1808
1832
|
|
|
@@ -2017,11 +2041,30 @@ function liveAudio(
|
|
|
2017
2041
|
* player falls back to /api/media for a local file it can seek, and uses this
|
|
2018
2042
|
* for everything else.
|
|
2019
2043
|
*/
|
|
2044
|
+
/** Audio, from whatever this is: the shape every non-browser source took. */
|
|
2020
2045
|
function transcode(
|
|
2021
2046
|
request: IncomingMessage,
|
|
2022
2047
|
response: ServerResponse,
|
|
2023
2048
|
source: string,
|
|
2024
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,
|
|
2025
2068
|
): void {
|
|
2026
2069
|
const [command, ...prefix] = ffmpeg as [string, ...string[]];
|
|
2027
2070
|
const child = spawn(
|
|
@@ -2035,9 +2078,7 @@ function transcode(
|
|
|
2035
2078
|
// when they are handed to it for a file on disk.
|
|
2036
2079
|
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
2037
2080
|
"-i", source,
|
|
2038
|
-
|
|
2039
|
-
"-f", "mp3",
|
|
2040
|
-
"-b:a", "192k",
|
|
2081
|
+
...outputArgs,
|
|
2041
2082
|
"-",
|
|
2042
2083
|
],
|
|
2043
2084
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
@@ -2055,7 +2096,7 @@ function transcode(
|
|
|
2055
2096
|
started = true;
|
|
2056
2097
|
response.writeHead(200, {
|
|
2057
2098
|
...CORS,
|
|
2058
|
-
"content-type":
|
|
2099
|
+
"content-type": contentType,
|
|
2059
2100
|
"cache-control": "no-store",
|
|
2060
2101
|
// Length is unknowable up front, and a browser is happy without it.
|
|
2061
2102
|
"transfer-encoding": "chunked",
|
|
@@ -2392,6 +2433,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2392
2433
|
connections,
|
|
2393
2434
|
paywall,
|
|
2394
2435
|
ffmpeg: tools.ffmpeg,
|
|
2436
|
+
ffprobe: tools.ffprobe,
|
|
2395
2437
|
load: (next) => loadSource(tools, next),
|
|
2396
2438
|
...(directory ? { directory } : {}),
|
|
2397
2439
|
...(follows ? { follows, vapidPublicKey } : {}),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{t as e}from"./index-ABOb54bx.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|