@torrent-tv/proxy 2.73.1 → 2.74.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 +1447 -1437
- package/CLAUDE.md +165 -160
- package/docs/container-architecture.md +192 -184
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +205 -205
- package/services/container/Container.js +354 -135
- package/services/container/MatroskaContainer.js +1155 -516
- package/services/container/Mp4Container.js +858 -392
- package/services/container/SubtitleFileContainer.js +323 -261
- package/services/controllers/SubtitleController.js +128 -127
- package/services/delivery-probe.js +64 -6
- package/services/hls-session-manager.js +32 -35
- package/services/language-detect.js +174 -228
- package/services/playback-planner.js +747 -747
- package/services/produced-index.js +300 -0
- package/services/torrent-worker/subtitle-cues.js +582 -633
- package/services/tracks/TextSubtitleTrack.js +287 -47
- package/services/tracks/index.js +14 -14
- package/test/delivery-probe.test.js +67 -0
- package/test/matroska-blocks.test.js +0 -0
- package/test/mp4-subtitles.test.js +173 -127
- package/test/produced-index.test.js +188 -0
- package/test/subtitle-cue-framing.test.js +200 -202
- package/test/subtitle-cue-walk.test.js +369 -0
- package/test/subtitle-defaults.test.js +97 -97
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +370 -370
- package/services/container-index/matroska-blocks.js +0 -202
- package/services/container-index/matroska-subtitles.js +0 -372
- package/services/container-index/mp4-subtitles.js +0 -404
- package/services/subtitle-convert.js +0 -144
- package/services/subtitle-defaults.js +0 -157
- package/services/tracks/subtitle-markup.js +0 -104
|
@@ -1,127 +1,128 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Subtitle controller — interface layer over SubtitleOrchestrator.
|
|
3
|
-
*
|
|
4
|
-
* Routes (HTTP or data-channel) call this, not the domain module directly.
|
|
5
|
-
* Handles external files vs embedded tracks branching, header setting, and
|
|
6
|
-
* cursor/covered-cluster bookkeeping. Domain work (cluster walk, conversion,
|
|
7
|
-
* language detection) stays in orchestrator/domain.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { subtitleOrchestrator } from "../orchestrators/SubtitleOrchestrator.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
stream.on("
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
this.
|
|
35
|
-
this.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// The
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// `
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
"X-Subtitle-
|
|
117
|
-
"X-Subtitle-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file Subtitle controller — interface layer over SubtitleOrchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Routes (HTTP or data-channel) call this, not the domain module directly.
|
|
5
|
+
* Handles external files vs embedded tracks branching, header setting, and
|
|
6
|
+
* cursor/covered-cluster bookkeeping. Domain work (cluster walk, conversion,
|
|
7
|
+
* language detection) stays in orchestrator/domain.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { subtitleOrchestrator } from "../orchestrators/SubtitleOrchestrator.js";
|
|
11
|
+
import { SubtitleFileContainer } from "../container/SubtitleFileContainer.js";
|
|
12
|
+
import { TextSubtitleTrack } from "../tracks/TextSubtitleTrack.js";
|
|
13
|
+
import { detectLanguage } from "../language-detect.js";
|
|
14
|
+
|
|
15
|
+
const EXTERNAL_MAX_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
function readFileFully(file, maxBytes) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const stream = file.createReadStream();
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let total = 0;
|
|
22
|
+
stream.on("data", (chunk) => {
|
|
23
|
+
total += chunk.length;
|
|
24
|
+
if (total > maxBytes) { stream.destroy(); reject(new Error("subtitle file exceeds size cap")); return; }
|
|
25
|
+
chunks.push(chunk);
|
|
26
|
+
});
|
|
27
|
+
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
28
|
+
stream.on("error", reject);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class SubtitleController {
|
|
33
|
+
constructor({ sourceRegistry, torrentPool }) {
|
|
34
|
+
this.sourceRegistry = sourceRegistry;
|
|
35
|
+
this.torrentPool = torrentPool;
|
|
36
|
+
this.orchestrator = subtitleOrchestrator;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Serve external subtitle file or embedded track.
|
|
41
|
+
* Returns { vtt, language, headers } or { error, status }.
|
|
42
|
+
*/
|
|
43
|
+
async getSubtitle({ sourceKey, fileIndex, trackIndex, since, after }) {
|
|
44
|
+
const rec = this.sourceRegistry.get(sourceKey);
|
|
45
|
+
if (!rec) return { error: "Source key was not found.", status: 404 };
|
|
46
|
+
const torrent = await this.torrentPool.getTorrent(rec.sourceType, rec.source);
|
|
47
|
+
const file = torrent.files[fileIndex];
|
|
48
|
+
if (!file) return { error: "File index was not found in torrent.", status: 404 };
|
|
49
|
+
|
|
50
|
+
const hasTrack = trackIndex !== undefined && trackIndex !== "" && Number.isFinite(Number(trackIndex));
|
|
51
|
+
if (!hasTrack) {
|
|
52
|
+
const name = file.name ?? "";
|
|
53
|
+
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
54
|
+
const release = this.torrentPool.acquireFile(torrent, fileIndex);
|
|
55
|
+
try {
|
|
56
|
+
const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
|
|
57
|
+
const text = SubtitleFileContainer.decodeBytes(bytes);
|
|
58
|
+
const vtt = SubtitleFileContainer.toVtt(text, ext);
|
|
59
|
+
if (!vtt) return { error: `Unsupported subtitle format: ${ext}`, status: 422 };
|
|
60
|
+
// The language is read from the CONVERTED document, not from the file.
|
|
61
|
+
// The conversion has already dropped everything that is not the words —
|
|
62
|
+
// and on an ASS file that is half of it, in Latin letters, which is what
|
|
63
|
+
// made a Russian track answer `en` (field 2026-09-01, and the whole of
|
|
64
|
+
// `research/subtitle-language-ass-markup-2026-09-01.md`).
|
|
65
|
+
return { vtt, language: TextSubtitleTrack.detectLanguageFromVtt(vtt), headers: {} };
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return { error: `Could not read subtitle file: ${e?.message ?? e}`, status: 502 };
|
|
68
|
+
} finally {
|
|
69
|
+
release();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const idx = Number(trackIndex);
|
|
74
|
+
if (!Number.isInteger(idx) || idx < 0) return { error: "trackIndex must be a non-negative integer.", status: 400 };
|
|
75
|
+
|
|
76
|
+
// Resolve via orchestrator (domain: cluster walk or MP4 sample ranges)
|
|
77
|
+
const tracks = await this.orchestrator.getTracks(torrent, fileIndex, sourceKey);
|
|
78
|
+
const track = Array.isArray(tracks) ? tracks.find((c) => c.declaredIndex === idx) ?? null : null;
|
|
79
|
+
// Also try domain's declaredIndex-agnostic lookup via getCues path — keep compat with existing subtitle-cues declaredIndex
|
|
80
|
+
let held = null;
|
|
81
|
+
try {
|
|
82
|
+
// Need trackNumber for domain call — find via declared workspace
|
|
83
|
+
const domainTracks = await this.orchestrator.getDeclaredTracks(torrent, fileIndex, sourceKey);
|
|
84
|
+
// If not found, fall back to direct cuesHeldFor via trackNumber from tracks list
|
|
85
|
+
const target = track ?? domainTracks.find((t) => t.declaredIndex === idx) ?? null;
|
|
86
|
+
const trackNumber = target?.trackNumber ?? track?.trackNumber;
|
|
87
|
+
if (trackNumber != null) {
|
|
88
|
+
held = await this.orchestrator.getCues(this.torrentPool, torrent, fileIndex, sourceKey, trackNumber);
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
if (held && Array.isArray(held.cues)) {
|
|
92
|
+
const cursor = held.cues.reduce((h, c) => Math.max(h, Number(c.seq) || 0), 0);
|
|
93
|
+
const fresh = Number.isInteger(since) ? held.cues.filter((c) => (Number(c.seq) || 0) > since)
|
|
94
|
+
: Number.isFinite(after) ? held.cues.filter((c) => c.startSeconds > after) : held.cues;
|
|
95
|
+
const codecId = held.track?.codecId ?? track?.codecId ?? "";
|
|
96
|
+
const vtt = TextSubtitleTrack.cuesToVtt(fresh, codecId);
|
|
97
|
+
// Two things this reads, and each of them was wrong before 2.68.1.
|
|
98
|
+
//
|
|
99
|
+
// It reads the cues through `finalizeCues`, so what reaches the detector
|
|
100
|
+
// is the words and not ASS's `{\…}` override groups, which are Latin on a
|
|
101
|
+
// Russian track. (The dialogue row's own fields are gone earlier now, in
|
|
102
|
+
// the container that framed them — before 2.72.1 they were not gone at
|
|
103
|
+
// all, and the detector was reading them too.)
|
|
104
|
+
//
|
|
105
|
+
// And it reads EVERY cue held so far, not the `fresh` subset that is
|
|
106
|
+
// being sent. A re-subscription after a reconnect asks only for what this
|
|
107
|
+
// page missed, which can be three lines, and three lines are not a sample
|
|
108
|
+
// of a language.
|
|
109
|
+
const language = detectLanguage(
|
|
110
|
+
TextSubtitleTrack.finalizeCues(held.cues, codecId).map((cue) => cue.text).join("\n")
|
|
111
|
+
);
|
|
112
|
+
return {
|
|
113
|
+
vtt,
|
|
114
|
+
language,
|
|
115
|
+
headers: {
|
|
116
|
+
"X-Subtitle-Covered-Clusters": String(held.coveredClusters ?? 0),
|
|
117
|
+
"X-Subtitle-Indexed-Clusters": String(held.indexedClusters ?? 0),
|
|
118
|
+
"X-Subtitle-Cursor": String(cursor)
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { pending: true, status: 202 };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async warm(torrent, fileIndex, sourceKey) {
|
|
126
|
+
return this.orchestrator.warm(torrent, fileIndex, sourceKey);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -69,7 +69,22 @@ export const PROBE_INTERVAL_MS = 500;
|
|
|
69
69
|
* measurable on the same connection, so it is measured and added rather than
|
|
70
70
|
* assumed.
|
|
71
71
|
*
|
|
72
|
-
*
|
|
72
|
+
* There is a fourth term, and without it the third one arrives too late to
|
|
73
|
+
* help. `echoIntervalMs` is the widest gap between two echoes this connection
|
|
74
|
+
* has SHOWN, so it can only grow after a late echo has landed — and a browser
|
|
75
|
+
* whose tab has just been hidden goes quiet before it has taught us anything.
|
|
76
|
+
* The browser measures its own event-loop delay and puts it in every echo, so
|
|
77
|
+
* the delay is known BEFORE the silence rather than after it: an echo timer
|
|
78
|
+
* set for half a second cannot fire until the loop runs, and a loop reported
|
|
79
|
+
* as 4297 ms behind cannot answer sooner than that. Field 2026-09-03: a viewer
|
|
80
|
+
* paused at 15:24:21, the tab went hidden at 15:24:35, `loopLag` climbed
|
|
81
|
+
* 681 → 1881 → 4297 → 5957 ms, and at 15:26:05 the probes read `gap 12 of 11`
|
|
82
|
+
* and printed `association-stopped` on a connection that was `flowing` again
|
|
83
|
+
* seven seconds later. Adding the peer's own reported lag makes that allowance
|
|
84
|
+
* 15 instead of 11, and the gap of 12 is then what it was — a browser whose
|
|
85
|
+
* timers are frozen, not an association that stopped.
|
|
86
|
+
*
|
|
87
|
+
* @param {{ queuedBytes: number, bytesPerSecond: number, rttMs: number, echoIntervalMs?: number, peerLoopLagMs?: number, intervalMs?: number }} state
|
|
73
88
|
* @returns {number | null} Probes that may legitimately be outstanding, or null
|
|
74
89
|
* when no rate has been measured yet and nothing can be said.
|
|
75
90
|
*/
|
|
@@ -78,13 +93,18 @@ export function allowedGap({
|
|
|
78
93
|
bytesPerSecond,
|
|
79
94
|
rttMs,
|
|
80
95
|
echoIntervalMs = 0,
|
|
96
|
+
peerLoopLagMs = 0,
|
|
81
97
|
intervalMs = PROBE_INTERVAL_MS
|
|
82
98
|
}) {
|
|
83
99
|
if (!(bytesPerSecond > 0) || !(intervalMs > 0)) {
|
|
84
100
|
return null;
|
|
85
101
|
}
|
|
86
102
|
const drainMs = (Math.max(queuedBytes, 0) / bytesPerSecond) * 1000;
|
|
87
|
-
const waitMs =
|
|
103
|
+
const waitMs =
|
|
104
|
+
drainMs +
|
|
105
|
+
Math.max(rttMs, 0) +
|
|
106
|
+
Math.max(echoIntervalMs, 0) +
|
|
107
|
+
Math.max(peerLoopLagMs, 0);
|
|
88
108
|
// At least one: a probe sent and not yet echoed is the ordinary state.
|
|
89
109
|
return Math.max(1, Math.ceil(waitMs / intervalMs));
|
|
90
110
|
}
|
|
@@ -149,6 +169,8 @@ export function probeWedgeIsCertain({ stuckForMs, longestHealthySeenGapMs, inter
|
|
|
149
169
|
* @property {number} longestHealthySeenGapMs - The longest gap between two advances this connection has shown while not flagged as a wedge.
|
|
150
170
|
* @property {number | null} peerBytes - The far end's transport-level received total, as last reported.
|
|
151
171
|
* @property {number | null} peerBytesAtTick - The same, as it stood at the previous tick.
|
|
172
|
+
* @property {number | null} peerLoopLagMs - The far end's own event-loop delay, as last reported.
|
|
173
|
+
* @property {string | null} peerVisibility - Whether the far end's tab is visible or hidden, as last reported.
|
|
152
174
|
* @property {boolean} probeCaptureStarted - One evidence-gathering attempt per wedge; reset once `seen` advances again.
|
|
153
175
|
* @property {ReturnType<typeof setInterval> | null} timer
|
|
154
176
|
*/
|
|
@@ -185,7 +207,12 @@ export function probeWedgeIsCertain({ stuckForMs, longestHealthySeenGapMs, inter
|
|
|
185
207
|
* behind the probes are. `null` where the far end does not report it, and then
|
|
186
208
|
* the rule falls back to what it says without the term.
|
|
187
209
|
*
|
|
188
|
-
*
|
|
210
|
+
* `peerLoopLagMs` and `peerVisibility` are printed but never compared against
|
|
211
|
+
* anything here: they are what made the allowance as wide as it is, and a
|
|
212
|
+
* reader of the log cannot check that arithmetic unless the terms are on the
|
|
213
|
+
* same line as the result.
|
|
214
|
+
*
|
|
215
|
+
* @param {{ seq: number, seen: Map<string, number> | Record<string, number>, labels: string[], echoes: number, echoAgeMs: number | null, allowed?: Map<string, number | null> | Record<string, number | null>, echoStaleMs?: number, peerBytesAdvancing?: boolean | null, peerLoopLagMs?: number | null, peerVisibility?: string | null }} state
|
|
189
216
|
* @returns {{ verdict: string, detail: string }}
|
|
190
217
|
*/
|
|
191
218
|
export function readProbeState(state) {
|
|
@@ -224,7 +251,9 @@ export function readProbeState(state) {
|
|
|
224
251
|
`echoAge=${state.echoAgeMs === null ? "never" : `${state.echoAgeMs}ms`}` +
|
|
225
252
|
(state.peerBytesAdvancing === null || state.peerBytesAdvancing === undefined
|
|
226
253
|
? ""
|
|
227
|
-
: ` peerBytes=${advancing ? "advancing" : "still"}`)
|
|
254
|
+
: ` peerBytes=${advancing ? "advancing" : "still"}`) +
|
|
255
|
+
(Number.isFinite(state.peerLoopLagMs) ? ` peerLoopLag=${Math.round(Number(state.peerLoopLagMs))}ms` : "") +
|
|
256
|
+
(state.peerVisibility ? ` peerTab=${state.peerVisibility}` : "");
|
|
228
257
|
|
|
229
258
|
if (state.echoes === 0) {
|
|
230
259
|
return { verdict: "no-echo-yet", detail };
|
|
@@ -326,6 +355,7 @@ export function createDeliveryProbe({
|
|
|
326
355
|
bytesPerSecond,
|
|
327
356
|
rttMs,
|
|
328
357
|
echoIntervalMs: connection.echoIntervalMs,
|
|
358
|
+
peerLoopLagMs: connection.peerLoopLagMs ?? 0,
|
|
329
359
|
intervalMs
|
|
330
360
|
});
|
|
331
361
|
// Several channels can carry one label only in malformed cases; the
|
|
@@ -354,10 +384,18 @@ export function createDeliveryProbe({
|
|
|
354
384
|
labels: [...new Set(connection.channels.values())],
|
|
355
385
|
echoes: connection.echoes,
|
|
356
386
|
echoAgeMs: connection.echoAt === 0 ? null : now - connection.echoAt,
|
|
387
|
+
peerLoopLagMs: connection.peerLoopLagMs,
|
|
388
|
+
peerVisibility: connection.peerVisibility,
|
|
357
389
|
allowed,
|
|
358
390
|
// Same arithmetic for the echo's own age: the peer cannot answer sooner
|
|
359
|
-
// than its own cadence allows,
|
|
360
|
-
echoStaleMs:
|
|
391
|
+
// than its own cadence allows, nor sooner than its own event loop runs.
|
|
392
|
+
echoStaleMs:
|
|
393
|
+
widest > 0
|
|
394
|
+
? widest * intervalMs +
|
|
395
|
+
rttMs +
|
|
396
|
+
connection.echoIntervalMs +
|
|
397
|
+
Math.max(connection.peerLoopLagMs ?? 0, 0)
|
|
398
|
+
: 0
|
|
361
399
|
});
|
|
362
400
|
if (verdict !== connection.verdict || now - connection.reportedAt >= REPORT_INTERVAL_MS) {
|
|
363
401
|
connection.verdict = verdict;
|
|
@@ -430,6 +468,13 @@ export function createDeliveryProbe({
|
|
|
430
468
|
peerBytes: null,
|
|
431
469
|
/** @type {number | null} */
|
|
432
470
|
peerBytesAtTick: null,
|
|
471
|
+
// The far end's own event-loop delay and tab state, as last reported.
|
|
472
|
+
// The lag is a term in every allowance below; the tab state is only
|
|
473
|
+
// printed, so that a wide allowance can be read back to its cause.
|
|
474
|
+
/** @type {number | null} */
|
|
475
|
+
peerLoopLagMs: null,
|
|
476
|
+
/** @type {string | null} */
|
|
477
|
+
peerVisibility: null,
|
|
433
478
|
verdict: "",
|
|
434
479
|
reportedAt: 0,
|
|
435
480
|
lastSeenAdvanceAt: 0,
|
|
@@ -509,6 +554,19 @@ export function createDeliveryProbe({
|
|
|
509
554
|
if (Number.isFinite(peerBytes) && peerBytes >= 0) {
|
|
510
555
|
connection.peerBytes = peerBytes;
|
|
511
556
|
}
|
|
557
|
+
// How far behind the far end's own event loop is running. A browser that
|
|
558
|
+
// cannot run its timers cannot answer a probe, and every allowance here
|
|
559
|
+
// is a wait the answer has to fit inside — so this is a term in the
|
|
560
|
+
// arithmetic, not a note. It is the peer's own measurement, taken on the
|
|
561
|
+
// peer, and it arrives on the direction that survives a freeze.
|
|
562
|
+
const peerLoopLag = Number(echo?.report?.loopLagMs);
|
|
563
|
+
if (Number.isFinite(peerLoopLag) && peerLoopLag >= 0) {
|
|
564
|
+
connection.peerLoopLagMs = peerLoopLag;
|
|
565
|
+
}
|
|
566
|
+
const peerVisibility = echo?.report?.visibility;
|
|
567
|
+
if (typeof peerVisibility === "string" && peerVisibility.length > 0) {
|
|
568
|
+
connection.peerVisibility = peerVisibility;
|
|
569
|
+
}
|
|
512
570
|
if (connection.echoAt !== 0) {
|
|
513
571
|
const sinceLast = now - connection.echoAt;
|
|
514
572
|
if (sinceLast > connection.echoIntervalMs) {
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
} from "./ffmpeg-banner.js";
|
|
61
61
|
import { resolveSegmentFormat, SEGMENT_FORMAT_IDS } from "./segment-formats/index.js";
|
|
62
62
|
import { audioRenditionName } from "./audio-inventory.js";
|
|
63
|
+
import { ProducedIndex } from "./produced-index.js";
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
66
|
* Whether an encoder run died because its INPUT went away, rather than because
|
|
@@ -2862,9 +2863,8 @@ export class HlsSessionManager {
|
|
|
2862
2863
|
const pieces = new Map();
|
|
2863
2864
|
let names;
|
|
2864
2865
|
try {
|
|
2865
|
-
names =
|
|
2866
|
-
|
|
2867
|
-
}))
|
|
2866
|
+
names = this.#producedIndex(session)
|
|
2867
|
+
.fileNames()
|
|
2868
2868
|
.filter((name) => session.segmentFormat.isSegmentFileName(name))
|
|
2869
2869
|
.sort();
|
|
2870
2870
|
} catch {
|
|
@@ -3439,9 +3439,7 @@ export class HlsSessionManager {
|
|
|
3439
3439
|
async #observedStreamMbps(session) {
|
|
3440
3440
|
let names;
|
|
3441
3441
|
try {
|
|
3442
|
-
names = this.#
|
|
3443
|
-
try { return readdirSync(dir, { withFileTypes: false }); } catch { return []; }
|
|
3444
|
-
});
|
|
3442
|
+
names = this.#producedIndex(session).fileNames();
|
|
3445
3443
|
} catch {
|
|
3446
3444
|
return null;
|
|
3447
3445
|
}
|
|
@@ -3858,14 +3856,7 @@ export class HlsSessionManager {
|
|
|
3858
3856
|
#contiguousAheadSeconds(session, viewerSegment) {
|
|
3859
3857
|
let present;
|
|
3860
3858
|
try {
|
|
3861
|
-
|
|
3862
|
-
session.knownNonEmptySegments = new Set();
|
|
3863
|
-
}
|
|
3864
|
-
present = usableSegmentIndices(
|
|
3865
|
-
this.#runDirs(session),
|
|
3866
|
-
this.segmentFormat,
|
|
3867
|
-
session.knownNonEmptySegments
|
|
3868
|
-
);
|
|
3859
|
+
present = this.#producedIndex(session).segmentNumbers();
|
|
3869
3860
|
} catch {
|
|
3870
3861
|
return null;
|
|
3871
3862
|
}
|
|
@@ -6669,9 +6660,7 @@ export class HlsSessionManager {
|
|
|
6669
6660
|
*/
|
|
6670
6661
|
#latestProducedSegment(session) {
|
|
6671
6662
|
let highest = null;
|
|
6672
|
-
for (const name of this.#
|
|
6673
|
-
try { return readdirSync(dir, { withFileTypes: false }); } catch { return []; }
|
|
6674
|
-
})) {
|
|
6663
|
+
for (const name of this.#producedIndex(session).fileNames()) {
|
|
6675
6664
|
if (!this.segmentFormat.isSegmentFileName(name)) {
|
|
6676
6665
|
continue;
|
|
6677
6666
|
}
|
|
@@ -9834,6 +9823,9 @@ export class HlsSessionManager {
|
|
|
9834
9823
|
} catch {
|
|
9835
9824
|
// Already gone, or being rewritten: either way nothing to do.
|
|
9836
9825
|
}
|
|
9826
|
+
// The index answers from what it read; a file removed on purpose
|
|
9827
|
+
// must not still be an answer in the same tick.
|
|
9828
|
+
this.#producedIndex(session).invalidate();
|
|
9837
9829
|
}
|
|
9838
9830
|
return { kind: "warming-up" };
|
|
9839
9831
|
}
|
|
@@ -10111,15 +10103,27 @@ export class HlsSessionManager {
|
|
|
10111
10103
|
* @returns {string[]}
|
|
10112
10104
|
*/
|
|
10113
10105
|
#runDirs(session) {
|
|
10114
|
-
|
|
10115
|
-
|
|
10116
|
-
|
|
10117
|
-
|
|
10118
|
-
|
|
10119
|
-
|
|
10120
|
-
|
|
10121
|
-
|
|
10106
|
+
return this.#producedIndex(session).runDirs();
|
|
10107
|
+
}
|
|
10108
|
+
|
|
10109
|
+
/**
|
|
10110
|
+
* This session's one statement of what it has produced.
|
|
10111
|
+
*
|
|
10112
|
+
* Made on first use and kept on the session, so the directory times it
|
|
10113
|
+
* remembers survive between requests — which is the whole of what makes it
|
|
10114
|
+
* cheaper than the walk it replaces.
|
|
10115
|
+
*
|
|
10116
|
+
* @param {HlsSession} session
|
|
10117
|
+
* @returns {ProducedIndex}
|
|
10118
|
+
*/
|
|
10119
|
+
#producedIndex(session) {
|
|
10120
|
+
if (!(session.producedIndex instanceof ProducedIndex)) {
|
|
10121
|
+
session.producedIndex = new ProducedIndex({
|
|
10122
|
+
dirPath: session.dirPath,
|
|
10123
|
+
segmentFormat: session.segmentFormat ?? this.segmentFormat
|
|
10124
|
+
});
|
|
10122
10125
|
}
|
|
10126
|
+
return session.producedIndex;
|
|
10123
10127
|
}
|
|
10124
10128
|
|
|
10125
10129
|
/**
|
|
@@ -10130,16 +10134,7 @@ export class HlsSessionManager {
|
|
|
10130
10134
|
* @returns {Promise<string | null>}
|
|
10131
10135
|
*/
|
|
10132
10136
|
async #findProducedFile(session, fileName) {
|
|
10133
|
-
|
|
10134
|
-
const candidate = path.join(dir, fileName);
|
|
10135
|
-
try {
|
|
10136
|
-
await access(candidate);
|
|
10137
|
-
return candidate;
|
|
10138
|
-
} catch {
|
|
10139
|
-
// Not this run's; try an older one.
|
|
10140
|
-
}
|
|
10141
|
-
}
|
|
10142
|
-
return null;
|
|
10137
|
+
return this.#producedIndex(session).pathOf(fileName);
|
|
10143
10138
|
}
|
|
10144
10139
|
|
|
10145
10140
|
/**
|
|
@@ -10185,6 +10180,8 @@ export class HlsSessionManager {
|
|
|
10185
10180
|
: null
|
|
10186
10181
|
);
|
|
10187
10182
|
if (removed !== null) {
|
|
10183
|
+
// Removed on purpose, so the index must not go on answering with it.
|
|
10184
|
+
this.#producedIndex(session).invalidate();
|
|
10188
10185
|
logger.info(
|
|
10189
10186
|
`transcode ${session.id} discarded segment #${removed}: ` +
|
|
10190
10187
|
"the run ended while it was open, so it holds no usable piece"
|