@torrent-tv/proxy 2.25.0 → 2.26.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 2.26.0
2
+
3
+ - **New**: The keyframe-index measurement now answers the question it was raising. Each file's summary reports the distribution of how far produced segments fell from the playlist (median and worst, not one extreme), how many keyframes were read from the container, and — the discriminator — **how many of the disagreeing segments began at ANOTHER time the same table names**. That separates the two explanations that have been argued rather than measured: a table describing times the file does not have, against a table listing only some keyframes with our grid built over its gaps. Every deviation measured on 2026-08-17 was positive, 0.58-2.96 s, which is what a cut pushed forward to the next real keyframe looks like. The summary is also written every 25 distinct boundaries instead of only when a session is disposed, because a proxy restart — every addon update is one — takes its sessions with it and the summary was routinely never written.
4
+
5
+ ## 2.25.1
6
+
7
+ - **Fix**: Picture and sound are back in step. Two releases in a row moved a segment's stamp toward the playlist — 2.24.1 per session, 2.25.0 by one offset for the whole family — and both desynced playback in the field the same day. The reason is what the first segment of a run is: it is not CUT at all, it begins where ffmpeg's seek landed, and the picture must land on a keyframe while the sound needs none, so after every restart the two runs genuinely begin at different real times and the whole run carries that difference (measured: the sound's #292 began at 1587.892 s and #293 at 1592.692 s, one segment apart, the run shifted 2.5 s from the grid). Labelling each track with its own true time is what keeps them together in real time; a segment is stamped with its own start again, as it was for weeks before 2.24.1. What stays from those releases is the part that was right: one published timeline per family, and a warning when a piece lands further from the playlist than a player will bridge.
8
+ - **Chore**: The run's state now answers the questions its process handle used to be asked. Ten sites that re-derived "is this run alive" from a child-process handle, and every read of "is it suspended", now read the state machine shipped in 2.23.0; the `encoderPaused` field is gone. The two places that ask about a NAMED process — the predecessor a restart is replacing, and a deliberate stop — still ask the OS, which remains the authority on whether a pid exists.
9
+
1
10
  ## 2.25.0
2
11
 
3
12
  - **Fix**: Picture and sound drifted apart after a seek, by exactly the amount the grid had been corrected. 2.24.1 made every segment stamp itself against the playlist its own session published — but each session froze that playlist at its own creation, and a soundtrack or a quality step is created later than the picture it accompanies, so it froze a table that had since been corrected. Two members of one family then stated the same moment differently, and the corrections measured on the field file are 0.6-2.9 s. A family now publishes ONE timeline: a session created inside a family takes its base's published table verbatim and writes its own playlist from it, while the live table goes on being corrected for cutting, which is what keeps a re-encoded step aligned with the copy it joins.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.25.0",
3
+ "version": "2.26.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -20,7 +20,13 @@ import { logger } from "../utils/logger.js";
20
20
  import { readKeyframeIndex } from "./container-index/index.js";
21
21
  import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
22
22
  import { speedFromReadings } from "./encoder-readings.js";
23
- import { ENCODE_RUN_EVENT, ENCODE_RUN_STATE, INITIAL_RUN_STATE, nextState } from "./encode-run-state.js";
23
+ import {
24
+ ENCODE_RUN_EVENT,
25
+ ENCODE_RUN_STATE,
26
+ INITIAL_RUN_STATE,
27
+ nextState,
28
+ processCanBeSignalled
29
+ } from "./encode-run-state.js";
24
30
  import { ENCODE_EXIT, classifyEncodeExit } from "./encode-exit.js";
25
31
 
26
32
  /** Own package version, stamped onto session-start log lines. */
@@ -196,6 +202,18 @@ export function newIndexCheck() {
196
202
  disagreed: 0,
197
203
  maxDeviationSec: 0,
198
204
  firstDisagreementIndex: -1,
205
+ // Every deviation, so the summary can report a distribution instead of one
206
+ // extreme. Bounded by the number of distinct boundaries a session produces.
207
+ deviations: [],
208
+ // Of the segments that started away from the playlist, how many began at
209
+ // ANOTHER time in the very list the grid was built from. This is the
210
+ // measurement that separates the two explanations: a table that describes
211
+ // times the file does not have, against a table that lists only SOME
212
+ // keyframes and a grid built over its gaps. Asked 2026-08-17 by the user,
213
+ // who was right that the second is far more likely — every deviation
214
+ // measured that day was positive, 0.58-2.96 s, which is what a cut pushed
215
+ // forward to the next real keyframe looks like.
216
+ landedOnAnotherKeyframe: 0,
199
217
  // Which boundaries have been counted. A segment can be requested again, and
200
218
  // a repeat is the same boundary, not new evidence.
201
219
  seen: new Set()
@@ -211,12 +229,17 @@ export function newIndexCheck() {
211
229
  * start the playlist declared for it.
212
230
  * @returns {void}
213
231
  */
214
- export function noteIndexDeviation(check, index, deviationSec) {
232
+ export function noteIndexDeviation(check, index, deviationSec, landedOnKeyframe = null) {
215
233
  if (check.seen.has(index)) {
216
234
  return;
217
235
  }
218
236
  check.seen.add(index);
219
237
  check.checked += 1;
238
+ check.deviations ??= [];
239
+ check.deviations.push(deviationSec);
240
+ if (landedOnKeyframe === true) {
241
+ check.landedOnAnotherKeyframe = (check.landedOnAnotherKeyframe ?? 0) + 1;
242
+ }
220
243
  if (deviationSec > SEGMENT_START_DISAGREEMENT_SEC) {
221
244
  check.disagreed += 1;
222
245
  if (check.firstDisagreementIndex < 0) {
@@ -1817,7 +1840,7 @@ export class HlsSessionManager {
1817
1840
  // `encode-run-state.js`. Written at every event today and read by nothing
1818
1841
  // yet: a refused pair in the log is the model disagreeing with reality,
1819
1842
  // and that disagreement is the measurement this release exists to take.
1820
- // The fields it will replace — `state`, `progress.state`, `encoderPaused`
1843
+ // The fields it replaces — `state`, `progress.state`
1821
1844
  // and the repeated liveness checks — keep their current writes meanwhile.
1822
1845
  runState: INITIAL_RUN_STATE,
1823
1846
  lastError: "",
@@ -1952,7 +1975,6 @@ export class HlsSessionManager {
1952
1975
  // encoder is currently suspended for running too far past it.
1953
1976
  // See #enforceLookAhead.
1954
1977
  lastRequestedSegment: null,
1955
- encoderPaused: false,
1956
1978
  encoderPauseUnsupported: false,
1957
1979
  seekFirstFarAt: 0,
1958
1980
  // Circuit breaker: consecutive FAST failures (see SEEK_FAST_FAIL_MS) at
@@ -2744,7 +2766,7 @@ export class HlsSessionManager {
2744
2766
  if (aheadSeconds === null) {
2745
2767
  // The segment the viewer needs does not exist. Whatever else is on disk,
2746
2768
  // this encoder has work to do right now.
2747
- if (session.encoderPaused && this.#resumeEncoder(session, "the viewer needs a segment nobody has made")) {
2769
+ if (session.runState === ENCODE_RUN_STATE.SUSPENDED && this.#resumeEncoder(session, "the viewer needs a segment nobody has made")) {
2748
2770
  this.#transitionRun(session, ENCODE_RUN_EVENT.RESUME_ORDERED);
2749
2771
  }
2750
2772
  return;
@@ -2772,7 +2794,7 @@ export class HlsSessionManager {
2772
2794
  );
2773
2795
  }
2774
2796
 
2775
- if (!session.encoderPaused && aheadSeconds > LOOKAHEAD_PAUSE_SECONDS) {
2797
+ if (session.runState !== ENCODE_RUN_STATE.SUSPENDED && aheadSeconds > LOOKAHEAD_PAUSE_SECONDS) {
2776
2798
  // The decision names what it was taken on. Suspending the encoder stops
2777
2799
  // the only thing that reads the input, so a wrong reading here stops the
2778
2800
  // download too — measured 2026-08-06: the log said "135s ahead" while
@@ -2786,7 +2808,7 @@ export class HlsSessionManager {
2786
2808
  `(viewer at #${viewerSegment}, unbroken through #${reading.lastCovered}, ` +
2787
2809
  `${reading.total} segment file(s) present)`
2788
2810
  );
2789
- } else if (session.encoderPaused && aheadSeconds <= LOOKAHEAD_RESUME_SECONDS) {
2811
+ } else if (session.runState === ENCODE_RUN_STATE.SUSPENDED && aheadSeconds <= LOOKAHEAD_RESUME_SECONDS) {
2790
2812
  if (this.#resumeEncoder(session, `${Math.round(aheadSeconds)}s ahead of the viewer`)) {
2791
2813
  this.#transitionRun(session, ENCODE_RUN_EVENT.RESUME_ORDERED);
2792
2814
  }
@@ -2929,7 +2951,7 @@ export class HlsSessionManager {
2929
2951
  #pauseEncoder(session, reason) {
2930
2952
  // Any pair spanning this would count a stopped encoder as slow.
2931
2953
  session.learnSample = null;
2932
- if (session.encoderPaused || session.encoderPauseUnsupported || !session.ffmpeg?.pid) {
2954
+ if (session.runState === ENCODE_RUN_STATE.SUSPENDED || session.encoderPauseUnsupported || !session.ffmpeg?.pid) {
2933
2955
  return;
2934
2956
  }
2935
2957
  try {
@@ -2942,7 +2964,6 @@ export class HlsSessionManager {
2942
2964
  );
2943
2965
  return;
2944
2966
  }
2945
- session.encoderPaused = true;
2946
2967
  this.#transitionRun(session, ENCODE_RUN_EVENT.SUSPEND_ORDERED);
2947
2968
  logger.info(
2948
2969
  `transcode ${session.id} encoder suspended — ${reason} ` +
@@ -2965,7 +2986,7 @@ export class HlsSessionManager {
2965
2986
  #resumeEncoder(session, reason) {
2966
2987
  // Any pair spanning this would count a stopped encoder as slow.
2967
2988
  session.learnSample = null;
2968
- if (!session.encoderPaused || !session.ffmpeg?.pid) {
2989
+ if (session.runState !== ENCODE_RUN_STATE.SUSPENDED || !session.ffmpeg?.pid) {
2969
2990
  return false;
2970
2991
  }
2971
2992
  let continued = true;
@@ -2977,7 +2998,6 @@ export class HlsSessionManager {
2977
2998
  // stops a dead run being reported as producing again.
2978
2999
  continued = false;
2979
3000
  }
2980
- session.encoderPaused = false;
2981
3001
  // Two records of one moment must not contradict each other: a line saying
2982
3002
  // the encoder resumed, beside a return value saying nothing was resumed, is
2983
3003
  // the sort of pair that costs an hour of reading a field log.
@@ -3080,9 +3100,9 @@ export class HlsSessionManager {
3080
3100
 
3081
3101
  async #reportHostLoad() {
3082
3102
  const encoding = [...this.sessionsById.values()].filter(
3083
- (session) => session?.ffmpeg != null && !hasChildExited(session.ffmpeg) && session.state !== "disposed"
3103
+ (session) => processCanBeSignalled(session?.runState) && session.state !== "disposed"
3084
3104
  );
3085
- const runningNow = encoding.filter((session) => session.encoderPaused !== true);
3105
+ const runningNow = encoding.filter((session) => session.runState !== ENCODE_RUN_STATE.SUSPENDED);
3086
3106
  if (runningNow.length === 0) {
3087
3107
  // No encoder is RUNNING. A suspended one costs nothing, and counting it
3088
3108
  // as work meant this was never reached: measured 2026-08-15, four minutes
@@ -3156,7 +3176,7 @@ export class HlsSessionManager {
3156
3176
  // the addon host actually were (2026-08-15: `ffmpeg=0% system=24%`, both
3157
3177
  // encoders suspended, and the speed beside it a stale figure from before
3158
3178
  // they stopped).
3159
- const suspended = encoding.filter((session) => session.encoderPaused === true).length;
3179
+ const suspended = encoding.filter((session) => session.runState === ENCODE_RUN_STATE.SUSPENDED).length;
3160
3180
  const running = encoding.length - suspended;
3161
3181
  const machine = await readMachineState();
3162
3182
  const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
@@ -3753,7 +3773,6 @@ export class HlsSessionManager {
3753
3773
  // judged finished — see getFileStream.
3754
3774
  session.usesExplicitCuts = Boolean(cutTimes && cutTimes.length > 0);
3755
3775
  session.encodeStartIndex = safeIndex;
3756
- session.encoderPaused = false;
3757
3776
  session.pendingRestartIndex = -1;
3758
3777
  session.lastRestartAt = Date.now();
3759
3778
  session.state = session.state === "disposed" ? "disposed" : "starting";
@@ -4191,7 +4210,7 @@ export class HlsSessionManager {
4191
4210
  }
4192
4211
  // Nothing is encoding: a rung the viewer has switched away from is left
4193
4212
  // exactly so, and its held requests must not bring its encoder back.
4194
- if (session.ffmpeg == null || hasChildExited(session.ffmpeg)) {
4213
+ if (!processCanBeSignalled(session.runState)) {
4195
4214
  return;
4196
4215
  }
4197
4216
  // A seek already settling is about to move the encoder to where the VIEWER
@@ -4375,7 +4394,7 @@ export class HlsSessionManager {
4375
4394
  // "already covered", so nothing could ever restart it. Measured 2026-08-04:
4376
4395
  // one ffmpeg failure turned into a session that answered 500 to every
4377
4396
  // segment for as long as the viewer kept trying.
4378
- const runIsAlive = session.ffmpeg != null && !hasChildExited(session.ffmpeg);
4397
+ const runIsAlive = processCanBeSignalled(session.runState);
4379
4398
  if (runIsAlive && index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS) {
4380
4399
  logger.info(
4381
4400
  `transcode ${session.id} seek to ${positionSeconds.toFixed(1)}s (#${index}) ` +
@@ -4429,7 +4448,7 @@ export class HlsSessionManager {
4429
4448
  // ten seconds, each killing a run that was encoding #865. The guard below
4430
4449
  // did not catch it — it only decides whether to let the current run finish,
4431
4450
  // not whether a new run is needed at all.
4432
- if (target === session.encodeStartIndex && session.ffmpeg != null && !hasChildExited(session.ffmpeg)) {
4451
+ if (target === session.encodeStartIndex && processCanBeSignalled(session.runState)) {
4433
4452
  logger.info(
4434
4453
  `transcode ${session.id} seek #${target} ignored — the current run already starts there`
4435
4454
  );
@@ -4457,7 +4476,7 @@ export class HlsSessionManager {
4457
4476
  // Holding it was also expensive in the other direction: a genuine second
4458
4477
  // seek could be delayed by the whole grace.
4459
4478
  const producedThisRun = this.#producedSecondsThisRun(session);
4460
- const runIsAlive = session.ffmpeg != null && !hasChildExited(session.ffmpeg);
4479
+ const runIsAlive = processCanBeSignalled(session.runState);
4461
4480
  const allowedBecause = !runIsAlive
4462
4481
  ? "run is dead"
4463
4482
  : `viewer moved; run had produced ${producedThisRun.toFixed(1)}s`;
@@ -4784,7 +4803,14 @@ export class HlsSessionManager {
4784
4803
  #noteIndexAccuracy(session, index, trueStart, declaredStart) {
4785
4804
  const deviation = Math.abs(trueStart - declaredStart);
4786
4805
  session.indexCheck ??= newIndexCheck();
4787
- noteIndexDeviation(session.indexCheck, index, deviation);
4806
+ // Did this segment begin at ANOTHER keyframe from the same list? Half an
4807
+ // audio frame is the tolerance — anything the list names is exact, so a
4808
+ // match is a match. `keyframeTimes` is the list the grid was built from, so
4809
+ // this compares the file against the table on the table's own terms.
4810
+ const knownKeyframe = Array.isArray(session.keyframeTimes)
4811
+ ? session.keyframeTimes.some((time) => Math.abs(time - trueStart) <= 0.05)
4812
+ : null;
4813
+ noteIndexDeviation(session.indexCheck, index, deviation, knownKeyframe);
4788
4814
  if (deviation > SEGMENT_START_DISAGREEMENT_SEC) {
4789
4815
  // Which boundary the true start DOES match, if any. This is what tells
4790
4816
  // the two possible faults apart, and they need opposite fixes: matching
@@ -4806,6 +4832,14 @@ export class HlsSessionManager {
4806
4832
  : "the container's keyframe index disagrees with the file; using the file")
4807
4833
  );
4808
4834
  }
4835
+ // Said as the evidence accumulates, not only when the session is disposed.
4836
+ // A proxy restart takes its sessions with it — every addon update does —
4837
+ // and a summary that only ever appears at the end is a summary that is
4838
+ // routinely never written. Twenty-five distinct boundaries is enough for
4839
+ // the proportion to mean something and rare enough not to repeat itself.
4840
+ if (session.indexCheck.checked > 0 && session.indexCheck.checked % 25 === 0) {
4841
+ this.#logIndexAccuracy(session);
4842
+ }
4809
4843
  this.correctBoundaryFromSegment(session, index, trueStart);
4810
4844
  }
4811
4845
 
@@ -4972,12 +5006,19 @@ export class HlsSessionManager {
4972
5006
  if (!check || check.checked === 0) {
4973
5007
  return;
4974
5008
  }
5009
+ const deviations = [...(check.deviations ?? [])].sort((left, right) => left - right);
5010
+ const median = deviations.length > 0 ? deviations[Math.floor(deviations.length / 2)] : 0;
5011
+ const landed = check.landedOnAnotherKeyframe ?? 0;
4975
5012
  logger.info(
4976
5013
  `keyframe-index ${session.containerFormat || "unknown"} "${session.fileName}": ` +
4977
5014
  `${check.disagreed} of ${check.checked} produced segments started away from the playlist, ` +
4978
- `worst ${check.maxDeviationSec.toFixed(3)}s` +
5015
+ `median ${median.toFixed(3)}s worst ${check.maxDeviationSec.toFixed(3)}s` +
4979
5016
  (check.firstDisagreementIndex >= 0 ? ` (first at #${check.firstDisagreementIndex})` : "") +
4980
- ` [tolerance ${SEGMENT_START_DISAGREEMENT_SEC}s]`
5017
+ // The discriminator, stated in the same line as the count it explains: a
5018
+ // segment that began at another time the SAME table names was not
5019
+ // mis-described by the table — the grid was built over a gap in it.
5020
+ `; ${landed} of them began at another keyframe the table names` +
5021
+ ` [tolerance ${SEGMENT_START_DISAGREEMENT_SEC}s, ${(session.keyframeTimes?.length ?? 0)} keyframes read]`
4981
5022
  );
4982
5023
  }
4983
5024
 
@@ -5059,7 +5100,7 @@ export class HlsSessionManager {
5059
5100
  .map((member) => this.#observedAudioCost.get(this.#audioCostKey(member))?.version ?? 0)
5060
5101
  .reduce((total, one) => total + one, 0);
5061
5102
  const running = [...this.#familyOf(owner)]
5062
- .filter((member) => member.ffmpeg != null && !hasChildExited(member.ffmpeg)).length;
5103
+ .filter((member) => processCanBeSignalled(member.runState)).length;
5063
5104
  // What each running encode was last seen doing, which is BOTH an input to
5064
5105
  // the answer twice over — it withdraws a step measured below realtime, and
5065
5106
  // it prices every running picture in the committed total — and a figure
@@ -5217,7 +5258,7 @@ export class HlsSessionManager {
5217
5258
  for (const session of this.sessionsById.values()) {
5218
5259
  if (session?.ffmpeg != null &&
5219
5260
  !hasChildExited(session.ffmpeg) &&
5220
- session.encoderPaused !== true &&
5261
+ session.runState !== ENCODE_RUN_STATE.SUSPENDED &&
5221
5262
  session.state !== "disposed") {
5222
5263
  running += 1;
5223
5264
  }
@@ -5231,7 +5272,7 @@ export class HlsSessionManager {
5231
5272
  session.state === "disposed" ||
5232
5273
  session.state === "failed" ||
5233
5274
  !session.ffmpeg ||
5234
- session.encoderPaused === true
5275
+ session.runState === ENCODE_RUN_STATE.SUSPENDED
5235
5276
  ) {
5236
5277
  session.learnSample = null;
5237
5278
  return;
@@ -5336,7 +5377,7 @@ export class HlsSessionManager {
5336
5377
  if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
5337
5378
  return;
5338
5379
  }
5339
- if (session.encoderPaused === true) {
5380
+ if (session.runState === ENCODE_RUN_STATE.SUSPENDED) {
5340
5381
  return; // a suspended run reports a cumulative figure that is decaying
5341
5382
  }
5342
5383
  // Always asked, not only below realtime. A re-encode near 1x may be the
@@ -5387,7 +5428,7 @@ export class HlsSessionManager {
5387
5428
  if (!Number.isFinite(runStartedAt) || Date.now() - runStartedAt < DECODE_LEARNING_SETTLE_MS) {
5388
5429
  return;
5389
5430
  }
5390
- if (session.encoderPaused === true) {
5431
+ if (session.runState === ENCODE_RUN_STATE.SUSPENDED) {
5391
5432
  return;
5392
5433
  }
5393
5434
  if (await this.#classifyTranscodeBound(session) === "download") {
@@ -5630,7 +5671,7 @@ export class HlsSessionManager {
5630
5671
  #pricedConcurrentCost(session) {
5631
5672
  let cost = 0;
5632
5673
  for (const member of this.#familyOf(session)) {
5633
- if (member === session || member.ffmpeg == null || hasChildExited(member.ffmpeg)) {
5674
+ if (member === session || !processCanBeSignalled(member.runState)) {
5634
5675
  continue;
5635
5676
  }
5636
5677
  if (member.audioOnly === true) {
@@ -5659,7 +5700,7 @@ export class HlsSessionManager {
5659
5700
  // price to look up for another film's session — so a reading taken while
5660
5701
  // one is running cannot be attributed either.
5661
5702
  return this.#runningEncoders() > this.#familyOf(session).filter(
5662
- (member) => member.ffmpeg != null && !hasChildExited(member.ffmpeg)
5703
+ (member) => processCanBeSignalled(member.runState)
5663
5704
  ).length
5664
5705
  ? null
5665
5706
  : cost;
@@ -5683,7 +5724,7 @@ export class HlsSessionManager {
5683
5724
  if (member.audioOnly === true || member.transcodeVideo !== true) {
5684
5725
  continue;
5685
5726
  }
5686
- if (member.ffmpeg == null || hasChildExited(member.ffmpeg)) {
5727
+ if (!processCanBeSignalled(member.runState)) {
5687
5728
  continue;
5688
5729
  }
5689
5730
  const height = this.variantHeightOf(member);
@@ -5727,7 +5768,7 @@ export class HlsSessionManager {
5727
5768
  // that cost is spread, not a discount on it — and pricing a parked
5728
5769
  // encoder at zero would offer a step on the strength of a pause that ends
5729
5770
  // the moment the viewer catches up.
5730
- if (member.ffmpeg == null || hasChildExited(member.ffmpeg)) {
5771
+ if (!processCanBeSignalled(member.runState)) {
5731
5772
  continue;
5732
5773
  }
5733
5774
  if (member.audioOnly === true) {
@@ -7079,9 +7120,30 @@ export class HlsSessionManager {
7079
7120
  // is kept, because it is the honest one and it is what keeps speech and
7080
7121
  // subtitles together on a file whose index is slightly out (2026-08-06,
7081
7122
  // 4.17 s of drift on a Matroska index that lied).
7082
- let stampStart = trueStart ?? publishedStart;
7123
+ // STAMPED WITH ITS OWN TRUE START, always. Two attempts at moving it
7124
+ // toward the playlist both made things worse, and the reason is in what
7125
+ // the first segment of a run is: it is not CUT at all — it begins where
7126
+ // ffmpeg's seek landed. The picture must land on a keyframe; the sound
7127
+ // needs none and starts at the instant asked for. So after every
7128
+ // restart the two runs genuinely begin at different real times, and the
7129
+ // whole run carries that difference (field 2026-08-17: the sound's
7130
+ // #292 began at 1587.892 s and #293 at 1592.692 s — exactly one segment
7131
+ // apart, the whole run shifted 2.5 s from the grid).
7132
+ //
7133
+ // Labelling each track with its own true time is therefore what keeps
7134
+ // picture and sound together in real time. Moving them onto the
7135
+ // published grid — separately (2.24.1) or by one family offset (2.25.0)
7136
+ // — closes a gap that is real and opens one that is not: it desynced
7137
+ // playback in the field within the hour, twice.
7138
+ //
7139
+ // What that leaves unsolved is the reason those attempts were made: a
7140
+ // playlist that disagrees with the media by more than a player bridges
7141
+ // makes hls.js refetch the same fragment for ever (1908 times each for
7142
+ // two segments, measured). The answer to THAT is to make the published
7143
+ // grid agree with where the runs really begin — not to relabel the
7144
+ // media. Recorded as its own roadmap item rather than guessed at here.
7145
+ const stampStart = trueStart ?? publishedStart;
7083
7146
  if (trueStart !== null && Math.abs(trueStart - publishedStart) > PLAYER_BUFFER_HOLE_SEC) {
7084
- stampStart = publishedStart;
7085
7147
  this.#notePlaylistDisagreement(session, index, trueStart, publishedStart);
7086
7148
  }
7087
7149
  const prepared = session.segmentFormat.prepareSegmentBytes(bytes, {
@@ -63,6 +63,7 @@ async function managerWithRunAhead() {
63
63
  seekTarget: null,
64
64
  waitEpoch: 0,
65
65
  firstWantedAt: new Map(),
66
+ runState: "PRODUCING",
66
67
  ffmpeg: { pid: 4321, exitCode: null, signalCode: null, kill() {}, once(event, handler) { if (event === "exit") handler(); } },
67
68
  progress: { state: "running", processedSeconds: RUN_STARTS_AT * SEGMENT_SECONDS + 400, startPositionSeconds: RUN_STARTS_AT * SEGMENT_SECONDS }
68
69
  };
@@ -286,6 +286,7 @@ test("a segment request hands the encoder to the variant the viewer moved to", a
286
286
  base.lastRequestedSegment = 25;
287
287
  const encoder = fakeEncoder();
288
288
  base.ffmpeg = encoder;
289
+ base.runState = "PRODUCING";
289
290
 
290
291
  const served = await manager.resolveVariantFile(BASE_ID, 540, "segment-00025.mp4");
291
292
 
@@ -317,6 +318,7 @@ test("a rung is placed where the player asked it for, not where the other rung h
317
318
  manager.sessionsById.set(VARIANT_ID, variant);
318
319
  base.variants = new Map([[540, VARIANT_ID]]);
319
320
  base.ffmpeg = fakeEncoder();
321
+ base.runState = "PRODUCING";
320
322
  // The rung being left had read fourteen segments further than the picture had
321
323
  // played — an encoder running at several times realtime fills the buffer far
322
324
  // ahead. Measured 2026-08-11: 56 s of gap, and using the read head placed the
@@ -347,6 +349,7 @@ test("warming a rung prepares it without taking the encoder from the one on scre
347
349
  base.variants = new Map([[540, VARIANT_ID]]);
348
350
  const encoder = fakeEncoder();
349
351
  base.ffmpeg = encoder;
352
+ base.runState = "PRODUCING";
350
353
 
351
354
  const prepared = await manager.prepareVariant(BASE_ID, 540, 240);
352
355
 
@@ -373,6 +376,7 @@ test("a rung warmed at the playhead survives the switch that lands just ahead of
373
376
  manager.sessionsById.set(VARIANT_ID, variant);
374
377
  base.variants = new Map([[540, VARIANT_ID]]);
375
378
  base.ffmpeg = fakeEncoder();
379
+ base.runState = "PRODUCING";
376
380
 
377
381
  // Warmed AT THE PLAYHEAD (240 s = segment #60), which is what the browser
378
382
  // sends from server 0.10.0 onwards, and the run is alive and has produced a
@@ -380,6 +384,7 @@ test("a rung warmed at the playhead survives the switch that lands just ahead of
380
384
  await manager.prepareVariant(BASE_ID, 540, 240);
381
385
  variant.encodeStartIndex = 59;
382
386
  variant.ffmpeg = fakeEncoder();
387
+ variant.runState = "PRODUCING";
383
388
  variant.progress = { ...variant.progress, processedSeconds: 268 };
384
389
  variant.seekTarget = null;
385
390
  variant.seekSettleTimer = null;
@@ -413,6 +418,7 @@ test("a rung warmed PAST the switch is repositioned, which is what warming late
413
418
  manager.sessionsById.set(VARIANT_ID, variant);
414
419
  base.variants = new Map([[540, VARIANT_ID]]);
415
420
  base.ffmpeg = fakeEncoder();
421
+ base.runState = "PRODUCING";
416
422
 
417
423
  // The same session, warmed where the BUFFER ended rather than where the
418
424
  // picture was — 60 s further on, which is an ordinary cushion. This is what
@@ -420,6 +426,7 @@ test("a rung warmed PAST the switch is repositioned, which is what warming late
420
426
  await manager.prepareVariant(BASE_ID, 540, 300);
421
427
  variant.encodeStartIndex = 74;
422
428
  variant.ffmpeg = fakeEncoder();
429
+ variant.runState = "PRODUCING";
423
430
  variant.progress = { ...variant.progress, processedSeconds: 310 };
424
431
  variant.seekTarget = null;
425
432
  variant.seekSettleTimer = null;
@@ -447,6 +454,7 @@ test("the rung on screen fetching its own segments does not cancel a warm-up", a
447
454
  const warmedEncoder = fakeEncoder();
448
455
  variant.ffmpeg = warmedEncoder;
449
456
  base.ffmpeg = fakeEncoder();
457
+ base.runState = "PRODUCING";
450
458
  await manager.prepareVariant(BASE_ID, 540, 100);
451
459
 
452
460
  // The viewer has not moved: the rung they are watching goes on asking for its
@@ -521,6 +529,7 @@ test("a playlist or an init segment does not move the encoder", async (t) => {
521
529
  manager.sessionsById.set(VARIANT_ID, variant);
522
530
  base.variants = new Map([[540, VARIANT_ID]]);
523
531
  base.ffmpeg = fakeEncoder();
532
+ base.runState = "PRODUCING";
524
533
 
525
534
  base.variants = new Map([[540, VARIANT_ID]]);
526
535
  await manager.resolveVariantFile(BASE_ID, 540, "index.m3u8");
@@ -719,6 +728,7 @@ test("an audio track is prepared at the position the switch will land on", async
719
728
  rendition.audioOnly = true;
720
729
  rendition.audioTrackIndex = 1;
721
730
  rendition.ffmpeg = fakeEncoder();
731
+ rendition.runState = "PRODUCING";
722
732
  rendition.encodeStartIndex = 0;
723
733
  manager.sessionsById.set(VARIANT_ID, rendition);
724
734
  base.audioRenditionSessions = new Map([[1, VARIANT_ID]]);
@@ -790,6 +800,7 @@ test("a quality step being warmed is not refused by its own cost", async (t) =>
790
800
  // Running, and running well: it says of itself that it holds twice realtime,
791
801
  // i.e. half a second of work per second of video.
792
802
  warming.ffmpeg = fakeEncoder();
803
+ warming.runState = "PRODUCING";
793
804
  warming.lastAloneSpeed = 2;
794
805
  manager.sessionsById.set(BASE_ID, base);
795
806
  manager.sessionsById.set(VARIANT_ID, warming);
@@ -1,338 +1,334 @@
1
- /**
2
- * @file A finished segment on disk must reach the viewer as bytes.
3
- *
4
- * The module tests cover each piece of the fMP4 path on its own, and every one
5
- * of them passed while playback was dead: 2.9.124 called
6
- * `readSelfContainedStartSeconds` from `fmp4.js` without importing it, and the
7
- * unit test imports that function straight from `mp4-boxes.js`, so the gap
8
- * between a module and its CALLER was invisible. This test asks the session
9
- * manager for a segment that exists and insists on getting it.
10
- *
11
- * The second half pins the reason a one-word slip cost a whole release: the
12
- * failure was reported as "still being produced". Measured 2026-08-08 — segment
13
- * #0 was held for 45 281 ms with twelve finished segments in the directory, and
14
- * the log said nothing at all. Anything that goes wrong while preparing a file
15
- * that EXISTS must be named and answered, never turned into an endless wait.
16
- */
17
-
18
- import test from "node:test";
19
- import assert from "node:assert/strict";
20
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
21
- import os from "node:os";
22
- import path from "node:path";
23
- import { HlsSessionManager } from "../services/hls-session-manager.js";
24
- import { ENCODE_RUN_STATE } from "../services/encode-run-state.js";
25
- import { fmp4Format } from "../services/segment-formats/fmp4.js";
26
-
27
- const MOVIE_TIMESCALE = 1000;
28
- const VIDEO_TIMESCALE = 90_000;
29
- const AUDIO_TIMESCALE = 48_000;
30
- const SEGMENT_START_SECONDS = 12.5;
31
- const SESSION_ID = "11111111-2222-3333-4444-555555555555";
32
-
33
- /**
34
- * @param {string} type
35
- * @param {Buffer} body
36
- * @returns {Buffer}
37
- */
38
- function box(type, body) {
39
- const head = Buffer.alloc(8);
40
- head.writeUInt32BE(8 + body.length, 0);
41
- head.write(type, 4, "latin1");
42
- return Buffer.concat([head, body]);
43
- }
44
-
45
- /**
46
- * `elst` holding one empty edit — how the `segment` muxer records where the
47
- * piece sits on the source timeline.
48
- *
49
- * @param {number} offsetSeconds
50
- * @returns {Buffer}
51
- */
52
- function emptyEdit(offsetSeconds) {
53
- const body = Buffer.alloc(16);
54
- body.writeUInt32BE(1, 4); // entry count
55
- body.writeUInt32BE(Math.round(offsetSeconds * MOVIE_TIMESCALE), 8); // duration
56
- body.writeInt32BE(-1, 12); // media_time
57
- return box("elst", body);
58
- }
59
-
60
- /**
61
- * @param {number} trackId
62
- * @param {number} timescale
63
- * @param {number} offsetSeconds
64
- * @returns {Buffer}
65
- */
66
- function trak(trackId, timescale, offsetSeconds) {
67
- const tkhdBody = Buffer.alloc(84);
68
- tkhdBody.writeUInt32BE(trackId, 12);
69
- const mdhdBody = Buffer.alloc(20);
70
- mdhdBody.writeUInt32BE(timescale, 12);
71
- return box("trak", Buffer.concat([
72
- box("tkhd", tkhdBody),
73
- box("edts", emptyEdit(offsetSeconds)),
74
- box("mdia", box("mdhd", mdhdBody))
75
- ]));
76
- }
77
-
78
- /**
79
- * @param {number} trackId
80
- * @returns {Buffer}
81
- */
82
- function traf(trackId) {
83
- const tfhdBody = Buffer.alloc(8);
84
- tfhdBody.writeUInt32BE(trackId, 4);
85
- const tfdtBody = Buffer.alloc(12);
86
- tfdtBody.writeUInt8(1, 0); // version 1 — 64-bit
87
- tfdtBody.writeBigUInt64BE(0n, 4); // what ffmpeg writes: zero
88
- return box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
89
- }
90
-
91
- /**
92
- * A piece shaped like the `segment` muxer's output: header, two fragments and a
93
- * trailing random-access index, all in one file.
94
- *
95
- * @param {number} offsetSeconds
96
- * @returns {Buffer}
97
- */
98
- function selfContainedPiece(offsetSeconds) {
99
- const mvhdBody = Buffer.alloc(100);
100
- mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
101
- const moov = box("moov", Buffer.concat([
102
- box("mvhd", mvhdBody),
103
- trak(1, VIDEO_TIMESCALE, offsetSeconds),
104
- trak(2, AUDIO_TIMESCALE, offsetSeconds)
105
- ]));
106
- const moof = box("moof", Buffer.concat([traf(1), traf(2)]));
107
- const mdat = box("mdat", Buffer.alloc(64, 0x5a));
108
- const mfra = box("mfra", Buffer.alloc(24, 0));
109
- return Buffer.concat([box("ftyp", Buffer.alloc(16, 0)), moov, moof, mdat, mfra]);
110
- }
111
-
112
- /**
113
- * A manager holding one session whose segments are already on disk, cut at
114
- * explicit times — the ordinary keyframe-cut path.
115
- *
116
- * @param {{ segmentFormat?: object }} [overrides]
117
- * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
118
- */
119
- async function managerWithReadySegment(overrides = {}) {
120
- const dirPath = await mkdtemp(path.join(os.tmpdir(), "segment-serve-"));
121
- const piece = selfContainedPiece(SEGMENT_START_SECONDS);
122
- // Two segments, because a piece is only finished once the next one exists.
123
- await writeFile(path.join(dirPath, "segment-00000.mp4"), piece);
124
- await writeFile(path.join(dirPath, "segment-00001.mp4"), piece);
125
-
126
- const manager = new HlsSessionManager({
127
- enabled: true,
128
- ffmpegBin: "ffmpeg",
129
- localBindHost: "127.0.0.1",
130
- localPort: 9090
131
- });
132
- const session = {
133
- id: SESSION_ID,
134
- dirPath,
135
- state: "ready",
136
- fileName: "video.mkv",
137
- startedAt: Date.now(),
138
- createEntryMs: Date.now(),
139
- lastAccessedAt: Date.now(),
140
- ffmpeg: null,
141
- lastError: "",
142
- consumers: new Set(),
143
- segmentFormat: overrides.segmentFormat ?? fmp4Format,
144
- usesExplicitCuts: true,
145
- useSyntheticPlaylist: true,
146
- playlistText: "#EXTM3U\n",
147
- segmentBoundaries: [0, SEGMENT_START_SECONDS, 25],
148
- initBytes: fmp4Format.extractInit(piece),
149
- encodeStartIndex: 0,
150
- firstSegmentLogged: false,
151
- waitEpoch: 0
152
- };
153
- manager.sessionsById.set(SESSION_ID, session);
154
- return { manager, session, dirPath };
155
- }
156
-
157
- test("serving a segment records what its real start says about the container's index", async (t) => {
158
- const { manager, session, dirPath } = await managerWithReadySegment();
159
- t.after(async () => {
160
- await manager.disposeAll();
161
- await rm(dirPath, { recursive: true, force: true });
162
- });
163
- // The tally is counted in the module tests; what this pins is that serving a
164
- // segment reaches it at all. A counter nothing increments reports a clean
165
- // index for every file forever, which is worse than no measurement.
166
- session.indexCheck = { checked: 0, disagreed: 0, maxDeviationSec: 0, firstDisagreementIndex: -1, seen: new Set() };
167
-
168
- await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
169
-
170
- assert.equal(session.indexCheck.checked, 1, "the boundary that was just produced must have been examined");
171
- });
172
-
173
- test("a segment that exists is served, not reported as still being produced", async (t) => {
174
- const { manager, dirPath, session } = await managerWithReadySegment();
175
- t.after(async () => {
176
- await manager.disposeAll();
177
- await rm(dirPath, { recursive: true, force: true });
178
- });
179
-
180
- const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
181
-
182
- assert.equal(result.kind, "file", "a finished segment on disk must come back as bytes");
183
- assert.equal(result.contentType, fmp4Format.segmentContentType);
184
-
185
- const chunks = [];
186
- for await (const chunk of result.stream) {
187
- chunks.push(chunk);
188
- }
189
- const served = Buffer.concat(chunks);
190
- assert.equal(served.toString("latin1", 4, 8), "moof", "the init header must be stripped off a media segment");
191
-
192
- // Where the PLAYLIST the player holds puts this segment which is what the
193
- // fragment must be stamped with whenever the piece's own position is further
194
- // away than a player will bridge. This fixture's piece says 12.5 s while the
195
- // playlist says 0, and stamping the piece's figure is what killed a film on
196
- // 2026-08-17: the browser asked for two segments 1908 times each over ten
197
- // minutes, each served in 4 ms, because a fragment landing 2.5 s from where
198
- // it was expected is not recognised as buffered. Reading the piece's own
199
- // position still happens — it is the step that threw in 2.9.124, it feeds the
200
- // index tally and it corrects the grid for rungs made later — it just no
201
- // longer contradicts the timeline the player was sent. See
202
- // test/published-timeline.test.js for the rule itself.
203
- assert.equal(
204
- Number(served.readBigUInt64BE(served.indexOf("tfdt") + 8)),
205
- 0,
206
- "the segment must be stamped where the playlist the player holds says it begins"
207
- );
208
- assert.equal(
209
- session.indexCheck.checked,
210
- 1,
211
- "and the piece's own position must still have been read, or nothing measures the index"
212
- );
213
- });
214
-
215
- test("a fault while preparing an existing segment is named, not turned into a wait", async (t) => {
216
- const broken = {
217
- ...fmp4Format,
218
- readSegmentStartSeconds() {
219
- throw new ReferenceError("readSelfContainedStartSeconds is not defined");
220
- }
221
- };
222
- const { manager, dirPath } = await managerWithReadySegment({ segmentFormat: broken });
223
- t.after(async () => {
224
- await manager.disposeAll();
225
- await rm(dirPath, { recursive: true, force: true });
226
- });
227
-
228
- const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
229
-
230
- assert.equal(
231
- result.kind,
232
- "failed",
233
- "answering 'warming-up' hides the fault and holds every request until the viewer gives up"
234
- );
235
- assert.match(result.message, /readSelfContainedStartSeconds is not defined/);
236
- });
237
-
238
- test("a run's FIRST segment is served once the encoder has passed it, without waiting for a next one", async (t) => {
239
- const { manager, session, dirPath } = await managerWithReadySegment();
240
- t.after(async () => {
241
- await manager.disposeAll();
242
- await rm(dirPath, { recursive: true, force: true });
243
- });
244
- // The shape a resume takes: a run begun mid-file, so its first segment has no
245
- // successor and nothing is producing one. Waiting for that successor is what
246
- // held #317 for 46 s and then answered 404 to a browser that had given up.
247
- await rm(path.join(dirPath, "segment-00001.mp4"));
248
- session.encodeStartIndex = 0;
249
- session.ffmpeg = { killed: true, kill() {} };
250
- session.progress = { processedSeconds: SEGMENT_START_SECONDS + 10 };
251
-
252
- const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
253
-
254
- assert.equal(
255
- result.kind,
256
- "file",
257
- "the encoder is past this segment's end, so it is finished the absence of a next one says nothing"
258
- );
259
- });
260
-
261
- test("a segment is found in the run directory that produced it, newest run first", async (t) => {
262
- const { manager, session, dirPath } = await managerWithReadySegment();
263
- t.after(async () => {
264
- await manager.disposeAll();
265
- await rm(dirPath, { recursive: true, force: true });
266
- });
267
- // Runs write into a directory each — that is what lets a restart begin
268
- // without waiting for its predecessor to die, which measured 0.7-1.3 s of
269
- // every seek. A later run's answer supersedes an earlier one's, because the
270
- // older file may be the truncated output of a run that was killed mid-write.
271
- const { mkdir } = await import("node:fs/promises");
272
- const piece = selfContainedPiece(SEGMENT_START_SECONDS);
273
- await mkdir(path.join(dirPath, "run-1"), { recursive: true });
274
- await mkdir(path.join(dirPath, "run-2"), { recursive: true });
275
- await writeFile(path.join(dirPath, "run-1", "segment-00000.mp4"), Buffer.alloc(8));
276
- await writeFile(path.join(dirPath, "run-2", "segment-00000.mp4"), piece);
277
- await writeFile(path.join(dirPath, "run-2", "segment-00001.mp4"), piece);
278
- await rm(path.join(dirPath, "segment-00000.mp4"));
279
- await rm(path.join(dirPath, "segment-00001.mp4"));
280
- session.encodeStartIndex = 0;
281
-
282
- const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
283
-
284
- assert.equal(result.kind, "file", "a segment produced by a run must be found in that run's directory");
285
- const chunks = [];
286
- for await (const chunk of result.stream) {
287
- chunks.push(chunk);
288
- }
289
- assert.ok(
290
- Buffer.concat(chunks).length > 8,
291
- "the newest run's output must win the older file here is the 8-byte stub a killed run leaves"
292
- );
293
- });
294
-
295
- test("serving a run's own segment moves the run out of STARTING", async (t) => {
296
- const { manager, session, dirPath } = await managerWithReadySegment();
297
- t.after(async () => {
298
- await manager.disposeAll();
299
- await rm(dirPath, { recursive: true, force: true });
300
- });
301
- // The table lives in `encode-run-state.js` and is tested there as a graph.
302
- // What this pins is that a real serve REACHES it: a state nothing writes
303
- // describes every run as still starting, for ever, and the log built on it
304
- // would say so too.
305
- session.runState = ENCODE_RUN_STATE.STARTING;
306
- // Where the run in force is writing. The fixture's segments live directly in
307
- // the session directory, which is exactly what a single run's directory is
308
- // here.
309
- session.runDirPath = dirPath;
310
-
311
- await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
312
-
313
- assert.equal(session.runState, ENCODE_RUN_STATE.PRODUCING);
314
- });
315
-
316
- test("a segment left by an earlier run does not claim the new run has produced", async (t) => {
317
- const { manager, session, dirPath } = await managerWithReadySegment();
318
- t.after(async () => {
319
- await manager.disposeAll();
320
- await rm(dirPath, { recursive: true, force: true });
321
- });
322
- // A seek places the new run in a directory of its own; the previous run's
323
- // segments stay servable and are served from theirs. They say nothing about
324
- // what the run now starting has done — and a run believed to be producing is
325
- // one the look-ahead may suspend and the seek path may wave through as
326
- // "already covered by the running encode".
327
- //
328
- // Deliberately a segment ABOVE the new run's start index, because that is the
329
- // case an index comparison gets wrong: after a backward seek the old run's
330
- // output sits ahead of the new run's beginning.
331
- session.runState = ENCODE_RUN_STATE.STARTING;
332
- session.encodeStartIndex = 0;
333
- session.runDirPath = path.join(dirPath, "run-7");
334
-
335
- await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
336
-
337
- assert.equal(session.runState, ENCODE_RUN_STATE.STARTING);
338
- });
1
+ /**
2
+ * @file A finished segment on disk must reach the viewer as bytes.
3
+ *
4
+ * The module tests cover each piece of the fMP4 path on its own, and every one
5
+ * of them passed while playback was dead: 2.9.124 called
6
+ * `readSelfContainedStartSeconds` from `fmp4.js` without importing it, and the
7
+ * unit test imports that function straight from `mp4-boxes.js`, so the gap
8
+ * between a module and its CALLER was invisible. This test asks the session
9
+ * manager for a segment that exists and insists on getting it.
10
+ *
11
+ * The second half pins the reason a one-word slip cost a whole release: the
12
+ * failure was reported as "still being produced". Measured 2026-08-08 — segment
13
+ * #0 was held for 45 281 ms with twelve finished segments in the directory, and
14
+ * the log said nothing at all. Anything that goes wrong while preparing a file
15
+ * that EXISTS must be named and answered, never turned into an endless wait.
16
+ */
17
+
18
+ import test from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import { HlsSessionManager } from "../services/hls-session-manager.js";
24
+ import { ENCODE_RUN_STATE } from "../services/encode-run-state.js";
25
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
26
+
27
+ const MOVIE_TIMESCALE = 1000;
28
+ const VIDEO_TIMESCALE = 90_000;
29
+ const AUDIO_TIMESCALE = 48_000;
30
+ const SEGMENT_START_SECONDS = 12.5;
31
+ const SESSION_ID = "11111111-2222-3333-4444-555555555555";
32
+
33
+ /**
34
+ * @param {string} type
35
+ * @param {Buffer} body
36
+ * @returns {Buffer}
37
+ */
38
+ function box(type, body) {
39
+ const head = Buffer.alloc(8);
40
+ head.writeUInt32BE(8 + body.length, 0);
41
+ head.write(type, 4, "latin1");
42
+ return Buffer.concat([head, body]);
43
+ }
44
+
45
+ /**
46
+ * `elst` holding one empty edit — how the `segment` muxer records where the
47
+ * piece sits on the source timeline.
48
+ *
49
+ * @param {number} offsetSeconds
50
+ * @returns {Buffer}
51
+ */
52
+ function emptyEdit(offsetSeconds) {
53
+ const body = Buffer.alloc(16);
54
+ body.writeUInt32BE(1, 4); // entry count
55
+ body.writeUInt32BE(Math.round(offsetSeconds * MOVIE_TIMESCALE), 8); // duration
56
+ body.writeInt32BE(-1, 12); // media_time
57
+ return box("elst", body);
58
+ }
59
+
60
+ /**
61
+ * @param {number} trackId
62
+ * @param {number} timescale
63
+ * @param {number} offsetSeconds
64
+ * @returns {Buffer}
65
+ */
66
+ function trak(trackId, timescale, offsetSeconds) {
67
+ const tkhdBody = Buffer.alloc(84);
68
+ tkhdBody.writeUInt32BE(trackId, 12);
69
+ const mdhdBody = Buffer.alloc(20);
70
+ mdhdBody.writeUInt32BE(timescale, 12);
71
+ return box("trak", Buffer.concat([
72
+ box("tkhd", tkhdBody),
73
+ box("edts", emptyEdit(offsetSeconds)),
74
+ box("mdia", box("mdhd", mdhdBody))
75
+ ]));
76
+ }
77
+
78
+ /**
79
+ * @param {number} trackId
80
+ * @returns {Buffer}
81
+ */
82
+ function traf(trackId) {
83
+ const tfhdBody = Buffer.alloc(8);
84
+ tfhdBody.writeUInt32BE(trackId, 4);
85
+ const tfdtBody = Buffer.alloc(12);
86
+ tfdtBody.writeUInt8(1, 0); // version 1 — 64-bit
87
+ tfdtBody.writeBigUInt64BE(0n, 4); // what ffmpeg writes: zero
88
+ return box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
89
+ }
90
+
91
+ /**
92
+ * A piece shaped like the `segment` muxer's output: header, two fragments and a
93
+ * trailing random-access index, all in one file.
94
+ *
95
+ * @param {number} offsetSeconds
96
+ * @returns {Buffer}
97
+ */
98
+ function selfContainedPiece(offsetSeconds) {
99
+ const mvhdBody = Buffer.alloc(100);
100
+ mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
101
+ const moov = box("moov", Buffer.concat([
102
+ box("mvhd", mvhdBody),
103
+ trak(1, VIDEO_TIMESCALE, offsetSeconds),
104
+ trak(2, AUDIO_TIMESCALE, offsetSeconds)
105
+ ]));
106
+ const moof = box("moof", Buffer.concat([traf(1), traf(2)]));
107
+ const mdat = box("mdat", Buffer.alloc(64, 0x5a));
108
+ const mfra = box("mfra", Buffer.alloc(24, 0));
109
+ return Buffer.concat([box("ftyp", Buffer.alloc(16, 0)), moov, moof, mdat, mfra]);
110
+ }
111
+
112
+ /**
113
+ * A manager holding one session whose segments are already on disk, cut at
114
+ * explicit times — the ordinary keyframe-cut path.
115
+ *
116
+ * @param {{ segmentFormat?: object }} [overrides]
117
+ * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
118
+ */
119
+ async function managerWithReadySegment(overrides = {}) {
120
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "segment-serve-"));
121
+ const piece = selfContainedPiece(SEGMENT_START_SECONDS);
122
+ // Two segments, because a piece is only finished once the next one exists.
123
+ await writeFile(path.join(dirPath, "segment-00000.mp4"), piece);
124
+ await writeFile(path.join(dirPath, "segment-00001.mp4"), piece);
125
+
126
+ const manager = new HlsSessionManager({
127
+ enabled: true,
128
+ ffmpegBin: "ffmpeg",
129
+ localBindHost: "127.0.0.1",
130
+ localPort: 9090
131
+ });
132
+ const session = {
133
+ id: SESSION_ID,
134
+ dirPath,
135
+ state: "ready",
136
+ fileName: "video.mkv",
137
+ startedAt: Date.now(),
138
+ createEntryMs: Date.now(),
139
+ lastAccessedAt: Date.now(),
140
+ ffmpeg: null,
141
+ lastError: "",
142
+ consumers: new Set(),
143
+ segmentFormat: overrides.segmentFormat ?? fmp4Format,
144
+ usesExplicitCuts: true,
145
+ useSyntheticPlaylist: true,
146
+ playlistText: "#EXTM3U\n",
147
+ segmentBoundaries: [0, SEGMENT_START_SECONDS, 25],
148
+ initBytes: fmp4Format.extractInit(piece),
149
+ encodeStartIndex: 0,
150
+ firstSegmentLogged: false,
151
+ waitEpoch: 0
152
+ };
153
+ manager.sessionsById.set(SESSION_ID, session);
154
+ return { manager, session, dirPath };
155
+ }
156
+
157
+ test("serving a segment records what its real start says about the container's index", async (t) => {
158
+ const { manager, session, dirPath } = await managerWithReadySegment();
159
+ t.after(async () => {
160
+ await manager.disposeAll();
161
+ await rm(dirPath, { recursive: true, force: true });
162
+ });
163
+ // The tally is counted in the module tests; what this pins is that serving a
164
+ // segment reaches it at all. A counter nothing increments reports a clean
165
+ // index for every file forever, which is worse than no measurement.
166
+ session.indexCheck = { checked: 0, disagreed: 0, maxDeviationSec: 0, firstDisagreementIndex: -1, seen: new Set() };
167
+
168
+ await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
169
+
170
+ assert.equal(session.indexCheck.checked, 1, "the boundary that was just produced must have been examined");
171
+ });
172
+
173
+ test("a segment that exists is served, not reported as still being produced", async (t) => {
174
+ const { manager, dirPath, session } = await managerWithReadySegment();
175
+ t.after(async () => {
176
+ await manager.disposeAll();
177
+ await rm(dirPath, { recursive: true, force: true });
178
+ });
179
+
180
+ const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
181
+
182
+ assert.equal(result.kind, "file", "a finished segment on disk must come back as bytes");
183
+ assert.equal(result.contentType, fmp4Format.segmentContentType);
184
+
185
+ const chunks = [];
186
+ for await (const chunk of result.stream) {
187
+ chunks.push(chunk);
188
+ }
189
+ const served = Buffer.concat(chunks);
190
+ assert.equal(served.toString("latin1", 4, 8), "moof", "the init header must be stripped off a media segment");
191
+
192
+ // The piece's OWN start, carried into the fragment it belongs to. Two
193
+ // releases tried moving it toward the playlist instead (2.24.1 per session,
194
+ // 2.25.0 by one family offset) and both desynced picture from sound in the
195
+ // field the same day: the first segment of a run is not cut, it begins where
196
+ // the seek landed, and the picture must land on a keyframe while the sound
197
+ // need not. Reading the piece's position is also the step that threw in
198
+ // 2.9.124, and it still feeds the index tally and the grid correction.
199
+ assert.equal(
200
+ Number(served.readBigUInt64BE(served.indexOf("tfdt") + 8)),
201
+ Math.round(SEGMENT_START_SECONDS * VIDEO_TIMESCALE),
202
+ "the segment must be stamped with where it really begins"
203
+ );
204
+ assert.equal(
205
+ session.indexCheck.checked,
206
+ 1,
207
+ "and the piece's own position must still have been read, or nothing measures the index"
208
+ );
209
+ });
210
+
211
+ test("a fault while preparing an existing segment is named, not turned into a wait", async (t) => {
212
+ const broken = {
213
+ ...fmp4Format,
214
+ readSegmentStartSeconds() {
215
+ throw new ReferenceError("readSelfContainedStartSeconds is not defined");
216
+ }
217
+ };
218
+ const { manager, dirPath } = await managerWithReadySegment({ segmentFormat: broken });
219
+ t.after(async () => {
220
+ await manager.disposeAll();
221
+ await rm(dirPath, { recursive: true, force: true });
222
+ });
223
+
224
+ const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
225
+
226
+ assert.equal(
227
+ result.kind,
228
+ "failed",
229
+ "answering 'warming-up' hides the fault and holds every request until the viewer gives up"
230
+ );
231
+ assert.match(result.message, /readSelfContainedStartSeconds is not defined/);
232
+ });
233
+
234
+ test("a run's FIRST segment is served once the encoder has passed it, without waiting for a next one", async (t) => {
235
+ const { manager, session, dirPath } = await managerWithReadySegment();
236
+ t.after(async () => {
237
+ await manager.disposeAll();
238
+ await rm(dirPath, { recursive: true, force: true });
239
+ });
240
+ // The shape a resume takes: a run begun mid-file, so its first segment has no
241
+ // successor and nothing is producing one. Waiting for that successor is what
242
+ // held #317 for 46 s and then answered 404 to a browser that had given up.
243
+ await rm(path.join(dirPath, "segment-00001.mp4"));
244
+ session.encodeStartIndex = 0;
245
+ session.ffmpeg = { killed: true, kill() {} };
246
+ session.progress = { processedSeconds: SEGMENT_START_SECONDS + 10 };
247
+
248
+ const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
249
+
250
+ assert.equal(
251
+ result.kind,
252
+ "file",
253
+ "the encoder is past this segment's end, so it is finished — the absence of a next one says nothing"
254
+ );
255
+ });
256
+
257
+ test("a segment is found in the run directory that produced it, newest run first", async (t) => {
258
+ const { manager, session, dirPath } = await managerWithReadySegment();
259
+ t.after(async () => {
260
+ await manager.disposeAll();
261
+ await rm(dirPath, { recursive: true, force: true });
262
+ });
263
+ // Runs write into a directory each — that is what lets a restart begin
264
+ // without waiting for its predecessor to die, which measured 0.7-1.3 s of
265
+ // every seek. A later run's answer supersedes an earlier one's, because the
266
+ // older file may be the truncated output of a run that was killed mid-write.
267
+ const { mkdir } = await import("node:fs/promises");
268
+ const piece = selfContainedPiece(SEGMENT_START_SECONDS);
269
+ await mkdir(path.join(dirPath, "run-1"), { recursive: true });
270
+ await mkdir(path.join(dirPath, "run-2"), { recursive: true });
271
+ await writeFile(path.join(dirPath, "run-1", "segment-00000.mp4"), Buffer.alloc(8));
272
+ await writeFile(path.join(dirPath, "run-2", "segment-00000.mp4"), piece);
273
+ await writeFile(path.join(dirPath, "run-2", "segment-00001.mp4"), piece);
274
+ await rm(path.join(dirPath, "segment-00000.mp4"));
275
+ await rm(path.join(dirPath, "segment-00001.mp4"));
276
+ session.encodeStartIndex = 0;
277
+
278
+ const result = await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
279
+
280
+ assert.equal(result.kind, "file", "a segment produced by a run must be found in that run's directory");
281
+ const chunks = [];
282
+ for await (const chunk of result.stream) {
283
+ chunks.push(chunk);
284
+ }
285
+ assert.ok(
286
+ Buffer.concat(chunks).length > 8,
287
+ "the newest run's output must win — the older file here is the 8-byte stub a killed run leaves"
288
+ );
289
+ });
290
+
291
+ test("serving a run's own segment moves the run out of STARTING", async (t) => {
292
+ const { manager, session, dirPath } = await managerWithReadySegment();
293
+ t.after(async () => {
294
+ await manager.disposeAll();
295
+ await rm(dirPath, { recursive: true, force: true });
296
+ });
297
+ // The table lives in `encode-run-state.js` and is tested there as a graph.
298
+ // What this pins is that a real serve REACHES it: a state nothing writes
299
+ // describes every run as still starting, for ever, and the log built on it
300
+ // would say so too.
301
+ session.runState = ENCODE_RUN_STATE.STARTING;
302
+ // Where the run in force is writing. The fixture's segments live directly in
303
+ // the session directory, which is exactly what a single run's directory is
304
+ // here.
305
+ session.runDirPath = dirPath;
306
+
307
+ await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
308
+
309
+ assert.equal(session.runState, ENCODE_RUN_STATE.PRODUCING);
310
+ });
311
+
312
+ test("a segment left by an earlier run does not claim the new run has produced", async (t) => {
313
+ const { manager, session, dirPath } = await managerWithReadySegment();
314
+ t.after(async () => {
315
+ await manager.disposeAll();
316
+ await rm(dirPath, { recursive: true, force: true });
317
+ });
318
+ // A seek places the new run in a directory of its own; the previous run's
319
+ // segments stay servable and are served from theirs. They say nothing about
320
+ // what the run now starting has done — and a run believed to be producing is
321
+ // one the look-ahead may suspend and the seek path may wave through as
322
+ // "already covered by the running encode".
323
+ //
324
+ // Deliberately a segment ABOVE the new run's start index, because that is the
325
+ // case an index comparison gets wrong: after a backward seek the old run's
326
+ // output sits ahead of the new run's beginning.
327
+ session.runState = ENCODE_RUN_STATE.STARTING;
328
+ session.encodeStartIndex = 0;
329
+ session.runDirPath = path.join(dirPath, "run-7");
330
+
331
+ await manager.getFileStream(SESSION_ID, "segment-00000.mp4", { requestSeq: 1 });
332
+
333
+ assert.equal(session.runState, ENCODE_RUN_STATE.STARTING);
334
+ });