@torrent-tv/proxy 2.80.4 → 2.80.6

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +1 -1
  3. package/routes/api/delivery-sink/get.js +8 -4
  4. package/server.js +415 -403
  5. package/services/data-channel-handler.js +87 -25
  6. package/services/delivery-probe.js +38 -5
  7. package/services/encode/CoverageMap.js +77 -4
  8. package/services/encode/EncodePlan.js +1025 -358
  9. package/services/encode/EncodeRun.js +42 -22
  10. package/services/encode/SegmentDemand.js +0 -0
  11. package/services/encode/SegmentStore.js +55 -3
  12. package/services/encode/open-piece.js +47 -24
  13. package/services/encode/run-command.js +12 -2
  14. package/services/hls-session-manager.js +38 -158
  15. package/services/hwaccel.js +182 -54
  16. package/services/orchestrators/EncodeOrchestrator.js +123 -94
  17. package/services/output/LiveOutputs.js +233 -213
  18. package/services/output/Timeline.js +333 -256
  19. package/services/priority/PriorityMap.js +262 -108
  20. package/services/priority/PriorityOrchestrator.js +31 -6
  21. package/services/quality/EncodeCost.js +555 -500
  22. package/services/torrent-pool.js +9 -4
  23. package/test/encode-orchestrator.test.js +195 -65
  24. package/test/encode-plan-viewers.test.js +719 -0
  25. package/test/encode-plan.test.js +174 -81
  26. package/test/open-piece.test.js +152 -0
  27. package/test/output-speed.test.js +86 -0
  28. package/test/priority-map-download.test.js +25 -7
  29. package/test/priority-map.test.js +134 -83
  30. package/test/seek-landing.test.js +109 -76
  31. package/test/segment-demand.test.js +54 -56
  32. package/test/wedge-certainty.test.js +3 -3
  33. package/test/flushed-piece.test.js +0 -108
@@ -119,10 +119,10 @@ import { createDeliveryProbe, PROBE_INTERVAL_MS } from "./delivery-probe.js";
119
119
  * @returns {{ certain: boolean, needMs: number | null }}
120
120
  */
121
121
  export function wedgeIsCertain({ queuedBytes, bytesPerSecond, flatForMs, longestHealthyFlatMs = 0 }) {
122
- if (!(queuedBytes > 0) || !(bytesPerSecond > 0)) {
122
+ if (!(bytesPerSecond > 0)) {
123
123
  return { certain: false, needMs: null };
124
124
  }
125
- const drainMs = (queuedBytes / bytesPerSecond) * 1000;
125
+ const drainMs = (Math.max(queuedBytes, 0) / bytesPerSecond) * 1000;
126
126
  const needMs = Math.max(drainMs, longestHealthyFlatMs, PROBE_INTERVAL_MS);
127
127
  return { certain: flatForMs >= needMs, needMs };
128
128
  }
@@ -221,6 +221,37 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
221
221
  };
222
222
  };
223
223
 
224
+ /**
225
+ * Update the browser-side channel byte counter for a session.
226
+ *
227
+ * The transport's `bytesReceived` advances on SACKs (140 B/s on a wedged
228
+ * association) even though every data channel is flat — so it gives a false
229
+ * "peer still sending" through every other tick (field 2026-09-06: 6 h 50 min
230
+ * wedge, `flowing`/`association-stopped` alternating every 500 ms). Channel
231
+ * counters are in the same `probe-echo` report and are flat on a wedge.
232
+ *
233
+ * @param {string} sessionId
234
+ * @param {object} report - The `report` object from `probe-echo`.
235
+ * @returns {void}
236
+ */
237
+ const noteBrowserReport = (sessionId, report) => {
238
+ const connection = connections.get(sessionId);
239
+ if (!connection || !report || typeof report !== "object") return;
240
+ const channels = report.channels;
241
+ if (!channels || typeof channels !== "object") return;
242
+ let total = 0;
243
+ let hasBytes = false;
244
+ for (const v of Object.values(channels)) {
245
+ if (v && typeof v.bytes === "number" && Number.isFinite(v.bytes)) {
246
+ total += v.bytes;
247
+ hasBytes = true;
248
+ }
249
+ }
250
+ if (hasBytes) {
251
+ connection.browserChannelBytes = total;
252
+ }
253
+ };
254
+
224
255
  /**
225
256
  * @param {string} sessionId
226
257
  * @param {string} tag
@@ -228,7 +259,7 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
228
259
  * @param {DataChannel} channel
229
260
  * @returns {() => void} Stops the watch.
230
261
  */
