@torrent-tv/proxy 2.9.45 → 2.9.47
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 +8 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +26 -1
- package/services/torrent-pool.js +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.47
|
|
2
|
+
|
|
3
|
+
- **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.
|
|
4
|
+
|
|
5
|
+
## 2.9.46
|
|
6
|
+
|
|
7
|
+
- **New**: `getFileStats` now reports `resumeNeededBytes` / `resumeDownloadedBytes` — the bytes still to download in the 16 MB window ahead of the file's current read position (tracked per file by `prioritizeByteRange`, cleared on torrent removal), counted byte-accurately including partial pieces. Lets the browser show how much is left to download and the time to resume while buffering.
|
|
8
|
+
|
|
1
9
|
## 2.9.45
|
|
2
10
|
|
|
3
11
|
- **New**: HLS transcode output switched from MPEG-TS (`.ts`) to **fMP4/CMAF** (`.m4s` segments + a shared `init.mp4`). Codec parameter sets (SPS/PPS) now live once in the init segment (referenced by `#EXT-X-MAP`) instead of being repeated in every segment. Benefits: (1) hardware encoders that do not repeat parameter sets — notably the CM4 / HA-Yellow `h264_v4l2m2m` — produce independently-usable segments (on `.ts` the segments after the first lacked SPS/PPS → "non-existing PPS", which is why v4l2m2m was rejected); (2) lower container overhead. The synthetic VOD playlist now emits `#EXT-X-VERSION:7` + `#EXT-X-MAP`; each seek-restart run rewrites `init.mp4`, so `getFileStream` caches and serves the FIRST init for the whole session — it is codec-config-only and position-independent (verified: a single init cleanly decodes segments produced by a later seek-restart run). Raised v4l2m2m `-num_capture_buffers` to 32 (the default 4 deadlocks / drops frames on the CM4). **Verified**: server-side clean decode of the synthetic playlist across seek-restart runs; end-to-end playback **and seek** in hls.js 1.6.16. **Still needs**: verification on native iOS HLS (Safari fMP4) before relying on it. NOTE: v4l2m2m still emits a residual no-picture access unit that the strict startup test rejects, so it continues to fall back to software for now (no regression); fMP4 removes the SPS/PPS blocker — the remaining quirk is separate.
|
package/package.json
CHANGED
|
@@ -48,6 +48,15 @@ const MAX_LOOKAHEAD_SEGMENTS = 8;
|
|
|
48
48
|
// succession (stall-recovery seeks); without a cooldown ffmpeg ping-pongs
|
|
49
49
|
// between positions, restarting endlessly and producing nothing.
|
|
50
50
|
const RESTART_COOLDOWN_MS = 4_000;
|
|
51
|
+
// Encoder stall watchdog. A running ffmpeg emits `-progress` output on stdout
|
|
52
|
+
// continuously while it encodes; when it hangs mid-file (alive, but producing
|
|
53
|
+
// no output and no stderr — a deadlock, e.g. a stalled input read), that output
|
|
54
|
+
// stops and `progress.updatedAt` freezes. If a segment INSIDE the look-ahead
|
|
55
|
+
// window is being demanded but progress has not advanced for this long, the
|
|
56
|
+
// encoder is wedged (observed: the segment 503s forever). Treat it like a seek
|
|
57
|
+
// and restart ffmpeg at the demanded segment. Conservative — a slow-but-moving
|
|
58
|
+
// encode keeps advancing `updatedAt`, so this only fires on a true freeze.
|
|
59
|
+
const ENCODER_STALL_MS = 12_000;
|
|
51
60
|
// Seek debounce. A far (out-of-window) segment request is a server-side seek.
|
|
52
61
|
// Rather than restart ffmpeg on the first one, wait a short quiet period:
|
|
53
62
|
// further far requests re-arm it and update the target to the latest index, so
|
|
@@ -1804,12 +1813,28 @@ export class HlsSessionManager {
|
|
|
1804
1813
|
// and position-independent, but each seek-restart run REWRITES init.mp4, so
|
|
1805
1814
|
// cache the FIRST one and always serve that — otherwise the init the player
|
|
1806
1815
|
// fetched could differ from a later run's, breaking playback after a seek.
|
|
1816
|
+
//
|
|
1817
|
+
// ffmpeg creates init.mp4 before it has finished writing the fMP4 header
|
|
1818
|
+
// boxes into it (unlike segments, its write is not gated behind an atomic
|
|
1819
|
+
// rename), so a read can race a moment where the file EXISTS but is still
|
|
1820
|
+
// EMPTY. Root cause of a real incident: that empty read used to be cached
|
|
1821
|
+
// as `session.initBytes` — a zero-length Buffer is still a truthy object,
|
|
1822
|
+
// so `if (session.initBytes)` treated it as "already resolved" and served
|
|
1823
|
+
// the empty file for the rest of the session's life, permanently breaking
|
|
1824
|
+
// playback (hls.js can never initialize its SourceBuffer from an empty
|
|
1825
|
+
// init segment) while the transcode itself kept encoding normally. Guard
|
|
1826
|
+
// on non-empty content on both the cache check and the fresh read, so an
|
|
1827
|
+
// empty read is treated as not-yet-ready and the caller's long-poll keeps
|
|
1828
|
+
// retrying until ffmpeg has actually written the header.
|
|
1807
1829
|
if (fileName === SEGMENT_INIT_FILE_NAME) {
|
|
1808
|
-
if (session.initBytes) {
|
|
1830
|
+
if (session.initBytes && session.initBytes.length > 0) {
|
|
1809
1831
|
return { kind: "file", stream: Readable.from([session.initBytes]), contentType: "video/mp4", isPlaylist: false };
|
|
1810
1832
|
}
|
|
1811
1833
|
try {
|
|
1812
1834
|
const bytes = await readFile(path.join(session.dirPath, SEGMENT_INIT_FILE_NAME));
|
|
1835
|
+
if (bytes.length === 0) {
|
|
1836
|
+
return { kind: "warming-up" };
|
|
1837
|
+
}
|
|
1813
1838
|
session.initBytes = bytes;
|
|
1814
1839
|
return { kind: "file", stream: Readable.from([bytes]), contentType: "video/mp4", isPlaylist: false };
|
|
1815
1840
|
} catch {
|
package/services/torrent-pool.js
CHANGED
|
@@ -295,6 +295,16 @@ export class TorrentPool {
|
|
|
295
295
|
*/
|
|
296
296
|
#lastAccess = new Map();
|
|
297
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Last byte offset each active reader is streaming from, keyed by torrent then
|
|
300
|
+
* fileIndex. Set by prioritizeByteRange on every /stream range request; read by
|
|
301
|
+
* getFileStats to report how much of the window ahead of the read head is still
|
|
302
|
+
* to download — the "amount left to resume" shown while buffering.
|
|
303
|
+
*
|
|
304
|
+
* @type {Map<import("webtorrent").Torrent, Map<number, number>>}
|
|
305
|
+
*/
|
|
306
|
+
#readPositionByTorrent = new Map();
|
|
307
|
+
|
|
298
308
|
/** Global disk cap in bytes (0 = disabled). */
|
|
299
309
|
#maxDiskBytes = 0;
|
|
300
310
|
|
|
@@ -700,6 +710,7 @@ export class TorrentPool {
|
|
|
700
710
|
}
|
|
701
711
|
this.fileUsageByTorrent.delete(torrent);
|
|
702
712
|
this.#lastAccess.delete(torrent);
|
|
713
|
+
this.#readPositionByTorrent.delete(torrent);
|
|
703
714
|
const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
|
|
704
715
|
try {
|
|
705
716
|
torrent.destroy({ destroyStore: true }, () => {
|
|
@@ -743,6 +754,14 @@ export class TorrentPool {
|
|
|
743
754
|
|
|
744
755
|
const header = this.#getHeaderRangeProgress(torrent, file);
|
|
745
756
|
|
|
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.
|
|
759
|
+
const readPositions = this.#readPositionByTorrent.get(torrent);
|
|
760
|
+
const readByteStart = readPositions ? readPositions.get(fileIndex) : undefined;
|
|
761
|
+
const resume = typeof readByteStart === "number"
|
|
762
|
+
? this.#getResumeWindowProgress(torrent, file, readByteStart)
|
|
763
|
+
: null;
|
|
764
|
+
|
|
746
765
|
// Null-safe downloaded/progress (webtorrent's own getters throw on a
|
|
747
766
|
// deselected null piece — see fileDownloadedBytes).
|
|
748
767
|
const fileLength = typeof file.length === "number" ? file.length : 0;
|
|
@@ -753,6 +772,9 @@ export class TorrentPool {
|
|
|
753
772
|
fileProgress: fileLength > 0 ? Math.max(0, Math.min(1, fileDownloaded / fileLength)) : 0,
|
|
754
773
|
fileDownloaded,
|
|
755
774
|
fileLength,
|
|
775
|
+
// Resume window (ahead of the read head): bytes needed vs downloaded.
|
|
776
|
+
resumeNeededBytes: resume ? resume.totalBytes : null,
|
|
777
|
+
resumeDownloadedBytes: resume ? resume.downloadedBytes : null,
|
|
756
778
|
// Phase-1 progress: how much of the header/index region (the bytes the
|
|
757
779
|
// codec probe needs before transcoding can start) is downloaded. Counted
|
|
758
780
|
// by whole pieces from the torrent bitfield, so it advances coarsely
|
|
@@ -808,6 +830,45 @@ export class TorrentPool {
|
|
|
808
830
|
return { totalBytes, downloadedBytes };
|
|
809
831
|
}
|
|
810
832
|
|
|
833
|
+
/**
|
|
834
|
+
* Count, by whole torrent pieces, how many bytes of the window AHEAD of the
|
|
835
|
+
* current read position are downloaded — i.e. how much is still to download
|
|
836
|
+
* before playback can continue from that point. Mirrors
|
|
837
|
+
* {@link #getHeaderRangeProgress}, for the moving read head.
|
|
838
|
+
*
|
|
839
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
840
|
+
* @param {import("webtorrent").TorrentFile} file
|
|
841
|
+
* @param {number} readByteStart - Byte offset within the file the reader is at.
|
|
842
|
+
* @returns {{ totalBytes: number, downloadedBytes: number } | null}
|
|
843
|
+
*/
|
|
844
|
+
#getResumeWindowProgress(torrent, file, readByteStart) {
|
|
845
|
+
const pieceLength = Number(torrent?.pieceLength);
|
|
846
|
+
const bitfield = torrent?.bitfield;
|
|
847
|
+
const fileLength = Number(file?.length);
|
|
848
|
+
if (
|
|
849
|
+
!Number.isFinite(pieceLength) || pieceLength <= 0 ||
|
|
850
|
+
!bitfield || typeof bitfield.get !== "function" ||
|
|
851
|
+
!Number.isFinite(fileLength) || fileLength <= 0
|
|
852
|
+
) {
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
const fileOffset = Number.isFinite(file.offset) ? file.offset : 0;
|
|
856
|
+
const windowStart = Math.max(0, Math.min(Number(readByteStart) || 0, fileLength - 1));
|
|
857
|
+
const windowEnd = Math.min(fileLength - 1, windowStart + PRIORITY_WINDOW_BYTES - 1);
|
|
858
|
+
const firstPiece = Math.floor((fileOffset + windowStart) / pieceLength);
|
|
859
|
+
const lastPiece = Math.floor((fileOffset + windowEnd) / pieceLength);
|
|
860
|
+
// Byte-accurate: count the PARTIAL progress of in-progress pieces (not whole
|
|
861
|
+
// pieces), so "amount left" moves smoothly instead of jumping by a whole
|
|
862
|
+
// piece (8 MB here) at a time.
|
|
863
|
+
let totalBytes = 0;
|
|
864
|
+
let downloadedBytes = 0;
|
|
865
|
+
for (let piece = firstPiece; piece <= lastPiece; piece += 1) {
|
|
866
|
+
totalBytes += pieceLength;
|
|
867
|
+
downloadedBytes += pieceDownloadedBytes(torrent, piece);
|
|
868
|
+
}
|
|
869
|
+
return { totalBytes, downloadedBytes };
|
|
870
|
+
}
|
|
871
|
+
|
|
811
872
|
/**
|
|
812
873
|
* Pre-fetch the leading and trailing bytes of a torrent file so that
|
|
813
874
|
* WebTorrent prioritises the pieces that contain file headers and footers.
|
|
@@ -967,6 +1028,16 @@ export class TorrentPool {
|
|
|
967
1028
|
const fileEndPiece = Math.floor((fileOffset + fileLength - 1) / pieceLength);
|
|
968
1029
|
|
|
969
1030
|
const safeStart = Math.max(0, Number(byteStart) || 0);
|
|
1031
|
+
|
|
1032
|
+
// Remember where this file is being read from, so getFileStats can report the
|
|
1033
|
+
// download progress of the window ahead of the read head (resume amount).
|
|
1034
|
+
let readPositions = this.#readPositionByTorrent.get(torrent);
|
|
1035
|
+
if (!readPositions) {
|
|
1036
|
+
readPositions = new Map();
|
|
1037
|
+
this.#readPositionByTorrent.set(torrent, readPositions);
|
|
1038
|
+
}
|
|
1039
|
+
readPositions.set(fileIndex, safeStart);
|
|
1040
|
+
|
|
970
1041
|
const absStart = fileOffset + safeStart;
|
|
971
1042
|
const playheadPiece = Math.floor(absStart / pieceLength);
|
|
972
1043
|
const absWindowEnd = Math.min(
|