@torrent-tv/proxy 2.9.47 → 2.9.49

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,12 @@
1
+ ## 2.9.49
2
+
3
+ - **New**: `getSessionProgress` (the transcode-session progress endpoint) now also reports `outputMbps` — the observed produced bitrate from recently completed segment files (already computed internally for the viewer-link budget check, `#checkLinkBudget`), so the browser can turn its OWN measured link throughput into a delivery-speed multiplier for the unified download/transcode/delivery playback-start ETA, the same way the transcode's own `speed` already is one.
4
+
5
+ ## 2.9.48
6
+
7
+ - **Fix**: The "bytes still needed to resume" figure shown while buffering could jump UP mid-poll even though nothing regressed, which read as confusing/broken. Root cause: the resume-window progress (`resumeNeededBytes`/`resumeDownloadedBytes`) was always computed against the LIVE read position, which slides forward as the file is read/transcoded further — so when the window moved past an already-downloaded piece into a fresh, never-touched one, "bytes needed" jumped up (a moving reference frame, not a real setback). `getFileStats` now accepts an optional `resumeAnchorByteStart` and always returns the byte offset the window was computed against; the browser client captures that offset on the FIRST poll of a buffering episode and sends it back on every subsequent poll of the SAME episode, so the window stays pinned to a fixed target and the figure only ever decreases as real download progress happens. Verified: with the anchor pinned, repeated polls report the same "needed" while the live read position moves with no new data, and a real download of a piece inside the frozen window correctly decreases it.
8
+ - **Fix**: A rapid sequence of seeks could leave playback permanently stuck — field-diagnosed from a live session (5 seek-restarts in 16 seconds), showing `failed to rename file segment-NNNNN.m4s.tmp` and a zombie ffmpeg still writing a `.tmp` file ~30 seconds after being "killed" by two later restarts, even after the session had already been released. Root cause: `#startEncodeRun` sent `SIGTERM` to the previous ffmpeg process and immediately spawned the replacement into the SAME session directory without waiting for it to actually exit. `ChildProcess.killed` only means a signal was sent, not that the process died — ffmpeg's own blocking read of our torrent-backed `/stream` input can defer signal handling for a long time while starved, so on a rapid sequence of seeks multiple ffmpeg processes ended up alive concurrently, fighting over CPU and racing each other's file writes in the same directory; none of them would finish a segment in time, which is what "stuck at seek" looks like to the viewer. Fixed by awaiting the previous process's exit (escalating from `SIGTERM` to `SIGKILL` if it does not exit within a grace period, reusing the `waitForChildExit` helper `disposeSession` already used correctly) before spawning the replacement. A new per-session generation counter (`encodeRunGeneration`) lets a restart that was superseded by an even newer seek while it was waiting abort instead of also spawning a process — verified with a standalone race simulation: 5 overlapping restarts against a slow-to-die previous process spawn exactly 1 process, matching the LATEST requested target.
9
+
1
10
  ## 2.9.47
2
11
 
3
12
  - **Fix**: Playback could get permanently stuck (hls.js endlessly re-fetching the manifest and the first segment, buffer never advancing) even though the transcode itself was encoding fine, running ahead of realtime. Root cause: ffmpeg creates the fMP4 `init.mp4` file before it finishes writing the codec-header boxes into it (unlike segments, its write is not gated behind an atomic rename), so a request could race a moment where the file exists but is still empty. That empty read was then cached forever as the session's init segment — a zero-length `Buffer` is still a truthy object, so the `if (session.initBytes)` cache guard treated it as "already resolved" and kept serving the empty file for the rest of the session, which hls.js can never initialize a SourceBuffer from. Fixed by treating a zero-byte read as not-yet-ready (keeps the caller's existing long-poll retrying) instead of caching it as final.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.47",
3
+ "version": "2.9.49",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -40,7 +40,14 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
40
40
  const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
41
41
  const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
42
42
 
