@torrent-tv/proxy 2.71.1 → 2.72.1
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 +26 -0
- package/bin/cli.js +8 -0
- package/package.json +1 -1
- package/server.js +4 -0
- package/services/container/Container.js +35 -0
- package/services/container/MatroskaContainer.js +42 -0
- package/services/container/Mp4Container.js +22 -1
- package/services/container/SubtitleFileContainer.js +261 -0
- package/services/container/index.js +1 -0
- package/services/container-index/matroska-subtitles.js +6 -1
- package/services/controllers/SubtitleController.js +6 -25
- package/services/health-collector.js +37 -3
- package/services/hls-session-manager.js +10584 -10243
- package/services/piece-store/piece-lru.js +43 -0
- package/services/piece-store/shared-piece-store.js +214 -11
- package/services/playback-planner.js +7 -0
- package/services/subtitle-convert.js +74 -80
- package/services/torrent-worker/subtitle-cues.js +18 -53
- package/services/tracks/TextSubtitleTrack.js +18 -0
- package/services/tracks/index.js +1 -0
- package/services/tracks/subtitle-markup.js +104 -0
- package/services/tunnel-client.js +27 -0
- package/test/health-metrics.test.js +37 -0
- package/test/piece-store-eviction.test.js +98 -10
- package/test/piece-store-slow-disk.test.js +114 -0
- package/test/produced-copy-choice.test.js +361 -0
- package/test/subtitle-cue-framing.test.js +202 -0
- package/test/subtitle-language.test.js +19 -8
|
@@ -248,6 +248,49 @@ export class PieceLru {
|
|
|
248
248
|
};
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
/**
|
|
252
|
+
* How long this piece will be waited for, in pieces.
|
|
253
|
+
*
|
|
254
|
+
* A window starts at what its reader needs NEXT and runs forward, so a piece
|
|
255
|
+
* near the start of a window is wanted sooner than one at its far end, and
|
|
256
|
+
* one beyond every window is wanted later still. That is the comparison the
|
|
257
|
+
* store needs when a piece arrives at a full store: is the arrival wanted
|
|
258
|
+
* sooner or later than the piece it would displace?
|
|
259
|
+
*
|
|
260
|
+
* `#distanceToWindow` cannot answer it — everything inside any window is zero
|
|
261
|
+
* there, so a piece at the front of the reader's own window and one at the
|
|
262
|
+
* far end of somebody else's look identical.
|
|
263
|
+
*
|
|
264
|
+
* A piece BEHIND every window is counted by how far behind, because a reader
|
|
265
|
+
* walking forward will not come back to it: behind is late, not early.
|
|
266
|
+
*
|
|
267
|
+
* @param {number} index
|
|
268
|
+
* @returns {number} -1 when nobody has declared anything.
|
|
269
|
+
*/
|
|
270
|
+
waitFor(index) {
|
|
271
|
+
let soonest = -1;
|
|
272
|
+
for (const range of this.#protected.values()) {
|
|
273
|
+
const wait = index < range.from ? range.from - index : index - range.from;
|
|
274
|
+
if (soonest === -1 || wait < soonest) {
|
|
275
|
+
soonest = wait;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return soonest;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The piece that would be evicted next, and how long it will be waited for.
|
|
283
|
+
*
|
|
284
|
+
* @returns {{ index: number | null, wait: number }}
|
|
285
|
+
*/
|
|
286
|
+
nextVictim() {
|
|
287
|
+
const choice = this.evictionChoice();
|
|
288
|
+
return {
|
|
289
|
+
index: choice.index,
|
|
290
|
+
wait: choice.index === null ? -1 : this.waitFor(choice.index)
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
251
294
|
/**
|
|
252
295
|
* Whether a reader is holding this piece right now.
|
|
253
296
|
*
|
|
@@ -272,6 +272,12 @@ const REVIVAL_AGE_SAMPLES = 200;
|
|
|
272
272
|
*/
|
|
273
273
|
const REUSE_GAP_SAMPLES = 200;
|
|
274
274
|
|
|
275
|
+
/** How many write durations are kept for the median. */
|
|
276
|
+
const WRITE_DURATION_SAMPLES = 50;
|
|
277
|
+
/** How many admission times are kept, and how far back they are counted. */
|
|
278
|
+
const ARRIVAL_SAMPLES = 100;
|
|
279
|
+
const ARRIVAL_WINDOW_MS = 10_000;
|
|
280
|
+
|
|
275
281
|
/**
|
|
276
282
|
* The middle value of a sample, or null when there is nothing to take a middle
|
|
277
283
|
* of. Null rather than zero: no revivals and instant revivals are different
|
|
@@ -355,7 +361,29 @@ export class SharedPieceStore {
|
|
|
355
361
|
*/
|
|
356
362
|
returnedWhilePinned: 0,
|
|
357
363
|
/** Spills that found the disk already holding identical bytes. */
|
|
358
|
-
spillsSkipped: 0
|
|
364
|
+
spillsSkipped: 0,
|
|
365
|
+
/**
|
|
366
|
+
* Blocks a second registration of the same piece displaced. Expected to be
|
|
367
|
+
* small and non-zero: two callers racing for one piece is ordinary. What is
|
|
368
|
+
* NOT ordinary is the block going missing when it happens, which is what
|
|
369
|
+
* killed the process on 2026-09-02.
|
|
370
|
+
*/
|
|
371
|
+
blocksDisplaced: 0,
|
|
372
|
+
/** Admissions that waited for the disk instead of evicting another piece. */
|
|
373
|
+
waitedForDisk: 0,
|
|
374
|
+
/**
|
|
375
|
+
* Admissions that grew memory because the disk had stopped answering.
|
|
376
|
+
* Non-zero means the store exceeded its allowance on purpose, and by how
|
|
377
|
+
* many pieces.
|
|
378
|
+
*/
|
|
379
|
+
grewWaitingForDisk: 0,
|
|
380
|
+
/**
|
|
381
|
+
* Times a block was wanted, none was free, and the pool was already at its
|
|
382
|
+
* ceiling. Zero by construction — a block is only taken after a slot has
|
|
383
|
+
* been claimed, and slots are what the ceiling counts — so a number here
|
|
384
|
+
* means the two have come apart and the pool is growing past its allowance.
|
|
385
|
+
*/
|
|
386
|
+
blocksBeyondCeiling: 0
|
|
359
387
|
};
|
|
360
388
|
/**
|
|
361
389
|
* Blocks that hold no piece, most recently freed last.
|
|
@@ -384,6 +412,19 @@ export class SharedPieceStore {
|
|
|
384
412
|
* @type {number[]}
|
|
385
413
|
*/
|
|
386
414
|
#reuseGaps = [];
|
|
415
|
+
/**
|
|
416
|
+
* How long recent writes to disk took, in milliseconds, and when pieces were
|
|
417
|
+
* admitted. Together they say how much room the store needs beyond what the
|
|
418
|
+
* readers ask for: while one write is finishing, more pieces arrive, and each
|
|
419
|
+
* needs somewhere to go. Without that room every arrival evicts something,
|
|
420
|
+
* which is what produced 233 evictions against 119 completed writes in a
|
|
421
|
+
* minute on 2026-09-02.
|
|
422
|
+
*
|
|
423
|
+
* @type {number[]}
|
|
424
|
+
*/
|
|
425
|
+
#writeDurations = [];
|
|
426
|
+
/** When the last few pieces were admitted, for the arrival rate. */
|
|
427
|
+
#admittedAt = [];
|
|
387
428
|
/** Piece index → when it was written out, for the age it comes back at. */
|
|
388
429
|
#spilledAt = new Map();
|
|
389
430
|
/**
|
|
@@ -460,6 +501,10 @@ export class SharedPieceStore {
|
|
|
460
501
|
// grows (roadmap item 2).
|
|
461
502
|
blocksAllocated: this.#blocksAllocated,
|
|
462
503
|
blocksFree: this.#freeBlocks.length,
|
|
504
|
+
blocksDisplaced: this.#counters.blocksDisplaced,
|
|
505
|
+
blocksInUse: this.#blocksInUse(),
|
|
506
|
+
blocksInFlight: this.#blocksInFlight(),
|
|
507
|
+
blocksBeyondCeiling: this.#counters.blocksBeyondCeiling,
|
|
463
508
|
blockBytes: this.#blocksAllocated * this.#chunkLength,
|
|
464
509
|
reuseGapMs: this.#reuseGapCeilingMs(),
|
|
465
510
|
revivalAgeMedianMs: median(this.#revivalAges),
|
|
@@ -505,7 +550,12 @@ export class SharedPieceStore {
|
|
|
505
550
|
if (demand.readers > 0) {
|
|
506
551
|
this.#everHadReader = true;
|
|
507
552
|
const pieces = Math.max(MIN_RESIDENT_PIECES, demand.unionPieces, demand.widestPieces);
|
|
508
|
-
|
|
553
|
+
// Plus room to absorb what arrives while a write is finishing. Asking for
|
|
554
|
+
// exactly what the readers want leaves no free place ever, so every
|
|
555
|
+
// arrival evicts one of them — measured 2026-09-02: `6 reader(s) want 23
|
|
556
|
+
// piece(s) of 23 the store may hold`, and 233 evictions in the minute
|
|
557
|
+
// that followed.
|
|
558
|
+
return (pieces + this.slackPieces()) * this.#chunkLength;
|
|
509
559
|
}
|
|
510
560
|
// Readers that have GONE are not the same as readers that have not arrived.
|
|
511
561
|
// A store whose readers ended has nothing to hold pieces for — its torrent
|
|
@@ -515,7 +565,7 @@ export class SharedPieceStore {
|
|
|
515
565
|
// reader is being filled for one that is on its way, and asks for what it
|
|
516
566
|
// was opened with until the first read says what it needs.
|
|
517
567
|
return this.#everHadReader
|
|
518
|
-
? MIN_RESIDENT_PIECES * this.#chunkLength
|
|
568
|
+
? (MIN_RESIDENT_PIECES + this.slackPieces()) * this.#chunkLength
|
|
519
569
|
: this.#growthCeiling * this.#chunkLength;
|
|
520
570
|
}
|
|
521
571
|
|
|
@@ -700,7 +750,21 @@ export class SharedPieceStore {
|
|
|
700
750
|
}
|
|
701
751
|
|
|
702
752
|
#registerPiece(index, buffer) {
|
|
753
|
+
// A piece may already be here. Both paths that register one look first and
|
|
754
|
+
// then await — `put` waits for a slot, `#revive` waits for the disk — and
|
|
755
|
+
// in that gap another caller can register the same index. Overwriting the
|
|
756
|
+
// entry used to drop the previous block on the floor: its memory was not
|
|
757
|
+
// returned to the pool and the pool's own count was never decremented, so
|
|
758
|
+
// the count climbed past the ceiling for ever and the pool degenerated into
|
|
759
|
+
// allocating a fresh block per piece. Field 2026-09-02: a store holding
|
|
760
|
+
// THREE pieces reported 812 MB committed against 68 MB allowed, 739 blocks
|
|
761
|
+
// allocated and 676 still alive, and the process was killed at 4.37 GB.
|
|
762
|
+
const displaced = this.#buffers.get(index);
|
|
703
763
|
this.#buffers.set(index, buffer);
|
|
764
|
+
if (displaced !== undefined && displaced !== buffer) {
|
|
765
|
+
this.#counters.blocksDisplaced += 1;
|
|
766
|
+
this.#returnBlock(displaced);
|
|
767
|
+
}
|
|
704
768
|
this.#lru.touch(index);
|
|
705
769
|
this.#noteProgress();
|
|
706
770
|
}
|
|
@@ -730,8 +794,10 @@ export class SharedPieceStore {
|
|
|
730
794
|
}
|
|
731
795
|
|
|
732
796
|
const bytes = Buffer.from(buffer, 0, this.#lengthOf(index));
|
|
797
|
+
const startedAt = Date.now();
|
|
733
798
|
const spill = this.#disk.write(index, bytes).then(
|
|
734
799
|
() => {
|
|
800
|
+
this.#noteWriteDuration(Date.now() - startedAt);
|
|
735
801
|
this.#counters.spills += 1;
|
|
736
802
|
this.#spilledAt.set(index, Date.now());
|
|
737
803
|
this.#evicting.delete(index);
|
|
@@ -760,6 +826,84 @@ export class SharedPieceStore {
|
|
|
760
826
|
this.#wake();
|
|
761
827
|
}
|
|
762
828
|
|
|
829
|
+
/**
|
|
830
|
+
* Record how long one write to disk took.
|
|
831
|
+
*
|
|
832
|
+
* @param {number} durationMs
|
|
833
|
+
* @returns {void}
|
|
834
|
+
*/
|
|
835
|
+
#noteWriteDuration(durationMs) {
|
|
836
|
+
this.#writeDurations.push(Math.max(0, durationMs));
|
|
837
|
+
if (this.#writeDurations.length > WRITE_DURATION_SAMPLES) {
|
|
838
|
+
this.#writeDurations.shift();
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Record that a piece arrived, for the arrival rate. */
|
|
843
|
+
#noteArrival() {
|
|
844
|
+
const now = Date.now();
|
|
845
|
+
this.#admittedAt.push(now);
|
|
846
|
+
while (this.#admittedAt.length > ARRIVAL_SAMPLES
|
|
847
|
+
|| (this.#admittedAt.length > 0 && now - this.#admittedAt[0] > ARRIVAL_WINDOW_MS)) {
|
|
848
|
+
this.#admittedAt.shift();
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* How many pieces the store needs room for beyond what the readers ask for.
|
|
854
|
+
*
|
|
855
|
+
* Measured, not chosen: the pieces that arrive while one write to disk is
|
|
856
|
+
* finishing. Without this room every arrival must evict something, and each
|
|
857
|
+
* eviction holds its block until its write completes — so a disk slower than
|
|
858
|
+
* the swarm turns every admission into one more block held. On 2026-09-02
|
|
859
|
+
* that was 233 evictions against 119 completed writes in a minute, and 203
|
|
860
|
+
* blocks held with three pieces resident.
|
|
861
|
+
*
|
|
862
|
+
* Zero until both quantities have been seen, because a slack invented before
|
|
863
|
+
* anything is measured is a chosen number, and this store has been bitten by
|
|
864
|
+
* those.
|
|
865
|
+
*
|
|
866
|
+
* @returns {number} Pieces.
|
|
867
|
+
*/
|
|
868
|
+
slackPieces() {
|
|
869
|
+
if (this.#writeDurations.length === 0 || this.#admittedAt.length < 2) {
|
|
870
|
+
return 0;
|
|
871
|
+
}
|
|
872
|
+
const sorted = [...this.#writeDurations].sort((left, right) => left - right);
|
|
873
|
+
const writeMs = sorted[Math.floor(sorted.length / 2)];
|
|
874
|
+
const spanMs = this.#admittedAt[this.#admittedAt.length - 1] - this.#admittedAt[0];
|
|
875
|
+
if (!(spanMs > 0)) {
|
|
876
|
+
return 0;
|
|
877
|
+
}
|
|
878
|
+
const perMs = (this.#admittedAt.length - 1) / spanMs;
|
|
879
|
+
return Math.ceil(perMs * writeMs);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Blocks that are not free: resident pieces, blocks being written out, and
|
|
884
|
+
* blocks taken but not yet registered.
|
|
885
|
+
*
|
|
886
|
+
* This is what the ceiling has to bound, and until 2026-09-02 it bounded
|
|
887
|
+
* resident pieces instead. The difference is exactly the memory that goes
|
|
888
|
+
* missing from the count: a piece being spilled leaves `#buffers` the moment
|
|
889
|
+
* the eviction begins, while its block stays held until the write it feeds
|
|
890
|
+
* has finished.
|
|
891
|
+
*
|
|
892
|
+
* @returns {number}
|
|
893
|
+
*/
|
|
894
|
+
#blocksInUse() {
|
|
895
|
+
return this.#blocksAllocated - this.#freeBlocks.length;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Blocks held by writes that have not finished.
|
|
900
|
+
*
|
|
901
|
+
* @returns {number}
|
|
902
|
+
*/
|
|
903
|
+
#blocksInFlight() {
|
|
904
|
+
return Math.max(0, this.#blocksInUse() - this.#buffers.size);
|
|
905
|
+
}
|
|
906
|
+
|
|
763
907
|
/**
|
|
764
908
|
* Whether admitting one more piece would need something evicted first.
|
|
765
909
|
*
|
|
@@ -769,7 +913,7 @@ export class SharedPieceStore {
|
|
|
769
913
|
* @returns {boolean}
|
|
770
914
|
*/
|
|
771
915
|
#isFullNow() {
|
|
772
|
-
return this.#
|
|
916
|
+
return this.#blocksInUse() + this.#outstandingPieces >= this.#growthCeiling;
|
|
773
917
|
}
|
|
774
918
|
|
|
775
919
|
/**
|
|
@@ -859,12 +1003,39 @@ export class SharedPieceStore {
|
|
|
859
1003
|
|
|
860
1004
|
async #claimSlotOnce() {
|
|
861
1005
|
// Reserve before suspension so concurrent callers see the reservation.
|
|
862
|
-
if (this.#
|
|
1006
|
+
if (this.#blocksInUse() + this.#outstandingPieces < this.#growthCeiling) {
|
|
863
1007
|
this.#outstandingPieces += 1;
|
|
864
1008
|
this.#pinnedWaitStartedAt = 0;
|
|
865
1009
|
return true;
|
|
866
1010
|
}
|
|
867
1011
|
|
|
1012
|
+
// Full, and evicting would make it worse rather than better. A piece being
|
|
1013
|
+
// written out has already left `#buffers` while its block is still held —
|
|
1014
|
+
// the write reads from that block — so evicting another one converts a
|
|
1015
|
+
// resident block into an in-flight block and takes a fresh block for the
|
|
1016
|
+
// arrival: the memory in use goes UP by one per admission for as long as
|
|
1017
|
+
// the disk is behind. Field 2026-09-02: 233 evictions against 119 completed
|
|
1018
|
+
// writes in a minute, 203 blocks held with three pieces resident, and the
|
|
1019
|
+
// process killed at 4.37 GB.
|
|
1020
|
+
//
|
|
1021
|
+
// So when the disk is what the store is waiting for, it waits. A completing
|
|
1022
|
+
// write calls `#noteProgress`, which wakes whoever is here.
|
|
1023
|
+
if (this.#blocksInFlight() > 0 && this.#blocksInUse() >= this.#growthCeiling) {
|
|
1024
|
+
if (this.#pinnedWaitStartedAt === 0) {
|
|
1025
|
+
this.#pinnedWaitStartedAt = Date.now();
|
|
1026
|
+
}
|
|
1027
|
+
const stillFor = Date.now() - Math.max(this.#pinnedWaitStartedAt, this.#lastProgressAt);
|
|
1028
|
+
if (stillFor < PINNED_WAIT_MS) {
|
|
1029
|
+
this.#counters.waitedForDisk += 1;
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
// The disk has stopped answering. Falling through to eviction is the
|
|
1033
|
+
// lesser failure: it grows memory, and the line above says by how much,
|
|
1034
|
+
// where refusing would fail the read outright.
|
|
1035
|
+
this.#pinnedWaitStartedAt = 0;
|
|
1036
|
+
this.#counters.grewWaitingForDisk += 1;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
868
1039
|
const { index: victim, protectionYielded, distance } = this.#lru.evictionChoice();
|
|
869
1040
|
if (victim === null) {
|
|
870
1041
|
// Nothing may leave. Wait while the store is still MOVING — a spill
|
|
@@ -984,6 +1155,19 @@ export class SharedPieceStore {
|
|
|
984
1155
|
this.#noteReuseGap(Date.now() - spare.freedAt);
|
|
985
1156
|
return spare.buffer;
|
|
986
1157
|
}
|
|
1158
|
+
// Nothing spare and the pool is already as large as it is allowed to be.
|
|
1159
|
+
// This cannot happen while the accounting is sound: a block is taken only
|
|
1160
|
+
// after a slot has been claimed, and slots are exactly what the ceiling
|
|
1161
|
+
// counts. It is recorded rather than hidden because when it does happen the
|
|
1162
|
+
// pool grows without bound, and every block it then allocates is used once
|
|
1163
|
+
// and thrown to a collector that has no reason to run — the heap stays at
|
|
1164
|
+
// 50 MB of 2240 while the process reaches four gigabytes.
|
|
1165
|
+
// Strictly greater: reaching the ceiling exactly is what a full store looks
|
|
1166
|
+
// like, and a slot has just been claimed for this block. Being ALREADY past
|
|
1167
|
+
// it and allocating anyway is the state that runs away.
|
|
1168
|
+
if (this.#blocksAllocated > this.#growthCeiling) {
|
|
1169
|
+
this.#counters.blocksBeyondCeiling += 1;
|
|
1170
|
+
}
|
|
987
1171
|
this.#blocksAllocated += 1;
|
|
988
1172
|
return this.#watchForCollection(new SharedArrayBuffer(this.#chunkLength));
|
|
989
1173
|
}
|
|
@@ -1162,6 +1346,7 @@ export class SharedPieceStore {
|
|
|
1162
1346
|
return;
|
|
1163
1347
|
}
|
|
1164
1348
|
|
|
1349
|
+
this.#noteArrival();
|
|
1165
1350
|
const declared = this.#lru.wants(index);
|
|
1166
1351
|
if (declared) {
|
|
1167
1352
|
this.#counters.admittedInsideWindow += 1;
|
|
@@ -1169,12 +1354,30 @@ export class SharedPieceStore {
|
|
|
1169
1354
|
this.#counters.admittedOutsideWindow += 1;
|
|
1170
1355
|
}
|
|
1171
1356
|
|
|
1172
|
-
// A piece
|
|
1173
|
-
// straight to disk
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
|
|
1357
|
+
// A piece that will be wanted LATER than the one it would displace goes
|
|
1358
|
+
// straight to disk instead of being admitted.
|
|
1359
|
+
//
|
|
1360
|
+
// Two cases, and the second was missing until 2026-09-02. The first: the
|
|
1361
|
+
// arrival is in nobody's window at all. The second: it IS in somebody's
|
|
1362
|
+
// window, but further from every read head than the piece the store would
|
|
1363
|
+
// have to evict to make room for it — so admitting it would write out the
|
|
1364
|
+
// nearer piece and read it back sooner. The field session had six readers
|
|
1365
|
+
// whose windows covered the whole file, so the first case never applied
|
|
1366
|
+
// and `0 of those went straight to disk` while the store spilled 233
|
|
1367
|
+
// pieces in a minute.
|
|
1368
|
+
//
|
|
1369
|
+
// Only when SOMETHING is declared: before the first read there is no
|
|
1370
|
+
// basis for calling one piece more wanted than another.
|
|
1371
|
+
const worseThanTheVictim = () => {
|
|
1372
|
+
const arriving = this.#lru.waitFor(index);
|
|
1373
|
+
if (arriving < 0) {
|
|
1374
|
+
return false;
|
|
1375
|
+
}
|
|
1376
|
+
const victim = this.#lru.nextVictim();
|
|
1377
|
+
return victim.index !== null && victim.wait >= 0 && arriving > victim.wait;
|
|
1378
|
+
};
|
|
1379
|
+
if (this.#lru.protectedCount > 0 && this.#isFullNow()
|
|
1380
|
+
&& (!declared || worseThanTheVictim())) {
|
|
1178
1381
|
this.#counters.admittedToDisk += 1;
|
|
1179
1382
|
await this.#writeThrough(index, bytes);
|
|
1180
1383
|
this.#noteProgress();
|
|
@@ -502,6 +502,13 @@ export function createPlaybackPlanner({
|
|
|
502
502
|
if (offer && offer.copy.length === 0 && offer.transcode.length === 0) {
|
|
503
503
|
withOffer.cannotServe =
|
|
504
504
|
"This proxy cannot keep up with this file at any quality right now.";
|
|
505
|
+
// The description travels with the refusal, and only with it. It is what
|
|
506
|
+
// lets the browser ask the rest of the pool the same question without
|
|
507
|
+
// anybody else adding the torrent, fetching a byte or running ffmpeg —
|
|
508
|
+
// the expensive half of finding out what this file IS has been paid here,
|
|
509
|
+
// once. Everyone else answers by arithmetic against their own startup
|
|
510
|
+
// benchmarks.
|
|
511
|
+
withOffer.mediaInfoForOffer = plan.mediaInfoForOffer;
|
|
505
512
|
}
|
|
506
513
|
return withOffer;
|
|
507
514
|
}
|
|
@@ -5,8 +5,17 @@
|
|
|
5
5
|
* ASS/SSA (.ass/.ssa) to WebVTT so the browser can attach them to a `<track>`
|
|
6
6
|
* without any client-side conversion. The proxy owns subtitle conversion so it
|
|
7
7
|
* can also run language detection where the full text is available.
|
|
8
|
+
*
|
|
9
|
+
* Reading a format's own framing is NOT here — it is `SubtitleFileContainer`
|
|
10
|
+
* for a file beside the film, and `MatroskaContainer` / `Mp4Container` for a
|
|
11
|
+
* track inside it. What is here is everything after that: a cue's missing end
|
|
12
|
+
* time, its codec's markup, and writing the WebVTT document. One writer, so a
|
|
13
|
+
* pushed cue and a pulled one cannot read differently.
|
|
8
14
|
*/
|
|
9
15
|
|
|
16
|
+
import { SubtitleFileContainer } from "./container/SubtitleFileContainer.js";
|
|
17
|
+
import { plainCueText } from "./tracks/subtitle-markup.js";
|
|
18
|
+
|
|
10
19
|
/**
|
|
11
20
|
* Decode subtitle bytes to text. Prefers UTF-8 (honouring a BOM); if the UTF-8
|
|
12
21
|
* decode yields many replacement characters the bytes are re-decoded as
|
|
@@ -41,110 +50,95 @@ function stripBom(text) {
|
|
|
41
50
|
return typeof text === "string" && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
function srtTsToVtt(ts) {
|
|
45
|
-
return ts.replace(",", ".");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
53
|
/**
|
|
49
|
-
*
|
|
54
|
+
* One cue's start or end as WebVTT writes it: `hh:mm:ss.mmm`.
|
|
50
55
|
*
|
|
51
|
-
* @param {
|
|
56
|
+
* @param {number} seconds
|
|
52
57
|
* @returns {string}
|
|
53
58
|
*/
|
|
54
|
-
function
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
return out.join("\n");
|
|
59
|
+
export function vttTime(seconds) {
|
|
60
|
+
const safe = Math.max(0, Number(seconds) || 0);
|
|
61
|
+
const hours = Math.floor(safe / 3600);
|
|
62
|
+
const minutes = Math.floor((safe % 3600) / 60);
|
|
63
|
+
const rest = safe % 60;
|
|
64
|
+
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${rest.toFixed(3).padStart(6, "0")}`;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Resolve what a cue is missing and take its codec's markup off, so what is
|
|
69
|
+
* left is what a player shows.
|
|
70
|
+
*
|
|
71
|
+
* The container's framing is NOT undone here — it is undone where the cue is
|
|
72
|
+
* read, by the container that framed it, which is the only place the framing is
|
|
73
|
+
* known. Until 2.72.1 this function tried to do both by counting commas, and
|
|
74
|
+
* on an embedded ASS track it showed every field of the dialogue row to the
|
|
75
|
+
* viewer.
|
|
76
|
+
*
|
|
77
|
+
* A cue with no duration — a Matroska SimpleBlock, which subtitles rarely use —
|
|
78
|
+
* is given the time until the next one IN THIS LIST, and the last such cue a
|
|
79
|
+
* few seconds. Not an invention about the film: it is what a player does with
|
|
80
|
+
* an open-ended cue, made explicit so every consumer agrees on it.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ startSeconds: number, endSeconds?: number | null, text: string }[]} cues
|
|
83
|
+
* @param {string} codecId
|
|
84
|
+
* @returns {{ startSeconds: number, endSeconds: number, text: string }[]}
|
|
85
|
+
*/
|
|
86
|
+
export function finalizeCues(cues, codecId) {
|
|
87
|
+
const result = [];
|
|
88
|
+
(Array.isArray(cues) ? cues : []).forEach((cue, index) => {
|
|
89
|
+
const next = cues[index + 1];
|
|
90
|
+
const endSeconds = cue.endSeconds ?? (next ? next.startSeconds : cue.startSeconds + 4);
|
|
91
|
+
const text = plainCueText(cue.text, codecId);
|
|
92
|
+
if (!text) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
result.push({ startSeconds: cue.startSeconds, endSeconds, text });
|
|
96
|
+
});
|
|
97
|
+
return result;
|
|
80
98
|
}
|
|
81
99
|
|
|
82
100
|
/**
|
|
83
|
-
*
|
|
101
|
+
* A WebVTT document from a list of cues — the one writer, used by every path
|
|
102
|
+
* that produces subtitles: a file beside the film, a track inside it, a pull
|
|
103
|
+
* and a push.
|
|
84
104
|
*
|
|
85
|
-
* @param {string}
|
|
105
|
+
* @param {{ startSeconds: number, endSeconds?: number | null, text: string }[]} cues
|
|
106
|
+
* @param {string} codecId
|
|
86
107
|
* @returns {string}
|
|
87
108
|
*/
|
|
88
|
-
function
|
|
89
|
-
const lines =
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const trimmed = line.trim();
|
|
95
|
-
if (trimmed === "[Events]") {
|
|
96
|
-
inEvents = true;
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
if (trimmed.startsWith("[") && trimmed.endsWith("]") && inEvents) {
|
|
100
|
-
inEvents = false;
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
if (!inEvents) {
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
if (trimmed.startsWith("Format:")) {
|
|
107
|
-
formatCols = trimmed.slice("Format:".length).split(",").map((c) => c.trim().toLowerCase());
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
if (trimmed.startsWith("Dialogue:") && formatCols) {
|
|
111
|
-
const parts = trimmed.slice("Dialogue:".length).split(",");
|
|
112
|
-
const startIdx = formatCols.indexOf("start");
|
|
113
|
-
const endIdx = formatCols.indexOf("end");
|
|
114
|
-
const textIdx = formatCols.indexOf("text");
|
|
115
|
-
if (startIdx < 0 || endIdx < 0 || textIdx < 0) {
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
const cueText = stripAssTags(parts.slice(textIdx).join(","));
|
|
119
|
-
if (!cueText) {
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
cues.push(`${assTsToVtt((parts[startIdx] ?? "").trim())} --> ${assTsToVtt((parts[endIdx] ?? "").trim())}\n${cueText}`);
|
|
123
|
-
}
|
|
109
|
+
export function cuesToVtt(cues, codecId) {
|
|
110
|
+
const lines = ["WEBVTT", ""];
|
|
111
|
+
for (const cue of finalizeCues(cues, codecId)) {
|
|
112
|
+
lines.push(`${vttTime(cue.startSeconds)} --> ${vttTime(cue.endSeconds)}`);
|
|
113
|
+
lines.push(cue.text);
|
|
114
|
+
lines.push("");
|
|
124
115
|
}
|
|
125
|
-
return
|
|
116
|
+
return lines.join("\n");
|
|
126
117
|
}
|
|
127
118
|
|
|
128
119
|
/**
|
|
129
120
|
* Convert subtitle text to WebVTT by file extension. Returns null for formats
|
|
130
121
|
* that cannot be converted in-place (image-based .sup, ambiguous .sub, .ttml).
|
|
131
122
|
*
|
|
123
|
+
* The reading is `SubtitleFileContainer`'s: the file states how its own cues
|
|
124
|
+
* are framed — SubRip by position, ASS by the `Format:` line of `[Events]` —
|
|
125
|
+
* and that is a fact about the file, not about this conversion.
|
|
126
|
+
*
|
|
132
127
|
* @param {string} text
|
|
133
128
|
* @param {string} ext - Lowercase extension including the dot, e.g. ".srt".
|
|
134
129
|
* @returns {string | null}
|
|
135
130
|
*/
|
|
136
131
|
export function convertSubtitleToVtt(text, ext) {
|
|
137
132
|
const clean = stripBom(text);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return assToVtt(clean);
|
|
147
|
-
default:
|
|
148
|
-
return null;
|
|
133
|
+
const extension = String(ext ?? "").toLowerCase();
|
|
134
|
+
if (extension === ".vtt" || extension === ".webvtt") {
|
|
135
|
+
// Already what a browser reads. Parsing it to write it back would drop its
|
|
136
|
+
// styles, its regions and its cue identifiers for nothing.
|
|
137
|
+
return clean.trimStart().startsWith("WEBVTT") ? clean : `WEBVTT\n\n${clean}`;
|
|
138
|
+
}
|
|
139
|
+
if (!SubtitleFileContainer.detect(extension)) {
|
|
140
|
+
return null;
|
|
149
141
|
}
|
|
142
|
+
const cues = new SubtitleFileContainer({ extension }).readCues(clean);
|
|
143
|
+
return cues === null ? null : cuesToVtt(cues, extension);
|
|
150
144
|
}
|