@torrent-tv/proxy 2.91.0 → 2.92.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 (145) hide show
  1. package/bin/cli.js +3 -2
  2. package/package.json +18 -2
  3. package/routes/api/playback-plan/post.js +21 -4
  4. package/routes/api/sources/files/get.js +20 -28
  5. package/routes/api/sources/post.js +4 -2
  6. package/routes/api/sources/stats/get.js +4 -11
  7. package/routes/api/sources/viewer/post.js +16 -0
  8. package/routes/api/sources/warm/post.js +6 -215
  9. package/routes/api/subtitles/get.js +22 -125
  10. package/routes/api/subtitles/post.js +26 -0
  11. package/routes/api/transcode-sessions/post.js +24 -2
  12. package/routes/media/get.js +80 -0
  13. package/routes/stream/get.js +26 -94
  14. package/routes/transcode/audio-warm/get.js +9 -21
  15. package/routes/transcode/variant-warm/get.js +8 -14
  16. package/server.js +410 -46
  17. package/services/encode/AdmittedInput.js +136 -0
  18. package/services/encode/CushionReport.js +1 -76
  19. package/services/encode/EncodeInputs.js +142 -0
  20. package/services/encode/EncodeOrchestrator.js +4 -1
  21. package/services/encode/EncodeRun.js +13 -4
  22. package/services/encode/EncodeRuns.js +132 -52
  23. package/services/encode/InputFailures.js +37 -0
  24. package/services/encode/MatroskaInput.js +158 -0
  25. package/services/encode/OutputOpening.js +16 -64
  26. package/services/encode/Renditions.js +1 -17
  27. package/services/encode/admitted-command.js +60 -0
  28. package/services/encode/annex-b-input.js +37 -0
  29. package/services/encode/bit-payload.js +29 -0
  30. package/services/encode/output/OutputCatalog.js +1 -1
  31. package/services/encode/output/Timeline.js +11 -0
  32. package/services/encode/pcm-normalization.js +30 -0
  33. package/services/encode/piece-completeness.js +54 -1
  34. package/services/encode/quality/HostLoad.js +19 -73
  35. package/services/encode/quality/QualityOffer.js +1 -1
  36. package/services/encode/run-command.js +1 -589
  37. package/services/encode/segment-formats/mp4-boxes.js +3 -1
  38. package/services/encode/segment-formats/presentation-segment.js +114 -0
  39. package/services/media/ContainerOrchestrator.js +118 -48
  40. package/services/media/KeyframeTables.js +16 -115
  41. package/services/media/MediaReadRequests.js +120 -0
  42. package/services/media/ProbeRequests.js +135 -0
  43. package/services/media/SegmentInputs.js +48 -0
  44. package/services/media/SourceFile.js +6 -0
  45. package/services/media/SubtitleOrchestrator.js +190 -4
  46. package/services/media/await-plan.js +47 -0
  47. package/services/media/container/AsfContainer.js +217 -0
  48. package/services/media/container/AviContainer.js +343 -34
  49. package/services/media/container/CadencedPictureOrder.js +45 -0
  50. package/services/media/container/Container.js +7 -3
  51. package/services/media/container/ContainerFactory.js +10 -3
  52. package/services/media/container/FfprobeContainer.js +222 -0
  53. package/services/media/container/MatroskaContainer.js +72 -46
  54. package/services/media/container/Mp4Container.js +472 -139
  55. package/services/media/container/MpegPsContainer.js +281 -0
  56. package/services/media/container/MpegTsContainer.js +277 -0
  57. package/services/media/container/PacketIndex.js +270 -0
  58. package/services/media/container/PacketRecords.js +237 -0
  59. package/services/media/container/RetainedBytes.js +39 -0
  60. package/services/media/container/RetainedReads.js +51 -0
  61. package/services/media/container/ac3-frame.js +46 -0
  62. package/services/media/container/asf-packets.js +153 -0
  63. package/services/media/container/avc-picture-order.js +50 -0
  64. package/services/media/container/avi-open-dml.js +47 -0
  65. package/services/media/container/dts-frame.js +51 -0
  66. package/services/media/container/ebml-stream.js +12 -1
  67. package/services/media/container/ffprobe-record.js +45 -0
  68. package/services/media/container/flv-packet-ranges.js +27 -0
  69. package/services/media/container/following-packet-time.js +37 -0
  70. package/services/media/container/h264-configuration.js +183 -0
  71. package/services/media/container/h264-elementary-index.js +245 -0
  72. package/services/media/container/hevc-configuration.js +165 -0
  73. package/services/media/container/hevc-elementary-index.js +132 -0
  74. package/services/media/container/hevc-picture-order.js +66 -0
  75. package/services/media/container/latm-frame.js +99 -0
  76. package/services/media/container/matroska-packets.js +207 -0
  77. package/services/media/container/memory-unavailable.js +8 -0
  78. package/services/media/container/mp3-packet-dependencies.js +41 -0
  79. package/services/media/container/mp4-fragments.js +148 -0
  80. package/services/media/container/mpeg-audio-frame.js +19 -0
  81. package/services/media/container/mpeg-audio-reservoir.js +27 -0
  82. package/services/media/container/mpeg-elementary-index.js +196 -0
  83. package/services/media/container/mpeg-sequence-reader.js +69 -0
  84. package/services/media/container/mpeg-ts-packets.js +93 -0
  85. package/services/media/container/mpeg-video-configuration.js +50 -0
  86. package/services/media/container/mpeg4-picture-timing.js +108 -0
  87. package/services/media/container/pes-header.js +34 -0
  88. package/services/media/playback-declarations.js +57 -0
  89. package/services/media/playback-planner.js +72 -230
  90. package/services/media/probe-packets.js +61 -0
  91. package/services/media/read-playback-declarations.js +18 -0
  92. package/services/media/tracks/AudioTrack.js +9 -0
  93. package/services/media/tracks/TextSubtitleTrack.js +4 -1
  94. package/services/server/OutputLifecycle.js +2 -0
  95. package/services/server/SegmentServing.js +40 -33
  96. package/services/server/ViewerRequests.js +22 -41
  97. package/services/server/controllers/PlaybackController.js +4 -0
  98. package/services/server/controllers/SubtitleController.js +8 -29
  99. package/services/server/transcode-session-files.js +58 -99
  100. package/services/server/wire-outputs.js +58 -15
  101. package/services/storage/IndexMemory.js +85 -0
  102. package/services/storage/MachineBudget.js +27 -3
  103. package/services/storage/allowance.js +8 -1
  104. package/services/storage/piece-store/piece-disk-store.js +18 -3
  105. package/services/storage/piece-store/piece-lru.js +8 -4
  106. package/services/storage/piece-store/shared-piece-store.js +26 -11
  107. package/services/storage/segment-store/SegmentStore.js +49 -18
  108. package/services/storage/wire.js +7 -0
  109. package/services/torrent/Contents.js +52 -20
  110. package/services/torrent/demand/DemandRegister.js +13 -4
  111. package/services/torrent/demand/Window.js +13 -2
  112. package/services/torrent/demand-progress.js +25 -0
  113. package/services/torrent/download/DeadlineScheduler.js +167 -0
  114. package/services/torrent/download/FutureDownload.js +166 -0
  115. package/services/torrent/download/SwarmSelection.js +30 -59
  116. package/services/torrent/download/registry.js +45 -5
  117. package/services/torrent/download/withdraw-claim.js +2 -0
  118. package/services/torrent/torrent-pool.js +39 -600
  119. package/services/torrent/worker/channel.js +8 -1
  120. package/services/torrent/worker/client.js +74 -72
  121. package/services/torrent/worker/files-in-use.js +9 -0
  122. package/services/torrent/worker/handle-state.js +12 -0
  123. package/services/torrent/worker/held-bytes.js +82 -99
  124. package/services/torrent/worker/held-file-bytes.js +17 -0
  125. package/services/torrent/worker/piece-reader.js +67 -683
  126. package/services/torrent/worker/pool-adapter.js +37 -123
  127. package/services/torrent/worker/protocol.js +6 -17
  128. package/services/torrent/worker/whole-file-stats.js +2 -0
  129. package/services/torrent/worker/worker.js +86 -156
  130. package/services/transport/data-channel-handler.js +56 -13
  131. package/services/viewer/DownloadMaps.js +168 -0
  132. package/services/viewer/NativeSourceMap.js +33 -0
  133. package/services/viewer/PriorityMap.js +49 -40
  134. package/services/viewer/PriorityOrchestrator.js +42 -13
  135. package/services/viewer/SourcePreparation.js +278 -0
  136. package/services/viewer/Viewer.js +11 -0
  137. package/services/viewer/Viewers.js +109 -2
  138. package/services/viewer/playback-readiness.js +50 -63
  139. package/services/viewer/segment-demands.js +37 -0
  140. package/services/viewer/urgent-output-ready.js +26 -0
  141. package/services/encode/run-inputs.js +0 -53
  142. package/services/media/keyframe-probe.js +0 -157
  143. package/services/media/media-info-probe.js +0 -93
  144. package/services/torrent/worker/background-fill.js +0 -213
  145. package/services/torrent/worker/resume-warm.js +0 -107
