@torrent-tv/proxy 2.107.55 → 2.107.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/ViewerRequests.js +53 -34
- 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,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The parameters of a media read, and the metadata demand of an encode
|
|
3
|
+
* input, which lasts as long as the signal of its request.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class MediaReadParams {
|
|
7
|
+
#heldFile;
|
|
8
|
+
#packetMemory;
|
|
9
|
+
#readProbe;
|
|
10
|
+
#probePackets;
|
|
11
|
+
#baseUrl;
|
|
12
|
+
#observeWait;
|
|
13
|
+
#reads;
|
|
14
|
+
#maps;
|
|
15
|
+
#learnKeyframes;
|
|
16
|
+
#formatOf;
|
|
17
|
+
#warn;
|
|
18
|
+
/** @type {WeakMap<AbortSignal, Map<string, { statements: Set<string>, abort: () => void }>>} */
|
|
19
|
+
#trackers = new WeakMap();
|
|
20
|
+
|
|
21
|
+
constructor({ heldFile, packetMemory, readProbe, probePackets, baseUrl, observeWait, reads, maps,
|
|
22
|
+
learnKeyframes, formatOf, warn }) {
|
|
23
|
+
this.#heldFile = heldFile;
|
|
24
|
+
this.#packetMemory = packetMemory;
|
|
25
|
+
this.#readProbe = readProbe;
|
|
26
|
+
this.#probePackets = probePackets;
|
|
27
|
+
this.#baseUrl = baseUrl;
|
|
28
|
+
this.#observeWait = observeWait;
|
|
29
|
+
this.#reads = reads;
|
|
30
|
+
this.#maps = maps;
|
|
31
|
+
this.#learnKeyframes = learnKeyframes;
|
|
32
|
+
this.#formatOf = formatOf;
|
|
33
|
+
this.#warn = warn;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async paramsFor({ sourceKey, fileIndex, requestId, packetInterval, demand, readPriority, signal }) {
|
|
37
|
+
const file = this.#heldFile(sourceKey, fileIndex);
|
|
38
|
+
if (!file) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const metadataTracker = readPriority?.requestId && signal
|
|
42
|
+
? this.#trackerFor(signal, sourceKey, fileIndex) : null;
|
|
43
|
+
const maps = this.#maps;
|
|
44
|
+
const params = {
|
|
45
|
+
sourceKey,
|
|
46
|
+
fileIndex,
|
|
47
|
+
requestId,
|
|
48
|
+
signal,
|
|
49
|
+
packetInterval,
|
|
50
|
+
readPriority,
|
|
51
|
+
packetMemory: this.#packetMemory(sourceKey, fileIndex),
|
|
52
|
+
downloadInterval: demand ? { from: demand.from, to: demand.to } : null,
|
|
53
|
+
readRange: (start, end) => file.readHeld(start, Math.min(end, file.length - 1)),
|
|
54
|
+
fileSize: file.length,
|
|
55
|
+
probe: (statement, onRecord) => this.#readProbe({ sourceKey, fileIndex, statement: `ffprobe:${statement}`,
|
|
56
|
+
probe: ({ requestId, signal }) => {
|
|
57
|
+
const url = new URL("/media", this.#baseUrl());
|
|
58
|
+
url.searchParams.set("sourceKey", sourceKey);
|
|
59
|
+
url.searchParams.set("fileIndex", String(fileIndex));
|
|
60
|
+
url.searchParams.set("readId", requestId);
|
|
61
|
+
return this.#probePackets({ url: url.toString(), statement, signal, onRecord });
|
|
62
|
+
} }),
|
|
63
|
+
label: file.name,
|
|
64
|
+
fileOffset: file.offset,
|
|
65
|
+
onObserved: fields => {
|
|
66
|
+
const process = fields.statement === "keyframes" ? "P5"
|
|
67
|
+
: String(fields.statement).startsWith("ffprobe:") ? "X1" : "P2";
|
|
68
|
+
const details = { process, fileIndex, requestId, statement: fields.statement, cached: fields.cached,
|
|
69
|
+
result: fields.result, reason: fields.reason, ranges: fields.ranges,
|
|
70
|
+
queuedMs: fields.queuedMs, readMs: fields.readMs };
|
|
71
|
+
this.#observeWait(sourceKey, "process", { ...details, phase: "begin", at: fields.askedAt });
|
|
72
|
+
this.#observeWait(sourceKey, "process", { ...details, phase: "end", at: fields.endedAt,
|
|
73
|
+
startedAt: fields.askedAt, endedAt: fields.endedAt });
|
|
74
|
+
},
|
|
75
|
+
onTracks: tracks => file.noteVideo(tracks.some(track => track.type === "video")),
|
|
76
|
+
onReadStart: () => ({ storage: this.#reads.revision(sourceKey, fileIndex), memory: this.#reads.memoryRevision(),
|
|
77
|
+
demand: maps.epoch(sourceKey, fileIndex) }),
|
|
78
|
+
onReadResult: async (statement, result, revision) => {
|
|
79
|
+
if (revision.demand !== maps.epoch(sourceKey, fileIndex)) return;
|
|
80
|
+
if (demand && !maps.wantsInterval(sourceKey, fileIndex, demand)) return;
|
|
81
|
+
if (statement === "keyframes" && result.kind === "result") {
|
|
82
|
+
this.#learnKeyframes(params, { times: result.value?.times ?? null, tolerance: result.value?.tolerance ?? 0,
|
|
83
|
+
format: this.#formatOf(sourceKey, fileIndex) });
|
|
84
|
+
}
|
|
85
|
+
const finished = this.#reads.record(params, statement, result, revision.storage, revision.memory);
|
|
86
|
+
if (statement === "chapters" && result.passive) await maps.withdrawMetadata(sourceKey, fileIndex, statement);
|
|
87
|
+
else {
|
|
88
|
+
const metadataStatement = requestId ? `${statement}:${requestId}` : statement;
|
|
89
|
+
if (metadataTracker && result.kind === "needs-ranges") metadataTracker.statements.add(metadataStatement);
|
|
90
|
+
if (metadataTracker && result.kind !== "needs-ranges") metadataTracker.statements.delete(metadataStatement);
|
|
91
|
+
await maps.metadata({ sourceKey, fileIndex, statement: metadataStatement, result,
|
|
92
|
+
...(statement === "chapters" ? { priority: 1, urgent: false, deadlineAt: null } : {}),
|
|
93
|
+
...(demand ? { priority: demand.priority, urgent: demand.urgent, deadlineAt: demand.deadlineAt, interval: demand, leadSeconds: demand.leadSeconds ?? 0 } : {}),
|
|
94
|
+
...(readPriority?.requestId ? { priority: readPriority.rank, urgent: readPriority.urgent === true,
|
|
95
|
+
deadlineAt: Number.isFinite(readPriority.deadlineAt) ? readPriority.deadlineAt : Number.POSITIVE_INFINITY,
|
|
96
|
+
scope: "preparation" } : {}),
|
|
97
|
+
// Neither a viewer's interval nor an encode input asked for it: the
|
|
98
|
+
// file read itself, and that read is wanted while the file is.
|
|
99
|
+
...(!demand && !readPriority?.requestId ? { owner: "file" } : {}) });
|
|
100
|
+
}
|
|
101
|
+
if (finished && result.kind === "result") {
|
|
102
|
+
void maps.refresh(sourceKey, fileIndex).catch(error =>
|
|
103
|
+
this.#warn(`download map source=${sourceKey} file=${fileIndex} metadata refresh failed: ${error?.message ?? error}`));
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
portionBytes: file.portionBytes
|
|
107
|
+
};
|
|
108
|
+
return params;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#trackerFor(signal, sourceKey, fileIndex) {
|
|
112
|
+
const trackerKey = `${sourceKey}:${fileIndex}`;
|
|
113
|
+
let trackersForSignal = this.#trackers.get(signal);
|
|
114
|
+
if (!trackersForSignal) this.#trackers.set(signal, trackersForSignal = new Map());
|
|
115
|
+
let metadataTracker = trackersForSignal.get(trackerKey);
|
|
116
|
+
if (!metadataTracker) {
|
|
117
|
+
metadataTracker = { statements: new Set(), abort: null };
|
|
118
|
+
const tracker = metadataTracker;
|
|
119
|
+
tracker.abort = () => {
|
|
120
|
+
for (const statement of tracker.statements) {
|
|
121
|
+
void this.#maps.withdrawMetadata(sourceKey, fileIndex, statement).catch(error =>
|
|
122
|
+
this.#warn(`encode input demand withdrawal failed: ${error.message}`));
|
|
123
|
+
}
|
|
124
|
+
tracker.statements.clear();
|
|
125
|
+
trackersForSignal.delete(trackerKey);
|
|
126
|
+
if (trackersForSignal.size === 0) this.#trackers.delete(signal);
|
|
127
|
+
};
|
|
128
|
+
trackersForSignal.set(trackerKey, tracker);
|
|
129
|
+
if (signal.aborted) tracker.abort();
|
|
130
|
+
else signal.addEventListener("abort", tracker.abort, { once: true });
|
|
131
|
+
}
|
|
132
|
+
return metadataTracker;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The reads a source preparation makes: its `inspect` port, and the
|
|
3
|
+
* publication of the native source map that follows a read.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `server.js` verbatim (torrent-tv/meta#280): every free variable
|
|
6
|
+
* is a port, and the preparation it reports to is passed with each call, since
|
|
7
|
+
* it is built after this owner.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class PreparationReads {
|
|
11
|
+
#paramsFor;
|
|
12
|
+
#containers;
|
|
13
|
+
#subtitles;
|
|
14
|
+
#fileLengthOf;
|
|
15
|
+
#viewersOf;
|
|
16
|
+
#segmentSeconds;
|
|
17
|
+
#preparationInterval;
|
|
18
|
+
#segmentInputs;
|
|
19
|
+
#nativeMaps;
|
|
20
|
+
#maps;
|
|
21
|
+
#record;
|
|
22
|
+
#planEncodersSoon;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} ports
|
|
26
|
+
* @param {(work: object) => Promise<object | null>} ports.paramsFor - MediaReadParams#paramsFor.
|
|
27
|
+
* @param {{ inspect: Function, containerFor: Function }} ports.containers
|
|
28
|
+
* @param {{ inspectFile: Function, inspectPackets: Function }} ports.subtitles
|
|
29
|
+
* @param {(sourceKey: string, fileIndex: number) => number | undefined} ports.fileLengthOf
|
|
30
|
+
* @param {(sourceKey: string) => object[]} ports.viewersOf - Viewers#forSource.
|
|
31
|
+
* @param {number} ports.segmentSeconds
|
|
32
|
+
* @param {(role: string, from: number, segmentSeconds: number) => object} ports.preparationInterval
|
|
33
|
+
* @param {(input: { index: object, tracks: object[] }) => { forInterval: Function }} ports.segmentInputs
|
|
34
|
+
* @param {{ packets: Function, original: Function }} ports.nativeMaps
|
|
35
|
+
* @param {{ metadata: Function, native: Function }} ports.maps
|
|
36
|
+
* @param {(params: object, statement: string, result: object, storage: unknown, memory: unknown) => boolean} ports.record
|
|
37
|
+
* @param {() => void} ports.planEncodersSoon
|
|
38
|
+
*/
|
|
39
|
+
constructor({ paramsFor, containers, subtitles, fileLengthOf, viewersOf, segmentSeconds, preparationInterval,
|
|
40
|
+
segmentInputs, nativeMaps, maps, record, planEncodersSoon }) {
|
|
41
|
+
this.#paramsFor = paramsFor;
|
|
42
|
+
this.#containers = containers;
|
|
43
|
+
this.#subtitles = subtitles;
|
|
44
|
+
this.#fileLengthOf = fileLengthOf;
|
|
45
|
+
this.#viewersOf = viewersOf;
|
|
46
|
+
this.#segmentSeconds = segmentSeconds;
|
|
47
|
+
this.#preparationInterval = preparationInterval;
|
|
48
|
+
this.#segmentInputs = segmentInputs;
|
|
49
|
+
this.#nativeMaps = nativeMaps;
|
|
50
|
+
this.#maps = maps;
|
|
51
|
+
this.#record = record;
|
|
52
|
+
this.#planEncodersSoon = planEncodersSoon;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The native source map of a work whose input has been read.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} work
|
|
59
|
+
* @param {object} preparation - `{ accepts, urgentReadyFor, ... }`, the source preparation.
|
|
60
|
+
*/
|
|
61
|
+
async publishNative(work, preparation) {
|
|
62
|
+
if (!work.nativeInput || !preparation.accepts(work)) return Promise.resolve();
|
|
63
|
+
const watching = this.#viewersOf(work.sourceKey)
|
|
64
|
+
.filter(viewer => viewer.source.selectedFileIndex === work.fileIndex && viewer.outputs.size === 0);
|
|
65
|
+
const convert = work.nativeInput.container ? this.#nativeMaps.original : this.#nativeMaps.packets;
|
|
66
|
+
const zones = await convert({ ...work.nativeInput, viewers: watching,
|
|
67
|
+
allowanceSeconds: this.#segmentSeconds,
|
|
68
|
+
urgentReadyFor: viewer => preparation.urgentReadyFor(viewer) });
|
|
69
|
+
if (!preparation.accepts(work)) return;
|
|
70
|
+
return this.#maps.native({ sourceKey: work.sourceKey, fileIndex: work.fileIndex, zones });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reads what the work asks for, and publishes what each read finds.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} work
|
|
77
|
+
* @param {object} preparation
|
|
78
|
+
* @returns {Promise<object>} The answer the source preparation takes.
|
|
79
|
+
*/
|
|
80
|
+
async inspect(work, preparation) {
|
|
81
|
+
const params = await this.#paramsFor(work);
|
|
82
|
+
if (!params) return { kind: "needs-source" };
|
|
83
|
+
params.isCurrent = () => preparation.accepts(work);
|
|
84
|
+
const originalContainer = work.statement === "packets" ? await this.#containers.containerFor(params) : null;
|
|
85
|
+
const navigation = work.statement === "packets" && work.role !== "subtitle-embedded"
|
|
86
|
+
? await this.#containers.inspect(params, "source-navigation") : { kind: "result", value: false };
|
|
87
|
+
if (navigation.kind !== "result") return navigation;
|
|
88
|
+
const originalRanges = navigation.value;
|
|
89
|
+
if (!originalRanges && work.statement === "packets" && work.role === "source-rest" && preparation.accepts(work)) {
|
|
90
|
+
const length = this.#fileLengthOf(work.sourceKey, work.fileIndex);
|
|
91
|
+
if (length > 0) {
|
|
92
|
+
const statement = `${work.statement}:${work.requestId}:input`;
|
|
93
|
+
await this.#maps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex, statement,
|
|
94
|
+
result: { kind: "needs-ranges", ranges: [[0, length - 1]], requestId: statement },
|
|
95
|
+
scope: "preparation", ...preparation.demandFor(work) });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if ((work.statement === "packets" && (work.role !== "source-rest" || originalRanges)) || work.role === "subtitle-embedded" && work.statement === "subtitle-cues") {
|
|
99
|
+
const info = await this.#containers.inspect({ ...params, onReadResult: undefined }, "media-info");
|
|
100
|
+
if (info.kind !== "result") return info;
|
|
101
|
+
const positions = this.#viewersOf(work.sourceKey)
|
|
102
|
+
.filter(viewer => viewer.source.selectedFileIndex === work.fileIndex &&
|
|
103
|
+
(work.ownerId === undefined || viewer.id === work.ownerId))
|
|
104
|
+
.map(viewer => viewer.positionSeconds() ?? 0);
|
|
105
|
+
const from = (positions.length ? Math.min(...positions) : 0) + (Number(info.value?.startTimeSeconds) || 0);
|
|
106
|
+
params.packetInterval = this.#preparationInterval(work.role, from, this.#segmentSeconds);
|
|
107
|
+
if (work.role === "subtitle-embedded") {
|
|
108
|
+
const tracks = await this.#containers.containerFor(params).then(container => container.readTracks());
|
|
109
|
+
const track = tracks.find(track => track.type === "subtitle" && track.declaredIndex === work.trackIndex);
|
|
110
|
+
if (!track || track.isTextBased?.() !== true) return { kind: "terminal", reason: "subtitle-track-not-supported" };
|
|
111
|
+
params.packetInterval.trackIds = [track.trackNumber];
|
|
112
|
+
params.subtitleTrackIndex = work.trackIndex;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
params.onReadResult = async (statement, result, revision) => {
|
|
116
|
+
if (!preparation.accepts(work)) return;
|
|
117
|
+
this.#record(params, statement, result, revision.storage, revision.memory);
|
|
118
|
+
const inputStatement = `${work.statement}:${work.requestId}:input`;
|
|
119
|
+
if (statement === "source-ranges" && result.kind === "result") {
|
|
120
|
+
if (work.selected) work.inputRanges = result.value.ranges;
|
|
121
|
+
if (work.role === "source-rest") {
|
|
122
|
+
const info = await this.#containers.inspect({ ...params, onReadResult: undefined }, "media-info");
|
|
123
|
+
if (!preparation.accepts(work)) return;
|
|
124
|
+
if (info.kind === "result") {
|
|
125
|
+
work.nativeInput = { container: originalContainer, durationSeconds: info.value.durationSeconds,
|
|
126
|
+
startTimeSeconds: info.value.startTimeSeconds ?? 0 };
|
|
127
|
+
await this.publishNative(work, preparation);
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
await this.#maps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex,
|
|
131
|
+
statement: inputStatement, result: { kind: "needs-ranges", ranges: result.value.ranges, requestId: inputStatement },
|
|
132
|
+
scope: "preparation", ...preparation.demandFor(work) });
|
|
133
|
+
if (work.selected) await preparation.bytesChanged(work.sourceKey, work.fileIndex);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (statement === "packets" && work.role === "source-rest" && result.kind === "result") {
|
|
137
|
+
const tracks = await this.#containers.containerFor(params).then(container => container.readTracks());
|
|
138
|
+
const info = await this.#containers.inspect({ ...params, onReadResult: undefined }, "media-info");
|
|
139
|
+
if (!preparation.accepts(work)) return;
|
|
140
|
+
if (info.kind === "result") {
|
|
141
|
+
work.nativeInput = { index: result.value, tracks, durationSeconds: info.value.durationSeconds,
|
|
142
|
+
startTimeSeconds: info.value.startTimeSeconds ?? 0 };
|
|
143
|
+
await this.publishNative(work, preparation);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (statement === "packets" && work.role !== "source-rest" && result.kind === "result" && (work.selected || work.ownerFileIndex !== undefined)) {
|
|
147
|
+
const tracks = await this.#containers.containerFor(params).then(container => container.readTracks());
|
|
148
|
+
if (!preparation.accepts(work)) return;
|
|
149
|
+
const bounds = tracks.filter(track => ["video", "audio"].includes(track.type))
|
|
150
|
+
.map(track => result.value.boundsOf(track.trackNumber)).filter(Boolean);
|
|
151
|
+
const interval = params.packetInterval ?? (bounds.length ? {
|
|
152
|
+
from: Math.max(0, Math.min(...bounds.map(bound => bound.start))), to: Math.max(...bounds.map(bound => bound.end))
|
|
153
|
+
} : null);
|
|
154
|
+
if (interval && interval.to > interval.from) {
|
|
155
|
+
const input = work.role === "subtitle-embedded"
|
|
156
|
+
? result.value.inputFor({ trackId: params.packetInterval.trackIds[0], ...interval })
|
|
157
|
+
: this.#segmentInputs({ index: result.value, tracks }).forInterval(interval);
|
|
158
|
+
if (work.selected) work.inputRanges = input.kind === "result" ? input.ranges : null;
|
|
159
|
+
await this.#maps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex,
|
|
160
|
+
statement: inputStatement, result: input.kind === "result" ? { kind: "needs-ranges", ranges: input.ranges, requestId: inputStatement } : input,
|
|
161
|
+
scope: "preparation", ...preparation.demandFor(work) });
|
|
162
|
+
if (work.selected) await preparation.bytesChanged(work.sourceKey, work.fileIndex);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!preparation.accepts(work)) return;
|
|
166
|
+
await this.#maps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex,
|
|
167
|
+
statement: `${work.statement}:${work.requestId}`, result, scope: "preparation", ...preparation.demandFor(work) });
|
|
168
|
+
preparation.result(work, result);
|
|
169
|
+
if (["subtitle-file", "subtitle-cues"].includes(statement)) this.#planEncodersSoon();
|
|
170
|
+
};
|
|
171
|
+
return work.statement === "subtitle-file"
|
|
172
|
+
? this.#subtitles.inspectFile(params) : work.statement === "subtitle-cues"
|
|
173
|
+
? this.#subtitles.inspectPackets(params) : this.#containers.inspect(params, originalRanges ? "source-ranges" : work.statement);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -108,7 +108,7 @@ function sourceCaptureOf(stats, actionId, generation) {
|
|
|
108
108
|
return { ...source, actionId: actionId ?? null, generation: Number.isSafeInteger(generation) ? generation : null };
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
function encoderCaptureOf(sampleCaptures, liveRuns, actionId, generation) {
|
|
111
|
+
export function encoderCaptureOf(sampleCaptures, liveRuns, actionId, generation) {
|
|
112
112
|
const runCaptures = (liveRuns ?? []).map(run => ({ owner: "proxy-encoder", clock: "performance",
|
|
113
113
|
clockEpoch: run.progress?.clockEpoch, startedAt: run.progress?.updatedAtMonotonic,
|
|
114
114
|
completedAt: run.progress?.updatedAtMonotonic, revision: run.progress?.updatedAtMonotonic,
|
|
@@ -117,19 +117,56 @@ function encoderCaptureOf(sampleCaptures, liveRuns, actionId, generation) {
|
|
|
117
117
|
Number.isFinite(run.progress?.updatedAtMonotonic) ? null : "encoder-progress-capture-unavailable" }));
|
|
118
118
|
const captures = [...(Array.isArray(sampleCaptures) ? sampleCaptures : []), ...runCaptures];
|
|
119
119
|
const epoch = captures[0]?.clockEpoch;
|
|
120
|
-
const
|
|
121
|
-
|
|
120
|
+
const samplesOrdered = (Array.isArray(sampleCaptures) ? sampleCaptures : []).every((capture, index, samples) =>
|
|
121
|
+
index === 0 || capture.revision >= samples[index - 1].revision);
|
|
122
|
+
const valid = captures.length > 0 && samplesOrdered && epoch === performance.timeOrigin && captures.every(capture =>
|
|
123
|
+
capture.owner === "proxy-encoder" && capture.clock === "performance" && capture.validity === "valid" &&
|
|
122
124
|
Number.isFinite(capture.clockEpoch) && capture.clockEpoch === epoch && Number.isFinite(capture.startedAt) &&
|
|
123
125
|
Number.isFinite(capture.completedAt) && capture.completedAt >= capture.startedAt &&
|
|
124
126
|
Number.isFinite(capture.revision));
|
|
125
|
-
if (!valid)
|
|
127
|
+
if (!valid) {
|
|
128
|
+
const projected = captures.find(capture => capture.provenance === "projected");
|
|
129
|
+
return { ...unknownCapture("proxy-encoder", actionId, generation,
|
|
130
|
+
projected?.unknownReason ?? "encoder-capture-unavailable"),
|
|
131
|
+
samples: captures, ...(projected ? { provenance: projected.provenance, projectionSource: projected.source } : {}) };
|
|
132
|
+
}
|
|
126
133
|
return { owner: "proxy-encoder", clock: "performance", clockEpoch: epoch,
|
|
127
134
|
startedAt: Math.min(...captures.map(capture => capture.startedAt)),
|
|
128
135
|
completedAt: Math.max(...captures.map(capture => capture.completedAt)), actionId: actionId ?? null,
|
|
129
|
-
generation: Number.isSafeInteger(generation) ? generation : null,
|
|
136
|
+
generation: Number.isSafeInteger(generation) ? generation : null, provenance: "measured",
|
|
130
137
|
revision: captures.at(-1).revision ?? null, samples: captures, validity: "valid", unknownReason: null };
|
|
131
138
|
}
|
|
132
139
|
|
|
140
|
+
export function trackRateReadings(readings, { predicted, projected }, now, playbackReadiness) {
|
|
141
|
+
const measured = (readings ?? []).filter(reading => reading.at <= now);
|
|
142
|
+
const summary = measured.length > 0 ? playbackReadiness.rateSummaryOf(measured) : null;
|
|
143
|
+
if (summary) return { summary, captures: measured.map(reading => ({ owner: reading.captureOwner,
|
|
144
|
+
clock: reading.captureClock, clockEpoch: reading.captureClockEpoch,
|
|
145
|
+
startedAt: reading.captureStartedAtMonotonic, completedAt: reading.captureCompletedAtMonotonic,
|
|
146
|
+
revision: reading.at,
|
|
147
|
+
validity: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
|
|
148
|
+
Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
|
|
149
|
+
Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
|
|
150
|
+
reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
|
|
151
|
+
? "valid" : "unknown",
|
|
152
|
+
unknownReason: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
|
|
153
|
+
Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
|
|
154
|
+
Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
|
|
155
|
+
reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
|
|
156
|
+
? null : "encoder-sample-capture-unavailable" })) };
|
|
157
|
+
const rate = Number(predicted ?? projected);
|
|
158
|
+
if (Number.isFinite(rate) && rate > 0) {
|
|
159
|
+
const seed = new playbackReadiness.RateTrend();
|
|
160
|
+
seed.add(now, rate);
|
|
161
|
+
const source = predicted === null || predicted === undefined
|
|
162
|
+
? "current-cost-projection" : "stored-encode-prediction";
|
|
163
|
+
return { summary: seed.snapshot(), captures: [{ owner: "proxy-encoder", clock: "performance",
|
|
164
|
+
clockEpoch: null, startedAt: null, completedAt: null, revision: null, provenance: "projected", source,
|
|
165
|
+
validity: "unknown", unknownReason: "projected-encoder-speed-has-no-capture" }] };
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
133
170
|
function observeForecastInput(observeWait, sourceKey, state, event) {
|
|
134
171
|
if (!state) return;
|
|
135
172
|
observeWait?.(sourceKey, "process", {
|
|
@@ -956,6 +993,8 @@ export class ViewerRequests {
|
|
|
956
993
|
const liveRuns = this.#host.encodeRuns.liveRunsOf?.(output) ?? [];
|
|
957
994
|
const encoderCapture = encoderCaptureOf(trackRate?.captures, liveRuns, actionId, generation);
|
|
958
995
|
const encoderCaptureValid = encoderCapture.validity === "valid";
|
|
996
|
+
const projectedRateCanSeedForecast = trackRate?.captures?.length > 0 &&
|
|
997
|
+
trackRate.captures.every(capture => capture.provenance === "projected");
|
|
959
998
|
const processingStarted = liveRuns.some(({ progress }) => progress.processedSeconds > progress.startPositionSeconds);
|
|
960
999
|
const startedAt = liveRuns.find(({ startedAt }) => startedAt > 0)?.startedAt ?? now;
|
|
961
1000
|
tracks.push({
|
|
@@ -995,7 +1034,10 @@ export class ViewerRequests {
|
|
|
995
1034
|
this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
|
|
996
1035
|
durationSeconds: output.timeline?.publishedStartOf(output.timeline.segmentCount) ?? session.file.durationSeconds,
|
|
997
1036
|
segmentCount: output.timeline?.segmentCount ?? segments.length,
|
|
998
|
-
|
|
1037
|
+
// A projection is explicitly not a measured capture, but remains the
|
|
1038
|
+
// numeric seed it was before capture provenance was carried here.
|
|
1039
|
+
// Other invalid measured captures stay out of the forecast.
|
|
1040
|
+
readings: encoderCaptureValid || projectedRateCanSeedForecast ? trackRates : [],
|
|
999
1041
|
capture: encoderCapture,
|
|
1000
1042
|
segments,
|
|
1001
1043
|
tailSegments: forecastGeometry?.tailSegments ?? [],
|
|
@@ -1247,39 +1289,16 @@ export class ViewerRequests {
|
|
|
1247
1289
|
* would drop the later-told, earlier-taken one (torrent-tv/meta#178).
|
|
1248
1290
|
*/
|
|
1249
1291
|
#trackRateReadings(output, now) {
|
|
1250
|
-
const readings =
|
|
1251
|
-
const summary = readings.length > 0 ? this.#host.playbackReadiness.rateSummaryOf(readings) : null;
|
|
1252
|
-
if (summary) return { summary, captures: readings.map(reading => ({ owner: reading.captureOwner,
|
|
1253
|
-
clock: reading.captureClock, clockEpoch: reading.captureClockEpoch,
|
|
1254
|
-
startedAt: reading.captureStartedAtMonotonic, completedAt: reading.captureCompletedAtMonotonic,
|
|
1255
|
-
revision: reading.at,
|
|
1256
|
-
validity: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
|
|
1257
|
-
Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
|
|
1258
|
-
Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
|
|
1259
|
-
reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
|
|
1260
|
-
? "valid" : "unknown",
|
|
1261
|
-
unknownReason: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
|
|
1262
|
-
Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
|
|
1263
|
-
Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
|
|
1264
|
-
reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
|
|
1265
|
-
? null : "encoder-sample-capture-unavailable" })) };
|
|
1292
|
+
const readings = this.#host.encodeSpeedReadingsOf?.(output) ?? [];
|
|
1266
1293
|
// ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
|
|
1267
1294
|
// encoder, and it is not kept among the readings. Added on every poll
|
|
1268
1295
|
// between runs, it put the machine's share of that moment — 1 % at a
|
|
1269
1296
|
// cold start — on the whole film, and the forecast read 0.03x
|
|
1270
1297
|
// (torrent-tv/meta#178).
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
return { summary: seed.snapshot(), captures: [{ owner: "proxy-encoder", clock: "performance",
|
|
1276
|
-
clockEpoch: performance.timeOrigin, startedAt: now, completedAt: now, revision: now,
|
|
1277
|
-
validity: "valid", unknownReason: null }] };
|
|
1278
|
-
}
|
|
1279
|
-
// Progress over wall time includes source waits, which the forecast prices
|
|
1280
|
-
// separately. Keep the rate unknown until a measured or calibrated
|
|
1281
|
-
// processing rate exists; single-piece runs now publish their own rate.
|
|
1282
|
-
return null;
|
|
1298
|
+
return trackRateReadings(readings, {
|
|
1299
|
+
predicted: this.#host.predictedEncodeSpeedOf?.(output),
|
|
1300
|
+
projected: this.#host.projectedEncodeSpeedOf?.(output)
|
|
1301
|
+
}, now, this.#host.playbackReadiness);
|
|
1283
1302
|
}
|
|
1284
1303
|
|
|
1285
1304
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The log of a forwarded transcode-session response (the registry's
|
|
3
|
+
* request, followed from its arrival to its last byte).
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `server.js` verbatim (torrent-tv/meta#280). It imports nothing:
|
|
6
|
+
* the logger and the clock are passed in.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Registers, on `app`, the hooks that write the `[forecast-http]` and
|
|
11
|
+
* `[http-response]` lines of a `POST /api/transcode-sessions` carrying
|
|
12
|
+
* `x-ttv-forwarded-request-id`. Any other request logs nothing.
|
|
13
|
+
*
|
|
14
|
+
* @param {import("fastify").FastifyInstance} app
|
|
15
|
+
* @param {{ log: (line: string) => void, warn: (line: string) => void, now?: () => number }} ports
|
|
16
|
+
*/
|
|
17
|
+
export function installForwardedResponseLog(app, { log, warn, now = () => performance.now() }) {
|
|
18
|
+
const forwardedSessionResponseStarts = new WeakMap();
|
|
19
|
+
const logStage = (stage, req) => {
|
|
20
|
+
const startedAt = forwardedSessionResponseStarts.get(req);
|
|
21
|
+
const requestId = req.headers["x-ttv-forwarded-request-id"];
|
|
22
|
+
if (startedAt === undefined || typeof requestId !== "string") return;
|
|
23
|
+
const at = now();
|
|
24
|
+
log(`[forecast-http] stage=${stage} requestId=${requestId} atMs=${at.toFixed(3)} ` +
|
|
25
|
+
`elapsedMs=${(at - startedAt).toFixed(3)}`);
|
|
26
|
+
};
|
|
27
|
+
app.addHook("onRequest", async (req, reply) => {
|
|
28
|
+
const requestId = req.headers["x-ttv-forwarded-request-id"];
|
|
29
|
+
if (typeof requestId !== "string" || req.method !== "POST" || req.url !== "/api/transcode-sessions") return;
|
|
30
|
+
const startedAt = now();
|
|
31
|
+
forwardedSessionResponseStarts.set(req, startedAt);
|
|
32
|
+
logStage("onRequest", req);
|
|
33
|
+
reply.raw.once("finish", () => {
|
|
34
|
+
logStage("finish", req);
|
|
35
|
+
log(`[http-response] requestId=${requestId} method=${req.method} url=${req.url} ` +
|
|
36
|
+
`responseMs=${(now() - startedAt).toFixed(1)} status=${reply.statusCode} ` +
|
|
37
|
+
`contentLength=${reply.raw.getHeader("content-length") ?? "unknown"}`);
|
|
38
|
+
});
|
|
39
|
+
reply.raw.once("close", () => {
|
|
40
|
+
if (!reply.raw.writableFinished) {
|
|
41
|
+
logStage("close", req);
|
|
42
|
+
warn(`[http-response] requestId=${requestId} method=${req.method} url=${req.url} ` +
|
|
43
|
+
`closedMs=${(now() - startedAt).toFixed(1)} status=${reply.statusCode}`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
app.addHook("preSerialization", async (req, _reply, payload) => {
|
|
48
|
+
logStage("preSerialization", req);
|
|
49
|
+
return payload;
|
|
50
|
+
});
|
|
51
|
+
app.addHook("onSend", async (req, _reply, payload) => {
|
|
52
|
+
logStage("onSend", req);
|
|
53
|
+
return payload;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Where playback starts: viewer changes reach the source preparation and
|
|
3
|
+
* the download maps, and what an earlier process left behind is adopted.
|
|
4
|
+
*
|
|
5
|
+
* The subscription is made before the adoption, so whatever the adoption or
|
|
6
|
+
* anything after it changes about viewers reaches the maps. This is the seam
|
|
7
|
+
* the start order is tested through, since `server.js` cannot run in a test.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} graph
|
|
12
|
+
* @param {{ viewerChanges: import("node:events").EventEmitter,
|
|
13
|
+
* lifecycle: { adoptSegmentsLeftBehind(): object } }} graph.outputParts
|
|
14
|
+
* @param {{ viewersChanged(): Promise<unknown> }} graph.downloadMaps
|
|
15
|
+
* @param {() => { refresh(): unknown }} graph.preparation - read when a change comes, never at the call
|
|
16
|
+
* @param {() => void} graph.onStateChanged
|
|
17
|
+
* @param {(line: string) => void} graph.warn
|
|
18
|
+
* @returns {object} what `adoptSegmentsLeftBehind` returned
|
|
19
|
+
*/
|
|
20
|
+
export function startPlayback({ outputParts, downloadMaps, preparation, onStateChanged, warn }) {
|
|
21
|
+
outputParts.viewerChanges.on("change", () => {
|
|
22
|
+
void preparation().refresh();
|
|
23
|
+
void downloadMaps.viewersChanged().catch(error => warn(`download maps after a viewer change: ${error.message}`));
|
|
24
|
+
onStateChanged();
|
|
25
|
+
});
|
|
26
|
+
// What the last life of this process left on the disk. The kernel kills this
|
|
27
|
+
// one often enough for that to be an ordinary state rather than an odd one —
|
|
28
|
+
// twice in a single viewing on 2026-09-02 — and when it does, no exit handler
|
|
29
|
+
// runs and nothing is cleared up. So this is both the cleanup and the only
|
|
30
|
+
// record that those encoders ended at all: it says what it found before it
|
|
31
|
+
// decides anything, keeps the segments whose closure is proven, and removes
|
|
32
|
+
// the one piece per output that was being written when the process died.
|
|
33
|
+
return outputParts.lifecycle.adoptSegmentsLeftBehind();
|
|
34
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The torrent-pool adapters of the output graph: what the encoders and
|
|
3
|
+
* the machine budget ask of the torrent thread, as plain functions.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of the `wireOutputs` call in `server.js` verbatim
|
|
6
|
+
* (torrent-tv/meta#280). It imports nothing: the pool and the registry are
|
|
7
|
+
* passed in.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} ports
|
|
12
|
+
* @param {object} ports.torrentPool
|
|
13
|
+
* @param {{ get: (sourceKey: string) => { sourceType: string, source: unknown } | undefined }} ports.sourceRegistry
|
|
14
|
+
* @param {string} ports.wholeFilesRoot - Where whole files are kept (`completedFilesRoot()`).
|
|
15
|
+
*/
|
|
16
|
+
export function torrentPoolPorts({ torrentPool, sourceRegistry, wholeFilesRoot }) {
|
|
17
|
+
return {
|
|
18
|
+
readEncodeRanges: async (source, ranges, maxBytes) => {
|
|
19
|
+
const torrent = torrentPool.knownTorrent(source.sourceKey);
|
|
20
|
+
return torrent ? torrentPool.readHeldRangesOf(torrent, source.fileIndex, ranges, maxBytes) : null;
|
|
21
|
+
},
|
|
22
|
+
openEncodeWholeFile: source => torrentPool.acquireWholeFile(source),
|
|
23
|
+
// Which bytes of a file are downloaded whole now — what an original
|
|
24
|
+
// input's stretch may extend over. A snapshot: the copy that follows
|
|
25
|
+
// holds the pieces it reads, and refuses where one has gone since.
|
|
26
|
+
readEncodeHeldRanges: async (source) => {
|
|
27
|
+
const torrent = torrentPool.knownTorrent(source.sourceKey);
|
|
28
|
+
return torrent ? torrentPool.heldRangesOf(torrent, source.fileIndex) : [];
|
|
29
|
+
},
|
|
30
|
+
// What the spilled pieces weigh on the torrent thread, and how to tell them
|
|
31
|
+
// their share of the disk. The owner of the disk is on this side, where the
|
|
32
|
+
// segments are; the pieces are on the other.
|
|
33
|
+
spillDisk: typeof torrentPool.allowSpillBytes === "function"
|
|
34
|
+
? {
|
|
35
|
+
held: () => torrentPool.spilledBytes ?? 0,
|
|
36
|
+
allow: (bytes) => torrentPool.allowSpillBytes(bytes)
|
|
37
|
+
}
|
|
38
|
+
: null,
|
|
39
|
+
// Files downloaded whole, kept as files. They live on the torrent thread
|
|
40
|
+
// too, and until 2026-09-14 they had no bound of any kind — a 2.8 GB film
|
|
41
|
+
// on a host whose disk is often a 32 GB card.
|
|
42
|
+
wholeFiles: typeof torrentPool.allowWholeFileBytes === "function"
|
|
43
|
+
? {
|
|
44
|
+
held: () => torrentPool.wholeFileBytes ?? 0,
|
|
45
|
+
required: () => torrentPool.wholeFileRequiredBytes ?? 0,
|
|
46
|
+
allow: (bytes) => torrentPool.allowWholeFileBytes(bytes),
|
|
47
|
+
root: wholeFilesRoot
|
|
48
|
+
}
|
|
49
|
+
: null,
|
|
50
|
+
// The pieces held IN memory, on the torrent thread. Told their share by the
|
|
51
|
+
// same owner that divides the disk: what does not fit here is spilled
|
|
52
|
+
// there, so the two cannot be divided apart.
|
|
53
|
+
memoryClaimant: typeof torrentPool.allowMemoryBytes === "function"
|
|
54
|
+
? {
|
|
55
|
+
held: () => torrentPool.memoryClaim?.held ?? 0,
|
|
56
|
+
wanted: () => torrentPool.memoryClaim?.wanted ?? 0,
|
|
57
|
+
allow: (bytes) => torrentPool.allowMemoryBytes(bytes)
|
|
58
|
+
}
|
|
59
|
+
: null,
|
|
60
|
+
getTorrentTotals: async () => {
|
|
61
|
+
if (typeof torrentPool.getTorrentTotals !== "function") {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return torrentPool.getTorrentTotals();
|
|
65
|
+
},
|
|
66
|
+
setDownloadRate: (bytesPerSecond, because) => torrentPool.setDownloadRate?.(bytesPerSecond, because),
|
|
67
|
+
setSpeculativeHold: (held, because) => torrentPool.setSpeculativeHold?.(held, because),
|
|
68
|
+
getSourceStats: async (sourceKey, fileIndex, options) => {
|
|
69
|
+
const record = sourceRegistry.get(sourceKey);
|
|
70
|
+
if (!record) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const torrent = torrentPool.knownTorrent(sourceKey);
|
|
75
|
+
if (!torrent) return null;
|
|
76
|
+
// Awaited for the same reason as the stats route: this now crosses a
|
|
77
|
+
// thread boundary and returns a promise.
|
|
78
|
+
return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null, options);
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
// Pull one whole file onto the disk. Used for a soundtrack that ships beside
|
|
84
|
+
// the picture, once the encoder is as far ahead of the viewer as it is
|
|
85
|
+
// allowed to get — the one moment the swarm's capacity is demonstrably
|
|
86
|
+
// spare. A bounded read of the file's whole length, NOT `file.select()`:
|
|
87
|
+
// selecting a file alongside the readers' own windows is what made a seek
|
|
88
|
+
// wait 93 s while the swarm fetched 2.47 GB in file order (see
|
|
89
|
+
// `#syncSelections` in `torrent/torrent-pool.js`).
|
|
90
|
+
fetchWholeFile: async ({ sourceKey, fileIndex }) => {
|
|
91
|
+
const record = sourceRegistry.get(sourceKey);
|
|
92
|
+
if (!record) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
96
|
+
// The same background fill the warm-up starts when a file is chosen, not a
|
|
97
|
+
// second way of doing it. It is guarded against running twice on one file,
|
|
98
|
+
// so the two triggers converge instead of putting two readers on the same
|
|
99
|
+
// soundtrack — and only one of them would have stood aside for the
|
|
100
|
+
// picture. This trigger remains for the session that never had a warm-up
|
|
101
|
+
// before it.
|
|
102
|
+
await torrentPool.fillFileInBackground?.(torrent, fileIndex);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|