@torrent-tv/proxy 2.57.0 → 2.58.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 +12 -0
- package/package.json +2 -2
- package/services/container-index/matroska-subtitles.js +83 -4
- package/services/delivery-probe.js +42 -6
- package/services/subtitle-defaults.js +28 -2
- package/services/torrent-worker/worker.js +701 -646
- package/test/delivery-probe.test.js +34 -0
- package/test/subtitle-track-numbering.test.js +111 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 2.58.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: `utp-native` moves to 2.5.3-ttv.6, which removes the fault the ninth core dump named. Its stack carries no uTP frame at all — `uv_timer_stop` ← `uv_close` ← `PerIsolatePlatformData::Shutdown` ← `NodePlatform::UnregisterIsolate` ← `Worker::Run` — the torrent worker thread ending and node walking its loop into memory that had gone. `uv_udp_t` and `uv_timer_t` are fields of a struct that lives inside a JavaScript Buffer, and they are registered on the loop, so libuv holds pointers into collector-owned memory for as long as they stay registered; the thread ending takes that memory with the heap. On a main thread nobody notices because the process is ending anyway, and this proxy runs the torrent client in a worker. Three changes: a cleanup hook takes the handles off the loop before the environment goes; a plain int says whether the environment can still be called into and every callback reads it first; a strong reference holds the buffer until both handles have closed. 77 checks pass on the target.
|
|
4
|
+
- **New**: the torrent worker says why it is ending, from inside itself, before anything is torn down — `beforeExit` with what still held the loop open, `exit` with the code, and uncaught errors and rejections. The parent already had a line for an unexpected exit and it never printed once: the fault happens during the thread's own teardown, before the parent's `exit` event is delivered, so the reading that would name the cause was being eaten by the failure it was meant to explain.
|
|
5
|
+
- **Fix**: the delivery probe's allowance takes in the far end's own answering cadence, measured on the same connection. Without it the allowance collapsed to a single probe whenever the queue was empty, while a hidden tab echoes about once a second because the browser throttles its timers — 67 `association-stopped` and 66 `reverse-direction-gone` against 84 `flowing` on a connection carrying 3.4 MB/s with every queue at zero (2026-08-27).
|
|
6
|
+
|
|
7
|
+
## 2.57.1
|
|
8
|
+
|
|
9
|
+
- **New**: The flags a Matroska file states about its own subtitle tracks are read, instead of being guessed from the words a releaser typed into a track's name. `FlagForced` (`0x55AA`): "Set to 1 if the track is eligible for automatic selection by the player if it matches the user's language preference, even if the user's preferences would not normally enable subtitles with the selected audio track" — a track of signs and foreign speech, not of dialogue. `FlagHearingImpaired` (`0x55AB`): "suitable for users with hearing impairments". Both travel with the playback plan, so the browser can say which is which; until now "fors" and "SDH" in a menu were whatever text someone happened to write.
|
|
10
|
+
- **Fix**: A track the file marks unusable is no longer offered. `FlagEnabled` (`0xB9`, default 1): "Set to 1 if the track is usable." It is left out of the extraction list but KEPT in the declared one, because ffmpeg keeps it: `matroskadec.c` parses `MATROSKA_ID_TRACKFLAGENABLED` as `EBML_NONE`, reading the element and storing nothing, so the stream is created and numbered like any other. Dropping it would have shifted every track after it off `0:s:N`, which is the numbering defect 2.56.0 exists to remove. An element written with zero length carries its default, and a value outside the declared 0-1 range is not a statement either — only an explicit zero takes a track away.
|
|
11
|
+
- **Fix**: `LanguageBCP47` (`0x22B59D`) is read and takes precedence, which the specification states as a MUST: "If this element is used, then any Language elements used in the same TrackEntry MUST be ignored." A file writing `pt-BR` beside `por` was being shown as plain Portuguese. The tag replaces the code in the list this proxy owns and rides BESIDE it in the list that is lined up against ffmpeg's `-i` banner, which prints the three-letter form — reporting `pt-BR` there would break the pairing and cost the `FlagDefault` reading with it.
|
|
12
|
+
|
|
1
13
|
## 2.57.0
|
|
2
14
|
|
|
3
15
|
- **New**: the packet witness records the seconds BEFORE a freeze, not only after it. A size-bounded ring (4 x 16 MB, wrapping) runs on the WebRTC UDP port for exactly as long as a data channel is open, and its files are copied aside the moment a wedge is declared. The two field captures that placed this defect (`research/delivery-freeze-sender-silent-2026-08-26.md`) both began 30 s after the queue was already stuck, so neither holds the onset.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@torrent-tv/proxy",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.58.0",
|
|
4
4
|
"description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"publishConfig": {
|
|
@@ -45,6 +45,6 @@
|
|
|
45
45
|
"@biomejs/biome": "^2.5.7"
|
|
46
46
|
},
|
|
47
47
|
"overrides": {
|
|
48
|
-
"utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.
|
|
48
|
+
"utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.6"
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -34,6 +34,29 @@ const ID_CODEC_PRIVATE = 0x63a2;
|
|
|
34
34
|
const ID_LANGUAGE = 0x22b59c;
|
|
35
35
|
const ID_NAME = 0x536e;
|
|
36
36
|
const ID_FLAG_DEFAULT = 0x88;
|
|
37
|
+
/**
|
|
38
|
+
* The rest of what a TrackEntry says about itself, RFC 9559 §5.1.4.1. Read
|
|
39
|
+
* because the file states them and a releaser's own wording in `Name` is the
|
|
40
|
+
* only thing we had before: "fors" and "SDH" in a menu were whatever text
|
|
41
|
+
* someone happened to type.
|
|
42
|
+
*
|
|
43
|
+
* `FlagEnabled` defaults to 1 and means "the track is usable"; a track that
|
|
44
|
+
* says 0 is counted but not offered. `FlagForced` applies only to subtitles and
|
|
45
|
+
* defaults to 0. `FlagHearingImpaired` is set "if and only if the track is
|
|
46
|
+
* suitable for users with hearing impairments". `FlagVisualImpaired`,
|
|
47
|
+
* `FlagOriginal` and `FlagCommentary` bear on the AUDIO choice and are read
|
|
48
|
+
* with that work, not here — see roadmap item 55.
|
|
49
|
+
*/
|
|
50
|
+
const ID_FLAG_ENABLED = 0xb9;
|
|
51
|
+
const ID_FLAG_FORCED = 0x55aa;
|
|
52
|
+
const ID_FLAG_HEARING_IMPAIRED = 0x55ab;
|
|
53
|
+
/**
|
|
54
|
+
* The language as RFC 5646 writes it. The specification is a MUST: "If this
|
|
55
|
+
* element is used, then any Language elements used in the same TrackEntry MUST
|
|
56
|
+
* be ignored" — so where both are present, this one is the answer and the
|
|
57
|
+
* three-letter code is not.
|
|
58
|
+
*/
|
|
59
|
+
const ID_LANGUAGE_BCP47 = 0x22b59d;
|
|
37
60
|
const ID_CUES = 0x1c53bb6b;
|
|
38
61
|
const ID_CUE_POINT = 0xbb;
|
|
39
62
|
const ID_CUE_TRACK_POSITIONS = 0xb7;
|
|
@@ -69,9 +92,18 @@ const TEXT_CODECS = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA"]);
|
|
|
69
92
|
* ever names. Text tracks alone are not a numbering: a file whose PGS track
|
|
70
93
|
* comes first would have every text track one lower here than in the browser.
|
|
71
94
|
* @property {string} codecId
|
|
72
|
-
* @property {string} language - The
|
|
95
|
+
* @property {string} language - The language the file declares: its RFC 5646
|
|
96
|
+
* tag where it writes one, and the three-letter code otherwise. The
|
|
97
|
+
* specification requires that order — where `LanguageBCP47` is present, the
|
|
98
|
+
* `Language` element MUST be ignored.
|
|
99
|
+
* @property {string} languageBcp47 - The RFC 5646 tag alone, or "".
|
|
73
100
|
* @property {string} name - What the file calls the track, if anything.
|
|
74
101
|
* @property {boolean} isDefault
|
|
102
|
+
* @property {boolean} isForced - `FlagForced`: the track carries what a viewer
|
|
103
|
+
* needs even when they asked for no subtitles — signs, and dialogue in
|
|
104
|
+
* another language. It does NOT carry the film's own dialogue.
|
|
105
|
+
* @property {boolean} isHearingImpaired - `FlagHearingImpaired`: suitable for
|
|
106
|
+
* viewers who cannot hear, so it carries non-speech sound as well as speech.
|
|
75
107
|
* @property {string} codecPrivate - The ASS/SSA header, base64, or "".
|
|
76
108
|
* @property {number[]} clusterPositions - File offsets of clusters whose cue
|
|
77
109
|
* points name this track, ascending. Empty when the file indexes only its
|
|
@@ -156,6 +188,12 @@ export async function readSubtitlePlan(readRange, fileSize) {
|
|
|
156
188
|
// amounts to, and whether the file said anything at all.
|
|
157
189
|
let isDefault = true;
|
|
158
190
|
let declaresDefault = false;
|
|
191
|
+
// Defaults straight from RFC 9559: a track is usable and not forced unless
|
|
192
|
+
// the file says otherwise, and the impaired flags are absent until claimed.
|
|
193
|
+
let isEnabled = true;
|
|
194
|
+
let isForced = false;
|
|
195
|
+
let isHearingImpaired = false;
|
|
196
|
+
let languageBcp47 = "";
|
|
159
197
|
for (const field of iterateElements(head, entry.dataOffset, entryEnd)) {
|
|
160
198
|
if (field.id === ID_TRACK_NUMBER) {
|
|
161
199
|
trackNumber = readUint(head, field.dataOffset, field.size);
|
|
@@ -165,11 +203,23 @@ export async function readSubtitlePlan(readRange, fileSize) {
|
|
|
165
203
|
codecId = readString(head, field);
|
|
166
204
|
} else if (field.id === ID_LANGUAGE) {
|
|
167
205
|
language = readString(head, field);
|
|
206
|
+
} else if (field.id === ID_LANGUAGE_BCP47) {
|
|
207
|
+
languageBcp47 = readString(head, field);
|
|
168
208
|
} else if (field.id === ID_NAME) {
|
|
169
209
|
name = readString(head, field);
|
|
170
210
|
} else if (field.id === ID_FLAG_DEFAULT) {
|
|
171
211
|
isDefault = readUint(head, field.dataOffset, field.size) === 1;
|
|
172
212
|
declaresDefault = true;
|
|
213
|
+
} else if (field.id === ID_FLAG_ENABLED) {
|
|
214
|
+
// An element written with zero length carries its default, which for
|
|
215
|
+
// this one is 1 — so an empty element must not read as "unusable", and
|
|
216
|
+
// neither must a value outside the declared 0-1 range. Only an explicit
|
|
217
|
+
// zero takes a track away.
|
|
218
|
+
isEnabled = field.size === 0 || readUint(head, field.dataOffset, field.size) !== 0;
|
|
219
|
+
} else if (field.id === ID_FLAG_FORCED) {
|
|
220
|
+
isForced = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
|
|
221
|
+
} else if (field.id === ID_FLAG_HEARING_IMPAIRED) {
|
|
222
|
+
isHearingImpaired = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
|
|
173
223
|
} else if (field.id === ID_CODEC_PRIVATE) {
|
|
174
224
|
codecPrivate = head.toString("base64", field.dataOffset, field.dataOffset + field.size);
|
|
175
225
|
}
|
|
@@ -177,17 +227,46 @@ export async function readSubtitlePlan(readRange, fileSize) {
|
|
|
177
227
|
if (type !== TRACK_TYPE_SUBTITLE || trackNumber === null) {
|
|
178
228
|
continue;
|
|
179
229
|
}
|
|
180
|
-
|
|
181
|
-
|
|
230
|
+
// A track the file marks unusable is still COUNTED. FlagEnabled says "the
|
|
231
|
+
// track is usable", and a player should not offer it — but ffmpeg does not
|
|
232
|
+
// drop it: `matroskadec.c` parses `MATROSKA_ID_TRACKFLAGENABLED` as
|
|
233
|
+
// `EBML_NONE`, reading the element and keeping nothing, so the stream is
|
|
234
|
+
// created and numbered like any other. Leaving it out of this list would
|
|
235
|
+
// therefore shift `declaredIndex` off ffmpeg's `0:s:N` for every track
|
|
236
|
+
// after it, which is the numbering defect this file was fixed for a day
|
|
237
|
+
// earlier. It is counted here and refused where it is offered instead.
|
|
238
|
+
//
|
|
239
|
+
// `language` here stays the three-letter code, because this list exists to
|
|
240
|
+
// be lined up against ffmpeg's banner, which prints that code. The RFC 5646
|
|
241
|
+
// tag rides beside it for whoever displays the track.
|
|
242
|
+
declared.push({
|
|
243
|
+
trackNumber,
|
|
244
|
+
codecId,
|
|
245
|
+
language,
|
|
246
|
+
languageBcp47,
|
|
247
|
+
name,
|
|
248
|
+
isDefault,
|
|
249
|
+
declaresDefault,
|
|
250
|
+
isEnabled,
|
|
251
|
+
isForced,
|
|
252
|
+
isHearingImpaired
|
|
253
|
+
});
|
|
254
|
+
if (!TEXT_CODECS.has(codecId) || !isEnabled) {
|
|
182
255
|
continue;
|
|
183
256
|
}
|
|
184
257
|
tracks.push({
|
|
185
258
|
trackNumber,
|
|
186
259
|
declaredIndex: declared.length - 1,
|
|
187
260
|
codecId,
|
|
188
|
-
|
|
261
|
+
// This list is ours and is not compared with ffmpeg's, so it carries the
|
|
262
|
+
// language the file states most precisely: where RFC 5646 is written, the
|
|
263
|
+
// three-letter code MUST be ignored.
|
|
264
|
+
language: languageBcp47 || language,
|
|
265
|
+
languageBcp47,
|
|
189
266
|
name,
|
|
190
267
|
isDefault,
|
|
268
|
+
isForced,
|
|
269
|
+
isHearingImpaired,
|
|
191
270
|
codecPrivate,
|
|
192
271
|
clusterPositions: []
|
|
193
272
|
});
|
|
@@ -58,16 +58,33 @@ export const PROBE_INTERVAL_MS = 500;
|
|
|
58
58
|
* bytes away. Add one round trip for the echo to come back. A probe is behind
|
|
59
59
|
* only when it is later than that.
|
|
60
60
|
*
|
|
61
|
-
*
|
|
61
|
+
* There is a third term, and leaving it out made this worse than the constant
|
|
62
|
+
* it replaced. The browser answers on ITS own schedule, not ours: it batches
|
|
63
|
+
* what it has seen and echoes on a timer, and that timer is throttled to about
|
|
64
|
+
* once a second whenever the tab is hidden. So with an empty queue the
|
|
65
|
+
* allowance collapsed to one probe and the bound to half a second, while echoes
|
|
66
|
+
* legitimately arrived every second - measured 2026-08-27, 67 `association-
|
|
67
|
+
* stopped` and 66 `reverse-direction-gone` against 84 `flowing` on a connection
|
|
68
|
+
* carrying 3.4 MB/s with every queue at zero. The peer's own cadence is
|
|
69
|
+
* measurable on the same connection, so it is measured and added rather than
|
|
70
|
+
* assumed.
|
|
71
|
+
*
|
|
72
|
+
* @param {{ queuedBytes: number, bytesPerSecond: number, rttMs: number, echoIntervalMs?: number, intervalMs?: number }} state
|
|
62
73
|
* @returns {number | null} Probes that may legitimately be outstanding, or null
|
|
63
74
|
* when no rate has been measured yet and nothing can be said.
|
|
64
75
|
*/
|
|
65
|
-
export function allowedGap({
|
|
76
|
+
export function allowedGap({
|
|
77
|
+
queuedBytes,
|
|
78
|
+
bytesPerSecond,
|
|
79
|
+
rttMs,
|
|
80
|
+
echoIntervalMs = 0,
|
|
81
|
+
intervalMs = PROBE_INTERVAL_MS
|
|
82
|
+
}) {
|
|
66
83
|
if (!(bytesPerSecond > 0) || !(intervalMs > 0)) {
|
|
67
84
|
return null;
|
|
68
85
|
}
|
|
69
86
|
const drainMs = (Math.max(queuedBytes, 0) / bytesPerSecond) * 1000;
|
|
70
|
-
const waitMs = drainMs + Math.max(rttMs, 0);
|
|
87
|
+
const waitMs = drainMs + Math.max(rttMs, 0) + Math.max(echoIntervalMs, 0);
|
|
71
88
|
// At least one: a probe sent and not yet echoed is the ordinary state.
|
|
72
89
|
return Math.max(1, Math.ceil(waitMs / intervalMs));
|
|
73
90
|
}
|
|
@@ -231,7 +248,13 @@ export function createDeliveryProbe({ log, intervalMs = PROBE_INTERVAL_MS, readD
|
|
|
231
248
|
} catch {
|
|
232
249
|
queuedBytes = 0;
|
|
233
250
|
}
|
|
234
|
-
const allowance = allowedGap({
|
|
251
|
+
const allowance = allowedGap({
|
|
252
|
+
queuedBytes,
|
|
253
|
+
bytesPerSecond,
|
|
254
|
+
rttMs,
|
|
255
|
+
echoIntervalMs: connection.echoIntervalMs,
|
|
256
|
+
intervalMs
|
|
257
|
+
});
|
|
235
258
|
// Several channels can carry one label only in malformed cases; the
|
|
236
259
|
// larger allowance is the safer of the two.
|
|
237
260
|
const held = allowed.get(label);
|
|
@@ -252,7 +275,9 @@ export function createDeliveryProbe({ log, intervalMs = PROBE_INTERVAL_MS, readD
|
|
|
252
275
|
echoes: connection.echoes,
|
|
253
276
|
echoAgeMs: connection.echoAt === 0 ? null : now - connection.echoAt,
|
|
254
277
|
allowed,
|
|
255
|
-
|
|
278
|
+
// Same arithmetic for the echo's own age: the peer cannot answer sooner
|
|
279
|
+
// than its own cadence allows, and a hidden tab's is about a second.
|
|
280
|
+
echoStaleMs: widest > 0 ? widest * intervalMs + rttMs + connection.echoIntervalMs : 0
|
|
256
281
|
});
|
|
257
282
|
if (verdict !== connection.verdict || now - connection.reportedAt >= REPORT_INTERVAL_MS) {
|
|
258
283
|
connection.verdict = verdict;
|
|
@@ -268,6 +293,10 @@ export function createDeliveryProbe({ log, intervalMs = PROBE_INTERVAL_MS, readD
|
|
|
268
293
|
connection = {
|
|
269
294
|
id: sessionId,
|
|
270
295
|
tag,
|
|
296
|
+
// The longest this peer has ever taken between two echoes. Its own
|
|
297
|
+
// schedule, measured rather than assumed - a hidden tab answers
|
|
298
|
+
// about once a second because the browser throttles the timer.
|
|
299
|
+
echoIntervalMs: 0,
|
|
271
300
|
channels: new Map(),
|
|
272
301
|
seq: 0,
|
|
273
302
|
sentAt: 0,
|
|
@@ -321,7 +350,14 @@ export function createDeliveryProbe({ log, intervalMs = PROBE_INTERVAL_MS, readD
|
|
|
321
350
|
}
|
|
322
351
|
}
|
|
323
352
|
}
|
|
324
|
-
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
if (connection.echoAt !== 0) {
|
|
355
|
+
const sinceLast = now - connection.echoAt;
|
|
356
|
+
if (sinceLast > connection.echoIntervalMs) {
|
|
357
|
+
connection.echoIntervalMs = sinceLast;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
connection.echoAt = now;
|
|
325
361
|
connection.echoes += 1;
|
|
326
362
|
},
|
|
327
363
|
|
|
@@ -95,7 +95,20 @@ export function mergeContainerSubtitleFlags(bannerTracks, declared) {
|
|
|
95
95
|
const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
|
|
96
96
|
const container = Array.isArray(declared) ? declared : [];
|
|
97
97
|
const undecided = () => ({
|
|
98
|
-
|
|
98
|
+
// The container reading could not be lined up, so nothing of it is used —
|
|
99
|
+
// including the flags, which would otherwise be attributed to the wrong
|
|
100
|
+
// track.
|
|
101
|
+
tracks: banner.map((track) => ({
|
|
102
|
+
...track,
|
|
103
|
+
declaresDefault: false,
|
|
104
|
+
isForced: false,
|
|
105
|
+
isHearingImpaired: false,
|
|
106
|
+
// Not "the container says this track is unusable" — nothing of the
|
|
107
|
+
// container is being used here. A track is offered unless it was read to
|
|
108
|
+
// say otherwise.
|
|
109
|
+
isEnabled: true,
|
|
110
|
+
languageBcp47: ""
|
|
111
|
+
}))
|
|
99
112
|
});
|
|
100
113
|
if (container.length === 0) {
|
|
101
114
|
return { ...undecided(), aligned: false, reason: "the container declares no subtitle track" };
|
|
@@ -123,7 +136,20 @@ export function mergeContainerSubtitleFlags(bannerTracks, declared) {
|
|
|
123
136
|
tracks: banner.map((track, order) => ({
|
|
124
137
|
...track,
|
|
125
138
|
isDefault: container[order].isDefault === true,
|
|
126
|
-
declaresDefault: container[order].declaresDefault === true
|
|
139
|
+
declaresDefault: container[order].declaresDefault === true,
|
|
140
|
+
// Read from the file rather than guessed from the track's name. Both are
|
|
141
|
+
// stated by the container itself (RFC 9559 §5.1.4.1) and neither reaches
|
|
142
|
+
// ffmpeg's `-i` banner, which is where every other field here comes from.
|
|
143
|
+
isForced: container[order].isForced === true,
|
|
144
|
+
isHearingImpaired: container[order].isHearingImpaired === true,
|
|
145
|
+
// FlagEnabled, so the browser can leave an unusable track out of the
|
|
146
|
+
// menu. It stays in this list and keeps its number: ffmpeg creates a
|
|
147
|
+
// stream for it either way.
|
|
148
|
+
isEnabled: container[order].isEnabled !== false,
|
|
149
|
+
// The RFC 5646 tag, where the file writes one. Kept beside the code
|
|
150
|
+
// rather than replacing it: what this list is aligned against is ffmpeg's
|
|
151
|
+
// banner, which prints the three-letter form.
|
|
152
|
+
languageBcp47: typeof container[order].languageBcp47 === "string" ? container[order].languageBcp47 : ""
|
|
127
153
|
})),
|
|
128
154
|
aligned: true,
|
|
129
155
|
reason: ""
|