@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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @file The once-per-file line that says what a file's work tags read.
3
+ *
4
+ * Moved out of `server.js` verbatim (torrent-tv/meta#280).
5
+ */
6
+
7
+ import { describeWorkTags } from "./ContainerOrchestrator.js";
8
+
9
+ export class WorkTagsReport {
10
+ #logged = new Set();
11
+ #log;
12
+
13
+ /** @param {{ log: (line: string) => void }} ports */
14
+ constructor({ log }) {
15
+ this.#log = log;
16
+ }
17
+
18
+ /**
19
+ * @param {{ sourceKey: string, fileIndex: number }} address
20
+ * @param {string} label
21
+ * @param {{ kind: string }} result
22
+ */
23
+ note({ sourceKey, fileIndex }, label, result) {
24
+ const key = `${sourceKey}:${fileIndex}`;
25
+ if (result.kind !== "pending" && result.kind !== "needs-ranges" && !this.#logged.has(key)) {
26
+ this.#logged.add(key);
27
+ this.#log(`container metadata "${label}": ${describeWorkTags(result)}`);
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,36 @@
1
+ import { ContainerTrack } from "./tracks/ContainerTrack.js";
2
+
3
+ /**
4
+ * What a segment reads: the one rule that names the tracks of a choice and the
5
+ * interval the segment states. Pure; keeps no state.
6
+ */
7
+
8
+ /** The track a choice names, or null. The picture is the first usable video track (torrent-tv/meta#49) and ignores `choice.index`; any other type is chosen by its place among the tracks of that type. */
9
+ export function trackFor(trackList, choice) {
10
+ if (choice.type === "video") return ContainerTrack.firstUsable(trackList, "video");
11
+ return trackList.filter(track => track.type === choice.type)[choice.index] ?? null;
12
+ }
13
+
14
+ /** The tracks of every choice, in choice order, and each one's mode; null when any choice finds no track. */
15
+ export function selectTracks(trackList, choices) {
16
+ const wanted = [], modes = new Map();
17
+ for (const choice of choices) {
18
+ const track = trackFor(trackList, choice);
19
+ if (!track) return null;
20
+ wanted.push(track);
21
+ modes.set(track, choice.mode);
22
+ }
23
+ return { wanted, modes };
24
+ }
25
+
26
+ /** The interval a segment states: the source interval, then `trackIds`, then `modes`. The key order matters: it is the `rangesKey`. */
27
+ export function readingInterval({ wanted, modes }, sourceInterval) {
28
+ return { ...sourceInterval, trackIds: wanted.map(track => track.trackNumber),
29
+ modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
30
+ }
31
+
32
+ export function segmentReading(trackList, choices, sourceInterval) {
33
+ const selection = selectTracks(trackList, choices);
34
+ if (!selection) return null;
35
+ return { ...selection, interval: readingInterval(selection, sourceInterval) };
36
+ }