@torrent-tv/proxy 2.9.90 → 2.9.91
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,8 @@
|
|
|
1
|
+
## 2.9.91
|
|
2
|
+
|
|
3
|
+
- **Fix**: The encoder no longer runs away from the viewer. Nothing bounded how far ahead it produced: measured 2026-08-04, three minutes after a film was opened the encode had reached 00:39:24 of a 01:26:51 source at 12.8x while the viewer was still at the start, and the torrent had pulled 80% of 4.7 GB to feed it — the pool owner's bandwidth and disk spent on a viewer who may watch two minutes, the pieces being read evicted from memory by pieces forty minutes ahead, and the swarm busy with anything but the segment being waited for. An encoder more than two minutes of content ahead of the last segment its viewer asked for is now **suspended**, and released once the viewer is within a minute of it — or at once when a segment is requested. Suspended rather than killed on purpose: restarting costs about nine seconds on this hardware, so a viewer reaching the end of the produced range would stall every time, while suspending keeps the process, its input and its position. POSIX only; where `SIGSTOP` does not exist the attempt fails once, is logged, and that session keeps the old behaviour. Every path that terminates an encoder now releases it first — a suspended process does not act on `SIGTERM` until it is continued, which would have hung the wait a seek performs before starting its replacement.
|
|
4
|
+
- **New**: A reader reports what it waited for. When a read blocks a second or more on a piece, the log names the piece, its position in the read, and the offset the read started at. The first segment after a seek-restart costs 9.2-9.4 s and there was no way to tell whether that is the swarm, the piece picker or ffmpeg; now there is.
|
|
5
|
+
|
|
1
6
|
## 2.9.90
|
|
2
7
|
|
|
3
8
|
- **New**: The output container is chosen per session, by the viewer, instead of once per proxy. `POST /api/transcode-sessions` accepts `segmentFormat`; `--segment-format` remains the default for a client that expresses no preference, and an unrecognised value falls back to it rather than to the library default. The browser is the only party that knows what its media stack will accept for the tracks it asked to be copied: a copied MP3 track cannot be appended from fMP4 at all (`audio/mp4; codecs="mp4a.69"` is refused by MediaSource) but works from MPEG-TS, which hls.js demuxes itself and hands to a plain `audio/mpeg` buffer — the same file, the same browser, silent loop one way and normal playback the other. Sessions are keyed by container too, so two viewers wanting different ones do not share an encoder. Nothing branches on the format outside `services/segment-formats/`; the manager now reads it off the session.
|
package/package.json
CHANGED
|
@@ -63,6 +63,23 @@ const RUN_FIRST_SEGMENT_GRACE_MS = 30_000;
|
|
|
63
63
|
// and restart ffmpeg at the demanded segment. Conservative — a slow-but-moving
|
|
64
64
|
// encode keeps advancing `updatedAt`, so this only fires on a true freeze.
|
|
65
65
|
const ENCODER_STALL_MS = 12_000;
|
|
66
|
+
// How far ahead of the viewer the encoder may run before it is stopped, and how
|
|
67
|
+
// far it must fall back to before it is let go again.
|
|
68
|
+
//
|
|
69
|
+
// Nothing used to bound this. Measured 2026-08-04 on the copy path: three
|
|
70
|
+
// minutes after a film was opened the encode had reached 00:39:24 of a 01:26:51
|
|
71
|
+
// source at 12.8x while the viewer was still at the start, and the torrent had
|
|
72
|
+
// pulled 80% of 4.7 GB to feed it. That costs the pool owner's bandwidth and
|
|
73
|
+
// disk for a viewer who may watch two minutes, evicts from memory the pieces
|
|
74
|
+
// the viewer is actually reading, and competes for the swarm with the segment
|
|
75
|
+
// being waited on.
|
|
76
|
+
//
|
|
77
|
+
// In seconds of content rather than segments, because a segment is 4 s of
|
|
78
|
+
// re-encoded video but a whole keyframe interval on the copy path. Generous
|
|
79
|
+
// enough that ordinary watching never touches it: the encoder fills two minutes
|
|
80
|
+
// ahead, stops, and is released as soon as the viewer has spent a minute of it.
|
|
81
|
+
const LOOKAHEAD_PAUSE_SECONDS = 120;
|
|
82
|
+
const LOOKAHEAD_RESUME_SECONDS = 60;
|
|
66
83
|
// Seek debounce. A far (out-of-window) segment request is a server-side seek.
|
|
67
84
|
// Rather than restart ffmpeg on the first one, wait a short quiet period:
|
|
68
85
|
// further far requests re-arm it and update the target to the latest index, so
|
|
@@ -787,6 +804,7 @@ export class HlsSessionManager {
|
|
|
787
804
|
// benchmark (the only path that can pick/step resolution). Cheap no-op scan
|
|
788
805
|
// otherwise.
|
|
789
806
|
this.budgetTimer = setInterval(() => {
|
|
807
|
+
this.#enforceLookAhead();
|
|
790
808
|
void this.#enforceRealtimeBudget();
|
|
791
809
|
}, BUDGET_CHECK_INTERVAL_MS);
|
|
792
810
|
this.budgetTimer.unref();
|
|
@@ -1160,6 +1178,12 @@ export class HlsSessionManager {
|
|
|
1160
1178
|
// Bumped by every viewer seek; a held segment request that started under
|
|
1161
1179
|
// an older value gives up at once. See requestSeek.
|
|
1162
1180
|
waitEpoch: 0,
|
|
1181
|
+
// Highest segment the viewer has actually asked for, and whether the
|
|
1182
|
+
// encoder is currently suspended for running too far past it.
|
|
1183
|
+
// See #enforceLookAhead.
|
|
1184
|
+
lastRequestedSegment: null,
|
|
1185
|
+
encoderPaused: false,
|
|
1186
|
+
encoderPauseUnsupported: false,
|
|
1163
1187
|
seekFirstFarAt: 0,
|
|
1164
1188
|
// Circuit breaker: consecutive FAST failures (see SEEK_FAST_FAIL_MS) at
|
|
1165
1189
|
// seekFailureTarget. Reset whenever a run starts at a DIFFERENT target or
|
|
@@ -1549,6 +1573,94 @@ export class HlsSessionManager {
|
|
|
1549
1573
|
return true;
|
|
1550
1574
|
}
|
|
1551
1575
|
|
|
1576
|
+
/**
|
|
1577
|
+
* Stop encoders that have run too far ahead of their viewer, and release
|
|
1578
|
+
* those the viewer has caught up with.
|
|
1579
|
+
*
|
|
1580
|
+
* The encoder is SUSPENDED, not killed. Killing would be simpler, but
|
|
1581
|
+
* restarting it costs about nine seconds on this hardware — the torrent has
|
|
1582
|
+
* to serve a fresh position and ffmpeg has to reach its first keyframe — so a
|
|
1583
|
+
* viewer reaching the end of the produced range would stall every time.
|
|
1584
|
+
* Suspending keeps the process, its open input and its position, and costs
|
|
1585
|
+
* nothing to undo.
|
|
1586
|
+
*
|
|
1587
|
+
* POSIX only. `SIGSTOP` does not exist on Windows, where `process.kill`
|
|
1588
|
+
* throws; the attempt is made once per session and, if it fails, that session
|
|
1589
|
+
* simply keeps its old unbounded behaviour rather than breaking.
|
|
1590
|
+
*
|
|
1591
|
+
* @returns {void}
|
|
1592
|
+
*/
|
|
1593
|
+
#enforceLookAhead() {
|
|
1594
|
+
for (const session of this.sessionsById.values()) {
|
|
1595
|
+
if (!session || session.state === "disposed" || !session.ffmpeg) {
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
const encodedTo = Number(session.progress?.processedSeconds);
|
|
1599
|
+
if (!Number.isFinite(encodedTo)) {
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
// Where the viewer is. Before the first segment request, the position the
|
|
1603
|
+
// run started at — so a session nobody has read from yet is bounded too.
|
|
1604
|
+
const viewerAt = Number.isInteger(session.lastRequestedSegment)
|
|
1605
|
+
? this.#segmentStartTime(session, session.lastRequestedSegment)
|
|
1606
|
+
: this.#segmentStartTime(session, session.encodeStartIndex ?? 0);
|
|
1607
|
+
const ahead = encodedTo - viewerAt;
|
|
1608
|
+
if (!session.encoderPaused && ahead > LOOKAHEAD_PAUSE_SECONDS) {
|
|
1609
|
+
this.#pauseEncoder(session, `${Math.round(ahead)}s ahead of the viewer`);
|
|
1610
|
+
} else if (session.encoderPaused && ahead <= LOOKAHEAD_RESUME_SECONDS) {
|
|
1611
|
+
this.#resumeEncoder(session, `${Math.round(ahead)}s ahead of the viewer`);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* Suspend a session's encoder. No-op when already paused or unsupported here.
|
|
1618
|
+
*
|
|
1619
|
+
* @param {HlsSession} session
|
|
1620
|
+
* @param {string} reason
|
|
1621
|
+
* @returns {void}
|
|
1622
|
+
*/
|
|
1623
|
+
#pauseEncoder(session, reason) {
|
|
1624
|
+
if (session.encoderPaused || session.encoderPauseUnsupported || !session.ffmpeg?.pid) {
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
try {
|
|
1628
|
+
process.kill(session.ffmpeg.pid, "SIGSTOP");
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
session.encoderPauseUnsupported = true;
|
|
1631
|
+
logger.info(
|
|
1632
|
+
`transcode ${session.id} cannot suspend the encoder on this platform ` +
|
|
1633
|
+
`(${error instanceof Error ? error.message : String(error)}); look-ahead stays unbounded`
|
|
1634
|
+
);
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
session.encoderPaused = true;
|
|
1638
|
+
logger.info(
|
|
1639
|
+
`transcode ${session.id} encoder suspended — ${reason} ` +
|
|
1640
|
+
`"${session.fileName}"`
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Let a suspended encoder run again.
|
|
1646
|
+
*
|
|
1647
|
+
* @param {HlsSession} session
|
|
1648
|
+
* @param {string} reason
|
|
1649
|
+
* @returns {void}
|
|
1650
|
+
*/
|
|
1651
|
+
#resumeEncoder(session, reason) {
|
|
1652
|
+
if (!session.encoderPaused || !session.ffmpeg?.pid) {
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
try {
|
|
1656
|
+
process.kill(session.ffmpeg.pid, "SIGCONT");
|
|
1657
|
+
} catch {
|
|
1658
|
+
// The process is gone; the exit handler will deal with it.
|
|
1659
|
+
}
|
|
1660
|
+
session.encoderPaused = false;
|
|
1661
|
+
logger.info(`transcode ${session.id} encoder resumed — ${reason} "${session.fileName}"`);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1552
1664
|
async #enforceRealtimeBudget() {
|
|
1553
1665
|
if (this.videoEncoder?.kind !== "software") {
|
|
1554
1666
|
return;
|
|
@@ -1728,6 +1840,9 @@ export class HlsSessionManager {
|
|
|
1728
1840
|
async #startEncodeRun(session, startIndex) {
|
|
1729
1841
|
const generation = ++session.encodeRunGeneration;
|
|
1730
1842
|
const previousFfmpeg = session.ffmpeg;
|
|
1843
|
+
// A suspended process does not act on SIGTERM until it is continued, so the
|
|
1844
|
+
// wait below would never end. Let it run before asking it to stop.
|
|
1845
|
+
this.#resumeEncoder(session, "terminating for a new run");
|
|
1731
1846
|
if (previousFfmpeg && !hasChildExited(previousFfmpeg)) {
|
|
1732
1847
|
try {
|
|
1733
1848
|
previousFfmpeg.kill("SIGTERM");
|
|
@@ -1760,6 +1875,7 @@ export class HlsSessionManager {
|
|
|
1760
1875
|
// Terminate any existing encode process before starting a new one. The
|
|
1761
1876
|
// old process's exit handler no-ops because session.ffmpeg is reassigned
|
|
1762
1877
|
// below (it checks identity).
|
|
1878
|
+
this.#resumeEncoder(session, "terminating");
|
|
1763
1879
|
if (session.ffmpeg && !session.ffmpeg.killed) {
|
|
1764
1880
|
try {
|
|
1765
1881
|
session.ffmpeg.kill("SIGTERM");
|
|
@@ -1933,6 +2049,7 @@ export class HlsSessionManager {
|
|
|
1933
2049
|
// judged finished — see getFileStream.
|
|
1934
2050
|
session.usesExplicitCuts = Boolean(cutTimes && cutTimes.length > 0);
|
|
1935
2051
|
session.encodeStartIndex = safeIndex;
|
|
2052
|
+
session.encoderPaused = false;
|
|
1936
2053
|
session.pendingRestartIndex = -1;
|
|
1937
2054
|
session.lastRestartAt = Date.now();
|
|
1938
2055
|
session.state = session.state === "disposed" ? "disposed" : "starting";
|
|
@@ -2585,6 +2702,17 @@ export class HlsSessionManager {
|
|
|
2585
2702
|
|
|
2586
2703
|
const filePath = path.join(session.dirPath, fileName);
|
|
2587
2704
|
const isPlaylist = fileName === PLAYLIST_FILE_NAME;
|
|
2705
|
+
if (!isPlaylist) {
|
|
2706
|
+
// Where the viewer actually is. Recorded for every segment request,
|
|
2707
|
+
// served or not, because it is what bounds how far ahead the encoder is
|
|
2708
|
+
// allowed to run — see #enforceLookAhead.
|
|
2709
|
+
const requested = session.segmentFormat.segmentIndexFromName(fileName);
|
|
2710
|
+
if (requested >= 0) {
|
|
2711
|
+
session.lastRequestedSegment = requested;
|
|
2712
|
+
// A viewer who has caught up must not wait out the monitor's interval.
|
|
2713
|
+
this.#resumeEncoder(session, "a segment was requested");
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2588
2716
|
try {
|
|
2589
2717
|
await access(filePath);
|
|
2590
2718
|
|
|
@@ -2793,6 +2921,7 @@ export class HlsSessionManager {
|
|
|
2793
2921
|
session.seekSettleTimer = null;
|
|
2794
2922
|
}
|
|
2795
2923
|
|
|
2924
|
+
this.#resumeEncoder(session, "session disposed");
|
|
2796
2925
|
if (session.ffmpeg && !session.ffmpeg.killed) {
|
|
2797
2926
|
session.ffmpeg.kill("SIGTERM");
|
|
2798
2927
|
await waitForChildExit(session.ffmpeg);
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { findSharedStore } from "../piece-store/shared-piece-store.js";
|
|
24
|
+
import { logger } from "../../utils/logger.js";
|
|
25
|
+
|
|
26
|
+
/** Only waits at least this long are reported; sequential reading stays silent. */
|
|
27
|
+
const PIECE_WAIT_LOG_MS = 1_000;
|
|
24
28
|
|
|
25
29
|
/**
|
|
26
30
|
* How far ahead of the read head pieces are asked for.
|
|
@@ -318,7 +322,21 @@ export async function* readFragments({
|
|
|
318
322
|
criticalMark = markCritical(torrent, pieceIndex, Math.min(lastPiece, pieceIndex + criticalRun), criticalMark);
|
|
319
323
|
}
|
|
320
324
|
|
|
325
|
+
const waitStartedAt = Date.now();
|
|
321
326
|
await whenPieceReady(torrent, pieceIndex, cancellation);
|
|
327
|
+
// What a reader spent waiting for data, attributed to the exact piece. A
|
|
328
|
+
// seek's cost is dominated by the first segment after the encoder
|
|
329
|
+
// restarts (measured 9.2-9.4 s), and without this there is no way to say
|
|
330
|
+
// whether that is the swarm, the picker, or ffmpeg. Logged only when the
|
|
331
|
+
// wait is long enough to matter, so ordinary sequential reading is silent.
|
|
332
|
+
const waitedMs = Date.now() - waitStartedAt;
|
|
333
|
+
if (waitedMs >= PIECE_WAIT_LOG_MS) {
|
|
334
|
+
logger.info(
|
|
335
|
+
`piece-reader: waited ${waitedMs}ms for piece ${pieceIndex} ` +
|
|
336
|
+
`(${pieceIndex - firstPiece + 1} of ${lastPiece - firstPiece + 1} in a read from ` +
|
|
337
|
+
`${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}")`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
322
340
|
|
|
323
341
|
// Pinned BEFORE it is located, and before any await that could let an
|
|
324
342
|
// eviction run: the offset is only meaningful while the piece is held.
|