@torrent-tv/proxy 2.83.8 → 2.84.0
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 +10 -0
- package/CLAUDE.md +5 -3
- package/docs/encode-architecture.md +26 -1
- package/docs/logs.md +97 -51
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +139 -137
- package/services/data-channel-handler.js +7 -0
- package/services/hls-session-manager.js +9777 -10006
- package/services/output/OutputSpec.js +202 -195
- package/services/priority/PriorityMap.js +390 -362
- package/services/priority/PriorityOrchestrator.js +316 -318
- package/services/viewer/Viewer.js +60 -33
- package/services/viewer/positions.js +120 -48
- package/test/held-request-width.test.js +8 -5
- package/test/orchestrator-wired.test.js +14 -8
- package/test/output-spec.test.js +157 -157
- package/test/plan-is-steady.test.js +223 -0
- package/test/priority-map-emptied.test.js +207 -207
- package/test/priority-map-per-output.test.js +209 -211
- package/test/quality-variants.test.js +1167 -1222
- package/test/seek-target-not-superseded.test.js +14 -12
- package/test/stale-request-after-seek.test.js +2 -3
- package/test/viewer.test.js +115 -108
- package/test/viewer-position-on-open.test.js +0 -92
|
@@ -42,7 +42,7 @@ async function managerAfterSeek() {
|
|
|
42
42
|
localBindHost: "127.0.0.1",
|
|
43
43
|
localPort: 9090
|
|
44
44
|
});
|
|
45
|
-
|
|
45
|
+
const session = {
|
|
46
46
|
id: SESSION_ID,
|
|
47
47
|
dirPath,
|
|
48
48
|
// Where this file is cut, held by the file. A fixture that stated it
|
|
@@ -66,9 +66,11 @@ async function managerAfterSeek() {
|
|
|
66
66
|
segmentCount: 399,
|
|
67
67
|
encodeStartIndex: SEGMENT_AT_SEEK,
|
|
68
68
|
waitEpoch: 1,
|
|
69
|
-
furthestViewerSeconds: SEEK_TO_SECONDS,
|
|
70
69
|
runState: "PRODUCING"
|
|
71
|
-
}
|
|
70
|
+
};
|
|
71
|
+
manager.sessionsById.set(SESSION_ID, session);
|
|
72
|
+
viewerOf(session, "").moveTo(SEEK_TO_SECONDS);
|
|
73
|
+
viewerOf(session, "").playing = false;
|
|
72
74
|
return { manager, dirPath };
|
|
73
75
|
}
|
|
74
76
|
|
|
@@ -141,15 +143,15 @@ test("the viewer who made the request is the one it is judged against", async ()
|
|
|
141
143
|
const { manager, dirPath } = await managerAfterSeek();
|
|
142
144
|
const session = manager.sessionsById.get(SESSION_ID);
|
|
143
145
|
session.viewers = new Map();
|
|
144
|
-
// Two people watching one copied picture
|
|
145
|
-
//
|
|
146
|
-
//
|
|
146
|
+
// Two people watching one copied picture: one at the seek target, one a
|
|
147
|
+
// hundred segments back and waiting for a segment there. Asked about nobody
|
|
148
|
+
// in particular the answer is the FURTHEST of them, because what lies behind
|
|
149
|
+
// that one has already been made.
|
|
147
150
|
const behind = SEGMENT_AT_SEEK - 100;
|
|
148
|
-
viewerOf(session, "behind").
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
};
|
|
151
|
+
viewerOf(session, "behind").moveTo(behind * SEGMENT_SECONDS);
|
|
152
|
+
viewerOf(session, "behind").playing = false;
|
|
153
|
+
viewerOf(session, "ahead").moveTo(SEEK_TO_SECONDS);
|
|
154
|
+
viewerOf(session, "ahead").playing = false;
|
|
153
155
|
|
|
154
156
|
assert.equal(
|
|
155
157
|
manager.requestStillWanted(SESSION_ID, `segment-${String(behind).padStart(5, "0")}.mp4`, "behind"),
|
|
@@ -159,7 +161,7 @@ test("the viewer who made the request is the one it is judged against", async ()
|
|
|
159
161
|
assert.equal(
|
|
160
162
|
manager.requestStillWanted(SESSION_ID, `segment-${String(behind).padStart(5, "0")}.mp4`),
|
|
161
163
|
false,
|
|
162
|
-
"unnamed, it
|
|
164
|
+
"unnamed, it is judged against the furthest viewer, which is the leader"
|
|
163
165
|
);
|
|
164
166
|
|
|
165
167
|
await rm(dirPath, { recursive: true, force: true });
|
|
@@ -155,9 +155,8 @@ test("a request cannot move the viewer's position backwards", async (t) => {
|
|
|
155
155
|
manager.requestSeek(SESSION_ID, 2083.4, "viewer-1");
|
|
156
156
|
await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(BEHIND_INDEX), { requestSeq: 1 });
|
|
157
157
|
|
|
158
|
-
assert.
|
|
159
|
-
manager.viewers.get("viewer-1")?.positionSeconds(),
|
|
160
|
-
2083.4,
|
|
158
|
+
assert.ok(
|
|
159
|
+
Math.abs((manager.viewers.get("viewer-1")?.positionSeconds() ?? -1) - 2083.4) < 1,
|
|
161
160
|
"a stale request must not rewrite what the viewer said about themselves"
|
|
162
161
|
);
|
|
163
162
|
});
|
package/test/viewer.test.js
CHANGED
|
@@ -1,108 +1,115 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file One object per person watching, and one deletion to forget them.
|
|
3
|
-
*
|
|
4
|
-
* It was six maps hung on the session, each keyed by consumer id, and the
|
|
5
|
-
* forgetting was already wrong: releasing a consumer emptied none of them, so a
|
|
6
|
-
* viewer who had left went on counting as wanting their soundtrack until their
|
|
7
|
-
* head expired, and their entries stayed for the life of the session.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import test from "node:test";
|
|
11
|
-
import assert from "node:assert/strict";
|
|
12
|
-
import { SourceFile } from "../services/source/SourceFile.js";
|
|
13
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
14
|
-
import os from "node:os";
|
|
15
|
-
import path from "node:path";
|
|
16
|
-
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
17
|
-
import { Viewer, viewerOf, viewersOf } from "../services/viewer/Viewer.js";
|
|
18
|
-
|
|
19
|
-
const SESSION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
20
|
-
|
|
21
|
-
test("presence and position are two facts, and presence does not wait for a request", () => {
|
|
22
|
-
const now = 1_000_000;
|
|
23
|
-
const viewer = new Viewer("someone", now);
|
|
24
|
-
|
|
25
|
-
// The whole of the 2026-09-05 failure in one assertion: a viewer who has
|
|
26
|
-
// arrived and asked for nothing yet IS watching. Answered from the position
|
|
27
|
-
// alone — which is written only when a segment is requested — this read
|
|
28
|
-
// false, every encoder on their output was stopped for having nobody, and
|
|
29
|
-
// the `init.mp4` they needed in order to request their first segment was
|
|
30
|
-
// therefore never made.
|
|
31
|
-
assert.equal(viewer.position, null, "nothing has placed them yet");
|
|
32
|
-
assert.equal(viewer.isPresent(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
viewer.seen(now -
|
|
40
|
-
assert.equal(viewer.isPresent(
|
|
41
|
-
|
|
42
|
-
// Something SAID they are gone
|
|
43
|
-
//
|
|
44
|
-
viewer.
|
|
45
|
-
viewer.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
viewer.
|
|
54
|
-
assert.equal(viewer.positionSeconds(), 40
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file One object per person watching, and one deletion to forget them.
|
|
3
|
+
*
|
|
4
|
+
* It was six maps hung on the session, each keyed by consumer id, and the
|
|
5
|
+
* forgetting was already wrong: releasing a consumer emptied none of them, so a
|
|
6
|
+
* viewer who had left went on counting as wanting their soundtrack until their
|
|
7
|
+
* head expired, and their entries stayed for the life of the session.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { SourceFile } from "../services/source/SourceFile.js";
|
|
13
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
17
|
+
import { Viewer, viewerOf, viewersOf } from "../services/viewer/Viewer.js";
|
|
18
|
+
|
|
19
|
+
const SESSION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
20
|
+
|
|
21
|
+
test("presence and position are two facts, and presence does not wait for a request", () => {
|
|
22
|
+
const now = 1_000_000;
|
|
23
|
+
const viewer = new Viewer("someone", now);
|
|
24
|
+
|
|
25
|
+
// The whole of the 2026-09-05 failure in one assertion: a viewer who has
|
|
26
|
+
// arrived and asked for nothing yet IS watching. Answered from the position
|
|
27
|
+
// alone — which is written only when a segment is requested — this read
|
|
28
|
+
// false, every encoder on their output was stopped for having nobody, and
|
|
29
|
+
// the `init.mp4` they needed in order to request their first segment was
|
|
30
|
+
// therefore never made.
|
|
31
|
+
assert.equal(viewer.position, null, "nothing has placed them yet");
|
|
32
|
+
assert.equal(viewer.isPresent(), true, "and they are still here");
|
|
33
|
+
|
|
34
|
+
// SILENCE IS NOT ABSENCE, however long it lasts. A paused viewer, a hidden
|
|
35
|
+
// tab whose timers the browser has throttled, and one holding two minutes of
|
|
36
|
+
// cushion all say nothing and are all still watching — so there is no
|
|
37
|
+
// interval that separates them from someone who has left, and the threshold
|
|
38
|
+
// that used to try is gone.
|
|
39
|
+
viewer.seen(now - 600_000);
|
|
40
|
+
assert.equal(viewer.isPresent(), true, "ten minutes of silence is not leaving");
|
|
41
|
+
|
|
42
|
+
// Something SAID they are gone: the browser released the session, or their
|
|
43
|
+
// connection closed. That is the only way out.
|
|
44
|
+
viewer.gone = true;
|
|
45
|
+
assert.equal(viewer.isPresent(), false, "a statement is what ends it");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("position is one fact with one writer, and a function of time", () => {
|
|
49
|
+
const viewer = new Viewer("someone");
|
|
50
|
+
assert.equal(viewer.positionSeconds(), null);
|
|
51
|
+
|
|
52
|
+
viewer.moveTo(40, 1_000_000);
|
|
53
|
+
viewer.playing = false;
|
|
54
|
+
assert.equal(viewer.positionSeconds(1_000_000), 40);
|
|
55
|
+
assert.equal(viewer.positionSeconds(1_060_000), 40, "a stopped picture covers no film");
|
|
56
|
+
|
|
57
|
+
viewer.playing = true;
|
|
58
|
+
assert.equal(viewer.positionSeconds(1_010_000), 50, "a playing one covers a second a second");
|
|
59
|
+
|
|
60
|
+
// A seek is the same statement, and there is no second field for it to win
|
|
61
|
+
// against: two writers taking turns on one reading is what made the priority
|
|
62
|
+
// map jump backwards several times a second in the field on 2026-09-13.
|
|
63
|
+
viewer.moveTo(900, 1_010_000);
|
|
64
|
+
assert.equal(viewer.positionSeconds(1_010_000), 900);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("asking for a viewer twice is the same viewer", () => {
|
|
68
|
+
const session = {};
|
|
69
|
+
const first = viewerOf(session, "one");
|
|
70
|
+
first.audio = { trackIndex: 3, transcode: false };
|
|
71
|
+
|
|
72
|
+
assert.equal(viewerOf(session, "one"), first);
|
|
73
|
+
assert.equal(viewerOf(session, "one").audio.trackIndex, 3);
|
|
74
|
+
assert.notEqual(viewerOf(session, "two"), first);
|
|
75
|
+
assert.equal(viewersOf(session).size, 2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("releasing a consumer forgets everything that was true of them alone", async (t) => {
|
|
79
|
+
const dirPath = await mkdtemp(path.join(os.tmpdir(), "viewer-"));
|
|
80
|
+
t.after(async () => {
|
|
81
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
82
|
+
});
|
|
83
|
+
const manager = new HlsSessionManager({
|
|
84
|
+
enabled: true,
|
|
85
|
+
ffmpegBin: "ffmpeg",
|
|
86
|
+
localBindHost: "127.0.0.1",
|
|
87
|
+
localPort: 9090
|
|
88
|
+
});
|
|
89
|
+
const session = {
|
|
90
|
+
id: SESSION_ID,
|
|
91
|
+
dirPath,
|
|
92
|
+
state: "live",
|
|
93
|
+
file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }),
|
|
94
|
+
// An ordinary session reads its own file, and its sound is inside it. The
|
|
95
|
+
// three differ only for a soundtrack shipped as a file of its own.
|
|
96
|
+
get inputFile() { return this.file; },
|
|
97
|
+
get audioFile() { return this.file; },
|
|
98
|
+
consumers: new Set(["staying", "leaving"]),
|
|
99
|
+
viewers: new Map(),
|
|
100
|
+
lastAccessedAt: Date.now()
|
|
101
|
+
};
|
|
102
|
+
manager.sessionsById.set(SESSION_ID, session);
|
|
103
|
+
|
|
104
|
+
const leaving = viewerOf(session, "leaving");
|
|
105
|
+
leaving.audio = { trackIndex: 2, transcode: true };
|
|
106
|
+
leaving.activeVariantId = "some-variant";
|
|
107
|
+
leaving.position = { segment: 40, seconds: 160, at: Date.now() };
|
|
108
|
+
viewerOf(session, "staying").audio = { trackIndex: 0, transcode: false };
|
|
109
|
+
|
|
110
|
+
await manager.releaseSessionConsumer(SESSION_ID, "leaving", "the tab was closed");
|
|
111
|
+
|
|
112
|
+
assert.equal(session.viewers.has("leaving"), false, "one deletion, not six");
|
|
113
|
+
assert.equal(session.viewers.has("staying"), true, "and it takes nobody else with it");
|
|
114
|
+
assert.equal(session.consumers.size, 1);
|
|
115
|
+
});
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Opening a file at a position puts the SOUND there too.
|
|
3
|
-
*
|
|
4
|
-
* The audio rendition is a session of its own, and where it starts is computed
|
|
5
|
-
* from where the viewer is. That reading had three sources and only two were
|
|
6
|
-
* consulted — a position seeked to, and the last segment this session served —
|
|
7
|
-
* both of which are written by things that have not happened yet at the moment
|
|
8
|
-
* a file is opened at a position. So the answer was zero.
|
|
9
|
-
*
|
|
10
|
-
* Field 2026-08-21, `Minions.and.Monsters.1080p.mkv` reopened from the address
|
|
11
|
-
* bar at 52:07:
|
|
12
|
-
*
|
|
13
|
-
* 18:02:55.124 8ed85605 start=3130s -ss 3125.25 -an -map 0:v:0 -c:v copy
|
|
14
|
-
* 18:02:55.632 33b0b046 start=0s no -ss -vn -map 0:a:0 -c:a aac
|
|
15
|
-
*
|
|
16
|
-
* The picture went to segment #781, the sound to #0. The player asked both for
|
|
17
|
-
* #782; the picture had it, the sound re-encoded 57.5 s of a 3130 s film in the
|
|
18
|
-
* 45 s the request lasted and then answered 404 — shown to the viewer as "the
|
|
19
|
-
* proxy accepted the request but sent no video".
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import assert from "node:assert/strict";
|
|
23
|
-
import test from "node:test";
|
|
24
|
-
|
|
25
|
-
import { resolveViewerPosition, viewerPositionSource } from "../services/hls-session-manager.js";
|
|
26
|
-
|
|
27
|
-
test("a file opened at a position has its viewer at that position", () => {
|
|
28
|
-
// Nothing has been seeked and nothing served yet — the state at the instant
|
|
29
|
-
// the audio rendition is created.
|
|
30
|
-
assert.equal(resolveViewerPosition({ openedAt: 3130 }), 3130);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("a seek beats everything else", () => {
|
|
34
|
-
assert.equal(
|
|
35
|
-
resolveViewerPosition({ seeked: 900, lastRequestedStart: 400, openedAt: 3130 }),
|
|
36
|
-
900
|
|
37
|
-
);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("what has been served beats where the file was opened", () => {
|
|
41
|
-
// The opening position is the oldest of the three readings: once a segment
|
|
42
|
-
// has been served, that is where the reading is.
|
|
43
|
-
assert.equal(resolveViewerPosition({ lastRequestedStart: 400, openedAt: 3130 }), 400);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("with nothing to go on the answer is the beginning", () => {
|
|
47
|
-
assert.equal(resolveViewerPosition({}), 0);
|
|
48
|
-
assert.equal(resolveViewerPosition({ seeked: 0, lastRequestedStart: 0, openedAt: 0 }), 0);
|
|
49
|
-
// Values that are not readings must not become one.
|
|
50
|
-
assert.equal(resolveViewerPosition({ seeked: Number.NaN, openedAt: Number.NaN }), 0);
|
|
51
|
-
assert.equal(resolveViewerPosition({ seeked: -5, openedAt: -5 }), 0);
|
|
52
|
-
assert.equal(resolveViewerPosition({ lastRequestedStart: null, openedAt: undefined }), 0);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Which of the three answered is a separate question, and the audio start needs
|
|
57
|
-
* it. A seek and a served segment are request edges — the picture is behind
|
|
58
|
-
* them by however deep the viewer's buffer is, which is what the subtraction in
|
|
59
|
-
* `#audioStartSecondsFor` converts. The opening position is not an edge: it is
|
|
60
|
-
* where the session was made, nothing has been asked for since, and a browser
|
|
61
|
-
* that has just opened holds nothing.
|
|
62
|
-
*
|
|
63
|
-
* Field 2026-08-31: a page opened at 588s, no report yet, and the whole 120 s
|
|
64
|
-
* look-ahead was subtracted — the sound started at 460s and its first segment
|
|
65
|
-
* took 38.8 s to appear against the picture's 8.4 s.
|
|
66
|
-
*/
|
|
67
|
-
test("the reading says which of the three it came from", () => {
|
|
68
|
-
assert.equal(viewerPositionSource({ seeked: 900, lastRequestedStart: 400, openedAt: 3130 }), "seeked");
|
|
69
|
-
assert.equal(viewerPositionSource({ lastRequestedStart: 400, openedAt: 3130 }), "requested");
|
|
70
|
-
assert.equal(viewerPositionSource({ openedAt: 3130 }), "opened");
|
|
71
|
-
assert.equal(viewerPositionSource({}), "none");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("the source agrees with the position, reading for reading", () => {
|
|
75
|
-
const readings = [
|
|
76
|
-
{ seeked: 900, lastRequestedStart: 400, openedAt: 3130 },
|
|
77
|
-
{ lastRequestedStart: 400, openedAt: 3130 },
|
|
78
|
-
{ openedAt: 3130 },
|
|
79
|
-
{ seeked: Number.NaN, openedAt: Number.NaN },
|
|
80
|
-
{ seeked: -5, openedAt: -5 },
|
|
81
|
-
{}
|
|
82
|
-
];
|
|
83
|
-
for (const reading of readings) {
|
|
84
|
-
const position = resolveViewerPosition(reading);
|
|
85
|
-
const source = viewerPositionSource(reading);
|
|
86
|
-
assert.equal(
|
|
87
|
-
source === "none",
|
|
88
|
-
position === 0,
|
|
89
|
-
`no source must mean no position, and the other way round: ${JSON.stringify(reading)}`
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
});
|