nixamp 0.5.9 → 0.5.11
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/attach.js +5 -1
- package/dist/audio.d.ts +25 -0
- package/dist/audio.js +65 -0
- package/dist/protocol.d.ts +34 -2
- package/dist/protocol.js +11 -0
- package/dist/server.d.ts +16 -2
- package/dist/server.js +51 -18
- package/package.json +1 -1
- package/src/attach.ts +5 -1
- package/src/audio.ts +78 -0
- package/src/protocol.ts +37 -2
- package/src/server.ts +64 -19
- package/web/dist/assets/{hls-3VKVEQE3-Cj5Lwh7A.js → hls-3VKVEQE3-Bug6MFON.js} +1 -1
- package/web/dist/assets/index-CRfW9BIP.js +1 -0
- package/web/dist/assets/{mpegts-CKFUPaK9.js → mpegts-BtEekTJ6.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Bj_ZfP5s.js → mpegts-LO6RVLD6-DW1kEon1.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-ABOb54bx.js +0 -1
package/dist/attach.js
CHANGED
|
@@ -23,7 +23,11 @@ import { KEY_HEADER } from "./share.js";
|
|
|
23
23
|
const RECONNECT_MS = 1000;
|
|
24
24
|
/** A remote track has no path, because no filesystem path leaves the machine. */
|
|
25
25
|
export function applySnapshot(state, snapshot) {
|
|
26
|
-
|
|
26
|
+
// A frame without a list is not an empty library, it is a frame with nothing
|
|
27
|
+
// new to say about it -- which is every frame but the first.
|
|
28
|
+
if (snapshot.tracks !== undefined) {
|
|
29
|
+
state.tracks = snapshot.tracks.map((track) => ({ path: "", ...track }));
|
|
30
|
+
}
|
|
27
31
|
state.index = snapshot.index;
|
|
28
32
|
state.playing = snapshot.playing;
|
|
29
33
|
state.position = snapshot.position;
|
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
|
@@ -25,7 +25,24 @@ export interface RemoteTrack {
|
|
|
25
25
|
export interface Snapshot {
|
|
26
26
|
/** Bumped on every push so a client can drop an out-of-order frame. */
|
|
27
27
|
revision: number;
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The library, sent when it is news and left out when it is not.
|
|
30
|
+
*
|
|
31
|
+
* It used to ride in every frame. At twelve frames a second over a library of
|
|
32
|
+
* five thousand, that is five megabytes a second of JSON for a client to
|
|
33
|
+
* parse on the thread that is also decoding the audio -- which is exactly
|
|
34
|
+
* what it sounded like. It is sent on the first frame of a subscription and
|
|
35
|
+
* again whenever the list actually changes; absent means "the same as
|
|
36
|
+
* before", and a client keeps what it had.
|
|
37
|
+
*/
|
|
38
|
+
tracks?: RemoteTrack[];
|
|
39
|
+
/**
|
|
40
|
+
* How many tracks there are, in every frame.
|
|
41
|
+
*
|
|
42
|
+
* A client that has not received a list yet still has to draw something, and
|
|
43
|
+
* a count is four bytes rather than half a megabyte.
|
|
44
|
+
*/
|
|
45
|
+
trackCount: number;
|
|
29
46
|
index: number;
|
|
30
47
|
playing: boolean;
|
|
31
48
|
position: number;
|
|
@@ -61,4 +78,19 @@ export declare const COMMAND_TYPES: readonly ["play", "toggle", "stop", "next",
|
|
|
61
78
|
export declare function parseCommand(input: unknown): Command | null;
|
|
62
79
|
/** The name a remote shows for a track. */
|
|
63
80
|
export declare function remoteName(track: RemoteTrack): string;
|
|
64
|
-
export declare function emptySnapshot():
|
|
81
|
+
export declare function emptySnapshot(): FullSnapshot;
|
|
82
|
+
/**
|
|
83
|
+
* Fold a frame into what the client already had.
|
|
84
|
+
*
|
|
85
|
+
* A frame without `tracks` is not a frame with no tracks: it is a frame that
|
|
86
|
+
* had nothing new to say about them. Every client needs this, so none of them
|
|
87
|
+
* should write it twice.
|
|
88
|
+
*/
|
|
89
|
+
export declare function merge(previous: FullSnapshot, incoming: Snapshot): FullSnapshot;
|
|
90
|
+
/**
|
|
91
|
+
* A snapshot a client has already folded, so the library is known to be there.
|
|
92
|
+
* Every reader wants this one; only the wire carries the other.
|
|
93
|
+
*/
|
|
94
|
+
export type FullSnapshot = Snapshot & {
|
|
95
|
+
tracks: RemoteTrack[];
|
|
96
|
+
};
|
package/dist/protocol.js
CHANGED
|
@@ -41,6 +41,7 @@ export function emptySnapshot() {
|
|
|
41
41
|
return {
|
|
42
42
|
revision: 0,
|
|
43
43
|
tracks: [],
|
|
44
|
+
trackCount: 0,
|
|
44
45
|
index: 0,
|
|
45
46
|
playing: false,
|
|
46
47
|
position: 0,
|
|
@@ -51,3 +52,13 @@ export function emptySnapshot() {
|
|
|
51
52
|
root: "",
|
|
52
53
|
};
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Fold a frame into what the client already had.
|
|
57
|
+
*
|
|
58
|
+
* A frame without `tracks` is not a frame with no tracks: it is a frame that
|
|
59
|
+
* had nothing new to say about them. Every client needs this, so none of them
|
|
60
|
+
* should write it twice.
|
|
61
|
+
*/
|
|
62
|
+
export function merge(previous, incoming) {
|
|
63
|
+
return { ...incoming, tracks: incoming.tracks ?? previous.tracks };
|
|
64
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -111,7 +111,8 @@ export declare function parseRange(header: string | undefined, size: number): By
|
|
|
111
111
|
export declare function safeJoin(rootDir: string, urlPath: string): string | null;
|
|
112
112
|
/** What the HTTP layer needs from a player. Tests hand it a fake. */
|
|
113
113
|
export interface Engine {
|
|
114
|
-
|
|
114
|
+
/** `withTracks` false leaves the library out, for a frame that is only motion. */
|
|
115
|
+
snapshot(withTracks?: boolean): Snapshot;
|
|
115
116
|
command(command: Command): void;
|
|
116
117
|
subscribe(listener: (snapshot: Snapshot) => void): () => void;
|
|
117
118
|
/** Absolute path of a track, or undefined when the index is not one. */
|
|
@@ -161,7 +162,11 @@ export declare class PlayerEngine implements Engine {
|
|
|
161
162
|
fps?: number);
|
|
162
163
|
private readonly silent;
|
|
163
164
|
private consume;
|
|
164
|
-
|
|
165
|
+
/**
|
|
166
|
+
* The current state. `withTracks` carries the library, which is worth half a
|
|
167
|
+
* megabyte on a real one and is only news when it has changed.
|
|
168
|
+
*/
|
|
169
|
+
snapshot(withTracks?: boolean): Snapshot;
|
|
165
170
|
trackPath(index: number): string | undefined;
|
|
166
171
|
command(command: Command): void;
|
|
167
172
|
private clamp;
|
|
@@ -169,6 +174,13 @@ export declare class PlayerEngine implements Engine {
|
|
|
169
174
|
private start;
|
|
170
175
|
private halt;
|
|
171
176
|
subscribe(listener: (snapshot: Snapshot) => void): () => void;
|
|
177
|
+
/**
|
|
178
|
+
* Send the state to everyone watching.
|
|
179
|
+
*
|
|
180
|
+
* The library goes only when `listChanged` says it has, which is what turned
|
|
181
|
+
* five megabytes a second into a few kilobytes: an analyser tick has nothing
|
|
182
|
+
* to say about the track list, and it fires twelve times a second.
|
|
183
|
+
*/
|
|
172
184
|
private push;
|
|
173
185
|
stop(): void;
|
|
174
186
|
replace(tracks: Track[], root: string): void;
|
|
@@ -205,6 +217,8 @@ export interface HandlerOptions {
|
|
|
205
217
|
listenKey?: string | null;
|
|
206
218
|
/** How to run ffmpeg, for the sources a browser cannot play by itself. */
|
|
207
219
|
ffmpeg?: string[];
|
|
220
|
+
/** Where ffprobe is, for asking what is inside a file before re-encoding it. */
|
|
221
|
+
ffprobe?: string[];
|
|
208
222
|
/** Who is listening, for the admin view. */
|
|
209
223
|
connections?: Connections;
|
|
210
224
|
/**
|
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";
|
|
@@ -347,10 +348,15 @@ export class PlayerEngine {
|
|
|
347
348
|
this.pending = joined.subarray(at);
|
|
348
349
|
this.dirty = true;
|
|
349
350
|
}
|
|
350
|
-
|
|
351
|
+
/**
|
|
352
|
+
* The current state. `withTracks` carries the library, which is worth half a
|
|
353
|
+
* megabyte on a real one and is only news when it has changed.
|
|
354
|
+
*/
|
|
355
|
+
snapshot(withTracks = true) {
|
|
351
356
|
return {
|
|
352
357
|
revision: this.revision,
|
|
353
|
-
tracks: toRemoteTracks(this.tracks),
|
|
358
|
+
...(withTracks ? { tracks: toRemoteTracks(this.tracks) } : {}),
|
|
359
|
+
trackCount: this.tracks.length,
|
|
354
360
|
index: this.state.index,
|
|
355
361
|
playing: this.state.playing,
|
|
356
362
|
position: this.state.position,
|
|
@@ -444,11 +450,18 @@ export class PlayerEngine {
|
|
|
444
450
|
}
|
|
445
451
|
};
|
|
446
452
|
}
|
|
447
|
-
|
|
453
|
+
/**
|
|
454
|
+
* Send the state to everyone watching.
|
|
455
|
+
*
|
|
456
|
+
* The library goes only when `listChanged` says it has, which is what turned
|
|
457
|
+
* five megabytes a second into a few kilobytes: an analyser tick has nothing
|
|
458
|
+
* to say about the track list, and it fires twelve times a second.
|
|
459
|
+
*/
|
|
460
|
+
push(listChanged = false) {
|
|
448
461
|
this.revision++;
|
|
449
462
|
if (this.listeners.size === 0)
|
|
450
463
|
return;
|
|
451
|
-
const snapshot = this.snapshot();
|
|
464
|
+
const snapshot = this.snapshot(listChanged);
|
|
452
465
|
for (const listener of this.listeners)
|
|
453
466
|
listener(snapshot);
|
|
454
467
|
}
|
|
@@ -467,7 +480,7 @@ export class PlayerEngine {
|
|
|
467
480
|
this.state.index = 0;
|
|
468
481
|
this.state.position = 0;
|
|
469
482
|
this.state.note = "";
|
|
470
|
-
this.push();
|
|
483
|
+
this.push(true);
|
|
471
484
|
}
|
|
472
485
|
retag(tracks, root) {
|
|
473
486
|
// Dropped rather than applied if the library moved underneath: somebody
|
|
@@ -479,8 +492,9 @@ export class PlayerEngine {
|
|
|
479
492
|
return;
|
|
480
493
|
this.tracks = tracks;
|
|
481
494
|
// No stop, no index reset: the only thing that changes is what the titles
|
|
482
|
-
// say, and every remote finds out because a snapshot goes out
|
|
483
|
-
this.
|
|
495
|
+
// say, and every remote finds out because a snapshot goes out -- carrying
|
|
496
|
+
// the list, since the titles are the whole point of this one.
|
|
497
|
+
this.push(true);
|
|
484
498
|
}
|
|
485
499
|
}
|
|
486
500
|
/** An engine with no library behind it, for the hosted PWA. */
|
|
@@ -1539,15 +1553,24 @@ export function createHandler(engine, options) {
|
|
|
1539
1553
|
json(response, 404, { error: "no such track" });
|
|
1540
1554
|
return;
|
|
1541
1555
|
}
|
|
1542
|
-
watch(request, response, "media", engine.snapshot().tracks[index]?.title ?? file);
|
|
1556
|
+
watch(request, response, "media", engine.snapshot().tracks?.[index]?.title ?? file);
|
|
1543
1557
|
// A browser asks for every track here, and a matroska or an avi handed
|
|
1544
1558
|
// to it raw is bytes it cannot play. Seeking is what this route is for
|
|
1545
1559
|
// and transcoding gives it up, but an unseekable film beats a silent
|
|
1546
1560
|
// one -- and the seekable formats are untouched.
|
|
1547
|
-
if (playsInBrowser(file))
|
|
1561
|
+
if (playsInBrowser(file)) {
|
|
1548
1562
|
sendFile(request, response, file);
|
|
1549
|
-
|
|
1563
|
+
}
|
|
1564
|
+
else if (hasPicture(file)) {
|
|
1565
|
+
// A film. It used to arrive as MP3 with `-vn`, which is to say as a
|
|
1566
|
+
// soundtrack over a blank panel; what ffprobe finds inside decides how
|
|
1567
|
+
// little work it takes to keep the picture.
|
|
1568
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
|
|
1569
|
+
pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
|
|
1570
|
+
}
|
|
1571
|
+
else {
|
|
1550
1572
|
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
1573
|
+
}
|
|
1551
1574
|
return;
|
|
1552
1575
|
}
|
|
1553
1576
|
// Whatever the source is, this comes back as MP3 a browser will play:
|
|
@@ -1562,11 +1585,11 @@ export function createHandler(engine, options) {
|
|
|
1562
1585
|
return;
|
|
1563
1586
|
}
|
|
1564
1587
|
const current = engine.snapshot();
|
|
1565
|
-
if (current.
|
|
1588
|
+
if (current.trackCount === 0) {
|
|
1566
1589
|
json(response, 404, { error: "nothing is playing" });
|
|
1567
1590
|
return;
|
|
1568
1591
|
}
|
|
1569
|
-
watch(request, response, "stream", current.tracks[current.index]?.title ?? "live");
|
|
1592
|
+
watch(request, response, "stream", current.tracks?.[current.index]?.title ?? "live");
|
|
1570
1593
|
liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"]);
|
|
1571
1594
|
return;
|
|
1572
1595
|
}
|
|
@@ -1581,7 +1604,7 @@ export function createHandler(engine, options) {
|
|
|
1581
1604
|
json(response, 403, { error: "media streaming is off" });
|
|
1582
1605
|
return;
|
|
1583
1606
|
}
|
|
1584
|
-
watch(request, response, "stream", engine.snapshot().tracks[index]?.title ?? source);
|
|
1607
|
+
watch(request, response, "stream", engine.snapshot().tracks?.[index]?.title ?? source);
|
|
1585
1608
|
transcode(request, response, source, options.ffmpeg ?? ["ffmpeg"]);
|
|
1586
1609
|
return;
|
|
1587
1610
|
}
|
|
@@ -1748,7 +1771,18 @@ function liveAudio(request, response, engine, ffmpeg) {
|
|
|
1748
1771
|
* player falls back to /api/media for a local file it can seek, and uses this
|
|
1749
1772
|
* for everything else.
|
|
1750
1773
|
*/
|
|
1774
|
+
/** Audio, from whatever this is: the shape every non-browser source took. */
|
|
1751
1775
|
function transcode(request, response, source, ffmpeg) {
|
|
1776
|
+
pipeFfmpeg(request, response, source, ffmpeg, ["-vn", "-f", "mp3", "-b:a", "192k"], "audio/mpeg");
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Run ffmpeg and hand its output straight to the caller.
|
|
1780
|
+
*
|
|
1781
|
+
* The output arguments belong to the caller, because the same plumbing carries
|
|
1782
|
+
* a film and a song and the only difference is what ffmpeg is asked to write --
|
|
1783
|
+
* which for a film is decided by what ffprobe found inside it.
|
|
1784
|
+
*/
|
|
1785
|
+
function pipeFfmpeg(request, response, source, ffmpeg, outputArgs, contentType) {
|
|
1752
1786
|
const [command, ...prefix] = ffmpeg;
|
|
1753
1787
|
const child = spawn(command, [
|
|
1754
1788
|
...prefix,
|
|
@@ -1759,9 +1793,7 @@ function transcode(request, response, source, ffmpeg) {
|
|
|
1759
1793
|
// when they are handed to it for a file on disk.
|
|
1760
1794
|
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
1761
1795
|
"-i", source,
|
|
1762
|
-
|
|
1763
|
-
"-f", "mp3",
|
|
1764
|
-
"-b:a", "192k",
|
|
1796
|
+
...outputArgs,
|
|
1765
1797
|
"-",
|
|
1766
1798
|
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
1767
1799
|
let failed = "";
|
|
@@ -1776,7 +1808,7 @@ function transcode(request, response, source, ffmpeg) {
|
|
|
1776
1808
|
started = true;
|
|
1777
1809
|
response.writeHead(200, {
|
|
1778
1810
|
...CORS,
|
|
1779
|
-
"content-type":
|
|
1811
|
+
"content-type": contentType,
|
|
1780
1812
|
"cache-control": "no-store",
|
|
1781
1813
|
// Length is unknowable up front, and a browser is happy without it.
|
|
1782
1814
|
"transfer-encoding": "chunked",
|
|
@@ -2094,6 +2126,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2094
2126
|
connections,
|
|
2095
2127
|
paywall,
|
|
2096
2128
|
ffmpeg: tools.ffmpeg,
|
|
2129
|
+
ffprobe: tools.ffprobe,
|
|
2097
2130
|
load: (next) => loadSource(tools, next),
|
|
2098
2131
|
...(directory ? { directory } : {}),
|
|
2099
2132
|
...(follows ? { follows, vapidPublicKey } : {}),
|
|
@@ -2321,7 +2354,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2321
2354
|
},
|
|
2322
2355
|
nowPlaying: () => {
|
|
2323
2356
|
const snapshot = engine.snapshot();
|
|
2324
|
-
return snapshot.tracks[snapshot.index]?.title ?? "";
|
|
2357
|
+
return snapshot.tracks?.[snapshot.index]?.title ?? "";
|
|
2325
2358
|
},
|
|
2326
2359
|
onConfig: (remote) => {
|
|
2327
2360
|
const next = applyRemoteConfig(paywallConfig, remote?.x402);
|
package/package.json
CHANGED
package/src/attach.ts
CHANGED
|
@@ -26,7 +26,11 @@ const RECONNECT_MS = 1000;
|
|
|
26
26
|
|
|
27
27
|
/** A remote track has no path, because no filesystem path leaves the machine. */
|
|
28
28
|
export function applySnapshot(state: State, snapshot: Snapshot): void {
|
|
29
|
-
|
|
29
|
+
// A frame without a list is not an empty library, it is a frame with nothing
|
|
30
|
+
// new to say about it -- which is every frame but the first.
|
|
31
|
+
if (snapshot.tracks !== undefined) {
|
|
32
|
+
state.tracks = snapshot.tracks.map((track) => ({ path: "", ...track }));
|
|
33
|
+
}
|
|
30
34
|
state.index = snapshot.index;
|
|
31
35
|
state.playing = snapshot.playing;
|
|
32
36
|
state.position = snapshot.position;
|
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
|
@@ -27,7 +27,24 @@ export interface RemoteTrack {
|
|
|
27
27
|
export interface Snapshot {
|
|
28
28
|
/** Bumped on every push so a client can drop an out-of-order frame. */
|
|
29
29
|
revision: number;
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* The library, sent when it is news and left out when it is not.
|
|
32
|
+
*
|
|
33
|
+
* It used to ride in every frame. At twelve frames a second over a library of
|
|
34
|
+
* five thousand, that is five megabytes a second of JSON for a client to
|
|
35
|
+
* parse on the thread that is also decoding the audio -- which is exactly
|
|
36
|
+
* what it sounded like. It is sent on the first frame of a subscription and
|
|
37
|
+
* again whenever the list actually changes; absent means "the same as
|
|
38
|
+
* before", and a client keeps what it had.
|
|
39
|
+
*/
|
|
40
|
+
tracks?: RemoteTrack[];
|
|
41
|
+
/**
|
|
42
|
+
* How many tracks there are, in every frame.
|
|
43
|
+
*
|
|
44
|
+
* A client that has not received a list yet still has to draw something, and
|
|
45
|
+
* a count is four bytes rather than half a megabyte.
|
|
46
|
+
*/
|
|
47
|
+
trackCount: number;
|
|
31
48
|
index: number;
|
|
32
49
|
playing: boolean;
|
|
33
50
|
position: number;
|
|
@@ -83,10 +100,11 @@ export function remoteName(track: RemoteTrack): string {
|
|
|
83
100
|
return track.artist ? `${track.artist} — ${track.title}` : track.title;
|
|
84
101
|
}
|
|
85
102
|
|
|
86
|
-
export function emptySnapshot():
|
|
103
|
+
export function emptySnapshot(): FullSnapshot {
|
|
87
104
|
return {
|
|
88
105
|
revision: 0,
|
|
89
106
|
tracks: [],
|
|
107
|
+
trackCount: 0,
|
|
90
108
|
index: 0,
|
|
91
109
|
playing: false,
|
|
92
110
|
position: 0,
|
|
@@ -97,3 +115,20 @@ export function emptySnapshot(): Snapshot {
|
|
|
97
115
|
root: "",
|
|
98
116
|
};
|
|
99
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Fold a frame into what the client already had.
|
|
121
|
+
*
|
|
122
|
+
* A frame without `tracks` is not a frame with no tracks: it is a frame that
|
|
123
|
+
* had nothing new to say about them. Every client needs this, so none of them
|
|
124
|
+
* should write it twice.
|
|
125
|
+
*/
|
|
126
|
+
export function merge(previous: FullSnapshot, incoming: Snapshot): FullSnapshot {
|
|
127
|
+
return { ...incoming, tracks: incoming.tracks ?? previous.tracks };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A snapshot a client has already folded, so the library is known to be there.
|
|
132
|
+
* Every reader wants this one; only the wire carries the other.
|
|
133
|
+
*/
|
|
134
|
+
export type FullSnapshot = Snapshot & { tracks: RemoteTrack[] };
|