@torrent-tv/proxy 2.59.0 → 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.
@@ -6,13 +6,15 @@ set $mask = *(unsigned long *)($base + 8)
6
6
  printf "asochash=%p mask=%lu\n", $hash, $mask
7
7
  set $i = 0
8
8
  set $stcb = 0
9
- while $i <= $mask && $stcb == 0
10
- set $head = *(unsigned long *)($hash + $i * 8)
11
- if $head != 0
12
- set $stcb = $head
13
- printf "bucket %lu -> stcb=%p\n", $i, $stcb
9
+ if $hash != 0
10
+ while $i <= $mask && $stcb == 0
11
+ set $head = *(unsigned long *)($hash + $i * 8)
12
+ if $head != 0
13
+ set $stcb = $head
14
+ printf "bucket %lu -> stcb=%p\n", $i, $stcb
15
+ end
16
+ set $i = $i + 1
14
17
  end
15
- set $i = $i + 1
16
18
  end
17
19
  if $stcb == 0
18
20
  printf "no association found (no viewer connected?)\n"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.59.0",
3
+ "version": "2.59.2",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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
- logger.info(`transcode ${session.id} ${runLabel} ffmpeg ${describeFfmpegArgs(args)}`);
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
+ });