43
- const stats = torrentPool.getFileStats(torrent, fileIndex);
43
+ // Optional: pin the resume window to a FIXED byte offset for the duration of
44
+ // one buffering episode (see getFileStats JSDoc) instead of the live, moving
45
+ // read position — otherwise "bytes needed" can jump up mid-poll as the window
46
+ // slides forward with playback/encoding progress.
47
+ const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
48
+ const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
49
+
50
+ const stats = torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
44
51
 
45
52
  // Diagnostic: surface the real swarm state per poll so a cold-start download
46
53
  // stall (0 peers / header not advancing → playback-plan blocks on the codec
@@ -14,7 +14,7 @@ export async function handleApiTranscodeSessionsProgressGet(req, reply, { hlsSes
14
14
  return reply.code(400).send({ error: "sessionId is required." });
15
15
  }
16
16
 
17
- const progress = hlsSessionManager.getSessionProgress(sessionId);
17
+ const progress = await hlsSessionManager.getSessionProgress(sessionId);
18
18
  if (!progress) {
19
19
  return reply.code(404).send({ error: "Transcode session was not found." });
20
20
  }
@@ -68,6 +68,10 @@ const SEEK_SETTLE_MS = 1_200;
68
68
  // Hard cap on the total settle wait, measured from the first far request of a
69
69
  // burst, so a still-moving scrubber cannot delay a genuine seek forever.
70
70
  const SEEK_SETTLE_MAX_MS = 2_500;
71
+ // Grace period to wait for the PREVIOUS ffmpeg process to exit (per signal
72
+ // escalation step: SIGTERM, then SIGKILL) before spawning its replacement into
73
+ // the same session directory. See #startEncodeRun.
74
+ const ENCODE_RUN_TERMINATE_GRACE_MS = 2_000;
71
75
  // Idle TTL: a session is disposed this long after the last segment/playlist
72
76
  // access. Long enough that a viewer who pauses, backgrounds the tab, or briefly
73
77
  // turns the phone off can resume WITHOUT a cold ffmpeg restart (the warm session
@@ -161,6 +165,20 @@ function waitForChildExit(child, timeoutMs = 2_000) {
161
165
  });
162
166
  }
163
167
 
168
+ /**
169
+ * Whether a child process has genuinely exited. `ChildProcess.killed` only
170
+ * means `.kill()` was called — the process can stay alive well after that
171
+ * (blocked in I/O, ignoring/delaying the signal). `exitCode`/`signalCode` are
172
+ * only set once the `exit` event has actually fired, so this is the reliable
173
+ * check before treating a directory/file as free for a new process to use.
174
+ *
175
+ * @param {import("node:child_process").ChildProcess} child
176
+ * @returns {boolean}
177
+ */
178
+ function hasChildExited(child) {
179
+ return child.exitCode !== null || child.signalCode !== null;
180
+ }
181
+
164
182
  /**
165
183
  * Convert a bind-all host address to the loopback address so that
166
184
  * the HLS input URL is always reachable from the same machine.
@@ -600,6 +618,9 @@ function normalizeLogFileName(fileName, fileIndex) {
600
618
  * @property {string} lastError
601
619
  * @property {Set<string>} consumers - Consumer IDs currently using this session.
602
620
  * @property {object} progress - Live progress metrics updated from ffmpeg stdout.
621
+ * @property {number} encodeRunGeneration - Bumped on every #startEncodeRun call;
622
+ * lets a call that awaited the previous ffmpeg's exit detect it was superseded
623
+ * by a newer restart request and abort instead of spawning a second process.
603
624
  */
604
625
 
