@torrent-tv/proxy 2.9.33 → 2.9.35

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 CHANGED
@@ -1,3 +1,11 @@
1
+ ## 2.9.35
2
+
3
+ - **Fix**: Large torrents (many files / seasons) no longer fail with "Trying to send message larger than max-message-size" when a file is picked. The browser sends the source registration body — the base64-encoded `.torrent` — in a single data-channel message; a big multi-season pack's `.torrent` carries thousands of piece hashes (e.g. Poirot, 13 seasons: 420 KB → ~560 KB base64), exceeding libdatachannel's default advertised limit of 256 KB, so the browser's `channel.send()` threw. The proxy now advertises a 16 MB `a=max-message-size`, so the browser permits the large send. Responses (proxy→browser) were already safe — they stream in small chunks. Verified the SDP now carries `a=max-message-size:16777216` (was `262144`).
4
+
5
+ ## 2.9.34
6
+
7
+ - **New**: Cold-start reduction (OpenSpec change `cold-start`). Creating a transcode session no longer runs a second full ffmpeg input scan: the playback planner caches the media info (duration/resolution/fps/start-time/HDR) parsed from the probe it already ran, and `createSession` reuses it (falling back to its own probe only when the cache cannot serve — e.g. after a restart, or a missing critical field). The banner parsers now live in a shared `ffmpeg-banner.js` so both sides parse identically. Once a plan probe succeeds the proxy also warms the START of the file body (~16 MB, fire-and-forget) so the first segment's encode reads downloaded data instead of waiting on pieces. Session startup is now measurable in the log: `cold-start <id>: media-info=<ms> (cached|probed) keyframes=<ms|skipped> create-total=<ms>` and, once per session, `cold-start <id>: first-segment ready +<ms>`.
8
+
1
9
  ## 2.9.33
2
10
 
3
11
  - **New**: HDR / 10-bit tone mapping (OpenSpec change `transcode-quality`, part 3). An HDR source (BT.2020 with a PQ `smpte2084` or HLG `arib-std-b67` transfer) re-encoded to 8-bit SDR without tone mapping looks washed-out and desaturated. The proxy now detects HDR from the probe and, when re-encoding video on the software path, inserts a `zscale`+`tonemap` (hable) chain to convert HDR→BT.709 SDR properly. It is **gated on filter availability**: at startup the proxy checks this ffmpeg build for the `zscale` (libzimg) and `tonemap` filters (`hwaccel: HDR tone mapping available/unavailable …`); when either is missing it falls back to the previous plain 8-bit convert (still plays, just washed-out). The tone map runs after the downscale (cheaper on ARM). Logged per session as `hdr=1 tonemap=on|off`. Hardware encoders keep their current path for now (tone mapping there is a follow-up). No client change — the browser plays the resulting SDR HLS.
