@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.
- package/bin/cli.js +3 -2
- package/package.json +18 -2
- package/routes/api/playback-plan/post.js +21 -4
- package/routes/api/sources/files/get.js +20 -28
- package/routes/api/sources/post.js +4 -2
- package/routes/api/sources/stats/get.js +4 -11
- package/routes/api/sources/viewer/post.js +16 -0
- package/routes/api/sources/warm/post.js +6 -215
- package/routes/api/subtitles/get.js +22 -125
- package/routes/api/subtitles/post.js +26 -0
- package/routes/api/transcode-sessions/post.js +24 -2
- package/routes/media/get.js +80 -0
- package/routes/stream/get.js +26 -94
- package/routes/transcode/audio-warm/get.js +9 -21
- package/routes/transcode/variant-warm/get.js +8 -14
- package/server.js +410 -46
- package/services/encode/AdmittedInput.js +136 -0
- package/services/encode/CushionReport.js +1 -76
- package/services/encode/EncodeInputs.js +142 -0
- package/services/encode/EncodeOrchestrator.js +4 -1
- package/services/encode/EncodeRun.js +13 -4
- package/services/encode/EncodeRuns.js +132 -52
- package/services/encode/InputFailures.js +37 -0
- package/services/encode/MatroskaInput.js +158 -0
- package/services/encode/OutputOpening.js +16 -64
- package/services/encode/Renditions.js +1 -17
- package/services/encode/admitted-command.js +60 -0
- package/services/encode/annex-b-input.js +37 -0
- package/services/encode/bit-payload.js +29 -0
- package/services/encode/output/OutputCatalog.js +1 -1
- package/services/encode/output/Timeline.js +11 -0
- package/services/encode/pcm-normalization.js +30 -0
- package/services/encode/piece-completeness.js +54 -1
- package/services/encode/quality/HostLoad.js +19 -73
- package/services/encode/quality/QualityOffer.js +1 -1
- package/services/encode/run-command.js +1 -589
- package/services/encode/segment-formats/mp4-boxes.js +3 -1
- package/services/encode/segment-formats/presentation-segment.js +114 -0
- package/services/media/ContainerOrchestrator.js +118 -48
- package/services/media/KeyframeTables.js +16 -115
- package/services/media/MediaReadRequests.js +120 -0
- package/services/media/ProbeRequests.js +135 -0
- package/services/media/SegmentInputs.js +48 -0
- package/services/media/SourceFile.js +6 -0
- package/services/media/SubtitleOrchestrator.js +190 -4
- package/services/media/await-plan.js +47 -0
- package/services/media/container/AsfContainer.js +217 -0
- package/services/media/container/AviContainer.js +343 -34
- package/services/media/container/CadencedPictureOrder.js +45 -0
- package/services/media/container/Container.js +7 -3
- package/services/media/container/ContainerFactory.js +10 -3
- package/services/media/container/FfprobeContainer.js +222 -0
- package/services/media/container/MatroskaContainer.js +72 -46
- package/services/media/container/Mp4Container.js +472 -139
- package/services/media/container/MpegPsContainer.js +281 -0
- package/services/media/container/MpegTsContainer.js +277 -0
- package/services/media/container/PacketIndex.js +270 -0
- package/services/media/container/PacketRecords.js +237 -0
- package/services/media/container/RetainedBytes.js +39 -0
- package/services/media/container/RetainedReads.js +51 -0
- package/services/media/container/ac3-frame.js +46 -0
- package/services/media/container/asf-packets.js +153 -0
- package/services/media/container/avc-picture-order.js +50 -0
- package/services/media/container/avi-open-dml.js +47 -0
- package/services/media/container/dts-frame.js +51 -0
- package/services/media/container/ebml-stream.js +12 -1
- package/services/media/container/ffprobe-record.js +45 -0
- package/services/media/container/flv-packet-ranges.js +27 -0
- package/services/media/container/following-packet-time.js +37 -0
- package/services/media/container/h264-configuration.js +183 -0
- package/services/media/container/h264-elementary-index.js +245 -0
- package/services/media/container/hevc-configuration.js +165 -0
- package/services/media/container/hevc-elementary-index.js +132 -0
- package/services/media/container/hevc-picture-order.js +66 -0
- package/services/media/container/latm-frame.js +99 -0
- package/services/media/container/matroska-packets.js +207 -0
- package/services/media/container/memory-unavailable.js +8 -0
- package/services/media/container/mp3-packet-dependencies.js +41 -0
- package/services/media/container/mp4-fragments.js +148 -0
- package/services/media/container/mpeg-audio-frame.js +19 -0
- package/services/media/container/mpeg-audio-reservoir.js +27 -0
- package/services/media/container/mpeg-elementary-index.js +196 -0
- package/services/media/container/mpeg-sequence-reader.js +69 -0
- package/services/media/container/mpeg-ts-packets.js +93 -0
- package/services/media/container/mpeg-video-configuration.js +50 -0
- package/services/media/container/mpeg4-picture-timing.js +108 -0
- package/services/media/container/pes-header.js +34 -0
- package/services/media/playback-declarations.js +57 -0
- package/services/media/playback-planner.js +72 -230
- package/services/media/probe-packets.js +61 -0
- package/services/media/read-playback-declarations.js +18 -0
- package/services/media/tracks/AudioTrack.js +9 -0
- package/services/media/tracks/TextSubtitleTrack.js +4 -1
- package/services/server/OutputLifecycle.js +2 -0
- package/services/server/SegmentServing.js +40 -33
- package/services/server/ViewerRequests.js +22 -41
- package/services/server/controllers/PlaybackController.js +4 -0
- package/services/server/controllers/SubtitleController.js +8 -29
- package/services/server/transcode-session-files.js +58 -99
- package/services/server/wire-outputs.js +58 -15
- package/services/storage/IndexMemory.js +85 -0
- package/services/storage/MachineBudget.js +27 -3
- package/services/storage/allowance.js +8 -1
- package/services/storage/piece-store/piece-disk-store.js +18 -3
- package/services/storage/piece-store/piece-lru.js +8 -4
- package/services/storage/piece-store/shared-piece-store.js +26 -11
- package/services/storage/segment-store/SegmentStore.js +49 -18
- package/services/storage/wire.js +7 -0
- package/services/torrent/Contents.js +52 -20
- package/services/torrent/demand/DemandRegister.js +13 -4
- package/services/torrent/demand/Window.js +13 -2
- package/services/torrent/demand-progress.js +25 -0
- package/services/torrent/download/DeadlineScheduler.js +167 -0
- package/services/torrent/download/FutureDownload.js +166 -0
- package/services/torrent/download/SwarmSelection.js +30 -59
- package/services/torrent/download/registry.js +45 -5
- package/services/torrent/download/withdraw-claim.js +2 -0
- package/services/torrent/torrent-pool.js +39 -600
- package/services/torrent/worker/channel.js +8 -1
- package/services/torrent/worker/client.js +74 -72
- package/services/torrent/worker/files-in-use.js +9 -0
- package/services/torrent/worker/handle-state.js +12 -0
- package/services/torrent/worker/held-bytes.js +82 -99
- package/services/torrent/worker/held-file-bytes.js +17 -0
- package/services/torrent/worker/piece-reader.js +67 -683
- package/services/torrent/worker/pool-adapter.js +37 -123
- package/services/torrent/worker/protocol.js +6 -17
- package/services/torrent/worker/whole-file-stats.js +2 -0
- package/services/torrent/worker/worker.js +86 -156
- package/services/transport/data-channel-handler.js +56 -13
- package/services/viewer/DownloadMaps.js +168 -0
- package/services/viewer/NativeSourceMap.js +33 -0
- package/services/viewer/PriorityMap.js +49 -40
- package/services/viewer/PriorityOrchestrator.js +42 -13
- package/services/viewer/SourcePreparation.js +278 -0
- package/services/viewer/Viewer.js +11 -0
- package/services/viewer/Viewers.js +109 -2
- package/services/viewer/playback-readiness.js +50 -63
- package/services/viewer/segment-demands.js +37 -0
- package/services/viewer/urgent-output-ready.js +26 -0
- package/services/encode/run-inputs.js +0 -53
- package/services/media/keyframe-probe.js +0 -157
- package/services/media/media-info-probe.js +0 -93
- package/services/torrent/worker/background-fill.js +0 -213
- 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
|
|
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
|
|
269
|
+
return null;
|
|
96
270
|
}
|
|
97
271
|
try {
|
|
98
272
|
const answer = await this.cues.held(torrent, fileIndex, sourceKey, trackNumber);
|
|
99
|
-
return answer
|
|
273
|
+
return answer?.track ? answer : null;
|
|
100
274
|
} catch (e) {
|
|
101
275
|
logger.warn(`subtitle-orchestrator: getCues failed: ${e?.message ?? e}`);
|
|
102
|
-
return
|
|
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
|
+
}
|