@torrent-tv/proxy 2.97.4 → 2.97.5

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.97.4",
3
+ "version": "2.97.5",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -17,7 +17,7 @@ import { computeOutputDimensions } from "./args.js";
17
17
  import { buildAdmittedCommand } from "./admitted-command.js";
18
18
  import { buildOriginalCommand } from "./source-command.js";
19
19
  import { writeAdmittedInput } from "./AdmittedInput.js";
20
- import { cutOf, cutShiftOf, judgeNeighbors, judgePiece } from "./piece-completeness.js";
20
+ import { cutOf, cutShiftOf, judgeNeighbors, judgePiece, onItsCutClock } from "./piece-completeness.js";
21
21
  import { presentationSegment } from "./segment-formats/presentation-segment.js";
22
22
  import { InputFailures, failedAdmittedInput } from "./InputFailures.js";
23
23
  import { readProcessCpuSeconds } from "./host-load.js";
@@ -302,7 +302,13 @@ export class EncodeRuns {
302
302
  runStateOf(output) {
303
303
  if (this.#host.encodeInputs?.failureOf(output)) return ENCODE_RUN_STATE.ENDED_FAILED;
304
304
  if (output && this.liveRunsOf(output).length === 0 && this.#inputFailures.reason(output.outputKey)) return ENCODE_RUN_STATE.ENDED_FAILED;
305
- return output ? this.#host.encodeOrchestrator.stateOf(output.outputKey) : runStateOf([]);
305
+ const state = output ? this.#host.encodeOrchestrator.stateOf(output.outputKey) : runStateOf([]);
306
+ // A piece of a longer input failed and is to be made from an input holding
307
+ // it alone: production has not ended, and a request for it is held rather
308
+ // than refused (torrent-tv/meta#178).
309
+ if (state === ENCODE_RUN_STATE.ENDED_FAILED && this.liveRunsOf(output).length === 0 &&
310
+ this.#inputFailures.retrying(output.outputKey)) return ENCODE_RUN_STATE.IDLE;
311
+ return state;
306
312
  }
307
313
 
308
314
  lastErrorOf(output) {
@@ -374,6 +380,9 @@ export class EncodeRuns {
374
380
  return null;
375
381
  }
376
382
  const address = session.outputKey ?? "";
383
+ // `bytes` is the piece the proxy cut again by presentation time at the
384
+ // published grid; without it the piece is as the muxer cut it.
385
+ const cutByProxy = bytes !== null;
377
386
  try {
378
387
  const mediaRanges = format.readMediaRanges(bytes ?? this.#host.segmentFiles.closedBytesOf(address, name));
379
388
 
@@ -390,16 +399,27 @@ export class EncodeRuns {
390
399
  // piece has closed, and holds for every piece after it. It is a fact
391
400
  // of each piece the run makes, so it travels with the piece.
392
401
  if (run.cutShiftSeconds === null && index === run.from) {
393
- run.cutShiftSeconds = cutShiftOf(mediaRanges, grid?.[run.from], session.spec.carries !== "audio-only" ? "vide" : "soun",
402
+ // Read from the piece as the muxer closed it: a piece cut again by
403
+ // the proxy starts where the grid says, whatever the muxer did.
404
+ const asMuxed = cutByProxy ? format.readMediaRanges(this.#host.segmentFiles.closedBytesOf(address, name)) : mediaRanges;
405
+ run.cutShiftSeconds = cutShiftOf(asMuxed, grid?.[run.from], session.spec.carries !== "audio-only" ? "vide" : "soun",
394
406
  timing?.endMicros ?? null);
407
+ // Said once per run: a piece judged on the wrong clock is refused by
408
+ // a millisecond, and nothing else in the log says which clock it was
409
+ // judged on (torrent-tv/meta#178).
410
+ this.#host.logger.info(`encode-run #${run.from}..#${run.to} on ${address}: its muxer counts cuts ` +
411
+ (run.cutShiftSeconds === null ? "from a first packet more than a frame away from its start (shift unknown)"
412
+ : `${run.cutShiftSeconds.toFixed(6)}s from its start at ${grid?.[run.from]}s`) +
413
+ (cutByProxy ? "; its pieces are cut again by the proxy at the published grid, so the shift does not apply to them" : ""));
395
414
  }
396
- if (mediaRanges && run.cutShiftSeconds !== null) mediaRanges.cutShiftSeconds = run.cutShiftSeconds;
415
+ onItsCutClock(mediaRanges, run.cutShiftSeconds, cutByProxy);
397
416
  }
398
417
  const { whole, throughSeconds, reason } = judgePiece(format, mediaRanges, cut, interval);
399
418
  if (!whole) {
400
419
  this.#host.logger.warn(
401
420
  `encode: not publishing piece ${index} of ${address.slice(0, 60)}: ` +
402
421
  (reason ?? (throughSeconds === null ? "it holds no playable media" : `produced through ${throughSeconds}s, cut ${cut}s`)) +
422
+ ` cutBy=${cutByProxy ? "proxy" : "muxer"} shift=${Number.isFinite(mediaRanges?.cutShiftSeconds) ? mediaRanges.cutShiftSeconds : "none"}` +
403
423
  (interval ? ` expected=${interval.from}..${interval.to} tracks=${JSON.stringify(mediaRanges.tracks.map(track => ({
404
424
  kind: track.kind, timescale: track.timescale, frame: track.productionFrame,
405
425
  first: track.ranges[0], last: track.ranges.at(-1), count: track.ranges.length })),
@@ -809,8 +829,11 @@ export class EncodeRuns {
809
829
  // really reaches is the input's answer. An original input is one copied
810
830
  // stretch, and the run is given exactly that stretch (torrent-tv/meta#158).
811
831
  const segmentCount = Number(session.timeline?.segmentCount) || 0;
812
- const mayReach = Number.isInteger(ordered?.to) && ordered.to >= startIndex ? ordered.to
813
- : segmentCount > startIndex ? segmentCount - 1 : startIndex;
832
+ // A piece whose longer input failed is made from an input holding it alone,
833
+ // so a run reaches no further than the number before it.
834
+ const mayReach = this.#inputFailures.boundFor(session.outputKey, startIndex,
835
+ Number.isInteger(ordered?.to) && ordered.to >= startIndex ? ordered.to
836
+ : segmentCount > startIndex ? segmentCount - 1 : startIndex);
814
837
  const admittedInput = this.#host.encodeInputs?.take(session, startIndex, mayReach) ?? null;
815
838
  if (!admittedInput) return null;
816
839
  const inputEnd = admittedInput.original ? admittedInput.to : startIndex;
@@ -1099,7 +1122,7 @@ export class EncodeRuns {
1099
1122
  if (inputKey && failedAdmittedInput({ ending: ENCODE_EXIT.FAILED,
1100
1123
  code: error.code ? null : 1, because: error.message },
1101
1124
  session.spec.transcodesVideo && this.#host.videoEncoder.kind !== "software")) {
1102
- this.#inputFailures.note(session.outputKey, startIndex, inputKey, error.message);
1125
+ this.#inputFailures.note(session.outputKey, startIndex, inputKey, error.message, { pieces: inputEnd - startIndex + 1 });
1103
1126
  this.#host.invalidateWaits(session);
1104
1127
  this.#host.productionFailed(session);
1105
1128
  }
@@ -1234,8 +1257,11 @@ export class EncodeRuns {
1234
1257
  const index = run.head;
1235
1258
  const key = run.admittedInputKeys.get(index);
1236
1259
  if (key) {
1237
- this.#inputFailures.note(session.outputKey, index, key, ended.because);
1238
- this.#host.logger.warn(`encode: unchanged input will not be retried output=${session.outputKey} segment=${index} input=${key} reason=${ended.because}`);
1260
+ const pieces = [...run.admittedInputKeys.values()].filter(held => held === key).length;
1261
+ this.#inputFailures.note(session.outputKey, index, key, ended.because, { pieces });
1262
+ this.#host.logger.warn(pieces > 1
1263
+ ? `encode: piece ${index} will be made again from an input holding it alone output=${session.outputKey} input=${key} (held ${pieces} pieces) reason=${ended.because}`
1264
+ : `encode: unchanged input will not be retried output=${session.outputKey} segment=${index} input=${key} reason=${ended.because}`);
1239
1265
  }
1240
1266
  }
1241
1267
  this.#host.encodeOrchestrator.noteEnded(ended);
@@ -12,7 +12,18 @@ export function failedAdmittedInput(ended, hardwareEncode = false) {
12
12
  return ended.ending === ENCODE_EXIT.SHORT || ended.ending === ENCODE_EXIT.FAILED;
13
13
  }
14
14
 
15
- /** A failed input is retried only after its identity — source, file, ranges and what is stated about them — or the output parameters change. */
15
+ /**
16
+ * A failed input is retried only after its identity — source, file, ranges and
17
+ * what is stated about them — or the output parameters change.
18
+ *
19
+ * A failure of an input that held more pieces than the one that failed is not
20
+ * the end of the output: the piece is made again from an input holding it
21
+ * alone, which is another input (`retryAlone`). Only the failure of that input
22
+ * — or of any input holding one piece — ends production. Field 2026-10-11,
23
+ * torrent-tv/meta#178: one refused piece of a six-piece copy ended the viewer's
24
+ * playback with a 500 while the same piece made alone was published in an
25
+ * earlier session.
26
+ */
16
27
  export class InputFailures {
17
28
  #failures = new Map();
18
29
 
@@ -28,11 +39,47 @@ export class InputFailures {
28
39
  return null;
29
40
  }
30
41
 
31
- reason(address) { return this.#failures.get(address)?.values().next().value?.reason ?? null; }
42
+ /** Why production of this output has ended, or null while every failed piece can still be made alone. */
43
+ reason(address) {
44
+ for (const entry of this.#failures.get(address)?.values() ?? []) if (!entry.retryAlone) return entry.reason;
45
+ return null;
46
+ }
47
+
48
+ /** Whether a failed piece of this output waits to be made from an input holding it alone. */
49
+ retrying(address) {
50
+ for (const entry of this.#failures.get(address)?.values() ?? []) if (entry.retryAlone) return true;
51
+ return false;
52
+ }
53
+
54
+ /**
55
+ * The last number a run starting at `from` may reach so that a piece waiting
56
+ * to be made alone is not taken again inside a longer input: the number
57
+ * before it, or `from` itself when the run starts there.
58
+ *
59
+ * @param {string} address
60
+ * @param {number} from
61
+ * @param {number} to
62
+ * @returns {number}
63
+ */
64
+ boundFor(address, from, to) {
65
+ let bound = to;
66
+ for (const [index, entry] of this.#failures.get(address) ?? []) {
67
+ if (!entry.retryAlone || index < from || index > bound) continue;
68
+ bound = index === from ? from : index - 1;
69
+ }
70
+ return bound;
71
+ }
32
72
 
33
- note(address, index, key, reason) {
73
+ /**
74
+ * @param {string} address
75
+ * @param {number} index - The piece that failed.
76
+ * @param {string} key
77
+ * @param {string} reason
78
+ * @param {{ pieces?: number }} [held] - How many pieces the failed input held.
79
+ */
80
+ note(address, index, key, reason, { pieces = 1 } = {}) {
34
81
  const entries = this.#failures.get(address) ?? new Map();
35
- entries.set(index, { key, reason });
82
+ entries.set(index, { key, reason, retryAlone: pieces > 1 });
36
83
  this.#failures.set(address, entries);
37
84
  }
38
85
 
@@ -70,6 +70,29 @@ export function judgePiece(format, ranges, cutSeconds, interval) {
70
70
  return { whole: true, throughSeconds };
71
71
  }
72
72
 
73
+ /**
74
+ * Put a piece on the clock it was cut by, for {@link judgePiece}.
75
+ *
76
+ * The run's cut shift ({@link cutShiftOf}) says where the segment MUXER counts
77
+ * its cuts from, so it belongs only to a piece the muxer cut. A copied picture
78
+ * read from the original file is cut again by the proxy, by presentation time
79
+ * at the published grid (`presentationSegment`), and that piece is judged on
80
+ * the grid as published. Field 2026-10-11, torrent-tv/meta#178: an open-GOP
81
+ * HEVC piece cut again at 2511.676..2521.686 holds the next keyframe, which its
82
+ * leading pictures need, and so ends one frame (672 of 16 000 ticks) past the
83
+ * cut, as it must; moved by the muxer's shift of −0.001 s the allowance ended
84
+ * one millisecond before that frame, the piece was refused and playback ended.
85
+ *
86
+ * @param {object | null | undefined} ranges - The piece's media intervals.
87
+ * @param {number | null} shift - The run's cut shift, or null when not known.
88
+ * @param {boolean} cutByProxy - Whether the proxy cut the piece again.
89
+ * @returns {object | null | undefined} The same intervals.
90
+ */
91
+ export function onItsCutClock(ranges, shift, cutByProxy) {
92
+ if (ranges && !cutByProxy && Number.isFinite(shift)) ranges.cutShiftSeconds = shift;
93
+ return ranges;
94
+ }
95
+
73
96
  /**
74
97
  * How far after its requested start a run's segment muxer counts its cuts
75
98
  * from, read from the run's FIRST piece, or `null` when that piece does not