@@ -0,0 +1,98 @@
1
+ # Design: Cold-start reduction (proxy side)
2
+
3
+ Written to be executed as specified. Read before coding:
4
+
5
+ - `services/playback-planner.js`: `probeStreamCodecs` (how the ffmpeg banner
6
+ is parsed today), `getPlan` (prefetch → probe → pending loop → plan cache
7
+ and its key/invalidation).
8
+ - `services/hls-session-manager.js`: `probeInputMediaInfo` (~line 383 — what
9
+ it parses: durationSeconds, width, height, fps, startTime, isHdr),
10
+ `createSession` (~line 806: the probe call at ~817, keyframe probe at
11
+ ~854), the segment-ready path (where segment 00000 first becomes
12
+ servable — grep the long-poll the `routes/transcode/session-file/get.js`
13
+ route uses).
14
+ - `services/torrent-pool.js`: `prefetchFileEdges` (~line 608) — signature
15
+ `(torrent, fileIndex, { headBytes, tailBytes, timeoutMs })`.
16
+ - `server.js`: how `playbackPlanner` and `hlsSessionManager` are constructed
17
+ and injected.
18
+
19
+ ## 1. Media-info reuse (kill ffmpeg scan #2)
20
+
21
+ Both probes run ffmpeg over the same input URL and parse the same stderr
22
+ banner; they just extract different fields. Unify:
23
+
24
+ 1. Extract the banner-parsing helpers that `probeInputMediaInfo` uses
25
+ (duration/width/height/fps/startTime/HDR detection) so the planner can
26
+ apply them to ITS probe's stderr. Where they live is the executor's
27
+ choice (export from hls-session-manager or move to a small shared
28
+ module) — do NOT duplicate the parsing logic.
29
+ 2. In the planner, on a probe whose codecs were detected (the same condition
30
+ that allows caching the plan), build a `mediaInfo` object
31
+ `{ durationSeconds, width, height, fps, startTime, isHdr }` and cache it
32
+ ALONGSIDE the plan — same key, same lifetime, same invalidation. A
33
+ pending (empty) probe caches nothing, exactly like the plan.
34
+ 3. Planner exposes `getCachedMediaInfo({ sourceKey, fileIndex })` →
35
+ `MediaInfo | null`.
36
+ 4. `server.js` passes it into HlsSessionManager (new constructor option
37
+ `getCachedMediaInfo`).
38
+ 5. `createSession`:
39
+
40
+ const cached = this.getCachedMediaInfo?.({ sourceKey, fileIndex }) ?? null;
41
+ const usable = cached
42
+ && Number.isFinite(cached.durationSeconds) && cached.durationSeconds > 0
43
+ && Number.isFinite(cached.width) && cached.width > 0
44
+ && Number.isFinite(cached.height) && cached.height > 0;
45
+ const mediaInfo = usable ? cached : await probeInputMediaInfo(this.ffmpegBin, inputUrl.toString());
46
+
47
+ Duration/width/height gate the cache because downstream logic depends on
48
+ them (synthetic VOD playlist, resolution ladder); fps/startTime/isHdr
49
+ have safe defaults and do not gate. The probe stays as the fallback —
50
+ behaviour is unchanged whenever the cache cannot serve.
51
+
52
+ Keyframe probe (`probeVideoKeyframeTimes`) is NOT touched: it runs only on
53
+ the video-copy path and probes different data (packet flags, not the
54
+ banner).
55
+
56
+ ## 2. Body-start prefetch
57
+
58
+ In `getPlan`, at the point where the probe succeeded and the plan is about
59
+ to be cached/returned, fire and FORGET:
60
+
61
+ void torrentPool.prefetchFileEdges(torrent, fileIndex, {
62
+ headBytes: BODY_PREFETCH_BYTES, // 16 MB
63
+ tailBytes: 0,
64
+ timeoutMs: 60_000
65
+ }).catch(() => {});
66
+
67
+ const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
68
+
69
+ No new torrent-pool method — `prefetchFileEdges` with `tailBytes: 0` is
70
+ exactly a head prefetch. Not awaited: the client's session-create follows
71
+ within a couple of seconds and ffmpeg reads sequentially behind the
72
+ prefetch. 16 MB ≈ 25 s of typical 5 Mbit media — covers the first segments
73
+ without denting the disk cap. Do NOT start it on pending polls (the header
74
+ prefetch must keep absolute priority while the probe is starving).
75
+
76
+ ## 3. Stage timings
77
+
78
+ - `createSession` measures and logs (one line, existing logger, prefix
79
+ matches the session log style):
80
+
81
+ cold-start <sessionId8>: media-info=<ms> (cached|probed) keyframes=<ms|skipped> create-total=<ms>
82
+
83
+ - When the FIRST segment file of a fresh session becomes servable (the
84
+ long-poll in the session-file path returns it), log once per session:
85
+
86
+ cold-start <sessionId8>: first-segment ready +<ms since createSession entry>
87
+
88
+ Store the create-entry timestamp on the session object; guard with a
89
+ boolean so the line logs once.
90
+
91
+ ## Rules — do NOT
92
+
93
+ - Do NOT change probe semantics, the pending/poll contract, or the plan
94
+ cache key.
95
+ - Do NOT prioritise body bytes before the probe has succeeded.
96
+ - Do NOT make session create fail on a cache problem — any doubt → fall
97
+ back to the probe.
98
+ - No behavioural change for the video-copy path beyond the timing lines.
@@ -0,0 +1,62 @@
1
+ # Proposal: Cold-start reduction (proxy side)
2
+
3
+ ## Why
4
+
5
+ Field recordings (mobile tester, 2026-07-08) show ~90 seconds from picking an
6
+ episode to a picture. The proxy owns two chunks of that:
7
+
8
+ 1. **A redundant ffmpeg input scan.** The playback planner probes the input
9
+ (`probeStreamCodecs`) and CACHES the plan — then `createSession` in
10
+ hls-session-manager immediately probes the SAME input again
11
+ (`probeInputMediaInfo`) for duration/resolution/fps/startTime/HDR. Each
12
+ probe is a full ffmpeg startup + banner scan over the torrent-backed HTTP
13
+ stream — seconds on the HA host, on the critical path, twice.
14
+ 2. **First-segment piece latency.** The planner prefetches only the file
15
+ EDGES (256 KB head + 2 MB tail — what the codec probe needs). The first
16
+ segment's encode then needs the first ~10–20 MB of the file BODY, whose
17
+ pieces start downloading only when ffmpeg asks for them — while the swarm
18
+ sat mostly idle during the plan poll.
19
+
20
+ There is also no per-stage timing in the logs, so field regressions in
21
+ session startup are invisible.
22
+
23
+ ## What Changes
24
+
25
+ - **Reuse the planner's probe in session create.** The planner parses and
26
+ caches the full media info (duration, width/height, fps, startTime, isHdr)
27
+ from the probe it already runs; hls-session-manager consults that cache
28
+ and skips its own `probeInputMediaInfo` on a hit (probe stays as the
29
+ fallback). One full ffmpeg scan disappears from the critical path.
30
+ - **Prefetch the file-body start once the plan is ready.** After a successful
31
+ probe, fire-and-forget prefetch of the first 16 MB of the file, so the
32
+ session's ffmpeg reads hit already-downloaded data instead of paying piece
33
+ latency at encode time.
34
+ - **Stage timings in the log.** Session create logs probe ms (and
35
+ cache-hit/probed), keyframe-probe ms, and the time from create to the
36
+ first servable segment — so cold-start is measurable per stage in the
37
+ field, next to the client-side summary line (server `cold-start` change).
38
+
39
+ Client-side counterparts (phase instrumentation, earlier prebuffer start)
40
+ live in the server repo's `cold-start` change. The two changes are
41
+ independent — no wire-format coupling, either releases alone.
42
+
43
+ NOT in this change (candidates for later, kept out deliberately):
44
+ next-episode speculative prefetch (bandwidth cost needs field data first);
45
+ encoder-side first-segment speed-ups (faster warm-up preset would need a
46
+ mid-stream switch — parked with transcode-quality 2.2b).
47
+
48
+ ## Capabilities
49
+
50
+ ### New Capabilities
51
+
52
+ - `cold-start`: proxy-side session startup latency behaviour.
53
+
54
+ ## Impact
55
+
56
+ - `services/playback-planner.js` — parse + cache full media info; expose
57
+ `getCachedMediaInfo`; body-start prefetch after a successful probe.
58
+ - `services/hls-session-manager.js` — consult the cache in `createSession`;
59
+ stage-timing log lines.
60
+ - `server.js` — wire `getCachedMediaInfo` into the HlsSessionManager deps.
61
+ - Release: proxy `npm run patch` + ha-addon version bump (standard order:
62
+ proxy first, then addon).
@@ -0,0 +1,47 @@
1
+ # cold-start — delta spec (proxy)
2
+
3
+ ## ADDED Requirements
4
+
5
+ ### Requirement: Session create reuses the planner's probe
6
+
7
+ Creating an HLS transcode session SHALL NOT re-probe an input whose media
8
+ info the playback planner already probed and cached for the same source and
9
+ file. The cached info is used only when its critical fields (duration,
10
+ width, height) are present and valid; otherwise the session SHALL probe as
11
+ before. Cache lifetime and invalidation follow the plan cache exactly.
12
+
13
+ #### Scenario: Warm plan, immediate session
14
+ - **WHEN** the browser requests a transcode session right after receiving a
15
+ playback plan for the same file
16
+ - **THEN** the session starts without a second ffmpeg input scan and its
17
+ playlist/ladder decisions are identical to what the probe would have
18
+ produced
19
+
20
+ #### Scenario: Cache cannot serve
21
+ - **WHEN** no cached media info exists (e.g. proxy restarted between plan
22
+ and session) or a critical field is missing
23
+ - **THEN** the session probes the input itself, exactly as before this
24
+ change
25
+
26
+ ### Requirement: The file-body start is warm before the encoder needs it
27
+
28
+ Once a playback plan probe succeeds, the proxy SHALL prefetch the beginning
29
+ of the file body (bounded, ~16 MB) in the background, without delaying the
30
+ plan response and without competing with a still-running header probe.
31
+
32
+ #### Scenario: First segment does not wait for pieces
33
+ - **WHEN** the viewer confirms playback within the normal flow (seconds
34
+ after the plan)
35
+ - **THEN** the encoder's initial reads are served from already-downloaded
36
+ data and the first segment's production is not blocked on piece download
37
+
38
+ ### Requirement: Session startup is measurable per stage
39
+
40
+ Session creation SHALL log the media-info acquisition time (and whether it
41
+ was cached or probed), the keyframe-probe time when it runs, and — once per
42
+ session — the time from session-create entry to the first servable segment.
43
+
44
+ #### Scenario: Field regression triage
45
+ - **WHEN** a tester reports a slow start
46
+ - **THEN** the proxy log shows, for that session, where the time went:
47
+ media info, keyframes, or first-segment production
@@ -0,0 +1,48 @@
1
+ # Tasks: Cold-start reduction (proxy side)
2
+
3
+ Execute in order; design.md is normative. Read the code regions listed at
4
+ the top of design.md first.
5
+
6
+ ## 1. Media-info reuse
7
+
8
+ - [ ] 1.1 Make the banner-parse helpers of `probeInputMediaInfo` reusable by
9
+ the planner (export or shared module — no duplication). `node --check`
10
+ both files.
11
+ - [ ] 1.2 playback-planner: build + cache `mediaInfo` alongside the plan on
12
+ a codecs-detected probe; expose `getCachedMediaInfo({ sourceKey,
13
+ fileIndex })`. Pending probes cache nothing.
14
+ - [ ] 1.3 server.js: pass `getCachedMediaInfo` into HlsSessionManager;
15
+ hls-session-manager: consult it in `createSession` with the
16
+ critical-fields gate (duration/width/height), probe as fallback.
17
+ - [ ] 1.4 Verify equivalence: for one file, run plan → session with the
18
+ cache and (by temporarily disabling the wiring) without it — the
19
+ session log line (duration, segments, ladder/rung) must be identical.
20
+
21
+ ## 2. Body-start prefetch
22
+
23
+ - [ ] 2.1 playback-planner: fire-and-forget 16 MB head prefetch
24
+ (`prefetchFileEdges` with `tailBytes: 0`) at the probe-success point;
25
+ never on pending polls; never awaited.
26
+ - [ ] 2.2 Verify: on a fresh well-seeded magnet, proxy log/order shows the
27
+ prefetch starting right after the plan while the session that follows
28
+ produces its first segment without a piece-wait stall (compare
29
+ first-segment ms with task 3 timings before/after).
30
+
31
+ ## 3. Stage timings
32
+
33
+ - [ ] 3.1 `createSession`: one `cold-start …: media-info=<ms> (cached|probed)
34
+ keyframes=<ms|skipped> create-total=<ms>` line.
35
+ - [ ] 3.2 First-segment-ready line (`+<ms>` since create entry), once per
36
+ session (timestamp on the session object + boolean guard).
37
+
38
+ ## 4. Verification and release
39
+
40
+ - [ ] 4.1 Regression: normal HLS transcode start, video-copy start (keyframe
41
+ probe still runs), seek-restart, quality switch — behaviour unchanged;
42
+ timings visible in the log.
43
+ - [ ] 4.2 Before/after numbers on the dev HA proxy for one cold magnet:
44
+ media-info ms (probed → cached) and first-segment ms. Record them in
45
+ this file.
46
+ - [ ] 4.3 CHANGELOG entry (current version + 1 patch); `npm run patch`; bump
47
+ ha-addon `config.yaml` + its CHANGELOG; push; update the addon in HA
48
+ (proxy FIRST, then addon — release order per root CLAUDE.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.33",
3
+ "version": "2.9.35",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
package/server.js CHANGED
@@ -138,7 +138,11 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
138
138
  } catch {
139
139
  return null;
140
140
  }
