nixamp 0.28.34 → 0.28.36
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 +1 -1
- package/dist/audio.js +2 -2
- package/dist/channels.d.ts +10 -17
- package/dist/channels.js +88 -73
- package/dist/fragments.d.ts +5 -0
- package/dist/fragments.js +51 -0
- package/dist/playlist-input.d.ts +26 -0
- package/dist/playlist-input.js +95 -0
- package/dist/server.js +6 -7
- package/package.json +1 -1
- package/src/audio.ts +2 -2
- package/src/channels.ts +83 -73
- package/src/fragments.ts +46 -0
- package/src/playlist-input.ts +99 -0
- package/src/server.ts +6 -6
- package/web/dist/assets/{ar-VrGuqDJ1.js → ar-CRLmcpxV.js} +1 -1
- package/web/dist/assets/{cs-BeAjcU6v.js → cs-C7lpT9_7.js} +1 -1
- package/web/dist/assets/{da-BNZRvKIo.js → da-C6icvAjb.js} +1 -1
- package/web/dist/assets/{de-wojJIc28.js → de-B6Viw_1k.js} +1 -1
- package/web/dist/assets/{es-TBeIvWWd.js → es-h3YK7MBo.js} +1 -1
- package/web/dist/assets/{fi-BTJBgPsj.js → fi-tF8W-0sw.js} +1 -1
- package/web/dist/assets/{fr-RK0lilTn.js → fr-CPVGzn8e.js} +1 -1
- package/web/dist/assets/{hi-CBoAq3fV.js → hi-DiPAdtgw.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-BKjc5SVE.js → hls-3VKVEQE3-C_fSzE0p.js} +1 -1
- package/web/dist/assets/{hu-DQJWQrRO.js → hu-BWP27GQV.js} +1 -1
- package/web/dist/assets/{id-Cna1-87i.js → id-C9v9_aJK.js} +1 -1
- package/web/dist/assets/index-_aGaTsm2.js +3 -0
- package/web/dist/assets/index-jLCo7PIA.css +1 -0
- package/web/dist/assets/{it-C0-zmqYC.js → it-DtOTP5Bt.js} +1 -1
- package/web/dist/assets/{mpegts-BNQTCz1W.js → mpegts-DBK5aOjj.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Broe1vXI.js → mpegts-LO6RVLD6-0zaiW5HW.js} +1 -1
- package/web/dist/assets/{nl-lOW6ra6U.js → nl-CAX3A01l.js} +1 -1
- package/web/dist/assets/{ru-DFxf90Fd.js → ru-DuLcjNsF.js} +1 -1
- package/web/dist/assets/{sv-BGtVXk9A.js → sv-C3ZewPxY.js} +1 -1
- package/web/dist/assets/{uk-8tzzdKt6.js → uk-D7PUipxA.js} +1 -1
- package/web/dist/assets/{vi-B_ZVNIlv.js → vi-wUJlLppj.js} +1 -1
- package/web/dist/assets/{zh-4dtZ4IK9.js → zh-Bd_9PHqO.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +23 -23
- package/web/dist/assets/index-B4f3TCeT.js +0 -3
- package/web/dist/assets/index-BaDD_PRM.css +0 -1
package/dist/audio.d.ts
CHANGED
|
@@ -137,7 +137,7 @@ export interface Codecs {
|
|
|
137
137
|
* answering other requests, and a synchronous probe per media request is how
|
|
138
138
|
* the whole library came to be tagged with the process wedged solid.
|
|
139
139
|
*/
|
|
140
|
-
export declare function codecsOf(tools: Tools, path: string, input?: string[]): Promise<Codecs>;
|
|
140
|
+
export declare function codecsOf(tools: Tools, path: string, input?: string[], signal?: AbortSignal): Promise<Codecs>;
|
|
141
141
|
/**
|
|
142
142
|
* The title, artist and album a file carries in its tags, however the
|
|
143
143
|
* container spells the keys (ID3 says TIT2 but ffprobe says title; MP4 and
|
package/dist/audio.js
CHANGED
|
@@ -356,7 +356,7 @@ function readTags(stdout, fallback) {
|
|
|
356
356
|
* answering other requests, and a synchronous probe per media request is how
|
|
357
357
|
* the whole library came to be tagged with the process wedged solid.
|
|
358
358
|
*/
|
|
359
|
-
export async function codecsOf(tools, path, input = []) {
|
|
359
|
+
export async function codecsOf(tools, path, input = [], signal) {
|
|
360
360
|
const [cmd, ...rest] = tools.ffprobe;
|
|
361
361
|
const empty = { video: "", audio: "", container: "" };
|
|
362
362
|
if (!cmd)
|
|
@@ -380,7 +380,7 @@ export async function codecsOf(tools, path, input = []) {
|
|
|
380
380
|
// Headers the source's site expects, for a link resolved by yt-dlp.
|
|
381
381
|
...input,
|
|
382
382
|
path,
|
|
383
|
-
], { stdio: ["ignore", "pipe", "ignore"] });
|
|
383
|
+
], { stdio: ["ignore", "pipe", "ignore"], signal });
|
|
384
384
|
let out = "";
|
|
385
385
|
child.stdout.on("data", (chunk) => {
|
|
386
386
|
out += chunk.toString("utf8");
|
package/dist/channels.d.ts
CHANGED
|
@@ -35,9 +35,9 @@ export interface ChannelInfo {
|
|
|
35
35
|
redials?: number;
|
|
36
36
|
/**
|
|
37
37
|
* For a channel that plays a list: the entries, in order, and which one is
|
|
38
|
-
* on.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
38
|
+
* on. Local files are normalized into one continuous stream. After the
|
|
39
|
+
* final entry the show ends. A restart picks up at the saved entry and
|
|
40
|
+
* position within it.
|
|
41
41
|
*/
|
|
42
42
|
playlist?: string[];
|
|
43
43
|
playlistAt?: number;
|
|
@@ -104,6 +104,7 @@ export interface PullResume {
|
|
|
104
104
|
position: number;
|
|
105
105
|
/** For a list of things rather than one: every entry, in order. */
|
|
106
106
|
playlist?: string[];
|
|
107
|
+
playlistAt?: number;
|
|
107
108
|
}
|
|
108
109
|
/**
|
|
109
110
|
* A source read by us rather than by ffmpeg: what to tell ffmpeg it is,
|
|
@@ -159,18 +160,9 @@ export declare const BACKLOG_AUDIO: number;
|
|
|
159
160
|
* until the channel ended, however long that was.
|
|
160
161
|
*/
|
|
161
162
|
export declare const LISTENER_QUEUE: number;
|
|
162
|
-
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* Six seconds of 720p is about 4 MB. Six seconds of a 1080p transport stream
|
|
167
|
-
* copied straight through is nearer 12, and of 4K nearer 30 -- so a fixed
|
|
168
|
-
* 4 MB hands a 4K joiner under a second of video, which is the live edge with
|
|
169
|
-
* no cushion, which is the play-wait-play loop the backlog exists to prevent.
|
|
170
|
-
* So the cap follows the stream: seconds times the rate it is actually
|
|
171
|
-
* running at, between the old floor and a ceiling that keeps a channel's
|
|
172
|
-
* memory bounded whatever it is carrying.
|
|
173
|
-
*/
|
|
163
|
+
/** Media-time window for complete MP4 fragments. Byte limits are a separate
|
|
164
|
+
* memory ceiling; the measured byte rate is only a fallback for inputs with
|
|
165
|
+
* no usable fragment timestamps. It must never make low-bitrate video old. */
|
|
174
166
|
export declare const BACKLOG_SECONDS = 6;
|
|
175
167
|
export declare const BACKLOG_VIDEO_MAX: number;
|
|
176
168
|
/**
|
|
@@ -187,6 +179,7 @@ export declare const RATE_WINDOW_MS = 5000;
|
|
|
187
179
|
export declare function cleanId(value: unknown, fallback?: string): string;
|
|
188
180
|
export interface ChannelOptions {
|
|
189
181
|
ffmpeg: string[];
|
|
182
|
+
ffprobe?: string[];
|
|
190
183
|
onStart?: (info: ChannelInfo) => void;
|
|
191
184
|
onEnd?: (info: ChannelInfo) => void;
|
|
192
185
|
/**
|
|
@@ -250,6 +243,8 @@ export declare class Channel {
|
|
|
250
243
|
* can begin at; for MP3 any point will do, a frame announces itself.
|
|
251
244
|
*/
|
|
252
245
|
private recent;
|
|
246
|
+
private fragmentClock;
|
|
247
|
+
private fragmentTimes;
|
|
253
248
|
private recentBytes;
|
|
254
249
|
/** The rate window: when it opened, what has arrived in it, and what the last closed one measured. */
|
|
255
250
|
private rateStart;
|
|
@@ -266,8 +261,6 @@ export declare class Channel {
|
|
|
266
261
|
private readonly sourceTaps;
|
|
267
262
|
/** Pulls the plug on the current read-through, when there is one. */
|
|
268
263
|
private throughAbort;
|
|
269
|
-
/** ffmpeg concat manifest for a local multi-file live. */
|
|
270
|
-
private concatListPath;
|
|
271
264
|
constructor(info: ChannelInfo, options: ChannelOptions, onGone: (id: string) => void);
|
|
272
265
|
start(format: string): void;
|
|
273
266
|
/**
|
package/dist/channels.js
CHANGED
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
18
|
import { randomBytes } from "node:crypto";
|
|
19
|
-
import { mkdirSync,
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import { Fragments, isOpening } from "./fragments.js";
|
|
19
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { PlaylistInputStream } from "./playlist-input.js";
|
|
22
|
+
import { FragmentClock, Fragments, isOpening } from "./fragments.js";
|
|
23
23
|
/**
|
|
24
24
|
* How far back of the saved place a film is picked up from, in seconds. The
|
|
25
25
|
* place is written down every so often and a restart lands between two
|
|
@@ -65,18 +65,9 @@ export const BACKLOG_AUDIO = 64 * 1024;
|
|
|
65
65
|
* until the channel ended, however long that was.
|
|
66
66
|
*/
|
|
67
67
|
export const LISTENER_QUEUE = 16 * 1024 * 1024;
|
|
68
|
-
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* Six seconds of 720p is about 4 MB. Six seconds of a 1080p transport stream
|
|
73
|
-
* copied straight through is nearer 12, and of 4K nearer 30 -- so a fixed
|
|
74
|
-
* 4 MB hands a 4K joiner under a second of video, which is the live edge with
|
|
75
|
-
* no cushion, which is the play-wait-play loop the backlog exists to prevent.
|
|
76
|
-
* So the cap follows the stream: seconds times the rate it is actually
|
|
77
|
-
* running at, between the old floor and a ceiling that keeps a channel's
|
|
78
|
-
* memory bounded whatever it is carrying.
|
|
79
|
-
*/
|
|
68
|
+
/** Media-time window for complete MP4 fragments. Byte limits are a separate
|
|
69
|
+
* memory ceiling; the measured byte rate is only a fallback for inputs with
|
|
70
|
+
* no usable fragment timestamps. It must never make low-bitrate video old. */
|
|
80
71
|
export const BACKLOG_SECONDS = 6;
|
|
81
72
|
export const BACKLOG_VIDEO_MAX = 48 * 1024 * 1024;
|
|
82
73
|
/**
|
|
@@ -166,6 +157,8 @@ export class Channel {
|
|
|
166
157
|
* can begin at; for MP3 any point will do, a frame announces itself.
|
|
167
158
|
*/
|
|
168
159
|
recent = [];
|
|
160
|
+
fragmentClock = new FragmentClock();
|
|
161
|
+
fragmentTimes = new WeakMap();
|
|
169
162
|
recentBytes = 0;
|
|
170
163
|
/** The rate window: when it opened, what has arrived in it, and what the last closed one measured. */
|
|
171
164
|
rateStart = 0;
|
|
@@ -182,8 +175,6 @@ export class Channel {
|
|
|
182
175
|
sourceTaps = new Set();
|
|
183
176
|
/** Pulls the plug on the current read-through, when there is one. */
|
|
184
177
|
throughAbort = null;
|
|
185
|
-
/** ffmpeg concat manifest for a local multi-file live. */
|
|
186
|
-
concatListPath = null;
|
|
187
178
|
constructor(info, options, onGone) {
|
|
188
179
|
this.info = info;
|
|
189
180
|
this.options = options;
|
|
@@ -240,44 +231,29 @@ export class Channel {
|
|
|
240
231
|
this.fragments = new Fragments();
|
|
241
232
|
const [command, ...prefix] = this.options.ffmpeg;
|
|
242
233
|
this.info.live = resume.live;
|
|
243
|
-
if (!resume.live)
|
|
234
|
+
if (!resume.live || resume.playlist?.length)
|
|
244
235
|
this.info.position = Math.max(0, resume.position);
|
|
245
236
|
// A list plays entry by entry: the one that is on is what gets dialled,
|
|
246
237
|
// and an entry that ended is followed by the next, at once.
|
|
247
238
|
const list = resume.playlist && resume.playlist.length > 0 ? resume.playlist : null;
|
|
248
|
-
|
|
249
|
-
// process for every file makes browsers see a new MP4 stream at each
|
|
250
|
-
// boundary, even when the HTTP listener stays attached. The concat
|
|
251
|
-
// demuxer keeps timestamps and the output pipe continuous.
|
|
252
|
-
const concatList = list && list.length > 1 && list.every((one) => !/^https?:\/\//i.test(one))
|
|
253
|
-
? list
|
|
254
|
-
: null;
|
|
255
|
-
if (this.concatListPath) {
|
|
256
|
-
rmSync(this.concatListPath, { force: true });
|
|
257
|
-
this.concatListPath = null;
|
|
258
|
-
}
|
|
259
|
-
if (concatList) {
|
|
260
|
-
const dir = mkdtempSync(join(tmpdir(), "nixamp-playlist-"));
|
|
261
|
-
this.concatListPath = join(dir, "playlist.txt");
|
|
262
|
-
const quote = (one) => one.replaceAll("'", "'\\''");
|
|
263
|
-
writeFileSync(this.concatListPath, concatList.map((one) => `file '${quote(one)}'`).join("\n") + "\n");
|
|
264
|
-
}
|
|
239
|
+
const localPlaylist = list && list.length > 1 && list.every(one => !/^https?:\/\//i.test(one)) ? list : null;
|
|
265
240
|
let at = 0;
|
|
266
241
|
if (list) {
|
|
267
242
|
this.info.playlist = list;
|
|
268
|
-
at = Math.min(Math.max(0, this.info.playlistAt ?? 0), list.length - 1);
|
|
243
|
+
at = Math.min(Math.max(0, resume.playlistAt ?? this.info.playlistAt ?? 0), list.length - 1);
|
|
269
244
|
this.info.playlistAt = at;
|
|
270
245
|
}
|
|
271
246
|
let current = list ? list[at] : source;
|
|
272
247
|
// The last entry is the end of the show, not a way back to the first:
|
|
273
248
|
// a list that played through is over, and says so with the outro.
|
|
274
|
-
this.advance = list && list.length > 1 && !
|
|
249
|
+
this.advance = list && list.length > 1 && !localPlaylist
|
|
275
250
|
? () => {
|
|
276
251
|
if (at + 1 >= list.length)
|
|
277
252
|
return false;
|
|
278
253
|
at += 1;
|
|
279
254
|
this.info.playlistAt = at;
|
|
280
255
|
current = list[at];
|
|
256
|
+
this.info.position = 0;
|
|
281
257
|
return true;
|
|
282
258
|
}
|
|
283
259
|
: null;
|
|
@@ -296,7 +272,7 @@ export class Channel {
|
|
|
296
272
|
// the place was written down a moment ago and a moment of it twice is
|
|
297
273
|
// better than a moment of it missing. A live source is joined as is,
|
|
298
274
|
// and a film that has barely started is started.
|
|
299
|
-
const from = resume.live ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND));
|
|
275
|
+
const from = resume.live && !list ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND));
|
|
300
276
|
const seek = from > 0 ? ["-ss", String(from)] : [];
|
|
301
277
|
// Read the source here rather than in ffmpeg, when a policy wants the
|
|
302
278
|
// original bytes and the source is the kind that can be. ffmpeg then
|
|
@@ -306,14 +282,19 @@ export class Channel {
|
|
|
306
282
|
this.throughAbort?.abort();
|
|
307
283
|
this.throughAbort = null;
|
|
308
284
|
// The outro is a file read by ffmpeg itself: a pipe cannot loop.
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
:
|
|
285
|
+
const playlistInput = localPlaylist ? new PlaylistInputStream({
|
|
286
|
+
ffmpeg: this.options.ffmpeg,
|
|
287
|
+
ffprobe: this.options.ffprobe ?? this.options.ffmpeg.map(one => one.replace(/ffmpeg(?=(?:\.exe)?$)/, "ffprobe")),
|
|
288
|
+
files: localPlaylist, at: this.info.playlistAt ?? 0, position: from,
|
|
289
|
+
video: this.info.kind === "video", width: this.info.codecs?.width, height: this.info.codecs?.height,
|
|
290
|
+
}) : null;
|
|
291
|
+
const through = this.outroOn ? null : playlistInput
|
|
292
|
+
? { format: "mpegts", open: async (signal) => playlistInput.open(signal) }
|
|
293
|
+
: this.options.through?.(this.info, from, input, audio) ?? null;
|
|
294
|
+
this.info.teed = through !== null && !playlistInput;
|
|
295
|
+
const playlistEncode = !playlistInput ? null : this.info.kind === "video"
|
|
296
|
+
? ["-c", "copy", "-bsf:a", "aac_adtstoasc", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"]
|
|
297
|
+
: ["-af", "aresample=async=1:first_pts=0", ...encode];
|
|
317
298
|
const child = spawn(command, [
|
|
318
299
|
...prefix,
|
|
319
300
|
"-hide_banner",
|
|
@@ -333,7 +314,7 @@ export class Channel {
|
|
|
333
314
|
// headers, which only a source read through us leaves out,
|
|
334
315
|
// are no use to a pipe and are not here (a source that needs
|
|
335
316
|
// them is not read through us in the first place).
|
|
336
|
-
...input,
|
|
317
|
+
...(playlistInput ? ["-probesize", "32768", "-analyzeduration", "1000000"] : input),
|
|
337
318
|
"-f", through.format,
|
|
338
319
|
...(paced ? ["-re"] : []),
|
|
339
320
|
"-i", "pipe:0",
|
|
@@ -354,12 +335,12 @@ export class Channel {
|
|
|
354
335
|
// What the source's site expects on the request: a user agent, a
|
|
355
336
|
// referer, a cookie. A link resolved by yt-dlp comes with these,
|
|
356
337
|
// and a CDN that got them from yt-dlp and not from us answers 403.
|
|
357
|
-
...
|
|
338
|
+
...input, ...seek, "-i", current,
|
|
358
339
|
// The sound, when the site keeps it apart from the picture: a
|
|
359
340
|
// second input, dialled the same way, that the encode maps in.
|
|
360
341
|
...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
|
|
361
342
|
]),
|
|
362
|
-
...encode,
|
|
343
|
+
...(playlistEncode ?? encode),
|
|
363
344
|
"pipe:1",
|
|
364
345
|
], { stdio: [through ? "pipe" : "ignore", "pipe", "pipe", "pipe"] });
|
|
365
346
|
let sent = false;
|
|
@@ -374,14 +355,22 @@ export class Channel {
|
|
|
374
355
|
let progress = "";
|
|
375
356
|
child.stdio[3]?.on("data", (chunk) => {
|
|
376
357
|
progress = (progress + chunk.toString("utf8")).slice(-4000);
|
|
377
|
-
if (this.child !== child || resume.live)
|
|
358
|
+
if (this.child !== child || (resume.live && !list))
|
|
378
359
|
return;
|
|
379
360
|
const lines = progress.split("\n");
|
|
380
361
|
progress = lines.pop() ?? "";
|
|
381
362
|
for (const line of lines) {
|
|
382
363
|
const match = /^out_time_us=(\d+)/.exec(line.trim());
|
|
383
|
-
if (match)
|
|
384
|
-
|
|
364
|
+
if (match) {
|
|
365
|
+
const seconds = Number(match[1]) / 1e6;
|
|
366
|
+
if (playlistInput) {
|
|
367
|
+
const place = playlistInput.place(seconds);
|
|
368
|
+
this.info.playlistAt = place.at;
|
|
369
|
+
this.info.position = place.position;
|
|
370
|
+
}
|
|
371
|
+
else
|
|
372
|
+
this.info.position = from + seconds;
|
|
373
|
+
}
|
|
385
374
|
}
|
|
386
375
|
});
|
|
387
376
|
child.stdio[3]?.on("error", () => undefined);
|
|
@@ -389,6 +378,8 @@ export class Channel {
|
|
|
389
378
|
// An ffmpeg that was replaced can still have a chunk in the pipe.
|
|
390
379
|
if (this.child !== child)
|
|
391
380
|
return;
|
|
381
|
+
if (!sent)
|
|
382
|
+
this.info.error = undefined;
|
|
392
383
|
sent = true;
|
|
393
384
|
this.info.bytes += chunk.byteLength;
|
|
394
385
|
this.rearm(child);
|
|
@@ -399,9 +390,14 @@ export class Channel {
|
|
|
399
390
|
// Only the ffmpeg we are currently running gets to say the source
|
|
400
391
|
// dropped. One that was killed to make way for a restart is not news.
|
|
401
392
|
child.on("error", () => { if (this.child === child)
|
|
402
|
-
this.dropped(sent); });
|
|
403
|
-
child.on("close", () => {
|
|
404
|
-
this.
|
|
393
|
+
this.dropped(sent, false); });
|
|
394
|
+
child.on("close", (code, signal) => {
|
|
395
|
+
if (this.child !== child)
|
|
396
|
+
return;
|
|
397
|
+
if (playlistInput?.error)
|
|
398
|
+
this.stderr = playlistInput.error.message;
|
|
399
|
+
this.dropped(sent, code === 0 && !signal && (!playlistInput || playlistInput.complete));
|
|
400
|
+
});
|
|
405
401
|
};
|
|
406
402
|
this.redial = dial;
|
|
407
403
|
dial();
|
|
@@ -423,6 +419,7 @@ export class Channel {
|
|
|
423
419
|
const kind = this.info.kind ?? "audio";
|
|
424
420
|
const old = this.child;
|
|
425
421
|
this.child = null;
|
|
422
|
+
this.throughAbort?.abort();
|
|
426
423
|
if (this.watchdog)
|
|
427
424
|
clearTimeout(this.watchdog);
|
|
428
425
|
this.watchdog = null;
|
|
@@ -473,7 +470,7 @@ export class Channel {
|
|
|
473
470
|
this.info.playlistAt = 0;
|
|
474
471
|
this.startOver();
|
|
475
472
|
const show = this.dialed;
|
|
476
|
-
this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0 });
|
|
473
|
+
this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0, playlistAt: 0 });
|
|
477
474
|
return true;
|
|
478
475
|
}
|
|
479
476
|
const dial = this.redial;
|
|
@@ -510,6 +507,8 @@ export class Channel {
|
|
|
510
507
|
if (this.info.kind === "video")
|
|
511
508
|
this.fragments = new Fragments();
|
|
512
509
|
this.recent = [];
|
|
510
|
+
this.fragmentClock = new FragmentClock();
|
|
511
|
+
this.fragmentTimes = new WeakMap();
|
|
513
512
|
this.recentBytes = 0;
|
|
514
513
|
// A new source may be a different size of stream, and the rate measured
|
|
515
514
|
// off the old one is not evidence about this one.
|
|
@@ -569,7 +568,7 @@ export class Channel {
|
|
|
569
568
|
* worth dialling again, and a URL that has never once produced a byte is a
|
|
570
569
|
* mistake somebody made, and retrying it for ever helps nobody.
|
|
571
570
|
*/
|
|
572
|
-
dropped(sent) {
|
|
571
|
+
dropped(sent, clean = false) {
|
|
573
572
|
if (this.closing || !this.redial)
|
|
574
573
|
return;
|
|
575
574
|
// An outro that stopped is over; there is nothing after it.
|
|
@@ -578,28 +577,28 @@ export class Channel {
|
|
|
578
577
|
return;
|
|
579
578
|
}
|
|
580
579
|
this.child = null;
|
|
580
|
+
this.throughAbort?.abort();
|
|
581
581
|
if (this.watchdog)
|
|
582
582
|
clearTimeout(this.watchdog);
|
|
583
583
|
this.watchdog = null;
|
|
584
584
|
const said = lastLine(this.stderr);
|
|
585
585
|
if (said)
|
|
586
586
|
this.info.error = said;
|
|
587
|
-
this.failures = sent ? 0 : this.failures + 1;
|
|
587
|
+
this.failures = sent && clean ? 0 : this.failures + 1;
|
|
588
588
|
if (this.failures >= GIVE_UP) {
|
|
589
589
|
this.close();
|
|
590
590
|
return;
|
|
591
591
|
}
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
|
|
596
|
-
const moved = this.advance?.() ?? false;
|
|
592
|
+
// Only a clean EOF advances a per-URL playlist. Decoder errors, a
|
|
593
|
+
// watchdog kill, or a failed normalized input recover at the saved place
|
|
594
|
+
// with bounded retries; an MP4 header alone is not a completed show.
|
|
595
|
+
const moved = clean ? this.advance?.() ?? false : false;
|
|
597
596
|
// A show that ended: a film or a podcast that played to its end, a
|
|
598
597
|
// list whose last entry did. That is not a source that dropped, and it
|
|
599
598
|
// is not dialled again from the top; it is over, and the outro says
|
|
600
599
|
// so. A live feed that stopped is a feed that dropped, and is redialled.
|
|
601
600
|
const list = (this.info.playlist?.length ?? 0) > 0;
|
|
602
|
-
if (sent && !moved && (this.info.live === false || list)) {
|
|
601
|
+
if (clean && sent && !moved && (this.info.live === false || list)) {
|
|
603
602
|
void this.endShow();
|
|
604
603
|
return;
|
|
605
604
|
}
|
|
@@ -637,8 +636,23 @@ export class Channel {
|
|
|
637
636
|
}
|
|
638
637
|
const cap = this.backlogCap();
|
|
639
638
|
for (const box of this.fragments.push(chunk)) {
|
|
639
|
+
const time = this.fragmentClock.read(box);
|
|
640
|
+
if (time !== null)
|
|
641
|
+
this.fragmentTimes.set(box, time);
|
|
640
642
|
if (!isOpening(boxType(box)))
|
|
641
|
-
this.remember(box, cap, true);
|
|
643
|
+
this.remember(box, time === null ? cap : BACKLOG_VIDEO_MAX, true);
|
|
644
|
+
if (time !== null) {
|
|
645
|
+
// Drop complete old fragments even when six seconds occupy only a
|
|
646
|
+
// few kilobytes (slides, static cameras, or a paused game screen).
|
|
647
|
+
while (this.recent.length > 0) {
|
|
648
|
+
const oldest = this.fragmentTimes.get(this.recent[0]);
|
|
649
|
+
if (oldest === undefined || time - oldest < BACKLOG_SECONDS)
|
|
650
|
+
break;
|
|
651
|
+
do {
|
|
652
|
+
this.recentBytes -= this.recent.shift().byteLength;
|
|
653
|
+
} while (this.recent.length && boxType(this.recent[0]) !== "moof");
|
|
654
|
+
}
|
|
655
|
+
}
|
|
642
656
|
this.send(box);
|
|
643
657
|
}
|
|
644
658
|
}
|
|
@@ -818,8 +832,13 @@ export class Channel {
|
|
|
818
832
|
// Wait for ffmpeg to take it, or for the pipe to go: a pipe that
|
|
819
833
|
// closed never drains, and waiting on it would hold the read open.
|
|
820
834
|
await new Promise((done) => {
|
|
821
|
-
|
|
822
|
-
|
|
835
|
+
const finish = () => {
|
|
836
|
+
stdin.off("drain", finish);
|
|
837
|
+
stdin.off("close", finish);
|
|
838
|
+
done();
|
|
839
|
+
};
|
|
840
|
+
stdin.once("drain", finish);
|
|
841
|
+
stdin.once("close", finish);
|
|
823
842
|
});
|
|
824
843
|
}
|
|
825
844
|
}
|
|
@@ -917,10 +936,6 @@ export class Channel {
|
|
|
917
936
|
this.child = null;
|
|
918
937
|
this.throughAbort?.abort();
|
|
919
938
|
this.throughAbort = null;
|
|
920
|
-
if (this.concatListPath) {
|
|
921
|
-
rmSync(dirname(this.concatListPath), { force: true, recursive: true });
|
|
922
|
-
this.concatListPath = null;
|
|
923
|
-
}
|
|
924
939
|
this.endTaps();
|
|
925
940
|
try {
|
|
926
941
|
child?.stdin?.end();
|
|
@@ -1228,7 +1243,7 @@ export function rememberedNow(channels) {
|
|
|
1228
1243
|
kept.codecs = one.codecs;
|
|
1229
1244
|
if (typeof one.live === "boolean")
|
|
1230
1245
|
kept.live = one.live;
|
|
1231
|
-
if (!one.live && typeof one.position === "number" && one.position > 0)
|
|
1246
|
+
if ((!one.live || one.playlist?.length) && typeof one.position === "number" && one.position > 0)
|
|
1232
1247
|
kept.position = Math.floor(one.position);
|
|
1233
1248
|
if (one.startedBy)
|
|
1234
1249
|
kept.startedBy = one.startedBy;
|
package/dist/fragments.d.ts
CHANGED
|
@@ -35,6 +35,11 @@ export declare function firstBox(buffer: Buffer): {
|
|
|
35
35
|
} | null;
|
|
36
36
|
/** The boxes that describe the stream rather than carry it. */
|
|
37
37
|
export declare function isOpening(type: string): boolean;
|
|
38
|
+
/** Decode timestamps use each track's own timescale, never its byte rate. */
|
|
39
|
+
export declare class FragmentClock {
|
|
40
|
+
private scales;
|
|
41
|
+
read(box: Buffer): number | null;
|
|
42
|
+
}
|
|
38
43
|
/**
|
|
39
44
|
* A fragmented MP4 arriving in pieces, handed back a box at a time.
|
|
40
45
|
*
|
package/dist/fragments.js
CHANGED
|
@@ -60,6 +60,57 @@ export function firstBox(buffer) {
|
|
|
60
60
|
export function isOpening(type) {
|
|
61
61
|
return type === "ftyp" || type === "moov";
|
|
62
62
|
}
|
|
63
|
+
function children(box) {
|
|
64
|
+
let rest = box.subarray(box.readUInt32BE(0) === 1 ? BIG : HEADER);
|
|
65
|
+
const found = [];
|
|
66
|
+
for (;;) {
|
|
67
|
+
const next = firstBox(rest);
|
|
68
|
+
if (!next)
|
|
69
|
+
return found;
|
|
70
|
+
found.push(next.box);
|
|
71
|
+
rest = next.rest;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Decode timestamps use each track's own timescale, never its byte rate. */
|
|
75
|
+
export class FragmentClock {
|
|
76
|
+
scales = new Map();
|
|
77
|
+
read(box) {
|
|
78
|
+
const type = box.toString("latin1", 4, 8);
|
|
79
|
+
if (type === "moov") {
|
|
80
|
+
for (const track of children(box).filter(one => one.type === "trak")) {
|
|
81
|
+
const parts = children(track.bytes);
|
|
82
|
+
const tkhd = parts.find(one => one.type === "tkhd")?.bytes;
|
|
83
|
+
const mdia = parts.find(one => one.type === "mdia")?.bytes;
|
|
84
|
+
const mdhd = mdia && children(mdia).find(one => one.type === "mdhd")?.bytes;
|
|
85
|
+
if (!tkhd || !mdhd)
|
|
86
|
+
continue;
|
|
87
|
+
const idAt = tkhd[8] === 1 ? 28 : 20;
|
|
88
|
+
const scaleAt = mdhd[8] === 1 ? 28 : 20;
|
|
89
|
+
if (tkhd.length < idAt + 4 || mdhd.length < scaleAt + 4)
|
|
90
|
+
continue;
|
|
91
|
+
const scale = mdhd.readUInt32BE(scaleAt);
|
|
92
|
+
if (scale)
|
|
93
|
+
this.scales.set(tkhd.readUInt32BE(idAt), scale);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (type !== "moof")
|
|
97
|
+
return null;
|
|
98
|
+
const times = [];
|
|
99
|
+
for (const track of children(box).filter(one => one.type === "traf")) {
|
|
100
|
+
const parts = children(track.bytes);
|
|
101
|
+
const tfhd = parts.find(one => one.type === "tfhd")?.bytes;
|
|
102
|
+
const tfdt = parts.find(one => one.type === "tfdt")?.bytes;
|
|
103
|
+
if (!tfhd || tfhd.length < 16 || !tfdt || tfdt.length < (tfdt[8] === 1 ? 20 : 16))
|
|
104
|
+
continue;
|
|
105
|
+
const scale = this.scales.get(tfhd.readUInt32BE(12));
|
|
106
|
+
if (!scale)
|
|
107
|
+
continue;
|
|
108
|
+
const time = tfdt[8] === 1 ? Number(tfdt.readBigUInt64BE(12)) : tfdt.readUInt32BE(12);
|
|
109
|
+
times.push(time / scale);
|
|
110
|
+
}
|
|
111
|
+
return times.length ? Math.min(...times) : null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
63
114
|
/**
|
|
64
115
|
* A fragmented MP4 arriving in pieces, handed back a box at a time.
|
|
65
116
|
*
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface PlaylistInput {
|
|
2
|
+
ffmpeg: string[];
|
|
3
|
+
ffprobe: string[];
|
|
4
|
+
files: string[];
|
|
5
|
+
at: number;
|
|
6
|
+
position: number;
|
|
7
|
+
video: boolean;
|
|
8
|
+
width?: number;
|
|
9
|
+
height?: number;
|
|
10
|
+
}
|
|
11
|
+
/** Decode each file independently; feed one persistent muxer a fixed stream
|
|
12
|
+
* profile and continuous timestamps. A concat demuxer cannot decode a list
|
|
13
|
+
* whose input codecs differ. Only one entry is encoded at a time, with pipe
|
|
14
|
+
* backpressure bounding read-ahead independently of the directory's size. */
|
|
15
|
+
export declare class PlaylistInputStream {
|
|
16
|
+
private readonly options;
|
|
17
|
+
private entries;
|
|
18
|
+
error: Error | null;
|
|
19
|
+
complete: boolean;
|
|
20
|
+
constructor(options: PlaylistInput);
|
|
21
|
+
place(seconds: number): {
|
|
22
|
+
at: number;
|
|
23
|
+
position: number;
|
|
24
|
+
};
|
|
25
|
+
open(signal: AbortSignal): AsyncGenerator<Buffer>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { codecsOf } from "./audio.js";
|
|
3
|
+
/** Decode each file independently; feed one persistent muxer a fixed stream
|
|
4
|
+
* profile and continuous timestamps. A concat demuxer cannot decode a list
|
|
5
|
+
* whose input codecs differ. Only one entry is encoded at a time, with pipe
|
|
6
|
+
* backpressure bounding read-ahead independently of the directory's size. */
|
|
7
|
+
export class PlaylistInputStream {
|
|
8
|
+
options;
|
|
9
|
+
entries = [];
|
|
10
|
+
error = null;
|
|
11
|
+
complete = false;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.options = options;
|
|
14
|
+
}
|
|
15
|
+
place(seconds) {
|
|
16
|
+
const entry = this.entries.findLast(one => seconds >= one.start) ?? this.entries[0];
|
|
17
|
+
return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position) }
|
|
18
|
+
: { at: this.options.at, position: this.options.position };
|
|
19
|
+
}
|
|
20
|
+
async *open(signal) {
|
|
21
|
+
const o = this.options;
|
|
22
|
+
const [command, ...prefix] = o.ffmpeg;
|
|
23
|
+
const ratio = Math.min(1, 1280 / (o.width || 1280), 720 / (o.height || 720));
|
|
24
|
+
const width = Math.max(2, Math.floor((o.width || 1280) * ratio / 2) * 2);
|
|
25
|
+
const height = Math.max(2, Math.floor((o.height || 720) * ratio / 2) * 2);
|
|
26
|
+
let offset = 0;
|
|
27
|
+
try {
|
|
28
|
+
for (let at = o.at; at < o.files.length && !signal.aborted; at++) {
|
|
29
|
+
const file = o.files[at];
|
|
30
|
+
const codecs = await codecsOf({ ffmpeg: o.ffmpeg, ffprobe: o.ffprobe, play: null }, file, [], AbortSignal.any([signal, AbortSignal.timeout(10_000)]));
|
|
31
|
+
if (signal.aborted)
|
|
32
|
+
return;
|
|
33
|
+
if (!codecs.audio && !codecs.video)
|
|
34
|
+
throw new Error(`Cannot decode playlist entry ${at + 1}`);
|
|
35
|
+
const position = at === o.at ? o.position : 0;
|
|
36
|
+
const duration = (codecs.duration ?? 0) - position;
|
|
37
|
+
if (duration <= 0)
|
|
38
|
+
throw new Error(`No playable duration for playlist entry ${at + 1}`);
|
|
39
|
+
this.entries.push({ at, start: offset, position });
|
|
40
|
+
const hasVideo = Boolean(codecs.video);
|
|
41
|
+
const extra = o.video && !hasVideo
|
|
42
|
+
? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
|
|
43
|
+
: o.video && !codecs.audio ? ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"] : [];
|
|
44
|
+
const args = [
|
|
45
|
+
...prefix, "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "2",
|
|
46
|
+
"-threads", "2", ...(position > 0 ? ["-ss", String(position)] : []), "-i", file,
|
|
47
|
+
...extra, "-t", String(duration),
|
|
48
|
+
...(o.video ? [
|
|
49
|
+
"-map", hasVideo ? "0:v:0" : "1:v:0", "-map", codecs.audio ? "0:a:0" : "1:a:0",
|
|
50
|
+
"-vf", `fps=30,scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,setpts=PTS-STARTPTS`,
|
|
51
|
+
"-c:v", "libx264", "-threads", "2", "-preset", "veryfast", "-tune", "zerolatency",
|
|
52
|
+
"-crf", "23", "-pix_fmt", "yuv420p", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
|
|
53
|
+
] : ["-map", "0:a:0", "-vn"]),
|
|
54
|
+
"-af", "aresample=48000,asetpts=PTS-STARTPTS", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
|
|
55
|
+
"-mpegts_flags", "+initial_discontinuity", "-output_ts_offset", String(offset), "-mpegts_copyts", "1", "-muxdelay", "0", "-muxpreload", "0",
|
|
56
|
+
"-f", "mpegts", "pipe:1",
|
|
57
|
+
];
|
|
58
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
59
|
+
let tail = "";
|
|
60
|
+
child.stderr.on("data", chunk => { tail = (tail + chunk.toString()).slice(-1500); });
|
|
61
|
+
const closed = new Promise((resolve) => {
|
|
62
|
+
child.once("error", error => { tail = error.message; resolve(-1); });
|
|
63
|
+
child.once("close", code => resolve(code));
|
|
64
|
+
});
|
|
65
|
+
const abort = () => { child.kill("SIGKILL"); };
|
|
66
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
67
|
+
try {
|
|
68
|
+
for await (const chunk of child.stdout) {
|
|
69
|
+
if (signal.aborted)
|
|
70
|
+
return;
|
|
71
|
+
yield chunk;
|
|
72
|
+
}
|
|
73
|
+
const code = await closed;
|
|
74
|
+
if (signal.aborted)
|
|
75
|
+
return;
|
|
76
|
+
if (code !== 0)
|
|
77
|
+
throw new Error(`Playlist entry ${at + 1} failed: ${tail.trim()}`);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
signal.removeEventListener("abort", abort);
|
|
81
|
+
abort();
|
|
82
|
+
await closed;
|
|
83
|
+
}
|
|
84
|
+
// Match the frame grid used above so rounding cannot accumulate a
|
|
85
|
+
// backwards timestamp over hundreds of short files.
|
|
86
|
+
offset += o.video ? Math.ceil(duration * 30) / 30 : duration;
|
|
87
|
+
}
|
|
88
|
+
this.complete = !signal.aborted;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
this.error = error instanceof Error ? error : new Error(String(error));
|
|
92
|
+
throw this.error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|