@torrent-tv/proxy 2.107.56 → 2.107.58

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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @file What an encoder run reads: the input of a piece's run and the sound's
3
+ * packets walked from the picture's copy. Moved from the composition root
4
+ * (torrent-tv/meta#275, step 5 of #270); every collaborator is a port, so this
5
+ * file imports nothing.
6
+ */
7
+
8
+ export class EncodeInputResolution {
9
+ #paramsFor;
10
+ #sourceRanges;
11
+ #selectTracks;
12
+ #readingInterval;
13
+ #trackFor;
14
+ #declaredTracks;
15
+ #known;
16
+ #outputs;
17
+ #log;
18
+ #warn;
19
+ #now;
20
+
21
+ /**
22
+ * @param {object} ports
23
+ * @param {(request: object) => Promise<object | null>} ports.paramsFor MediaReadParams#paramsFor
24
+ * @param {object} ports.sourceRanges SegmentSourceRanges (tracksOf, shiftOf, navigable, readInput)
25
+ * @param {Function} ports.selectTracks segment-reading
26
+ * @param {Function} ports.readingInterval segment-reading
27
+ * @param {Function} ports.trackFor segment-reading
28
+ * @param {(sourceKey: string, fileIndex: number) => object[] | undefined} ports.declaredTracks
29
+ * @param {(sourceKey: string, fileIndex: number) => object | null | undefined} ports.known
30
+ * @param {() => Iterable<object>} ports.outputs the output catalog's values
31
+ * @param {(line: string) => void} ports.log
32
+ * @param {(line: string) => void} ports.warn
33
+ * @param {() => number} [ports.now]
34
+ */
35
+ constructor({ paramsFor, sourceRanges, selectTracks, readingInterval, trackFor, declaredTracks, known, outputs, log, warn, now = Date.now }) {
36
+ this.#paramsFor = paramsFor;
37
+ this.#sourceRanges = sourceRanges;
38
+ this.#selectTracks = selectTracks;
39
+ this.#readingInterval = readingInterval;
40
+ this.#trackFor = trackFor;
41
+ this.#declaredTracks = declaredTracks;
42
+ this.#known = known;
43
+ this.#outputs = outputs;
44
+ this.#log = log;
45
+ this.#warn = warn;
46
+ this.#now = now;
47
+ }
48
+
49
+ // `timing`, when given, is told how long each read of a file waited behind
50
+ // the reads asked before it and how long it took (torrent-tv/meta#166).
51
+ async resolve(output, fromIndex, toIndex, timing = null, isCurrent = null, signal = null, readPriority = null) {
52
+ const grid = output.timeline?.published ?? output.timeline?.boundaries;
53
+ const from = grid?.[fromIndex], to = grid?.[toIndex + 1];
54
+ if (!Number.isFinite(from) || !(to > from)) return { kind: "terminal", reason: "output-interval-not-declared" };
55
+ const selected = new Map();
56
+ for (const type of ["video", "audio"]) {
57
+ const spec = output.spec[type];
58
+ if (!spec) continue;
59
+ const list = selected.get(spec.fileIndex) ?? [];
60
+ list.push({ type, index: type === "video" ? null : spec.trackIndex,
61
+ mode: type === "video" ? spec.encode ? "transcode" : "copy" : spec.transcode ? "transcode" : "copy" });
62
+ selected.set(spec.fileIndex, list);
63
+ }
64
+ // Every selected file is read the same way in one run: from the original
65
+ // file when each container can name the bytes the interval needs, from
66
+ // reassembled packets otherwise.
67
+ const files = [];
68
+ for (const [fileIndex, choices] of selected) {
69
+ const params = await this.#paramsFor({ sourceKey: output.file.sourceKey, fileIndex,
70
+ requestId: readPriority?.requestId, readPriority, signal });
71
+ if (!params) return { kind: "needs-source" };
72
+ if (isCurrent) params.isCurrent = isCurrent;
73
+ if (signal) params.signal = signal;
74
+ if (timing) {
75
+ params.walkAccount = timing.walk ??= { lockMs: 0, reads: 0, bytes: 0 };
76
+ params.onTimed = (statement, waitedMs, readMs) => {
77
+ timing.reads += 1;
78
+ timing.queuedMs += waitedMs;
79
+ timing.readMs += readMs;
80
+ const measured = timing.readsByStatement[statement] ??= { queuedMs: 0, readMs: 0 };
81
+ measured.queuedMs += waitedMs;
82
+ measured.readMs += readMs;
83
+ };
84
+ }
85
+ const tracks = await this.#sourceRanges.tracksOf(params);
86
+ if (tracks.kind !== "result") return tracks;
87
+ const selection = this.#selectTracks(tracks.trackList, choices);
88
+ if (!selection) return { kind: "terminal", reason: "selected-track-is-absent" };
89
+ const { wanted, modes } = selection;
90
+ const media = await this.#sourceRanges.shiftOf(params);
91
+ if (media.kind !== "result") return media;
92
+ const timeShiftSeconds = media.shift;
93
+ const sourceInterval = output.timeline.sourceInterval(fromIndex, toIndex, timeShiftSeconds);
94
+ params.packetInterval = this.#readingInterval(selection, sourceInterval);
95
+ const navigation = await this.#sourceRanges.navigable(params);
96
+ if (navigation.kind !== "result") return navigation;
97
+ files.push({ fileIndex, choices, params, wanted, modes, timeShiftSeconds, sourceInterval, navigable: navigation.value });
98
+ }
99
+ const original = output.segmentFormat.supportsOriginalInput === true && files.every(file => file.navigable);
100
+ const sources = [];
101
+ for (const { fileIndex, choices, params, wanted, modes, timeShiftSeconds, sourceInterval } of files) {
102
+ const read = await this.#sourceRanges.readInput(params, { original, tracks: wanted, modes, interval: sourceInterval });
103
+ if (read.kind !== "result") return read.answer;
104
+ if (original) {
105
+ sources.push({ sourceKey: params.sourceKey, fileIndex, timeShiftSeconds,
106
+ input: { ...read.input, original: true, selections: wanted.map(track => ({ track,
107
+ index: track.declaredIndex ?? choices.find(choice => choice.type === track.type)?.index ?? 0 })) } });
108
+ continue;
109
+ }
110
+ sources.push({ sourceKey: params.sourceKey, fileIndex, input: read.input, timeShiftSeconds });
111
+ }
112
+ return { kind: "result", sources };
113
+ }
114
+
115
+ // THE SOUND'S PACKETS FROM THE PICTURE'S COPY. A Matroska soundtrack is
116
+ // fed to its encoder packet by packet, and finding its packets walks the
117
+ // clusters the Cues name for the piece — picture and sound together,
118
+ // about 20 MB for 0.3 MB of sound on the field's 1080p film. The picture's
119
+ // input copies those same clusters through the torrent thread first, and
120
+ // walked a second time there each soundtrack piece took 6-11 s, slower
121
+ // than the film plays (r61, torrent-tv/meta#178). Walked from the copy
122
+ // while it is held, the sound reads only its own packets later.
123
+ stretchCopied(output, from, to, input) {
124
+ for (const source of input.sources ?? []) {
125
+ const container = this.#known(source.sourceKey, source.fileIndex);
126
+ const tracks = this.#declaredTracks(source.sourceKey, source.fileIndex);
127
+ if (typeof container?.learnPackets !== "function" || !Array.isArray(tracks)) continue;
128
+ const carried = new Set((source.input.selections ?? []).map(selection => selection.track?.trackNumber));
129
+ const sounds = new Set();
130
+ for (const other of this.#outputs()) {
131
+ const sound = other.spec?.audio;
132
+ if (other.spec?.video || !sound || other.file?.sourceKey !== source.sourceKey || sound.fileIndex !== source.fileIndex) continue;
133
+ const track = this.#trackFor(tracks, { type: "audio", index: sound.trackIndex });
134
+ if (track && !carried.has(track.trackNumber)) sounds.add(track.trackNumber);
135
+ }
136
+ if (!sounds.size || !Number.isFinite(source.input.from) || !(source.input.to > source.input.from)) continue;
137
+ const read = (start, end) => {
138
+ const answer = input.read(source.fileIndex, start, end);
139
+ return answer && typeof answer.then !== "function" && answer.end === end ? answer.bytes : null;
140
+ };
141
+ const holdsUntil = start => {
142
+ if (input.heldUntil) return input.heldUntil(source.fileIndex, start);
143
+ const range = source.input.ranges.find(([first, last]) => first <= start && start <= last);
144
+ return range ? range[1] + 1 : null;
145
+ };
146
+ const startedAt = this.#now();
147
+ const releaseInput = input.retain?.();
148
+ void container.learnPackets({ from: source.input.from, to: source.input.to, trackIds: [...sounds] }, read, holdsUntil).then(learned =>
149
+ this.#log(`encode input #${from}..#${to} of ${output.outputKey}: soundtrack packets of track(s) ${[...sounds].join(",")} ` +
150
+ `over ${source.input.from.toFixed(3)}-${source.input.to.toFixed(3)}s ` +
151
+ (learned === "walked" ? "walked" : `not all walked (${learned === "memory" ? "memory refused" : "the copy does not hold the rest"})`) +
152
+ ` from its copy in ${this.#now() - startedAt}ms`),
153
+ error => this.#warn(`encode input #${from}..#${to} of ${output.outputKey}: walking the soundtrack from its copy failed: ${error?.message ?? error}`))
154
+ .finally(() => releaseInput?.());
155
+ }
156
+ }
157
+ }
@@ -48,6 +48,19 @@ import path from "node:path";
48
48
  import { fileURLToPath } from "node:url";
