@torrent-tv/proxy 2.78.0 → 2.80.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.
@@ -23,7 +23,7 @@ import { speedFromReadings } from "./encoder-readings.js";
23
23
  import { availableShareFrom } from "./available-share.js";
24
24
  import { contentionPenalty } from "./contention.js";
25
25
  import { minimumBufferFrom } from "./supply-margin.js";
26
- import { mapForViewer } from "./encode/DemandMap.js";
26
+ import { mapForViewer } from "./priority/PriorityMap.js";
27
27
  import { baseDrawFrom, costPerMegabyteFrom } from "./torrent-cost.js";
28
28
  import { medianOf, movedBeyondScatter, scatterOf } from "./learned-median.js";
29
29
  import {
@@ -69,6 +69,7 @@ import { Output, Outputs } from "./output/Output.js";
69
69
  import { masterPlaylistText, mediaPlaylistText, segmentIndexForTime } from "./output/playlists.js";
70
70
  import { SourceFiles, sourceDecodeCharacteristics } from "./source/SourceFile.js";
71
71
  import { ProducedIndex } from "./produced-index.js";
72
+ import { discardOpenPiece } from "./encode/open-piece.js";
72
73
  import { SegmentStore } from "./encode/SegmentStore.js";
73
74
  import { EncodeCost } from "./quality/EncodeCost.js";
74
75
  import {
@@ -148,82 +149,6 @@ export function contiguousEnd(present, from) {
148
149
  return last;
149
150
  }
150
151
 
151
- /**
152
- * Remove the piece a run had open when it ended, if that piece is unusable.
153
- *
154
- * The `segment` muxer creates its output file when it OPENS it and writes into
155
- * it until the next cut, so at any instant exactly one file in a run's
156
- * directory is unfinished: the highest-numbered one. A run that reaches the end
157
- * of its work closes that file and it is a good piece; a run killed for a seek
158
- * does not — measured 2026-09-03, ffmpeg exited 19 ms after SIGTERM and left
159
- * `segment-00025.mp4` at zero bytes, which then closed the only hole in the
160
- * numbering and convinced the look-ahead to keep the encoder stopped for having
161
- * "produced" it.
162
- *
163
- * Only an unusable piece goes. A run stopped between two cuts leaves a finished
164
- * file behind, and deleting good output would mean making it a second time.
165
- *
166
- * @param {string | null | undefined} runDirPath
167
- * @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} segmentFormat
168
- * @param {((raw: Buffer) => boolean) | null} judgeUsable - Whether a non-empty
169
- * piece carries what it should. Null where nothing can say, and then only an
170
- * empty file is removed.
171
- * @returns {Promise<number | null>} The segment number removed, or null.
172
- */
173
- export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeUsable) {
174
- if (!runDirPath || typeof segmentFormat?.isSegmentFileName !== "function") {
175
- return null;
176
- }
177
- // Only inside the stretch the ended run was given. Every run of an output
178
- // writes into one directory now — they are kept apart by their intervals
179
- // rather than by a directory each — so the highest-numbered file in there may
180
- // belong to a run that is still going, and removing it would take away a
181
- // piece somebody is producing.
182
- const from = Number.isInteger(within?.from) ? within.from : 0;
183
- const to = Number.isInteger(within?.to) && within.to >= from ? within.to : Number.MAX_SAFE_INTEGER;
184
- let highest = null;
185
- try {
186
- for (const name of await readdir(runDirPath)) {
187
- if (!segmentFormat.isSegmentFileName(name)) {
188
- continue;
189
- }
190
- const index = segmentFormat.segmentIndexFromName(name);
191
- if (index < from || index > to) {
192
- continue;
193
- }
194
- if (index >= 0 && (highest === null || index > highest.index)) {
195
- highest = { index, name };
196
- }
197
- }
198
- } catch {
199
- return null; // The run wrote nothing, or its directory is already gone.
200
- }
201
- if (highest === null) {
202
- return null;
203
- }
204
- const filePath = path.join(runDirPath, highest.name);
205
- let unusable = false;
206
- try {
207
- const info = await stat(filePath);
208
- if (info.size === 0) {
209
- unusable = true;
210
- } else if (typeof judgeUsable === "function") {
211
- unusable = !judgeUsable(await readFile(filePath));
212
- }
213
- } catch {
214
- return null; // Gone between the listing and the question.
215
- }
216
- if (!unusable) {
217
- return null;
218
- }
219
- try {
220
- await unlink(filePath);
221
- return highest.index;
222
- } catch {
223
- return null; // Already removed.
224
- }
225
- }
226
-
227
152
  /**
228
153
  * Which segment numbers a session actually holds, across every run it has had.
229
154
  *
@@ -1746,10 +1671,10 @@ export class HlsSessionManager {
1746
1671
  });
1747
1672
  this.encodeOrchestrator = new EncodeOrchestrator({
1748
1673
  maxRunsFor: (address) => this.maxRunsForOutput(address),
1749
- makeRun: ({ address, from }) => this.#makeRunAt(address, from),
1674
+ makeRun: ({ address, from, to }) => this.#makeRunAt(address, from, to),
1750
1675
  segmentSeconds: this.segmentDurationSec,
1751
1676
  restartCostSec: RUN_RESTART_COST_SEC,
1752
- lookaheadSegments: Math.ceil(this.lookaheadSeconds / this.segmentDurationSec),
1677
+ segmentStore: this.segmentStore,
1753
1678
  logger
1754
1679
  });
1755
1680
  // Where each file is cut, held once per file and grid rather than once per
@@ -2006,8 +1931,7 @@ export class HlsSessionManager {
2006
1931
  if (normalizedStartPosition > 0 || existing.runs.size === 0) {
2007
1932
  const at = this.#segmentIndexForTime(existing, normalizedStartPosition);
2008
1933
  if (runStartingAt(existing, at) === null && ownRunMaking(existing, at) === null) {
2009
- void this.#startEncodeRun(existing, at, normalizedStartPosition, "a viewer opened the film here")
2010
- .catch(() => {});
1934
+ this.#startEncodeRun(existing, at, normalizedStartPosition, "a viewer opened the film here");
2011
1935
  }
2012
1936
  }
2013
1937
  existing.lastAccessedAt = Date.now();
@@ -3371,7 +3295,7 @@ export class HlsSessionManager {
3371
3295
  * @param {{ linkMbps: number, bufferedAheadSec: number, consumerId?: string, positionSeconds?: number }} report
3372
3296
  * @returns {boolean}
3373
3297
  */
3374
- recordNetReport(sessionId, { linkMbps, bufferedAheadSec, consumerId, positionSeconds }) {
3298
+ recordNetReport(sessionId, { linkMbps, bufferedAheadSec, consumerId, positionSeconds, playing }) {
3375
3299
  const named = this.sessionsById.get(sessionId);
3376
3300
  if (!named || named.state === "disposed") {
3377
3301
  return false;
@@ -3387,15 +3311,9 @@ export class HlsSessionManager {
3387
3311
  // report of one of them says nothing about the other's encoder.
3388
3312
  const session = this.#activeVariant(named, typeof consumerId === "string" ? consumerId : "");
3389
3313
  const now = Date.now();
3390
- this.viewers.of(session, typeof consumerId === "string" && consumerId.length > 0 ? consumerId : "").netReport = {
3391
- linkMbps,
3392
- bufferedAheadSec,
3393
- // Where the picture is, said by the viewer rather than worked out from
3394
- // their buffer. Null from a browser that does not send it.
3395
- positionSeconds:
3396
- Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
3397
- at: now
3398
- };
3314
+ this.viewers
3315
+ .of(session, typeof consumerId === "string" && consumerId.length > 0 ? consumerId : "")
3316
+ .report({ linkMbps, bufferedAheadSec, positionSeconds, playing }, now);
3399
3317
  // A stale reading must not go on deciding for the viewers still here: a
3400
3318
  // report describes a link at a moment, and a viewer who seeked since then
3401
3319
  // is somewhere else entirely.
@@ -3445,8 +3363,7 @@ export class HlsSessionManager {
3445
3363
  // `viewerPositionSource`. Only a seek writes it, so a request does not erase
3446
3364
  // it.
3447
3365
  const viewer = this.viewers.of(session, consumerId, now);
3448
- const seeked = viewer.position?.seeked ?? null;
3449
- viewer.position = { segment, seconds, at: now, seeked };
3366
+ viewer.moveTo(seconds, now);
3450
3367
  this.planEncodersSoon();
3451
3368
  const staleAfterMs = this.presenceStaleAfterMs();
3452
3369
  let furthest = { segment, seconds };
@@ -3459,8 +3376,9 @@ export class HlsSessionManager {
3459
3376
  this.#viewerLeaves(session, key);
3460
3377
  continue;
3461
3378
  }
3462
- if (other.position !== null && other.position.segment > furthest.segment) {
3463
- furthest = { segment: other.position.segment, seconds: other.position.seconds };
3379
+ const theirs = other.positionSeconds();
3380
+ if (theirs !== null && theirs > furthest.seconds) {
3381
+ furthest = { segment: this.#segmentIndexForTime(session, theirs), seconds: theirs };
3464
3382
  }
3465
3383
  }
3466
3384
  return furthest;
@@ -3775,7 +3693,7 @@ export class HlsSessionManager {
3775
3693
  * One viewer's demand map, translated into this output's segment numbers.
3776
3694
  *
3777
3695
  * The map itself is seconds of film and knows nothing about cut grids
3778
- * (`services/encode/DemandMap.js`). The translation is this output's own
3696
+ * (`services/priority/PriorityMap.js`). The translation is this output's own
3779
3697
  * business, and it is exact: the timeline holds the boundaries.
3780
3698
  *
3781
3699
  * Two measurements feed it, and neither is chosen here:
@@ -3792,9 +3710,13 @@ export class HlsSessionManager {
3792
3710
  * @returns {{from: number, to: number, priority: number}[]} In segment
3793
3711
  * numbers, both ends inclusive.
3794
3712
  */
3795
- #demandZonesFor(session, atSegment) {
3713
+ #demandZonesFor(session, atSeconds) {
3796
3714
  const boundaries = session.timeline?.boundaries ?? [];
3797
3715
  const segmentCount = Number(session.timeline?.segmentCount) || 0;
3716
+ // Where they are, in this output's own numbering. The viewer holds seconds;
3717
+ // every cut grid turns them into its own numbers, and two grids of one film
3718
+ // give different numbers for the same second.
3719
+ const atSegment = segmentCount > 0 ? this.#segmentIndexForTime(session, atSeconds) : 0;
3798
3720
  if (segmentCount <= 0) {
3799
3721
  // No playlist yet: the only thing that can be said is that they want
3800
3722
  // where they are.
@@ -3803,7 +3725,7 @@ export class HlsSessionManager {
3803
3725
  const durationSeconds = Number(boundaries[boundaries.length - 1]) ||
3804
3726
  segmentCount * this.segmentDurationSec;
3805
3727
  const zones = mapForViewer({
3806
- atSeconds: this.#segmentStartTime(session, atSegment),
3728
+ atSeconds,
3807
3729
  durationSeconds,
3808
3730
  allowanceSeconds: minimumBufferFrom({
3809
3731
  segmentSeconds: this.segmentDurationSec,
@@ -4008,7 +3930,11 @@ export class HlsSessionManager {
4008
3930
  // named. A viewer with no position is one assembled by hand outside
4009
3931
  // this class; they want the beginning, which is where an output
4010
3932
  // starts when nobody says otherwise.
4011
- const at = viewer.position?.segment ?? 0;
3933
+ // SECONDS, and turned into this output's own numbering below. A
3934
+ // segment number taken off the viewer would mean two different
3935
+ // moments of film on the picture and on the soundtrack, which are cut
3936
+ // independently: 454 pieces against 401 on the field file.
3937
+ const at = viewer.positionSeconds() ?? 0;
4012
3938
  // Their own map, in seconds of film, from measurements: how much must
4013
3939
  // be ready before they set off so that they never stop — the observed
4014
3940
  // allowance for this file plus what an encoder at THIS machine's
@@ -4048,7 +3974,7 @@ export class HlsSessionManager {
4048
3974
  * @param {number} from
4049
3975
  * @returns {object | null}
4050
3976
  */
4051
- #makeRunAt(address, from) {
3977
+ #makeRunAt(address, from, to) {
4052
3978
  let base = null;
4053
3979
  for (const session of this.sessionsById.values()) {
4054
3980
  if (session.outputKey === address && session.state !== "disposed") {
@@ -4079,13 +4005,18 @@ export class HlsSessionManager {
4079
4005
  if (base.failedStartAt === from && base.failedStartCount >= MAX_FAILED_STARTS) {
4080
4006
  return null;
4081
4007
  }
4082
- // Answered with nothing straight away and the run started behind it,
4083
- // because the plan is arithmetic and must not wait on a spawn.
4084
- void this.#startEncodeRun(base, from, undefined, "the plan asked for an encoder here").catch((error) => {
4008
+ // The encoder is built and handed back in this same call. Nothing here
4009
+ // waits: the one thing this path used to wait for was the death of the run
4010
+ // it was replacing, and that killing is gone. Answering with nothing while
4011
+ // the encoder was built behind the answer is what let the same stretch be
4012
+ // started over and over — 684 starts in 482 seconds of field 2026-09-05.
4013
+ try {
4014
+ return this.#startEncodeRun(base, from, undefined, "the plan asked for an encoder here", { to });
4015
+ } catch (error) {
4085
4016
  const message = error instanceof Error ? error.message : String(error);
4086
4017
  logger.warn(`transcode could not start a run at #${from} of ${address}: ${message}`);
4087
- });
4088
- return null;
4018
+ return null;
4019
+ }
4089
4020
  }
4090
4021
 
4091
4022
  /**
@@ -5657,7 +5588,16 @@ export class HlsSessionManager {
5657
5588
  return segmentCount > 0 && end >= segmentCount - 1 ? -1 : end;
5658
5589
  }
5659
5590
 
5660
- async #startEncodeRun(session, startIndex, positionSecondsOverride, because = "a viewer needs it") {
5591
+ // Returns the encoder it built, or nothing when there was nothing to build.
5592
+ // It waits for nothing: the one thing it used to wait for was the death of
5593
+ // the run it was replacing, and that killing is gone.
5594
+ #startEncodeRun(
5595
+ session,
5596
+ startIndex,
5597
+ positionSecondsOverride,
5598
+ because = "a viewer needs it",
5599
+ ordered = null
5600
+ ) {
5661
5601
  // A new run starts its own reckoning: a pair spanning the restart would
5662
5602
  // count the gap between two runs as slow encoding.
5663
5603
  session.learnSample = null;
@@ -5670,25 +5610,21 @@ export class HlsSessionManager {
5670
5610
  // does not await: everything below is the restart path, which is measured
5671
5611
  // in milliseconds and has been worked on twice to keep it that way.
5672
5612
  this.#accountBackwardRestart(session, startIndex);
5673
- // Which run, if any, is being REPLACED. A run is replaced only when the new
5674
- // one begins at or before it: that is a reposition of the same work. A run
5675
- // starting further on is a second encoder beside it, which is the whole
5676
- // point of a session holding a set.
5677
- const previousRun = liveRunsOf(session).find((run) => run.from >= startIndex) ?? null;
5678
- const previousProcess = previousRun?.process ?? null;
5679
- // The stretch the run being replaced was given, read before the new one
5680
- // takes over: with every run of an output writing into one directory, the
5681
- // piece to discard has to be looked for inside that run's own numbers.
5682
- const previousRunDirPath = previousRun ? session.dirPath : null;
5683
- const previousRunSpan = {
5684
- from: Number.isInteger(previousRun?.from) ? previousRun.from : 0,
5685
- to: Number.isInteger(previousRun?.to) ? previousRun.to : -1
5686
- };
5687
- // WHERE it starts was decided by whoever called, and is not touched here.
5688
- // Only how far it may work is answered, and it is answered by reading the
5689
- // one coverage map. Moving the start was the second authority's doing and
5690
- // is gone with it.
5691
- const runEnd = this.#runEndFrom(session, startIndex, previousRun);
5613
+ // STARTING AN ENCODER STOPS NOTHING. It used to stop any live run whose own
5614
+ // start was not below this one's a rule left over from when a session
5615
+ // held exactly one run and "the previous one" meant "the only one". Once a
5616
+ // session could hold several, that rule began killing runs the plan had
5617
+ // decided to keep: 294 stops for this reason in eight minutes of field
5618
+ // 2026-09-05, against four starts asked for by a viewer. Who is stopped is
5619
+ // decided in one place, and it is not this one.
5620
+ //
5621
+ // HOW FAR IT MAY WORK is decided by the same one place and arrives here as
5622
+ // an argument. Reading it off the coverage map a second time was the last
5623
+ // remaining second answer to that question: the plan computed `to`, passed
5624
+ // it, and the parameter list did not name it.
5625
+ const runEnd = Number.isInteger(ordered?.to)
5626
+ ? ordered.to
5627
+ : this.#runEndFrom(session, startIndex, null);
5692
5628
  // The restart backs off a segment or two from what was asked for, so the
5693
5629
  // request that prompted it is recorded under a HIGHER index than the run
5694
5630
  // starts at. Looking it up by the start index alone found nothing and the
@@ -5727,31 +5663,6 @@ export class HlsSessionManager {
5727
5663
  // current run failing. On any host with a hardware encoder that meant a
5728
5664
  // downgrade to libx264 for good on every seek. A run writes its own state,
5729
5665
  // so there is nothing left to mistake.
5730
- if (previousRun && previousProcess && !hasChildExited(previousProcess)) {
5731
- previousRun.stop("a new run is taking its place");
5732
- // NOT awaited. Runs are kept apart by their stretches, so the predecessor
5733
- // cannot corrupt this one's output by still writing; letting it die in the
5734
- // background removes 0.7-1.3 s from every seek (measured 2.9.132, where
5735
- // that wait was essentially the whole cost of a restart).
5736
- const termSentAt = Date.now();
5737
- void waitForChildExit(previousProcess, ENCODE_RUN_TERMINATE_GRACE_MS).then(async () => {
5738
- logger.info(
5739
- `transcode ${session.id} restart: previous run took ${Date.now() - termSentAt}ms to exit after SIGTERM`
5740
- );
5741
- // Only now: while the process lives it may still close the file, and a
5742
- // piece removed from under it would be written on into nothing.
5743
- await this.#discardUnfinishedPiece(session, previousRunDirPath, previousRunSpan);
5744
- });
5745
- if (!hasChildExited(previousProcess)) {
5746
- try {
5747
- previousProcess.kill("SIGKILL");
5748
- } catch {
5749
- // Best effort.
5750
- }
5751
- await waitForChildExit(previousProcess, ENCODE_RUN_TERMINATE_GRACE_MS);
5752
- await this.#discardUnfinishedPiece(session, previousRunDirPath, previousRunSpan);
5753
- }
5754
- }
5755
5666
  // A newer restart (or disposal) won the race while we were waiting for the
5756
5667
  // old process to die — it either already spawned its own replacement or
5757
5668
  // there is nothing left to start. Do not also spawn from this stale call.
@@ -5824,7 +5735,9 @@ export class HlsSessionManager {
5824
5735
  spawn: (spawnArgs) =>
5825
5736
  spawn(this.ffmpegBin, spawnArgs, {
5826
5737
  cwd: session.dirPath,
5827
- stdio: ["ignore", "pipe", "pipe"]
5738
+ // A fourth channel: the encoder names every piece it has CLOSED on it,
5739
+ // which is the only proof a piece is whole.
5740
+ stdio: ["ignore", "pipe", "pipe", "pipe"]
5828
5741
  }),
5829
5742
  logger,
5830
5743
  // The film's last segment number, which is what tells "it reached the
@@ -5834,6 +5747,7 @@ export class HlsSessionManager {
5834
5747
  session.timeline?.segmentCount > 0 ? session.timeline.segmentCount - 1 : null,
5835
5748
  inputUnavailable: (message) => isInputUnavailable(message),
5836
5749
  onProgress: (report) => this.#noteRunProgress(session, run, report),
5750
+ onClosed: (name) => this.segmentStore.markClosed(session.outputKey ?? "", session.segmentFormat.segmentIndexFromName(name)),
5837
5751
  onEnded: (ended) => this.noteRunEnded(session, run, ended)
5838
5752
  });
5839
5753
  session.runs.add(run);
@@ -5866,6 +5780,7 @@ export class HlsSessionManager {
5866
5780
  `live ${liveStart.toFixed(3)}s, apart ${(liveStart - startSeconds).toFixed(3)}s), ` +
5867
5781
  `numbering from #${safeIndex}`
5868
5782
  );
5783
+ return run;
5869
5784
  }
5870
5785
 
5871
5786
  /**
@@ -6047,7 +5962,7 @@ export class HlsSessionManager {
6047
5962
  const at = Number.isInteger(session.lastRequestedSegment)
6048
5963
  ? session.lastRequestedSegment
6049
5964
  : ended.from;
6050
- this.#startEncodeRun(session, at, undefined, "its input came back").catch(() => {});
5965
+ this.#startEncodeRun(session, at, undefined, "its input came back");
6051
5966
  }, delayMs);
6052
5967
  session.inputRetryTimer.unref?.();
6053
5968
  return;
@@ -6449,141 +6364,28 @@ export class HlsSessionManager {
6449
6364
  if (!named || named.state === "disposed") {
6450
6365
  return false;
6451
6366
  }
6452
- // The seeking viewer's OWN head moves with them. Without this their head
6453
- // would still hold the segment they asked for before the jump, and the very
6454
- // next thing they ask for — the segment at the seek target — would be
6455
- // judged stale against it and refused. That refusal, from the shared field,
6456
- // is the freeze of 2026-08-18; keeping per-viewer heads without moving them
6457
- // on a seek would bring it back one viewer at a time.
6458
- if (consumerId) {
6459
- this.viewers.of(named, consumerId).position = {
6460
- segment: this.#segmentIndexForTime(named, positionSeconds),
6461
- seconds: positionSeconds,
6462
- at: Date.now(),
6463
- // Stated, not inferred. It is what makes this viewer's position a
6464
- // "seeked" one for as long as they stay there.
6465
- seeked: positionSeconds
6466
- };
6467
- }
6468
- // The browser holds one session id for the whole file and knows nothing of
6469
- // variants, so a seek it reports means the stream on screen.
6367
+ // A SEEK DOES ONE THING: it puts the viewer where they now are.
6470
6368
  //
6471
- // The session's own figure, which is what an encode run is placed by. It is
6472
- // NOT this viewer's position that is their head above — and the two used
6473
- // to be one field called `viewerPositionSeconds`: with two viewers the name
6474
- // was a falsehood, since a session has one of these and as many positions
6475
- // as it has viewers.
6476
- named.furthestViewerSeconds = positionSeconds;
6477
- // What the viewer SAID, kept apart from what requests imply. A request is
6478
- // evidence about where the player is reading; a reported seek is the viewer
6479
- // stating where they are, and after one, requests already in flight
6480
- // describe a place that no longer exists. Field 2026-08-17: a seek to
6481
- // 2083.4 s restarted both runs at #373, a request for #371 issued before it
6482
- // arrived a second later, and the encoder was dragged back to #370 — three
6483
- // segments behind the viewer, who then waited for it to return.
6484
- named.viewerReportedSeconds = positionSeconds;
6485
- named.lastAccessedAt = Date.now();
6486
- // The audio the viewer is listening to moves with them. It is a separate
6487
- // encoder on a separate session that the browser cannot name, and nothing
6488
- // else would ever reposition it: a request far AHEAD of its run is not
6489
- // treated as a seek anywhere in this class, so after a forward jump the
6490
- // audio would be held, refused, and left grinding forward from where it
6491
- // was — the picture playing over silence for as long as the jump was.
6492
- // Only the track being LISTENED to. A track the viewer left keeps its place
6493
- // but not an encoder, and seeking it would start one for nobody — which is
6494
- // how a single viewer came to have three ffmpeg processes and three readers
6495
- // on one file (2026-08-15), enough to pin every resident piece and kill the
6496
- // session outright.
6497
- // The seeking viewer's OWN soundtrack, not every soundtrack the session
6498
- // has: with two viewers, moving the other one's audio to a position they
6499
- // are not at would take their sound away and produce for nobody.
6500
- const listening = this.#audioChoiceOf(named, consumerId);
6501
- const rendition = this.liveOutputs.renditionsOf(named).find(
6502
- (other) =>
6503
- (other.audioTrackIndex ?? 0) === listening.trackIndex &&
6504
- (other.transcodeAudio === true) === listening.transcode
6505
- );
6506
- if (rendition) {
6507
- rendition.lastAccessedAt = Date.now();
6508
- this.#seekSession(rendition, positionSeconds);
6509
- }
6510
- const onScreen = this.#activeVariant(named, consumerId);
6511
- // A seek backwards while somebody else is watching ahead must not drag
6512
- // their picture back with it: field shape of 2026-09-04, two viewers who
6513
- // opened a film together, one jumps back an hour, and the other's segments
6514
- // stop being made.
6369
+ // It used to do eleven, and wrote that position into five places: two
6370
+ // fields on this session, two more on the soundtrack's, and the viewer. It
6371
+ // also asked whether the jump would drag another viewer back, started an
6372
+ // encoder itself, cancelled outstanding requests, backed off a segment to
6373
+ // the preceding keyframe, and set a timer to restart ffmpeg. So it was a
6374
+ // third authority over the encoders beside the plan and the start path, and
6375
+ // not one of its branches ever asked what had already been made — a viewer
6376
+ // jumping into a stretch that was finished and on disk got a fresh encoder
6377
+ // for it.
6515
6378
  //
6516
- // The answer is not to refuse the seek but to give it a run of its own. A
6517
- // session holds as many runs as the machine affords, they write into the
6518
- // output's one directory, and each viewer is served whatever any of them
6519
- // has made.
6520
- if (this.#wouldDragAnotherViewerBack(onScreen, consumerId, positionSeconds)) {
6521
- const target = this.#segmentIndexForTime(onScreen, positionSeconds);
6522
- if (runStartingAt(onScreen, target) !== null || ownRunMaking(onScreen, target) !== null) {
6523
- logger.info(
6524
- `transcode ${onScreen.id} seek to ${positionSeconds.toFixed(1)}s for ${consumerId} is ` +
6525
- `already being made by a run of its own "${onScreen.file.name}"`
6526
- );
6527
- return true;
6528
- }
6529
- logger.info(
6530
- `transcode ${onScreen.id} seek to ${positionSeconds.toFixed(1)}s starting a run of its ` +
6531
- `own for ${consumerId}: another viewer is watching ahead, and one run cannot be in two ` +
6532
- `places "${onScreen.file.name}"`
6533
- );
6534
- // Not awaited: this call answers the browser, and what the viewer waits
6535
- // for afterwards is the segment, which the ordinary loading flow already
6536
- // knows how to wait for. The other viewer's picture is left alone, which
6537
- // is the whole point.
6538
- void this.#startEncodeRun(onScreen, target, positionSeconds, "a viewer jumped back past another")
6539
- .catch((error) => {
6540
- const message = error instanceof Error ? error.message : String(error);
6541
- logger.warn(`transcode ${onScreen.id} could not start a run at that position: ${message}`);
6542
- });
6543
- return true;
6544
- }
6545
- return this.#seekSession(onScreen, positionSeconds);
6546
- }
6547
-
6548
- /**
6549
- * Whether repositioning this session would take the picture away from
6550
- * somebody else.
6551
- *
6552
- * Two things have to hold. The seek has to be one that actually restarts the
6553
- * run — a position the run already covers going forward costs nobody
6554
- * anything. And another viewer has to be live and AHEAD of it, since a run
6555
- * only ever moves forward: what lies behind the furthest viewer has already
6556
- * been made, and dragging the run back is what stops it being made for them.
6557
- *
6558
- * @param {HlsSession} session
6559
- * @param {string} consumerId - The one seeking, who does not count.
6560
- * @param {number} positionSeconds
6561
- * @returns {boolean}
6562
- */
6563
- #wouldDragAnotherViewerBack(session, consumerId, positionSeconds) {
6564
- if (!session || !consumerId || !processCanBeSignalled(runStateOf(session))) {
6565
- return false;
6566
- }
6567
- const target = this.#segmentIndexForTime(session, positionSeconds);
6568
- const head = earliestRunStart(session);
6569
- if (!Number.isInteger(head) || target >= head) {
6570
- return false;
6571
- }
6572
- const staleAfterMs = this.presenceStaleAfterMs();
6573
- const now = Date.now();
6574
- for (const [otherId, viewer] of viewersOf(session)) {
6575
- if (otherId === consumerId || !viewer.isPresent(now, staleAfterMs)) {
6576
- continue;
6577
- }
6578
- if (viewer.position.segment > target) {
6579
- return true;
6580
- }
6379
+ // What follows from the move happens by itself: the priority map is built
6380
+ // from where the viewers are, and both orchestrators read the map.
6381
+ if (consumerId) {
6382
+ this.viewers.of(named, consumerId).moveTo(positionSeconds);
6581
6383
  }
6582
- return false;
6384
+ named.lastAccessedAt = Date.now();
6385
+ this.planEncodersSoon();
6386
+ return true;
6583
6387
  }
6584
6388
 
6585
-
6586
-
6587
6389
  /**
6588
6390
  * Reposition THIS session, with no forwarding.
6589
6391
  *
@@ -7276,7 +7078,7 @@ export class HlsSessionManager {
7276
7078
  // run would be discarded for nothing, which is the shape the field
7277
7079
  // already showed (eleven restarts in four minutes, eight of them dying
7278
7080
  // with `run had produced 0.0s`).
7279
- void this.#startEncodeRun(member, index, trueStart).catch(() => {});
7081
+ this.#startEncodeRun(member, index, trueStart);
7280
7082
  }
7281
7083
  }
7282
7084
 
@@ -9835,70 +9637,23 @@ export class HlsSessionManager {
9835
9637
  // running happily ahead. A segment is finished once the NEXT one has been
9836
9638
  // started, or once the run producing it has ended.
9837
9639
  if (!isPlaylist && cutsAtGivenTimes(session)) {
9640
+ // WHAT PROVES A PIECE IS WHOLE is the encoder's own word for it: it
9641
+ // names each file on a channel of its own the moment it closes it, and
9642
+ // the store keeps those names.
9643
+ //
9644
+ // What stood here instead was the existence of the NEXT file, with two
9645
+ // exceptions bolted on because it is not true. It is never true of the
9646
+ // last piece of a run — nothing is producing a next one — so the first
9647
+ // segment of every run was held: measured 2026-08-09, #807 held while
9648
+ // it lay on disk, and in August the same shape held #317 for 46 seconds
9649
+ // and then answered 404 to a browser that had given up.
9838
9650
  const index = session.segmentFormat.segmentIndexFromName(fileName);
9839
- const isLast = index >= Math.max(0, (session.timeline.boundaries?.length ?? 1) - 2);
9840
- // Only the CURRENT run can have a file open, and only its own next
9841
- // piece is evidence that it has moved on. A run that has ended closed
9842
- // everything it wrote, so its files need no such proof — and taking the
9843
- // proof from whichever run happened to hold the next number is how a
9844
- // file being written came to be read as finished.
9845
- if (!isLast && liveRunsOf(session).length > 0) {
9846
- const nextName = session.segmentFormat.segmentFileName(index + 1);
9847
- const nextPath = path.join(session.dirPath, nextName);
9848
- // The FIRST segment of a run cannot be judged by "the next one
9849
- // exists": nothing is producing a next one yet, because the run has
9850
- // only just begun here. Waiting for it holds precisely the segment a
9851
- // resume depends on — measured 2026-08-09, #807 held while it lay on
9852
- // disk, and in August the same shape held #317 for 46 s and then
9853
- // answered 404 to a browser that had given up.
9854
- //
9855
- // What "finished" means for it is that the encoder has moved PAST the
9856
- // end of its span. ffmpeg reports the output timestamp of its last
9857
- // encoded frame, so a run whose position is beyond this segment's end
9858
- // has necessarily closed it.
9859
- const isRunStart = runStartingAt(session, index) !== null;
9860
- const encoderPosition = Number(session.progress?.processedSeconds);
9861
- const segmentEnd = this.#segmentStartTime(session, index + 1);
9862
- if (isRunStart && Number.isFinite(encoderPosition) && encoderPosition > segmentEnd) {
9863
- // Past its end — it is complete, whatever the directory says about
9864
- // what comes next.
9865
- } else {
9866
- try {
9867
- await access(nextPath);
9868
- } catch {
9869
- this.#explainHold(
9870
- session,
9871
- fileName,
9872
- `it exists, but the next segment (#${index + 1}) has not been started yet`
9873
- );
9874
- return { kind: "warming-up" };
9875
- }
9876
- }
9877
- }
9878
- }
9879
- // A run that has walked into a stretch another run was given stops here.
9880
- //
9881
- // A run's own end is set when it starts, from the gaps of that moment,
9882
- // and a viewer who opened the same film somewhere else afterwards is not
9883
- // in that picture: their run took the stretch in front, and this one is
9884
- // now producing numbers they are producing too. Nothing may write a name
9885
- // another run wants — that is the whole reason runs used to be kept in
9886
- // separate directories, and this is what replaces it now that they share
9887
- // one.
9888
- if (!isPlaylist) {
9889
- const served = session.segmentFormat.segmentIndexFromName(fileName);
9890
- // The run that made this number, and whether ANOTHER run was given it.
9891
- // A run may not write a name another run wants — that is the whole
9892
- // reason runs used to be kept in separate directories — so the one that
9893
- // walked in stops where it got to.
9894
- const mine = Number.isInteger(served) && served >= 0 ? ownRunMaking(session, served) : null;
9895
- const madeByAnother = mine === null
9896
- ? null
9897
- : this.runMakingSegment(session, served, mine);
9898
- if (madeByAnother !== null) {
9899
- mine.stop(`it reached #${served}, which #${madeByAnother.from}..#${madeByAnother.to} was given`);
9651
+ if (!this.segmentStore.isClosed(session.outputKey ?? "", index)) {
9652
+ this.#explainHold(session, fileName, "the encoder has not closed it yet");
9653
+ return { kind: "warming-up" };
9900
9654
  }
9901
9655
  }
9656
+
9902
9657
  // Cold-start: log the first servable SEGMENT of this session exactly once
9903
9658
  // — the time from session-create entry to a playable first segment.
9904
9659
  if (!isPlaylist && !session.firstSegmentLogged) {