605
626
  /**
@@ -878,6 +899,7 @@ export class HlsSessionManager {
878
899
  startedAt: Date.now(),
879
900
  lastAccessedAt: Date.now(),
880
901
  ffmpeg: null,
902
+ encodeRunGeneration: 0,
881
903
  lastError: "",
882
904
  // Cold-start timing: entry timestamp + a once-guard so the first servable
883
905
  // segment logs its latency exactly once.
@@ -979,7 +1001,7 @@ export class HlsSessionManager {
979
1001
  `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
980
1002
  );
981
1003
 
982
- this.#startEncodeRun(session, 0);
1004
+ await this.#startEncodeRun(session, 0);
983
1005
 
984
1006
  try {
985
1007
  await this.waitUntilReady(session);
@@ -1229,7 +1251,7 @@ export class HlsSessionManager {
1229
1251
  if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
1230
1252
  return false; // let the previous action settle
1231
1253
  }
1232
- this.#applyBudgetDownshift(
1254
+ await this.#applyBudgetDownshift(
1233
1255
  session,
1234
1256
  `link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps buffer=${report.bufferedAheadSec.toFixed(1)}s`,
1235
1257
  "link"
@@ -1301,7 +1323,7 @@ export class HlsSessionManager {
1301
1323
  session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
1302
1324
  continue;
1303
1325
  }
1304
- this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
1326
+ await this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
1305
1327
  }
1306
1328
  }
1307
1329
 
@@ -1349,9 +1371,9 @@ export class HlsSessionManager {
1349
1371
  * @param {HlsSession} session
1350
1372
  * @param {string} reasonText - Measurement summary for the log line.
1351
1373
  * @param {"cpu" | "unknown" | "link"} bound
1352
- * @returns {void}
1374
+ * @returns {Promise<void>}
1353
1375
  */
1354
- #applyBudgetDownshift(session, reasonText, bound) {
1376
+ async #applyBudgetDownshift(session, reasonText, bound) {
1355
1377
  const nextIndex = session.budgetRungIndex + 1;
1356
1378
  const rung = session.budgetLadder[nextIndex];
1357
1379
  if (!rung) {
@@ -1380,22 +1402,66 @@ export class HlsSessionManager {
1380
1402
  `(rung ${nextIndex + 1}/${session.budgetLadder.length}, downshift ${session.budgetDownshifts}/${BUDGET_MAX_DOWNSHIFTS}), ` +
1381
1403
  `restart at segment #${currentSeg} "${session.fileName}"`
1382
1404
  );
1383
- this.#startEncodeRun(session, currentSeg);
1405
+ await this.#startEncodeRun(session, currentSeg);
1384
1406
  }
1385
1407
 
1386
1408
  /**
1387
1409
  * (Re)start the ffmpeg encode run beginning at segment `startIndex`.
1388
1410
  *
1389
- * Any ffmpeg process currently running for this session is terminated first.
1411
+ * Any ffmpeg process currently running for this session is terminated FIRST
1412
+ * AND ITS EXIT IS AWAITED before the replacement is spawned into the same
1413
+ * directory. This closes a real incident: a fire-and-forget SIGTERM does not
1414
+ * mean the process is dead — `ChildProcess.killed` reflects only that a
1415
+ * signal was sent, not that the process exited (ffmpeg's own blocking read of
1416
+ * our torrent-backed `/stream` input can defer signal handling for a long
1417
+ * time while starved). On a rapid sequence of seeks this left multiple
1418
+ * ffmpeg processes alive concurrently, all writing into the SAME session
1419
+ * directory — observed as `failed to rename file segment-NNNNN.m4s.tmp`
1420
+ * (a dying process racing a fresh one) and a zombie process still writing a
1421
+ * `.tmp` file ~30s after being "killed" by two LATER restarts, even after the
1422
+ * session had already been released. Multiple ffmpeg processes fighting over
1423
+ * CPU and the same files on a weak host is what a seek could get "stuck" on.
1424
+ *
1425
+ * Because this now awaits, a NEWER restart request can arrive while an OLDER
1426
+ * one is still waiting for the previous process to die. `encodeRunGeneration`
1427
+ * resolves that: each call captures its own generation number, and after the
1428
+ * await, a call whose generation was superseded aborts without spawning —
1429
+ * only the LATEST requested target ever actually starts a process.
1430
+ *
1390
1431
  * Segment files are named with a global index (`-start_number`) so they
1391
1432
  * always line up with the synthetic VOD playlist regardless of where
1392
1433
  * encoding started — this is what makes server-side seeking work.
1393
1434
  *
1394
1435
  * @param {HlsSession} session
1395
1436
  * @param {number} startIndex
1396
- * @returns {void}
1437
+ * @returns {Promise<void>}
1397
1438
  */
