@torrent-tv/proxy 2.107.26 → 2.107.28

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.107.26",
3
+ "version": "2.107.28",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -1045,10 +1045,13 @@ export async function startProxyServer({
1045
1045
  if (!params) return { kind: "needs-source" };
1046
1046
  if (timing) {
1047
1047
  params.walkAccount = timing.walk ??= { lockMs: 0, reads: 0, bytes: 0 };
1048
- params.onTimed = (_statement, waitedMs, readMs) => {
1048
+ params.onTimed = (statement, waitedMs, readMs) => {
1049
1049
  timing.reads += 1;
1050
1050
  timing.queuedMs += waitedMs;
1051
1051
  timing.readMs += readMs;
1052
+ const measured = timing.readsByStatement[statement] ??= { queuedMs: 0, readMs: 0 };
1053
+ measured.queuedMs += waitedMs;
1054
+ measured.readMs += readMs;
1052
1055
  };
1053
1056
  }
1054
1057
  const tracks = await containerOrchestrator.inspect(params, "tracks");
@@ -194,9 +194,10 @@ export class EncodeAdmission {
194
194
  }
195
195
 
196
196
  /**
197
- * Whether a not-yet-opened format can take a place on the whole machine.
198
- * The output choice calls this for each candidate rung; the final claim uses
199
- * `admitsWatching` with the same arithmetic after registering the output.
197
+ * Whether an output candidate can take a place on the whole machine. A
198
+ * partial output already in the catalogue uses its measured cost; a
199
+ * not-yet-opened output uses the same candidate estimate as before. The final
200
+ * claim uses `admitsWatching` after registering the output.
200
201
  *
201
202
  * @param {object} spec
202
203
  * @param {object} file
@@ -207,13 +208,22 @@ export class EncodeAdmission {
207
208
  if (!address) {
208
209
  return { admitted: false, reason: "the output has no address", speedX: null };
209
210
  }
211
+ if (this.#host.finished?.(address) === true) {
212
+ return { admitted: true, reason: "everything it serves is already made", speedX: null };
213
+ }
210
214
  if ((this.#host.liveRunsByAddress().get(address) ?? 0) > 0) {
211
215
  return { admitted: true, reason: "an encoder already runs there", speedX: null };
212
216
  }
213
217
  if (this.#heldWithoutRun().has(address)) {
214
218
  return { admitted: true, reason: "it already holds a place for another viewer", speedX: null };
215
219
  }
216
- const load = this.#host.loadForCandidate?.(spec, file) ?? null;
220
+ // A partial output already in the catalogue has its measured rate. Use it
221
+ // before the candidate estimate; completed outputs took the return above.
222
+ const existing = this.#host.loadOf(address);
223
+ const hasMeasuredExistingCost = Number.isFinite(existing?.costSec) && existing.costSec > 0;
224
+ const load = hasMeasuredExistingCost
225
+ ? existing
226
+ : this.#host.loadForCandidate?.(spec, file) ?? existing;
217
227
  return this.#assessCandidate(address, load);
218
228
  }
219
229
 
@@ -413,7 +413,7 @@ export class EncodeInputs {
413
413
  // field 2026-10-10, a picture's request took 38 s before its copy began and
414
414
  // nothing said on what (torrent-tv/meta#166).
415
415
  request.timing ??= { askedAt: this.#now(), attempts: 0, resolutions: 0, resolveMs: 0,
416
- reads: 0, queuedMs: 0, readMs: 0, heldMs: 0, budgetMs: 0 };
416
+ reads: 0, queuedMs: 0, readMs: 0, readsByStatement: {}, heldMs: 0, budgetMs: 0 };
417
417
  request.timing.attempts += 1;
418
418
  this.#observe(request, "attempt", { request: request.key, attempt: request.timing.attempts });
419
419
  request.promise = (async () => {
@@ -742,9 +742,12 @@ function awaitedIn(waiting, arrived) {
742
742
  */
743
743
  function describeTiming(timing, now) {
744
744
  if (!timing) return "";
745
+ const statements = Object.entries(timing.readsByStatement ?? {}).map(([statement, measured]) =>
746
+ `${statement} queued ${measured.queuedMs}ms/read ${measured.readMs}ms`).join(", ");
745
747
  return `; asked ${Math.max(0, now - timing.askedAt)}ms ago over ${timing.attempts} attempt(s): ` +
746
748
  `${timing.resolutions} resolution(s) ${timing.resolveMs}ms ` +
747
749
  `(${timing.reads} file read(s): queued ${timing.queuedMs}ms, reading ${timing.readMs}ms), ` +
750
+ (statements ? `by statement: ${statements}, ` : "") +
748
751
  `held ranges ${timing.heldMs}ms, budget ${timing.budgetMs}ms` +
749
752
  // Where a packet input's walk spent its read: waiting for the same
750
753
  // track's earlier walks, or reading the file (r62, torrent-tv/meta#178).
@@ -228,6 +228,20 @@ export function decideOutputFormat({
228
228
  rateControl: limit === null ? null : softwareRateControlFor({ width: w, height: h, fps, source, capKbps: limit })
229
229
  }));
230
230
  };
231
+ let machineRefusal = null;
232
+ const machineAdmissionFor = (spec) => typeof machineAdmission === "function" ? machineAdmission(spec) : null;
233
+ const machineCanContinue = (spec) => machineAdmissionFor(spec)?.admitted !== false;
234
+ const admittedByMachine = (spec) => {
235
+ if (!machineCan(spec.video?.encode?.width ?? 0, spec.video?.encode?.height ?? 0)) {
236
+ return false;
237
+ }
238
+ const answer = machineAdmissionFor(spec);
239
+ if (answer?.admitted === false) {
240
+ machineRefusal ??= answer;
241
+ return false;
242
+ }
243
+ return true;
244
+ };
231
245
  const wantedFormats = formatsAt(width, height);
232
246
  const wantedSpec = wantedFormats[0];
233
247
  const wantedKey = wantedSpec.toKey();
@@ -247,6 +261,7 @@ export function decideOutputFormat({
247
261
  wanted: { width, height },
248
262
  nextLowerArea: nextRungAreaBelow(buildResolutionLadder(ceiling.w, ceiling.h), height),
249
263
  atRisk: measured && !canSustainOutput({ benchmark, ...priced, outputPixelsPerSec: width * height * fps, frame: { width, height } }).sustainable,
264
+ canProduce: (candidate) => machineCanContinue(candidate.spec),
250
265
  judge: (candidate) => judge(candidate.spec),
251
266
  candidates: servingCandidates({
252
267
  wanted: wantedSpec,
@@ -262,18 +277,6 @@ export function decideOutputFormat({
262
277
  }
263
278
 
264
279
  let anySizeHeld = false;
265
- let machineRefusal = null;
266
- const admittedByMachine = (spec) => {
267
- if (!machineCan(spec.video?.encode?.width ?? 0, spec.video?.encode?.height ?? 0)) {
268
- return false;
269
- }
270
- const answer = typeof machineAdmission === "function" ? machineAdmission(spec) : null;
271
- if (answer?.admitted === false) {
272
- machineRefusal ??= answer;
273
- return false;
274
- }
275
- return true;
276
- };
277
280
  if (machineCan(width, height)) {
278
281
  for (const spec of wantedFormats) {
279
282
  if (!admittedByMachine(spec)) {
@@ -48,15 +48,17 @@ import { admissionRank } from "./link-budget.js";
48
48
  * on this viewer's own ladder; zero when there is none.
49
49
  * @param {boolean} params.atRisk - Producing `wanted` here is measured not to
50
50
  * keep up with what the file needs. False where nothing measured it.
51
+ * @param {(candidate: Candidate) => boolean} [params.canProduce] - Whether
52
+ * this machine can keep producing an output that is not finished.
51
53
  * @param {(candidate: Candidate) => { admitted: boolean, load: object }} params.judge -
52
54
  * What this viewer's link does with the candidate's whole load.
53
55
  * @param {Candidate[]} params.candidates
54
56
  * @returns {{ key: string, answer: object } | null} The output to serve, or
55
57
  * null to produce `wanted`.
56
58
  */
57
- export function chooseServingOutput({ mode, wanted, nextLowerArea, atRisk, judge, candidates }) {
59
+ export function chooseServingOutput({ mode, wanted, nextLowerArea, atRisk, canProduce = () => true, judge, candidates }) {
58
60
  const admitted = (candidates ?? [])
59
- .filter((candidate) => candidate.readyHere === true)
61
+ .filter((candidate) => candidate.readyHere === true && canProduce(candidate))
60
62
  .map((candidate) => ({ candidate, answer: judge(candidate) }))
61
63
  .filter((one) => one.answer.admitted);
62
64
  const areaOf = (one) => one.candidate.width * one.candidate.height;
@@ -212,15 +212,20 @@ export class AsfContainer extends Container {
212
212
  const index = await this.readPacketIndex();
213
213
  return { times: index.keyframesOf(track.trackNumber), tolerance: 0 };
214
214
  }
215
- async readPacketIndex(interval) {
215
+ async readPacketIndex(interval, { isCurrent } = {}) {
216
+ if (isCurrent?.() === false) return null;
216
217
  await this.#readHeader();
218
+ if (isCurrent?.() === false) return null;
217
219
  if (!this.#packets && Number.isFinite(interval?.from) && Number.isFinite(interval?.to) && interval.to > interval.from) {
218
- const window = await this.#readWindow(interval);
220
+ const window = await this.#readWindow(interval, isCurrent);
221
+ if (isCurrent?.() === false) return null;
219
222
  if (window) return window;
220
223
  }
224
+ if (isCurrent?.() === false) return null;
221
225
  if (!this.#packets) {
222
226
  const index = await readAsfPackets({ readRange: this.readRange, fileSize: this.fileSize, tracks: this.#tracks,
223
- info: { ...this.#info, durationSeconds: this.#duration }, state: this.#packetState, packetMemory: this.packetMemory });
227
+ info: { ...this.#info, durationSeconds: this.#duration }, state: this.#packetState, packetMemory: this.packetMemory, isCurrent });
228
+ if (index === null) return null;
224
229
  const timeline = this.#tracks.find(track => track.type === "video") ?? this.#tracks.find(track => track.type === "audio");
225
230
  const bounds = timeline ? index.boundsOf(timeline.trackNumber) : null;
226
231
  if (bounds) {
@@ -246,17 +251,21 @@ export class AsfContainer extends Container {
246
251
  * interval, is read again from twice as far back, and from the first packet
247
252
  * at the latest; which it reaches is checked on what it read, not assumed.
248
253
  */
249
- async #readWindow(interval) {
254
+ async #readWindow(interval, isCurrent) {
250
255
  const data = await this.#dataLayout();
256
+ if (isCurrent?.() === false) return null;
251
257
  if (!data) return null;
252
258
  const requested = this.#tracks.filter(track => ["video", "audio"].includes(track.type) &&
253
259
  (!interval.trackIds?.length || interval.trackIds.includes(track.trackNumber)));
254
260
  if (!requested.length) return null;
255
261
  const info = { ...this.#info, durationSeconds: this.#duration };
256
262
  for (let lead = this.#info.prerollSeconds + WINDOW_LEAD_SECONDS; ; lead *= 2) {
263
+ if (isCurrent?.() === false) return null;
257
264
  const sentFrom = interval.from - lead;
258
- const first = await this.#packetSentBefore(sentFrom, data, interval.to + WINDOW_LEAD_SECONDS);
259
- const window = await readAsfWindow({ readRange: this.readRange, data, tracks: this.#tracks, requested, info, first, interval, sentFrom });
265
+ const first = await this.#packetSentBefore(sentFrom, data, interval.to + WINDOW_LEAD_SECONDS, isCurrent);
266
+ if (isCurrent?.() === false) return null;
267
+ const window = await readAsfWindow({ readRange: this.readRange, data, tracks: this.#tracks, requested, info, first, interval, sentFrom, isCurrent });
268
+ if (isCurrent?.() === false) return null;
260
269
  if (window.startCovered || first === 0) return window.index;
261
270
  }
262
271
  }
@@ -291,14 +300,16 @@ export class AsfContainer extends Container {
291
300
  * bytes the window from it to `until` is estimated to take, so the swarm is
292
301
  * asked for the packet and the window it begins at once.
293
302
  */
294
- async #packetSentBefore(seconds, data, until) {
295
- let low = 0, high = data.count, lowSent = await this.#sentAt(0, data), highSent = this.#duration;
303
+ async #packetSentBefore(seconds, data, until, isCurrent) {
304
+ if (isCurrent?.() === false) return null;
305
+ let low = 0, high = data.count, lowSent = await this.#sentAt(0, data, 1, isCurrent), highSent = this.#duration;
306
+ if (isCurrent?.() === false) return null;
296
307
  if (lowSent > seconds) return 0;
297
308
  // Past the last packet nothing is sent before the film ends; the last
298
309
  // packet itself is read only when the search needs it.
299
310
  if (!(highSent > seconds)) {
300
311
  high = data.count - 1;
301
- highSent = await this.#sentAt(high, data);
312
+ highSent = await this.#sentAt(high, data, 1, isCurrent);
302
313
  if (highSent <= seconds) return high;
303
314
  }
304
315
  for (const [number, at] of this.#sent) {
@@ -307,12 +318,14 @@ export class AsfContainer extends Container {
307
318
  else { high = number; highSent = at; }
308
319
  }
309
320
  for (let guesses = 0; high - low > 1 && seconds - lowSent > WINDOW_SEARCH_SECONDS; guesses++) {
321
+ if (isCurrent?.() === false) return null;
310
322
  const perSecond = (high - low) / (highSent - lowSent);
311
323
  const aim = seconds - WINDOW_SEARCH_SECONDS / 2;
312
324
  const guess = guesses >= 3 || !(perSecond > 0) ? Math.floor((low + high) / 2) : low + Math.floor((aim - lowSent) * perSecond);
313
325
  const probe = Math.min(high - 1, Math.max(low + 1, guess));
314
326
  const window = perSecond > 0 ? Math.ceil((until - aim) * perSecond) : 1;
315
- const at = await this.#sentAt(probe, data, Math.max(1, window));
327
+ const at = await this.#sentAt(probe, data, Math.max(1, window), isCurrent);
328
+ if (isCurrent?.() === false) return null;
316
329
  if (at <= seconds) { low = probe; lowSent = at; }
317
330
  else { high = probe; highSent = at; }
318
331
  }
@@ -323,7 +336,8 @@ export class AsfContainer extends Container {
323
336
  * When packet `number` is sent. A packet that is not held is said as
324
337
  * missing with the `packets` packets from it.
325
338
  */
326
- async #sentAt(number, data, packets = 1) {
339
+ async #sentAt(number, data, packets = 1, isCurrent) {
340
+ if (isCurrent?.() === false) return null;
327
341
  if (!this.#sent.has(number)) {
328
342
  const at = data.start + number * data.length;
329
343
  try {
@@ -333,6 +347,6 @@ export class AsfContainer extends Container {
333
347
  throw new BytesUnavailable(at, Math.min(data.start + data.count * data.length, at + packets * data.length) - 1, 0);
334
348
  }
335
349
  }
336
- return this.#sent.get(number);
350
+ return isCurrent?.() === false ? null : this.#sent.get(number);
337
351
  }
338
352
  }
@@ -20,7 +20,7 @@ export async function readAsfData({ readRange, fileSize, info }) {
20
20
  }
21
21
 
22
22
  /** ASF payload addresses, including fragmented and compressed media objects. */
23
- export async function readAsfPackets({ readRange, fileSize, tracks, info, firstPicture = false, packetMemory, state = {} }) {
23
+ export async function readAsfPackets({ readRange, fileSize, tracks, info, firstPicture = false, packetMemory, state = {}, isCurrent }) {
24
24
  if (!state.initialized) {
25
25
  const data = await readAsfData({ readRange, fileSize, info });
26
26
  Object.assign(state, { initialized: true, at: data.start, end: data.end, count: data.count, packets: 0,
@@ -30,6 +30,7 @@ export async function readAsfPackets({ readRange, fileSize, tracks, info, firstP
30
30
  const { end, count, frames, assembling } = state;
31
31
  const declared = new Map(tracks.map(track => [track.trackNumber, track]));
32
32
  while (at < end && (!count || packets < count)) {
33
+ if (isCurrent?.() === false) return null;
33
34
  const previousObjects = new Map([...assembling].map(([id, object]) => [id, { ...object, ranges: [...object.ranges] }]));
34
35
  const previousLengths = new Map([...frames].map(([id, stream]) => [id, stream.length]));
35
36
  try {
@@ -37,6 +38,7 @@ export async function readAsfPackets({ readRange, fileSize, tracks, info, firstP
37
38
  packets++;
38
39
  state.at = at;
39
40
  state.packets = packets;
41
+ if (isCurrent?.() === false) return null;
40
42
  if (firstPicture) {
41
43
  const video = tracks.find(track => track.type === "video");
42
44
  const stream = video && frames.get(video.trackNumber);
@@ -105,7 +107,7 @@ export async function readAsfPacketTime({ readRange, at, end, info }) {
105
107
  * @param {number} params.sentFrom - When packet `first` is sent, as far as the caller knows.
106
108
  * @returns {Promise<{ index: PacketIndex, startCovered: boolean }>}
107
109
  */
108
- export async function readAsfWindow({ readRange, data, tracks, requested, info, first, interval, sentFrom }) {
110
+ export async function readAsfWindow({ readRange, data, tracks, requested, info, first, interval, sentFrom, isCurrent }) {
109
111
  const declared = new Map(tracks.map(track => [track.trackNumber, track]));
110
112
  // Not admitted to IndexMemory: one segment's records, a few hundred, held
111
113
  // by the request that asked and dropped with it. Nothing owns a window to
@@ -125,23 +127,30 @@ export async function readAsfWindow({ readRange, data, tracks, requested, info,
125
127
  const held = (start, end) => buffer !== null && start >= bufferAt && end < bufferAt + buffer.length;
126
128
  const read = (start, end) => held(start, end) ? buffer.subarray(start - bufferAt, end - bufferAt + 1) : readRange(start, end);
127
129
  const load = async () => {
130
+ if (isCurrent?.() === false) return false;
128
131
  try {
129
132
  buffer = await readRange(at, Math.min(dataEnd - 1, at + WINDOW_PORTION_PACKETS * data.length - 1));
130
133
  } catch (error) {
134
+ if (isCurrent?.() === false) return false;
131
135
  if (!isUnavailable(error)) throw error;
132
136
  try {
133
137
  buffer = await readRange(at, Math.min(dataEnd - 1, at + data.length - 1));
134
138
  } catch (missing) {
139
+ if (isCurrent?.() === false) return false;
135
140
  if (!isUnavailable(missing)) throw missing;
136
141
  const ahead = Math.ceil(Math.max(0, interval.to + WINDOW_LEAD_SECONDS - sent) * rate);
137
142
  throw new BytesUnavailable(at, Math.min(dataEnd - 1, Math.max(at + data.length, at + ahead) - 1), 0);
138
143
  }
139
144
  }
145
+ if (isCurrent?.() === false) return false;
140
146
  bufferAt = at;
147
+ return true;
141
148
  };
142
149
  for (;;) {
150
+ if (isCurrent?.() === false) return null;
143
151
  if (at >= dataEnd) { reachedEnd = true; break; }
144
- if (!held(at, Math.min(dataEnd, at + data.length) - 1)) await load();
152
+ if (!held(at, Math.min(dataEnd, at + data.length) - 1) && !(await load())) return null;
153
+ if (isCurrent?.() === false) return null;
145
154
  ({ next: at, sent } = await readPacket({ readRange: read, at, end: data.end, info, declared, frames, assembling, started }));
146
155
  for (const id of ids) {
147
156
  const stream = frames.get(id), times = after.get(id);