@torrent-tv/proxy 2.73.0 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +1447 -1432
  2. package/CLAUDE.md +165 -160
  3. package/docs/container-architecture.md +192 -184
  4. package/package.json +1 -1
  5. package/routes/api/subtitles/get.js +205 -205
  6. package/services/container/Container.js +354 -135
  7. package/services/container/MatroskaContainer.js +1155 -516
  8. package/services/container/Mp4Container.js +858 -392
  9. package/services/container/SubtitleFileContainer.js +323 -261
  10. package/services/controllers/SubtitleController.js +128 -127
  11. package/services/delivery-probe.js +64 -6
  12. package/services/hls-session-manager.js +32 -35
  13. package/services/language-detect.js +174 -228
  14. package/services/orchestrators/SubtitleOrchestrator.js +64 -5
  15. package/services/playback-planner.js +747 -747
  16. package/services/produced-index.js +300 -0
  17. package/services/torrent-worker/subtitle-cues.js +582 -618
  18. package/services/tracks/TextSubtitleTrack.js +287 -47
  19. package/services/tracks/index.js +14 -14
  20. package/test/delivery-probe.test.js +67 -0
  21. package/test/matroska-blocks.test.js +0 -0
  22. package/test/mp4-subtitles.test.js +173 -127
  23. package/test/produced-index.test.js +188 -0
  24. package/test/subtitle-cue-framing.test.js +200 -202
  25. package/test/subtitle-cue-source.test.js +104 -0
  26. package/test/subtitle-cue-walk.test.js +369 -0
  27. package/test/subtitle-defaults.test.js +97 -97
  28. package/test/subtitle-language.test.js +252 -252
  29. package/test/subtitle-track-numbering.test.js +370 -370
  30. package/services/container-index/matroska-blocks.js +0 -202
  31. package/services/container-index/matroska-subtitles.js +0 -372
  32. package/services/container-index/mp4-subtitles.js +0 -404
  33. package/services/subtitle-convert.js +0 -144
  34. package/services/subtitle-defaults.js +0 -157
  35. 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 { convertSubtitleToVtt, cuesToVtt, decodeSubtitleBytes, finalizeCues } from "../subtitle-convert.js";
