@torrent-tv/proxy 2.73.0 → 2.74.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 +1447 -1432
- package/CLAUDE.md +165 -160
- package/docs/container-architecture.md +192 -184
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +205 -205
- package/services/container/Container.js +354 -135
- package/services/container/MatroskaContainer.js +1155 -516
- package/services/container/Mp4Container.js +858 -392
- package/services/container/SubtitleFileContainer.js +323 -261
- package/services/controllers/SubtitleController.js +128 -127
- package/services/delivery-probe.js +64 -6
- package/services/hls-session-manager.js +32 -35
- package/services/language-detect.js +174 -228
- package/services/orchestrators/SubtitleOrchestrator.js +64 -5
- package/services/playback-planner.js +747 -747
- package/services/produced-index.js +300 -0
- package/services/torrent-worker/subtitle-cues.js +582 -618
- package/services/tracks/TextSubtitleTrack.js +287 -47
- package/services/tracks/index.js +14 -14
- package/test/delivery-probe.test.js +67 -0
- package/test/matroska-blocks.test.js +0 -0
- package/test/mp4-subtitles.test.js +173 -127
- package/test/produced-index.test.js +188 -0
- package/test/subtitle-cue-framing.test.js +200 -202
- package/test/subtitle-cue-source.test.js +104 -0
- package/test/subtitle-cue-walk.test.js +369 -0
- package/test/subtitle-defaults.test.js +97 -97
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +370 -370
- package/services/container-index/matroska-blocks.js +0 -202
- package/services/container-index/matroska-subtitles.js +0 -372
- package/services/container-index/mp4-subtitles.js +0 -404
- package/services/subtitle-convert.js +0 -144
- package/services/subtitle-defaults.js +0 -157
- package/services/tracks/subtitle-markup.js +0 -104
|
@@ -1,618 +1,582 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Subtitle cues gathered from the clusters a viewer has already brought
|
|
3
|
-
* in, never from clusters they have not.
|
|
4
|
-
*
|
|
5
|
-
* The rule this file exists to keep (stated by the user 2026-08-20): subtitles
|
|
6
|
-
* arrive the way the picture does, or they are not offered. So nothing here
|
|
7
|
-
* requests a byte. It looks at what the torrent already holds, reads the
|
|
8
|
-
* clusters inside it, and returns what it found; the region the viewer is
|
|
9
|
-
* watching is downloaded before they reach it, so its cues are ready before
|
|
10
|
-
* they are needed. A region nobody has watched has no cues, and that is
|
|
11
|
-
* correct — there is nobody to show them to.
|
|
12
|
-
*
|
|
13
|
-
* Why not ffmpeg: measured 2026-08-19, extracting one subtitle track of
|
|
14
|
-
* `Minions.and.Monsters.1080p.mkv` took **752 seconds** and pulled the download
|
|
15
|
-
* from 2.7 % to 81 % of a 6.5 GB film, because a subtitle stream is sparse and
|
|
16
|
-
* the demuxer walks the container to the end whatever range is asked of it.
|
|
17
|
-
* Reading the clusters costs nothing extra at all.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
*
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* @param {object}
|
|
330
|
-
* @param {
|
|
331
|
-
* @param {
|
|
332
|
-
* @param {number} trackNumber
|
|
333
|
-
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
334
|
-
*/
|
|
335
|
-
async function
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
track
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
* @param {
|
|
562
|
-
* @
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
forgetOne(key);
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
forgetOne(`${sourceKey}:${fileIndex}`);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* Drop one file's state, but not while a walk of it is still running: the
|
|
593
|
-
* record of which clusters have been read lives in that state, and a walk left
|
|
594
|
-
* writing into a discarded copy while a new one starts beside it is the one
|
|
595
|
-
* path that defeats the serialization above.
|
|
596
|
-
*
|
|
597
|
-
* @param {string} key
|
|
598
|
-
* @returns {void}
|
|
599
|
-
*/
|
|
600
|
-
function forgetOne(key) {
|
|
601
|
-
const state = byFile.get(key);
|
|
602
|
-
if (!state) {
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
// Held, so that a walk started before this call is not left orphaned; the
|
|
606
|
-
// entry is dropped the moment the queue empties, and nothing is handed this
|
|
607
|
-
// state in the meantime.
|
|
608
|
-
state.forgotten = true;
|
|
609
|
-
void state.chain.then(() => {
|
|
610
|
-
if (byFile.get(key) === state) {
|
|
611
|
-
byFile.delete(key);
|
|
612
|
-
}
|
|
613
|
-
}, () => {
|
|
614
|
-
if (byFile.get(key) === state) {
|
|
615
|
-
byFile.delete(key);
|
|
616
|
-
}
|
|
617
|
-
});
|
|
618
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file Subtitle cues gathered from the clusters a viewer has already brought
|
|
3
|
+
* in, never from clusters they have not.
|
|
4
|
+
*
|
|
5
|
+
* The rule this file exists to keep (stated by the user 2026-08-20): subtitles
|
|
6
|
+
* arrive the way the picture does, or they are not offered. So nothing here
|
|
7
|
+
* requests a byte. It looks at what the torrent already holds, reads the
|
|
8
|
+
* clusters inside it, and returns what it found; the region the viewer is
|
|
9
|
+
* watching is downloaded before they reach it, so its cues are ready before
|
|
10
|
+
* they are needed. A region nobody has watched has no cues, and that is
|
|
11
|
+
* correct — there is nobody to show them to.
|
|
12
|
+
*
|
|
13
|
+
* Why not ffmpeg: measured 2026-08-19, extracting one subtitle track of
|
|
14
|
+
* `Minions.and.Monsters.1080p.mkv` took **752 seconds** and pulled the download
|
|
15
|
+
* from 2.7 % to 81 % of a 6.5 GB film, because a subtitle stream is sparse and
|
|
16
|
+
* the demuxer walks the container to the end whatever range is asked of it.
|
|
17
|
+
* Reading the clusters costs nothing extra at all.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { MatroskaContainer } from "../container/MatroskaContainer.js";
|
|
21
|
+
import { Mp4Container } from "../container/Mp4Container.js";
|
|
22
|
+
import { TextSubtitleTrack } from "../tracks/TextSubtitleTrack.js";
|
|
23
|
+
import { detectLanguage } from "../language-detect.js";
|
|
24
|
+
import { logger } from "../../utils/logger.js";
|
|
25
|
+
|
|
26
|
+
/** How long a read of already-held bytes may take before it is given up. */
|
|
27
|
+
const READ_ABANDON_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
/** @type {Map<string, { plan: object | null, harvested: Map<number, Set<number>>, cues: Map<number, object[]> }>} */
|
|
30
|
+
const byFile = new Map();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether every piece covering a byte range is already downloaded.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} torrent
|
|
36
|
+
* @param {object} file
|
|
37
|
+
* @param {number} start - Offset within the FILE.
|
|
38
|
+
* @param {number} end - Inclusive.
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
function rangeIsHeld(torrent, file, start, end) {
|
|
42
|
+
const pieceLength = Number(torrent?.pieceLength);
|
|
43
|
+
const offset = Number(file?.offset) || 0;
|
|
44
|
+
if (!Number.isFinite(pieceLength) || pieceLength <= 0 || !torrent?.bitfield) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const first = Math.floor((offset + start) / pieceLength);
|
|
48
|
+
const last = Math.floor((offset + end) / pieceLength);
|
|
49
|
+
for (let index = first; index <= last; index += 1) {
|
|
50
|
+
if (!torrent.bitfield.get(index)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read a byte range of a file straight from the store, without asking the swarm
|
|
59
|
+
* for anything.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} file
|
|
62
|
+
* @param {number} start
|
|
63
|
+
* @param {number} end - Inclusive.
|
|
64
|
+
* @returns {Promise<Buffer | null>}
|
|
65
|
+
*/
|
|
66
|
+
function readHeld(file, start, end) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const chunks = [];
|
|
69
|
+
let stream;
|
|
70
|
+
try {
|
|
71
|
+
stream = file.createReadStream({ start, end });
|
|
72
|
+
} catch {
|
|
73
|
+
resolve(null);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let settled = false;
|
|
77
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
78
|
+
let abandon = null;
|
|
79
|
+
const settle = (value) => {
|
|
80
|
+
if (settled) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
settled = true;
|
|
84
|
+
if (abandon !== null) {
|
|
85
|
+
clearTimeout(abandon);
|
|
86
|
+
}
|
|
87
|
+
if (value === null) {
|
|
88
|
+
stream.destroy?.();
|
|
89
|
+
}
|
|
90
|
+
resolve(value);
|
|
91
|
+
};
|
|
92
|
+
// A read of bytes the torrent already holds either answers or it does not.
|
|
93
|
+
// This is not a measurement of anything and no figure is derived from it:
|
|
94
|
+
// it is the point past which such a read is presumed lost, so that one
|
|
95
|
+
// stream which never ends cannot hold this file's walk — and with it the
|
|
96
|
+
// browser's own request for its subtitles — for the rest of the session.
|
|
97
|
+
abandon = setTimeout(() => {
|
|
98
|
+
logger.info(
|
|
99
|
+
`subtitles: a read of ${start}-${end} in "${String(file.name).slice(0, 40)}" ` +
|
|
100
|
+
`did not finish in ${READ_ABANDON_MS / 1000}s and was given up`
|
|
101
|
+
);
|
|
102
|
+
settle(null);
|
|
103
|
+
}, READ_ABANDON_MS);
|
|
104
|
+
abandon.unref?.();
|
|
105
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
106
|
+
stream.on("end", () => settle(Buffer.concat(chunks)));
|
|
107
|
+
stream.on("error", () => settle(null));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A container over one file of a torrent, told how to read it.
|
|
113
|
+
*
|
|
114
|
+
* The container is given two functions and never the torrent: whether a byte
|
|
115
|
+
* range is already downloaded, and how to read one without asking the swarm for
|
|
116
|
+
* anything. That is the whole of what this layer knows that the container does
|
|
117
|
+
* not, and reducing it to two functions is what lets the reading itself live
|
|
118
|
+
* where the format is specified.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} torrent
|
|
121
|
+
* @param {object} file
|
|
122
|
+
* @returns {MatroskaContainer | Mp4Container}
|
|
123
|
+
*/
|
|
124
|
+
function containerOver(state, torrent, file) {
|
|
125
|
+
if (state.container) {
|
|
126
|
+
return state.container;
|
|
127
|
+
}
|
|
128
|
+
const held = async (start, end) => readHeld(file, start, Math.min(end, file.length - 1));
|
|
129
|
+
const params = {
|
|
130
|
+
readRange: held,
|
|
131
|
+
readHeld: held,
|
|
132
|
+
isHeld: (start, end) => rangeIsHeld(torrent, file, start, Math.min(end, file.length - 1)),
|
|
133
|
+
fileSize: file.length,
|
|
134
|
+
label: String(file.name ?? "")
|
|
135
|
+
};
|
|
136
|
+
const name = String(file.name ?? "");
|
|
137
|
+
state.container = /\.(mp4|m4v)$/i.test(name) ? new Mp4Container(params) : new MatroskaContainer(params);
|
|
138
|
+
return state.container;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The subtitle tracks of a file, read once and kept.
|
|
143
|
+
*
|
|
144
|
+
* The head and the Cues table are two short reads, and they ARE fetched if
|
|
145
|
+
* missing — they are kilobytes, they are needed before anything can be offered,
|
|
146
|
+
* and the codec probe has already pulled the head for every file that plays.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} torrent
|
|
149
|
+
* @param {number} fileIndex
|
|
150
|
+
* @param {string} key - `sourceKey:fileIndex`.
|
|
151
|
+
* @returns {Promise<object | null>}
|
|
152
|
+
*/
|
|
153
|
+
async function planFor(torrent, fileIndex, key) {
|
|
154
|
+
const state = stateFor(key);
|
|
155
|
+
if (state.plan !== null) {
|
|
156
|
+
return state.plan;
|
|
157
|
+
}
|
|
158
|
+
// The head and the Cues table are two reads that DO wait on the swarm, so two
|
|
159
|
+
// callers arriving together would both make them. One promise, awaited by
|
|
160
|
+
// whoever asks while it is in flight.
|
|
161
|
+
if (!state.planPromise) {
|
|
162
|
+
state.planPromise = readPlan(torrent, fileIndex, state).finally(() => {
|
|
163
|
+
state.planPromise = null;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return state.planPromise;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The state kept for one file, created on first use.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} key - `sourceKey:fileIndex`.
|
|
173
|
+
* @returns {object}
|
|
174
|
+
*/
|
|
175
|
+
function stateFor(key) {
|
|
176
|
+
let state = byFile.get(key);
|
|
177
|
+
// A state that has been forgotten is not handed out again, even in the moment
|
|
178
|
+
// between the call and the walk that was still running finishing.
|
|
179
|
+
if (state?.forgotten === true) {
|
|
180
|
+
state = undefined;
|
|
181
|
+
}
|
|
182
|
+
if (!state) {
|
|
183
|
+
state = {
|
|
184
|
+
plan: null,
|
|
185
|
+
planPromise: null,
|
|
186
|
+
forgotten: false,
|
|
187
|
+
// One walk of a file at a time — see `serialize`.
|
|
188
|
+
chain: Promise.resolve(),
|
|
189
|
+
// The container over this file, built once. It caches what it has parsed
|
|
190
|
+
// — a `moov` box is tens of megabytes off a torrent — so building a fresh
|
|
191
|
+
// one per call would throw that away on every request.
|
|
192
|
+
container: null,
|
|
193
|
+
harvested: new Map(),
|
|
194
|
+
cues: new Map(),
|
|
195
|
+
seq: new Map(),
|
|
196
|
+
walked: new Set(),
|
|
197
|
+
// The found-order cursor of the last cue PUSHED for each track, so a
|
|
198
|
+
// second warmup pass sends only what a first one did not — the same
|
|
199
|
+
// found-order idea `?since=` uses for a browser's own pull.
|
|
200
|
+
pushed: new Map()
|
|
201
|
+
};
|
|
202
|
+
byFile.set(key, state);
|
|
203
|
+
}
|
|
204
|
+
return state;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Run `work` after every walk of this file already started, and before any
|
|
209
|
+
* started after it.
|
|
210
|
+
*
|
|
211
|
+
* Both entry points here — a browser's own pull and the warmup that runs ahead
|
|
212
|
+
* of it — mark a cluster as walked only AFTER reading and parsing it, which is
|
|
213
|
+
* two suspension points later. Until 2.56.0 nothing stopped a second call
|
|
214
|
+
* arriving in between: `warmActiveFiles` runs on every verified piece AND on a
|
|
215
|
+
* 3 s timer, so on a fast download the same cluster was read and parsed several
|
|
216
|
+
* times over and the same line could be pushed twice under different `seq`
|
|
217
|
+
* numbers. Each of those reads is a WebTorrent file stream, which selects and
|
|
218
|
+
* deselects its pieces, so the repetition reached the piece picker as well.
|
|
219
|
+
*
|
|
220
|
+
* @template T
|
|
221
|
+
* @param {object} state
|
|
222
|
+
* @param {() => Promise<T>} work
|
|
223
|
+
* @returns {Promise<T>}
|
|
224
|
+
*/
|
|
225
|
+
function serialize(state, work) {
|
|
226
|
+
const run = state.chain.then(work, work);
|
|
227
|
+
// The queue must survive a failed walk, so what is chained is the settled
|
|
228
|
+
// form; the caller still sees the rejection.
|
|
229
|
+
state.chain = run.then(() => undefined, () => undefined);
|
|
230
|
+
return run;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Read one file's subtitle plan — the tracks it declares and where the clusters
|
|
235
|
+
* holding them are. Called once per file; see `planFor`.
|
|
236
|
+
*
|
|
237
|
+
* @param {object} torrent
|
|
238
|
+
* @param {number} fileIndex
|
|
239
|
+
* @param {object} state
|
|
240
|
+
* @returns {Promise<object>}
|
|
241
|
+
*/
|
|
242
|
+
async function readPlan(torrent, fileIndex, state) {
|
|
243
|
+
const file = torrent?.files?.[fileIndex];
|
|
244
|
+
// `declared` is what the container itself says about its subtitle tracks, in
|
|
245
|
+
// its own order. Empty means the container said nothing — which is a real
|
|
246
|
+
// answer and not a missing one: nothing is then shown unasked. An MP4 has no
|
|
247
|
+
// element that means "show this subtitle track by default", so it declares
|
|
248
|
+
// nothing however many tracks it carries.
|
|
249
|
+
const empty = { tracks: [], declared: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
|
|
250
|
+
if (!file) {
|
|
251
|
+
state.plan = empty;
|
|
252
|
+
return state.plan;
|
|
253
|
+
}
|
|
254
|
+
const container = containerOver(state, torrent, file);
|
|
255
|
+
const name = String(file.name);
|
|
256
|
+
if (/\.mp4$/i.test(name) || /\.m4v$/i.test(name)) {
|
|
257
|
+
// An MP4 states every sample's byte range in its own table, so a cue costs
|
|
258
|
+
// its own few dozen bytes rather than the cluster around it. The samples
|
|
259
|
+
// are carried as `clusterPositions` of one byte range each, so the harvest
|
|
260
|
+
// treats both containers the same way.
|
|
261
|
+
const mp4 = await container.readSubtitlePlan();
|
|
262
|
+
state.plan = mp4
|
|
263
|
+
? {
|
|
264
|
+
...empty,
|
|
265
|
+
tracks: mp4.tracks.map((track, order) => ({
|
|
266
|
+
trackNumber: track.trackId,
|
|
267
|
+
declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
268
|
+
codecId: track.format,
|
|
269
|
+
language: track.language,
|
|
270
|
+
name: "",
|
|
271
|
+
isDefault: order === 0,
|
|
272
|
+
codecPrivate: "",
|
|
273
|
+
clusterPositions: [],
|
|
274
|
+
samples: track.samples
|
|
275
|
+
}))
|
|
276
|
+
}
|
|
277
|
+
: empty;
|
|
278
|
+
return state.plan;
|
|
279
|
+
}
|
|
280
|
+
if (!/\.mkv$/i.test(name) && !/\.webm$/i.test(name)) {
|
|
281
|
+
state.plan = empty;
|
|
282
|
+
return state.plan;
|
|
283
|
+
}
|
|
284
|
+
const plan = await container.readSubtitlePlan();
|
|
285
|
+
state.plan = plan ?? empty;
|
|
286
|
+
if (state.plan.tracks.length > 0) {
|
|
287
|
+
logger.info(
|
|
288
|
+
`subtitles: "${String(file.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s) ` +
|
|
289
|
+
`of ${state.plan.declared.length} declared — ` +
|
|
290
|
+
state.plan.tracks
|
|
291
|
+
// `s:N` is the number the browser names (ffmpeg's own), and it differs
|
|
292
|
+
// from the file's track number whenever a picture track sits among them.
|
|
293
|
+
.map((track) => `s:${track.declaredIndex}=${track.trackNumber}:${track.language || "?"}` +
|
|
294
|
+
`${track.name ? `/${track.name}` : ""}(${track.clusterPositions.length} indexed)`)
|
|
295
|
+
.join(" ")
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return state.plan;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The order a cue was FOUND in, which is the only cursor a browser can follow.
|
|
303
|
+
*
|
|
304
|
+
* A cue's TIME cannot serve as one. Cues are harvested out of whichever
|
|
305
|
+
* clusters happen to be downloaded, and those are not contiguous, so the set
|
|
306
|
+
* grows in the middle as well as at the end. A browser that remembered "the
|
|
307
|
+
* latest time I hold" and asked for everything past it would never be sent the
|
|
308
|
+
* cues that turn up BEHIND that mark afterwards — which is exactly the stretch
|
|
309
|
+
* it is about to play. Measured 2026-08-20 on a viewer at 272 s: one answer
|
|
310
|
+
* carried cues out to 1176 s, and from then on every cue between the two was
|
|
311
|
+
* filtered away for the rest of the session, with 59 of 276 clusters read.
|
|
312
|
+
*
|
|
313
|
+
* Found-order is monotonic by construction, so `?since=<n>` is exact however
|
|
314
|
+
* the file arrives.
|
|
315
|
+
*
|
|
316
|
+
* @param {{ seq: Map<number, number> }} state
|
|
317
|
+
* @param {number} trackNumber
|
|
318
|
+
* @returns {number}
|
|
319
|
+
*/
|
|
320
|
+
function nextSeq(state, trackNumber) {
|
|
321
|
+
const next = (state.seq.get(trackNumber) ?? 0) + 1;
|
|
322
|
+
state.seq.set(trackNumber, next);
|
|
323
|
+
return next;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Every cue of one track that can be read from what is already downloaded.
|
|
328
|
+
*
|
|
329
|
+
* @param {object} torrent
|
|
330
|
+
* @param {number} fileIndex
|
|
331
|
+
* @param {string} sourceKey
|
|
332
|
+
* @param {number} trackNumber
|
|
333
|
+
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
334
|
+
*/
|
|
335
|
+
export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
|
|
336
|
+
const key = `${sourceKey}:${fileIndex}`;
|
|
337
|
+
// A torrent that cannot say which pieces it holds makes every range read as
|
|
338
|
+
// "not downloaded", so the walk reads nothing and returns an empty list —
|
|
339
|
+
// which is also what a file with no cues yet returns, and that is how this
|
|
340
|
+
// went unnoticed for a session (2026-09-03: 283 clusters indexed, 0 walked,
|
|
341
|
+
// the browser served `WEBVTT` and nothing else). The stand-in the main thread
|
|
342
|
+
// holds is exactly such a torrent; only the thread that owns the object has
|
|
343
|
+
// the bitfield. Nothing here can repair that, so it says so instead.
|
|
344
|
+
if (!torrent?.bitfield || !(Number(torrent?.pieceLength) > 0)) {
|
|
345
|
+
logger.warn(
|
|
346
|
+
`subtitles: asked for cues of "${String(torrent?.name ?? sourceKey).slice(0, 40)}" ` +
|
|
347
|
+
"on a torrent that cannot say which pieces it holds — no cluster can be read here, " +
|
|
348
|
+
"and the answer would be an empty document indistinguishable from a file with no cues"
|
|
349
|
+
);
|
|
350
|
+
return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
|
|
351
|
+
}
|
|
352
|
+
const plan = await planFor(torrent, fileIndex, key);
|
|
353
|
+
const state = stateFor(key);
|
|
354
|
+
const track = plan?.tracks?.find((candidate) => candidate.trackNumber === trackNumber) ?? null;
|
|
355
|
+
if (!track) {
|
|
356
|
+
return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
|
|
357
|
+
}
|
|
358
|
+
return serialize(state, () => walkFor(torrent, fileIndex, state, plan, track, trackNumber));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The walk itself. Only ever entered through `cuesHeldFor`, which is what keeps
|
|
363
|
+
* one file to one walk at a time.
|
|
364
|
+
*
|
|
365
|
+
* @param {object} torrent
|
|
366
|
+
* @param {number} fileIndex
|
|
367
|
+
* @param {object} state
|
|
368
|
+
* @param {object} plan
|
|
369
|
+
* @param {object} track
|
|
370
|
+
* @param {number} trackNumber
|
|
371
|
+
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
372
|
+
*/
|
|
373
|
+
async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
|
|
374
|
+
const file = torrent.files[fileIndex];
|
|
375
|
+
const container = containerOver(state, torrent, file);
|
|
376
|
+
const cuesOf = (number) => {
|
|
377
|
+
let held = state.cues.get(number);
|
|
378
|
+
if (!held) {
|
|
379
|
+
held = [];
|
|
380
|
+
state.cues.set(number, held);
|
|
381
|
+
}
|
|
382
|
+
return held;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
if (Array.isArray(track.samples)) {
|
|
386
|
+
// An MP4 states every cue's byte range, so the container reads per sample.
|
|
387
|
+
let harvested = state.harvested.get(trackNumber);
|
|
388
|
+
if (!harvested) {
|
|
389
|
+
harvested = new Set();
|
|
390
|
+
state.harvested.set(trackNumber, harvested);
|
|
391
|
+
}
|
|
392
|
+
const cues = cuesOf(trackNumber);
|
|
393
|
+
for (const cue of await container.readHeldSamples(track, harvested)) {
|
|
394
|
+
cues.push({ ...cue, seq: nextSeq(state, trackNumber) });
|
|
395
|
+
}
|
|
396
|
+
cues.sort((left, right) => left.startSeconds - right.startSeconds);
|
|
397
|
+
return {
|
|
398
|
+
cues,
|
|
399
|
+
coveredClusters: harvested.size,
|
|
400
|
+
indexedClusters: track.samples.length,
|
|
401
|
+
track
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Matroska: one walk of the file fills every track, because a cluster carries
|
|
406
|
+
// the blocks of every track that has anything to say over its span.
|
|
407
|
+
const found = await container.walkHeldClusters(plan, state.walked);
|
|
408
|
+
for (const [number, cues] of found) {
|
|
409
|
+
const into = cuesOf(number);
|
|
410
|
+
for (const cue of cues) {
|
|
411
|
+
into.push({ ...cue, seq: nextSeq(state, number) });
|
|
412
|
+
}
|
|
413
|
+
into.sort((left, right) => left.startSeconds - right.startSeconds);
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
cues: state.cues.get(trackNumber) ?? [],
|
|
417
|
+
// Every track is filled by the same walk, so this is a fact about the FILE
|
|
418
|
+
// and reads the same whichever track asked.
|
|
419
|
+
coveredClusters: state.walked.size,
|
|
420
|
+
indexedClusters: track.clusterPositions.length,
|
|
421
|
+
track
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Walk whatever clusters have newly arrived, for every text track a file
|
|
427
|
+
* carries, and report what is new since the last call — so the cues can be
|
|
428
|
+
* PUSHED to a browser rather than left for it to come back and ask.
|
|
429
|
+
*
|
|
430
|
+
* `cuesHeldFor` already skips positions it has walked before (`state.walked`),
|
|
431
|
+
* so calling this on a timer or on every verified piece is cheap once a file
|
|
432
|
+
* is caught up: the only cost is deciding there is nothing new to read. It is
|
|
433
|
+
* `getSubtitleCues` run ahead of being asked, on the same state that call
|
|
434
|
+
* itself would build — nothing is duplicated, and a file nobody has opened
|
|
435
|
+
* costs nothing beyond this.
|
|
436
|
+
*
|
|
437
|
+
* @param {object} torrent
|
|
438
|
+
* @param {number} fileIndex
|
|
439
|
+
* @param {string} sourceKey
|
|
440
|
+
* @returns {Promise<{ trackIndex: number, cues: object[], language: string }[]>}
|
|
441
|
+
* One entry per track that gained at least one cue since the last call.
|
|
442
|
+
* `trackIndex` is `declaredIndex` — the track's position among ALL the file's
|
|
443
|
+
* subtitle tracks, which is ffmpeg's `0:s:N` and the only number the browser
|
|
444
|
+
* knows. NOT the container's own track number, and not the position among the
|
|
445
|
+
* readable tracks either: counting those alone puts every text track after a
|
|
446
|
+
* picture-based one in the wrong place.
|
|
447
|
+
*/
|
|
448
|
+
export async function warmSubtitleCues(torrent, fileIndex, sourceKey) {
|
|
449
|
+
const key = `${sourceKey}:${fileIndex}`;
|
|
450
|
+
const plan = await planFor(torrent, fileIndex, key);
|
|
451
|
+
const state = stateFor(key);
|
|
452
|
+
const fresh = [];
|
|
453
|
+
const tracks = plan?.tracks ?? [];
|
|
454
|
+
for (let order = 0; order < tracks.length; order += 1) {
|
|
455
|
+
const track = tracks[order];
|
|
456
|
+
const held = await cuesHeldFor(torrent, fileIndex, sourceKey, track.trackNumber);
|
|
457
|
+
const since = state.pushed.get(track.trackNumber) ?? 0;
|
|
458
|
+
const newCues = held.cues.filter((cue) => (Number(cue.seq) || 0) > since);
|
|
459
|
+
if (newCues.length === 0) {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const highest = newCues.reduce((max, cue) => Math.max(max, Number(cue.seq) || 0), since);
|
|
463
|
+
state.pushed.set(track.trackNumber, highest);
|
|
464
|
+
const codecId = held.track?.codecId ?? track.codecId;
|
|
465
|
+
const cues = TextSubtitleTrack.finalizeCues(newCues, codecId);
|
|
466
|
+
fresh.push({
|
|
467
|
+
// ffmpeg's own numbering, which is the only one the browser knows.
|
|
468
|
+
trackIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
469
|
+
cues,
|
|
470
|
+
language: held.track?.language ?? "",
|
|
471
|
+
// What the CUES say the language is, re-read on every push over every cue
|
|
472
|
+
// held so far rather than over this batch. A track whose container states
|
|
473
|
+
// no language is unreadable at the start of a session — a handful of cues
|
|
474
|
+
// is not a sample of a language, and the detector refuses to answer on one
|
|
475
|
+
// — so the answer has to be re-taken as the film downloads, and the label
|
|
476
|
+
// moved when it arrives. Costs about 6 ms per push, measured; pushes
|
|
477
|
+
// arrive about once a second per file being read.
|
|
478
|
+
detectedLanguage: detectLanguage(
|
|
479
|
+
TextSubtitleTrack.finalizeCues(held.cues, codecId).map((cue) => cue.text).join("\n")
|
|
480
|
+
),
|
|
481
|
+
// Where the browser should resume from if it has to ask again — after a
|
|
482
|
+
// reconnect, which loses the subscription these pushes ride on.
|
|
483
|
+
cursor: highest,
|
|
484
|
+
// What this batch is ABOUT, in film time, so a log can be read against
|
|
485
|
+
// the position being played.
|
|
486
|
+
spanStartSeconds: cues.length > 0 ? cues[0].startSeconds : null,
|
|
487
|
+
spanEndSeconds: cues.length > 0 ? cues[cues.length - 1].endSeconds : null,
|
|
488
|
+
walkedClusters: held.coveredClusters ?? 0,
|
|
489
|
+
indexedClusters: held.indexedClusters ?? 0
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return fresh;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* The text subtitle tracks of a file, for the menu the viewer sees.
|
|
497
|
+
*
|
|
498
|
+
* @param {object} torrent
|
|
499
|
+
* @param {number} fileIndex
|
|
500
|
+
* @param {string} sourceKey
|
|
501
|
+
* @returns {Promise<object[]>}
|
|
502
|
+
*/
|
|
503
|
+
export async function subtitleTracksOf(torrent, fileIndex, sourceKey) {
|
|
504
|
+
const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
|
|
505
|
+
return (plan?.tracks ?? []).map((track, order) => ({
|
|
506
|
+
trackNumber: track.trackNumber,
|
|
507
|
+
declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
508
|
+
codecId: track.codecId,
|
|
509
|
+
language: track.language,
|
|
510
|
+
name: track.name,
|
|
511
|
+
isDefault: track.isDefault,
|
|
512
|
+
indexedClusters: track.clusterPositions.length
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* What the container itself says about its subtitle tracks, in its own order
|
|
518
|
+
* and including the picture-based ones.
|
|
519
|
+
*
|
|
520
|
+
* Separate from `subtitleTracksOf`, which lists only what can be turned into
|
|
521
|
+
* WebVTT and is indexed by position in the subtitle API. This one exists to be
|
|
522
|
+
* lined up against ffmpeg's `0:s:N` numbering, which counts every subtitle
|
|
523
|
+
* stream, so leaving the picture ones out would shift it.
|
|
524
|
+
*
|
|
525
|
+
* @param {object} torrent
|
|
526
|
+
* @param {number} fileIndex
|
|
527
|
+
* @param {string} sourceKey
|
|
528
|
+
* @returns {Promise<object[]>}
|
|
529
|
+
*/
|
|
530
|
+
export async function declaredSubtitleTracksOf(torrent, fileIndex, sourceKey) {
|
|
531
|
+
const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
|
|
532
|
+
return plan?.declared ?? [];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Forget a file's cues — the torrent is gone, and holding them would keep the
|
|
537
|
+
* text of a film nobody is watching.
|
|
538
|
+
*
|
|
539
|
+
* @param {string} sourceKey
|
|
540
|
+
* @param {number} [fileIndex]
|
|
541
|
+
* @returns {void}
|
|
542
|
+
*/
|
|
543
|
+
export function forgetSubtitles(sourceKey, fileIndex) {
|
|
544
|
+
if (fileIndex === undefined) {
|
|
545
|
+
for (const key of [...byFile.keys()]) {
|
|
546
|
+
if (key.startsWith(`${sourceKey}:`)) {
|
|
547
|
+
forgetOne(key);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
forgetOne(`${sourceKey}:${fileIndex}`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Drop one file's state, but not while a walk of it is still running: the
|
|
557
|
+
* record of which clusters have been read lives in that state, and a walk left
|
|
558
|
+
* writing into a discarded copy while a new one starts beside it is the one
|
|
559
|
+
* path that defeats the serialization above.
|
|
560
|
+
*
|
|
561
|
+
* @param {string} key
|
|
562
|
+
* @returns {void}
|
|
563
|
+
*/
|
|
564
|
+
function forgetOne(key) {
|
|
565
|
+
const state = byFile.get(key);
|
|
566
|
+
if (!state) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
// Held, so that a walk started before this call is not left orphaned; the
|
|
570
|
+
// entry is dropped the moment the queue empties, and nothing is handed this
|
|
571
|
+
// state in the meantime.
|
|
572
|
+
state.forgotten = true;
|
|
573
|
+
void state.chain.then(() => {
|
|
574
|
+
if (byFile.get(key) === state) {
|
|
575
|
+
byFile.delete(key);
|
|
576
|
+
}
|
|
577
|
+
}, () => {
|
|
578
|
+
if (byFile.get(key) === state) {
|
|
579
|
+
byFile.delete(key);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
}
|