@torrent-tv/proxy 2.9.60 → 2.9.62
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 +314 -306
- package/package.json +1 -1
- package/routes/api/transcode-sessions/seek/post.js +50 -0
- package/routes/transcode/session-file/get.js +145 -111
- package/server.js +220 -216
- package/services/hls-session-manager.js +73 -14
package/package.json
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file POST /api/transcode-sessions/:sessionId/seek — the viewer's seek target.
|
|
3
|
+
*
|
|
4
|
+
* The browser reports WHERE THE VIEWER ACTUALLY SEEKED, explicitly, the moment
|
|
5
|
+
* the scrub ends. This is the only authoritative source of that intent: the
|
|
6
|
+
* position lives in the browser (`video.currentTime`) and nowhere else.
|
|
7
|
+
*
|
|
8
|
+
* Why this route exists at all: our synthetic VOD playlist advertises every
|
|
9
|
+
* segment of the file, but segments only exist once ffmpeg has produced them.
|
|
10
|
+
* A player is entitled by the HLS contract to fetch any advertised segment and
|
|
11
|
+
* get it immediately, so when one 503s it legitimately probes elsewhere —
|
|
12
|
+
* field log 2026-08-02 shows a correct target burst (#973..#985, the real seek)
|
|
13
|
+
* followed by scattered probing across the whole file (#125, #173, #251, #326,
|
|
14
|
+
* #478...). Inferring the target from that request stream made the encoder
|
|
15
|
+
* restart at #519 — a probe, not the seek — and the viewer waited ten seconds
|
|
16
|
+
* for a segment nobody wanted. Segment requests are data fetches, not commands;
|
|
17
|
+
* the seek target now arrives here instead of being guessed from them.
|
|
18
|
+
*
|
|
19
|
+
* Mirrors how Jellyfin/Plex handle server-side seeking (an explicit start
|
|
20
|
+
* position from the client), rather than heuristics over request patterns.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {import("fastify").FastifyRequest} req
|
|
25
|
+
* @param {import("fastify").FastifyReply} reply
|
|
26
|
+
* @param {{ hlsSessionManager: import("../../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
export async function handleApiTranscodeSessionSeekPost(req, reply, { hlsSessionManager }) {
|
|
30
|
+
const sessionId = req.params?.sessionId;
|
|
31
|
+
const body = req.body && typeof req.body === "object" ? req.body : {};
|
|
32
|
+
const positionSeconds = Number(body.positionSeconds);
|
|
33
|
+
|
|
34
|
+
if (!Number.isFinite(positionSeconds) || positionSeconds < 0) {
|
|
35
|
+
reply.code(400);
|
|
36
|
+
return reply.send({ error: "positionSeconds must be a non-negative number." });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const applied = hlsSessionManager.requestSeek(sessionId, positionSeconds);
|
|
40
|
+
if (!applied) {
|
|
41
|
+
// Unknown or disposed session — nothing to steer. Not an error worth
|
|
42
|
+
// surfacing to the viewer: the seek will be handled by whatever session
|
|
43
|
+
// replaces it.
|
|
44
|
+
reply.code(404);
|
|
45
|
+
return reply.send({ error: "No such transcode session." });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
reply.code(204);
|
|
49
|
+
return reply.send();
|
|
50
|
+
}
|
|
@@ -1,111 +1,145 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
1
|
+
import { logger } from "../../../utils/logger.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How long a request for a not-yet-produced file is held before answering with
|
|
5
|
+
* a retryable 503.
|
|
6
|
+
*
|
|
7
|
+
* MEASUREMENT MODE (2026-08-02): deliberately far above any plausible player
|
|
8
|
+
* deadline, so OUR limit never fires first. Whatever ends the wait is then the
|
|
9
|
+
* player's own behaviour — which is exactly what we need to observe. The
|
|
10
|
+
* `[hold]` log line at the call site records, per request, whether the segment
|
|
11
|
+
* arrived, whether we gave up, or whether the CLIENT aborted, and after how
|
|
12
|
+
* long.
|
|
13
|
+
*
|
|
14
|
+
* The previous value (2 s) was chosen to dodge a reported iOS AVPlayer ~3.5 s
|
|
15
|
+
* response-header deadline. That deadline never appears in our own logs (no
|
|
16
|
+
* -12889 across six hours of production logs), and every reference project
|
|
17
|
+
* holds rather than refusing: Jellyfin and hls-vod-too hold unbounded,
|
|
18
|
+
* hls-media-server holds 10 s. The early refusal is what made the player probe
|
|
19
|
+
* scattered positions, which then steered the encoder off target. Choose the
|
|
20
|
+
* final value from what this measurement shows, not from a number read
|
|
21
|
+
* elsewhere.
|
|
22
|
+
*/
|
|
23
|
+
const SEGMENT_WAIT_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Serve HLS playlist and segment files from an active transcode session.
|
|
27
|
+
*
|
|
28
|
+
* Briefly waits for the requested file to appear, then answers with a
|
|
29
|
+
* retryable 503 rather than holding the connection, so clients — in
|
|
30
|
+
* particular iOS's native HLS player — never hit their own response
|
|
31
|
+
* deadline while a segment is still being produced.
|
|
32
|
+
*
|
|
33
|
+
* GET /transcode/:sessionId/:fileName
|
|
34
|
+
*
|
|
35
|
+
* @param {import("fastify").FastifyRequest} req
|
|
36
|
+
* @param {import("fastify").FastifyReply} reply
|
|
37
|
+
* @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
*/
|
|
40
|
+
export async function handleTranscodeSessionFileGet(req, reply, { hlsSessionManager }) {
|
|
41
|
+
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
42
|
+
const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
|
|
43
|
+
// Hold the request only briefly, then answer "retry" instead of waiting for
|
|
44
|
+
// the segment. iOS's native HLS player (AVPlayer) enforces a hard ~3.5 s
|
|
45
|
+
// deadline on RESPONSE HEADERS and raises -12889 ("No response for media
|
|
46
|
+
// file") when it passes — it then cancels in-flight requests, probes
|
|
47
|
+
// neighbouring positions and can restart the stream from the beginning. That
|
|
48
|
+
// is exactly the post-seek "player thrashing" seen in the field, because a
|
|
49
|
+
// seek restarts ffmpeg and the first segment then takes far longer than 3.5 s
|
|
50
|
+
// to appear. Holding the connection for 30 s (as this did) guaranteed the
|
|
51
|
+
// timeout on every seek. A short hold keeps the fast path intact (a ready or
|
|
52
|
+
// nearly-ready segment is still served on the first request) while a slow one
|
|
53
|
+
// gets a prompt retryable answer, which resets the player's own deadline.
|
|
54
|
+
// hls.js is unaffected: it consumes the 503 through its retry policy, whose
|
|
55
|
+
// budget the client widens to match (see hls-player.js fragLoadPolicy).
|
|
56
|
+
// Instrumented wait. `clientAborted` flips when the player drops the
|
|
57
|
+
// connection while we are still holding it — the single most informative
|
|
58
|
+
// signal about its real patience, and observable only from this side.
|
|
59
|
+
const holdStartedAt = Date.now();
|
|
60
|
+
let clientAborted = false;
|
|
61
|
+
const onClientAbort = () => { clientAborted = true; };
|
|
62
|
+
req.raw.on("close", onClientAbort);
|
|
63
|
+
|
|
64
|
+
const result = await waitForSessionFile(hlsSessionManager, sessionId, fileName, SEGMENT_WAIT_MS);
|
|
65
|
+
|
|
66
|
+
req.raw.off("close", onClientAbort);
|
|
67
|
+
const heldMs = Date.now() - holdStartedAt;
|
|
68
|
+
if (result.isPlaylist !== true) {
|
|
69
|
+
const outcome = clientAborted
|
|
70
|
+
? "client-aborted"
|
|
71
|
+
: result.kind === "ok" ? "served" : result.kind;
|
|
72
|
+
logger.info(`[hold] ${fileName} ${outcome} after ${heldMs}ms`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (result.kind === "not-found") {
|
|
76
|
+
return reply.code(404).send({ error: "Transcode session file was not found." });
|
|
77
|
+
}
|
|
78
|
+
if (result.kind === "warming-up") {
|
|
79
|
+
// The segment is still being produced (e.g. just after a seek-restart).
|
|
80
|
+
// Return a retryable 503 — never 202, which hls.js cannot consume as a
|
|
81
|
+
// media segment — so the player retries the fetch shortly.
|
|
82
|
+
reply.header("Retry-After", "1");
|
|
83
|
+
// `Retry-After` tells the player to re-request THIS segment after a short
|
|
84
|
+
// pause. Without it a bare 503 reads as "nothing here", and the player goes
|
|
85
|
+
// looking elsewhere: because our synthetic VOD playlist lists every segment
|
|
86
|
+
// of the file, it believes they all exist and SCANS them (field log: one
|
|
87
|
+
// user seek produced probes at #617, #717, #732…). That scan is what used
|
|
88
|
+
// to steer the encoder off the real target. Whether iOS's native player
|
|
89
|
+
// honours the hint is not guaranteed — its behaviour is closed — but this
|
|
90
|
+
// is the standard, correct way to say "wait, don't look elsewhere", and
|
|
91
|
+
// hls.js already retries the same fragment regardless.
|
|
92
|
+
reply.header("Retry-After", "1");
|
|
93
|
+
return reply.code(503).send({ error: "Transcode segment is still being produced." });
|
|
94
|
+
}
|
|
95
|
+
if (result.kind === "failed") {
|
|
96
|
+
return reply.code(500).send({ error: result.message });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (result.isPlaylist) {
|
|
100
|
+
reply.header("Cache-Control", "no-store");
|
|
101
|
+
} else {
|
|
102
|
+
reply.header("Cache-Control", "public, max-age=60");
|
|
103
|
+
}
|
|
104
|
+
reply.header("Content-Type", result.contentType);
|
|
105
|
+
return reply.send(result.stream);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Poll `hlsSessionManager.getFileStream()` until the file is available,
|
|
110
|
+
* the session fails, or the timeout elapses.
|
|
111
|
+
*
|
|
112
|
+
* @param {import("../../../services/hls-session-manager.js").HlsSessionManager} hlsSessionManager
|
|
113
|
+
* @param {string} sessionId
|
|
114
|
+
* @param {string} fileName
|
|
115
|
+
* @param {number} timeoutMs
|
|
116
|
+
* @returns {Promise<Awaited<ReturnType<import("../../../services/hls-session-manager.js").HlsSessionManager["getFileStream"]>>>}
|
|
117
|
+
*/
|
|
118
|
+
async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs) {
|
|
119
|
+
const startedAt = Date.now();
|
|
120
|
+
// One sequence number for THIS request, reused by every poll below, so the
|
|
121
|
+
// session can tell a newly-arrived request apart from an old one polling
|
|
122
|
+
// again — see HlsSessionManager#ensureEncodingFor for the encoder ping-pong
|
|
123
|
+
// this prevents when one seek-bar scrub fires several segment requests.
|
|
124
|
+
const requestSeq = hlsSessionManager.nextRequestSeq(sessionId);
|
|
125
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
126
|
+
const result = await hlsSessionManager.getFileStream(sessionId, fileName, { requestSeq });
|
|
127
|
+
if (result.kind !== "warming-up") {
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
await delay(300);
|
|
131
|
+
}
|
|
132
|
+
return { kind: "warming-up" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve after a given number of milliseconds.
|
|
137
|
+
*
|
|
138
|
+
* @param {number} ms
|
|
139
|
+
* @returns {Promise<void>}
|
|
140
|
+
*/
|
|
141
|
+
function delay(ms) {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
setTimeout(resolve, ms);
|
|
144
|
+
});
|
|
145
|
+
}
|