@torrent-tv/proxy 2.80.5 → 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.
@@ -199,6 +199,10 @@ export class EncodeRun {
199
199
  * kept so a failure can quote what produced it.
200
200
  * @param {boolean} [params.usesExplicitCuts] - Whether this run cuts at times
201
201
  * it was given, which decides how a segment is judged finished.
202
+ * @param {(name: string) => number | null} [params.indexOfName] - The number
203
+ * a closed piece's name carries. How a piece is named belongs to the format
204
+ * that writes it, so it arrives as a plain function rather than this class
205
+ * knowing any naming.
202
206
  */
203
207
  constructor({
204
208
  address,
@@ -215,7 +219,8 @@ export class EncodeRun {
215
219
  lastSegmentIndex,
216
220
  inputUnavailable,
217
221
  argsDescribed = "",
218
- usesExplicitCuts = false
222
+ usesExplicitCuts = false,
223
+ indexOfName
219
224
  }) {
220
225
  this.address = address;
221
226
  this.encoder = encoder;
@@ -234,6 +239,7 @@ export class EncodeRun {
234
239
  this.inputUnavailable = typeof inputUnavailable === "function" ? inputUnavailable : () => false;
235
240
  this.argsDescribed = argsDescribed;
236
241
  this.usesExplicitCuts = usesExplicitCuts === true;
242
+ this.indexOfName = typeof indexOfName === "function" ? indexOfName : () => null;
237
243
  /** The last thing ffmpeg said on stderr, which is what a failure is explained by. */
238
244
  this.lastError = "";
239
245
  }
@@ -456,6 +462,18 @@ export class EncodeRun {
456
462
  if (!this.#stopping) {
457
463
  this.#provenName = name;
458
464
  }
465
+ // WHAT THIS RUN HAS MADE IS THIS RUN'S OWN FACT, and this channel is where
466
+ // it learns it. It used to be told from outside, by whoever listed the
467
+ // output directory — which every run of an output shares — so a run
468
+ // inherited every number any other run had ever left there. Field
469
+ // 2026-09-06: a run beginning at the piece for 3:39 reported "reached #20
470
+ // (483 segment(s))", having produced none of them, and its head therefore
471
+ // described somebody else's work. Both the claim it holds and the cleanup
472
+ // after it read that head.
473
+ const index = this.indexOfName(name);
474
+ if (Number.isInteger(index)) {
475
+ this.noteProduced(index);
476
+ }
459
477
  this.onClosed(name);
460
478
  }
461
479
  }
Binary file
@@ -98,6 +98,10 @@ export class SegmentStore {
98
98
  */
99
99
  #closed = new Map();
100
100
 
101
+ /** Pieces already reported as taken on the successor rule, so one is said
102
+ * once. @type {Map<string, Set<number>>} */
103
+ #unreportedSaid = new Map();
104
+
101
105
  /** @type {{ info: Function, warn: Function }} */
102
106
  #logger;
103
107
 
@@ -308,7 +312,55 @@ export class SegmentStore {
308
312
  if (this.#closed.get(key)?.has(index)) {
309
313
  return true;
310
314
  }
311
- return this.refresh(key).byNumber.has(index + 1);
315
+ const bySuccessor = this.refresh(key).byNumber.has(index + 1);
316
+ if (bySuccessor) {
317
+ this.#noteUnreported(key, index);
318
+ }
319
+ return bySuccessor;
320
+ }
321
+
322
+ /**
323
+ * A piece taken as finished because the NEXT one exists, with nothing from a
324
+ * run to say so.
325
+ *
326
+ * The successor rule is for what this process did not watch being written —
327
+ * pieces from a previous life of it. It is also the one way an unfinished
328
+ * piece can be served: a file that stops short still has a successor if
329
+ * anything wrote one, and then its name promises a whole span while it holds
330
+ * a fraction. Field 2026-09-06: 110 698 bytes served under a name whose
331
+ * neighbours are 12 MB, 40 ms of film where the playlist declared 10.4 s, and
332
+ * the player jumped the hole it left.
333
+ *
334
+ * That cannot arise from two encoders any more — their stretches no longer
335
+ * overlap — so what is left is a piece from a process that died without
336
+ * clearing up. Said once per piece, with its size, so a return of it is
337
+ * visible rather than inferred.
338
+ *
339
+ * @param {string} key
340
+ * @param {number} index
341
+ */
342
+ #noteUnreported(key, index) {
343
+ let said = this.#unreportedSaid.get(key);
344
+ if (!said) {
345
+ said = new Set();
346
+ this.#unreportedSaid.set(key, said);
347
+ }
348
+ if (said.has(index)) {
349
+ return;
350
+ }
351
+ said.add(index);
352
+ let bytes = -1;
353
+ try {
354
+ bytes = statSync(this.pathOf(key, index)).size;
355
+ } catch {
356
+ // Gone between the listing and this: nothing to report about it.
357
+ return;
358
+ }
359
+ this.#logger?.info?.(
360
+ `segment store: #${index} of ${key.slice(0, 60)} is taken as finished because ` +
361
+ `#${index + 1} exists — no run reported it (${bytes} bytes). Expected only for ` +
362
+ "pieces left by a previous life of this process."
363
+ );
312
364
  }
313
365
 
314
366
  /**
@@ -65,7 +65,28 @@ export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeU
65
65
  // belong to a run that is still going, and removing it would take away a
66
66
  // piece somebody is producing.
67
67
  const from = Number.isInteger(within?.from) ? within.from : 0;
68
- const to = Number.isInteger(within?.to) && within.to >= from ? within.to : Number.MAX_SAFE_INTEGER;
68
+ // THE RUN'S OWN REACH, which is a fact it holds and needs nothing measured.
69
+ //
70
+ // A run cannot have opened a file above the one just past the last it named:
71
+ // ffmpeg names a piece when it closes it and opens the next, so the open piece
72
+ // is at most `proven + 1`, and where it named nothing at all the open one is
73
+ // the first it was given.
74
+ //
75
+ // Used only where no end was declared — `to` below `from`, which is how "to
76
+ // the end of the track" is written everywhere here. Such a run had no bound at
77
+ // all: the search covered the whole directory and took the highest-numbered
78
+ // file in it. Every run of an output writes into that one
79
+ // directory, so what it took was a piece a LIVE run had just finished. Field
80
+ // 2026-09-06: the piece holding 2:47-2:57 went that way, its number is spent
81
+ // for good because names only grow, and the picture stood still for 647 s.
82
+ const provenIndex = typeof provenName === "string" && segmentFormat.isSegmentFileName(provenName)
83
+ ? segmentFormat.segmentIndexFromName(provenName)
84
+ : null;
85
+ const reach = Number.isInteger(provenIndex) && provenIndex >= from ? provenIndex + 1 : from;
86
+ // The declared end is the bound wherever there is one. The run's own reach is
87
+ // the bound of LAST RESORT, for a run given none: before it, such a run had no
88
+ // bound at all and the search covered the whole directory.
89
+ const to = Number.isInteger(within?.to) && within.to >= from ? within.to : reach;
69
90
  let highest = null;
70
91
  try {
71
92
  for (const name of await readdir(runDirPath)) {
@@ -208,7 +208,17 @@ export function nearestKeyframeAtOrBefore(keyframeTimes, target) {
208
208
  export function seekLandingOffsetFor(material, keyframe) {
209
209
  // A re-encode trims to the requested time itself, so it needs no help and
210
210
  // must not be pushed past what it was asked for.
211
- if (material?.transcodeVideo === true) {
211
+ //
212
+ // AN OUTPUT CARRYING ONLY SOUND IS SUCH A RE-ENCODE, and asking about the
213
+ // picture missed it: the flag says whether the PICTURE is re-encoded, and an
214
+ // output with no picture answers no. So the whole offset was added to the
215
+ // soundtrack's own seek, and the sound then played 130 ms ahead of the picture
216
+ // from every restart onward — reported by the viewer 2026-09-06 as lips out of
217
+ // step with the voice from two minutes in, and measured: both outputs were
218
+ // repositioned to the same boundary and both given `-ss 166.963435`, after
219
+ // which the copied picture landed on the keyframe at 166.833 while the
220
+ // re-encoded sound began where it was asked.
221
+ if (material?.transcodeVideo === true || material?.audioOnly === true) {
212
222
  return 0;
213
223
  }
214
224
  // A grid whose times are approximate needs that error added on top, or a name
@@ -504,7 +514,7 @@ export function buildRunCommand({
504
514
  if (snappedKeyframe !== null) {
505
515
  const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
506
516
  if (snappedKeyframe > 0) {
507
- args.push("-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ transcodeVideo, file }, snappedKeyframe)));
517
+ args.push("-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ audioOnly, transcodeVideo, file }, snappedKeyframe)));
508
518
  }
509
519
  args.push("-i", inputUrl);
510
520
  // The coarse landing, not the exact target: the residual below is discarded
@@ -23,7 +23,6 @@ import { speedFromReadings } from "./encoder-readings.js";
23
23
  import { availableShareFrom } from "./available-share.js";
24
24
  import { contentionPenalty } from "./contention.js";
25
25
  import { minimumBufferFrom } from "./supply-margin.js";
26
- import { mapForViewer } from "./priority/PriorityMap.js";
27
26
  import { PriorityOrchestrator } from "./priority/PriorityOrchestrator.js";
28
27
  import { baseDrawFrom, costPerMegabyteFrom } from "./torrent-cost.js";
29
28
  import { medianOf, movedBeyondScatter, scatterOf } from "./learned-median.js";
@@ -556,12 +555,6 @@ const SEGMENT_STORE_IDLE_MS = 6 * 60 * 60 * 1000;
556
555
  const SEGMENT_STORE_FREE_SHARE = 0.25;
557
556
  /** What the store may hold where the free space cannot be read at all. */
558
557
  const SEGMENT_STORE_FALLBACK_BYTES = 2 * 1024 * 1024 * 1024;
559
- /**
560
- * What it costs to stop an encoder and start another where the material is
561
- * missing. Measured on the addon host 2026-09-04: a spawn with its input open
562
- * is 0.12 s there, 0.5-0.6 s on a developer's desktop.
563
- */
564
- const RUN_RESTART_COST_SEC = 0.12;
565
558
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
566
559
  // Realtime budget — runtime downswitch (software encoder only). Periodically
567
560
  // check each active software-transcode session's ffmpeg `speed`; when it stays
@@ -1475,6 +1468,7 @@ export class HlsSessionManager {
1475
1468
  // could not be measured, and then nothing is corrected — the alternative
1476
1469
  // is inventing a penalty, which is the same fault as inventing a fill rate.
1477
1470
  contentionPenalties = null,
1471
+ copySpeedX = null,
1478
1472
  tonemapSupported = false,
1479
1473
  getCachedMediaInfo = null,
1480
1474
  getCachedAudioTracks = null,
@@ -1529,6 +1523,10 @@ export class HlsSessionManager {
1529
1523
  this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
1530
1524
  this.setPriorityMap = typeof setPriorityMap === "function" ? setPriorityMap : null;
1531
1525
  this.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
1526
+ // Seconds of film per second when the picture is COPIED, measured at
1527
+ // startup. Nothing else prices that branch: the other benchmarks measure
1528
+ // encoding and decoding, and a copy does neither.
1529
+ this.copySpeedX = Number.isFinite(copySpeedX) && copySpeedX > 0 ? copySpeedX : null;
1532
1530
  // Totals across every torrent this proxy holds, used to price what the
1533
1531
  // torrent itself costs the machine (item 7). Optional: a proxy wired
1534
1532
  // without it simply never learns that figure.
@@ -1612,6 +1610,7 @@ export class HlsSessionManager {
1612
1610
  benchmark: this.softwarePresetBenchmark,
1613
1611
  decodeModel: this.decodeCostModel,
1614
1612
  contentionPenalties: this.contentionPenalties,
1613
+ copySpeedX: this.copySpeedX,
1615
1614
  availability: this.hostAvailability
1616
1615
  }),
1617
1616
  audioCostKey: (session) => this.#audioCostKey(session),
@@ -1638,7 +1637,8 @@ export class HlsSessionManager {
1638
1637
  maxRunsFor: (address) => this.maxRunsForOutput(address),
1639
1638
  makeRun: ({ address, from, to }) => this.#makeRunAt(address, from, to),
1640
1639
  segmentSeconds: this.segmentDurationSec,
1641
- restartCostSec: RUN_RESTART_COST_SEC,
1640
+ contentionPenalties: this.contentionPenalties,
1641
+ startingSpeedFor: (address) => this.encodeCost.speedForOutput(address),
1642
1642
  segmentStore: this.segmentStore,
1643
1643
  logger
1644
1644
  });
@@ -3659,63 +3659,6 @@ export class HlsSessionManager {
3659
3659
  return readers;
3660
3660
  }
3661
3661
 
3662
- /**
3663
- * One viewer's demand map, translated into this output's segment numbers.
3664
- *
3665
- * The map itself is seconds of film and knows nothing about cut grids
3666
- * (`services/priority/PriorityMap.js`). The translation is this output's own
3667
- * business, and it is exact: the timeline holds the boundaries.
3668
- *
3669
- * Two measurements feed it, and neither is chosen here:
3670
- *
3671
- * 1. the allowance below which an interruption reaches the viewer, from this
3672
- * file's own recent interruptions (`minimumBufferFrom`). Null until the
3673
- * reader has seen two of them, and then only the segment itself counts;
3674
- * 2. how fast this machine encodes THIS track, from ffmpeg's own progress.
3675
- * Below realtime it decides how much has to exist before playback starts;
3676
- * above it, nothing beyond the allowance is needed.
3677
- *
3678
- * @param {object} session
3679
- * @param {number} atSegment - Where the viewer is.
3680
- * @returns {{from: number, to: number, priority: number}[]} In segment
3681
- * numbers, both ends inclusive.
3682
- */
3683
- #demandZonesFor(session, atSeconds, playing = true) {
3684
- const boundaries = session.timeline?.boundaries ?? [];
3685
- const segmentCount = Number(session.timeline?.segmentCount) || 0;
3686
- // Where they are, in this output's own numbering. The viewer holds seconds;
3687
- // every cut grid turns them into its own numbers, and two grids of one film
3688
- // give different numbers for the same second.
3689
- const atSegment = segmentCount > 0 ? this.#segmentIndexForTime(session, atSeconds) : 0;
3690
- if (segmentCount <= 0) {
3691
- // No playlist yet: the only thing that can be said is that they want
3692
- // where they are.
3693
- return [{ from: atSegment, to: atSegment, priority: 3 }];
3694
- }
3695
- const durationSeconds = Number(boundaries[boundaries.length - 1]) ||
3696
- segmentCount * this.segmentDurationSec;
3697
- const zones = mapForViewer({
3698
- atSeconds,
3699
- durationSeconds,
3700
- allowanceSeconds: minimumBufferFrom({
3701
- segmentSeconds: this.segmentDurationSec,
3702
- worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
3703
- })?.seconds ?? this.segmentDurationSec,
3704
- playing
3705
- });
3706
- /** @type {{from: number, to: number, priority: number}[]} */
3707
- const inSegments = [];
3708
- for (const zone of zones) {
3709
- const from = Math.max(atSegment, this.#segmentIndexForTime(session, zone.from));
3710
- const to = Math.min(segmentCount - 1, this.#segmentIndexForTime(session, zone.to));
3711
- if (to >= from) {
3712
- inSegments.push({ from, to, priority: zone.priority });
3713
- }
3714
- }
3715
- return inSegments.length > 0
3716
- ? inSegments
3717
- : [{ from: atSegment, to: atSegment, priority: 3 }];
3718
- }
3719
3662
 
3720
3663
  /**
3721
3664
  * Say what the cushion is, for every session.
@@ -3871,62 +3814,35 @@ export class HlsSessionManager {
3871
3814
  }
3872
3815
  coverage.markReadyAll(this.segmentStore.provenNumbers(address));
3873
3816
  for (const session of sessions) {
3874
- for (const run of this.#runsOfSession(session)) {
3817
+ for (const run of liveRunsOf(session)) {
3875
3818
  this.encodeOrchestrator.adopt(address, run);
3876
3819
  }
3877
- // What the viewers of this session are waiting for, as spans. A viewer
3878
- // wants the segment they are at and the cushion in front of it, which
3879
- // is what the encoder is steered by everywhere else in this class.
3880
- //
3881
- // PRESENCE AND POSITION ARE ASKED SEPARATELY, and that is the whole of
3882
- // the 2026-09-05 fix. Presence decides whether this viewer states a
3883
- // want at all; position decides what the want is. Asked as one question
3884
- // — which is what a single field written only by segment requests
3885
- // amounted to — a viewer who had just arrived answered "absent", every
3886
- // encoder on their output was stopped for having nobody, and the
3887
- // `init.mp4` they were waiting for in order to request their first
3888
- // segment was therefore never made.
3889
- for (const [consumerId, viewer] of viewersOf(session)) {
3890
- if (!viewer.isPresent(now, staleAfterMs)) {
3891
- this.encodeOrchestrator.release(`${session.id}:${consumerId}`);
3892
- continue;
3893
- }
3894
- // Placed when they arrived, from the position their own request
3895
- // named. A viewer with no position is one assembled by hand outside
3896
- // this class; they want the beginning, which is where an output
3897
- // starts when nobody says otherwise.
3898
- // SECONDS, and turned into this output's own numbering below. A
3899
- // segment number taken off the viewer would mean two different
3900
- // moments of film on the picture and on the soundtrack, which are cut
3901
- // independently: 454 pieces against 401 on the field file.
3902
- const at = viewer.positionSeconds() ?? 0;
3903
- // Their own map, in seconds of film, from measurements: how much must
3904
- // be ready before they set off so that they never stop — the observed
3905
- // allowance for this file plus what an encoder at THIS machine's
3906
- // measured speed will fail to deliver in time — then what it reaches
3907
- // while they watch that, then the rest of the track. No constant is
3908
- // consulted: the 120 seconds that used to size this window were the
3909
- // suspended encoder's threshold, one chosen number answering seven
3910
- // different questions.
3911
- for (const zone of this.#demandZonesFor(session, at, viewer.playing !== false)) {
3912
- this.encodeOrchestrator.want({
3913
- // The claimant is the PERSON, without the priority in it: their
3914
- // zones are separate windows, but they leave together, and
3915
- // `release` matches on this name.
3916
- claimant: `${session.id}:${consumerId}`,
3917
- address,
3918
- from: zone.from,
3919
- to: zone.to,
3920
- priority: zone.priority
3921
- });
3922
- }
3923
- }
3924
3820
  }
3925
3821
  }
3822
+ // THE MAP IS BUILT ONCE, IN ITS OWN LAYER, AND BOTH SIDES READ THAT ONE.
3823
+ //
3824
+ // It used to be built twice: once here, per viewer, converted and stated to
3825
+ // the encoding as a window each, and once again inside `publishFor` for the
3826
+ // downloading. Two answers to one question, and the encoding's copy carried
3827
+ // the viewer's NAME as the key of a claim — against the rule that the
3828
+ // encoding and the viewer are not connected at all.
3926
3829
  this.priority.publishFor({
3927
3830
  sessionGroups: byOutput.values(),
3928
3831
  staleAfterMs: this.presenceStaleAfterMs()
3929
3832
  });
3833
+ // Converted into each output's own numbering, because two outputs of one
3834
+ // film are cut independently and the same second is a different number in
3835
+ // each: 454 pieces against 401 on the field file.
3836
+ for (const [address, sessions] of byOutput) {
3837
+ const timeline = sessions[0].timeline;
3838
+ this.encodeOrchestrator.notePriorityMap(
3839
+ address,
3840
+ timeline?.inSegments?.(
3841
+ this.priority.mapFor(sessions[0].sourceKey, sessions[0].fileIndex),
3842
+ Number(timeline?.segmentCount) || 0
3843
+ ) ?? []
3844
+ );
3845
+ }
3930
3846
  this.encodeOrchestrator.reconcile();
3931
3847
  }
3932
3848
 
@@ -3988,34 +3904,6 @@ export class HlsSessionManager {
3988
3904
  }
3989
3905
  }
3990
3906
 
3991
- /**
3992
- * This session's run, told what the disk holds before it is asked where it
3993
- * has got to.
3994
- *
3995
- * A run learns of its own segments as they are SERVED, which is not when they
3996
- * are made — a viewer two minutes behind the encoder has asked for none of
3997
- * what is in front of them. The store knows, so the run is told, and its head
3998
- * is then the same figure the look-ahead and the plan have always used.
3999
- *
4000
- * @param {HlsSession} session
4001
- * @returns {import("./encode/EncodeRun.js").EncodeRun[]}
4002
- */
4003
- #runsOfSession(session) {
4004
- const runs = liveRunsOf(session);
4005
- if (runs.length === 0) {
4006
- return runs;
4007
- }
4008
- const produced = this.producedSegmentNumbers(session);
4009
- for (const run of runs) {
4010
- for (const index of produced) {
4011
- if (index >= run.from) {
4012
- run.noteProduced(index);
4013
- }
4014
- }
4015
- }
4016
- return runs;
4017
- }
4018
-
4019
3907
  /**
4020
3908
  * How many encoders this machine can afford on one output at once.
4021
3909
  *
@@ -4031,19 +3919,10 @@ export class HlsSessionManager {
4031
3919
  * @returns {number}
4032
3920
  */
4033
3921
  maxRunsForOutput(address) {
4034
- let alone = 0;
4035
- for (const session of this.sessionsById.values()) {
4036
- if (session.outputKey !== address || session.state === "disposed") {
4037
- continue;
4038
- }
4039
- const measured = Number(session.recentSpeed?.speed);
4040
- if (Number.isFinite(measured) && measured > alone) {
4041
- alone = measured;
4042
- }
4043
- }
3922
+ const alone = this.encodeCost.speedForOutput(address);
4044
3923
  if (!(alone > 0)) {
4045
- // Nothing measured on this output yet. One encoder is what it has, and
4046
- // what it has is what it keeps until there is a reading to argue with.
3924
+ // The host measured nothing at all, which is a broken startup rather than
3925
+ // a state to plan around. One encoder is what it keeps.
4047
3926
  return 1;
4048
3927
  }
4049
3928
  let affordable = 1;
@@ -5716,6 +5595,7 @@ export class HlsSessionManager {
5716
5595
  session.timeline?.segmentCount > 0 ? session.timeline.segmentCount - 1 : null,
5717
5596
  inputUnavailable: (message) => isInputUnavailable(message),
5718
5597
  onProgress: (report) => this.#noteRunProgress(session, run, report),
5598
+ indexOfName: (name) => session.segmentFormat.segmentIndexFromName(name),
5719
5599
  onClosed: (name) => this.segmentStore.markClosed(session.outputKey ?? "", session.segmentFormat.segmentIndexFromName(name)),
5720
5600
  onEnded: (ended) => this.noteRunEnded(session, run, ended)
5721
5601
  });
@@ -7070,11 +6950,11 @@ export class HlsSessionManager {
7070
6950
  if (!output) {
7071
6951
  return false;
7072
6952
  }
7073
- const left = this.viewers.leaves(output, consumerId);
7074
- if (left) {
7075
- this.encodeOrchestrator.release(`${output.id}:${consumerId}`);
7076
- }
7077
- return left;
6953
+ // Nothing to release in the encoding: it holds one map per output, built
6954
+ // from where the viewers are, and the map that arrives next simply does not
6955
+ // have this one in it. A name to release was the last place a viewer
6956
+ // appeared inside the encoding at all.
6957
+ return this.viewers.leaves(output, consumerId);
7078
6958
  }
7079
6959
 
7080
6960
  /**