@torrent-tv/proxy 2.107.56 → 2.107.57
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 +1 -1
- package/server.js +195 -1139
- package/services/encode/EncodeInputResolution.js +157 -0
- package/services/encode/measure-host.js +147 -0
- package/services/media/SegmentSourceRanges.js +95 -0
- package/services/media/SubtitleWalk.js +172 -0
- package/services/media/WorkTagsReport.js +30 -0
- package/services/media/segment-reading.js +36 -0
- package/services/server/MediaReadParams.js +134 -0
- package/services/server/PreparationReads.js +175 -0
- package/services/server/forwarded-response-log.js +55 -0
- package/services/server/start-playback.js +34 -0
- package/services/server/torrent-pool-ports.js +105 -0
- package/services/server/wire-outputs.js +57 -47
- package/services/storage/processor-share.js +24 -0
- package/services/torrent/PlaybackResolution.js +397 -0
- package/services/viewer/watched-addresses.js +51 -0
package/server.js
CHANGED
|
@@ -11,7 +11,6 @@ import fastifyCors from "@fastify/cors";
|
|
|
11
11
|
import fastifyHelmet from "@fastify/helmet";
|
|
12
12
|
import getPort from "get-port";
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
|
-
import { performance } from "node:perf_hooks";
|
|
15
14
|
import { SourceWaitTrace } from "./utils/wait-trace.js";
|
|
16
15
|
import { handleHealthGet } from "./routes/health/get.js";
|
|
17
16
|
import { handleMediaGet } from "./routes/media/get.js";
|
|
@@ -19,11 +18,14 @@ import { ProbeRequests } from "./services/media/ProbeRequests.js";
|
|
|
19
18
|
import { IndexMemory } from "./services/storage/IndexMemory.js";
|
|
20
19
|
import { LARGEST_GROWTH_BYTES } from "./services/media/container/PacketRecords.js";
|
|
21
20
|
import { probePackets } from "./services/media/probe-packets.js";
|
|
22
|
-
import { trackChoicesOf } from "./services/viewer/segment-demands.js";
|
|
23
|
-
import { PlaybackProjection } from "./services/torrent/PlaybackProjection.js";
|
|
24
21
|
import { SegmentInputTable } from "./services/viewer/segment-input-table.js";
|
|
25
22
|
import { SourcePreparation, preparationInterval } from "./services/viewer/SourcePreparation.js";
|
|
26
23
|
import { publishPlaybackBeforePreparationRefresh } from "./services/server/publish-playback-before-preparation-refresh.js";
|
|
24
|
+
import { WorkTagsReport } from "./services/media/WorkTagsReport.js";
|
|
25
|
+
import { PreparationReads } from "./services/server/PreparationReads.js";
|
|
26
|
+
import { MediaReadParams } from "./services/server/MediaReadParams.js";
|
|
27
|
+
import { torrentPoolPorts } from "./services/server/torrent-pool-ports.js";
|
|
28
|
+
import { installForwardedResponseLog } from "./services/server/forwarded-response-log.js";
|
|
27
29
|
import { handleHealthzGet } from "./routes/healthz/get.js";
|
|
28
30
|
import { handleApiDeliverySinkGet } from "./routes/api/delivery-sink/get.js";
|
|
29
31
|
import { handleApiLinkProbeGet } from "./routes/api/link-probe/get.js";
|
|
@@ -63,34 +65,23 @@ import { wireOutputs } from "./services/server/wire-outputs.js";
|
|
|
63
65
|
import { createPlaybackPlanner } from "./services/media/playback-planner.js";
|
|
64
66
|
import { KeyframeTables } from "./services/media/KeyframeTables.js";
|
|
65
67
|
import { contentsOf } from "./services/torrent/Contents.js";
|
|
66
|
-
import { joinedWithinPieces } from "./services/torrent/demand/pieces.js";
|
|
67
68
|
import { coalescedZones, withoutRepeatedZones } from "./services/torrent/map-zones.js";
|
|
68
|
-
import {
|
|
69
|
-
import { containerOrchestrator, CONTAINER_HEAD_BYTES
|
|
69
|
+
import { SubtitleWalk } from "./services/media/SubtitleWalk.js";
|
|
70
|
+
import { containerOrchestrator, CONTAINER_HEAD_BYTES } from "./services/media/ContainerOrchestrator.js";
|
|
70
71
|
import { readPlaybackDeclarations } from "./services/media/read-playback-declarations.js";
|
|
71
72
|
import { DownloadMaps } from "./services/viewer/DownloadMaps.js";
|
|
72
73
|
import { nativeSourceMap, nativeOriginalSourceMap } from "./services/viewer/NativeSourceMap.js";
|
|
73
74
|
import { MediaReadRequests } from "./services/media/MediaReadRequests.js";
|
|
75
|
+
import { EncodeInputResolution } from "./services/encode/EncodeInputResolution.js";
|
|
74
76
|
import { SegmentInputs } from "./services/media/SegmentInputs.js";
|
|
75
|
-
import {
|
|
76
|
-
import {
|
|
77
|
+
import { PlaybackResolution } from "./services/torrent/PlaybackResolution.js";
|
|
78
|
+
import { SegmentSourceRanges } from "./services/media/SegmentSourceRanges.js";
|
|
79
|
+
import { readingInterval, segmentReading, selectTracks, trackFor } from "./services/media/segment-reading.js";
|
|
77
80
|
import { pauseCoefficient, viewerStartsOn } from "./services/viewer/PriorityMap.js";
|
|
78
|
-
import {
|
|
79
|
-
import {
|
|
80
|
-
warmSubtitleCues,
|
|
81
|
-
cuesHeldFor,
|
|
82
|
-
subtitleTracksOf,
|
|
83
|
-
declaredSubtitleTracksOf,
|
|
84
|
-
forgetSubtitles
|
|
85
|
-
} from "./services/media/SubtitleCues.js";
|
|
86
|
-
import { detectVideoEncoder, benchmarkDecodeCost, benchmarkContention, benchmarkCopySpeed, detectTonemapSupport, softwareDescriptor } from "./services/encode/hwaccel.js";
|
|
87
|
-
import { calibrateEncoder, HostCalibration } from "./services/encode/calibration.js";
|
|
88
|
-
import { readHostFingerprint } from "./services/encode/fingerprint.js";
|
|
89
|
-
import { measureStartAndStop } from "./services/encode/start-stop-cost.js";
|
|
90
|
-
import { benchmarkAudio } from "./services/encode/audio-calibration.js";
|
|
81
|
+
import { measureHost } from "./services/encode/measure-host.js";
|
|
91
82
|
import { logger } from "./utils/logger.js";
|
|
92
|
-
import { takeTurns } from "./utils/take-turns.js";
|
|
93
83
|
import { completedFilesRoot } from "./services/storage/files/CompletedFiles.js";
|
|
84
|
+
import { startPlayback } from "./services/server/start-playback.js";
|
|
94
85
|
import { startupCpuProfile } from "./services/storage/cpu-profile.js";
|
|
95
86
|
import { startProcessorReport } from "./services/storage/processor-report.js";
|
|
96
87
|
import { processorBound } from "./services/storage/machine-processor.js";
|
|
@@ -154,17 +145,8 @@ export async function startProxyServer({
|
|
|
154
145
|
// receives torrent source payloads that may be arbitrarily large.
|
|
155
146
|
bodyLimit: 256 * 1024 * 1024 // 256 MB
|
|
156
147
|
});
|
|
157
|
-
const forwardedSessionResponseStarts = new WeakMap();
|
|
158
148
|
const waitTrace = new SourceWaitTrace({ emit: value => logger.info(`[wait-trace] ${JSON.stringify(value)}`) });
|
|
159
149
|
waitTrace.install(app);
|
|
160
|
-
const logForwardedSessionResponseStage = (stage, req) => {
|
|
161
|
-
const startedAt = forwardedSessionResponseStarts.get(req);
|
|
162
|
-
const requestId = req.headers["x-ttv-forwarded-request-id"];
|
|
163
|
-
if (startedAt === undefined || typeof requestId !== "string") return;
|
|
164
|
-
const at = performance.now();
|
|
165
|
-
logger.info(`[forecast-http] stage=${stage} requestId=${requestId} atMs=${at.toFixed(3)} ` +
|
|
166
|
-
`elapsedMs=${(at - startedAt).toFixed(3)}`);
|
|
167
|
-
};
|
|
168
150
|
|
|
169
151
|
await app.register(fastifyHelmet, {
|
|
170
152
|
// Proxy serves media to a different origin (registry UI), so CORP must allow cross-origin usage.
|
|
@@ -184,34 +166,7 @@ export async function startProxyServer({
|
|
|
184
166
|
app.addHook("onRequest", async (_req, reply) => {
|
|
185
167
|
reply.header("Access-Control-Allow-Private-Network", "true");
|
|
186
168
|
});
|
|
187
|
-
app
|
|
188
|
-
const requestId = req.headers["x-ttv-forwarded-request-id"];
|
|
189
|
-
if (typeof requestId !== "string" || req.method !== "POST" || req.url !== "/api/transcode-sessions") return;
|
|
190
|
-
const startedAt = performance.now();
|
|
191
|
-
forwardedSessionResponseStarts.set(req, startedAt);
|
|
192
|
-
logForwardedSessionResponseStage("onRequest", req);
|
|
193
|
-
reply.raw.once("finish", () => {
|
|
194
|
-
logForwardedSessionResponseStage("finish", req);
|
|
195
|
-
logger.info(`[http-response] requestId=${requestId} method=${req.method} url=${req.url} ` +
|
|
196
|
-
`responseMs=${(performance.now() - startedAt).toFixed(1)} status=${reply.statusCode} ` +
|
|
197
|
-
`contentLength=${reply.raw.getHeader("content-length") ?? "unknown"}`);
|
|
198
|
-
});
|
|
199
|
-
reply.raw.once("close", () => {
|
|
200
|
-
if (!reply.raw.writableFinished) {
|
|
201
|
-
logForwardedSessionResponseStage("close", req);
|
|
202
|
-
logger.warn(`[http-response] requestId=${requestId} method=${req.method} url=${req.url} ` +
|
|
203
|
-
`closedMs=${(performance.now() - startedAt).toFixed(1)} status=${reply.statusCode}`);
|
|
204
|
-
}
|
|
205
|
-
});
|
|
206
|
-
});
|
|
207
|
-
app.addHook("preSerialization", async (req, _reply, payload) => {
|
|
208
|
-
logForwardedSessionResponseStage("preSerialization", req);
|
|
209
|
-
return payload;
|
|
210
|
-
});
|
|
211
|
-
app.addHook("onSend", async (req, _reply, payload) => {
|
|
212
|
-
logForwardedSessionResponseStage("onSend", req);
|
|
213
|
-
return payload;
|
|
214
|
-
});
|
|
169
|
+
installForwardedResponseLog(app, { log: line => logger.info(line), warn: line => logger.warn(line) });
|
|
215
170
|
|
|
216
171
|
const sourceRegistry = createSourceRegistry(200);
|
|
217
172
|
// The torrent runs on its own thread. Profiling a live seek (2026-08-02)
|
|
@@ -235,21 +190,19 @@ export async function startProxyServer({
|
|
|
235
190
|
heapSnapshots,
|
|
236
191
|
onHoldingsChanged: () => onStateChanged(),
|
|
237
192
|
onSourceForgotten: ({ sourceKey }) => {
|
|
238
|
-
sourcePreparation
|
|
193
|
+
sourcePreparation.forget(sourceKey);
|
|
239
194
|
mediaReads.forget(sourceKey);
|
|
240
195
|
chapters.forget(sourceKey);
|
|
241
196
|
probeReads.forget(sourceKey);
|
|
242
|
-
subtitles.forget(sourceKey);
|
|
197
|
+
subtitleWalk.subtitles.forget(sourceKey);
|
|
243
198
|
keyframeTables.forget(sourceKey);
|
|
244
199
|
playbackPlanner.forget(sourceKey);
|
|
245
200
|
indexMemory.forget(sourceKey);
|
|
246
201
|
downloadMaps.retire(sourceKey);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
for (const key of playbackProjections.keys()) if (key.startsWith(`${sourceKey}:`)) playbackProjections.delete(key);
|
|
250
|
-
for (const key of madeRates.keys()) if (key.startsWith(`${sourceKey}:`)) madeRates.delete(key);
|
|
202
|
+
sourceRanges.forget(sourceKey);
|
|
203
|
+
resolution.forget(sourceKey);
|
|
251
204
|
segmentInputs.forget(sourceKey);
|
|
252
|
-
|
|
205
|
+
encodeInputs?.bytesChanged();
|
|
253
206
|
},
|
|
254
207
|
// Pieces arriving is ANNOUNCED by the thread that owns the swarm; what is
|
|
255
208
|
// done about it — walking a file's new subtitle clusters and pushing what
|
|
@@ -265,121 +218,27 @@ export async function startProxyServer({
|
|
|
265
218
|
for (const fileIndex of fileIndexes) {
|
|
266
219
|
mediaReads.bytesChanged(sourceKey, fileIndex);
|
|
267
220
|
probeReads.bytesChanged(sourceKey, fileIndex);
|
|
268
|
-
void sourcePreparation
|
|
221
|
+
void sourcePreparation.bytesChanged(sourceKey, fileIndex).catch(error =>
|
|
269
222
|
logger.warn(`source input readiness failed: ${error.message}`));
|
|
270
|
-
void pushFreshCues(
|
|
223
|
+
void subtitleWalk.pushFreshCues(sourceKey, fileIndex);
|
|
271
224
|
// A keyframe table somebody asked for while its bytes had not arrived
|
|
272
225
|
// is read again now; nothing was recorded for that wait.
|
|
273
226
|
keyframeTables.readAgainIfUnanswered({ sourceKey, fileIndex, logName: String(fileIndex) });
|
|
274
227
|
}
|
|
275
228
|
// Which bytes arrived, when the event names them: an input waiting for
|
|
276
229
|
// named bytes is tried again only when some of them have come.
|
|
277
|
-
|
|
230
|
+
encodeInputs?.bytesChanged(Array.isArray(arrived) ? { sourceKey, ranges: arrived } : null);
|
|
278
231
|
}
|
|
279
232
|
});
|
|
280
233
|
const selectedPort = await getPort({
|
|
281
234
|
port: buildPortCandidates(port)
|
|
282
235
|
});
|
|
283
|
-
// Auto-detect the best available H.264 encoder (hardware-accelerated or
|
|
284
|
-
// software) once at startup, with a real test-encode and graceful fallback.
|
|
285
|
-
// Only needed when transcoding can occur.
|
|
286
|
-
let videoEncoder = transcodeAudio
|
|
287
|
-
? await detectVideoEncoder({ ffmpegBin, logger })
|
|
288
|
-
: null;
|
|
289
236
|
// The cores this process may use (a container's quota or CPU set): the
|
|
290
|
-
// readings
|
|
291
|
-
//
|
|
237
|
+
// readings divide out a share of those, the unit every later share of the
|
|
238
|
+
// machine is in (torrent-tv/meta#180, phase 3).
|
|
292
239
|
const processorCores = processorBound().cores;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
// not about the encoder at all: a host with a GPU decodes in software just
|
|
296
|
-
// the same — no hardware decoder is asked for anywhere — and what a second
|
|
297
|
-
// job costs is a property of the machine. So a GPU host had no decode cost,
|
|
298
|
-
// no contention penalty and no encoder throughput, and the quality offer,
|
|
299
|
-
// which is arithmetic over those three, had nothing to compute from.
|
|
300
|
-
//
|
|
301
|
-
// The decode model first and the throughput second — they are independent
|
|
302
|
-
// (the rungs are timed on raw frames), but the order keeps the two figures
|
|
303
|
-
// side by side in the log.
|
|
304
|
-
const decodeCostModel = transcodeAudio
|
|
305
|
-
? await benchmarkDecodeCost({ ffmpegBin, logger, processorCores })
|
|
306
|
-
: null;
|
|
307
|
-
// What a second job costs on this host. Measured because the budget adds
|
|
308
|
-
// independent prices and this host says two jobs that each fit alone do not
|
|
309
|
-
// fit together — 2.6× on the addon box (2026-08-18).
|
|
310
|
-
const contentionPenalties = transcodeAudio
|
|
311
|
-
? await benchmarkContention({ ffmpegBin, logger })
|
|
312
|
-
: null;
|
|
313
|
-
// WHICH MODES THIS HOST MAY ENCODE WITH, AND WHAT EACH COSTS AT EVERY SIZE
|
|
314
|
-
// (roadmap item 97, step 14). Every setting an output can actually be
|
|
315
|
-
// encoded at is put through the product's own arguments and its segments
|
|
316
|
-
// decoded, then timed at the sizes of the ladder; a mode that fails either is
|
|
317
|
-
// not used, and a size nothing was timed at is not offered. Software is
|
|
318
|
-
// calibrated too when detection chose a device: it is what a failing device
|
|
319
|
-
// falls back to for the rest of the process, and it must not be priced by
|
|
320
|
-
// the device's figures.
|
|
321
|
-
//
|
|
322
|
-
// And WHICH CONFIGURATION all of it describes — the ffmpeg and x264 builds,
|
|
323
|
-
// the processor and the threads, the device and its driver — which is what
|
|
324
|
-
// decides whether anything kept from an earlier run still applies.
|
|
325
|
-
const fingerprint = videoEncoder
|
|
326
|
-
? await readHostFingerprint({ ffmpegBin, encoder: videoEncoder, processorCores })
|
|
327
|
-
: null;
|
|
328
|
-
/** @type {Record<string, object[]>} */
|
|
329
|
-
const calibratedModes = {};
|
|
330
|
-
if (videoEncoder) {
|
|
331
|
-
const selectedCalibration = await calibrateEncoder({ ffmpegBin, encoder: videoEncoder, logger, processorCores });
|
|
332
|
-
if (selectedCalibration.assessmentComplete) {
|
|
333
|
-
calibratedModes[videoEncoder.kind] = selectedCalibration.modes;
|
|
334
|
-
}
|
|
335
|
-
if (videoEncoder.kind !== "software") {
|
|
336
|
-
const softwareCalibration = await calibrateEncoder({ ffmpegBin, encoder: softwareDescriptor(), logger, processorCores });
|
|
337
|
-
if (softwareCalibration.assessmentComplete) {
|
|
338
|
-
calibratedModes.software = softwareCalibration.modes;
|
|
339
|
-
}
|
|
340
|
-
// A device that passed detection's own test and then none of whose modes
|
|
341
|
-
// passed the product's is not used: detection encodes a synthetic pattern
|
|
342
|
-
// with arguments of its own, and it is the product's arguments a viewer
|
|
343
|
-
// is served with.
|
|
344
|
-
if (Array.isArray(calibratedModes[videoEncoder.kind]) && calibratedModes[videoEncoder.kind].length === 0) {
|
|
345
|
-
logger.warn(
|
|
346
|
-
`calibration: ${videoEncoder.name} passed detection but none of its modes produced correct segments ` +
|
|
347
|
-
"through the product's own arguments; encoding in software instead"
|
|
348
|
-
);
|
|
349
|
-
videoEncoder = softwareDescriptor();
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
const calibration = videoEncoder ? new HostCalibration({ byKind: calibratedModes, fingerprint }) : null;
|
|
354
|
-
// What this host does with a picture it does NOT re-encode. Every other
|
|
355
|
-
// startup measurement prices encoding or decoding, and a copied picture does
|
|
356
|
-
// neither — it reads packets and writes them out again — so that whole branch
|
|
357
|
-
// had no speed until its own run had been running long enough to report one.
|
|
358
|
-
// The encoding layer decides where encoders go from arrivals, and an arrival
|
|
359
|
-
// cannot be computed without a speed, so the moment it mattered most was the
|
|
360
|
-
// moment nothing was known. Measured whatever the encoder is: copying does not
|
|
361
|
-
// touch it.
|
|
362
|
-
const copySpeedX = transcodeAudio
|
|
363
|
-
? await benchmarkCopySpeed({ ffmpegBin, logger })
|
|
364
|
-
: null;
|
|
365
|
-
const audioCalibration = transcodeAudio
|
|
366
|
-
? await benchmarkAudio({ ffmpegBin, logger })
|
|
367
|
-
: [];
|
|
368
|
-
// WHAT A START AND A STOP COST HERE, before any viewer exists. Both decide one
|
|
369
|
-
// thing — leave an encoder where it stands, or kill it and start another —
|
|
370
|
-
// and both used to be learned only from runs that had ENDED, so at a cold
|
|
371
|
-
// open they were zero. Zero does not read as "not measured": it reads as
|
|
372
|
-
// "free", and a free move is always taken. Field 2026-09-08: an encoder moved
|
|
373
|
-
// between two adjacent numbers every half second and produced nothing.
|
|
374
|
-
const startStopCost = transcodeAudio
|
|
375
|
-
? await measureStartAndStop({ ffmpegBin, encoder: videoEncoder, logger })
|
|
376
|
-
: null;
|
|
377
|
-
// Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
|
|
378
|
-
// Detected once; the session manager applies the tonemap chain only for HDR
|
|
379
|
-
// sources on the software path when available.
|
|
380
|
-
const tonemapSupported = transcodeAudio
|
|
381
|
-
? await detectTonemapSupport({ ffmpegBin, logger })
|
|
382
|
-
: false;
|
|
240
|
+
const { videoEncoder, decodeCostModel, contentionPenalties, calibration, copySpeedX, audioCalibration, startStopCost,
|
|
241
|
+
tonemapSupported } = await measureHost({ ffmpegBin, logger, transcode: transcodeAudio, processorCores });
|
|
383
242
|
// Where every file's keyframe table lives, and the only thing that reads one.
|
|
384
243
|
// Built here rather than inside the session manager because it is not a fact
|
|
385
244
|
// about a session: the playback planner warms it while a file is being opened
|
|
@@ -390,18 +249,6 @@ export async function startProxyServer({
|
|
|
390
249
|
// written out is the media layer's, while which clusters may be read, where
|
|
391
250
|
// the cursor stands and what one walk at a time means are the TORRENT's own
|
|
392
251
|
// rules. The media layer used to import the second directly.
|
|
393
|
-
/**
|
|
394
|
-
* One file of one torrent, reduced to what the subtitle walk may know: a
|
|
395
|
-
* name, a length, its one container, which ranges are downloaded whole, how
|
|
396
|
-
* to read one of those without asking the swarm, the portion one read may
|
|
397
|
-
* span, and where the viewers stand. File offsets and the bitfield stay in
|
|
398
|
-
* the torrent's thread; the piece length crosses as a plain number.
|
|
399
|
-
*
|
|
400
|
-
* @param {string} sourceKey
|
|
401
|
-
* @param {number} fileIndex
|
|
402
|
-
* @param {object} [known] - The torrent handle when the caller already has it.
|
|
403
|
-
* @returns {Promise<import("./services/media/SubtitleCues.js").HeldFile | null>}
|
|
404
|
-
*/
|
|
405
252
|
const chapters = new ChapterOrchestrator({
|
|
406
253
|
containers: containerOrchestrator,
|
|
407
254
|
subscribe: (sourceKey, fileIndex, listener) => mediaReads.subscribe(sourceKey, fileIndex, listener),
|
|
@@ -416,99 +263,18 @@ export async function startProxyServer({
|
|
|
416
263
|
const torrent = torrentPool.knownTorrent(sourceKey);
|
|
417
264
|
const file = torrent?.files?.[fileIndex];
|
|
418
265
|
if (!file) return torrent ? { invalid: true } : null;
|
|
419
|
-
return
|
|
266
|
+
return mediaParams.paramsFor({ sourceKey, fileIndex });
|
|
420
267
|
}
|
|
421
268
|
});
|
|
422
269
|
const chapterController = new ChapterController({ sourceRegistry, chapters });
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
429
|
-
const file = torrent?.files?.[fileIndex];
|
|
430
|
-
if (!file || !(file.length > 0)) {
|
|
431
|
-
return null;
|
|
432
|
-
}
|
|
433
|
-
return {
|
|
434
|
-
sourceKey,
|
|
435
|
-
fileIndex,
|
|
436
|
-
name: String(file.name ?? ""),
|
|
437
|
-
length: file.length,
|
|
438
|
-
portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined,
|
|
439
|
-
// The file's ONE container — the one the track table, the media info and
|
|
440
|
-
// the keyframe table are read from. Built here only when nobody has built
|
|
441
|
-
// it yet, so a pass of the walk does not fetch the file's edges again.
|
|
442
|
-
container: async () => {
|
|
443
|
-
const known = containerOrchestrator.known(sourceKey, fileIndex);
|
|
444
|
-
if (known !== undefined) {
|
|
445
|
-
return known;
|
|
446
|
-
}
|
|
447
|
-
const params = await containerOver({ sourceKey, fileIndex, tailBytes: CONTAINER_HEAD_BYTES });
|
|
448
|
-
return params ? containerOrchestrator.containerFor(params) : null;
|
|
449
|
-
},
|
|
450
|
-
heldRanges: () => torrentPool.heldRangesOf(torrent, fileIndex),
|
|
451
|
-
readHeld: (start, end) => torrentPool.readHeldOf(torrent, fileIndex, start, end),
|
|
452
|
-
// Where the viewers of this file stand: the start of every urgent stretch
|
|
453
|
-
// of its priority map, which is where each viewer's own need begins.
|
|
454
|
-
wantedSeconds: () => viewerStartsOn(outputParts.priority?.mapFor(sourceKey, fileIndex)),
|
|
455
|
-
// A pass left readable clusters for the next one: run it.
|
|
456
|
-
askAgain: () => {
|
|
457
|
-
void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
|
|
458
|
-
}
|
|
459
|
-
};
|
|
460
|
-
};
|
|
461
|
-
/**
|
|
462
|
-
* Walk whatever new cues a file now holds and push them to the viewers.
|
|
463
|
-
*
|
|
464
|
-
* One walk of a file at a time, and one more after it whenever pieces
|
|
465
|
-
* arrived meanwhile (`coalescing`): `verified` fires per piece, so a queue of
|
|
466
|
-
* identical passes would only postpone the one with something new to find,
|
|
467
|
-
* and dropping them — as this used to — lost the pass that would have found
|
|
468
|
-
* the last pieces of a file.
|
|
469
|
-
*/
|
|
470
|
-
const pushFreshCues = coalescing(async (sourceKey, fileIndex) => {
|
|
471
|
-
try {
|
|
472
|
-
const file = await heldFileFor(sourceKey, fileIndex);
|
|
473
|
-
if (!file) {
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
for (const entry of await warmSubtitleCues(file)) {
|
|
477
|
-
if (subtitles.hasPacketCues(sourceKey, fileIndex, entry.trackIndex)) continue;
|
|
478
|
-
const span = entry.spanStartSeconds === null
|
|
479
|
-
? "empty"
|
|
480
|
-
: `${entry.spanStartSeconds.toFixed(1)}-${entry.spanEndSeconds.toFixed(1)}s`;
|
|
481
|
-
logger.info(
|
|
482
|
-
`subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} track ${entry.trackIndex}: ` +
|
|
483
|
-
`${entry.cues.length} new cue(s) covering ${span}` +
|
|
484
|
-
(entry.withdrawn.length > 0 ? `, ${entry.withdrawn.length} taken back` : "") +
|
|
485
|
-
`, clusters walked ${entry.walkedClusters}/${entry.indexedClusters}, cursor ${entry.cursor}`
|
|
486
|
-
);
|
|
487
|
-
onSubtitleCues?.({ sourceKey, fileIndex, ...entry });
|
|
488
|
-
}
|
|
489
|
-
} catch (error) {
|
|
490
|
-
logger.warn(`subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} failed: ${error?.message ?? error}`);
|
|
491
|
-
}
|
|
492
|
-
});
|
|
493
|
-
const subtitles = new SubtitleOrchestrator(containerOrchestrator, {
|
|
270
|
+
const subtitleWalk = new SubtitleWalk({
|
|
271
|
+
torrents: torrentPool,
|
|
272
|
+
containers: containerOrchestrator,
|
|
273
|
+
paramsFor: request => mediaParams.paramsFor(request),
|
|
274
|
+
wantedSeconds: (sourceKey, fileIndex) => viewerStartsOn(outputParts.priority?.mapFor(sourceKey, fileIndex)),
|
|
494
275
|
publish: entry => onSubtitleCues?.(entry),
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
return file ? warmSubtitleCues(file) : [];
|
|
498
|
-
},
|
|
499
|
-
held: async (torrent, fileIndex, sourceKey, trackNumber) => {
|
|
500
|
-
const file = await heldFileFor(sourceKey, fileIndex, torrent);
|
|
501
|
-
return file ? cuesHeldFor(file, trackNumber) : null;
|
|
502
|
-
},
|
|
503
|
-
tracksOf: async (torrent, fileIndex, sourceKey) => {
|
|
504
|
-
const file = await heldFileFor(sourceKey, fileIndex, torrent);
|
|
505
|
-
return file ? subtitleTracksOf(file) : [];
|
|
506
|
-
},
|
|
507
|
-
declaredTracksOf: async (torrent, fileIndex, sourceKey) => {
|
|
508
|
-
const file = await heldFileFor(sourceKey, fileIndex, torrent);
|
|
509
|
-
return file ? declaredSubtitleTracksOf(file) : [];
|
|
510
|
-
},
|
|
511
|
-
forget: forgetSubtitles
|
|
276
|
+
log: line => logger.info(line),
|
|
277
|
+
warn: line => logger.warn(line)
|
|
512
278
|
});
|
|
513
279
|
// WHAT A FILE STATES ABOUT ITSELF IS READ HERE, ON THIS THREAD.
|
|
514
280
|
//
|
|
@@ -523,389 +289,13 @@ export async function startProxyServer({
|
|
|
523
289
|
//
|
|
524
290
|
// Missing metadata bytes are demand in the same file map as playback.
|
|
525
291
|
// Readers never start their own downloads or wait for source bytes.
|
|
526
|
-
/** Source byte ranges of one segment interval
|
|
527
|
-
const
|
|
528
|
-
/**
|
|
529
|
-
* WHAT ONE SEGMENT OF AN OUTPUT IS, kept from pass to pass: where it lies in
|
|
530
|
-
* the source, what it reads — its tracks and the key its bytes are kept under
|
|
531
|
-
* — and, once known, those bytes joined at the precision the torrent fetches,
|
|
532
|
-
* by output and segment. Worked out again only when the output's timeline,
|
|
533
|
-
* the tracks, the file's start time or the output's choices change; forgotten
|
|
534
|
-
* with the source. None of it depends on where a viewer stands, and a pass is
|
|
535
|
-
* made every time one moves: looked up, made and joined again for each of a
|
|
536
|
-
* film's 3500 segments on every pass, they were 6 s of the 40 s the passes
|
|
537
|
-
* cost the main thread in fifteen minutes on Home Assistant (r30,
|
|
538
|
-
* torrent-tv/meta#178).
|
|
539
|
-
*/
|
|
540
|
-
const segmentReadings = new Map();
|
|
541
|
-
/** One output's segments, by index. */
|
|
542
|
-
const segmentRecordsOf = (map, output) => {
|
|
543
|
-
const key = `${map.sourceKey}:${map.fileIndex}:${output.outputKey}`;
|
|
544
|
-
let records = segmentReadings.get(key);
|
|
545
|
-
if (!records) segmentReadings.set(key, records = new Map());
|
|
546
|
-
return records;
|
|
547
|
-
};
|
|
548
|
-
/** The choices of one output's tracks as a key, worked out once for the list of them a pass shares. */
|
|
549
|
-
const choicesKeys = new WeakMap();
|
|
550
|
-
const choicesKeyOf = (choices) => {
|
|
551
|
-
let key = choicesKeys.get(choices);
|
|
552
|
-
if (key === undefined) choicesKeys.set(choices, key = choices.map(choice => `${choice.type}/${choice.index}/${choice.mode}`).join(","));
|
|
553
|
-
return key;
|
|
554
|
-
};
|
|
555
|
-
const segmentRecordOf = (records, demand, { timeline, trackList, shift, map }) => {
|
|
556
|
-
const choicesKey = choicesKeyOf(demand.tracks);
|
|
557
|
-
const kept = records.get(demand.index);
|
|
558
|
-
if (kept && kept.timeline === timeline && kept.trackList === trackList && kept.shift === shift && kept.choicesKey === choicesKey) return kept;
|
|
559
|
-
const sourceInterval = timeline.sourceInterval(demand.index, demand.index, shift);
|
|
560
|
-
const selected = demand.tracks.map(choice => ({ choice, track: choice.type === "video"
|
|
561
|
-
? ContainerTrack.firstUsable(trackList, "video")
|
|
562
|
-
: trackList.filter(track => track.type === choice.type)[choice.index] }));
|
|
563
|
-
let reading = null;
|
|
564
|
-
if (!selected.some(({ track }) => !track)) {
|
|
565
|
-
const wanted = selected.map(({ track }) => track);
|
|
566
|
-
const modes = new Map(selected.map(({ choice, track }) => [track, choice.mode]));
|
|
567
|
-
const interval = { ...sourceInterval, trackIds: wanted.map(track => track.trackNumber),
|
|
568
|
-
modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
|
|
569
|
-
reading = { wanted, modes, interval, rangesKey: `${map.sourceKey}:${map.fileIndex}:${JSON.stringify(interval)}` };
|
|
570
|
-
}
|
|
571
|
-
// `joined` is the segment's ranges as the torrent is told them, made from
|
|
572
|
-
// the list `ranges` at `pieceLength`; `registered` is the list the input
|
|
573
|
-
// table was last given.
|
|
574
|
-
const record = { timeline, trackList, shift, choicesKey, sourceInterval, reading,
|
|
575
|
-
ranges: null, pieceLength: Number.NaN, joined: null, registered: null };
|
|
576
|
-
records.set(demand.index, record);
|
|
577
|
-
return record;
|
|
578
|
-
};
|
|
579
|
-
/** What the torrent is told of each played file, kept from map to map (`PlaybackProjection`); forgotten with the source. */
|
|
580
|
-
const playbackProjections = new Map();
|
|
292
|
+
/** Source byte ranges of one segment interval; the cache is the resolver's, forgotten with the source. */
|
|
293
|
+
const sourceRanges = new SegmentSourceRanges({ orchestrator: containerOrchestrator });
|
|
581
294
|
/** Which source bytes each segment of an output reads, for the forecast (`segment-input-table.js`). */
|
|
582
295
|
const segmentInputs = new SegmentInputTable();
|
|
583
|
-
/** An output's cut grid, checked once for each timeline. */
|
|
584
|
-
const checkedGrids = new WeakMap();
|
|
585
|
-
const gridOf = (output) => {
|
|
586
|
-
const timeline = output.timeline;
|
|
587
|
-
if (!timeline || typeof timeline !== "object") return null;
|
|
588
|
-
if (checkedGrids.has(timeline)) return checkedGrids.get(timeline);
|
|
589
|
-
const grid = timeline.published ?? timeline.boundaries;
|
|
590
|
-
if (!Array.isArray(grid)) {
|
|
591
|
-
checkedGrids.set(timeline, null);
|
|
592
|
-
return null;
|
|
593
|
-
}
|
|
594
|
-
for (let index = 0; index + 1 < grid.length; index++) {
|
|
595
|
-
if (!Number.isFinite(grid[index]) || !Number.isFinite(grid[index + 1]) || !(grid[index + 1] > grid[index])) {
|
|
596
|
-
throw new Error("An output segment needs a valid interval.");
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
checkedGrids.set(timeline, grid);
|
|
600
|
-
return grid;
|
|
601
|
-
};
|
|
602
|
-
/**
|
|
603
|
-
* How many bytes a second of an output's film takes, from what it has made,
|
|
604
|
-
* read again only when what it has made changes (`SegmentStore.revisionOf`):
|
|
605
|
-
* summed over every made segment on every pass, it was a walk of the whole
|
|
606
|
-
* film a second (torrent-tv/meta#178).
|
|
607
|
-
*/
|
|
608
|
-
const madeRates = new Map();
|
|
609
|
-
const bytesPerSecondOf = (output, revision) => {
|
|
610
|
-
const kept = madeRates.get(output.outputKey);
|
|
611
|
-
if (kept && kept.revision === revision && kept.timeline === output.timeline) return kept.bytesPerSecond;
|
|
612
|
-
let bytes = 0, seconds = 0;
|
|
613
|
-
for (const [index, size] of outputParts.segmentStore.sizesOf(output.outputKey)) {
|
|
614
|
-
const span = output.timeline.publishedStartOf(index + 1) - output.timeline.publishedStartOf(index);
|
|
615
|
-
if (span > 0) { bytes += size; seconds += span; }
|
|
616
|
-
}
|
|
617
|
-
const bytesPerSecond = seconds > 0 ? bytes / seconds : null;
|
|
618
|
-
madeRates.set(output.outputKey, { revision, timeline: output.timeline, bytesPerSecond });
|
|
619
|
-
return bytesPerSecond;
|
|
620
|
-
};
|
|
621
|
-
// WHAT THE PASSES COST, said once a minute: how many maps were taken, how
|
|
622
|
-
// many segments a map made the projection work out again and how many of
|
|
623
|
-
// those maps it worked out whole, how many segments had their ranges worked
|
|
624
|
-
// out, the windows told, and the time from a pass's start to its result —
|
|
625
|
-
// which includes waits for reads, so it bounds the main thread's share
|
|
626
|
-
// rather than measuring it (torrent-tv/meta#178).
|
|
627
|
-
const passCosts = { since: Date.now(), passes: 0, whole: 0, reworked: 0, resolved: 0, windows: 0, ms: 0, maxMs: 0 };
|
|
628
|
-
const notePass = ({ whole = false, reworked = 0, resolved = 0, windows = 0, ms = 0 }) => {
|
|
629
|
-
passCosts.passes += 1;
|
|
630
|
-
if (whole) passCosts.whole += 1;
|
|
631
|
-
passCosts.reworked += reworked; passCosts.resolved += resolved; passCosts.windows += windows;
|
|
632
|
-
passCosts.ms += ms; passCosts.maxMs = Math.max(passCosts.maxMs, ms);
|
|
633
|
-
const now = Date.now();
|
|
634
|
-
if (now - passCosts.since < 60_000) return;
|
|
635
|
-
logger.info(`download map passes in ${((now - passCosts.since) / 1000).toFixed(1)}s: ${passCosts.passes} map(s), ` +
|
|
636
|
-
`${passCosts.whole} worked out whole, ${passCosts.reworked} segment(s) worked out again, ` +
|
|
637
|
-
`${passCosts.resolved} segment(s) had their ranges worked out, ${passCosts.windows} window(s) told; ` +
|
|
638
|
-
`${Math.round(passCosts.ms)}ms from start to result in all, longest ${Math.round(passCosts.maxMs)}ms`);
|
|
639
|
-
Object.assign(passCosts, { since: now, passes: 0, whole: 0, reworked: 0, resolved: 0, windows: 0, ms: 0, maxMs: 0 });
|
|
640
|
-
};
|
|
641
|
-
// A container whose packet table a pass did not wait for builds the map
|
|
642
|
-
// again once, when the table is built or refused (`directSegmentInput`).
|
|
643
|
-
const refreshAfterTable = new WeakSet();
|
|
644
|
-
const refreshWhenBuilt = (container, built, { sourceKey, fileIndex }) => {
|
|
645
|
-
if (refreshAfterTable.has(container)) return;
|
|
646
|
-
refreshAfterTable.add(container);
|
|
647
|
-
void built.then(() => {
|
|
648
|
-
refreshAfterTable.delete(container);
|
|
649
|
-
return downloadMaps.refresh(sourceKey, fileIndex);
|
|
650
|
-
}).catch(error => logger.warn(`download map after a packet table: ${error?.message ?? error}`));
|
|
651
|
-
};
|
|
652
|
-
let outputPartsReady = false;
|
|
653
|
-
const downloadMaps = new DownloadMaps({
|
|
654
|
-
// The ranges a picture reads at open are named again by every segment;
|
|
655
|
-
// only the zone the swarm would rank first is sent (torrent-tv/meta#166).
|
|
656
|
-
// Left out of what is SENT and nowhere else: every segment's own zones are
|
|
657
|
-
// also what says which bytes that segment needs, and a segment whose zones
|
|
658
|
-
// all repeat another's — two of one group of pictures in an AVI read the
|
|
659
|
-
// same bytes — lost them with 2.96.11, and the start forecast answered
|
|
660
|
-
// "source-input-ranges-unavailable" for ever (Home Assistant 2026-10-11).
|
|
661
|
-
publish: (map) => torrentPool.setPriorityMap({ ...map, zones: withoutRepeatedZones(map.zones) }),
|
|
662
|
-
// And what is sent is packed: a stretch of one level and priority is one
|
|
663
|
-
// window, not one zone per range of each of its segments
|
|
664
|
-
// (`coalescedZones`, torrent-tv/meta#178). The playback zones come packed
|
|
665
|
-
// already from the projection; this packs them with the native ones.
|
|
666
|
-
pack: (zones) => coalescedZones(zones),
|
|
667
|
-
log: (line) => logger.info(line),
|
|
668
|
-
observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
|
|
669
|
-
// A file's own reads are wanted while a present viewer has the file chosen
|
|
670
|
-
// or on screen. The viewers are assembled below; until they exist no read
|
|
671
|
-
// can have been asked for by one.
|
|
672
|
-
fileWanted: (sourceKey, fileIndex) => (outputPartsReady ? outputParts.viewers.forSource(sourceKey) : [])
|
|
673
|
-
.some(viewer => viewer.source?.selectedFileIndex === fileIndex ||
|
|
674
|
-
viewer.source?.visibleFileIndices?.includes(fileIndex)),
|
|
675
|
-
resolvePlayback: async (map) => {
|
|
676
|
-
const fileKey = `${map.sourceKey}:${map.fileIndex}`;
|
|
677
|
-
if (map.zones.length === 0) {
|
|
678
|
-
playbackProjections.get(fileKey)?.clear();
|
|
679
|
-
return [];
|
|
680
|
-
}
|
|
681
|
-
const params = await containerOver(map);
|
|
682
|
-
if (!params || !map.isCurrent()) return [];
|
|
683
|
-
const tracks = await containerOrchestrator.inspect(params, "tracks");
|
|
684
|
-
if (tracks.kind !== "result" || !map.isCurrent()) return [];
|
|
685
|
-
const container = containerOrchestrator.known(map.sourceKey, map.fileIndex);
|
|
686
|
-
if (typeof container?.readSourceRanges !== "function" && typeof container?.readPacketIndex !== "function") return [];
|
|
687
|
-
const media = await containerOrchestrator.inspect(params, "media-info");
|
|
688
|
-
if (media.kind !== "result" || !map.isCurrent()) return [];
|
|
689
|
-
const shift = Number(media.value?.startTimeSeconds) || 0;
|
|
690
|
-
// A zone's ranges are stated to the torrent at the precision it fetches:
|
|
691
|
-
// ranges closer than a piece hold the same pieces as one.
|
|
692
|
-
const pieceLength = Number(torrentPool.knownTorrent(map.sourceKey)?.pieceLength);
|
|
693
|
-
const passStartedAt = performance.now();
|
|
694
|
-
// THE PROJECTION IS KEPT while what it was made from stands: the file's
|
|
695
|
-
// tracks, where its clock starts and how the torrent fetches. Any of them
|
|
696
|
-
// changed, every segment's ranges are other ranges.
|
|
697
|
-
let projection = playbackProjections.get(fileKey);
|
|
698
|
-
if (!projection || projection.trackList !== tracks.value || projection.shift !== shift ||
|
|
699
|
-
!Object.is(projection.pieceLength, pieceLength)) {
|
|
700
|
-
projection?.clear();
|
|
701
|
-
projection = new PlaybackProjection();
|
|
702
|
-
Object.assign(projection, { trackList: tracks.value, shift, pieceLength });
|
|
703
|
-
playbackProjections.set(fileKey, projection);
|
|
704
|
-
}
|
|
705
|
-
// Every output of this file somebody is watching, with what decides how
|
|
706
|
-
// long its segments take to make and deliver, what it has made, and what
|
|
707
|
-
// its next run would copy.
|
|
708
|
-
const played = [];
|
|
709
|
-
/** By output: what its segments read and how they are looked up. */
|
|
710
|
-
const readers = new Map();
|
|
711
|
-
for (const output of outputParts.outputs.values()) {
|
|
712
|
-
if (output.file.sourceKey !== map.sourceKey) continue;
|
|
713
|
-
const consumers = [...outputParts.viewers.forOutput(output).values()].filter(viewer =>
|
|
714
|
-
viewer.isPresent() && !outputParts.outputs.supersededBy(output, viewer.activeVariantId ?? null));
|
|
715
|
-
if (consumers.length === 0) continue;
|
|
716
|
-
const choices = trackChoicesOf(output, map.fileIndex);
|
|
717
|
-
if (choices.length === 0) continue;
|
|
718
|
-
const grid = gridOf(output);
|
|
719
|
-
if (!grid) continue;
|
|
720
|
-
const revision = outputParts.segmentStore.revisionOf(output.outputKey);
|
|
721
|
-
const links = consumers.map(viewer => viewer.linkReading()?.linkMbps)
|
|
722
|
-
.filter(value => Number.isFinite(value) && value > 0);
|
|
723
|
-
// Every piece answers for its exact ending boundary, as the encode
|
|
724
|
-
// plan reads it: the player may request the preceding piece there.
|
|
725
|
-
played.push({ outputKey: output.outputKey, grid,
|
|
726
|
-
encodeSpeed: outputParts.encodeCost.latestSpeedReadingOf(output)?.speed,
|
|
727
|
-
bytesPerSecond: bytesPerSecondOf(output, revision), linkMbps: links.length ? Math.min(...links) : null,
|
|
728
|
-
readyRevision: revision, ready: () => outputParts.segmentStore.sizesOf(output.outputKey),
|
|
729
|
-
target: outputParts.encodeInputs?.targetInputBytes?.(output) ?? null });
|
|
730
|
-
readers.set(output.outputKey, { records: segmentRecordsOf(map, output), choices,
|
|
731
|
-
facts: { timeline: output.timeline, trackList: tracks.value, shift, map } });
|
|
732
|
-
}
|
|
733
|
-
// WHAT THE MAP CHANGED, and only that, is worked out again here.
|
|
734
|
-
projection.update({ zones: map.zones, outputs: played });
|
|
735
|
-
const reworked = projection.lastUpdate;
|
|
736
|
-
let resolved = 0;
|
|
737
|
-
// A container that names the bytes of an interval from an index it holds
|
|
738
|
-
// answers every segment by arithmetic: once that index is read
|
|
739
|
-
// ("source-navigation"), the ranges are computed here directly instead of
|
|
740
|
-
// through the file's queue of reads, three steps per segment. On Home
|
|
741
|
-
// Assistant those steps made a pass over a two-hour AVI take minutes while
|
|
742
|
-
// the arithmetic takes milliseconds (torrent-tv/meta#151).
|
|
743
|
-
const navigated = await containerOrchestrator.inspect(params, "source-navigation");
|
|
744
|
-
if (!map.isCurrent()) return [];
|
|
745
|
-
const direct = navigated.kind === "result" && navigated.value === true &&
|
|
746
|
-
typeof container?.readSourceRanges === "function" ? container : null;
|
|
747
|
-
// An output whose file-level reading is not there in this pass — the
|
|
748
|
-
// index or Cues that say where a segment is, not yet downloaded, or the
|
|
749
|
-
// table its segments read, refused memory — answers its other segments
|
|
750
|
-
// the same way. One unanswered request is kept to be retried when the
|
|
751
|
-
// bytes or the memory change, and the map is built again when it
|
|
752
|
-
// answers; asking every segment held the main thread for 3.3 s on a cold
|
|
753
|
-
// two-hour AVI (torrent-tv/meta#178).
|
|
754
|
-
const waiting = new Set();
|
|
755
|
-
const turn = takeTurns();
|
|
756
|
-
// ONLY THE SEGMENTS WHOSE RANGES ARE NOT KNOWN are worked out, in the
|
|
757
|
-
// order the swarm is to fetch them. Range resolution can use either a
|
|
758
|
-
// direct source index or the fallback reads below; yield between zones
|
|
759
|
-
// in both cases so a large map cannot hold the main thread across its
|
|
760
|
-
// full projection.
|
|
761
|
-
for (const zone of projection.unresolved()) {
|
|
762
|
-
if (!map.isCurrent()) return [];
|
|
763
|
-
await turn();
|
|
764
|
-
if (!map.isCurrent()) return [];
|
|
765
|
-
const resolutionStartedAt = performance.now();
|
|
766
|
-
const resolution = { outcome: "unresolved", method: null, trackCount: null, rangeCount: null, rangeBytes: null };
|
|
767
|
-
const resolutionId = `${map.fileIndex}:${zone.outputKey}:${zone.index}:${passStartedAt.toFixed(3)}`;
|
|
768
|
-
waitTrace.source(map.sourceKey, "process", { process: "P4.1", phase: "begin", resolutionId,
|
|
769
|
-
sourceId: `${map.sourceKey}:${map.fileIndex}`, fileIndex: map.fileIndex, outputKey: zone.outputKey,
|
|
770
|
-
segment: zone.index, priority: zone.priority, deadlineAt: zone.deadlineAt });
|
|
771
|
-
try {
|
|
772
|
-
const reader = readers.get(zone.outputKey);
|
|
773
|
-
if (!reader) {
|
|
774
|
-
resolution.outcome = "reader-missing";
|
|
775
|
-
continue;
|
|
776
|
-
}
|
|
777
|
-
// WHAT A SEGMENT READS IS WORKED OUT ONCE (`segmentRecordOf`). Rebuilt
|
|
778
|
-
// for every segment of the film on every map, this pass held the main
|
|
779
|
-
// thread up to 1.5 s at a time on Home Assistant (torrent-tv/meta#178).
|
|
780
|
-
const record = segmentRecordOf(reader.records, { index: zone.index, tracks: reader.choices }, reader.facts);
|
|
781
|
-
const reading = record.reading;
|
|
782
|
-
if (!reading) {
|
|
783
|
-
resolution.outcome = "segment-reading-unavailable";
|
|
784
|
-
projection.setUnreadable(zone.outputKey, zone.index);
|
|
785
|
-
continue;
|
|
786
|
-
}
|
|
787
|
-
const { wanted, modes, interval, rangesKey } = reading;
|
|
788
|
-
resolution.trackCount = wanted.length;
|
|
789
|
-
resolution.trackIds = wanted.map(track => track.trackNumber);
|
|
790
|
-
resolution.sourceInterval = { from: interval.from, to: interval.to };
|
|
791
|
-
const convert = (ranges, method) => {
|
|
792
|
-
if (record.ranges !== ranges || record.pieceLength !== pieceLength) {
|
|
793
|
-
record.ranges = ranges;
|
|
794
|
-
record.pieceLength = pieceLength;
|
|
795
|
-
record.joined = joinedWithinPieces(ranges, pieceLength);
|
|
796
|
-
}
|
|
797
|
-
const joined = record.joined;
|
|
798
|
-
resolution.method = method;
|
|
799
|
-
resolution.rangeCount = joined.length;
|
|
800
|
-
resolution.rangeBytes = joined.reduce((total, [start, end]) => total + end - start + 1, 0);
|
|
801
|
-
// What the segment reads is kept for the forecast in a table of its
|
|
802
|
-
// own, whatever form the torrent is told it in.
|
|
803
|
-
if (record.registered !== joined) {
|
|
804
|
-
segmentInputs.set(map.sourceKey, map.fileIndex, zone.outputKey, zone.index, joined);
|
|
805
|
-
record.registered = joined;
|
|
806
|
-
}
|
|
807
|
-
projection.setRanges(zone.outputKey, zone.index, joined);
|
|
808
|
-
resolution.outcome = "resolved";
|
|
809
|
-
resolved += 1;
|
|
810
|
-
};
|
|
811
|
-
// The bytes one segment needs are a fact of the file and the output, so
|
|
812
|
-
// they are worked out once. A whole film is thousands of segments and
|
|
813
|
-
// the map changes every few seconds; worked out afresh on every change,
|
|
814
|
-
// a pass over a two-hour AVI never finished and the next segment's bytes
|
|
815
|
-
// were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
|
|
816
|
-
const known = record.ranges ?? segmentSourceRanges.get(rangesKey);
|
|
817
|
-
if (known) { convert(known, record.ranges ? "segment-record-cache" : "source-range-cache"); continue; }
|
|
818
|
-
if (waiting.has(zone.outputKey)) {
|
|
819
|
-
resolution.outcome = "waiting-for-output-input";
|
|
820
|
-
continue;
|
|
821
|
-
}
|
|
822
|
-
// What is resolved so far is stated before a lower zone is worked out
|
|
823
|
-
// (DownloadMaps, torrent-tv/meta#178).
|
|
824
|
-
map.workingOut?.(zone.priority, () => projection.windows());
|
|
825
|
-
if (direct) {
|
|
826
|
-
// Asked per zone too: a soundtrack of an AVI is read from its own
|
|
827
|
-
// packets, which the same index states (torrent-tv/meta#166).
|
|
828
|
-
let value = null;
|
|
829
|
-
try {
|
|
830
|
-
value = await directSegmentInput(direct, { interval, tracks: wanted, mode: track => modes.get(track) },
|
|
831
|
-
built => refreshWhenBuilt(direct, built, map));
|
|
832
|
-
} catch { value = null; }
|
|
833
|
-
// A table still being built holds this output's segments, and only
|
|
834
|
-
// this output's: the map is built again when it is.
|
|
835
|
-
if (value?.kind === "pending") {
|
|
836
|
-
resolution.method = "direct-source-index";
|
|
837
|
-
resolution.outcome = "source-index-pending";
|
|
838
|
-
waiting.add(zone.outputKey);
|
|
839
|
-
continue;
|
|
840
|
-
}
|
|
841
|
-
if (value?.kind === "result") segmentSourceRanges.set(rangesKey, value.ranges);
|
|
842
|
-
if (value?.kind === "result") {
|
|
843
|
-
convert(value.ranges, "direct-source-index");
|
|
844
|
-
continue;
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
resolution.method = "container-index-fallback";
|
|
848
|
-
const intervalParams = await containerOver({ ...map, packetInterval: interval,
|
|
849
|
-
requestId: `download:${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`,
|
|
850
|
-
demand: { ...zone.owner, leadSeconds: zone.leadSeconds } });
|
|
851
|
-
if (!map.isCurrent()) {
|
|
852
|
-
resolution.outcome = "map-replaced";
|
|
853
|
-
return [];
|
|
854
|
-
}
|
|
855
|
-
if (!intervalParams) {
|
|
856
|
-
resolution.outcome = "source-file-unavailable";
|
|
857
|
-
continue;
|
|
858
|
-
}
|
|
859
|
-
const navigation = await containerOrchestrator.inspect(intervalParams, "source-navigation");
|
|
860
|
-
if (!map.isCurrent()) {
|
|
861
|
-
resolution.outcome = "map-replaced";
|
|
862
|
-
return [];
|
|
863
|
-
}
|
|
864
|
-
if (navigation.kind !== "result") {
|
|
865
|
-
resolution.outcome = `source-navigation-${navigation.kind}`;
|
|
866
|
-
if (navigation.kind === "needs-ranges" || navigation.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
867
|
-
continue;
|
|
868
|
-
}
|
|
869
|
-
const originalRanges = navigation.value;
|
|
870
|
-
const packets = await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
|
|
871
|
-
if (!map.isCurrent()) {
|
|
872
|
-
resolution.outcome = "map-replaced";
|
|
873
|
-
return [];
|
|
874
|
-
}
|
|
875
|
-
if (packets.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
876
|
-
if (packets.kind !== "result") {
|
|
877
|
-
resolution.outcome = `source-packets-${packets.kind}`;
|
|
878
|
-
continue;
|
|
879
|
-
}
|
|
880
|
-
const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
|
|
881
|
-
if (input.kind !== "result") {
|
|
882
|
-
resolution.outcome = `segment-input-${input.kind}`;
|
|
883
|
-
continue;
|
|
884
|
-
}
|
|
885
|
-
segmentSourceRanges.set(rangesKey, input.ranges);
|
|
886
|
-
convert(input.ranges, originalRanges ? "container-source-ranges" : "packet-index");
|
|
887
|
-
} catch (error) {
|
|
888
|
-
resolution.outcome = "error";
|
|
889
|
-
resolution.error = error?.message ?? String(error);
|
|
890
|
-
throw error;
|
|
891
|
-
} finally {
|
|
892
|
-
const resolutionEndedAt = performance.now();
|
|
893
|
-
waitTrace.source(map.sourceKey, "process", { process: "P4.1", phase: "end", resolutionId,
|
|
894
|
-
sourceId: `${map.sourceKey}:${map.fileIndex}`, fileIndex: map.fileIndex, outputKey: zone.outputKey,
|
|
895
|
-
segment: zone.index, priority: zone.priority,
|
|
896
|
-
startedAt: resolutionStartedAt, endedAt: resolutionEndedAt, durationMs: resolutionEndedAt - resolutionStartedAt,
|
|
897
|
-
...resolution });
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
const result = projection.windows();
|
|
901
|
-
notePass({ whole: reworked.whole, reworked: reworked.segments, resolved, windows: result.length,
|
|
902
|
-
ms: performance.now() - passStartedAt });
|
|
903
|
-
return result;
|
|
904
|
-
}
|
|
905
|
-
});
|
|
906
296
|
const mediaReads = new MediaReadRequests({
|
|
907
|
-
read: (params, statement) => statement === "subtitle-file" ? subtitles.inspectFile(params)
|
|
908
|
-
: statement === "subtitle-cues" ? subtitles.inspectPackets(params) : containerOrchestrator.inspect(params, statement),
|
|
297
|
+
read: (params, statement) => statement === "subtitle-file" ? subtitleWalk.subtitles.inspectFile(params)
|
|
298
|
+
: statement === "subtitle-cues" ? subtitleWalk.subtitles.inspectPackets(params) : containerOrchestrator.inspect(params, statement),
|
|
909
299
|
failed: (params, statement, error) => logger.warn(`media source=${params.sourceKey} file=${params.fileIndex} statement=${statement} retry failed: ${error?.message ?? error}`)
|
|
910
300
|
});
|
|
911
301
|
const probeReads = new ProbeRequests({
|
|
@@ -918,116 +308,41 @@ export async function startProxyServer({
|
|
|
918
308
|
});
|
|
919
309
|
const indexMemory = new IndexMemory({
|
|
920
310
|
growthBytes: LARGEST_GROWTH_BYTES,
|
|
921
|
-
reviseBudget: () => { void
|
|
922
|
-
changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged();
|
|
311
|
+
reviseBudget: () => { void machineBudget.revise("memory", "media metadata").catch(error => logger.warn(`packet index budget: ${error.message}`)); },
|
|
312
|
+
changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); encodeInputs?.memoryChanged(); }
|
|
313
|
+
});
|
|
314
|
+
const mediaParams = new MediaReadParams({
|
|
315
|
+
heldFile: (sourceKey, fileIndex) => {
|
|
316
|
+
if (!sourceRegistry.get(sourceKey)) return null;
|
|
317
|
+
const torrent = torrentPool.knownTorrent(sourceKey);
|
|
318
|
+
const file = torrent?.files?.[fileIndex];
|
|
319
|
+
if (!file || !(file.length > 0)) return null;
|
|
320
|
+
return { length: file.length, name: String(file.name ?? ""), offset: file.offset,
|
|
321
|
+
portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined,
|
|
322
|
+
readHeld: (start, end) => torrentPool.readHeldOf(torrent, fileIndex, start, end),
|
|
323
|
+
noteVideo: hasVideo => contentsOf(torrent).noteVideo(fileIndex, hasVideo) };
|
|
324
|
+
},
|
|
325
|
+
packetMemory: (sourceKey, fileIndex) => indexMemory.forFile(sourceKey, fileIndex),
|
|
326
|
+
readProbe: request => probeReads.read(request),
|
|
327
|
+
probePackets,
|
|
328
|
+
baseUrl: () => localBaseUrl,
|
|
329
|
+
observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
|
|
330
|
+
reads: { revision: (...a) => mediaReads.revision(...a), memoryRevision: () => mediaReads.memoryRevision(),
|
|
331
|
+
record: (...a) => mediaReads.record(...a) },
|
|
332
|
+
maps: { epoch: (...a) => downloadMaps.epoch(...a), wantsInterval: (...a) => downloadMaps.wantsInterval(...a),
|
|
333
|
+
metadata: entry => downloadMaps.metadata(entry), withdrawMetadata: (...a) => downloadMaps.withdrawMetadata(...a),
|
|
334
|
+
refresh: (...a) => downloadMaps.refresh(...a) },
|
|
335
|
+
learnKeyframes: (params, table) => keyframeTables.learn(params, table),
|
|
336
|
+
formatOf: (sourceKey, fileIndex) => containerOrchestrator.known(sourceKey, fileIndex)?.formatName ?? "unrecognised",
|
|
337
|
+
warn: line => logger.warn(line)
|
|
923
338
|
});
|
|
924
|
-
const encodeMetadataRequests = new WeakMap();
|
|
925
|
-
const containerOver = async ({ sourceKey, fileIndex, requestId, packetInterval, demand, readPriority, signal }) => {
|
|
926
|
-
const record = sourceRegistry.get(sourceKey);
|
|
927
|
-
if (!record) {
|
|
928
|
-
return null;
|
|
929
|
-
}
|
|
930
|
-
const torrent = torrentPool.knownTorrent(sourceKey);
|
|
931
|
-
const file = torrent?.files?.[fileIndex];
|
|
932
|
-
if (!file || !(file.length > 0)) {
|
|
933
|
-
return null;
|
|
934
|
-
}
|
|
935
|
-
let metadataTracker = null;
|
|
936
|
-
let trackersForSignal = null;
|
|
937
|
-
const trackerKey = `${sourceKey}:${fileIndex}`;
|
|
938
|
-
if (readPriority?.requestId && signal) {
|
|
939
|
-
trackersForSignal = encodeMetadataRequests.get(signal);
|
|
940
|
-
if (!trackersForSignal) encodeMetadataRequests.set(signal, trackersForSignal = new Map());
|
|
941
|
-
metadataTracker = trackersForSignal.get(trackerKey);
|
|
942
|
-
if (!metadataTracker) {
|
|
943
|
-
metadataTracker = { statements: new Set(), abort: null };
|
|
944
|
-
metadataTracker.abort = () => {
|
|
945
|
-
for (const statement of metadataTracker.statements) {
|
|
946
|
-
void downloadMaps.withdrawMetadata(sourceKey, fileIndex, statement).catch(error =>
|
|
947
|
-
logger.warn(`encode input demand withdrawal failed: ${error.message}`));
|
|
948
|
-
}
|
|
949
|
-
metadataTracker.statements.clear();
|
|
950
|
-
trackersForSignal.delete(trackerKey);
|
|
951
|
-
if (trackersForSignal.size === 0) encodeMetadataRequests.delete(signal);
|
|
952
|
-
};
|
|
953
|
-
trackersForSignal.set(trackerKey, metadataTracker);
|
|
954
|
-
if (signal.aborted) metadataTracker.abort();
|
|
955
|
-
else signal.addEventListener("abort", metadataTracker.abort, { once: true });
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
const params = {
|
|
959
|
-
sourceKey,
|
|
960
|
-
fileIndex,
|
|
961
|
-
requestId,
|
|
962
|
-
signal,
|
|
963
|
-
packetInterval,
|
|
964
|
-
readPriority,
|
|
965
|
-
packetMemory: indexMemory.forFile(sourceKey, fileIndex),
|
|
966
|
-
downloadInterval: demand ? { from: demand.from, to: demand.to } : null,
|
|
967
|
-
readRange: (start, end) =>
|
|
968
|
-
torrentPool.readHeldOf(torrent, fileIndex, start, Math.min(end, file.length - 1)),
|
|
969
|
-
fileSize: file.length,
|
|
970
|
-
probe: (statement, onRecord) => probeReads.read({ sourceKey, fileIndex, statement: `ffprobe:${statement}`,
|
|
971
|
-
probe: ({ requestId, signal }) => {
|
|
972
|
-
const url = new URL("/media", outputParts.localBaseUrl);
|
|
973
|
-
url.searchParams.set("sourceKey", sourceKey);
|
|
974
|
-
url.searchParams.set("fileIndex", String(fileIndex));
|
|
975
|
-
url.searchParams.set("readId", requestId);
|
|
976
|
-
return probePackets({ url: url.toString(), statement, signal, onRecord });
|
|
977
|
-
} }),
|
|
978
|
-
label: String(file.name ?? ""),
|
|
979
|
-
fileOffset: file.offset,
|
|
980
|
-
onObserved: fields => {
|
|
981
|
-
const process = fields.statement === "keyframes" ? "P5"
|
|
982
|
-
: String(fields.statement).startsWith("ffprobe:") ? "X1" : "P2";
|
|
983
|
-
const details = { process, fileIndex, requestId, statement: fields.statement, cached: fields.cached,
|
|
984
|
-
result: fields.result, reason: fields.reason, ranges: fields.ranges,
|
|
985
|
-
queuedMs: fields.queuedMs, readMs: fields.readMs };
|
|
986
|
-
waitTrace.source(sourceKey, "process", { ...details, phase: "begin", at: fields.askedAt });
|
|
987
|
-
waitTrace.source(sourceKey, "process", { ...details, phase: "end", at: fields.endedAt,
|
|
988
|
-
startedAt: fields.askedAt, endedAt: fields.endedAt });
|
|
989
|
-
},
|
|
990
|
-
onTracks: (tracks) => contentsOf(torrent).noteVideo(fileIndex, tracks.some((track) => track.type === "video")),
|
|
991
|
-
onReadStart: () => ({ storage: mediaReads.revision(sourceKey, fileIndex), memory: mediaReads.memoryRevision(), demand: downloadMaps.epoch(sourceKey, fileIndex) }),
|
|
992
|
-
onReadResult: async (statement, result, revision) => {
|
|
993
|
-
if (revision.demand !== downloadMaps.epoch(sourceKey, fileIndex)) return;
|
|
994
|
-
if (demand && !downloadMaps.wantsInterval(sourceKey, fileIndex, demand)) return;
|
|
995
|
-
if (statement === "keyframes" && result.kind === "result") {
|
|
996
|
-
keyframeTables.learn(params, { times: result.value?.times ?? null, tolerance: result.value?.tolerance ?? 0,
|
|
997
|
-
format: containerOrchestrator.known(sourceKey, fileIndex)?.formatName ?? "unrecognised" });
|
|
998
|
-
}
|
|
999
|
-
const finished = mediaReads.record(params, statement, result, revision.storage, revision.memory);
|
|
1000
|
-
if (statement === "chapters" && result.passive) await downloadMaps.withdrawMetadata(sourceKey, fileIndex, statement);
|
|
1001
|
-
else {
|
|
1002
|
-
const metadataStatement = requestId ? `${statement}:${requestId}` : statement;
|
|
1003
|
-
if (metadataTracker && result.kind === "needs-ranges") metadataTracker.statements.add(metadataStatement);
|
|
1004
|
-
if (metadataTracker && result.kind !== "needs-ranges") metadataTracker.statements.delete(metadataStatement);
|
|
1005
|
-
await downloadMaps.metadata({ sourceKey, fileIndex, statement: metadataStatement, result,
|
|
1006
|
-
...(statement === "chapters" ? { priority: 1, urgent: false, deadlineAt: null } : {}),
|
|
1007
|
-
...(demand ? { priority: demand.priority, urgent: demand.urgent, deadlineAt: demand.deadlineAt, interval: demand, leadSeconds: demand.leadSeconds ?? 0 } : {}),
|
|
1008
|
-
...(readPriority?.requestId ? { priority: readPriority.rank, urgent: readPriority.urgent === true,
|
|
1009
|
-
deadlineAt: Number.isFinite(readPriority.deadlineAt) ? readPriority.deadlineAt : Number.POSITIVE_INFINITY,
|
|
1010
|
-
scope: "preparation" } : {}),
|
|
1011
|
-
// Neither a viewer's interval nor an encode input asked for it: the
|
|
1012
|
-
// file read itself, and that read is wanted while the file is.
|
|
1013
|
-
...(!demand && !readPriority?.requestId ? { owner: "file" } : {}) });
|
|
1014
|
-
}
|
|
1015
|
-
if (finished && result.kind === "result") {
|
|
1016
|
-
void downloadMaps.refresh(sourceKey, fileIndex).catch(error =>
|
|
1017
|
-
logger.warn(`download map source=${sourceKey} file=${fileIndex} metadata refresh failed: ${error?.message ?? error}`));
|
|
1018
|
-
}
|
|
1019
|
-
},
|
|
1020
|
-
portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined
|
|
1021
|
-
};
|
|
1022
|
-
return params;
|
|
1023
|
-
};
|
|
1024
339
|
const keyframeTables = new KeyframeTables({
|
|
1025
340
|
// Read by the same container that answers the track table and the media
|
|
1026
341
|
// info, from the same header, and now in the same thread as the session
|
|
1027
342
|
// that is waiting for it.
|
|
1028
343
|
readTable: async ({ sourceKey, fileIndex }) => {
|
|
1029
344
|
// Both edges: the Cues of a Matroska file are at the end.
|
|
1030
|
-
const params = await
|
|
345
|
+
const params = await mediaParams.paramsFor({ sourceKey, fileIndex, tailBytes: CONTAINER_HEAD_BYTES });
|
|
1031
346
|
if (!params) {
|
|
1032
347
|
return null;
|
|
1033
348
|
}
|
|
@@ -1043,17 +358,19 @@ export async function startProxyServer({
|
|
|
1043
358
|
};
|
|
1044
359
|
}
|
|
1045
360
|
});
|
|
1046
|
-
|
|
361
|
+
const encodeInputResolution = new EncodeInputResolution({
|
|
362
|
+
paramsFor: request => mediaParams.paramsFor(request), sourceRanges,
|
|
363
|
+
selectTracks, readingInterval, trackFor,
|
|
364
|
+
declaredTracks: (sourceKey, fileIndex) => containerOrchestrator.tracks.get(`${sourceKey}:${fileIndex}`),
|
|
365
|
+
known: (sourceKey, fileIndex) => containerOrchestrator.known(sourceKey, fileIndex),
|
|
366
|
+
outputs: () => outputs.values(), // lazy: the catalog of the graph built next
|
|
367
|
+
log: line => logger.info(line), warn: line => logger.warn(line)
|
|
368
|
+
});
|
|
1047
369
|
const outputParts = wireOutputs({
|
|
1048
370
|
observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
|
|
1049
371
|
pinnedPreset,
|
|
1050
372
|
indexMemory,
|
|
1051
373
|
readMetadataActivity: () => containerOrchestrator.activity(),
|
|
1052
|
-
onViewerChanged: () => {
|
|
1053
|
-
void sourcePreparation?.refresh();
|
|
1054
|
-
void downloadMaps.viewersChanged().catch(error => logger.warn(`download maps after a viewer change: ${error.message}`));
|
|
1055
|
-
onStateChanged();
|
|
1056
|
-
},
|
|
1057
374
|
onEncodersChanged: () => onStateChanged(),
|
|
1058
375
|
sourceInputsFor: (output, index) => segmentInputs.inputsFor(output.file.sourceKey, output.outputKey, index),
|
|
1059
376
|
readSourceMedia: async (params) => {
|
|
@@ -1065,143 +382,9 @@ export async function startProxyServer({
|
|
|
1065
382
|
error.canRetry = true;
|
|
1066
383
|
throw error;
|
|
1067
384
|
},
|
|
1068
|
-
|
|
1069
|
-
// the reads asked before it and how long it took (torrent-tv/meta#166).
|
|
1070
|
-
resolveEncodeInput: async (output, fromIndex, toIndex, timing = null, isCurrent = null, signal = null, readPriority = null) => {
|
|
1071
|
-
const grid = output.timeline?.published ?? output.timeline?.boundaries;
|
|
1072
|
-
const from = grid?.[fromIndex], to = grid?.[toIndex + 1];
|
|
1073
|
-
if (!Number.isFinite(from) || !(to > from)) return { kind: "terminal", reason: "output-interval-not-declared" };
|
|
1074
|
-
const selected = new Map();
|
|
1075
|
-
for (const type of ["video", "audio"]) {
|
|
1076
|
-
const spec = output.spec[type];
|
|
1077
|
-
if (!spec) continue;
|
|
1078
|
-
const list = selected.get(spec.fileIndex) ?? [];
|
|
1079
|
-
list.push({ type, index: type === "video" ? null : spec.trackIndex,
|
|
1080
|
-
mode: type === "video" ? spec.encode ? "transcode" : "copy" : spec.transcode ? "transcode" : "copy" });
|
|
1081
|
-
selected.set(spec.fileIndex, list);
|
|
1082
|
-
}
|
|
1083
|
-
// Every selected file is read the same way in one run: from the original
|
|
1084
|
-
// file when each container can name the bytes the interval needs, from
|
|
1085
|
-
// reassembled packets otherwise.
|
|
1086
|
-
const files = [];
|
|
1087
|
-
for (const [fileIndex, choices] of selected) {
|
|
1088
|
-
const params = await containerOver({ sourceKey: output.file.sourceKey, fileIndex,
|
|
1089
|
-
requestId: readPriority?.requestId, readPriority, signal });
|
|
1090
|
-
if (!params) return { kind: "needs-source" };
|
|
1091
|
-
if (isCurrent) params.isCurrent = isCurrent;
|
|
1092
|
-
if (signal) params.signal = signal;
|
|
1093
|
-
if (timing) {
|
|
1094
|
-
params.walkAccount = timing.walk ??= { lockMs: 0, reads: 0, bytes: 0 };
|
|
1095
|
-
params.onTimed = (statement, waitedMs, readMs) => {
|
|
1096
|
-
timing.reads += 1;
|
|
1097
|
-
timing.queuedMs += waitedMs;
|
|
1098
|
-
timing.readMs += readMs;
|
|
1099
|
-
const measured = timing.readsByStatement[statement] ??= { queuedMs: 0, readMs: 0 };
|
|
1100
|
-
measured.queuedMs += waitedMs;
|
|
1101
|
-
measured.readMs += readMs;
|
|
1102
|
-
};
|
|
1103
|
-
}
|
|
1104
|
-
const tracks = await containerOrchestrator.inspect(params, "tracks");
|
|
1105
|
-
if (tracks.kind !== "result") return tracks;
|
|
1106
|
-
const container = containerOrchestrator.known(params.sourceKey, fileIndex);
|
|
1107
|
-
if (typeof container?.readPacketIndex !== "function" && typeof container?.readSourceRanges !== "function") {
|
|
1108
|
-
return { kind: "needs-index", reason: "packet-index-not-yet-available" };
|
|
1109
|
-
}
|
|
1110
|
-
const modes = new Map(), wanted = [];
|
|
1111
|
-
for (const choice of choices) {
|
|
1112
|
-
// The picture is the first usable video track (torrent-tv/meta#49);
|
|
1113
|
-
// a soundtrack keeps the number every list of them uses.
|
|
1114
|
-
const track = choice.type === "video" ? ContainerTrack.firstUsable(tracks.value, "video")
|
|
1115
|
-
: tracks.value.filter(track => track.type === choice.type)[choice.index];
|
|
1116
|
-
if (!track) return { kind: "terminal", reason: "selected-track-is-absent" };
|
|
1117
|
-
wanted.push(track); modes.set(track, choice.mode);
|
|
1118
|
-
}
|
|
1119
|
-
const media = await containerOrchestrator.inspect(params, "media-info");
|
|
1120
|
-
if (media.kind !== "result") return media;
|
|
1121
|
-
const timeShiftSeconds = Number(media.value?.startTimeSeconds) || 0;
|
|
1122
|
-
const sourceInterval = output.timeline.sourceInterval(fromIndex, toIndex, timeShiftSeconds);
|
|
1123
|
-
params.packetInterval = { ...sourceInterval,
|
|
1124
|
-
trackIds: wanted.map(track => track.trackNumber),
|
|
1125
|
-
modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
|
|
1126
|
-
const navigation = await containerOrchestrator.inspect(params, "source-navigation");
|
|
1127
|
-
if (navigation.kind !== "result") return navigation;
|
|
1128
|
-
files.push({ fileIndex, choices, params, wanted, modes, timeShiftSeconds, sourceInterval, navigable: navigation.value === true });
|
|
1129
|
-
}
|
|
1130
|
-
const original = output.segmentFormat.supportsOriginalInput === true && files.every(file => file.navigable);
|
|
1131
|
-
const sources = [];
|
|
1132
|
-
for (const { fileIndex, choices, params, wanted, modes, timeShiftSeconds, sourceInterval } of files) {
|
|
1133
|
-
if (original) {
|
|
1134
|
-
const ranges = await containerOrchestrator.inspect(params, "source-ranges");
|
|
1135
|
-
if (ranges.kind !== "result") return ranges;
|
|
1136
|
-
sources.push({ sourceKey: params.sourceKey, fileIndex, timeShiftSeconds,
|
|
1137
|
-
input: { ...ranges.value, original: true, selections: wanted.map(track => ({ track,
|
|
1138
|
-
index: track.declaredIndex ?? choices.find(choice => choice.type === track.type)?.index ?? 0 })) } });
|
|
1139
|
-
continue;
|
|
1140
|
-
}
|
|
1141
|
-
const packets = await containerOrchestrator.inspect(params, "packets");
|
|
1142
|
-
if (packets.kind !== "result") return packets;
|
|
1143
|
-
const input = new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({
|
|
1144
|
-
...sourceInterval, mode: track => modes.get(track) });
|
|
1145
|
-
if (input.kind !== "result") return input;
|
|
1146
|
-
sources.push({ sourceKey: params.sourceKey, fileIndex, input, timeShiftSeconds });
|
|
1147
|
-
}
|
|
1148
|
-
return { kind: "result", sources };
|
|
1149
|
-
},
|
|
1150
|
-
// THE SOUND'S PACKETS FROM THE PICTURE'S COPY. A Matroska soundtrack is
|
|
1151
|
-
// fed to its encoder packet by packet, and finding its packets walks the
|
|
1152
|
-
// clusters the Cues name for the piece — picture and sound together,
|
|
1153
|
-
// about 20 MB for 0.3 MB of sound on the field's 1080p film. The picture's
|
|
1154
|
-
// input copies those same clusters through the torrent thread first, and
|
|
1155
|
-
// walked a second time there each soundtrack piece took 6-11 s, slower
|
|
1156
|
-
// than the film plays (r61, torrent-tv/meta#178). Walked from the copy
|
|
1157
|
-
// while it is held, the sound reads only its own packets later.
|
|
1158
|
-
stretchCopied: (output, from, to, input) => {
|
|
1159
|
-
for (const source of input.sources ?? []) {
|
|
1160
|
-
const container = containerOrchestrator.known(source.sourceKey, source.fileIndex);
|
|
1161
|
-
const tracks = containerOrchestrator.tracks.get(`${source.sourceKey}:${source.fileIndex}`);
|
|
1162
|
-
if (typeof container?.learnPackets !== "function" || !Array.isArray(tracks)) continue;
|
|
1163
|
-
const carried = new Set((source.input.selections ?? []).map(selection => selection.track?.trackNumber));
|
|
1164
|
-
const sounds = new Set();
|
|
1165
|
-
for (const other of outputParts.outputs.values()) {
|
|
1166
|
-
const sound = other.spec?.audio;
|
|
1167
|
-
if (other.spec?.video || !sound || other.file?.sourceKey !== source.sourceKey || sound.fileIndex !== source.fileIndex) continue;
|
|
1168
|
-
const track = tracks.filter(candidate => candidate.type === "audio")[sound.trackIndex];
|
|
1169
|
-
if (track && !carried.has(track.trackNumber)) sounds.add(track.trackNumber);
|
|
1170
|
-
}
|
|
1171
|
-
if (!sounds.size || !Number.isFinite(source.input.from) || !(source.input.to > source.input.from)) continue;
|
|
1172
|
-
const read = (start, end) => {
|
|
1173
|
-
const answer = input.read(source.fileIndex, start, end);
|
|
1174
|
-
return answer && typeof answer.then !== "function" && answer.end === end ? answer.bytes : null;
|
|
1175
|
-
};
|
|
1176
|
-
const holdsUntil = start => {
|
|
1177
|
-
if (input.heldUntil) return input.heldUntil(source.fileIndex, start);
|
|
1178
|
-
const range = source.input.ranges.find(([first, last]) => first <= start && start <= last);
|
|
1179
|
-
return range ? range[1] + 1 : null;
|
|
1180
|
-
};
|
|
1181
|
-
const startedAt = Date.now();
|
|
1182
|
-
const releaseInput = input.retain?.();
|
|
1183
|
-
void container.learnPackets({ from: source.input.from, to: source.input.to, trackIds: [...sounds] }, read, holdsUntil).then(learned =>
|
|
1184
|
-
logger.info(`encode input #${from}..#${to} of ${output.outputKey}: soundtrack packets of track(s) ${[...sounds].join(",")} ` +
|
|
1185
|
-
`over ${source.input.from.toFixed(3)}-${source.input.to.toFixed(3)}s ` +
|
|
1186
|
-
(learned === "walked" ? "walked" : `not all walked (${learned === "memory" ? "memory refused" : "the copy does not hold the rest"})`) +
|
|
1187
|
-
` from its copy in ${Date.now() - startedAt}ms`),
|
|
1188
|
-
error => logger.warn(`encode input #${from}..#${to} of ${output.outputKey}: walking the soundtrack from its copy failed: ${error?.message ?? error}`))
|
|
1189
|
-
.finally(() => releaseInput?.());
|
|
1190
|
-
}
|
|
1191
|
-
},
|
|
385
|
+
encodeInputResolution,
|
|
1192
386
|
repriceEncodeDemand: demand => downloadMaps.repriceRequest(demand.requestId, demand),
|
|
1193
|
-
|
|
1194
|
-
const torrent = torrentPool.knownTorrent(source.sourceKey);
|
|
1195
|
-
return torrent ? torrentPool.readHeldRangesOf(torrent, source.fileIndex, ranges, maxBytes) : null;
|
|
1196
|
-
},
|
|
1197
|
-
openEncodeWholeFile: source => torrentPool.acquireWholeFile(source),
|
|
1198
|
-
// Which bytes of a file are downloaded whole now — what an original
|
|
1199
|
-
// input's stretch may extend over. A snapshot: the copy that follows
|
|
1200
|
-
// holds the pieces it reads, and refuses where one has gone since.
|
|
1201
|
-
readEncodeHeldRanges: async (source) => {
|
|
1202
|
-
const torrent = torrentPool.knownTorrent(source.sourceKey);
|
|
1203
|
-
return torrent ? torrentPool.heldRangesOf(torrent, source.fileIndex) : [];
|
|
1204
|
-
},
|
|
387
|
+
...torrentPoolPorts({ torrentPool, sourceRegistry, wholeFilesRoot: completedFilesRoot() }),
|
|
1205
388
|
enabled: transcodeAudio,
|
|
1206
389
|
keyframeTables,
|
|
1207
390
|
ffmpegBin,
|
|
@@ -1222,50 +405,12 @@ export async function startProxyServer({
|
|
|
1222
405
|
// CPU-bound transcode from a download-starved input before downscaling.
|
|
1223
406
|
// What every torrent here has moved, so the proxy can price its own
|
|
1224
407
|
// downloading, hashing and delivery against the machine (roadmap item 7).
|
|
1225
|
-
// What the spilled pieces weigh on the torrent thread, and how to tell them
|
|
1226
|
-
// their share of the disk. The owner of the disk is on this side, where the
|
|
1227
|
-
// segments are; the pieces are on the other.
|
|
1228
|
-
spillDisk: typeof torrentPool.allowSpillBytes === "function"
|
|
1229
|
-
? {
|
|
1230
|
-
held: () => torrentPool.spilledBytes ?? 0,
|
|
1231
|
-
allow: (bytes) => torrentPool.allowSpillBytes(bytes)
|
|
1232
|
-
}
|
|
1233
|
-
: null,
|
|
1234
|
-
// Files downloaded whole, kept as files. They live on the torrent thread
|
|
1235
|
-
// too, and until 2026-09-14 they had no bound of any kind — a 2.8 GB film
|
|
1236
|
-
// on a host whose disk is often a 32 GB card.
|
|
1237
|
-
wholeFiles: typeof torrentPool.allowWholeFileBytes === "function"
|
|
1238
|
-
? {
|
|
1239
|
-
held: () => torrentPool.wholeFileBytes ?? 0,
|
|
1240
|
-
required: () => torrentPool.wholeFileRequiredBytes ?? 0,
|
|
1241
|
-
allow: (bytes) => torrentPool.allowWholeFileBytes(bytes),
|
|
1242
|
-
root: completedFilesRoot()
|
|
1243
|
-
}
|
|
1244
|
-
: null,
|
|
1245
|
-
// The pieces held IN memory, on the torrent thread. Told their share by the
|
|
1246
|
-
// same owner that divides the disk: what does not fit here is spilled
|
|
1247
|
-
// there, so the two cannot be divided apart.
|
|
1248
|
-
memoryClaimant: typeof torrentPool.allowMemoryBytes === "function"
|
|
1249
|
-
? {
|
|
1250
|
-
held: () => torrentPool.memoryClaim?.held ?? 0,
|
|
1251
|
-
wanted: () => torrentPool.memoryClaim?.wanted ?? 0,
|
|
1252
|
-
allow: (bytes) => torrentPool.allowMemoryBytes(bytes)
|
|
1253
|
-
}
|
|
1254
|
-
: null,
|
|
1255
408
|
diagnosticsRoot: stateDir || "",
|
|
1256
409
|
budgetPolicy,
|
|
1257
|
-
getTorrentTotals: async () => {
|
|
1258
|
-
if (typeof torrentPool.getTorrentTotals !== "function") {
|
|
1259
|
-
return null;
|
|
1260
|
-
}
|
|
1261
|
-
return torrentPool.getTorrentTotals();
|
|
1262
|
-
},
|
|
1263
410
|
// The priority map, on its way to the downloading. It lives in another
|
|
1264
411
|
// thread, so the map crosses the worker channel; what it does with it —
|
|
1265
412
|
// seconds into bytes, what to ask the swarm for, what to keep in memory —
|
|
1266
413
|
// is its own business.
|
|
1267
|
-
setDownloadRate: (bytesPerSecond, because) => torrentPool.setDownloadRate?.(bytesPerSecond, because),
|
|
1268
|
-
setSpeculativeHold: (held, because) => torrentPool.setSpeculativeHold?.(held, because),
|
|
1269
414
|
setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
|
|
1270
415
|
const record = sourceRegistry.get(sourceKey);
|
|
1271
416
|
if (!record) {
|
|
@@ -1274,15 +419,15 @@ export async function startProxyServer({
|
|
|
1274
419
|
try {
|
|
1275
420
|
if (zones.length === 0) {
|
|
1276
421
|
mediaReads.retain(sourceKey, fileIndex, params => params.requestId?.startsWith("prepare:"));
|
|
1277
|
-
if (!
|
|
422
|
+
if (!viewers.forSource(sourceKey).length) probeReads.forget(sourceKey, fileIndex);
|
|
1278
423
|
await downloadMaps.forget(sourceKey, fileIndex, { keepPreparation: true });
|
|
1279
|
-
|
|
1280
|
-
void sourcePreparation
|
|
424
|
+
resolution.clear(sourceKey, fileIndex);
|
|
425
|
+
void sourcePreparation.refresh();
|
|
1281
426
|
return;
|
|
1282
427
|
}
|
|
1283
428
|
await publishPlaybackBeforePreparationRefresh(
|
|
1284
429
|
() => downloadMaps.playback({ sourceKey, fileIndex, durationSeconds, zones }),
|
|
1285
|
-
() => { void sourcePreparation
|
|
430
|
+
() => { void sourcePreparation.refresh(); }
|
|
1286
431
|
);
|
|
1287
432
|
mediaReads.retain(sourceKey, fileIndex, params => !params.requestId?.startsWith("download:") ||
|
|
1288
433
|
zones.some(zone => zone.from === params.downloadInterval?.from && zone.to === params.downloadInterval?.to));
|
|
@@ -1290,21 +435,6 @@ export async function startProxyServer({
|
|
|
1290
435
|
logger.warn(`download map source=${sourceKey} file=${fileIndex} publication failed: ${error?.message ?? error}`);
|
|
1291
436
|
}
|
|
1292
437
|
},
|
|
1293
|
-
getSourceStats: async (sourceKey, fileIndex, options) => {
|
|
1294
|
-
const record = sourceRegistry.get(sourceKey);
|
|
1295
|
-
if (!record) {
|
|
1296
|
-
return null;
|
|
1297
|
-
}
|
|
1298
|
-
try {
|
|
1299
|
-
const torrent = torrentPool.knownTorrent(sourceKey);
|
|
1300
|
-
if (!torrent) return null;
|
|
1301
|
-
// Awaited for the same reason as the stats route: this now crosses a
|
|
1302
|
-
// thread boundary and returns a promise.
|
|
1303
|
-
return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null, options);
|
|
1304
|
-
} catch {
|
|
1305
|
-
return null;
|
|
1306
|
-
}
|
|
1307
|
-
},
|
|
1308
438
|
// Reuse the media info the planner already probed for this file (same
|
|
1309
439
|
// ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
|
|
1310
440
|
// only at session-create time, after playbackPlanner is initialised.
|
|
@@ -1319,35 +449,55 @@ export async function startProxyServer({
|
|
|
1319
449
|
// bytes, and that read cost 8.1 s of every cold start (field 2026-09-03).
|
|
1320
450
|
getContainerMediaInfo: async ({ sourceKey, fileIndex }) => {
|
|
1321
451
|
try {
|
|
1322
|
-
const params = await
|
|
452
|
+
const params = await mediaParams.paramsFor({ sourceKey, fileIndex });
|
|
1323
453
|
return params ? await containerOrchestrator.getMediaInfo(params) : null;
|
|
1324
454
|
} catch {
|
|
1325
455
|
return null;
|
|
1326
456
|
}
|
|
1327
|
-
},
|
|
1328
|
-
// Pull one whole file onto the disk. Used for a soundtrack that ships beside
|
|
1329
|
-
// the picture, once the encoder is as far ahead of the viewer as it is
|
|
1330
|
-
// allowed to get — the one moment the swarm's capacity is demonstrably
|
|
1331
|
-
// spare. A bounded read of the file's whole length, NOT `file.select()`:
|
|
1332
|
-
// selecting a file alongside the readers' own windows is what made a seek
|
|
1333
|
-
// wait 93 s while the swarm fetched 2.47 GB in file order (see
|
|
1334
|
-
// `#syncSelections` in `torrent/torrent-pool.js`).
|
|
1335
|
-
fetchWholeFile: async ({ sourceKey, fileIndex }) => {
|
|
1336
|
-
const record = sourceRegistry.get(sourceKey);
|
|
1337
|
-
if (!record) {
|
|
1338
|
-
return;
|
|
1339
|
-
}
|
|
1340
|
-
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
1341
|
-
// The same background fill the warm-up starts when a file is chosen, not a
|
|
1342
|
-
// second way of doing it. It is guarded against running twice on one file,
|
|
1343
|
-
// so the two triggers converge instead of putting two readers on the same
|
|
1344
|
-
// soundtrack — and only one of them would have stood aside for the
|
|
1345
|
-
// picture. This trigger remains for the session that never had a warm-up
|
|
1346
|
-
// before it.
|
|
1347
|
-
await torrentPool.fillFileInBackground?.(torrent, fileIndex);
|
|
1348
457
|
}
|
|
1349
458
|
});
|
|
1350
|
-
|
|
459
|
+
const { viewers, viewerRequests, outputs, encodeRuns, serving, renditions, encodeInputs, quality,
|
|
460
|
+
lifecycle, outputTimes, machineBudget, lookaheadSeconds, segmentDurationSec, localBaseUrl,
|
|
461
|
+
encodeOrchestrator, playedOutputsOf } = outputParts;
|
|
462
|
+
/**
|
|
463
|
+
* The download map's pass: what the torrent is told about the file a viewer
|
|
464
|
+
* plays, worked out from the viewers' map (torrent-tv/meta#273).
|
|
465
|
+
*/
|
|
466
|
+
const resolution = new PlaybackResolution({
|
|
467
|
+
paramsFor: request => mediaParams.paramsFor(request),
|
|
468
|
+
sourceRanges,
|
|
469
|
+
segmentReading,
|
|
470
|
+
playedOutputsOf,
|
|
471
|
+
pieceLengthOf: sourceKey => Number(torrentPool.knownTorrent(sourceKey)?.pieceLength),
|
|
472
|
+
noteSegmentInput: (...args) => segmentInputs.set(...args),
|
|
473
|
+
refresh: (sourceKey, fileIndex) => downloadMaps.refresh(sourceKey, fileIndex),
|
|
474
|
+
observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
|
|
475
|
+
log: line => logger.info(line),
|
|
476
|
+
warn: line => logger.warn(line)
|
|
477
|
+
});
|
|
478
|
+
const downloadMaps = new DownloadMaps({
|
|
479
|
+
// The ranges a picture reads at open are named again by every segment;
|
|
480
|
+
// only the zone the swarm would rank first is sent (torrent-tv/meta#166).
|
|
481
|
+
// Left out of what is SENT and nowhere else: every segment's own zones are
|
|
482
|
+
// also what says which bytes that segment needs, and a segment whose zones
|
|
483
|
+
// all repeat another's — two of one group of pictures in an AVI read the
|
|
484
|
+
// same bytes — lost them with 2.96.11, and the start forecast answered
|
|
485
|
+
// "source-input-ranges-unavailable" for ever (Home Assistant 2026-10-11).
|
|
486
|
+
publish: (map) => torrentPool.setPriorityMap({ ...map, zones: withoutRepeatedZones(map.zones) }),
|
|
487
|
+
// And what is sent is packed: a stretch of one level and priority is one
|
|
488
|
+
// window, not one zone per range of each of its segments
|
|
489
|
+
// (`coalescedZones`, torrent-tv/meta#178). The playback zones come packed
|
|
490
|
+
// already from the projection; this packs them with the native ones.
|
|
491
|
+
pack: (zones) => coalescedZones(zones),
|
|
492
|
+
log: (line) => logger.info(line),
|
|
493
|
+
observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
|
|
494
|
+
// A file's own reads are wanted while a present viewer has the file chosen
|
|
495
|
+
// or on screen.
|
|
496
|
+
fileWanted: (sourceKey, fileIndex) => viewers.forSource(sourceKey)
|
|
497
|
+
.some(viewer => viewer.source?.selectedFileIndex === fileIndex ||
|
|
498
|
+
viewer.source?.visibleFileIndices?.includes(fileIndex)),
|
|
499
|
+
resolvePlayback: map => resolution.resolve(map)
|
|
500
|
+
});
|
|
1351
501
|
// Where the processor goes, and at a cold start what the main thread does
|
|
1352
502
|
// with it, when the operator asks (torrent-tv/meta#177).
|
|
1353
503
|
const startupProfile = startupCpuProfile({
|
|
@@ -1359,8 +509,8 @@ export async function startProxyServer({
|
|
|
1359
509
|
});
|
|
1360
510
|
startProcessorReport({
|
|
1361
511
|
log: (line) => logger.info(line),
|
|
1362
|
-
ffmpegRuns: () => [...
|
|
1363
|
-
.flatMap((output) =>
|
|
512
|
+
ffmpegRuns: () => [...outputs.values()]
|
|
513
|
+
.flatMap((output) => encodeRuns.liveRunsOf(output).map((run) => ({
|
|
1364
514
|
pid: run.process?.pid,
|
|
1365
515
|
label: `${output.outputKey} #${run.from}..#${run.to}`,
|
|
1366
516
|
film: Number(run.progress?.processedSeconds) - Number(run.progress?.startPositionSeconds ?? 0)
|
|
@@ -1368,7 +518,7 @@ export async function startProxyServer({
|
|
|
1368
518
|
.filter((run) => Number.isInteger(run.pid)),
|
|
1369
519
|
detailed: () => startupProfile.active(),
|
|
1370
520
|
// What the pause rule judges from (torrent-tv/meta#180, phase 4).
|
|
1371
|
-
onRunSeconds: (seconds) =>
|
|
521
|
+
onRunSeconds: (seconds) => encodeOrchestrator?.noteRunSeconds(seconds)
|
|
1372
522
|
});
|
|
1373
523
|
// What the last life of this process left on the disk. The kernel kills this
|
|
1374
524
|
// one often enough for that to be an ordinary state rather than an odd one —
|
|
@@ -1377,20 +527,24 @@ export async function startProxyServer({
|
|
|
1377
527
|
// record that those encoders ended at all: it says what it found before it
|
|
1378
528
|
// decides anything, keeps the segments whose closure is proven, and removes
|
|
1379
529
|
// the one piece per output that was being written when the process died.
|
|
1380
|
-
outputParts
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
530
|
+
startPlayback({ outputParts, downloadMaps, preparation: () => sourcePreparation, onStateChanged,
|
|
531
|
+
warn: line => logger.warn(line) });
|
|
532
|
+
const preparationReads = new PreparationReads({
|
|
533
|
+
paramsFor: request => mediaParams.paramsFor(request),
|
|
534
|
+
containers: containerOrchestrator,
|
|
535
|
+
subtitles: subtitleWalk.subtitles,
|
|
536
|
+
fileLengthOf: (sourceKey, fileIndex) => torrentPool.knownTorrent(sourceKey)?.files?.[fileIndex]?.length,
|
|
537
|
+
viewersOf: sourceKey => viewers.forSource(sourceKey),
|
|
538
|
+
segmentSeconds: segmentDurationSec,
|
|
539
|
+
preparationInterval,
|
|
540
|
+
segmentInputs: input => new SegmentInputs(input),
|
|
541
|
+
nativeMaps: { packets: input => nativeSourceMap(input), original: input => nativeOriginalSourceMap(input) },
|
|
542
|
+
maps: downloadMaps,
|
|
543
|
+
record: (...args) => mediaReads.record(...args),
|
|
544
|
+
planEncodersSoon: () => encodeRuns.planEncodersSoon()
|
|
545
|
+
});
|
|
546
|
+
const sourcePreparation = new SourcePreparation({
|
|
547
|
+
viewers,
|
|
1394
548
|
candidatesFor: async sourceKey => {
|
|
1395
549
|
const record = sourceRegistry.get(sourceKey);
|
|
1396
550
|
if (!record) return [];
|
|
@@ -1413,15 +567,15 @@ export async function startProxyServer({
|
|
|
1413
567
|
priorityFor: (viewer, priority, work) => {
|
|
1414
568
|
if (!sourcePreparation.subtitleReadyFor(viewer)) return priority;
|
|
1415
569
|
const fileIndex = work.ownerFileIndex ?? work.fileIndex;
|
|
1416
|
-
const output = [...
|
|
1417
|
-
output.file?.fileIndex === fileIndex &&
|
|
1418
|
-
!
|
|
1419
|
-
const viewerCount =
|
|
570
|
+
const output = [...outputs.values()].find(output => output.file?.sourceKey === work.sourceKey &&
|
|
571
|
+
output.file?.fileIndex === fileIndex && viewers.forOutput(output).has(viewer.id) &&
|
|
572
|
+
!outputs.supersededBy(output, viewer.activeVariantId ?? null));
|
|
573
|
+
const viewerCount = viewers.forSource(work.sourceKey)
|
|
1420
574
|
.filter(person => person.source.selectedFileIndex === fileIndex).length;
|
|
1421
575
|
if (!output) {
|
|
1422
576
|
const coefficient = pauseCoefficient({ playing: viewer.playing || viewer.waiting, viewerCount,
|
|
1423
577
|
pauseSeconds: viewer.pausedAt === null ? 0 : (Date.now() - viewer.pausedAt) / 1000,
|
|
1424
|
-
allowanceSeconds:
|
|
578
|
+
allowanceSeconds: segmentDurationSec,
|
|
1425
579
|
urgentReady: sourcePreparation.urgentReadyFor(viewer) });
|
|
1426
580
|
return Math.max(1, 1 + Math.floor((priority - 1) * coefficient));
|
|
1427
581
|
}
|
|
@@ -1433,107 +587,13 @@ export async function startProxyServer({
|
|
|
1433
587
|
const held = await torrentPool.heldRangesOf(torrent, work.fileIndex);
|
|
1434
588
|
return work.inputRanges.every(([start, end]) => held.some(([from, to]) => from <= start && to >= end));
|
|
1435
589
|
},
|
|
1436
|
-
sourceRestNeededFor: (sourceKey, fileIndex) => ![...
|
|
590
|
+
sourceRestNeededFor: (sourceKey, fileIndex) => ![...outputs.values()].some(output =>
|
|
1437
591
|
output.file?.sourceKey === sourceKey && output.file?.fileIndex === fileIndex &&
|
|
1438
|
-
|
|
592
|
+
viewers.forOutput(output).size > 0),
|
|
1439
593
|
playbackAppliedFor: (sourceKey, fileIndex) => downloadMaps.hasAppliedPlayback(sourceKey, fileIndex),
|
|
1440
594
|
readyChanged: (work, ready) => logger.info(`source urgent request=${work.requestId} viewer=${work.ownerId} ` +
|
|
1441
595
|
`source=${work.sourceKey} file=${work.fileIndex} inputReady=${ready}`),
|
|
1442
|
-
inspect:
|
|
1443
|
-
const params = await containerOver(work);
|
|
1444
|
-
if (!params) return { kind: "needs-source" };
|
|
1445
|
-
params.isCurrent = () => sourcePreparation.accepts(work);
|
|
1446
|
-
const originalContainer = work.statement === "packets" ? await containerOrchestrator.containerFor(params) : null;
|
|
1447
|
-
const navigation = work.statement === "packets" && work.role !== "subtitle-embedded"
|
|
1448
|
-
? await containerOrchestrator.inspect(params, "source-navigation") : { kind: "result", value: false };
|
|
1449
|
-
if (navigation.kind !== "result") return navigation;
|
|
1450
|
-
const originalRanges = navigation.value;
|
|
1451
|
-
if (!originalRanges && work.statement === "packets" && work.role === "source-rest" && sourcePreparation.accepts(work)) {
|
|
1452
|
-
const file = torrentPool.knownTorrent(work.sourceKey)?.files?.[work.fileIndex];
|
|
1453
|
-
if (file?.length > 0) {
|
|
1454
|
-
const statement = `${work.statement}:${work.requestId}:input`;
|
|
1455
|
-
await downloadMaps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex, statement,
|
|
1456
|
-
result: { kind: "needs-ranges", ranges: [[0, file.length - 1]], requestId: statement },
|
|
1457
|
-
scope: "preparation", ...sourcePreparation.demandFor(work) });
|
|
1458
|
-
}
|
|
1459
|
-
}
|
|
1460
|
-
if ((work.statement === "packets" && (work.role !== "source-rest" || originalRanges)) || work.role === "subtitle-embedded" && work.statement === "subtitle-cues") {
|
|
1461
|
-
const info = await containerOrchestrator.inspect({ ...params, onReadResult: undefined }, "media-info");
|
|
1462
|
-
if (info.kind !== "result") return info;
|
|
1463
|
-
const positions = outputParts.viewers.forSource(work.sourceKey)
|
|
1464
|
-
.filter(viewer => viewer.source.selectedFileIndex === work.fileIndex &&
|
|
1465
|
-
(work.ownerId === undefined || viewer.id === work.ownerId))
|
|
1466
|
-
.map(viewer => viewer.positionSeconds() ?? 0);
|
|
1467
|
-
const from = (positions.length ? Math.min(...positions) : 0) + (Number(info.value?.startTimeSeconds) || 0);
|
|
1468
|
-
params.packetInterval = preparationInterval(work.role, from, outputParts.segmentDurationSec);
|
|
1469
|
-
if (work.role === "subtitle-embedded") {
|
|
1470
|
-
const tracks = await containerOrchestrator.containerFor(params).then(container => container.readTracks());
|
|
1471
|
-
const track = tracks.find(track => track.type === "subtitle" && track.declaredIndex === work.trackIndex);
|
|
1472
|
-
if (!track || track.isTextBased?.() !== true) return { kind: "terminal", reason: "subtitle-track-not-supported" };
|
|
1473
|
-
params.packetInterval.trackIds = [track.trackNumber];
|
|
1474
|
-
params.subtitleTrackIndex = work.trackIndex;
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
params.onReadResult = async (statement, result, revision) => {
|
|
1478
|
-
if (!sourcePreparation.accepts(work)) return;
|
|
1479
|
-
mediaReads.record(params, statement, result, revision.storage, revision.memory);
|
|
1480
|
-
const inputStatement = `${work.statement}:${work.requestId}:input`;
|
|
1481
|
-
if (statement === "source-ranges" && result.kind === "result") {
|
|
1482
|
-
if (work.selected) work.inputRanges = result.value.ranges;
|
|
1483
|
-
if (work.role === "source-rest") {
|
|
1484
|
-
const info = await containerOrchestrator.inspect({ ...params, onReadResult: undefined }, "media-info");
|
|
1485
|
-
if (!sourcePreparation.accepts(work)) return;
|
|
1486
|
-
if (info.kind === "result") {
|
|
1487
|
-
work.nativeInput = { container: originalContainer, durationSeconds: info.value.durationSeconds,
|
|
1488
|
-
startTimeSeconds: info.value.startTimeSeconds ?? 0 };
|
|
1489
|
-
await publishNative(work);
|
|
1490
|
-
}
|
|
1491
|
-
} else {
|
|
1492
|
-
await downloadMaps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex,
|
|
1493
|
-
statement: inputStatement, result: { kind: "needs-ranges", ranges: result.value.ranges, requestId: inputStatement },
|
|
1494
|
-
scope: "preparation", ...sourcePreparation.demandFor(work) });
|
|
1495
|
-
if (work.selected) await sourcePreparation.bytesChanged(work.sourceKey, work.fileIndex);
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
1498
|
-
if (statement === "packets" && work.role === "source-rest" && result.kind === "result") {
|
|
1499
|
-
const tracks = await containerOrchestrator.containerFor(params).then(container => container.readTracks());
|
|
1500
|
-
const info = await containerOrchestrator.inspect({ ...params, onReadResult: undefined }, "media-info");
|
|
1501
|
-
if (!sourcePreparation.accepts(work)) return;
|
|
1502
|
-
if (info.kind === "result") {
|
|
1503
|
-
work.nativeInput = { index: result.value, tracks, durationSeconds: info.value.durationSeconds,
|
|
1504
|
-
startTimeSeconds: info.value.startTimeSeconds ?? 0 };
|
|
1505
|
-
await publishNative(work);
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
if (statement === "packets" && work.role !== "source-rest" && result.kind === "result" && (work.selected || work.ownerFileIndex !== undefined)) {
|
|
1509
|
-
const tracks = await containerOrchestrator.containerFor(params).then(container => container.readTracks());
|
|
1510
|
-
if (!sourcePreparation.accepts(work)) return;
|
|
1511
|
-
const bounds = tracks.filter(track => ["video", "audio"].includes(track.type))
|
|
1512
|
-
.map(track => result.value.boundsOf(track.trackNumber)).filter(Boolean);
|
|
1513
|
-
const interval = params.packetInterval ?? (bounds.length ? {
|
|
1514
|
-
from: Math.max(0, Math.min(...bounds.map(bound => bound.start))), to: Math.max(...bounds.map(bound => bound.end))
|
|
1515
|
-
} : null);
|
|
1516
|
-
if (interval && interval.to > interval.from) {
|
|
1517
|
-
const input = work.role === "subtitle-embedded"
|
|
1518
|
-
? result.value.inputFor({ trackId: params.packetInterval.trackIds[0], ...interval })
|
|
1519
|
-
: new SegmentInputs({ index: result.value, tracks }).forInterval(interval);
|
|
1520
|
-
if (work.selected) work.inputRanges = input.kind === "result" ? input.ranges : null;
|
|
1521
|
-
await downloadMaps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex,
|
|
1522
|
-
statement: inputStatement, result: input.kind === "result" ? { kind: "needs-ranges", ranges: input.ranges, requestId: inputStatement } : input,
|
|
1523
|
-
scope: "preparation", ...sourcePreparation.demandFor(work) });
|
|
1524
|
-
if (work.selected) await sourcePreparation.bytesChanged(work.sourceKey, work.fileIndex);
|
|
1525
|
-
}
|
|
1526
|
-
}
|
|
1527
|
-
if (!sourcePreparation.accepts(work)) return;
|
|
1528
|
-
await downloadMaps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex,
|
|
1529
|
-
statement: `${work.statement}:${work.requestId}`, result, scope: "preparation", ...sourcePreparation.demandFor(work) });
|
|
1530
|
-
sourcePreparation.result(work, result);
|
|
1531
|
-
if (["subtitle-file", "subtitle-cues"].includes(statement)) outputParts.encodeRuns.planEncodersSoon();
|
|
1532
|
-
};
|
|
1533
|
-
return work.statement === "subtitle-file"
|
|
1534
|
-
? subtitles.inspectFile(params) : work.statement === "subtitle-cues"
|
|
1535
|
-
? subtitles.inspectPackets(params) : containerOrchestrator.inspect(params, originalRanges ? "source-ranges" : work.statement);
|
|
1536
|
-
},
|
|
596
|
+
inspect: work => preparationReads.inspect(work, sourcePreparation),
|
|
1537
597
|
withdraw: work => {
|
|
1538
598
|
if (work.role === "source-rest" && work.statement === "packets") {
|
|
1539
599
|
void downloadMaps.native({ sourceKey: work.sourceKey, fileIndex: work.fileIndex, zones: [] })
|
|
@@ -1551,7 +611,7 @@ export async function startProxyServer({
|
|
|
1551
611
|
}
|
|
1552
612
|
},
|
|
1553
613
|
reprice: (work, demand) => {
|
|
1554
|
-
if (work.nativeInput) void publishNative(work)
|
|
614
|
+
if (work.nativeInput) void preparationReads.publishNative(work, sourcePreparation)
|
|
1555
615
|
.catch(error => logger.warn(`native source map repricing failed: ${error.message}`));
|
|
1556
616
|
void downloadMaps.repriceMetadata(work.sourceKey, work.fileIndex, `${work.statement}:${work.requestId}`, demand)
|
|
1557
617
|
.catch(error => logger.warn(`source preparation request=${work.requestId} repricing failed: ${error.message}`));
|
|
@@ -1564,12 +624,12 @@ export async function startProxyServer({
|
|
|
1564
624
|
const playbackPlanner = createPlaybackPlanner({
|
|
1565
625
|
ffmpegBin,
|
|
1566
626
|
transcodeAudioEnabled: transcodeAudio,
|
|
1567
|
-
localBaseUrl
|
|
627
|
+
localBaseUrl,
|
|
1568
628
|
sourceRegistry,
|
|
1569
629
|
torrentPool,
|
|
1570
630
|
subscribeSource: (sourceKey, fileIndex, listener) => mediaReads.subscribe(sourceKey, fileIndex, listener),
|
|
1571
631
|
readDeclarations: async ({ sourceKey, fileIndex }) => {
|
|
1572
|
-
const params = await
|
|
632
|
+
const params = await mediaParams.paramsFor({ sourceKey, fileIndex });
|
|
1573
633
|
if (!params) return { kind: "terminal", reason: "source-forgotten" };
|
|
1574
634
|
return readPlaybackDeclarations(containerOrchestrator, params);
|
|
1575
635
|
},
|
|
@@ -1580,14 +640,14 @@ export async function startProxyServer({
|
|
|
1580
640
|
// container on its behalf, in the other thread, with the answer carried
|
|
1581
641
|
// back over the channel.
|
|
1582
642
|
declaredTracksOf: async ({ sourceKey, fileIndex }) => {
|
|
1583
|
-
const params = await
|
|
643
|
+
const params = await mediaParams.paramsFor({ sourceKey, fileIndex });
|
|
1584
644
|
return params ? await containerOrchestrator.getTracks(params) : [];
|
|
1585
645
|
},
|
|
1586
646
|
warmKeyframeIndex: (params) => keyframeTables.warm(params),
|
|
1587
647
|
// The quality menu is on screen from the moment a file is opened, so the
|
|
1588
648
|
// heights this host can actually serve have to be answerable before any
|
|
1589
649
|
// encoder exists — from the probe and the startup benchmarks alone.
|
|
1590
|
-
predictOfferedHeights: (mediaInfo) =>
|
|
650
|
+
predictOfferedHeights: (mediaInfo) => quality.predictOfferedHeights(mediaInfo)
|
|
1591
651
|
});
|
|
1592
652
|
|
|
1593
653
|
app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
|
|
@@ -1611,39 +671,35 @@ export async function startProxyServer({
|
|
|
1611
671
|
app.post("/api/sources", async (req, reply) => {
|
|
1612
672
|
// A cold start begins with the first source opened (torrent-tv/meta#177).
|
|
1613
673
|
startupProfile.begin();
|
|
1614
|
-
return handleApiSourcesPost(req, reply, { sourceRegistry, viewers
|
|
674
|
+
return handleApiSourcesPost(req, reply, { sourceRegistry, viewers,
|
|
1615
675
|
associateWaitTrace: (request, sourceKey) => waitTrace.associate(request, sourceKey) });
|
|
1616
676
|
});
|
|
1617
677
|
app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
|
|
1618
|
-
handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool, viewerRequests
|
|
678
|
+
handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool, viewerRequests })
|
|
1619
679
|
);
|
|
1620
680
|
app.get("/api/sources/:sourceKey/files", async (req, reply) =>
|
|
1621
|
-
handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers
|
|
1622
|
-
viewerRequests
|
|
681
|
+
handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers,
|
|
682
|
+
viewerRequests })
|
|
1623
683
|
);
|
|
1624
684
|
app.get("/api/sources/:sourceKey/files/:fileIndex/fingerprint", async (req, reply) =>
|
|
1625
685
|
handleApiSourceFingerprintGet(req, reply, {
|
|
1626
686
|
sourceRegistry,
|
|
1627
687
|
inspectFingerprint: async (address) => {
|
|
1628
|
-
const params = await
|
|
688
|
+
const params = await mediaParams.paramsFor(address);
|
|
1629
689
|
return params ? containerOrchestrator.inspect(params, "fingerprint") : { kind: "pending" };
|
|
1630
690
|
}
|
|
1631
691
|
})
|
|
1632
692
|
);
|
|
1633
693
|
// What the opened file states about the work, and its cover: read only from
|
|
1634
694
|
// the edges of the file, which opening it fetches anyway (meta#139).
|
|
1635
|
-
const
|
|
695
|
+
const workTagsReport = new WorkTagsReport({ log: line => logger.info(line) });
|
|
1636
696
|
app.get("/api/sources/:sourceKey/files/:fileIndex/container-metadata", async (req, reply) =>
|
|
1637
697
|
handleApiSourceContainerMetadataGet(req, reply, {
|
|
1638
698
|
sourceRegistry,
|
|
1639
699
|
inspectWorkTags: async (address) => {
|
|
1640
|
-
const params = await
|
|
700
|
+
const params = await mediaParams.paramsFor(address);
|
|
1641
701
|
const result = params ? await containerOrchestrator.inspect(params, "work-tags") : { kind: "pending" };
|
|
1642
|
-
|
|
1643
|
-
if (result.kind !== "pending" && result.kind !== "needs-ranges" && !workTagsLogged.has(key)) {
|
|
1644
|
-
workTagsLogged.add(key);
|
|
1645
|
-
logger.info(`container metadata "${params?.label ?? ""}": ${describeWorkTags(result)}`);
|
|
1646
|
-
}
|
|
702
|
+
workTagsReport.note(address, params?.label ?? "", result);
|
|
1647
703
|
return result;
|
|
1648
704
|
}
|
|
1649
705
|
})
|
|
@@ -1652,7 +708,7 @@ export async function startProxyServer({
|
|
|
1652
708
|
handleApiSourceCoverGet(req, reply, {
|
|
1653
709
|
sourceRegistry,
|
|
1654
710
|
inspectCover: async (address) => {
|
|
1655
|
-
const params = await
|
|
711
|
+
const params = await mediaParams.paramsFor(address);
|
|
1656
712
|
return params ? containerOrchestrator.inspect(params, "cover") : { kind: "pending" };
|
|
1657
713
|
}
|
|
1658
714
|
})
|
|
@@ -1660,11 +716,11 @@ export async function startProxyServer({
|
|
|
1660
716
|
app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
|
|
1661
717
|
handleApiSourceWarmPost(req, reply, {
|
|
1662
718
|
sourceRegistry,
|
|
1663
|
-
viewers
|
|
719
|
+
viewers
|
|
1664
720
|
})
|
|
1665
721
|
);
|
|
1666
722
|
app.post("/api/sources/:sourceKey/files/:fileIndex/viewer", async (req, reply) =>
|
|
1667
|
-
handleApiSourceViewerPost(req, reply, { sourceRegistry, viewers
|
|
723
|
+
handleApiSourceViewerPost(req, reply, { sourceRegistry, viewers })
|
|
1668
724
|
);
|
|
1669
725
|
// The browser's own log, kept beside the proxy's: a file per session when
|
|
1670
726
|
// the proxy writes its log to a file, its console otherwise. See the route's
|
|
@@ -1673,17 +729,17 @@ export async function startProxyServer({
|
|
|
1673
729
|
? createClientLogFiles(logFile)
|
|
1674
730
|
: createClientLogConsole((message) => logger.info(message));
|
|
1675
731
|
app.post("/api/client-logs", async (req, reply) =>
|
|
1676
|
-
handleApiClientLogsPost(req, reply, { clientLogs, viewerRequests
|
|
732
|
+
handleApiClientLogsPost(req, reply, { clientLogs, viewerRequests })
|
|
1677
733
|
);
|
|
1678
734
|
app.post("/api/playback-plan", async (req, reply) =>
|
|
1679
|
-
handleApiPlaybackPlanPost(req, reply, { playbackPlanner, viewers
|
|
735
|
+
handleApiPlaybackPlanPost(req, reply, { playbackPlanner, viewers,
|
|
1680
736
|
observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields) })
|
|
1681
737
|
);
|
|
1682
738
|
app.post("/api/playback-plan/audio-tracks", async (req, reply) =>
|
|
1683
739
|
handleApiPlaybackPlanAudioTracksPost(req, reply, { playbackPlanner })
|
|
1684
740
|
);
|
|
1685
741
|
app.post("/api/subtitles", async (req, reply) => handleApiSubtitlesPost(req, reply, {
|
|
1686
|
-
viewers
|
|
742
|
+
viewers, sourceRegistry,
|
|
1687
743
|
subtitleTracksFor: (sourceKey, fileIndex) =>
|
|
1688
744
|
(containerOrchestrator.tracks.get(`${sourceKey}:${fileIndex}`) ?? []).filter(track => track.type === "subtitle"),
|
|
1689
745
|
subtitleFilesFor: (sourceKey, fileIndex) => {
|
|
@@ -1696,13 +752,13 @@ export async function startProxyServer({
|
|
|
1696
752
|
handleApiSubtitlesGet(req, reply, {
|
|
1697
753
|
sourceRegistry,
|
|
1698
754
|
torrentPool,
|
|
1699
|
-
viewers
|
|
1700
|
-
subtitles
|
|
755
|
+
viewers,
|
|
756
|
+
subtitles: subtitleWalk.subtitles
|
|
1701
757
|
})
|
|
1702
758
|
);
|
|
1703
759
|
app.get("/encode-input/:token/:fileIndex", (req, reply) =>
|
|
1704
|
-
handleEncodeInputGet(req, reply, { inputOf: token =>
|
|
1705
|
-
noteFirstInputBytes: (token, fileIndex, bytes) =>
|
|
760
|
+
handleEncodeInputGet(req, reply, { inputOf: token => encodeRuns.originalInputOf(token),
|
|
761
|
+
noteFirstInputBytes: (token, fileIndex, bytes) => encodeRuns.noteFirstInputBytes(token, fileIndex, bytes),
|
|
1706
762
|
refused: ({ fileIndex, start, runTag }) => logger.warn(`encode input: run ${runTag ?? "?"} asked for byte ${start} of file ${fileIndex}, which it was not given`) })
|
|
1707
763
|
);
|
|
1708
764
|
app.get("/stream", async (req, reply) =>
|
|
@@ -1712,47 +768,47 @@ export async function startProxyServer({
|
|
|
1712
768
|
// So a session that has produced nothing yet can still show it is being
|
|
1713
769
|
// fed. The route knows only files; the session id rides on the URL the
|
|
1714
770
|
// session itself built.
|
|
1715
|
-
noteInputBytes: (sessionId, bytes) =>
|
|
1716
|
-
noteFirstInputBytes: (token, fileIndex, bytes) =>
|
|
771
|
+
noteInputBytes: (sessionId, bytes) => encodeRuns.noteInputBytes(sessionId, bytes),
|
|
772
|
+
noteFirstInputBytes: (token, fileIndex, bytes) => encodeRuns.noteFirstInputBytes(token, fileIndex, bytes),
|
|
1717
773
|
// When the encoder reading this waits for its input, so its speed is
|
|
1718
774
|
// measured over its own work and not over the swarm's.
|
|
1719
|
-
noteInputWaiting: (runToken, waiting) =>
|
|
775
|
+
noteInputWaiting: (runToken, waiting) => encodeRuns.noteInputWaiting(runToken, waiting)
|
|
1720
776
|
})
|
|
1721
777
|
);
|
|
1722
778
|
app.post("/api/transcode-sessions", async (req, reply) =>
|
|
1723
|
-
handleApiTranscodeSessionsPost(req, reply, { viewerRequests
|
|
779
|
+
handleApiTranscodeSessionsPost(req, reply, { viewerRequests, renditions, quality, outputs, lookaheadSeconds, sourceRegistry, torrentPool,
|
|
1724
780
|
subscribeSource: (sourceKey, fileIndex, listener) => mediaReads.subscribe(sourceKey, fileIndex, listener) })
|
|
1725
781
|
);
|
|
1726
782
|
app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
|
|
1727
|
-
handleApiTranscodeSessionReleasePost(req, reply, { lifecycle
|
|
783
|
+
handleApiTranscodeSessionReleasePost(req, reply, { lifecycle })
|
|
1728
784
|
);
|
|
1729
785
|
app.get("/api/transcode-sessions/:sessionId/progress", async (req, reply) =>
|
|
1730
|
-
handleApiTranscodeSessionsProgressGet(req, reply, { viewerRequests
|
|
786
|
+
handleApiTranscodeSessionsProgressGet(req, reply, { viewerRequests })
|
|
1731
787
|
);
|
|
1732
788
|
app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
|
|
1733
789
|
// A viewer's statement about itself goes to the VIEWER layer, not through
|
|
1734
790
|
// the session manager: what it needs is the live sessions and the registry
|
|
1735
791
|
// of viewers, and nothing about encoding.
|
|
1736
792
|
handleApiTranscodeSessionNetReportPost(req, reply, {
|
|
1737
|
-
outputs
|
|
1738
|
-
viewers
|
|
1739
|
-
renditions
|
|
1740
|
-
quality
|
|
1741
|
-
planSoon: () =>
|
|
793
|
+
outputs,
|
|
794
|
+
viewers,
|
|
795
|
+
renditions,
|
|
796
|
+
quality,
|
|
797
|
+
planSoon: () => encodeRuns.planEncodersSoon(),
|
|
1742
798
|
segmentAt: (output, seconds) => {
|
|
1743
799
|
// A cut table not built yet has no pieces to move between.
|
|
1744
|
-
try { return
|
|
800
|
+
try { return outputTimes.segmentIndexForTime(output, seconds); } catch { return null; }
|
|
1745
801
|
}
|
|
1746
802
|
})
|
|
1747
803
|
);
|
|
1748
804
|
app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
|
|
1749
|
-
handleApiTranscodeSessionFragmentFarPost(req, reply, { serving
|
|
805
|
+
handleApiTranscodeSessionFragmentFarPost(req, reply, { serving })
|
|
1750
806
|
);
|
|
1751
807
|
app.post("/api/transcode-sessions/:sessionId/seek", async (req, reply) =>
|
|
1752
|
-
handleApiTranscodeSessionSeekPost(req, reply, { viewerRequests
|
|
808
|
+
handleApiTranscodeSessionSeekPost(req, reply, { viewerRequests })
|
|
1753
809
|
);
|
|
1754
810
|
app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
|
|
1755
|
-
handleTranscodeSessionFileGet(req, reply, { serving
|
|
811
|
+
handleTranscodeSessionFileGet(req, reply, { serving, viewerRequests })
|
|
1756
812
|
);
|
|
1757
813
|
// A quality variant's files. Registered before the static handler for the
|
|
1758
814
|
// same reason as the line above, and kept a separate route rather than a
|
|
@@ -1761,22 +817,22 @@ export async function startProxyServer({
|
|
|
1761
817
|
// Fastify matches a static segment ahead of a parameter either way — stated
|
|
1762
818
|
// here so the order is not "tidied" into a bug.
|
|
1763
819
|
app.get("/transcode/:sessionId/v/:height/warm", async (req, reply) =>
|
|
1764
|
-
handleTranscodeVariantWarmGet(req, reply, { renditions
|
|
820
|
+
handleTranscodeVariantWarmGet(req, reply, { renditions, serving, viewerRequests })
|
|
1765
821
|
);
|
|
1766
822
|
app.get("/transcode/:sessionId/a/:track/warm", async (req, reply) =>
|
|
1767
|
-
handleTranscodeAudioWarmGet(req, reply, { renditions
|
|
823
|
+
handleTranscodeAudioWarmGet(req, reply, { renditions, serving, viewerRequests })
|
|
1768
824
|
);
|
|
1769
825
|
app.get("/transcode/:sessionId/a/:trackIndex/:fileName", async (req, reply) =>
|
|
1770
|
-
handleTranscodeAudioFileGet(req, reply, { renditions
|
|
826
|
+
handleTranscodeAudioFileGet(req, reply, { renditions, serving, viewerRequests })
|
|
1771
827
|
);
|
|
1772
828
|
app.get("/transcode/:sessionId/v/:height/:fileName", async (req, reply) =>
|
|
1773
|
-
handleTranscodeVariantFileGet(req, reply, { renditions
|
|
829
|
+
handleTranscodeVariantFileGet(req, reply, { renditions, serving, viewerRequests })
|
|
1774
830
|
);
|
|
1775
831
|
|
|
1776
832
|
app.addHook("onClose", async () => {
|
|
1777
833
|
// Order matters: stop the ffmpeg readers (HLS sessions) before destroying
|
|
1778
834
|
// the torrents whose files they read from, then remove the torrent data.
|
|
1779
|
-
await
|
|
835
|
+
await lifecycle.disposeAll();
|
|
1780
836
|
await torrentPool.destroyAll();
|
|
1781
837
|
});
|
|
1782
838
|
|