@@ -0,0 +1,114 @@
1
+ import { readTrackEdits, walkBoxes } from "./mp4-boxes.js";
2
+
3
+ /** Repartition closed muxer output by presentation interval, preserving decode order. */
4
+ export function presentationSegment(current, following, { from, to }) {
5
+ const own = packetsOf(current);
6
+ const next = following ? packetsOf(following) : null;
7
+ const tracks = [];
8
+ for (const [id, track] of own.tracks) {
9
+ const edit = own.edits.get(id);
10
+ if (!edit) throw new Error("A segment track has no declared clock.");
11
+ const samples = [...track.samples];
12
+ if (track.kind === "soun" && next?.tracks.has(id)) {
13
+ const nextEdit = next.edits.get(id);
14
+ if (!nextEdit || nextEdit.timescale !== edit.timescale) throw new Error("Adjacent segment clocks differ.");
15
+ for (const sample of next.tracks.get(id).samples) {
16
+ samples.push({ ...sample, dts: sample.dts + nextEdit.offset - edit.offset });
17
+ }
18
+ }
19
+ const start = BigInt(Math.round(from * Number(edit.timescale)));
20
+ const end = BigInt(Math.round(to * Number(edit.timescale)));
21
+ const selected = samples.filter(sample => {
22
+ const pts = sample.dts + sample.composition + edit.offset;
23
+ return pts < end && pts + sample.duration > start;
24
+ });
25
+ selected.sort((left, right) => left.dts < right.dts ? -1 : left.dts > right.dts ? 1 : 0);
26
+ if (!selected.length) throw new Error("A presentation segment lacks required media.");
27
+ tracks.push({ id, samples: selected });
28
+ }
29
+ const makeMoof = offset => box("moof", full("mfhd", 0, 0, u32(1)), ...tracks.map(track => {
30
+ const entries = track.samples.map(sample => Buffer.concat([
31
+ u32(Number(sample.duration)), u32(sample.bytes.length), u32(sample.flags), i32(Number(sample.composition))
32
+ ]));
33
+ const payload = Buffer.concat([u32(track.samples.length), i32(offset), ...entries]);
34
+ offset += track.samples.reduce((bytes, sample) => bytes + sample.bytes.length, 0);
35
+ const clock = Buffer.alloc(8);
36
+ clock.writeBigUInt64BE(track.samples[0].dts);
37
+ return box("traf", full("tfhd", 0, 0x20000, u32(track.id)), full("tfdt", 1, 0, clock), full("trun", 1, 0xf01, payload));
38
+ }));
39
+ const size = makeMoof(0).length;
40
+ return Buffer.concat([own.init, makeMoof(size + 8), box("mdat", ...tracks.flatMap(track => track.samples.map(sample => sample.bytes)))]);
41
+ }
42
+
43
+ function packetsOf(raw) {
44
+ const edits = readTrackEdits(raw);
45
+ const tracks = new Map();
46
+ const defaults = new Map();
47
+ const payloads = [];
48
+ walkBoxes(raw, (type, start, end) => { if (type === "mdat") payloads.push([start, end]); }, 0, raw.length, () => false);
49
+ let id = null, firstMoof = null;
50
+ walkBoxes(raw, (type, start) => {
51
+ if (type === "tkhd") id = raw.readUInt32BE(start + (raw[start] === 1 ? 20 : 12));
52
+ if (type === "hdlr" && id !== null) tracks.set(id, { kind: raw.toString("latin1", start + 8, start + 12), samples: [] });
53
+ if (type === "trex") defaults.set(raw.readUInt32BE(start + 4), {
54
+ duration: raw.readUInt32BE(start + 12), size: raw.readUInt32BE(start + 16), flags: raw.readUInt32BE(start + 20)
55
+ });
56
+ });
57
+ walkBoxes(raw, (type, start, end) => {
58
+ if (type !== "moof") return;
59
+ const moof = start - 8;
60
+ firstMoof ??= moof;
61
+ walkBoxes(raw, (type, trafStart, trafEnd) => {
62
+ if (type !== "traf") return;
63
+ let trackId, clock = 0n, base = moof, data = null, values;
64
+ walkBoxes(raw, (type, at, boxEnd) => {
65
+ if (type === "tfhd") {
66
+ const flags = raw.readUIntBE(at + 1, 3);
67
+ trackId = raw.readUInt32BE(at + 4);
68
+ values = { ...defaults.get(trackId) };
69
+ let cursor = at + 8;
70
+ if (flags & 1) { base = Number(raw.readBigUInt64BE(cursor)); cursor += 8; }
71
+ if (flags & 2) cursor += 4;
72
+ for (const [flag, field] of [[8, "duration"], [16, "size"], [32, "flags"]]) {
73
+ if (flags & flag) { values[field] = raw.readUInt32BE(cursor); cursor += 4; }
74
+ }
75
+ } else if (type === "tfdt") {
76
+ clock = raw[at] === 1 ? raw.readBigUInt64BE(at + 4) : BigInt(raw.readUInt32BE(at + 4));
77
+ } else if (type === "trun") {
78
+ if (!tracks.has(trackId) || !values) throw new Error("Fragment refers to an undeclared track.");
79
+ const flags = raw.readUIntBE(at + 1, 3), count = raw.readUInt32BE(at + 4);
80
+ let cursor = at + 8, firstFlags = null;
81
+ if (flags & 1) { data = base + raw.readInt32BE(cursor); cursor += 4; }
82
+ if (flags & 4) { firstFlags = raw.readUInt32BE(cursor); cursor += 4; }
83
+ if (data === null) throw new Error("Fragment has no explicit payload location.");
84
+ for (let index = 0; index < count; index++) {
85
+ const sample = { ...values, composition: 0n, dts: clock };
86
+ if (index === 0 && firstFlags !== null) sample.flags = firstFlags;
87
+ for (const [flag, field] of [[0x100, "duration"], [0x200, "size"], [0x400, "flags"], [0x800, "composition"]]) {
88
+ if (flags & flag) {
89
+ if (cursor + 4 > boxEnd) throw new Error("Fragment sample declaration is truncated.");
90
+ sample[field] = field === "composition" ? BigInt(raw[at] === 1 ? raw.readInt32BE(cursor) : raw.readUInt32BE(cursor)) : raw.readUInt32BE(cursor);
91
+ cursor += 4;
92
+ }
93
+ }
94
+ if (!(sample.duration > 0) || !(sample.size > 0) || !Number.isSafeInteger(data) ||
95
+ !payloads.some(([start, end]) => data >= start && data + sample.size <= end)) throw new Error("Fragment sample bytes are incomplete.");
96
+ sample.duration = BigInt(sample.duration);
97
+ sample.bytes = raw.subarray(data, data + sample.size);
98
+ tracks.get(trackId).samples.push(sample);
99
+ data += sample.size;
100
+ clock += sample.duration;
101
+ }
102
+ }
103
+ }, trafStart, trafEnd, () => false);
104
+ }, start, end, () => false);
105
+ }, 0, raw.length, () => false);
106
+ if (firstMoof === null) throw new Error("A closed segment has no fragments.");
107
+ if (tracks.size === 0 || [...tracks.values()].some(track => track.samples.length === 0)) throw new Error("A closed segment lacks declared track samples.");
108
+ return { init: raw.subarray(0, firstMoof), edits, tracks };
109
+ }
110
+
111
+ function box(type, ...parts) { const payload = Buffer.concat(parts); return Buffer.concat([u32(payload.length + 8), Buffer.from(type), payload]); }
112
+ function full(type, version, flags, payload) { const header = Buffer.alloc(4); header[0] = version; header.writeUIntBE(flags, 1, 3); return box(type, header, payload); }
113
+ function u32(value) { const bytes = Buffer.alloc(4); bytes.writeUInt32BE(value); return bytes; }
114
+ function i32(value) { const bytes = Buffer.alloc(4); bytes.writeInt32BE(value); return bytes; }
@@ -16,7 +16,8 @@
16
16
  */