141
- }
141
+ },
142
+ // Reuse the media info the planner already probed for this file (same
143
+ // ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
144
+ // only at session-create time, after playbackPlanner is initialised.
145
+ getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params)
142
146
  });
143
147
  const playbackPlanner = createPlaybackPlanner({
144
148
  ffmpegBin,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @file ffmpeg banner parsers.
3
+ *
4
+ * Pure helpers that extract media info from the ffmpeg `-i` stderr banner
5
+ * (printed before any decoding): duration, start time, video resolution,
6
+ * frame rate and HDR transfer. Shared by the playback planner (which runs the
7
+ * codec probe) and the HLS session manager (which needs the same fields when
8
+ * building a session), so a session can reuse the planner's probe instead of
9
+ * running a second ffmpeg scan of the same input.
10
+ */
11
+
12
+ /**
13
+ * Extract the total duration in seconds from ffmpeg stderr output.
14
+ * Returns `null` if the duration line is absent or unparseable.
15
+ *
16
+ * @param {string} stderrText
17
+ * @returns {number | null}
18
+ */
19
+ export function parseFfmpegDurationSeconds(stderrText) {
20
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
21
+ return null;
22
+ }
23
+ const match = stderrText.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
24
+ if (!match) {
25
+ return null;
26
+ }
27
+ const hours = Number(match[1]);
28
+ const minutes = Number(match[2]);
29
+ const seconds = Number(match[3]);
30
+ if (![hours, minutes, seconds].every((item) => Number.isFinite(item))) {
31
+ return null;
32
+ }
33
+ return hours * 3600 + minutes * 60 + seconds;
34
+ }
35
+
36
+ /**
37
+ * Parse the container start time (seconds) from ffmpeg's "Duration: …, start:
38
+ * X, …" line. Many MKVs report a small non-zero start (e.g. 0.1 s); preserving
39
+ * it via `-copyts` would put a hole at the beginning, so we normalize it away.
40
+ * Returns 0 when absent.
41
+ *
42
+ * @param {string} stderrText
43
+ * @returns {number}
44
+ */
45
+ export function parseFfmpegStartTimeSeconds(stderrText) {
46
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
47
+ return 0;
48
+ }
49
+ const match = stderrText.match(/Duration:[^\n]*?start:\s*(-?\d+(?:\.\d+)?)/i);
50
+ if (!match) {
51
+ return 0;
52
+ }
53
+ const value = Number(match[1]);
54
+ return Number.isFinite(value) ? value : 0;
55
+ }
56
+
57
+ /**
58
+ * Parse the source video resolution from ffmpeg's stderr (the "Stream … Video:
59
+ * … WxH" line). Returns `{ width: null, height: null }` when absent.
60
+ *
61
+ * @param {string} stderrText
62
+ * @returns {{ width: number | null, height: number | null }}
63
+ */
64
+ export function parseFfmpegVideoDimensions(stderrText) {
65
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
66
+ return { width: null, height: null };
67
+ }
68
+ const match = stderrText.match(/Video:[^\n]*?\b(\d{2,5})x(\d{2,5})\b/i);
69
+ if (!match) {
70
+ return { width: null, height: null };
71
+ }
72
+ const width = Number(match[1]);
73
+ const height = Number(match[2]);
74
+ return {
75
+ width: Number.isFinite(width) && width > 0 ? width : null,
76
+ height: Number.isFinite(height) && height > 0 ? height : null
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Parse the source frame rate from the ffmpeg "Video:" line
82
+ * (e.g. "… 23.98 fps," / "… 25 fps,"). Returns null when absent.
83
+ *
84
+ * @param {string} stderrText
85
+ * @returns {number | null}
86
+ */
87
+ export function parseFfmpegVideoFps(stderrText) {
88
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
89
+ return null;
90
+ }
91
+ const videoLine = stderrText.match(/Video:[^\n]*/i);
92
+ if (!videoLine) {
93
+ return null;
94
+ }
95
+ const match = videoLine[0].match(/([\d.]+)\s*fps/i);
96
+ if (!match) {
97
+ return null;
98
+ }
99
+ const value = Number(match[1]);
100
+ return Number.isFinite(value) && value > 0 ? value : null;
101
+ }
102
+
103
+ /**
104
+ * Detect an HDR / wide-gamut source from the ffmpeg "Video:" line's colour
105
+ * metadata. HDR is identified by the transfer function — `smpte2084` (PQ /
106
+ * HDR10) or `arib-std-b67` (HLG). Re-encoding such a source to 8-bit SDR
107
+ * without tone mapping produces a washed-out, desaturated picture, so this
108
+ * gates the tonemap filter chain.
109
+ *
110
+ * @param {string} stderrText
111
+ * @returns {boolean}
112
+ */
113
+ export function parseFfmpegHdr(stderrText) {
114
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
115
+ return false;
116
+ }
117
+ const videoLine = stderrText.match(/Video:[^\n]*/i);
118
+ if (!videoLine) {
119
+ return false;
120
+ }
121
+ // ffmpeg prints the colour info in parentheses, e.g.
122
+ // "yuv420p10le(tv, bt2020nc/bt2020/smpte2084)". The transfer (last token) is
123
+ // the reliable HDR signal.
124
+ return /\b(smpte2084|arib-std-b67|arib_std_b67)\b/i.test(videoLine[0]);
125
+ }
@@ -22,6 +22,13 @@ import {
22
22
  TRANSCODE_FPS,
23
23
  chooseOutputFps
24
24
  } from "./hwaccel.js";
