@torrent-tv/proxy 2.55.3 → 2.55.5

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 CHANGED
@@ -1,4 +1,11 @@
1
- ## 2.55.3
1
+ ## 2.55.5
2
+
3
+ - **New**: Subtitle cues are now PUSHED to the browser the moment they are read, over the WebRTC data channel — not fetched by the browser on a timer. Every declared track was already being warmed off the piece-`verified` event (2.55.4); what changed is that the result now travels to the browser unprompted instead of sitting on the proxy until the next poll asked for it. `data-channel-handler.js` remembers which channel last asked about a file's subtitles (piggy-backing on the browser's own first `/api/subtitles?trackIndex=` request — no separate subscribe message) and sends new cues there directly (`{ type: "subtitle-cues", fileIndex, trackIndex, cues, language }`), for every track the container declares, not only the one on screen. Rides the existing `proxy-control` data channel — the same one the request itself used, which is never the one carrying segment bytes, so a push cannot queue behind video. `finalizeCues` (end-time synthesis + ASS-dialogue stripping) is factored out of the HTTP route into `services/torrent-worker/subtitle-cues.js` so a pushed cue and a pulled one are built the same way. A browser's one-off seed fetch per track (for whatever is already read at the moment a file opens) and the external-subtitle-FILE path (`.srt`/`.ass` beside the video — a single whole-file read, no incremental delivery to begin with) are unchanged.
4
+ - **Chore**: The push subscription only fires for an embedded-track request (`trackIndex` present) — an external subtitle file's request carries a different file's own index in `fileIndex` and was being registered under a key nothing ever publishes to. Harmless (the worker's plan for a non-container file is empty, so nothing was ever sent there), but pointless bookkeeping is still a bug waiting to be one.
5
+
6
+ ## 2.55.4
7
+
8
+ - **Fix**: A file's subtitle cues are now walked the moment a piece verifies, not on a 3 s poll. The poll (2.55.3) closed the worst of it but still left every new cluster waiting up to 3 s after its piece arrived, and "waiting" at all was the thing objected to — a cue's readiness must not depend on which of two independent timers happens to fire first. `torrent.on("verified", …)` is WebTorrent's own signal for exactly this instant, set in the same place the bitfield itself is (`_markVerified`), so the walk now runs off the same event that makes a piece a piece rather than off a schedule. The 3 s poll stays as a fallback — it only matters for a listener attached after some pieces already verified, or if a `verified` handler ever throws — so nothing that used to be caught can now be missed.
2
9
 
3
10
  - **Fix**: A track's subtitle cues are now walked ahead of being asked for, instead of only when a browser first requests them. `cuesHeldFor` only ever read clusters on demand, inside the `/api/subtitles` request itself — cheap once caught up, but the FIRST call for a track had to walk the whole backlog of already-downloaded-but-unparsed clusters serially, with no `pending`/streaming pattern the way the ffmpeg fallback has one. On a film well into playback that backlog is not small (`Minions.and.Monsters.1080p.mkv` indexes over a thousand cluster positions per track), so a viewer who turned subtitles on after watching for a while waited on that catch-up instead of seeing cues appear at once — the opposite of the rule this file states its own reason for existing ("the region the viewer is watching is downloaded before they reach it, so its cues are ready before they are needed"): true of the DATA, not of when it got READ. A new periodic pass in the torrent worker (`warmSubtitleCues`, every 3 s, one per actively-read file) walks new clusters as they arrive, reusing `cuesHeldFor`'s own memoized state — a file nobody has opened costs nothing, and a file being watched is caught up by the time a track is switched on.
4
11
 
package/bin/cli.js CHANGED
@@ -179,6 +179,9 @@ let udpPortMapper = null;
179
179
  /** @type {ReturnType<typeof createWebRtcManager> | null} */
180
180
  let webRtcManager = null;
181
181
 
182
+ /** @type {ReturnType<typeof createDataChannelHandler> | null} */
183
+ let dataChannelHandler = null;
184
+
182
185
  /** @type {import("../services/nat-classifier.js").NatClassification | null} Latest NAT classification (for WebRTC port prediction). */
183
186
  let natInfo = null;
184
187
 
@@ -289,7 +292,11 @@ try {
289
292
  maxDiskBytes,
290
293
  memoryBytes,
291
294
  segmentFormat: options.segmentFormat,
292
- stateDir: options.stateDir
295
+ stateDir: options.stateDir,
296
+ // Late-bound the same way `webRtcManager` is below: the torrent pool is
297
+ // built inside `startProxyServer`, before `dataChannelHandler` — the
298
+ // thing that actually owns a channel to push down — exists.
299
+ onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event)
293
300
  });
