@torrent-tv/proxy 2.95.22 → 2.95.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.95.22",
3
+ "version": "2.95.24",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -1,7 +1,7 @@
1
1
  import { parseRange } from "../../utils/parse-range.js";
2
2
 
3
3
  /** FFmpeg reads only original byte ranges already owned by its live run. */
4
- export function handleEncodeInputGet(req, reply, { inputOf }) {
4
+ export function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }) {
5
5
  const address = req.raw?.socket?.remoteAddress;
6
6
  if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(address)) return reply.code(403).send();
7
7
  const token = Number(req.params.token), fileIndex = Number(req.params.fileIndex);
@@ -26,7 +26,12 @@ export function handleEncodeInputGet(req, reply, { inputOf }) {
26
26
  // end of a content range). A cap of 1 MiB here ended every original-source
27
27
  // segment longer than that on 6.1 (torrent-tv/meta#151).
28
28
  const held = input.read(fileIndex, range.start, range.end, true);
29
- if (!held) return reply.code(503).send({ error: "Original input bytes are outside the admitted ranges." });
29
+ if (!held) {
30
+ // FFmpeg asked for a byte the run was not given: the container named too
31
+ // little. Said, because FFmpeg itself reports only a failed seek or read.
32
+ refused({ token, fileIndex, start: range.start, runTag: input.runTag ?? null });
33
+ return reply.code(503).send({ error: "Original input bytes are outside the admitted ranges." });
34
+ }
30
35
  return reply.code(206).header("Content-Length", String(held.bytes.length))
31
36
  .header("Content-Range", `bytes ${range.start}-${held.end}/${length}`).send(held.bytes);
32
37
  }
package/server.js CHANGED
@@ -1147,7 +1147,8 @@ export async function startProxyServer({
1147
1147
  })
1148
1148
  );
1149
1149
  app.get("/encode-input/:token/:fileIndex", (req, reply) =>
1150
- handleEncodeInputGet(req, reply, { inputOf: token => outputParts.encodeRuns.originalInputOf(token) })
1150
+ handleEncodeInputGet(req, reply, { inputOf: token => outputParts.encodeRuns.originalInputOf(token),
1151
+ refused: ({ fileIndex, start, runTag }) => logger.warn(`encode input: run ${runTag ?? "?"} asked for byte ${start} of file ${fileIndex}, which it was not given`) })
1151
1152
  );