49
49
  import { configurationKeyOf, kindOfEncoderName } from "./fingerprint.js";
50
50
 
51
+ /**
52
+ * The writer used when none is handed in (a check that only wants the file).
53
+ * The proxy always hands in the atomic one from `utils/atomic-json.js`, which
54
+ * this layer may not import.
55
+ *
56
+ * @param {string} filePath
57
+ * @param {unknown} value
58
+ * @returns {void}
59
+ */
60
+ function writeInPlace(filePath, value) {
61
+ writeFileSync(filePath, JSON.stringify(value));
62
+ }
63
+
51
64
  /**
52
65
  * The directory of the installed proxy, where these observations are kept when
53
66
  * the deployment names no state directory. Deliberately not the working
@@ -84,6 +97,8 @@ export class LocalObservations {
84
97
 
85
98
  /** @type {{ info: (m: string) => void, warn: (m: string) => void }} */
86
99
  #logger;
100
+ /** @type {(filePath: string, value: unknown) => void} */
101
+ #writeJson;
87
102
 
88
103
  /** @type {() => number} */
89
104
  #now;
@@ -99,16 +114,19 @@ export class LocalObservations {
99
114
  * @param {import("./fingerprint.js").HostFingerprint} params.fingerprint
100
115
  * @param {string} params.filePath - Where the readings are kept; empty keeps
101
116
  * them for this process only.
117
+ * @param {(filePath: string, value: unknown) => void} [params.writeJson] - How
118
+ * the file is saved; throws on failure. The atomic writer is handed in.
102
119
  * @param {{ info: (m: string) => void, warn: (m: string) => void }} [params.logger]
103
120
  * @param {() => number} [params.now]
104
121
  */
105
- constructor({ fingerprint, filePath, logger = null, now = Date.now }) {
122
+ constructor({ fingerprint, filePath, writeJson = writeInPlace, logger = null, now = Date.now }) {
106
123
  if (!fingerprint || typeof fingerprint !== "object") {
107
124
  throw new TypeError("LocalObservations requires the host's fingerprint");
108
125
  }
109
126
  this.#fingerprint = fingerprint;
110
127
  this.#filePath = typeof filePath === "string" ? filePath : "";
111
128
  this.#logger = logger ?? { info: () => {}, warn: () => {} };
129
+ this.#writeJson = writeJson;
112
130
  this.#now = now;
113
131
  }
114
132
 
@@ -367,7 +385,7 @@ export class LocalObservations {
367
385
  return;
368
386
  }
369
387
  try {
370
- writeFileSync(this.#filePath, JSON.stringify({ version: FILE_VERSION, entries: this.#entries }));
388
+ this.#writeJson(this.#filePath, { version: FILE_VERSION, entries: this.#entries });
371
389
  } catch (error) {
372
390
  // A read-only install keeps what it learned for this process only.
373
391
  this.#logger.warn(
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @file What this host does, measured once at startup before any viewer
3
+ * exists: the encoder, what decoding and a second job cost, the calibrated
4
+ * modes, the copy speed, audio, a start and a stop, and tone-mapping.
5
+ *
6
+ * Moved out of `server.js` verbatim (torrent-tv/meta#280): the same awaits in
7
+ * the same order. `measures` defaults to the real functions; a test passes
8
+ * fakes so that no ffmpeg runs.
9
+ */
10
+
11
+ import {
12
+ detectVideoEncoder,
13
+ benchmarkDecodeCost,
14
+ benchmarkContention,
15
+ benchmarkCopySpeed,
16
+ detectTonemapSupport,
17
+ softwareDescriptor
18
+ } from "./hwaccel.js";
19
+ import { calibrateEncoder, HostCalibration } from "./calibration.js";
20
+ import { readHostFingerprint } from "./fingerprint.js";
21
+ import { measureStartAndStop } from "./start-stop-cost.js";
22
+ import { benchmarkAudio } from "./audio-calibration.js";
23
+
24
+ const DEFAULT_MEASURES = {
25
+ detectVideoEncoder,
26
+ benchmarkDecodeCost,
27
+ benchmarkContention,
28
+ readHostFingerprint,
29
+ calibrateEncoder,
30
+ softwareDescriptor,
31
+ benchmarkCopySpeed,
32
+ benchmarkAudio,
33
+ measureStartAndStop,
34
+ detectTonemapSupport
35
+ };
36
+
37
+ /**
38
+ * @param {object} params
39
+ * @param {string} params.ffmpegBin
40
+ * @param {{ info: Function, warn: Function }} params.logger
41
+ * @param {boolean} params.transcode - Whether transcoding can occur; false measures nothing.
42
+ * @param {number} params.processorCores - The cores this process may use (a container's quota or CPU set).
43
+ * @param {typeof DEFAULT_MEASURES} [params.measures]
44
+ */
45
+ export async function measureHost({ ffmpegBin, logger, transcode, processorCores, measures = DEFAULT_MEASURES }) {
46
+ const {
47
+ detectVideoEncoder, benchmarkDecodeCost, benchmarkContention, readHostFingerprint, calibrateEncoder,
48
+ softwareDescriptor, benchmarkCopySpeed, benchmarkAudio, measureStartAndStop, detectTonemapSupport
49
+ } = measures;
50
+ // Auto-detect the best available H.264 encoder (hardware-accelerated or
51
+ // software) once at startup, with a real test-encode and graceful fallback.
52
+ // Only needed when transcoding can occur.
53
+ let videoEncoder = transcode
54
+ ? await detectVideoEncoder({ ffmpegBin, logger })
55
+ : null;
56
+ // WHAT THIS HOST DOES, measured before any viewer exists. Every one of these
57
+ // was gated on the chosen encoder being SOFTWARE, and two of the three are
58
+ // not about the encoder at all: a host with a GPU decodes in software just
59
+ // the same — no hardware decoder is asked for anywhere — and what a second
60
+ // job costs is a property of the machine. So a GPU host had no decode cost,
61
+ // no contention penalty and no encoder throughput, and the quality offer,
62
+ // which is arithmetic over those three, had nothing to compute from.
63
+ //
64
+ // The decode model first and the throughput second — they are independent
65
+ // (the rungs are timed on raw frames), but the order keeps the two figures
66
+ // side by side in the log.
67
+ const decodeCostModel = transcode
68
+ ? await benchmarkDecodeCost({ ffmpegBin, logger, processorCores })
69
+ : null;
70
+ // What a second job costs on this host. Measured because the budget adds
71
+ // independent prices and this host says two jobs that each fit alone do not
72
+ // fit together — 2.6× on the addon box (2026-08-18).
73
+ const contentionPenalties = transcode
74
+ ? await benchmarkContention({ ffmpegBin, logger })
75
+ : null;
76
+ // WHICH MODES THIS HOST MAY ENCODE WITH, AND WHAT EACH COSTS AT EVERY SIZE
77
+ // (roadmap item 97, step 14). Every setting an output can actually be
78
+ // encoded at is put through the product's own arguments and its segments
79
+ // decoded, then timed at the sizes of the ladder; a mode that fails either is
80
+ // not used, and a size nothing was timed at is not offered. Software is
81
+ // calibrated too when detection chose a device: it is what a failing device
82
+ // falls back to for the rest of the process, and it must not be priced by
83
+ // the device's figures.
84
+ //
85
+ // And WHICH CONFIGURATION all of it describes — the ffmpeg and x264 builds,
86
+ // the processor and the threads, the device and its driver — which is what
87
+ // decides whether anything kept from an earlier run still applies.
88
+ const fingerprint = videoEncoder
89
+ ? await readHostFingerprint({ ffmpegBin, encoder: videoEncoder, processorCores })
90
+ : null;
91
+ /** @type {Record<string, object[]>} */
92
+ const calibratedModes = {};
93
+ if (videoEncoder) {
94
+ const selectedCalibration = await calibrateEncoder({ ffmpegBin, encoder: videoEncoder, logger, processorCores });
95
+ if (selectedCalibration.assessmentComplete) {
96
+ calibratedModes[videoEncoder.kind] = selectedCalibration.modes;
97
+ }
98
+ if (videoEncoder.kind !== "software") {
99
+ const softwareCalibration = await calibrateEncoder({ ffmpegBin, encoder: softwareDescriptor(), logger, processorCores });
100
+ if (softwareCalibration.assessmentComplete) {
101
+ calibratedModes.software = softwareCalibration.modes;
102
+ }
103
+ // A device that passed detection's own test and then none of whose modes
104
+ // passed the product's is not used: detection encodes a synthetic pattern
105
+ // with arguments of its own, and it is the product's arguments a viewer
106
+ // is served with.
107
+ if (Array.isArray(calibratedModes[videoEncoder.kind]) && calibratedModes[videoEncoder.kind].length === 0) {
108
+ logger.warn(
109
+ `calibration: ${videoEncoder.name} passed detection but none of its modes produced correct segments ` +
110
+ "through the product's own arguments; encoding in software instead"
111
+ );
112
+ videoEncoder = softwareDescriptor();
113
+ }
114
+ }
115
+ }
116
+ const calibration = videoEncoder ? new HostCalibration({ byKind: calibratedModes, fingerprint }) : null;
117
+ // What this host does with a picture it does NOT re-encode. Every other
118
+ // startup measurement prices encoding or decoding, and a copied picture does
119
+ // neither — it reads packets and writes them out again — so that whole branch
120
+ // had no speed until its own run had been running long enough to report one.
121
+ // The encoding layer decides where encoders go from arrivals, and an arrival
122
+ // cannot be computed without a speed, so the moment it mattered most was the
123
+ // moment nothing was known. Measured whatever the encoder is: copying does not
124
+ // touch it.
125
+ const copySpeedX = transcode
126
+ ? await benchmarkCopySpeed({ ffmpegBin, logger })
127
+ : null;
128
+ const audioCalibration = transcode
129
+ ? await benchmarkAudio({ ffmpegBin, logger })
130
+ : [];
131
+ // WHAT A START AND A STOP COST HERE, before any viewer exists. Both decide one
132
+ // thing — leave an encoder where it stands, or kill it and start another —
133
+ // and both used to be learned only from runs that had ENDED, so at a cold
134
+ // open they were zero. Zero does not read as "not measured": it reads as
135
+ // "free", and a free move is always taken. Field 2026-09-08: an encoder moved
136
+ // between two adjacent numbers every half second and produced nothing.
137
+ const startStopCost = transcode
138
+ ? await measureStartAndStop({ ffmpegBin, encoder: videoEncoder, logger })
139
+ : null;
140
+ // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
141
+ // Detected once; the session manager applies the tonemap chain only for HDR
142
+ // sources on the software path when available.
143
+ const tonemapSupported = transcode
144
+ ? await detectTonemapSupport({ ffmpegBin, logger })
145
+ : false;
146
+ return { videoEncoder, decodeCostModel, contentionPenalties, calibration, copySpeedX, audioCalibration, startStopCost, tonemapSupported };
147
+ }
@@ -1,9 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
+ import { writeJsonAtomic } from "../../utils/atomic-json.js";
5
+ import { logger } from "../../utils/logger.js";
4
6
 
5
7
  const ID_FILE = "proxy-identity.json";
6
8
 
9
+ /** Ids that could not be written, by identity file: kept so the process keeps one id. */
10
+ const unsavedIds = new Map();
11
+
7
12
  /**
8
13
  * Resolve this installation's registry id. Without a state directory the id
9
14
  * remains ephemeral; with one, the id survives process restarts.
@@ -27,12 +32,18 @@ export function installationId({ stateDir, explicitId, makeId = randomUUID } = {
27
32
  }
28
33
  }
29
34
 
30
- const id = requestedId || savedId || makeId();
35
+ const id = requestedId || savedId || unsavedIds.get(file) || makeId();
31
36
  if (id !== savedId) {
32
- mkdirSync(stateDir, { recursive: true });
33
- const temporary = `${file}.${process.pid}.tmp`;
34
- writeFileSync(temporary, `${JSON.stringify({ id })}\n`, { mode: 0o600 });
35
- renameSync(temporary, file);
37
+ try {
38
+ mkdirSync(stateDir, { recursive: true });
39
+ writeJsonAtomic(file, { id }, { mode: 0o600 });
40
+ unsavedIds.delete(file);
41
+ } catch (error) {
42
+ if (unsavedIds.get(file) !== id) {
43
+ logger.warn(`Cannot save the proxy identity file ${file} (${error.message}); the installation id lasts only until this process exits`);
44
+ }
45
+ unsavedIds.set(file, id);
46
+ }
36
47
  }
37
48
  return id;
38
49
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @file One resolver of a segment's source byte ranges (torrent-tv/meta#272).
3
+ *
4
+ * The chain tracks, media-info, source-navigation, then the direct index, the
5
+ * container's source ranges or its packets, with the cache of what was worked
6
+ * out, was in `server.js` and repeated by the encoder's input. The method names
7
+ * are those of wait-trace P4.1.
8
+ */
9
+ import { directSegmentInput } from "./direct-segment-input.js";
10
+ import { SegmentInputs } from "./SegmentInputs.js";
11
+
12
+ const FALLBACK = "container-index-fallback";
13
+
14
+ export class SegmentSourceRanges {
15
+ /** @type {Map<string, Array<[number, number]>>} */
16
+ #ranges = new Map();
17
+ #orchestrator;
18
+
19
+ /** @param {{ orchestrator: { inspect(params: object, statement: string): Promise<object>, known(sourceKey: string, fileIndex: number): object | null | undefined } }} ports */
20
+ constructor({ orchestrator }) {
21
+ this.#orchestrator = orchestrator;
22
+ }
23
+
24
+ keyOf(sourceKey, fileIndex, interval) {
25
+ return `${sourceKey}:${fileIndex}:${JSON.stringify(interval)}`;
26
+ }
27
+
28
+ cached(rangesKey) {
29
+ const ranges = this.#ranges.get(rangesKey);
30
+ return ranges ? { kind: "result", ranges, method: "source-range-cache" } : null;
31
+ }
32
+
33
+ async tracksOf(params, isCurrent = () => true) {
34
+ const answer = await this.#orchestrator.inspect(params, "tracks");
35
+ if (!isCurrent()) return { kind: "stale" };
36
+ if (answer.kind !== "result") return answer;
37
+ const container = this.#orchestrator.known(params.sourceKey, params.fileIndex);
38
+ if (typeof container?.readSourceRanges !== "function" && typeof container?.readPacketIndex !== "function") {
39
+ return { kind: "needs-index", reason: "packet-index-not-yet-available" };
40
+ }
41
+ return { kind: "result", trackList: answer.value, container };
42
+ }
43
+
44
+ async shiftOf(params, isCurrent = () => true) {
45
+ const answer = await this.#orchestrator.inspect(params, "media-info");
46
+ if (!isCurrent()) return { kind: "stale" };
47
+ if (answer.kind !== "result") return answer;
48
+ return { kind: "result", shift: Number(answer.value?.startTimeSeconds) || 0 };
49
+ }
50
+
51
+ async navigable(params, isCurrent = () => true) {
52
+ const answer = await this.#orchestrator.inspect(params, "source-navigation");
53
+ if (!isCurrent()) return { kind: "stale" };
54
+ if (answer.kind !== "result") return answer;
55
+ return { kind: "result", value: answer.value === true };
56
+ }
57
+
58
+ async readInput(params, { original, tracks, modes, interval }, isCurrent = () => true) {
59
+ const answer = await this.#orchestrator.inspect(params, original ? "source-ranges" : "packets");
60
+ if (!isCurrent()) return { kind: "stale", step: "packets" };
61
+ if (answer.kind !== "result") return { kind: answer.kind, step: "packets", answer };
62
+ const input = original ? answer.value
63
+ : new SegmentInputs({ index: answer.value, tracks }).forInterval({ ...interval, mode: track => modes.get(track) });
64
+ if (input.kind !== "result") return { kind: input.kind, step: "input", answer: input };
65
+ return { kind: "result", method: original ? "container-source-ranges" : "packet-index", input };
66
+ }
67
+
68
+ async resolve({ rangesKey, direct, interval, tracks, modes, intervalParams, isCurrent = () => true, whenBuilt = () => {} }) {
69
+ if (direct) {
70
+ let value = null;
71
+ try {
72
+ value = await directSegmentInput(direct, { interval, tracks, mode: track => modes.get(track) }, whenBuilt);
73
+ } catch { value = null; }
74
+ if (value?.kind === "pending") return { kind: "pending", method: "direct-source-index", step: "direct" };
75
+ if (value?.kind === "result") {
76
+ this.#ranges.set(rangesKey, value.ranges);
77
+ return { kind: "result", ranges: value.ranges, method: "direct-source-index" };
78
+ }
79
+ }
80
+ const params = await intervalParams();
81
+ if (!isCurrent()) return { kind: "stale", method: FALLBACK, step: "params" };
82
+ if (!params) return { kind: "needs-source", method: FALLBACK, step: "params" };
83
+ const navigation = await this.navigable(params, isCurrent);
84
+ if (navigation.kind === "stale") return { kind: "stale", method: FALLBACK, step: "navigation" };
85
+ if (navigation.kind !== "result") return { kind: navigation.kind, method: FALLBACK, step: "navigation", answer: navigation };
86
+ const read = await this.readInput(params, { original: navigation.value, tracks, modes, interval }, isCurrent);
87
+ if (read.kind !== "result") return { ...read, method: FALLBACK };
88
+ this.#ranges.set(rangesKey, read.input.ranges);
89
+ return { kind: "result", ranges: read.input.ranges, method: read.method };
90
+ }
91
+
92
+ forget(sourceKey) {
93
+ for (const key of this.#ranges.keys()) if (key.startsWith(`${sourceKey}:`)) this.#ranges.delete(key);
94
+ }
95
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * @file The walk that finds new subtitle cues in what a torrent has downloaded
3
+ * and pushes them to the viewers.
4
+ *
5
+ * Subtitles are two layers put together, and this is the one place that knows
6
+ * about both: what a file STATES about its subtitle tracks and how a cue is
7
+ * written out is the media layer's, while which clusters may be read, where
8
+ * the cursor stands and what one walk at a time means are the TORRENT's own
9
+ * rules. The media layer used to import the second directly.
10
+ *
11
+ * Moved out of `server.js` verbatim (torrent-tv/meta#280): every free variable
12
+ * is a port.
13
+ */
14
+
15
+ import { SubtitleOrchestrator } from "./SubtitleOrchestrator.js";
16
+ import {
17
+ warmSubtitleCues,
18
+ cuesHeldFor,
19
+ subtitleTracksOf,
20
+ declaredSubtitleTracksOf,
21
+ forgetSubtitles
22
+ } from "./SubtitleCues.js";
23
+ import { CONTAINER_HEAD_BYTES } from "./ContainerOrchestrator.js";
24
+ import { coalescing } from "../../utils/coalesce.js";
25
+
26
+ export class SubtitleWalk {
27
+ /** The orchestrator the walk feeds; `server.js` reads it where it read `subtitles`. */
28
+ subtitles;
29
+ #torrents;
30
+ #containers;
31
+ #paramsFor;
32
+ #wantedSeconds;
33
+ #publish;
34
+ #log;
35
+ #warn;
36
+ #walk;
37
+
38
+ /**
39
+ * @param {object} ports
40
+ * @param {{ knownTorrent: Function, heldRangesOf: Function, readHeldOf: Function }} ports.torrents
41
+ * @param {{ known: Function, containerFor: Function }} ports.containers - The container orchestrator.
42
+ * @param {(request: object) => Promise<object | null>} ports.paramsFor
43
+ * @param {(sourceKey: string, fileIndex: number) => number[]} ports.wantedSeconds
44
+ * Where the viewers of a file stand: the start of every urgent stretch of
45
+ * its priority map, ascending whole seconds; `[]` when there is none.
46
+ * @param {(entry: object) => void} ports.publish
47
+ * @param {(line: string) => void} ports.log
48
+ * @param {(line: string) => void} ports.warn
49
+ */
50
+ constructor({ torrents, containers, paramsFor, wantedSeconds, publish, log, warn }) {
51
+ this.#torrents = torrents;
52
+ this.#containers = containers;
53
+ this.#paramsFor = paramsFor;
54
+ this.#wantedSeconds = wantedSeconds;
55
+ this.#publish = publish;
56
+ this.#log = log;
57
+ this.#warn = warn;
58
+ this.#walk = coalescing((sourceKey, fileIndex) => this.#walkFile(sourceKey, fileIndex));
59
+ this.subtitles = new SubtitleOrchestrator(containers, {
60
+ publish: entry => this.#publish(entry),
61
+ warm: async (torrent, fileIndex, sourceKey) => {
62
+ const file = await this.heldFileFor(sourceKey, fileIndex, torrent);
63
+ return file ? warmSubtitleCues(file) : [];
64
+ },
65
+ held: async (torrent, fileIndex, sourceKey, trackNumber) => {
66
+ const file = await this.heldFileFor(sourceKey, fileIndex, torrent);
67
+ return file ? cuesHeldFor(file, trackNumber) : null;
68
+ },
69
+ tracksOf: async (torrent, fileIndex, sourceKey) => {
70
+ const file = await this.heldFileFor(sourceKey, fileIndex, torrent);
71
+ return file ? subtitleTracksOf(file) : [];
72
+ },
73
+ declaredTracksOf: async (torrent, fileIndex, sourceKey) => {
74
+ const file = await this.heldFileFor(sourceKey, fileIndex, torrent);
75
+ return file ? declaredSubtitleTracksOf(file) : [];
76
+ },
77
+ forget: forgetSubtitles
78
+ });
79
+ }
80
+
81
+ /**
82
+ * One file of one torrent, reduced to what the subtitle walk may know: a
83
+ * name, a length, its one container, which ranges are downloaded whole, how
84
+ * to read one of those without asking the swarm, the portion one read may
85
+ * span, and where the viewers stand. File offsets and the bitfield stay in
86
+ * the torrent's thread; the piece length crosses as a plain number.
87
+ *
88
+ * @param {string} sourceKey
89
+ * @param {number} fileIndex
90
+ * @param {object} [known] - The torrent handle when the caller already has it.
91
+ * @returns {Promise<import("./SubtitleCues.js").HeldFile | null>}
92
+ */
93
+ heldFileFor = async (sourceKey, fileIndex, known = null) => {
94
+ let torrent = known;
95
+ if (!torrent) {
96
+ torrent = this.#torrents.knownTorrent(sourceKey);
97
+ if (!torrent) return null;
98
+ }
99
+ const file = torrent?.files?.[fileIndex];
100
+ if (!file || !(file.length > 0)) {
101
+ return null;
102
+ }
103
+ return {
104
+ sourceKey,
105
+ fileIndex,
106
+ name: String(file.name ?? ""),
107
+ length: file.length,
108
+ portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined,
109
+ // The file's ONE container — the one the track table, the media info and
110
+ // the keyframe table are read from. Built here only when nobody has built
111
+ // it yet, so a pass of the walk does not fetch the file's edges again.
112
+ container: async () => {
113
+ const known = this.#containers.known(sourceKey, fileIndex);
114
+ if (known !== undefined) {
115
+ return known;
116
+ }
117
+ const params = await this.#paramsFor({ sourceKey, fileIndex, tailBytes: CONTAINER_HEAD_BYTES });
118
+ return params ? this.#containers.containerFor(params) : null;
119
+ },
120
+ heldRanges: () => this.#torrents.heldRangesOf(torrent, fileIndex),
121
+ readHeld: (start, end) => this.#torrents.readHeldOf(torrent, fileIndex, start, end),
122
+ // Where the viewers of this file stand: the start of every urgent stretch
123
+ // of its priority map, which is where each viewer's own need begins.
124
+ wantedSeconds: () => this.#wantedSeconds(sourceKey, fileIndex),
125
+ // A pass left readable clusters for the next one: run it.
126
+ askAgain: () => {
127
+ void this.pushFreshCues(sourceKey, fileIndex);
128
+ }
129
+ };
130
+ };
131
+
132
+ /**
133
+ * Walk whatever new cues a file now holds and push them to the viewers.
134
+ *
135
+ * One walk of a file at a time, and one more after it whenever pieces
136
+ * arrived meanwhile (`coalescing`): `verified` fires per piece, so a queue of
137
+ * identical passes would only postpone the one with something new to find,
138
+ * and dropping them — as this used to — lost the pass that would have found
139
+ * the last pieces of a file.
140
+ *
141
+ * @param {string} sourceKey
142
+ * @param {number} fileIndex
143
+ * @returns {Promise<void>}
144
+ */
145
+ pushFreshCues(sourceKey, fileIndex) {
146
+ return this.#walk(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
147
+ }
148
+
149
+ async #walkFile(sourceKey, fileIndex) {
150
+ try {
151
+ const file = await this.heldFileFor(sourceKey, fileIndex);
152
+ if (!file) {
153
+ return;
154
+ }
155
+ for (const entry of await warmSubtitleCues(file)) {
156
+ if (this.subtitles.hasPacketCues(sourceKey, fileIndex, entry.trackIndex)) continue;
157
+ const span = entry.spanStartSeconds === null
158
+ ? "empty"
159
+ : `${entry.spanStartSeconds.toFixed(1)}-${entry.spanEndSeconds.toFixed(1)}s`;
160
+ this.#log(
161
+ `subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} track ${entry.trackIndex}: ` +
162
+ `${entry.cues.length} new cue(s) covering ${span}` +
163
+ (entry.withdrawn.length > 0 ? `, ${entry.withdrawn.length} taken back` : "") +
164
+ `, clusters walked ${entry.walkedClusters}/${entry.indexedClusters}, cursor ${entry.cursor}`
165
+ );
166
+ this.#publish({ sourceKey, fileIndex, ...entry });
167
+ }
168
+ } catch (error) {
169
+ this.#warn(`subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} failed: ${error?.message ?? error}`);
170
+ }
171
+ }
172
+ }