@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
|
@@ -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,
|
|
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).
|
|
1013
|
-
//
|
|
1014
|
-
// closes
|
|
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.#
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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),
|