@torrent-tv/proxy 2.92.6 → 2.93.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.92.6",
3
+ "version": "2.93.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -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
+ }
@@ -46,6 +46,11 @@ export function peerCanServe(wire, piece) {
46
46
  wire.peerPieces?.get(piece);
47
47
  }
48
48
 
49
+ function peerCanRequest(wire) {
50
+ return !wire.destroyed &&
51
+ (!wire.peerChoking || (wire.hasFast && wire.peerAllowedFastSet?.length > 0));
52
+ }
53
+
49
54
  /** Identical map ordering for present requests and conditional future requests. */
50
55
  export function compareDownloadCandidates(a, b) {
51
56
  return a.deadlineAt - b.deadlineAt || b.priority - a.priority || a.order - b.order || a.piece - b.piece;
@@ -62,11 +67,18 @@ export class DeadlineScheduler {
62
67
  this.#store = findStore;
63
68
  }
64
69
 
65
- reconcile(now = Date.now()) {
70
+ reconcile(now = Date.now(), { torrent = null, wire = null } = {}) {
66
71
  if (this.#running) return { requested: 0, reassigned: 0 };
72
+ // WebTorrent updates each peer in turn. A full or choked peer cannot issue
73
+ // a request, so its heartbeat must not scan every file's byte availability.
74
+ if (wire && (!peerCanRequest(wire) ||
75
+ (wire.requests?.length ?? 0) >= peerRequestCapacity(torrent, wire))) {
76
+ return { requested: 0, reassigned: 0 };
77
+ }
67
78
  this.#running = true;
68
79
  try {
69
- return dispatchDownloadCandidates(downloadCandidates(this.#entries(), this.#store), now);
80
+ const entries = [...this.#entries()].filter(entry => !torrent || entry.torrent === torrent);
81
+ return dispatchDownloadCandidates(downloadCandidates(entries, this.#store), now, { wire });
70
82
  } finally {
71
83
  this.#running = false;
72
84
  }
@@ -112,7 +124,7 @@ export function downloadCandidates(entries, findStore = pieceStoreOf) {
112
124
  }
113
125
 
114
126
  /** Run against protocol peers or an isolated future-state copy. */
115
- export function dispatchDownloadCandidates(candidates, now) {
127
+ export function dispatchDownloadCandidates(candidates, now, { wire: changedWire = null } = {}) {
116
128
  let requested = 0, reassigned = 0;
117
129
  // Rates and advertised limits cannot change during this synchronous pass.
118
130
  // Read each peer once, and only inspect torrents once regardless of map size.
@@ -125,15 +137,14 @@ export function dispatchDownloadCandidates(candidates, now) {
125
137
  }
126
138
  return reading;
127
139
  };
128
- const usable = wire => !wire.destroyed &&
129
- (!wire.peerChoking || (wire.hasFast && wire.peerAllowedFastSet?.length > 0));
130
- const available = (torrent, wire) => usable(wire) &&
140
+ const available = (torrent, wire) => peerCanRequest(wire) &&
131
141
  (wire.requests?.length ?? 0) < readingOf(torrent, wire).capacity;
132
142
  const completionAt = (torrent, wire, bytes) =>
133
143
  requestCompletionAt(wire, bytes, now, readingOf(torrent, wire).speed);
134
144
  const writable = new Map();
135
145
  for (const torrent of new Set(candidates.map(candidate => candidate.torrent))) {
136
- const peers = (torrent.wires ?? []).filter(wire => available(torrent, wire));
146
+ const peers = (changedWire ? [changedWire] : torrent.wires ?? [])
147
+ .filter(wire => available(torrent, wire));
137
148
  if (peers.length) writable.set(torrent, peers);
138
149
  }
139
150
  for (const candidate of candidates) {
@@ -58,8 +58,8 @@ export function demandFor(torrent) {
58
58
  const entry = { torrent, register, selection: new SwarmSelection({ torrent, register }), withdrawalRevision: 0 };
59
59
  if (typeof torrent._request === "function" && typeof torrent._updateWire === "function") {
60
60
  entry.previousWireUpdate = torrent._updateWire;
61
- torrent._updateWire = () => {
62
- if (!publishingSelections) scheduler.reconcile();
61
+ torrent._updateWire = wire => {
62
+ if (!publishingSelections) scheduler.reconcile(Date.now(), { torrent, wire });
63
63
  };
64
64
  }
65
65
  byTorrent.set(torrent, entry);