1398
- #startEncodeRun(session, startIndex) {
1439
+ async #startEncodeRun(session, startIndex) {
1440
+ const generation = ++session.encodeRunGeneration;
1441
+ const previousFfmpeg = session.ffmpeg;
1442
+ if (previousFfmpeg && !hasChildExited(previousFfmpeg)) {
1443
+ try {
1444
+ previousFfmpeg.kill("SIGTERM");
1445
+ } catch {
1446
+ // Best effort.
1447
+ }
1448
+ await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
1449
+ if (!hasChildExited(previousFfmpeg)) {
1450
+ try {
1451
+ previousFfmpeg.kill("SIGKILL");
1452
+ } catch {
1453
+ // Best effort.
1454
+ }
1455
+ await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
1456
+ }
1457
+ }
1458
+ // A newer restart (or disposal) won the race while we were waiting for the
1459
+ // old process to die — it either already spawned its own replacement or
1460
+ // there is nothing left to start. Do not also spawn from this stale call.
1461
+ if (session.encodeRunGeneration !== generation || session.state === "disposed") {
1462
+ return;
1463
+ }
1464
+
1399
1465
  const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
1400
1466
  // 0-based output time of this segment, from the boundary table (uniform for
1401
1467
  // re-encode, real keyframe for copy).
@@ -1640,7 +1706,7 @@ export class HlsSessionManager {
1640
1706
  `transcode ${session.id} hardware encoder ${failedEncoder} failed ` +
1641
1707
  `(${session.lastError}); falling back to software libx264 and restarting`
1642
1708
  );
1643
- this.#startEncodeRun(session, session.encodeStartIndex);
1709
+ void this.#startEncodeRun(session, session.encodeStartIndex);
1644
1710
  return;
1645
1711
  }
1646
1712
  session.state = "failed";
@@ -1722,7 +1788,7 @@ export class HlsSessionManager {
1722
1788
  session.seekTarget = null;
1723
1789
  session.seekFirstFarAt = 0;
1724
1790
  logger.info(`transcode ${session.id} seek settle → restart at segment #${target}`);
1725
- this.#startEncodeRun(session, target);
1791
+ void this.#startEncodeRun(session, target);
1726
1792
  }
1727
1793
 
1728
1794
  /**
@@ -1904,9 +1970,9 @@ export class HlsSessionManager {
1904
1970
  * Also refreshes `lastAccessedAt` to prevent the session from expiring.
1905
1971
  *
1906
1972
  * @param {string} sessionId
1907
- * @returns {object | null}
1973
+ * @returns {Promise<object | null>}
1908
1974
  */
