@torrent-tv/proxy 2.107.55 → 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/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 { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
69
- import { containerOrchestrator, CONTAINER_HEAD_BYTES, describeWorkTags } from "./services/media/ContainerOrchestrator.js";
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 { directSegmentInput } from "./services/media/direct-segment-input.js";
76
- import { ContainerTrack } from "./services/media/tracks/ContainerTrack.js";
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 { coalescing } from "./utils/coalesce.js";
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.addHook("onRequest", async (req, reply) => {
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?.forget(sourceKey);
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
- for (const key of segmentSourceRanges.keys()) if (key.startsWith(`${sourceKey}:`)) segmentSourceRanges.delete(key);
248
- for (const key of segmentReadings.keys()) if (key.startsWith(`${sourceKey}:`)) segmentReadings.delete(key);
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
- outputParts.encodeInputs?.bytesChanged();
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?.bytesChanged(sourceKey, fileIndex).catch(error =>
221
+ void sourcePreparation.bytesChanged(sourceKey, fileIndex).catch(error =>
269
222
  logger.warn(`source input readiness failed: ${error.message}`));
270
- void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
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
- outputParts.encodeInputs?.bytesChanged(Array.isArray(arrived) ? { sourceKey, ranges: arrived } : null);
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 below divide out a share of those, the unit every later share of
291
- // the machine is in (torrent-tv/meta#180, phase 3).
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
- // WHAT THIS HOST DOES, measured before any viewer exists. Every one of these
294
- // was gated on the chosen encoder being SOFTWARE, and two of the three are
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 containerOver({ sourceKey, fileIndex });
266
+ return mediaParams.paramsFor({ sourceKey, fileIndex });
420
267
  }
421
268
  });
422
269
  const chapterController = new ChapterController({ sourceRegistry, chapters });
423
- const heldFileFor = async (sourceKey, fileIndex, known = null) => {
424
- let torrent = known;
425
- if (!torrent) {
426
- torrent = torrentPool.knownTorrent(sourceKey);
427
- if (!torrent) return null;
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
- warm: async (torrent, fileIndex, sourceKey) => {
496
- const file = await heldFileFor(sourceKey, fileIndex, torrent);
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, by file and interval; forgotten with the source. */
527
- const segmentSourceRanges = new Map();
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 outputParts.machineBudget.revise("memory", "media metadata").catch(error => logger.warn(`packet index budget: ${error.message}`)); },
922
- changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); outputParts.encodeInputs?.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 containerOver({ sourceKey, fileIndex, tailBytes: CONTAINER_HEAD_BYTES });
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
- let sourcePreparation;
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
- // `timing`, when given, is told how long each read of a file waited behind
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
- readEncodeRanges: async (source, ranges, maxBytes) => {
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 (!outputParts.viewers.forSource(sourceKey).length) probeReads.forget(sourceKey, fileIndex);
422
+ if (!viewers.forSource(sourceKey).length) probeReads.forget(sourceKey, fileIndex);
1278
423
  await downloadMaps.forget(sourceKey, fileIndex, { keepPreparation: true });
1279
- playbackProjections.get(`${sourceKey}:${fileIndex}`)?.clear();
1280
- void sourcePreparation?.refresh();
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?.refresh(); }
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 containerOver({ sourceKey, fileIndex });
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
- outputPartsReady = true;
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: () => [...outputParts.outputs.values()]
1363
- .flatMap((output) => outputParts.encodeRuns.liveRunsOf(output).map((run) => ({
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) => outputParts.encodeOrchestrator?.noteRunSeconds(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.lifecycle.adoptSegmentsLeftBehind();
1381
- const publishNative = async work => {
1382
- if (!work.nativeInput || !sourcePreparation.accepts(work)) return Promise.resolve();
1383
- const viewers = outputParts.viewers.forSource(work.sourceKey)
1384
- .filter(viewer => viewer.source.selectedFileIndex === work.fileIndex && viewer.outputs.size === 0);
1385
- const convert = work.nativeInput.container ? nativeOriginalSourceMap : nativeSourceMap;
1386
- const zones = await convert({ ...work.nativeInput, viewers,
1387
- allowanceSeconds: outputParts.segmentDurationSec,
1388
- urgentReadyFor: viewer => sourcePreparation.urgentReadyFor(viewer) });
1389
- if (!sourcePreparation.accepts(work)) return;
1390
- return downloadMaps.native({ sourceKey: work.sourceKey, fileIndex: work.fileIndex, zones });
1391
- };
1392
- sourcePreparation = new SourcePreparation({
1393
- viewers: outputParts.viewers,
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 = [...outputParts.outputs.values()].find(output => output.file?.sourceKey === work.sourceKey &&
1417
- output.file?.fileIndex === fileIndex && outputParts.viewers.forOutput(output).has(viewer.id) &&
1418
- !outputParts.outputs.supersededBy(output, viewer.activeVariantId ?? null));
1419
- const viewerCount = outputParts.viewers.forSource(work.sourceKey)
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: outputParts.segmentDurationSec,
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) => ![...outputParts.outputs.values()].some(output =>
590
+ sourceRestNeededFor: (sourceKey, fileIndex) => ![...outputs.values()].some(output =>
1437
591
  output.file?.sourceKey === sourceKey && output.file?.fileIndex === fileIndex &&
1438
- outputParts.viewers.forOutput(output).size > 0),
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: async work => {
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: outputParts.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 containerOver({ sourceKey, fileIndex });
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 containerOver({ sourceKey, fileIndex });
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) => outputParts.quality.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: outputParts.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: outputParts.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: outputParts.viewers,
1622
- viewerRequests: outputParts.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 containerOver(address);
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 workTagsLogged = new Set();
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 containerOver(address);
700
+ const params = await mediaParams.paramsFor(address);
1641
701
  const result = params ? await containerOrchestrator.inspect(params, "work-tags") : { kind: "pending" };
1642
- const key = `${address.sourceKey}:${address.fileIndex}`;
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 containerOver(address);
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: outputParts.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: outputParts.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: outputParts.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: outputParts.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: outputParts.viewers, sourceRegistry,
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: outputParts.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 => outputParts.encodeRuns.originalInputOf(token),
1705
- noteFirstInputBytes: (token, fileIndex, bytes) => outputParts.encodeRuns.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) => outputParts.encodeRuns.noteInputBytes(sessionId, bytes),
1716
- noteFirstInputBytes: (token, fileIndex, bytes) => outputParts.encodeRuns.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) => outputParts.encodeRuns.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: outputParts.viewerRequests, renditions: outputParts.renditions, quality: outputParts.quality, outputs: outputParts.outputs, lookaheadSeconds: outputParts.lookaheadSeconds, sourceRegistry, torrentPool,
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: outputParts.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: outputParts.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: outputParts.outputs,
1738
- viewers: outputParts.viewers,
1739
- renditions: outputParts.renditions,
1740
- quality: outputParts.quality,
1741
- planSoon: () => outputParts.encodeRuns.planEncodersSoon(),
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 outputParts.outputTimes.segmentIndexForTime(output, seconds); } catch { return null; }
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: outputParts.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: outputParts.viewerRequests })
808
+ handleApiTranscodeSessionSeekPost(req, reply, { viewerRequests })
1753
809
  );
1754
810
  app.get("/transcode/:sessionId/:fileName", async (req, reply) =>
1755
- handleTranscodeSessionFileGet(req, reply, { serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
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: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
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: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
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: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
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: outputParts.renditions, serving: outputParts.serving, viewerRequests: outputParts.viewerRequests })
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 outputParts.lifecycle.disposeAll();
835
+ await lifecycle.disposeAll();
1780
836
  await torrentPool.destroyAll();
1781
837
  });
1782
838