25
+ import {
26
+ parseFfmpegDurationSeconds,
27
+ parseFfmpegStartTimeSeconds,
28
+ parseFfmpegVideoDimensions,
29
+ parseFfmpegVideoFps,
30
+ parseFfmpegHdr
31
+ } from "./ffmpeg-banner.js";
25
32
 
26
33
  const PLAYLIST_FILE_NAME = "index.m3u8";
27
34
  const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
@@ -206,51 +213,6 @@ function parseFfmpegTimestamp(value) {
206
213
  return hours * 3600 + minutes * 60 + seconds;
207
214
  }
208
215
 
209
- /**
210
- * Extract the total duration in seconds from ffmpeg stderr output.
211
- * Returns `null` if the duration line is absent or unparseable.
212
- *
213
- * @param {string} stderrText
214
- * @returns {number | null}
215
- */
216
- function parseFfmpegDurationSeconds(stderrText) {
217
- if (typeof stderrText !== "string" || stderrText.length === 0) {
218
- return null;
219
- }
220
- const match = stderrText.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
221
- if (!match) {
222
- return null;
223
- }
224
- const hours = Number(match[1]);
225
- const minutes = Number(match[2]);
226
- const seconds = Number(match[3]);
227
- if (![hours, minutes, seconds].every((item) => Number.isFinite(item))) {
228
- return null;
229
- }
230
- return hours * 3600 + minutes * 60 + seconds;
231
- }
232
-
233
- /**
234
- * Parse the container start time (seconds) from ffmpeg's "Duration: …, start:
235
- * X, …" line. Many MKVs report a small non-zero start (e.g. 0.1 s); preserving
236
- * it via `-copyts` would put a hole at the beginning, so we normalize it away.
237
- * Returns 0 when absent.
238
- *
239
- * @param {string} stderrText
240
- * @returns {number}
241
- */
242
- function parseFfmpegStartTimeSeconds(stderrText) {
243
- if (typeof stderrText !== "string" || stderrText.length === 0) {
244
- return 0;
245
- }
246
- const match = stderrText.match(/Duration:[^\n]*?start:\s*(-?\d+(?:\.\d+)?)/i);
247
- if (!match) {
248
- return 0;
249
- }
250
- const value = Number(match[1]);
251
- return Number.isFinite(value) ? value : 0;
252
- }
253
-
254
216
  /**
255
217
  * Format a seconds value as `HH:MM:SS`, or `"n/a"` if not finite.
256
218
  *
@@ -302,76 +264,6 @@ function computeProgressMetrics(processedSeconds, totalSeconds, startPositionSec
302
264
  };
303
265
  }
304
266
 
305
- /**
306
- * Parse the source video resolution from ffmpeg's stderr (the "Stream … Video:
307
- * … WxH" line). Returns `{ width: null, height: null }` when absent.
308
- *
309
- * @param {string} stderrText
310
- * @returns {{ width: number | null, height: number | null }}
311
- */
312
- function parseFfmpegVideoDimensions(stderrText) {
313
- if (typeof stderrText !== "string" || stderrText.length === 0) {
314
- return { width: null, height: null };
315
- }
316
- const match = stderrText.match(/Video:[^\n]*?\b(\d{2,5})x(\d{2,5})\b/i);
317
- if (!match) {
318
- return { width: null, height: null };
319
- }
320
- const width = Number(match[1]);
321
- const height = Number(match[2]);
322
- return {
323
- width: Number.isFinite(width) && width > 0 ? width : null,
324
- height: Number.isFinite(height) && height > 0 ? height : null
325
- };
326
- }
327
-
328
- /**
329
- * Parse the source frame rate from the ffmpeg "Video:" line
330
- * (e.g. "… 23.98 fps," / "… 25 fps,"). Returns null when absent.
331
- *
332
- * @param {string} stderrText
333
- * @returns {number | null}
334
- */
335
- function parseFfmpegVideoFps(stderrText) {
336
- if (typeof stderrText !== "string" || stderrText.length === 0) {
337
- return null;
338
- }
339
- const videoLine = stderrText.match(/Video:[^\n]*/i);
340
- if (!videoLine) {
341
- return null;
342
- }
343
- const match = videoLine[0].match(/([\d.]+)\s*fps/i);
344
- if (!match) {
345
- return null;
346
- }
347
- const value = Number(match[1]);
348
- return Number.isFinite(value) && value > 0 ? value : null;
349
- }
350
-
351
- /**
352
- * Detect an HDR / wide-gamut source from the ffmpeg "Video:" line's colour
353
- * metadata. HDR is identified by the transfer function — `smpte2084` (PQ /
354
- * HDR10) or `arib-std-b67` (HLG). Re-encoding such a source to 8-bit SDR
355
- * without tone mapping produces a washed-out, desaturated picture, so this
356
- * gates the tonemap filter chain.
357
- *
358
- * @param {string} stderrText
359
- * @returns {boolean}
360
- */
361
- function parseFfmpegHdr(stderrText) {
362
- if (typeof stderrText !== "string" || stderrText.length === 0) {
363
- return false;
364
- }
365
- const videoLine = stderrText.match(/Video:[^\n]*/i);
366
- if (!videoLine) {
367
- return false;
368
- }
369
- // ffmpeg prints the colour info in parentheses, e.g.
370
- // "yuv420p10le(tv, bt2020nc/bt2020/smpte2084)". The transfer (last token) is
371
- // the reliable HDR signal.
372
- return /\b(smpte2084|arib-std-b67|arib_std_b67)\b/i.test(videoLine[0]);
373
- }
374
-
375
267
  /**
376
268
  * Run a short ffmpeg probe to extract the total duration AND video resolution
377
269
  * of a stream from the container header. Both are printed almost immediately
@@ -682,10 +574,14 @@ export class HlsSessionManager {
682
574
  videoEncoder = null,
683
575
  softwarePresetBenchmark = null,
684
576
  getSourceStats = null,
685
- tonemapSupported = false
577
+ tonemapSupported = false,
578
+ getCachedMediaInfo = null
686
579
  }) {
687
580
  this.enabled = Boolean(enabled);
688
581
  this.ffmpegBin = ffmpegBin;
582
+ // Optional accessor for media info the playback planner already probed for
583
+ // (sourceKey, fileIndex), so session create can skip its own ffmpeg scan.
584
+ this.getCachedMediaInfo = typeof getCachedMediaInfo === "function" ? getCachedMediaInfo : null;
689
585
  // Optional async accessor for a source's live download stats, used by the
690
586
  // realtime budget to tell a CPU limit from a download-starved input:
691
587
  // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
@@ -804,17 +700,35 @@ export class HlsSessionManager {
804
700
  }
805
701
 
806
702
  const sessionId = randomUUID();
703
+ const createEntryMs = Date.now();
807
704
  const sessionDir = createSessionDirPath(sessionId);
808
705
  await mkdir(sessionDir, { recursive: true });
809
706
  const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
810
707
  inputUrl.searchParams.set("sourceKey", sourceKey);
811
708
  inputUrl.searchParams.set("fileIndex", String(fileIndex));
812
709
 
813
- // Probe the full media duration up-front so we can serve a complete VOD
814
- // playlist (terminated with #EXT-X-ENDLIST) immediately. This gives the
815
- // player the correct total duration and a fully seekable timeline before a
816
- // single segment has been transcoded.
817
- const mediaInfo = await probeInputMediaInfo(this.ffmpegBin, inputUrl.toString());
710
+ // Media info (duration/resolution/fps/startTime/HDR) up front, so we can
711
+ // serve a complete VOD playlist (#EXT-X-ENDLIST) with the correct total
712
+ // duration and a fully seekable timeline before a single segment exists.
713
+ // Reuse the planner's probe when it is available and complete — the plan
714
+ // request just ran the same ffmpeg scan over the same input. Fall back to
715
+ // a fresh probe otherwise (proxy restarted between plan and session, or a
716
+ // critical field is missing).
717
+ const mediaInfoStartMs = Date.now();
718
+ const cachedMediaInfo = this.getCachedMediaInfo?.({ sourceKey, fileIndex }) ?? null;
719
+ const cachedUsable =
720
+ cachedMediaInfo &&
721
+ Number.isFinite(cachedMediaInfo.durationSeconds) &&
722
+ cachedMediaInfo.durationSeconds > 0 &&
723
+ Number.isFinite(cachedMediaInfo.width) &&
724
+ cachedMediaInfo.width > 0 &&
725
+ Number.isFinite(cachedMediaInfo.height) &&
726
+ cachedMediaInfo.height > 0;
727
+ const mediaInfo = cachedUsable
728
+ ? cachedMediaInfo
729
+ : await probeInputMediaInfo(this.ffmpegBin, inputUrl.toString());
730
+ const mediaInfoMs = Date.now() - mediaInfoStartMs;
731
+ const mediaInfoSource = cachedUsable ? "cached" : "probed";
818
732
  const durationSeconds = mediaInfo.durationSeconds;
819
733
  const sourceWidth = mediaInfo.width;
820
734
  const sourceHeight = mediaInfo.height;
@@ -847,11 +761,14 @@ export class HlsSessionManager {
847
761
  // uniform grid (current behaviour). Re-encoded video uses a uniform grid
848
762
  // (its fixed GOP makes the cuts land there).
849
763
  let keyframeTimes = null;
764
+ let keyframeMs = -1; // -1 = not run (skipped)
850
765
  if (hasDuration && !transcodeVideo) {
851
766
  // Short timeout: mp4 keyframes come from the moov index (fast); containers
852
767
  // that force a full packet scan time out and fall back to a uniform grid,
853
768
  // so this never adds more than ~6 s to session start.
769
+ const keyframeStartMs = Date.now();
854
770
  keyframeTimes = await probeVideoKeyframeTimes(this.ffmpegBin, inputUrl.toString(), 6_000);
771
+ keyframeMs = Date.now() - keyframeStartMs;
855
772
  if (!keyframeTimes) {
856
773
  logger.warn(
857
774
  `transcode ${sessionId}: keyframe probe unavailable; using uniform grid ` +
@@ -859,6 +776,10 @@ export class HlsSessionManager {
859
776
  );
860
777
  }
861
778
  }
779
+ logger.info(
780
+ `cold-start ${sessionId.slice(0, 8)}: media-info=${mediaInfoMs}ms (${mediaInfoSource}) ` +
781
+ `keyframes=${keyframeMs < 0 ? "skipped" : `${keyframeMs}ms`} create-total=${Date.now() - createEntryMs}ms`
782
+ );
862
783
  const segmentBoundaries = hasDuration
863
784
  ? computeSegmentBoundaries({
864
785
  transcodeVideo,
@@ -908,6 +829,10 @@ export class HlsSessionManager {
908
829
  lastAccessedAt: Date.now(),
909
830
  ffmpeg: null,
910
831
  lastError: "",
832
+ // Cold-start timing: entry timestamp + a once-guard so the first servable
833
+ // segment logs its latency exactly once.
834
+ createEntryMs,
835
+ firstSegmentLogged: false,
911
836
  consumers: new Set(consumerId ? [consumerId] : []),
912
837
  // Transcode parameters retained so the encode run can be restarted at an
913
838
  // arbitrary segment when the player seeks (server-side seeking).
@@ -1673,6 +1598,14 @@ export class HlsSessionManager {
1673
1598
  try {
1674
1599
  await access(filePath);
1675
1600
  const isPlaylist = fileName === PLAYLIST_FILE_NAME;
1601
+ // Cold-start: log the first servable SEGMENT of this session exactly once
1602
+ // — the time from session-create entry to a playable first segment.
1603
+ if (!isPlaylist && !session.firstSegmentLogged) {
1604
+ session.firstSegmentLogged = true;
1605
+ logger.info(
1606
+ `cold-start ${sessionId.slice(0, 8)}: first-segment ready +${Date.now() - session.createEntryMs}ms`
1607
+ );
1608
+ }
1676
1609
  return {
1677
1610
  kind: "file",
1678
1611
  stream: isPlaylist
@@ -7,10 +7,23 @@
7
7
  */