1909
- getSessionProgress(sessionId) {
1975
+ async getSessionProgress(sessionId) {
1910
1976
  if (!isSafeSessionId(sessionId)) {
1911
1977
  return null;
1912
1978
  }
@@ -1924,6 +1990,13 @@ export class HlsSessionManager {
1924
1990
  const warmupRemainingSeconds = isWarmupPhase
1925
1991
  ? Math.max(0, warmupTotalSeconds - warmupElapsedSeconds)
1926
1992
  : null;
1993
+ // Observed OUTPUT bitrate (Mbit/s) from recently completed segment sizes —
1994
+ // already computed for the viewer-link budget check (#checkLinkBudget); also
1995
+ // exposed here so the browser can turn its OWN measured link throughput into
1996
+ // a "content-seconds delivered per wall-clock second" rate for the unified
1997
+ // three-stage ETA (download / transcode / delivery), the same way the
1998
+ // transcode's own `speed` already is one. Null when not enough segments yet.
1999
+ const outputMbps = await this.#observedStreamMbps(session);
1927
2000
  return {
1928
2001
  sessionId: session.id,
1929
2002
  state: session.progress.state,
@@ -1939,6 +2012,7 @@ export class HlsSessionManager {
1939
2012
  // of a percentage of the whole-file transcode.
1940
2013
  segmentDurationSec: this.segmentDurationSec,
1941
2014
  speed: session.progress.speed,
2015
+ outputMbps,
1942
2016
  updatedAt: session.progress.updatedAt,
1943
2017
  error: session.state === "failed" ? session.lastError : ""
1944
2018
  };
@@ -727,6 +727,16 @@ export class TorrentPool {
727
727
  *
728
728
  * @param {import("webtorrent").Torrent} torrent
729
729
  * @param {number | null} [fileIndex] - Zero-based file index, or null for torrent-level only.
730
+ * @param {{ resumeAnchorByteStart?: number | null }} [options] - `resumeAnchorByteStart`
731
+ * pins the resume window to a FIXED byte offset instead of the live (moving)
732
+ * read position. Without it, the window is anchored to wherever the file is
733
+ * CURRENTLY being read from — which slides forward as playback/encoding
734
+ * advances, so "bytes still needed" can jump up mid-poll even though nothing
735
+ * regressed (the window just moved past already-downloaded pieces into
736
+ * fresh ones). The caller should capture the returned `resumeAnchorByteStart`
737
+ * on the FIRST poll of a buffering episode and pass it back on subsequent
738
+ * polls of that SAME episode, so "bytes needed" counts down monotonically
739
+ * against a fixed target instead of chasing a moving one.
730
740
  * @returns {{
731
741
  * numPeers: number,
732
742
  * downloadSpeed: number,
@@ -736,7 +746,7 @@ export class TorrentPool {
736
746
  * fileLength: number | null
737
747
  * }}
738
748
  */
739
- getFileStats(torrent, fileIndex = null) {
749
+ getFileStats(torrent, fileIndex = null, options = {}) {
740
750
  const numPeers = typeof torrent?.numPeers === "number" ? torrent.numPeers : 0;
741
751
  const downloadSpeed = typeof torrent?.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
742
752
  const uploadSpeed = typeof torrent?.uploadSpeed === "number" ? torrent.uploadSpeed : 0;
@@ -754,10 +764,14 @@ export class TorrentPool {
754
764
 
755
765
  const header = this.#getHeaderRangeProgress(torrent, file);
756
766
 
757
- // Bytes still to download in the window ahead of where this file is being
758
- // read — "how much left to resume". Null until a read position is known.
767
+ // Bytes still to download in the window ahead of the anchor point "how
768
+ // much left to resume". Null until a read position is known. The anchor is
769
+ // the caller-supplied FROZEN offset when given (see JSDoc above), otherwise
770
+ // the live (moving) read position tracked from /stream range requests.
759
771
  const readPositions = this.#readPositionByTorrent.get(torrent);
760
- const readByteStart = readPositions ? readPositions.get(fileIndex) : undefined;
772
+ const liveReadByteStart = readPositions ? readPositions.get(fileIndex) : undefined;
773
+ const requestedAnchor = options?.resumeAnchorByteStart;
774
+ const readByteStart = Number.isFinite(requestedAnchor) ? requestedAnchor : liveReadByteStart;
761
775
  const resume = typeof readByteStart === "number"
762
776
  ? this.#getResumeWindowProgress(torrent, file, readByteStart)
763
777
  : null;
@@ -772,9 +786,11 @@ export class TorrentPool {
772
786
  fileProgress: fileLength > 0 ? Math.max(0, Math.min(1, fileDownloaded / fileLength)) : 0,
773
787
  fileDownloaded,
774
788
  fileLength,
775
- // Resume window (ahead of the read head): bytes needed vs downloaded.
789
+ // Resume window (ahead of the anchor): bytes needed vs downloaded, plus
790
+ // the anchor itself so the caller can pin it for the rest of one episode.
776
791
  resumeNeededBytes: resume ? resume.totalBytes : null,
777
792
  resumeDownloadedBytes: resume ? resume.downloadedBytes : null,
793
+ resumeAnchorByteStart: typeof readByteStart === "number" ? readByteStart : null,
778
794
  // Phase-1 progress: how much of the header/index region (the bytes the
779
795
  // codec probe needs before transcoding can start) is downloaded. Counted
780
796
  // by whole pieces from the torrent bitfield, so it advances coarsely