@torrent-tv/proxy 2.83.3 → 2.83.4
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 +19 -0
- package/package.json +1 -1
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -0
- package/research/priority-map-is-the-truth-2026-09-12.md +207 -0
- package/routes/api/sources/files/get.js +23 -9
- package/routes/api/sources/warm/post.js +26 -22
- package/routes/api/transcode-sessions/post.js +1 -49
- package/routes/stream/get.js +2 -36
- package/services/controllers/SubtitleController.js +0 -3
- package/services/hls-session-manager.js +3 -68
- package/services/playback-planner.js +12 -13
- package/services/priority/PriorityOrchestrator.js +40 -6
- package/services/torrent/Contents.js +324 -0
- package/services/torrent/files.js +8 -1
- package/services/torrent-pool.js +302 -100
- package/services/torrent-worker/client.js +0 -24
- package/services/torrent-worker/pool-adapter.js +3 -33
- package/services/torrent-worker/protocol.js +0 -4
- package/services/torrent-worker/worker.js +52 -59
- package/test/file-edges.test.js +191 -0
- package/test/priority-map-emptied.test.js +207 -0
- package/test/source-files-route.test.js +103 -0
- package/test/stream-route.test.js +4 -8
- package/test/swarm-follows-readers.test.js +96 -14
- package/test/torrent-contents.test.js +235 -0
- package/test/upload-hurry.test.js +66 -28
- package/services/torrent-worker/file-claims.js +0 -91
- package/test/file-claims.test.js +0 -64
package/routes/stream/get.js
CHANGED
|
@@ -208,8 +208,6 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
208
208
|
return;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
const releaseFile = torrentPool.acquireFile(torrent, fileIndex);
|
|
212
|
-
|
|
213
211
|
const range = parseRange(req.headers.range, file.length);
|
|
214
212
|
// Prioritize the pieces at this read position so a seek (a request at a new
|
|
215
213
|
// byte offset) downloads first instead of waiting behind the sequential
|
|
@@ -343,8 +341,6 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
343
341
|
logger.warn(line);
|
|
344
342
|
}
|
|
345
343
|
reply.raw.destroy();
|
|
346
|
-
} finally {
|
|
347
|
-
releaseFile();
|
|
348
344
|
}
|
|
349
345
|
return;
|
|
350
346
|
}
|
|
@@ -355,41 +351,11 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
355
351
|
|
|
356
352
|
if (!range) {
|
|
357
353
|
reply.header("Content-Length", String(file.length));
|
|
358
|
-
|
|
359
|
-
bindRelease(stream, reply, releaseFile);
|
|
360
|
-
return reply.send(stream);
|
|
354
|
+
return reply.send(file.createReadStream());
|
|
361
355
|
}
|
|
362
356
|
|
|
363
357
|
reply.code(206);
|
|
364
358
|
reply.header("Content-Length", String(contentLength));
|
|
365
359
|
reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
|
|
366
|
-
|
|
367
|
-
bindRelease(stream, reply, releaseFile);
|
|
368
|
-
return reply.send(stream);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Attach event listeners that release the file reference exactly once when
|
|
373
|
-
* the stream or the underlying HTTP connection closes.
|
|
374
|
-
*
|
|
375
|
-
* @param {import("node:stream").Readable} stream
|
|
376
|
-
* @param {import("fastify").FastifyReply} reply
|
|
377
|
-
* @param {() => void} release
|
|
378
|
-
* @returns {void}
|
|
379
|
-
*/
|
|
380
|
-
function bindRelease(stream, reply, release) {
|
|
381
|
-
let released = false;
|
|
382
|
-
const releaseOnce = () => {
|
|
383
|
-
if (released) {
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
released = true;
|
|
387
|
-
release();
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
stream.on("close", releaseOnce);
|
|
391
|
-
stream.on("end", releaseOnce);
|
|
392
|
-
stream.on("error", releaseOnce);
|
|
393
|
-
reply.raw.once("close", releaseOnce);
|
|
394
|
-
reply.raw.once("finish", releaseOnce);
|
|
360
|
+
return reply.send(file.createReadStream({ start, end }));
|
|
395
361
|
}
|
|
@@ -51,7 +51,6 @@ export class SubtitleController {
|
|
|
51
51
|
if (!hasTrack) {
|
|
52
52
|
const name = file.name ?? "";
|
|
53
53
|
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
54
|
-
const release = this.torrentPool.acquireFile(torrent, fileIndex);
|
|
55
54
|
try {
|
|
56
55
|
const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
|
|
57
56
|
const text = SubtitleFileContainer.decodeBytes(bytes);
|
|
@@ -65,8 +64,6 @@ export class SubtitleController {
|
|
|
65
64
|
return { vtt, language: TextSubtitleTrack.detectLanguageFromVtt(vtt), headers: {} };
|
|
66
65
|
} catch (e) {
|
|
67
66
|
return { error: `Could not read subtitle file: ${e?.message ?? e}`, status: 502 };
|
|
68
|
-
} finally {
|
|
69
|
-
release();
|
|
70
67
|
}
|
|
71
68
|
}
|
|
72
69
|
|
|
@@ -1680,12 +1680,7 @@ export class HlsSessionManager {
|
|
|
1680
1680
|
// keyframe times and which container they were read from. Present only for
|
|
1681
1681
|
// a variant of a session cut at the source's keyframes, and it is what
|
|
1682
1682
|
// makes the two interchangeable.
|
|
1683
|
-
inheritedGrid = null
|
|
1684
|
-
// Called once for a session that is actually created, and expected to
|
|
1685
|
-
// return a function that lets the source go. It is what keeps the torrent's
|
|
1686
|
-
// data alive for as long as a viewer has a session on it — see
|
|
1687
|
-
// disposeSession.
|
|
1688
|
-
acquireSource = null
|
|
1683
|
+
inheritedGrid = null
|
|
1689
1684
|
}) {
|
|
1690
1685
|
if (!this.enabled) {
|
|
1691
1686
|
const error = new Error("Audio transcoding is disabled on this proxy.");
|
|
@@ -2540,40 +2535,6 @@ export class HlsSessionManager {
|
|
|
2540
2535
|
lastLoggedAt: 0
|
|
2541
2536
|
}
|
|
2542
2537
|
};
|
|
2543
|
-
// Kept so a variant of this session can take its own hold on the same
|
|
2544
|
-
// source: a variant is another encode of the same file and must keep the
|
|
2545
|
-
// torrent's data alive exactly as this one does.
|
|
2546
|
-
session.acquireSource = typeof acquireSource === "function" ? acquireSource : null;
|
|
2547
|
-
if (typeof acquireSource === "function") {
|
|
2548
|
-
// One claim per file this session READS. Almost always that is one file;
|
|
2549
|
-
// a muxed session whose soundtrack ships beside the picture reads two, and
|
|
2550
|
-
// holding only the picture would leave the sound to be swept off the disk
|
|
2551
|
-
// from under a running encoder.
|
|
2552
|
-
const claim = (heldFileIndex) => {
|
|
2553
|
-
try {
|
|
2554
|
-
const release = acquireSource(heldFileIndex);
|
|
2555
|
-
return typeof release === "function" ? release : null;
|
|
2556
|
-
} catch {
|
|
2557
|
-
return null;
|
|
2558
|
-
}
|
|
2559
|
-
};
|
|
2560
|
-
const releases = [claim(undefined)];
|
|
2561
|
-
if (audioInputUrl) {
|
|
2562
|
-
releases.push(claim(audioSource.fileIndex));
|
|
2563
|
-
}
|
|
2564
|
-
const held = releases.filter((release) => typeof release === "function");
|
|
2565
|
-
session.releaseSource = held.length > 0
|
|
2566
|
-
? () => {
|
|
2567
|
-
for (const release of held) {
|
|
2568
|
-
try {
|
|
2569
|
-
release();
|
|
2570
|
-
} catch {
|
|
2571
|
-
// Best effort — a session must always finish being disposed.
|
|
2572
|
-
}
|
|
2573
|
-
}
|
|
2574
|
-
}
|
|
2575
|
-
: null;
|
|
2576
|
-
}
|
|
2577
2538
|
// The viewer who asked for this session, so a browser that names itself
|
|
2578
2539
|
// never has to have requested a segment first for its own soundtrack choice
|
|
2579
2540
|
// to be known — nor for its own POSITION to be known, which is the same
|
|
@@ -7839,8 +7800,7 @@ export class HlsSessionManager {
|
|
|
7839
7800
|
// creations (field 2026-08-17, corrections of 0.6-2.9 s).
|
|
7840
7801
|
published: base.timeline.published
|
|
7841
7802
|
}
|
|
7842
|
-
: null
|
|
7843
|
-
acquireSource: base.acquireSource
|
|
7803
|
+
: null
|
|
7844
7804
|
})
|
|
7845
7805
|
.then(async (variant) => {
|
|
7846
7806
|
// Making a session takes seconds — a probe and a keyframe index — and
|
|
@@ -8628,15 +8588,7 @@ export class HlsSessionManager {
|
|
|
8628
8588
|
boundaries: base.timeline.boundaries,
|
|
8629
8589
|
published: base.timeline.published
|
|
8630
8590
|
}
|
|
8631
|
-
: null
|
|
8632
|
-
// Hold the file this rendition will READ. For a soundtrack shipped beside
|
|
8633
|
-
// the picture that is a different file of the same torrent, and nothing
|
|
8634
|
-
// else claims it: the base holds the picture, and the disk sweep deletes
|
|
8635
|
-
// what nobody is holding — which is how a film being watched was deleted
|
|
8636
|
-
// on 2026-08-06.
|
|
8637
|
-
acquireSource: () => base.acquireSource?.(
|
|
8638
|
-
this.#resolveAudioSource(base.file.sourceKey, base.file.fileIndex, trackIndex).fileIndex
|
|
8639
|
-
)
|
|
8591
|
+
: null
|
|
8640
8592
|
});
|
|
8641
8593
|
return rendition ?? null;
|
|
8642
8594
|
}
|
|
@@ -10017,23 +9969,6 @@ export class HlsSessionManager {
|
|
|
10017
9969
|
}
|
|
10018
9970
|
}
|
|
10019
9971
|
|
|
10020
|
-
// Let go of the source. While a session exists its torrent must survive
|
|
10021
|
-
// both cleanups the pool runs, and until now neither knew about it: the
|
|
10022
|
-
// only claim on a file is taken by a READ, and during a seek there is no
|
|
10023
|
-
// read at all — the old encoder is dead and the new one has not started.
|
|
10024
|
-
// Field 2026-08-06, that window met the thirty-second disk sweep and the
|
|
10025
|
-
// film being watched was evicted mid-seek, six gigabytes deleted, after
|
|
10026
|
-
// which the new encoder had nothing to read. The session's own thirty
|
|
10027
|
-
// minutes governed the session, never the data under it.
|
|
10028
|
-
if (typeof session.releaseSource === "function") {
|
|
10029
|
-
try {
|
|
10030
|
-
session.releaseSource();
|
|
10031
|
-
} catch {
|
|
10032
|
-
// Best effort — a session must always finish being disposed.
|
|
10033
|
-
}
|
|
10034
|
-
session.releaseSource = null;
|
|
10035
|
-
}
|
|
10036
|
-
|
|
10037
9972
|
if (session.inputRetryTimer) {
|
|
10038
9973
|
clearTimeout(session.inputRetryTimer);
|
|
10039
9974
|
session.inputRetryTimer = null;
|
|
@@ -10,7 +10,7 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
import { logger } from "../utils/logger.js";
|
|
11
11
|
import { Container } from "./container/Container.js";
|
|
12
12
|
import { buildAudioInventory } from "./audio-inventory.js";
|
|
13
|
-
import {
|
|
13
|
+
import { contentsOf } from "./torrent/Contents.js";
|
|
14
14
|
import {
|
|
15
15
|
parseFfmpegDurationSeconds,
|
|
16
16
|
parseFfmpegStartTimeSeconds,
|
|
@@ -339,22 +339,18 @@ export function createPlaybackPlanner({
|
|
|
339
339
|
/**
|
|
340
340
|
* The files beside this picture that belong to it, in three groups.
|
|
341
341
|
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
342
|
+
* Asked of what the torrent says about itself, which works the grouping out
|
|
343
|
+
* once and keeps it. It used to be worked out here on every call, and again
|
|
344
|
+
* on the warm-up's own path over the same list, so one opened film paired the
|
|
345
|
+
* same files several times and neither side could be sure of the other's
|
|
346
|
+
* answer.
|
|
346
347
|
*
|
|
347
348
|
* @param {object} torrent
|
|
348
349
|
* @param {number} fileIndex
|
|
349
350
|
* @returns {{ audio: object[], subtitles: object[], images: object[] }}
|
|
350
351
|
*/
|
|
351
352
|
function sidecarsOf(torrent, fileIndex) {
|
|
352
|
-
return
|
|
353
|
-
files: torrent?.files ?? [],
|
|
354
|
-
videoIndex: fileIndex,
|
|
355
|
-
torrentName: typeof torrent?.name === "string" ? torrent.name : "",
|
|
356
|
-
videoCount: countVideoFiles(torrent?.files ?? [])
|
|
357
|
-
});
|
|
353
|
+
return contentsOf(torrent ?? {}).sidecarsOf(fileIndex);
|
|
358
354
|
}
|
|
359
355
|
|
|
360
356
|
async function withContainerDefaults(torrent, fileIndex, subtitleTracks) {
|
|
@@ -630,7 +626,10 @@ export function createPlaybackPlanner({
|
|
|
630
626
|
// codecs. A transient empty probe must NOT be cached: otherwise the wrong
|
|
631
627
|
// plan (file treated as directly playable) sticks permanently for this
|
|
632
628
|
// file, and an unsupported codec like xvid gets copied → black video.
|
|
633
|
-
|
|
629
|
+
// Awaited in the literal sense: this answer cannot be given until the
|
|
630
|
+
// file has said what is in it, and a person is watching a loading screen
|
|
631
|
+
// for as long as that takes.
|
|
632
|
+
await torrentPool.prefetchFileEdges(torrent, fileIndex, { awaited: true });
|
|
634
633
|
edgesReadyMs = Date.now() - planEntryMs;
|
|
635
634
|
// The keyframe index reads the tail of the file, which the probe has just
|
|
636
635
|
// waited for as well. Started here it overlaps the probe instead of
|
|
@@ -653,7 +652,7 @@ export function createPlaybackPlanner({
|
|
|
653
652
|
) {
|
|
654
653
|
attempt += 1;
|
|
655
654
|
await delay(Math.min(3_000, 500 + attempt * 250));
|
|
656
|
-
await torrentPool.prefetchFileEdges(torrent, fileIndex);
|
|
655
|
+
await torrentPool.prefetchFileEdges(torrent, fileIndex, { awaited: true });
|
|
657
656
|
probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
|
|
658
657
|
}
|
|
659
658
|
const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
|
|
@@ -22,7 +22,13 @@ export class PriorityOrchestrator {
|
|
|
22
22
|
/** Where the map goes once it is built. @type {(published: object) => void} */
|
|
23
23
|
#publish;
|
|
24
24
|
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* The last map published per film and file, so an unchanged one is not
|
|
27
|
+
* resent — and what it was about, so that a file everybody has left can be
|
|
28
|
+
* published as wanting nothing.
|
|
29
|
+
*
|
|
30
|
+
* @type {Map<string, { shape: string, sourceKey: string, fileIndex: number, durationSeconds: number }>}
|
|
31
|
+
*/
|
|
26
32
|
#last = new Map();
|
|
27
33
|
|
|
28
34
|
/** The last map BUILT per film and file, for whoever reads instead of being
|
|
@@ -138,13 +144,33 @@ export class PriorityOrchestrator {
|
|
|
138
144
|
// second by second, which is the same comparison over far fewer values.
|
|
139
145
|
const zones = runsOf(map);
|
|
140
146
|
const shape = JSON.stringify(zones);
|
|
141
|
-
if (this.#last.get(key) !== shape) {
|
|
142
|
-
|
|
147
|
+
if (this.#last.get(key)?.shape !== shape) {
|
|
148
|
+
// The length is remembered with the shape, so that a file whose viewers
|
|
149
|
+
// have all gone can still be spoken for: what is published then is the
|
|
150
|
+
// same statement with nothing in it, and it has to name the file it is
|
|
151
|
+
// about.
|
|
152
|
+
this.#last.set(key, { shape, sourceKey, fileIndex, durationSeconds });
|
|
143
153
|
this.#publish({ sourceKey, fileIndex, durationSeconds, zones });
|
|
144
154
|
}
|
|
145
155
|
return map;
|
|
146
156
|
}
|
|
147
157
|
|
|
158
|
+
/**
|
|
159
|
+
* NOBODY WANTS ANYTHING OF THIS FILE ANY MORE — said, rather than left to be
|
|
160
|
+
* inferred from silence.
|
|
161
|
+
*
|
|
162
|
+
* The map used to be deleted from this class's memory and published nowhere,
|
|
163
|
+
* so what had been stated about that file on the swarm's behalf stood until
|
|
164
|
+
* the torrent itself was removed. Read from the register, a film nobody had
|
|
165
|
+
* watched for an hour was indistinguishable from one being watched now.
|
|
166
|
+
*
|
|
167
|
+
* @param {{ sourceKey: string, fileIndex: number, durationSeconds: number }} what
|
|
168
|
+
* @returns {void}
|
|
169
|
+
*/
|
|
170
|
+
#publishNothingFor({ sourceKey, fileIndex, durationSeconds }) {
|
|
171
|
+
this.#publish({ sourceKey, fileIndex, durationSeconds, zones: [] });
|
|
172
|
+
}
|
|
173
|
+
|
|
148
174
|
/**
|
|
149
175
|
* Build and publish the map for every file anybody is watching.
|
|
150
176
|
*
|
|
@@ -222,8 +248,12 @@ export class PriorityOrchestrator {
|
|
|
222
248
|
// for it and called from nowhere.
|
|
223
249
|
for (const key of [...this.#maps.keys()]) {
|
|
224
250
|
if (!byFile.has(key)) {
|
|
251
|
+
const last = this.#last.get(key);
|
|
225
252
|
this.#maps.delete(key);
|
|
226
253
|
this.#last.delete(key);
|
|
254
|
+
if (last) {
|
|
255
|
+
this.#publishNothingFor(last);
|
|
256
|
+
}
|
|
227
257
|
}
|
|
228
258
|
}
|
|
229
259
|
for (const address of [...this.#byOutput.keys()]) {
|
|
@@ -232,9 +262,13 @@ export class PriorityOrchestrator {
|
|
|
232
262
|
}
|
|
233
263
|
}
|
|
234
264
|
for (const one of byFile.values()) {
|
|
235
|
-
// A file of unknown length cannot be divided into zones
|
|
236
|
-
//
|
|
237
|
-
|
|
265
|
+
// A file of unknown length cannot be divided into zones at all. A file
|
|
266
|
+
// whose viewers have all gone CAN be spoken for, and must be: `build`
|
|
267
|
+
// with nobody watching produces a map with nothing in it, which is the
|
|
268
|
+
// truth and is what withdraws what was stated for them. Skipped, as it
|
|
269
|
+
// was, the last thing said about that file stood for as long as the
|
|
270
|
+
// torrent did.
|
|
271
|
+
if (one.durationSeconds > 0) {
|
|
238
272
|
this.build(one);
|
|
239
273
|
}
|
|
240
274
|
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file WHAT IS IN ONE TORRENT — its files, which of them carry a picture, and
|
|
3
|
+
* which files belong to which picture.
|
|
4
|
+
*
|
|
5
|
+
* The torrent's own statement about itself, held instead of recomputed. The
|
|
6
|
+
* pairing rules already lived beside this file, as functions; what did not
|
|
7
|
+
* exist was anywhere to keep their answer, so every caller worked it out again
|
|
8
|
+
* — the warm-up on one path and the playback plan on another, over the same
|
|
9
|
+
* list, several times per opened film.
|
|
10
|
+
*
|
|
11
|
+
* It is a class rather than a set of functions because the fact it holds passes
|
|
12
|
+
* the test this project uses for that: the composition is born with the file
|
|
13
|
+
* list, dies with the record of the torrent, and is addressed by one name.
|
|
14
|
+
*
|
|
15
|
+
* **What it must NOT hold**, and the boundary is the point:
|
|
16
|
+
*
|
|
17
|
+
* - nothing about a FILM. A poster, a title, a description are answers from a
|
|
18
|
+
* third party about an identity, and that identity comes from a file's own
|
|
19
|
+
* bytes rather than from the torrent's list of names. They are keyed by the
|
|
20
|
+
* picture's file, arrive late or not at all, and must never delay playback;
|
|
21
|
+
* - no reference to another layer. Not a container, whose life is a file's and
|
|
22
|
+
* which reads bytes; not a priority map, which is built from viewers and
|
|
23
|
+
* lives above this; not a viewer. What leaves here is plain values — indices
|
|
24
|
+
* into the torrent's own list — and whoever takes them holds nothing of this
|
|
25
|
+
* object.
|
|
26
|
+
*
|
|
27
|
+
* **What was measured and deliberately NOT built**, so that it is not proposed
|
|
28
|
+
* again as an oversight: telling a real episode from an extra. Over the 134
|
|
29
|
+
* torrents of the survey collection, the words that would say so (`sample`,
|
|
30
|
+
* `trailer`, `extra`, `bonus`, `preview`, `making`) matched four files, and all
|
|
31
|
+
* four were ordinary titles — "Making cash with her pussy" is an episode, not a
|
|
32
|
+
* making-of. Size says no more: the ratio of a video to the median of its own
|
|
33
|
+
* torrent runs continuously from 0.00 to 1.00 with no gap anywhere, because a
|
|
34
|
+
* collection of short clips is made of short clips. So there is nothing here to
|
|
35
|
+
* derive a rule from, and a rule invented anyway would decide the order in
|
|
36
|
+
* which a stranger's bandwidth is spent. Every picture is an item.
|
|
37
|
+
*
|
|
38
|
+
* Nothing here reads bytes, waits on the swarm or knows about ffmpeg. Like the
|
|
39
|
+
* functions it is built on, it is a function of the list of names.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
AUDIO_SIDECAR_EXTENSIONS,
|
|
44
|
+
IMAGE_SIDECAR_EXTENSIONS,
|
|
45
|
+
SUBTITLE_SIDECAR_EXTENSIONS,
|
|
46
|
+
VIDEO_FILE_EXTENSIONS,
|
|
47
|
+
extensionOf,
|
|
48
|
+
matchSidecarFiles,
|
|
49
|
+
splitTorrentPath
|
|
50
|
+
} from "./files.js";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One picture of a torrent, with the files that belong to it.
|
|
54
|
+
*
|
|
55
|
+
* @typedef {object} TorrentItem
|
|
56
|
+
* @property {number} fileIndex - Index of the picture in the torrent's own list.
|
|
57
|
+
* @property {string} name - Its file name, extension included.
|
|
58
|
+
* @property {string[]} folders - The folders above it, relative to the torrent root.
|
|
59
|
+
* @property {string} relativePath - Both of those as one path.
|
|
60
|
+
* @property {number} length - Bytes.
|
|
61
|
+
* @property {import("./files.js").SidecarFile[]} audio - Soundtracks beside it.
|
|
62
|
+
* @property {import("./files.js").SidecarFile[]} subtitles - Subtitle files beside it.
|
|
63
|
+
* @property {import("./files.js").SidecarFile[]} images - Contact sheets and covers.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One file of a torrent that belongs to no picture.
|
|
68
|
+
*
|
|
69
|
+
* @typedef {object} TorrentLeftover
|
|
70
|
+
* @property {number} fileIndex
|
|
71
|
+
* @property {string} name
|
|
72
|
+
* @property {string[]} folders
|
|
73
|
+
* @property {string} relativePath
|
|
74
|
+
* @property {number} length
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Files in the order a person reads them: by folder, then by name, with runs of
|
|
79
|
+
* digits compared as numbers.
|
|
80
|
+
*
|
|
81
|
+
* A torrent's own order is whatever the tool that made it chose, and it is
|
|
82
|
+
* routinely by size — one measured release lists its episodes 08, 06, 07, 01,
|
|
83
|
+
* 02, 10. Nothing downstream depends on the position: every item carries the
|
|
84
|
+
* torrent's own index, and that is what a file is opened by.
|
|
85
|
+
*
|
|
86
|
+
* `numeric` is what makes 2 come before 10; comparing the strings would put
|
|
87
|
+
* "10" before "2".
|
|
88
|
+
*
|
|
89
|
+
* @param {{ relativePath: string }} left
|
|
90
|
+
* @param {{ relativePath: string }} right
|
|
91
|
+
* @returns {number}
|
|
92
|
+
*/
|
|
93
|
+
function inReadingOrder(left, right) {
|
|
94
|
+
return left.relativePath.localeCompare(right.relativePath, undefined, {
|
|
95
|
+
numeric: true,
|
|
96
|
+
sensitivity: "base"
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class TorrentContents {
|
|
101
|
+
/** @type {TorrentItem[]} */
|
|
102
|
+
#items = [];
|
|
103
|
+
|
|
104
|
+
/** Which item a file belongs to, as its picture or as one of its parts. @type {Map<number, TorrentItem>} */
|
|
105
|
+
#itemByFile = new Map();
|
|
106
|
+
|
|
107
|
+
/** How many files carry a picture. @type {number} */
|
|
108
|
+
#videoCount = 0;
|
|
109
|
+
|
|
110
|
+
/** @type {TorrentLeftover[]} */
|
|
111
|
+
#leftovers = [];
|
|
112
|
+
|
|
113
|
+
/** Every file as this class reads it, in the torrent's own order. @type {object[]} */
|
|
114
|
+
#described = [];
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {object} params
|
|
118
|
+
* @param {Array<{ path?: string, name?: string, length?: number }>} params.files -
|
|
119
|
+
* The torrent's own list, in its own order.
|
|
120
|
+
* @param {string} [params.name] - The torrent's name, which WebTorrent
|
|
121
|
+
* prefixes to every path in a multi-file torrent.
|
|
122
|
+
*/
|
|
123
|
+
constructor({ files, name = "" }) {
|
|
124
|
+
const list = Array.isArray(files) ? files : [];
|
|
125
|
+
const described = list.map((file, fileIndex) => {
|
|
126
|
+
const { folders, name: fileName } = splitTorrentPath(file?.path ?? file?.name ?? "", name);
|
|
127
|
+
return {
|
|
128
|
+
fileIndex,
|
|
129
|
+
name: fileName,
|
|
130
|
+
folders,
|
|
131
|
+
relativePath: [...folders, fileName].join("/"),
|
|
132
|
+
length: Number.isFinite(file?.length) ? file.length : 0,
|
|
133
|
+
isVideo: VIDEO_FILE_EXTENSIONS.has(extensionOf(fileName))
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
this.#described = described;
|
|
137
|
+
this.#videoCount = described.filter((file) => file.isVideo).length;
|
|
138
|
+
|
|
139
|
+
const claimed = new Set();
|
|
140
|
+
for (const file of described.filter((one) => one.isVideo).sort(inReadingOrder)) {
|
|
141
|
+
const { audio, subtitles, images } = matchSidecarFiles({
|
|
142
|
+
files: list,
|
|
143
|
+
videoIndex: file.fileIndex,
|
|
144
|
+
torrentName: name,
|
|
145
|
+
videoCount: this.#videoCount
|
|
146
|
+
});
|
|
147
|
+
/** @type {TorrentItem} */
|
|
148
|
+
const item = {
|
|
149
|
+
fileIndex: file.fileIndex,
|
|
150
|
+
name: file.name,
|
|
151
|
+
folders: file.folders,
|
|
152
|
+
relativePath: file.relativePath,
|
|
153
|
+
length: file.length,
|
|
154
|
+
audio,
|
|
155
|
+
subtitles,
|
|
156
|
+
images
|
|
157
|
+
};
|
|
158
|
+
this.#items.push(item);
|
|
159
|
+
claimed.add(file.fileIndex);
|
|
160
|
+
this.#itemByFile.set(file.fileIndex, item);
|
|
161
|
+
for (const part of [...audio, ...subtitles, ...images]) {
|
|
162
|
+
claimed.add(part.fileIndex);
|
|
163
|
+
// A part paired with two pictures is answered for by the first that
|
|
164
|
+
// took it, which in this order is the earlier episode. Both items keep
|
|
165
|
+
// it in their own lists, because both genuinely offer it; this map
|
|
166
|
+
// answers the other question — which picture to fetch it beside — and
|
|
167
|
+
// there one answer is what stops it being fetched twice.
|
|
168
|
+
if (!this.#itemByFile.has(part.fileIndex)) {
|
|
169
|
+
this.#itemByFile.set(part.fileIndex, item);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
this.#leftovers = described
|
|
175
|
+
.filter((file) => !claimed.has(file.fileIndex))
|
|
176
|
+
.sort(inReadingOrder)
|
|
177
|
+
.map((file) => ({
|
|
178
|
+
fileIndex: file.fileIndex,
|
|
179
|
+
name: file.name,
|
|
180
|
+
folders: file.folders,
|
|
181
|
+
relativePath: file.relativePath,
|
|
182
|
+
length: file.length
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Every picture of this torrent with what belongs to it, in reading order.
|
|
188
|
+
*
|
|
189
|
+
* @returns {TorrentItem[]}
|
|
190
|
+
*/
|
|
191
|
+
get items() {
|
|
192
|
+
return this.#items;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* How many files of this torrent carry a picture.
|
|
197
|
+
*
|
|
198
|
+
* @returns {number}
|
|
199
|
+
*/
|
|
200
|
+
get videoCount() {
|
|
201
|
+
return this.#videoCount;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Files that belong to no picture: the release's own notes, a screenshot pack
|
|
206
|
+
* matched to nothing, anything a torrent carries beside its films.
|
|
207
|
+
*
|
|
208
|
+
* @returns {TorrentLeftover[]}
|
|
209
|
+
*/
|
|
210
|
+
get leftovers() {
|
|
211
|
+
return this.#leftovers;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* WHAT THIS FILE IS, by its name alone.
|
|
216
|
+
*
|
|
217
|
+
* The one place the question is answered for the whole product: the browser
|
|
218
|
+
* used to answer it again, twice — a list of extensions in its torrent parser
|
|
219
|
+
* and a second, shorter pair inside its picker — and the three had already
|
|
220
|
+
* diverged.
|
|
221
|
+
*
|
|
222
|
+
* @param {number} fileIndex
|
|
223
|
+
* @returns {"video" | "audio" | "subtitle" | "image" | "other"}
|
|
224
|
+
*/
|
|
225
|
+
kindOf(fileIndex) {
|
|
226
|
+
const file = this.#described[fileIndex];
|
|
227
|
+
if (!file) {
|
|
228
|
+
return "other";
|
|
229
|
+
}
|
|
230
|
+
if (file.isVideo) {
|
|
231
|
+
return "video";
|
|
232
|
+
}
|
|
233
|
+
const extension = extensionOf(file.name);
|
|
234
|
+
if (AUDIO_SIDECAR_EXTENSIONS.has(extension)) {
|
|
235
|
+
return "audio";
|
|
236
|
+
}
|
|
237
|
+
if (SUBTITLE_SIDECAR_EXTENSIONS.has(extension)) {
|
|
238
|
+
return "subtitle";
|
|
239
|
+
}
|
|
240
|
+
if (IMAGE_SIDECAR_EXTENSIONS.has(extension)) {
|
|
241
|
+
return "image";
|
|
242
|
+
}
|
|
243
|
+
return "other";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Every file of the torrent, in the order a person reads them, with what each
|
|
248
|
+
* one is and where it sits.
|
|
249
|
+
*
|
|
250
|
+
* What the browser is given: it shows this list and asks nothing about the
|
|
251
|
+
* names itself.
|
|
252
|
+
*
|
|
253
|
+
* @returns {{ fileIndex: number, name: string, relativePath: string, length: number, kind: string }[]}
|
|
254
|
+
*/
|
|
255
|
+
files() {
|
|
256
|
+
return [...this.#described]
|
|
257
|
+
.sort(inReadingOrder)
|
|
258
|
+
.map((file) => ({
|
|
259
|
+
fileIndex: file.fileIndex,
|
|
260
|
+
name: file.name,
|
|
261
|
+
relativePath: file.relativePath,
|
|
262
|
+
length: file.length,
|
|
263
|
+
kind: this.kindOf(file.fileIndex)
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The item this file belongs to — as its picture, or as one of its parts.
|
|
269
|
+
*
|
|
270
|
+
* @param {number} fileIndex
|
|
271
|
+
* @returns {TorrentItem | null}
|
|
272
|
+
*/
|
|
273
|
+
itemOf(fileIndex) {
|
|
274
|
+
return this.#itemByFile.get(fileIndex) ?? null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The files beside one picture that belong to it, in three groups.
|
|
279
|
+
*
|
|
280
|
+
* The shape the warm-up and the playback plan each worked out for themselves,
|
|
281
|
+
* answered from what was decided once, at construction.
|
|
282
|
+
*
|
|
283
|
+
* @param {number} fileIndex - The picture's index in the torrent.
|
|
284
|
+
* @returns {{ audio: import("./files.js").SidecarFile[], subtitles: import("./files.js").SidecarFile[], images: import("./files.js").SidecarFile[] }}
|
|
285
|
+
*/
|
|
286
|
+
sidecarsOf(fileIndex) {
|
|
287
|
+
const item = this.#itemByFile.get(fileIndex);
|
|
288
|
+
if (!item || item.fileIndex !== fileIndex) {
|
|
289
|
+
return { audio: [], subtitles: [], images: [] };
|
|
290
|
+
}
|
|
291
|
+
return { audio: item.audio, subtitles: item.subtitles, images: item.images };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** What has been worked out per torrent, and from how many files. @type {WeakMap<object, { contents: TorrentContents, fileCount: number }>} */
|
|
296
|
+
const byTorrent = new WeakMap();
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* What is in this torrent, worked out once.
|
|
300
|
+
*
|
|
301
|
+
* Found from the torrent itself, in the shape the piece store and the demand
|
|
302
|
+
* register already use, because the same instance is wanted by callers that
|
|
303
|
+
* have no way to hand it to one another.
|
|
304
|
+
*
|
|
305
|
+
* A torrent added from a magnet has no files until its metadata arrives, so the
|
|
306
|
+
* answer is rebuilt when the list changes size — which it does exactly once,
|
|
307
|
+
* from nothing to everything.
|
|
308
|
+
*
|
|
309
|
+
* @param {{ files?: unknown[], name?: string }} torrent
|
|
310
|
+
* @returns {TorrentContents}
|
|
311
|
+
*/
|
|
312
|
+
export function contentsOf(torrent) {
|
|
313
|
+
const files = Array.isArray(torrent?.files) ? torrent.files : [];
|
|
314
|
+
const held = byTorrent.get(torrent);
|
|
315
|
+
if (held && held.fileCount === files.length) {
|
|
316
|
+
return held.contents;
|
|
317
|
+
}
|
|
318
|
+
const contents = new TorrentContents({
|
|
319
|
+
files,
|
|
320
|
+
name: typeof torrent?.name === "string" ? torrent.name : ""
|
|
321
|
+
});
|
|
322
|
+
byTorrent.set(torrent, { contents, fileCount: files.length });
|
|
323
|
+
return contents;
|
|
324
|
+
}
|
|
@@ -135,7 +135,14 @@ export const VIDEO_FILE_EXTENSIONS = new Set([
|
|
|
135
135
|
".m2p",
|
|
136
136
|
".mxf",
|
|
137
137
|
".rm",
|
|
138
|
-
".rmvb"
|
|
138
|
+
".rmvb",
|
|
139
|
+
// A Video CD's MPEG-1 stream. It was offered by the browser and not counted
|
|
140
|
+
// here, which is the divergence that made this one list rather than two
|
|
141
|
+
// (measured 2026-09-12, and the only extension the two disagreed on). The
|
|
142
|
+
// union is the safe way to settle it: a file that is offered and turns out to
|
|
143
|
+
// be unplayable is one probe, while a file that is a film and is never
|
|
144
|
+
// offered cannot be reached at all.
|
|
145
|
+
".dat"
|
|
139
146
|
]);
|
|
140
147
|
|
|
141
148
|
/**
|