@torrent-tv/proxy 2.59.1 → 2.59.2
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/CHANGELOG.md +1209 -1205
- package/package.json +1 -1
- package/services/ffmpeg-banner.js +48 -0
- package/services/hls-session-manager.js +51 -2
- package/test/ffmpeg-stream-counts.test.js +78 -0
package/package.json
CHANGED
|
@@ -119,6 +119,54 @@ export function parseFfmpegVideoDimensions(stderrText) {
|
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Count the streams the source declares, by type, from ffmpeg's banner
|
|
124
|
+
* ("Stream #0:3(rus): Audio: ac3 …").
|
|
125
|
+
*
|
|
126
|
+
* Every `-map` this proxy builds carries the `?` suffix, which tells ffmpeg to
|
|
127
|
+
* drop the mapping silently when the stream is absent instead of refusing. Map
|
|
128
|
+
* every stream of a run away that way and the output is left with nothing in
|
|
129
|
+
* it, which ffmpeg reports as `Output file does not contain any stream` and
|
|
130
|
+
* exit 255 — three sessions died that way on 2026-08-26 and the log held only
|
|
131
|
+
* the exit code. What separates "we asked for a track that is not there" from
|
|
132
|
+
* every other cause of 255 is this count, so it is read once, with the rest of
|
|
133
|
+
* the banner, and kept for the failure to quote.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} stderrText
|
|
136
|
+
* @returns {{ video: number, audio: number, subtitle: number, other: number } | null}
|
|
137
|
+
* Null when the banner carried no stream lines at all — which is itself
|
|
138
|
+
* different from a file that genuinely has none.
|
|
139
|
+
*/
|
|
140
|
+
export function parseFfmpegStreamCounts(stderrText) {
|
|
141
|
+
if (typeof stderrText !== "string" || stderrText.length === 0) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const lines = stderrText.match(/^\s*Stream #\d+:\d+.*$/gim);
|
|
145
|
+
if (!lines || lines.length === 0) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const counts = { video: 0, audio: 0, subtitle: 0, other: 0 };
|
|
149
|
+
for (const line of lines) {
|
|
150
|
+
// The type follows the stream's id and its optional language/metadata:
|
|
151
|
+
// "Stream #0:1(eng): Audio: aac". Attached pictures also announce
|
|
152
|
+
// themselves as Video, and they are counted as such deliberately — that is
|
|
153
|
+
// exactly what `0:v:0?` would map, and a cover image mapped as the picture
|
|
154
|
+
// is one of the ways a run ends up producing nothing anyone can watch.
|
|
155
|
+
const match = line.match(/Stream #\d+:\d+(?:\[[^\]]*\])?(?:\([^)]*\))?:\s*(\w+)/i);
|
|
156
|
+
const kind = match ? match[1].toLowerCase() : "";
|
|
157
|
+
if (kind === "video") {
|
|
158
|
+
counts.video += 1;
|
|
159
|
+
} else if (kind === "audio") {
|
|
160
|
+
counts.audio += 1;
|
|
161
|
+
} else if (kind === "subtitle") {
|
|
162
|
+
counts.subtitle += 1;
|
|
163
|
+
} else {
|
|
164
|
+
counts.other += 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return counts;
|
|
168
|
+
}
|
|
169
|
+
|
|
122
170
|
/**
|
|
123
171
|
* Parse the source frame rate from the ffmpeg "Video:" line
|
|
124
172
|
* (e.g. "… 23.98 fps," / "… 25 fps,"). Returns null when absent.
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
parseFfmpegBitrateKbps,
|
|
54
54
|
parseFfmpegDurationSeconds,
|
|
55
55
|
parseFfmpegStartTimeSeconds,
|
|
56
|
+
parseFfmpegStreamCounts,
|
|
56
57
|
parseFfmpegVideoDimensions,
|
|
57
58
|
parseFfmpegVideoFps,
|
|
58
59
|
parseFfmpegHdr
|
|
@@ -854,6 +855,10 @@ async function probeInputMediaInfo(ffmpegBin, inputUrl) {
|
|
|
854
855
|
height: dims.height,
|
|
855
856
|
fps: parseFfmpegVideoFps(stderr),
|
|
856
857
|
startTime: parseFfmpegStartTimeSeconds(stderr),
|
|
858
|
+
// Only ever read when a run fails, and read HERE because by then the
|
|
859
|
+
// banner is long gone: this probe is the one place the source says what
|
|
860
|
+
// it holds.
|
|
861
|
+
streamCounts: parseFfmpegStreamCounts(stderr),
|
|
857
862
|
isHdr: parseFfmpegHdr(stderr)
|
|
858
863
|
});
|
|
859
864
|
};
|
|
@@ -2109,6 +2114,10 @@ export class HlsSessionManager {
|
|
|
2109
2114
|
audioOnly: audioOnly === true,
|
|
2110
2115
|
audioRenditions: audioRenditions === true,
|
|
2111
2116
|
outputFps,
|
|
2117
|
+
// What the source declared it holds, kept for a failed run to quote. Null
|
|
2118
|
+
// when the media info came from a cache that predates this field or from
|
|
2119
|
+
// a probe whose banner carried no stream lines.
|
|
2120
|
+
sourceStreamCounts: mediaInfo.streamCounts ?? null,
|
|
2112
2121
|
// Client-requested target box (the orientation-independent ceiling). Kept
|
|
2113
2122
|
// for the session key and reference; the actual encode uses encodeWidth/
|
|
2114
2123
|
// encodeHeight, which the realtime budget may have downscaled below this.
|
|
@@ -4806,7 +4815,12 @@ export class HlsSessionManager {
|
|
|
4806
4815
|
session.runCounter = (session.runCounter ?? 0) + 1;
|
|
4807
4816
|
const runLabel = `run#${session.runCounter}`;
|
|
4808
4817
|
session.runLabel = runLabel;
|
|
4809
|
-
|
|
4818
|
+
const describedArgs = describeFfmpegArgs(args);
|
|
4819
|
+
// Kept so a failure can quote the command that produced it instead of
|
|
4820
|
+
// leaving whoever reads the log to find it among the lines of the runs that
|
|
4821
|
+
// succeeded around it.
|
|
4822
|
+
session.lastRunArgsDescribed = describedArgs;
|
|
4823
|
+
logger.info(`transcode ${session.id} ${runLabel} ffmpeg ${describedArgs}`);
|
|
4810
4824
|
|
|
4811
4825
|
const ffmpeg = spawn(this.ffmpegBin, args, {
|
|
4812
4826
|
cwd: session.runDirPath ?? session.dirPath,
|
|
@@ -5108,11 +5122,46 @@ export class HlsSessionManager {
|
|
|
5108
5122
|
session.progress.updatedAt = Date.now();
|
|
5109
5123
|
this.#transitionRun(session, ENCODE_RUN_EVENT.EXITED_FAILED);
|
|
5110
5124
|
logger.error(
|
|
5111
|
-
`transcode ${session.id} ${session.runLabel ?? "run#?"} encode-run failed: ${session.lastError}`
|
|
5125
|
+
`transcode ${session.id} ${session.runLabel ?? "run#?"} encode-run failed: ${session.lastError}` +
|
|
5126
|
+
` — ${this.#describeTrackSelection(session)}` +
|
|
5127
|
+
`\n ffmpeg ${session.lastRunArgsDescribed ?? "(command not recorded)"}`
|
|
5112
5128
|
);
|
|
5113
5129
|
});
|
|
5114
5130
|
}
|
|
5115
5131
|
|
|
5132
|
+
/**
|
|
5133
|
+
* What this run asked the source for, against what the source said it has.
|
|
5134
|
+
*
|
|
5135
|
+
* Written for one failure in particular: every `-map` this proxy builds ends
|
|
5136
|
+
* in `?`, so ffmpeg drops a mapping for an absent stream without complaint,
|
|
5137
|
+
* and a run whose mappings ALL drop produces a file with nothing in it —
|
|
5138
|
+
* reported as `Output file does not contain any stream`, exit 255. Read from
|
|
5139
|
+
* the exit code alone that is indistinguishable from any other refusal. Read
|
|
5140
|
+
* beside the tracks the file actually holds it is unmistakable, and it names
|
|
5141
|
+
* which side is wrong: an audio index past the end of the list is ours, no
|
|
5142
|
+
* streams at all is the source's.
|
|
5143
|
+
*
|
|
5144
|
+
* @param {HlsSession} session
|
|
5145
|
+
* @returns {string}
|
|
5146
|
+
*/
|
|
5147
|
+
#describeTrackSelection(session) {
|
|
5148
|
+
const wanted = [];
|
|
5149
|
+
if (session.audioOnly === true) {
|
|
5150
|
+
wanted.push(`audio 0:a:${session.audioTrackIndex ?? 0}`);
|
|
5151
|
+
} else if (this.#servesAudioSeparately(session)) {
|
|
5152
|
+
wanted.push("video 0:v:0");
|
|
5153
|
+
} else {
|
|
5154
|
+
wanted.push("video 0:v:0", `audio 0:a:${session.audioTrackIndex ?? 0}`);
|
|
5155
|
+
}
|
|
5156
|
+
const counts = session.sourceStreamCounts;
|
|
5157
|
+
const held = counts
|
|
5158
|
+
? `the source holds ${counts.video} video, ${counts.audio} audio, ` +
|
|
5159
|
+
`${counts.subtitle} subtitle` +
|
|
5160
|
+
(counts.other > 0 ? `, ${counts.other} other` : "")
|
|
5161
|
+
: "what the source holds was not recorded";
|
|
5162
|
+
return `this run asked for ${wanted.join(" + ")}, and ${held}`;
|
|
5163
|
+
}
|
|
5164
|
+
|
|
5116
5165
|
/**
|
|
5117
5166
|
* Ensure the encoder is producing (or will soon produce) the requested
|
|
5118
5167
|
* segment. If the segment is far ahead of the current encode head, or
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What the source said it holds, read from ffmpeg's own banner.
|
|
3
|
+
*
|
|
4
|
+
* The reading exists for one failure: every `-map` this proxy builds ends in
|
|
5
|
+
* `?`, so ffmpeg drops a mapping for a stream that is not there and says
|
|
6
|
+
* nothing. Map every stream of a run away that way and the output has nothing
|
|
7
|
+
* in it — `Output file does not contain any stream`, exit 255. Three sessions
|
|
8
|
+
* died that way on 2026-08-26 and the log held only the code.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
|
|
14
|
+
import { parseFfmpegStreamCounts } from "../services/ffmpeg-banner.js";
|
|
15
|
+
|
|
16
|
+
const FIELD_BANNER = `
|
|
17
|
+
Input #0, matroska,webm, from 'http://127.0.0.1:9090/stream?sourceKey=b3f08efc':
|
|
18
|
+
Duration: 01:32:39.00, start: 0.000000, bitrate: 9354 kb/s
|
|
19
|
+
Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x1080, 23.98 fps
|
|
20
|
+
Stream #0:1(rus): Audio: ac3, 48000 Hz, 5.1, fltp, 640 kb/s
|
|
21
|
+
Stream #0:2(eng): Audio: aac, 48000 Hz, stereo, fltp, 128 kb/s
|
|
22
|
+
Stream #0:3(rus): Subtitle: subrip
|
|
23
|
+
Stream #0:4(eng): Subtitle: hdmv_pgs_subtitle
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
test("each stream is counted under the type ffmpeg names it", () => {
|
|
27
|
+
assert.deepEqual(parseFfmpegStreamCounts(FIELD_BANNER), {
|
|
28
|
+
video: 1,
|
|
29
|
+
audio: 2,
|
|
30
|
+
subtitle: 2,
|
|
31
|
+
other: 0
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a file with no audio is what makes an audio-only run produce nothing", () => {
|
|
36
|
+
// This is the shape the diagnostic is for: the rendition asks for `0:a:0?`,
|
|
37
|
+
// the file has no audio at all, the mapping is dropped in silence and the
|
|
38
|
+
// segment muxer is handed an output with no streams.
|
|
39
|
+
const counts = parseFfmpegStreamCounts(`
|
|
40
|
+
Stream #0:0: Video: hevc (Main 10), yuv420p10le, 3840x2160, 23.98 fps
|
|
41
|
+
`);
|
|
42
|
+
assert.equal(counts.audio, 0);
|
|
43
|
+
assert.equal(counts.video, 1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("an audio index past the end of the list is OUR fault, and the counts say so", () => {
|
|
47
|
+
// A viewer picking the third dub on a file carrying one: `0:a:2?` drops, and
|
|
48
|
+
// the count is what separates that from a source that delivered nothing.
|
|
49
|
+
const counts = parseFfmpegStreamCounts(FIELD_BANNER);
|
|
50
|
+
const askedFor = 4;
|
|
51
|
+
assert.ok(askedFor >= counts.audio, "the request is outside what the file holds");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("an attached cover image is counted as video, because that is what would be mapped", () => {
|
|
55
|
+
const counts = parseFfmpegStreamCounts(`
|
|
56
|
+
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 320 kb/s
|
|
57
|
+
Stream #0:1: Video: mjpeg (Baseline), yuvj420p(pc), 600x600 (attached pic)
|
|
58
|
+
`);
|
|
59
|
+
assert.equal(counts.video, 1);
|
|
60
|
+
assert.equal(counts.audio, 1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a banner carrying no stream lines is not the same as a file with no streams", () => {
|
|
64
|
+
// Null says "not recorded"; zeroes would claim a measurement nobody took.
|
|
65
|
+
assert.equal(parseFfmpegStreamCounts("Duration: 00:10:00.00, start: 0.000000"), null);
|
|
66
|
+
assert.equal(parseFfmpegStreamCounts(""), null);
|
|
67
|
+
assert.equal(parseFfmpegStreamCounts(undefined), null);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a stream carrying metadata in brackets is still read", () => {
|
|
71
|
+
// MP4 writes `Stream #0:1[0x2](eng)`, which the earlier shape of this regex
|
|
72
|
+
// would have skipped.
|
|
73
|
+
const counts = parseFfmpegStreamCounts(`
|
|
74
|
+
Stream #0:0[0x1](und): Video: h264 (avc1), yuv420p, 1280x720, 24 fps
|
|
75
|
+
Stream #0:1[0x2](eng): Audio: aac (mp4a), 48000 Hz, stereo, fltp
|
|
76
|
+
`);
|
|
77
|
+
assert.deepEqual(counts, { video: 1, audio: 1, subtitle: 0, other: 0 });
|
|
78
|
+
});
|