@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.
- package/CHANGELOG.md +1874 -1860
- package/bin/cli.js +14 -5
- package/package.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -0
- package/research/handover-2026-09-13.md +209 -0
- package/routes/api/subtitles/get.js +26 -1
- package/server.js +2 -1
- package/services/data-channel-handler.js +120 -83
- package/services/orchestrators/EncodeOrchestrator.js +42 -12
- package/services/torrent-pool.js +3 -3
- package/services/torrent-worker/client.js +5 -2
- package/services/torrent-worker/worker.js +21 -2
- package/services/viewer/Viewer.js +359 -345
- package/services/viewer/Viewers.js +44 -0
- package/test/logger-repeats.test.js +15 -1
- package/test/send-chunk-bytes.test.js +86 -0
- package/test/viewer-subtitle-subscription.test.js +96 -0
- package/utils/logger.js +44 -14
|
@@ -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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
/**
|