@torrent-tv/proxy 2.83.6 → 2.83.8

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.
@@ -201,6 +201,50 @@ export class Viewers {
201
201
  return true;
202
202
  }
203
203
 
204
+ /**
205
+ * This viewer has subtitles switched on for this file.
206
+ *
207
+ * Said by the request that turns them on, which is the only place that knows
208
+ * both the person and the file. Registering it here rather than against a
209
+ * channel is what makes it survive a reconnect and a deliberate rotation of
210
+ * the association.
211
+ *
212
+ * @param {string} consumerId
213
+ * @param {string} sourceKey
214
+ * @param {number} fileIndex
215
+ * @returns {boolean} Whether anybody by that name is known to want them.
216
+ */
217
+ wantsCues(consumerId, sourceKey, fileIndex) {
218
+ const viewer = consumerId ? this.#byId.get(consumerId) : null;
219
+ if (!viewer || !sourceKey || !Number.isInteger(fileIndex)) {
220
+ return false;
221
+ }
222
+ viewer.wantsCuesFor.add(`${sourceKey}:${fileIndex}`);
223
+ return true;
224
+ }
225
+
226
+ /**
227
+ * Who has subtitles switched on for this file.
228
+ *
229
+ * The answer is a list of NAMES. Whoever delivers to them resolves a name to
230
+ * whatever channel that person is reachable on right now, which is the whole
231
+ * point: the recipients outlive the connection.
232
+ *
233
+ * @param {string} sourceKey
234
+ * @param {number} fileIndex
235
+ * @returns {string[]}
236
+ */
237
+ wantingCues(sourceKey, fileIndex) {
238
+ const key = `${sourceKey}:${fileIndex}`;
239
+ const names = [];
240
+ for (const [consumerId, viewer] of this.#byId) {
241
+ if (viewer.wantsCuesFor.has(key)) {
242
+ names.push(consumerId);
243
+ }
244
+ }
245
+ return names;
246
+ }
247
+
204
248
  /**
205
249
  * How many named viewers are watching anything. For the log line and for a
206
250
  * check that the registry does not grow.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @file The browser's log, written beside the proxy's.
3
+ *
4
+ * What these hold is the naming, because the name is the whole point: the two
5
+ * halves of a session have to join without guessing, and a torrent name is
6
+ * arbitrary bytes chosen by a stranger that is about to become a path.
7
+ */
8
+
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtempSync, readdirSync, readFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import { createClientLogFiles } from "../utils/client-log-file.js";
16
+
17
+ /** A directory with a proxy log in it, which is what the sink is given. */
18
+ function aDirectory() {
19
+ const dir = mkdtempSync(join(tmpdir(), "ttv-client-log-"));
20
+ return { dir, proxyLog: join(dir, "proxy.log") };
21
+ }
22
+
23
+ const aSession = (over = {}) => ({
24
+ sessionId: "abcd1234",
25
+ startedAt: "2026-09-13T16:05:24.123Z",
26
+ torrentName: "Reacher.S04E07.1080p.rus.LostFilm.TV.mkv",
27
+ infoHash: "94dda59fdf252447d6ca5a8775dab2d11a832da5",
28
+ ...over
29
+ });
30
+
31
+ test("the file lands beside the proxy log, named by when it started and what is watched", async () => {
32
+ const { dir, proxyLog } = aDirectory();
33
+ const logs = createClientLogFiles(proxyLog);
34
+ logs.write(aSession(), ["first line"]);
35
+ await logs.close();
36
+
37
+ const [name] = readdirSync(dir).filter((f) => f.startsWith("client-"));
38
+ assert.ok(name, "a client log file was written");
39
+ // The stamp is UTC and sorts; the session id joins it to the proxy's lines;
40
+ // the film says which one it is without opening the file.
41
+ assert.match(name, /^client-20260913-160524-abcd1234-Reacher\.S04E07\.1080p\.rus\.LostFilm\.TV\.mkv-94dda59f\.log$/);
42
+ assert.equal(readFileSync(join(dir, name), "utf8"), "first line\n");
43
+ });
44
+
45
+ test("lines from one session go on appending to the one file", async () => {
46
+ const { dir, proxyLog } = aDirectory();
47
+ const logs = createClientLogFiles(proxyLog);
48
+ logs.write(aSession(), ["one", "two"]);
49
+ logs.write(aSession(), ["three"]);
50
+ await logs.close();
51
+
52
+ const names = readdirSync(dir).filter((f) => f.startsWith("client-"));
53
+ assert.equal(names.length, 1);
54
+ assert.equal(readFileSync(join(dir, names[0]), "utf8"), "one\ntwo\nthree\n");
55
+ });
56
+
57
+ test("a session that has not chosen a film yet still gets its own file", async () => {
58
+ // The page opening, a proxy being chosen, a connection failing — all before
59
+ // any torrent exists, and all worth keeping.
60
+ const { dir, proxyLog } = aDirectory();
61
+ const logs = createClientLogFiles(proxyLog);
62
+ logs.write(aSession({ torrentName: "", infoHash: "" }), ["before any film"]);
63
+ await logs.close();
64
+
65
+ const [name] = readdirSync(dir).filter((f) => f.startsWith("client-"));
66
+ assert.match(name, /-no-torrent-yet\.log$/);
67
+ });
68
+
69
+ test("the file keeps its name when the film arrives mid-session", async () => {
70
+ // Keyed by the session, not by the name: the earlier lines belong to the same
71
+ // viewing and must not start a second file.
72
+ const { dir, proxyLog } = aDirectory();
73
+ const logs = createClientLogFiles(proxyLog);
74
+ logs.write(aSession({ torrentName: "", infoHash: "" }), ["before"]);
75
+ logs.write(aSession(), ["after"]);
76
+ await logs.close();
77
+
78
+ const names = readdirSync(dir).filter((f) => f.startsWith("client-"));
79
+ assert.equal(names.length, 1);
80
+ assert.match(names[0], /-no-torrent-yet\.log$/);
81
+ assert.equal(readFileSync(join(dir, names[0]), "utf8"), "before\nafter\n");
82
+ });
83
+
84
+ test("a hostile torrent name cannot escape the directory", async () => {
85
+ const { dir, proxyLog } = aDirectory();
86
+ const logs = createClientLogFiles(proxyLog);
87
+ logs.write(aSession({ torrentName: "../../etc/passwd" }), ["x"]);
88
+ await logs.close();
89
+
90
+ const names = readdirSync(dir).filter((f) => f.startsWith("client-"));
91
+ assert.equal(names.length, 1);
92
+ assert.ok(!names[0].includes(".."), `no traversal in ${names[0]}`);
93
+ assert.ok(!names[0].includes("/"), `no separator in ${names[0]}`);
94
+ });
95
+
96
+ test("two sessions of one film are two files", async () => {
97
+ const { dir, proxyLog } = aDirectory();
98
+ const logs = createClientLogFiles(proxyLog);
99
+ logs.write(aSession({ sessionId: "aaaa1111" }), ["one viewer"]);
100
+ logs.write(aSession({ sessionId: "bbbb2222", startedAt: "2026-09-13T16:07:11.000Z" }), ["another"]);
101
+ await logs.close();
102
+
103
+ assert.equal(readdirSync(dir).filter((f) => f.startsWith("client-")).length, 2);
104
+ });
105
+
106
+ test("no proxy log path means nothing is written and nothing throws", async () => {
107
+ const logs = createClientLogFiles("");
108
+ logs.write(aSession(), ["dropped"]);
109
+ await logs.close();
110
+ });
@@ -0,0 +1,86 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { bodySender } from "../services/data-channel-handler.js";
4
+
5
+ /**
6
+ * Drives the sender over a body arriving in the given read sizes, and answers
7
+ * both what reached the far end and what was written — so "the body is
8
+ * unchanged" is asserted against the body rather than against a length.
9
+ *
10
+ * @param {number} sizeBytes
11
+ * @param {number[]} readSizes - The sizes the body read hands over, in order.
12
+ * @returns {{ sent: Buffer[], counted: number, written: Buffer }}
13
+ */
14
+ function sendBody(sizeBytes, readSizes) {
15
+ /** @type {Buffer[]} */
16
+ const sent = [];
17
+ const sender = bodySender((bytes) => sent.push(Buffer.from(bytes)), sizeBytes);
18
+ /** @type {Buffer[]} */
19
+ const written = [];
20
+ let counted = 0;
21
+ let next = 0;
22
+ for (const size of readSizes) {
23
+ const block = Buffer.alloc(size);
24
+ for (let index = 0; index < size; index += 1) {
25
+ block[index] = next & 0xff;
26
+ next += 1;
27
+ }
28
+ written.push(block);
29
+ counted += sender.push(block);
30
+ }
31
+ counted += sender.flush();
32
+ return { sent, counted, written: Buffer.concat(written) };
33
+ }
34
+
35
+ test("with no size chosen, one body read is one message", () => {
36
+ const { sent, counted } = sendBody(0, [1000, 2000, 3]);
37
+ assert.deepEqual(
38
+ sent.map((message) => message.length),
39
+ [1000, 2000, 3]
40
+ );
41
+ assert.equal(counted, 3);
42
+ });
43
+
44
+ test("a smaller size cuts a body read into messages of exactly that size", () => {
45
+ const { sent, counted } = sendBody(400, [1000]);
46
+ assert.deepEqual(
47
+ sent.map((message) => message.length),
48
+ [400, 400, 200]
49
+ );
50
+ assert.equal(counted, 3);
51
+ });
52
+
53
+ test("a larger size joins body reads until it is filled", () => {
54
+ const { sent } = sendBody(2500, [1000, 1000, 1000, 1000]);
55
+ assert.deepEqual(
56
+ sent.map((message) => message.length),
57
+ [2500, 1500]
58
+ );
59
+ });
60
+
61
+ test("every byte arrives once, in order, whatever the size", () => {
62
+ for (const size of [0, 1, 7, 400, 2500, 999_999]) {
63
+ const { sent, written } = sendBody(size, [1000, 3, 5000, 17]);
64
+ assert.deepEqual(Buffer.concat(sent), written, `size ${size} changed the body`);
65
+ }
66
+ });
67
+
68
+ test("a size larger than the whole body still sends it, once, on flush", () => {
69
+ const { sent, written, counted } = sendBody(10_000, [100, 200]);
70
+ assert.equal(sent.length, 1);
71
+ assert.equal(counted, 1);
72
+ assert.deepEqual(sent[0], written);
73
+ });
74
+
75
+ test("the count is of messages, which is what a reading is attributed to", () => {
76
+ // 163 messages for 10 657 210 bytes is the field reading of 2026-09-12; at
77
+ // 16 KB the same body is 651 of them, and that difference is the measurement.
78
+ const { counted } = sendBody(16 * 1024, [10_657_210]);
79
+ assert.equal(counted, Math.ceil(10_657_210 / (16 * 1024)));
80
+ });
81
+
82
+ test("nothing is sent for an empty body, and flush has nothing to release", () => {
83
+ const { sent, counted } = sendBody(400, []);
84
+ assert.deepEqual(sent, []);
85
+ assert.equal(counted, 0);
86
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @file Who wants subtitle cues, and why the answer is a person and not a channel.
3
+ *
4
+ * The subscription used to be a `Set<DataChannel>` inside the transport, keyed
5
+ * by `sourceKey:fileIndex`. Two things followed from that, and both are field
6
+ * facts rather than worries:
7
+ *
8
+ * 1. a seamless reconnect lost subtitles for the rest of the session, because
9
+ * the new channel is a different object and nothing re-subscribed it;
10
+ * 2. the transport had to sniff `/api/subtitles` out of the request path and
11
+ * derive a torrent key to build that key at all — application routing and
12
+ * torrent identity, inside the layer that is only supposed to carry bytes.
13
+ *
14
+ * Held against the viewer instead, both go away by construction: whatever
15
+ * channel the person is reachable on next, they are still subscribed, and the
16
+ * transport never learns what a subtitle is.
17
+ */
18
+
19
+ import test from "node:test";
20
+ import assert from "node:assert/strict";
21
+
22
+ import { Viewers } from "../services/viewer/Viewers.js";
23
+
24
+ /** An output is only ever held by id here, so the thinnest possible stand-in. */
25
+ const anOutput = (id) => ({ id, viewers: new Set() });
26
+
27
+ test("a viewer who switched subtitles on is listed for that file", () => {
28
+ const viewers = new Viewers();
29
+ viewers.of(anOutput("picture"), "alice");
30
+
31
+ assert.equal(viewers.wantsCues("alice", "src-1", 3), true);
32
+ assert.deepEqual(viewers.wantingCues("src-1", 3), ["alice"]);
33
+ });
34
+
35
+ test("nobody is listed for a file nobody asked about", () => {
36
+ const viewers = new Viewers();
37
+ viewers.of(anOutput("picture"), "alice");
38
+ viewers.wantsCues("alice", "src-1", 3);
39
+
40
+ assert.deepEqual(viewers.wantingCues("src-1", 4), []);
41
+ assert.deepEqual(viewers.wantingCues("src-2", 3), []);
42
+ });
43
+
44
+ test("two viewers of one file are both listed, and one file each is kept apart", () => {
45
+ const viewers = new Viewers();
46
+ const picture = anOutput("picture");
47
+ viewers.of(picture, "alice");
48
+ viewers.of(picture, "bob");
49
+
50
+ viewers.wantsCues("alice", "src-1", 0);
51
+ viewers.wantsCues("bob", "src-1", 0);
52
+ viewers.wantsCues("bob", "src-1", 1);
53
+
54
+ assert.deepEqual(viewers.wantingCues("src-1", 0).sort(), ["alice", "bob"]);
55
+ assert.deepEqual(viewers.wantingCues("src-1", 1), ["bob"]);
56
+ });
57
+
58
+ test("the subscription outlives the connection, which is the whole point", () => {
59
+ // Nothing here mentions a channel, and that is the assertion: the registry
60
+ // has no way to express "this channel", so a channel dying cannot take a
61
+ // subscription with it.
62
+ const viewers = new Viewers();
63
+ viewers.of(anOutput("picture"), "alice");
64
+ viewers.wantsCues("alice", "src-1", 3);
65
+
66
+ // A reconnect is, from here, nothing at all: the same person is still known.
67
+ viewers.seen("alice");
68
+
69
+ assert.deepEqual(viewers.wantingCues("src-1", 3), ["alice"]);
70
+ });
71
+
72
+ test("a viewer who has gone is no longer listed", () => {
73
+ const viewers = new Viewers();
74
+ const picture = anOutput("picture");
75
+ viewers.of(picture, "alice");
76
+ viewers.wantsCues("alice", "src-1", 3);
77
+
78
+ viewers.hasGone("alice", (id) => (id === "picture" ? picture : null));
79
+
80
+ assert.deepEqual(viewers.wantingCues("src-1", 3), []);
81
+ });
82
+
83
+ test("an unknown name subscribes to nothing", () => {
84
+ const viewers = new Viewers();
85
+ assert.equal(viewers.wantsCues("nobody", "src-1", 3), false);
86
+ assert.deepEqual(viewers.wantingCues("src-1", 3), []);
87
+ });
88
+
89
+ test("a request missing the file index registers nothing", () => {
90
+ const viewers = new Viewers();
91
+ viewers.of(anOutput("picture"), "alice");
92
+
93
+ assert.equal(viewers.wantsCues("alice", "src-1", Number.NaN), false);
94
+ assert.equal(viewers.wantsCues("alice", "", 3), false);
95
+ assert.deepEqual(viewers.wantingCues("src-1", 3), []);
96
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * @file Where a browser's own log lands: a file beside the proxy's, per session.
3
+ *
4
+ * WHY THIS EXISTS. Both halves of every failure this project investigates live
5
+ * in two places at once — what the proxy did, and what the page saw — and until
6
+ * now the second half went to the SERVER's standard output on the droplet.
7
+ * Every release recreates that container and destroys it. On 2026-09-13 a
8
+ * viewer reported a desync and a frozen picture, and the analysis ran on half
9
+ * the evidence for exactly this reason.
10
+ *
11
+ * Written here instead, next to the proxy's own log, on the same durable
12
+ * directory (`/data` on the addon host). One file per viewing session, named so
13
+ * the two halves join without guessing: the session's start, then the torrent.
14
+ */
15
+
16
+ import { createWriteStream, mkdirSync, renameSync, statSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+
19
+ /** One file may reach this before it is rotated. */
20
+ const MAX_BYTES = 16 * 1024 * 1024;
21
+
22
+ /**
23
+ * Make a name safe for a file and short enough to read in a directory listing.
24
+ *
25
+ * Deliberately strict: a torrent name is arbitrary bytes chosen by a stranger,
26
+ * and it is about to become a path.
27
+ *
28
+ * @param {string} value
29
+ * @param {number} max
30
+ * @returns {string}
31
+ */
32
+ function safeName(value, max) {
33
+ const cleaned = String(value ?? "")
34
+ .replace(/[^\p{L}\p{N}._-]+/gu, "-")
35
+ .replace(/-+/g, "-")
36
+ .replace(/^[-.]+|[-.]+$/g, "");
37
+ return cleaned.slice(0, max);
38
+ }
39
+
40
+ /**
41
+ * `2026-09-13T16:05:24.123Z` → `20260913-160524`, which sorts and reads.
42
+ *
43
+ * @param {string} iso
44
+ * @returns {string}
45
+ */
46
+ function stamp(iso) {
47
+ const when = new Date(iso);
48
+ const at = Number.isNaN(when.getTime()) ? new Date() : when;
49
+ const pad = (n) => String(n).padStart(2, "0");
50
+ return `${at.getUTCFullYear()}${pad(at.getUTCMonth() + 1)}${pad(at.getUTCDate())}` +
51
+ `-${pad(at.getUTCHours())}${pad(at.getUTCMinutes())}${pad(at.getUTCSeconds())}`;
52
+ }
53
+
54
+ /**
55
+ * A sink that writes one session's browser lines to its own file.
56
+ *
57
+ * @param {string} proxyLogPath - The proxy's own log file; its directory is
58
+ * used, so the two halves are always side by side.
59
+ * @returns {{ write: (session: object, lines: string[]) => void, close: () => void }}
60
+ */
61
+ export function createClientLogFiles(proxyLogPath) {
62
+ const directory = typeof proxyLogPath === "string" && proxyLogPath.length > 0
63
+ ? dirname(proxyLogPath)
64
+ : "";
65
+ /** @type {Map<string, { stream: import("node:fs").WriteStream, path: string, bytes: number }>} */
66
+ const open = new Map();
67
+
68
+ /**
69
+ * The file for this session, opened on first use.
70
+ *
71
+ * Keyed by the session's own id rather than by the name, because the name
72
+ * gains the torrent the moment one is chosen and the earlier lines — the
73
+ * page opening, the proxy being chosen, a connection failing — belong to the
74
+ * same session and must not start a second file.
75
+ *
76
+ * @param {{ sessionId: string, startedAt: string, torrentName: string, infoHash: string }} session
77
+ * @returns {{ stream: import("node:fs").WriteStream, path: string, bytes: number } | null}
78
+ */
79
+ function fileFor(session) {
80
+ if (!directory) return null;
81
+ const key = session.sessionId;
82
+ const existing = open.get(key);
83
+ if (existing) return existing;
84
+ const film = session.torrentName
85
+ ? `${safeName(session.torrentName, 60)}-${safeName(session.infoHash, 8)}`
86
+ : "no-torrent-yet";
87
+ const path = join(directory, `client-${stamp(session.startedAt)}-${safeName(session.sessionId, 8)}-${film}.log`);
88
+ try {
89
+ mkdirSync(directory, { recursive: true });
90
+ const record = {
91
+ stream: createWriteStream(path, { flags: "a" }),
92
+ path,
93
+ bytes: statSync(path, { throwIfNoEntry: false })?.size ?? 0
94
+ };
95
+ // A file that stops being writable must not take the proxy down with it,
96
+ // and must not be retried on every line.
97
+ record.stream.on("error", () => open.delete(key));
98
+ open.set(key, record);
99
+ return record;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ return {
106
+ write(session, lines) {
107
+ const record = fileFor(session);
108
+ if (!record) return;
109
+ for (const line of lines) {
110
+ const text = `${line}\n`;
111
+ record.bytes += Buffer.byteLength(text);
112
+ record.stream.write(text);
113
+ }
114
+ if (record.bytes >= MAX_BYTES) {
115
+ try {
116
+ record.stream.end(() => {
117
+ try {
118
+ renameSync(record.path, `${record.path}.1`);
119
+ } catch {
120
+ // Renaming a file that has gone leaves nothing to do.
121
+ }
122
+ });
123
+ } catch {
124
+ // A rotation that cannot happen leaves the file growing, which is
125
+ // better than losing the session's log to an exception.
126
+ }
127
+ open.delete(session.sessionId);
128
+ }
129
+ },
130
+ /**
131
+ * Finish every open file.
132
+ *
133
+ * Awaitable, and that is not a convenience: a write stream opens and
134
+ * flushes asynchronously, so a process that ends without waiting loses the
135
+ * last lines of every session — which are the ones a crash is explained by.
136
+ *
137
+ * @returns {Promise<void>}
138
+ */
139
+ async close() {
140
+ const ending = [...open.values()].map((record) => new Promise((resolve) => {
141
+ try {
142
+ record.stream.end(resolve);
143
+ } catch {
144
+ resolve(undefined);
145
+ }
146
+ }));
147
+ open.clear();
148
+ await Promise.all(ending);
149
+ }
150
+ };
151
+ }