@torrent-tv/proxy 2.83.5 → 2.83.7

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.
@@ -14,7 +14,7 @@
14
14
 
15
15
  import test from "node:test";
16
16
  import assert from "node:assert/strict";
17
- import { logger } from "../utils/logger.js";
17
+ import { logger, writeAlreadyDecided } from "../utils/logger.js";
18
18
 
19
19
  /** What reached the console while `body` ran. */
20
20
  function captured(body) {
@@ -109,6 +109,20 @@ test("when it is said again, it says how many were held back", async () => {
109
109
  assert.ok(Number(said[2]) > 0, `the span it covers must be stated: ${later[0]}`);
110
110
  });
111
111
 
112
+ test("a line already decided on another thread is written as it is", () => {
113
+ const message = unique("forwarded");
114
+ const lines = captured(() => {
115
+ writeAlreadyDecided("info", message);
116
+ writeAlreadyDecided("info", message);
117
+ writeAlreadyDecided("warn", message);
118
+ });
119
+ // The worker holds the same rule and applies it before forwarding. Deciding
120
+ // again here would be one decision taken twice on two different histories,
121
+ // and the file's own promise is that a line cannot reach the console and miss
122
+ // the file.
123
+ assert.equal(lines.length, 3, "a forwarded line is not judged a second time");
124
+ });
125
+
112
126
  test("every level goes through the same rule", () => {
113
127
  const message = unique("levels");
114
128
  const lines = captured(() => {
@@ -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
+ });
package/utils/logger.js CHANGED
@@ -166,6 +166,30 @@ export const logger = {
166
166
  error: (message) => write("error", message, chalk.red, console.error)
167
167
  };
168
168
 
169
+ /**
170
+ * Write a line that has ALREADY been through the repeat rule on another thread.
171
+ *
172
+ * The worker holds the same rule and applies it before forwarding, so running
173
+ * it again here would be one decision taken twice, on two different histories.
174
+ * It does not lose lines today — a re-printed line carries its held-back count,
175
+ * which makes the text unique — but that is an accident of the wording, and the
176
+ * file's own promise is that a line cannot reach the console and miss the file.
177
+ * It also filled this thread's bounded map with keys that can never repeat.
178
+ *
179
+ * @param {string} level
180
+ * @param {string} message - Already decided; written as it is.
181
+ * @returns {void}
182
+ */
183
+ export function writeAlreadyDecided(level, message) {
184
+ const colour = level === "error"
185
+ ? chalk.red
186
+ : level === "warn" ? chalk.yellow : level === "success" ? chalk.green : chalk.cyan;
187
+ const toConsole = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
188
+ const line = `${PREFIX} [${ts()}] ${message}`;
189
+ toConsole(colour(line));
190
+ toFile(line);
191
+ }
192
+
169
193
  /**
170
194
  * An established fact is said once, then with decreasing frequency.
171
195
  *
@@ -195,6 +219,22 @@ const REPEAT_KEYS = 512;
195
219
  /** @type {Map<string, { suppressed: number, printedAt: number, interval: number }>} */
196
220
  const recent = new Map();
197
221
 
222
+ /**
223
+ * What to append when a line is said again after repeats were held back.
224
+ *
225
+ * One function because it was written twice, in the two branches that decide to
226
+ * speak — which is how two statements of one rule drift apart.
227
+ *
228
+ * @param {number} heldBack
229
+ * @param {number} overMs
230
+ * @returns {string}
231
+ */
232
+ function heldBackSuffix(heldBack, overMs) {
233
+ return heldBack > 0
234
+ ? ` [said ${heldBack} more time(s) in the last ${(overMs / 1000).toFixed(1)}s]`
235
+ : "";
236
+ }
237
+
198
238
  /**
199
239
  * Whether this line is a repeat to hold back, and what to say if it is not.
200
240
  *
@@ -223,12 +263,7 @@ function repeatCheck(message) {
223
263
  }
224
264
  }
225
265
  recent.set(message, { suppressed: 0, printedAt: now, interval: REPEAT_FIRST_MS });
226
- return {
227
- hold: false,
228
- suffix: heldBack > 0
229
- ? ` [said ${heldBack} more time(s) in the last ${(overMs / 1000).toFixed(1)}s]`
230
- : ""
231
- };
266
+ return { hold: false, suffix: heldBackSuffix(heldBack, overMs) };
232
267
  }
233
268
  if (now - seen.printedAt < seen.interval) {
234
269
  seen.suppressed += 1;
@@ -242,14 +277,9 @@ function repeatCheck(message) {
242
277
  printedAt: now,
243
278
  interval: Math.min(REPEAT_MAX_MS, seen.interval * 2)
244
279
  });
245
- return {
246
- hold: false,
247
- // SAID, not merely hidden: the rate is the fact here, and a log that quietly
248
- // drops repeats reports a healthy proxy where a loop was spinning.
249
- suffix: heldBack > 0
250
- ? ` [said ${heldBack} more time(s) in the last ${(overMs / 1000).toFixed(1)}s]`
251
- : ""
252
- };
280
+ // SAID, not merely hidden: the rate is the fact here, and a log that quietly
281
+ // drops repeats reports a healthy proxy where a loop was spinning.
282
+ return { hold: false, suffix: heldBackSuffix(heldBack, overMs) };
253
283
  }
254
284
 
255
285
  /**