@torrent-tv/proxy 2.96.2 → 2.96.4

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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/services/encode/EncodePlan.js +1 -1
  3. package/services/encode/EncodeRuns.js +3 -3
  4. package/services/encode/OriginalInput.js +4 -4
  5. package/services/encode/OutputTimes.js +1 -1
  6. package/services/encode/args.js +2 -2
  7. package/services/encode/calibration.js +2 -15
  8. package/services/encode/encode-run-state.js +1 -1
  9. package/services/encode/hwaccel.js +5 -59
  10. package/services/encode/output/OutputSpec.js +13 -0
  11. package/services/encode/output/playlists.js +2 -3
  12. package/services/encode/piece-completeness.js +16 -3
  13. package/services/encode/quality/link-budget.js +1 -1
  14. package/services/encode/source-command.js +1 -1
  15. package/services/media/container/avi-index.js +1 -1
  16. package/services/media/container/ebml-reader.js +0 -27
  17. package/services/media/container/ebml-stream.js +1 -1
  18. package/services/media/container/matroska-work-tags.js +1 -1
  19. package/services/media/container/work-tags.js +2 -2
  20. package/services/media/ffmpeg-banner.js +0 -90
  21. package/services/server/SegmentServing.js +1 -1
  22. package/services/server/ViewerRequests.js +1 -2
  23. package/services/storage/core-dumps.js +1 -1
  24. package/services/storage/memory-report.js +5 -5
  25. package/services/storage/segment-store/SegmentStore.js +1 -1
  26. package/services/torrent/demand/DemandRegister.js +1 -2
  27. package/services/torrent/demand/index.js +2 -2
  28. package/services/torrent/demand/pieces.js +0 -32
  29. package/services/torrent/files.js +2 -24
  30. package/services/torrent/torrent-pool.js +2 -2
  31. package/services/torrent/worker/channel.js +1 -1
  32. package/services/torrent/worker/fastest-wires.js +1 -1
  33. package/services/torrent/worker/piece-reader.js +1 -112
  34. package/services/torrent/worker/protocol.js +6 -10
  35. package/services/torrent/worker/worker.js +5 -5
  36. package/services/transport/delivery-probe.js +1 -1
  37. package/services/transport/packet-witness.js +5 -5
  38. package/services/transport/usrsctp-state.js +1 -1
  39. package/services/viewer/PriorityMap.js +2 -30
  40. package/services/viewer/PriorityOrchestrator.js +34 -17
  41. package/services/viewer/choices.js +0 -14
  42. package/services/viewer/media-time.js +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.96.2",
