@torrent-tv/proxy 2.83.8 → 2.85.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 +23 -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/link-probe/get.js +51 -0
- package/routes/api/transcode-sessions/net-report/post.js +84 -64
- package/routes/api/transcode-sessions/post.js +139 -137
- package/routes/transcode/session-file/get.js +268 -240
- package/server.js +3 -0
- package/services/data-channel-handler.js +12 -0
- package/services/delivery-probe.js +842 -828
- package/services/hls-session-manager.js +9759 -10006
- package/services/output/OutputSpec.js +202 -195
- package/services/priority/PriorityMap.js +390 -362
- package/services/priority/PriorityOrchestrator.js +320 -318
- package/services/viewer/Viewer.js +179 -83
- package/services/viewer/positions.js +120 -48
- package/services/viewer/report-intake.js +63 -0
- package/test/held-request-width.test.js +8 -5
- package/test/link-probe.test.js +56 -0
- package/test/orchestrator-wired.test.js +14 -8
- package/test/output-spec.test.js +157 -157
- package/test/plan-is-steady.test.js +225 -0
- package/test/priority-map-emptied.test.js +205 -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-states.test.js +151 -0
- package/test/viewer.test.js +136 -108
- package/test/viewer-position-on-open.test.js +0 -92
|
@@ -1,195 +1,202 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file What a session PRODUCES, stated once and used as its identity.
|
|
3
|
-
*
|
|
4
|
-
* Three domain axes already have a home in this package: what a file states
|
|
5
|
-
* about itself (`container/`), what a track states about itself (`tracks/`),
|
|
6
|
-
* and what anybody wants off the swarm (`demand/`). This is the fourth — what
|
|
7
|
-
* we make out of them — and it had no home at all: the identity of a session
|
|
8
|
-
* was a `[...].join(":")` inside a 300-line function, with no name, no test and
|
|
9
|
-
* nothing printing it.
|
|
10
|
-
*
|
|
11
|
-
* The rule it exists to express, stated by the user 2026-09-03:
|
|
12
|
-
*
|
|
13
|
-
* > A session belongs to the tracks its output actually carries. Its key holds
|
|
14
|
-
* > those tracks' parameters and nothing else. Two sessions whose parameters
|
|
15
|
-
* > agree ARE the same session, and the encoded result is reused by definition.
|
|
16
|
-
*
|
|
17
|
-
* So reuse between viewers is not a feature built on top of this — it is what a
|
|
18
|
-
* correctly built key already means. A field of the REQUEST that does not
|
|
19
|
-
* change one byte of the output must not appear here; measured 2026-09-03, two
|
|
20
|
-
* viewers of one copied picture got two sessions whose output was identical
|
|
21
|
-
* byte for byte (`research/two-viewers-one-file-2026-09-03.md`).
|
|
22
|
-
*
|
|
23
|
-
* What deliberately does NOT appear:
|
|
24
|
-
*
|
|
25
|
-
* 1. the viewer, in any form. Not the consumer id, not where they started, not
|
|
26
|
-
* their viewport. A viewer is not a property of the material;
|
|
27
|
-
* 2. the bitrate ceiling the viewer's measured link puts on a re-encode. It is
|
|
28
|
-
* a runtime parameter of a SHARED session — the worst link among the live
|
|
29
|
-
* consumers decides — so two viewers on one encode is the design, not a
|
|
30
|
-
* collision. Rate control appears in neither the SPS nor the PPS, which is
|
|
31
|
-
* why it can move under a player that has already cached the init;
|
|
32
|
-
* 3. the video track number. Only `0:v:0` is ever mapped, so the file names the
|
|
33
|
-
* picture. Add it here the day a second video track can be chosen.
|
|
34
|
-
*
|
|
35
|
-
* A limit worth stating: two specs that agree name interchangeable output
|
|
36
|
-
* within ONE proxy. A copied picture's bytes depend only on the source, but a
|
|
37
|
-
* re-encoded one's depend on this host's encoder, its preset and its rate cap —
|
|
38
|
-
* so this is not enough to reuse segments BETWEEN proxies (roadmap item 41).
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The picture an output carries.
|
|
43
|
-
*/
|
|
44
|
-
export class VideoOutput {
|
|
45
|
-
/**
|
|
46
|
-
* @param {object} params
|
|
47
|
-
* @param {number} params.fileIndex - The file the picture is read from.
|
|
48
|
-
* @param {{ width: number, height: number,
|
|
49
|
-
* Null when the picture is copied — then the output is the source's own
|
|
50
|
-
* size and nothing about a target box can change it. The box when it is
|
|
51
|
-
* re-encoded, with `
|
|
52
|
-
* budget must not move it.
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
* @param {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
* @param {
|
|
147
|
-
* @param {
|
|
148
|
-
*
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file What a session PRODUCES, stated once and used as its identity.
|
|
3
|
+
*
|
|
4
|
+
* Three domain axes already have a home in this package: what a file states
|
|
5
|
+
* about itself (`container/`), what a track states about itself (`tracks/`),
|
|
6
|
+
* and what anybody wants off the swarm (`demand/`). This is the fourth — what
|
|
7
|
+
* we make out of them — and it had no home at all: the identity of a session
|
|
8
|
+
* was a `[...].join(":")` inside a 300-line function, with no name, no test and
|
|
9
|
+
* nothing printing it.
|
|
10
|
+
*
|
|
11
|
+
* The rule it exists to express, stated by the user 2026-09-03:
|
|
12
|
+
*
|
|
13
|
+
* > A session belongs to the tracks its output actually carries. Its key holds
|
|
14
|
+
* > those tracks' parameters and nothing else. Two sessions whose parameters
|
|
15
|
+
* > agree ARE the same session, and the encoded result is reused by definition.
|
|
16
|
+
*
|
|
17
|
+
* So reuse between viewers is not a feature built on top of this — it is what a
|
|
18
|
+
* correctly built key already means. A field of the REQUEST that does not
|
|
19
|
+
* change one byte of the output must not appear here; measured 2026-09-03, two
|
|
20
|
+
* viewers of one copied picture got two sessions whose output was identical
|
|
21
|
+
* byte for byte (`research/two-viewers-one-file-2026-09-03.md`).
|
|
22
|
+
*
|
|
23
|
+
* What deliberately does NOT appear:
|
|
24
|
+
*
|
|
25
|
+
* 1. the viewer, in any form. Not the consumer id, not where they started, not
|
|
26
|
+
* their viewport. A viewer is not a property of the material;
|
|
27
|
+
* 2. the bitrate ceiling the viewer's measured link puts on a re-encode. It is
|
|
28
|
+
* a runtime parameter of a SHARED session — the worst link among the live
|
|
29
|
+
* consumers decides — so two viewers on one encode is the design, not a
|
|
30
|
+
* collision. Rate control appears in neither the SPS nor the PPS, which is
|
|
31
|
+
* why it can move under a player that has already cached the init;
|
|
32
|
+
* 3. the video track number. Only `0:v:0` is ever mapped, so the file names the
|
|
33
|
+
* picture. Add it here the day a second video track can be chosen.
|
|
34
|
+
*
|
|
35
|
+
* A limit worth stating: two specs that agree name interchangeable output
|
|
36
|
+
* within ONE proxy. A copied picture's bytes depend only on the source, but a
|
|
37
|
+
* re-encoded one's depend on this host's encoder, its preset and its rate cap —
|
|
38
|
+
* so this is not enough to reuse segments BETWEEN proxies (roadmap item 41).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The picture an output carries.
|
|
43
|
+
*/
|
|
44
|
+
export class VideoOutput {
|
|
45
|
+
/**
|
|
46
|
+
* @param {object} params
|
|
47
|
+
* @param {number} params.fileIndex - The file the picture is read from.
|
|
48
|
+
* @param {{ width: number, height: number, exactSize: boolean } | null} params.encode
|
|
49
|
+
* Null when the picture is copied — then the output is the source's own
|
|
50
|
+
* size and nothing about a target box can change it. The box when it is
|
|
51
|
+
* re-encoded, with `exactSize` saying that box is produced exactly as asked
|
|
52
|
+
* and the realtime budget must not move it.
|
|
53
|
+
*
|
|
54
|
+
* `exactSize` says NOTHING ABOUT WHO ASKED. Every rung of a master playlist
|
|
55
|
+
* sets it, whether the viewer picked that rung or the player moved itself,
|
|
56
|
+
* because two rungs allowed to drift would land on one height and the
|
|
57
|
+
* choice between them would mean nothing. It was called `manual` until
|
|
58
|
+
* 2026-09-13, and that name was read as "the viewer did this" four times
|
|
59
|
+
* over a few weeks — in field logs where the viewer had touched nothing.
|
|
60
|
+
*/
|
|
61
|
+
constructor({ fileIndex, encode = null }) {
|
|
62
|
+
this.fileIndex = Number.isInteger(fileIndex) && fileIndex >= 0 ? fileIndex : 0;
|
|
63
|
+
this.encode = encode
|
|
64
|
+
? {
|
|
65
|
+
width: Number.isInteger(encode.width) && encode.width > 0 ? encode.width : 0,
|
|
66
|
+
height: Number.isInteger(encode.height) && encode.height > 0 ? encode.height : 0,
|
|
67
|
+
exactSize: encode.exactSize === true
|
|
68
|
+
}
|
|
69
|
+
: null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
toKey() {
|
|
76
|
+
if (!this.encode) {
|
|
77
|
+
return `v=${this.fileIndex}/copy`;
|
|
78
|
+
}
|
|
79
|
+
const box = `${this.encode.width}x${this.encode.height}`;
|
|
80
|
+
return `v=${this.fileIndex}/enc:${box}:${this.encode.exactSize ? "exact" : "budget"}`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The soundtrack an output carries.
|
|
86
|
+
*/
|
|
87
|
+
export class AudioOutput {
|
|
88
|
+
/**
|
|
89
|
+
* @param {object} params
|
|
90
|
+
* @param {number} params.fileIndex - The file the TRACK lives in, which for a
|
|
91
|
+
* dub shipped beside the picture is not the picture's file.
|
|
92
|
+
* @param {number} params.trackIndex - `0:a:N` inside that file. The flat
|
|
93
|
+
* number the browser sends spans the picture's own tracks and the files
|
|
94
|
+
* beside it, and two flat numbers of two different pictures can name one
|
|
95
|
+
* track; this is the number that cannot.
|
|
96
|
+
* @param {boolean} params.transcode - Re-encoded to AAC, or copied.
|
|
97
|
+
*/
|
|
98
|
+
constructor({ fileIndex, trackIndex, transcode }) {
|
|
99
|
+
this.fileIndex = Number.isInteger(fileIndex) && fileIndex >= 0 ? fileIndex : 0;
|
|
100
|
+
this.trackIndex = Number.isInteger(trackIndex) && trackIndex >= 0 ? trackIndex : 0;
|
|
101
|
+
this.transcode = transcode === true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
toKey() {
|
|
108
|
+
return `a=${this.fileIndex}/${this.trackIndex}/${this.transcode ? "aac" : "copy"}`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Where an output is cut.
|
|
114
|
+
*
|
|
115
|
+
* Both forms belong to a FILE and not to the session: the keyframe grid is that
|
|
116
|
+
* file's own keyframe times, and the uniform grid is derived from that file's
|
|
117
|
+
* duration. A soundtrack takes the grid of the picture it accompanies, so its
|
|
118
|
+
* grid names the picture's file — which is what makes two soundtrack sessions
|
|
119
|
+
* cut for two different pictures tell themselves apart.
|
|
120
|
+
*/
|
|
121
|
+
export class CutGrid {
|
|
122
|
+
/**
|
|
123
|
+
* @param {object} params
|
|
124
|
+
* @param {"keyframe" | "uniform"} params.kind
|
|
125
|
+
* @param {number} params.fileIndex - Whose keyframes, or whose duration.
|
|
126
|
+
*/
|
|
127
|
+
constructor({ kind, fileIndex }) {
|
|
128
|
+
this.kind = kind === "keyframe" ? "keyframe" : "uniform";
|
|
129
|
+
this.fileIndex = Number.isInteger(fileIndex) && fileIndex >= 0 ? fileIndex : 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
toKey() {
|
|
136
|
+
return `grid=${this.kind === "keyframe" ? "kf" : "even"}@${this.fileIndex}`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* One encode of one torrent's material: which tracks, in what form, cut how,
|
|
142
|
+
* packaged how.
|
|
143
|
+
*/
|
|
144
|
+
export class OutputSpec {
|
|
145
|
+
/**
|
|
146
|
+
* @param {object} params
|
|
147
|
+
* @param {string} params.sourceKey - `torrent:<infohash>`, the canonical
|
|
148
|
+
* identity of the torrent itself: a magnet and a `.torrent` file for the
|
|
149
|
+
* same content produce the same one (`torrent-source-key.js`). Nothing
|
|
150
|
+
* further is needed to say WHICH film this is.
|
|
151
|
+
* @param {string} params.segmentFormatId - fMP4 or MPEG-TS. Two viewers
|
|
152
|
+
* asking for different containers cannot share one ffmpeg.
|
|
153
|
+
* @param {CutGrid} params.grid
|
|
154
|
+
* @param {VideoOutput | null} params.video
|
|
155
|
+
* @param {AudioOutput | null} params.audio
|
|
156
|
+
*/
|
|
157
|
+
constructor({ sourceKey, segmentFormatId, grid, video = null, audio = null }) {
|
|
158
|
+
this.sourceKey = String(sourceKey ?? "");
|
|
159
|
+
this.segmentFormatId = String(segmentFormatId ?? "");
|
|
160
|
+
this.grid = grid;
|
|
161
|
+
this.video = video;
|
|
162
|
+
this.audio = audio;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What this output carries, in the vocabulary the rest of the class uses.
|
|
167
|
+
*
|
|
168
|
+
* `muxed` is the one case where a session legitimately holds the parameters
|
|
169
|
+
* of two tracks: a browser that does not understand rendition groups must be
|
|
170
|
+
* sent its sound inside the picture's own stream.
|
|
171
|
+
*
|
|
172
|
+
* @returns {"video-only" | "audio-only" | "muxed" | "empty"}
|
|
173
|
+
*/
|
|
174
|
+
get carries() {
|
|
175
|
+
if (this.video && this.audio) {
|
|
176
|
+
return "muxed";
|
|
177
|
+
}
|
|
178
|
+
if (this.video) {
|
|
179
|
+
return "video-only";
|
|
180
|
+
}
|
|
181
|
+
if (this.audio) {
|
|
182
|
+
return "audio-only";
|
|
183
|
+
}
|
|
184
|
+
return "empty";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The identity. Two outputs with the same one are the same output.
|
|
189
|
+
*
|
|
190
|
+
* @returns {string}
|
|
191
|
+
*/
|
|
192
|
+
toKey() {
|
|
193
|
+
const parts = [this.sourceKey, `fmt=${this.segmentFormatId}`, this.grid.toKey(), this.carries];
|
|
194
|
+
if (this.video) {
|
|
195
|
+
parts.push(this.video.toKey());
|
|
196
|
+
}
|
|
197
|
+
if (this.audio) {
|
|
198
|
+
parts.push(this.audio.toKey());
|
|
199
|
+
}
|
|
200
|
+
return parts.join(":");
|
|
201
|
+
}
|
|
202
|
+
}
|