@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/server.js CHANGED
@@ -1,216 +1,220 @@
1
- /**
2
- * @file Proxy HTTP server bootstrap.
3
- *
4
- * Creates and configures the Fastify application, registers all routes and
5
- * plugins, then starts listening on the first available port at or above the
6
- * requested one.
7
- */
8
-
9
- import Fastify from "fastify";
10
- import fastifyCors from "@fastify/cors";
11
- import fastifyHelmet from "@fastify/helmet";
12
- import fastifyStatic from "@fastify/static";
13
- import getPort from "get-port";
14
- import path from "node:path";
15
- import { createRequire } from "node:module";
16
- import { fileURLToPath } from "node:url";
17
- import { handleHealthGet } from "./routes/health/get.js";
18
- import { handleHealthzGet } from "./routes/healthz/get.js";
19
- import { handleApiSourcesPost } from "./routes/api/sources/post.js";
20
- import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
21
- import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
22
- import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
23
- import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
24
- import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
25
- import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
26
- import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
27
- import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-sessions/net-report/post.js";
28
- import { handleStreamGet } from "./routes/stream/get.js";
29
- import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
30
- import { createSourceRegistry } from "./store/source-registry.js";
31
- import { TorrentPool } from "./services/torrent-pool.js";
32
- import { HlsSessionManager } from "./services/hls-session-manager.js";
33
- import { createPlaybackPlanner } from "./services/playback-planner.js";
34
- import { detectVideoEncoder, benchmarkSoftwarePresets, detectTonemapSupport } from "./services/hwaccel.js";
35
- import { logger } from "./utils/logger.js";
36
-
37
- const __filename = fileURLToPath(import.meta.url);
38
- const __dirname = path.dirname(__filename);
39
- const require = createRequire(import.meta.url);
40
- const { version } = require("./package.json");
41
- const publicRoot = path.resolve(__dirname, "./public");
42
-
43
- /**
44
- * Build a list of candidate port numbers starting at `startPort`.
45
- *
46
- * @param {number} startPort
47
- * @param {number} [maxAttempts=51]
48
- * @returns {number[]}
49
- */
50
- function buildPortCandidates(startPort, maxAttempts = 51) {
51
- const ports = [];
52
- for (let index = 0; index < maxAttempts; index += 1) {
53
- ports.push(startPort + index);
54
- }
55
- return ports;
56
- }
57
-
58
- /**
59
- * @typedef {Object} ProxyServerOptions
60
- * @property {string} host - Bind host (e.g. "127.0.0.1" or "0.0.0.0").
61
- * @property {number} port - Preferred listen port.
62
- * @property {boolean} transcodeAudio - Whether HLS audio transcoding is enabled.
63
- * @property {string} ffmpegBin - Path to the ffmpeg executable.
64
- * @property {number} [maxDiskBytes] - Global disk cap for torrent data (undefined = pool default).
65
- * @property {string} [segmentFormat] - HLS output container: "fmp4" (default) or "mpegts".
66
- */
67
-
68
- /**
69
- * Create, configure, and start the proxy HTTP server.
70
- *
71
- * @param {ProxyServerOptions} options
72
- * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
73
- */
74
- export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, segmentFormat }) {
75
- const app = Fastify({
76
- // No practical body-size limit — the proxy server is localhost-only and
77
- // receives torrent source payloads that may be arbitrarily large.
78
- bodyLimit: 256 * 1024 * 1024 // 256 MB
79
- });
80
-
81
- await app.register(fastifyHelmet, {
82
- // Proxy serves media to a different origin (registry UI), so CORP must allow cross-origin usage.
83
- crossOriginResourcePolicy: {
84
- policy: "cross-origin"
85
- }
86
- });
87
- await app.register(fastifyCors, {
88
- origin: true,
89
- methods: ["GET", "POST", "OPTIONS"],
90
- allowedHeaders: ["Content-Type", "Range"]
91
- });
92
-
93
- // Allow browser requests from an HTTPS page to this private-network proxy
94
- // without triggering Chromium's Private Network Access permission prompt.
95
- app.addHook("onRequest", async (_req, reply) => {
96
- reply.header("Access-Control-Allow-Private-Network", "true");
97
- });
98
-
99
- const sourceRegistry = createSourceRegistry(200);
100
- const torrentPool = new TorrentPool({ maxDiskBytes });
101
- const selectedPort = await getPort({
102
- port: buildPortCandidates(port)
103
- });
104
- // Auto-detect the best available H.264 encoder (hardware-accelerated or
105
- // software) once at startup, with a real test-encode and graceful fallback.
106
- // Only needed when transcoding can occur.
107
- const videoEncoder = transcodeAudio
108
- ? await detectVideoEncoder({ ffmpegBin, logger })
109
- : null;
110
- // For software libx264, benchmark preset throughput once at startup so the
111
- // session manager can pick the highest-quality preset that still encodes each
112
- // stream faster than realtime. Hardware encoders use their own fixed preset.
113
- const softwarePresetBenchmark = videoEncoder?.kind === "software"
114
- ? await benchmarkSoftwarePresets({ ffmpegBin, logger })
115
- : null;
116
- // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
117
- // Detected once; the session manager applies the tonemap chain only for HDR
118
- // sources on the software path when available.
119
- const tonemapSupported = transcodeAudio
120
- ? await detectTonemapSupport({ ffmpegBin, logger })
121
- : false;
122
- const hlsSessionManager = new HlsSessionManager({
123
- enabled: transcodeAudio,
124
- ffmpegBin,
125
- localBindHost: host,
126
- localPort: selectedPort,
127
- videoEncoder,
128
- softwarePresetBenchmark,
129
- tonemapSupported,
130
- segmentFormatId: segmentFormat,
131
- // Live download stats accessor for the realtime budget: lets it tell a
132
- // CPU-bound transcode from a download-starved input before downscaling.
133
- getSourceStats: async (sourceKey, fileIndex) => {
134
- const record = sourceRegistry.get(sourceKey);
135
- if (!record) {
136
- return null;
137
- }
138
- try {
139
- const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
140
- return torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
141
- } catch {
142
- return null;
143
- }
144
- },
145
- // Reuse the media info the planner already probed for this file (same
146
- // ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
147
- // only at session-create time, after playbackPlanner is initialised.
148
- getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params)
149
- });
150
- const playbackPlanner = createPlaybackPlanner({
151
- ffmpegBin,
152
- transcodeAudioEnabled: transcodeAudio,
153
- localBaseUrl: hlsSessionManager.localBaseUrl,
154
- sourceRegistry,
155
- torrentPool
156
- });
157
-
158
- app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
159
- app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
160
- app.post("/api/sources", async (req, reply) =>
161
- handleApiSourcesPost(req, reply, { sourceRegistry })
162
- );
163
- app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
164
- handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
165
- );
166
- app.get("/api/sources/:sourceKey/files", async (req, reply) =>
167
- handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
168
- );
169
- app.post("/api/playback-plan", async (req, reply) =>
170
- handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
171
- );
172
- app.get("/api/subtitles", async (req, reply) =>
173
- handleApiSubtitlesGet(req, reply, {
174
- sourceRegistry,
175
- torrentPool,
176
- ffmpegBin,
177
- localBaseUrl: hlsSessionManager.localBaseUrl
178
- })
179
- );
180
- app.get("/stream", async (req, reply) =>
181
- handleStreamGet(req, reply, { sourceRegistry, torrentPool })
182
- );
183
- app.post("/api/transcode-sessions", async (req, reply) =>
184
- handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager })
185
- );
186
- app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
187
- handleApiTranscodeSessionReleasePost(req, reply, { hlsSessionManager })
188
- );
189
- app.get("/api/transcode-sessions/:sessionId/progress", async (req, reply) =>
190
- handleApiTranscodeSessionsProgressGet(req, reply, { hlsSessionManager })
191
- );
192
- app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
193
- handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager })
194
- );
195
- app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
196
- handleTranscodeSessionFileGet(req, reply, { hlsSessionManager })
197
- );
198
- await app.register(fastifyStatic, {
199
- root: publicRoot,
200
- prefix: "/",
201
- serveDotFiles: true
202
- });
203
-
204
- app.addHook("onClose", async () => {
205
- // Order matters: stop the ffmpeg readers (HLS sessions) before destroying
206
- // the torrents whose files they read from, then remove the torrent data.
207
- await hlsSessionManager.disposeAll();
208
- await torrentPool.destroyAll();
209
- });
210
-
211
- await app.listen({ host, port: selectedPort });
212
- return {
213
- app,
214
- port: selectedPort
215
- };
216
- }
1
+ /**
2
+ * @file Proxy HTTP server bootstrap.
3
+ *
4
+ * Creates and configures the Fastify application, registers all routes and
5
+ * plugins, then starts listening on the first available port at or above the
6
+ * requested one.
7
+ */
8
+
9
+ import Fastify from "fastify";
10
+ import fastifyCors from "@fastify/cors";
11
+ import fastifyHelmet from "@fastify/helmet";
12
+ import fastifyStatic from "@fastify/static";
13
+ import getPort from "get-port";
14
+ import path from "node:path";
15
+ import { createRequire } from "node:module";
16
+ import { fileURLToPath } from "node:url";
17
+ import { handleHealthGet } from "./routes/health/get.js";
18
+ import { handleHealthzGet } from "./routes/healthz/get.js";
19
+ import { handleApiSourcesPost } from "./routes/api/sources/post.js";
20
+ import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
21
+ import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
22
+ import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
23
+ import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
24
+ import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
25
+ import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
26
+ import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
27
+ import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-sessions/net-report/post.js";
28
+ import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessions/seek/post.js";
29
+ import { handleStreamGet } from "./routes/stream/get.js";
30
+ import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
31
+ import { createSourceRegistry } from "./store/source-registry.js";
32
+ import { TorrentPool } from "./services/torrent-pool.js";
33
+ import { HlsSessionManager } from "./services/hls-session-manager.js";
34
+ import { createPlaybackPlanner } from "./services/playback-planner.js";
35
+ import { detectVideoEncoder, benchmarkSoftwarePresets, detectTonemapSupport } from "./services/hwaccel.js";
36
+ import { logger } from "./utils/logger.js";
37
+
38
+ const __filename = fileURLToPath(import.meta.url);
39
+ const __dirname = path.dirname(__filename);
40
+ const require = createRequire(import.meta.url);
41
+ const { version } = require("./package.json");
42
+ const publicRoot = path.resolve(__dirname, "./public");
43
+
44
+ /**
45
+ * Build a list of candidate port numbers starting at `startPort`.
46
+ *
47
+ * @param {number} startPort
48
+ * @param {number} [maxAttempts=51]
49
+ * @returns {number[]}
50
+ */
51
+ function buildPortCandidates(startPort, maxAttempts = 51) {
52
+ const ports = [];
53
+ for (let index = 0; index < maxAttempts; index += 1) {
54
+ ports.push(startPort + index);
55
+ }
56
+ return ports;
57
+ }
58
+
59
+ /**
60
+ * @typedef {Object} ProxyServerOptions
61
+ * @property {string} host - Bind host (e.g. "127.0.0.1" or "0.0.0.0").
62
+ * @property {number} port - Preferred listen port.
63
+ * @property {boolean} transcodeAudio - Whether HLS audio transcoding is enabled.
64
+ * @property {string} ffmpegBin - Path to the ffmpeg executable.
65
+ * @property {number} [maxDiskBytes] - Global disk cap for torrent data (undefined = pool default).
66
+ * @property {string} [segmentFormat] - HLS output container: "fmp4" (default) or "mpegts".
67
+ */
68
+
69
+ /**
70
+ * Create, configure, and start the proxy HTTP server.
71
+ *
72
+ * @param {ProxyServerOptions} options
73
+ * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
74
+ */
75
+ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin, maxDiskBytes, segmentFormat }) {
76
+ const app = Fastify({
77
+ // No practical body-size limit the proxy server is localhost-only and
78
+ // receives torrent source payloads that may be arbitrarily large.
79
+ bodyLimit: 256 * 1024 * 1024 // 256 MB
80
+ });
81
+
82
+ await app.register(fastifyHelmet, {
83
+ // Proxy serves media to a different origin (registry UI), so CORP must allow cross-origin usage.
84
+ crossOriginResourcePolicy: {
85
+ policy: "cross-origin"
86
+ }
87
+ });
88
+ await app.register(fastifyCors, {
89
+ origin: true,
90
+ methods: ["GET", "POST", "OPTIONS"],
91
+ allowedHeaders: ["Content-Type", "Range"]
92
+ });
93
+
94
+ // Allow browser requests from an HTTPS page to this private-network proxy
95
+ // without triggering Chromium's Private Network Access permission prompt.
96
+ app.addHook("onRequest", async (_req, reply) => {
97
+ reply.header("Access-Control-Allow-Private-Network", "true");
98
+ });
99
+
100
+ const sourceRegistry = createSourceRegistry(200);
101
+ const torrentPool = new TorrentPool({ maxDiskBytes });
102
+ const selectedPort = await getPort({
103
+ port: buildPortCandidates(port)
104
+ });
105
+ // Auto-detect the best available H.264 encoder (hardware-accelerated or
106
+ // software) once at startup, with a real test-encode and graceful fallback.
107
+ // Only needed when transcoding can occur.
108
+ const videoEncoder = transcodeAudio
109
+ ? await detectVideoEncoder({ ffmpegBin, logger })
110
+ : null;
111
+ // For software libx264, benchmark preset throughput once at startup so the
112
+ // session manager can pick the highest-quality preset that still encodes each
113
+ // stream faster than realtime. Hardware encoders use their own fixed preset.
114
+ const softwarePresetBenchmark = videoEncoder?.kind === "software"
115
+ ? await benchmarkSoftwarePresets({ ffmpegBin, logger })
116
+ : null;
117
+ // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
118
+ // Detected once; the session manager applies the tonemap chain only for HDR
119
+ // sources on the software path when available.
120
+ const tonemapSupported = transcodeAudio
121
+ ? await detectTonemapSupport({ ffmpegBin, logger })
122
+ : false;
123
+ const hlsSessionManager = new HlsSessionManager({
124
+ enabled: transcodeAudio,
125
+ ffmpegBin,
126
+ localBindHost: host,
127
+ localPort: selectedPort,
128
+ videoEncoder,
129
+ softwarePresetBenchmark,
130
+ tonemapSupported,
131
+ segmentFormatId: segmentFormat,
132
+ // Live download stats accessor for the realtime budget: lets it tell a
133
+ // CPU-bound transcode from a download-starved input before downscaling.
134
+ getSourceStats: async (sourceKey, fileIndex) => {
135
+ const record = sourceRegistry.get(sourceKey);
136
+ if (!record) {
137
+ return null;
138
+ }
139
+ try {
140
+ const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
141
+ return torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
142
+ } catch {
143
+ return null;
144
+ }
145
+ },
146
+ // Reuse the media info the planner already probed for this file (same
147
+ // ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
148
+ // only at session-create time, after playbackPlanner is initialised.
149
+ getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params)
150
+ });
151
+ const playbackPlanner = createPlaybackPlanner({
152
+ ffmpegBin,
153
+ transcodeAudioEnabled: transcodeAudio,
154
+ localBaseUrl: hlsSessionManager.localBaseUrl,
155
+ sourceRegistry,
156
+ torrentPool
157
+ });
158
+
159
+ app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
160
+ app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
161
+ app.post("/api/sources", async (req, reply) =>
162
+ handleApiSourcesPost(req, reply, { sourceRegistry })
163
+ );
164
+ app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
165
+ handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
166
+ );
167
+ app.get("/api/sources/:sourceKey/files", async (req, reply) =>
168
+ handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
169
+ );
170
+ app.post("/api/playback-plan", async (req, reply) =>
171
+ handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
172
+ );
173
+ app.get("/api/subtitles", async (req, reply) =>
174
+ handleApiSubtitlesGet(req, reply, {
175
+ sourceRegistry,
176
+ torrentPool,
177
+ ffmpegBin,
178
+ localBaseUrl: hlsSessionManager.localBaseUrl
179
+ })
180
+ );
181
+ app.get("/stream", async (req, reply) =>
182
+ handleStreamGet(req, reply, { sourceRegistry, torrentPool })
183
+ );
184
+ app.post("/api/transcode-sessions", async (req, reply) =>
185
+ handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager })
186
+ );
187
+ app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
188
+ handleApiTranscodeSessionReleasePost(req, reply, { hlsSessionManager })
189
+ );
190
+ app.get("/api/transcode-sessions/:sessionId/progress", async (req, reply) =>
191
+ handleApiTranscodeSessionsProgressGet(req, reply, { hlsSessionManager })
192
+ );
193
+ app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
194
+ handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager })
195
+ );
196
+ app.post("/api/transcode-sessions/:sessionId/seek", async (req, reply) =>
197
+ handleApiTranscodeSessionSeekPost(req, reply, { hlsSessionManager })
198
+ );
199
+ app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
200
+ handleTranscodeSessionFileGet(req, reply, { hlsSessionManager })
201
+ );
202
+ await app.register(fastifyStatic, {
203
+ root: publicRoot,
204
+ prefix: "/",
205
+ serveDotFiles: true
206
+ });
207
+
208
+ app.addHook("onClose", async () => {
209
+ // Order matters: stop the ffmpeg readers (HLS sessions) before destroying
210
+ // the torrents whose files they read from, then remove the torrent data.
211
+ await hlsSessionManager.disposeAll();
212
+ await torrentPool.destroyAll();
213
+ });
214
+
215
+ await app.listen({ host, port: selectedPort });
216
+ return {
217
+ app,
218
+ port: selectedPort
219
+ };
220
+ }
@@ -1953,20 +1953,26 @@ export class HlsSessionManager {
1953
1953
  if (index === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
1954
1954
  return;
1955
1955
  }
1956
- // Far request = a server-side seek. Do NOT restart on the first one:
1957
- // debounce a burst of scattered requests into a single restart at the
1958
- // position the player ended on. Record the latest target and (re)arm the
1959
- // settle timer; the caller long-polls / the client retries meanwhile.
1960
- session.seekTarget = index;
1961
- if (session.seekSettleTimer) {
1962
- clearTimeout(session.seekSettleTimer);
1963
- } else {
1964
- session.seekFirstFarAt = Date.now();
1965
- }
1966
- const waited = Date.now() - session.seekFirstFarAt;
1967
- const delay = waited >= SEEK_SETTLE_MAX_MS ? 0 : Math.min(SEEK_SETTLE_MS, SEEK_SETTLE_MAX_MS - waited);
1968
- session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), delay);
1969
- session.seekSettleTimer.unref?.();
1956
+ // A far request is NOT treated as a seek. Measured 2026-08-02: on a single
1957
+ // viewer seek the player opens ~25 CONCURRENT requests spanning #904..#1101
1958
+ // and holds them all for the full 60 s without aborting any — normal
1959
+ // read-ahead, not probing. There is therefore no such thing as "the segment
1960
+ // the player ended on": at any instant a couple of dozen different indices
1961
+ // are outstanding, so any rule picking one of them picks noise. Doing so
1962
+ // produced NINE encoder restarts in one minute (#576→#885→#609→#591→#673→
1963
+ // #833→#624→#1071→#1101), each killed 5-8 s in, turning a seek into a
1964
+ // ~70 s ordeal.
1965
+ //
1966
+ // The seek target now arrives explicitly from the browser (requestSeek,
1967
+ // POST /api/transcode-sessions/:id/seek) the only place the viewer's
1968
+ // intent actually exists. Same split as Jellyfin (startTimeTicks) and
1969
+ // webtor (?t=): requests fetch data, they do not steer the encoder.
1970
+ //
1971
+ // Requests are still valuable, just not as commands: they are a queue of
1972
+ // claims. Held open until produced (the player waits), served from disk
1973
+ // when behind the encoder, and the LOWEST outstanding index marks where the
1974
+ // viewer is actually stalled — the honest input for what to produce first.
1975
+ // See research/hls-seek-prior-art-2026-08-02.md.
1970
1976
  }
