@torrent-tv/proxy 2.9.2 → 2.9.4
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 +9 -0
- package/CLAUDE.md +62 -0
- package/package.json +1 -1
- package/services/data-channel-handler.js +121 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.9.4
|
|
2
|
+
|
|
3
|
+
- **Chore**: Temporary `[net-debug]` instrumentation in `data-channel-handler.js` now splits transfer timing into `fetchMs` (waiting for the local route, incl. ffmpeg segment finalization), `ttfbMs` (time to first body chunk), `sendMs` (channel send duration) and `chunks`, to locate where early-segment latency is spent (transport vs segment production).
|
|
4
|
+
|
|
5
|
+
## 2.9.3
|
|
6
|
+
|
|
7
|
+
- **New**: WebRTC data-channel response bodies are now sent as **binary** frames (`sendMessageBinary`) instead of base64-encoded JSON `response-chunk` messages, removing the ~33% base64 overhead and the JSON encode cost. Frame layout: `[flags(1)][idLen(1)][requestId(ASCII)][payload]`. Control messages (`response-start`, `response-error`, `pong`) remain JSON strings. Requires the matching browser client (server ≥ 0.8.0); **deploy the server before the proxy**.
|
|
8
|
+
- **New**: Backpressure on the send loop — `data-channel-handler.js` pauses queuing body chunks once the channel's `bufferedAmount()` exceeds 8 MB and resumes when it drains below 1 MB (`setBufferedAmountLowThreshold` + `onBufferedAmountLow`), with a 5 s timeout fallback. Prevents the SCTP send buffer from ballooning and stalling throughput.
|
|
9
|
+
|
|
1
10
|
## 2.6.3
|
|
2
11
|
|
|
3
12
|
- **Fix**: Data channel handler now logs **all** requests regardless of body presence — `GET /transcode/…`, `GET /api/…/progress`, `GET /api/…/stats` etc. were previously invisible in logs. Non-2xx response statuses and fetch errors are also logged, enabling diagnosis of HLS manifest load failures.
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# proxy — @torrent-tv/proxy (WebTorrent + ffmpeg)
|
|
2
|
+
|
|
3
|
+
Downloads a torrent and streams the chosen file to the browser, transcoding to
|
|
4
|
+
HLS only when needed. See the parent `../CLAUDE.md` for the overall architecture
|
|
5
|
+
and release process.
|
|
6
|
+
|
|
7
|
+
## Deployment-agnostic — important
|
|
8
|
+
|
|
9
|
+
The HA addon is only ONE way to run this; bare npm and Docker are planned. Keep
|
|
10
|
+
all code free of Home-Assistant assumptions. Anything host-specific (GPU
|
|
11
|
+
devices, ffmpeg build, CLI flags) belongs in the `ha-addon` layer. Hardware
|
|
12
|
+
detection must probe at runtime and fall back gracefully; do not assume a
|
|
13
|
+
Linux-only host (e.g. POSIX-only signals must degrade elsewhere).
|
|
14
|
+
|
|
15
|
+
## Layout
|
|
16
|
+
|
|
17
|
+
- `bin/cli.js` — CLI entry; resolves `ffmpegBin` (uses `--ffmpeg-bin` if given,
|
|
18
|
+
else bundled ffmpeg-static, else PATH `ffmpeg`).
|
|
19
|
+
- `server.js` — Fastify setup; detects the video encoder at startup
|
|
20
|
+
(`detectVideoEncoder`) and passes it to `HlsSessionManager`.
|
|
21
|
+
- `routes/<path>/<method>.js` — same convention as the server repo.
|
|
22
|
+
- `routes/stream/get.js` — byte-range torrent file streaming (HTTP 206).
|
|
23
|
+
- `routes/api/playback-plan/post.js` — codec/container/duration probe result.
|
|
24
|
+
- `routes/transcode/session-file/get.js` — serves HLS playlist/segments;
|
|
25
|
+
long-polls while a segment is being produced, returns retryable 503 (never
|
|
26
|
+
202 — hls.js can't consume it).
|
|
27
|
+
- `services/`:
|
|
28
|
+
- `playback-planner.js` — single ffmpeg probe returns audioCodec, videoCodec,
|
|
29
|
+
container, durationSeconds. `mode` is advisory; the browser decides.
|
|
30
|
+
- `hls-session-manager.js` — one ffmpeg per (source, file, settings). Serves a
|
|
31
|
+
synthetic full-duration VOD playlist; produces segments on demand; restarts
|
|
32
|
+
ffmpeg at the requested segment for server-side seeking. Short idle TTL.
|
|
33
|
+
Uses the detected `videoEncoder` for video re-encode (copy otherwise).
|
|
34
|
+
- `hwaccel.js` — detect best H.264 encoder (NVENC/QSV/VAAPI/V4L2M2M) with a
|
|
35
|
+
STRICT startup test: encode `testsrc2` through the real HLS pipeline, then
|
|
36
|
+
verify each segment decodes independently (catches non-IDR/corrupted hw
|
|
37
|
+
output). Falls back to software libx264. Runtime fallback to software if a
|
|
38
|
+
hw encode later fails. v4l2m2m is gated by this test (fails on HA Yellow).
|
|
39
|
+
- `data-channel-handler.js` — forwards WebRTC data-channel requests to the
|
|
40
|
+
local HTTP server (loopback), so the same routes serve both transports.
|
|
41
|
+
|
|
42
|
+
## Gotchas
|
|
43
|
+
|
|
44
|
+
- Do NOT use `-hls_playlist_type event` — it breaks duration/seek. VOD only.
|
|
45
|
+
- A transitive dep (`ip-set`, via webtorrent) ships a hostile
|
|
46
|
+
`preinstall: npx only-allow pnpm` that breaks `npm install`. The addon works
|
|
47
|
+
around it with `--ignore-scripts` + a targeted rebuild of `node-datachannel`;
|
|
48
|
+
if you ever change install flow, keep that in mind.
|
|
49
|
+
|
|
50
|
+
## Changelog
|
|
51
|
+
|
|
52
|
+
Every behavioural change must be recorded in `CHANGELOG.md` — add an entry under
|
|
53
|
+
a new `## <version>` heading at the top (the next patch version that
|
|
54
|
+
`npm run patch` will publish), following the existing
|
|
55
|
+
`- **New**/**Fix**/**Chore**:` format. See the parent `../CLAUDE.md`.
|
|
56
|
+
|
|
57
|
+
## Release
|
|
58
|
+
|
|
59
|
+
`npm run patch` (publishes to npm + pushes tags). The HA addon then needs its
|
|
60
|
+
own version bump to pull the new package. Publish proxy BEFORE bumping the addon.
|
|
61
|
+
|
|
62
|
+
**Any proxy change requires bumping the ha-addon version** (`ha-addon/torrent_tv_proxy/config.yaml`). The addon installs the proxy from npm at build time and the build is cached; without a version bump the plugin will NOT update and keeps running the old proxy. So after `npm run patch`, always bump the addon `config.yaml` version, push, and update the addon.
|
package/package.json
CHANGED
|
@@ -16,12 +16,23 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Proxy → Browser
|
|
18
18
|
* ```
|
|
19
|
-
* { type: "response-start", requestId, status, headers }
|
|
20
|
-
* { type: "response-
|
|
21
|
-
* { type: "
|
|
22
|
-
* { type: "pong", id }
|
|
19
|
+
* { type: "response-start", requestId, status, headers } (JSON string)
|
|
20
|
+
* { type: "response-error", requestId, error: string } (JSON string)
|
|
21
|
+
* { type: "pong", id } (JSON string)
|
|
23
22
|
* ```
|
|
24
23
|
*
|
|
24
|
+
* Response bodies are sent as BINARY data-channel messages (not JSON), to
|
|
25
|
+
* avoid the ~33% base64 overhead and the JSON encode/decode cost. Each binary
|
|
26
|
+
* frame is laid out as:
|
|
27
|
+
* ```
|
|
28
|
+
* byte 0 flags (bit 0: done)
|
|
29
|
+
* byte 1 idLen (length of the requestId in bytes)
|
|
30
|
+
* bytes 2..2+N requestId (ASCII)
|
|
31
|
+
* bytes 2+N.. payload (raw body bytes; empty on the final done frame)
|
|
32
|
+
* ```
|
|
33
|
+
* Control messages stay JSON strings so the browser can distinguish them from
|
|
34
|
+
* body frames by message type (string vs ArrayBuffer).
|
|
35
|
+
*
|
|
25
36
|
* The protocol mirrors the tunnel relay protocol so both transports share
|
|
26
37
|
* the same mental model and the same browser-side `WebRtcProxy` implementation.
|
|
27
38
|
*/
|
|
@@ -151,6 +162,10 @@ export function createDataChannelHandler({ proxyPort, onLog }) {
|
|
|
151
162
|
const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
|
|
152
163
|
|
|
153
164
|
let response;
|
|
165
|
+
// [net-debug] TEMPORARY: time spent in the local fetch (waiting for the
|
|
166
|
+
// route to return a response — e.g. long-polling until an HLS segment is
|
|
167
|
+
// finalized by ffmpeg) vs. the body transfer over the data channel.
|
|
168
|
+
const fetchStartedAt = Date.now();
|
|
154
169
|
try {
|
|
155
170
|
response = await fetch(targetUrl, {
|
|
156
171
|
method,
|
|
@@ -177,30 +192,116 @@ export function createDataChannelHandler({ proxyPort, onLog }) {
|
|
|
177
192
|
send(channel, { type: "response-start", requestId, status: response.status, headers: responseHeaders });
|
|
178
193
|
|
|
179
194
|
if (!response.body) {
|
|
180
|
-
|
|
195
|
+
sendChunk(channel, requestId, null, true);
|
|
181
196
|
return;
|
|
182
197
|
}
|
|
183
198
|
|
|
184
199
|
try {
|
|
185
200
|
const reader = response.body.getReader();
|
|
201
|
+
// [net-debug] TEMPORARY: measure transfer size/time and channel buffering.
|
|
202
|
+
// fetchMs = time waiting for the route (incl. ffmpeg segment finalization).
|
|
203
|
+
// ttfbMs = time from body-read start to the first chunk with data (loopback).
|
|
204
|
+
// sendMs = total body read+send duration over the data channel.
|
|
205
|
+
const fetchMs = Date.now() - fetchStartedAt;
|
|
206
|
+
const sendStartedAt = Date.now();
|
|
207
|
+
let firstByteMs = -1;
|
|
208
|
+
let chunks = 0;
|
|
209
|
+
let totalBytes = 0;
|
|
210
|
+
let maxBuffered = 0;
|
|
186
211
|
while (true) {
|
|
187
212
|
const { done, value } = await reader.read();
|
|
188
213
|
if (done) {
|
|
189
|
-
|
|
214
|
+
sendChunk(channel, requestId, null, true);
|
|
215
|
+
const elapsedMs = Date.now() - sendStartedAt;
|
|
216
|
+
let bufferedNow = 0;
|
|
217
|
+
try { bufferedNow = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0; } catch { /* ignore */ }
|
|
218
|
+
log(
|
|
219
|
+
`[net-debug] sent ${path}${queryInfo} bytes=${totalBytes} fetchMs=${fetchMs} ` +
|
|
220
|
+
`ttfbMs=${firstByteMs} sendMs=${elapsedMs} chunks=${chunks} ` +
|
|
221
|
+
`maxBuffered=${maxBuffered} bufferedAtEnd=${bufferedNow}`
|
|
222
|
+
);
|
|
190
223
|
break;
|
|
191
224
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
225
|
+
if (firstByteMs < 0) firstByteMs = Date.now() - sendStartedAt;
|
|
226
|
+
chunks += 1;
|
|
227
|
+
totalBytes += value.length;
|
|
228
|
+
try {
|
|
229
|
+
const b = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
|
|
230
|
+
if (b > maxBuffered) maxBuffered = b;
|
|
231
|
+
} catch { /* ignore */ }
|
|
232
|
+
sendChunk(channel, requestId, value, false);
|
|
233
|
+
// Backpressure: do not keep queuing chunks once the channel's outgoing
|
|
234
|
+
// buffer is large — wait for it to drain. Prevents the SCTP send buffer
|
|
235
|
+
// from ballooning, which stalls throughput.
|
|
236
|
+
await waitForBufferDrain(channel);
|
|
198
237
|
}
|
|
199
238
|
} catch {
|
|
200
|
-
|
|
239
|
+
sendChunk(channel, requestId, null, true);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Send a response body frame as a BINARY data-channel message.
|
|
245
|
+
* Layout: [flags(1)][idLen(1)][requestId(ASCII)][payload].
|
|
246
|
+
*
|
|
247
|
+
* @param {DataChannel} channel
|
|
248
|
+
* @param {string} requestId
|
|
249
|
+
* @param {Uint8Array | null} bytes - Body bytes, or null/empty for the done frame.
|
|
250
|
+
* @param {boolean} done
|
|
251
|
+
* @returns {void}
|
|
252
|
+
*/
|
|
253
|
+
function sendChunk(channel, requestId, bytes, done) {
|
|
254
|
+
try {
|
|
255
|
+
const idBuf = Buffer.from(requestId, "ascii");
|
|
256
|
+
const header = Buffer.allocUnsafe(2 + idBuf.length);
|
|
257
|
+
header[0] = done ? 1 : 0;
|
|
258
|
+
header[1] = idBuf.length;
|
|
259
|
+
idBuf.copy(header, 2);
|
|
260
|
+
const frame =
|
|
261
|
+
bytes && bytes.length > 0 ? Buffer.concat([header, Buffer.from(bytes)]) : header;
|
|
262
|
+
channel.sendMessageBinary(frame);
|
|
263
|
+
} catch {
|
|
264
|
+
// Channel closed between check and send — safe to ignore.
|
|
201
265
|
}
|
|
202
266
|
}
|
|
203
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Resolve once the channel's outgoing buffer has drained below the low-water
|
|
270
|
+
* mark. No-op (resolves immediately) when the buffer is already small or the
|
|
271
|
+
* channel does not expose buffer APIs. A timeout fallback guards against a
|
|
272
|
+
* missed low-water event so the send loop can never deadlock.
|
|
273
|
+
*
|
|
274
|
+
* @param {DataChannel} channel
|
|
275
|
+
* @returns {Promise<void>}
|
|
276
|
+
*/
|
|
277
|
+
function waitForBufferDrain(channel) {
|
|
278
|
+
return new Promise((resolve) => {
|
|
279
|
+
try {
|
|
280
|
+
if (typeof channel.bufferedAmount !== "function" || channel.bufferedAmount() <= DC_BUFFER_HIGH_WATER) {
|
|
281
|
+
resolve();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
let settled = false;
|
|
285
|
+
const done = () => {
|
|
286
|
+
if (settled) return;
|
|
287
|
+
settled = true;
|
|
288
|
+
resolve();
|
|
289
|
+
};
|
|
290
|
+
channel.setBufferedAmountLowThreshold(DC_BUFFER_LOW_WATER);
|
|
291
|
+
channel.onBufferedAmountLow(done);
|
|
292
|
+
// Guard against a race where the buffer drained between the check above
|
|
293
|
+
// and registering the callback (the low-water event would never fire).
|
|
294
|
+
if (channel.bufferedAmount() <= DC_BUFFER_LOW_WATER) {
|
|
295
|
+
done();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
setTimeout(done, DC_BUFFER_DRAIN_TIMEOUT_MS);
|
|
299
|
+
} catch {
|
|
300
|
+
resolve();
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
204
305
|
/**
|
|
205
306
|
* Serialise `message` to JSON and send it over the data channel.
|
|
206
307
|
* Errors are silently swallowed — the channel may have closed between
|
|
@@ -226,3 +327,10 @@ export function createDataChannelHandler({ proxyPort, onLog }) {
|
|
|
226
327
|
* Only the known proxy API and streaming routes are accepted.
|
|
227
328
|
*/
|
|
228
329
|
const PATH_ALLOWLIST_RE = /^(?:\/api\/|\/stream(?:$|\?)|\/?transcode\/|\/health(?:z)?(?:$|\?))/;
|
|
330
|
+
|
|
331
|
+
/** Pause sending body chunks once the channel buffer exceeds this many bytes. */
|
|
332
|
+
const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
|
|
333
|
+
/** Resume sending once the channel buffer drains to this many bytes. */
|
|
334
|
+
const DC_BUFFER_LOW_WATER = 1 * 1024 * 1024;
|
|
335
|
+
/** Safety fallback so the send loop cannot deadlock on a missed drain event. */
|
|
336
|
+
const DC_BUFFER_DRAIN_TIMEOUT_MS = 5000;
|