17
17
 
18
18
  import { ContainerFactory } from "./container/ContainerFactory.js";
19
- import { isUnavailable } from "./container/unavailable.js";
19
+ import { BytesUnavailable, isUnavailable } from "./container/unavailable.js";
20
+ import { IndexMemoryUnavailable } from "./container/memory-unavailable.js";
20
21
  import { logger } from "../../utils/logger.js";
21
22
 
22
23
  /**
@@ -29,6 +30,12 @@ import { logger } from "../../utils/logger.js";
29
30
  export const CONTAINER_HEAD_BYTES = 256 * 1024;
30
31
 
31
32
  export class ContainerOrchestrator {
33
+ #reads = new Map();
34
+ #lifetimes = new Map();
35
+ #activityEpoch = 0;
36
+ #activeReads = 0;
37
+
38
+ activity() { return { epoch: this.#activityEpoch, active: this.#activeReads > 0 }; }
32
39
  constructor() {
33
40
  /** @type {Map<string, import("./container/Container.js").Container|null>} */
34
41
  this.cache = new Map();
@@ -60,6 +67,93 @@ export class ContainerOrchestrator {
60
67
  return this.cache.get(`${sourceKey}:${fileIndex}`);
61
68
  }
62
69
 
70
+ packetIndexBytes() {
71
+ return [...new Set(this.cache.values())].reduce((bytes, container) => bytes + (container?.packetIndexBytes?.() ?? 0), 0);
72
+ }
73
+
74
+ /** Read one statement without converting missing bytes into an empty answer. */
75
+ async inspect(params, statement = "tracks") {
76
+ if (!["container", "tracks", "media-info", "keyframes", "packets"].includes(statement)) {
77
+ throw new TypeError(`Unknown media statement: ${statement}`);
78
+ }
79
+ const key = `${params.sourceKey}:${params.fileIndex}`;
80
+ let lifetime = this.#lifetimes.get(key);
81
+ if (!lifetime) this.#lifetimes.set(key, lifetime = {});
82
+ const isCurrent = () => this.#lifetimes.get(key) === lifetime && params.isCurrent?.() !== false;
83
+ const previous = this.#reads.get(key) ?? Promise.resolve();
84
+ const reading = previous.catch(() => undefined).then(async () => {
85
+ if (!isCurrent()) return { result: { kind: "terminal", reason: "request-obsolete", requestId: params.requestId } };
86
+ const revision = params.onReadStart?.(statement);
87
+ const result = await this.#inspectRead({ ...params, isCurrent }, statement);
88
+ return { result, revision };
89
+ });
90
+ this.#reads.set(key, reading);
91
+ try {
92
+ const { result, revision } = await reading;
93
+ if (!isCurrent()) return { kind: "terminal", reason: "request-obsolete", requestId: params.requestId };
94
+ if (result.kind === "needs-ranges") params.onNeedsRanges?.(result);
95
+ if (statement === "tracks" && result.kind === "result") params.onTracks?.(result.value);
96
+ await params.onReadResult?.(statement, result, revision);
97
+ return result;
98
+ } finally {
99
+ if (this.#reads.get(key) === reading) this.#reads.delete(key);
100
+ }
101
+ }
102
+
103
+ async #inspectRead(params, statement) {
104
+ const requestId = params.requestId ?? `${params.sourceKey}:${params.fileIndex}:${statement}`;
105
+ this.#activityEpoch++;
106
+ this.#activeReads++;
107
+ try {
108
+ const container = await this.containerFor(params);
109
+ if (!container) {
110
+ const result = { kind: "terminal", reason: "format-not-supported", requestId };
111
+ logger.info(`media request=${requestId} terminal=${result.reason}`);
112
+ return result;
113
+ }
114
+ let value;
115
+ if (statement === "container") {
116
+ value = container;
117
+ } else if (statement === "tracks") {
118
+ const key = `${params.sourceKey}:${params.fileIndex}`;
119
+ value = this.tracks.get(key);
120
+ if (!value) {
121
+ value = await container.readTracks();
122
+ if (!Array.isArray(value)) throw new Error("Container did not return a track table.");
123
+ if (params.isCurrent?.() === false) return { kind: "terminal", reason: "request-obsolete", requestId };
124
+ this.tracks.set(key, value);
125
+ }
126
+ } else if (statement === "media-info") {
127
+ value = await container.readMediaInfo();
128
+ } else if (statement === "keyframes") {
129
+ value = await container.readKeyframeIndex();
130
+ } else if (statement === "packets") {
131
+ value = await container.readPacketIndex(params.packetInterval);
132
+ await value?.prepareAudioDependencies?.(params.packetInterval, container.readRange);
133
+ } else {
134
+ throw new TypeError(`Unknown media statement: ${statement}`);
135
+ }
136
+ return { kind: "result", value, requestId };
137
+ } catch (error) {
138
+ if (error instanceof IndexMemoryUnavailable) {
139
+ const result = { kind: "needs-memory", bytes: error.bytes, requestId };
140
+ logger.info(`media request=${requestId} needs memory=${error.bytes}`);
141
+ return result;
142
+ }
143
+ if (isUnavailable(error)) {
144
+ const result = { kind: "needs-ranges", ranges: [[error.start, error.end]], requestId };
145
+ logger.info(`media request=${requestId} needs bytes=${error.start}-${error.end}`);
146
+ return result;
147
+ }
148
+ const result = { kind: "terminal", reason: "media-read-failed", message: error?.message ?? String(error), requestId };
149
+ logger.warn(`media request=${requestId} terminal=${result.reason}: ${result.message}`);
150
+ return result;
151
+ } finally {
152
+ this.#activeReads--;
153
+ this.#activityEpoch++;
154
+ }
155
+ }
156
+
63
157
  /**
64
158
  * The file's one container, built on first ask.
65
159
  *
@@ -74,12 +168,13 @@ export class ContainerOrchestrator {
74
168
  * @throws {import("./container/unavailable.js").BytesUnavailable} While the
75
169
  * head has not arrived. Nothing is kept for it.
76
170
  */
