@torrent-tv/proxy 2.92.5 → 2.93.0
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/package.json +1 -1
- package/routes/api/sources/fingerprint/get.js +31 -0
- package/server.js +10 -0
- package/services/media/ContainerOrchestrator.js +23 -2
- package/services/media/oshash.js +41 -0
- package/services/storage/piece-store/piece-lru.js +9 -5
- package/services/storage/piece-store/shared-piece-store.js +7 -4
- package/services/torrent/download/DeadlineScheduler.js +31 -14
- package/services/torrent/download/SwarmSelection.js +3 -1
package/package.json
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenSubtitles hash of one file of a registered source.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/sources/:sourceKey/files/:fileIndex/fingerprint
|
|
5
|
+
*
|
|
6
|
+
* Answers `200 { hash, size }` once both edges of the file are here,
|
|
7
|
+
* `202 { status: "pending" }` while they are not (the demand for them is already
|
|
8
|
+
* stated; the page asks again), and `404` for a file that has no hash because it
|
|
9
|
+
* is shorter than 128 KiB. Computing it reads 128 KiB, so it costs nothing worth
|
|
10
|
+
* a limit; it does not start a download of anything else.
|
|
11
|
+
*
|
|
12
|
+
* @param {import("fastify").FastifyRequest} req
|
|
13
|
+
* @param {import("fastify").FastifyReply} reply
|
|
14
|
+
* @param {{
|
|
15
|
+
* sourceRegistry: { get: (key: string) => unknown },
|
|
16
|
+
* inspectFingerprint: (address: { sourceKey: string, fileIndex: number, requestId?: string }) => Promise<{ kind: string, value?: { hash: string, size: number }, reason?: string }>
|
|
17
|
+
* }} deps
|
|
18
|
+
* @returns {Promise<void>}
|
|
19
|
+
*/
|
|
20
|
+
export async function handleApiSourceFingerprintGet(req, reply, { sourceRegistry, inspectFingerprint }) {
|
|
21
|
+
const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
22
|
+
const fileIndex = Number(req.params?.fileIndex);
|
|
23
|
+
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
24
|
+
return reply.code(400).send({ error: "sourceKey and a file index are required." });
|
|
25
|
+
}
|
|
26
|
+
if (!sourceRegistry.get(sourceKey)) return reply.code(404).send({ error: "Source key was not found." });
|
|
27
|
+
const result = await inspectFingerprint({ sourceKey, fileIndex, requestId: req.id });
|
|
28
|
+
if (result.kind === "result" && result.value) return reply.send({ hash: result.value.hash, size: result.value.size });
|
|
29
|
+
if (result.kind === "terminal") return reply.code(404).send({ error: `This file has no hash (${result.reason ?? "unavailable"}).` });
|
|
30
|
+
return reply.code(202).send({ status: "pending" });
|
|
31
|
+
}
|
package/server.js
CHANGED
|
@@ -24,6 +24,7 @@ import { handleApiLinkProbeGet } from "./routes/api/link-probe/get.js";
|
|
|
24
24
|
import { handleApiSourcesPost } from "./routes/api/sources/post.js";
|
|
25
25
|
import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
|
|
26
26
|
import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
|
|
27
|
+
import { handleApiSourceFingerprintGet } from "./routes/api/sources/fingerprint/get.js";
|
|
27
28
|
import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
|
|
28
29
|
import { handleApiSourceViewerPost } from "./routes/api/sources/viewer/post.js";
|
|
29
30
|
import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
|
|
@@ -971,6 +972,15 @@ export async function startProxyServer({
|
|
|
971
972
|
app.get("/api/sources/:sourceKey/files", async (req, reply) =>
|
|
972
973
|
handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers: outputParts.viewers })
|
|
973
974
|
);
|
|
975
|
+
app.get("/api/sources/:sourceKey/files/:fileIndex/fingerprint", async (req, reply) =>
|
|
976
|
+
handleApiSourceFingerprintGet(req, reply, {
|
|
977
|
+
sourceRegistry,
|
|
978
|
+
inspectFingerprint: async (address) => {
|
|
979
|
+
const params = await containerOver(address);
|
|
980
|
+
return params ? containerOrchestrator.inspect(params, "fingerprint") : { kind: "pending" };
|
|
981
|
+
}
|
|
982
|
+
})
|
|
983
|
+
);
|
|
974
984
|
app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
|
|
975
985
|
handleApiSourceWarmPost(req, reply, {
|
|
976
986
|
sourceRegistry,
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { ContainerFactory } from "./container/ContainerFactory.js";
|
|
19
|
-
import { BytesUnavailable, isUnavailable } from "./container/unavailable.js";
|
|
19
|
+
import { BytesUnavailable, isUnavailable, strictReader } from "./container/unavailable.js";
|
|
20
|
+
import { OSHASH_EDGE_BYTES, hasOshash, oshash } from "./oshash.js";
|
|
20
21
|
import { IndexMemoryUnavailable } from "./container/memory-unavailable.js";
|
|
21
22
|
import { logger } from "../../utils/logger.js";
|
|
22
23
|
|
|
@@ -29,6 +30,21 @@ import { logger } from "../../utils/logger.js";
|
|
|
29
30
|
*/
|
|
30
31
|
export const CONTAINER_HEAD_BYTES = 256 * 1024;
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The size of the file and its OpenSubtitles hash, from the two edges. A read
|
|
35
|
+
* that has not arrived throws `BytesUnavailable`, so nothing partial is kept.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ readRange: (start: number, end: number) => Promise<Buffer | null>, fileSize: number }} params
|
|
38
|
+
* @returns {Promise<{ hash: string, size: number } | null>} `null` for a file too short to have one.
|
|
39
|
+
*/
|
|
40
|
+
async function readFingerprint({ readRange, fileSize }) {
|
|
41
|
+
if (!hasOshash(fileSize)) return null;
|
|
42
|
+
const read = strictReader(readRange, fileSize);
|
|
43
|
+
const head = await read(0, OSHASH_EDGE_BYTES - 1);
|
|
44
|
+
const tail = await read(fileSize - OSHASH_EDGE_BYTES, fileSize - 1);
|
|
45
|
+
return { hash: oshash(fileSize, head, tail), size: fileSize };
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
export class ContainerOrchestrator {
|
|
33
49
|
#reads = new Map();
|
|
34
50
|
#lifetimes = new Map();
|
|
@@ -73,7 +89,7 @@ export class ContainerOrchestrator {
|
|
|
73
89
|
|
|
74
90
|
/** Read one statement without converting missing bytes into an empty answer. */
|
|
75
91
|
async inspect(params, statement = "tracks") {
|
|
76
|
-
if (!["container", "tracks", "media-info", "keyframes", "packets"].includes(statement)) {
|
|
92
|
+
if (!["container", "tracks", "media-info", "keyframes", "packets", "fingerprint"].includes(statement)) {
|
|
77
93
|
throw new TypeError(`Unknown media statement: ${statement}`);
|
|
78
94
|
}
|
|
79
95
|
const key = `${params.sourceKey}:${params.fileIndex}`;
|
|
@@ -105,6 +121,11 @@ export class ContainerOrchestrator {
|
|
|
105
121
|
this.#activityEpoch++;
|
|
106
122
|
this.#activeReads++;
|
|
107
123
|
try {
|
|
124
|
+
if (statement === "fingerprint") {
|
|
125
|
+
// A fact about the bytes at the two ends of the file, whatever the format.
|
|
126
|
+
const value = await readFingerprint(params);
|
|
127
|
+
return value ? { kind: "result", value, requestId } : { kind: "terminal", reason: "file-too-short", requestId };
|
|
128
|
+
}
|
|
108
129
|
const container = await this.containerFor(params);
|
|
109
130
|
if (!container) {
|
|
110
131
|
const result = { kind: "terminal", reason: "format-not-supported", requestId };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The OpenSubtitles file hash ("moviehash", "oshash").
|
|
3
|
+
*
|
|
4
|
+
* The size of the file plus its first 64 KiB and its last 64 KiB, each read as
|
|
5
|
+
* little-endian 64-bit words and added modulo 2^64; written as 16 hexadecimal
|
|
6
|
+
* digits. Databases of releases (OpenSubtitles, StashDB, ThePornDB) keep it per
|
|
7
|
+
* file, so a match names this exact release; nothing else about the file is
|
|
8
|
+
* needed, which is why it can be computed from the two edges the proxy downloads
|
|
9
|
+
* first.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Bytes read from each end of the file. */
|
|
13
|
+
export const OSHASH_EDGE_BYTES = 65536;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether a file is long enough to have the hash: both edges must be whole and
|
|
17
|
+
* distinct, as the format of the hash requires.
|
|
18
|
+
*
|
|
19
|
+
* @param {number} size
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
export function hasOshash(size) {
|
|
23
|
+
return Number.isInteger(size) && size >= OSHASH_EDGE_BYTES * 2;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {number} size - The size of the file in bytes.
|
|
28
|
+
* @param {Uint8Array} head - Its first {@link OSHASH_EDGE_BYTES} bytes.
|
|
29
|
+
* @param {Uint8Array} tail - Its last {@link OSHASH_EDGE_BYTES} bytes.
|
|
30
|
+
* @returns {string} Sixteen lowercase hexadecimal digits.
|
|
31
|
+
*/
|
|
32
|
+
export function oshash(size, head, tail) {
|
|
33
|
+
if (!hasOshash(size)) throw new RangeError("a file shorter than 128 KiB has no oshash");
|
|
34
|
+
if (head.byteLength !== OSHASH_EDGE_BYTES || tail.byteLength !== OSHASH_EDGE_BYTES) throw new RangeError("each edge must be 64 KiB");
|
|
35
|
+
let sum = BigInt(size);
|
|
36
|
+
for (const edge of [head, tail]) {
|
|
37
|
+
const words = new DataView(edge.buffer, edge.byteOffset, edge.byteLength);
|
|
38
|
+
for (let at = 0; at < OSHASH_EDGE_BYTES; at += 8) sum += words.getBigUint64(at, true);
|
|
39
|
+
}
|
|
40
|
+
return BigInt.asUintN(64, sum).toString(16).padStart(16, "0");
|
|
41
|
+
}
|
|
@@ -289,15 +289,16 @@ export class PieceLru {
|
|
|
289
289
|
*
|
|
290
290
|
* @returns {{ readers: number, names: string[], unionPieces: number, widestPieces: number, capacity: number }}
|
|
291
291
|
*/
|
|
292
|
-
demand() {
|
|
292
|
+
demand({ memoryRequiredOnly = false } = {}) {
|
|
293
293
|
// WHO THEY ARE, not only how many. A "reader" here is whoever declared a
|
|
294
294
|
// range, and on 2026-09-08 the field said `5 reader(s) want 24 piece(s) of
|
|
295
295
|
// 25` on a session with two encoders — because the priority map declares one
|
|
296
296
|
// range per zone and four of its zones were arriving as four readers. The
|
|
297
297
|
// count alone could not say that, and choosing between "narrow the windows"
|
|
298
298
|
// and "raise the allowance" was guesswork until the names were printed.
|
|
299
|
-
const
|
|
300
|
-
const
|
|
299
|
+
const entries = [...this.#protected.entries()].filter(([, range]) => !memoryRequiredOnly || range.memoryRequired);
|
|
300
|
+
const names = entries.map(([name]) => String(name)).sort();
|
|
301
|
+
const ranges = entries.map(([, range]) => range)
|
|
301
302
|
.map((range) => ({ from: range.from, to: range.to }))
|
|
302
303
|
.sort((left, right) => left.from - right.from);
|
|
303
304
|
let unionPieces = 0;
|
|
@@ -432,9 +433,11 @@ export class PieceLru {
|
|
|
432
433
|
* urgent. A caller that states none is treated as wanting these pieces
|
|
433
434
|
* least of everyone who did state one, so an unstated range can never
|
|
434
435
|
* displace a stated one.
|
|
436
|
+
* @param {boolean} [memoryRequired] - Read windows require room; download
|
|
437
|
+
* preferences can yield under pressure.
|
|
435
438
|
* @returns {void}
|
|
436
439
|
*/
|
|
437
|
-
protect(readerId, from, to, urgency, deadlineAt, priority = 1) {
|
|
440
|
+
protect(readerId, from, to, urgency, deadlineAt, priority = 1, memoryRequired = true) {
|
|
438
441
|
if (!Number.isInteger(from) || !Number.isInteger(to) || to < from) {
|
|
439
442
|
return;
|
|
440
443
|
}
|
|
@@ -443,7 +446,8 @@ export class PieceLru {
|
|
|
443
446
|
to,
|
|
444
447
|
urgency: Number.isFinite(urgency) ? Number(urgency) : Number.MAX_SAFE_INTEGER,
|
|
445
448
|
deadlineAt,
|
|
446
|
-
priority
|
|
449
|
+
priority,
|
|
450
|
+
memoryRequired
|
|
447
451
|
});
|
|
448
452
|
}
|
|
449
453
|
|
|
@@ -658,10 +658,13 @@ export class SharedPieceStore {
|
|
|
658
658
|
* range to declare.
|
|
659
659
|
*/
|
|
660
660
|
get wantedBytes() {
|
|
661
|
+
// Download preferences may use spare RAM, but cannot become a required
|
|
662
|
+
// floor or a retained reading window after the map has been withdrawn.
|
|
661
663
|
const demand = this.#lru.demand();
|
|
664
|
+
const required = this.#lru.demand({ memoryRequiredOnly: true });
|
|
662
665
|
const pinned = this.#lru.pinnedCount;
|
|
663
666
|
if (demand.readers > 0) {
|
|
664
|
-
this.#widestSeenPieces = Math.max(this.#widestSeenPieces,
|
|
667
|
+
this.#widestSeenPieces = Math.max(this.#widestSeenPieces, required.widestPieces);
|
|
665
668
|
const pieces = Math.max(MIN_RESIDENT_PIECES, demand.unionPieces, demand.widestPieces, pinned);
|
|
666
669
|
// Plus room to absorb what arrives while a write is finishing. Asking for
|
|
667
670
|
// exactly what the readers want leaves no free place ever, so every
|
|
@@ -710,7 +713,7 @@ export class SharedPieceStore {
|
|
|
710
713
|
// also pin pieces without a priority-map range, so those measured pins are
|
|
711
714
|
// part of the same minimum. Exceeding the share is the lesser failure, and
|
|
712
715
|
// the line below says when it happens.
|
|
713
|
-
const demand = this.#lru.demand();
|
|
716
|
+
const demand = this.#lru.demand({ memoryRequiredOnly: true });
|
|
714
717
|
const activeDemandPieces = Math.max(
|
|
715
718
|
MIN_RESIDENT_PIECES,
|
|
716
719
|
demand.readers > 0 ? demand.unionPieces : 0,
|
|
@@ -1848,8 +1851,8 @@ export class SharedPieceStore {
|
|
|
1848
1851
|
* @param {number} [urgency]
|
|
1849
1852
|
* @returns {void}
|
|
1850
1853
|
*/
|
|
1851
|
-
protectRange(readerId, from, to, urgency, deadlineAt, priority) {
|
|
1852
|
-
this.#lru.protect(readerId, from, to, urgency, deadlineAt, priority);
|
|
1854
|
+
protectRange(readerId, from, to, urgency, deadlineAt, priority, memoryRequired = true) {
|
|
1855
|
+
this.#lru.protect(readerId, from, to, urgency, deadlineAt, priority, memoryRequired);
|
|
1853
1856
|
// A READER DECLARES ITSELF IN A MOMENT; the allowance was re-derived once a
|
|
1854
1857
|
// minute. Between the two a read met whatever the store had shrunk to while
|
|
1855
1858
|
// nobody was reading, and a claim gives up after five seconds — twelve
|
|
@@ -25,8 +25,7 @@ function requestArrival(wire, request, now) {
|
|
|
25
25
|
return requestArrivals.get(request);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export function peerRequestCapacity(torrent, wire) {
|
|
29
|
-
const speed = Math.max(0, Number(wire.downloadSpeed?.()) || 0);
|
|
28
|
+
export function peerRequestCapacity(torrent, wire, speed = Math.max(0, Number(wire.downloadSpeed?.()) || 0)) {
|
|
30
29
|
if (wire.type === "webSeed") {
|
|
31
30
|
return Math.min(1 + Math.ceil(speed / torrent.pieceLength), torrent.maxWebConns);
|
|
32
31
|
}
|
|
@@ -36,8 +35,7 @@ export function peerRequestCapacity(torrent, wire) {
|
|
|
36
35
|
return capacity;
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
export function requestCompletionAt(wire, bytes, now) {
|
|
40
|
-
const speed = Number(wire.downloadSpeed?.());
|
|
38
|
+
export function requestCompletionAt(wire, bytes, now, speed = Number(wire.downloadSpeed?.())) {
|
|
41
39
|
if (!(speed > 0)) return Number.POSITIVE_INFINITY;
|
|
42
40
|
const queued = (wire.requests ?? []).reduce((total, request) => total + request.length, 0);
|
|
43
41
|
return now + (queued + bytes) / speed * 1000;
|
|
@@ -116,26 +114,45 @@ export function downloadCandidates(entries, findStore = pieceStoreOf) {
|
|
|
116
114
|
/** Run against protocol peers or an isolated future-state copy. */
|
|
117
115
|
export function dispatchDownloadCandidates(candidates, now) {
|
|
118
116
|
let requested = 0, reassigned = 0;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
117
|
+
// Rates and advertised limits cannot change during this synchronous pass.
|
|
118
|
+
// Read each peer once, and only inspect torrents once regardless of map size.
|
|
119
|
+
const readings = new Map();
|
|
120
|
+
const readingOf = (torrent, wire) => {
|
|
121
|
+
let reading = readings.get(wire);
|
|
122
|
+
if (!reading) {
|
|
123
|
+
const speed = Math.max(0, Number(wire.downloadSpeed?.()) || 0);
|
|
124
|
+
readings.set(wire, reading = { speed, capacity: peerRequestCapacity(torrent, wire, speed) });
|
|
125
|
+
}
|
|
126
|
+
return reading;
|
|
127
|
+
};
|
|
128
|
+
const usable = wire => !wire.destroyed &&
|
|
129
|
+
(!wire.peerChoking || (wire.hasFast && wire.peerAllowedFastSet?.length > 0));
|
|
130
|
+
const available = (torrent, wire) => usable(wire) &&
|
|
131
|
+
(wire.requests?.length ?? 0) < readingOf(torrent, wire).capacity;
|
|
132
|
+
const completionAt = (torrent, wire, bytes) =>
|
|
133
|
+
requestCompletionAt(wire, bytes, now, readingOf(torrent, wire).speed);
|
|
134
|
+
const writable = new Map();
|
|
135
|
+
for (const torrent of new Set(candidates.map(candidate => candidate.torrent))) {
|
|
136
|
+
const peers = (torrent.wires ?? []).filter(wire => available(torrent, wire));
|
|
137
|
+
if (peers.length) writable.set(torrent, peers);
|
|
138
|
+
}
|
|
122
139
|
for (const candidate of candidates) {
|
|
123
140
|
if (!writable.size) break;
|
|
124
141
|
const { torrent, piece, deadlineAt, requestId } = candidate;
|
|
125
142
|
if (!writable.has(torrent)) continue;
|
|
126
|
-
const
|
|
127
|
-
|
|
143
|
+
const peers = writable.get(torrent);
|
|
144
|
+
const wires = peers.filter(wire => peerCanServe(wire, piece) && available(torrent, wire));
|
|
128
145
|
while (wires.length) {
|
|
129
|
-
wires.sort((a, b) =>
|
|
146
|
+
wires.sort((a, b) => completionAt(torrent, a, BLOCK_BYTES) - completionAt(torrent, b, BLOCK_BYTES) ||
|
|
130
147
|
(a.requests?.length ?? 0) - (b.requests?.length ?? 0));
|
|
131
148
|
const wire = wires[0];
|
|
132
149
|
const before = wire.requests?.length ?? 0;
|
|
133
150
|
let accepted = torrent._request(wire, piece, false);
|
|
134
151
|
if (!accepted && Number.isFinite(deadlineAt)) {
|
|
135
|
-
const fasterAt =
|
|
152
|
+
const fasterAt = completionAt(torrent, wire, BLOCK_BYTES);
|
|
136
153
|
for (const slower of torrent.wires ?? []) {
|
|
137
154
|
if (slower === wire || typeof slower.cancel !== "function") continue;
|
|
138
|
-
const lateAt =
|
|
155
|
+
const lateAt = completionAt(torrent, slower, 0);
|
|
139
156
|
if (!Number.isFinite(lateAt) || !(lateAt > deadlineAt && fasterAt < lateAt)) continue;
|
|
140
157
|
const request = slower.requests?.find(one => one.piece === piece);
|
|
141
158
|
if (!request) continue;
|
|
@@ -159,9 +176,9 @@ export function dispatchDownloadCandidates(candidates, now) {
|
|
|
159
176
|
torrent.emit?.("download-requested", { piece, deadlineAt, requestId, peer: wire.remoteAddress, queued: wire.requests?.length ?? 0 });
|
|
160
177
|
}
|
|
161
178
|
if (!accepted || (wire.requests?.length ?? 0) <= before ||
|
|
162
|
-
(wire.requests?.length ?? 0) >=
|
|
179
|
+
(wire.requests?.length ?? 0) >= readingOf(torrent, wire).capacity) wires.shift();
|
|
163
180
|
}
|
|
164
|
-
if (!
|
|
181
|
+
if (!peers.some(wire => available(torrent, wire))) writable.delete(torrent);
|
|
165
182
|
}
|
|
166
183
|
return { requested, reassigned };
|
|
167
184
|
}
|
|
@@ -235,7 +235,9 @@ export class SwarmSelection {
|
|
|
235
235
|
// The level goes with the range: it is what eviction compares when
|
|
236
236
|
// everything resident is wanted by somebody, and dropping it here is
|
|
237
237
|
// what left the store choosing by recency alone.
|
|
238
|
-
|
|
238
|
+
// The download map is an eviction preference. Actual reads own pins;
|
|
239
|
+
// declaring a whole-file tail must not set a whole-file memory floor.
|
|
240
|
+
store.protectRange(window.claimant, range.from, range.to, window.urgency, window.deadlineAt, window.priority, false);
|
|
239
241
|
holding.add(window.claimant);
|
|
240
242
|
}
|
|
241
243
|
}
|