nixamp 0.28.35 → 0.28.37
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 +93 -85
- 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 +88 -85
- package/src/fragments.ts +46 -0
- package/src/playlist-input.ts +99 -0
- package/src/server.ts +6 -6
- package/web/dist/assets/{ar-B8r60Lgd.js → ar-DhdXPO3p.js} +1 -1
- package/web/dist/assets/{cs-C_jKpXHz.js → cs-CoTzYnZt.js} +1 -1
- package/web/dist/assets/{da-qhb4qzGL.js → da-M4GDhrcC.js} +1 -1
- package/web/dist/assets/{de-B1U3oniY.js → de-D7yI8cd_.js} +1 -1
- package/web/dist/assets/{es-jT9qQhJM.js → es-BMGb7XlT.js} +1 -1
- package/web/dist/assets/{fi-CD8a3RyK.js → fi-CXNsmIrh.js} +1 -1
- package/web/dist/assets/{fr-CBYEPF15.js → fr-BpvyrESQ.js} +1 -1
- package/web/dist/assets/{hi-DS7Jlgq3.js → hi-DO7CO-fI.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-BVtRqG0G.js → hls-3VKVEQE3-BCxdTCEU.js} +1 -1
- package/web/dist/assets/{hu-BZ__rJ87.js → hu-CosB3vlS.js} +1 -1
- package/web/dist/assets/{id-DkM6FK8S.js → id-DNumDlwq.js} +1 -1
- package/web/dist/assets/index-CY3lAwM2.js +3 -0
- package/web/dist/assets/index-jLCo7PIA.css +1 -0
- package/web/dist/assets/{it-DmhEUJ4D.js → it-IJKhyCGw.js} +1 -1
- package/web/dist/assets/{mpegts-DGMTjRf5.js → mpegts-DsEoUEPH.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Cct2UCqZ.js → mpegts-LO6RVLD6-CC8jMmPy.js} +1 -1
- package/web/dist/assets/{nl-D-gXjVr5.js → nl-BU0Mn-e-.js} +1 -1
- package/web/dist/assets/{ru-BbqAccEb.js → ru-ClqNCOTW.js} +1 -1
- package/web/dist/assets/{sv-BMdjgsVR.js → sv-Df_aXTD6.js} +1 -1
- package/web/dist/assets/{uk-DLs9Y4CC.js → uk-K5yQBF0h.js} +1 -1
- package/web/dist/assets/{vi-_Y4nCAgc.js → vi-CYfcOUeA.js} +1 -1
- package/web/dist/assets/{zh-DIJTp7RL.js → zh-a2v2x-3e.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +23 -23
- package/web/dist/assets/index-BaDD_PRM.css +0 -1
- package/web/dist/assets/index-Dkxbx4QN.js +0 -3
|
@@ -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
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -904,7 +904,7 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
904
904
|
if (assumed)
|
|
905
905
|
console.log(` "${id}": the source would not say what it holds; carrying it as ${kind}.`);
|
|
906
906
|
// A film has a length and a place to go back to; a live source has neither.
|
|
907
|
-
//
|
|
907
|
+
// Listeners join a playlist live; its producer resumes the saved entry and position.
|
|
908
908
|
const playlist = known.playlist && known.playlist.length > 0 ? known.playlist : null;
|
|
909
909
|
const live = playlist ? true : (known.live ?? !((codecs.duration ?? 0) > 0));
|
|
910
910
|
const encode = kind === "video"
|
|
@@ -926,14 +926,12 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
926
926
|
// MP3's tags name the episode and the show, and "Inspiring Founders" is a
|
|
927
927
|
// card; "ep-0412-final.mp3" is not.
|
|
928
928
|
const tagged = looksLikeFileName(name) && codecs.tags?.title ? codecs.tags.title : name;
|
|
929
|
-
const channel = channels.pull(id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0, ...(playlist ? { playlist } : {}) },
|
|
929
|
+
const channel = channels.pull(id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0, ...(playlist ? { playlist, playlistAt: known.playlistAt ?? 0 } : {}) },
|
|
930
930
|
// Known before the first dial: whether the source is a transport stream
|
|
931
931
|
// decides whether it can be read here for a source-boundary relay.
|
|
932
932
|
assumed ? undefined : codecs);
|
|
933
933
|
if (channel && !assumed)
|
|
934
934
|
channel.info.codecs = codecs;
|
|
935
|
-
if (channel && playlist && known.playlistAt !== undefined)
|
|
936
|
-
channel.info.playlistAt = known.playlistAt;
|
|
937
935
|
if (channel) {
|
|
938
936
|
// Its picture from wherever it came with one, and a line about it: what
|
|
939
937
|
// was given, else the show and the album the file's own tags name.
|
|
@@ -965,7 +963,7 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
965
963
|
// told otherwise would cut fMP4 segments for a stream that did not need
|
|
966
964
|
// them.
|
|
967
965
|
if (channel && kind === "video") {
|
|
968
|
-
channel.info.emits = encode.includes("libx264") ? "h264" : codecs.video || "h264";
|
|
966
|
+
channel.info.emits = (playlist && playlist.length > 1 && playlist.every(one => !/^https?:\/\//i.test(one))) || encode.includes("libx264") ? "h264" : codecs.video || "h264";
|
|
969
967
|
}
|
|
970
968
|
return channel;
|
|
971
969
|
}
|
|
@@ -5588,6 +5586,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5588
5586
|
const outro = new Outro({ ffmpeg: tools.ffmpeg, dir: join(stateDir(), "outro"), onEvent: (message) => console.log(message) });
|
|
5589
5587
|
const channels = new Channels({
|
|
5590
5588
|
ffmpeg: tools.ffmpeg,
|
|
5589
|
+
ffprobe: tools.ffprobe,
|
|
5591
5590
|
...(tools.carries ? { outro: (kind) => outro.clip(kind) } : {}),
|
|
5592
5591
|
onOutro: (info) => console.log(` "${info.id}" has ended; the outro plays for an hour.`),
|
|
5593
5592
|
onStart: (info) => console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
|
|
@@ -5627,7 +5626,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5627
5626
|
// every restart used to take CNN off the air until somebody noticed.
|
|
5628
5627
|
const remembering = (list) => rememberChannels(stateDir(), options.port, list);
|
|
5629
5628
|
for (const one of rememberedChannels(stateDir(), options.port)) {
|
|
5630
|
-
const where = one.position && !one.live ? `, from ${Math.floor(one.position / 60)}m${Math.floor(one.position % 60)}s` : "";
|
|
5629
|
+
const where = one.position && (!one.live || one.playlist?.length) ? `, from ${Math.floor(one.position / 60)}m${Math.floor(one.position % 60)}s` : "";
|
|
5631
5630
|
console.log(` Putting "${one.id}" (${one.name}) back on the air${where}.`);
|
|
5632
5631
|
// With what was written down about it: what it holds, so the source is
|
|
5633
5632
|
// not asked again, and where it had got to, so a film carries on.
|
|
@@ -5651,7 +5650,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5651
5650
|
let lastRemembered = "";
|
|
5652
5651
|
setInterval(() => {
|
|
5653
5652
|
const now = rememberedNow(channels);
|
|
5654
|
-
//
|
|
5653
|
+
// Both the entry and its elapsed time advance during a directory live.
|
|
5655
5654
|
if (!now.some((one) => one.position !== undefined || one.playlist))
|
|
5656
5655
|
return;
|
|
5657
5656
|
const text = JSON.stringify(now);
|
package/package.json
CHANGED
package/src/audio.ts
CHANGED
|
@@ -442,7 +442,7 @@ export interface Codecs {
|
|
|
442
442
|
* answering other requests, and a synchronous probe per media request is how
|
|
443
443
|
* the whole library came to be tagged with the process wedged solid.
|
|
444
444
|
*/
|
|
445
|
-
export async function codecsOf(tools: Tools, path: string, input: string[] = []): Promise<Codecs> {
|
|
445
|
+
export async function codecsOf(tools: Tools, path: string, input: string[] = [], signal?: AbortSignal): Promise<Codecs> {
|
|
446
446
|
const [cmd, ...rest] = tools.ffprobe;
|
|
447
447
|
const empty: Codecs = { video: "", audio: "", container: "" };
|
|
448
448
|
if (!cmd) return empty;
|
|
@@ -469,7 +469,7 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
|
|
|
469
469
|
...input,
|
|
470
470
|
path,
|
|
471
471
|
],
|
|
472
|
-
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
472
|
+
{ stdio: ["ignore", "pipe", "ignore"], signal },
|
|
473
473
|
);
|
|
474
474
|
let out = "";
|
|
475
475
|
child.stdout.on("data", (chunk: Buffer) => {
|
package/src/channels.ts
CHANGED
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
18
18
|
import { randomBytes } from "node:crypto";
|
|
19
|
-
import { mkdirSync,
|
|
19
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { dirname, join } from "node:path";
|
|
21
|
-
import {
|
|
21
|
+
import { PlaylistInputStream } from "./playlist-input.ts";
|
|
22
22
|
import type { Readable } from "node:stream";
|
|
23
|
-
import { Fragments, isOpening } from "./fragments.ts";
|
|
23
|
+
import { FragmentClock, Fragments, isOpening } from "./fragments.ts";
|
|
24
24
|
|
|
25
25
|
/** Somewhere for a channel's audio to go. A response, in practice. */
|
|
26
26
|
export interface Listener {
|
|
@@ -59,9 +59,9 @@ export interface ChannelInfo {
|
|
|
59
59
|
redials?: number;
|
|
60
60
|
/**
|
|
61
61
|
* For a channel that plays a list: the entries, in order, and which one is
|
|
62
|
-
* on.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
62
|
+
* on. Local files are normalized into one continuous stream. After the
|
|
63
|
+
* final entry the show ends. A restart picks up at the saved entry and
|
|
64
|
+
* position within it.
|
|
65
65
|
*/
|
|
66
66
|
playlist?: string[];
|
|
67
67
|
playlistAt?: number;
|
|
@@ -121,6 +121,7 @@ export interface PullResume {
|
|
|
121
121
|
position: number;
|
|
122
122
|
/** For a list of things rather than one: every entry, in order. */
|
|
123
123
|
playlist?: string[];
|
|
124
|
+
playlistAt?: number;
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
/**
|
|
@@ -188,18 +189,9 @@ export const BACKLOG_AUDIO = 64 * 1024;
|
|
|
188
189
|
*/
|
|
189
190
|
export const LISTENER_QUEUE = 16 * 1024 * 1024;
|
|
190
191
|
|
|
191
|
-
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* Six seconds of 720p is about 4 MB. Six seconds of a 1080p transport stream
|
|
196
|
-
* copied straight through is nearer 12, and of 4K nearer 30 -- so a fixed
|
|
197
|
-
* 4 MB hands a 4K joiner under a second of video, which is the live edge with
|
|
198
|
-
* no cushion, which is the play-wait-play loop the backlog exists to prevent.
|
|
199
|
-
* So the cap follows the stream: seconds times the rate it is actually
|
|
200
|
-
* running at, between the old floor and a ceiling that keeps a channel's
|
|
201
|
-
* memory bounded whatever it is carrying.
|
|
202
|
-
*/
|
|
192
|
+
/** Media-time window for complete MP4 fragments. Byte limits are a separate
|
|
193
|
+
* memory ceiling; the measured byte rate is only a fallback for inputs with
|
|
194
|
+
* no usable fragment timestamps. It must never make low-bitrate video old. */
|
|
203
195
|
export const BACKLOG_SECONDS = 6;
|
|
204
196
|
export const BACKLOG_VIDEO_MAX = 48 * 1024 * 1024;
|
|
205
197
|
/**
|
|
@@ -253,6 +245,7 @@ export function cleanId(value: unknown, fallback = "main"): string {
|
|
|
253
245
|
|
|
254
246
|
export interface ChannelOptions {
|
|
255
247
|
ffmpeg: string[];
|
|
248
|
+
ffprobe?: string[];
|
|
256
249
|
onStart?: (info: ChannelInfo) => void;
|
|
257
250
|
onEnd?: (info: ChannelInfo) => void;
|
|
258
251
|
/**
|
|
@@ -314,6 +307,8 @@ export class Channel {
|
|
|
314
307
|
* can begin at; for MP3 any point will do, a frame announces itself.
|
|
315
308
|
*/
|
|
316
309
|
private recent: Buffer[] = [];
|
|
310
|
+
private fragmentClock = new FragmentClock();
|
|
311
|
+
private fragmentTimes = new WeakMap<Buffer, number>();
|
|
317
312
|
private recentBytes = 0;
|
|
318
313
|
/** The rate window: when it opened, what has arrived in it, and what the last closed one measured. */
|
|
319
314
|
private rateStart = 0;
|
|
@@ -330,8 +325,6 @@ export class Channel {
|
|
|
330
325
|
private readonly sourceTaps = new Set<Listener>();
|
|
331
326
|
/** Pulls the plug on the current read-through, when there is one. */
|
|
332
327
|
private throughAbort: AbortController | null = null;
|
|
333
|
-
/** ffmpeg concat manifest for a local multi-file live. */
|
|
334
|
-
private concatListPath: string | null = null;
|
|
335
328
|
|
|
336
329
|
constructor(
|
|
337
330
|
readonly info: ChannelInfo,
|
|
@@ -403,42 +396,27 @@ export class Channel {
|
|
|
403
396
|
if (this.info.kind === "video") this.fragments = new Fragments();
|
|
404
397
|
const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
|
|
405
398
|
this.info.live = resume.live;
|
|
406
|
-
if (!resume.live) this.info.position = Math.max(0, resume.position);
|
|
399
|
+
if (!resume.live || resume.playlist?.length) this.info.position = Math.max(0, resume.position);
|
|
407
400
|
// A list plays entry by entry: the one that is on is what gets dialled,
|
|
408
401
|
// and an entry that ended is followed by the next, at once.
|
|
409
402
|
const list = resume.playlist && resume.playlist.length > 0 ? resume.playlist : null;
|
|
410
|
-
|
|
411
|
-
// process for every file makes browsers see a new MP4 stream at each
|
|
412
|
-
// boundary, even when the HTTP listener stays attached. The concat
|
|
413
|
-
// demuxer keeps timestamps and the output pipe continuous.
|
|
414
|
-
const concatList = list && list.length > 1 && list.every((one) => !/^https?:\/\//i.test(one))
|
|
415
|
-
? list
|
|
416
|
-
: null;
|
|
417
|
-
if (this.concatListPath) {
|
|
418
|
-
rmSync(this.concatListPath, { force: true });
|
|
419
|
-
this.concatListPath = null;
|
|
420
|
-
}
|
|
421
|
-
if (concatList) {
|
|
422
|
-
const dir = mkdtempSync(join(tmpdir(), "nixamp-playlist-"));
|
|
423
|
-
this.concatListPath = join(dir, "playlist.txt");
|
|
424
|
-
const quote = (one: string): string => one.replaceAll("'", "'\\''");
|
|
425
|
-
writeFileSync(this.concatListPath, concatList.map((one) => `file '${quote(one)}'`).join("\n") + "\n");
|
|
426
|
-
}
|
|
403
|
+
const localPlaylist = list && list.length > 1 && list.every(one => !/^https?:\/\//i.test(one)) ? list : null;
|
|
427
404
|
let at = 0;
|
|
428
405
|
if (list) {
|
|
429
406
|
this.info.playlist = list;
|
|
430
|
-
at = Math.min(Math.max(0, this.info.playlistAt ?? 0), list.length - 1);
|
|
407
|
+
at = Math.min(Math.max(0, resume.playlistAt ?? this.info.playlistAt ?? 0), list.length - 1);
|
|
431
408
|
this.info.playlistAt = at;
|
|
432
409
|
}
|
|
433
410
|
let current = list ? (list[at] as string) : source;
|
|
434
411
|
// The last entry is the end of the show, not a way back to the first:
|
|
435
412
|
// a list that played through is over, and says so with the outro.
|
|
436
|
-
this.advance = list && list.length > 1 && !
|
|
413
|
+
this.advance = list && list.length > 1 && !localPlaylist
|
|
437
414
|
? () => {
|
|
438
415
|
if (at + 1 >= list.length) return false;
|
|
439
416
|
at += 1;
|
|
440
417
|
this.info.playlistAt = at;
|
|
441
418
|
current = list[at] as string;
|
|
419
|
+
this.info.position = 0;
|
|
442
420
|
return true;
|
|
443
421
|
}
|
|
444
422
|
: null;
|
|
@@ -457,7 +435,7 @@ export class Channel {
|
|
|
457
435
|
// the place was written down a moment ago and a moment of it twice is
|
|
458
436
|
// better than a moment of it missing. A live source is joined as is,
|
|
459
437
|
// and a film that has barely started is started.
|
|
460
|
-
const from = resume.live ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND));
|
|
438
|
+
const from = resume.live && !list ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND));
|
|
461
439
|
const seek = from > 0 ? ["-ss", String(from)] : [];
|
|
462
440
|
// Read the source here rather than in ffmpeg, when a policy wants the
|
|
463
441
|
// original bytes and the source is the kind that can be. ffmpeg then
|
|
@@ -467,26 +445,22 @@ export class Channel {
|
|
|
467
445
|
this.throughAbort?.abort();
|
|
468
446
|
this.throughAbort = null;
|
|
469
447
|
// The outro is a file read by ffmpeg itself: a pipe cannot loop.
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
:
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
"-c:a", "aac", "-b:a", "160k", "-ac", "2",
|
|
487
|
-
"-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
|
488
|
-
]
|
|
489
|
-
: null;
|
|
448
|
+
const playlistInput = localPlaylist ? new PlaylistInputStream({
|
|
449
|
+
ffmpeg: this.options.ffmpeg,
|
|
450
|
+
ffprobe: this.options.ffprobe ?? this.options.ffmpeg.map(one => one.replace(/ffmpeg(?=(?:\.exe)?$)/, "ffprobe")),
|
|
451
|
+
files: localPlaylist, at: this.info.playlistAt ?? 0, position: from,
|
|
452
|
+
video: this.info.kind === "video", width: this.info.codecs?.width, height: this.info.codecs?.height,
|
|
453
|
+
}) : null;
|
|
454
|
+
const through = this.outroOn ? null : playlistInput
|
|
455
|
+
? { format: "mpegts", open: async (signal: AbortSignal) => playlistInput.open(signal) }
|
|
456
|
+
: this.options.through?.(this.info, from, input, audio) ?? null;
|
|
457
|
+
this.info.teed = through !== null && !playlistInput;
|
|
458
|
+
// AAC's AudioSpecificConfig is produced by the bitstream filter on
|
|
459
|
+
// its first packet. Writing an empty moov earlier omits that config:
|
|
460
|
+
// HLS remuxing then emits AAC with no ADTS headers and browsers fail.
|
|
461
|
+
const playlistEncode = !playlistInput ? null : this.info.kind === "video"
|
|
462
|
+
? ["-c", "copy", "-bsf:a", "aac_adtstoasc", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof+delay_moov"]
|
|
463
|
+
: ["-af", "aresample=async=1:first_pts=0", ...encode];
|
|
490
464
|
const child = spawn(
|
|
491
465
|
command,
|
|
492
466
|
[
|
|
@@ -508,7 +482,7 @@ export class Channel {
|
|
|
508
482
|
// headers, which only a source read through us leaves out,
|
|
509
483
|
// are no use to a pipe and are not here (a source that needs
|
|
510
484
|
// them is not read through us in the first place).
|
|
511
|
-
...input,
|
|
485
|
+
...(playlistInput ? ["-probesize", "32768", "-analyzeduration", "1000000"] : input),
|
|
512
486
|
"-f", through.format,
|
|
513
487
|
...(paced ? ["-re"] : []),
|
|
514
488
|
"-i", "pipe:0",
|
|
@@ -529,12 +503,12 @@ export class Channel {
|
|
|
529
503
|
// What the source's site expects on the request: a user agent, a
|
|
530
504
|
// referer, a cookie. A link resolved by yt-dlp comes with these,
|
|
531
505
|
// and a CDN that got them from yt-dlp and not from us answers 403.
|
|
532
|
-
...
|
|
506
|
+
...input, ...seek, "-i", current,
|
|
533
507
|
// The sound, when the site keeps it apart from the picture: a
|
|
534
508
|
// second input, dialled the same way, that the encode maps in.
|
|
535
509
|
...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
|
|
536
510
|
]),
|
|
537
|
-
...(
|
|
511
|
+
...(playlistEncode ?? encode),
|
|
538
512
|
"pipe:1",
|
|
539
513
|
],
|
|
540
514
|
{ stdio: [through ? "pipe" : "ignore", "pipe", "pipe", "pipe"] },
|
|
@@ -551,18 +525,26 @@ export class Channel {
|
|
|
551
525
|
let progress = "";
|
|
552
526
|
(child.stdio[3] as Readable | null)?.on("data", (chunk: Buffer) => {
|
|
553
527
|
progress = (progress + chunk.toString("utf8")).slice(-4000);
|
|
554
|
-
if (this.child !== child || resume.live) return;
|
|
528
|
+
if (this.child !== child || (resume.live && !list)) return;
|
|
555
529
|
const lines = progress.split("\n");
|
|
556
530
|
progress = lines.pop() ?? "";
|
|
557
531
|
for (const line of lines) {
|
|
558
532
|
const match = /^out_time_us=(\d+)/.exec(line.trim());
|
|
559
|
-
if (match)
|
|
533
|
+
if (match) {
|
|
534
|
+
const seconds = Number(match[1]) / 1e6;
|
|
535
|
+
if (playlistInput) {
|
|
536
|
+
const place = playlistInput.place(seconds);
|
|
537
|
+
this.info.playlistAt = place.at;
|
|
538
|
+
this.info.position = place.position;
|
|
539
|
+
} else this.info.position = from + seconds;
|
|
540
|
+
}
|
|
560
541
|
}
|
|
561
542
|
});
|
|
562
543
|
(child.stdio[3] as Readable | null)?.on("error", () => undefined);
|
|
563
544
|
child.stdout?.on("data", (chunk: Buffer) => {
|
|
564
545
|
// An ffmpeg that was replaced can still have a chunk in the pipe.
|
|
565
546
|
if (this.child !== child) return;
|
|
547
|
+
if (!sent) this.info.error = undefined;
|
|
566
548
|
sent = true;
|
|
567
549
|
this.info.bytes += chunk.byteLength;
|
|
568
550
|
this.rearm(child);
|
|
@@ -572,8 +554,12 @@ export class Channel {
|
|
|
572
554
|
drain(child.stderr, (tail) => { this.stderr = tail; });
|
|
573
555
|
// Only the ffmpeg we are currently running gets to say the source
|
|
574
556
|
// dropped. One that was killed to make way for a restart is not news.
|
|
575
|
-
child.on("error", () => { if (this.child === child) this.dropped(sent); });
|
|
576
|
-
child.on("close", () => {
|
|
557
|
+
child.on("error", () => { if (this.child === child) this.dropped(sent, false); });
|
|
558
|
+
child.on("close", (code, signal) => {
|
|
559
|
+
if (this.child !== child) return;
|
|
560
|
+
if (playlistInput?.error) this.stderr = playlistInput.error.message;
|
|
561
|
+
this.dropped(sent, code === 0 && !signal && (!playlistInput || playlistInput.complete));
|
|
562
|
+
});
|
|
577
563
|
};
|
|
578
564
|
|
|
579
565
|
this.redial = dial;
|
|
@@ -596,6 +582,7 @@ export class Channel {
|
|
|
596
582
|
const kind = this.info.kind ?? "audio";
|
|
597
583
|
const old = this.child;
|
|
598
584
|
this.child = null;
|
|
585
|
+
this.throughAbort?.abort();
|
|
599
586
|
if (this.watchdog) clearTimeout(this.watchdog);
|
|
600
587
|
this.watchdog = null;
|
|
601
588
|
if (this.timer) clearTimeout(this.timer);
|
|
@@ -642,7 +629,7 @@ export class Channel {
|
|
|
642
629
|
this.info.playlistAt = 0;
|
|
643
630
|
this.startOver();
|
|
644
631
|
const show = this.dialed;
|
|
645
|
-
this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0 });
|
|
632
|
+
this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0, playlistAt: 0 });
|
|
646
633
|
return true;
|
|
647
634
|
}
|
|
648
635
|
const dial = this.redial;
|
|
@@ -676,6 +663,8 @@ export class Channel {
|
|
|
676
663
|
private startOver(preserveListeners = false): void {
|
|
677
664
|
if (this.info.kind === "video") this.fragments = new Fragments();
|
|
678
665
|
this.recent = [];
|
|
666
|
+
this.fragmentClock = new FragmentClock();
|
|
667
|
+
this.fragmentTimes = new WeakMap();
|
|
679
668
|
this.recentBytes = 0;
|
|
680
669
|
// A new source may be a different size of stream, and the rate measured
|
|
681
670
|
// off the old one is not evidence about this one.
|
|
@@ -734,7 +723,7 @@ export class Channel {
|
|
|
734
723
|
* worth dialling again, and a URL that has never once produced a byte is a
|
|
735
724
|
* mistake somebody made, and retrying it for ever helps nobody.
|
|
736
725
|
*/
|
|
737
|
-
private dropped(sent: boolean): void {
|
|
726
|
+
private dropped(sent: boolean, clean = false): void {
|
|
738
727
|
if (this.closing || !this.redial) return;
|
|
739
728
|
// An outro that stopped is over; there is nothing after it.
|
|
740
729
|
if (this.outroOn) {
|
|
@@ -742,26 +731,26 @@ export class Channel {
|
|
|
742
731
|
return;
|
|
743
732
|
}
|
|
744
733
|
this.child = null;
|
|
734
|
+
this.throughAbort?.abort();
|
|
745
735
|
if (this.watchdog) clearTimeout(this.watchdog);
|
|
746
736
|
this.watchdog = null;
|
|
747
737
|
const said = lastLine(this.stderr);
|
|
748
738
|
if (said) this.info.error = said;
|
|
749
|
-
this.failures = sent ? 0 : this.failures + 1;
|
|
739
|
+
this.failures = sent && clean ? 0 : this.failures + 1;
|
|
750
740
|
if (this.failures >= GIVE_UP) {
|
|
751
741
|
this.close();
|
|
752
742
|
return;
|
|
753
743
|
}
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
const moved = this.advance?.() ?? false;
|
|
744
|
+
// Only a clean EOF advances a per-URL playlist. Decoder errors, a
|
|
745
|
+
// watchdog kill, or a failed normalized input recover at the saved place
|
|
746
|
+
// with bounded retries; an MP4 header alone is not a completed show.
|
|
747
|
+
const moved = clean ? this.advance?.() ?? false : false;
|
|
759
748
|
// A show that ended: a film or a podcast that played to its end, a
|
|
760
749
|
// list whose last entry did. That is not a source that dropped, and it
|
|
761
750
|
// is not dialled again from the top; it is over, and the outro says
|
|
762
751
|
// so. A live feed that stopped is a feed that dropped, and is redialled.
|
|
763
752
|
const list = (this.info.playlist?.length ?? 0) > 0;
|
|
764
|
-
if (sent && !moved && (this.info.live === false || list)) {
|
|
753
|
+
if (clean && sent && !moved && (this.info.live === false || list)) {
|
|
765
754
|
void this.endShow();
|
|
766
755
|
return;
|
|
767
756
|
}
|
|
@@ -798,7 +787,21 @@ export class Channel {
|
|
|
798
787
|
}
|
|
799
788
|
const cap = this.backlogCap();
|
|
800
789
|
for (const box of this.fragments.push(chunk)) {
|
|
801
|
-
|
|
790
|
+
const time = this.fragmentClock.read(box);
|
|
791
|
+
if (time !== null) this.fragmentTimes.set(box, time);
|
|
792
|
+
if (!isOpening(boxType(box))) this.remember(box, time === null ? cap : BACKLOG_VIDEO_MAX, true);
|
|
793
|
+
if (time !== null) {
|
|
794
|
+
// Drop complete old fragments even when six seconds occupy only a
|
|
795
|
+
// few kilobytes (slides, static cameras, or a paused game screen).
|
|
796
|
+
while (this.recent.length > 0) {
|
|
797
|
+
const oldest = this.fragmentTimes.get(this.recent[0]!);
|
|
798
|
+
// Audio frame rounding and floating-point subtraction must not
|
|
799
|
+
// retain one extra two-second fragment at the window boundary.
|
|
800
|
+
if (oldest === undefined || time - oldest < BACKLOG_SECONDS - 0.05) break;
|
|
801
|
+
do { this.recentBytes -= this.recent.shift()!.byteLength; }
|
|
802
|
+
while (this.recent.length && boxType(this.recent[0]!) !== "moof");
|
|
803
|
+
}
|
|
804
|
+
}
|
|
802
805
|
this.send(box);
|
|
803
806
|
}
|
|
804
807
|
}
|
|
@@ -978,8 +981,11 @@ export class Channel {
|
|
|
978
981
|
// Wait for ffmpeg to take it, or for the pipe to go: a pipe that
|
|
979
982
|
// closed never drains, and waiting on it would hold the read open.
|
|
980
983
|
await new Promise<void>((done) => {
|
|
981
|
-
|
|
982
|
-
|
|
984
|
+
const finish = (): void => {
|
|
985
|
+
stdin.off("drain", finish); stdin.off("close", finish); done();
|
|
986
|
+
};
|
|
987
|
+
stdin.once("drain", finish);
|
|
988
|
+
stdin.once("close", finish);
|
|
983
989
|
});
|
|
984
990
|
}
|
|
985
991
|
}
|
|
@@ -1063,10 +1069,7 @@ export class Channel {
|
|
|
1063
1069
|
this.child = null;
|
|
1064
1070
|
this.throughAbort?.abort();
|
|
1065
1071
|
this.throughAbort = null;
|
|
1066
|
-
|
|
1067
|
-
rmSync(dirname(this.concatListPath), { force: true, recursive: true });
|
|
1068
|
-
this.concatListPath = null;
|
|
1069
|
-
}
|
|
1072
|
+
|
|
1070
1073
|
this.endTaps();
|
|
1071
1074
|
try {
|
|
1072
1075
|
child?.stdin?.end();
|
|
@@ -1437,7 +1440,7 @@ export function rememberedNow(channels: Channels): RememberedChannel[] {
|
|
|
1437
1440
|
if (one.kind) kept.kind = one.kind;
|
|
1438
1441
|
if (one.codecs) kept.codecs = one.codecs;
|
|
1439
1442
|
if (typeof one.live === "boolean") kept.live = one.live;
|
|
1440
|
-
if (!one.live && typeof one.position === "number" && one.position > 0) kept.position = Math.floor(one.position);
|
|
1443
|
+
if ((!one.live || one.playlist?.length) && typeof one.position === "number" && one.position > 0) kept.position = Math.floor(one.position);
|
|
1441
1444
|
if (one.startedBy) kept.startedBy = one.startedBy;
|
|
1442
1445
|
if (one.playlist && one.playlist.length > 0) {
|
|
1443
1446
|
kept.playlist = one.playlist;
|
package/src/fragments.ts
CHANGED
|
@@ -64,6 +64,52 @@ export function isOpening(type: string): boolean {
|
|
|
64
64
|
return type === "ftyp" || type === "moov";
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
function children(box: Buffer): Box[] {
|
|
68
|
+
let rest = box.subarray(box.readUInt32BE(0) === 1 ? BIG : HEADER);
|
|
69
|
+
const found: Box[] = [];
|
|
70
|
+
for (;;) {
|
|
71
|
+
const next = firstBox(rest);
|
|
72
|
+
if (!next) return found;
|
|
73
|
+
found.push(next.box);
|
|
74
|
+
rest = next.rest;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Decode timestamps use each track's own timescale, never its byte rate. */
|
|
79
|
+
export class FragmentClock {
|
|
80
|
+
private scales = new Map<number, number>();
|
|
81
|
+
read(box: Buffer): number | null {
|
|
82
|
+
const type = box.toString("latin1", 4, 8);
|
|
83
|
+
if (type === "moov") {
|
|
84
|
+
for (const track of children(box).filter(one => one.type === "trak")) {
|
|
85
|
+
const parts = children(track.bytes);
|
|
86
|
+
const tkhd = parts.find(one => one.type === "tkhd")?.bytes;
|
|
87
|
+
const mdia = parts.find(one => one.type === "mdia")?.bytes;
|
|
88
|
+
const mdhd = mdia && children(mdia).find(one => one.type === "mdhd")?.bytes;
|
|
89
|
+
if (!tkhd || !mdhd) continue;
|
|
90
|
+
const idAt = tkhd[8] === 1 ? 28 : 20;
|
|
91
|
+
const scaleAt = mdhd[8] === 1 ? 28 : 20;
|
|
92
|
+
if (tkhd.length < idAt + 4 || mdhd.length < scaleAt + 4) continue;
|
|
93
|
+
const scale = mdhd.readUInt32BE(scaleAt);
|
|
94
|
+
if (scale) this.scales.set(tkhd.readUInt32BE(idAt), scale);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (type !== "moof") return null;
|
|
98
|
+
const times: number[] = [];
|
|
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)) continue;
|
|
104
|
+
const scale = this.scales.get(tfhd.readUInt32BE(12));
|
|
105
|
+
if (!scale) continue;
|
|
106
|
+
const time = tfdt[8] === 1 ? Number(tfdt.readBigUInt64BE(12)) : tfdt.readUInt32BE(12);
|
|
107
|
+
times.push(time / scale);
|
|
108
|
+
}
|
|
109
|
+
return times.length ? Math.min(...times) : null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
67
113
|
/**
|
|
68
114
|
* A fragmented MP4 arriving in pieces, handed back a box at a time.
|
|
69
115
|
*
|