@torrent-tv/proxy 2.107.53 → 2.107.55
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/sources/stats/get.js +4 -4
- package/routes/api/transcode-sessions/net-report/post.js +18 -0
- package/routes/api/transcode-sessions/progress/get.js +9 -1
- package/services/encode/EncodeRun.js +13 -3
- package/services/encode/RunProgress.js +11 -2
- package/services/encode/SpeedReadings.js +10 -2
- package/services/server/ViewerRequests.js +172 -44
- package/services/torrent/worker/worker.js +27 -3
- package/services/viewer/OutputForecastGeometry.js +19 -1
- package/services/viewer/Viewer.js +117 -17
- package/services/viewer/ViewerPlaybackState.js +25 -7
- package/services/viewer/playback-readiness.js +253 -32
|
@@ -149,14 +149,16 @@ export class SessionRate {
|
|
|
149
149
|
#inverseSum = 0;
|
|
150
150
|
#zero = false;
|
|
151
151
|
#weighted = 0;
|
|
152
|
+
#capture = null;
|
|
152
153
|
|
|
153
|
-
add(at, value) {
|
|
154
|
+
add(at, value, capture = null) {
|
|
154
155
|
if (!Number.isFinite(at) || !Number.isFinite(value) || value < 0 || at <= (this.#last?.at ?? Number.NEGATIVE_INFINITY)) {
|
|
155
156
|
return false;
|
|
156
157
|
}
|
|
157
158
|
if (this.#last) this.#weighted += this.#last.value * (at - this.#last.at);
|
|
158
159
|
this.#first ??= { at, value };
|
|
159
160
|
this.#last = { at, value };
|
|
161
|
+
this.#capture = mergeCaptureSummary(this.#capture, capture);
|
|
160
162
|
this.#count += 1;
|
|
161
163
|
this.#sum += value;
|
|
162
164
|
if (value > 0) this.#inverseSum += 1 / value;
|
|
@@ -177,6 +179,10 @@ export class SessionRate {
|
|
|
177
179
|
spanSeconds: spanMs / 1000
|
|
178
180
|
};
|
|
179
181
|
}
|
|
182
|
+
|
|
183
|
+
captureSnapshot() {
|
|
184
|
+
return this.#capture ? { ...this.#capture } : null;
|
|
185
|
+
}
|
|
180
186
|
}
|
|
181
187
|
|
|
182
188
|
/**
|
|
@@ -192,12 +198,12 @@ export class RateTrend {
|
|
|
192
198
|
this.#horizonMs = Math.max(0, horizonSeconds) * 1000;
|
|
193
199
|
}
|
|
194
200
|
|
|
195
|
-
add(at, value) {
|
|
201
|
+
add(at, value, capture = null) {
|
|
196
202
|
if (!Number.isFinite(at) || !Number.isFinite(value) || value < 0 ||
|
|
197
203
|
at <= (this.#readings.at(-1)?.at ?? Number.NEGATIVE_INFINITY)) {
|
|
198
204
|
return false;
|
|
199
205
|
}
|
|
200
|
-
this.#readings.push({ at, value });
|
|
206
|
+
this.#readings.push({ at, value, capture: capture ? { ...capture } : null });
|
|
201
207
|
const cutoff = at - this.#horizonMs;
|
|
202
208
|
while (this.#readings.length > 1 && this.#readings[1].at <= cutoff) {
|
|
203
209
|
this.#readings.shift();
|
|
@@ -211,6 +217,22 @@ export class RateTrend {
|
|
|
211
217
|
}
|
|
212
218
|
return summarizeRate(this.#readings);
|
|
213
219
|
}
|
|
220
|
+
|
|
221
|
+
captureSnapshot() {
|
|
222
|
+
return this.#readings.map(reading => reading.capture ? { ...reading.capture } : null);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function mergeCaptureSummary(previous, capture) {
|
|
227
|
+
if (!capture || capture.validity !== "valid" || !Number.isFinite(capture.clockEpoch) ||
|
|
228
|
+
!Number.isFinite(capture.startedAt) || !Number.isFinite(capture.completedAt) ||
|
|
229
|
+
capture.completedAt < capture.startedAt) return { validity: "unknown", unknownReason: "missing-rate-capture" };
|
|
230
|
+
if (!previous) return { ...capture };
|
|
231
|
+
if (previous.validity !== "valid" || previous.clockEpoch !== capture.clockEpoch) {
|
|
232
|
+
return { validity: "invalid", unknownReason: "rate-capture-epoch-mismatch" };
|
|
233
|
+
}
|
|
234
|
+
return { ...previous, startedAt: Math.min(previous.startedAt, capture.startedAt),
|
|
235
|
+
completedAt: Math.max(previous.completedAt, capture.completedAt), revision: capture.revision };
|
|
214
236
|
}
|
|
215
237
|
|
|
216
238
|
/**
|
|
@@ -233,7 +255,10 @@ export class RateTrend {
|
|
|
233
255
|
* clockOffsetSeconds?: number, clientRanges?: Array<{ start: number, end: number }>,
|
|
234
256
|
* appendService?: object, fragmentService?: object,
|
|
235
257
|
* segments: Array<{ index: number, startSeconds: number, endSeconds: number,
|
|
258
|
+
* sourceInputs?: Array<{ sourceId: string, ranges: Array<{ start: number, end: number }> }>,
|
|
236
259
|
* mediaRanges?: { timescale: bigint | null, ranges: Array<{ start: bigint, end: bigint, frame: bigint }> } }>,
|
|
260
|
+
* tailSegments?: Array<{ index: number, startSeconds: number, endSeconds: number,
|
|
261
|
+
* sourceInputs?: Array<{ sourceId: string, ranges: Array<{ start: number, end: number }> }> }>,
|
|
237
262
|
* readySegmentIndices: number[], segmentSizesBytes: Map<number, number> | object }>} input.tracks
|
|
238
263
|
* `mediaRanges` are a read piece's ranges on its served timeline, in ticks;
|
|
239
264
|
* `clockOffsetSeconds` is the `timestampOffset` the page's player applied to
|
|
@@ -255,13 +280,23 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
255
280
|
// THE NEAR WINDOW AND THE SUFFIX. The look-ahead is scheduled with exact
|
|
256
281
|
// segment times and source ranges when known. Missing range metadata is priced
|
|
257
282
|
// from aggregate file density and measured download service without claiming
|
|
258
|
-
// an address or byte. Beyond the look-ahead, {@link scheduleTail}
|
|
259
|
-
//
|
|
283
|
+
// an address or byte. Beyond the look-ahead, {@link scheduleTail} checks
|
|
284
|
+
// mapped source-piece deadlines and estimates unmapped source, encoder and
|
|
285
|
+
// delivery work from aggregate service.
|
|
260
286
|
const lookahead = finiteNonNegative(input.lookaheadSeconds);
|
|
261
287
|
const horizonEnd = lookahead > 0 ? finiteNonNegative(input.positionSeconds) + lookahead : Number.POSITIVE_INFINITY;
|
|
262
|
-
const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) =>
|
|
263
|
-
Array.isArray(track.segments)
|
|
264
|
-
|
|
288
|
+
const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) => {
|
|
289
|
+
if (!Array.isArray(track.segments)) return track;
|
|
290
|
+
const tailProfileProvided = Array.isArray(track.tailSegments);
|
|
291
|
+
return { ...track, segmentCount: track.segmentCount ?? track.segments.length,
|
|
292
|
+
tailProfileProvided,
|
|
293
|
+
// Keep exact future source maps separately from the bounded near-window
|
|
294
|
+
// schedule. Tail source work can then be checked at the segment
|
|
295
|
+
// deadlines without treating the whole suffix as one final deadline.
|
|
296
|
+
tailSegments: (Array.isArray(track.tailSegments) ? track.tailSegments : track.segments)
|
|
297
|
+
.filter(segment => isPiece(segment) && segment.startSeconds >= horizonEnd),
|
|
298
|
+
segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds), horizonEnd) };
|
|
299
|
+
});
|
|
265
300
|
const duration = finiteNonNegative(input.durationSeconds);
|
|
266
301
|
// Each track's served ticks are placed on the clock the page reports by the
|
|
267
302
|
// offset its player applied (`timestampOffset`).
|
|
@@ -743,13 +778,19 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
743
778
|
}
|
|
744
779
|
}
|
|
745
780
|
const tailStock = [];
|
|
781
|
+
const tailCushionStock = [];
|
|
782
|
+
let tailUnmappedSources = new Set();
|
|
783
|
+
let tailUnmappedTracks = new Set();
|
|
746
784
|
if (trackState.some(({ tailEndSeconds }) => Number.isFinite(tailEndSeconds))) {
|
|
747
|
-
const stop = scheduleTail({ trackState, sourceState, runStarts, trackFinish,
|
|
748
|
-
link, position, buffered, reserve, transferFinish, constrain, duration, stock: tailStock
|
|
749
|
-
|
|
785
|
+
const stop = scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArrivals, runStarts, trackFinish,
|
|
786
|
+
link, position, buffered, reserve, transferFinish, constrain, duration, stock: tailStock,
|
|
787
|
+
cushionStock: tailCushionStock });
|
|
788
|
+
if (stop?.reason) {
|
|
750
789
|
return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
|
|
751
790
|
...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
|
|
752
791
|
}
|
|
792
|
+
tailUnmappedSources = stop?.unmappedSources ?? tailUnmappedSources;
|
|
793
|
+
tailUnmappedTracks = stop?.unmappedTracks ?? tailUnmappedTracks;
|
|
753
794
|
}
|
|
754
795
|
const firstRequiredStages = trackState.slice(0, requiredTracks).map((state, trackIndex) => {
|
|
755
796
|
const first = state.segments.find(segment => segment.endSeconds > position);
|
|
@@ -838,6 +879,15 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
838
879
|
const firstRequiredSegmentsSeconds = firstRequiredTrackSeconds.every(Number.isFinite)
|
|
839
880
|
? Math.max(0, ...firstRequiredTrackSeconds) : null;
|
|
840
881
|
const delay = Math.max(0, ...constraints);
|
|
882
|
+
if (tailUnmappedSources.size > 0 || tailUnmappedTracks.size > 0) {
|
|
883
|
+
return { ...result(false, null, buffered, reserve, null, "tail-source-addresses-unavailable", preparedSegments),
|
|
884
|
+
diagnosticDelaySeconds: Number.isFinite(delay) ? delay : null,
|
|
885
|
+
unavailableTailSources: [...tailUnmappedSources].sort(),
|
|
886
|
+
unavailableTailTracks: [...tailUnmappedTracks].sort(),
|
|
887
|
+
tail: tailStock,
|
|
888
|
+
firstRequiredSegmentsSeconds,
|
|
889
|
+
firstRequiredStages };
|
|
890
|
+
}
|
|
841
891
|
// THE STOCK THE START ASKS FOR HAS TO FIT. Starting without a stop while the
|
|
842
892
|
// source comes slower than the film plays means holding, at the start, what
|
|
843
893
|
// is already here and everything that arrives before it. Where that is more
|
|
@@ -848,10 +898,11 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
848
898
|
// share (Home Assistant 2026-10-26, torrent-tv/meta#239). Said as such, and
|
|
849
899
|
// the viewer keeps waiting rather than starting into stops.
|
|
850
900
|
const storageBytes = Number(input.sourceStorageBytes);
|
|
851
|
-
const tailParts =
|
|
901
|
+
const tailParts = tailCushionStock.filter(part => !part.skipped);
|
|
852
902
|
const cushionBytes = Number.isFinite(delay) && tailParts.length > 0
|
|
853
903
|
? tailParts.reduce((sum, part) => sum + Math.max(0, part.totalBytes - part.missingBytes) +
|
|
854
|
-
Math.max(0, part.missingBytes - part.
|
|
904
|
+
Math.max(0, part.missingBytes - (part.planned ? (part.finish > delay ? part.missingBytes : 0)
|
|
905
|
+
: part.bytesPerSecond * Math.max(0, part.finish - delay))), 0)
|
|
855
906
|
: null;
|
|
856
907
|
if (Number.isFinite(delay) && Number.isFinite(storageBytes) && storageBytes >= 0 && tailParts.length > 0) {
|
|
857
908
|
if (cushionBytes > storageBytes) {
|
|
@@ -941,20 +992,182 @@ function scheduleTransfers(jobs, link) {
|
|
|
941
992
|
}
|
|
942
993
|
|
|
943
994
|
/**
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
*
|
|
949
|
-
*
|
|
950
|
-
|
|
995
|
+
* Schedule exact tail source ranges at the deadlines of the output pieces that
|
|
996
|
+
* need them. A source piece used by multiple tracks is assigned to its earliest
|
|
997
|
+
* deadline once. When a tail range is absent, the aggregate density estimate
|
|
998
|
+
* remains in force and is labelled as an unmapped estimate in `stock`.
|
|
999
|
+
*
|
|
1000
|
+
* @returns {{ reason: string, unavailableSource?: object } | null}
|
|
1001
|
+
*/
|
|
1002
|
+
function scheduleTailSourceRanges({ trackState, sourceState, sourceByteCoverage, sourceArrivals,
|
|
1003
|
+
position, reserve, constrain, stock, cushionStock }) {
|
|
1004
|
+
const pieceRequests = new Map();
|
|
1005
|
+
const unmappedSources = new Set();
|
|
1006
|
+
const unmappedTracks = new Set();
|
|
1007
|
+
const referencedSources = new Set();
|
|
1008
|
+
const completeSources = new Set();
|
|
1009
|
+
|
|
1010
|
+
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
1011
|
+
const state = trackState[trackIndex];
|
|
1012
|
+
if (!(state.tailSegmentCount > 0)) continue;
|
|
1013
|
+
const tailSegments = (Array.isArray(state.track.tailSegments) ? state.track.tailSegments : [])
|
|
1014
|
+
.filter(segment => segment.startSeconds < state.tailEndSeconds);
|
|
1015
|
+
const tailSources = new Set(state.track.sourceIds ?? []);
|
|
1016
|
+
for (const segment of tailSegments) {
|
|
1017
|
+
for (const input of segment.sourceInputs ?? []) {
|
|
1018
|
+
if (typeof input?.sourceId === "string") tailSources.add(input.sourceId);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
if (state.track.tailProfileProvided === true) {
|
|
1022
|
+
for (const sourceId of tailSources) referencedSources.add(sourceId);
|
|
1023
|
+
}
|
|
1024
|
+
const readyTailCount = [...state.ready].reduce((count, index) => count + Number(index >= state.segmentCount - state.tailSegmentCount), 0);
|
|
1025
|
+
const unfinishedTailCount = Math.max(0, state.tailSegmentCount - readyTailCount);
|
|
1026
|
+
if (state.track.tailProfileProvided !== true) continue;
|
|
1027
|
+
if (unfinishedTailCount > 0 && tailSources.size === 0) {
|
|
1028
|
+
unmappedTracks.add(state.track.id ?? `track-${trackIndex}`);
|
|
1029
|
+
}
|
|
1030
|
+
if (tailSegments.filter(segment => !state.ready.has(segment.index)).length < unfinishedTailCount && tailSources.size > 0) {
|
|
1031
|
+
for (const sourceId of tailSources) {
|
|
1032
|
+
if (sourceState.get(sourceId)?.source.complete !== true) unmappedSources.add(sourceId);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
for (const segment of tailSegments) {
|
|
1037
|
+
if (state.ready.has(segment.index)) continue;
|
|
1038
|
+
const exactInputs = Array.isArray(segment.sourceInputs) ? segment.sourceInputs : null;
|
|
1039
|
+
for (const sourceId of tailSources) {
|
|
1040
|
+
const source = sourceState.get(sourceId);
|
|
1041
|
+
if (!source) return { reason: "source-measurement-unavailable",
|
|
1042
|
+
unavailableSource: { sourceId, trackId: state.track.id, segmentIndex: segment.index, phase: "tail" } };
|
|
1043
|
+
if (source.source.complete === true) {
|
|
1044
|
+
if (!completeSources.has(sourceId)) stock?.push({ sourceId, skipped: "complete" });
|
|
1045
|
+
completeSources.add(sourceId);
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
const ranges = exactInputs === null ? null : inputRangesOf(exactInputs, sourceId);
|
|
1049
|
+
if (ranges === null || ranges.some(range => range.end > source.source.fileLength) ||
|
|
1050
|
+
!Number.isSafeInteger(source.source.pieceLength) || source.source.pieceLength <= 0 ||
|
|
1051
|
+
!Number.isSafeInteger(source.source.fileOffset) || !Number.isSafeInteger(source.source.fileLength) ||
|
|
1052
|
+
source.source.fileLength <= 0) {
|
|
1053
|
+
unmappedSources.add(sourceId);
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
let requests = pieceRequests.get(sourceId);
|
|
1057
|
+
if (!requests) {
|
|
1058
|
+
requests = new Map();
|
|
1059
|
+
pieceRequests.set(sourceId, requests);
|
|
1060
|
+
}
|
|
1061
|
+
for (const range of ranges) {
|
|
1062
|
+
for (const piece of sourcePiecesFor([range], source.source)) {
|
|
1063
|
+
const first = Math.floor((source.source.fileOffset + piece.start) / source.source.pieceLength);
|
|
1064
|
+
const last = Math.ceil((source.source.fileOffset + piece.end) / source.source.pieceLength) - 1;
|
|
1065
|
+
for (let pieceIndex = first; pieceIndex <= last; pieceIndex += 1) {
|
|
1066
|
+
const start = Math.max(0, pieceIndex * source.source.pieceLength - source.source.fileOffset);
|
|
1067
|
+
const end = Math.min(source.source.fileLength, (pieceIndex + 1) * source.source.pieceLength - source.source.fileOffset);
|
|
1068
|
+
if (!(end > start)) continue;
|
|
1069
|
+
const current = requests.get(pieceIndex);
|
|
1070
|
+
const deadlineSeconds = segment.endSeconds;
|
|
1071
|
+
if (!current || deadlineSeconds < current.deadlineSeconds) {
|
|
1072
|
+
requests.set(pieceIndex, { sourceId, pieceIndex, start, end, deadlineSeconds,
|
|
1073
|
+
trackIndex, segmentIndex: segment.index, addressed: true });
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// A partially mapped source falls back as a whole to density estimation so
|
|
1083
|
+
// exact pieces and guessed bytes cannot both charge the same suffix work.
|
|
1084
|
+
for (const sourceId of unmappedSources) pieceRequests.delete(sourceId);
|
|
1085
|
+
const requests = [...pieceRequests.values()].flatMap(byPiece => [...byPiece.values()])
|
|
1086
|
+
.sort((left, right) => left.deadlineSeconds - right.deadlineSeconds ||
|
|
1087
|
+
left.sourceId.localeCompare(right.sourceId) || left.pieceIndex - right.pieceIndex);
|
|
1088
|
+
let tailLatestArrival = 0;
|
|
1089
|
+
const stockBySource = new Map();
|
|
1090
|
+
for (const request of requests) {
|
|
1091
|
+
const source = sourceState.get(request.sourceId);
|
|
1092
|
+
const { service } = source;
|
|
1093
|
+
const piece = { start: request.start, end: request.end };
|
|
1094
|
+
const coverage = sourceByteCoverage.get(request.sourceId) ?? [];
|
|
1095
|
+
const missing = uncoveredIntervals(piece, coverage);
|
|
1096
|
+
const heldRanges = numericCoverage((Array.isArray(source.source.residence) ? source.source.residence : [])
|
|
1097
|
+
.filter(range => range?.location !== "missing" &&
|
|
1098
|
+
Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.end > range.start));
|
|
1099
|
+
const heldBytes = heldRanges.reduce((sum, range) => sum + Math.max(0,
|
|
1100
|
+
Math.min(piece.end, range.end) - Math.max(piece.start, range.start)), 0);
|
|
1101
|
+
const plan = { finish: 0 };
|
|
1102
|
+
let arrivalAt = latestArrival(piece, sourceArrivals.get(request.sourceId), plan);
|
|
1103
|
+
let sourcePlanned = arrivalAt > 0 && plan.finish >= arrivalAt;
|
|
1104
|
+
const rate = service.tailRate ?? service.rate;
|
|
1105
|
+
if (missing.length > 0) {
|
|
1106
|
+
if (!rate || !(rate.rateAt(0) > 0)) return { reason: rate ? "service-not-advancing" : "download-rate-unavailable",
|
|
1107
|
+
unavailableSource: { sourceId: request.sourceId, pieceIndex: request.pieceIndex,
|
|
1108
|
+
estimatedBytes: missing.reduce((sum, range) => sum + range.end - range.start, 0), phase: "tail" } };
|
|
1109
|
+
const missingBytes = missing.reduce((sum, range) => sum + range.end - range.start, 0);
|
|
1110
|
+
arrivalAt = rate.finish(missingBytes, Math.max(service.finish, arrivalAt));
|
|
1111
|
+
sourcePlanned = arrivalAt > 0 && plan.finish >= arrivalAt;
|
|
1112
|
+
service.finish = arrivalAt;
|
|
1113
|
+
const arrival = { start: piece.start, end: piece.end, begin: arrivalAt, finish: arrivalAt };
|
|
1114
|
+
sourceByteCoverage.set(request.sourceId, withCoverage(coverage, arrival));
|
|
1115
|
+
const arrivals = sourceArrivals.get(request.sourceId) ?? [];
|
|
1116
|
+
sourceArrivals.set(request.sourceId, withArrival(arrivals, arrival, [...arrivals, arrival]));
|
|
1117
|
+
} else {
|
|
1118
|
+
service.finish = Math.max(service.finish, arrivalAt);
|
|
1119
|
+
}
|
|
1120
|
+
tailLatestArrival = Math.max(tailLatestArrival, arrivalAt);
|
|
1121
|
+
const ahead = Math.max(0, request.deadlineSeconds - position);
|
|
1122
|
+
constrain(arrivalAt - Math.max(0, ahead - reserve), "source", request.trackIndex, request.segmentIndex,
|
|
1123
|
+
arrivalAt, ahead, true, arrivalAt, sourcePlanned);
|
|
1124
|
+
const pieceStock = { sourceId: request.sourceId, pieceIndex: request.pieceIndex,
|
|
1125
|
+
start: request.start, end: request.end, deadlineSeconds: request.deadlineSeconds,
|
|
1126
|
+
addressed: true, totalBytes: request.end - request.start,
|
|
1127
|
+
missingBytes: Math.max(0, request.end - request.start - heldBytes),
|
|
1128
|
+
planned: missing.length === 0 && sourcePlanned,
|
|
1129
|
+
bytesPerSecond: rate?.rateAt(0) ?? 0, finish: arrivalAt };
|
|
1130
|
+
cushionStock?.push(pieceStock);
|
|
1131
|
+
let sourceStock = stockBySource.get(request.sourceId);
|
|
1132
|
+
if (!sourceStock) {
|
|
1133
|
+
sourceStock = { sourceId: request.sourceId, addressed: true, uniquePieces: 0,
|
|
1134
|
+
start: request.start, end: request.end, earliestDeadlineSeconds: request.deadlineSeconds,
|
|
1135
|
+
latestDeadlineSeconds: request.deadlineSeconds, totalBytes: 0, missingBytes: 0,
|
|
1136
|
+
bytesPerSecond: pieceStock.bytesPerSecond, finish: 0 };
|
|
1137
|
+
stockBySource.set(request.sourceId, sourceStock);
|
|
1138
|
+
}
|
|
1139
|
+
sourceStock.uniquePieces += 1;
|
|
1140
|
+
sourceStock.start = Math.min(sourceStock.start, request.start);
|
|
1141
|
+
sourceStock.end = Math.max(sourceStock.end, request.end);
|
|
1142
|
+
sourceStock.earliestDeadlineSeconds = Math.min(sourceStock.earliestDeadlineSeconds, request.deadlineSeconds);
|
|
1143
|
+
sourceStock.latestDeadlineSeconds = Math.max(sourceStock.latestDeadlineSeconds, request.deadlineSeconds);
|
|
1144
|
+
sourceStock.totalBytes += pieceStock.totalBytes;
|
|
1145
|
+
sourceStock.missingBytes += pieceStock.missingBytes;
|
|
1146
|
+
sourceStock.finish = Math.max(sourceStock.finish, arrivalAt);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
stock?.push(...stockBySource.values());
|
|
1150
|
+
|
|
1151
|
+
return { latestArrival: tailLatestArrival, referencedSources, unmappedSources, unmappedTracks };
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Estimate the suffix after the exact near window. Mapped tail source ranges
|
|
1156
|
+
* receive individual piece deadlines; missing ranges retain the aggregate
|
|
1157
|
+
* density estimate, which is explicitly unaddressed. Processing and delivery
|
|
1158
|
+
* remain duration-level estimates. The schedule is conditional on measured
|
|
1159
|
+
* services continuing and does not claim exact frame timing.
|
|
951
1160
|
*
|
|
952
1161
|
* @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
|
|
953
1162
|
*/
|
|
954
|
-
function scheduleTail({ trackState, sourceState, runStarts, trackFinish, link,
|
|
955
|
-
position, buffered, reserve, transferFinish, constrain, duration, stock = null }) {
|
|
1163
|
+
function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArrivals, runStarts, trackFinish, link,
|
|
1164
|
+
position, buffered, reserve, transferFinish, constrain, duration, stock = null, cushionStock = null }) {
|
|
956
1165
|
const tailStart = Math.max(position, ...trackState.map(state => state.tailStartSeconds));
|
|
957
1166
|
if (!Number.isFinite(tailStart)) return null;
|
|
1167
|
+
const exactSources = scheduleTailSourceRanges({ trackState, sourceState, sourceByteCoverage, sourceArrivals,
|
|
1168
|
+
position, reserve, constrain, stock, cushionStock });
|
|
1169
|
+
if (exactSources?.reason) return exactSources;
|
|
1170
|
+
let sourceArrivalAt = exactSources?.latestArrival ?? 0;
|
|
958
1171
|
const sourceDurations = new Map();
|
|
959
1172
|
for (const state of trackState) {
|
|
960
1173
|
const seconds = Math.max(0, state.tailEndSeconds - tailStart) * state.tailWorkFraction;
|
|
@@ -963,12 +1176,15 @@ function scheduleTail({ trackState, sourceState, runStarts, trackFinish, link,
|
|
|
963
1176
|
workFraction: state.tailWorkFraction, tailSegments: state.tailSegmentCount });
|
|
964
1177
|
continue;
|
|
965
1178
|
}
|
|
966
|
-
|
|
1179
|
+
const sourceIds = new Set([...(state.track.sourceIds ?? []), ...(state.track.tailSegments ?? [])
|
|
1180
|
+
.filter(segment => segment.startSeconds < state.tailEndSeconds)
|
|
1181
|
+
.flatMap(segment => (segment.sourceInputs ?? []).map(item => item.sourceId))]);
|
|
1182
|
+
for (const sourceId of sourceIds) {
|
|
1183
|
+
if (exactSources?.referencedSources.has(sourceId) && !exactSources.unmappedSources.has(sourceId)) continue;
|
|
967
1184
|
sourceDurations.set(sourceId, Math.max(sourceDurations.get(sourceId) ?? 0, seconds));
|
|
968
1185
|
}
|
|
969
1186
|
}
|
|
970
1187
|
|
|
971
|
-
let sourceArrivalAt = 0;
|
|
972
1188
|
for (const [sourceId, seconds] of sourceDurations) {
|
|
973
1189
|
const source = sourceState.get(sourceId);
|
|
974
1190
|
if (!source) return { reason: "source-measurement-unavailable" };
|
|
@@ -1005,8 +1221,11 @@ function scheduleTail({ trackState, sourceState, runStarts, trackFinish, link,
|
|
|
1005
1221
|
sourceArrivalAt = Math.max(sourceArrivalAt, service.finish);
|
|
1006
1222
|
// What this stretch weighs and what it still lacks: the stock the start
|
|
1007
1223
|
// asks to hold is read from these once the start's delay is known.
|
|
1008
|
-
|
|
1009
|
-
|
|
1224
|
+
const sourceStock = { sourceId, seconds, windowEnd, addressed: false, estimatedAddress: "file-density",
|
|
1225
|
+
totalBytes: estimateUnmappedSourceBytes(source.source, seconds, duration, []) ?? bytes,
|
|
1226
|
+
missingBytes: bytes, bytesPerSecond: rate.rateAt(0), finish: service.finish };
|
|
1227
|
+
stock?.push(sourceStock);
|
|
1228
|
+
cushionStock?.push(sourceStock);
|
|
1010
1229
|
const state = trackState.find(candidate => candidate.track.sourceIds?.includes(sourceId));
|
|
1011
1230
|
const index = state?.segments.at(-1)?.index ?? 0;
|
|
1012
1231
|
const ahead = Math.max(0, (state?.tailEndSeconds ?? tailStart) - position);
|
|
@@ -1059,7 +1278,8 @@ function scheduleTail({ trackState, sourceState, runStarts, trackFinish, link,
|
|
|
1059
1278
|
constrain(completedAt - Math.max(0, ahead - reserve), "delivery", 0,
|
|
1060
1279
|
trackState[0]?.segments.at(-1)?.index ?? 0, completedAt, ahead, false, sourceArrivalAt, false);
|
|
1061
1280
|
}
|
|
1062
|
-
return
|
|
1281
|
+
return { unmappedSources: exactSources?.unmappedSources ?? new Set(),
|
|
1282
|
+
unmappedTracks: exactSources?.unmappedTracks ?? new Set() };
|
|
1063
1283
|
}
|
|
1064
1284
|
|
|
1065
1285
|
/**
|
|
@@ -1187,11 +1407,12 @@ function withSeekStartTolerance(ranges, position, unit) {
|
|
|
1187
1407
|
* before `horizonEnd`, the first piece, every piece that has been read and
|
|
1188
1408
|
* every piece that is made. A piece ending just before a seek target is kept
|
|
1189
1409
|
* as a prerequisite because its media timestamps can extend past its nominal
|
|
1190
|
-
* playlist boundary. A piece
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1193
|
-
*
|
|
1194
|
-
*
|
|
1410
|
+
* playlist boundary. A piece that is none of these changes no near-window
|
|
1411
|
+
* answer: its source map may still contribute an intermediate tail deadline.
|
|
1412
|
+
* Its media ranges are not placed in the exact browser-time schedule, it is
|
|
1413
|
+
* not where the track starts, no unread near-window piece is placed by it
|
|
1414
|
+
* (only read pieces are), it lends no frame to what the page holds, and it has
|
|
1415
|
+
* no size to measure the output's rate by.
|
|
1195
1416
|
*
|
|
1196
1417
|
* @param {{ segments: object[], readySegmentIndices?: number[] }} track
|
|
1197
1418
|
* @param {number} position - Seconds.
|