@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.101.19",
3
+ "version": "2.101.21",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -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
- * Open the whole file a response under way can continue from, if there is one.
113
+ * Acquire the whole file a response can continue from, if there is one.
113
114
  *
114
- * Any failure to open it leaves the response on the torrent, which can still
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:fs/promises").FileHandle | null>}
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 openWholeFileToContinue(torrentPool, sourceKey, fileIndex, length) {
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
- return await open(whole.path, "r");
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
- const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
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:fs/promises").FileHandle} handle
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, handle, from, end, onWritten) {
166
+ async function writeFromWholeFile(raw, read, from, end, onWritten) {
152
167
  let written = 0;
168
+ let stream = null;
153
169
  try {
154
- for await (const chunk of handle.createReadStream({ start: from, end, autoClose: false })) {
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
- await handle.close().catch(() => undefined);
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:fs/promises").FileHandle} handle - Already open; closed for HEAD.
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, handle) {
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
- return reply.send(handle.createReadStream());
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
- let handle;
228
- try {
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
- return serveWholeFile(req, reply, whole, handle);
255
+ handle = await open(whole.path, "r");
241
256
  } catch (error) {
242
- await handle.close().catch(() => undefined);
243
- throw error;
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 openWholeFileToContinue(torrentPool, sourceKey, fileIndex, file.length);
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 openWholeFileToContinue(torrentPool, sourceKey, fileIndex, file.length);
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.