@torrent-tv/proxy 2.41.0 → 2.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +133 -0
- package/services/container-index/matroska-blocks.js +202 -0
- package/services/container-index/matroska-subtitles.js +262 -0
- package/services/container-index/mp4-subtitles.js +380 -0
- package/services/torrent-worker/client.js +20 -0
- package/services/torrent-worker/pool-adapter.js +32 -0
- package/services/torrent-worker/protocol.js +4 -0
- package/services/torrent-worker/subtitle-cues.js +291 -0
- package/services/torrent-worker/worker.js +19 -0
- package/test/matroska-blocks.test.js +0 -0
- package/test/mp4-subtitles.test.js +127 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The text subtitle tracks of an MP4, and where each cue's bytes are.
|
|
3
|
+
*
|
|
4
|
+
* The same rule as the Matroska side: nothing is extracted with ffmpeg and
|
|
5
|
+
* nothing is fetched for its own sake. Here it is cheaper still. Matroska hides
|
|
6
|
+
* its subtitle blocks inside clusters shared with the picture, so a cue costs
|
|
7
|
+
* whatever cluster holds it; an MP4 states every sample's offset and length in
|
|
8
|
+
* the sample table, so a cue costs its own bytes and nothing more — usually a
|
|
9
|
+
* few dozen of them.
|
|
10
|
+
*
|
|
11
|
+
* The tables, from ISO/IEC 14496-12:
|
|
12
|
+
*
|
|
13
|
+
* stsd — what the samples are (`tx3g` timed text, `wvtt` WebVTT, `stpp` TTML)
|
|
14
|
+
* stts — how long each sample lasts, run-length encoded (§8.6.1.2)
|
|
15
|
+
* stsz — how long each sample is, in bytes (§8.7.3)
|
|
16
|
+
* stsc — how samples are grouped into chunks (§8.7.4)
|
|
17
|
+
* stco / co64 — where each chunk begins in the file (§8.7.5)
|
|
18
|
+
*
|
|
19
|
+
* Together they give, for sample N: when it starts, how long it stays, and the
|
|
20
|
+
* exact byte range holding it. That is everything needed to show a cue without
|
|
21
|
+
* reading anything else.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const HEADER_BYTES = 8;
|
|
25
|
+
const LARGE_SIZE_MARKER = 1;
|
|
26
|
+
const LARGE_HEADER_BYTES = 16;
|
|
27
|
+
const PROBE_BYTES = 64;
|
|
28
|
+
const MAX_MOOV_BYTES = 32 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
/** Handlers that mean "this track is text on screen". */
|
|
31
|
+
const TEXT_HANDLERS = new Set(["text", "sbtl", "subt"]);
|
|
32
|
+
/** Sample formats this can turn into cues. `stpp` (TTML) is XML and is not one. */
|
|
33
|
+
const TEXT_FORMATS = new Set(["tx3g", "text", "wvtt"]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One box header at `offset`, or null when the bytes do not hold one.
|
|
37
|
+
*
|
|
38
|
+
* @param {Buffer} buffer
|
|
39
|
+
* @param {number} offset
|
|
40
|
+
* @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
|
|
41
|
+
*/
|
|
42
|
+
function readBox(buffer, offset) {
|
|
43
|
+
if (offset + HEADER_BYTES > buffer.length) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
let size = buffer.readUInt32BE(offset);
|
|
47
|
+
const type = buffer.toString("latin1", offset + 4, offset + 8);
|
|
48
|
+
let headerBytes = HEADER_BYTES;
|
|
49
|
+
if (size === LARGE_SIZE_MARKER) {
|
|
50
|
+
if (offset + LARGE_HEADER_BYTES > buffer.length) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
size = Number(buffer.readBigUInt64BE(offset + 8));
|
|
54
|
+
headerBytes = LARGE_HEADER_BYTES;
|
|
55
|
+
}
|
|
56
|
+
if (size < headerBytes) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return { type, size, dataOffset: offset + headerBytes, end: offset + size };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every direct child of a range with the given type.
|
|
64
|
+
*
|
|
65
|
+
* @param {Buffer} buffer
|
|
66
|
+
* @param {number} start
|
|
67
|
+
* @param {number} end
|
|
68
|
+
* @param {string} type
|
|
69
|
+
* @returns {{ type: string, size: number, dataOffset: number, end: number }[]}
|
|
70
|
+
*/
|
|
71
|
+
function childrenOf(buffer, start, end, type) {
|
|
72
|
+
const found = [];
|
|
73
|
+
let at = start;
|
|
74
|
+
while (at < end) {
|
|
75
|
+
const box = readBox(buffer, at);
|
|
76
|
+
if (!box) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
if (box.type === type) {
|
|
80
|
+
found.push(box);
|
|
81
|
+
}
|
|
82
|
+
at = box.end;
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The first child of a range with the given type, or null.
|
|
89
|
+
*
|
|
90
|
+
* @param {Buffer} buffer
|
|
91
|
+
* @param {number} start
|
|
92
|
+
* @param {number} end
|
|
93
|
+
* @param {string} type
|
|
94
|
+
* @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
|
|
95
|
+
*/
|
|
96
|
+
function childOf(buffer, start, end, type) {
|
|
97
|
+
return childrenOf(buffer, start, end, type)[0] ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Walk the top level of the file to find `moov`, reading only box headers.
|
|
102
|
+
*
|
|
103
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
104
|
+
* @param {number} fileSize
|
|
105
|
+
* @returns {Promise<{ offset: number, size: number } | null>}
|
|
106
|
+
*/
|
|
107
|
+
async function findMoov(readRange, fileSize) {
|
|
108
|
+
let at = 0;
|
|
109
|
+
while (at < fileSize) {
|
|
110
|
+
const probe = await readRange(at, Math.min(fileSize - 1, at + PROBE_BYTES - 1));
|
|
111
|
+
if (!probe || probe.length < HEADER_BYTES) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const box = readBox(probe, 0);
|
|
115
|
+
if (!box) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (box.type === "moov") {
|
|
119
|
+
return { offset: at, size: box.size };
|
|
120
|
+
}
|
|
121
|
+
at += box.size;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Sample durations, expanded from the run-length table.
|
|
128
|
+
*
|
|
129
|
+
* @param {Buffer} moov
|
|
130
|
+
* @param {{ dataOffset: number, end: number }} stts
|
|
131
|
+
* @param {number} total - How many samples the size table declares.
|
|
132
|
+
* @returns {number[]} Ticks each sample lasts.
|
|
133
|
+
*/
|
|
134
|
+
function sampleDurations(moov, stts, total) {
|
|
135
|
+
const durations = new Array(total).fill(0);
|
|
136
|
+
const entries = moov.readUInt32BE(stts.dataOffset + 4);
|
|
137
|
+
let at = stts.dataOffset + 8;
|
|
138
|
+
let sample = 0;
|
|
139
|
+
for (let entry = 0; entry < entries && at + 8 <= stts.end && sample < total; entry += 1, at += 8) {
|
|
140
|
+
const count = moov.readUInt32BE(at);
|
|
141
|
+
const delta = moov.readUInt32BE(at + 4);
|
|
142
|
+
for (let index = 0; index < count && sample < total; index += 1, sample += 1) {
|
|
143
|
+
durations[sample] = delta;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return durations;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Sample sizes, whether the table states one for all or one for each.
|
|
151
|
+
*
|
|
152
|
+
* @param {Buffer} moov
|
|
153
|
+
* @param {{ dataOffset: number, end: number }} stsz
|
|
154
|
+
* @returns {number[]}
|
|
155
|
+
*/
|
|
156
|
+
function sampleSizes(moov, stsz) {
|
|
157
|
+
const uniform = moov.readUInt32BE(stsz.dataOffset + 4);
|
|
158
|
+
const count = moov.readUInt32BE(stsz.dataOffset + 8);
|
|
159
|
+
if (uniform > 0) {
|
|
160
|
+
return new Array(count).fill(uniform);
|
|
161
|
+
}
|
|
162
|
+
const sizes = new Array(count).fill(0);
|
|
163
|
+
let at = stsz.dataOffset + 12;
|
|
164
|
+
for (let index = 0; index < count && at + 4 <= stsz.end; index += 1, at += 4) {
|
|
165
|
+
sizes[index] = moov.readUInt32BE(at);
|
|
166
|
+
}
|
|
167
|
+
return sizes;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Where every sample of a track begins in the file.
|
|
172
|
+
*
|
|
173
|
+
* The sample-to-chunk table says how many samples each run of chunks holds, and
|
|
174
|
+
* the chunk-offset table says where each chunk starts; a sample's own offset is
|
|
175
|
+
* its chunk's start plus the sizes of the samples before it in that chunk.
|
|
176
|
+
*
|
|
177
|
+
* @param {Buffer} moov
|
|
178
|
+
* @param {{ dataOffset: number, end: number }} stsc
|
|
179
|
+
* @param {number[]} chunkOffsets
|
|
180
|
+
* @param {number[]} sizes
|
|
181
|
+
* @returns {number[]}
|
|
182
|
+
*/
|
|
183
|
+
function sampleOffsets(moov, stsc, chunkOffsets, sizes) {
|
|
184
|
+
const offsets = new Array(sizes.length).fill(0);
|
|
185
|
+
const entries = moov.readUInt32BE(stsc.dataOffset + 4);
|
|
186
|
+
/** @type {{ firstChunk: number, perChunk: number }[]} */
|
|
187
|
+
const runs = [];
|
|
188
|
+
let at = stsc.dataOffset + 8;
|
|
189
|
+
for (let entry = 0; entry < entries && at + 12 <= stsc.end; entry += 1, at += 12) {
|
|
190
|
+
runs.push({ firstChunk: moov.readUInt32BE(at), perChunk: moov.readUInt32BE(at + 4) });
|
|
191
|
+
}
|
|
192
|
+
let sample = 0;
|
|
193
|
+
for (let run = 0; run < runs.length && sample < sizes.length; run += 1) {
|
|
194
|
+
const from = runs[run].firstChunk;
|
|
195
|
+
const to = run + 1 < runs.length ? runs[run + 1].firstChunk - 1 : chunkOffsets.length;
|
|
196
|
+
for (let chunk = from; chunk <= to && sample < sizes.length; chunk += 1) {
|
|
197
|
+
let inChunk = chunkOffsets[chunk - 1];
|
|
198
|
+
if (inChunk === undefined) {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
for (let index = 0; index < runs[run].perChunk && sample < sizes.length; index += 1, sample += 1) {
|
|
202
|
+
offsets[sample] = inChunk;
|
|
203
|
+
inChunk += sizes[sample];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return offsets;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @typedef {object} Mp4SubtitleSample
|
|
212
|
+
* @property {number} startSeconds
|
|
213
|
+
* @property {number} endSeconds
|
|
214
|
+
* @property {number} offset - Where the sample's bytes are in the file.
|
|
215
|
+
* @property {number} size
|
|
216
|
+
*/
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @typedef {object} Mp4SubtitleTrack
|
|
220
|
+
* @property {number} trackId
|
|
221
|
+
* @property {string} format - `tx3g`, `text` or `wvtt`.
|
|
222
|
+
* @property {string} language - Three letters, as the file declares them.
|
|
223
|
+
* @property {Mp4SubtitleSample[]} samples - In time order.
|
|
224
|
+
*/
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The text subtitle tracks of an MP4, with every cue's time and byte range.
|
|
228
|
+
*
|
|
229
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
230
|
+
* @param {number} fileSize
|
|
231
|
+
* @returns {Promise<{ tracks: Mp4SubtitleTrack[] } | null>}
|
|
232
|
+
*/
|
|
233
|
+
export async function readMp4SubtitlePlan(readRange, fileSize) {
|
|
234
|
+
const found = await findMoov(readRange, fileSize);
|
|
235
|
+
if (!found || found.size > MAX_MOOV_BYTES) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const moov = await readRange(found.offset, Math.min(fileSize - 1, found.offset + found.size - 1));
|
|
239
|
+
if (!moov || moov.length < HEADER_BYTES) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
const moovBox = readBox(moov, 0);
|
|
243
|
+
if (!moovBox) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** @type {Mp4SubtitleTrack[]} */
|
|
248
|
+
const tracks = [];
|
|
249
|
+
for (const trak of childrenOf(moov, moovBox.dataOffset, moov.length, "trak")) {
|
|
250
|
+
const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
|
|
251
|
+
if (!mdia) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const hdlr = childOf(moov, mdia.dataOffset, mdia.end, "hdlr");
|
|
255
|
+
const handler = hdlr ? moov.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) : "";
|
|
256
|
+
if (!TEXT_HANDLERS.has(handler)) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
|
|
260
|
+
if (!mdhd) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const version = moov[mdhd.dataOffset];
|
|
264
|
+
const timescale = moov.readUInt32BE(version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12);
|
|
265
|
+
if (!timescale) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
// The language is five bits per letter, offset from 0x60, packed into two
|
|
269
|
+
// bytes after the times (ISO/IEC 14496-12 §8.4.2.3).
|
|
270
|
+
const languageAt = version === 1 ? mdhd.dataOffset + 32 : mdhd.dataOffset + 20;
|
|
271
|
+
let language = "";
|
|
272
|
+
if (languageAt + 2 <= mdhd.end) {
|
|
273
|
+
const packed = moov.readUInt16BE(languageAt);
|
|
274
|
+
language = [10, 5, 0]
|
|
275
|
+
.map((shift) => String.fromCharCode(((packed >> shift) & 0x1f) + 0x60))
|
|
276
|
+
.join("")
|
|
277
|
+
.replace(/[^a-z]/g, "");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
|
|
281
|
+
const trackId = tkhd
|
|
282
|
+
? moov.readUInt32BE(moov[tkhd.dataOffset] === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12)
|
|
283
|
+
: tracks.length + 1;
|
|
284
|
+
|
|
285
|
+
const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
|
|
286
|
+
const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
|
|
287
|
+
if (!stbl) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const stsd = childOf(moov, stbl.dataOffset, stbl.end, "stsd");
|
|
291
|
+
const first = stsd && readBox(moov, stsd.dataOffset + 8);
|
|
292
|
+
const format = first ? first.type : "";
|
|
293
|
+
if (!TEXT_FORMATS.has(format)) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const stts = childOf(moov, stbl.dataOffset, stbl.end, "stts");
|
|
297
|
+
const stsz = childOf(moov, stbl.dataOffset, stbl.end, "stsz");
|
|
298
|
+
const stsc = childOf(moov, stbl.dataOffset, stbl.end, "stsc");
|
|
299
|
+
const stco = childOf(moov, stbl.dataOffset, stbl.end, "stco");
|
|
300
|
+
const co64 = childOf(moov, stbl.dataOffset, stbl.end, "co64");
|
|
301
|
+
if (!stts || !stsz || !stsc || (!stco && !co64)) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const sizes = sampleSizes(moov, stsz);
|
|
306
|
+
const durations = sampleDurations(moov, stts, sizes.length);
|
|
307
|
+
const chunkOffsets = [];
|
|
308
|
+
if (stco) {
|
|
309
|
+
const count = moov.readUInt32BE(stco.dataOffset + 4);
|
|
310
|
+
let at = stco.dataOffset + 8;
|
|
311
|
+
for (let index = 0; index < count && at + 4 <= stco.end; index += 1, at += 4) {
|
|
312
|
+
chunkOffsets.push(moov.readUInt32BE(at));
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
const count = moov.readUInt32BE(co64.dataOffset + 4);
|
|
316
|
+
let at = co64.dataOffset + 8;
|
|
317
|
+
for (let index = 0; index < count && at + 8 <= co64.end; index += 1, at += 8) {
|
|
318
|
+
chunkOffsets.push(Number(moov.readBigUInt64BE(at)));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const offsets = sampleOffsets(moov, stsc, chunkOffsets, sizes);
|
|
322
|
+
|
|
323
|
+
/** @type {Mp4SubtitleSample[]} */
|
|
324
|
+
const samples = [];
|
|
325
|
+
let ticks = 0;
|
|
326
|
+
for (let index = 0; index < sizes.length; index += 1) {
|
|
327
|
+
const start = ticks / timescale;
|
|
328
|
+
ticks += durations[index];
|
|
329
|
+
// An empty sample is a gap between cues, which the format uses to say
|
|
330
|
+
// "nothing on screen"; it is not a cue and would show as a blank line.
|
|
331
|
+
if (sizes[index] > 2) {
|
|
332
|
+
samples.push({
|
|
333
|
+
startSeconds: start,
|
|
334
|
+
endSeconds: ticks / timescale,
|
|
335
|
+
offset: offsets[index],
|
|
336
|
+
size: sizes[index]
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
tracks.push({ trackId, format, language, samples });
|
|
341
|
+
}
|
|
342
|
+
return { tracks };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The text of one sample.
|
|
347
|
+
*
|
|
348
|
+
* `tx3g` is a two-byte length followed by UTF-8; anything after that is styling
|
|
349
|
+
* boxes, which this deliberately drops. `wvtt` is a sequence of boxes, and the
|
|
350
|
+
* text lives in the `payl` inside a `vttc`.
|
|
351
|
+
*
|
|
352
|
+
* @param {Buffer} bytes
|
|
353
|
+
* @param {string} format
|
|
354
|
+
* @returns {string}
|
|
355
|
+
*/
|
|
356
|
+
export function decodeSubtitleSample(bytes, format) {
|
|
357
|
+
if (format === "wvtt") {
|
|
358
|
+
let at = 0;
|
|
359
|
+
const parts = [];
|
|
360
|
+
while (at + HEADER_BYTES <= bytes.length) {
|
|
361
|
+
const box = readBox(bytes, at);
|
|
362
|
+
if (!box) {
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (box.type === "vttc") {
|
|
366
|
+
const payl = childOf(bytes, box.dataOffset, Math.min(bytes.length, box.end), "payl");
|
|
367
|
+
if (payl) {
|
|
368
|
+
parts.push(bytes.toString("utf8", payl.dataOffset, Math.min(bytes.length, payl.end)));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
at = box.end;
|
|
372
|
+
}
|
|
373
|
+
return parts.join("\n").trim();
|
|
374
|
+
}
|
|
375
|
+
if (bytes.length < 2) {
|
|
376
|
+
return "";
|
|
377
|
+
}
|
|
378
|
+
const length = bytes.readUInt16BE(0);
|
|
379
|
+
return bytes.toString("utf8", 2, Math.min(bytes.length, 2 + length)).trim();
|
|
380
|
+
}
|
|
@@ -242,6 +242,26 @@ export class TorrentWorkerClient {
|
|
|
242
242
|
return this.#caller.call(Command.FILE_STATS, { sourceKey, fileIndex, resumeAnchorByteStart });
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
/**
|
|
246
|
+
* The text subtitle tracks a file carries.
|
|
247
|
+
*
|
|
248
|
+
* @param {{ sourceKey: string, fileIndex: number }} params
|
|
249
|
+
* @returns {Promise<{ tracks: object[] }>}
|
|
250
|
+
*/
|
|
251
|
+
async getSubtitleTracks({ sourceKey, fileIndex }) {
|
|
252
|
+
return this.#caller.call(Command.SUBTITLE_TRACKS, { sourceKey, fileIndex });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The cues of one subtitle track that can be read from what is downloaded.
|
|
257
|
+
*
|
|
258
|
+
* @param {{ sourceKey: string, fileIndex: number, trackNumber: number }} params
|
|
259
|
+
* @returns {Promise<object>}
|
|
260
|
+
*/
|
|
261
|
+
async getSubtitleCues({ sourceKey, fileIndex, trackNumber }) {
|
|
262
|
+
return this.#caller.call(Command.SUBTITLE_CUES, { sourceKey, fileIndex, trackNumber });
|
|
263
|
+
}
|
|
264
|
+
|
|
245
265
|
/**
|
|
246
266
|
* Bytes every torrent on the worker has moved, downloaded and uploaded apart.
|
|
247
267
|
*
|
|
@@ -141,6 +141,38 @@ export class WorkerTorrentPool {
|
|
|
141
141
|
});
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The text subtitle tracks a file carries, read from its own header.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} torrent
|
|
148
|
+
* @param {number} fileIndex
|
|
149
|
+
* @returns {Promise<object[]>}
|
|
150
|
+
*/
|
|
151
|
+
async getSubtitleTracks(torrent, fileIndex) {
|
|
152
|
+
const sourceKey = torrent?.sourceKey;
|
|
153
|
+
if (!sourceKey) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
const answer = await this.#client.getSubtitleTracks({ sourceKey, fileIndex });
|
|
157
|
+
return Array.isArray(answer?.tracks) ? answer.tracks : [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The cues of one subtitle track that the downloaded clusters already carry.
|
|
162
|
+
*
|
|
163
|
+
* @param {object} torrent
|
|
164
|
+
* @param {number} fileIndex
|
|
165
|
+
* @param {number} trackNumber
|
|
166
|
+
* @returns {Promise<object | null>}
|
|
167
|
+
*/
|
|
168
|
+
async getSubtitleCues(torrent, fileIndex, trackNumber) {
|
|
169
|
+
const sourceKey = torrent?.sourceKey;
|
|
170
|
+
if (!sourceKey) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return this.#client.getSubtitleCues({ sourceKey, fileIndex, trackNumber });
|
|
174
|
+
}
|
|
175
|
+
|
|
144
176
|
/**
|
|
145
177
|
* Reorder piece selection around a read position.
|
|
146
178
|
*
|
|
@@ -65,6 +65,10 @@ export const Command = {
|
|
|
65
65
|
CANCEL_READ: "cancel-read",
|
|
66
66
|
/** Pre-fetch the head and tail a codec probe needs. */
|
|
67
67
|
PREFETCH_EDGES: "prefetch-edges",
|
|
68
|
+
/** The text subtitle tracks a file carries, for the viewer's menu. */
|
|
69
|
+
SUBTITLE_TRACKS: "subtitle-tracks",
|
|
70
|
+
/** Cues of one subtitle track, from the clusters already downloaded. */
|
|
71
|
+
SUBTITLE_CUES: "subtitle-cues",
|
|
68
72
|
/** Shut the client down, optionally deleting downloaded data. */
|
|
69
73
|
DESTROY_ALL: "destroy-all"
|
|
70
74
|
};
|