@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.
@@ -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
  };