@torrent-tv/proxy 2.101.34 → 2.101.36
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
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { logger } from "../../../../utils/logger.js";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Return download statistics for a registered torrent source.
|
|
@@ -65,7 +66,13 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
65
66
|
// Awaited: with the torrent on its own thread this is a round trip, not a
|
|
66
67
|
// local lookup. Without the await the reply was the pending promise itself,
|
|
67
68
|
// which serialises to `{}` — the empty stats seen in the field 2026-08-02.
|
|
68
|
-
const
|
|
69
|
+
const statsStartedAt = performance.now();
|
|
70
|
+
let stats;
|
|
71
|
+
try {
|
|
72
|
+
stats = await torrentPool.getFileStats(torrent, fileIndex);
|
|
73
|
+
} finally {
|
|
74
|
+
logger.info(`[stats] ${sourceKey.slice(0, 8)} getFileStatsMs=${(performance.now() - statsStartedAt).toFixed(1)}`);
|
|
75
|
+
}
|
|
69
76
|
if (stats?.pending || !stats) {
|
|
70
77
|
const waitEstimate = initialEstimate();
|
|
71
78
|
return reply.code(202).send({ pending: true, ...(waitEstimate ? { waitEstimate } : {}) });
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { logger } from "../../../utils/logger.js";
|
|
13
|
+
import { performance } from "node:perf_hooks";
|
|
13
14
|
import { OUTPUT_NO_CAPACITY, OUTPUT_UNAVAILABLE } from "../../../services/encode/output/index.js";
|
|
14
15
|
import { replyNoCapacity, replyOutputUnavailable } from "../../../services/server/http-responses.js";
|
|
15
16
|
import { waitForPlan } from "../../../services/media/await-plan.js";
|
|
@@ -81,38 +82,49 @@ export async function handleApiTranscodeSessionsPost(req, reply, { viewerRequest
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
const cancellation = new AbortController();
|
|
85
|
+
const timing = { startedAt: performance.now(), openMs: 0, openCalls: 0,
|
|
86
|
+
planWaitInclusiveMs: null, masterMs: null, offerMs: null, tracksMs: null };
|
|
84
87
|
const cancelled = () => cancellation.abort();
|
|
85
88
|
reply.raw?.once?.("close", cancelled);
|
|
86
89
|
if (reply.raw?.destroyed) cancelled();
|
|
87
90
|
try {
|
|
88
|
-
const open = () =>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
91
|
+
const open = async () => {
|
|
92
|
+
const openStartedAt = performance.now();
|
|
93
|
+
timing.openCalls += 1;
|
|
94
|
+
try {
|
|
95
|
+
return await viewerRequests.createOrGetSession({
|
|
96
|
+
signal: cancellation.signal,
|
|
97
|
+
sourceKey,
|
|
98
|
+
fileIndex,
|
|
99
|
+
transcodeVideo,
|
|
100
|
+
transcodeAudio,
|
|
101
|
+
consumerId,
|
|
102
|
+
fileName,
|
|
103
|
+
targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
|
|
104
|
+
targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
|
|
105
|
+
exactSize,
|
|
106
|
+
visiblePicture,
|
|
107
|
+
audioRenditions,
|
|
108
|
+
startPositionSeconds:
|
|
109
|
+
Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
|
|
110
|
+
? startPositionSeconds
|
|
111
|
+
: 0,
|
|
112
|
+
viewerLinkMbps: Number.isFinite(viewerLinkMbps) && viewerLinkMbps > 0 ? viewerLinkMbps : null,
|
|
113
|
+
linkSampleMbps: Number.isFinite(linkSampleMbps) && linkSampleMbps > 0 ? linkSampleMbps : null,
|
|
114
|
+
linkSampleAt: Number.isFinite(linkSampleAt) && linkSampleAt > 0 ? linkSampleAt : null,
|
|
115
|
+
linkSampleAgeMs: Number.isFinite(linkSampleAgeMs) && linkSampleAgeMs >= 0 ? linkSampleAgeMs : null,
|
|
116
|
+
bufferedAheadSec: Number.isFinite(bufferedAheadSec) && bufferedAheadSec >= 0 ? bufferedAheadSec : 0,
|
|
117
|
+
bufferLimitSeconds:
|
|
118
|
+
Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0 ? bufferLimitSeconds : null,
|
|
119
|
+
audioTrackIndex:
|
|
120
|
+
Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0,
|
|
121
|
+
segmentFormatId
|
|
122
|
+
});
|
|
123
|
+
} finally {
|
|
124
|
+
timing.openMs += performance.now() - openStartedAt;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const planWaitStartedAt = performance.now();
|
|
116
128
|
const session = subscribeSource ? (await waitForPlan({ signal: cancellation.signal,
|
|
117
129
|
subscribe: listener => subscribeSource(sourceKey, fileIndex, listener),
|
|
118
130
|
read: async () => {
|
|
@@ -123,13 +135,22 @@ export async function handleApiTranscodeSessionsPost(req, reply, { viewerRequest
|
|
|
123
135
|
}
|
|
124
136
|
}
|
|
125
137
|
})).session : await open();
|
|
138
|
+
timing.planWaitInclusiveMs = performance.now() - planWaitStartedAt;
|
|
126
139
|
// The index of quality variants, when this session has more than one to
|
|
127
140
|
// offer. Its presence is what tells the browser it can change quality
|
|
128
141
|
// without a new session: the player switches variants itself, appending the
|
|
129
142
|
// new one after what is already buffered. Absent for a copied video, whose
|
|
130
143
|
// segments are cut at the source's own keyframes and so cannot be spliced
|
|
131
144
|
// with a re-encoded rung.
|
|
145
|
+
let stageStartedAt = performance.now();
|
|
132
146
|
const hasVariants = renditions.buildMasterPlaylist(session.id) !== null;
|
|
147
|
+
timing.masterMs = performance.now() - stageStartedAt;
|
|
148
|
+
stageStartedAt = performance.now();
|
|
149
|
+
const offeredHeights = quality.offeredHeights(session);
|
|
150
|
+
timing.offerMs = performance.now() - stageStartedAt;
|
|
151
|
+
stageStartedAt = performance.now();
|
|
152
|
+
const tracks = renditions.declaredTracks(session);
|
|
153
|
+
timing.tracksMs = performance.now() - stageStartedAt;
|
|
133
154
|
return reply.send({
|
|
134
155
|
sessionId: session.id,
|
|
135
156
|
playlistPath: `/transcode/${session.id}/index.m3u8`,
|
|
@@ -149,12 +170,12 @@ export async function handleApiTranscodeSessionsPost(req, reply, { viewerRequest
|
|
|
149
170
|
// it, it fell back to a ladder of its own invention and offered rungs the
|
|
150
171
|
// proxy had just refused, and picking one re-opened the session at a
|
|
151
172
|
// height measured at a third of realtime.
|
|
152
|
-
offeredHeights
|
|
173
|
+
offeredHeights,
|
|
153
174
|
// What this session's output will carry, stated rather than left to be
|
|
154
175
|
// discovered. The browser checks what it actually got against this: a
|
|
155
176
|
// track that never arrives is otherwise noticed only by its absence,
|
|
156
177
|
// minutes later, as a black picture with working sound.
|
|
157
|
-
tracks
|
|
178
|
+
tracks,
|
|
158
179
|
// The soundtrack this viewer is sent and whether it is re-encoded — the
|
|
159
180
|
// proxy's decision, which can differ from what the page asked for: a
|
|
160
181
|
// track no figure states a rate for is re-encoded even for a browser that
|
|
@@ -196,5 +217,13 @@ export async function handleApiTranscodeSessionsPost(req, reply, { viewerRequest
|
|
|
196
217
|
return reply.code(500).send({ error: `Failed to prepare transcode session: ${message}` });
|
|
197
218
|
} finally {
|
|
198
219
|
reply.raw?.removeListener?.("close", cancelled);
|
|
220
|
+
const elapsed = (key) => Number.isFinite(timing[key]) ? timing[key].toFixed(1) : "n/a";
|
|
221
|
+
logger.info(
|
|
222
|
+
`transcode-session timing outcome=${cancellation.signal.aborted ? "cancelled" : "handler-return"} ` +
|
|
223
|
+
`consumer=${consumerId || "unknown"} file=${fileIndex} openCalls=${timing.openCalls} ` +
|
|
224
|
+
`openMs=${timing.openMs.toFixed(1)} planWaitInclusiveMs=${elapsed("planWaitInclusiveMs")} ` +
|
|
225
|
+
`masterMs=${elapsed("masterMs")} offerMs=${elapsed("offerMs")} tracksMs=${elapsed("tracksMs")} ` +
|
|
226
|
+
`handlerMs=${(performance.now() - timing.startedAt).toFixed(1)}`
|
|
227
|
+
);
|
|
199
228
|
}
|
|
200
229
|
}
|
|
@@ -1031,48 +1031,6 @@ export class SegmentServing {
|
|
|
1031
1031
|
}
|
|
1032
1032
|
}
|
|
1033
1033
|
|
|
1034
|
-
/**
|
|
1035
|
-
* Wait for a valid HLS playlist or a terminal output state, without polling.
|
|
1036
|
-
*
|
|
1037
|
-
* @param {HlsSession} session
|
|
1038
|
-
* @returns {Promise<void>}
|
|
1039
|
-
*/
|
|
1040
|
-
async waitUntilReady(session) {
|
|
1041
|
-
// With a synthetic VOD playlist there is nothing to wait for: the playlist
|
|
1042
|
-
// is generated from the probed duration and is available immediately.
|
|
1043
|
-
// Individual segments are long-polled by the segment route as ffmpeg
|
|
1044
|
-
// produces them.
|
|
1045
|
-
if (session.useSyntheticPlaylist) {
|
|
1046
|
-
if (this.#host.encodeRuns.hasFailed(session)) {
|
|
1047
|
-
throw new Error(this.#host.encodeRuns.lastErrorOf(session) || "ffmpeg failed to start HLS session.");
|
|
1048
|
-
}
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
const playlistPath = path.join(this.#host.segmentStore.pathFor(session.outputKey ?? ""), PLAYLIST_FILE_NAME);
|
|
1053
|
-
while (this.#host.outputs.get(session.id) === session) {
|
|
1054
|
-
const change = this.subscribeFileChange(session.id);
|
|
1055
|
-
try {
|
|
1056
|
-
if (this.#host.encodeRuns.hasFailed(session)) {
|
|
1057
|
-
throw new Error(this.#host.encodeRuns.lastErrorOf(session) || "ffmpeg failed to start HLS session.");
|
|
1058
|
-
}
|
|
1059
|
-
try {
|
|
1060
|
-
await access(playlistPath);
|
|
1061
|
-
const text = await readFile(playlistPath, "utf8");
|
|
1062
|
-
if (text.includes("#EXTM3U")) {
|
|
1063
|
-
return;
|
|
1064
|
-
}
|
|
1065
|
-
} catch (_error) {
|
|
1066
|
-
// Playlist is not ready yet.
|
|
1067
|
-
}
|
|
1068
|
-
await change.changed;
|
|
1069
|
-
} finally {
|
|
1070
|
-
change.release();
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
throw new Error("HLS output was disposed before its playlist became available.");
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
1034
|
/**
|
|
1077
1035
|
* Answer the player's report that a delivered fragment sits far from the edge
|
|
1078
1036
|
* of its buffer, with the one fact only this side holds: which boundary the
|
|
@@ -11,13 +11,6 @@ import { logger } from "../../utils/logger.js";
|
|
|
11
11
|
import { isOutputName, OutputSpec } from "../encode/output/index.js";
|
|
12
12
|
import { withSubtitleReadiness } from "./subtitle-readiness.js";
|
|
13
13
|
|
|
14
|
-
function isWarmupTimeoutError(error) {
|
|
15
|
-
if (!(error instanceof Error)) {
|
|
16
|
-
return false;
|
|
17
|
-
}
|
|
18
|
-
return error.message === "HLS playlist is still warming up.";
|
|
19
|
-
}
|
|
20
|
-
|
|
21
14
|
/**
|
|
22
15
|
* The segment number an assignment is recorded under for this file, −1 for the
|
|
23
16
|
* init, or null for anything that is neither (a playlist).
|
|
@@ -93,7 +86,7 @@ export class ViewerRequests {
|
|
|
93
86
|
#forecastPieces = new WeakMap();
|
|
94
87
|
|
|
95
88
|
/**
|
|
96
|
-
* @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `
|
|
89
|
+
* @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `planEncodersSoon`, `encodeRuns`, `encodeSpeedReadingsOf`, `outputTimes`, `outputs`, `lookaheadSeconds`, `quality`, `qualityOffer`, `renditions`, `segmentDurationSec`, `segmentStore`, `sourceFiles`, `startupWaitMs`, `viewers`, `acceptsGeneration`, `generationOfRequest`, `noteGivenOutput`, `holdForResponse`, `noteServingVerdict`, `servingVerdictOf`
|
|
97
90
|
*/
|
|
98
91
|
constructor(host) {
|
|
99
92
|
this.#host = host;
|
|
@@ -421,19 +414,13 @@ export class ViewerRequests {
|
|
|
421
414
|
// Where the first encoder goes is the plan's, from where the viewers stand.
|
|
422
415
|
this.#host.planEncodersSoon();
|
|
423
416
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (existed && !isWarmupTimeoutError(error)) {
|
|
432
|
-
throw error;
|
|
433
|
-
}
|
|
434
|
-
// Not a failure of the request: the synthetic playlist is already there
|
|
435
|
-
// and segments appear as the encoder produces them.
|
|
436
|
-
}
|
|
417
|
+
// Return the allocated session before a keyframe-based output writes its
|
|
418
|
+
// first playlist. The browser can then poll this session's live playback
|
|
419
|
+
// forecast while the source, proxy and client-link work is still underway;
|
|
420
|
+
// otherwise this request hides all revised estimates until the first
|
|
421
|
+
// segment is already ready. The playlist route and the viewer's existing
|
|
422
|
+
// wait loop handle output that is still being prepared, and the progress
|
|
423
|
+
// route reports a terminal encoder failure.
|
|
437
424
|
return output;
|
|
438
425
|
}
|
|
439
426
|
|
|
@@ -631,7 +631,6 @@ export function wireOutputs({
|
|
|
631
631
|
getSourceStats: (...args) => parts.getSourceStats?.(...args) ?? null,
|
|
632
632
|
disposeSession: (...args) => parts.lifecycle.disposeSession(...args),
|
|
633
633
|
planEncodersSoon: (...args) => parts.encodeRuns.planEncodersSoon(...args),
|
|
634
|
-
waitUntilReady: (...args) => parts.serving.waitUntilReady(...args),
|
|
635
634
|
noteViewerWaiting: (viewer, wait) => parts.encodeOrchestrator?.noteViewerWaiting(viewer, wait),
|
|
636
635
|
get encodeRuns() { return parts.encodeRuns; },
|
|
637
636
|
// When this viewer's playback can start and run to the end: the viewer
|