@torrent-tv/proxy 2.55.4 → 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 +5 -0
- package/bin/cli.js +9 -2
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +9 -35
- package/server.js +4 -2
- package/services/data-channel-handler.js +85 -1
- package/services/torrent-worker/client.js +15 -2
- package/services/torrent-worker/protocol.js +8 -1
- package/services/torrent-worker/subtitle-cues.js +92 -11
- package/services/torrent-worker/worker.js +19 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
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
|
+
|
|
1
6
|
## 2.55.4
|
|
2
7
|
|
|
3
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.
|
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
|
-
|
|
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
|
@@ -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
|
-
*
|
|
330
|
-
*
|
|
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
|
|
341
|
-
|
|
342
|
-
|
|
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({
|
|
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 = {
|
|
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
|
|
322
|
-
*
|
|
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
|
|
326
|
-
* is deciding there is nothing new to read. It is
|
|
327
|
-
* of being asked, on the same state that call
|
|
328
|
-
* duplicated, and a
|
|
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<
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
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
|
/**
|
|
@@ -508,7 +508,9 @@ setInterval(() => {
|
|
|
508
508
|
}, STORE_REPORT_INTERVAL_MS).unref();
|
|
509
509
|
|
|
510
510
|
/**
|
|
511
|
-
* Walk subtitle cues for every actively-read file of one torrent
|
|
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.
|
|
512
514
|
*
|
|
513
515
|
* @param {string} sourceKey
|
|
514
516
|
* @param {object} torrent
|
|
@@ -520,9 +522,22 @@ function warmActiveFiles(sourceKey, torrent) {
|
|
|
520
522
|
return;
|
|
521
523
|
}
|
|
522
524
|
for (const fileIndex of usage.keys()) {
|
|
523
|
-
warmSubtitleCues(torrent, fileIndex, sourceKey)
|
|
524
|
-
|
|
525
|
-
|
|
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
|
+
});
|
|
526
541
|
}
|
|
527
542
|
}
|
|
528
543
|
|