@torrent-tv/proxy 2.99.3 → 2.99.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
|
@@ -1248,7 +1248,9 @@ export function createDataChannelHandler({
|
|
|
1248
1248
|
const loop = eventLoopDelay();
|
|
1249
1249
|
const mbps = elapsedMs > 0 ? (totalBytes * 8) / (elapsedMs * 1000) : 0;
|
|
1250
1250
|
log(
|
|
1251
|
-
|
|
1251
|
+
// The request's own id, so the page's line for the same answer can
|
|
1252
|
+
// be read beside this one without comparing two machines' clocks.
|
|
1253
|
+
`[net-debug] sent ${path}${queryInfo} requestId=${requestId} bytes=${totalBytes} fetchMs=${fetchMs} ` +
|
|
1252
1254
|
`ttfbMs=${firstByteMs} sendMs=${elapsedMs} chunks=${chunks} ` +
|
|
1253
1255
|
// The size of one message, so a reading can be attributed to the
|
|
1254
1256
|
// size it was taken at rather than to the run it came from.
|
|
@@ -1335,54 +1337,86 @@ export function createDataChannelHandler({
|
|
|
1335
1337
|
}
|
|
1336
1338
|
|
|
1337
1339
|
/**
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
1341
|
-
* after {@link DC_BUFFER_DRAIN_TIMEOUT_MS} even though the buffer was still
|
|
1342
|
-
* high, so the send loop kept queueing into a wedged channel and the queue
|
|
1343
|
-
* grew to 399 MB (field 2026-09-06). This version waits until the buffer
|
|
1344
|
-
* actually drains, polling as a fallback for a missed low-water event, and
|
|
1345
|
-
* never resolves while the channel is still holding bytes above the low-water
|
|
1346
|
-
* mark.
|
|
1340
|
+
* The send loops waiting on each channel for its library queue to empty. The
|
|
1341
|
+
* native channel has one low-water callback, so it is set once per channel
|
|
1342
|
+
* and wakes every loop waiting on it; each then reads the queue itself.
|
|
1347
1343
|
*
|
|
1344
|
+
* @type {WeakMap<DataChannel, Set<() => void>>}
|
|
1345
|
+
*/
|
|
1346
|
+
const drainWaiters = new WeakMap();
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1348
1349
|
* @param {DataChannel} channel
|
|
1350
|
+
* @returns {Set<() => void> | null} Null for a channel that cannot say when its queue empties.
|
|
1351
|
+
*/
|
|
1352
|
+
function drainWaitersOf(channel) {
|
|
1353
|
+
let waiters = drainWaiters.get(channel);
|
|
1354
|
+
if (waiters) return waiters;
|
|
1355
|
+
if (typeof channel.onBufferedAmountLow !== "function" || typeof channel.setBufferedAmountLowThreshold !== "function") {
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
waiters = new Set();
|
|
1359
|
+
drainWaiters.set(channel, waiters);
|
|
1360
|
+
channel.setBufferedAmountLowThreshold(0);
|
|
1361
|
+
channel.onBufferedAmountLow(() => {
|
|
1362
|
+
for (const wake of [...waiters]) wake();
|
|
1363
|
+
});
|
|
1364
|
+
return waiters;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Resolve once the library holds nothing of this channel's that usrsctp has
|
|
1369
|
+
* not yet taken, or the request is cancelled.
|
|
1370
|
+
*
|
|
1371
|
+
* WHY EMPTY, AND NOT A HIGH-WATER MARK. libdatachannel keeps ONE queue for
|
|
1372
|
+
* every channel of a connection, first in first out (`SctpTransport::send`,
|
|
1373
|
+
* v0.24.2), and usrsctp's own send buffer sits after it. A control answer —
|
|
1374
|
+
* the forecast a held viewer waits for, a seek's acknowledgement — waits
|
|
1375
|
+
* behind everything handed over before it, on every channel. With the 8 MiB
|
|
1376
|
+
* mark this was, and with usrsctp's buffer sized by the 16 MiB message limit,
|
|
1377
|
+
* probes on all three channels stood 2.8-5.1 s behind on Home Assistant while
|
|
1378
|
+
* copied 1080p pieces of 11-12 MB crossed, and a viewer whose picture was
|
|
1379
|
+
* already buffered was started that much later (r46, torrent-tv/meta#178).
|
|
1380
|
+
* usrsctp's own buffer keeps the wire busy while this loop is away; whether
|
|
1381
|
+
* it is deep enough for the rate and this thread's pauses is read from the
|
|
1382
|
+
* `sent` line's rate and event-loop figures, not assumed.
|
|
1383
|
+
*
|
|
1384
|
+
* Cancellation ends the wait at once; elapsed time alone never resumes
|
|
1385
|
+
* sending, so a wedged channel does not grow a queue (399 MB, field
|
|
1386
|
+
* 2026-09-06).
|
|
1387
|
+
*
|
|
1388
|
+
* @param {DataChannel} channel
|
|
1389
|
+
* @param {AbortSignal} signal
|
|
1349
1390
|
* @returns {Promise<void>}
|
|
1350
1391
|
*/
|
|
1351
1392
|
function waitForBufferDrain(channel, signal) {
|
|
1352
1393
|
return new Promise((resolve) => {
|
|
1353
1394
|
try {
|
|
1354
|
-
if (signal.aborted || typeof channel.bufferedAmount !== "function" || channel.bufferedAmount() <=
|
|
1395
|
+
if (signal.aborted || typeof channel.bufferedAmount !== "function" || channel.bufferedAmount() <= 0) {
|
|
1355
1396
|
resolve();
|
|
1356
1397
|
return;
|
|
1357
1398
|
}
|
|
1358
|
-
|
|
1399
|
+
const waiters = drainWaitersOf(channel);
|
|
1359
1400
|
let settled = false;
|
|
1360
1401
|
const done = () => {
|
|
1361
1402
|
if (settled) return;
|
|
1362
1403
|
settled = true;
|
|
1363
|
-
|
|
1404
|
+
waiters?.delete(wake);
|
|
1364
1405
|
signal.removeEventListener("abort", done);
|
|
1365
1406
|
resolve();
|
|
1366
1407
|
};
|
|
1367
|
-
|
|
1368
|
-
// The native channel exposes one callback for all requests. Sampling
|
|
1369
|
-
// avoids one request replacing another's callback. Cancellation ends
|
|
1370
|
-
// the wait immediately; elapsed time alone never resumes sending.
|
|
1371
|
-
poll = setInterval(() => {
|
|
1408
|
+
const wake = () => {
|
|
1372
1409
|
try {
|
|
1373
|
-
if (channel.bufferedAmount() <=
|
|
1374
|
-
done();
|
|
1375
|
-
}
|
|
1410
|
+
if (channel.bufferedAmount() <= 0) done();
|
|
1376
1411
|
} catch {
|
|
1377
1412
|
done();
|
|
1378
1413
|
}
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
//
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
}
|
|
1414
|
+
};
|
|
1415
|
+
signal.addEventListener("abort", done, { once: true });
|
|
1416
|
+
waiters?.add(wake);
|
|
1417
|
+
// The queue may have emptied between the first reading and the
|
|
1418
|
+
// registration, when no further low-water event would come.
|
|
1419
|
+
wake();
|
|
1386
1420
|
if (signal.aborted) done();
|
|
1387
1421
|
} catch {
|
|
1388
1422
|
resolve();
|
|
@@ -1461,6 +1495,3 @@ const TRANSPORT_HEARTBEAT_MS = 5_000;
|
|
|
1461
1495
|
// registry does not end a healthy watch.
|
|
1462
1496
|
const TRANSPORT_UNKNOWN_HEARTBEATS = 3;
|
|
1463
1497
|
const SEND_QUEUE_STUCK_MS = 5_000;
|
|
1464
|
-
const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
|
|
1465
|
-
/** Resume sending once the channel buffer drains to this many bytes. */
|
|
1466
|
-
const DC_BUFFER_LOW_WATER = 1 * 1024 * 1024;
|
|
@@ -26,16 +26,20 @@ const ICE_SERVERS = ["stun:stun.l.google.com:19302", "stun:stun.cloudflare.com:3
|
|
|
26
26
|
const PORT_PREDICTION_WINDOW = 16;
|
|
27
27
|
|
|
28
28
|
// Max size of a single data-channel message the proxy advertises (SDP
|
|
29
|
-
// `a=max-message-size`) and will accept
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
29
|
+
// `a=max-message-size`) and will accept: libdatachannel's own default.
|
|
30
|
+
//
|
|
31
|
+
// It was 16 MiB so that a page could send a large .torrent in one message
|
|
32
|
+
// (Poirot: ~560 KB base64). The page has cut every request body above 128 KiB
|
|
33
|
+
// into 64 KiB frames since server 0.8.58 (`REQUEST_CHUNK_BYTES`), and the
|
|
34
|
+
// proxy's responses go in reader-sized frames, so no message needs more.
|
|
35
|
+
//
|
|
36
|
+
// What 16 MiB also did: libdatachannel sizes usrsctp's send buffer to at least
|
|
37
|
+
// this value (`SctpTransport`, v0.24.2), and its queue is shared by every
|
|
38
|
+
// channel of a connection. A forecast answer or a probe then waited behind up
|
|
39
|
+
// to 16 MiB of video: 2.8-5.1 s on Home Assistant while copied 1080p pieces
|
|
40
|
+
// crossed (r46, torrent-tv/meta#178). At the default the buffer is the 1 MiB
|
|
41
|
+
// libdatachannel gives usrsctp (`SctpTransport::SetSettings`).
|
|
42
|
+
const MAX_DC_MESSAGE_BYTES = 256 * 1024;
|
|
39
43
|
|
|
40
44
|
/**
|
|
41
45
|
* Build predicted srflx ICE candidates for a symmetric NAT.
|
|
@@ -251,11 +251,18 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
251
251
|
const processingCoverage = numericCoverage((Array.isArray(track.processingRanges) ? track.processingRanges : [])
|
|
252
252
|
.filter(range => Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.end > range.start));
|
|
253
253
|
const measuredBitsPerMediaSecond = state.bitsPerMediaSecond;
|
|
254
|
+
const trackSources = Array.isArray(track.sourceIds) ? track.sourceIds : [];
|
|
254
255
|
for (const segment of segments) {
|
|
255
|
-
// Media the page already holds is not transferred again
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
256
|
+
// Media the page already holds is not transferred again: every range of
|
|
257
|
+
// the piece still ahead of the viewer lies inside what the page holds.
|
|
258
|
+
let held = segment.ranges.length > 0;
|
|
259
|
+
for (const range of segment.ranges) {
|
|
260
|
+
if (compare(range.end, at) > 0 && !contains(state.clientRanges, latest(range.start, at), range.end)) {
|
|
261
|
+
held = false;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (held) {
|
|
259
266
|
continue;
|
|
260
267
|
}
|
|
261
268
|
const exactBytes = sizeOf(track.segmentSizesBytes, segment.index);
|
|
@@ -275,28 +282,30 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
275
282
|
const remainingProcessing = uncoveredIntervals({ start: workStart, end: segment.endSeconds }, processingCoverage);
|
|
276
283
|
const encodeWork = produced ? 0 : remainingProcessing.reduce((sum, part) => sum + part.end - part.start, 0);
|
|
277
284
|
|
|
278
|
-
|
|
285
|
+
let sourceByteIntervals = NO_SOURCE_BYTES;
|
|
279
286
|
if (!produced && encodeWork > 0) {
|
|
287
|
+
// Each source the piece reads, in order and once: the track's own, then
|
|
288
|
+
// those its inputs name. Gathered with loops and no copies, because
|
|
289
|
+
// this runs for every piece still to play on every poll of a waiting
|
|
290
|
+
// viewer, and the sets and filtered lists it built were a quarter of
|
|
291
|
+
// the forecast's time on a two-hour film (torrent-tv/meta#178).
|
|
280
292
|
const exactInputs = Array.isArray(segment.sourceInputs) ? segment.sourceInputs : null;
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
293
|
+
const count = trackSources.length + (exactInputs?.length ?? 0);
|
|
294
|
+
for (let place = 0; place < count; place += 1) {
|
|
295
|
+
const sourceId = place < trackSources.length ? trackSources[place]
|
|
296
|
+
: exactInputs[place - trackSources.length]?.sourceId;
|
|
297
|
+
if (namedBefore(sourceId, place, trackSources, exactInputs)) continue;
|
|
286
298
|
const source = sourceState.get(sourceId);
|
|
287
299
|
if (!source) {
|
|
288
300
|
unknownReason ??= "source-measurement-unavailable";
|
|
289
301
|
} else if (source.source.complete !== true) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
} else sourceByteIntervals.set(sourceId, ranges);
|
|
297
|
-
continue;
|
|
302
|
+
const ranges = exactInputs ? inputRangesOf(exactInputs, sourceId) : null;
|
|
303
|
+
if (ranges === null) {
|
|
304
|
+
unknownReason ??= "source-input-ranges-unavailable";
|
|
305
|
+
} else {
|
|
306
|
+
if (sourceByteIntervals === NO_SOURCE_BYTES) sourceByteIntervals = new Map();
|
|
307
|
+
sourceByteIntervals.set(sourceId, ranges);
|
|
298
308
|
}
|
|
299
|
-
unknownReason ??= "source-input-ranges-unavailable";
|
|
300
309
|
}
|
|
301
310
|
}
|
|
302
311
|
}
|
|
@@ -456,10 +465,14 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
456
465
|
const prepared = state.clientRanges.map((range) => ({ ...range }));
|
|
457
466
|
const lastRange = state.segments.at(-1).ranges.at(-1);
|
|
458
467
|
const trackEnd = lastRange ? earliest(end, lastRange.end) : end;
|
|
468
|
+
// What the page holds without a gap from the viewer only changes when a
|
|
469
|
+
// piece is added to it, so the end after one piece is the end before the
|
|
470
|
+
// next.
|
|
471
|
+
let after = contiguousEnd(delivered, at);
|
|
459
472
|
for (const completion of completions.filter((item) => item.trackIndex === trackIndex)) {
|
|
460
|
-
const before =
|
|
473
|
+
const before = after;
|
|
461
474
|
for (const range of completion.segment.ranges) addMediaRange(delivered, range);
|
|
462
|
-
|
|
475
|
+
after = contiguousEnd(delivered, at);
|
|
463
476
|
const ahead = secondsAhead(earliest(before, trackEnd), at);
|
|
464
477
|
constrain(completion.at - ahead, "delivery", trackIndex, completion.segment.index, completion.at, ahead, false);
|
|
465
478
|
if (compare(after, before) <= 0 && completion.segment.ranges.some(({ start }) => compare(start, before) > 0)) {
|
|
@@ -468,7 +481,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
468
481
|
return result(false, null, buffered, reserve, null, 'media-continuity-unavailable', preparedSegments);
|
|
469
482
|
}
|
|
470
483
|
}
|
|
471
|
-
if (compare(
|
|
484
|
+
if (compare(after, trackEnd) < 0) {
|
|
472
485
|
// Everything this track still needs has been delivered in the schedule
|
|
473
486
|
// and its media still stops short of the end: a hole between ranges
|
|
474
487
|
// already held, which no arrival crosses.
|
|
@@ -807,6 +820,41 @@ function latestArrival(interval, ranges = []) {
|
|
|
807
820
|
return finish;
|
|
808
821
|
}
|
|
809
822
|
|
|
823
|
+
/** A piece that reads no source byte still to arrive; never written to. */
|
|
824
|
+
const NO_SOURCE_BYTES = new Map();
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Whether the source named at `place` of a piece's sources — the track's own,
|
|
828
|
+
* then those its inputs name — was named at an earlier place.
|
|
829
|
+
*/
|
|
830
|
+
function namedBefore(sourceId, place, trackSources, exactInputs) {
|
|
831
|
+
for (let earlier = 0; earlier < place; earlier += 1) {
|
|
832
|
+
const named = earlier < trackSources.length ? trackSources[earlier]
|
|
833
|
+
: exactInputs[earlier - trackSources.length]?.sourceId;
|
|
834
|
+
if (named === sourceId) return true;
|
|
835
|
+
}
|
|
836
|
+
return false;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* The byte ranges a piece reads of one source, in the order its inputs state
|
|
841
|
+
* them, or null when an input of that source states none or one that is not a
|
|
842
|
+
* non-empty run of whole bytes.
|
|
843
|
+
*/
|
|
844
|
+
function inputRangesOf(exactInputs, sourceId) {
|
|
845
|
+
let ranges = null;
|
|
846
|
+
for (const input of exactInputs) {
|
|
847
|
+
if (input?.sourceId !== sourceId) continue;
|
|
848
|
+
if (!Array.isArray(input.ranges) || input.ranges.length === 0) return null;
|
|
849
|
+
for (const range of input.ranges) {
|
|
850
|
+
if (!range || !Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.end) ||
|
|
851
|
+
range.start < 0 || range.end <= range.start) return null;
|
|
852
|
+
}
|
|
853
|
+
ranges = ranges === null ? input.ranges : ranges.concat(input.ranges);
|
|
854
|
+
}
|
|
855
|
+
return ranges;
|
|
856
|
+
}
|
|
857
|
+
|
|
810
858
|
/** Exact gaps in sorted disjoint numeric coverage, without per-range splitting. */
|
|
811
859
|
function uncoveredIntervals(interval, served) {
|
|
812
860
|
let low = 0, high = served.length;
|