@torrent-tv/proxy 2.87.11 → 2.87.13

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,12 @@
1
+ ## 2.87.13
2
+
3
+ - **Fix**: Include distinct active piece reads in memory demand and preserve their slots immediately, so reads without priority-map ranges cannot deadlock the torrent piece store.
4
+
5
+ ## 2.87.12
6
+
7
+ - **Fix**: Check each automatic output against whole-machine encoding occupancy before opening it, so a rejected size can resolve to a lower admissible rung instead of failing the request.
8
+ - **Fix**: Seed the proxy readiness forecast with the browser's initial link measurement and buffer ceiling, and use the measured-cost machine projection until a live encode reading arrives.
9
+
1
10
  ## 2.87.11
2
11
 
3
12
  - **Fix**: Calculate proxy-side playback readiness from measured source-download, encoding, and client-delivery rates; account for prepared segments, selected audio tracks, and the browser's measured buffer capacity.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.87.11",
3
+ "version": "2.87.13",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1,29 +1,29 @@
1
- /**
2
- * Refresh metadata for audio tracks whose sidecar headers were unavailable
3
- * when the playback plan was built.
4
- *
5
- * POST /api/playback-plan/audio-tracks
6
- *
7
- * @param {import("fastify").FastifyRequest} req
8
- * @param {import("fastify").FastifyReply} reply
9
- * @param {{ playbackPlanner: object }} deps
10
- */
11
- export async function handleApiPlaybackPlanAudioTracksPost(req, reply, { playbackPlanner }) {
12
- const payload = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
13
- const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
14
- const fileIndex = Number(payload.fileIndex);
15
- if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
16
- return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
17
- }
18
- try {
19
- const { PlaybackController } = await import("../../../../services/server/controllers/PlaybackController.js");
20
- const controller = new PlaybackController({ playbackPlanner });
21
- return reply.send(await controller.refreshAudioTracks({ sourceKey, fileIndex }));
22
- } catch (error) {
23
- if (error instanceof Error && error.code === "SOURCE_NOT_FOUND") {
24
- return reply.code(404).send({ error: error.message });
25
- }
26
- const message = error instanceof Error ? error.message : String(error);
27
- return reply.code(500).send({ error: `Failed to refresh audio track metadata: ${message}` });
28
- }
29
- }
1
+ /**
2
+ * Refresh metadata for audio tracks whose sidecar headers were unavailable
3
+ * when the playback plan was built.
4
+ *
5
+ * POST /api/playback-plan/audio-tracks
6
+ *
7
+ * @param {import("fastify").FastifyRequest} req
8
+ * @param {import("fastify").FastifyReply} reply
9
+ * @param {{ playbackPlanner: object }} deps
10
+ */
11
+ export async function handleApiPlaybackPlanAudioTracksPost(req, reply, { playbackPlanner }) {
12
+ const payload = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
13
+ const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
14
+ const fileIndex = Number(payload.fileIndex);
15
+ if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
16
+ return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
17
+ }
18
+ try {
19
+ const { PlaybackController } = await import("../../../../services/server/controllers/PlaybackController.js");
20
+ const controller = new PlaybackController({ playbackPlanner });
21
+ return reply.send(await controller.refreshAudioTracks({ sourceKey, fileIndex }));
22
+ } catch (error) {
23
+ if (error instanceof Error && error.code === "SOURCE_NOT_FOUND") {
24
+ return reply.code(404).send({ error: error.message });
25
+ }
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ return reply.code(500).send({ error: `Failed to refresh audio track metadata: ${message}` });
28
+ }
29
+ }
@@ -1,217 +1,217 @@
1
- import { logger } from "../../../../utils/logger.js";
2
- import { TEXT_SUBTITLE_SIDECAR_EXTENSIONS } from "../../../../services/torrent/files.js";
3
- import { contentsOf } from "../../../../services/torrent/Contents.js";
4
-
5
- const metadataWarmups = new Map();
6
- const METADATA_RETRY_MS = 2500;
7
-
8
- function startMetadataWarmup({ sourceKey, items, preferredFileIndex, userAgent, playbackPlanner, torrentPool, torrent }) {
9
- if (!playbackPlanner || !Array.isArray(items) || items.length === 0) {
10
- return false;
11
- }
12
- let state = metadataWarmups.get(sourceKey);
13
- if (!state) {
14
- state = { pending: new Set(), preferredFileIndex: null, running: false, items: [] };
15
- metadataWarmups.set(sourceKey, state);
16
- }
17
- state.items = items;
18
- for (const item of items) {
19
- if (Number.isInteger(item?.fileIndex)) {
20
- state.pending.add(item.fileIndex);
21
- }
22
- }
23
- if (Number.isInteger(preferredFileIndex) && state.pending.has(preferredFileIndex)) {
24
- state.preferredFileIndex = preferredFileIndex;
25
- }
26
- if (state.running) {
27
- return true;
28
- }
29
- state.running = true;
30
- void (async () => {
31
- try {
32
- while (state.pending.size > 0) {
33
- const fileIndex = state.preferredFileIndex !== null && state.pending.has(state.preferredFileIndex)
34
- ? state.preferredFileIndex
35
- : state.pending.values().next().value;
36
- state.preferredFileIndex = null;
37
- if (!Number.isInteger(fileIndex)) {
38
- break;
39
- }
40
- const item = state.items.find((entry) => entry.fileIndex === fileIndex);
41
- if (!item) {
42
- state.pending.delete(fileIndex);
43
- continue;
44
- }
45
- const sidecars = contentsOf(torrent).sidecarsOf(fileIndex);
46
- const sidecarFiles = [...sidecars.audio, ...sidecars.subtitles];
47
- await Promise.all(sidecarFiles.map((file) =>
48
- torrentPool.prefetchFileEdges(torrent, file.fileIndex, {
49
- tailBytes: 0,
50
- headBytes: TEXT_SUBTITLE_SIDECAR_EXTENSIONS.has(file.extension)
51
- ? Math.max(1, file.length)
52
- : undefined,
53
- timeoutMs: 10_000
54
- }).catch((error) => {
55
- logger.info(`warm ${sourceKey.slice(0, 8)}:${fileIndex}: sidecar metadata not ready for "${file.name}": ${error?.message ?? error}`);
56
- })
57
- ));
58
- const plan = await playbackPlanner.getPlan({
59
- sourceKey,
60
- fileIndex,
61
- userAgent,
62
- maxWaitMs: 0,
63
- background: true
64
- }).catch((error) => {
65
- logger.info(`warm ${sourceKey.slice(0, 8)}:${fileIndex}: media metadata not ready: ${error?.message ?? error}`);
66
- return null;
67
- });
68
- if (plan && !plan.pending) {
69
- state.pending.delete(fileIndex);
70
- } else if (torrent.files?.[fileIndex]?.done === true) {
71
- state.pending.delete(fileIndex);
72
- }
73
- if (state.pending.size > 0) {
74
- await new Promise((resolve) => setTimeout(resolve, METADATA_RETRY_MS));
75
- }
76
- }
77
- } catch (error) {
78
- logger.warn(`warm ${sourceKey.slice(0, 8)}: metadata queue stopped: ${error?.message ?? error}`);
79
- } finally {
80
- state.running = false;
81
- if (state.pending.size === 0) {
82
- metadataWarmups.delete(sourceKey);
83
- }
84
- }
85
- })();
86
- return true;
87
- }
88
-
89
- /**
90
- * Start fetching a source before anyone asks to play it.
91
- *
92
- * POST /api/sources/:sourceKey/warm { fileIndex?: number, positionSeconds?: number, userAgent?: string }
93
- *
94
- * Everything a torrent must do before the first byte of video can be served
95
- * takes seconds and none of it depends on the viewer: announce to the
96
- * trackers, connect to peers, be unchoked by them, and fetch the two pieces at
97
- * the file's edges that the codec probe reads. Measured 2026-08-04 on a cold
98
- * 7.4 GB torrent: 6.7 s of the 10.3 s before playback was those two pieces
99
- * arriving, with the swarm ramping from nothing.
100
- *
101
- * That work used to begin only when a file had been chosen, because it was
102
- * buried inside the playback plan. It can begin as soon as the viewer has
103
- * picked a TORRENT — while they are still reading the list of episodes — and
104
- * then most or all of it has happened by the time they choose.
105
- *
106
- * Returns as soon as the work is under way. The caller is not waiting for a
107
- * result; it is only saying "you may start". Failures are logged and answered
108
- * as `started: false` rather than as an error, because nothing is broken if a
109
- * warm-up does not happen — the ordinary path still does all of it.
110
- *
111
- * @param {import("fastify").FastifyRequest} req
112
- * @param {import("fastify").FastifyReply} reply
113
- * @param {{
114
- * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
115
- * torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool,
116
- * playbackPlanner?: ReturnType<import("../../../../services/media/playback-planner.js").createPlaybackPlanner>,
117
- * durationOf?: ({ sourceKey: string, fileIndex: number }) => Promise<number | null>
118
- * }} deps
119
- * @returns {Promise<void>}
120
- */
121
- export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torrentPool, playbackPlanner = null, durationOf = null }) {
122
- const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
123
- if (!sourceKey) {
124
- return reply.code(400).send({ error: "sourceKey is required." });
125
- }
126
-
127
- const sourceRecord = sourceRegistry.get(sourceKey);
128
- if (!sourceRecord) {
129
- return reply.code(404).send({ error: "Source key was not found." });
130
- }
131
-
132
- const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
133
- const requestedIndex = Number(body.fileIndex);
134
- const fileIndex = Number.isInteger(requestedIndex) && requestedIndex >= 0 ? requestedIndex : null;
135
- // Where the viewer is about to resume, if they are resuming. The edges below
136
- // are what the codec probe reads; this is what the VIEWER will read, and until
137
- // now nothing asked for it before the encoder did.
138
- const requestedPosition = Number(body.positionSeconds);
139
- const positionSeconds = Number.isFinite(requestedPosition) && requestedPosition > 0
140
- ? requestedPosition
141
- : 0;
142
- const userAgent = typeof body.userAgent === "string" ? body.userAgent : "";
143
-
144
- // Adding the torrent is what announces to the trackers and starts connecting
145
- // to peers, and it is also what a magnet needs in order to fetch its
146
- // metadata. It is awaited because everything else needs the torrent object,
147
- // and because until it resolves there is nothing to report.
148
- let torrent;
149
- try {
150
- torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
151
- } catch (error) {
152
- const message = error instanceof Error ? error.message : String(error);
153
- logger.warn(`warm ${sourceKey.slice(0, 8)}: could not add the torrent: ${message}`);
154
- return reply.send({ started: false, swarm: false, edges: false });
155
- }
156
-
157
- const contents = contentsOf(torrent);
158
- const named = fileIndex !== null && torrent.files?.[fileIndex] ? fileIndex : null;
159
- if (named !== null) {
160
- const selectedSidecars = contents.sidecarsOf(named);
161
- for (const file of [...selectedSidecars.audio, ...selectedSidecars.subtitles]) {
162
- void torrentPool.prefetchFileEdges(torrent, file.fileIndex, {
163
- tailBytes: 0,
164
- headBytes: TEXT_SUBTITLE_SIDECAR_EXTENSIONS.has(file.extension)
165
- ? Math.max(1, file.length)
166
- : undefined,
167
- timeoutMs: 10_000,
168
- awaited: true
169
- }).catch((error) => {
170
- logger.info(`warm ${sourceKey.slice(0, 8)}:${named}: selected sidecar metadata not ready for "${file.name}": ${error?.message ?? error}`);
171
- });
172
- }
173
- }
174
- const orderedItems = named === null
175
- ? contents.items
176
- : [...contents.items.filter((item) => item.fileIndex === named), ...contents.items.filter((item) => item.fileIndex !== named)];
177
- const metadataStarted = startMetadataWarmup({
178
- sourceKey,
179
- items: orderedItems,
180
- preferredFileIndex: named,
181
- userAgent,
182
- playbackPlanner,
183
- torrentPool,
184
- torrent
185
- });
186
- const sidecarCount = new Set(orderedItems.flatMap((item) => {
187
- const matched = contents.sidecarsOf(item.fileIndex);
188
- return [...matched.audio, ...matched.subtitles].map((file) => file.fileIndex);
189
- })).size;
190
- const fillStarted = typeof torrentPool.fillTorrent === "function"
191
- ? await torrentPool.fillTorrent(torrent).catch((error) => {
192
- logger.warn(`warm ${sourceKey.slice(0, 8)}: whole-torrent fill could not start: ${error?.message ?? error}`);
193
- return false;
194
- })
195
- : false;
196
- if (named !== null && positionSeconds > 0 && typeof torrentPool.warmResumePosition === "function") {
197
- void Promise.resolve(
198
- (typeof durationOf === "function"
199
- ? durationOf({ sourceKey, fileIndex: named })
200
- : Promise.resolve(null)
201
- ).then((durationSeconds) =>
202
- torrentPool.warmResumePosition(torrent, named, positionSeconds, durationSeconds)
203
- )
204
- ).catch((error) => {
205
- logger.warn(`warm ${sourceKey.slice(0, 8)}: the viewer's position failed: ${error?.message ?? error}`);
206
- });
207
- }
208
-
209
- logger.info(
210
- `warm ${sourceKey.slice(0, 8)}: swarm started for "${torrent.name}"` +
211
- `, metadata for ${contents.items.length} video file(s) ${metadataStarted ? "queued" : "unavailable"}` +
212
- `, ${sidecarCount} related audio/subtitle file(s)` +
213
- (fillStarted ? ", whole-torrent fill enabled at TAIL urgency" : ", whole-torrent fill unavailable")
214
- );
215
-
216
- return reply.send({ started: true, swarm: true, edges: metadataStarted, sidecars: sidecarCount, fill: fillStarted });
217
- }
1
+ import { logger } from "../../../../utils/logger.js";
2
+ import { TEXT_SUBTITLE_SIDECAR_EXTENSIONS } from "../../../../services/torrent/files.js";
3
+ import { contentsOf } from "../../../../services/torrent/Contents.js";
4
+
5
+ const metadataWarmups = new Map();
6
+ const METADATA_RETRY_MS = 2500;
7
+
8
+ function startMetadataWarmup({ sourceKey, items, preferredFileIndex, userAgent, playbackPlanner, torrentPool, torrent }) {
9
+ if (!playbackPlanner || !Array.isArray(items) || items.length === 0) {
10
+ return false;
11
+ }
12
+ let state = metadataWarmups.get(sourceKey);
13
+ if (!state) {
14
+ state = { pending: new Set(), preferredFileIndex: null, running: false, items: [] };
15
+ metadataWarmups.set(sourceKey, state);
16
+ }
17
+ state.items = items;
18
+ for (const item of items) {
19
+ if (Number.isInteger(item?.fileIndex)) {
20
+ state.pending.add(item.fileIndex);
21
+ }
22
+ }
23
+ if (Number.isInteger(preferredFileIndex) && state.pending.has(preferredFileIndex)) {
24
+ state.preferredFileIndex = preferredFileIndex;
25
+ }
26
+ if (state.running) {
27
+ return true;
28
+ }
29
+ state.running = true;
30
+ void (async () => {
31
+ try {
32
+ while (state.pending.size > 0) {
33
+ const fileIndex = state.preferredFileIndex !== null && state.pending.has(state.preferredFileIndex)
34
+ ? state.preferredFileIndex
35
+ : state.pending.values().next().value;
36
+ state.preferredFileIndex = null;
37
+ if (!Number.isInteger(fileIndex)) {
38
+ break;
39
+ }
40
+ const item = state.items.find((entry) => entry.fileIndex === fileIndex);
41
+ if (!item) {
42
+ state.pending.delete(fileIndex);
43
+ continue;
44
+ }
45
+ const sidecars = contentsOf(torrent).sidecarsOf(fileIndex);
46
+ const sidecarFiles = [...sidecars.audio, ...sidecars.subtitles];
47
+ await Promise.all(sidecarFiles.map((file) =>
48
+ torrentPool.prefetchFileEdges(torrent, file.fileIndex, {
49
+ tailBytes: 0,
50
+ headBytes: TEXT_SUBTITLE_SIDECAR_EXTENSIONS.has(file.extension)
51
+ ? Math.max(1, file.length)
52
+ : undefined,
53
+ timeoutMs: 10_000
54
+ }).catch((error) => {
55
+ logger.info(`warm ${sourceKey.slice(0, 8)}:${fileIndex}: sidecar metadata not ready for "${file.name}": ${error?.message ?? error}`);
56
+ })
57
+ ));
58
+ const plan = await playbackPlanner.getPlan({
59
+ sourceKey,
60
+ fileIndex,
61
+ userAgent,
62
+ maxWaitMs: 0,
63
+ background: true
64
+ }).catch((error) => {
65
+ logger.info(`warm ${sourceKey.slice(0, 8)}:${fileIndex}: media metadata not ready: ${error?.message ?? error}`);
66
+ return null;
67
+ });
68
+ if (plan && !plan.pending) {
69
+ state.pending.delete(fileIndex);
70
+ } else if (torrent.files?.[fileIndex]?.done === true) {
71
+ state.pending.delete(fileIndex);
72
+ }
73
+ if (state.pending.size > 0) {
74
+ await new Promise((resolve) => setTimeout(resolve, METADATA_RETRY_MS));
75
+ }
76
+ }
77
+ } catch (error) {
78
+ logger.warn(`warm ${sourceKey.slice(0, 8)}: metadata queue stopped: ${error?.message ?? error}`);
79
+ } finally {
80
+ state.running = false;
81
+ if (state.pending.size === 0) {
82
+ metadataWarmups.delete(sourceKey);
83
+ }
84
+ }
85
+ })();
86
+ return true;
87
+ }
88
+
89
+ /**
90
+ * Start fetching a source before anyone asks to play it.
91
+ *
92
+ * POST /api/sources/:sourceKey/warm { fileIndex?: number, positionSeconds?: number, userAgent?: string }
93
+ *
94
+ * Everything a torrent must do before the first byte of video can be served
95
+ * takes seconds and none of it depends on the viewer: announce to the
96
+ * trackers, connect to peers, be unchoked by them, and fetch the two pieces at
97
+ * the file's edges that the codec probe reads. Measured 2026-08-04 on a cold
98
+ * 7.4 GB torrent: 6.7 s of the 10.3 s before playback was those two pieces
99
+ * arriving, with the swarm ramping from nothing.
100
+ *
101
+ * That work used to begin only when a file had been chosen, because it was
102
+ * buried inside the playback plan. It can begin as soon as the viewer has
103
+ * picked a TORRENT — while they are still reading the list of episodes — and
104
+ * then most or all of it has happened by the time they choose.
105
+ *
106
+ * Returns as soon as the work is under way. The caller is not waiting for a
107
+ * result; it is only saying "you may start". Failures are logged and answered
108
+ * as `started: false` rather than as an error, because nothing is broken if a
109
+ * warm-up does not happen — the ordinary path still does all of it.
110
+ *
111
+ * @param {import("fastify").FastifyRequest} req
112
+ * @param {import("fastify").FastifyReply} reply
113
+ * @param {{
114
+ * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
115
+ * torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool,
116
+ * playbackPlanner?: ReturnType<import("../../../../services/media/playback-planner.js").createPlaybackPlanner>,
117
+ * durationOf?: ({ sourceKey: string, fileIndex: number }) => Promise<number | null>
118
+ * }} deps
119
+ * @returns {Promise<void>}
120
+ */
121
+ export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torrentPool, playbackPlanner = null, durationOf = null }) {
122
+ const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
123
+ if (!sourceKey) {
124
+ return reply.code(400).send({ error: "sourceKey is required." });
125
+ }
126
+
127
+ const sourceRecord = sourceRegistry.get(sourceKey);
128
+ if (!sourceRecord) {
129
+ return reply.code(404).send({ error: "Source key was not found." });
130
+ }
131
+
132
+ const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
133
+ const requestedIndex = Number(body.fileIndex);
134
+ const fileIndex = Number.isInteger(requestedIndex) && requestedIndex >= 0 ? requestedIndex : null;
135
+ // Where the viewer is about to resume, if they are resuming. The edges below
136
+ // are what the codec probe reads; this is what the VIEWER will read, and until
137
+ // now nothing asked for it before the encoder did.
138
+ const requestedPosition = Number(body.positionSeconds);
139
+ const positionSeconds = Number.isFinite(requestedPosition) && requestedPosition > 0
140
+ ? requestedPosition
141
+ : 0;
142
+ const userAgent = typeof body.userAgent === "string" ? body.userAgent : "";
143
+
144
+ // Adding the torrent is what announces to the trackers and starts connecting
145
+ // to peers, and it is also what a magnet needs in order to fetch its
146
+ // metadata. It is awaited because everything else needs the torrent object,
147
+ // and because until it resolves there is nothing to report.
148
+ let torrent;
149
+ try {
150
+ torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
151
+ } catch (error) {
152
+ const message = error instanceof Error ? error.message : String(error);
153
+ logger.warn(`warm ${sourceKey.slice(0, 8)}: could not add the torrent: ${message}`);
154
+ return reply.send({ started: false, swarm: false, edges: false });
155
+ }
156
+
157
+ const contents = contentsOf(torrent);
158
+ const named = fileIndex !== null && torrent.files?.[fileIndex] ? fileIndex : null;
159
+ if (named !== null) {
160
+ const selectedSidecars = contents.sidecarsOf(named);
161
+ for (const file of [...selectedSidecars.audio, ...selectedSidecars.subtitles]) {
162
+ void torrentPool.prefetchFileEdges(torrent, file.fileIndex, {
163
+ tailBytes: 0,
164
+ headBytes: TEXT_SUBTITLE_SIDECAR_EXTENSIONS.has(file.extension)
165
+ ? Math.max(1, file.length)
166
+ : undefined,
167
+ timeoutMs: 10_000,
168
+ awaited: true
169
+ }).catch((error) => {
170
+ logger.info(`warm ${sourceKey.slice(0, 8)}:${named}: selected sidecar metadata not ready for "${file.name}": ${error?.message ?? error}`);
171
+ });
172
+ }
173
+ }
174
+ const orderedItems = named === null
175
+ ? contents.items
176
+ : [...contents.items.filter((item) => item.fileIndex === named), ...contents.items.filter((item) => item.fileIndex !== named)];
177
+ const metadataStarted = startMetadataWarmup({
178
+ sourceKey,
179
+ items: orderedItems,
180
+ preferredFileIndex: named,
181
+ userAgent,
182
+ playbackPlanner,
183
+ torrentPool,
184
+ torrent
185
+ });
186
+ const sidecarCount = new Set(orderedItems.flatMap((item) => {
187
+ const matched = contents.sidecarsOf(item.fileIndex);
188
+ return [...matched.audio, ...matched.subtitles].map((file) => file.fileIndex);
189
+ })).size;
190
+ const fillStarted = typeof torrentPool.fillTorrent === "function"
191
+ ? await torrentPool.fillTorrent(torrent).catch((error) => {
192
+ logger.warn(`warm ${sourceKey.slice(0, 8)}: whole-torrent fill could not start: ${error?.message ?? error}`);
193
+ return false;
194
+ })
195
+ : false;
196
+ if (named !== null && positionSeconds > 0 && typeof torrentPool.warmResumePosition === "function") {
197
+ void Promise.resolve(
198
+ (typeof durationOf === "function"
199
+ ? durationOf({ sourceKey, fileIndex: named })
200
+ : Promise.resolve(null)
201
+ ).then((durationSeconds) =>
202
+ torrentPool.warmResumePosition(torrent, named, positionSeconds, durationSeconds)
203
+ )
204
+ ).catch((error) => {
205
+ logger.warn(`warm ${sourceKey.slice(0, 8)}: the viewer's position failed: ${error?.message ?? error}`);
206
+ });
207
+ }
208
+
209
+ logger.info(
210
+ `warm ${sourceKey.slice(0, 8)}: swarm started for "${torrent.name}"` +
211
+ `, metadata for ${contents.items.length} video file(s) ${metadataStarted ? "queued" : "unavailable"}` +
212
+ `, ${sidecarCount} related audio/subtitle file(s)` +
213
+ (fillStarted ? ", whole-torrent fill enabled at TAIL urgency" : ", whole-torrent fill unavailable")
214
+ );
215
+
216
+ return reply.send({ started: true, swarm: true, edges: metadataStarted, sidecars: sidecarCount, fill: fillStarted });
217
+ }
@@ -58,6 +58,12 @@ export async function handleApiTranscodeSessionsPost(req, reply, { viewerRequest
58
58
  // a browser that does not know about them would get no sound at all.
59
59
  const audioRenditions = payload.audioRenditions === true;
60
60
  const startPositionSeconds = Number(payload.startPositionSeconds);
61
+ const viewerLinkMbps = Number(payload.viewerLinkMbps);
62
+ const linkSampleMbps = Number(payload.linkSampleMbps);
63
+ const linkSampleAt = Number(payload.linkSampleAt);
64
+ const linkSampleAgeMs = Number(payload.linkSampleAgeMs);
65
+ const bufferedAheadSec = Number(payload.bufferedAheadSec);
66
+ const bufferLimitSeconds = Number(payload.bufferLimitSeconds);
61
67
  const audioTrackIndex = Number(payload.audioTrackIndex);
62
68
  // Which container to produce. The browser knows what its media stack will
63
69
  // accept for the tracks it asked to be copied; an absent or unknown value
@@ -90,6 +96,13 @@ export async function handleApiTranscodeSessionsPost(req, reply, { viewerRequest
90
96
  Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
91
97
  ? startPositionSeconds
92
98
  : 0,
99
+ viewerLinkMbps: Number.isFinite(viewerLinkMbps) && viewerLinkMbps > 0 ? viewerLinkMbps : null,
100
+ linkSampleMbps: Number.isFinite(linkSampleMbps) && linkSampleMbps > 0 ? linkSampleMbps : null,
101
+ linkSampleAt: Number.isFinite(linkSampleAt) && linkSampleAt > 0 ? linkSampleAt : null,
102
+ linkSampleAgeMs: Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0 ? linkSampleAgeMs : null,
103
+ bufferedAheadSec: Number.isFinite(bufferedAheadSec) && bufferedAheadSec >= 0 ? bufferedAheadSec : 0,
104
+ bufferLimitSeconds:
105
+ Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0 ? bufferLimitSeconds : null,
93
106
  audioTrackIndex:
94
107
  Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0,
95
108
  segmentFormatId