8
8
 
9
9
  import { spawn } from "node:child_process";
10
+ import {
11
+ parseFfmpegDurationSeconds,
12
+ parseFfmpegStartTimeSeconds,
13
+ parseFfmpegVideoDimensions,
14
+ parseFfmpegVideoFps,
15
+ parseFfmpegHdr
16
+ } from "./ffmpeg-banner.js";
10
17
 
11
18
  /** Audio codecs that browsers can decode natively without transcoding. */
12
19
  const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
13
20
 
21
+ // Once the plan probe succeeds, warm the START of the file body so the
22
+ // transcode session's ffmpeg reads hit downloaded data instead of paying
23
+ // piece latency at encode time (the edge prefetch only covers head+tail for
24
+ // the codec probe). ~16 MB ≈ the first segments of typical media.
25
+ const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
26
+
14
27
  /** Subtitle codecs that can be converted to WebVTT (text-based). */
15
28
  const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
16
29
 
@@ -134,7 +147,9 @@ function parseStreamCodecs(ffmpegOutput) {
134
147
  * @param {string} options.inputUrl
135
148
  * @param {string} [options.userAgent=""]
136
149
  * @param {number} [options.timeoutMs=8000]
137
- * @returns {Promise<{ audioCodec: string, videoCodec: string }>}
150
+ * @returns {Promise<{ audioCodec: string, videoCodec: string, container: string, durationSeconds: number, videoWidth: number, videoHeight: number, audioTracks: object[], subtitleTracks: object[], stderr: string }>}
151
+ * Parsed banner fields plus the raw `stderr`, so the caller can derive the
152
+ * full media info (fps/startTime/HDR) without a second ffmpeg scan.
138
153
  */
139
154
  function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
140
155
  return new Promise((resolve) => {
@@ -167,7 +182,7 @@ function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_
167
182
  if (!ffmpeg.killed) {
168
183
  ffmpeg.kill("SIGTERM");
169
184
  }
170
- finish(parseStreamCodecs(stderr));
185
+ finish({ ...parseStreamCodecs(stderr), stderr });
171
186
  }, timeoutMs);
172
187
 
173
188
  ffmpeg.stderr.on("data", (chunk) => {
@@ -176,12 +191,12 @@ function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_
176
191
 
177
192
  ffmpeg.on("error", () => {
178
193
  clearTimeout(timeoutId);
179
- finish({ audioCodec: "", videoCodec: "" });
194
+ finish({ audioCodec: "", videoCodec: "", stderr: "" });
180
195
  });
181
196
 
182
197
  ffmpeg.on("exit", () => {
183
198
  clearTimeout(timeoutId);
184
- finish(parseStreamCodecs(stderr));
199
+ finish({ ...parseStreamCodecs(stderr), stderr });
185
200
  });
186
201
  });
187
202
  }
