@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.
@@ -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
- return pieces * this.#chunkLength;
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.#buffers.size + this.#outstandingPieces >= this.#growthCeiling;
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.#buffers.size + this.#outstandingPieces < this.#growthCeiling) {
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 no reader has declared, arriving at a store with no room, goes
1173
- // straight to disk. It costs the same one write it would have cost when
1174
- // the next arrival evicted it, and it saves pushing out a piece a reader
1175
- // is about to read. Only when SOMETHING is declared: before the first
1176
- // read there is no basis for calling a piece unwanted.
1177
- if (!declared && this.#lru.protectedCount > 0 && this.#isFullNow()) {
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
- * Convert SubRip (.srt) text to WebVTT.
54
+ * One cue's start or end as WebVTT writes it: `hh:mm:ss.mmm`.
50
55
  *
51
- * @param {string} text
56
+ * @param {number} seconds
52
57
  * @returns {string}
53
58
  */
54
- function srtToVtt(text) {
55
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
56
- const out = ["WEBVTT", ""];
57
- for (const line of lines) {
58
- const m = line.match(/^(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})(.*)?$/);
59
- out.push(m ? `${srtTsToVtt(m[1])} --> ${srtTsToVtt(m[2])}${m[3] ?? ""}` : line);
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
- function assTsToVtt(ts) {
65
- const m = ts.match(/^(\d+):(\d{2}):(\d{2})\.(\d{2})$/);
66
- if (!m) {
67
- return "00:00:00.000";
68
- }
69
- const ms = (parseInt(m[4], 10) * 10).toString().padStart(3, "0");
70
- return `${m[1].padStart(2, "0")}:${m[2]}:${m[3]}.${ms}`;
71
- }
72
-
73
- function stripAssTags(text) {
74
- return text
75
- .replace(/\{[^}]*\}/g, "")
76
- .replace(/\\N/g, "\n")
77
- .replace(/\\n/g, "\n")
78
- .replace(/\\h/g, " ")
79
- .trim();
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
- * Convert ASS/SSA text to WebVTT (only the [Events] section; styling dropped).
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} text
105
+ * @param {{ startSeconds: number, endSeconds?: number | null, text: string }[]} cues
106
+ * @param {string} codecId
86
107
  * @returns {string}
87
108
  */
88
- function assToVtt(text) {
89
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
90
- let inEvents = false;
91
- let formatCols = null;
92
- const cues = [];
93
- for (const line of lines) {
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 cues.length === 0 ? "WEBVTT\n" : `WEBVTT\n\n${cues.join("\n\n")}`;
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
- switch (ext) {
139
- case ".vtt":
140
- case ".webvtt":
141
- return clean.trimStart().startsWith("WEBVTT") ? clean : `WEBVTT\n\n${clean}`;
142
- case ".srt":
143
- return srtToVtt(clean);
144
- case ".ass":
145
- case ".ssa":
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
  }