231
- const watchSendQueue = (sessionId, tag, label, channel) => {
262
+ const watchSendQueue = (sessionId, tag, label, channel) => {
232
263
  let lowestSinceDrain = Number.POSITIVE_INFINITY;
233
264
  let stuckSince = 0;
234
265
  let previous = null;
@@ -243,6 +274,7 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
243
274
  // would be cleared a second after the wedged channel set it and the line
244
275
  // would print for every second of a 54-minute episode.
245
276
  let wedgeSaid = false;
277
+ let lastStuckLogAt = 0;
246
278
  let connection = connections.get(sessionId);
247
279
  if (!connection) {
248
280
  connection = {
@@ -257,7 +289,12 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
257
289
  sentAt: 0,
258
290
  sentBytes: 0,
259
291
  bytesPerSecond: 0,
260
- longestHealthyFlatMs: 0
292
+ longestHealthyFlatMs: 0,
293
+ // Browser-side channel bytes (sum of report.channels[].bytes). Transport's
294
+ // bytesReceived advances on SACKs (140 B/s on a wedge) and gives a false
295
+ // "peer still sending" every other tick — channel bytes are flat on a wedge.
296
+ browserChannelBytes: 0,
297
+ browserChannelBytesAtStuck: -1
261
298
  };
262
299
  connections.set(sessionId, connection);
263
300
  }
@@ -395,6 +432,7 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
395
432
  stuckSince = 0;
396
433
  previous = null;
397
434
  receivedWhenStuck = -1;
435
+ connection.browserChannelBytesAtStuck = -1;
398
436
  wedgeSaid = false;
399
437
  // The queue moved, so whatever was called a wedge has cleared. Let a
400
438
  // later one be recorded too: one mistaken call must not spend the
@@ -409,7 +447,8 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
409
447
  }
410
448
  const snapshot = getTransportSnapshot?.(sessionId) ?? null;
411
449
  if (!snapshot) {
412
- if (now - stuckSince >= SEND_QUEUE_STUCK_MS) {
450
+ if (now - stuckSince >= SEND_QUEUE_STUCK_MS && now - lastStuckLogAt >= 30_000) {
451
+ lastStuckLogAt = now;
413
452
  log(`[dc] Session ${tag} "${label}": send queue stuck at ${queued}B for ` +
414
453
  `${Math.round((now - stuckSince) / 1000)}s — no transport to ask`);
415
454
  }
@@ -421,6 +460,9 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
421
460
  if (receivedWhenStuck < 0) {
422
461
  receivedWhenStuck = snapshot.bytesReceived;
423
462
  }
463
+ if (connection.browserChannelBytesAtStuck < 0) {
464
+ connection.browserChannelBytesAtStuck = connection.browserChannelBytes;
465
+ }
424
466
  // The periodic line waits for {@link SEND_QUEUE_STUCK_MS}, because a
425
467
  // queue that has merely not fallen for a second is ordinary and a line a
426
468
  // second for it is noise. The WEDGE below does not wait for it: its own
@@ -428,15 +470,18 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
428
470
  // on a fast link that is under a second. Holding the evidence back for a
429
471
  // fixed five seconds would repeat, in miniature, the mistake that left
430
472
  // both field captures without an onset in them.
431
- if (now - stuckSince >= SEND_QUEUE_STUCK_MS) {
473
+ // Rate-limit to 30 s — field 2026-09-06 printed 49 295 lines (31 % of log)
474
+ // for one 6 h 50 min wedge, one line per second per channel.
475
+ if (now - stuckSince >= SEND_QUEUE_STUCK_MS && now - lastStuckLogAt >= 30_000) {
476
+ lastStuckLogAt = now;
432
477
  log(
433
478
  `[dc] Session ${tag} "${label}": send queue stuck at ${queued}B for ` +
434
- `${Math.round((now - stuckSince) / 1000)}s — transport ` +
435
- `sent=${snapshot.bytesSent}${sentDelta === null ? "" : ` (+${sentDelta})`} ` +
436
- `received=${snapshot.bytesReceived}${recvDelta === null ? "" : ` (+${recvDelta})`} ` +
437
- `rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
479
+ `${Math.round((now - stuckSince) / 1000)}s — transport ` +
480
+ `sent=${snapshot.bytesSent}${sentDelta === null ? "" : ` (+${sentDelta})`} ` +
481
+ `received=${snapshot.bytesReceived}${recvDelta === null ? "" : ` (+${recvDelta})`} ` +
482
+ `rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
438
483
  );
439
- }
484
+ }
440
485
  // Roadmap item 11: ask for the packet-level truth the moment the wedge is
441
486
  // CERTAIN, not after a chosen delay. The three facts below are not
442
487
  // ambiguous together — the queue has not fallen, the accepted-byte
@@ -452,7 +497,10 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
452
497
  flatForMs,
453
498
  longestHealthyFlatMs: connection.longestHealthyFlatMs
454
499
  });
455
- const peerStillSending = snapshot.bytesReceived > receivedWhenStuck;
500
+ const hasBrowserBytes = Number.isFinite(connection.browserChannelBytes) && connection.browserChannelBytesAtStuck >= 0;
501
+ const peerStillSending = hasBrowserBytes
502
+ ? connection.browserChannelBytes > connection.browserChannelBytesAtStuck
503
+ : snapshot.bytesReceived > receivedWhenStuck;
456
504
  if (verdict.certain && peerStillSending && !wedgeSaid) {
457
505
  wedgeSaid = true;
458
506
  log(
@@ -470,7 +518,7 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
470
518
  peerStillSending
471
519
  ) {
472
520
  connection.captureStarted = true;
473
- const started = witness.maybeCapture({
521
+ witness.maybeCapture({
474
522
  sessionId,
475
523
  tag,
476
524
  label,
@@ -478,12 +526,6 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
478
526
  queuedBytes: queued,
479
527
  stuckForMs: now - stuckSince
480
528
  });
481
- if (!started) {
482
- // Refused for now (no remote endpoint yet, capture already running
483
- // elsewhere, cooldown): let the next tick try again rather than
484
- // spending the one attempt per wedge on a refusal.
485
- connection.captureStarted = false;
486
- }
487
529
  }
488
530
  if (usrsctpState && verdict.certain && peerStillSending) {
489
531
  // Its own single-flight and cooldown, independent of the witness's —
@@ -501,7 +543,7 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
501
543
  return stop;
502
544
  };
503
545
 
504
- return { watchSendQueue, readDelivery };
546
+ return { watchSendQueue, readDelivery, noteBrowserReport };
505
547
  }
506
548
 
507
549
  /**
@@ -676,7 +718,7 @@ export function createDataChannelHandler({
676
718
  /** Request id → its ASCII bytes; see {@link requestIdBytes}. */
677
719
  const requestIdCache = new Map();
678
720
 
679
- const { watchSendQueue, readDelivery } = makeSendQueueWatcher({
721
+ const { watchSendQueue, readDelivery, noteBrowserReport } = makeSendQueueWatcher({
680
722
  log: (message) => log(message),
681
723
  getTransportSnapshot,
682
724
  witness,
@@ -884,6 +926,7 @@ export function createDataChannelHandler({
884
926
  // on working through a freeze, so it arrives when nothing else does.
885
927
  if (message.type === "probe-echo") {
886
928
  deliveryProbe.noteEcho(sessionId, message);
929
+ noteBrowserReport(sessionId, message.report);
887
930
  if (message.report && typeof message.report === "object") {
888
931
  const report = message.report;
889
932
  const channels = report.channels && typeof report.channels === "object"
@@ -1142,8 +1185,13 @@ export function createDataChannelHandler({
1142
1185
  /**
1143
1186
  * Resolve once the channel's outgoing buffer has drained below the low-water
1144
1187
  * mark. No-op (resolves immediately) when the buffer is already small or the
1145
- * channel does not expose buffer APIs. A timeout fallback guards against a
1146
- * missed low-water event so the send loop can never deadlock.
1188
+ * channel does not expose buffer APIs. The previous implementation resolved
1189
+ * after {@link DC_BUFFER_DRAIN_TIMEOUT_MS} even though the buffer was still
1190
+ * high, so the send loop kept queueing into a wedged channel and the queue
1191
+ * grew to 399 MB (field 2026-09-06). This version waits until the buffer
1192
+ * actually drains, polling as a fallback for a missed low-water event, and
1193
+ * never resolves while the channel is still holding bytes above the low-water
1194
+ * mark.
1147
1195
  *
1148
1196
  * @param {DataChannel} channel
1149
1197
  * @returns {Promise<void>}
@@ -1155,21 +1203,35 @@ export function createDataChannelHandler({
1155
1203
  resolve();
1156
1204
  return;
1157
1205
  }
1206
+ let poll = null;
1158
1207
  let settled = false;
1159
1208
  const done = () => {
1160
1209
  if (settled) return;
1161
1210
  settled = true;
1211
+ if (poll) clearInterval(poll);
1212
+ try { channel.onBufferedAmountLow(() => {}); } catch {}
1162
1213
  resolve();
1163
1214
  };
1164
1215
  channel.setBufferedAmountLowThreshold(DC_BUFFER_LOW_WATER);
1165
1216
  channel.onBufferedAmountLow(done);
1217
+ // Poll as a fallback for a missed low-water event — the previous
1218
+ // 5 s timeout resolved while the buffer was still high, which is the
1219
+ // defect. Polling waits until the condition is actually met.
1220
+ poll = setInterval(() => {
1221
+ try {
1222
+ if (channel.bufferedAmount() <= DC_BUFFER_LOW_WATER) {
1223
+ done();
1224
+ }
1225
+ } catch {
1226
+ done();
1227
+ }
1228
+ }, 50);
1229
+ if (typeof poll.unref === "function") poll.unref();
1166
1230
  // Guard against a race where the buffer drained between the check above
1167
1231
  // and registering the callback (the low-water event would never fire).
1168
1232
  if (channel.bufferedAmount() <= DC_BUFFER_LOW_WATER) {
1169
1233
  done();
1170
- return;
1171
1234
  }
1172
- setTimeout(done, DC_BUFFER_DRAIN_TIMEOUT_MS);
1173
1235
  } catch {
1174
1236
  resolve();
1175
1237
  }
@@ -372,11 +372,22 @@ export function createDeliveryProbe({
372
372
  0
373
373
  );
374
374
  // Advancing SINCE THE PREVIOUS TICK, not since the connection began: the
375
- // question is whether bytes are crossing now.
376
- const peerBytesAdvancing = connection.peerBytes === null
377
- ? null
378
- : connection.peerBytesAtTick === null || connection.peerBytes > connection.peerBytesAtTick;
379
- connection.peerBytesAtTick = connection.peerBytes;
375
+ // question is whether bytes are crossing now. Prefer channel bytes —
376
+ // transport's bytesReceived advances on SACKs (140 B/s on a wedge) and
377
+ // gives a false "advancing" every other tick, while channel bytes are flat.
378
+ let peerBytesAdvancing = null;
379
+ if (connection.peerChannelBytes !== null) {
380
+ peerBytesAdvancing =
381
+ connection.peerChannelBytesAtTick === null ||
382
+ connection.peerChannelBytes > connection.peerChannelBytesAtTick;
383
+ connection.peerChannelBytesAtTick = connection.peerChannelBytes;
384
+ // keep transport's tick in sync for fallback logging
385
+ connection.peerBytesAtTick = connection.peerBytes;
386
+ } else if (connection.peerBytes !== null) {
387
+ peerBytesAdvancing =
388
+ connection.peerBytesAtTick === null || connection.peerBytes > connection.peerBytesAtTick;
389
+ connection.peerBytesAtTick = connection.peerBytes;
390
+ }
380
391
  const { verdict, detail } = readProbeState({
381
392
  seq: connection.seq,
382
393
  seen: connection.seen,
@@ -468,6 +479,13 @@ export function createDeliveryProbe({
468
479
  peerBytes: null,
469
480
  /** @type {number | null} */
470
481
  peerBytesAtTick: null,
482
+ // Sum of per-channel bytes (report.channels[].bytes). Transport's
483
+ // bytesReceived advances on SACKs (140 B/s on a wedge) and gives a
484
+ // false "advancing" every other tick — channel bytes are flat on a wedge.
485
+ /** @type {number | null} */
486
+ peerChannelBytes: null,
487
+ /** @type {number | null} */
488
+ peerChannelBytesAtTick: null,
471
489
  // The far end's own event-loop delay and tab state, as last reported.
472
490
  // The lag is a term in every allowance below; the tab state is only
473
491
  // printed, so that a wide allowance can be read back to its cause.
@@ -554,6 +572,21 @@ export function createDeliveryProbe({
554
572
  if (Number.isFinite(peerBytes) && peerBytes >= 0) {
555
573
  connection.peerBytes = peerBytes;
556
574
  }
575
+ // Sum of per-channel bytes. Transport's bytesReceived advances on SACKs
576
+ // (140 B/s on a wedge) and gives a false "advancing" every other tick —
577
+ // channel bytes are flat on a wedge, so they are the correct signal.
578
+ const ch = echo?.report?.channels;
579
+ if (ch && typeof ch === "object") {
580
+ let total = 0;
581
+ let has = false;
582
+ for (const v of Object.values(ch)) {
583
+ if (v && typeof v.bytes === "number" && Number.isFinite(v.bytes)) {
584
+ total += v.bytes;
585
+ has = true;
586
+ }
587
+ }
588
+ if (has) connection.peerChannelBytes = total;
589
+ }
557
590
  // How far behind the far end's own event loop is running. A browser that
558
591
  // cannot run its timers cannot answer a probe, and every allowance here
559
592
  // is a wait the answer has to fit inside — so this is a term in the
@@ -135,6 +135,31 @@ export class CoverageMap {
135
135
  this.#claims.delete(run);
136
136
  }
137
137
 
138
+ /**
139
+ * How many numbers between two are already made.
140
+ *
141
+ * What an encoder driving from one to the other would produce a SECOND time,
142
+ * and therefore what the swarm would be asked to fetch a second time. It is a
143
+ * term of when that encoder arrives, not a separate question about whether to
144
+ * move it: an encoder that has to re-make three hundred pieces on its way is
145
+ * simply slower to get there, and the model compares arrivals.
146
+ *
147
+ * @param {number} from - Inclusive.
148
+ * @param {number} to - Inclusive.
149
+ * @returns {number}
150
+ */
151
+ madeBetween(from, to) {
152
+ const first = Number.isInteger(from) && from > 0 ? from : 0;
153
+ const last = Number.isInteger(to) ? to : -1;
154
+ let count = 0;
155
+ for (let index = first; index <= last; index += 1) {
156
+ if (this.isReady(index)) {
157
+ count += 1;
158
+ }
159
+ }
160
+ return count;
161
+ }
162
+
138
163
  /**
139
164
  * @param {number} index
140
165
  * @returns {boolean}
@@ -195,7 +220,7 @@ export class CoverageMap {
195
220
  continue;
196
221
  }
197
222
  const maker = this.makerOf(at);
198
- if (maker === null || maker === exceptRun) {
223
+ if (maker === null || CoverageMap.#isExcepted(maker, exceptRun)) {
199
224
  return at;
200
225
  }
201
226
  }
@@ -226,7 +251,7 @@ export class CoverageMap {
226
251
  continue;
227
252
  }
228
253
  const maker = this.makerOf(at);
229
- if (maker !== null && maker !== exceptRun) {
254
+ if (maker !== null && !CoverageMap.#isExcepted(maker, exceptRun)) {
230
255
  at += 1;
231
256
  continue;
232
257
  }
@@ -235,6 +260,36 @@ export class CoverageMap {
235
260
  return at - start;
236
261
  }
237
262
 
263
+ /**
264
+ * How many numbers from `index` onward have NOT been made, claims ignored.
265
+ *
266
+ * The stretch an encoder placed here could work through before it would be
267
+ * re-making something that exists. Claims are deliberately left out: a claim
268
+ * says another encoder MEANS to make it, and where two encoders are being
269
+ * placed in one pass the claims of the moment are about to be re-cut — so
270
+ * asking about them here gives an answer that was true a step ago. Where one
271
+ * encoder's road ends because another begins is decided once, over all the
272
+ * placements together, after they are known.
273
+ *
274
+ * @param {number} index
275
+ * @returns {number} Zero when `index` is already made; the rest of the track
276
+ * when nothing ahead is; `Infinity` when the length is not yet known.
277
+ */
278
+ unmadeRunFrom(index) {
279
+ const start = Number.isInteger(index) && index > 0 ? index : 0;
280
+ if (this.isReady(start)) {
281
+ return 0;
282
+ }
283
+ if (this.#segmentCount <= 0) {
284
+ return Number.POSITIVE_INFINITY;
285
+ }
286
+ let end = start;
287
+ while (end < this.#segmentCount && !this.isReady(end)) {
288
+ end += 1;
289
+ }
290
+ return end - start;
291
+ }
292
+
238
293
  /**
239
294
  * How many numbers from `index` onwards are free — nobody has made them and
240
295
  * nobody is making them.
@@ -273,7 +328,7 @@ export class CoverageMap {
273
328
  break;
274
329
  }
275
330
  const maker = this.makerOf(at);
276
- if (maker !== null && maker !== exceptRun) {
331
+ if (maker !== null && !CoverageMap.#isExcepted(maker, exceptRun)) {
277
332
  break;
278
333
  }
279
334
  at += 1;
@@ -293,6 +348,24 @@ export class CoverageMap {
293
348
  * @param {object | null} exceptRun
294
349
  * @returns {number | null}
295
350
  */
351
+ /**
352
+ * Is this claim one of the runs the caller is setting aside?
353
+ *
354
+ * A single run or a set of them: a pass that re-cuts several roads at once has
355
+ * to ask about all of them together, and asking once per run gave an answer
356
+ * true of no moment.
357
+ *
358
+ * @param {object} maker
359
+ * @param {object | Set<object> | null} except
360
+ * @returns {boolean}
361
+ */
362
+ static #isExcepted(maker, except) {
363
+ if (except === null || except === undefined) {
364
+ return false;
365
+ }
366
+ return except instanceof Set ? except.has(maker) : maker === except;
367
+ }
368
+
296
369
  #firstCoveredFrom(index, exceptRun) {
297
370
  let lowest = null;
298
371
  for (const ready of this.#ready) {
@@ -301,7 +374,7 @@ export class CoverageMap {
301
374
  }
302
375
  }
303
376
  for (const [run, span] of this.#claims) {
304
- if (run === exceptRun) {
377
+ if (CoverageMap.#isExcepted(run, exceptRun)) {
305
378
  continue;
306
379
  }
307
380
  // A claim that has already begun covers `index` itself.