@torrent-tv/proxy 2.96.0 → 2.96.2

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/bin/cli.js CHANGED
@@ -617,9 +617,6 @@ try {
617
617
  onSignal(sessionId, signal) {
618
618
  webRtcManager?.handleSignal(sessionId, signal);
619
619
  },
620
- // Asked by a server that still polls; the same description the proxy
621
- // sends on its own when it changes.
622
- onHealthRequest: () => describeSelf(),
623
620
  // Whether this host could sustain a file it has only been told about. The
624
621
  // same arithmetic the first offer uses, against this host's own startup
625
622
  // benchmarks — no torrent, no bytes, no ffmpeg — so the browser can ask
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.96.0",
3
+ "version": "2.96.2",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -285,6 +285,15 @@ export class EncodeRun {
285
285
  this.indexOfName = typeof indexOfName === "function" ? indexOfName : () => null;
286
286
  /** The last thing ffmpeg said on stderr, which is what a failure is explained by. */
287
287
  this.lastError = "";
288
+ /**
289
+ * How far after the start it was asked for this run's segment muxer counts
290
+ * its cuts from, in seconds. The muxer measures every cut from its first
291
+ * reference packet, and a copied stream's first packet lies up to one frame
292
+ * after the requested start, so every piece of the run ends that much later
293
+ * than its cut. `null` until the run's first piece has closed
294
+ * (`piece-completeness.js`, `cutShiftOf`).
295
+ */
296
+ this.cutShiftSeconds = null;
288
297
  // EXISTING IS RUNNING. There is no moment at which a built run is not yet a
289
298
  // process, so there is no second act for two owners to perform.
290
299
  this.#begin(because);
@@ -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, judgeNeighbors, judgePiece } from "./piece-completeness.js";
20
+ import { cutOf, cutShiftOf, judgeNeighbors, judgePiece } from "./piece-completeness.js";
21
21
  import { presentationSegment } from "./segment-formats/presentation-segment.js";
22
22
  import { InputFailures, failedAdmittedInput } from "./InputFailures.js";
23
23
 
@@ -351,9 +351,13 @@ export class EncodeRuns {
351
351
  * @param {HlsSession} session
352
352
  * @param {string} name - The working name the encoder closed it under.
353
353
  * @param {number} index
354
+ * @param {object | false} [admitted] - The original input the run reads, when it reads one.
355
+ * @param {Buffer | null} [bytes]
356
+ * @param {EncodeRun | null} [run] - The run that closed it, which keeps where its muxer counts cuts from.
357
+ * @param {{ endMicros: bigint } | null} [timing] - The piece's end in the muxer's clock, from its segment list.
354
358
  * @returns {object | null | false}
355
359
  */
356
- #wholeClosedPiece(session, name, index, admitted = false, bytes = null) {
360
+ #wholeClosedPiece(session, name, index, admitted = false, bytes = null, run = null, timing = null) {
357
361
 
358
362
  const format = session.segmentFormat;
359
363
  if (!format?.readMediaRanges || !Number.isInteger(index) || index < 0) {
@@ -371,6 +375,16 @@ export class EncodeRuns {
371
375
  .map(input => [input.track.type === "video" ? "vide" : "soun", input.sourceEndSeconds])) } : {}),
372
376
  requiredKinds: [session.spec.carries !== "audio-only" ? "vide" : null,
373
377
  session.spec.audio && !this.#host.servesAudioSeparately(session) ? "soun" : null].filter(Boolean) } : undefined;
378
+ if (run) {
379
+ // Where the muxer counts this run's cuts from is known once its first
380
+ // piece has closed, and holds for every piece after it. It is a fact
381
+ // of each piece the run makes, so it travels with the piece.
382
+ if (run.cutShiftSeconds === null && index === run.from) {
383
+ run.cutShiftSeconds = cutShiftOf(mediaRanges, grid?.[run.from], session.spec.carries !== "audio-only" ? "vide" : "soun",
384
+ timing?.endMicros ?? null);
385
+ }
386
+ if (mediaRanges && run.cutShiftSeconds !== null) mediaRanges.cutShiftSeconds = run.cutShiftSeconds;
387
+ }
374
388
  const { whole, throughSeconds, reason } = judgePiece(format, mediaRanges, cut, interval);
375
389
  if (!whole) {
376
390
  this.#host.logger.warn(
@@ -990,7 +1004,7 @@ export class EncodeRuns {
990
1004
  // Why this encoder exists, recorded with its argument list. It used to be
991
1005
  // handed to a separate `start` call; there is no separate call now.
992
1006
  because,
993
- onClosed: (name, following) => {
1007
+ onClosed: (name, following, timing) => {
994
1008
 
995
1009
  const index = session.segmentFormat.segmentIndexFromName(
996
1010
  session.segmentFormat.servedNameOf?.(name) ?? name);
@@ -1012,7 +1026,7 @@ export class EncodeRuns {
1012
1026
  throw error;
1013
1027
  }
1014
1028
  }
1015
- const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes);
1029
+ const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes, run, timing);
1016
1030
  if (mediaRanges === false) throw Object.assign(new Error(`Closed piece #${index} has incomplete media.`),
1017
1031
  { code: "ERR_INCOMPLETE_MEDIA" });
1018
1032
  const published = this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat, { mediaRanges, bytes });
@@ -30,82 +30,81 @@ export function cutOf(timeline, index) {
30
30
  }
31
31
 
32
32
  /**
33
- * Track positions read from empty edits retain their movie-tick uncertainty.
34
- * Fresh closures also carry the muxer's packet end and its actual cut, so a
35
- * restarted run is judged on its own reference clock. Neither allowance is a
36
- * browser's frame-join rule.
33
+ * Whether a closed piece holds its stretch of film. With `interval` (a run
34
+ * reading the original file, which states the stretch each piece covers) the
35
+ * piece's tracks are compared with that stretch; otherwise with the cut.
36
+ *
37
+ * Both are compared on the clock the producing run's muxer cut by: moved by
38
+ * `ranges.cutShiftSeconds`, which the run states for every piece it closes
39
+ * ({@link cutShiftOf}) and which travels with the piece. A piece whose run is
40
+ * not known — one adopted from an earlier life of the process — has none, and
41
+ * is compared with the cut as published.
37
42
  *
38
43
  * @param {{ producedThroughSeconds?: (ranges: object) => number | null }} format
39
44
  * @param {object} ranges - The piece's media intervals, as the format read them.
40
45
  * @param {number | undefined} cutSeconds - From {@link cutOf}.
41
- * @returns {{ whole: boolean, throughSeconds: number | null }}
46
+ * @param {{ from: number, to: number, requiredKinds: string[], sourceEnds?: object }} [interval]
47
+ * @returns {{ whole: boolean, throughSeconds: number | null, reason?: string }}
42
48
  */
43
49
  export function judgePiece(format, ranges, cutSeconds, interval) {
44
50
  const throughSeconds = format.producedThroughSeconds?.(ranges) ?? null;
45
51
  if (throughSeconds === null) {
46
52
  return { whole: false, throughSeconds };
47
53
  }
54
+ const shift = Number.isFinite(ranges?.cutShiftSeconds) ? ranges.cutShiftSeconds : 0;
48
55
  if (interval) {
49
- const reason = intervalFailure(ranges, interval);
56
+ const reason = intervalFailure(ranges, { ...interval, shift });
50
57
  return reason ? { whole: false, throughSeconds, reason } : { whole: true, throughSeconds };
51
58
  }
52
- const cutMicros = ranges?.production?.cutMicros ??
53
- (Number.isFinite(cutSeconds) ? BigInt(Math.round(cutSeconds * 1_000_000)) : null);
59
+ const cutMicros = Number.isFinite(cutSeconds) ? BigInt(Math.round((cutSeconds + shift) * 1_000_000)) : null;
54
60
  if (cutMicros !== null && ranges?.tracks?.length) {
55
- const tracks = ranges.tracks;
56
- const production = ranges.production;
57
- const reference = production ? tracks.find(({ kind }) => kind === production.kind) : null;
58
- const referenceEnd = reference?.ranges.at(-1)?.end;
59
- const whole = tracks.every((track) => {
60
- const { timescale, ranges: held, positionErrorTicks = 0n } = track;
61
+ const whole = ranges.tracks.every(({ timescale, ranges: held, positionErrorTicks = 0n }) => {
61
62
  const end = held.at(-1)?.end;
62
- if (end === undefined) return false;
63
- if (referenceEnd !== undefined) {
64
- if (track === reference) return 2n * production.endMicros + 1n >= 2n * cutMicros;
65
- // The segment muxer's CSV reports packet time before movenc truncates
66
- // the empty edit. Compare on that clock, including another track's
67
- // relative end and the precision of both written track positions.
68
- const difference = (end + positionErrorTicks) * reference.timescale -
69
- (referenceEnd - (reference.positionErrorTicks ?? 0n)) * timescale;
70
- const denominator = timescale * reference.timescale;
71
- return 2n * difference * 1_000_000n +
72
- (2n * production.endMicros + 1n) * denominator >= 2n * cutMicros * denominator;
73
- }
74
- return (end + positionErrorTicks) * 1_000_000n >= cutMicros * timescale;
63
+ return end !== undefined && (end + positionErrorTicks) * 1_000_000n >= cutMicros * timescale;
75
64
  });
76
65
  return { whole, throughSeconds };
77
66
  }
78
67
  if (cutMicros !== null && throughSeconds < Number(cutMicros) / 1_000_000) {
79
-
80
68
  return { whole: false, throughSeconds };
81
69
  }
82
70
  return { whole: true, throughSeconds };
83
71
  }
84
72
 
85
- /** The muxer cuts relative to its first reference packet, not the requested seek. */
86
- export function productionOf(timing, cutTimes, relativeIndex, kind) {
87
- if (!timing || timing.originMicros === null || !Number.isFinite(cutTimes?.[relativeIndex])) return null;
88
- return {
89
- kind,
90
- endMicros: timing.endMicros,
91
- cutMicros: timing.originMicros + BigInt(Math.round(cutTimes[relativeIndex] * 1_000_000)) -
92
- BigInt(Math.round(SEGMENT_CUT_TIME_DELTA_SECONDS * 1_000_000))
93
- };
94
- }
95
-
96
73
  /**
97
- * The first CSV entry has start_time = 0 even after a seek (segment.c).
98
- * Recover its reference packet's PTS from the reported end minus the exact
99
- * sample span. The edit-list offset cancels, including its lost precision.
74
+ * How far after its requested start a run's segment muxer counts its cuts
75
+ * from, read from the run's FIRST piece, or `null` when that piece does not
76
+ * start within one frame of the start.
77
+ *
78
+ * With `endMicros`, the end of the piece in the muxer's own clock as its
79
+ * segment list states it, the first packet is that end less the span of the
80
+ * piece's samples: exact, because the span is a sum of sample durations and
81
+ * the muxer's clock needs no track position. Without it, the first sample's
82
+ * position is read, which the piece states only to within its position error.
83
+ *
84
+ * The muxer measures every cut time from its first packet of the reference
85
+ * stream, not from the start it was asked for (measured on ffmpeg 8.1.2,
86
+ * 2026-10-09: AAC copied from Matroska, asked to start at 10.385 s with its
87
+ * first packet at 10.403 s and a cut at 10.386 s from the start, closed the
88
+ * piece at 20.805 s — the first packet at or after 10.403 + 10.386). A copied
89
+ * stream cannot start between its packets, so its first packet lies up to one
90
+ * frame after the requested start, and every cut of the run moves by as much.
91
+ *
92
+ * @param {object} ranges - The first piece's media intervals.
93
+ * @param {number} startSeconds - Where the run was asked to start.
94
+ * @param {string} kind - The reference track: `vide` when the output carries a picture, else `soun`.
95
+ * @returns {number | null}
100
96
  */
101
- export function originOf(ranges, endMicros, kind) {
102
- const track = ranges?.tracks?.find(track => track.kind === kind);
103
- if (track?.firstSampleStart === undefined || !track.ranges.length) return null;
104
- const ticks = endMicros * track.timescale -
105
- (track.ranges.at(-1).end - track.firstSampleStart) * 1_000_000n;
106
- // CSV rounds to microseconds; rescale with the muxer's nearest rounding.
107
- return ticks >= 0n ? (2n * ticks + track.timescale) / (2n * track.timescale) :
108
- -((-2n * ticks + track.timescale) / (2n * track.timescale));
97
+ export function cutShiftOf(ranges, startSeconds, kind, endMicros = null) {
98
+ const track = ranges?.tracks?.find((candidate) => candidate.kind === kind);
99
+ const first = track?.ranges?.[0];
100
+ if (!first || !(track.timescale > 0n) || !Number.isFinite(startSeconds)) return null;
101
+ const timescale = Number(track.timescale);
102
+ const firstSeconds = typeof endMicros === "bigint" && track.firstSampleStart !== undefined
103
+ ? Number(endMicros) / 1_000_000 - Number(track.ranges.at(-1).end - track.firstSampleStart) / timescale
104
+ : Number(first.start) / timescale;
105
+ const shift = firstSeconds - startSeconds;
106
+ const allowance = Number((track.productionFrame ?? first.frame) + (track.positionErrorTicks ?? 0n)) / timescale;
107
+ return Math.abs(shift) <= allowance ? shift : null;
109
108
  }
110
109
 
111
110
  /** Compare adjacent production ranges in their declared track clocks. */
@@ -129,15 +128,19 @@ export function judgeNeighbors(left, right) {
129
128
  return { whole: true };
130
129
  }
131
130
 
132
- /** Every required track must cover the interval with at most one-frame error. */
133
- function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {} }) {
131
+ /**
132
+ * Every required track must cover the interval with at most one-frame error,
133
+ * on the clock the run's muxer cut by: the interval moved by `shift`
134
+ * ({@link cutShiftOf}). A track's own end in the file is not moved.
135
+ */
136
+ function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, shift = 0 }) {
134
137
  if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from) || !requiredKinds?.length) return "segment-interval-is-not-declared";
135
138
  for (const kind of requiredKinds) {
136
139
  const track = coverage?.tracks?.find(track => track.kind === kind);
137
140
  if (!track?.ranges?.length || !(track.timescale > 0n)) return `segment-missing-${kind}`;
138
- const start = BigInt(Math.round(from * Number(track.timescale)));
139
- const through = Number.isFinite(sourceEnds[kind]) ? Math.min(to, sourceEnds[kind]) : to;
140
- if (!(through > from)) return `segment-interval-is-empty-${kind}`;
141
+ const start = BigInt(Math.round((from + shift) * Number(track.timescale)));
142
+ const through = Number.isFinite(sourceEnds[kind]) ? Math.min(to + shift, sourceEnds[kind]) : to + shift;
143
+ if (!(through > from + shift)) return `segment-interval-is-empty-${kind}`;
141
144
  const end = BigInt(Math.round(through * Number(track.timescale)));
142
145
  const first = track.ranges[0];
143
146
  const frame = track.productionFrame ?? first.frame;
@@ -20,7 +20,6 @@
20
20
 
21
21
  import { WebSocket } from "ws";
22
22
 
23
- /** @import { HealthMetrics } from './health-collector.js' */
24
23
 
25
24
  /**
26
25
  * Configuration for the tunnel client.
@@ -52,9 +51,6 @@ import { WebSocket } from "ws";
52
51
  * Answered from this host's startup benchmarks alone: no torrent is added, no
53
52
  * bytes are fetched and ffmpeg is not run, so it costs milliseconds and can
54
53
  * be asked of every proxy in the pool at once.
55
- * @property {() => HealthMetrics} [onHealthRequest]
56
- * Called when the server sends a `health-request` message. The return value is
57
- * sent back as `health-response` and used by the server to score this proxy.
58
54
  * @property {(message: string) => void} [onLog]
59
55
  * Optional structured log sink.
60
56
  */
@@ -136,7 +132,6 @@ export function createTunnelClient({
136
132
  baseUrl = "",
137
133
  onSignal,
138
134
  onConnect,
139
- onHealthRequest,
140
135
  onCanServeRequest,
141
136
  onLog,
142
137
  connectionLifetimeMs = CONNECTION_LIFETIME_MS
@@ -278,6 +273,14 @@ export function createTunnelClient({
278
273
  return;
279
274
  }
280
275
 
276
+ // The server's answer to this proxy's keepalive, echoed at once on the
277
+ // same connection: the server measures the tunnel round trip from it on
278
+ // its own clock, and chooses proxies partly by that.
279
+ if (message.type === "rtt-probe") {
280
+ send({ type: "rtt-echo", sentAt: message.sentAt }, connection);
281
+ return;
282
+ }
283
+
281
284
  // Could this host serve a file it is only told ABOUT? Asked when the
282
285
  // proxy a viewer landed on has refused the file, so the browser can be
283
286
  // sent somewhere that will work instead of being shown an error. The
@@ -297,29 +300,6 @@ export function createTunnelClient({
297
300
  send({ type: "can-serve-response", requestId: message.requestId, offer }, connection);
298
301
  return;
299
302
  }
300
-
301
- // Health check: server requests current metrics for proxy scoring.
302
- if (message.type === "health-request") {
303
- // Awaited: the answer now carries which films this proxy holds, and the
304
- // truthful list of those lives on the torrent thread.
305
- void (async () => {
306
- let answer = {};
307
- try {
308
- answer = typeof onHealthRequest === "function" ? await onHealthRequest() : {};
309
- } catch {
310
- // silent-ok: a proxy that cannot describe itself is scored on
311
- // nothing rather than not answered at all, which would drop it out
312
- // of every selection until the next poll.
313
- }
314
- send({
315
- type: "health-response",
316
- requestId: message.requestId,
317
- metrics: answer?.metrics ?? answer ?? {},
318
- holds: Array.isArray(answer?.holds) ? answer.holds : []
319
- }, connection);
320
- })();
321
- return;
322
- }
323
303
  });
324
304
 
325
305
  connection.addEventListener("close", (event) => {