294
301
  app = started.app;
295
302
  actualPort = started.port;
@@ -411,7 +418,7 @@ try {
411
418
  onLog: (message) => logger.info(message)
412
419
  });
413
420
 
414
- const dataChannelHandler = createDataChannelHandler({
421
+ dataChannelHandler = createDataChannelHandler({
415
422
  proxyPort: actualPort,
416
423
  onLog: (message) => logger.info(message),
417
424
  // Lets a stuck send queue ask the transport what it is doing. Late-bound:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.55.3",
3
+ "version": "2.55.5",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -27,6 +27,7 @@
27
27
  import { spawn } from "node:child_process";
28
28
  import { convertSubtitleToVtt, decodeSubtitleBytes } from "../../../services/subtitle-convert.js";
29
29
  import { detectLanguage } from "../../../services/language-detect.js";
30
+ import { finalizeCues } from "../../../services/torrent-worker/subtitle-cues.js";
30
31
  import { logger } from "../../../utils/logger.js";
31
32
 
32
33
  // Safety cap: no embedded extraction may outlive this.
@@ -324,12 +325,10 @@ async function cuesFromDownloadedClusters(torrentPool, torrent, fileIndex, track
324
325
  }
325
326
 
326
327
  /**
327
- * WebVTT from cues read out of the container.
328
- *
329
- * A cue with no duration — a SimpleBlock, which subtitles rarely use — is given
330
- * the time until the next one, and the last such cue a few seconds. That is not
331
- * an invention about the film: it is what a player does with an open-ended cue,
332
- * made explicit here so the file is valid.
328
+ * WebVTT from cues read out of the container. The end-time synthesis and ASS
329
+ * stripping are shared with the push path — see `finalizeCues` in
330
+ * `services/torrent-worker/subtitle-cues.js` so a pulled cue and a pushed
331
+ * one read identically; this function only adds the WebVTT framing.
333
332
  *
334
333
  * @param {{ startSeconds: number, endSeconds: number | null, text: string }[]} cues
335
334
  * @param {string} codecId
@@ -337,39 +336,14 @@ async function cuesFromDownloadedClusters(torrentPool, torrent, fileIndex, track
337
336
  */
338
337
  function cuesToVtt(cues, codecId) {
339
338
  const lines = ["WEBVTT", ""];
340
- const isAss = codecId === "S_TEXT/ASS" || codecId === "S_TEXT/SSA";
341
- cues.forEach((cue, index) => {
342
- const next = cues[index + 1];
343
- const end = cue.endSeconds ?? (next ? next.startSeconds : cue.startSeconds + 4);
344
- const text = isAss ? assDialogueToText(cue.text) : cue.text.trim();
345
- if (!text) {
346
- return;
347
- }
348
- lines.push(`${vttTime(cue.startSeconds)} --> ${vttTime(end)}`);
349
- lines.push(text);
339
+ for (const cue of finalizeCues(cues, codecId)) {
340
+ lines.push(`${vttTime(cue.startSeconds)} --> ${vttTime(cue.endSeconds)}`);
341
+ lines.push(cue.text);
350
342
  lines.push("");
351
- });
343
+ }
352
344
  return lines.join("\n");
353
345
  }
354
346
 
355
- /**
356
- * The visible text of an ASS dialogue row.
357
- *
358
- * A block carries the fields after `Dialogue:` without their header — nine of
359
- * them, then the text, which itself holds override groups in braces.
360
- *
361
- * @param {string} raw
362
- * @returns {string}
363
- */
364
- function assDialogueToText(raw) {
365
- const fields = raw.split(",");
366
- const text = fields.length > 9 ? fields.slice(9).join(",") : raw;
367
- return text
368
- .replace(/\{[^}]*\}/g, "")
369
- .replace(/\\N/gi, "\n")
370
- .trim();
371
- }
372
-
373
347
  /**
374
348
  * A time in the form WebVTT requires.
375
349
  *
package/server.js CHANGED
@@ -80,7 +80,9 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
80
80
  * @param {ProxyServerOptions} options
81
81
  * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
82
82
  */
83
- export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat, stateDir }) {
83
+ export async function startProxyServer({
84
+ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat, stateDir, onSubtitleCues
85
+ }) {
84
86
  const app = Fastify({
85
87
  // No practical body-size limit — the proxy server is localhost-only and
86
88
  // receives torrent source payloads that may be arbitrarily large.
@@ -112,7 +114,7 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
112
114
  // idled. Serving a segment shared that thread, so reading an already-finished
113
115
  // 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
114
116
  // adapter keeps TorrentPool's interface, so nothing downstream changed.
115
- const torrentPool = new WorkerTorrentPool({ maxDiskBytes, memoryBytes });
117
+ const torrentPool = new WorkerTorrentPool({ maxDiskBytes, memoryBytes, onSubtitleCues });
116
118
  const selectedPort = await getPort({
117
119
  port: buildPortCandidates(port)
118
120
  });
@@ -19,7 +19,11 @@
19
19
  * { type: "response-start", requestId, status, headers } (JSON string)
20
20
  * { type: "response-error", requestId, error: string } (JSON string)
21
21
  * { type: "pong", id } (JSON string)
22
+ * { type: "subtitle-cues", fileIndex, trackIndex, cues, language } (JSON string)
22
23
  * ```
24
+ * The last one is unsolicited — sent the moment new cues are read from a
25
+ * file's already-downloaded pieces, to whichever channel last asked for that
26
+ * file's subtitles over `/api/subtitles`. Not a response to any `requestId`.
23
27
  *
24
28
  * Response bodies are sent as BINARY data-channel messages (not JSON), to
25
29
  * avoid the ~33% base64 overhead and the JSON encode/decode cost. Each binary
@@ -296,6 +300,66 @@ export function encodeFrame(idBytes, bytes, done) {
296
300
  }
297
301
 
298
302
  export function createDataChannelHandler({ proxyPort, onLog, getTransportSnapshot }) {
303
+ /**
304
+ * Channels currently interested in one file's subtitle cues, keyed by
305
+ * `sourceKey:fileIndex`. Populated the moment a browser asks for an
306
+ * embedded track — there is no separate subscribe message on the wire, the
307
+ * existing `/api/subtitles` request already says which file a viewer opened
308
+ * subtitles for. Pruned on channel close and, defensively, on a failed send.
309
+ *
310
+ * @type {Map<string, Set<DataChannel>>}
311
+ */
312
+ const subtitleSubscribers = new Map();
313
+
314
+ /**
315
+ * @param {string} sourceKey
316
+ * @param {number} fileIndex
317
+ * @param {DataChannel} channel
318
+ * @returns {void}
319
+ */
320
+ function subscribeSubtitles(sourceKey, fileIndex, channel) {
321
+ const key = `${sourceKey}:${fileIndex}`;
322
+ let set = subtitleSubscribers.get(key);
323
+ if (!set) {
324
+ set = new Set();
325
+ subtitleSubscribers.set(key, set);
326
+ }
327
+ set.add(channel);
328
+ }
329
+
330
+ /** @param {DataChannel} channel */
331
+ function unsubscribeSubtitlesAll(channel) {
332
+ for (const set of subtitleSubscribers.values()) {
333
+ set.delete(channel);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Send new cues to every channel watching this file — the push side of
339
+ * subtitles arriving as they download rather than being polled for. Cues
340
+ * are tiny (kilobytes at most for a whole track), so this is one message,
341
+ * not a stream.
342
+ *
343
+ * @param {{ sourceKey: string, fileIndex: number, trackIndex: number, cues: object[], language: string }} event
344
+ * @returns {void}
345
+ */
346
+ function publishSubtitleCues({ sourceKey, fileIndex, trackIndex, cues, language }) {
347
+ const set = subtitleSubscribers.get(`${sourceKey}:${fileIndex}`);
348
+ if (!set || set.size === 0) {
349
+ return;
350
+ }
351
+ const message = { type: "subtitle-cues", fileIndex, trackIndex, cues, language };
352
+ for (const channel of set) {
353
+ try {
354
+ channel.sendMessage(JSON.stringify(message));
355
+ } catch {
356
+ // Closed between the subscription and this send; onClosed will not
357
+ // fire for a channel that is already gone, so drop it here too.
358
+ set.delete(channel);
359
+ }
360
+ }
361
+ }
362
+
299
363
  /** Request id → its ASCII bytes; see {@link requestIdBytes}. */
300
364
  const requestIdCache = new Map();
301
365
 
@@ -460,6 +524,7 @@ export function createDataChannelHandler({ proxyPort, onLog, getTransportSnapsho
460
524
  clearTimeout(entry.timer);
461
525
  }
462
526
  partials.clear();
527
+ unsubscribeSubtitlesAll(channel);
463
528
  log(`[dc] Session ${tag}: channel closed`);
464
529
  });
465
530
 
@@ -490,6 +555,25 @@ export function createDataChannelHandler({ proxyPort, onLog, getTransportSnapsho
490
555
  return;
491
556
  }
492
557
 
558
+ // Piggy-backs on the browser's own request for an EMBEDDED track — no
559
+ // separate subscribe message. `trackIndex` is what tells the two request
560
+ // shapes apart: an external subtitle FILE (no trackIndex) names a
561
+ // different file's own index in `fileIndex` — the subtitle file's, not the
562
+ // video's — and subscribing under that would just be a key nothing ever
563
+ // publishes to (an external file is one whole-file read, not something
564
+ // this walks incrementally). `fileIndex` alone would also scope this to
565
+ // the wrong grain for the real case — a torrent can carry several playable
566
+ // files — so the pair is what a push is ever addressed to.
567
+ if (path === "/api/subtitles" && typeof query === "string") {
568
+ const params = new URLSearchParams(query);
569
+ const sourceKey = params.get("sourceKey");
570
+ const fileIndex = Number(params.get("fileIndex"));
571
+ const hasTrackIndex = params.get("trackIndex") !== null && params.get("trackIndex") !== "";
572
+ if (sourceKey && Number.isInteger(fileIndex) && hasTrackIndex) {
573
+ subscribeSubtitles(sourceKey, fileIndex, channel);
574
+ }
575
+ }
576
+
493
577
  const queryInfo = query ? `?${query}` : "";
494
578
  const bodyInfo =
495
579
  body != null && typeof body === "string" && body.length > 0
@@ -698,7 +782,7 @@ export function createDataChannelHandler({ proxyPort, onLog, getTransportSnapsho
698
782
  }
699
783
  }
700
784
 
701
- return { handleChannel };
785
+ return { handleChannel, publishSubtitleCues };
702
786
  }
703
787
 
704
788
  /**
@@ -67,14 +67,18 @@ export class TorrentWorkerClient {
67
67
  /** Reads consuming fragments in place, keyed by request id. */
68
68
  #fragmentReaders = new Map();
69
69
 
70
+ /** @type {(event: { sourceKey: string, fileIndex: number, trackIndex: number, cues: object[], language: string }) => void} */
71
+ #onSubtitleCues;
72
+
70
73
  /**
71
- * @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
74
+ * @param {{ maxDiskBytes?: number, memoryBytes?: number, onSubtitleCues?: (event: object) => void }} [options]
72
75
  */
73
- constructor({ maxDiskBytes, memoryBytes } = {}) {
76
+ constructor({ maxDiskBytes, memoryBytes, onSubtitleCues } = {}) {
74
77
  this.#worker = new Worker(fileURLToPath(WORKER_URL), {
75
78
  workerData: { maxDiskBytes, memoryBytes }
76
79
  });
77
80
  this.#caller = createCaller(this.#worker);
81
+ this.#onSubtitleCues = onSubtitleCues ?? (() => undefined);
78
82
 
79
83
  this.#worker.on("message", (message) => {
80
84
  // A failed read must fail its stream. This is checked BEFORE the caller
@@ -184,6 +188,15 @@ export class TorrentWorkerClient {
184
188
  case Event.LOG:
185
189
  logger.info(`torrent-worker: ${message.message}`);
186
190
  break;
191
+ case Event.SUBTITLE_CUES_READY:
192
+ this.#onSubtitleCues({
193
+ sourceKey: message.sourceKey,
194
+ fileIndex: message.fileIndex,
195
+ trackIndex: message.trackIndex,
196
+ cues: message.cues,
197
+ language: message.language
198
+ });
199
+ break;
187
200
  default:
188
201
  break;
189
202
  }
@@ -103,7 +103,14 @@ export const Event = {
103
103
  /** The main thread consumed a chunk — see {@link STREAM_HIGH_WATER_CHUNKS}. */
104
104
  CHUNK_ACK: "chunk-ack",
105
105
  /** A log line, so worker output reaches the same place as everything else. */
106
- LOG: "log"
106
+ LOG: "log",
107
+ /**
108
+ * New subtitle cues were read for one track, unprompted — the worker found
109
+ * them off its own `verified`-piece walk, not in answer to a
110
+ * {@link Command.SUBTITLE_CUES} call. Lets the main thread PUSH them to
111
+ * whichever browser is watching instead of waiting to be asked.
112
+ */
113
+ SUBTITLE_CUES_READY: "subtitle-cues-ready"
107
114
  };
108
115
 
109
116
  /**
@@ -99,7 +99,17 @@ function readHeld(file, start, end) {
99
99
  async function planFor(torrent, fileIndex, key) {
100
100
  let state = byFile.get(key);
101
101
  if (!state) {
102
- state = { plan: null, harvested: new Map(), cues: new Map(), seq: new Map(), walked: new Set() };
102
+ state = {
103
+ plan: null,
104
+ harvested: new Map(),
105
+ cues: new Map(),
106
+ seq: new Map(),
107
+ walked: new Set(),
108
+ // The found-order cursor of the last cue PUSHED for each track, so a
109
+ // second warmup pass sends only what a first one did not — the same
110
+ // found-order idea `?since=` uses for a browser's own pull.
111
+ pushed: new Map()
112
+ };
103
113
  byFile.set(key, state);
104
114
  }
105
115
  if (state.plan !== null) {
@@ -318,25 +328,96 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
318
328
 
319
329
  /**
320
330
  * Walk whatever clusters have newly arrived, for every text track a file
321
- * carries so the browser's first request for a track finds its cues already
322
- * caught up instead of paying for the whole backlog inside that request.
331
+ * carries, and report what is new since the last call so the cues can be
332
+ * PUSHED to a browser rather than left for it to come back and ask.
323
333
  *
324
334
  * `cuesHeldFor` already skips positions it has walked before (`state.walked`),
325
- * so calling this on a timer is cheap once a file is caught up: the only cost
326
- * is deciding there is nothing new to read. It is `getSubtitleCues` run ahead
327
- * of being asked, on the same state that call itself would build — nothing is
328
- * duplicated, and a browser that never asks costs nothing beyond this.
335
+ * so calling this on a timer or on every verified piece is cheap once a file
336
+ * is caught up: the only cost is deciding there is nothing new to read. It is
337
+ * `getSubtitleCues` run ahead of being asked, on the same state that call
338
+ * itself would build — nothing is duplicated, and a file nobody has opened
339
+ * costs nothing beyond this.
329
340
  *
330
341
  * @param {object} torrent
331
342
  * @param {number} fileIndex
332
343
  * @param {string} sourceKey
333
- * @returns {Promise<void>}
344
+ * @returns {Promise<{ trackIndex: number, cues: object[], language: string }[]>}
345
+ * One entry per track that gained at least one cue since the last call.
346
+ * `trackIndex` is the track's position among `plan.tracks` — the same
347
+ * indexing `/api/subtitles?trackIndex=` and the browser's menu use, NOT the
348
+ * container's own track number, which the browser never sees.
334
349
  */
335
350
  export async function warmSubtitleCues(torrent, fileIndex, sourceKey) {
336
- const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
337
- for (const track of plan?.tracks ?? []) {
338
- await cuesHeldFor(torrent, fileIndex, sourceKey, track.trackNumber);
351
+ const key = `${sourceKey}:${fileIndex}`;
352
+ const plan = await planFor(torrent, fileIndex, key);
353
+ const state = byFile.get(key);
354
+ const fresh = [];
355
+ const tracks = plan?.tracks ?? [];
356
+ for (let trackIndex = 0; trackIndex < tracks.length; trackIndex += 1) {
357
+ const track = tracks[trackIndex];
358
+ const held = await cuesHeldFor(torrent, fileIndex, sourceKey, track.trackNumber);
359
+ const since = state.pushed.get(track.trackNumber) ?? 0;
360
+ const newCues = held.cues.filter((cue) => (Number(cue.seq) || 0) > since);
361
+ if (newCues.length === 0) {
362
+ continue;
363
+ }
364
+ const highest = newCues.reduce((max, cue) => Math.max(max, Number(cue.seq) || 0), since);
365
+ state.pushed.set(track.trackNumber, highest);
366
+ fresh.push({
367
+ trackIndex,
368
+ cues: finalizeCues(newCues, held.track?.codecId ?? track.codecId),
369
+ language: held.track?.language ?? ""
370
+ });
339
371
  }
372
+ return fresh;
373
+ }
374
+
375
+ /**
376
+ * Resolve a cue's end time and strip codec-specific formatting, turning a raw
377
+ * block-derived cue into text a player can show directly. The same step
378
+ * `routes/api/subtitles/get.js` applies when building WebVTT for a pull —
379
+ * factored out here so a pushed cue and a pulled one read identically.
380
+ *
381
+ * A cue with no duration — a SimpleBlock, which subtitles rarely use — is
382
+ * given the time until the next one IN THIS LIST, and the last such cue a few
383
+ * seconds. Not an invention about the film: it is what a player does with an
384
+ * open-ended cue, made explicit so every consumer agrees on it.
385
+ *
386
+ * @param {{ startSeconds: number, endSeconds: number | null, text: string }[]} cues
387
+ * @param {string} codecId
388
+ * @returns {{ startSeconds: number, endSeconds: number, text: string }[]}
389
+ */
390
+ export function finalizeCues(cues, codecId) {
391
+ const isAss = codecId === "S_TEXT/ASS" || codecId === "S_TEXT/SSA";
392
+ const result = [];
393
+ cues.forEach((cue, index) => {
394
+ const next = cues[index + 1];
395
+ const endSeconds = cue.endSeconds ?? (next ? next.startSeconds : cue.startSeconds + 4);
396
+ const text = isAss ? assDialogueToText(cue.text) : cue.text.trim();
397
+ if (!text) {
398
+ return;
399
+ }
400
+ result.push({ startSeconds: cue.startSeconds, endSeconds, text });
401
+ });
402
+ return result;
403
+ }
404
+
405
+ /**
406
+ * The visible text of an ASS dialogue row.
407
+ *
408
+ * A block carries the fields after `Dialogue:` without their header — nine of
409
+ * them, then the text, which itself holds override groups in braces.
410
+ *
411
+ * @param {string} raw
412
+ * @returns {string}
413
+ */
414
+ function assDialogueToText(raw) {
415
+ const fields = raw.split(",");
416
+ const text = fields.length > 9 ? fields.slice(9).join(",") : raw;
417
+ return text
418
+ .replace(/\{[^}]*\}/g, "")
419
+ .replace(/\\N/gi, "\n")
420
+ .trim();
340
421
  }
341
422
 
342
423
  /**
@@ -507,25 +507,82 @@ setInterval(() => {
507
507
  }
508
508
  }, STORE_REPORT_INTERVAL_MS).unref();
509
509
 
510
+ /**
511
+ * Walk subtitle cues for every actively-read file of one torrent, and PUSH
512
+ * whatever came out new to the main thread — which is what makes a browser's
513
+ * copy current without it having asked.
514
+ *
515
+ * @param {string} sourceKey
516
+ * @param {object} torrent
517
+ * @returns {void}
518
+ */
519
+ function warmActiveFiles(sourceKey, torrent) {
520
+ const usage = pool.fileUsageByTorrent.get(torrent);
521
+ if (!usage) {
522
+ return;
523
+ }
524
+ for (const fileIndex of usage.keys()) {
525
+ warmSubtitleCues(torrent, fileIndex, sourceKey)
526
+ .then((fresh) => {
527
+ for (const entry of fresh) {
528
+ parentPort.postMessage({
529
+ type: Event.SUBTITLE_CUES_READY,
530
+ sourceKey,
531
+ fileIndex,
532
+ trackIndex: entry.trackIndex,
533
+ cues: entry.cues,
534
+ language: entry.language
535
+ });
536
+ }
537
+ })
538
+ .catch((error) => {
539
+ log(`subtitle warmup ${sourceKey}:${fileIndex} failed: ${error instanceof Error ? error.message : error}`);
540
+ });
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Torrents already wired to warm their subtitle cues the moment a piece
546
+ * verifies, so the same torrent is not listened to twice.
547
+ *
548
+ * @type {WeakSet<object>}
549
+ */
550
+ const subtitleWarmupWired = new WeakSet();
551
+
552
+ /**
553
+ * A piece becoming readable is the actual event a cue can be pulled from —
554
+ * "downloaded", not "about to be encoded or copied": what a viewer reaches is
555
+ * decided by the read window ahead of the playhead, not by which of the two
556
+ * paths a segment takes, and the piece exists (and is worth reading for
557
+ * subtitles) whichever one that is. `verified` is WebTorrent's own signal for
558
+ * exactly that instant, set at the same place the bitfield itself is (`
559
+ * _markVerified`), so nothing here is guessing at readiness a different way.
560
+ *
561
+ * @param {string} sourceKey
562
+ * @param {object} torrent
563
+ * @returns {void}
564
+ */
565
+ function ensureSubtitleWarmupWired(sourceKey, torrent) {
566
+ if (subtitleWarmupWired.has(torrent)) {
567
+ return;
568
+ }
569
+ subtitleWarmupWired.add(torrent);
570
+ torrent.on("verified", () => warmActiveFiles(sourceKey, torrent));
571
+ }
572
+
510
573
  /**
511
574
  * How often an actively-read file's subtitle cues are walked ahead of being
512
- * asked for. Cheap once caught up (`warmSubtitleCues` skips clusters it has
513
- * already read), so this can run often; every read.js pass is one it does not
514
- * have to do.
575
+ * asked for, as a fallback beside the per-piece `verified` listener above
576
+ * catches a listener attached after pieces already verified, and anything the
577
+ * event path might otherwise miss. Cheap once caught up (`warmSubtitleCues`
578
+ * skips clusters it has already read).
515
579
  */
516
580
  const SUBTITLE_WARMUP_INTERVAL_MS = 3_000;
517
581
 
518
582
  setInterval(() => {
519
583
  for (const [sourceKey, torrent] of pool.torrents) {
520
- const usage = pool.fileUsageByTorrent.get(torrent);
521
- if (!usage) {
522
- continue;
523
- }
524
- for (const fileIndex of usage.keys()) {
525
- warmSubtitleCues(torrent, fileIndex, sourceKey).catch((error) => {
526
- log(`subtitle warmup ${sourceKey}:${fileIndex} failed: ${error instanceof Error ? error.message : error}`);
527
- });
528
- }
584
+ ensureSubtitleWarmupWired(sourceKey, torrent);
585
+ warmActiveFiles(sourceKey, torrent);
529
586
  }
530
587
  }, SUBTITLE_WARMUP_INTERVAL_MS).unref();
531
588