@torrent-tv/proxy 2.14.2 → 2.15.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 +12 -0
- package/package.json +1 -1
- package/routes/transcode/audio-warm/get.js +71 -0
- package/server.js +4 -0
- package/services/hls-session-manager.js +205 -19
- package/services/piece-store/shared-piece-store.js +49 -4
- package/test/behind-head-repair.test.js +38 -4
- package/test/quality-variants.test.js +75 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 2.15.0
|
|
2
|
+
|
|
3
|
+
- **New**: An audio track is prepared before the player is told to change to it — `GET /transcode/:id/a/:track/warm?position=<seconds>`, the same shape the quality rung has had since 2.12.0. Changing track makes the player discard the audio it holds, and it cannot show a frame until the new track covers the playhead: switching first and producing second therefore put the track's whole cold start on screen as a spinner over a stopped picture. Prepared first, the player finds the bytes already made. A track prepared for a change the viewer then did not make is stopped, as a warmed rung is.
|
|
4
|
+
- **Fix**: The audio track a viewer leaves is stopped, and a seek reaches only the track being listened to. Each track is an ffmpeg process AND a reader holding pieces of the torrent, and one viewer who had changed track once had three readers on one file — picture, the track chosen and the track left. At a seek all three revived their windows at once, every resident piece was pinned, a read ended with zero bytes, ffmpeg read that as the end of the file, and every encoder died; the sessions answered 500 to everything after that until the viewer gave up.
|
|
5
|
+
- **Fix**: A read waits for a piece to be released instead of failing outright. Every resident piece being read at once is not a permanent condition — a pin lasts one read of one piece — so the store now waits for one, and a released pin wakes whoever is waiting. Failing there ended a read with zero bytes, which is indistinguishable from the end of the file to the process reading it. A five-second deadline keeps a genuine deadlock visible, and the wait re-checks on a timer: waiting on events alone hung, because when everything is pinned and nothing else is in flight there is no event left to fire — it hung this store's own test for the ten minutes a run is allowed.
|
|
6
|
+
|
|
7
|
+
## 2.14.3
|
|
8
|
+
|
|
9
|
+
- **Fix**: A separately published audio track begins where the PICTURE is, measured rather than guessed. The position this class keeps is the read head, and the viewer sits behind it by whatever the player has buffered — a figure the browser already reports with every link report, so the playhead is one subtraction away (less one segment of margin, since the report can be ten seconds old). 2.14.2 subtracted the whole look-ahead instead, which was safe but made the encoder produce up to two minutes of audio nobody would hear before reaching the part that was wanted. A report older than fifteen seconds is ignored — a viewer may have seeked since — and then the whole look-ahead is subtracted as before.
|
|
10
|
+
- **Fix**: A request behind the encode run is acted on when the player ASKS AGAIN, not after three seconds of waiting. Repetition is the player saying it still needs that exact segment; a delay only says time has passed, and those three seconds were part of the twenty a track change cost. A scan is told apart by what else is being asked for — more than three distinct segments behind the run within two seconds is the player sweeping the playlist, and moving the encoder to one of them would be moving it to a number picked at random.
|
|
11
|
+
- **Fix**: That scan count is taken over a two-second window rather than over the life of the run. Accumulated, it would have crossed the threshold on any long session and disabled the repair for good — silently, since nothing about a repair that never fires is logged.
|
|
12
|
+
|
|
1
13
|
## 2.14.2
|
|
2
14
|
|
|
3
15
|
- **Fix**: A separately published audio track starts BEHIND the picture's read head, and a request behind its run is answered as before. Two mistakes compounded in 2.14.1 and left the viewer on a spinner that never ended. The position this class keeps is written by the segments a session serves — the READ head — while the viewer's picture sits behind it by everything they have buffered, so the track was started AHEAD of them: field 2026-08-15, the run placed at segment #16 while the player asked for #10. On top of that, 2.14.1 had begun answering such a request "not found" at once instead of holding it, which turned a condition the encoder used to correct in twenty seconds into a permanent refusal: hls.js retried #10 for a minute and a half, raised a fatal network error, recovered, and retried it again. The prompt refusal is withdrawn — it was written for a probe and met a real request — and the track now starts a whole look-ahead behind the read head, which is exactly how far apart the two can be. The price is audio the player already holds: at ten to twenty times realtime and 75 KB a piece, a second or two of work.
|
package/package.json
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file GET /transcode/:sessionId/a/:track/warm?position=<seconds> — prepare an
|
|
3
|
+
* audio track before the player is told to change to it.
|
|
4
|
+
*
|
|
5
|
+
* The picture keeps playing while a quality rung is warmed (`variant-warm`),
|
|
6
|
+
* and a track change deserves the same: the player discards the audio it holds
|
|
7
|
+
* the moment it switches and cannot show a frame until the new track covers the
|
|
8
|
+
* playhead, so switching first and producing second shows the track's cold
|
|
9
|
+
* start as a spinner over a stopped picture (measured 2026-08-15).
|
|
10
|
+
*
|
|
11
|
+
* Answers 204 when the segment at that position is ready, so the caller can
|
|
12
|
+
* switch into bytes that already exist; 503 while it is still being made.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { waitForSessionFile } from "../session-file/get.js";
|
|
16
|
+
|
|
17
|
+
/** How long to hold the request before telling the caller to retry. */
|
|
18
|
+
const WARM_WAIT_MS = 12_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {import("fastify").FastifyRequest} req
|
|
22
|
+
* @param {import("fastify").FastifyReply} reply
|
|
23
|
+
* @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
24
|
+
*/
|
|
25
|
+
export async function handleTranscodeAudioWarmGet(req, reply, { hlsSessionManager }) {
|
|
26
|
+
const baseSessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
27
|
+
const trackIndex = Number(req.params.track);
|
|
28
|
+
const positionSeconds = Number(req.query?.position);
|
|
29
|
+
|
|
30
|
+
if (
|
|
31
|
+
!Number.isInteger(trackIndex) ||
|
|
32
|
+
trackIndex < 0 ||
|
|
33
|
+
!Number.isFinite(positionSeconds) ||
|
|
34
|
+
positionSeconds < 0
|
|
35
|
+
) {
|
|
36
|
+
return reply.code(400).send({ error: "A track index and a non-negative position are required." });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let prepared;
|
|
40
|
+
try {
|
|
41
|
+
prepared = await hlsSessionManager.prepareAudioTrack(baseSessionId, trackIndex, positionSeconds);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
44
|
+
reply.header("Retry-After", "1");
|
|
45
|
+
return reply.code(503).send({ error: `Could not prepare the audio track: ${message}` });
|
|
46
|
+
}
|
|
47
|
+
if (!prepared) {
|
|
48
|
+
return reply.code(404).send({ error: "No such audio track for this transcode session." });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const result = await waitForSessionFile(
|
|
52
|
+
hlsSessionManager,
|
|
53
|
+
prepared.sessionId,
|
|
54
|
+
prepared.fileName,
|
|
55
|
+
WARM_WAIT_MS
|
|
56
|
+
);
|
|
57
|
+
if (result.kind === "file") {
|
|
58
|
+
// The bytes are the player's to fetch; the handle opened to reach them is
|
|
59
|
+
// ours to close, or a long-running proxy walks to EMFILE one track change
|
|
60
|
+
// at a time.
|
|
61
|
+
result.stream?.destroy?.();
|
|
62
|
+
return reply.code(204).send();
|
|
63
|
+
}
|
|
64
|
+
if (result.kind === "failed") {
|
|
65
|
+
return reply.code(500).send({ error: result.message });
|
|
66
|
+
}
|
|
67
|
+
// Still being produced. The caller may switch anyway — it will wait where it
|
|
68
|
+
// would have waited before — or ask again.
|
|
69
|
+
reply.header("Retry-After", "1");
|
|
70
|
+
return reply.code(503).send({ error: "The audio track is still warming up." });
|
|
71
|
+
}
|
package/server.js
CHANGED
|
@@ -32,6 +32,7 @@ import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/g
|
|
|
32
32
|
import { handleTranscodeVariantFileGet } from "./routes/transcode/variant-file/get.js";
|
|
33
33
|
import { handleTranscodeAudioFileGet } from "./routes/transcode/audio-file/get.js";
|
|
34
34
|
import { handleTranscodeVariantWarmGet } from "./routes/transcode/variant-warm/get.js";
|
|
35
|
+
import { handleTranscodeAudioWarmGet } from "./routes/transcode/audio-warm/get.js";
|
|
35
36
|
import { createSourceRegistry } from "./store/source-registry.js";
|
|
36
37
|
import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
|
|
37
38
|
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
@@ -249,6 +250,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
249
250
|
app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
|
|
250
251
|
handleTranscodeVariantWarmGet(req, reply, { hlsSessionManager })
|
|
251
252
|
);
|
|
253
|
+
app.get("/transcode/:sessionId/a/:track/warm", async (req, reply) =>
|
|
254
|
+
handleTranscodeAudioWarmGet(req, reply, { hlsSessionManager })
|
|
255
|
+
);
|
|
252
256
|
app.get("/transcode/:sessionId/a/:trackIndex/:fileName", async (req, reply) =>
|
|
253
257
|
handleTranscodeAudioFileGet(req, reply, { hlsSessionManager })
|
|
254
258
|
);
|
|
@@ -354,6 +354,10 @@ const READ_WINDOW_SECONDS = 30;
|
|
|
354
354
|
const READ_WINDOW_MIN_BYTES = 16 * 1024 * 1024;
|
|
355
355
|
const READ_WINDOW_MAX_BYTES = 96 * 1024 * 1024;
|
|
356
356
|
const LOOKAHEAD_PAUSE_SECONDS = 120;
|
|
357
|
+
// How old a viewer's link report may be and still describe where they are. It
|
|
358
|
+
// is sent every 10 s, and a seek in between moves them somewhere this cannot
|
|
359
|
+
// predict — so anything older is treated as no report at all.
|
|
360
|
+
const NET_REPORT_FRESH_MS = 15_000;
|
|
357
361
|
const LOOKAHEAD_RESUME_SECONDS = 60;
|
|
358
362
|
// Seek debounce. A far (out-of-window) segment request is a server-side seek.
|
|
359
363
|
// Rather than restart ffmpeg on the first one, wait a short quiet period:
|
|
@@ -389,7 +393,18 @@ const SEEK_SETTLE_MS = 300;
|
|
|
389
393
|
// seek settles on its own — the seek is what should move the encoder — and
|
|
390
394
|
// short enough that a session cannot sit on an unanswerable request, which
|
|
391
395
|
// measured two minutes forty-one before a viewer gave up.
|
|
392
|
-
|
|
396
|
+
// A request behind the run is acted on once it has been REPEATED, not once it
|
|
397
|
+
// has waited: repetition is the player saying it still needs this exact
|
|
398
|
+
// segment, while a delay only says time has passed. The floor below stays as a
|
|
399
|
+
// last guard against acting on a single stray poll.
|
|
400
|
+
const BEHIND_HEAD_REPAIR_MIN_ASKS = 2;
|
|
401
|
+
// More distinct indices than this behind the head at once is the player
|
|
402
|
+
// scanning the playlist rather than waiting for a frame.
|
|
403
|
+
const BEHIND_HEAD_SCAN_INDICES = 3;
|
|
404
|
+
// The window the count above is taken over. A player's scan lands inside half a
|
|
405
|
+
// second (field log 2026-08-02); a viewer waiting asks every few seconds.
|
|
406
|
+
const BEHIND_HEAD_SCAN_WINDOW_MS = 2_000;
|
|
407
|
+
const BEHIND_HEAD_REPAIR_MS = 400;
|
|
393
408
|
// How far behind the run a request may be and still be treated as the encoder
|
|
394
409
|
// standing in the wrong place rather than as a player scanning the playlist. A
|
|
395
410
|
// misplaced run is out by at most the buffer the player was holding — measured
|
|
@@ -2886,6 +2901,7 @@ export class HlsSessionManager {
|
|
|
2886
2901
|
// for the seek that should move the encoder. It also stops the map growing
|
|
2887
2902
|
// for the life of a session.
|
|
2888
2903
|
session.firstWantedAt = new Map();
|
|
2904
|
+
session.behindHeadAsks = new Map();
|
|
2889
2905
|
const generation = ++session.encodeRunGeneration;
|
|
2890
2906
|
const previousFfmpeg = session.ffmpeg;
|
|
2891
2907
|
// A suspended process does not act on SIGTERM until it is continued, so the
|
|
@@ -3461,6 +3477,16 @@ export class HlsSessionManager {
|
|
|
3461
3477
|
if (!session.firstWantedAt.has(index)) {
|
|
3462
3478
|
session.firstWantedAt.set(index, Date.now());
|
|
3463
3479
|
}
|
|
3480
|
+
// How often each index behind the run has been asked for, and how many
|
|
3481
|
+
// distinct ones there are. The repair reads both: one index asked twice is
|
|
3482
|
+
// a viewer waiting, a dozen asked once each is the player scanning. Kept
|
|
3483
|
+
// only for what is behind the head — everything ahead is ordinary
|
|
3484
|
+
// read-ahead — and cleared with each run, like the record above.
|
|
3485
|
+
if (index < session.encodeStartIndex) {
|
|
3486
|
+
session.behindHeadAsks ??= new Map();
|
|
3487
|
+
const asked = session.behindHeadAsks.get(index);
|
|
3488
|
+
session.behindHeadAsks.set(index, { count: (asked?.count ?? 0) + 1, at: Date.now() });
|
|
3489
|
+
}
|
|
3464
3490
|
if (!session || session.state === "disposed" || index < 0) {
|
|
3465
3491
|
return;
|
|
3466
3492
|
}
|
|
@@ -3580,6 +3606,37 @@ export class HlsSessionManager {
|
|
|
3580
3606
|
if (session.seekSettleTimer != null) {
|
|
3581
3607
|
return;
|
|
3582
3608
|
}
|
|
3609
|
+
// What separates a request the viewer is waiting for from the player
|
|
3610
|
+
// scanning the playlist is not TIME but what else it is asking for. On a
|
|
3611
|
+
// seek hls.js fires dozens of DIFFERENT indices within half a second (field
|
|
3612
|
+
// log: #178, #681, #725, #807, #74, #245, #387) and abandons them all; a
|
|
3613
|
+
// viewer waiting for audio asks for the SAME one, over and over, because it
|
|
3614
|
+
// is the only thing that will let playback continue.
|
|
3615
|
+
//
|
|
3616
|
+
// So: this index has been asked for at least twice, and it is the only
|
|
3617
|
+
// thing behind the head being asked for. Both are facts about the traffic,
|
|
3618
|
+
// available at once, where a delay is a guess about it — and it was three
|
|
3619
|
+
// seconds of the twenty a track change cost on 2026-08-15.
|
|
3620
|
+
const asked = session.behindHeadAsks?.get(index)?.count ?? 0;
|
|
3621
|
+
if (asked < BEHIND_HEAD_REPAIR_MIN_ASKS) {
|
|
3622
|
+
return;
|
|
3623
|
+
}
|
|
3624
|
+
// Counted over a WINDOW, not over the run: a scan is many indices at once,
|
|
3625
|
+
// while the same map left to accumulate would eventually hold every
|
|
3626
|
+
// behind-head request a long run ever saw and switch the repair off for
|
|
3627
|
+
// good.
|
|
3628
|
+
const scanSince = Date.now() - BEHIND_HEAD_SCAN_WINDOW_MS;
|
|
3629
|
+
let distinctBehind = 0;
|
|
3630
|
+
for (const record of session.behindHeadAsks?.values() ?? []) {
|
|
3631
|
+
if (record.at >= scanSince) {
|
|
3632
|
+
distinctBehind += 1;
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
if (distinctBehind > BEHIND_HEAD_SCAN_INDICES) {
|
|
3636
|
+
// A scan, not a wait. Moving the encoder to one of these is moving it to
|
|
3637
|
+
// a number the player picked at random.
|
|
3638
|
+
return;
|
|
3639
|
+
}
|
|
3583
3640
|
const wantedAt = session.firstWantedAt?.get(index);
|
|
3584
3641
|
if (!Number.isFinite(wantedAt) || Date.now() - wantedAt < BEHIND_HEAD_REPAIR_MS) {
|
|
3585
3642
|
return;
|
|
@@ -3663,7 +3720,16 @@ export class HlsSessionManager {
|
|
|
3663
3720
|
// treated as a seek anywhere in this class, so after a forward jump the
|
|
3664
3721
|
// audio would be held, refused, and left grinding forward from where it
|
|
3665
3722
|
// was — the picture playing over silence for as long as the jump was.
|
|
3666
|
-
|
|
3723
|
+
// Only the track being LISTENED to. A track the viewer left keeps its place
|
|
3724
|
+
// but not an encoder, and seeking it would start one for nobody — which is
|
|
3725
|
+
// how a single viewer came to have three ffmpeg processes and three readers
|
|
3726
|
+
// on one file (2026-08-15), enough to pin every resident piece and kill the
|
|
3727
|
+
// session outright.
|
|
3728
|
+
const listening = named.activeAudioTrackIndex ?? named.audioTrackIndex;
|
|
3729
|
+
for (const [trackIndex, renditionId] of named.audioRenditionSessions ?? []) {
|
|
3730
|
+
if (trackIndex !== listening) {
|
|
3731
|
+
continue;
|
|
3732
|
+
}
|
|
3667
3733
|
const rendition = this.sessionsById.get(renditionId);
|
|
3668
3734
|
if (rendition && rendition.state !== "disposed") {
|
|
3669
3735
|
rendition.lastAccessedAt = Date.now();
|
|
@@ -4751,6 +4817,37 @@ export class HlsSessionManager {
|
|
|
4751
4817
|
return this.#viewerPositionOf(this.#activeVariant(base));
|
|
4752
4818
|
}
|
|
4753
4819
|
|
|
4820
|
+
/**
|
|
4821
|
+
* Where to start a separately published audio track, in seconds.
|
|
4822
|
+
*
|
|
4823
|
+
* The player, on changing track, discards the audio it holds and refills from
|
|
4824
|
+
* the PICTURE onwards — so that is where the encoder has to begin. What this
|
|
4825
|
+
* class knows directly is the read head, which runs ahead of the picture by
|
|
4826
|
+
* the player's own buffer; the browser reports that buffer with every link
|
|
4827
|
+
* report, so the picture is the one subtraction below.
|
|
4828
|
+
*
|
|
4829
|
+
* One segment of margin, because the report is up to ten seconds old and the
|
|
4830
|
+
* picture has moved on since — a run that begins a little early costs a
|
|
4831
|
+
* segment of audio nobody plays, while one that begins a little late is
|
|
4832
|
+
* behind the viewer and can only be fixed by restarting it.
|
|
4833
|
+
*
|
|
4834
|
+
* With no fresh report, the whole look-ahead is subtracted instead: it is the
|
|
4835
|
+
* furthest the two can be apart, so it cannot leave the run ahead of them.
|
|
4836
|
+
*
|
|
4837
|
+
* @param {HlsSession} base
|
|
4838
|
+
* @returns {number}
|
|
4839
|
+
*/
|
|
4840
|
+
#audioStartSecondsFor(base) {
|
|
4841
|
+
const watching = this.#activeVariant(base);
|
|
4842
|
+
const readHead = this.#viewerPositionOf(watching);
|
|
4843
|
+
const report = watching.netReport;
|
|
4844
|
+
const reportAge = Number.isFinite(report?.at) ? Date.now() - report.at : Number.POSITIVE_INFINITY;
|
|
4845
|
+
const buffered = reportAge <= NET_REPORT_FRESH_MS && Number.isFinite(report?.bufferedAheadSec)
|
|
4846
|
+
? report.bufferedAheadSec
|
|
4847
|
+
: LOOKAHEAD_PAUSE_SECONDS;
|
|
4848
|
+
return Math.max(0, readHead - buffered - this.segmentDurationSec);
|
|
4849
|
+
}
|
|
4850
|
+
|
|
4754
4851
|
#viewerPositionOf(session) {
|
|
4755
4852
|
if (Number.isFinite(session.viewerPositionSeconds) && session.viewerPositionSeconds > 0) {
|
|
4756
4853
|
return session.viewerPositionSeconds;
|
|
@@ -5041,6 +5138,54 @@ export class HlsSessionManager {
|
|
|
5041
5138
|
* @param {number} positionSeconds - Where the switch will happen.
|
|
5042
5139
|
* @returns {Promise<{ sessionId: string, fileName: string } | null>}
|
|
5043
5140
|
*/
|
|
5141
|
+
/**
|
|
5142
|
+
* Prepare an audio track at a position, so a change of track is instant.
|
|
5143
|
+
*
|
|
5144
|
+
* The player, told to change track, discards the audio it holds and cannot
|
|
5145
|
+
* show a frame until the new track covers the playhead — so switching first
|
|
5146
|
+
* and producing second puts the whole of the track's cold start on screen as
|
|
5147
|
+
* a spinner. Measured 2026-08-15: the picture stopped for as long as the
|
|
5148
|
+
* first piece took. Prepared first, the player finds the bytes already there.
|
|
5149
|
+
*
|
|
5150
|
+
* The same shape as {@link prepareVariant}, and for the same reason.
|
|
5151
|
+
*
|
|
5152
|
+
* @param {string} baseSessionId
|
|
5153
|
+
* @param {number} trackIndex
|
|
5154
|
+
* @param {number} positionSeconds
|
|
5155
|
+
* @returns {Promise<{ sessionId: string, fileName: string } | null>}
|
|
5156
|
+
*/
|
|
5157
|
+
async prepareAudioTrack(baseSessionId, trackIndex, positionSeconds) {
|
|
5158
|
+
const base = this.sessionsById.get(baseSessionId);
|
|
5159
|
+
if (!base || base.state === "disposed" || !this.#servesAudioSeparately(base)) {
|
|
5160
|
+
return null;
|
|
5161
|
+
}
|
|
5162
|
+
if (!this.#audioRenditionsOf(base).some((track) => track.trackIndex === trackIndex)) {
|
|
5163
|
+
return null;
|
|
5164
|
+
}
|
|
5165
|
+
const rendition = await this.#resolveAudioRenditionSession(base, trackIndex);
|
|
5166
|
+
if (!rendition) {
|
|
5167
|
+
return null;
|
|
5168
|
+
}
|
|
5169
|
+
// A track prepared for a change the viewer did not make would otherwise
|
|
5170
|
+
// encode for nobody until its own idle timer noticed — the same trap
|
|
5171
|
+
// warming a quality rung has, and the same answer.
|
|
5172
|
+
const stillWarming = base.warmingAudioSessionId;
|
|
5173
|
+
if (stillWarming && stillWarming !== rendition.id) {
|
|
5174
|
+
const abandoned = this.sessionsById.get(stillWarming);
|
|
5175
|
+
const listening = base.activeAudioTrackIndex ?? base.audioTrackIndex;
|
|
5176
|
+
const active = base.audioRenditionSessions?.get(listening);
|
|
5177
|
+
if (abandoned && abandoned.id !== active) {
|
|
5178
|
+
this.#stopEncodeRun(abandoned, "prepared for a track change the viewer did not make");
|
|
5179
|
+
}
|
|
5180
|
+
}
|
|
5181
|
+
base.warmingAudioSessionId = rendition.id;
|
|
5182
|
+
// Pointed at the position the switch will land on: an existing track is
|
|
5183
|
+
// parked wherever the viewer left it.
|
|
5184
|
+
this.#seekSession(rendition, positionSeconds);
|
|
5185
|
+
const index = this.#segmentIndexForTime(rendition, positionSeconds);
|
|
5186
|
+
return { sessionId: rendition.id, fileName: rendition.segmentFormat.segmentFileName(index) };
|
|
5187
|
+
}
|
|
5188
|
+
|
|
5044
5189
|
async prepareVariant(baseSessionId, height, positionSeconds) {
|
|
5045
5190
|
if (!isSafeSessionId(baseSessionId)) {
|
|
5046
5191
|
return null;
|
|
@@ -5299,9 +5444,53 @@ export class HlsSessionManager {
|
|
|
5299
5444
|
);
|
|
5300
5445
|
return { sessionId: null, error: message };
|
|
5301
5446
|
}
|
|
5447
|
+
if (isSegment && rendition) {
|
|
5448
|
+
this.#noteAudioTrackActive(base, rendition, trackIndex);
|
|
5449
|
+
}
|
|
5302
5450
|
return { sessionId: rendition?.id ?? null };
|
|
5303
5451
|
}
|
|
5304
5452
|
|
|
5453
|
+
/**
|
|
5454
|
+
* A SEGMENT of this track is what says the viewer is listening to it — the
|
|
5455
|
+
* player fetches the playlist and the init of tracks it may never choose.
|
|
5456
|
+
*
|
|
5457
|
+
* Every other track is then stopped. Each one is an ffmpeg process AND a
|
|
5458
|
+
* reader holding pieces of the torrent in memory, and the store can only
|
|
5459
|
+
* spill a piece nobody is reading: on 2026-08-15 a viewer who had changed
|
|
5460
|
+
* track once had three readers on one file — picture, the track they chose
|
|
5461
|
+
* and the track they left — and at a seek all three revived their windows at
|
|
5462
|
+
* once, every resident piece was pinned, a read ended with zero bytes, and
|
|
5463
|
+
* every encoder took that for the end of the file and died. Playback was over
|
|
5464
|
+
* for good; the sessions answered 500 to everything after that.
|
|
5465
|
+
*
|
|
5466
|
+
* Stopped, not disposed: the track keeps its place, its grid and its
|
|
5467
|
+
* position, so switching back does not build it again — the same treatment a
|
|
5468
|
+
* quality rung gets when the viewer moves off it.
|
|
5469
|
+
*
|
|
5470
|
+
* @param {HlsSession} base
|
|
5471
|
+
* @param {HlsSession} active
|
|
5472
|
+
* @param {number} trackIndex
|
|
5473
|
+
*/
|
|
5474
|
+
#noteAudioTrackActive(base, active, trackIndex) {
|
|
5475
|
+
if (base.activeAudioTrackIndex === trackIndex) {
|
|
5476
|
+
return;
|
|
5477
|
+
}
|
|
5478
|
+
base.activeAudioTrackIndex = trackIndex;
|
|
5479
|
+
for (const [otherIndex, sessionId] of base.audioRenditionSessions ?? []) {
|
|
5480
|
+
if (otherIndex === trackIndex) {
|
|
5481
|
+
continue;
|
|
5482
|
+
}
|
|
5483
|
+
const other = this.sessionsById.get(sessionId);
|
|
5484
|
+
if (!other || other.state === "disposed" || other.ffmpeg == null) {
|
|
5485
|
+
continue;
|
|
5486
|
+
}
|
|
5487
|
+
// Requests held on it are for segments nobody will produce now, and the
|
|
5488
|
+
// player stopped waiting for them the moment it changed track.
|
|
5489
|
+
other.waitEpoch = (other.waitEpoch ?? 0) + 1;
|
|
5490
|
+
this.#stopEncodeRun(other, `the viewer moved to audio track ${trackIndex}`);
|
|
5491
|
+
}
|
|
5492
|
+
}
|
|
5493
|
+
|
|
5305
5494
|
/**
|
|
5306
5495
|
* The session producing one audio track of this file, made on first request.
|
|
5307
5496
|
*
|
|
@@ -5334,26 +5523,23 @@ export class HlsSessionManager {
|
|
|
5334
5523
|
// while the player is asking for segment #537 — and the audio would begin
|
|
5335
5524
|
// at zero and never catch up, since nothing treats a far request as a
|
|
5336
5525
|
// seek. The accessor falls back to the last segment actually requested.
|
|
5337
|
-
//
|
|
5526
|
+
// Where the PICTURE is, not where it has been read to.
|
|
5338
5527
|
//
|
|
5339
5528
|
// The position this class keeps is written by the segments a session
|
|
5340
|
-
// serves, so it is the READ head, and the
|
|
5341
|
-
// by everything
|
|
5342
|
-
// audio
|
|
5343
|
-
//
|
|
5344
|
-
//
|
|
5345
|
-
//
|
|
5529
|
+
// serves, so it is the READ head, and the viewer's picture sits behind it
|
|
5530
|
+
// by everything the player has buffered. Started at the read head, the
|
|
5531
|
+
// audio run begins AHEAD of the viewer, and every request they then make
|
|
5532
|
+
// is behind a run that only moves forward — field 2026-08-15, placed at
|
|
5533
|
+
// #16 while the player asked for #10, and the audio arrived only after
|
|
5534
|
+
// the encoder was dragged back.
|
|
5346
5535
|
//
|
|
5347
|
-
// The
|
|
5348
|
-
//
|
|
5349
|
-
//
|
|
5350
|
-
//
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
(this.#viewerPositionOf(this.#activeVariant(base)) - LOOKAHEAD_PAUSE_SECONDS) / 10
|
|
5355
|
-
) * 10
|
|
5356
|
-
),
|
|
5536
|
+
// The distance is measured, not assumed: the browser reports how many
|
|
5537
|
+
// seconds it holds ahead of the picture with every link report, so the
|
|
5538
|
+
// playhead is one subtraction away. A stale report is no use — a viewer
|
|
5539
|
+
// who seeked since then is somewhere else entirely — so an old one is
|
|
5540
|
+
// ignored and the whole look-ahead is subtracted instead, which cannot
|
|
5541
|
+
// leave the run ahead of them.
|
|
5542
|
+
startPositionSeconds: this.#audioStartSecondsFor(base),
|
|
5357
5543
|
segmentFormatId: base.segmentFormat.id,
|
|
5358
5544
|
// Cut where the picture is cut. Two streams meant to be played together
|
|
5359
5545
|
// have to be divided at the same times, and the grid is the base's — the
|
|
@@ -109,6 +109,14 @@ const MIN_BUDGET_BYTES = 64 * 1024 * 1024;
|
|
|
109
109
|
* in. With one, a single reader would deadlock the store against itself.
|
|
110
110
|
*/
|
|
111
111
|
const MIN_RESIDENT_PIECES = 2;
|
|
112
|
+
/**
|
|
113
|
+
* How long a caller waits for a pinned piece to be released before the store
|
|
114
|
+
* calls it a deadlock. A pin lasts one read of one piece — milliseconds — so
|
|
115
|
+
* anything approaching this is a reader waiting for itself.
|
|
116
|
+
*/
|
|
117
|
+
const PINNED_WAIT_MS = 5_000;
|
|
118
|
+
/** How often a wait for a slot looks again when no event is due to wake it. */
|
|
119
|
+
const CLAIM_RETRY_MS = 50;
|
|
112
120
|
|
|
113
121
|
/**
|
|
114
122
|
* A chunk store holding pieces in a `SharedArrayBuffer`, spilling to disk.
|
|
@@ -151,6 +159,8 @@ export class SharedPieceStore {
|
|
|
151
159
|
* store is exhausted, when in fact it is merely mid-flight.
|
|
152
160
|
*/
|
|
153
161
|
#outstandingSlots = 0;
|
|
162
|
+
/** When the wait for a pinned piece began; 0 when nothing is waiting. */
|
|
163
|
+
#pinnedWaitStartedAt = 0;
|
|
154
164
|
/** Resolvers waiting for a slot to become claimable. @type {(() => void)[]} */
|
|
155
165
|
#waiters = [];
|
|
156
166
|
#lru;
|
|
@@ -169,7 +179,8 @@ export class SharedPieceStore {
|
|
|
169
179
|
fromDisk: 0,
|
|
170
180
|
spills: 0,
|
|
171
181
|
revivals: 0,
|
|
172
|
-
blockedByPins: 0
|
|
182
|
+
blockedByPins: 0,
|
|
183
|
+
waitedForPins: 0
|
|
173
184
|
};
|
|
174
185
|
|
|
175
186
|
/**
|
|
@@ -308,6 +319,8 @@ export class SharedPieceStore {
|
|
|
308
319
|
*/
|
|
309
320
|
unpin(index) {
|
|
310
321
|
this.#lru.unpin(index);
|
|
322
|
+
// A released pin can be exactly what a caller waiting for a slot needs.
|
|
323
|
+
this.#wake();
|
|
311
324
|
}
|
|
312
325
|
|
|
313
326
|
/**
|
|
@@ -330,6 +343,15 @@ export class SharedPieceStore {
|
|
|
330
343
|
for (const spill of this.#evicting.values()) {
|
|
331
344
|
void spill.then(() => this.#wake(), () => this.#wake());
|
|
332
345
|
}
|
|
346
|
+
// A wake is not guaranteed to come. Waiting for a spill is safe — one
|
|
347
|
+
// is in flight and will finish — but waiting for a PIN to be released
|
|
348
|
+
// is not: if every piece is held and nothing else is happening, there
|
|
349
|
+
// is no event left to fire, and the deadline that gives up cannot be
|
|
350
|
+
// reached because it is only tested inside an attempt. That is a hang,
|
|
351
|
+
// and it hung this store's own test for the full ten minutes a run is
|
|
352
|
+
// allowed. So the wait also re-checks on a timer.
|
|
353
|
+
const retry = setTimeout(() => this.#wake(), CLAIM_RETRY_MS);
|
|
354
|
+
retry.unref?.();
|
|
333
355
|
});
|
|
334
356
|
}
|
|
335
357
|
}
|
|
@@ -394,11 +416,34 @@ export class SharedPieceStore {
|
|
|
394
416
|
if (this.#evicting.size > 0 || this.#outstandingSlots > 0) {
|
|
395
417
|
return null;
|
|
396
418
|
}
|
|
397
|
-
// Every resident piece is being
|
|
398
|
-
//
|
|
419
|
+
// Every resident piece is being READ right now. That is not a permanent
|
|
420
|
+
// condition: a pin lasts as long as one read of one piece, and the reader
|
|
421
|
+
// releases it a moment later. So wait for that, exactly as the loop above
|
|
422
|
+
// waits for a spill — pins now wake the waiters.
|
|
423
|
+
//
|
|
424
|
+
// It became reachable when a viewer could have three readers on one file
|
|
425
|
+
// (2026-08-15: picture, the audio track chosen and the one left behind);
|
|
426
|
+
// failing here ended a read with zero bytes, which ffmpeg reads as the
|
|
427
|
+
// end of the file, so every encoder died and the session answered 500 to
|
|
428
|
+
// everything after that.
|
|
429
|
+
//
|
|
430
|
+
// The deadline is what keeps a genuine deadlock visible: a reader that
|
|
431
|
+
// holds a pin while waiting for a slot would otherwise wait for itself
|
|
432
|
+
// for ever.
|
|
433
|
+
if (this.#pinnedWaitStartedAt === 0) {
|
|
434
|
+
this.#pinnedWaitStartedAt = Date.now();
|
|
435
|
+
}
|
|
436
|
+
if (Date.now() - this.#pinnedWaitStartedAt < PINNED_WAIT_MS) {
|
|
437
|
+
this.#counters.waitedForPins += 1;
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
this.#pinnedWaitStartedAt = 0;
|
|
399
441
|
this.#counters.blockedByPins += 1;
|
|
400
|
-
throw new Error(
|
|
442
|
+
throw new Error(
|
|
443
|
+
`Every resident piece is pinned and none was released in ${PINNED_WAIT_MS}ms; no slot can be freed.`
|
|
444
|
+
);
|
|
401
445
|
}
|
|
446
|
+
this.#pinnedWaitStartedAt = 0;
|
|
402
447
|
|
|
403
448
|
const slot = this.#slotOf.get(victim);
|
|
404
449
|
|
|
@@ -71,16 +71,23 @@ async function managerWithRunAhead() {
|
|
|
71
71
|
return { manager, session, dirPath, restarts };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
test("a request behind the run is repaired once
|
|
74
|
+
test("a request behind the run is repaired once the player asks again", async (t) => {
|
|
75
75
|
const { manager, session, dirPath } = await managerWithRunAhead();
|
|
76
76
|
t.after(async () => {
|
|
77
77
|
await manager.disposeAll();
|
|
78
78
|
await rm(dirPath, { recursive: true, force: true });
|
|
79
79
|
});
|
|
80
|
-
// Asked for
|
|
81
|
-
|
|
80
|
+
// Asked for a moment ago and still unanswerable. What decides is that the
|
|
81
|
+
// player comes BACK for the same segment: it is the only one it wants, and no
|
|
82
|
+
// amount of waiting could say that as clearly.
|
|
83
|
+
session.firstWantedAt.set(WANTED, Date.now() - 1000);
|
|
84
|
+
const name = fmp4Format.segmentFileName(WANTED);
|
|
82
85
|
|
|
83
|
-
await manager.getFileStream(SESSION_ID,
|
|
86
|
+
await manager.getFileStream(SESSION_ID, name, { requestSeq: 1 });
|
|
87
|
+
|
|
88
|
+
assert.equal(session.seekTarget, null, "one poll is not yet evidence");
|
|
89
|
+
|
|
90
|
+
await manager.getFileStream(SESSION_ID, name, { requestSeq: 2 });
|
|
84
91
|
|
|
85
92
|
assert.equal(
|
|
86
93
|
session.seekTarget,
|
|
@@ -89,6 +96,33 @@ test("a request behind the run is repaired once it has waited", async (t) => {
|
|
|
89
96
|
);
|
|
90
97
|
});
|
|
91
98
|
|
|
99
|
+
test("a burst of different segments behind the run is a scan, and moves nothing", async (t) => {
|
|
100
|
+
const { manager, session, dirPath } = await managerWithRunAhead();
|
|
101
|
+
t.after(async () => {
|
|
102
|
+
await manager.disposeAll();
|
|
103
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
104
|
+
});
|
|
105
|
+
// What hls.js does on a seek: dozens of indices within half a second, each
|
|
106
|
+
// abandoned. Field log 2026-08-02: #178, #681, #725, #807, #74, #245, #387.
|
|
107
|
+
const scanned = [WANTED, WANTED - 40, WANTED - 120, WANTED - 200, WANTED - 300];
|
|
108
|
+
for (const index of scanned) {
|
|
109
|
+
session.firstWantedAt.set(index, Date.now() - 1000);
|
|
110
|
+
}
|
|
111
|
+
// Interleaved, as they arrive on the wire: the player opens them together
|
|
112
|
+
// rather than finishing with one before opening the next.
|
|
113
|
+
for (const seq of [1, 2]) {
|
|
114
|
+
for (const index of scanned) {
|
|
115
|
+
await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(index), { requestSeq: seq });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
assert.equal(
|
|
120
|
+
session.seekTarget,
|
|
121
|
+
null,
|
|
122
|
+
"moving the encoder to one of these would be moving it to a number the player picked at random"
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
92
126
|
test("a request behind the run is left alone at first", async (t) => {
|
|
93
127
|
const { manager, session, dirPath } = await managerWithRunAhead();
|
|
94
128
|
t.after(async () => {
|
|
@@ -638,19 +638,18 @@ test("a rung served by copy stays offered while a re-encoded rung is on screen",
|
|
|
638
638
|
);
|
|
639
639
|
});
|
|
640
640
|
|
|
641
|
-
test("a separately published audio track starts
|
|
641
|
+
test("a separately published audio track starts where the picture is, from the reported buffer", async (t) => {
|
|
642
642
|
const { manager, base, dirPath } = await managerWithBase();
|
|
643
643
|
t.after(async () => {
|
|
644
644
|
await manager.disposeAll();
|
|
645
645
|
await rm(dirPath, { recursive: true, force: true });
|
|
646
646
|
});
|
|
647
|
-
// What broke playback on 2026-08-15: the position this class keeps is where
|
|
648
|
-
// segments have been SERVED to, and the viewer's picture is behind it by
|
|
649
|
-
// everything they have buffered. Started at the read head, the audio run sat
|
|
650
|
-
// ahead of the viewer and every request they made was behind a run that only
|
|
651
|
-
// moves forward.
|
|
652
|
-
base.viewerPositionSeconds = 140;
|
|
653
647
|
base.audioSeparate = true;
|
|
648
|
+
// Served up to 140 s, and the browser says it holds 40 s ahead of the
|
|
649
|
+
// picture — so the viewer is at 100 s, and that, less a segment of margin,
|
|
650
|
+
// is where the track has to begin.
|
|
651
|
+
base.viewerPositionSeconds = 140;
|
|
652
|
+
base.netReport = { linkMbps: 20, bufferedAheadSec: 40, at: Date.now() };
|
|
654
653
|
manager.getCachedAudioTracks = () => [
|
|
655
654
|
{ index: 0, language: "rus", title: "", isDefault: true },
|
|
656
655
|
{ index: 1, language: "eng", title: "", isDefault: false }
|
|
@@ -663,13 +662,77 @@ test("a separately published audio track starts behind the picture's read head",
|
|
|
663
662
|
return { sessionId: VARIANT_ID, session: rendition };
|
|
664
663
|
};
|
|
665
664
|
|
|
666
|
-
// A segment, not the playlist: the playlist is answered from the base and
|
|
667
|
-
// deliberately starts no encoder.
|
|
668
665
|
await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
|
|
669
666
|
|
|
670
667
|
assert.equal(created.length, 1, "the track's own session was made");
|
|
671
|
-
assert.
|
|
672
|
-
created[0].startPositionSeconds
|
|
673
|
-
|
|
668
|
+
assert.equal(
|
|
669
|
+
created[0].startPositionSeconds,
|
|
670
|
+
96,
|
|
671
|
+
"140 s served, less the 40 s the player holds, less one segment of margin"
|
|
672
|
+
);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("a stale buffer report is not used to place an audio track", async (t) => {
|
|
676
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
677
|
+
t.after(async () => {
|
|
678
|
+
await manager.disposeAll();
|
|
679
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
680
|
+
});
|
|
681
|
+
base.audioSeparate = true;
|
|
682
|
+
base.viewerPositionSeconds = 300;
|
|
683
|
+
// Sent a minute ago: the viewer may have seeked anywhere since, so it says
|
|
684
|
+
// nothing about where they are now.
|
|
685
|
+
base.netReport = { linkMbps: 20, bufferedAheadSec: 5, at: Date.now() - 60_000 };
|
|
686
|
+
manager.getCachedAudioTracks = () => [
|
|
687
|
+
{ index: 0, language: "rus", title: "", isDefault: true },
|
|
688
|
+
{ index: 1, language: "eng", title: "", isDefault: false }
|
|
689
|
+
];
|
|
690
|
+
const created = [];
|
|
691
|
+
manager.createOrGetSession = async (params) => {
|
|
692
|
+
created.push(params);
|
|
693
|
+
const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
|
|
694
|
+
rendition.audioOnly = true;
|
|
695
|
+
return { sessionId: VARIANT_ID, session: rendition };
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
|
|
699
|
+
|
|
700
|
+
assert.equal(
|
|
701
|
+
created[0].startPositionSeconds,
|
|
702
|
+
176,
|
|
703
|
+
"the whole look-ahead is subtracted instead — it cannot leave the run ahead of the viewer"
|
|
704
|
+
);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("an audio track is prepared at the position the switch will land on", async (t) => {
|
|
708
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
709
|
+
t.after(async () => {
|
|
710
|
+
await manager.disposeAll();
|
|
711
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
712
|
+
});
|
|
713
|
+
base.audioSeparate = true;
|
|
714
|
+
manager.getCachedAudioTracks = () => [
|
|
715
|
+
{ index: 0, language: "rus", title: "", isDefault: true },
|
|
716
|
+
{ index: 1, language: "eng", title: "", isDefault: false }
|
|
717
|
+
];
|
|
718
|
+
const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
|
|
719
|
+
rendition.audioOnly = true;
|
|
720
|
+
rendition.audioTrackIndex = 1;
|
|
721
|
+
rendition.ffmpeg = fakeEncoder();
|
|
722
|
+
rendition.encodeStartIndex = 0;
|
|
723
|
+
manager.sessionsById.set(VARIANT_ID, rendition);
|
|
724
|
+
base.audioRenditionSessions = new Map([[1, VARIANT_ID]]);
|
|
725
|
+
|
|
726
|
+
const prepared = await manager.prepareAudioTrack(BASE_ID, 1, 240);
|
|
727
|
+
|
|
728
|
+
assert.deepEqual(
|
|
729
|
+
prepared,
|
|
730
|
+
{ sessionId: VARIANT_ID, fileName: "segment-00060.mp4" },
|
|
731
|
+
"the caller is told which segment to wait for — 240 s on a four-second grid"
|
|
732
|
+
);
|
|
733
|
+
assert.equal(
|
|
734
|
+
rendition.seekTarget,
|
|
735
|
+
59,
|
|
736
|
+
"and the track is pointed at the switch position, one back for the preceding keyframe"
|
|
674
737
|
);
|
|
675
738
|
});
|