@torrent-tv/proxy 2.100.13 → 2.100.14

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.100.13",
3
+ "version": "2.100.14",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -41,6 +41,22 @@ export async function handleTranscodeVariantFileGet(req, reply, { renditions, se
41
41
  if (refusedAsStale(req, reply, viewerRequests, fileName)) {
42
42
  return reply;
43
43
  }
44
+ // A held segment can be given to another output while it waits (a move
45
+ // between two outputs of this height); the request then resolves again.
46
+ for (;;) {
47
+ const answered = await serveResolved(req, reply, { renditions, serving, viewerRequests, baseSessionId, height, fileName, consumerId });
48
+ if (answered !== "reassigned") {
49
+ return answered;
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Resolve the address once and answer it from what it resolves to.
56
+ *
57
+ * @returns {Promise<unknown>} "reassigned" when the held segment was given to another output meanwhile.
58
+ */
59
+ async function serveResolved(req, reply, { renditions, serving, viewerRequests, baseSessionId, height, fileName, consumerId }) {
44
60
  const resolved = await renditions.resolveVariantFile(
45
61
  baseSessionId,
46
62
  height,
@@ -90,6 +106,7 @@ export async function handleTranscodeVariantFileGet(req, reply, { renditions, se
90
106
  serving,
91
107
  viewerRequests,
92
108
  sessionId: resolved.sessionId,
93
- fileName
109
+ fileName,
110
+ stillGiven: () => renditions.stillGivenTo(baseSessionId, height, fileName, consumerId, statedGenerationOf(req), resolved.sessionId)
94
111
  });
95
112
  }
@@ -39,12 +39,12 @@ export class Renditions {
39
39
  * The segment each viewer's move was last said to wait for on the output
40
40
  * they are leaving, so that a wait is said once per segment.
41
41
  *
42
- * @type {Map<string, number>}
42
+ * @type {Map<string, string>}
43
43
  */
44
44
  #moveWaitsSaid = new Map();
45
45
 
46
46
  /**
47
- * @param {object} host - `viewerSecondsOn`, `audioStartSecondsFor`, `activeOutputFor`, `viewersOf`, `audioRenditionName`, `logger`, `placeViewer`, `viewerLeaves`, `createOrGetSession`, `planEncodersSoon`, `disposeSession`, `viewerPositionOf`, `encodeRuns`, `fileStartTimeReads`, `getCachedAudioTracks`, `getCachedMediaInfo`, `getContainerMediaInfo`, `localBaseUrl`, `outputTimes`, `outputs`, `quality`, `qualityOffer`, `segmentDurationSec`, `sourceFiles`, `viewers`, `generationOfRequest`, `givenOutputOf`, `noteGivenOutput`, `chosenOutputOf`, `chooseOutput`, `storedPieceReady`, `headersCompatible`, `audioChoiceOf`, `linkMbpsOf`, `qualityModeOf`, `noteServingVerdict`, `limitsFor`, `heightsChosenAs`, `highestGivenSegmentOf`, `sameHeightSwitchOf`, `noteSameHeightSwitch`, `switchingOnto`, `switchingFrom`, `segmentClosed`, `bufferedSecondsOf`, `minimumBufferSecondsFor`
47
+ * @param {object} host - `viewerSecondsOn`, `audioStartSecondsFor`, `activeOutputFor`, `viewersOf`, `audioRenditionName`, `logger`, `placeViewer`, `viewerLeaves`, `createOrGetSession`, `planEncodersSoon`, `disposeSession`, `viewerPositionOf`, `encodeRuns`, `fileStartTimeReads`, `getCachedAudioTracks`, `getCachedMediaInfo`, `getContainerMediaInfo`, `localBaseUrl`, `outputTimes`, `outputs`, `quality`, `qualityOffer`, `segmentDurationSec`, `sourceFiles`, `viewers`, `generationOfRequest`, `givenOutputOf`, `noteGivenOutput`, `chosenOutputOf`, `chooseOutput`, `storedPieceReady`, `headersCompatible`, `audioChoiceOf`, `linkMbpsOf`, `qualityModeOf`, `noteServingVerdict`, `limitsFor`, `heightsChosenAs`, `highestGivenSegmentOf`, `sameHeightSwitchOf`, `noteSameHeightSwitch`, `switchingOnto`, `switchingFrom`, `segmentClosed`, `secondsUntilClosed`, `invalidateWaits`, `bufferedSecondsOf`, `minimumBufferSecondsFor`
48
48
  */
49
49
  constructor(host) {
50
50
  this.#host = host;
@@ -997,31 +997,31 @@ export class Renditions {
997
997
  this.#cancelSameHeightSwitch(base, consumerId, "there is nothing left of the film to give from it");
998
998
  return;
999
999
  }
1000
- // A segment closed BEHIND the one they will ask for says nothing about it:
1001
- // the move would hand them an output that does not yet have what they need.
1002
- if (publishedIndex !== null && publishedIndex < next) {
1003
- return;
1004
- }
1005
- if (!this.#host.segmentClosed(prepared.outputKey ?? "", next)) {
1006
- return;
1007
- }
1008
1000
  // THE SEGMENT ALREADY GIVEN FROM THE OUTPUT ON SCREEN. A request is held by
1009
1001
  // the output it was given from, and leaving that output stops its encoder:
1010
1002
  // a segment given and not yet closed there would then be made by nobody.
1011
1003
  // On Home Assistant a viewer waiting for #2176 was moved from #2177 and
1012
- // the picture stood for good (r71, torrent-tv/meta#178). The move waits for
1013
- // that segment to be closed; an output whose encoding has failed for good
1014
- // closes nothing, and its held request is the serving path's to answer.
1004
+ // the picture stood for good (r71, torrent-tv/meta#178). Nothing of it has
1005
+ // been sent — only closed pieces are — so it is given by whichever output
1006
+ // closes it sooner: the move then begins at it, or waits for it here. An
1007
+ // output whose encoding has failed for good closes nothing, and its held
1008
+ // request is the serving path's to answer.
1015
1009
  const open = this.#givenAndOpenOn(current, consumerId, move.askedHeights, next);
1010
+ let from = next;
1016
1011
  if (open !== null && !this.#host.encodeRuns.hasFailed(current)) {
1017
- if (this.#moveWaitsSaid.get(consumerId) !== open) {
1018
- this.#moveWaitsSaid.set(consumerId, open);
1019
- this.#host.logger.info(
1020
- `transcode ${base.id} ${consumerId}'s move to ${move.outputKey} waits for #${open} on ` +
1021
- `${current.outputKey}, given and not yet closed there`
1022
- );
1012
+ if (!this.#preparedClosesSooner(base, consumerId, move, current, prepared, open)) {
1013
+ return;
1014
+ }
1015
+ from = open;
1016
+ } else {
1017
+ // A segment closed BEHIND the one they will ask for says nothing about it:
1018
+ // the move would hand them an output that does not yet have what they need.
1019
+ if (publishedIndex !== null && publishedIndex < next) {
1020
+ return;
1021
+ }
1022
+ if (!this.#host.segmentClosed(prepared.outputKey ?? "", next)) {
1023
+ return;
1023
1024
  }
1024
- return;
1025
1025
  }
1026
1026
  // THE CUSHION, for a move that is not urgent (roadmap item 98): a move up
1027
1027
  // is made once the viewer holds the cushion this file needs, and their
@@ -1053,7 +1053,58 @@ export class Renditions {
1053
1053
  return;
1054
1054
  }
1055
1055
  }
1056
- this.#completeSameHeightSwitch(base, consumerId, move, prepared, current, next);
1056
+ if (from !== next) {
1057
+ // The held request follows the address: it is woken, finds the segment
1058
+ // given to the prepared output, and is answered from there.
1059
+ const generation = this.#host.generationOfRequest(consumerId, Number.NaN);
1060
+ for (const height of move.askedHeights) {
1061
+ if (this.#host.givenOutputOf(consumerId, generation, height, from) === current.outputKey) {
1062
+ this.#host.noteGivenOutput(consumerId, generation, height, from, prepared.outputKey ?? "");
1063
+ }
1064
+ }
1065
+ }
1066
+ this.#completeSameHeightSwitch(base, consumerId, move, prepared, current, from);
1067
+ if (from !== next) {
1068
+ this.#host.invalidateWaits?.(current);
1069
+ }
1070
+ }
1071
+
1072
+ /**
1073
+ * Whether the output being prepared closes the segment held on the output on
1074
+ * screen sooner than that output does, so that it is given from there.
1075
+ *
1076
+ * Both times are this machine's readings (`secondsUntilClosed`); either one
1077
+ * unknown, or a header of the prepared output not proven to decode where the
1078
+ * player's does, keeps the segment where it was given. Said once per segment
1079
+ * and answer.
1080
+ *
1081
+ * @param {HlsSession} base
1082
+ * @param {string} consumerId
1083
+ * @param {object} move
1084
+ * @param {HlsSession} current
1085
+ * @param {HlsSession} prepared
1086
+ * @param {number} index - The segment held on `current`.
1087
+ * @returns {boolean}
1088
+ */
1089
+ #preparedClosesSooner(base, consumerId, move, current, prepared, index) {
1090
+ const now = Date.now();
1091
+ const onCurrent = this.#host.secondsUntilClosed?.(current, index, now) ?? null;
1092
+ const onPrepared = this.#host.secondsUntilClosed?.(prepared, index, now) ?? null;
1093
+ const withInit = current.segmentFormat?.initFileName !== null && prepared.segmentFormat?.initFileName !== null;
1094
+ const compatible = !withInit || this.#host.headersCompatible(current.outputKey, prepared.outputKey)?.compatible === true;
1095
+ const sooner = compatible && onCurrent !== null && onPrepared !== null && onPrepared < onCurrent;
1096
+ const said = `${index}:${sooner}`;
1097
+ if (this.#moveWaitsSaid.get(consumerId) !== said) {
1098
+ this.#moveWaitsSaid.set(consumerId, said);
1099
+ const time = (seconds) => (seconds === null ? "unknown" : `${seconds.toFixed(1)}s`);
1100
+ this.#host.logger.info(
1101
+ `transcode ${base.id} ${consumerId}'s move to ${move.outputKey}: #${index}, given from ${current.outputKey} ` +
1102
+ `and not closed there, closes there in ${time(onCurrent)} and on the prepared output in ${time(onPrepared)}` +
1103
+ (compatible ? "" : " (its header is not proven to decode where the player's does)") +
1104
+ ` — ${sooner ? "given from the prepared output, the move begins at it" : "the move waits for it there"}`
1105
+ );
1106
+ }
1107
+ return sooner;
1057
1108
  }
1058
1109
 
1059
1110
  /**
@@ -1358,6 +1409,32 @@ export class Renditions {
1358
1409
  * @returns {Promise<{ sessionId: string | null, error?: string }>} The session
1359
1410
  * to serve the file from; a null id means there is no such variant.
1360
1411
  */
1412
+ /**
1413
+ * Whether this address is still given to the output a held request for it
1414
+ * waits on. A move may give a segment that is not closed yet to the output
1415
+ * the viewer is moved to (`#preparedClosesSooner`); the held request then
1416
+ * asks again and is answered from there.
1417
+ *
1418
+ * @param {string} baseSessionId
1419
+ * @param {number} height
1420
+ * @param {string} fileName
1421
+ * @param {string} consumerId
1422
+ * @param {number} statedGeneration
1423
+ * @param {string} sessionId - The output the request waits on.
1424
+ * @returns {boolean}
1425
+ */
1426
+ stillGivenTo(baseSessionId, height, fileName, consumerId, statedGeneration, sessionId) {
1427
+ const base = isOutputName(baseSessionId) ? this.#host.outputs.get(baseSessionId) : null;
1428
+ const waitedOn = this.#host.outputs.get(sessionId);
1429
+ const index = base?.segmentFormat?.segmentIndexFromName?.(fileName);
1430
+ if (!base || !waitedOn || !consumerId || !Number.isInteger(index) || index < 0) {
1431
+ return true;
1432
+ }
1433
+ const generation = this.#host.generationOfRequest(consumerId, statedGeneration);
1434
+ const key = this.#host.givenOutputOf(consumerId, generation, height, index);
1435
+ return !key || key === waitedOn.outputKey;
1436
+ }
1437
+
1361
1438
  async resolveVariantFile(baseSessionId, height, fileName, consumerId = "", statedGeneration = Number.NaN) {
1362
1439
  if (!isOutputName(baseSessionId)) {
1363
1440
  return { sessionId: null };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @file How long until a piece of an output is closed, from what is measured
3
+ * about that output's runs (torrent-tv/meta#178).
4
+ *
5
+ * Asked when a viewer is moved between two outputs of one height while a piece
6
+ * given to them from the output on screen is not closed yet: the piece is then
7
+ * given by whichever output closes it sooner. Every term is a reading of this
8
+ * machine; a term that is not known makes the answer unknown, never a guess.
9
+ */
10
+
11
+ /**
12
+ * Seconds until the piece is closed on its output, or null when that cannot be
13
+ * said.
14
+ *
15
+ * 1. A closed piece: 0.
16
+ * 2. A live run whose stretch holds the piece: the film from where the run has
17
+ * reached to the piece's end, at the output's speed, plus what is left of
18
+ * the run's opening when it has produced nothing yet.
19
+ * 3. No such run: a run opened here for it — the output's measured opening and
20
+ * the whole piece at its speed.
21
+ *
22
+ * @param {object} what
23
+ * @param {boolean} what.closed
24
+ * @param {number} what.index - The piece.
25
+ * @param {number} what.pieceStart - Where the piece begins, seconds of film.
26
+ * @param {number} what.pieceEnd - Where it ends.
27
+ * @param {Array<{ from: number, to: number, startedAt: number, startSeconds: number, reachedSeconds: number | null }>} what.runs
28
+ * The output's live runs: their stretch (`to` −1 for one with no end), when
29
+ * each started, where it began and how far it has produced.
30
+ * @param {number | null} what.speed - Seconds of film the output makes per second.
31
+ * @param {number | null} what.openSeconds - What opening a run costs this output.
32
+ * @param {number} what.now
33
+ * @returns {number | null}
34
+ */
35
+ export function secondsUntilClosed({ closed, index, pieceStart, pieceEnd, runs, speed, openSeconds, now }) {
36
+ if (closed) {
37
+ return 0;
38
+ }
39
+ if (!(speed > 0) || !Number.isFinite(pieceStart) || !Number.isFinite(pieceEnd) || pieceEnd <= pieceStart) {
40
+ return null;
41
+ }
42
+ const holding = runs.filter((run) => run.from <= index && (run.to < 0 || run.to >= index));
43
+ let best = null;
44
+ for (const run of holding) {
45
+ const reached = Number.isFinite(run.reachedSeconds) && run.reachedSeconds > run.startSeconds
46
+ ? run.reachedSeconds : null;
47
+ let seconds;
48
+ if (reached !== null) {
49
+ seconds = Math.max(0, pieceEnd - reached) / speed;
50
+ } else {
51
+ if (!Number.isFinite(openSeconds)) continue;
52
+ const opened = Math.max(0, (now - run.startedAt) / 1000);
53
+ seconds = Math.max(0, openSeconds - opened) + Math.max(0, pieceEnd - run.startSeconds) / speed;
54
+ }
55
+ best = best === null ? seconds : Math.min(best, seconds);
56
+ }
57
+ if (best !== null) {
58
+ return best;
59
+ }
60
+ if (holding.length > 0 || !Number.isFinite(openSeconds)) {
61
+ return null;
62
+ }
63
+ return openSeconds + (pieceEnd - pieceStart) / speed;
64
+ }
@@ -71,10 +71,13 @@ export function refusedAsStale(req, reply, viewerRequests, fileName) {
71
71
  *
72
72
  * @param {import("fastify").FastifyRequest} req
73
73
  * @param {import("fastify").FastifyReply} reply
74
- * @param {{ serving: object, viewerRequests: object, sessionId: string, fileName: string }} params
75
- * @returns {Promise<void>}
74
+ * @param {{ serving: object, viewerRequests: object, sessionId: string, fileName: string, stillGiven?: () => boolean }} params
75
+ * `stillGiven`: whether the address is still given to this session; when it
76
+ * stops being, nothing is answered and "reassigned" is returned for the
77
+ * caller to resolve the address again.
78
+ * @returns {Promise<void | "reassigned">}
76
79
  */
77
- export async function serveSessionFile(req, reply, { serving, viewerRequests, sessionId, fileName }) {
80
+ export async function serveSessionFile(req, reply, { serving, viewerRequests, sessionId, fileName, stillGiven = null }) {
78
81
  // Which viewer is asking. One session serves everyone watching a copied
79
82
  // picture, so without it their positions collapse into one and a seek by the
80
83
  // viewer in front releases the requests held for the viewer behind. Absent on
@@ -100,13 +103,18 @@ export async function serveSessionFile(req, reply, { serving, viewerRequests, se
100
103
  try {
101
104
  result = await waitForSessionFile(serving, sessionId, fileName, {
102
105
  until: clientGone,
103
- consumerId
106
+ consumerId,
107
+ stillGiven
104
108
  });
105
109
  } finally {
106
110
  req.raw.off("aborted", onClientAbort);
107
111
  reply.raw?.off("close", onResponseClose);
108
112
  }
109
113
  const heldMs = Date.now() - holdStartedAt;
114
+ if (!clientAborted && result.kind === "reassigned") {
115
+ logger.info(`[hold] ${fileName} given to another output after ${heldMs}ms; asked again there`);
116
+ return "reassigned";
117
+ }
110
118
  if (clientAborted || result.kind === "cancelled") {
111
119
  logger.info(`[hold] request=${req.id ?? "unknown"} ${fileName} cancelled after ${heldMs}ms`);
112
120
  result.stream?.destroy?.();
@@ -234,7 +242,7 @@ export async function waitForRequestedFile(req, reply, serving, sessionId, fileN
234
242
  }
235
243
  }
236
244
 
237
- export async function waitForSessionFile(serving, sessionId, fileName, { until = null, consumerId = "" } = {}) {
245
+ export async function waitForSessionFile(serving, sessionId, fileName, { until = null, consumerId = "", stillGiven = null } = {}) {
238
246
  let gone = false;
239
247
  const requesterGone = until ? until.then(() => { gone = true; }) : new Promise(() => {});
240
248
  // The viewer's position when this request was made. A seek makes every held
@@ -249,6 +257,9 @@ export async function waitForSessionFile(serving, sessionId, fileName, { until =
249
257
  while (!gone) {
250
258
  const change = serving.subscribeFileChange(sessionId);
251
259
  try {
260
+ if (stillGiven && !stillGiven()) {
261
+ return { kind: "reassigned" };
262
+ }
252
263
  const result = await serving.getFileStream(sessionId, fileName, { consumerId });
253
264
  if (gone) {
254
265
  result.stream?.destroy?.();
@@ -102,6 +102,7 @@ import { configurationKeyOf } from "../encode/fingerprint.js";
102
102
  import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
103
103
  import { downloadShare } from "../storage/processor-share.js";
104
104
  import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
105
+ import { secondsUntilClosed } from "../encode/piece-close-time.js";
105
106
 
106
107
  // Where a variant and an audio rendition live under a session — `v/<height>/…`
107
108
  // and `a/<track>/…` — is stated in `encode/output/playlists.js`, beside the lines that
@@ -460,6 +461,29 @@ export function wireOutputs({
460
461
  // asked before a preparation is recorded.
461
462
  admitsPreparation: (output) => parts.admission.admitsPreparation(output?.outputKey ?? ""),
462
463
  segmentClosed: (key, index) => parts.segmentStore.isClosed(key, index),
464
+ // How long until a piece is closed on an output, from that output's runs and
465
+ // the rate the start forecast reads for it (`encode/piece-close-time.js`).
466
+ secondsUntilClosed: (output, index, now) => {
467
+ const readings = parts.encodeRuns.speedReadingsOf(output).filter((reading) => reading.at <= now);
468
+ const summary = readings.length > 0 ? rateSummaryOf(readings) : null;
469
+ const speed = summary ? forecastRate(summary, now, 0, "piece") : parts.encodeCost.projectedSpeedOf(output);
470
+ return secondsUntilClosed({
471
+ closed: parts.segmentStore.isClosed(output.outputKey ?? "", index),
472
+ index,
473
+ pieceStart: output.timeline?.publishedStartOf?.(index),
474
+ pieceEnd: output.timeline?.publishedStartOf?.(index + 1),
475
+ runs: parts.encodeRuns.liveRunsOf(output).map((run) => ({
476
+ from: run.from,
477
+ to: Number.isInteger(run.to) ? run.to : -1,
478
+ startedAt: run.startedAt,
479
+ startSeconds: run.progress.startPositionSeconds,
480
+ reachedSeconds: run.progress.processedSeconds
481
+ })),
482
+ speed: Number.isFinite(speed) ? speed : null,
483
+ openSeconds: runStartSecondsOf(output),
484
+ now
485
+ });
486
+ },
463
487
  // What a gone output left in the store, and whether another output's header
464
488
  // may stand in for its own.
465
489
  storedPieceReady: (key, fileName) => parts.serving.hasStoredPiece(key, fileName),