@torrent-tv/proxy 2.9.10 → 2.9.12
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 +1 -1
- package/routes/stream/get.js +5 -0
- package/services/hls-session-manager.js +22 -6
- package/services/hwaccel.js +11 -1
- package/services/torrent-pool.js +46 -0
- package/utils/logger.js +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 2.9.12
|
|
2
|
+
|
|
3
|
+
- **Fix**: Eliminate PTS-gap glitches (stutter/freeze on video while audio keeps playing) at start and after seeking, for both transcode modes:
|
|
4
|
+
- **Branch A — video re-encoded** (`video=libx264`): use a fixed GOP (`-g`/`-keyint_min` = segmentDuration × fps, `-sc_threshold 0`) instead of `-force_key_frames expr:gte(t,n_forced*SEG)`. The old expression broke after a seek because `t` is shifted by `-output_ts_offset`, forcing keyframes at the wrong places and producing segments that did not line up with the playlist grid. A frame-count GOP is offset-independent → every segment is exactly segmentDuration and starts on a keyframe.
|
|
5
|
+
- **Branch B — video copied** (`video=copy`, only audio transcoded): keep the source's real timestamps with `-copyts` (and accurate seek) instead of relabelling onto a 4 s grid that does not match the source's own keyframe positions. Relabelling was the source of the holes in this mode.
|
|
6
|
+
- **Chore**: Session-start log tags the active branch (`branch=A(reencode,fixed-gop)` / `branch=B(copy,copyts)`) so glitches can be attributed to the right mode.
|
|
7
|
+
- **Fix**: Log timestamps reverted to UTC (`HH:MM:SS.mmm`) so the proxy and browser logs share one timezone and line up exactly when correlated.
|
|
8
|
+
|
|
9
|
+
## 2.9.11
|
|
10
|
+
|
|
11
|
+
- **New**: Seek-aware torrent piece prioritization. On every `/stream` range request the proxy now marks the torrent pieces at the read position **critical** (`TorrentPool.prioritizeByteRange` → `torrent.critical`, ~8 MB window). After a seek, ffmpeg opens the input at a new byte offset; previously those pieces waited behind the sequential download backlog, so seeking into an undownloaded region stalled ~15-18 s while the proxy fetched data. Now the seek position jumps the download queue.
|
|
12
|
+
|
|
1
13
|
## 2.9.10
|
|
2
14
|
|
|
3
15
|
- **Fix**: Raised the adaptive-preset speed margin (`PRESET_SPEED_MARGIN` 1.3 → 1.8). The preset benchmark runs at startup with an idle CPU, but during playback ffmpeg competes with in-process WebTorrent (download + SHA1 hashing) and delivery, so real throughput is lower than benchmarked. A 1.3× margin picked a preset that ran near/below realtime under load (e.g. `faster` at ~1.3×) and stalled; 1.8× picks a preset with genuine headroom (e.g. `veryfast`), keeping playback above 1× under real load.
|
package/package.json
CHANGED
package/routes/stream/get.js
CHANGED
|
@@ -64,6 +64,11 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool
|
|
|
64
64
|
const releaseFile = torrentPool.acquireFile(torrent, fileIndex);
|
|
65
65
|
|
|
66
66
|
const range = parseRange(req.headers.range, file.length);
|
|
67
|
+
// Prioritize the pieces at this read position so a seek (a request at a new
|
|
68
|
+
// byte offset) downloads first instead of waiting behind the sequential
|
|
69
|
+
// backlog — this is what caused ~15-18 s stalls when seeking into an
|
|
70
|
+
// undownloaded region.
|
|
71
|
+
torrentPool.prioritizeByteRange(torrent, fileIndex, range ? range.start : 0);
|
|
67
72
|
reply.header("Accept-Ranges", "bytes");
|
|
68
73
|
reply.header("Content-Type", "application/octet-stream");
|
|
69
74
|
reply.header("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`);
|
|
@@ -616,6 +616,9 @@ export class HlsSessionManager {
|
|
|
616
616
|
`transcode ${sessionId} start "${logName}" ` +
|
|
617
617
|
`video=${transcodeVideo ? `${this.videoEncoder.name}${softwarePreset ? `/${softwarePreset}` : ""}` : "copy"} ` +
|
|
618
618
|
`audio=${transcodeAudio ? "aac" : "copy"} ` +
|
|
619
|
+
// Branch tag for log correlation: A = video re-encode (fixed GOP, grid
|
|
620
|
+
// aligned, ts-offset); B = video copy (cut at source keyframes, copyts).
|
|
621
|
+
`branch=${transcodeVideo ? "A(reencode,fixed-gop)" : "B(copy,copyts)"} ` +
|
|
619
622
|
`${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
|
|
620
623
|
`duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
|
|
621
624
|
);
|
|
@@ -740,14 +743,27 @@ export class HlsSessionManager {
|
|
|
740
743
|
args.push(...this.videoEncoder.inputArgs);
|
|
741
744
|
}
|
|
742
745
|
if (startSeconds > 0) {
|
|
743
|
-
//
|
|
744
|
-
|
|
746
|
+
// Accurate seek before -i (decodes from the preceding keyframe and trims
|
|
747
|
+
// to the exact point), so the first output frame is exactly at startSeconds.
|
|
748
|
+
args.push("-accurate_seek", "-ss", String(startSeconds));
|
|
745
749
|
}
|
|
746
750
|
args.push("-i", session.inputUrl);
|
|
747
|
-
if (
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
|
|
751
|
+
if (session.transcodeVideo) {
|
|
752
|
+
// Branch A (re-encode): fixed GOP makes keyframes land exactly on the
|
|
753
|
+
// segment grid; relabel output onto the original timeline so segment N
|
|
754
|
+
// carries PTS = N × segmentDuration.
|
|
755
|
+
if (startSeconds > 0) {
|
|
756
|
+
args.push("-output_ts_offset", String(startSeconds));
|
|
757
|
+
}
|
|
758
|
+
} else {
|
|
759
|
+
// Branch B (video copied — only audio is transcoded): we cannot insert
|
|
760
|
+
// keyframes, so segments are cut at the source's own keyframes. Keep the
|
|
761
|
+
// source's real timestamps (`-copyts`) instead of relabelling, so the
|
|
762
|
+
// copied frames stay continuous across segment boundaries and seek-restarts
|
|
763
|
+
// (relabelling to a 4 s grid that does not match the real keyframe times is
|
|
764
|
+
// exactly what produced the PTS holes / glitches). Audio is transcoded on
|
|
765
|
+
// the same timeline.
|
|
766
|
+
args.push("-copyts");
|
|
751
767
|
}
|
|
752
768
|
args.push(
|
|
753
769
|
"-map",
|
package/services/hwaccel.js
CHANGED
|
@@ -90,7 +90,17 @@ export function softwareDescriptor() {
|
|
|
90
90
|
"-crf", SOFTWARE_CRF,
|
|
91
91
|
"-threads", String(CPU_THREADS),
|
|
92
92
|
"-pix_fmt", "yuv420p",
|
|
93
|
-
|
|
93
|
+
// Fixed GOP: a keyframe exactly every (segmentDurationSec × fps) frames,
|
|
94
|
+
// scene-cut keyframes disabled. This is frame-count based, so it is
|
|
95
|
+
// independent of the PTS offset used on seek-restart — every HLS segment
|
|
96
|
+
// is exactly segmentDurationSec long and starts on a keyframe, so segment
|
|
97
|
+
// boundaries line up with the synthetic playlist with no gaps. (The old
|
|
98
|
+
// `-force_key_frames expr:gte(t,n_forced*SEG)` broke after a seek because
|
|
99
|
+
// `t` is offset by `-output_ts_offset`, forcing keyframes at the wrong
|
|
100
|
+
// places.)
|
|
101
|
+
"-g", String(segmentDurationSec * TRANSCODE_FPS),
|
|
102
|
+
"-keyint_min", String(segmentDurationSec * TRANSCODE_FPS),
|
|
103
|
+
"-sc_threshold", "0"
|
|
94
104
|
];
|
|
95
105
|
}
|
|
96
106
|
};
|
package/services/torrent-pool.js
CHANGED
|
@@ -10,6 +10,11 @@ import crypto from "node:crypto";
|
|
|
10
10
|
import WebTorrent from "webtorrent";
|
|
11
11
|
import { logger } from "../utils/logger.js";
|
|
12
12
|
|
|
13
|
+
// Bytes ahead of a read position to mark CRITICAL (download-first) on each
|
|
14
|
+
// range request. Big enough to unstick a seek into an undownloaded region,
|
|
15
|
+
// small enough not to make "everything critical" (which defeats prioritization).
|
|
16
|
+
const PRIORITY_WINDOW_BYTES = 8 * 1024 * 1024;
|
|
17
|
+
|
|
13
18
|
/**
|
|
14
19
|
* Decode a raw torrent source value into the format expected by WebTorrent.
|
|
15
20
|
*
|
|
@@ -318,4 +323,45 @@ export class TorrentPool {
|
|
|
318
323
|
}
|
|
319
324
|
}
|
|
320
325
|
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Mark the torrent pieces covering a byte window of a file as CRITICAL, so
|
|
329
|
+
* WebTorrent downloads them before the rest of the selected file. Called on
|
|
330
|
+
* every range request: after a seek, the new read position jumps the download
|
|
331
|
+
* queue instead of waiting behind the sequential backlog (which caused
|
|
332
|
+
* ~15-18 s stalls when seeking into an undownloaded region).
|
|
333
|
+
*
|
|
334
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
335
|
+
* @param {number} fileIndex
|
|
336
|
+
* @param {number} byteStart - Start offset within the file.
|
|
337
|
+
* @param {number} [windowBytes] - Bytes ahead of `byteStart` to prioritize.
|
|
338
|
+
* @returns {void}
|
|
339
|
+
*/
|
|
340
|
+
prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes = PRIORITY_WINDOW_BYTES) {
|
|
341
|
+
if (!torrent || typeof torrent.critical !== "function" || !Array.isArray(torrent.files)) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const pieceLength = Number(torrent.pieceLength);
|
|
345
|
+
if (!Number.isFinite(pieceLength) || pieceLength <= 0) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const file = torrent.files[fileIndex];
|
|
349
|
+
if (!file) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const fileOffset = Number.isFinite(file.offset) ? file.offset : 0;
|
|
353
|
+
const safeStart = Math.max(0, Number(byteStart) || 0);
|
|
354
|
+
const absStart = fileOffset + safeStart;
|
|
355
|
+
const absEnd = Math.min(fileOffset + file.length - 1, absStart + Math.max(1, windowBytes) - 1);
|
|
356
|
+
const startPiece = Math.floor(absStart / pieceLength);
|
|
357
|
+
const endPiece = Math.floor(absEnd / pieceLength);
|
|
358
|
+
if (endPiece < startPiece) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
torrent.critical(startPiece, endPiece);
|
|
363
|
+
} catch {
|
|
364
|
+
// Best effort — never break streaming because prioritization failed.
|
|
365
|
+
}
|
|
366
|
+
}
|
|
321
367
|
}
|
package/utils/logger.js
CHANGED
|
@@ -10,13 +10,14 @@ import chalk from "chalk";
|
|
|
10
10
|
const PREFIX = "[proxy-client]";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Return the current time as a compact ISO-8601 string, e.g.
|
|
14
|
-
*
|
|
13
|
+
* Return the current time as a compact ISO-8601 (UTC) string, e.g.
|
|
14
|
+
* `12:34:56.789`. UTC is used deliberately so proxy and browser logs share the
|
|
15
|
+
* same timezone and line up exactly when correlating them.
|
|
15
16
|
*
|
|
16
17
|
* @returns {string}
|
|
17
18
|
*/
|
|
18
19
|
function ts() {
|
|
19
|
-
return new Date().toISOString().slice(11, 23); // "HH:MM:SS.mmm"
|
|
20
|
+
return new Date().toISOString().slice(11, 23); // "HH:MM:SS.mmm" (UTC)
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/**
|