@torrent-tv/proxy 2.83.6 → 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 -1868
- 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/viewer/Viewer.js +359 -345
- package/services/viewer/Viewers.js +44 -0
- package/test/send-chunk-bytes.test.js +86 -0
- package/test/viewer-subtitle-subscription.test.js +96 -0
|
@@ -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,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
|
+
});
|