@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,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)
|
|
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 (
|
|
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
|
-
+
|
|
834
|
+
+ passed[at] * refetchSecPerSegment;
|
|
824
835
|
if (arrival < soonest) {
|
|
825
836
|
soonest = arrival;
|
|
826
837
|
byWhom = encoder;
|
|
827
838
|
}
|
|
828
839
|
}
|
|
829
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
+
|
|
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
|
-
|
|
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,
|
|
146
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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,
|
|
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
|
-
|
|
108
|
-
|
|
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(
|
|
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]
|
|
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(
|
|
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]
|
|
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
|
}
|