@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
|
@@ -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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
* @
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
|
102
|
-
const track = ranges?.tracks?.find(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
/**
|
|
133
|
-
|
|
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) => {
|