@torrent-tv/proxy 2.107.55 → 2.107.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 valid = captures.length > 0 && epoch === performance.timeOrigin && captures.every(capture =>
121
- capture.owner === "proxy-encoder" && capture.clock === "performance" &&
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) return unknownCapture("proxy-encoder", actionId, generation, "encoder-capture-unavailable");
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
- readings: encoderCaptureValid ? trackRates : [],
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 = (this.#host.encodeSpeedReadingsOf?.(output) ?? []).filter(reading => reading.at <= now);
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
- const projected = Number(this.#host.predictedEncodeSpeedOf?.(output) ?? this.#host.projectedEncodeSpeedOf?.(output));
1272
- if (Number.isFinite(projected) && projected > 0) {
1273
- const seed = new this.#host.playbackReadiness.RateTrend();
1274
- seed.add(now, projected);
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
+ }