@torrent-tv/proxy 2.105.13 → 2.106.1
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/routes/api/playback-plan/post.js +10 -1
- package/server.js +9 -3
- package/services/encode/EncodeRuns.js +15 -1
- package/services/server/ViewerRequests.js +12 -0
- package/services/server/publish-playback-before-preparation-refresh.js +11 -0
- package/services/viewer/DownloadMaps.js +12 -1
- package/services/viewer/SourcePreparation.js +12 -4
- package/utils/wait-trace.js +4 -3
package/package.json
CHANGED
|
@@ -24,7 +24,7 @@ function getPayload(body) {
|
|
|
24
24
|
return {};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, sourceRegistry, torrentPool, ffmpegBin, localBaseUrl, viewers }) {
|
|
27
|
+
export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, sourceRegistry, torrentPool, ffmpegBin, localBaseUrl, viewers, observeWait }) {
|
|
28
28
|
const payload = getPayload(req.body);
|
|
29
29
|
const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
|
|
30
30
|
const fileIndex = Number(payload.fileIndex);
|
|
@@ -38,10 +38,13 @@ export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, s
|
|
|
38
38
|
if (typeof payload.positionSeconds === "number" && Number.isFinite(payload.positionSeconds) && payload.positionSeconds >= 0) selection.positionSeconds = payload.positionSeconds;
|
|
39
39
|
if (typeof payload.wantsToPlay === "boolean") selection.wantsToPlay = payload.wantsToPlay;
|
|
40
40
|
if (consumerId) viewers?.selectsFile(consumerId, sourceKey, fileIndex, Date.now(), selection);
|
|
41
|
+
const startedAt = performance.now();
|
|
41
42
|
|
|
42
43
|
// Interface delegates to PlaybackController (orchestrator + domain). Keeps route thin.
|
|
43
44
|
const { PlaybackController } = await import("../../../services/server/controllers/PlaybackController.js");
|
|
44
45
|
const controller = new PlaybackController({ torrentPool, sourceRegistry, ffmpegBin, localBaseUrl, playbackPlanner });
|
|
46
|
+
const waitFields = { process: "P3", sourceKey, fileIndex };
|
|
47
|
+
observeWait?.(sourceKey, "process", { ...waitFields, phase: "begin", at: startedAt });
|
|
45
48
|
const cancellation = new AbortController();
|
|
46
49
|
const aborted = () => cancellation.abort();
|
|
47
50
|
const closed = () => { if (!reply.raw?.writableEnded) aborted(); };
|
|
@@ -52,8 +55,14 @@ export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, s
|
|
|
52
55
|
const params = { sourceKey, fileIndex, userAgent };
|
|
53
56
|
const plan = payload.waitForReady === true
|
|
54
57
|
? await controller.getReadyPlan(params, { signal: cancellation.signal }) : await controller.getPlan(params);
|
|
58
|
+
const endedAt = performance.now();
|
|
59
|
+
observeWait?.(sourceKey, "process", { ...waitFields, phase: "end", at: endedAt, startedAt,
|
|
60
|
+
endedAt, durationMs: endedAt - startedAt, outcome: "plan-ready" });
|
|
55
61
|
return reply.send(plan);
|
|
56
62
|
} catch (error) {
|
|
63
|
+
const endedAt = performance.now();
|
|
64
|
+
observeWait?.(sourceKey, "process", { ...waitFields, phase: "end", at: endedAt, startedAt,
|
|
65
|
+
endedAt, durationMs: endedAt - startedAt, outcome: cancellation.signal.aborted ? "cancelled" : "error" });
|
|
57
66
|
if (cancellation.signal.aborted) return;
|
|
58
67
|
if (error instanceof Error && error.code === "SOURCE_NOT_FOUND") {
|
|
59
68
|
return reply.code(404).send({ error: error.message });
|
package/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { trackChoicesOf } from "./services/viewer/segment-demands.js";
|
|
|
23
23
|
import { PlaybackProjection } from "./services/torrent/PlaybackProjection.js";
|
|
24
24
|
import { SegmentInputTable } from "./services/viewer/segment-input-table.js";
|
|
25
25
|
import { SourcePreparation, preparationInterval } from "./services/viewer/SourcePreparation.js";
|
|
26
|
+
import { publishPlaybackBeforePreparationRefresh } from "./services/server/publish-playback-before-preparation-refresh.js";
|
|
26
27
|
import { handleHealthzGet } from "./routes/healthz/get.js";
|
|
27
28
|
import { handleApiDeliverySinkGet } from "./routes/api/delivery-sink/get.js";
|
|
28
29
|
import { handleApiLinkProbeGet } from "./routes/api/link-probe/get.js";
|
|
@@ -1213,7 +1214,6 @@ export async function startProxyServer({
|
|
|
1213
1214
|
setDownloadRate: (bytesPerSecond, because) => torrentPool.setDownloadRate?.(bytesPerSecond, because),
|
|
1214
1215
|
setSpeculativeHold: (held, because) => torrentPool.setSpeculativeHold?.(held, because),
|
|
1215
1216
|
setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
|
|
1216
|
-
void sourcePreparation?.refresh();
|
|
1217
1217
|
const record = sourceRegistry.get(sourceKey);
|
|
1218
1218
|
if (!record) {
|
|
1219
1219
|
return;
|
|
@@ -1224,9 +1224,13 @@ export async function startProxyServer({
|
|
|
1224
1224
|
if (!outputParts.viewers.forSource(sourceKey).length) probeReads.forget(sourceKey, fileIndex);
|
|
1225
1225
|
await downloadMaps.forget(sourceKey, fileIndex, { keepPreparation: true });
|
|
1226
1226
|
playbackProjections.get(`${sourceKey}:${fileIndex}`)?.clear();
|
|
1227
|
+
void sourcePreparation?.refresh();
|
|
1227
1228
|
return;
|
|
1228
1229
|
}
|
|
1229
|
-
await
|
|
1230
|
+
await publishPlaybackBeforePreparationRefresh(
|
|
1231
|
+
() => downloadMaps.playback({ sourceKey, fileIndex, durationSeconds, zones }),
|
|
1232
|
+
() => { void sourcePreparation?.refresh(); }
|
|
1233
|
+
);
|
|
1230
1234
|
mediaReads.retain(sourceKey, fileIndex, params => !params.requestId?.startsWith("download:") ||
|
|
1231
1235
|
zones.some(zone => zone.from === params.downloadInterval?.from && zone.to === params.downloadInterval?.to));
|
|
1232
1236
|
} catch (error) {
|
|
@@ -1375,6 +1379,7 @@ export async function startProxyServer({
|
|
|
1375
1379
|
const held = await torrentPool.heldRangesOf(torrent, work.fileIndex);
|
|
1376
1380
|
return work.inputRanges.every(([start, end]) => held.some(([from, to]) => from <= start && to >= end));
|
|
1377
1381
|
},
|
|
1382
|
+
playbackAppliedFor: (sourceKey, fileIndex) => downloadMaps.hasAppliedPlayback(sourceKey, fileIndex),
|
|
1378
1383
|
readyChanged: (work, ready) => logger.info(`source urgent request=${work.requestId} viewer=${work.ownerId} ` +
|
|
1379
1384
|
`source=${work.sourceKey} file=${work.fileIndex} inputReady=${ready}`),
|
|
1380
1385
|
inspect: async work => {
|
|
@@ -1614,7 +1619,8 @@ export async function startProxyServer({
|
|
|
1614
1619
|
handleApiClientLogsPost(req, reply, { clientLogs, viewerRequests: outputParts.viewerRequests })
|
|
1615
1620
|
);
|
|
1616
1621
|
app.post("/api/playback-plan", async (req, reply) =>
|
|
1617
|
-
handleApiPlaybackPlanPost(req, reply, { playbackPlanner, viewers: outputParts.viewers
|
|
1622
|
+
handleApiPlaybackPlanPost(req, reply, { playbackPlanner, viewers: outputParts.viewers,
|
|
1623
|
+
observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields) })
|
|
1618
1624
|
);
|
|
1619
1625
|
app.post("/api/playback-plan/audio-tracks", async (req, reply) =>
|
|
1620
1626
|
handleApiPlaybackPlanAudioTracksPost(req, reply, { playbackPlanner })
|
|
@@ -1234,7 +1234,21 @@ export class EncodeRuns {
|
|
|
1234
1234
|
if (mediaRanges === false && tentative) return null;
|
|
1235
1235
|
if (mediaRanges === false) throw Object.assign(new Error(`Closed piece #${index} has incomplete media.`),
|
|
1236
1236
|
{ code: "ERR_INCOMPLETE_MEDIA" });
|
|
1237
|
-
const
|
|
1237
|
+
const publishStartedAt = performance.now();
|
|
1238
|
+
const publishProcess = session.spec?.carries === "audio-only" ? "P11.a" : "P11.v";
|
|
1239
|
+
this.#host.observeWait?.(session.file.sourceKey, "process", { process: publishProcess, phase: "begin",
|
|
1240
|
+
output: address, segment: index, at: publishStartedAt });
|
|
1241
|
+
let published;
|
|
1242
|
+
let publishOutcome = "error";
|
|
1243
|
+
try {
|
|
1244
|
+
published = this.#host.segmentFiles.publish(address, name, session.segmentFormat, { mediaRanges, bytes });
|
|
1245
|
+
publishOutcome = published ? "published" : "not-published";
|
|
1246
|
+
} finally {
|
|
1247
|
+
const publishEndedAt = performance.now();
|
|
1248
|
+
this.#host.observeWait?.(session.file.sourceKey, "process", { process: publishProcess, phase: "end",
|
|
1249
|
+
output: address, segment: index, at: publishEndedAt, startedAt: publishStartedAt, endedAt: publishEndedAt,
|
|
1250
|
+
durationMs: publishEndedAt - publishStartedAt, outcome: publishOutcome });
|
|
1251
|
+
}
|
|
1238
1252
|
if (!published) throw new Error(`Closed piece #${index} could not be stored.`);
|
|
1239
1253
|
if (index === safeIndex) this.#host.observeWait?.(session.file.sourceKey, "first-piece-published", {
|
|
1240
1254
|
output: address, segment: index, startedAt: observedStart, endedAt: performance.now(),
|
|
@@ -865,6 +865,11 @@ export class ViewerRequests {
|
|
|
865
865
|
}
|
|
866
866
|
|
|
867
867
|
async #playbackReadinessFor(session, consumerId, progress, outputMbps) {
|
|
868
|
+
const waitStartedAt = performance.now();
|
|
869
|
+
const waitFields = { process: "P14", sessionId: session.id, consumerId };
|
|
870
|
+
this.#host.observeWait?.(session.file.sourceKey, "process", { ...waitFields, phase: "begin", at: waitStartedAt });
|
|
871
|
+
let waitOutcome = "error";
|
|
872
|
+
try {
|
|
868
873
|
const now = Date.now();
|
|
869
874
|
// WHERE AN ANSWER'S TIME GOES, said with each answer. On Home Assistant a
|
|
870
875
|
// held viewer's answer took 11.2 s while its forecast took 0.1-0.4 s and
|
|
@@ -1116,7 +1121,14 @@ export class ViewerRequests {
|
|
|
1116
1121
|
this.#host.noteViewerWaiting?.(consumerId || session.id,
|
|
1117
1122
|
holding?.kind === "production" ? { address: holding.track, index: holding.segment } : null);
|
|
1118
1123
|
delete forecast.firstRequiredStages;
|
|
1124
|
+
waitOutcome = "forecast-ready";
|
|
1119
1125
|
return forecast;
|
|
1126
|
+
} finally {
|
|
1127
|
+
const waitEndedAt = performance.now();
|
|
1128
|
+
this.#host.observeWait?.(session.file.sourceKey, "process", { ...waitFields, phase: "end", at: waitEndedAt,
|
|
1129
|
+
startedAt: waitStartedAt, endedAt: waitEndedAt, durationMs: waitEndedAt - waitStartedAt,
|
|
1130
|
+
outcome: waitOutcome });
|
|
1131
|
+
}
|
|
1120
1132
|
}
|
|
1121
1133
|
|
|
1122
1134
|
/**
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep source preparation stated until the viewer's playback map is applied.
|
|
3
|
+
*
|
|
4
|
+
* @param {() => Promise<unknown>} publishPlayback
|
|
5
|
+
* @param {() => unknown} refreshPreparation
|
|
6
|
+
* @returns {Promise<void>}
|
|
7
|
+
*/
|
|
8
|
+
export async function publishPlaybackBeforePreparationRefresh(publishPlayback, refreshPreparation) {
|
|
9
|
+
await publishPlayback();
|
|
10
|
+
refreshPreparation();
|
|
11
|
+
}
|
|
@@ -162,6 +162,11 @@ export class DownloadMaps {
|
|
|
162
162
|
return this.#files.get(`${sourceKey}:${fileIndex}`)?.epoch ?? 0;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
hasAppliedPlayback(sourceKey, fileIndex) {
|
|
166
|
+
const file = this.#files.get(`${sourceKey}:${fileIndex}`);
|
|
167
|
+
return Boolean(file?.zones.length && file.shape && file.appliedShape === file.shape);
|
|
168
|
+
}
|
|
169
|
+
|
|
165
170
|
native({ sourceKey, fileIndex, zones }) {
|
|
166
171
|
const file = this.#file(sourceKey, fileIndex);
|
|
167
172
|
file.nativeZones = zones;
|
|
@@ -325,7 +330,12 @@ export class DownloadMaps {
|
|
|
325
330
|
const answeredAt = this.#clock();
|
|
326
331
|
// A torrent not yet in the thread applies nothing, so this map was not
|
|
327
332
|
// delivered and the next one, however alike, has to be.
|
|
328
|
-
if (reply?.appliedMs === null
|
|
333
|
+
if (reply?.appliedMs === null) {
|
|
334
|
+
if (file.shape === shape) file.shape = null;
|
|
335
|
+
if (file.appliedShape === shape) file.appliedShape = null;
|
|
336
|
+
} else {
|
|
337
|
+
file.appliedShape = shape;
|
|
338
|
+
}
|
|
329
339
|
// The window open now, which a report since this map was asked for may have started.
|
|
330
340
|
const stats = this.#statsOf(file);
|
|
331
341
|
stats.sent += 1;
|
|
@@ -345,6 +355,7 @@ export class DownloadMaps {
|
|
|
345
355
|
// sent, and leaves nothing to compare the next one against.
|
|
346
356
|
file.pending = pending.catch(() => {
|
|
347
357
|
if (file.shape === shape) file.shape = null;
|
|
358
|
+
if (file.appliedShape === shape) file.appliedShape = null;
|
|
348
359
|
});
|
|
349
360
|
return pending;
|
|
350
361
|
}
|
|
@@ -38,6 +38,7 @@ export class SourcePreparation {
|
|
|
38
38
|
#reprice;
|
|
39
39
|
#inputReady;
|
|
40
40
|
#readyChanged;
|
|
41
|
+
#playbackAppliedFor;
|
|
41
42
|
#work = new Map();
|
|
42
43
|
#revision = 0;
|
|
43
44
|
#completedRevision = null;
|
|
@@ -45,7 +46,8 @@ export class SourcePreparation {
|
|
|
45
46
|
#candidateAbort = new AbortController();
|
|
46
47
|
|
|
47
48
|
constructor({ viewers, candidatesFor, relatedFilesFor = () => [], priorityFor = (_viewer, priority) => priority,
|
|
48
|
-
inputReady = async () => false, readyChanged = () => {},
|
|
49
|
+
inputReady = async () => false, readyChanged = () => {}, playbackAppliedFor = () => true,
|
|
50
|
+
inspect, withdraw, reprice = () => {}, failed }) {
|
|
49
51
|
this.#viewers = viewers;
|
|
50
52
|
this.#candidates = candidatesFor;
|
|
51
53
|
this.#related = relatedFilesFor;
|
|
@@ -56,6 +58,7 @@ export class SourcePreparation {
|
|
|
56
58
|
this.#reprice = reprice;
|
|
57
59
|
this.#inputReady = inputReady;
|
|
58
60
|
this.#readyChanged = readyChanged;
|
|
61
|
+
this.#playbackAppliedFor = playbackAppliedFor;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
refresh() {
|
|
@@ -80,8 +83,13 @@ export class SourcePreparation {
|
|
|
80
83
|
|
|
81
84
|
/** Current owners determine priority; old work never restores a withdrawn read. */
|
|
82
85
|
demandFor(work) {
|
|
86
|
+
const handoffFileIndex = work.selected || (work.role === "source-rest" && work.ownerFileIndex === work.fileIndex)
|
|
87
|
+
? work.fileIndex : null;
|
|
88
|
+
const opening = Number.isInteger(handoffFileIndex) && this.#viewers.forSource(work.sourceKey).some(viewer =>
|
|
89
|
+
(work.ownerId === undefined || viewer.id === work.ownerId) && viewer.source.selectedFileIndex === handoffFileIndex &&
|
|
90
|
+
viewer.outputs.size > 0 && !this.#playbackAppliedFor(work.sourceKey, handoffFileIndex));
|
|
83
91
|
if (work.selected && work.statement === "packets" &&
|
|
84
|
-
work.positions !== this.#positionsFor(work.sourceKey, work.fileIndex, work.ownerId)) return null;
|
|
92
|
+
work.positions !== this.#positionsFor(work.sourceKey, work.fileIndex, work.ownerId, opening)) return null;
|
|
85
93
|
if (work.role === "subtitle-embedded" && ["packets", "subtitle-cues"].includes(work.statement) &&
|
|
86
94
|
work.positions !== this.#positionsFor(work.sourceKey, work.fileIndex, work.ownerId, true)) return null;
|
|
87
95
|
const owners = this.#viewers.forSource(work.sourceKey).filter(viewer => {
|
|
@@ -90,9 +98,9 @@ export class SourcePreparation {
|
|
|
90
98
|
viewer.subtitle.trackIndex !== work.trackIndex)) return false;
|
|
91
99
|
const selected = viewer.source.selectedFileIndex;
|
|
92
100
|
if (work.ownerFileIndex !== undefined) return selected === work.ownerFileIndex &&
|
|
93
|
-
(work.role !== "source-rest" || viewer.outputs.size === 0);
|
|
101
|
+
(work.role !== "source-rest" || viewer.outputs.size === 0 || opening);
|
|
94
102
|
if (work.statement !== "packets") return selected === null || selected === work.fileIndex;
|
|
95
|
-
if (selected !== null) return selected === work.fileIndex && viewer.outputs.size === 0 && work.selected;
|
|
103
|
+
if (selected !== null) return selected === work.fileIndex && (viewer.outputs.size === 0 || opening) && work.selected;
|
|
96
104
|
return !work.selected && viewer.source.visibleFileIndices.includes(work.fileIndex);
|
|
97
105
|
});
|
|
98
106
|
if (!owners.length) return null;
|
package/utils/wait-trace.js
CHANGED
|
@@ -53,8 +53,9 @@ export class SourceWaitTrace {
|
|
|
53
53
|
!Number.isSafeInteger(attempt) || attempt < 0) return;
|
|
54
54
|
const context = { wait, attempt, operation, startedAt: this.now() };
|
|
55
55
|
this.requests.set(req, context);
|
|
56
|
-
const
|
|
57
|
-
|
|
56
|
+
const endpointProcess = processForRequest(req.url);
|
|
57
|
+
const process = endpointProcess ?? (typeof statedProcess === "string" &&
|
|
58
|
+
/^[A-Z][0-9]+(?:\.(?:[0-9]+[a-z]?|[va]))?$/.test(statedProcess) ? statedProcess : null);
|
|
58
59
|
this.write(context, "http-start", { process, phase: "begin", method: req.method, path: req.url,
|
|
59
60
|
requestId: req.headers["x-ttv-forwarded-request-id"] ?? req.id });
|
|
60
61
|
let finished = false;
|
|
@@ -92,11 +93,11 @@ export class SourceWaitTrace {
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
function processForRequest(path) {
|
|
96
|
+
if (/^\/api\/transcode-sessions\/[^/?]+\/progress(?:\?|$)/.test(path)) return "P14";
|
|
95
97
|
if (path.startsWith("/api/transcode-sessions")) return "P6.v";
|
|
96
98
|
if (path.startsWith("/api/playback-plan")) return "P3";
|
|
97
99
|
if (/\/transcode\/.*\.m3u8(?:\?|$)/.test(path)) return "P12";
|
|
98
100
|
if (/\/transcode\/.*(?:init|\.mp4)(?:\?|$)/.test(path)) return "P12";
|
|
99
101
|
if (/\/transcode\/.*(?:\.m4s|\.ts)(?:\?|$)/.test(path)) return "P12";
|
|
100
|
-
if (path.startsWith("/api/session-progress")) return "P14";
|
|
101
102
|
return null;
|
|
102
103
|
}
|