@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.
- package/CHANGELOG.md +23 -0
- package/bin/cli.js +20 -1
- package/biome.json +1 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/net-report/post.js +5 -0
- package/services/encode/EncodePlan.js +38 -12
- package/services/encode/EncodeRun.js +70 -0
- package/services/encode/SegmentStore.js +59 -0
- package/services/encode/open-piece.js +88 -0
- package/services/encode/run-budget.js +89 -0
- package/services/encode/run-command.js +15 -0
- package/services/encode/run-costs.js +94 -0
- package/services/hls-session-manager.js +99 -344
- package/services/orchestrators/EncodeOrchestrator.js +134 -17
- package/services/{encode/DemandMap.js → priority/PriorityMap.js} +99 -34
- package/services/usrsctp-state.js +35 -6
- package/services/viewer/Viewer.js +83 -0
- package/test/encode-orchestrator.test.js +36 -8
- package/test/encode-plan.test.js +67 -5
- package/test/one-authority.test.js +105 -0
- package/test/{demand-map.test.js → priority-map.test.js} +49 -12
- package/test/usrsctp-state.test.js +18 -2
|
@@ -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 "./
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3463
|
-
|
|
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/
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
4083
|
-
//
|
|
4084
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
5674
|
-
//
|
|
5675
|
-
//
|
|
5676
|
-
//
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
//
|
|
5680
|
-
//
|
|
5681
|
-
//
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
//
|
|
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
|
-
//
|
|
6472
|
-
//
|
|
6473
|
-
//
|
|
6474
|
-
//
|
|
6475
|
-
//
|
|
6476
|
-
|
|
6477
|
-
//
|
|
6478
|
-
//
|
|
6479
|
-
//
|
|
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
|
-
//
|
|
6517
|
-
//
|
|
6518
|
-
|
|
6519
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9840
|
-
|
|
9841
|
-
|
|
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) {
|