@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.60",
3
+ "version": "2.9.62",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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
- * How long a request for a not-yet-produced file is held before answering with
3
- * a retryable 503. Must stay comfortably below iOS AVPlayer's ~3.5 s
4
- * response-header deadline (see the call site for the full rationale).
5
- */
6
- const SEGMENT_WAIT_MS = 2_000;
7
-
8
- /**
9
- * Serve HLS playlist and segment files from an active transcode session.
10
- *
11
- * Briefly waits for the requested file to appear, then answers with a
12
- * retryable 503 rather than holding the connection, so clients — in
13
- * particular iOS's native HLS player — never hit their own response
14
- * deadline while a segment is still being produced.
15
- *
16
- * GET /transcode/:sessionId/:fileName
17
- *
18
- * @param {import("fastify").FastifyRequest} req
19
- * @param {import("fastify").FastifyReply} reply
20
- * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager }} deps
21
- * @returns {Promise<void>}
22
- */
23
- export async function handleTranscodeSessionFileGet(req, reply, { hlsSessionManager }) {
24
- const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
25
- const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
26
- // Hold the request only briefly, then answer "retry" instead of waiting for
27
- // the segment. iOS's native HLS player (AVPlayer) enforces a hard ~3.5 s
28
- // deadline on RESPONSE HEADERS and raises -12889 ("No response for media
29
- // file") when it passes it then cancels in-flight requests, probes
30
- // neighbouring positions and can restart the stream from the beginning. That
31
- // is exactly the post-seek "player thrashing" seen in the field, because a
32
- // seek restarts ffmpeg and the first segment then takes far longer than 3.5 s
33
- // to appear. Holding the connection for 30 s (as this did) guaranteed the
34
- // timeout on every seek. A short hold keeps the fast path intact (a ready or
35
- // nearly-ready segment is still served on the first request) while a slow one
36
- // gets a prompt retryable answer, which resets the player's own deadline.
37
- // hls.js is unaffected: it consumes the 503 through its retry policy, whose
38
- // budget the client widens to match (see hls-player.js fragLoadPolicy).
39
- const result = await waitForSessionFile(hlsSessionManager, sessionId, fileName, SEGMENT_WAIT_MS);
40
-
41
- if (result.kind === "not-found") {
42
- return reply.code(404).send({ error: "Transcode session file was not found." });
43
- }
44
- if (result.kind === "warming-up") {
45
- // The segment is still being produced (e.g. just after a seek-restart).
46
- // Return a retryable 503never 202, which hls.js cannot consume as a
47
- // media segment so the player retries the fetch shortly.
48
- reply.header("Retry-After", "1");
49
- // `Retry-After` tells the player to re-request THIS segment after a short
50
- // pause. Without it a bare 503 reads as "nothing here", and the player goes
51
- // looking elsewhere: because our synthetic VOD playlist lists every segment
52
- // of the file, it believes they all exist and SCANS them (field log: one
53
- // user seek produced probes at #617, #717, #732…). That scan is what used
54
- // to steer the encoder off the real target. Whether iOS's native player
55
- // honours the hint is not guaranteed its behaviour is closed — but this
56
- // is the standard, correct way to say "wait, don't look elsewhere", and
57
- // hls.js already retries the same fragment regardless.
58
- reply.header("Retry-After", "1");
59
- return reply.code(503).send({ error: "Transcode segment is still being produced." });
60
- }
61
- if (result.kind === "failed") {
62
- return reply.code(500).send({ error: result.message });
63
- }
64
-
65
- if (result.isPlaylist) {
66
- reply.header("Cache-Control", "no-store");
67
- } else {
68
- reply.header("Cache-Control", "public, max-age=60");
69
- }
70
- reply.header("Content-Type", result.contentType);
71
- return reply.send(result.stream);
72
- }
73
-
74
- /**
75
- * Poll `hlsSessionManager.getFileStream()` until the file is available,
76
- * the session fails, or the timeout elapses.
77
- *
78
- * @param {import("../../../services/hls-session-manager.js").HlsSessionManager} hlsSessionManager
79
- * @param {string} sessionId
80
- * @param {string} fileName
81
- * @param {number} timeoutMs
82
- * @returns {Promise<Awaited<ReturnType<import("../../../services/hls-session-manager.js").HlsSessionManager["getFileStream"]>>>}
83
- */
84
- async function waitForSessionFile(hlsSessionManager, sessionId, fileName, timeoutMs) {
85
- const startedAt = Date.now();
86
- // One sequence number for THIS request, reused by every poll below, so the
87
- // session can tell a newly-arrived request apart from an old one polling
88
- // again see HlsSessionManager#ensureEncodingFor for the encoder ping-pong
89
- // this prevents when one seek-bar scrub fires several segment requests.
90
- const requestSeq = hlsSessionManager.nextRequestSeq(sessionId);
91
- while (Date.now() - startedAt < timeoutMs) {
92
- const result = await hlsSessionManager.getFileStream(sessionId, fileName, { requestSeq });
93
- if (result.kind !== "warming-up") {
94
- return result;
95
- }
96
- await delay(300);
97
- }
98
- return { kind: "warming-up" };
99
- }
100
-
101
- /**
102
- * Resolve after a given number of milliseconds.
103
- *
104
- * @param {number} ms
105
- * @returns {Promise<void>}
106
- */
107
- function delay(ms) {
108
- return new Promise((resolve) => {
109
- setTimeout(resolve, ms);
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 passesit 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
+ }