@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,135 @@
1
+ import { IndexMemoryUnavailable } from "./container/memory-unavailable.js";
2
+
3
+ /** Probes repeat only after the resource they need changes. */
4
+ export class ProbeRequests {
5
+ #files = new Map();
6
+ #ids = new Map();
7
+ #publish;
8
+ #failed;
9
+ #next = 0;
10
+ #listeners = new Map();
11
+ #memoryRevision = 0;
12
+
13
+ constructor({ publish, failed }) {
14
+ this.#publish = publish;
15
+ this.#failed = failed;
16
+ }
17
+
18
+ read({ sourceKey, fileIndex, statement, probe }) {
19
+ const key = `${sourceKey}:${fileIndex}:${statement}`;
20
+ let request = this.#files.get(key);
21
+ if (!request) {
22
+ request = { key, sourceKey, fileIndex, statement, probe, revision: 0, readRevision: -1, result: null, pending: null, ranges: [] };
23
+ this.#files.set(key, request);
24
+ }
25
+ if (request.pending) return request.pending;
26
+ if (request.result && !["needs-ranges", "needs-memory"].includes(request.result.kind)) return Promise.resolve(request.result);
27
+ if (request.result?.kind === "needs-memory" ? request.readMemoryRevision === this.#memoryRevision : request.readRevision === request.revision) return Promise.resolve(request.result);
28
+ return this.#start(request);
29
+ }
30
+
31
+ subscribe(sourceKey, fileIndex, listener) {
32
+ const key = `${sourceKey}:${fileIndex}`;
33
+ const listeners = this.#listeners.get(key) ?? new Set();
34
+ listeners.add(listener);
35
+ this.#listeners.set(key, listeners);
36
+ return () => {
37
+ listeners.delete(listener);
38
+ if (!listeners.size) this.#listeners.delete(key);
39
+ };
40
+ }
41
+
42
+ #notify(request, result) {
43
+ for (const listener of this.#listeners.get(`${request.sourceKey}:${request.fileIndex}`) ?? []) listener(result);
44
+ }
45
+
46
+ withdraw(sourceKey, fileIndex) {
47
+ for (const [key, request] of this.#files) {
48
+ if (request.sourceKey !== sourceKey || request.fileIndex !== fileIndex ||
49
+ (!request.pending && ["result", "terminal"].includes(request.result?.kind))) continue;
50
+ this.#files.delete(key);
51
+ request.controller?.abort();
52
+ this.#notify(request, { kind: "cancelled" });
53
+ }
54
+ }
55
+
56
+ needs(requestId, start, end) {
57
+ const request = this.#ids.get(requestId);
58
+ if (!request) return false;
59
+ request.ranges.push([start, end]);
60
+ request.controller.abort();
61
+ return true;
62
+ }
63
+
64
+ bytesChanged(sourceKey, fileIndex) {
65
+ for (const request of this.#files.values()) {
66
+ if (request.sourceKey !== sourceKey || request.fileIndex !== fileIndex) continue;
67
+ request.revision++;
68
+ if (!request.pending && request.result?.kind === "needs-ranges") {
69
+ void this.#start(request).catch(error => this.#failed?.(request, error));
70
+ }
71
+ }
72
+ }
73
+
74
+ memoryChanged() {
75
+ this.#memoryRevision++;
76
+ for (const request of this.#files.values()) {
77
+ if (!request.pending && request.result?.kind === "needs-memory") {
78
+ void this.#start(request).catch(error => this.#failed?.(request, error));
79
+ }
80
+ }
81
+ }
82
+
83
+ forget(sourceKey, fileIndex) {
84
+ for (const [key, request] of this.#files) {
85
+ if (request.sourceKey !== sourceKey || (fileIndex !== undefined && request.fileIndex !== fileIndex)) continue;
86
+ this.#files.delete(key);
87
+ request.controller?.abort();
88
+ this.#notify(request, { kind: "cancelled", reason: "source-forgotten" });
89
+ }
90
+ }
91
+
92
+ async #start(request) {
93
+ const requestId = `${request.key}:${++this.#next}`;
94
+ request.readRevision = request.revision;
95
+ request.readMemoryRevision = this.#memoryRevision;
96
+ request.ranges = [];
97
+ request.controller = new AbortController();
98
+ this.#ids.set(requestId, request);
99
+ const pending = Promise.resolve().then(async () => {
100
+ let value;
101
+ try {
102
+ value = await request.probe({ requestId, signal: request.controller.signal });
103
+ } catch (error) {
104
+ if (this.#files.get(request.key) !== request) return { kind: "cancelled", requestId };
105
+ // Aborting a strict missing-byte read may reject the process promise.
106
+ // Its ranges remain demand and must be published before retrying.
107
+ if (error instanceof IndexMemoryUnavailable) {
108
+ value = { kind: "needs-memory", bytes: error.bytes };
109
+ } else if (request.ranges.length === 0) {
110
+ value = { kind: "terminal", reason: "media-probe-failed", message: error?.message ?? String(error) };
111
+ }
112
+ }
113
+ if (this.#files.get(request.key) !== request) return { kind: "cancelled", requestId };
114
+ request.result = request.ranges.length > 0
115
+ ? { kind: "needs-ranges", ranges: [...request.ranges], requestId }
116
+ : ["terminal", "needs-memory"].includes(value?.kind) ? { ...value, requestId } : { kind: "result", value, requestId };
117
+ await this.#publish({ sourceKey: request.sourceKey, fileIndex: request.fileIndex, statement: request.statement, result: request.result });
118
+ if (this.#files.get(request.key) === request) this.#notify(request, request.result);
119
+ return request.result;
120
+ }).finally(() => {
121
+ this.#ids.delete(requestId);
122
+ request.pending = null;
123
+ const changed = request.result?.kind === "needs-memory"
124
+ ? this.#memoryRevision !== request.readMemoryRevision
125
+ : request.result?.kind === "needs-ranges" && request.revision !== request.readRevision;
126
+ if (this.#files.get(request.key) === request && changed) {
127
+ queueMicrotask(() => {
128
+ if (!request.pending) void this.#start(request).catch(error => this.#failed?.(request, error));
129
+ });
130
+ }
131
+ });
132
+ request.pending = pending;
133
+ return pending;
134
+ }
135
+ }
@@ -0,0 +1,48 @@
1
+ /** The one conversion of a source interval into complete decode input. */
2
+ export class SegmentInputs {
3
+ #index;
4
+ #tracks;
5
+
6
+ constructor({ index, tracks }) {
7
+ if (typeof index?.inputFor !== "function" || !Array.isArray(tracks)) {
8
+ throw new TypeError("Segment input requires a packet index and declared tracks.");
9
+ }
10
+ this.#index = index;
11
+ this.#tracks = tracks;
12
+ }
13
+
14
+ forInterval({ from, to, tracks = this.#tracks, mode = "transcode" }) {
15
+ const packets = [];
16
+ const ranges = [];
17
+ for (const track of tracks) {
18
+ if (!["audio", "video"].includes(track.type)) continue;
19
+ const input = this.#index.inputFor({ trackId: track.trackNumber, from, to, mode: typeof mode === "function" ? mode(track) : mode });
20
+ if (input.kind !== "result") return { ...input, trackId: track.trackNumber };
21
+ packets.push({ track, ...input });
22
+ ranges.push(...input.ranges);
23
+ }
24
+ if (packets.length === 0) return { kind: "terminal", reason: "source-has-no-media-tracks" };
25
+ return { kind: "result", from, to, tracks: packets, ranges: unionRanges(ranges) };
26
+ }
27
+
28
+ downloadZones(zones) {
29
+ const converted = [];
30
+ for (const zone of zones) {
31
+ const input = this.forInterval(zone);
32
+ if (input.kind !== "result") return input;
33
+ for (const [byteStart, byteEnd] of input.ranges) converted.push({ ...zone, byteStart, byteEnd });
34
+ }
35
+ return { kind: "result", zones: converted };
36
+ }
37
+ }
38
+
39
+ function unionRanges(ranges) {
40
+ const ordered = ranges.map(range => [...range]).sort((a, b) => a[0] - b[0]);
41
+ const result = [];
42
+ for (const [start, end] of ordered) {
43
+ const previous = result.at(-1);
44
+ if (previous && start <= previous[1] + 1) previous[1] = Math.max(previous[1], end);
45
+ else result.push([start, end]);
46
+ }
47
+ return result;
48
+ }
@@ -189,6 +189,12 @@ export class SourceFile {
189
189
  return sourceDecodeCharacteristics(this.media);
190
190
  }
191
191
 
192
+ /** The measured source rate does not require decoded-frame characteristics. */
193
+ get megabitsPerSecond() {
194
+ const kbps = Number(this.media?.bitrateKbps);
195
+ return Number.isFinite(kbps) && kbps > 0 ? kbps / 1000 : null;
196
+ }
197
+
192
198
  /**
193
199
  * The address this proxy's own HTTP serves this file at.
194
200
  *
@@ -18,11 +18,178 @@
18
18
  */
19
19
 
20
20
  import { logger } from "../../utils/logger.js";
21
+ import { createHash } from "node:crypto";
21
22
  import { SubtitleFileContainer } from "./container/SubtitleFileContainer.js";
22
23
  import { TextSubtitleTrack } from "./tracks/TextSubtitleTrack.js";
23
24
  import { detectLanguage } from "./tracks/language-detect.js";
25
+ import { strictReader, isUnavailable } from "./container/unavailable.js";
24
26
 
25
27
  export class SubtitleOrchestrator {
28
+ #documents = new Map();
29
+ #documentListeners = new Map();
30
+ #packetCues = new Map();
31
+
32
+ /** Decode only the indexed subtitle packets whose bytes are already held. */
33
+ async inspectPackets(params) {
34
+ const statement = "subtitle-cues";
35
+ const revision = params.onReadStart?.(statement);
36
+ const requestId = params.requestId;
37
+ let result;
38
+ try {
39
+ const container = await this.containers.containerFor(params);
40
+ const track = (await container.readTracks()).find(track => track.type === "subtitle" && track.declaredIndex === params.subtitleTrackIndex);
41
+ if (track?.isTextBased?.() !== true) throw new Error("The selected subtitle track is not supported.");
42
+ const packetResult = typeof this.containers.inspect === "function"
43
+ ? await this.containers.inspect({ ...params, onReadStart: undefined, onReadResult: undefined,
44
+ onNeedsRanges: undefined, onTracks: undefined }, "packets")
45
+ : { kind: "result", value: await container.readPacketIndex(params.packetInterval) };
46
+ const input = packetResult.kind === "result"
47
+ ? packetResult.value.inputFor({ trackId: track.trackNumber, ...params.packetInterval }) : packetResult;
48
+ if (input.kind !== "result") result = { ...input, requestId };
49
+ else {
50
+ const key = `${params.sourceKey}:${params.fileIndex}:${track.trackNumber}`;
51
+ let state = this.#packetCues.get(key);
52
+ if (!state) {
53
+ this.#packetCues.set(key, state = { track, packets: new Map(), cursor: 0, ready: false });
54
+ state.seed = Promise.resolve().then(async () => {
55
+ const existing = await this.cues.held?.(null, params.fileIndex, params.sourceKey, track.trackNumber);
56
+ state.cursor = (existing?.cues ?? []).reduce((highest, cue) => Math.max(highest, Number(cue.seq) || 0), 0);
57
+ }).catch(error => {
58
+ if (this.#packetCues.get(key) === state) this.#packetCues.delete(key);
59
+ throw error;
60
+ });
61
+ }
62
+ await state.seed;
63
+ const read = strictReader(params.readRange, params.fileSize);
64
+ const pending = [];
65
+ for (const packet of input.packets) {
66
+ const identity = JSON.stringify([packet.ranges, packet.pts, packet.duration]);
67
+ if (state.packets.has(identity)) continue;
68
+ const portions = [];
69
+ for (const [from, to] of packet.ranges) portions.push(await read(from, to));
70
+ const bytes = Buffer.concat(portions);
71
+ if (packet.expectedHash && createHash("sha256").update(bytes).digest("hex") !== packet.expectedHash) {
72
+ throw new Error("Subtitle packet bytes do not match the declared source addresses.");
73
+ }
74
+ const text = container.cueTextOf(bytes, track.codecId);
75
+ const cue = { startSeconds: packet.pts,
76
+ endSeconds: packet.duration > 0 ? packet.pts + packet.duration : null, text };
77
+ pending.push({ identity, cue });
78
+ }
79
+ if (params.isCurrent?.() === false || this.#packetCues.get(key) !== state) {
80
+ return { kind: "terminal", reason: "request-obsolete", requestId };
81
+ }
82
+ state.ready = true;
83
+ const ordered = () => [...state.packets.values()].sort((first, second) => first.startSeconds - second.startSeconds);
84
+ const previous = new Map(TextSubtitleTrack.finalizeCues(ordered(), track.codecId).map(cue => [cue.seq, cue]));
85
+ pending.filter(({ identity }) => !state.packets.has(identity)).forEach(({ identity, cue }) => {
86
+ const complete = { ...cue, seq: ++state.cursor };
87
+ state.packets.set(identity, complete);
88
+ });
89
+ const withdrawn = [];
90
+ for (const cue of TextSubtitleTrack.finalizeCues(ordered(), track.codecId)) {
91
+ const old = previous.get(cue.seq);
92
+ if (old && old.endSeconds !== cue.endSeconds) {
93
+ withdrawn.push(cue.seq);
94
+ const raw = [...state.packets.values()].find(packet => packet.seq === cue.seq);
95
+ raw.seq = ++state.cursor;
96
+ }
97
+ }
98
+ const fresh = TextSubtitleTrack.finalizeCues(ordered(), track.codecId).filter(cue => !previous.has(cue.seq));
99
+ if (fresh.length && params.isCurrent?.() !== false) {
100
+ const cues = fresh;
101
+ this.cues.publish?.({ sourceKey: params.sourceKey, fileIndex: params.fileIndex,
102
+ trackIndex: track.declaredIndex, cues, withdrawn, language: track.language ?? "",
103
+ detectedLanguage: detectLanguage(TextSubtitleTrack.finalizeCues([...state.packets.values()], track.codecId).map(cue => cue.text).join("\n")),
104
+ cursor: state.cursor, spanStartSeconds: cues[0]?.startSeconds ?? null,
105
+ spanEndSeconds: cues.at(-1)?.endSeconds ?? null, walkedClusters: 0, indexedClusters: 0 });
106
+ }
107
+ result = { kind: "result", value: { cues: [...state.packets.values()], track, coveredClusters: 0, indexedClusters: 0 }, requestId };
108
+ }
109
+ } catch (error) {
110
+ result = isUnavailable(error)
111
+ ? { kind: "needs-ranges", ranges: [[error.start, error.end]], requestId }
112
+ : { kind: "terminal", reason: "subtitle-cue-read-failed", message: error?.message ?? String(error), requestId };
113
+ }
114
+ await params.onReadResult?.(statement, result, revision);
115
+ return result;
116
+ }
117
+
118
+ /** Read a text document through the same missing-byte declarations as media. */
119
+ async inspectFile(params) {
120
+ const statement = "subtitle-file";
121
+ const revision = params.onReadStart?.(statement);
122
+ const key = `${params.sourceKey}:${params.fileIndex}`;
123
+ const requestId = params.requestId ?? key;
124
+ let result = this.#documents.get(key);
125
+ if (result) result = { ...result, requestId };
126
+ else {
127
+ try {
128
+ const extension = String(params.label ?? "").slice(String(params.label ?? "").lastIndexOf(".")).toLowerCase();
129
+ if (![".srt", ".ass", ".ssa", ".vtt", ".webvtt"].includes(extension)) {
130
+ result = { kind: "terminal", reason: "subtitle-format-not-supported", requestId };
131
+ } else if (!Number.isSafeInteger(params.fileSize) || params.fileSize < 0 || params.fileSize > 8 * 1024 * 1024) {
132
+ result = { kind: "terminal", reason: "subtitle-size-not-supported", requestId };
133
+ } else {
134
+ const bytes = await strictReader(params.readRange, params.fileSize)(0, params.fileSize - 1);
135
+ const value = this.fileAsVtt(bytes, extension);
136
+ result = value ? { kind: "result", value, requestId }
137
+ : { kind: "terminal", reason: "subtitle-document-invalid", requestId };
138
+ }
139
+ } catch (error) {
140
+ result = isUnavailable(error)
141
+ ? { kind: "needs-ranges", ranges: [[error.start, error.end]], requestId }
142
+ : { kind: "terminal", reason: "subtitle-read-failed", message: error?.message ?? String(error), requestId };
143
+ }
144
+ if (params.isCurrent?.() === false) return { kind: "terminal", reason: "request-obsolete", requestId };
145
+ if (result.kind !== "needs-ranges") {
146
+ this.#documents.set(key, result);
147
+ for (const listener of [...(this.#documentListeners.get(key) ?? [])]) listener();
148
+ }
149
+ }
150
+ await params.onReadResult?.(statement, result, revision);
151
+ return result;
152
+ }
153
+
154
+ documentFor(sourceKey, fileIndex) {
155
+ return this.#documents.get(`${sourceKey}:${fileIndex}`) ?? null;
156
+ }
157
+
158
+ hasPacketCues(sourceKey, fileIndex, declaredIndex) {
159
+ const prefix = `${sourceKey}:${fileIndex}:`;
160
+ return [...this.#packetCues].some(([key, state]) => key.startsWith(prefix) && state.track.declaredIndex === declaredIndex);
161
+ }
162
+
163
+ hasTrackDeclaration(sourceKey, fileIndex) {
164
+ return this.containers.tracks?.has(`${sourceKey}:${fileIndex}`) === true;
165
+ }
166
+
167
+ /** Subscribe before checking, so publication cannot be missed. */
168
+ waitForDocument(sourceKey, fileIndex, signal) {
169
+ const key = `${sourceKey}:${fileIndex}`;
170
+ return new Promise((resolve, reject) => {
171
+ let listeners = this.#documentListeners.get(key);
172
+ if (!listeners) this.#documentListeners.set(key, listeners = new Set());
173
+ const cleanup = () => {
174
+ listeners.delete(check);
175
+ if (!listeners.size) this.#documentListeners.delete(key);
176
+ signal?.removeEventListener("abort", cancelled);
177
+ };
178
+ const cancelled = () => {
179
+ cleanup();
180
+ reject(signal?.reason ?? new DOMException("Subtitle request cancelled.", "AbortError"));
181
+ };
182
+ const check = () => {
183
+ const result = this.#documents.get(key);
184
+ if (!result) return;
185
+ cleanup();
186
+ resolve(result);
187
+ };
188
+ listeners.add(check);
189
+ signal?.addEventListener("abort", cancelled, { once: true });
190
+ if (signal?.aborted) cancelled(); else check();
191
+ });
192
+ }
26
193
  /**
27
194
  * @param {import("./ContainerOrchestrator.js").ContainerOrchestrator} containerOrchestrator
28
195
  * @param {object} [cues] - How the torrent reads subtitle cues: `warm`,
@@ -45,6 +212,8 @@ export class SubtitleOrchestrator {
45
212
  * @returns {Promise<import("./tracks/index.js").ContainerTrack[]>}
46
213
  */
47
214
  async getTracks(torrent, fileIndex, sourceKey, readRange, fileSize) {
215
+ const known = this.containers.tracks?.get(`${sourceKey}:${fileIndex}`);
216
+ if (Array.isArray(known)) return known.filter(track => track.type === "subtitle");
48
217
  try {
49
218
  const domain = await this.cues.tracksOf?.(torrent, fileIndex, sourceKey);
50
219
  if (Array.isArray(domain) && domain.length > 0) return domain;
@@ -62,6 +231,8 @@ export class SubtitleOrchestrator {
62
231
  * Declared subtitle tracks in container order (including image tracks) — for declaredIndex alignment.
63
232
  */
64
233
  async getDeclaredTracks(torrent, fileIndex, sourceKey) {
234
+ const known = this.containers.tracks?.get(`${sourceKey}:${fileIndex}`);
235
+ if (Array.isArray(known)) return known.filter(track => track.type === "subtitle");
65
236
  try {
66
237
  return (await this.cues.declaredTracksOf?.(torrent, fileIndex, sourceKey)) ?? [];
67
238
  } catch {
@@ -86,20 +257,23 @@ export class SubtitleOrchestrator {
86
257
  * @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
87
258
  */
88
259
  async getCues(torrent, fileIndex, sourceKey, trackNumber) {
89
- const empty = () => ({ cues: [], coveredClusters: 0, indexedClusters: 0, track: null });
260
+ const packets = this.#packetCues.get(`${sourceKey}:${fileIndex}:${trackNumber}`);
261
+ if (packets) return packets.ready
262
+ ? { cues: [...packets.packets.values()].sort((first, second) => first.startSeconds - second.startSeconds),
263
+ track: packets.track, coveredClusters: 0, indexedClusters: 0 } : null;
90
264
  if (typeof this.cues.held !== "function") {
91
265
  logger.warn(
92
266
  "subtitle-orchestrator: no walk was supplied, so no cue can be read — " +
93
267
  "an empty document here would be indistinguishable from a file with none"
94
268
  );
95
- return empty();
269
+ return null;
96
270
  }
97
271
  try {
98
272
  const answer = await this.cues.held(torrent, fileIndex, sourceKey, trackNumber);
99
- return answer ?? empty();
273
+ return answer?.track ? answer : null;
100
274
  } catch (e) {
101
275
  logger.warn(`subtitle-orchestrator: getCues failed: ${e?.message ?? e}`);
102
- return empty();
276
+ return null;
103
277
  }
104
278
  }
105
279
 
@@ -161,6 +335,18 @@ export class SubtitleOrchestrator {
161
335
  }
162
336
 
163
337
  forget(sourceKey, fileIndex) {
338
+ const prefix = fileIndex === undefined ? `${sourceKey}:` : `${sourceKey}:${fileIndex}:`;
339
+ for (const key of this.#packetCues.keys()) if (key.startsWith(prefix)) this.#packetCues.delete(key);
340
+ for (const key of this.#documents.keys()) {
341
+ if (fileIndex === undefined ? key.startsWith(`${sourceKey}:`) : key === `${sourceKey}:${fileIndex}`) this.#documents.delete(key);
342
+ }
343
+ for (const [key, listeners] of [...this.#documentListeners]) {
344
+ if (fileIndex === undefined ? key.startsWith(`${sourceKey}:`) : key === `${sourceKey}:${fileIndex}`) {
345
+ this.#documents.set(key, { kind: "terminal", reason: "source-forgotten" });
346
+ for (const listener of [...listeners]) listener();
347
+ this.#documents.delete(key);
348
+ }
349
+ }
164
350
  this.cues.forget?.(sourceKey, fileIndex);
165
351
  this.containers.forget(sourceKey, fileIndex);
166
352
  }
@@ -0,0 +1,47 @@
1
+ /** Subscribe before checking; pending source declarations have no elapsed deadline. */
2
+ export function waitForPlan({ read, subscribe, signal }) {
3
+ return new Promise((resolve, reject) => {
4
+ let done = false, running = false, changed = false, unsubscribe;
5
+ const finish = (error, value) => {
6
+ if (done) return;
7
+ done = true;
8
+ unsubscribe?.();
9
+ signal?.removeEventListener("abort", aborted);
10
+ if (error) reject(error); else resolve(value);
11
+ };
12
+ const aborted = () => finish(new DOMException("Playback preparation was cancelled.", "AbortError"));
13
+ const pump = async () => {
14
+ if (done || running) return;
15
+ running = true;
16
+ try {
17
+ do {
18
+ changed = false;
19
+ const plan = await read();
20
+ if (!plan.pending) finish(null, plan);
21
+ } while (changed && !done);
22
+ } catch (error) { finish(error); }
23
+ finally {
24
+ running = false;
25
+ if (changed && !done) void pump();
26
+ }
27
+ };
28
+ signal?.addEventListener("abort", aborted, { once: true });
29
+ if (signal?.aborted) { aborted(); return; }
30
+ try {
31
+ unsubscribe = subscribe(result => {
32
+ if (result?.kind === "cancelled") {
33
+ if (result.reason === "source-forgotten") {
34
+ const error = new Error("Playback source was forgotten.");
35
+ error.code = "SOURCE_FORGOTTEN";
36
+ finish(error);
37
+ } else aborted();
38
+ return;
39
+ }
40
+ changed = true;
41
+ void pump();
42
+ });
43
+ if (done) unsubscribe?.();
44
+ else void pump();
45
+ } catch (error) { finish(error); }
46
+ });
47
+ }