@torrent-tv/proxy 2.99.0 → 2.99.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.99.0",
3
+ "version": "2.99.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": {
package/server.js CHANGED
@@ -66,6 +66,7 @@ import { nativeSourceMap, nativeOriginalSourceMap } from "./services/viewer/Nati
66
66
  import { MediaReadRequests } from "./services/media/MediaReadRequests.js";
67
67
  import { SegmentInputs } from "./services/media/SegmentInputs.js";
68
68
  import { directSegmentInput } from "./services/media/direct-segment-input.js";
69
+ import { piecesRunPastEnd } from "./services/encode/output/Timeline.js";
69
70
  import { ContainerTrack } from "./services/media/tracks/ContainerTrack.js";
70
71
  import { pauseCoefficient, viewerStartsOn } from "./services/viewer/PriorityMap.js";
71
72
  import { coalescing } from "./utils/coalesce.js";
@@ -626,7 +627,9 @@ export async function startProxyServer({
626
627
  const revision = outputParts.segmentStore.revisionOf(output.outputKey);
627
628
  const links = consumers.map(viewer => viewer.linkReading()?.linkMbps)
628
629
  .filter(value => Number.isFinite(value) && value > 0);
629
- played.push({ outputKey: output.outputKey, grid,
630
+ // A piece copying a stream answers for the second its end falls in,
631
+ // as the encode plan reads it (`piecesRunPastEnd`).
632
+ played.push({ outputKey: output.outputKey, grid, runsPastEnd: piecesRunPastEnd(output.spec),
630
633
  encodeSpeed: outputParts.encodeCost.latestSpeedReadingOf(output)?.speed,
631
634
  bytesPerSecond: bytesPerSecondOf(output, revision), linkMbps: links.length ? Math.min(...links) : null,
632
635
  readyRevision: revision, ready: () => outputParts.segmentStore.sizesOf(output.outputKey),
@@ -25,6 +25,7 @@ import { cutOf, cutShiftOf, judgeNeighbors, judgePiece, leadingSecondsOf, lacksO
25
25
  import { leadingPictures, presentationSegment } from "./segment-formats/presentation-segment.js";
26
26
  import { InputFailures, failedAdmittedInput } from "./InputFailures.js";
27
27
  import { readProcessCpuSeconds } from "./host-load.js";
28
+ import { piecesRunPastEnd } from "./output/Timeline.js";
28
29
 
29
30
 
30
31
  /**
@@ -599,7 +600,7 @@ export class EncodeRuns {
599
600
  timeline?.inSegments?.(
600
601
  this.#host.priority.mapForOutput(address),
601
602
  Number(timeline?.segmentCount) || 0,
602
- { runsPastEnd: Boolean(spec?.audio) && spec?.transcodesAudio !== true }
603
+ { runsPastEnd: piecesRunPastEnd(spec) }
603
604
  ) ?? []
604
605
  );
605
606
  }
@@ -42,6 +42,27 @@
42
42
  * reveal where the file's cuts really are.
43
43
  */
44
44
 
45
+ /**
46
+ * Whether an output's pieces copy a stream, picture or sound, and so may hold
47
+ * a little past their published end: a copy ends at the first packet boundary
48
+ * at or after it, and the player places the next piece where this one truly
49
+ * ends (hls.js `updateFragPTSDTS`). A viewer standing at a published edge is
50
+ * then asked for by the piece BEFORE it.
51
+ *
52
+ * Field 2026-10-09, MP3 copied out of an AVI: a seek to 4000 s asked for the
53
+ * piece ending at 4000 s. Field r43 (Home Assistant, 2026-10-15), a copied
54
+ * Matroska picture: a seek to 1000.000 s, the published start of #96, asked for
55
+ * #95 on both tracks; the picture's pieces were not counted as copies, nothing
56
+ * made #95, and the picture stood 131 s until the viewer moved
57
+ * (torrent-tv/meta#178).
58
+ *
59
+ * @param {{ video?: object | null, audio?: object | null, transcodesVideo?: boolean, transcodesAudio?: boolean } | null | undefined} spec
60
+ * @returns {boolean}
61
+ */
62
+ export function piecesRunPastEnd(spec) {
63
+ return (Boolean(spec?.video) && spec.transcodesVideo !== true) || (Boolean(spec?.audio) && spec.transcodesAudio !== true);
64
+ }
65
+
45
66
  export class Timeline {
46
67
  /**
47
68
  * @param {object} params
@@ -496,7 +496,11 @@ export class QualityController {
496
496
  bufferedSec: buffer?.bufferedSec ?? 0,
497
497
  slope: buffer?.slope ?? null,
498
498
  reportGapSec: buffer?.reportGapSec ?? 0,
499
- secondsToReady: this.#secondsToReady(session)
499
+ secondsToReady: this.#secondsToReady(session),
500
+ // What is left of the film after them: a buffer holding all of it drains
501
+ // because the film ends.
502
+ remainingSec: Number.isFinite(buffer?.positionSec) && session.file?.durationSeconds > 0
503
+ ? Math.max(0, session.file.durationSeconds - buffer.positionSec) : null
500
504
  });
501
505
  if (!threat) {
502
506
  return false;
@@ -20,6 +20,13 @@
20
20
  *
21
21
  * A time to ready that is not known is not assumed to be short: with nothing to
22
22
  * show there is time left, a draining buffer is a threat.
23
+ *
24
+ * A BUFFER HOLDING THE REST OF THE FILM IS NO THREAT: it drains because the
25
+ * film ends, and no output can add to it. Field r43 (Home Assistant,
26
+ * 2026-10-15): the last two minutes of an AVI were read as a draining buffer,
27
+ * a faster setting was prepared 53 times and given up each time with nothing
28
+ * left to give, and the player was asked for 240p eight times
29
+ * (torrent-tv/meta#178).
23
30
  */
24
31
 
25
32
  /**
@@ -31,14 +38,19 @@
31
38
  * two reports.
32
39
  * @param {number | null} params.secondsToReady - How long the new output would
33
40
  * take to close the piece the viewer needs; null when not known.
41
+ * @param {number | null} [params.remainingSec] - The film left after where the
42
+ * viewer stands; null when not known.
34
43
  * @returns {{ threat: boolean, secondsToEmpty: number | null }}
35
44
  */
36
- export function drainThreat({ bufferedSec, slope, reportGapSec, secondsToReady }) {
45
+ export function drainThreat({ bufferedSec, slope, reportGapSec, secondsToReady, remainingSec = null }) {
37
46
  if (!Number.isFinite(slope) || slope >= 0) {
38
47
  return { threat: false, secondsToEmpty: null };
39
48
  }
40
49
  const held = Number.isFinite(bufferedSec) && bufferedSec > 0 ? bufferedSec : 0;
41
50
  const secondsToEmpty = held / -slope;
51
+ if (Number.isFinite(remainingSec) && held >= remainingSec) {
52
+ return { threat: false, secondsToEmpty };
53
+ }
42
54
  if (!Number.isFinite(secondsToReady) || secondsToReady < 0) {
43
55
  return { threat: true, secondsToEmpty };
44
56
  }
@@ -267,11 +267,18 @@ class RangeEntry {
267
267
 
268
268
  /** One output segment as the projection holds it. */
269
269
  class Segment {
270
- constructor(output, index, from, to) {
270
+ constructor(output, index, from, to, reach = to) {
271
271
  this.output = output;
272
272
  this.index = index;
273
273
  this.from = from;
274
274
  this.to = to;
275
+ /**
276
+ * Up to which second the map's zones are read for it: its end, and for a
277
+ * piece that copies a stream the second its end falls in as well, as the
278
+ * encode plan reads it (`Timeline.inSegments`): the player asks a viewer
279
+ * standing at its end for this piece, and its bytes are fetched for them.
280
+ */
281
+ this.reach = reach;
275
282
  /** Whether a zone of the map overlaps it. */
276
283
  this.demanded = false;
277
284
  /** Whether the output has it made. */
@@ -304,13 +311,16 @@ class Segment {
304
311
 
305
312
  /** One output: its grid, how long its segments take to make and deliver, what is made, and its next input. */
306
313
  class OutputState {
307
- constructor(outputKey, grid) {
314
+ constructor(outputKey, grid, runsPastEnd = false) {
308
315
  this.outputKey = outputKey;
309
316
  this.grid = grid;
317
+ this.runsPastEnd = runsPastEnd;
310
318
  /** @type {Segment[]} */
311
319
  this.segments = [];
312
320
  for (let index = 0; index + 1 < grid.length; index++) {
313
- this.segments.push(new Segment(this, index, grid[index], grid[index + 1]));
321
+ const end = grid[index + 1];
322
+ this.segments.push(new Segment(this, index, grid[index], end,
323
+ runsPastEnd && index + 2 < grid.length ? Math.max(end, Math.floor(end) + 1) : end));
314
324
  }
315
325
  this.encodeSpeed = undefined;
316
326
  this.bytesPerSecond = undefined;
@@ -370,7 +380,7 @@ export class PlaybackProjection {
370
380
  * @param {object} params
371
381
  * @param {Array<{ from: number, to: number, priority: number, urgent?: boolean, behind?: boolean, deferred?: boolean,
372
382
  * deadlineAt?: number }>} params.zones - The file's map zones, deadlines as instants.
373
- * @param {Array<{ outputKey: string, grid: number[], encodeSpeed?: number, bytesPerSecond?: number | null,
383
+ * @param {Array<{ outputKey: string, grid: number[], runsPastEnd?: boolean, encodeSpeed?: number, bytesPerSecond?: number | null,
374
384
  * linkMbps?: number | null, readyRevision: unknown, ready: () => Map<number, number>,
375
385
  * target: number | null }>} params.outputs - Every output of the file somebody is watching, with
376
386
  * what decides how long its segments take to make and deliver (`sourceDeadline`), what it has made
@@ -391,13 +401,13 @@ export class PlaybackProjection {
391
401
  for (const info of outputs) {
392
402
  seen.add(info.outputKey);
393
403
  let output = this.#outputs.get(info.outputKey);
394
- if (output && output.grid !== info.grid) {
404
+ if (output && (output.grid !== info.grid || output.runsPastEnd !== Boolean(info.runsPastEnd))) {
395
405
  this.#dropOutput(output);
396
406
  output = undefined;
397
407
  }
398
408
  const fresh = !output;
399
409
  if (fresh) {
400
- output = new OutputState(info.outputKey, info.grid);
410
+ output = new OutputState(info.outputKey, info.grid, Boolean(info.runsPastEnd));
401
411
  this.#outputs.set(info.outputKey, output);
402
412
  }
403
413
  // How long a segment of it takes to make and deliver.
@@ -426,7 +436,9 @@ export class PlaybackProjection {
426
436
  for (const segment of output.segments) if (this.#demand(segment, zones, ordered, nextBase, leadMoved)) reworked++;
427
437
  } else {
428
438
  for (const { from, to } of stretches) {
429
- for (let index = segmentAt(output.grid, from); index < output.segments.length && output.segments[index].from < to; index++) {
439
+ // A piece reaching past its end is read for the stretch after it too.
440
+ for (let index = Math.max(0, segmentAt(output.grid, from) - (output.runsPastEnd ? 1 : 0));
441
+ index < output.segments.length && output.segments[index].from < to; index++) {
430
442
  if (this.#demand(output.segments[index], zones, ordered, nextBase, false)) reworked++;
431
443
  }
432
444
  }
@@ -542,7 +554,7 @@ export class PlaybackProjection {
542
554
  #demand(segment, zones, ordered, base, leadMoved) {
543
555
  // The zones it overlaps, folded as they are met.
544
556
  let owner = null, ownerDeadline = Infinity, priority = 0, urgent = false, behind = true, deferred = true;
545
- const { from, to } = segment;
557
+ const { from, reach: to } = segment;
546
558
  for (let at = ordered ? firstEndingAfter(zones, from) : 0; at < zones.length; at += 1) {
547
559
  const zone = zones[at];
548
560
  if (ordered && !(zone.from < to)) break;
@@ -592,8 +604,8 @@ export class PlaybackProjection {
592
604
  let owner = null, ownerDeadline = Infinity;
593
605
  for (let at = this.#ordered ? firstEndingAfter(zones, segment.from) : 0; at < zones.length; at += 1) {
594
606
  const zone = zones[at];
595
- if (this.#ordered && !(zone.from < segment.to)) break;
596
- if (!(zone.priority > 0) || !(zone.from < segment.to && zone.to > segment.from)) continue;
607
+ if (this.#ordered && !(zone.from < segment.reach)) break;
608
+ if (!(zone.priority > 0) || !(zone.from < segment.reach && zone.to > segment.from)) continue;
597
609
  const deadline = Number.isFinite(zone.deadlineAt) ? zone.deadlineAt + Math.max(0, segment.from - zone.from) * 1000 : Infinity;
598
610
  if (owner === null || deadline < ownerDeadline) { owner = zone; ownerDeadline = deadline; }
599
611
  }
@@ -338,7 +338,8 @@ export function bufferOf(viewers, output, consumerId, spanSec) {
338
338
  }
339
339
  const trend = viewer.bufferTrend(spanSec);
340
340
  const held = Number.isFinite(viewer.bufferedSeconds) ? Math.max(0, viewer.bufferedSeconds) : 0;
341
- return { bufferedSec: held, slope: trend?.slope ?? null, reportGapSec: trend?.reportGapSec ?? 0 };
341
+ return { bufferedSec: held, slope: trend?.slope ?? null, reportGapSec: trend?.reportGapSec ?? 0,
342
+ positionSec: viewer.positionSeconds() ?? null };
342
343
  }
343
344
 
344
345
  /**