@@ -251,8 +266,26 @@ export function createPlaybackPlanner({
251
266
  }) {
252
267
  /** @type {Map<string, PlaybackPlan>} */
253
268
  const cache = new Map();
269
+ /**
270
+ * Full media info parsed from the SAME probe that produced the plan, cached
271
+ * under the same key so a transcode session can reuse it instead of running
272
+ * a second ffmpeg scan. Only set when the plan is cached (codecs detected).
273
+ * @type {Map<string, { durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
274
+ */
275
+ const mediaInfoCache = new Map();
254
276
 
255
277
  return {
278
+ /**
279
+ * Media info the planner already probed for this file, or `null`. Lets the
280
+ * HLS session manager skip its own duplicate `probeInputMediaInfo` scan.
281
+ *
282
+ * @param {{ sourceKey: string, fileIndex: number }} params
283
+ * @returns {{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean } | null}
284
+ */
285
+ getCachedMediaInfo({ sourceKey, fileIndex }) {
286
+ return mediaInfoCache.get(`${sourceKey}:${fileIndex}`) ?? null;
287
+ },
288
+
256
289
  /**
257
290
  * Return the playback plan for the given source file.
258
291
  * Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
@@ -364,6 +397,26 @@ export function createPlaybackPlanner({
364
397
  // prioritised by the prefetch above).
365
398
  if (codecsDetected) {
366
399
  cache.set(cacheKey, plan);
400
+ // Cache the full media info from THIS probe's banner (same helpers the
401
+ // session manager uses) so createSession can skip its own probe.
402
+ const dims = parseFfmpegVideoDimensions(probe.stderr);
403
+ mediaInfoCache.set(cacheKey, {
404
+ durationSeconds: parseFfmpegDurationSeconds(probe.stderr),
405
+ width: dims.width,
406
+ height: dims.height,
407
+ fps: parseFfmpegVideoFps(probe.stderr),
408
+ startTime: parseFfmpegStartTimeSeconds(probe.stderr),
409
+ isHdr: parseFfmpegHdr(probe.stderr)
410
+ });
411
+ // Warm the file-body start for the transcode session that follows.
412
+ // Fire-and-forget: never delays the plan response.
413
+ void torrentPool
414
+ .prefetchFileEdges(torrent, fileIndex, {
415
+ headBytes: BODY_PREFETCH_BYTES,
416
+ tailBytes: 0,
417
+ timeoutMs: 60_000
418
+ })
419
+ .catch(() => {});
367
420
  return plan;
368
421
  }
369
422
  return { ...plan, pending: true };
@@ -25,6 +25,18 @@ const ICE_SERVERS = ["stun:stun.l.google.com:19302", "stun:stun.cloudflare.com:3
25
25
  // values without bloating the SDP.
26
26
  const PORT_PREDICTION_WINDOW = 16;
27
27
 
28
+ // Max size of a single data-channel message the proxy advertises (SDP
29
+ // `a=max-message-size`) and will accept. The browser caps `channel.send()` at
30
+ // the REMOTE's advertised value, so this is what lets the browser send a large
31
+ // request body in one message — notably registering a source, whose body is
32
+ // the base64-encoded .torrent. A big multi-season pack's .torrent (thousands of
33
+ // piece hashes) can be hundreds of KB (e.g. Poirot: 420 KB → ~560 KB base64),
34
+ // which exceeds libdatachannel's ~256 KB default and made `send()` throw
35
+ // "message larger than max-message-size". 16 MB is a generous ceiling (memory
36
+ // is allocated per actual message, not reserved). Responses (proxy→browser)
37
+ // are already safe — they stream in small reader-sized chunks.
38
+ const MAX_DC_MESSAGE_BYTES = 16 * 1024 * 1024;
39
+
28
40
  /**
29
41
  * Build predicted srflx ICE candidates for a symmetric NAT.
30
42
  *
@@ -195,7 +207,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
195
207
  }
196
208
 
197
209
  // Base PeerConnection config shared by every session.
198
- const pcConfig = { iceServers: ICE_SERVERS };
210
+ const pcConfig = { iceServers: ICE_SERVERS, maxMessageSize: MAX_DC_MESSAGE_BYTES };
199
211
 
200
212
  // Single-port UDP mux: create ONE persistent listener that owns the shared
201
213
  // UDP socket for the proxy's whole lifetime, then have every PeerConnection