1152
1153
  app.get("/stream", async (req, reply) =>
1153
1154
  handleStreamGet(req, reply, {
@@ -794,13 +794,24 @@ function latenessOf(encoders, coverage, wanted, untilNeeded, rate, refetchSecPer
794
794
  const lateAt = new Map(ranks.map((rank) => [rank, 0]));
795
795
  /** When the last number of a rank is made, per rank. @type {Map<number, number>} */
796
796
  const doneAt = new Map(ranks.map((rank) => [rank, 0]));
797
+ // How many numbers each encoder has already passed over that are made, kept
798
+ // as the walk goes rather than counted again for every number: counted again,
799
+ // a film of two thousand pieces cost the plan four million steps, and the
800
+ // plan held the proxy's thread for most of every second (Home Assistant
801
+ // 2026-10-08, torrent-tv/meta#151). The same count `madeBetween` gives.
802
+ const passed = encoders.map((encoder) => coverage.madeBetween(encoder.at, first - 1));
797
803
  for (let index = first; index <= last; index += 1) {
804
+ const readyHere = coverage.isReady(index);
805
+ for (let at = 0; at < encoders.length; at += 1) {
806
+ if (readyHere && encoders[at].at <= index) passed[at] += 1;
807
+ }
798
808
  // Which encoder gets to this piece first, and when. One standing on it is
799
809
  // already there; one behind it must work its way up, re-making anything
800
810
  // already made on the way, which costs its own time and the swarm's.
801
811
  let soonest = Number.POSITIVE_INFINITY;
802
812
  let byWhom = null;
803
- for (const encoder of encoders) {
813
+ for (let at = 0; at < encoders.length; at += 1) {
814
+ const encoder = encoders[at];
804
815
  if (encoder.at > index) {
805
816
  continue;
806
817
  }
@@ -820,13 +831,13 @@ function latenessOf(encoders, coverage, wanted, untilNeeded, rate, refetchSecPer
820
831
  // stood still for 116.7 s.
821
832
  const arrival = encoder.delaySec
822
833
  + (index - encoder.at) / rate
823
- + coverage.madeBetween(encoder.at, index) * refetchSecPerSegment;
834
+ + passed[at] * refetchSecPerSegment;
824
835
  if (arrival < soonest) {
825
836
  soonest = arrival;
826
837
  byWhom = encoder;
827
838
  }
828
839
  }
829
- if (coverage.isReady(index)) {
840
+ if (readyHere) {
830
841
  // It exists. Nobody waits for it and nothing is owed — but whoever passes
831
842
  // over it makes it a second time, and the swarm fetches its bytes again.
832
843
  if (byWhom !== null) {
@@ -1103,8 +1114,19 @@ function gapFinderFor(coverage, surviving, rate, refetchSecPerSegment = 0) {
1103
1114
  // the map happens to be in.
1104
1115
  let best = null;
1105
1116
  let bestDue = Number.POSITIVE_INFINITY;
1117
+ // Where every encoder already placed stands, and how many made numbers lie
1118
+ // between it and the number the walk is on — kept as the walk goes, because
1119
+ // counted again for every number a two-hour film made this one walk
1120
+ // millions of steps, and the plan held the proxy's thread for most of every
1121
+ // second (Home Assistant 2026-10-08, torrent-tv/meta#151).
1122
+ const standing = [...placed.map((live) => live.at), ...(alsoPlaced ?? [])];
1123
+ const passed = standing.map((a) => coverage.madeBetween(a, start - 1));
1106
1124
  for (let index = start; index <= last; index += 1) {
1107
- if (coverage.isReady(index)) {
1125
+ const readyHere = coverage.isReady(index);
1126
+ for (let which = 0; which < standing.length; which += 1) {
1127
+ if (readyHere && standing[which] <= index) passed[which] += 1;
1128
+ }
1129
+ if (readyHere) {
1108
1130
  continue;
1109
1131
  }
1110
1132
  const deadline = deadlineAt(index);
@@ -1121,7 +1143,8 @@ function gapFinderFor(coverage, surviving, rate, refetchSecPerSegment = 0) {
1121
1143
  // second and a third on the very next numbers — three processes a segment
1122
1144
  // apart for one person, which is the waste this model exists to refuse.
1123
1145
  let soonest = Number.POSITIVE_INFINITY;
1124
- for (const a of [...placed.map((live) => live.at), ...(alsoPlaced ?? [])]) {
1146
+ for (let which = 0; which < standing.length; which += 1) {
1147
+ const a = standing[which];
1125
1148
  if (a > index) {
1126
1149
  // Standing past it. Encoders only move forward, so it never will.
1127
1150
  continue;
@@ -1141,7 +1164,7 @@ function gapFinderFor(coverage, surviving, rate, refetchSecPerSegment = 0) {
1141
1164
  // to arrive, and the model compares arrivals. Asked separately it was a
1142
1165
  // second authority over the same encoder, and the two disagreed.
1143
1166
  const arrival = (index - a + 1) / rate
1144
- + coverage.madeBetween(a, index) * refetchSecPerSegment;
1167
+ + passed[which] * refetchSecPerSegment;
1145
1168
  if (arrival < soonest) {
1146
1169
  soonest = arrival;
1147
1170
  }
@@ -478,7 +478,9 @@ export class EncodeRun {
478
478
  if (line.length > 0) {
479
479
  this.lastError = line;
480
480
  const errorText = this.#stderrReadTail + String(chunk);
481
- this.#inputTruncated ||= /Stream ends prematurely|Input\/output error|Error during demuxing/i.test(errorText);
481
+ // A failed seek leaves the demuxer wherever it stood, so what follows is
482
+ // not the stretch asked for and is never published (torrent-tv/meta#151).
483
+ this.#inputTruncated ||= /Stream ends prematurely|Input\/output error|Error during demuxing|Seek failed/i.test(errorText);
482
484
  this.#stderrReadTail = errorText.slice(-512);
483
485
  this.logger.warn(`ffmpeg #${this.from}..#${this.to} of ${this.address}: ${line}`);
484
486
  }
@@ -142,8 +142,9 @@ export class AviContainer extends Container {
142
142
  const record = { dispose: () => this.#index?.dispose?.() };
143
143
  const allocation = this.packetMemory?.forRecord?.(record, "index") ?? this.packetMemory;
144
144
  const index = await readAviIndex({ read: this.readRange, fileSize: this.fileSize, movi, idx1, allocation,
145
- streams: tracks.map(track => ({ type: track.type, timeBase: track.timeBase, sampleSize: track.sampleSize,
146
- startTimeSeconds: track.startTimeSeconds, indexChunks: streams[track.trackNumber].indexChunks })) });
145
+ streams: tracks.map(track => ({ type: track.type, codecId: track.codecId, timeBase: track.timeBase,
146
+ sampleSize: track.sampleSize, blockAlign: track.blockAlign ?? 0, startUnits: track.startUnits ?? 0,
147
+ indexChunks: streams[track.trackNumber].indexChunks })) });
147
148
  this.#index = index;
148
149
  return index;
149
150
  }
@@ -173,8 +174,12 @@ export class AviContainer extends Container {
173
174
  if (!index) return { kind: "needs-index", reason: "source-has-no-avi-index" };
174
175
  const tracks = await this.readTracks();
175
176
  const { movi } = await this.#topLevel();
176
- const requested = tracks.filter(track => ["video", "audio"].includes(track.type) &&
177
- (!interval.trackIds?.length || interval.trackIds.includes(track.trackNumber)));
177
+ // Every audio and video stream, whichever the run outputs: FFmpeg's AVI
178
+ // demuxer seeks to the earliest position any stream's index gives for the
179
+ // keyframe time (`pos_min`, avi_read_seek) and reads every stream's packets
180
+ // in file order. Naming only the run's own tracks left a picture-only run
181
+ // asking for sound it was not given, and its seek failed (torrent-tv/meta#151).
182
+ const requested = tracks.filter(track => ["video", "audio"].includes(track.type));
178
183
  const picture = ContainerTrack.firstUsable(tracks, "video");
179
184
  const padded = ([start, end]) => [Math.max(0, start), Math.min(this.fileSize - 1, end + FFMPEG_INPUT_BUFFER_BYTES)];
180
185
  const ranges = [[0, movi.start + 3], ...index.indexRanges, ...index.firstPackets(),
@@ -230,6 +235,7 @@ export class AviContainer extends Container {
230
235
  track = new AudioTrack({ ...params, codecId: waveCodec(tag),
231
236
  channels: strf.readUInt16LE(2), samplingFrequency: strf.readUInt32LE(4),
232
237
  codecPrivateB64: strf.subarray(18).toString("base64") });
238
+ track.blockAlign = strf.readUInt16LE(12);
233
239
  track.matroskaCodecId = "A_MS/ACM";
234
240
  track.matroskaCodecPrivateB64 = (strf.length === 16
235
241
  ? Buffer.concat([strf, Buffer.alloc(2)]) : strf).toString("base64");
@@ -238,6 +244,7 @@ export class AviContainer extends Container {
238
244
  track.startTimeSeconds = track.timeBase === null ? null : strh.readUInt32LE(28) * track.timeBase;
239
245
  track.durationSeconds = track.timeBase === null ? null : strh.readUInt32LE(32) * track.timeBase;
240
246
  track.sampleSize = strh.readUInt32LE(44);
247
+ track.startUnits = strh.readUInt32LE(28);
241
248
  if (["h264", "hevc"].includes(track.codecId)) track.presentationCadenceSeconds = track.timeBase;
242
249
  return track;
243
250
  });
@@ -74,8 +74,9 @@ class StreamPackets {
74
74
  * @param {number} params.fileSize
75
75
  * @param {{ start: number, end: number }} params.movi - The first `movi` list (`start` at its type).
76
76
  * @param {{ start: number, end: number } | null} params.idx1 - The `idx1` chunk's payload, if any.
77
- * @param {Array<{ type: string, timeBase: number | null, sampleSize: number, startTimeSeconds: number | null,
78
- * indexChunks: Array<{ start: number, end: number }> }>} params.streams - In stream-number order.
77
+ * @param {Array<{ type: string, codecId: string, timeBase: number | null, sampleSize: number, blockAlign: number,
78
+ * startUnits: number, indexChunks: Array<{ start: number, end: number }> }>} params.streams - In stream-number order,
79
+ * as the stream headers declare them (`strh` sample size and start, `WAVEFORMATEX` block alignment).
79
80
  * @param {{ reserve?: (bytes: number) => boolean, release?: (bytes: number) => void }} params.allocation
80
81
  * @returns {Promise<AviIndex | null>} Null when the file states no index.
81
82
  */
@@ -104,8 +105,38 @@ function reserve(allocation, count) {
104
105
  return bytes;
105
106
  }
106
107
 
107
- function durationOf(stream, length) {
108
- return stream.timeBase * (stream.sampleSize > 0 ? length / stream.sampleSize : 1);
108
+ /**
109
+ * A stream's clock as FFmpeg's AVI demuxer keeps it (avidec.c n8.1). The clock
110
+ * counts units: bytes where the stream has a sample size, otherwise blocks of
111
+ * the audio's `nBlockAlign` rounded up per packet (`get_duration`), otherwise
112
+ * one per packet, an empty one included. A packet's time is its units, divided
113
+ * by the sample size where there is one, times the time base. FFmpeg's header
114
+ * rules decide the sample size and the block alignment it uses; they are taken
115
+ * over exactly, since a time that disagrees with FFmpeg's moves the bytes a
116
+ * seek asks for out of the ranges named (field 2026-10-08: a two-hour MP3 AVI
117
+ * failed to seek at 384 s).
118
+ */
119
+ function clockOf(stream) {
120
+ // The header's sample size is a signed 32-bit field to FFmpeg; the start is
121
+ // multiplied by it as read, before any correction, and a start beyond an hour
122
+ // is discarded.
123
+ const declared = (stream.sampleSize ?? 0) | 0;
124
+ const startUnits = (stream.startUnits ?? 0) * stream.timeBase > 3600 ? 0 : stream.startUnits ?? 0;
125
+ let sampleSize = stream.type === "video" || declared < 0 ? 0 : declared;
126
+ let blockAlign = 0;
127
+ if (stream.type === "audio") {
128
+ blockAlign = stream.blockAlign > 0 ? stream.blockAlign : 0;
129
+ if (sampleSize && blockAlign && sampleSize !== blockAlign) sampleSize = blockAlign;
130
+ if (["aac", "flac", "mp2"].includes(stream.codecId) && blockAlign <= 4) blockAlign = 0;
131
+ if ((stream.codecId === "aac" && (blockAlign === 1024 || blockAlign === 4096) && sampleSize === blockAlign) ||
132
+ (stream.codecId === "mp3" && blockAlign === 1152 && sampleSize === 1152)) sampleSize = 0;
133
+ }
134
+ return {
135
+ sampleSize,
136
+ units: startUnits * Math.max(1, declared),
137
+ advance(length) { this.units += sampleSize ? length : blockAlign ? Math.ceil(length / blockAlign) : 1; },
138
+ seconds() { return stream.timeBase * (sampleSize ? Math.floor(this.units / sampleSize) : this.units); }
139
+ };
109
140
  }
110
141
 
111
142
  async function readIdx1({ read, fileSize, movi, idx1, streams, allocation }) {
@@ -139,7 +170,7 @@ async function readIdx1({ read, fileSize, movi, idx1, streams, allocation }) {
139
170
  const held = reserve(allocation, counts.reduce((sum, count) => sum + count, 0));
140
171
  try {
141
172
  const packets = counts.map(count => new StreamPackets(count));
142
- const clocks = streams.map(stream => stream.startTimeSeconds ?? 0);
173
+ const clocks = streams.map(clockOf);
143
174
  for await (const { bytes } of blocks(read, idx1.start, idx1.end, IDX1_ENTRY_BYTES)) {
144
175
  for (let offset = 0; offset < bytes.length; offset += IDX1_ENTRY_BYTES) {
145
176
  const chunkId = bytes.toString("ascii", offset, offset + 4);
@@ -151,8 +182,8 @@ async function readIdx1({ read, fileSize, movi, idx1, streams, allocation }) {
151
182
  // FFmpeg takes the index flag alone, and every entry as a keyframe
152
183
  // when no entry carries it (avi_read_idx1).
153
184
  const keyframe = !anyKeyframe || !!(bytes.readUInt32LE(offset + 4) & AVIIF_KEYFRAME);
154
- packets[id].push(start, length, clocks[id], keyframe);
155
- clocks[id] += durationOf(streams[id], length);
185
+ packets[id].push(start, length, clocks[id].seconds(), keyframe);
186
+ clocks[id].advance(length);
156
187
  }
157
188
  }
158
189
  return new AviIndex({ packets, indexRanges: [[idx1.start - 8, fileSize - 1]], held, allocation });
@@ -202,9 +233,8 @@ async function readOpenDml({ read, fileSize, streams, allocation }) {
202
233
  const held = reserve(allocation, counts.reduce((sum, count) => sum + count, 0));
203
234
  try {
204
235
  const packets = counts.map(count => new StreamPackets(count));
205
- const clocks = streams.map(stream => stream.startTimeSeconds ?? 0);
236
+ const clocks = streams.map(clockOf);
206
237
  for (const standard of parsed) {
207
- const stream = streams[standard.id];
208
238
  const tableStart = standard.start + 32;
209
239
  const tableEnd = tableStart + standard.count * standard.entryBytes;
210
240
  for await (const { bytes } of blocks(read, tableStart, tableEnd, standard.entryBytes)) {
@@ -212,8 +242,8 @@ async function readOpenDml({ read, fileSize, streams, allocation }) {
212
242
  const start = standard.base + bytes.readUInt32LE(offset);
213
243
  const encoded = bytes.readUInt32LE(offset + 4), length = encoded & 0x7fffffff;
214
244
  if (length && (start < 8 || start + length > fileSize)) throw new Error("AVI OpenDML packet exceeds the file.");
215
- packets[standard.id].push(start, length, clocks[standard.id], !(encoded & 0x80000000));
216
- clocks[standard.id] += durationOf(stream, length);
245
+ packets[standard.id].push(start, length, clocks[standard.id].seconds(), !(encoded & 0x80000000));
246
+ clocks[standard.id].advance(length);
217
247
  }
218
248
  }
219
249
  }