@torrent-tv/proxy 2.80.18 → 2.80.19

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.
@@ -703,7 +703,22 @@ export function buildRunCommand({
703
703
  "-segment_list_flags",
704
704
  "+live",
705
705
  ...explicitTimes,
706
- segmentFormat.segmentFileNameTemplate()
706
+ // UNDER A WORKING NAME, not the one it is served as. A piece under its
707
+ // served name is complete by construction then, whoever else is writing
708
+ // into the same directory — and the name arrives on the channel above the
709
+ // instant ffmpeg closes it, which is what turns it into the served one.
710
+ //
711
+ // The other branch needs none of this: the HLS muxer writes through a
712
+ // temporary name of its own (`+temp_file`), so its files appear under
713
+ // their final name whole.
714
+ //
715
+ // TAGGED WITH THE STRETCH IT WAS GIVEN, which names the run without any
716
+ // counter to keep: intervals never overlap, so two live runs of one output
717
+ // begin at different numbers by construction. That is what makes clearing
718
+ // up after a dead run a well-formed question — its unfinished pieces are
719
+ // the ones carrying its own tag — where before it was answered by taking
720
+ // the highest SERVED name inside its stretch and judging the bytes.
721
+ segmentFormat.makingFileNameTemplate(String(safeIndex))
707
722
  );
708
723
  } else {
709
724
  args.push(
@@ -70,7 +70,6 @@ import { Output, Outputs } from "./output/Output.js";
70
70
  import { masterPlaylistText, mediaPlaylistText, segmentIndexForTime } from "./output/playlists.js";
71
71
  import { SourceFiles, sourceDecodeCharacteristics } from "./source/SourceFile.js";
72
72
  import { ProducedIndex } from "./produced-index.js";
73
- import { discardOpenPiece } from "./encode/open-piece.js";
74
73
  import { SegmentStore } from "./encode/SegmentStore.js";
75
74
  import { EncodeCost } from "./quality/EncodeCost.js";
76
75
  import {
@@ -425,10 +424,6 @@ const TRUE_START_MEMORY = 200;
425
424
  // twice. It runs on the restart path and a session an hour in has thousands of
426
425
  // segments; the figure is for a comparison, not an inventory.
427
426
  const BACKWARD_RESTART_SCAN_SEGMENTS = 300;
428
- // Grace period to wait for the PREVIOUS ffmpeg process to exit (per signal
429
- // escalation step: SIGTERM, then SIGKILL) before spawning its replacement into
430
- // the same session directory. See #startEncodeRun.
431
- const ENCODE_RUN_TERMINATE_GRACE_MS = 2_000;
432
427
  // A seek-restart run that exits this fast never did real work — it failed at
433
428
  // the seek/open step itself (container demux error, bad audio frame boundary,
434
429
  // etc.), not mid-stream. Used to tell a genuine seek failure apart from a
@@ -5535,7 +5530,7 @@ export class HlsSessionManager {
5535
5530
  inputUnavailable: (message) => isInputUnavailable(message),
5536
5531
  onProgress: (report) => this.#noteRunProgress(session, run, report),
5537
5532
  indexOfName: (name) => session.segmentFormat.segmentIndexFromName(name),
5538
- onClosed: (name) => this.segmentStore.markClosed(session.outputKey ?? "", session.segmentFormat.segmentIndexFromName(name)),
5533
+ onClosed: (name) => this.segmentStore.publish(session.outputKey ?? "", name, session.segmentFormat),
5539
5534
  onEnded: (ended) => this.noteRunEnded(session, run, ended)
5540
5535
  });
5541
5536
  session.runs.add(run);
@@ -7692,31 +7687,15 @@ export class HlsSessionManager {
7692
7687
  // disposed. A run that had already finished or failed is not among them,
7693
7688
  // which is what keeps a stop from erasing how it actually ended.
7694
7689
  for (const run of running) {
7695
- const ffmpeg = run.process;
7696
- // The stretch it was given, read now rather than when the process finally
7697
- // exits: by then the session may have started another run with another
7698
- // stretch, and the piece to discard belongs to this one.
7699
- const stoppedSpan = {
7700
- from: Number.isInteger(run.from) ? run.from : 0,
7701
- to: Number.isInteger(run.to) ? run.to : -1
7702
- };
7703
7690
  // The run resumes itself if it was suspended — a stopped process does not
7704
7691
  // act on SIGTERM until it is continued — records the cause, and answers
7705
7692
  // its own exit. Nothing here has to null a field so that the exit is read
7706
7693
  // correctly, because there is no shared field left to misread.
7707
7694
  run.stop(reason);
7708
- // The session outlives its runs a stopped rung keeps serving what it
7709
- // made so the piece this one had open must not be left looking like one
7710
- // of them. Not awaited: the caller's own work does not depend on it, and
7711
- // the wait is for a process that has already been told to go.
7712
- // WHAT THE RUN ITSELF NAMED, which is the only thing that can say a piece
7713
- // is whole — a piece cut short still decodes. Written 2026-09-06 and never
7714
- // passed from here, so the clearing-up ran with nothing proven and fell
7715
- // back to bounding itself by the run's declared stretch alone.
7716
- const provenName = typeof run.provenName === "string" ? run.provenName : null;
7717
- void waitForChildExit(ffmpeg, ENCODE_RUN_TERMINATE_GRACE_MS).then(() =>
7718
- this.#discardUnfinishedPiece(session, session.dirPath, stoppedSpan, provenName)
7719
- );
7695
+ // Nothing is cleared up from here. What this run left open is under its
7696
+ // own working name, and the encoding layer removes it when the run's
7697
+ // ending reaches it one place, and it needs neither the stretch nor the
7698
+ // init bytes this method used to fetch to judge a file by its contents.
7720
7699
  }
7721
7700
  logger.info(`transcode ${session.id} ${running.length} encoder(s) stopped: ${reason}`);
7722
7701
  }
@@ -9589,63 +9568,6 @@ export class HlsSessionManager {
9589
9568
  return this.#producedIndex(session).pathOf(fileName);
9590
9569
  }
9591
9570
 
9592
- /**
9593
- * Throw away the piece a run was in the middle of when it ended.
9594
- *
9595
- * The `segment` muxer creates its output file when it opens it and writes
9596
- * into it until the next cut, so at any instant exactly one file in a run's
9597
- * directory is unfinished: the highest-numbered one. A run that reaches the
9598
- * end of its work closes that file properly and it is a good piece; a run
9599
- * killed for a seek does not — measured 2026-09-03, ffmpeg exited 19 ms after
9600
- * SIGTERM and left `segment-00025.mp4` at zero bytes.
9601
- *
9602
- * Leaving it is what created the deadlock this method exists to prevent: an
9603
- * empty file has a name like any other, so it closed the only hole in the
9604
- * numbering and the look-ahead kept the encoder stopped for having "produced"
9605
- * it. Removing it at the moment the run ends means the question never has to
9606
- * be asked again by anyone.
9607
- *
9608
- * A piece is removed only when it is unusable. A run that finished its last
9609
- * file — the ordinary end of a file, or a stop that arrived between two cuts
9610
- * — has nothing wrong with it, and deleting good output would cost the work
9611
- * of making it twice.
9612
- *
9613
- * @param {HlsSession} session
9614
- * @param {string | null | undefined} runDirPath
9615
- * @returns {Promise<void>}
9616
- */
9617
- async #discardUnfinishedPiece(session, runDirPath, within = null, provenName = null) {
9618
- const canJudgeTracks =
9619
- typeof session.segmentFormat?.hasEveryTrack === "function" &&
9620
- session.initBytes &&
9621
- session.initBytes.length > 0;
9622
- const removed = await discardOpenPiece(
9623
- runDirPath,
9624
- session.segmentFormat,
9625
- within,
9626
- canJudgeTracks
9627
- ? (raw) => {
9628
- const bytes = cutsAtGivenTimes(session) && session.segmentFormat.stripInit
9629
- ? session.segmentFormat.stripInit(raw)
9630
- : raw;
9631
- return session.segmentFormat.hasEveryTrack(bytes, session.initBytes);
9632
- }
9633
- : null,
9634
- provenName
9635
- );
9636
- if (removed !== null) {
9637
- // Removed on purpose, so the index must not go on answering with it.
9638
- this.#producedIndex(session).invalidate();
9639
- logger.info(
9640
- `transcode ${session.id} discarded segment #${removed}: ` +
9641
- "the run ended while it was open, so it holds no usable piece"
9642
- );
9643
- }
9644
- }
9645
-
9646
-
9647
-
9648
-
9649
9571
  #holdForProduction(session, fileName, isPlaylist, options) {
9650
9572
  /** @type {{ address: string, rank: number, topRank: number } | null} */
9651
9573
  let ranked = null;
@@ -273,11 +273,10 @@ export class EncodeOrchestrator {
273
273
  * @param {number} index
274
274
  */
275
275
  noteProduced(address, index) {
276
- // TOLD TO THE AUTHORITY, not only to the map. A piece being closed is a fact
277
- // about the disk, and the store is what holds those; told to the map alone
278
- // it would survive exactly until the next time the map is brought back into
279
- // step, and then be gone with no file to show for it.
280
- this.segmentStore?.markClosed(address, index);
276
+ // Nothing is told to the store: by the time this is called the piece is
277
+ // ALREADY under its served name, because the rename is what closing it
278
+ // means. A statement kept beside the disk would be a second owner of one
279
+ // fact, which is what item 87 removed from the coverage map.
281
280
  this.coverageOf(address).markReady(index);
282
281
  for (const run of this.runsOn(address)) {
283
282
  run.noteProduced(index);
@@ -375,6 +374,23 @@ export class EncodeOrchestrator {
375
374
  // when the reason it cuts the budget changes, so asking it three times in
376
375
  // one pass is three chances to say a thing that happened once.
377
376
  const maxRuns = this.#affordableOn(address, live);
377
+ // THE TERMS EVERY ARRIVAL IS COMPUTED FROM, named here so the line below can
378
+ // print them. A decision of this plan is `delay + (index - at) / rate +
379
+ // madeBetween * refetch` against a deadline, so without the rate and the two
380
+ // prices no recorded decision can be reproduced — which is what happened
381
+ // with the one-piece intervals of 2026-09-08: the rate was substituted six
382
+ // times from the speeds the session reported elsewhere and none of them gave
383
+ // the answer the plan had given.
384
+ const costs = this.#costs.seconds();
385
+ const refetchSecPerFilmSecond = this.refetchSecPerFilmSecond(address);
386
+ // The best figure this host has: what a run here is doing now, what one was
387
+ // last measured doing, or what the startup benchmark predicted. The first
388
+ // two are this output's own; the third exists before either.
389
+ const speedX = Math.max(
390
+ live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
391
+ this.#lastSpeed.get(address) ?? 0,
392
+ this.startingSpeedFor(address) || 0
393
+ );
378
394
  const actions = planEncoders({
379
395
  coverage,
380
396
  windows,
@@ -387,26 +403,19 @@ export class EncodeOrchestrator {
387
403
  // What a start and a kill cost, measured from this host's own runs rather
388
404
  // than written into the code from one machine's reading. Zero until
389
405
  // something has been measured, which is the same convention as the
390
- // refetch price below and is stated so the bias is known.
391
- ...this.#costs.seconds(),
406
+ // refetch price and is stated so the bias is known.
407
+ ...costs,
392
408
  // What a second of film costs to fetch again, in seconds of swarm time.
393
409
  // Answered by whoever measures the film's own byte rate and the swarm's;
394
410
  // zero until they have, which makes driving through look cheaper than it
395
411
  // is and is stated here so the bias is known.
396
- refetchSecPerFilmSecond: this.refetchSecPerFilmSecond(address),
412
+ refetchSecPerFilmSecond,
397
413
  // How much slower one encoder runs beside others, read off this host's own
398
414
  // startup measurement. A pure function over a measured table: beyond what
399
415
  // was measured it holds the largest reading rather than continuing a curve
400
416
  // nothing observed.
401
417
  contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty,
402
- // The best figure this host has: what a run here is doing now, what one
403
- // was last measured doing, or what the startup benchmark predicted. The
404
- // first two are this output's own; the third exists before either.
405
- speedX: Math.max(
406
- live.reduce((best, run) => Math.max(best, run.speedX || 0), 0),
407
- this.#lastSpeed.get(address) ?? 0,
408
- this.startingSpeedFor(address) || 0
409
- )
418
+ speedX
410
419
  });
411
420
 
412
421
  // A move is the plan taking a running encoder away from where it already
@@ -427,10 +436,21 @@ export class EncodeOrchestrator {
427
436
  // exiting, twelve of them normally — and the reasons printed beside them
428
437
  // read as moves, so the fault was diagnosed three times as something it was
429
438
  // not. An interval is what a run is, and it was the one thing missing.
439
+ //
440
+ // AND THE TERMS, for the same reason one step further: an interval says what
441
+ // was decided and the terms say why. `speed` is what every arrival is
442
+ // divided by, so a decision recorded without it can be re-read and not
443
+ // recomputed; `firstByte` and `kill` are what a start and a stop cost here;
444
+ // `refetch` is what a second of film costs to fetch again. A zero in the
445
+ // last three is a measurement nobody has taken, not a free operation, and it
446
+ // is printed so that reading it as free is a choice rather than an accident.
430
447
  if (actions.some((action) => action.type !== "keep")) {
431
448
  this.logger.info(
432
449
  `encode-plan on ${address}: ` +
433
- `${actions.map((action) => `${action.type} #${action.from ?? "?"}..#${action.to ?? "?"}`).join(", ")}`
450
+ `${actions.map((action) => `${action.type} #${action.from ?? "?"}..#${action.to ?? "?"}`).join(", ")}` +
451
+ ` [speed=${speedX.toFixed(2)}x firstByte=${costs.firstByteWaitSec.toFixed(2)}s ` +
452
+ `kill=${costs.killCostSec.toFixed(2)}s refetch=${refetchSecPerFilmSecond.toFixed(3)}s/s ` +
453
+ `maxRuns=${maxRuns} live=${live.length}]`
434
454
  );
435
455
  }
436
456
  if (actions.some((action) => action.type === "move")) {
@@ -547,17 +567,12 @@ export class EncodeOrchestrator {
547
567
  const onThisOutput = this.#runs.get(address) ?? [];
548
568
  onThisOutput.push(run);
549
569
  this.#runs.set(address, onThisOutput);
550
- // This run rewrites the stretch it was given, so what was closed inside that
551
- // stretch is no longer closed. Without this a number closed by an earlier run
552
- // stays servable while a later one is halfway through writing it again.
553
- //
554
- // Bounded by the run's own end, which is the same number the claim below
555
- // carries. Unbounded it unproved the whole film beyond the start of any run,
556
- // and readiness is now a projection of what is proven — so a one-segment run
557
- // at the beginning would have declared the rest of the output unmade.
558
- const runsTo = endOfRun({ from, to });
559
- this.segmentStore?.forgetClosed(address, from, runsTo);
560
- this.coverageOf(address).claim(run, from, runsTo);
570
+ // Nothing has to be un-proved when a run takes a stretch that has already
571
+ // been written. A piece is only ever NAMED as served once it is closed, and
572
+ // a run writing that number again writes under a working name until it
573
+ // closes its own — so the file standing there is a complete piece made by
574
+ // somebody, and serving it is right until the newer one replaces it whole.
575
+ this.coverageOf(address).claim(run, from, endOfRun({ from, to }));
561
576
  run.start(because);
562
577
  }
563
578
 
@@ -654,15 +669,11 @@ export class EncodeOrchestrator {
654
669
  this.#costs.note(ended);
655
670
  // Exactly one ending is normal — the run reached the end of the stretch it
656
671
  // was given and closed its last file. Every other leaves a piece open, and
657
- // that file looks finished however the run ended: stopped, ffmpeg writes it
658
- // out and names it like any other; killed harder, it leaves the bytes it
659
- // had. Either way it decodes and holds less film than its number promises.
660
- // So what is kept is what the run PROVED it finished, and nothing beyond.
661
- if (ended.ending !== ENCODE_EXIT.COMPLETE && this.segmentStore) {
662
- void this.segmentStore
663
- .discardOpenPieceOf(ended.address, { from: ended.from, to: ended.to }, null, ended.provenName)
664
- .catch(() => {});
665
- }
672
+ // that piece is under this run's OWN working name, so clearing up after it
673
+ // is a name match: no stretch to search and no bytes to judge. Done for
674
+ // every ending, the normal one included, since a run that finished cleanly
675
+ // has nothing under a working name and the sweep then removes nothing.
676
+ this.segmentStore?.clearUpAfter(ended.address, ended.from);
666
677
  this.coverageOf(ended.address).release(ended.run);
667
678
  const remaining = this.runsOn(ended.address).filter((run) => run !== ended.run);
668
679
  if (remaining.length === 0) {
@@ -76,6 +76,9 @@ function findFragmentBounds(bytes) {
76
76
  }
77
77
 
78
78
  const INIT_FILE_NAME = "init.mp4";
79
+ // What a piece is called while it is still being written. See
80
+ // `makingFileNameTemplate`.
81
+ const MAKING_PATTERN = /^making-([0-9a-z]+)-(\d{5})\.mp4$/;
79
82
  const SEGMENT_PATTERN = /^segment-(\d{5})\.mp4$/;
80
83
 
81
84
  /**
@@ -140,6 +143,57 @@ export const fmp4Format = {
140
143
  },
141
144
 
142
145
  /** The output path template for the `segment` muxer. */
146
+ /**
147
+ * The name ffmpeg is told to write a piece under WHILE IT IS MAKING IT.
148
+ *
149
+ * Not the name it is served under. A piece being written is not a piece, and
150
+ * under its final name it is indistinguishable from one — which is how half a
151
+ * segment came to be served: the proof of completeness was "the next file
152
+ * exists", true of one writer walking forward and false the moment two runs
153
+ * share an output, because the next file is then written by another process.
154
+ * Field 2026-09-08: `segment-00057.mp4` served at 2 268 361 bytes and then at
155
+ * 4 510 940, and the browser refused the whole one for the rest of the session.
156
+ *
157
+ * With a working name, the served name appears only when the encoder has said
158
+ * the piece is closed, and existence under it IS the proof — one rule, the
159
+ * same for every branch, and true whether or not our own process is alive.
160
+ *
161
+ * @returns {string}
162
+ */
163
+ makingFileNameTemplate(tag) {
164
+ return `making-${String(tag ?? "0").replace(/[^0-9a-z]/g, "")}-%05d.mp4`;
165
+ },
166
+
167
+ /**
168
+ * Which run is writing this working name, or null when the name is not one.
169
+ *
170
+ * The tag is what makes clearing up after a dead run possible without
171
+ * guessing: several runs write into one directory, so "the unfinished pieces"
172
+ * is only a well-formed question per run. It used to be answered by looking
173
+ * for the highest SERVED name inside the stretch the ended run was given and
174
+ * judging its bytes — a guess, and under the naming rule above it would remove
175
+ * a complete piece somebody else had closed.
176
+ *
177
+ * @param {string} name
178
+ * @returns {string | null}
179
+ */
180
+ makingTagOf(name) {
181
+ const match = MAKING_PATTERN.exec(String(name ?? "").trim());
182
+ return match ? match[1] : null;
183
+ },
184
+
185
+ /**
186
+ * The name a piece just closed under a working name is served as, or null when
187
+ * the name is not one of ours.
188
+ *
189
+ * @param {string} makingName
190
+ * @returns {string | null}
191
+ */
192
+ servedNameOf(makingName) {
193
+ const match = MAKING_PATTERN.exec(String(makingName ?? "").trim());
194
+ return match ? this.segmentFileName(Number(match[2])) : null;
195
+ },
196
+
143
197
  segmentFileNameTemplate() {
144
198
  return "segment-%05d.mp4";
145
199
  },
@@ -14,6 +14,9 @@
14
14
  * See {@link SegmentFormat} in `./index.js` for the interface contract.
15
15
  */
16
16
 
17
+ // What a piece is called while it is still being written. See
18
+ // `makingFileNameTemplate`.
19
+ const MAKING_PATTERN = /^making-([0-9a-z]+)-(\d{5})\.ts$/;
17
20
  const SEGMENT_PATTERN = /^segment-(\d{5})\.ts$/;
18
21
 
19
22
  /**
@@ -47,6 +50,57 @@ export const mpegtsFormat = {
47
50
  },
48
51
 
49
52
  /** The output path template for the `segment` muxer. */
53
+ /**
54
+ * The name ffmpeg is told to write a piece under WHILE IT IS MAKING IT.
55
+ *
56
+ * Not the name it is served under. A piece being written is not a piece, and
57
+ * under its final name it is indistinguishable from one — which is how half a
58
+ * segment came to be served: the proof of completeness was "the next file
59
+ * exists", true of one writer walking forward and false the moment two runs
60
+ * share an output, because the next file is then written by another process.
61
+ * Field 2026-09-08: `segment-00057.mp4` served at 2 268 361 bytes and then at
62
+ * 4 510 940, and the browser refused the whole one for the rest of the session.
63
+ *
64
+ * With a working name, the served name appears only when the encoder has said
65
+ * the piece is closed, and existence under it IS the proof — one rule, the
66
+ * same for every branch, and true whether or not our own process is alive.
67
+ *
68
+ * @returns {string}
69
+ */
70
+ makingFileNameTemplate(tag) {
71
+ return `making-${String(tag ?? "0").replace(/[^0-9a-z]/g, "")}-%05d.ts`;
72
+ },
73
+
74
+ /**
75
+ * Which run is writing this working name, or null when the name is not one.
76
+ *
77
+ * The tag is what makes clearing up after a dead run possible without
78
+ * guessing: several runs write into one directory, so "the unfinished pieces"
79
+ * is only a well-formed question per run. It used to be answered by looking
80
+ * for the highest SERVED name inside the stretch the ended run was given and
81
+ * judging its bytes — a guess, and under the naming rule above it would remove
82
+ * a complete piece somebody else had closed.
83
+ *
84
+ * @param {string} name
85
+ * @returns {string | null}
86
+ */
87
+ makingTagOf(name) {
88
+ const match = MAKING_PATTERN.exec(String(name ?? "").trim());
89
+ return match ? match[1] : null;
90
+ },
91
+
92
+ /**
93
+ * The name a piece just closed under a working name is served as, or null when
94
+ * the name is not one of ours.
95
+ *
96
+ * @param {string} makingName
97
+ * @returns {string | null}
98
+ */
99
+ servedNameOf(makingName) {
100
+ const match = MAKING_PATTERN.exec(String(makingName ?? "").trim());
101
+ return match ? this.segmentFileName(Number(match[2])) : null;
102
+ },
103
+
50
104
  segmentFileNameTemplate() {
51
105
  return "segment-%05d.ts";
52
106
  },
@@ -103,8 +103,12 @@ function aViewerAtTheStart(made) {
103
103
  */
104
104
  function theWholeFilmIsOnDisk(store, dir) {
105
105
  for (let index = 0; index < SEGMENTS; index += 1) {
106
+ // Under the served name, which is the whole of what says a piece is closed:
107
+ // it takes that name only when its writer has said so. There used to be a
108
+ // statement kept beside the disk as well, told by whoever noticed a piece
109
+ // being produced — a second owner of one fact, and the fault this file's own
110
+ // subject is.
106
111
  writeFileSync(path.join(dir, fmp4Format.segmentFileName(index)), Buffer.alloc(16));
107
- store.markClosed(PICTURE, index);
108
112
  }
109
113
  }
110
114
 
@@ -190,6 +190,10 @@ test("segments already made are known to the plan, whoever made them", (t) => {
190
190
  const coverage = manager.encodeOrchestrator.coverageOf(KEY);
191
191
  assert.equal(coverage.isReady(0), true);
192
192
  assert.equal(coverage.isReady(2), true);
193
- assert.equal(coverage.isReady(3), false, "the highest has no successor to prove it closed");
193
+ // Including the highest. It used to be excluded for having no successor to
194
+ // prove it closed, which left the last piece of every run unprovable for ever
195
+ // — and proved a half-written one whenever the number above it was written by
196
+ // another run of the same output. A served name is the proof now.
197
+ assert.equal(coverage.isReady(3), true, "its name says it is closed");
194
198
  void store;
195
199
  });