@torrent-tv/proxy 2.72.2 → 2.73.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 +16 -0
- package/docs/container-architecture.md +66 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +19 -0
- package/routes/stream/get.js +18 -1
- package/server.js +25 -1
- package/services/container/AviContainer.js +36 -0
- package/services/container/Container.js +41 -0
- package/services/container/MatroskaContainer.js +186 -1
- package/services/container/Mp4Container.js +158 -29
- package/services/container-index/ebml-reader.js +30 -0
- package/services/controllers/SubtitleController.js +1 -1
- package/services/hls-session-manager.js +167 -37
- package/services/orchestrators/ContainerOrchestrator.js +22 -0
- package/services/orchestrators/SubtitleOrchestrator.js +64 -5
- package/services/torrent-pool.js +46 -0
- package/services/torrent-worker/client.js +21 -0
- package/services/torrent-worker/container-tracks.js +134 -0
- package/services/torrent-worker/fastest-wires.js +29 -6
- package/services/torrent-worker/piece-reader.js +11 -4
- package/services/torrent-worker/pool-adapter.js +35 -0
- package/services/torrent-worker/protocol.js +12 -0
- package/services/torrent-worker/subtitle-cues.js +15 -0
- package/services/torrent-worker/worker.js +45 -1
- package/test/container-media-info.test.js +228 -0
- package/test/resume-warm.test.js +39 -0
- package/test/subtitle-cue-source.test.js +104 -0
- package/test/tail-duplication.test.js +48 -4
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
|
|
14
14
|
import { containerOrchestrator } from "./ContainerOrchestrator.js";
|
|
15
15
|
import {
|
|
16
|
-
cuesHeldFor as domainCuesHeldFor,
|
|
17
16
|
warmSubtitleCues as domainWarm,
|
|
18
17
|
subtitleTracksOf,
|
|
19
18
|
declaredSubtitleTracksOf,
|
|
@@ -65,18 +64,78 @@ export class SubtitleOrchestrator {
|
|
|
65
64
|
}
|
|
66
65
|
|
|
67
66
|
/**
|
|
68
|
-
* Cues already downloaded for one track
|
|
67
|
+
* Cues already downloaded for one track — ASKED OF THE TORRENT WORKER, never
|
|
68
|
+
* walked here.
|
|
69
|
+
*
|
|
70
|
+
* The walk decides what it may read from `torrent.bitfield` and
|
|
71
|
+
* `torrent.pieceLength`, and a torrent stand-in on the main thread has
|
|
72
|
+
* neither: it carries `infoHash`, `name` and a `files` list whose reads go
|
|
73
|
+
* back across the boundary (`torrent-worker/client.js`). So the same code
|
|
74
|
+
* called here answers that nothing is downloaded, walks no clusters, and
|
|
75
|
+
* returns an empty document — which is not a failure anything reports,
|
|
76
|
+
* because "no cues held" is a legitimate answer.
|
|
77
|
+
*
|
|
78
|
+
* Field 2026-09-03, and it is the whole reason this method changed. An
|
|
79
|
+
* episode already downloaded from an earlier sitting had its cues found
|
|
80
|
+
* within a second and a half of the file being opened, and the first four
|
|
81
|
+
* pushes — everything before 81.7 s — went out before the browser had
|
|
82
|
+
* subscribed. The catch-up pull that exists for exactly that case answered
|
|
83
|
+
* `WEBVTT` and nothing else, with `x-subtitle-covered-clusters: 0` against
|
|
84
|
+
* 283 indexed, so the viewer watched the opening of the episode with no
|
|
85
|
+
* subtitles and the rest of it with them.
|
|
86
|
+
*
|
|
87
|
+
* There is a second reason, independent of the bitfield. The register of what
|
|
88
|
+
* has been walked, what has been found and in what ORDER lives in the module
|
|
89
|
+
* that does the walking, and the worker already keeps one — the push path
|
|
90
|
+
* fills it. Walking again on the main thread would build a SECOND register
|
|
91
|
+
* with its own `seq` counter, and the browser mixes the cursors from both
|
|
92
|
+
* paths (`#rememberCursor`): two counters would make a cursor from a pull and
|
|
93
|
+
* a cursor from a push incomparable. One register, one walk, one cursor.
|
|
94
|
+
*
|
|
95
|
+
* @param {{ getSubtitleCues?: Function }} pool - The torrent pool, which is
|
|
96
|
+
* what holds the channel to the worker.
|
|
69
97
|
* @param {object} torrent
|
|
70
98
|
* @param {number} fileIndex
|
|
71
99
|
* @param {string} sourceKey
|
|
72
100
|
* @param {number} trackNumber - Container trackNumber
|
|
101
|
+
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
73
102
|
*/
|
|
74
|
-
async getCues(torrent, fileIndex, sourceKey, trackNumber) {
|
|
103
|
+
async getCues(pool, torrent, fileIndex, sourceKey, trackNumber) {
|
|
104
|
+
// Built fresh on each of the three paths that need it. One shared literal
|
|
105
|
+
// returned by reference would hand every caller the same array, and a
|
|
106
|
+
// single one of them appending to it would change what the next caller
|
|
107
|
+
// reads — which in a method about cue registers not leaking into each
|
|
108
|
+
// other would be a poor thing to introduce.
|
|
109
|
+
const empty = () => ({ cues: [], coveredClusters: 0, indexedClusters: 0, track: null });
|
|
110
|
+
if (typeof pool?.getSubtitleCues !== "function") {
|
|
111
|
+
// Nothing here can read pieces, and answering an empty document would be
|
|
112
|
+
// indistinguishable from a file that genuinely holds no cues.
|
|
113
|
+
logger.warn(
|
|
114
|
+
"subtitle-orchestrator: the torrent pool cannot be asked for cues, " +
|
|
115
|
+
"so none can be served — the walk needs the thread that owns the torrent"
|
|
116
|
+
);
|
|
117
|
+
return empty();
|
|
118
|
+
}
|
|
75
119
|
try {
|
|
76
|
-
|
|
120
|
+
const answer = await pool.getSubtitleCues(torrent, fileIndex, trackNumber);
|
|
121
|
+
if (!answer) {
|
|
122
|
+
return empty();
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
cues: Array.isArray(answer.cues) ? answer.cues : [],
|
|
126
|
+
coveredClusters: answer.coveredClusters ?? 0,
|
|
127
|
+
indexedClusters: answer.indexedClusters ?? 0,
|
|
128
|
+
// The worker answers with the track's own fields flat, because a
|
|
129
|
+
// `ContainerTrack` is a class and only plain objects cross the boundary.
|
|
130
|
+
track: {
|
|
131
|
+
codecId: answer.codecId ?? "",
|
|
132
|
+
codecPrivate: answer.codecPrivate ?? "",
|
|
133
|
+
language: answer.language ?? ""
|
|
134
|
+
}
|
|
135
|
+
};
|
|
77
136
|
} catch (e) {
|
|
78
137
|
logger.warn(`subtitle-orchestrator: getCues failed: ${e?.message ?? e}`);
|
|
79
|
-
return
|
|
138
|
+
return empty();
|
|
80
139
|
}
|
|
81
140
|
}
|
|
82
141
|
|
package/services/torrent-pool.js
CHANGED
|
@@ -1800,6 +1800,52 @@ export class TorrentPool {
|
|
|
1800
1800
|
}
|
|
1801
1801
|
}
|
|
1802
1802
|
|
|
1803
|
+
/**
|
|
1804
|
+
* Fetch a bounded region in the MIDDLE of a file.
|
|
1805
|
+
*
|
|
1806
|
+
* The warm-up fetches a file's two edges because the codec probe reads them.
|
|
1807
|
+
* A viewer resuming a film needs neither: they need the region under their own
|
|
1808
|
+
* position, and until now nothing asked for it before the encoder did. Field
|
|
1809
|
+
* 2026-09-03 — a retry after a crash reached the encoder 53 s after the button
|
|
1810
|
+
* was pressed, and only THEN was the piece under the viewer's position first
|
|
1811
|
+
* requested; it took another 46 s, and the browser gave up 0.4 s before it
|
|
1812
|
+
* landed.
|
|
1813
|
+
*
|
|
1814
|
+
* Read as an ordinary bounded read, never as a selection: claiming a whole
|
|
1815
|
+
* region alongside the readers' own windows is the mistake `#syncSelections`
|
|
1816
|
+
* was written against.
|
|
1817
|
+
*
|
|
1818
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
1819
|
+
* @param {number} fileIndex
|
|
1820
|
+
* @param {number} startByte
|
|
1821
|
+
* @param {number} bytes
|
|
1822
|
+
* @param {{ timeoutMs?: number }} [options]
|
|
1823
|
+
* @returns {Promise<void>}
|
|
1824
|
+
*/
|
|
1825
|
+
async prefetchFileRegion(torrent, fileIndex, startByte, bytes, { timeoutMs = 300_000 } = {}) {
|
|
1826
|
+
const file = torrent?.files?.[fileIndex];
|
|
1827
|
+
if (!file || typeof file.createReadStream !== "function") {
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
const fileSize = file.length;
|
|
1831
|
+
if (!Number.isFinite(fileSize) || fileSize <= 0 || !(bytes > 0)) {
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
const start = Math.max(0, Math.min(Math.floor(startByte), fileSize - 1));
|
|
1835
|
+
const end = Math.min(fileSize - 1, start + Math.floor(bytes) - 1);
|
|
1836
|
+
if (end <= start) {
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
const drained = new Promise((resolve) => {
|
|
1840
|
+
const stream = file.createReadStream({ start, end });
|
|
1841
|
+
stream.on("data", () => undefined);
|
|
1842
|
+
stream.once("end", resolve);
|
|
1843
|
+
stream.once("error", resolve);
|
|
1844
|
+
stream.once("close", resolve);
|
|
1845
|
+
});
|
|
1846
|
+
await Promise.race([drained, new Promise((resolve) => setTimeout(resolve, timeoutMs))]);
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1803
1849
|
/**
|
|
1804
1850
|
* The body of {@link prefetchFileEdges}, without the de-duplication.
|
|
1805
1851
|
*
|
|
@@ -349,6 +349,27 @@ export class TorrentWorkerClient {
|
|
|
349
349
|
return this.#caller.call(Command.CONTAINER_TRACKS, { sourceKey, fileIndex });
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
+
/**
|
|
353
|
+
* What one file declares about itself — format, duration, and where its own
|
|
354
|
+
* timeline begins.
|
|
355
|
+
*
|
|
356
|
+
* @param {{ sourceKey: string, fileIndex: number }} params
|
|
357
|
+
* @returns {Promise<{ info: import("../container/Container.js").ContainerMediaInfo | null }>}
|
|
358
|
+
*/
|
|
359
|
+
async getContainerMediaInfo({ sourceKey, fileIndex }) {
|
|
360
|
+
return this.#caller.call(Command.CONTAINER_MEDIA_INFO, { sourceKey, fileIndex });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Start fetching the region a viewer is about to resume at.
|
|
365
|
+
*
|
|
366
|
+
* @param {{ sourceKey: string, fileIndex: number, positionSeconds: number }} params
|
|
367
|
+
* @returns {Promise<{ started: boolean }>}
|
|
368
|
+
*/
|
|
369
|
+
async warmResumePosition({ sourceKey, fileIndex, positionSeconds }) {
|
|
370
|
+
return this.#caller.call(Command.WARM_POSITION, { sourceKey, fileIndex, positionSeconds });
|
|
371
|
+
}
|
|
372
|
+
|
|
352
373
|
/**
|
|
353
374
|
* The cues of one subtitle track that can be read from what is downloaded.
|
|
354
375
|
*
|
|
@@ -224,6 +224,140 @@ export async function containerAudioTracksOf(torrent, fileIndex, sourceKey, opti
|
|
|
224
224
|
/** Bytes of a file's head worth fetching before its track table is read. */
|
|
225
225
|
export const CONTAINER_HEAD_BYTES = HEAD_BYTES;
|
|
226
226
|
|
|
227
|
+
/**
|
|
228
|
+
* How much of the file to pull in under the viewer's resume position.
|
|
229
|
+
*
|
|
230
|
+
* One piece of a video torrent is 4-16 MB and a resume lands anywhere inside
|
|
231
|
+
* one, so anything smaller would still leave the encoder waiting for the piece
|
|
232
|
+
* it starts in. Eight megabytes covers that piece and usually the next.
|
|
233
|
+
*/
|
|
234
|
+
const RESUME_REGION_BYTES = 8 * 1024 * 1024;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Where a position in seconds falls in a file, in bytes.
|
|
238
|
+
*
|
|
239
|
+
* Proportional, and therefore approximate on a variable bitrate — which is what
|
|
240
|
+
* it is for: a prefetch that puts the swarm to work on roughly the right place
|
|
241
|
+
* while the plan and the session are still being built. The encoder's own read
|
|
242
|
+
* asks for the exact bytes a moment later and corrects it.
|
|
243
|
+
*
|
|
244
|
+
* A position past the end is clamped to the end rather than refused: a resume
|
|
245
|
+
* position can outlive the file it was recorded against, and reading the last
|
|
246
|
+
* bytes is harmless where reading past them is an error.
|
|
247
|
+
*
|
|
248
|
+
* @param {number} fileLength
|
|
249
|
+
* @param {number} durationSeconds
|
|
250
|
+
* @param {number} positionSeconds
|
|
251
|
+
* @returns {number}
|
|
252
|
+
*/
|
|
253
|
+
export function resumeByteOffset(fileLength, durationSeconds, positionSeconds) {
|
|
254
|
+
if (!(fileLength > 0) || !(durationSeconds > 0) || !(positionSeconds > 0)) {
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
const within = Math.min(positionSeconds, durationSeconds);
|
|
258
|
+
return Math.min(fileLength - 1, Math.floor((fileLength * within) / durationSeconds));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Start fetching the region a viewer is about to resume at.
|
|
263
|
+
*
|
|
264
|
+
* Where that region IS can only be worked out from two numbers the file itself
|
|
265
|
+
* holds — its length and its duration — so this belongs beside the container
|
|
266
|
+
* read rather than in the route: the route knows a position in seconds and
|
|
267
|
+
* nothing else. The conversion is proportional and therefore approximate on a
|
|
268
|
+
* variable bitrate; it is a prefetch, and the encoder's own read corrects it.
|
|
269
|
+
*
|
|
270
|
+
* @param {object} torrent
|
|
271
|
+
* @param {number} fileIndex
|
|
272
|
+
* @param {string} sourceKey
|
|
273
|
+
* @param {number} positionSeconds
|
|
274
|
+
* @param {{ prefetchEdges?: () => Promise<unknown>, fetchRegion?: (start: number, bytes: number) => Promise<unknown> }} options
|
|
275
|
+
* @returns {Promise<boolean>} Whether a region was asked for.
|
|
276
|
+
*/
|
|
277
|
+
export async function warmResumePosition(torrent, fileIndex, sourceKey, positionSeconds, options = {}) {
|
|
278
|
+
const file = torrent?.files?.[fileIndex];
|
|
279
|
+
if (!file || !(positionSeconds > 0) || typeof options.fetchRegion !== "function") {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
const info = await containerMediaInfoOf(torrent, fileIndex, sourceKey, options);
|
|
283
|
+
const duration = info?.durationSeconds;
|
|
284
|
+
if (!Number.isFinite(duration) || duration <= 0) {
|
|
285
|
+
logger.info(
|
|
286
|
+
`warm ${sourceKey.slice(0, 8)}: "${String(file.name).slice(0, 40)}" does not declare its ` +
|
|
287
|
+
"duration, so where the viewer's position falls in it cannot be worked out — " +
|
|
288
|
+
"the region under it is left to the encoder's own read"
|
|
289
|
+
);
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
const at = resumeByteOffset(file.length, duration, positionSeconds);
|
|
293
|
+
logger.info(
|
|
294
|
+
`warm ${sourceKey.slice(0, 8)}: fetching ${(RESUME_REGION_BYTES / (1024 * 1024)).toFixed(0)}MB under the ` +
|
|
295
|
+
`viewer's position ${positionSeconds.toFixed(1)}s of ${duration.toFixed(1)}s, which is ` +
|
|
296
|
+
`${(at / (1024 * 1024)).toFixed(1)}MB into "${String(file.name).slice(0, 40)}"`
|
|
297
|
+
);
|
|
298
|
+
await options.fetchRegion(at, RESUME_REGION_BYTES);
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* What one file declares about itself: format, duration, and where its own
|
|
304
|
+
* timeline begins.
|
|
305
|
+
*
|
|
306
|
+
* The same header the track table is read from, and the container instance is
|
|
307
|
+
* cached per file, so asking for this after the tracks costs no read at all.
|
|
308
|
+
* It exists because the alternative was a second reader: the session manager
|
|
309
|
+
* used to spawn an ffmpeg over the proxy's own HTTP to learn where a sidecar
|
|
310
|
+
* soundtrack's timeline begins, and that read cost 8121 ms in the field on
|
|
311
|
+
* 2026-09-03 while this layer had read the same header in 8 ms in the same
|
|
312
|
+
* second.
|
|
313
|
+
*
|
|
314
|
+
* @param {object} torrent
|
|
315
|
+
* @param {number} fileIndex
|
|
316
|
+
* @param {string} sourceKey
|
|
317
|
+
* @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
|
|
318
|
+
* @returns {Promise<import("../container/Container.js").ContainerMediaInfo | null>}
|
|
319
|
+
*/
|
|
320
|
+
export async function containerMediaInfoOf(torrent, fileIndex, sourceKey, options = {}) {
|
|
321
|
+
const file = torrent?.files?.[fileIndex];
|
|
322
|
+
if (!file || !Number.isFinite(file.length) || file.length <= 0) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
if (typeof options.prefetchEdges === "function") {
|
|
326
|
+
try {
|
|
327
|
+
await options.prefetchEdges();
|
|
328
|
+
} catch {
|
|
329
|
+
// A prefetch that failed is not a reason to skip the read: the read
|
|
330
|
+
// fetches what it needs itself, only more slowly.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const readRange = async (start, end) =>
|
|
334
|
+
readFetching(file, start, Math.min(end, file.length - 1));
|
|
335
|
+
try {
|
|
336
|
+
const info = await containerOrchestrator.getMediaInfo({
|
|
337
|
+
sourceKey,
|
|
338
|
+
fileIndex,
|
|
339
|
+
readRange,
|
|
340
|
+
fileSize: file.length,
|
|
341
|
+
label: String(file.name ?? "")
|
|
342
|
+
});
|
|
343
|
+
if (info) {
|
|
344
|
+
logger.info(
|
|
345
|
+
`container-info: "${String(file.name).slice(0, 40)}" is ${info.format}, ` +
|
|
346
|
+
`${info.durationSeconds === null ? "duration not declared" : `${info.durationSeconds.toFixed(3)}s`}, ` +
|
|
347
|
+
`${info.startTimeSeconds === null
|
|
348
|
+
? "start of its timeline not declared"
|
|
349
|
+
: `its timeline starts at ${info.startTimeSeconds.toFixed(6)}s`}`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
return info;
|
|
353
|
+
} catch (error) {
|
|
354
|
+
logger.warn(
|
|
355
|
+
`container-info: "${String(file.name).slice(0, 40)}" could not be read: ${error?.message ?? error}`
|
|
356
|
+
);
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
227
361
|
/**
|
|
228
362
|
* Forget one file's tracks, or every file of a source.
|
|
229
363
|
*
|
|
@@ -24,6 +24,19 @@
|
|
|
24
24
|
* instead of staying with whoever got it first.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* How short a piece's remaining tail must be for a second copy of it to be
|
|
29
|
+
* worth asking for.
|
|
30
|
+
*
|
|
31
|
+
* Sixteen blocks is 256 KB against the 4-16 MB piece they hold up. The figure
|
|
32
|
+
* comes from what was measured rather than chosen for roundness: the tails a
|
|
33
|
+
* blocked reader waits on were 2 to 14 blocks of 512 on 2026-08-19, and 3 of
|
|
34
|
+
* 512 in the field failure of 2026-09-03 that took 46.3 s. Above this the piece
|
|
35
|
+
* is still arriving normally and duplicating would spend the shared link on
|
|
36
|
+
* bytes already on their way.
|
|
37
|
+
*/
|
|
38
|
+
const SHORT_TAIL_BLOCKS = 16;
|
|
39
|
+
|
|
27
40
|
/**
|
|
28
41
|
* The library's own request entry. Internal, so its absence must be noticed
|
|
29
42
|
* rather than swallowed: without it this lever silently does nothing.
|
|
@@ -269,6 +282,15 @@ export function duplicateTailFor(torrent, pieceIndex) {
|
|
|
269
282
|
return { duplicated: 0, missing: missing.length, wires: candidates.length };
|
|
270
283
|
}
|
|
271
284
|
|
|
285
|
+
// A tail this short is what a blocked read is actually waiting on, and a
|
|
286
|
+
// second copy of it costs a few dozen kilobytes. A longer one is a piece
|
|
287
|
+
// still arriving normally, where duplicating would spend the shared link on
|
|
288
|
+
// bytes that are already coming — measured 2026-08-19, the tails a reader
|
|
289
|
+
// waits on are 2 to 14 blocks of 512.
|
|
290
|
+
if (missing.length > SHORT_TAIL_BLOCKS) {
|
|
291
|
+
return { duplicated: 0, missing: missing.length, wires: candidates.length };
|
|
292
|
+
}
|
|
293
|
+
|
|
272
294
|
let duplicated = 0;
|
|
273
295
|
// One block per wire: that is what the pipelines can usefully take at once,
|
|
274
296
|
// and it needs no number of its own.
|
|
@@ -281,13 +303,14 @@ export function duplicateTailFor(torrent, pieceIndex) {
|
|
|
281
303
|
// or it would free a THIRD wire's block as well.
|
|
282
304
|
if (torrent._request(candidates[index], pieceIndex, false) === true) {
|
|
283
305
|
duplicated += 1;
|
|
284
|
-
continue;
|
|
285
306
|
}
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
307
|
+
// A refusal means THAT wire's pipeline is full, and says nothing about the
|
|
308
|
+
// next one's — pipelines are per wire. This used to stop the whole pass on
|
|
309
|
+
// the first refusal, on the stated reasoning that the remaining wires were
|
|
310
|
+
// "no emptier", which is an assumption about other peers' queues that
|
|
311
|
+
// nothing here measures. The block whose reservation was freed stays in the
|
|
312
|
+
// piece's cancellation stack and is handed to whoever asks next, which is
|
|
313
|
+
// harmless: it is already in flight elsewhere.
|
|
291
314
|
}
|
|
292
315
|
return { duplicated, missing: missing.length, wires: candidates.length };
|
|
293
316
|
}
|
|
@@ -950,11 +950,18 @@ export async function* readFragments({
|
|
|
950
950
|
const result = askFastestWiresFor(torrent, pieceIndex);
|
|
951
951
|
if (result.asked === 0) {
|
|
952
952
|
tailWhenNothingPlaced = describePieceTail(torrent, pieceIndex);
|
|
953
|
-
// Nothing could be placed the ordinary way, which means every block
|
|
954
|
-
// is spoken for. That is exactly when a second copy of the last
|
|
955
|
-
// blocks is worth asking for.
|
|
956
|
-
duplicated += duplicateTailFor(torrent, pieceIndex).duplicated;
|
|
957
953
|
}
|
|
954
|
+
// Every attempt, not only the ones where nothing else could be placed.
|
|
955
|
+
// The ordinary steering asks for whatever blocks are still free; the
|
|
956
|
+
// read, meanwhile, ends when the LAST block arrives, and that block is
|
|
957
|
+
// reserved to one wire whether or not other blocks could be asked for.
|
|
958
|
+
// Field 2026-09-03: 46.3 s on one piece, ordinary requests placed on
|
|
959
|
+
// 54 of 87 attempts throughout, and a tail of 3 blocks of 512 held by
|
|
960
|
+
// wires at 51-99 KB/s to the end — while duplication, which ran only
|
|
961
|
+
// on the 33 attempts that placed nothing, managed 5 blocks in the
|
|
962
|
+
// whole wait. `duplicateTailFor` bounds itself by the tail's length,
|
|
963
|
+
// so a piece that is merely still arriving is left alone.
|
|
964
|
+
duplicated += duplicateTailFor(torrent, pieceIndex).duplicated;
|
|
958
965
|
pushed = {
|
|
959
966
|
asked: pushed.asked + result.asked,
|
|
960
967
|
refusedWhileReserved:
|
|
@@ -204,6 +204,41 @@ export class WorkerTorrentPool {
|
|
|
204
204
|
return Array.isArray(answer?.tracks) ? answer.tracks : [];
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Start fetching the region a viewer is about to resume at. Named in seconds
|
|
209
|
+
* here; the worker turns it into bytes, where the file's duration is readable.
|
|
210
|
+
*
|
|
211
|
+
* @param {object} torrent
|
|
212
|
+
* @param {number} fileIndex
|
|
213
|
+
* @param {number} positionSeconds
|
|
214
|
+
* @returns {Promise<boolean>}
|
|
215
|
+
*/
|
|
216
|
+
async warmResumePosition(torrent, fileIndex, positionSeconds) {
|
|
217
|
+
const sourceKey = torrent?.sourceKey;
|
|
218
|
+
if (!sourceKey) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
const answer = await this.#client.warmResumePosition({ sourceKey, fileIndex, positionSeconds });
|
|
222
|
+
return answer?.started === true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* What one file declares about itself: format, duration, and where its own
|
|
227
|
+
* timeline begins.
|
|
228
|
+
*
|
|
229
|
+
* @param {object} torrent
|
|
230
|
+
* @param {number} fileIndex
|
|
231
|
+
* @returns {Promise<import("../container/Container.js").ContainerMediaInfo | null>}
|
|
232
|
+
*/
|
|
233
|
+
async getContainerMediaInfo(torrent, fileIndex) {
|
|
234
|
+
const sourceKey = torrent?.sourceKey;
|
|
235
|
+
if (!sourceKey) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const answer = await this.#client.getContainerMediaInfo({ sourceKey, fileIndex });
|
|
239
|
+
return answer?.info ?? null;
|
|
240
|
+
}
|
|
241
|
+
|
|
207
242
|
/**
|
|
208
243
|
* The audio tracks one file declares, in the order ffmpeg numbers them
|
|
209
244
|
* `0:a:N`.
|
|
@@ -81,6 +81,18 @@ export const Command = {
|
|
|
81
81
|
* file beside it, which is the same question about a different file.
|
|
82
82
|
*/
|
|
83
83
|
CONTAINER_TRACKS: "container-tracks",
|
|
84
|
+
/**
|
|
85
|
+
* What a file declares about itself as a whole — format, duration, and where
|
|
86
|
+
* its own timeline begins. Read from the same header, by the same reader, as
|
|
87
|
+
* the track table above; the alternative was a second ffmpeg over the proxy's
|
|
88
|
+
* own HTTP reading the same bytes again.
|
|
89
|
+
*/
|
|
90
|
+
CONTAINER_MEDIA_INFO: "container-media-info",
|
|
91
|
+
/**
|
|
92
|
+
* Start fetching the region a viewer is about to resume at, named in seconds
|
|
93
|
+
* and turned into bytes here, where the file's own duration can be read.
|
|
94
|
+
*/
|
|
95
|
+
WARM_POSITION: "warm-position",
|
|
84
96
|
/** Cues of one subtitle track, from the clusters already downloaded. */
|
|
85
97
|
SUBTITLE_CUES: "subtitle-cues",
|
|
86
98
|
/** Shut the client down, optionally deleting downloaded data. */
|
|
@@ -311,6 +311,21 @@ function nextSeq(state, trackNumber) {
|
|
|
311
311
|
*/
|
|
312
312
|
export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
|
|
313
313
|
const key = `${sourceKey}:${fileIndex}`;
|
|
314
|
+
// A torrent that cannot say which pieces it holds makes every range read as
|
|
315
|
+
// "not downloaded", so the walk reads nothing and returns an empty list —
|
|
316
|
+
// which is also what a file with no cues yet returns, and that is how this
|
|
317
|
+
// went unnoticed for a session (2026-09-03: 283 clusters indexed, 0 walked,
|
|
318
|
+
// the browser served `WEBVTT` and nothing else). The stand-in the main thread
|
|
319
|
+
// holds is exactly such a torrent; only the thread that owns the object has
|
|
320
|
+
// the bitfield. Nothing here can repair that, so it says so instead.
|
|
321
|
+
if (!torrent?.bitfield || !(Number(torrent?.pieceLength) > 0)) {
|
|
322
|
+
logger.warn(
|
|
323
|
+
`subtitles: asked for cues of "${String(torrent?.name ?? sourceKey).slice(0, 40)}" ` +
|
|
324
|
+
"on a torrent that cannot say which pieces it holds — no cluster can be read here, " +
|
|
325
|
+
"and the answer would be an empty document indistinguishable from a file with no cues"
|
|
326
|
+
);
|
|
327
|
+
return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
|
|
328
|
+
}
|
|
314
329
|
const plan = await planFor(torrent, fileIndex, key);
|
|
315
330
|
const state = stateFor(key);
|
|
316
331
|
const track = plan?.tracks?.find((candidate) => candidate.trackNumber === trackNumber) ?? null;
|
|
@@ -28,7 +28,12 @@ import { createSendStream } from "./channel.js";
|
|
|
28
28
|
import { createFileClaims } from "./file-claims.js";
|
|
29
29
|
import { readFragments, supplyFiguresFor } from "./piece-reader.js";
|
|
30
30
|
import { cuesHeldFor, declaredSubtitleTracksOf, subtitleTracksOf, warmSubtitleCues } from "./subtitle-cues.js";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
CONTAINER_HEAD_BYTES,
|
|
33
|
+
containerMediaInfoOf,
|
|
34
|
+
containerTracksOf,
|
|
35
|
+
warmResumePosition
|
|
36
|
+
} from "./container-tracks.js";
|
|
32
37
|
import { fillFileInBackground } from "./background-fill.js";
|
|
33
38
|
import { Command, Event } from "./protocol.js";
|
|
34
39
|
import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../memory-report.js";
|
|
@@ -418,6 +423,45 @@ async function runCommand(command, params, id) {
|
|
|
418
423
|
};
|
|
419
424
|
}
|
|
420
425
|
|
|
426
|
+
case Command.CONTAINER_MEDIA_INFO: {
|
|
427
|
+
const torrent = await requireTorrent(params.sourceKey);
|
|
428
|
+
return {
|
|
429
|
+
info: await containerMediaInfoOf(torrent, params.fileIndex, params.sourceKey, {
|
|
430
|
+
// Same reason as the track table above: the file this is asked about
|
|
431
|
+
// is often one nobody has played yet, so its head has to be fetched
|
|
432
|
+
// before there is anything to read.
|
|
433
|
+
prefetchEdges: () =>
|
|
434
|
+
pool.prefetchFileEdges(torrent, params.fileIndex, {
|
|
435
|
+
headBytes: CONTAINER_HEAD_BYTES,
|
|
436
|
+
tailBytes: 0,
|
|
437
|
+
timeoutMs: 60_000
|
|
438
|
+
})
|
|
439
|
+
})
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
case Command.WARM_POSITION: {
|
|
444
|
+
const torrent = await requireTorrent(params.sourceKey);
|
|
445
|
+
return {
|
|
446
|
+
started: await warmResumePosition(
|
|
447
|
+
torrent,
|
|
448
|
+
params.fileIndex,
|
|
449
|
+
params.sourceKey,
|
|
450
|
+
params.positionSeconds,
|
|
451
|
+
{
|
|
452
|
+
prefetchEdges: () =>
|
|
453
|
+
pool.prefetchFileEdges(torrent, params.fileIndex, {
|
|
454
|
+
headBytes: CONTAINER_HEAD_BYTES,
|
|
455
|
+
tailBytes: 0,
|
|
456
|
+
timeoutMs: 60_000
|
|
457
|
+
}),
|
|
458
|
+
fetchRegion: (start, bytes) =>
|
|
459
|
+
pool.prefetchFileRegion(torrent, params.fileIndex, start, bytes)
|
|
460
|
+
}
|
|
461
|
+
)
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
421
465
|
case Command.SUBTITLE_CUES: {
|
|
422
466
|
const torrent = await requireTorrent(params.sourceKey);
|
|
423
467
|
const held = await cuesHeldFor(torrent, params.fileIndex, params.sourceKey, params.trackNumber);
|