@torrent-tv/proxy 2.9.61 → 2.9.63
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 +318 -310
- package/package.json +1 -1
- package/services/hls-session-manager.js +123 -17
package/package.json
CHANGED
|
@@ -69,6 +69,22 @@ const ENCODER_STALL_MS = 12_000;
|
|
|
69
69
|
// 367,732,369,368,370 seconds apart) collapses to ONE restart at the position
|
|
70
70
|
// the player ended on, instead of ping-ponging ffmpeg between positions and
|
|
71
71
|
// producing nothing.
|
|
72
|
+
// How many segments BEFORE the requested position the encoder starts.
|
|
73
|
+
//
|
|
74
|
+
// Required by how HLS players seek, per Apple's HLS authoring guidance: given a
|
|
75
|
+
// position, the player locates the nearest IDR (keyframe) *preceding* it,
|
|
76
|
+
// decodes from there, and only then presents from the requested point. So it
|
|
77
|
+
// always fetches segments BELOW the target — measured 2026-08-02 on iOS: a seek
|
|
78
|
+
// to #1082 fetched from #1074 (8 back), one to #1358 fetched from #1301 (57
|
|
79
|
+
// back), and in the latter case the player asked for NOTHING at or above the
|
|
80
|
+
// target, so an encoder starting exactly on it produced only files nobody was
|
|
81
|
+
// waiting for and playback hung indefinitely.
|
|
82
|
+
//
|
|
83
|
+
// The observed backoff is not constant, so this is a floor, not the whole
|
|
84
|
+
// answer: #fireSettledSeek also pulls the start down to the lowest segment the
|
|
85
|
+
// player is actually waiting on when that is lower still. Costs a few seconds
|
|
86
|
+
// of extra encoding per seek.
|
|
87
|
+
const SEEK_BACKOFF_SEGMENTS = 12;
|
|
72
88
|
const SEEK_SETTLE_MS = 1_200;
|
|
73
89
|
// Hard cap on the total settle wait, measured from the first far request of a
|
|
74
90
|
// burst, so a still-moving scrubber cannot delay a genuine seek forever.
|
|
@@ -1065,6 +1081,9 @@ export class HlsSessionManager {
|
|
|
1065
1081
|
// #wireEncodeProcess and MAX_SEEK_FAILURES.
|
|
1066
1082
|
seekFailureTarget: -1,
|
|
1067
1083
|
seekFailureCount: 0,
|
|
1084
|
+
// Lowest segment index the player is currently waiting for; -1 when
|
|
1085
|
+
// nothing is pending. See getFileStream and #fireSettledSeek.
|
|
1086
|
+
lowestAwaitedIndex: -1,
|
|
1068
1087
|
progress: {
|
|
1069
1088
|
state: "starting",
|
|
1070
1089
|
processedSeconds: 0,
|
|
@@ -1953,20 +1972,26 @@ export class HlsSessionManager {
|
|
|
1953
1972
|
if (index === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
|
|
1954
1973
|
return;
|
|
1955
1974
|
}
|
|
1956
|
-
//
|
|
1957
|
-
//
|
|
1958
|
-
//
|
|
1959
|
-
//
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1975
|
+
// A far request is NOT treated as a seek. Measured 2026-08-02: on a single
|
|
1976
|
+
// viewer seek the player opens ~25 CONCURRENT requests spanning #904..#1101
|
|
1977
|
+
// and holds them all for the full 60 s without aborting any — normal
|
|
1978
|
+
// read-ahead, not probing. There is therefore no such thing as "the segment
|
|
1979
|
+
// the player ended on": at any instant a couple of dozen different indices
|
|
1980
|
+
// are outstanding, so any rule picking one of them picks noise. Doing so
|
|
1981
|
+
// produced NINE encoder restarts in one minute (#576→#885→#609→#591→#673→
|
|
1982
|
+
// #833→#624→#1071→#1101), each killed 5-8 s in, turning a seek into a
|
|
1983
|
+
// ~70 s ordeal.
|
|
1984
|
+
//
|
|
1985
|
+
// The seek target now arrives explicitly from the browser (requestSeek,
|
|
1986
|
+
// POST /api/transcode-sessions/:id/seek) — the only place the viewer's
|
|
1987
|
+
// intent actually exists. Same split as Jellyfin (startTimeTicks) and
|
|
1988
|
+
// webtor (?t=): requests fetch data, they do not steer the encoder.
|
|
1989
|
+
//
|
|
1990
|
+
// Requests are still valuable, just not as commands: they are a queue of
|
|
1991
|
+
// claims. Held open until produced (the player waits), served from disk
|
|
1992
|
+
// when behind the encoder, and the LOWEST outstanding index marks where the
|
|
1993
|
+
// viewer is actually stalled — the honest input for what to produce first.
|
|
1994
|
+
// See research/hls-seek-prior-art-2026-08-02.md.
|
|
1970
1995
|
}
|
|
1971
1996
|
|
|
1972
1997
|
/**
|
|
@@ -1997,6 +2022,64 @@ export class HlsSessionManager {
|
|
|
1997
2022
|
return Math.max(0, processed - startPosition);
|
|
1998
2023
|
}
|
|
1999
2024
|
|
|
2025
|
+
/**
|
|
2026
|
+
* The viewer seeked. Called from POST /api/transcode-sessions/:id/seek with
|
|
2027
|
+
* the position the browser read off its own player once the scrub ended.
|
|
2028
|
+
*
|
|
2029
|
+
* This is the ONLY thing that repositions the encoder. It replaces inferring
|
|
2030
|
+
* the target from segment requests, which cannot work: a single seek leaves
|
|
2031
|
+
* ~25 concurrent requests outstanding across a wide span (measured), so no
|
|
2032
|
+
* rule over them can recover which one the viewer meant.
|
|
2033
|
+
*
|
|
2034
|
+
* The existing settle/cooldown/first-segment guards still apply — they
|
|
2035
|
+
* protect against restarting too eagerly, which is orthogonal to knowing
|
|
2036
|
+
* WHERE to restart.
|
|
2037
|
+
*
|
|
2038
|
+
* @param {string} sessionId
|
|
2039
|
+
* @param {number} positionSeconds - Absolute position on the source timeline.
|
|
2040
|
+
* @returns {boolean} False when the session is unknown or disposed.
|
|
2041
|
+
*/
|
|
2042
|
+
requestSeek(sessionId, positionSeconds) {
|
|
2043
|
+
const session = this.sessionsById.get(sessionId);
|
|
2044
|
+
if (!session || session.state === "disposed") {
|
|
2045
|
+
return false;
|
|
2046
|
+
}
|
|
2047
|
+
const index = this.#segmentIndexForTime(session, positionSeconds);
|
|
2048
|
+
const head = session.encodeStartIndex;
|
|
2049
|
+
const processed = Number.isFinite(session.progress?.processedSeconds)
|
|
2050
|
+
? session.progress.processedSeconds
|
|
2051
|
+
: this.#segmentStartTime(session, head);
|
|
2052
|
+
const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
|
|
2053
|
+
// Already covered by the running encode — the data is on its way, so
|
|
2054
|
+
// restarting would only destroy work the viewer is waiting for.
|
|
2055
|
+
if (index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS) {
|
|
2056
|
+
logger.info(
|
|
2057
|
+
`transcode ${session.id} seek to ${positionSeconds.toFixed(1)}s (#${index}) ` +
|
|
2058
|
+
`already within the running encode (#${head}..#${currentSeg}) — not restarting`
|
|
2059
|
+
);
|
|
2060
|
+
return true;
|
|
2061
|
+
}
|
|
2062
|
+
// Start BEFORE the requested position (see SEEK_BACKOFF_SEGMENTS): the
|
|
2063
|
+
// player needs a segment containing the preceding keyframe, so one that
|
|
2064
|
+
// begins exactly at the target is useless to it.
|
|
2065
|
+
const startIndex = Math.max(0, index - SEEK_BACKOFF_SEGMENTS);
|
|
2066
|
+
logger.info(
|
|
2067
|
+
`transcode ${session.id} viewer seek to ${positionSeconds.toFixed(1)}s → segment #${index}, ` +
|
|
2068
|
+
`starting at #${startIndex} (${SEEK_BACKOFF_SEGMENTS} back for the preceding keyframe)`
|
|
2069
|
+
);
|
|
2070
|
+
session.seekTarget = startIndex;
|
|
2071
|
+
if (session.seekSettleTimer) {
|
|
2072
|
+
clearTimeout(session.seekSettleTimer);
|
|
2073
|
+
} else {
|
|
2074
|
+
session.seekFirstFarAt = Date.now();
|
|
2075
|
+
}
|
|
2076
|
+
const waited = Date.now() - session.seekFirstFarAt;
|
|
2077
|
+
const delay = waited >= SEEK_SETTLE_MAX_MS ? 0 : Math.min(SEEK_SETTLE_MS, SEEK_SETTLE_MAX_MS - waited);
|
|
2078
|
+
session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), delay);
|
|
2079
|
+
session.seekSettleTimer.unref?.();
|
|
2080
|
+
return true;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2000
2083
|
#fireSettledSeek(session) {
|
|
2001
2084
|
const target = session.seekTarget;
|
|
2002
2085
|
session.seekSettleTimer = null;
|
|
@@ -2081,10 +2164,22 @@ export class HlsSessionManager {
|
|
|
2081
2164
|
: producedThisRun >= this.segmentDurationSec
|
|
2082
2165
|
? `run produced ${producedThisRun.toFixed(1)}s (first segment done)`
|
|
2083
2166
|
: `grace of ${RUN_FIRST_SEGMENT_GRACE_MS / 1000}s expired`;
|
|
2167
|
+
// The player may be waiting on something below our fixed backoff — its own
|
|
2168
|
+
// requests say exactly how far back it needs the keyframe, so honour that
|
|
2169
|
+
// rather than a guess. Only ever pulls the start EARLIER, never later.
|
|
2170
|
+
const awaited = session.lowestAwaitedIndex;
|
|
2171
|
+
const effectiveTarget = awaited >= 0 && awaited < target ? awaited : target;
|
|
2172
|
+
if (effectiveTarget !== target) {
|
|
2173
|
+
logger.info(
|
|
2174
|
+
`transcode ${session.id} pulling encode start #${target} → #${effectiveTarget} ` +
|
|
2175
|
+
`(lowest segment the player is waiting on)`
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2084
2178
|
session.seekTarget = null;
|
|
2085
2179
|
session.seekFirstFarAt = 0;
|
|
2086
|
-
|
|
2087
|
-
|
|
2180
|
+
session.lowestAwaitedIndex = -1;
|
|
2181
|
+
logger.info(`transcode ${session.id} seek settle → restart at segment #${effectiveTarget} (${allowedBecause})`);
|
|
2182
|
+
void this.#startEncodeRun(session, effectiveTarget);
|
|
2088
2183
|
}
|
|
2089
2184
|
|
|
2090
2185
|
/**
|
|
@@ -2287,9 +2382,20 @@ export class HlsSessionManager {
|
|
|
2287
2382
|
// to wait for the current encode run to reach it or to restart the encoder
|
|
2288
2383
|
// at this position (server-side seeking). The caller long-polls.
|
|
2289
2384
|
if (!isPlaylist) {
|
|
2385
|
+
const requestedIndex = this.segmentFormat.segmentIndexFromName(fileName);
|
|
2386
|
+
// Remember the LOWEST segment currently being waited on. The player
|
|
2387
|
+
// always fetches below the seek target (it needs the preceding keyframe),
|
|
2388
|
+
// and by how much varies — 8 segments in one measured seek, 57 in
|
|
2389
|
+
// another. This is that figure straight from the player, and
|
|
2390
|
+
// #fireSettledSeek uses it to pull the encode start down when the fixed
|
|
2391
|
+
// SEEK_BACKOFF_SEGMENTS floor is not deep enough. Reset whenever a run
|
|
2392
|
+
// starts, so it only ever describes the pending seek.
|
|
2393
|
+
if (requestedIndex >= 0 && (session.lowestAwaitedIndex < 0 || requestedIndex < session.lowestAwaitedIndex)) {
|
|
2394
|
+
session.lowestAwaitedIndex = requestedIndex;
|
|
2395
|
+
}
|
|
2290
2396
|
this.#ensureEncodingFor(
|
|
2291
2397
|
session,
|
|
2292
|
-
|
|
2398
|
+
requestedIndex,
|
|
2293
2399
|
Number.isFinite(options?.requestSeq) ? options.requestSeq : Number.MAX_SAFE_INTEGER
|
|
2294
2400
|
);
|
|
2295
2401
|
}
|