77
- async containerFor({ sourceKey, fileIndex, readRange, fileSize, label = "", portionBytes }) {
171
+ async containerFor({ sourceKey, fileIndex, readRange, fileSize, label = "", portionBytes, probe, packetMemory }) {
78
172
  const key = `${sourceKey}:${fileIndex}`;
79
173
  if (this.cache.has(key)) return this.cache.get(key);
80
174
  if (this.pending.has(key)) return this.pending.get(key);
81
- const p = ContainerFactory.create({ readRange, fileSize, label, portionBytes })
175
+ const p = ContainerFactory.create({ readRange, fileSize, label, portionBytes, probe, packetMemory })
82
176
  .then((c) => {
177
+ if (this.pending.get(key) !== p) throw new Error("Container request was withdrawn.");
83
178
  this.cache.set(key, c);
84
179
  if (c) logger.info(`container: ${c.formatName} for "${label}"`);
85
180
  else logger.info(`container: unknown for "${label}"`);
@@ -92,7 +187,7 @@ export class ContainerOrchestrator {
92
187
  throw e;
93
188
  })
94
189
  .finally(() => {
95
- this.pending.delete(key);
190
+ if (this.pending.get(key) === p) this.pending.delete(key);
96
191
  });
97
192
  this.pending.set(key, p);
98
193
  return p;
@@ -106,13 +201,8 @@ export class ContainerOrchestrator {
106
201
  * @returns {Promise<import("./container/Container.js").Container|null>}
107
202
  */
108
203
  async getContainer(params) {
109
- try {
110
- return await this.containerFor(params);
111
- } catch {
112
- // `containerFor` has already said what failed, where it was not a
113
- // shortage of bytes; either way nothing was kept.
114
- return null;
115
- }
204
+ const result = await this.inspect(params, "container");
205
+ return result.kind === "result" ? result.value : null;
116
206
  }
117
207
 
118
208
  /**
@@ -121,26 +211,8 @@ export class ContainerOrchestrator {
121
211
  * the bytes have not arrived; that empty answer is not kept.
122
212
  */
123
213
  async getTracks(params) {
124
- const key = `${params.sourceKey}:${params.fileIndex}`;
125
- const known = this.tracks.get(key);
126
- if (known) {
127
- return known;
128
- }
129
- try {
130
- const container = await this.containerFor(params);
131
- if (!container) return [];
132
- const tracks = await container.readTracks();
133
- if (Array.isArray(tracks)) {
134
- this.tracks.set(key, tracks);
135
- return tracks;
136
- }
137
- return [];
138
- } catch (e) {
139
- if (!isUnavailable(e)) {
140
- logger.warn(`container: readTracks failed for "${params.label}": ${e?.message ?? e}`);
141
- }
142
- return [];
143
- }
214
+ const result = await this.inspect(params, "tracks");
215
+ return result.kind === "result" ? result.value : [];
144
216
  }
145
217
 
146
218
  /**
@@ -155,16 +227,8 @@ export class ContainerOrchestrator {
155
227
  * @returns {Promise<import("./container/Container.js").ContainerMediaInfo|null>}
156
228
  */
157
229
  async getMediaInfo(params) {
158
- try {
159
- const container = await this.containerFor(params);
160
- if (!container) return null;
161
- return await container.readMediaInfo();
162
- } catch (e) {
163
- if (!isUnavailable(e)) {
164
- logger.warn(`container: readMediaInfo failed for "${params.label}": ${e?.message ?? e}`);
165
- }
166
- return null;
167
- }
230
+ const result = await this.inspect(params, "media-info");
231
+ return result.kind === "result" ? result.value : null;
168
232
  }
169
233
 
170
234
  /**
@@ -176,17 +240,23 @@ export class ContainerOrchestrator {
176
240
  * not record "no keyframes" for a file whose index is still downloading.
177
241
  */
178
242
  async getKeyframeIndex(params) {
179
- const container = await this.containerFor(params);
180
- if (!container) return null;
181
- try {
182
- return await container.readKeyframeIndex();
183
- } catch (e) {
184
- if (isUnavailable(e)) throw e;
185
- return null;
243
+ const result = await this.inspect(params, "keyframes");
244
+ if (result.kind === "needs-memory") throw new IndexMemoryUnavailable(result.bytes);
245
+ if (result.kind === "needs-ranges") {
246
+ const [start, end] = result.ranges[0];
247
+ throw new BytesUnavailable(start, end, 0);
186
248
  }
249
+ return result.kind === "result" ? result.value : null;
187
250
  }
188
251
 
189
252
  forget(sourceKey, fileIndex) {
253
+ const prefix = `${sourceKey}:`;
254
+ for (const key of this.#lifetimes.keys()) {
255
+ if (fileIndex === undefined ? key.startsWith(prefix) : key === `${sourceKey}:${fileIndex}`) {
256
+ this.#lifetimes.delete(key);
257
+ this.#reads.delete(key);
258
+ }
259
+ }
190
260
  if (fileIndex === undefined) {
191
261
  for (const k of [...this.cache.keys()]) if (k.startsWith(`${sourceKey}:`)) this.cache.delete(k);
192
262
  for (const k of [...this.pending.keys()]) if (k.startsWith(`${sourceKey}:`)) this.pending.delete(k);
@@ -1,69 +1,9 @@
1
- /**
2
- * @file Reading the keyframe table of a file: once, whoever asks, with a bound
3
- * on how long anybody waits for it.
4
- *
5
- * The table itself is `container/KeyframeTable.js`, one object per file. This is
6
- * the policy around filling it in, and it is a policy because three things had
7
- * to be true at once and each of them was learned from a field session:
8
- *
9
- * 1. **once per file, and one WAIT per file.** Two sessions created in the same
10
- * moment used to miss the cache together and read the table twice — which is
11
- * what two viewers opening one film do, measured 13 ms apart on 2026-09-03.
12
- * Whoever asks second joins the read already running;
13
- * 2. **the wait is bounded, the READ is not.** A picture cannot be copied
14
- * without the table, so the answer to "not yet" is to re-encode — but the
15
- * read goes on, lands in the file's own table, and is there for whoever holds
16
- * it. Cancelling it would make every later viewer pay the whole wait again;
17
- * 3. **a read that THREW is not an answer.** "The head is not downloaded" says
18
- * nothing about the file; turning it into "this file has no keyframes" makes
19
- * every picture of it a re-encode for as long as the process lives. Bytes
20
- * that have not arrived reach here as `BytesUnavailable` — until 2026-10-01
21
- * they came back as a reading of no times, which `KeyframeTable.learn`
22
- * records as answered, so point 3 held only for reads that crashed. When
23
- * pieces of such a file arrive the read is made again
24
- * (`readAgainIfUnanswered`), and its answer lands in the same table.
25
- *
26
- * **What it does NOT know.** Where the bytes come from. The reader is one
27
- * function handed in at construction — on this proxy it crosses to the torrent
28
- * thread, where one container per file answers the track table and the media
29
- * info from the same header — so this can be exercised with plain values alone.
30
- * It used to live in the session manager, which also carried a SECOND reader
31
- * that fetched the file over the proxy's own HTTP and parsed it there: a
32
- * transport inside a layer that must not have one, and a second answer to a
33
- * question with one owner.
34
- *
35
- * **Why this is not `ContainerOrchestrator`.** Both live on the main thread
36
- * (the parse moved there on 2026-09-15). That one holds the file's one
37
- * container, which keeps its own reading of the Cues table; this keeps the
38
- * ANSWER the sessions hold — the keyframe times taken from that reading, and
39
- * the wait around it, which a container cannot have because it does not know
40
- * there is a viewer.
41
- */
1
+ /** One shared keyframe table per source file. Reads use available bytes only. */
42
2
 
43
3
  import { KeyframeTable } from "./container/KeyframeTable.js";
44
4
  import { isUnavailable } from "./container/unavailable.js";
45
5
  import { logger as defaultLogger } from "../../utils/logger.js";
46
6
 
47
- /**
48
- * How long anybody waits for the table before giving up on copying the picture.
49
- *
50
- * Measured on the addon host, 2026-09-04, over seventeen files from
51
- * `Dropbox/trn` — four containers, pieces from 0.25 to 16 MB, files from 0.36 to
52
- * 20 GB, each torrent registered fresh so nothing of it was downloaded
53
- * (`research/keyframe-table-read-2026-09-04.md`). Every table that arrived did
54
- * so within 24.8 s, most within half a second; the two files that answered
55
- * nothing took 120.9 s and 120.5 s.
56
- *
57
- * Those two figures are not a coincidence and they are what fixes this one:
58
- * they are TWO of the bound the read already has — `READ_ABANDON_MS` in
59
- * `torrent/worker/resume-warm.js`, one for the wait on the file's edges and
60
- * one for the read itself, in series. A caller waiting for two of them is the
61
- * defect; waiting for one is the bound, and it leaves 2.4x over the slowest
62
- * table that did arrive. The line printed when it fires names which case
63
- * happened, so the field can move it rather than an argument.
64
- */
65
- export const KEYFRAME_TABLE_BUDGET_MS = 60_000;
66
-
67
7
  export class KeyframeTables {
68
8
  /** One per file, handed out rather than copied. @type {Map<string, KeyframeTable>} */
69
9
  #byFile = new Map();
@@ -74,9 +14,6 @@ export class KeyframeTables {
74
14
  /** @type {((params: { sourceKey: string, fileIndex: number }) => Promise<object | null>) | null} */
75
15
  #readTable;
76
16
 
77
- /** @type {number} */
78
- #budgetMs;
79
-
80
17
  /** @type {{ info: Function, warn: Function }} */
81
18
  #logger;
82
19
 
@@ -85,13 +22,10 @@ export class KeyframeTables {
85
22
  * @param {(params: { sourceKey: string, fileIndex: number }) => Promise<object | null>} [params.readTable] -
86
23
  * Whatever can answer what this file's container states. The whole of this
87
24
  * object's outside world.
88
- * @param {number} [params.budgetMs] - How long `within` waits.
89
25
  * @param {{ info: Function, warn: Function }} [params.logger]
90
26
  */
91
- constructor({ readTable = null, budgetMs = KEYFRAME_TABLE_BUDGET_MS, logger = defaultLogger } = {}) {
27
+ constructor({ readTable = null, logger = defaultLogger } = {}) {
92
28
  this.#readTable = typeof readTable === "function" ? readTable : null;
93
- const declared = Number(budgetMs);
94
- this.#budgetMs = Number.isFinite(declared) && declared > 0 ? declared : KEYFRAME_TABLE_BUDGET_MS;
95
29
  this.#logger = logger;
96
30
  }
97
31
 
@@ -104,11 +38,6 @@ export class KeyframeTables {
104
38
  return `${sourceKey}:${fileIndex}`;
105
39
  }
106
40
 
107
- /** How long a caller of `within` waits. @returns {number} */
108
- get budgetMs() {
109
- return this.#budgetMs;
110
- }
111
-
112
41
  /**
113
42
  * This file's table — the one object everyone reading it holds.
114
43
  *
@@ -173,6 +102,7 @@ export class KeyframeTables {
173
102
  }
174
103
  const work = started
175
104
  .then((reading) => {
105
+ if (this.#byFile.get(key) !== table) throw new DOMException("Keyframe request was withdrawn.", "AbortError");
176
106
  table.learn({
177
107
  times: Array.isArray(reading?.times) ? reading.times : null,
178
108
  tolerance: reading?.tolerance,
@@ -202,7 +132,7 @@ export class KeyframeTables {
202
132
  throw error;
203
133
  })
204
134
  .finally(() => {
205
- this.#reading.delete(key);
135
+ if (this.#reading.get(key) === work) this.#reading.delete(key);
206
136
  });
207
137
  this.#reading.set(key, work);
208
138
  return work;
@@ -247,47 +177,15 @@ export class KeyframeTables {
247
177
  void this.warm(params);
248
178
  }
249
179
 
250
- /**
251
- * The same read, with a bound on how long THIS caller waits for it.
252
- *
253
- * The read is not cancelled when the bound is reached — it goes on, and it
254
- * lands in the table this returns, which is the file's own. What the bound
255
- * decides is only whether this caller waits: a copied picture cannot be cut
256
- * without the table, so the answer to "not yet" is to re-encode, which needs
257
- * no table because it places the keyframes itself.
258
- *
259
- * @param {{ sourceKey: string, fileIndex: number, logName?: string }} params
260
- * @returns {Promise<{ table: KeyframeTable, arrived: boolean }>} `arrived` is
261
- * about THIS wait. False with an unanswered table means the read is still
262
- * running, or found the bytes not downloaded yet and will be made again
263
- * when they arrive — neither is the same as a file with no keyframes.
264
- */
180
+ /** Available-byte reads do not replace copying after elapsed time. */
265
181
  async within(params) {
266
- const read = this.read(params);
267
- let timer = null;
268
- const budget = new Promise((resolve) => {
269
- timer = setTimeout(() => resolve(null), this.#budgetMs);
270
- // A caller must not be held open by this timer alone.
271
- timer?.unref?.();
272
- });
273
- // Bytes not downloaded yet are "not arrived" to this caller: the table
274
- // stays unanswered and is read again when pieces of the file arrive.
275
- // Thrown out of here, a shortage of bytes would fail the opening of an
276
- // output. Any other failure is still the caller's to see.
277
- const answer = await Promise.race([
278
- read.then(
279
- (table) => ({ table, arrived: true }),
280
- (error) => {
281
- if (isUnavailable(error)) {
282
- return { table: this.of(params), arrived: false };
283
- }
284
- throw error;
285
- }
286
- ),
287
- budget
288
- ]);
289
- clearTimeout(timer);
290
- return answer ?? { table: this.of(params), arrived: false };
182
+ try {
183
+ const table = await this.read(params);
184
+ return { table, arrived: table.answered };
185
+ } catch (error) {
186
+ if (isUnavailable(error)) return { table: this.of(params), arrived: false };
187
+ throw error;
188
+ }
291
189
  }
292
190
 
293
191
  /**
@@ -342,11 +240,14 @@ export class KeyframeTables {
342
240
  for (const key of [...this.#byFile.keys()]) {
343
241
  if (key.startsWith(`${sourceKey}:`)) {
344
242
  this.#byFile.delete(key);
243
+ this.#reading.delete(key);
345
244
  }
346
245
  }
347
246
  return;
348
247
  }
349
- this.#byFile.delete(KeyframeTables.keyFor(sourceKey, fileIndex));
248
+ const key = KeyframeTables.keyFor(sourceKey, fileIndex);
249
+ this.#byFile.delete(key);
250
+ this.#reading.delete(key);
350
251
  }
351
252
 
352
253
  /** @returns {number} */
@@ -0,0 +1,120 @@
1
+ /** Retry unfinished statements when their missing resource changes. */
2
+ export class MediaReadRequests {
3
+ #requests = new Map();
4
+ #read;
5
+ #failed;
6
+ #versions = new Map();
7
+ #listeners = new Map();
8
+ #memoryVersion = 0;
9
+
10
+ memoryRevision() { return this.#memoryVersion; }
11
+
12
+ subscribe(sourceKey, fileIndex, listener) {
13
+ const key = `${sourceKey}:${fileIndex}`;
14
+ let listeners = this.#listeners.get(key);
15
+ if (!listeners) this.#listeners.set(key, listeners = new Set());
16
+ listeners.add(listener);
17
+ return () => {
18
+ listeners.delete(listener);
19
+ if (!listeners.size) this.#listeners.delete(key);
20
+ };
21
+ }
22
+
23
+ #notify(sourceKey, fileIndex, result) {
24
+ for (const listener of [...(this.#listeners.get(`${sourceKey}:${fileIndex}`) ?? [])]) listener(result);
25
+ }
26
+
27
+ constructor({ read, failed }) {
28
+ this.#read = read;
29
+ this.#failed = failed;
30
+ }
31
+
32
+ revision(sourceKey, fileIndex) {
33
+ return this.#versions.get(`${sourceKey}:${fileIndex}`) ?? 0;
34
+ }
35
+
36
+ record(params, statement, result, revision = this.revision(params.sourceKey, params.fileIndex), memoryRevision = this.#memoryVersion) {
37
+ const key = requestKey(params, statement);
38
+ if (!["needs-ranges", "needs-memory"].includes(result.kind)) {
39
+ this.#notify(params.sourceKey, params.fileIndex, result);
40
+ return this.#requests.delete(key);
41
+ }
42
+ const existing = this.#requests.get(key);
43
+ if (existing) { existing.params = params; existing.kind = result.kind; }
44
+ else this.#requests.set(key, { params, statement, kind: result.kind, running: false, changed: false });
45
+ const request = this.#requests.get(key);
46
+ // A read can change from missing memory to missing bytes. An event for
47
+ // its previous resource cannot authorize another read of unchanged bytes.
48
+ request.changed = result.kind === "needs-memory"
49
+ ? memoryRevision !== this.#memoryVersion : revision !== this.revision(params.sourceKey, params.fileIndex);
50
+ if (request.changed) {
51
+ if (!request.running) queueMicrotask(() => { if (!request.running) void this.#retry(request); });
52
+ }
53
+ }
54
+
55
+ memoryChanged() {
56
+ this.#memoryVersion++;
57
+ for (const request of this.#requests.values()) {
58
+ if (request.kind !== "needs-memory") continue;
59
+ // The retried statement announces its result. A memory event is not a
60
+ // source change for another statement still waiting for missing bytes.
61
+ request.changed = true;
62
+ if (!request.running) void this.#retry(request);
63
+ }
64
+ }
65
+
66
+ bytesChanged(sourceKey, fileIndex) {
67
+ const fileKey = `${sourceKey}:${fileIndex}`;
68
+ this.#versions.set(fileKey, this.revision(sourceKey, fileIndex) + 1);
69
+ this.#notify(sourceKey, fileIndex, { kind: "bytes-changed" });
70
+ for (const request of this.#requests.values()) {
71
+ if (request.params.sourceKey !== sourceKey || request.params.fileIndex !== fileIndex) continue;
72
+ if (request.kind !== "needs-ranges") continue;
73
+ request.changed = true;
74
+ if (!request.running) void this.#retry(request);
75
+ }
76
+ }
77
+
78
+ forget(sourceKey, fileIndex) {
79
+ for (const key of [...this.#listeners.keys()]) {
80
+ if (fileIndex === undefined ? key.startsWith(`${sourceKey}:`) : key === `${sourceKey}:${fileIndex}`) {
81
+ for (const listener of [...this.#listeners.get(key)]) listener({ kind: "cancelled", reason: "source-forgotten" });
82
+ this.#listeners.delete(key);
83
+ }
84
+ }
85
+ for (const [key, request] of this.#requests) {
86
+ if (request.params.sourceKey === sourceKey && (fileIndex === undefined || request.params.fileIndex === fileIndex)) {
87
+ this.#requests.delete(key);
88
+ }
89
+ }
90
+ for (const key of this.#versions.keys()) {
91
+ if (fileIndex === undefined ? key.startsWith(`${sourceKey}:`) : key === `${sourceKey}:${fileIndex}`) this.#versions.delete(key);
92
+ }
93
+ }
94
+
95
+ retain(sourceKey, fileIndex, wanted) {
96
+ for (const [key, request] of this.#requests) {
97
+ if (request.params.sourceKey === sourceKey && request.params.fileIndex === fileIndex &&
98
+ !wanted(request.params, request.statement)) this.#requests.delete(key);
99
+ }
100
+ }
101
+
102
+ async #retry(request) {
103
+ request.running = true;
104
+ const key = requestKey(request.params, request.statement);
105
+ try {
106
+ while (request.changed && this.#requests.get(key) === request) {
107
+ request.changed = false;
108
+ await this.#read(request.params, request.statement);
109
+ }
110
+ } catch (error) {
111
+ this.#failed?.(request.params, request.statement, error);
112
+ } finally {
113
+ request.running = false;
114
+ }
115
+ }
116
+ }
117
+
118
+ function requestKey(params, statement) {
119
+ return JSON.stringify([params.sourceKey, params.fileIndex, statement, params.requestId ?? null]);
120
+ }