12
- import { detectLanguage, detectLanguageFromVtt } from "../language-detect.js";
13
-
14
- const EXTERNAL_MAX_BYTES = 8 * 1024 * 1024;
15
-
16
- function readFileFully(file, maxBytes) {
17
- return new Promise((resolve, reject) => {
18
- const stream = file.createReadStream();
19
- const chunks = [];
20
- let total = 0;
21
- stream.on("data", (chunk) => {
22
- total += chunk.length;
23
- if (total > maxBytes) { stream.destroy(); reject(new Error("subtitle file exceeds size cap")); return; }
24
- chunks.push(chunk);
25
- });
26
- stream.on("end", () => resolve(Buffer.concat(chunks)));
27
- stream.on("error", reject);
28
- });
29
- }
30
-
31
- export class SubtitleController {
32
- constructor({ sourceRegistry, torrentPool }) {
33
- this.sourceRegistry = sourceRegistry;
34
- this.torrentPool = torrentPool;
35
- this.orchestrator = subtitleOrchestrator;
36
- }
37
-
38
- /**
39
- * Serve external subtitle file or embedded track.
40
- * Returns { vtt, language, headers } or { error, status }.
41
- */
42
- async getSubtitle({ sourceKey, fileIndex, trackIndex, since, after }) {
43
- const rec = this.sourceRegistry.get(sourceKey);
44
- if (!rec) return { error: "Source key was not found.", status: 404 };
45
- const torrent = await this.torrentPool.getTorrent(rec.sourceType, rec.source);
46
- const file = torrent.files[fileIndex];
47
- if (!file) return { error: "File index was not found in torrent.", status: 404 };
48
-
49
- const hasTrack = trackIndex !== undefined && trackIndex !== "" && Number.isFinite(Number(trackIndex));
50
- if (!hasTrack) {
51
- const name = file.name ?? "";
52
- const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
53
- const release = this.torrentPool.acquireFile(torrent, fileIndex);
54
- try {
55
- const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
56
- const text = decodeSubtitleBytes(bytes);
57
- const vtt = convertSubtitleToVtt(text, ext);
58
- if (!vtt) return { error: `Unsupported subtitle format: ${ext}`, status: 422 };
59
- // The language is read from the CONVERTED document, not from the file.
60
- // The conversion has already dropped everything that is not the words —
61
- // and on an ASS file that is half of it, in Latin letters, which is what
62
- // made a Russian track answer `en` (field 2026-09-01, and the whole of
63
- // `research/subtitle-language-ass-markup-2026-09-01.md`).
64
- return { vtt, language: detectLanguageFromVtt(vtt), headers: {} };
65
- } catch (e) {
66
- return { error: `Could not read subtitle file: ${e?.message ?? e}`, status: 502 };
67
- } finally {
68
- release();
69
- }
70
- }
71
-
72
- const idx = Number(trackIndex);
73
- if (!Number.isInteger(idx) || idx < 0) return { error: "trackIndex must be a non-negative integer.", status: 400 };
74
-
75
- // Resolve via orchestrator (domain: cluster walk or MP4 sample ranges)
76
- const tracks = await this.orchestrator.getTracks(torrent, fileIndex, sourceKey);
77
- const track = Array.isArray(tracks) ? tracks.find((c) => c.declaredIndex === idx) ?? null : null;
78
- // Also try domain's declaredIndex-agnostic lookup via getCues path keep compat with existing subtitle-cues declaredIndex
79
- let held = null;
80
- try {
81
- // Need trackNumber for domain call — find via declared workspace
82
- const domainTracks = await this.orchestrator.getDeclaredTracks(torrent, fileIndex, sourceKey);
83
- // If not found, fall back to direct cuesHeldFor via trackNumber from tracks list
84
- const target = track ?? domainTracks.find((t) => t.declaredIndex === idx) ?? null;
85
- const trackNumber = target?.trackNumber ?? track?.trackNumber;
86
- if (trackNumber != null) {
87
- held = await this.orchestrator.getCues(torrent, fileIndex, sourceKey, trackNumber);
88
- }
89
- } catch {}
90
- if (held && Array.isArray(held.cues)) {
91
- const cursor = held.cues.reduce((h, c) => Math.max(h, Number(c.seq) || 0), 0);
92
- const fresh = Number.isInteger(since) ? held.cues.filter((c) => (Number(c.seq) || 0) > since)
93
- : Number.isFinite(after) ? held.cues.filter((c) => c.startSeconds > after) : held.cues;
94
- const codecId = held.track?.codecId ?? track?.codecId ?? "";
95
- const vtt = cuesToVtt(fresh, codecId);
96
- // Two things this reads, and each of them was wrong before 2.68.1.
97
- //
98
- // It reads the cues through `finalizeCues`, so what reaches the detector
99
- // is the words and not ASS's `{\…}` override groups, which are Latin on a
100
- // Russian track. (The dialogue row's own fields are gone earlier now, in
101
- // the container that framed them before 2.72.1 they were not gone at
102
- // all, and the detector was reading them too.)
103
- //
104
- // And it reads EVERY cue held so far, not the `fresh` subset that is
105
- // being sent. A re-subscription after a reconnect asks only for what this
106
- // page missed, which can be three lines, and three lines are not a sample
107
- // of a language.
108
- const language = detectLanguage(
109
- finalizeCues(held.cues, codecId).map((cue) => cue.text).join("\n")
110
- );
111
- return {
112
- vtt,
113
- language,
114
- headers: {
115
- "X-Subtitle-Covered-Clusters": String(held.coveredClusters ?? 0),
116
- "X-Subtitle-Indexed-Clusters": String(held.indexedClusters ?? 0),
117
- "X-Subtitle-Cursor": String(cursor)
118
- }
119
- };
120
- }
121
- return { pending: true, status: 202 };
122
- }
123
-
124
- async warm(torrent, fileIndex, sourceKey) {
125
- return this.orchestrator.warm(torrent, fileIndex, sourceKey);
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
- * @param {{ queuedBytes: number, bytesPerSecond: number, rttMs: number, echoIntervalMs?: number, intervalMs?: number }} state
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 = drainMs + Math.max(rttMs, 0) + Math.max(echoIntervalMs, 0);
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
- * @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 }} state
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, and a hidden tab's is about a second.
360
- echoStaleMs: widest > 0 ? widest * intervalMs + rttMs + connection.echoIntervalMs : 0
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 = (this.#runDirs(session).flatMap((dir) => {
2866
- try { return readdirSync(dir, { withFileTypes: false }); } catch { return []; }
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.#runDirs(session).flatMap((dir) => {
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
- if (!(session.knownNonEmptySegments instanceof Set)) {
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.#runDirs(session).flatMap((dir) => {
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
- try {
10115
- return readdirSync(session.dirPath, { withFileTypes: true })
10116
- .filter((entry) => entry.isDirectory() && entry.name.startsWith("run-"))
10117
- .map((entry) => entry.name)
10118
- .sort((a, b) => Number(b.slice(4)) - Number(a.slice(4)))
10119
- .map((name) => path.join(session.dirPath, name));
10120
- } catch {
10121
- return [];
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
- for (const dir of this.#runDirs(session)) {
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"