@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.105.13",
3
+ "version": "2.106.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -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 downloadMaps.playback({ sourceKey, fileIndex, durationSeconds, zones });
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 published = this.#host.segmentFiles.publish(address, name, session.segmentFormat, { mediaRanges, bytes });
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 && file.shape === shape) file.shape = 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 = () => {}, inspect, withdraw, reprice = () => {}, failed }) {
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;
@@ -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 process = typeof statedProcess === "string" && /^[A-Z][0-9]+(?:\.(?:[0-9]+[a-z]?|[va]))?$/.test(statedProcess)
57
- ? statedProcess : processForRequest(req.url);
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
  }