@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,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
|
+
}
|
|
@@ -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
|
+
}
|