@torrent-tv/proxy 2.101.19 → 2.101.21
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
package/routes/stream/get.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { open } from "node:fs/promises";
|
|
10
|
+
import { Readable } from "node:stream";
|
|
10
11
|
import { parseRange } from "../../utils/parse-range.js";
|
|
11
12
|
import { logger } from "../../utils/logger.js";
|
|
12
13
|
|
|
@@ -109,49 +110,66 @@ function wholeFileFor(torrentPool, sourceKey, fileIndex) {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
/**
|
|
112
|
-
*
|
|
113
|
+
* Acquire the whole file a response can continue from, if there is one.
|
|
113
114
|
*
|
|
114
|
-
* Any failure to
|
|
115
|
+
* Any failure to acquire it leaves the response on the torrent, which can still
|
|
115
116
|
* answer; a file the disk owner removed is also forgotten here, as the route
|
|
116
117
|
* does before headers.
|
|
117
118
|
*
|
|
118
|
-
* @param {{ wholeFiles?: Map<string, { path: string, length: number, name: string }> }} torrentPool
|
|
119
|
+
* @param {{ wholeFiles?: Map<string, { path: string, length: number, name: string }>, acquireWholeFile: (source: { sourceKey: string, fileIndex: number }) => Promise<{ length: number, stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> } | null> }} torrentPool
|
|
119
120
|
* @param {string} sourceKey
|
|
120
121
|
* @param {number} fileIndex
|
|
121
122
|
* @param {number} length - The length the response was promised for.
|
|
122
|
-
* @returns {Promise<import("node:
|
|
123
|
+
* @returns {Promise<{ length: number, stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> } | null>}
|
|
123
124
|
*/
|
|
124
|
-
async function
|
|
125
|
+
async function acquireWholeFileRead(torrentPool, sourceKey, fileIndex, length) {
|
|
125
126
|
const whole = wholeFileFor(torrentPool, sourceKey, fileIndex);
|
|
126
127
|
if (!whole || whole.length !== length) {
|
|
127
128
|
return null;
|
|
128
129
|
}
|
|
129
130
|
try {
|
|
130
|
-
|
|
131
|
+
const read = await torrentPool.acquireWholeFile({ sourceKey, fileIndex });
|
|
132
|
+
if (!read) {
|
|
133
|
+
forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (read.length !== length) {
|
|
137
|
+
await read.release();
|
|
138
|
+
forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return read;
|
|
131
142
|
} catch (error) {
|
|
132
143
|
if (error.code === "ENOENT") {
|
|
133
|
-
|
|
134
|
-
if (torrentPool.wholeFiles.get(key) === whole) torrentPool.wholeFiles.delete(key);
|
|
144
|
+
forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
|
|
135
145
|
}
|
|
136
146
|
return null;
|
|
137
147
|
}
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
function forgetWholeFile(torrentPool, sourceKey, fileIndex, whole) {
|
|
151
|
+
const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
|
|
152
|
+
if (torrentPool.wholeFiles?.get(key) === whole) torrentPool.wholeFiles.delete(key);
|
|
153
|
+
}
|
|
154
|
+
|
|
140
155
|
/**
|
|
141
156
|
* Write bytes `from`..`end` of an open file to a response, each write waited
|
|
142
157
|
* for, and close the file.
|
|
143
158
|
*
|
|
144
159
|
* @param {import("node:http").ServerResponse} raw
|
|
145
|
-
* @param {import("node:
|
|
160
|
+
* @param {{ stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> }} read
|
|
146
161
|
* @param {number} from - Inclusive.
|
|
147
162
|
* @param {number} end - Inclusive.
|
|
148
163
|
* @param {(length: number) => void} onWritten
|
|
149
164
|
* @returns {Promise<number>} Bytes written.
|
|
150
165
|
*/
|
|
151
|
-
async function writeFromWholeFile(raw,
|
|
166
|
+
async function writeFromWholeFile(raw, read, from, end, onWritten) {
|
|
152
167
|
let written = 0;
|
|
168
|
+
let stream = null;
|
|
153
169
|
try {
|
|
154
|
-
|
|
170
|
+
stream = read.stream(from, end, () => {});
|
|
171
|
+
if (!stream) throw new Error("Completed file did not provide the admitted range.");
|
|
172
|
+
for await (const chunk of stream) {
|
|
155
173
|
if (raw.writableEnded || raw.destroyed) {
|
|
156
174
|
break;
|
|
157
175
|
}
|
|
@@ -162,7 +180,8 @@ async function writeFromWholeFile(raw, handle, from, end, onWritten) {
|
|
|
162
180
|
onWritten(chunk.length);
|
|
163
181
|
}
|
|
164
182
|
} finally {
|
|
165
|
-
|
|
183
|
+
stream?.destroy();
|
|
184
|
+
await read.release();
|
|
166
185
|
}
|
|
167
186
|
return written;
|
|
168
187
|
}
|
|
@@ -173,35 +192,41 @@ async function writeFromWholeFile(raw, handle, from, end, onWritten) {
|
|
|
173
192
|
* @param {import("fastify").FastifyRequest} req
|
|
174
193
|
* @param {import("fastify").FastifyReply} reply
|
|
175
194
|
* @param {{ path: string, length: number, name: string }} file
|
|
176
|
-
* @param {import("node:
|
|
195
|
+
* @param {{ stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> }} read
|
|
177
196
|
* @returns {Promise<void> | void}
|
|
178
197
|
*/
|
|
179
|
-
function serveWholeFile(req, reply, file,
|
|
198
|
+
function serveWholeFile(req, reply, file, read) {
|
|
180
199
|
const disposition = `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`;
|
|
181
|
-
if (req.method === "HEAD") {
|
|
182
|
-
reply.hijack();
|
|
183
|
-
reply.raw.writeHead(200, {
|
|
184
|
-
"Accept-Ranges": "bytes",
|
|
185
|
-
"Content-Type": "application/octet-stream",
|
|
186
|
-
"Content-Length": String(file.length),
|
|
187
|
-
"Content-Disposition": disposition
|
|
188
|
-
});
|
|
189
|
-
reply.raw.end();
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
200
|
reply.header("Accept-Ranges", "bytes");
|
|
193
201
|
reply.header("Content-Type", "application/octet-stream");
|
|
194
202
|
reply.header("Content-Disposition", disposition);
|
|
195
203
|
const range = parseRange(req.headers.range, file.length);
|
|
204
|
+
if (file.length === 0) {
|
|
205
|
+
reply.header("Content-Length", "0");
|
|
206
|
+
void read.release().catch(() => undefined);
|
|
207
|
+
return reply.send(Readable.from([]));
|
|
208
|
+
}
|
|
209
|
+
const start = range?.start ?? 0;
|
|
210
|
+
const end = range?.end ?? file.length - 1;
|
|
211
|
+
const stream = read.stream(start, end, () => {});
|
|
212
|
+
if (!stream) {
|
|
213
|
+
void read.release().catch(() => undefined);
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
196
216
|
if (!range) {
|
|
197
217
|
reply.header("Content-Length", String(file.length));
|
|
198
|
-
|
|
218
|
+
} else {
|
|
219
|
+
reply.code(206);
|
|
220
|
+
reply.header("Content-Length", String(end - start + 1));
|
|
221
|
+
reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
|
|
222
|
+
}
|
|
223
|
+
void read.release().catch(() => undefined);
|
|
224
|
+
try {
|
|
225
|
+
return reply.send(stream);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
stream.destroy();
|
|
228
|
+
throw error;
|
|
199
229
|
}
|
|
200
|
-
const { start, end } = range;
|
|
201
|
-
reply.code(206);
|
|
202
|
-
reply.header("Content-Length", String(end - start + 1));
|
|
203
|
-
reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
|
|
204
|
-
return reply.send(handle.createReadStream({ start, end }));
|
|
205
230
|
}
|
|
206
231
|
|
|
207
232
|
export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null, noteInputWaiting = null }) {
|
|
@@ -224,23 +249,36 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
224
249
|
// nothing the torrent would have told us.
|
|
225
250
|
const whole = wholeFileFor(torrentPool, sourceKey, fileIndex);
|
|
226
251
|
if (whole) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
// Open before committing headers: the disk owner may have removed an
|
|
230
|
-
// announced file. An open descriptor also preserves a live POSIX read.
|
|
231
|
-
handle = await open(whole.path, "r");
|
|
232
|
-
} catch (error) {
|
|
233
|
-
if (error.code !== "ENOENT") throw error;
|
|
234
|
-
const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
|
|
235
|
-
if (torrentPool.wholeFiles.get(key) === whole) torrentPool.wholeFiles.delete(key);
|
|
236
|
-
}
|
|
237
|
-
if (handle) {
|
|
238
|
-
if (req.method === "HEAD") await handle.close();
|
|
252
|
+
if (req.method === "HEAD") {
|
|
253
|
+
let handle;
|
|
239
254
|
try {
|
|
240
|
-
|
|
255
|
+
handle = await open(whole.path, "r");
|
|
241
256
|
} catch (error) {
|
|
242
|
-
|
|
243
|
-
|
|
257
|
+
if (error.code !== "ENOENT") throw error;
|
|
258
|
+
forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
|
|
259
|
+
}
|
|
260
|
+
if (handle) {
|
|
261
|
+
await handle.close();
|
|
262
|
+
reply.hijack();
|
|
263
|
+
reply.raw.writeHead(200, {
|
|
264
|
+
"Accept-Ranges": "bytes",
|
|
265
|
+
"Content-Type": "application/octet-stream",
|
|
266
|
+
"Content-Length": String(whole.length),
|
|
267
|
+
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(whole.name)}`
|
|
268
|
+
});
|
|
269
|
+
reply.raw.end();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
const read = await acquireWholeFileRead(torrentPool, sourceKey, fileIndex, whole.length);
|
|
274
|
+
if (read) {
|
|
275
|
+
try {
|
|
276
|
+
const sent = serveWholeFile(req, reply, whole, read);
|
|
277
|
+
if (sent) return sent;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
await read.release().catch(() => undefined);
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
244
282
|
}
|
|
245
283
|
}
|
|
246
284
|
}
|
|
@@ -338,7 +376,7 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
338
376
|
fragment.release();
|
|
339
377
|
break;
|
|
340
378
|
}
|
|
341
|
-
continueFrom = await
|
|
379
|
+
continueFrom = await acquireWholeFileRead(torrentPool, sourceKey, fileIndex, file.length);
|
|
342
380
|
if (continueFrom) {
|
|
343
381
|
fragment.release();
|
|
344
382
|
break;
|
|
@@ -368,7 +406,7 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
368
406
|
}
|
|
369
407
|
} catch (error) {
|
|
370
408
|
if (sent >= contentLength || req.raw.aborted || reply.raw.destroyed) throw error;
|
|
371
|
-
continueFrom = await
|
|
409
|
+
continueFrom = await acquireWholeFileRead(torrentPool, sourceKey, fileIndex, file.length);
|
|
372
410
|
if (!continueFrom) throw error;
|
|
373
411
|
}
|
|
374
412
|
if (continueFrom) {
|
|
@@ -43,8 +43,11 @@ export class Renditions {
|
|
|
43
43
|
*/
|
|
44
44
|
#moveWaitsSaid = new Map();
|
|
45
45
|
|
|
46
|
+
/** Viewers waiting for a preset's measured speed before a quality step up. @type {Set<string>} */
|
|
47
|
+
#presetSpeedWaitSaid = new Set();
|
|
48
|
+
|
|
46
49
|
/**
|
|
47
|
-
* @param {object} host - `viewerSecondsOn`, `audioStartSecondsFor`, `activeOutputFor`, `viewersOf`, `audioRenditionName`, `logger`, `placeViewer`, `viewerLeaves`, `createOrGetSession`, `planEncodersSoon`, `disposeSession`, `viewerPositionOf`, `encodeRuns`, `fileStartTimeReads`, `getCachedAudioTracks`, `getCachedMediaInfo`, `getContainerMediaInfo`, `localBaseUrl`, `outputTimes`, `outputs`, `quality`, `qualityOffer`, `segmentDurationSec`, `sourceFiles`, `viewers`, `generationOfRequest`, `givenOutputOf`, `noteGivenOutput`, `chosenOutputOf`, `chooseOutput`, `storedPieceReady`, `headersCompatible`, `audioChoiceOf`, `linkMbpsOf`, `qualityModeOf`, `noteServingVerdict`, `limitsFor`, `heightsChosenAs`, `highestGivenSegmentOf`, `sameHeightSwitchOf`, `noteSameHeightSwitch`, `switchingOnto`, `switchingFrom`, `segmentClosed`, `secondsUntilClosed`, `invalidateWaits`, `bufferedSecondsOf`, `minimumBufferSecondsFor`
|
|
50
|
+
* @param {object} host - `viewerSecondsOn`, `audioStartSecondsFor`, `activeOutputFor`, `viewersOf`, `audioRenditionName`, `logger`, `placeViewer`, `viewerLeaves`, `createOrGetSession`, `planEncodersSoon`, `disposeSession`, `viewerPositionOf`, `encodeRuns`, `fileStartTimeReads`, `getCachedAudioTracks`, `getCachedMediaInfo`, `getContainerMediaInfo`, `localBaseUrl`, `outputTimes`, `outputs`, `quality`, `qualityOffer`, `segmentDurationSec`, `sourceFiles`, `viewers`, `generationOfRequest`, `givenOutputOf`, `noteGivenOutput`, `chosenOutputOf`, `chooseOutput`, `storedPieceReady`, `headersCompatible`, `audioChoiceOf`, `linkMbpsOf`, `qualityModeOf`, `noteServingVerdict`, `limitsFor`, `heightsChosenAs`, `highestGivenSegmentOf`, `sameHeightSwitchOf`, `noteSameHeightSwitch`, `switchingOnto`, `switchingFrom`, `segmentClosed`, `secondsUntilClosed`, `invalidateWaits`, `bufferedSecondsOf`, `minimumBufferSecondsFor`, `processingSpeedOf`, `requiredSpeedOf`
|
|
48
51
|
*/
|
|
49
52
|
constructor(host) {
|
|
50
53
|
this.#host = host;
|
|
@@ -1052,6 +1055,25 @@ export class Renditions {
|
|
|
1052
1055
|
return;
|
|
1053
1056
|
}
|
|
1054
1057
|
}
|
|
1058
|
+
// A quality step up may choose a slower encoding preset. The benchmark is
|
|
1059
|
+
// only a forecast; keep the current output until this candidate has closed
|
|
1060
|
+
// pieces at the source's required speed. A step down is urgent and must
|
|
1061
|
+
// not wait on a measurement from the output meant to rescue playback.
|
|
1062
|
+
if (move.direction === "up" && move.changes === "preset" && prepared.spec?.transcodesVideo) {
|
|
1063
|
+
const speed = this.#host.processingSpeedOf?.(prepared) ?? null;
|
|
1064
|
+
const required = this.#host.requiredSpeedOf?.(prepared) ?? 1;
|
|
1065
|
+
if (!Number.isFinite(speed) || speed < required) {
|
|
1066
|
+
if (!this.#presetSpeedWaitSaid.has(consumerId)) {
|
|
1067
|
+
this.#presetSpeedWaitSaid.add(consumerId);
|
|
1068
|
+
this.#host.logger.info(
|
|
1069
|
+
`transcode ${base.id} keeps ${consumerId} on ${current.outputKey} while ${prepared.outputKey} ` +
|
|
1070
|
+
`proves its preset at ${Number.isFinite(speed) ? `${speed.toFixed(2)}x` : "an unmeasured speed"}; ` +
|
|
1071
|
+
`requires ${required.toFixed(2)}x`
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1055
1077
|
// The player fetches the fMP4 init once and decodes every later fragment
|
|
1056
1078
|
// against it. Changing bitrate outputs is safe only when the prepared
|
|
1057
1079
|
// output's init says the same thing to that decoder. The new segment is
|
|
@@ -1151,6 +1173,7 @@ export class Renditions {
|
|
|
1151
1173
|
*/
|
|
1152
1174
|
#completeSameHeightSwitch(base, consumerId, move, prepared, current, next) {
|
|
1153
1175
|
this.#moveWaitsSaid.delete(consumerId);
|
|
1176
|
+
this.#presetSpeedWaitSaid.delete(consumerId);
|
|
1154
1177
|
for (const height of move.askedHeights) {
|
|
1155
1178
|
this.#choose(consumerId, height, prepared, base);
|
|
1156
1179
|
}
|
|
@@ -1189,6 +1212,7 @@ export class Renditions {
|
|
|
1189
1212
|
*/
|
|
1190
1213
|
#cancelSameHeightSwitch(base, consumerId, reason) {
|
|
1191
1214
|
this.#moveWaitsSaid.delete(consumerId);
|
|
1215
|
+
this.#presetSpeedWaitSaid.delete(consumerId);
|
|
1192
1216
|
const move = this.#host.sameHeightSwitchOf(consumerId);
|
|
1193
1217
|
if (!move) {
|
|
1194
1218
|
return;
|
|
@@ -457,6 +457,7 @@ export function wireOutputs({
|
|
|
457
457
|
presetLadder: () => (parts.encoders.current?.kind === "software" && Array.isArray(parts.softwarePresetBenchmark)
|
|
458
458
|
? parts.softwarePresetBenchmark.map((entry) => entry.preset) : []),
|
|
459
459
|
presetSpeedOf: (output, preset) => parts.encodeCost.presetSpeedOf(output, preset, { segmentSeconds: parts.segmentDurationSec }),
|
|
460
|
+
processingSpeedOf: (output) => parts.encodeCost.latestSpeedReadingOf(output)?.speed ?? null,
|
|
460
461
|
// The price a move was decided at, given to an output with no measured
|
|
461
462
|
// piece: one already measured stands on its readings, and an output that
|
|
462
463
|
// runs was priced with its own cost counted beside the one on screen.
|