3
+ "version": "2.96.4",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -1270,7 +1270,7 @@ export function firstUnmetWant(coverage, windows) {
1270
1270
  * stated want is due now.
1271
1271
  * @returns {number[]} Where to start each encoder, ascending.
1272
1272
  */
1273
- export function placeEncoders({
1273
+ function placeEncoders({
1274
1274
  coverage,
1275
1275
  windows,
1276
1276
  howMany,
@@ -60,7 +60,7 @@ export function formatSeconds(seconds) {
60
60
  * @param {object[] | Set<object>} runs
61
61
  * @returns {number | null}
62
62
  */
63
- export function earliestRunStart(runs) {
63
+ function earliestRunStart(runs) {
64
64
  const live = liveRunsOf(runs);
65
65
  return live.length > 0 ? live[0].from : null;
66
66
  }
@@ -129,7 +129,7 @@ export function isInputUnavailable(message) {
129
129
  * @param {string[]} args
130
130
  * @returns {string}
131
131
  */
132
- export function describeFfmpegArgs(args) {
132
+ function describeFfmpegArgs(args) {
133
133
  const parts = [];
134
134
  for (let index = 0; index < args.length; index += 1) {
135
135
  const value = args[index];
@@ -370,7 +370,7 @@ export class EncodeRuns {
370
370
  const cut = cutOf(session.timeline, index);
371
371
  const grid = session.timeline.published ?? session.timeline.boundaries;
372
372
  const interval = admitted ? { from: grid?.[index], to: grid?.[index + 1],
373
- ...(index === session.timeline.segmentCount - 1 ? { sourceEnds: Object.fromEntries(
373
+ ...(index === session.timeline.segmentCount - 1 ? { endsWithSource: true, sourceEnds: Object.fromEntries(
374
374
  (admitted.tracks ?? []).filter(input => Number.isFinite(input.sourceEndSeconds))
375
375
  .map(input => [input.track.type === "video" ? "vide" : "soun", input.sourceEndSeconds])) } : {}),
376
376
  requiredKinds: [session.spec.carries !== "audio-only" ? "vide" : null,
@@ -37,10 +37,10 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
37
37
  const reads = { count: 0, bytes: 0, firstAt: 0, lastAt: 0 };
38
38
  return {
39
39
  kind: "result", original: true, sources, bytes, copyMs, fingerprint: hash.digest("hex"),
40
- tracks: sources.flatMap(source => (source.input.selections ?? []).map(selection => ({ track: selection.track,
41
- ...(Number.isFinite(source.input.sourceEnds?.[selection.track.trackNumber]) ? {
42
- sourceEndSeconds: source.input.sourceEnds[selection.track.trackNumber] - source.timeShiftSeconds
43
- } : {}) }))),
40
+ // No track's own end: the container names the bytes an interval needs,
41
+ // not where each track stops. The final piece is judged by the input
42
+ // reaching the end of the file instead (`piece-completeness.js`).
43
+ tracks: sources.flatMap(source => (source.input.selections ?? []).map(selection => ({ track: selection.track }))),
44
44
  read(fileIndex, start, end, partial = false) {
45
45
  if (released) return null;
46
46
  const entry = held.get(fileIndex);
@@ -31,7 +31,7 @@ const SEGMENT_START_DISAGREEMENT_SEC = 0.25;
31
31
  * judged not to have loaded — so the player asks for it again, and again.
32
32
  * Taken from the player's published default rather than chosen here.
33
33
  */
34
- export const PLAYER_BUFFER_HOLE_SEC = 0.5;
34
+ const PLAYER_BUFFER_HOLE_SEC = 0.5;
35
35
  /**
36
36
  * A live run of this session that begins exactly at this number, if there is
37
37
  * one.
@@ -31,7 +31,7 @@ export const TONEMAP_FILTER_CHAIN =
31
31
  export const TRANSCODE_FPS = 24;
32
32
  // Upper bound on the output frame rate: 50/60 fps sources are halved-in-effort
33
33
  // by capping to 30, protecting the realtime encode budget on weak hosts.
34
- export const MAX_OUTPUT_FPS = 30;
34
+ const MAX_OUTPUT_FPS = 30;
35
35
 
36
36
  /**
37
37
  * Choose an INTEGER output frame rate from the (possibly fractional) source
@@ -232,7 +232,7 @@ export function softwareRateControlFor({ width, height, fps, source = null, capK
232
232
  * @param {number} nominalKbps
233
233
  * @returns {number}
234
234
  */
235
- export function bufsizeKbpsFor(nominalKbps) {
235
+ function bufsizeKbpsFor(nominalKbps) {
236
236
  return Math.round(nominalKbps * CAP_BUFSIZE_FACTOR);
237
237
  }
238
238
 
@@ -53,7 +53,6 @@ import {
53
53
  } from "./hwaccel.js";
54
54
  import { interpolationErrorOf } from "./throughput.js";
55
55
 
56
- export { throughputAt, interpolationErrorOf } from "./throughput.js";
57
56
 
58
57
  /**
59
58
  * The sizes a mode is read at, smallest first.
@@ -64,7 +63,7 @@ export { throughputAt, interpolationErrorOf } from "./throughput.js";
64
63
  * rows, where most films are watched. 640x360 is the reference size every
65
64
  * earlier reading was taken at, so the figures stay comparable with them.
66
65
  */
67
- export const CALIBRATION_FRAMES = Object.freeze([
66
+ const CALIBRATION_FRAMES = Object.freeze([
68
67
  Object.freeze({ width: 256, height: 144 }),
69
68
  Object.freeze({ width: 640, height: 360 }),
70
69
  Object.freeze({ width: 1280, height: 720 }),
@@ -95,18 +94,6 @@ const CALIBRATION_SOURCE = Object.freeze({ width: 1920, height: 1080, pictureKbp
95
94
  * What the correctness check's segments carried.
96
95
  */
97
96
 
98
- /**
99
- * The settings a mode is known by, as text, for the log and for the key its
100
- * later observations are filed under.
101
- *
102
- * @param {{ benchmarkArgs: (rung: string | null) => string[] }} encoder
103
- * @param {string | null} rung
104
- * @returns {string}
105
- */
106
- export function settingsOf(encoder, rung) {
107
- return encoder.benchmarkArgs(rung).join(" ");
108
- }
109
-
110
97
  /**
111
98
  * Encode the calibration clip through the product's own arguments for this
112
99
  * mode, and check every segment decodes on its own.
@@ -114,7 +101,7 @@ export function settingsOf(encoder, rung) {
114
101
  * @param {{ ffmpegBin: string, encoder: object, rung: string | null, segmentDurationSec: number }} params
115
102
  * @returns {Promise<{ ok: boolean, reason: string, segmentKbps: { averageKbps: number, peakKbps: number } | null }>}
116
103
  */
117
- export async function checkModeProducesCorrectSegments({ ffmpegBin, encoder, rung, segmentDurationSec }) {
104
+ async function checkModeProducesCorrectSegments({ ffmpegBin, encoder, rung, segmentDurationSec }) {
118
105
  let directory;
119
106
  try {
120
107
  directory = mkdtempSync(path.join(os.tmpdir(), "tt-modecheck-"));
@@ -493,7 +493,7 @@ export function declaredContainment() {
493
493
  * @param {object[] | Set<object>} runs
494
494
  * @returns {object[]}
495
495
  */
496
- export function runsOf(runs) {
496
+ function runsOf(runs) {
497
497
  const listed = Array.isArray(runs) ? runs : runs instanceof Set ? [...runs] : [];
498
498
  return [...listed].sort((left, right) => left.from - right.from);
499
499
  }
@@ -53,8 +53,7 @@ import {
53
53
  export {
54
54
  chooseOutputFps,
55
55
  maxrateKbpsFor,
56
- nominalKbpsFor,
57
- TRANSCODE_FPS
56
+ nominalKbpsFor
58
57
  } from "./args.js";
59
58
 
60
59
  const BENCHMARK_REF_W = 640;
@@ -72,7 +71,7 @@ const BENCHMARK_DURATION_SEC = 3;
72
71
  * @param {{ width: number, height: number }} frame
73
72
  * @returns {number}
74
73
  */
75
- export function rawFrameCountFor(frame) {
74
+ function rawFrameCountFor(frame) {
76
75
  const referenceBytes = BENCHMARK_REF_W * BENCHMARK_REF_H * 1.5 * TRANSCODE_FPS * BENCHMARK_DURATION_SEC;
77
76
  const frameBytes = frame.width * frame.height * 1.5;
78
77
  return Math.max(TRANSCODE_FPS, Math.floor(referenceBytes / frameBytes));
@@ -614,10 +613,9 @@ export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBR
614
613
  // company, not the clip's own cost, so the smallest one says it soonest.
615
614
  const clip = path.join(clipsDir, "cal-h264-480-lo.mp4");
616
615
  const startedAt = Date.now();
617
- // Lifted once and decoded three times from the same bytes. Going through
618
- // `measureDecodeSlope` lifted it again for every reading — three process
619
- // starts on a path that is awaited before the proxy's tunnel opens, for a
620
- // remux whose result had not changed.
616
+ // Lifted once and decoded three times from the same bytes. Lifting it again
617
+ // for every reading cost three process starts on a path that is awaited
618
+ // before the proxy's tunnel opens, for a remux whose result had not changed.
621
619
  const streams = await extractFamilyStreams(ffmpegBin, [clip], "h264");
622
620
  const stream = streams?.[0];
623
621
  if (!stream) {
@@ -995,58 +993,6 @@ function runCapturingStderr(ffmpegBin, args, timeoutMs) {
995
993
  });
996
994
  }
997
995
 
998
- /**
999
- * Measure how fast this host DECODES a clip, from ffmpeg's own report of how
1000
- * much video it has processed.
1001
- *
1002
- * Two things are deliberately outside the measurement.
1003
- *
1004
- * **The process starting.** Wall-clock around the process cannot answer this:
1005
- * starting ffmpeg costs about a second, and on a quick machine a five-second
1006
- * clip decodes in a tenth of that, so the measurement would be of the program
1007
- * starting. Progress lines arrive AFTER it has started, and the slope between
1008
- * two of them — video processed against time taken — contains no part of the
1009
- * startup by construction.
1010
- *
1011
- * **The clip restarting.** This used to loop the clip with `-stream_loop -1`,
1012
- * and a loop is not free: measured 2026-08-22 on a desktop, a restart costs
1013
- * 0.03 s on the 480p clip and 0.12 s on the 1080p one — the decoder tearing
1014
- * down and re-allocating its frame buffers, which is why the price rises with
1015
- * the picture. A five-second clip decoded at 55x restarts eleven times a
1016
- * second, so that cost DOMINATED the reading: the same clips measured 53.7x
1017
- * looped against 80.3x in one continuous pass, and 11.8x against 15.8x. Worse,
1018
- * the bias is not shared — it depends on the clip's own resolution and on how
1019
- * fast the host is — so it does not cancel out of the fit, it tilts it. That is
1020
- * the fast-host failure recorded on 2026-08-20, where 1080p read cheaper than
1021
- * 720p, which is not a thing a decoder does.
1022
- *
1023
- * So the clip is fed to the decoder as ONE stream instead. An Annex-B
1024
- * elementary stream carries its parameter sets inline, so writing the same
1025
- * bytes again is simply more stream — the decoder never re-initialises, and
1026
- * there is no restart inside the window to measure. Verified against the
1027
- * continuous-pass truth on the same host: -0.2 % and -5.5 %, against -25 % and
1028
- * -33 % for the loop. Nothing is written to disk and the process is killed as
1029
- * soon as the window is wide enough.
1030
- *
1031
- * Exported because the property that broke here is checkable and was not being
1032
- * checked: a bigger picture must cost more than a smaller one of the same
1033
- * bitrate, and under the loop it did not.
1034
- *
1035
- * @param {string} ffmpegBin
1036
- * @param {string} clipPath
1037
- * @param {string} [family="h264"]
1038
- * @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
1039
- */
1040
- export async function measureDecodeSlope(ffmpegBin, clipPath, family = "h264") {
1041
- const streams = await extractFamilyStreams(ffmpegBin, [clipPath], family);
1042
- const stream = streams?.[0];
1043
- if (!stream) {
1044
- return null;
1045
- }
1046
- const measured = await decodePipedStream(ffmpegBin, stream);
1047
- return measured?.speed ? measured : null;
1048
- }
1049
-
1050
996
  /**
1051
997
  * Decode an elementary stream fed from memory, and report the slope.
1052
998
  *
@@ -258,6 +258,19 @@ export class OutputSpec {
258
258
  return this.audio?.fileIndex ?? -1;
259
259
  }
260
260
 
261
+ /**
262
+ * The files of the torrent whose bytes this output reads: the picture's file
263
+ * where it carries a picture, the soundtrack's where it carries sound. A
264
+ * soundtrack shipped beside the picture is a file of its own, and its bytes
265
+ * are wanted from that file and not from the picture's.
266
+ *
267
+ * @returns {number[]}
268
+ */
269
+ get sourceFileIndexes() {
270
+ return [...new Set([this.video?.fileIndex, this.audio?.fileIndex]
271
+ .filter((fileIndex) => Number.isInteger(fileIndex) && fileIndex >= 0))];
272
+ }
273
+
261
274
  /** @returns {number} */
262
275
  get audioSourceTrackIndex() {
263
276
  return this.audio?.trackIndex ?? 0;
@@ -42,7 +42,7 @@ export const PLAYLIST_FILE_NAME = "index.m3u8";
42
42
  * @param {string} language
43
43
  * @returns {string}
44
44
  */
45
- export function languageTag(language) {
45
+ function languageTag(language) {
46
46
  const code = String(language ?? "").toLowerCase();
47
47
  return LANGUAGE_TAGS.get(code) ?? code;
48
48
  }
@@ -57,7 +57,7 @@ export function languageTag(language) {
57
57
  * @param {string} value
58
58
  * @returns {string}
59
59
  */
60
- export function escapeAttribute(value) {
60
+ function escapeAttribute(value) {
61
61
  return String(value ?? "").replace(/"/g, "'").replace(/[\u0000-\u001f\u007f]/g, " ").trim();
62
62
  }
63
63
 
@@ -281,4 +281,3 @@ export function segmentIndexForTime(boundaries, seconds, segmentDurationSec) {
281
281
  return Math.min(result, boundaries.length - 2);
282
282
  }
283
283
 
284
- export { AUDIO_GROUP_ID, AUDIO_PATH_PREFIX, VARIANT_PATH_PREFIX };
@@ -43,7 +43,7 @@ export function cutOf(timeline, index) {
43
43
  * @param {{ producedThroughSeconds?: (ranges: object) => number | null }} format
44
44
  * @param {object} ranges - The piece's media intervals, as the format read them.
45
45
  * @param {number | undefined} cutSeconds - From {@link cutOf}.
46
- * @param {{ from: number, to: number, requiredKinds: string[], sourceEnds?: object }} [interval]
46
+ * @param {{ from: number, to: number, requiredKinds: string[], sourceEnds?: object, endsWithSource?: boolean }} [interval]
47
47
  * @returns {{ whole: boolean, throughSeconds: number | null, reason?: string }}
48
48
  */
49
49
  export function judgePiece(format, ranges, cutSeconds, interval) {
@@ -132,8 +132,18 @@ export function judgeNeighbors(left, right) {
132
132
  * Every required track must cover the interval with at most one-frame error,
133
133
  * on the clock the run's muxer cut by: the interval moved by `shift`
134
134
  * ({@link cutShiftOf}). A track's own end in the file is not moved.
135
+ *
136
+ * `endsWithSource` marks the final piece of a film. Its input is the rest of
137
+ * the file, and it is published only by a run that reached the end of that
138
+ * input, so a track that stops before the interval's end stops there in the
139
+ * file too: the end the piece shows is the track's own, and only a track
140
+ * running past the interval is refused. Where the input states a track's end
141
+ * (`sourceEnds`), that end is held to as before. Field 2026-10-09, Drifters
142
+ * episode 1: the picture's last packet ends at 1419.92 s and the separate
143
+ * soundtrack's at 1419.904 s against a final cut of 1419.993 s, and both last
144
+ * pieces were refused, so no viewer could reach the end of the episode.
135
145
  */
136
- function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, shift = 0 }) {
146
+ function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, shift = 0, endsWithSource = false }) {
137
147
  if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from) || !requiredKinds?.length) return "segment-interval-is-not-declared";
138
148
  for (const kind of requiredKinds) {
139
149
  const track = coverage?.tracks?.find(track => track.kind === kind);
@@ -157,7 +167,10 @@ function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, s
157
167
  if (range.end > reached) reached = range.end;
158
168
  }
159
169
  const finalFrame = track.productionFrame ?? track.ranges.at(-1).frame;
160
- if (reached < end - finalFrame - positionError || reached > end + finalFrame + positionError) return `segment-end-outside-interval-${kind}`;
170
+ const endsInFile = endsWithSource && !Number.isFinite(sourceEnds[kind]);
171
+ if ((reached < end - finalFrame - positionError && !endsInFile) || reached > end + finalFrame + positionError) {
172
+ return `segment-end-outside-interval-${kind}`;
173
+ }
161
174
  }
162
175
  return null;
163
176
 
@@ -179,7 +179,7 @@ export function soundtrackLoadOf(entry, transcode) {
179
179
  * @param {import("../../media/audio-inventory.js").AudioInventoryEntry | null | undefined} entry
180
180
  * @returns {boolean}
181
181
  */
182
- export function soundtrackHasFigure(entry) {
182
+ function soundtrackHasFigure(entry) {
183
183
  const peakKbps = Number(entry?.peakKbps);
184
184
  const bitrateKbps = Number(entry?.bitrateKbps);
185
185
  return (Number.isFinite(peakKbps) && peakKbps > 0) || (Number.isFinite(bitrateKbps) && bitrateKbps > 0);
@@ -11,7 +11,7 @@ function seekLandingOffsetFor(material, keyframe) {
11
11
  return next === undefined ? wanted : Math.min(wanted, (next - keyframe) / 2);
12
12
  }
13
13
 
14
- export function buildRunCommand({
14
+ function buildRunCommand({
15
15
  keyframes,
16
16
  inputFile,
17
17
  audioFile,
@@ -262,7 +262,7 @@ function streamOf(chunkId, streamCount) {
262
262
  }
263
263
 
264
264
  /** The packets an AVI index states, and the index bytes FFmpeg reads to learn them. */
265
- export class AviIndex {
265
+ class AviIndex {
266
266
  #held;
267
267
  #allocation;
268
268
  /** @type {Map<number, Uint32Array> | undefined} */
@@ -130,30 +130,3 @@ export function readFloat(buffer, offset, size) {
130
130
  }
131
131
  return null;
132
132
  }
133
-
134
- /**
135
- * Depth-first search for the first element with `id`, descending only into the
136
- * container ids listed in `descendInto`.
137
- *
138
- * @param {Buffer} buffer
139
- * @param {number} id - Element id to find.
140
- * @param {number[]} descendInto - Container ids worth entering.
141
- * @param {number} [start=0]
142
- * @param {number} [end=buffer.length]
143
- * @returns {{ dataOffset: number, size: number } | null}
144
- */
145
- export function findElement(buffer, id, descendInto, start = 0, end = buffer.length) {
146
- for (const element of iterateElements(buffer, start, end)) {
147
- if (element.id === id) {
148
- return { dataOffset: element.dataOffset, size: element.size };
149
- }
150
- if (descendInto.includes(element.id)) {
151
- const limit = Math.min(end, element.dataOffset + element.size);
152
- const found = findElement(buffer, id, descendInto, element.dataOffset, limit);
153
- if (found) {
154
- return found;
155
- }
156
- }
157
- }
158
- return null;
159
- }
@@ -29,7 +29,7 @@ export const MAX_HEADER_BYTES = 12;
29
29
  * @param {{ value: number, length: number }} size
30
30
  * @returns {boolean}
31
31
  */
32
- export function sizeIsUnknown(size) {
32
+ function sizeIsUnknown(size) {
33
33
  return size.value === 2 ** (7 * size.length) - 1;
34
34
  }
35
35
 
@@ -79,7 +79,7 @@ function simpleTags(buffer, start, end, out) {
79
79
  * @param {Buffer} data - The data of a `Tags` element.
80
80
  * @returns {Map<number, Array<{ name: string, value: string, isDefault: boolean }>>}
81
81
  */
82
- export function tagsByLevel(data) {
82
+ function tagsByLevel(data) {
83
83
  /** @type {Map<number, Array<{ name: string, value: string, isDefault: boolean }>>} */
84
84
  const levels = new Map();
85
85
  for (const tag of iterateElements(data)) {
@@ -18,10 +18,10 @@
18
18
  */
19
19
 
20
20
  /** Longest text taken from one field, in characters. A stated limit. */
21
- export const MAX_TEXT = 2000;
21
+ const MAX_TEXT = 2000;
22
22
 
23
23
  /** Most genres, track titles or chapter titles kept. A stated limit. */
24
- export const MAX_LIST = 32;
24
+ const MAX_LIST = 32;
25
25
 
26
26
  /** Largest cover image read, in bytes. A stated limit: covers measured in #136 are tens of kilobytes. */
27
27
  export const MAX_COVER_BYTES = 4 * 1024 * 1024;
@@ -33,27 +33,6 @@ export function parseFfmpegDurationSeconds(stderrText) {
33
33
  return hours * 3600 + minutes * 60 + seconds;
34
34
  }
35
35
 
36
- /**
37
- * Parse the container start time (seconds) from ffmpeg's "Duration: …, start:
38
- * X, …" line. Many MKVs report a small non-zero start (e.g. 0.1 s); preserving
39
- * it via `-copyts` would put a hole at the beginning, so we normalize it away.
40
- * Returns 0 when absent.
41
- *
42
- * @param {string} stderrText
43
- * @returns {number}
44
- */
45
- export function parseFfmpegStartTimeSeconds(stderrText) {
46
- if (typeof stderrText !== "string" || stderrText.length === 0) {
47
- return 0;
48
- }
49
- const match = stderrText.match(/Duration:[^\n]*?start:\s*(-?\d+(?:\.\d+)?)/i);
50
- if (!match) {
51
- return 0;
52
- }
53
- const value = Number(match[1]);
54
- return Number.isFinite(value) ? value : 0;
55
- }
56
-
57
36
  /**
58
37
  * Parse the bitrate (kbit/s) that the decode cost is priced from: the VIDEO
59
38
  * stream's own, falling back to the container's when the stream does not state
@@ -195,72 +174,3 @@ export function parseFfmpegVideoFps(stderrText) {
195
174
  const value = Number(match[1]);
196
175
  return Number.isFinite(value) && value > 0 ? value : null;
197
176
  }
198
-
199
- /**
200
- * Detect an HDR / wide-gamut source from the ffmpeg "Video:" line's colour
201
- * metadata. HDR is identified by the transfer function — `smpte2084` (PQ /
202
- * HDR10) or `arib-std-b67` (HLG). Re-encoding such a source to 8-bit SDR
203
- * without tone mapping produces a washed-out, desaturated picture, so this
204
- * gates the tonemap filter chain.
205
- *
206
- * @param {string} stderrText
207
- * @returns {boolean}
208
- */
209
- export function parseFfmpegHdr(stderrText) {
210
- if (typeof stderrText !== "string" || stderrText.length === 0) {
211
- return false;
212
- }
213
- const videoLine = stderrText.match(/Video:[^\n]*/i);
214
- if (!videoLine) {
215
- return false;
216
- }
217
- // ffmpeg prints the colour info in parentheses, e.g.
218
- // "yuv420p10le(tv, bt2020nc/bt2020/smpte2084)". The transfer (last token) is
219
- // the reliable HDR signal.
220
- return /\b(smpte2084|arib-std-b67|arib_std_b67)\b/i.test(videoLine[0]);
221
- }
222
-
223
- /**
224
- * How many bits per sample the video carries, from the pixel format ffmpeg
225
- * prints on its `Video:` line.
226
- *
227
- * This is a decode-cost input, not a colour one: a 10-bit stream holds wider
228
- * samples and is decoded with wider arithmetic, and how much that costs is a
229
- * property of the machine. It is therefore its own calibration family rather
230
- * than a multiplier on the 8-bit one.
231
- *
232
- * ffmpeg names the depth in the format itself — `yuv420p10le`, `yuv420p12le`,
233
- * `p010le` — and omits it at 8 bits (`yuv420p`, `nv12`). An unreadable line
234
- * gives null, and the caller then prices the source as 8-bit, which is what
235
- * every source was priced as before this existed.
236
- *
237
- * @param {string} stderrText
238
- * @returns {number | null}
239
- */
240
- export function parseFfmpegBitDepth(stderrText) {
241
- if (typeof stderrText !== "string" || stderrText.length === 0) {
242
- return null;
243
- }
244
- const videoLine = stderrText.match(/Video:[^\n]*/i);
245
- if (!videoLine) {
246
- return null;
247
- }
248
- // The pixel format is the token after the codec and its tag, and the depth
249
- // rides on its name. `p010`/`p016` are the two that state it without a `p`
250
- // separator, and they are 10 and 16 bits.
251
- const named = videoLine[0].match(/\byuv[a-z0-9]*?p(\d{1,2})(?:le|be)\b/i);
252
- if (named) {
253
- const depth = Number(named[1]);
254
- return Number.isFinite(depth) && depth >= 8 ? depth : null;
255
- }
256
- if (/\bp010(?:le|be)?\b/i.test(videoLine[0])) {
257
- return 10;
258
- }
259
- if (/\bp016(?:le|be)?\b/i.test(videoLine[0])) {
260
- return 16;
261
- }
262
- if (/\byuv[a-z0-9]*p\b|\bnv12\b|\bnv21\b|\bgbrp\b/i.test(videoLine[0])) {
263
- return 8;
264
- }
265
- return null;
266
- }
@@ -16,7 +16,7 @@ import { logger } from "../../utils/logger.js";
16
16
  import { cutsAtGivenTimes, isOutputName, PLAYLIST_FILE_NAME } from "../encode/output/index.js";
17
17
  // The index of variants. Served from the same route as the media playlist, so
18
18
  // it needs no path of its own.
19
- export const MASTER_PLAYLIST_FILE_NAME = "master.m3u8";
19
+ const MASTER_PLAYLIST_FILE_NAME = "master.m3u8";
20
20
  // Read segment files in large blocks so the body is delivered to the data
21
21
  // channel in few, big chunks. On a busy ARM host the in-process WebTorrent
22
22
  // hashing starves the event loop in bursts, so fewer read iterations means
@@ -584,8 +584,7 @@ export class ViewerRequests {
584
584
  output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined
585
585
  };
586
586
  });
587
- const sourceIndexes = [output.spec.video?.fileIndex, output.spec.audio?.fileIndex]
588
- .filter((fileIndex) => Number.isInteger(fileIndex) && fileIndex >= 0);
587
+ const sourceIndexes = [...output.spec.sourceFileIndexes];
589
588
  if (sourceIndexes.length === 0) {
590
589
  sourceIndexes.push(output.file.fileIndex);
591
590
  }
@@ -17,7 +17,7 @@ import path from "node:path";
17
17
  import { logger } from "../../utils/logger.js";
18
18
 
19
19
  /** How many to keep, newest first. */
20
- export const CORE_DUMPS_KEPT = 2;
20
+ const CORE_DUMPS_KEPT = 2;
21
21
 
22
22
  /**
23
23
  * Which dumps to remove, given what is there.
@@ -26,7 +26,7 @@ import v8 from "node:v8";
26
26
  import path from "node:path";
27
27
 
28
28
  /** How often the reading is taken and written. */
29
- export const MEMORY_REPORT_INTERVAL_MS = 60_000;
29
+ const MEMORY_REPORT_INTERVAL_MS = 60_000;
30
30
 
31
31
  /**
32
32
  * How often the torrent worker takes its own reading.
@@ -57,7 +57,7 @@ export const WORKER_MEMORY_SAMPLE_MS = 1_000;
57
57
  *
58
58
  * @returns {{ rss: number, heapUsed: number, heapTotal: number, external: number, arrayBuffers: number, heapLimit: number }}
59
59
  */
60
- export function readProcessMemory() {
60
+ function readProcessMemory() {
61
61
  const usage = process.memoryUsage();
62
62
  let heapLimit = 0;
63
63
  try {
@@ -158,7 +158,7 @@ const LARGE_MAPPING_BYTES = 2 * 1024 * 1024;
158
158
  *
159
159
  * @returns {Promise<ReturnType<typeof summariseMappings> | null>}
160
160
  */
161
- export async function readMappingSummary() {
161
+ async function readMappingSummary() {
162
162
  try {
163
163
  return summariseMappings(await readFile("/proc/self/smaps", "utf8"));
164
164
  } catch {
@@ -181,7 +181,7 @@ export async function readMappingSummary() {
181
181
  *
182
182
  * @returns {Promise<number | null>} Bytes, or null off Linux.
183
183
  */
184
- export async function readAnonymousMemory() {
184
+ async function readAnonymousMemory() {
185
185
  try {
186
186
  const text = await readFile("/proc/self/smaps_rollup", "utf8");
187
187
  const match = /^Anonymous:\s+(\d+)\s+kB$/m.exec(text);
@@ -206,7 +206,7 @@ export async function readAnonymousMemory() {
206
206
  * @param {string} directory
207
207
  * @returns {Promise<number | null>} Bytes free, or null where it cannot be read.
208
208
  */
209
- export async function readDiskFree(directory) {
209
+ async function readDiskFree(directory) {
210
210
  try {
211
211
  const stats = await statfs(directory);
212
212
  return Number(stats.bavail) * Number(stats.bsize);
@@ -46,7 +46,7 @@ import os from "node:os";
46
46
  import path from "node:path";
47
47
 
48
48
  /** Where every output's segments live. One root for the process. */
49
- export const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
49
+ const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
50
50
 
51
51
  /** The file inside each directory that says which output it holds. */
52
52
  const KEY_FILE = "key.txt";
@@ -17,7 +17,7 @@
17
17
  * about pieces — so it is testable with numbers alone.
18
18
  */
19
19
 
20
- import { isConditional, Urgency, URGENCY_ORDER } from "./Urgency.js";
20
+ import { isConditional, URGENCY_ORDER } from "./Urgency.js";
21
21
  import { unionOf, Window } from "./Window.js";
22
22
 
23
23
  export class DemandRegister {
@@ -208,4 +208,3 @@ export class DemandRegister {
208
208
  }
209
209
  }
210
210
 
211
- export { Urgency };
@@ -4,5 +4,5 @@
4
4
  // still used there.
5
5
  export { DemandRegister } from "./DemandRegister.js";
6
6
  export { Window } from "./Window.js";
7
- export { isConditional, selectionPriority, Urgency, urgencyName } from "./Urgency.js";
8
- export { bytesOf, piecesOf } from "./pieces.js";
7
+ export { isConditional, Urgency, urgencyName } from "./Urgency.js";
8
+ export { piecesOf } from "./pieces.js";
@@ -43,38 +43,6 @@ export function piecesOf({ fileOffset, byteStart, byteEnd, pieceLength }) {
43
43
  };
44
44
  }
45
45
 
46
- /**
47
- * The bytes of a file that a piece range covers, clamped to the file.
48
- *
49
- * The inverse of {@link piecesOf}, and here for the same reason: a caller that
50
- * still thinks in pieces — the reader, which walks a file piece by piece
51
- * because that is what arrives — states its need in bytes like everyone else,
52
- * and the conversion stays in this one file rather than being written out
53
- * again at the boundary.
54
- *
55
- * @param {object} params
56
- * @param {number} params.fileOffset - Where the file starts within the torrent.
57
- * @param {number} params.fileLength
58
- * @param {number} params.from - First piece, inclusive.
59
- * @param {number} params.to - Last piece, inclusive.
60
- * @param {number} params.pieceLength
61
- * @returns {{ byteStart: number, byteEnd: number } | null}
62
- */
63
- export function bytesOf({ fileOffset, fileLength, from, to, pieceLength }) {
64
- if (!Number.isFinite(pieceLength) || pieceLength <= 0) {
65
- return null;
66
- }
67
- if (!Number.isFinite(fileOffset) || !Number.isFinite(fileLength) || fileLength <= 0) {
68
- return null;
69
- }
70
- if (!Number.isInteger(from) || !Number.isInteger(to) || to < from) {
71
- return null;
72
- }
73
- const byteStart = Math.max(0, from * pieceLength - fileOffset);
74
- const byteEnd = Math.min(fileLength - 1, (to + 1) * pieceLength - 1 - fileOffset);
75
- return byteEnd < byteStart ? null : { byteStart, byteEnd };
76
- }
77
-
78
46
  /**
79
47
  * How many pieces a number of bytes needs, at worst.
80
48
  *
@@ -164,28 +164,6 @@ export function countVideoFiles(files) {
164
164
  return count;
165
165
  }
166
166
 
167
- /**
168
- * Subtitle formats that are text, and therefore small by construction — a whole
169
- * episode of dialogue is tens of kilobytes.
170
- *
171
- * The distinction is used to decide how much of such a file to fetch ahead of
172
- * the viewer: a text file is fetched WHOLE, because it is smaller than the
173
- * torrent's own piece and reading its edges would cost the same pieces as
174
- * reading all of it. An image-based one (`.sup`, `.sub`+`.idx`) is a picture per
175
- * cue and runs to tens of megabytes, so it gets the same edges treatment as
176
- * anything else.
177
- */
178
- export const TEXT_SUBTITLE_SIDECAR_EXTENSIONS = new Set([
179
- ".srt",
180
- ".ass",
181
- ".ssa",
182
- ".vtt",
183
- ".webvtt",
184
- ".ttml",
185
- ".smi",
186
- ".txt"
187
- ]);
188
-
189
167
  /**
190
168
  * Extensions whose track table a `Container` subclass can read. Everything else
191
169
  * in {@link AUDIO_SIDECAR_EXTENSIONS} is a bare stream: one track, no metadata.
@@ -224,7 +202,7 @@ export function baseNameOf(name) {
224
202
  * @param {string} extension - Lowercased, with the dot.
225
203
  * @returns {boolean}
226
204
  */
227
- export function declaresItsOwnTracks(extension) {
205
+ function declaresItsOwnTracks(extension) {
228
206
  return CONTAINER_BACKED_AUDIO.has(extension);
229
207
  }
230
208
 
@@ -331,7 +309,7 @@ export function namesPair(sidecarName, videoName) {
331
309
  * @param {string} videoName
332
310
  * @returns {boolean}
333
311
  */
334
- export function imageNamesPair(imageName, videoName) {
312
+ function imageNamesPair(imageName, videoName) {
335
313
  if (sameRelease(imageName, videoName)) {
336
314
  return true;
337
315
  }
@@ -614,7 +614,7 @@ function pieceDownloadedBytes(torrent, index) {
614
614
  * @param {import("webtorrent").Torrent} torrent
615
615
  * @returns {number}
616
616
  */
617
- export function torrentDownloadedBytes(torrent) {
617
+ function torrentDownloadedBytes(torrent) {
618
618
  if (!torrent?.bitfield || !Array.isArray(torrent.pieces)) {
619
619
  return 0;
620
620
  }
@@ -634,7 +634,7 @@ export function torrentDownloadedBytes(torrent) {
634
634
  * @param {import("webtorrent").TorrentFile & { _startPiece?: number, _endPiece?: number, offset?: number, length?: number }} file
635
635
  * @returns {number}
636
636
  */
637
- export function fileDownloadedBytes(torrent, file) {
637
+ function fileDownloadedBytes(torrent, file) {
638
638
  if (!torrent?.bitfield || !Array.isArray(torrent.pieces) || !file) {
639
639
  return 0;
640
640
  }
@@ -24,7 +24,7 @@ export function workerReplyError(message, fallback = "Torrent worker request fai
24
24
  *
25
25
  * @returns {() => number}
26
26
  */
27
- export function createRequestIds() {
27
+ function createRequestIds() {
28
28
  let next = 0;
29
29
  return () => (next += 1);
30
30
  }
@@ -326,7 +326,7 @@ export function duplicateTailFor(torrent, pieceIndex) {
326
326
  * @param {number} pieceIndex
327
327
  * @returns {boolean}
328
328
  */
329
- export function pieceIsFullyReserved(torrent, pieceIndex) {
329
+ function pieceIsFullyReserved(torrent, pieceIndex) {
330
330
  const piece = torrent?.pieces?.[pieceIndex];
331
331
  if (!piece || typeof piece.reserve !== "function") {
332
332
  return false;
@@ -30,41 +30,6 @@ import { minimumBufferFrom, requiredSpeedFrom } from "../supply-margin.js";
30
30
  const PIECE_WAIT_LOG_MS = 1_000;
31
31
 
32
32
 
33
- /**
34
- * Who is working on the piece a reader is blocked on, right now.
35
- *
36
- * The open question about a seek: a single 8 MiB piece takes 3.0-4.6 s to
37
- * arrive while the swarm as a whole is moving 4-6 MB/s, so roughly 2 MB/s is
38
- * reaching the piece that is actually being waited for. Whether that is because
39
- * few peers hold it, few are being asked, or each is slow cannot be told apart
40
- * from the outside — these three counts tell them apart.
41
- *
42
- * `wire.requests` is what has been asked of that peer and not yet answered; a
43
- * block is 16 KB, so `blocks x 16 KB` is the work in flight on this piece.
44
- *
45
- * @param {import("webtorrent").Torrent} torrent
46
- * @param {number} pieceIndex
47
- * @returns {{ peers: number, holders: number, askedOf: number, blocks: number }}
48
- */
49
- export function pieceSupply(torrent, pieceIndex) {
50
- const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
51
- let holders = 0;
52
- let askedOf = 0;
53
- let blocks = 0;
54
- for (const wire of wires) {
55
- if (wire?.peerPieces?.get?.(pieceIndex)) {
56
- holders += 1;
57
- }
58
- const requests = Array.isArray(wire?.requests) ? wire.requests : [];
59
- const forThisPiece = requests.filter((request) => request?.piece === pieceIndex).length;
60
- if (forThisPiece > 0) {
61
- askedOf += 1;
62
- blocks += forThisPiece;
63
- }
64
- }
65
- return { peers: wires.length, holders, askedOf, blocks };
66
- }
67
-
68
33
  /**
69
34
  * Wait until a piece has been downloaded and verified.
70
35
  *
@@ -286,80 +251,6 @@ function describeWaitLevels(key) {
286
251
  */
287
252
  const waitsByLevel = new Map();
288
253
 
289
- /**
290
- * How many readers are blocked on a torrent AT THIS MOMENT, by infohash.
291
- *
292
- * Not a history and not an average: the question it answers is "is anything the
293
- * viewer is watching waiting for the swarm right now", and the only honest
294
- * answer is a count of readers currently inside a wait.
295
- *
296
- * It exists so that work which is NOT what the viewer is watching — fetching a
297
- * soundtrack or a subtitle file they may switch to later — can proceed while the
298
- * swarm has room and stand aside the instant it does not. That ordering is the
299
- * whole of the requirement: the picture and the track being played come first,
300
- * the other tracks next, and reading the film far ahead last.
301
- *
302
- * @type {Map<string, number>}
303
- */
304
- const blockedReaders = new Map();
305
-
306
- /**
307
- * How many stalls a torrent's readers have had, ever. Only differences between
308
- * two readings of it mean anything.
309
- *
310
- * @type {Map<string, number>}
311
- */
312
- const stallsSeen = new Map();
313
-
314
- /**
315
- * Whether any reader on this torrent is waiting for a piece right now.
316
- *
317
- * @param {string} infoHash
318
- * @returns {boolean}
319
- */
320
- export function readersAreBlockedOn(infoHash) {
321
- return (blockedReaders.get(infoHash) ?? 0) > 0;
322
- }
323
-
324
- /**
325
- * How many times a reader on this torrent has been blocked since the process
326
- * started.
327
- *
328
- * Exists so that work of lower importance can ask "did the viewer stall while I
329
- * was busy?" — which is a different and stricter question than "is the viewer
330
- * stalled right now". On a swarm delivering exactly what the film needs, a
331
- * background fetch that only pauses DURING a stall still takes bandwidth
332
- * between them, and the stalls are the proof it had none to spare. Field
333
- * 2026-08-31: the swarm delivered 200-600 KB/s against the 399 KB/s the film
334
- * needs, and the picture stood still 145.6 s.
335
- *
336
- * @param {string} infoHash
337
- * @returns {number}
338
- */
339
- export function stallsSeenOn(infoHash) {
340
- return stallsSeen.get(infoHash) ?? 0;
341
- }
342
-
343
- /**
344
- * @param {string} infoHash
345
- * @param {number} delta
346
- * @returns {void}
347
- */
348
- function countBlockedReader(infoHash, delta) {
349
- if (!infoHash) {
350
- return;
351
- }
352
- if (delta > 0) {
353
- stallsSeen.set(infoHash, (stallsSeen.get(infoHash) ?? 0) + 1);
354
- }
355
- const next = (blockedReaders.get(infoHash) ?? 0) + delta;
356
- if (next > 0) {
357
- blockedReaders.set(infoHash, next);
358
- return;
359
- }
360
- blockedReaders.delete(infoHash);
361
- }
362
-
363
254
  function noteSupplyWait(key, label, waitedMs) {
364
255
  const history = supplyWaits.get(key) ?? [];
365
256
  history.push({ waitedMs, at: Date.now() });
@@ -427,9 +318,7 @@ export async function* readFragments({ torrent, fileIndex, start, end, cancellat
427
318
  let retries = 0;
428
319
  while (true) {
429
320
  const waitAt = Date.now();
430
- countBlockedReader(torrent.infoHash, 1);
431
- try { await whenPieceReady(torrent, index, cancellation, wanted); }
432
- finally { countBlockedReader(torrent.infoHash, -1); }
321
+ await whenPieceReady(torrent, index, cancellation, wanted);
433
322
  const waitedMs = Date.now() - waitAt;
434
323
  if (index > first && waitedMs >= PIECE_WAIT_LOG_MS) {
435
324
  const key = `${torrent.infoHash ?? "?"}/${file.name ?? "?"}`;
@@ -27,11 +27,12 @@
27
27
  * standard interface outside, ownership transfer inside. Callers cannot tell
28
28
  * the difference; the cost is a tenth of a percent of a segment's playing time.
29
29
  *
30
- * Chunk size follows from the same measurements: a round trip costs ~100 µs, so
31
- * 64 KB chunks would spend 13 ms per segment on overhead against 0.5 ms sent
32
- * whole. {@link STREAM_CHUNK_BYTES} of 1 MB puts a 10 MB segment at ten
33
- * messages — about 1 ms — while still allowing a read to be cancelled promptly
34
- * and keeping peak memory bounded.
30
+ * The unit of one message follows from the same measurements: a round trip
31
+ * costs ~100 µs, so 64 KB chunks would spend 13 ms per segment on overhead
32
+ * against 0.5 ms sent whole. A read crosses as one message per PIECE fragment —
33
+ * its position in shared memory, not its bytes (`piece-reader.js`) — so a
34
+ * segment of a few pieces is a few messages, and a read is still cancelled at
35
+ * the next fragment.
35
36
  *
36
37
  * Torrent objects cannot cross a thread boundary, so the main thread names them
37
38
  * by `sourceKey` (the identifier the registry already uses) and the worker owns
@@ -164,11 +165,6 @@ export const Event = {
164
165
  HOLDINGS_CHANGED: "holdings-changed"
165
166
  };
166
167
 
167
- /**
168
- * Bytes per CHUNK message. See the file header for why 1 MB.
169
- */
170
- export const STREAM_CHUNK_BYTES = 1024 * 1024;
171
-
172
168
  /**
173
169
  * How many chunks may be in flight before the worker waits for an acknowledgement.
174
170
  *
@@ -217,11 +217,11 @@ function sendFragment(id, fragment) {
217
217
  /**
218
218
  * Stream a byte range back as CHUNK messages.
219
219
  *
220
- * Reads through WebTorrent's own read stream — which serves already-downloaded
221
- * pieces from disk and waits for the rest — and forwards it in
222
- * {@link STREAM_CHUNK_BYTES} pieces, transferring ownership of each so nothing
223
- * is copied across the boundary. `createSendStream` applies the backpressure,
224
- * so a fast disk cannot outrun the main thread and rebuild the queue in memory.
220
+ * Reads the range piece by piece (`readFragments`), which waits for what has
221
+ * not arrived, and forwards each fragment as its position in shared memory, so
222
+ * nothing is copied across the boundary. `createSendStream` applies the
223
+ * backpressure, so a fast disk cannot outrun the main thread and rebuild the
224
+ * queue in memory.
225
225
  *
226
226
  * @param {object} params
227
227
  * @param {number} params.id - Request id; CHUNK/READ_END carry it.
@@ -133,7 +133,7 @@ export function allowedGap({
133
133
  * echoIntervalMs?: number, peerLoopLagMs?: number, intervalMs?: number }} state
134
134
  * @returns {number}
135
135
  */
136
- export function allowedWaitMs({
136
+ function allowedWaitMs({
137
137
  queuedBytes,
138
138
  bytesPerSecond,
139
139
  rttMs,
@@ -40,7 +40,7 @@ import os from "node:os";
40
40
  import path from "node:path";
41
41
 
42
42
  /** Per-packet capture length. Small: headers are what the signatures need. */
43
- export const WITNESS_SNAPLEN_BYTES = 128;
43
+ const WITNESS_SNAPLEN_BYTES = 128;
44
44
 
45
45
  /**
46
46
  * usrsctp's maximum retransmission timeout, seconds (its `RTO.max` default).
@@ -66,19 +66,19 @@ export const WITNESS_RING_FILE_MB = 16;
66
66
  export const WITNESS_RING_FILES = 4;
67
67
 
68
68
  /** Megabytes per tail file. A wedged session emits a few packets a second. */
69
- export const WITNESS_TAIL_FILE_MB = 8;
69
+ const WITNESS_TAIL_FILE_MB = 8;
70
70
 
71
71
  /** How many tail files may be written before tcpdump stops on its own. */
72
- export const WITNESS_TAIL_FILES = 2;
72
+ const WITNESS_TAIL_FILES = 2;
73
73
 
74
74
  /** Extra time before the SIGKILL fallback lands on a hanging tcpdump. */
75
- export const WITNESS_KILL_GRACE_MS = 5_000;
75
+ const WITNESS_KILL_GRACE_MS = 5_000;
76
76
 
77
77
  /** Minimum spacing between tail captures, whatever the reason for them. */
78
78
  export const WITNESS_COOLDOWN_MS = 10 * 60_000;
79
79
 
80
80
  /** How many old captures survive at startup, newest first. */
81
- export const WITNESS_CAPTURES_KEPT = 8;
81
+ const WITNESS_CAPTURES_KEPT = 8;
82
82
 
83
83
  /** Base name of the rolling ring, before tcpdump appends its file number. */
84
84
  export const WITNESS_RING_BASENAME = "packet-witness-ring.pcap";
@@ -39,7 +39,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
39
39
  export const SCTPSTATE_SCRIPT_PATH = path.join(HERE, "..", "..", "assets", "diagnostics", "sctpstate.gdb");
40
40
 
41
41
  /** How long gdb may run before it is killed. Generous: this is a rare, one-shot read. */
42
- export const GDB_TIMEOUT_MS = 15_000;
42
+ const GDB_TIMEOUT_MS = 15_000;
43
43
 
44
44
  /**
45
45
  * Create the reader.
@@ -95,10 +95,10 @@
95
95
  * Direction, urgent preparation and attenuation are separate map fields;
96
96
  * priority alone cannot distinguish an attenuated second from a passed one.
97
97
  */
98
- export const AT_A_WATCHING_VIEWER = 100;
98
+ const AT_A_WATCHING_VIEWER = 100;
99
99
 
100
100
  /** Behind everybody. */
101
- export const NOBODY_IS_COMING = 1;
101
+ const NOBODY_IS_COMING = 1;
102
102
 
103
103
  /** Nothing is wanted here at all. */
104
104
  const NOTHING = 0;
@@ -401,31 +401,3 @@ export function inWorkingOrder(runs) {
401
401
  (left, right) => right.priority - left.priority || left.from - right.from
402
402
  );
403
403
  }
404
-
405
- /**
406
- * The earliest second of film anybody is waiting on, or null when nobody is.
407
- *
408
- * WHY A SOUNDTRACK ASKS THE MAP AND NOT A VIEWER. Sound is produced for the
409
- * same reason a picture is — somebody is about to reach it — and the map is
410
- * where that is already stated, in seconds of film, merged over everybody
411
- * watching. Reading it from a viewer instead means the encoding layer holds a
412
- * person, and then every question about which person it is (the earliest of
413
- * two, the one on this rung, the one who has not reported yet) has to be
414
- * answered again in a second place. The map answers it once.
415
- *
416
- * The EARLIEST among the most urgent, because a track started at the leader's
417
- * position has nothing to give the viewer behind them, and the map's own order
418
- * — most urgent first, earliest film first within a priority — already says
419
- * which that is.
420
- *
421
- * @param {DemandZone[]} runs - From {@link runsOf}.
422
- * @returns {number | null} Seconds.
423
- */
424
- export function earliestUrgentSecond(runs) {
425
- const wanted = (runs ?? []).filter((zone) => zone && !zone.behind);
426
- if (wanted.length === 0) {
427
- return null;
428
- }
429
- const top = Math.max(...wanted.map((zone) => Number(zone.priority) || 0));
430
- return Math.min(...wanted.filter((zone) => (Number(zone.priority) || 0) === top).map((zone) => zone.from));
431
- }
@@ -216,23 +216,34 @@ export class PriorityOrchestrator {
216
216
  if (typeof sourceKey !== "string" || !sourceKey || !Number.isSafeInteger(fileIndex) || fileIndex < 0) {
217
217
  throw new TypeError("A priority map requires its output file's source and file index.");
218
218
  }
219
- const key = `${sourceKey}:${fileIndex}`;
220
219
  const durationSeconds = Number(session.file?.durationSeconds) || 0;
221
220
  // The first band is as wide as an interruption this file has actually
222
221
  // shown on this swarm, never a chosen number.
223
222
  const allowanceSeconds = this.#allowanceFor(session);
224
- let held = byFile.get(key);
225
- if (!held) {
226
- held = {
227
- sourceKey,
228
- fileIndex,
229
- durationSeconds,
230
- allowanceSeconds,
231
- viewers: [],
232
- demandKey: []
233
- };
234
- byFile.set(key, held);
235
- }
223
+ // EVERY FILE THE OUTPUT READS, not only the one it is named after. A
224
+ // soundtrack shipped beside the picture is an output of the picture's
225
+ // film whose bytes live in another file; mapped under the picture's
226
+ // file only, nobody asked the swarm for that file, its encoder waited
227
+ // for pieces for ever and the viewer who chose the soundtrack stood
228
+ // still (Home Assistant 2026-10-09, torrent-tv/meta#8).
229
+ const read = session.spec?.sourceFileIndexes;
230
+ const files = Array.isArray(read) && read.length ? read : [fileIndex];
231
+ const helds = files.map((index) => {
232
+ const fileKey = `${sourceKey}:${index}`;
233
+ let held = byFile.get(fileKey);
234
+ if (!held) {
235
+ held = {
236
+ sourceKey,
237
+ fileIndex: index,
238
+ durationSeconds,
239
+ allowanceSeconds,
240
+ viewers: [],
241
+ demandKey: []
242
+ };
243
+ byFile.set(fileKey, held);
244
+ }
245
+ return held;
246
+ });
236
247
  // Every output anybody holds a session for, whether or not a viewer is
237
248
  // consuming it — an output with nobody on it must get a map with
238
249
  // nothing in it, which is how the plan is told to stop its encoders.
@@ -240,16 +251,19 @@ export class PriorityOrchestrator {
240
251
  const address = session.outputKey ?? "";
241
252
  let mine = byOutput.get(address);
242
253
  if (!mine) {
243
- mine = { durationSeconds, allowanceSeconds, viewers: [], fileKey: key };
254
+ // Counted over a file the output itself reads, which is always among
255
+ // the files mapped above; the picture's file is not one for a
256
+ // soundtrack read from a file of its own.
257
+ mine = { durationSeconds, allowanceSeconds, viewers: [], fileKey: `${sourceKey}:${files[0]}` };
244
258
  byOutput.set(address, mine);
245
259
  }
246
260
  for (const viewer of this.#viewers.forOutput(session).values()) {
247
261
  if (!viewer.isPresent()) {
248
262
  continue;
249
263
  }
250
- held.demandKey.push([session.outputKey, viewer.id, viewer.audio?.trackIndex,
264
+ const demand = [session.outputKey, viewer.id, viewer.audio?.trackIndex,
251
265
  viewer.audio?.transcode, viewer.activeVariantId, viewer.warmingVariantId,
252
- viewer.warmingAudioId, this.#watchedBy(session, viewer)]);
266
+ viewer.warmingAudioId, this.#watchedBy(session, viewer)];
253
267
  const stated = {
254
268
  id: viewer.id,
255
269
  atSeconds: viewer.positionSeconds(now) ?? 0,
@@ -267,7 +281,10 @@ export class PriorityOrchestrator {
267
281
  ? viewer.wantsFilmNow()
268
282
  : viewer.playing === true || viewer.waiting === true)
269
283
  };
270
- held.viewers.push(stated);
284
+ for (const held of helds) {
285
+ held.demandKey.push(demand);
286
+ held.viewers.push(stated);
287
+ }
271
288
  if (this.#watchedBy(session, viewer)) {
272
289
  mine.viewers.push(stated);
273
290
  }
@@ -319,20 +319,6 @@ export function visiblePictureOf(viewers, output, consumerId) {
319
319
  return size ? { ...size } : null;
320
320
  }
321
321
 
322
- /**
323
- * Take the picture as this viewer's page measured it, sent with the request
324
- * that opened their output.
325
- *
326
- * @param {object} viewers
327
- * @param {string} consumerId
328
- * @param {unknown} size
329
- * @returns {void}
330
- */
331
- export function noteVisiblePicture(viewers, consumerId, size) {
332
- const viewer = consumerId ? viewers.get(consumerId) : null;
333
- viewer?.noteVisiblePicture(size);
334
- }
335
-
336
322
  /**
337
323
  * Which way this viewer's buffer is going, and how much film it holds, as their
338
324
  * page last said (`viewer/buffer-trend.js`).
@@ -162,7 +162,7 @@ export function subtract(left, right) {
162
162
  * @param {bigint} factor
163
163
  * @returns {MediaTime}
164
164
  */
165
- export function multiply(time, factor) {
165
+ function multiply(time, factor) {
166
166
  return { ticks: time.ticks * factor, timescale: time.timescale };
167
167
  }
168
168