1971
1977
 
1972
1978
  /**
@@ -1997,6 +2003,59 @@ export class HlsSessionManager {
1997
2003
  return Math.max(0, processed - startPosition);
1998
2004
  }
1999
2005
 
2006
+ /**
2007
+ * The viewer seeked. Called from POST /api/transcode-sessions/:id/seek with
2008
+ * the position the browser read off its own player once the scrub ended.
2009
+ *
2010
+ * This is the ONLY thing that repositions the encoder. It replaces inferring
2011
+ * the target from segment requests, which cannot work: a single seek leaves
2012
+ * ~25 concurrent requests outstanding across a wide span (measured), so no
2013
+ * rule over them can recover which one the viewer meant.
2014
+ *
2015
+ * The existing settle/cooldown/first-segment guards still apply — they
2016
+ * protect against restarting too eagerly, which is orthogonal to knowing
2017
+ * WHERE to restart.
2018
+ *
2019
+ * @param {string} sessionId
2020
+ * @param {number} positionSeconds - Absolute position on the source timeline.
2021
+ * @returns {boolean} False when the session is unknown or disposed.
2022
+ */
2023
+ requestSeek(sessionId, positionSeconds) {
2024
+ const session = this.sessionsById.get(sessionId);
2025
+ if (!session || session.state === "disposed") {
2026
+ return false;
2027
+ }
2028
+ const index = this.#segmentIndexForTime(session, positionSeconds);
2029
+ const head = session.encodeStartIndex;
2030
+ const processed = Number.isFinite(session.progress?.processedSeconds)
2031
+ ? session.progress.processedSeconds
2032
+ : this.#segmentStartTime(session, head);
2033
+ const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
2034
+ // Already covered by the running encode — the data is on its way, so
2035
+ // restarting would only destroy work the viewer is waiting for.
2036
+ if (index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS) {
2037
+ logger.info(
2038
+ `transcode ${session.id} seek to ${positionSeconds.toFixed(1)}s (#${index}) ` +
2039
+ `already within the running encode (#${head}..#${currentSeg}) — not restarting`
2040
+ );
2041
+ return true;
2042
+ }
2043
+ logger.info(
2044
+ `transcode ${session.id} viewer seek to ${positionSeconds.toFixed(1)}s → segment #${index}`
2045
+ );
2046
+ session.seekTarget = index;
2047
+ if (session.seekSettleTimer) {
2048
+ clearTimeout(session.seekSettleTimer);
2049
+ } else {
2050
+ session.seekFirstFarAt = Date.now();
2051
+ }
2052
+ const waited = Date.now() - session.seekFirstFarAt;
2053
+ const delay = waited >= SEEK_SETTLE_MAX_MS ? 0 : Math.min(SEEK_SETTLE_MS, SEEK_SETTLE_MAX_MS - waited);
2054
+ session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), delay);
2055
+ session.seekSettleTimer.unref?.();
2056
+ return true;
2057
+ }
2058
+
2000
2059
  #fireSettledSeek(session) {
2001
2060
  const target = session.seekTarget;
2002
2061
  session.seekSettleTimer = null;