@torrent-tv/proxy 2.77.0 → 2.79.0

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.
@@ -96,6 +96,28 @@ export class EncodeRun {
96
96
  /** @type {number} */
97
97
  #startedAt = 0;
98
98
 
99
+ /** Half a name left over from the last chunk of the encoder's own channel. */
100
+ #closedTail = "";
101
+
102
+ /**
103
+ * When this run was told to stop, so the death itself can be priced.
104
+ *
105
+ * The plan weighs moving an encoder against letting it drive on, and dying is
106
+ * one of the terms. It was measured in the field at 430-729 ms — larger than
107
+ * the start it is added to — by a log line that lived in the one place that
108
+ * killed a run. That place is gone, so the run times its own death.
109
+ */
110
+ #stopOrderedAt = 0;
111
+
112
+ /**
113
+ * When the first thing this run ever produced appeared.
114
+ *
115
+ * The other term the plan needs: a run started where nothing is downloaded
116
+ * waits for the swarm before it can encode a frame, and that wait is the
117
+ * largest part of what a restart costs. Nothing measured it before.
118
+ */
119
+ #firstOutputAt = 0;
120
+
99
121
  /** @type {number} */
100
122
  #speedX = 0;
101
123
 
@@ -132,6 +154,9 @@ export class EncodeRun {
132
154
  * @param {{ info: (line: string) => void, warn: (line: string) => void, error?: (line: string) => void }} params.logger
133
155
  * @param {() => number} [params.now]
134
156
  * @param {(ended: RunEnded) => void} [params.onEnded]
157
+ * @param {(name: string) => void} [params.onClosed] - Called with the file name
158
+ * of every piece the encoder has FINISHED writing, as the encoder itself
159
+ * names it on its own channel.
135
160
  * @param {(progress: { processedSeconds: number | null, speed: string | null }) => void} [params.onProgress]
136
161
  * Called for every `-progress` report. Seconds count from the START OF THIS
137
162
  * RUN on both branches — neither `-output_ts_offset` nor `-copyts` changes
@@ -158,6 +183,7 @@ export class EncodeRun {
158
183
  logger,
159
184
  now,
160
185
  onEnded,
186
+ onClosed,
161
187
  onProgress,
162
188
  lastSegmentIndex,
163
189
  inputUnavailable,
@@ -174,6 +200,9 @@ export class EncodeRun {
174
200
  this.now = typeof now === "function" ? now : Date.now;
175
201
  this.onEnded = typeof onEnded === "function" ? onEnded : () => {};
176
202
  this.onProgress = typeof onProgress === "function" ? onProgress : () => {};
203
+ // Told the NAME of every piece the encoder has closed. The name is the
204
+ // proof it is whole; nothing else here can prove that.
205
+ this.onClosed = typeof onClosed === "function" ? onClosed : () => {};
177
206
  this.lastSegmentIndex = typeof lastSegmentIndex === "function" ? lastSegmentIndex : () => null;
178
207
  this.inputUnavailable = typeof inputUnavailable === "function" ? inputUnavailable : () => false;
179
208
  this.argsDescribed = argsDescribed;
@@ -304,6 +333,16 @@ export class EncodeRun {
304
333
  */
305
334
  #wire(process) {
306
335
  process.stdout?.on("data", (chunk) => this.#readProgress(String(chunk)));
336
+ // THE CHANNEL THE ENCODER NAMES ITS FINISHED PIECES ON.
337
+ //
338
+ // A name arrives here when ffmpeg CLOSES the file, so the name is proof the
339
+ // piece is whole — measured on the addon host 2026-09-05. Nothing else can
340
+ // prove it: a file on disk may still be being written, and the only other
341
+ // evidence available was the existence of the NEXT file, which never comes
342
+ // for the last piece of a run.
343
+ //
344
+ // Lines can arrive split, so what is left over is kept for the next chunk.
345
+ process.stdio?.[3]?.on("data", (chunk) => this.#readClosedPieces(String(chunk)));
307
346
  process.stderr?.on("data", (chunk) => {
308
347
  const line = String(chunk).trim();
309
348
  if (line.length > 0) {
@@ -358,6 +397,9 @@ export class EncodeRun {
358
397
  noteProduced(index) {
359
398
  if (Number.isInteger(index) && index >= this.from) {
360
399
  this.#produced.add(index);
400
+ if (this.#firstOutputAt === 0) {
401
+ this.#firstOutputAt = this.now();
402
+ }
361
403
  if (this.#state === ENCODE_RUN_STATE.STARTING) {
362
404
  this.#transition(ENCODE_RUN_EVENT.FIRST_SEGMENT);
363
405
  }
@@ -369,6 +411,25 @@ export class EncodeRun {
369
411
  *
370
412
  * @param {number} speedX - Times realtime.
371
413
  */
414
+ /**
415
+ * Names of finished pieces, as the encoder writes them.
416
+ *
417
+ * @param {string} text
418
+ */
419
+ #readClosedPieces(text) {
420
+ this.#closedTail += text;
421
+ const lines = this.#closedTail.split(/\r?\n/);
422
+ // The last piece of the chunk may be half a name; it waits for the rest.
423
+ this.#closedTail = lines.pop() ?? "";
424
+ for (const line of lines) {
425
+ const name = line.trim();
426
+ if (name.length === 0) {
427
+ continue;
428
+ }
429
+ this.onClosed(name);
430
+ }
431
+ }
432
+
372
433
  noteSpeed(speedX) {
373
434
  if (Number.isFinite(speedX) && speedX > 0) {
374
435
  this.#speedX = speedX;
@@ -389,6 +450,7 @@ export class EncodeRun {
389
450
  }
390
451
  this.#stopping = true;
391
452
  this.#stopReason = because;
453
+ this.#stopOrderedAt = this.now();
392
454
  this.#transition(ENCODE_RUN_EVENT.STOP_ORDERED);
393
455
  // A suspended process does not act on SIGTERM until it is continued, so the
394
456
  // wait for its exit would never end. Let it run before asking it to stop.
@@ -554,6 +616,14 @@ export class EncodeRun {
554
616
  to: this.to,
555
617
  reached: this.reached,
556
618
  livedMs,
619
+ // How long dying took, and how long the first output took to appear.
620
+ // Null where the run was never told to stop, or never produced anything:
621
+ // an absent measurement says so rather than reading as zero.
622
+ dyingMs: this.#stopOrderedAt > 0 ? this.now() - this.#stopOrderedAt : null,
623
+ firstOutputMs:
624
+ this.#firstOutputAt > 0 && this.#startedAt > 0
625
+ ? this.#firstOutputAt - this.#startedAt
626
+ : null,
557
627
  normal: ending === ENCODE_EXIT.COMPLETE,
558
628
  lastError: this.lastError
559
629
  };
Binary file
@@ -39,6 +39,8 @@ import { rmSync } from "node:fs";
39
39
  import os from "node:os";
40
40
  import path from "node:path";
41
41
 
42
+ import { discardOpenPiece } from "./open-piece.js";
43
+
42
44
  /** Where every output's segments live. One root for the process. */
43
45
  export const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
44
46
 
@@ -256,6 +258,30 @@ export class SegmentStore {
256
258
  return proven.sort((left, right) => left - right);
257
259
  }
258
260
 
261
+ /**
262
+ * Whether this piece is finished, and may therefore be served.
263
+ *
264
+ * Two proofs, and the first is the good one:
265
+ *
266
+ * 1. **the encoder said so** — it names each file on a channel of its own the
267
+ * moment it closes it, so the name is the writer's own statement that the
268
+ * piece is whole;
269
+ * 2. **the next file exists** — which only proves it for pieces this process
270
+ * did not watch being written, left by an earlier life of it. It is not
271
+ * true of the last piece of any run, and that is what used to hold the
272
+ * first segment of every run from the viewer.
273
+ *
274
+ * @param {string} key
275
+ * @param {number} index
276
+ * @returns {boolean}
277
+ */
278
+ isClosed(key, index) {
279
+ if (this.#closed.get(key)?.has(index)) {
280
+ return true;
281
+ }
282
+ return this.refresh(key).byNumber.has(index + 1);
283
+ }
284
+
259
285
  /**
260
286
  * Say that a segment is closed for a reason the disk cannot show.
261
287
  *
@@ -277,6 +303,39 @@ export class SegmentStore {
277
303
  known.add(index);
278
304
  }
279
305
 
306
+ /**
307
+ * Throw away the piece a run had open when it ended, if it is unusable.
308
+ *
309
+ * The store owns this output's directory and knows how its files are named,
310
+ * so it is the one place that can answer which file a run left open. The
311
+ * judging of a NON-EMPTY file — does it carry every track it should — needs
312
+ * the output's init bytes and belongs to whoever holds them; passed in, and
313
+ * absent it only an empty file is removed, which is the case that caused this
314
+ * to be written (a run stopped 548 ms after starting left a zero-byte file
315
+ * whose name then read as a segment made).
316
+ *
317
+ * @param {string} key
318
+ * @param {{ from: number, to: number } | null} within - The run's own
319
+ * numbers: several runs write into one directory, so the piece to discard
320
+ * has to be looked for inside the stretch the ended run was given.
321
+ * @param {((raw: Buffer) => boolean) | null} [judgeUsable]
322
+ * @returns {Promise<number | null>} The segment number removed, or null.
323
+ */
324
+ async discardOpenPieceOf(key, within, judgeUsable = null) {
325
+ const format = this.#formats.get(key);
326
+ if (!format) {
327
+ return null;
328
+ }
329
+ const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable);
330
+ if (removed !== null) {
331
+ this.#held.delete(key);
332
+ this.#logger?.info?.(
333
+ `segment store: discarded the open piece #${removed} of ${key.slice(0, 60)}`
334
+ );
335
+ }
336
+ return removed;
337
+ }
338
+
280
339
  /**
281
340
  * The one number in this output whose closure nothing on disk proves.
282
341
  *
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @file The piece a run had open when it ended.
3
+ *
4
+ * A fact of a run's output directory, and therefore of the encoding layer. It
5
+ * lived in the eleven-thousand-line file that is being taken apart, where it was
6
+ * called by the one place that killed a run; stopping is decided in one place
7
+ * now and carried out in another, so the cleanup belongs to the layer that owns
8
+ * the directories rather than to whoever happened to do the killing.
9
+ */
10
+
11
+ import { readdir, readFile, stat, unlink } from "node:fs/promises";
12
+ import path from "node:path";
13
+
14
+ /**
15
+ * Remove the piece a run had open when it ended, if that piece is unusable.
16
+ *
17
+ * The `segment` muxer creates its output file when it OPENS it and writes into
18
+ * it until the next cut, so at any instant exactly one file in a run's
19
+ * directory is unfinished: the highest-numbered one. A run that reaches the end
20
+ * of its work closes that file and it is a good piece; a run killed for a seek
21
+ * does not — measured 2026-09-03, ffmpeg exited 19 ms after SIGTERM and left
22
+ * `segment-00025.mp4` at zero bytes, which then closed the only hole in the
23
+ * numbering and convinced the look-ahead to keep the encoder stopped for having
24
+ * "produced" it.
25
+ *
26
+ * Only an unusable piece goes. A run stopped between two cuts leaves a finished
27
+ * file behind, and deleting good output would mean making it a second time.
28
+ *
29
+ * @param {string | null | undefined} runDirPath
30
+ * @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} segmentFormat
31
+ * @param {((raw: Buffer) => boolean) | null} judgeUsable - Whether a non-empty
32
+ * piece carries what it should. Null where nothing can say, and then only an
33
+ * empty file is removed.
34
+ * @returns {Promise<number | null>} The segment number removed, or null.
35
+ */
36
+ export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeUsable) {
37
+ if (!runDirPath || typeof segmentFormat?.isSegmentFileName !== "function") {
38
+ return null;
39
+ }
40
+ // Only inside the stretch the ended run was given. Every run of an output
41
+ // writes into one directory now — they are kept apart by their intervals
42
+ // rather than by a directory each — so the highest-numbered file in there may
43
+ // belong to a run that is still going, and removing it would take away a
44
+ // piece somebody is producing.
45
+ const from = Number.isInteger(within?.from) ? within.from : 0;
46
+ const to = Number.isInteger(within?.to) && within.to >= from ? within.to : Number.MAX_SAFE_INTEGER;
47
+ let highest = null;
48
+ try {
49
+ for (const name of await readdir(runDirPath)) {
50
+ if (!segmentFormat.isSegmentFileName(name)) {
51
+ continue;
52
+ }
53
+ const index = segmentFormat.segmentIndexFromName(name);
54
+ if (index < from || index > to) {
55
+ continue;
56
+ }
57
+ if (index >= 0 && (highest === null || index > highest.index)) {
58
+ highest = { index, name };
59
+ }
60
+ }
61
+ } catch {
62
+ return null; // The run wrote nothing, or its directory is already gone.
63
+ }
64
+ if (highest === null) {
65
+ return null;
66
+ }
67
+ const filePath = path.join(runDirPath, highest.name);
68
+ let unusable = false;
69
+ try {
70
+ const info = await stat(filePath);
71
+ if (info.size === 0) {
72
+ unusable = true;
73
+ } else if (typeof judgeUsable === "function") {
74
+ unusable = !judgeUsable(await readFile(filePath));
75
+ }
76
+ } catch {
77
+ return null; // Gone between the listing and the question.
78
+ }
79
+ if (!unusable) {
80
+ return null;
81
+ }
82
+ try {
83
+ await unlink(filePath);
84
+ return highest.index;
85
+ } catch {
86
+ return null; // Already removed.
87
+ }
88
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @file How many encoders this machine can afford on one output.
3
+ *
4
+ * Not how many are wanted — that is the demand map's answer — but how many can
5
+ * run at once without making things worse. It is the SMALLEST of several
6
+ * limits, and every one of them is measured:
7
+ *
8
+ * 1. **the processor.** A second encoder slows the first: measured on the addon
9
+ * host at 1.70x for two at 854x480 and 1.98x at 1920x1080. The ladder is
10
+ * walked while the measured penalty still leaves every encoder above
11
+ * realtime, and it stops where the measurements stop rather than continuing
12
+ * a curve two points cannot describe;
13
+ * 2. **the swarm.** Every encoder reads the same torrent, so together they
14
+ * cannot consume faster than it is delivered. On the field file of
15
+ * 2026-09-05 a second of film weighs 842 KB and the swarm gave 2119-3347
16
+ * KB/s, so the encoders' speeds together may reach 2.5-4x — which is why a
17
+ * copy the processor would run at 8x runs at four, and why a third encoder
18
+ * makes all three slower rather than adding anything;
19
+ * 3. **memory for the torrent's pieces.** Encoders placed far apart hold
20
+ * windows that do not overlap, so the store must hold their SUM. On the
21
+ * addon host the store was already full on the readers of one viewer — "6
22
+ * readers want 14 pieces of 14 the store may hold" — so this is the limit
23
+ * that binds first there, not the processor.
24
+ *
25
+ * Which of them bound the answer is returned beside it, because "why is there
26
+ * only one encoder" is otherwise a question no log can answer.
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} RunBudget
31
+ * @property {number} runs - How many encoders may run on this output.
32
+ * @property {string} because - Which limit decided it.
33
+ */
34
+
35
+ /**
36
+ * @param {object} params
37
+ * @param {number} params.byProcessor - What the processor allows, from the
38
+ * measured speed and the measured penalty for concurrency. At least one.
39
+ * @param {number} [params.speedX] - The measured speed of one encoder here.
40
+ * @param {number} [params.refetchSecPerFilmSecond] - Seconds of swarm time per
41
+ * second of film: the film's own byte rate over what the swarm delivers. One
42
+ * encoder at speed `s` therefore consumes `s * refetch` of the swarm, and
43
+ * what they may consume together is all of it. Absent where neither rate has
44
+ * been measured, and then the swarm does not bound the answer.
45
+ * @param {number} [params.storeBytes] - What the piece store may hold.
46
+ * @param {number} [params.readerWindowBytes] - What one encoder's reader keeps.
47
+ * @returns {RunBudget}
48
+ */
49
+ export function affordableRuns({
50
+ byProcessor,
51
+ speedX,
52
+ refetchSecPerFilmSecond,
53
+ storeBytes,
54
+ readerWindowBytes
55
+ }) {
56
+ let runs = Number.isFinite(byProcessor) && byProcessor > 0 ? Math.floor(byProcessor) : 1;
57
+ let because = "the processor";
58
+
59
+ // The swarm. One encoder at speed `s` takes `s * refetch` of what is
60
+ // delivered, and everything running takes the sum, which cannot pass one.
61
+ if (
62
+ Number.isFinite(refetchSecPerFilmSecond) &&
63
+ refetchSecPerFilmSecond > 0 &&
64
+ Number.isFinite(speedX) &&
65
+ speedX > 0
66
+ ) {
67
+ const bySwarm = Math.max(1, Math.floor(1 / (speedX * refetchSecPerFilmSecond)));
68
+ if (bySwarm < runs) {
69
+ runs = bySwarm;
70
+ because = "what the swarm delivers";
71
+ }
72
+ }
73
+
74
+ // Memory: encoders far apart hold windows that do not overlap.
75
+ if (
76
+ Number.isFinite(storeBytes) &&
77
+ storeBytes > 0 &&
78
+ Number.isFinite(readerWindowBytes) &&
79
+ readerWindowBytes > 0
80
+ ) {
81
+ const byMemory = Math.max(1, Math.floor(storeBytes / readerWindowBytes));
82
+ if (byMemory < runs) {
83
+ runs = byMemory;
84
+ because = "memory for the torrent's pieces";
85
+ }
86
+ }
87
+
88
+ return { runs: Math.max(1, runs), because };
89
+ }
@@ -620,6 +620,21 @@ export function buildRunCommand({
620
620
  "0.05",
621
621
  "-segment_start_number",
622
622
  String(safeIndex),
623
+ // THE ENCODER SAYS WHEN A PIECE IS FINISHED, on a channel of its own.
624
+ //
625
+ // Measured on the addon host 2026-09-05: a name appears in this list when
626
+ // the file is CLOSED, not when it is created — at the third sample
627
+ // `seg-000.mp4` was on disk and absent from the list, and it appeared at
628
+ // the fourth, in the same moment `seg-001.mp4` came into being. So a name
629
+ // here is the writer's own statement that the piece is whole.
630
+ //
631
+ // Without it, a finished file is indistinguishable from one still being
632
+ // written, and the only proof available was the existence of the NEXT
633
+ // one — which never comes for the last piece of every run.
634
+ "-segment_list",
635
+ "pipe:3",
636
+ "-segment_list_flags",
637
+ "+live",
623
638
  ...explicitTimes,
624
639
  segmentFormat.segmentFileNameTemplate()
625
640
  );
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @file What starting and stopping an encoder costs on THIS host.
3
+ *
4
+ * Two figures, and both are terms in the one decision anybody makes about a
5
+ * running encoder: let it drive on through material that already exists, or
6
+ * stop it and start another where the material is missing.
7
+ *
8
+ * Before this file neither was measured here. The start was a single reading
9
+ * taken once on one machine and written into the code as a constant; the stop
10
+ * and the wait for the first output were not counted at all, so the comparison
11
+ * priced only one side of itself and always answered the same way.
12
+ *
13
+ * Readings are kept and read as their median: one starved start must not decide
14
+ * the rule, and a host that has since become busy must be able to change the
15
+ * answer.
16
+ */
17
+
18
+ /**
19
+ * How many recent readings a figure is taken from. The same reasoning as the
20
+ * other learned figures in this proxy: long enough that one reading does not
21
+ * move the answer, short enough that the answer still follows the host.
22
+ */
23
+ const RECENT_READINGS = 20;
24
+
25
+ /**
26
+ * The middle of a set of readings, or null when there are none.
27
+ *
28
+ * Written here rather than borrowed from the general helper one level up: this
29
+ * layer states facts and imports nothing above itself, and four lines of
30
+ * arithmetic are not worth breaking that for.
31
+ *
32
+ * @param {number[]} values
33
+ * @returns {number | null}
34
+ */
35
+ function middleOf(values) {
36
+ if (values.length === 0) {
37
+ return null;
38
+ }
39
+ const sorted = [...values].sort((left, right) => left - right);
40
+ const middle = Math.floor(sorted.length / 2);
41
+ return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
42
+ }
43
+
44
+ export class RunCosts {
45
+ /** How long dying took, in milliseconds. @type {number[]} */
46
+ #dying = [];
47
+
48
+ /** How long the first output took to appear, in milliseconds. @type {number[]} */
49
+ #firstOutput = [];
50
+
51
+ /**
52
+ * Take the two readings a finished run carries. Either may be absent — a run
53
+ * that was never told to stop did not die on command, and one that produced
54
+ * nothing has no first output — and an absent reading is not a zero.
55
+ *
56
+ * @param {{ dyingMs?: number | null, firstOutputMs?: number | null }} ended
57
+ */
58
+ note(ended) {
59
+ if (Number.isFinite(ended?.dyingMs)) {
60
+ RunCosts.#keep(this.#dying, /** @type {number} */ (ended.dyingMs));
61
+ }
62
+ if (Number.isFinite(ended?.firstOutputMs)) {
63
+ RunCosts.#keep(this.#firstOutput, /** @type {number} */ (ended.firstOutputMs));
64
+ }
65
+ }
66
+
67
+ /**
68
+ * @param {number[]} readings
69
+ * @param {number} value
70
+ */
71
+ static #keep(readings, value) {
72
+ readings.push(value);
73
+ while (readings.length > RECENT_READINGS) {
74
+ readings.shift();
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The two costs in seconds, from this host's own readings.
80
+ *
81
+ * Zero where nothing has been measured yet. Zero understates both, so a plan
82
+ * that has no readings prices moving an encoder as cheaper than it is — which
83
+ * is why the plan keeps a run it cannot compare rather than moving it.
84
+ *
85
+ * @returns {{ killCostSec: number, firstByteWaitSec: number, samples: number }}
86
+ */
87
+ seconds() {
88
+ return {
89
+ killCostSec: (middleOf(this.#dying) ?? 0) / 1000,
90
+ firstByteWaitSec: (middleOf(this.#firstOutput) ?? 0) / 1000,
91
+ samples: Math.min(this.#dying.length, this.#firstOutput.length)
92
+ };
93
+ }
94
+ }