@torrent-tv/proxy 2.107.56 → 2.107.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server.js +195 -1139
- package/services/encode/EncodeInputResolution.js +157 -0
- package/services/encode/measure-host.js +147 -0
- package/services/media/SegmentSourceRanges.js +95 -0
- package/services/media/SubtitleWalk.js +172 -0
- package/services/media/WorkTagsReport.js +30 -0
- package/services/media/segment-reading.js +36 -0
- package/services/server/MediaReadParams.js +134 -0
- package/services/server/PreparationReads.js +175 -0
- package/services/server/forwarded-response-log.js +55 -0
- package/services/server/start-playback.js +34 -0
- package/services/server/torrent-pool-ports.js +105 -0
- package/services/server/wire-outputs.js +57 -47
- package/services/storage/processor-share.js +24 -0
- package/services/torrent/PlaybackResolution.js +397 -0
- package/services/viewer/watched-addresses.js +51 -0
|
@@ -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
|
+
}
|