@torrent-tv/proxy 2.107.54 → 2.107.56

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.107.54",
3
+ "version": "2.107.56",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -108,7 +108,7 @@ function sourceCaptureOf(stats, actionId, generation) {
108
108
  return { ...source, actionId: actionId ?? null, generation: Number.isSafeInteger(generation) ? generation : null };
109
109
  }
110
110
 
111
- function encoderCaptureOf(sampleCaptures, liveRuns, actionId, generation) {
111
+ export function encoderCaptureOf(sampleCaptures, liveRuns, actionId, generation) {
112
112
  const runCaptures = (liveRuns ?? []).map(run => ({ owner: "proxy-encoder", clock: "performance",
113
113
  clockEpoch: run.progress?.clockEpoch, startedAt: run.progress?.updatedAtMonotonic,
114
114
  completedAt: run.progress?.updatedAtMonotonic, revision: run.progress?.updatedAtMonotonic,
@@ -117,19 +117,56 @@ function encoderCaptureOf(sampleCaptures, liveRuns, actionId, generation) {
117
117
  Number.isFinite(run.progress?.updatedAtMonotonic) ? null : "encoder-progress-capture-unavailable" }));
118
118
  const captures = [...(Array.isArray(sampleCaptures) ? sampleCaptures : []), ...runCaptures];
119
119
  const epoch = captures[0]?.clockEpoch;
120
- const valid = captures.length > 0 && epoch === performance.timeOrigin && captures.every(capture =>
121
- capture.owner === "proxy-encoder" && capture.clock === "performance" &&
120
+ const samplesOrdered = (Array.isArray(sampleCaptures) ? sampleCaptures : []).every((capture, index, samples) =>
121
+ index === 0 || capture.revision >= samples[index - 1].revision);
122
+ const valid = captures.length > 0 && samplesOrdered && epoch === performance.timeOrigin && captures.every(capture =>
123
+ capture.owner === "proxy-encoder" && capture.clock === "performance" && capture.validity === "valid" &&
122
124
  Number.isFinite(capture.clockEpoch) && capture.clockEpoch === epoch && Number.isFinite(capture.startedAt) &&
123
125
  Number.isFinite(capture.completedAt) && capture.completedAt >= capture.startedAt &&
124
126
  Number.isFinite(capture.revision));
125
- if (!valid) return unknownCapture("proxy-encoder", actionId, generation, "encoder-capture-unavailable");
127
+ if (!valid) {
128
+ const projected = captures.find(capture => capture.provenance === "projected");
129
+ return { ...unknownCapture("proxy-encoder", actionId, generation,
130
+ projected?.unknownReason ?? "encoder-capture-unavailable"),
131
+ samples: captures, ...(projected ? { provenance: projected.provenance, projectionSource: projected.source } : {}) };
132
+ }
126
133
  return { owner: "proxy-encoder", clock: "performance", clockEpoch: epoch,
127
134
  startedAt: Math.min(...captures.map(capture => capture.startedAt)),
128
135
  completedAt: Math.max(...captures.map(capture => capture.completedAt)), actionId: actionId ?? null,
129
- generation: Number.isSafeInteger(generation) ? generation : null,
136
+ generation: Number.isSafeInteger(generation) ? generation : null, provenance: "measured",
130
137
  revision: captures.at(-1).revision ?? null, samples: captures, validity: "valid", unknownReason: null };
131
138
  }
132
139
 
140
+ export function trackRateReadings(readings, { predicted, projected }, now, playbackReadiness) {
141
+ const measured = (readings ?? []).filter(reading => reading.at <= now);
142
+ const summary = measured.length > 0 ? playbackReadiness.rateSummaryOf(measured) : null;
143
+ if (summary) return { summary, captures: measured.map(reading => ({ owner: reading.captureOwner,
144
+ clock: reading.captureClock, clockEpoch: reading.captureClockEpoch,
145
+ startedAt: reading.captureStartedAtMonotonic, completedAt: reading.captureCompletedAtMonotonic,
146
+ revision: reading.at,
147
+ validity: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
148
+ Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
149
+ Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
150
+ reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
151
+ ? "valid" : "unknown",
152
+ unknownReason: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
153
+ Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
154
+ Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
155
+ reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
156
+ ? null : "encoder-sample-capture-unavailable" })) };
157
+ const rate = Number(predicted ?? projected);
158
+ if (Number.isFinite(rate) && rate > 0) {
159
+ const seed = new playbackReadiness.RateTrend();
160
+ seed.add(now, rate);
161
+ const source = predicted === null || predicted === undefined
162
+ ? "current-cost-projection" : "stored-encode-prediction";
163
+ return { summary: seed.snapshot(), captures: [{ owner: "proxy-encoder", clock: "performance",
164
+ clockEpoch: null, startedAt: null, completedAt: null, revision: null, provenance: "projected", source,
165
+ validity: "unknown", unknownReason: "projected-encoder-speed-has-no-capture" }] };
166
+ }
167
+ return null;
168
+ }
169
+
133
170
  function observeForecastInput(observeWait, sourceKey, state, event) {
134
171
  if (!state) return;
135
172
  observeWait?.(sourceKey, "process", {
@@ -867,6 +904,7 @@ export class ViewerRequests {
867
904
  const inventory = this.#host.segmentStore.sizesOf(output.outputKey);
868
905
  const segments = this.#host.outputForecastGeometry.segmentsOf(output, sessionInit, inventory,
869
906
  Number.isFinite(reportedPosition) ? reportedPosition : this.#host.viewerSecondsOn(session, consumerId, now));
907
+ const forecastGeometry = this.#host.outputForecastGeometry.get(output);
870
908
  const sourceIndexes = [...output.spec.sourceFileIndexes];
871
909
  if (sourceIndexes.length === 0) {
872
910
  sourceIndexes.push(output.file.fileIndex);
@@ -955,6 +993,8 @@ export class ViewerRequests {
955
993
  const liveRuns = this.#host.encodeRuns.liveRunsOf?.(output) ?? [];
956
994
  const encoderCapture = encoderCaptureOf(trackRate?.captures, liveRuns, actionId, generation);
957
995
  const encoderCaptureValid = encoderCapture.validity === "valid";
996
+ const projectedRateCanSeedForecast = trackRate?.captures?.length > 0 &&
997
+ trackRate.captures.every(capture => capture.provenance === "projected");
958
998
  const processingStarted = liveRuns.some(({ progress }) => progress.processedSeconds > progress.startPositionSeconds);
959
999
  const startedAt = liveRuns.find(({ startedAt }) => startedAt > 0)?.startedAt ?? now;
960
1000
  tracks.push({
@@ -994,10 +1034,14 @@ export class ViewerRequests {
994
1034
  this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
995
1035
  durationSeconds: output.timeline?.publishedStartOf(output.timeline.segmentCount) ?? session.file.durationSeconds,
996
1036
  segmentCount: output.timeline?.segmentCount ?? segments.length,
997
- readings: encoderCaptureValid ? trackRates : [],
1037
+ // A projection is explicitly not a measured capture, but remains the
1038
+ // numeric seed it was before capture provenance was carried here.
1039
+ // Other invalid measured captures stay out of the forecast.
1040
+ readings: encoderCaptureValid || projectedRateCanSeedForecast ? trackRates : [],
998
1041
  capture: encoderCapture,
999
1042
  segments,
1000
- geometry: this.#host.outputForecastGeometry.get(output)?.geometry,
1043
+ tailSegments: forecastGeometry?.tailSegments ?? [],
1044
+ geometry: forecastGeometry?.geometry,
1001
1045
  readySegmentIndices: [...inventory.keys()],
1002
1046
  segmentSizesBytes: inventory
1003
1047
  });
@@ -1245,39 +1289,16 @@ export class ViewerRequests {
1245
1289
  * would drop the later-told, earlier-taken one (torrent-tv/meta#178).
1246
1290
  */
1247
1291
  #trackRateReadings(output, now) {
1248
- const readings = (this.#host.encodeSpeedReadingsOf?.(output) ?? []).filter(reading => reading.at <= now);
1249
- const summary = readings.length > 0 ? this.#host.playbackReadiness.rateSummaryOf(readings) : null;
1250
- if (summary) return { summary, captures: readings.map(reading => ({ owner: reading.captureOwner,
1251
- clock: reading.captureClock, clockEpoch: reading.captureClockEpoch,
1252
- startedAt: reading.captureStartedAtMonotonic, completedAt: reading.captureCompletedAtMonotonic,
1253
- revision: reading.at,
1254
- validity: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
1255
- Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
1256
- Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
1257
- reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
1258
- ? "valid" : "unknown",
1259
- unknownReason: reading.captureOwner === "proxy-encoder" && reading.captureClock === "performance" &&
1260
- Number.isFinite(reading.captureClockEpoch) && Number.isFinite(reading.captureStartedAtMonotonic) &&
1261
- Number.isFinite(reading.captureCompletedAtMonotonic) && Number.isFinite(reading.at) &&
1262
- reading.captureCompletedAtMonotonic >= reading.captureStartedAtMonotonic
1263
- ? null : "encoder-sample-capture-unavailable" })) };
1292
+ const readings = this.#host.encodeSpeedReadingsOf?.(output) ?? [];
1264
1293
  // ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
1265
1294
  // encoder, and it is not kept among the readings. Added on every poll
1266
1295
  // between runs, it put the machine's share of that moment — 1 % at a
1267
1296
  // cold start — on the whole film, and the forecast read 0.03x
1268
1297
  // (torrent-tv/meta#178).
1269
- const projected = Number(this.#host.predictedEncodeSpeedOf?.(output) ?? this.#host.projectedEncodeSpeedOf?.(output));
1270
- if (Number.isFinite(projected) && projected > 0) {
1271
- const seed = new this.#host.playbackReadiness.RateTrend();
1272
- seed.add(now, projected);
1273
- return { summary: seed.snapshot(), captures: [{ owner: "proxy-encoder", clock: "performance",
1274
- clockEpoch: performance.timeOrigin, startedAt: now, completedAt: now, revision: now,
1275
- validity: "valid", unknownReason: null }] };
1276
- }
1277
- // Progress over wall time includes source waits, which the forecast prices
1278
- // separately. Keep the rate unknown until a measured or calibrated
1279
- // processing rate exists; single-piece runs now publish their own rate.
1280
- return null;
1298
+ return trackRateReadings(readings, {
1299
+ predicted: this.#host.predictedEncodeSpeedOf?.(output),
1300
+ projected: this.#host.projectedEncodeSpeedOf?.(output)
1301
+ }, now, this.#host.playbackReadiness);
1281
1302
  }
1282
1303
 
1283
1304
  }
@@ -27,10 +27,11 @@ export class OutputForecastGeometry {
27
27
  const count = timeline?.segmentCount ?? 0;
28
28
  let forecast = this.#byOutput.get(output);
29
29
  if (!forecast) {
30
- forecast = { pieces: [], geometry: {} };
30
+ forecast = { pieces: [], tailPieces: [], tailSegments: [], geometry: {} };
31
31
  this.#byOutput.set(output, forecast);
32
32
  }
33
33
  const kept = forecast.pieces;
34
+ const keptTail = forecast.tailPieces;
34
35
  const segments = [];
35
36
  const lookahead = Number(this.#host.lookaheadSeconds);
36
37
  const start = Number.isFinite(positionSeconds) ? Math.max(0, positionSeconds) : 0;
@@ -50,6 +51,23 @@ export class OutputForecastGeometry {
50
51
  else high = middle;
51
52
  }
52
53
  const last = low;
54
+ const tailSegments = [];
55
+ for (let index = last; index < count; index += 1) {
56
+ const startSeconds = timeline.publishedStartOf(index);
57
+ const endSeconds = timeline.publishedStartOf(index + 1);
58
+ const sourceInputs = this.#host.sourceInputsFor?.(output, index) ?? undefined;
59
+ const previous = keptTail[index];
60
+ if (previous && previous.startSeconds === startSeconds && previous.endSeconds === endSeconds &&
61
+ sameInputs(previous.sourceInputs, sourceInputs)) {
62
+ tailSegments.push(previous);
63
+ continue;
64
+ }
65
+ const segment = { index, startSeconds, endSeconds, sourceInputs };
66
+ keptTail[index] = segment;
67
+ tailSegments.push(segment);
68
+ }
69
+ if (keptTail.length > count) keptTail.length = count;
70
+ forecast.tailSegments = tailSegments;
53
71
  const indexes = new Set([0]);
54
72
  for (let index = first; index < last; index++) indexes.add(index);
55
73
  for (const index of indexes) {
@@ -255,7 +255,10 @@ function mergeCaptureSummary(previous, capture) {
255
255
  * clockOffsetSeconds?: number, clientRanges?: Array<{ start: number, end: number }>,
256
256
  * appendService?: object, fragmentService?: object,
257
257
  * segments: Array<{ index: number, startSeconds: number, endSeconds: number,
258
+ * sourceInputs?: Array<{ sourceId: string, ranges: Array<{ start: number, end: number }> }>,
258
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 }> }> }>,
259
262
  * readySegmentIndices: number[], segmentSizesBytes: Map<number, number> | object }>} input.tracks
260
263
  * `mediaRanges` are a read piece's ranges on its served timeline, in ticks;
261
264
  * `clockOffsetSeconds` is the `timestampOffset` the page's player applied to
@@ -277,13 +280,23 @@ export function predictPlaybackReadiness(input = {}) {
277
280
  // THE NEAR WINDOW AND THE SUFFIX. The look-ahead is scheduled with exact
278
281
  // segment times and source ranges when known. Missing range metadata is priced
279
282
  // from aggregate file density and measured download service without claiming
280
- // an address or byte. Beyond the look-ahead, {@link scheduleTail} estimates
281
- // remaining source, encoder and delivery work without visiting every segment.
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.
282
286
  const lookahead = finiteNonNegative(input.lookaheadSeconds);
283
287
  const horizonEnd = lookahead > 0 ? finiteNonNegative(input.positionSeconds) + lookahead : Number.POSITIVE_INFINITY;
284
- const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) =>
285
- Array.isArray(track.segments) ? { ...track, segmentCount: track.segmentCount ?? track.segments.length,
286
- segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds), horizonEnd) } : track);
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
+ });
287
300
  const duration = finiteNonNegative(input.durationSeconds);
288
301
  // Each track's served ticks are placed on the clock the page reports by the
289
302
  // offset its player applied (`timestampOffset`).
@@ -765,13 +778,19 @@ export function predictPlaybackReadiness(input = {}) {
765
778
  }
766
779
  }
767
780
  const tailStock = [];
781
+ const tailCushionStock = [];
782
+ let tailUnmappedSources = new Set();
783
+ let tailUnmappedTracks = new Set();
768
784
  if (trackState.some(({ tailEndSeconds }) => Number.isFinite(tailEndSeconds))) {
769
- const stop = scheduleTail({ trackState, sourceState, runStarts, trackFinish,
770
- link, position, buffered, reserve, transferFinish, constrain, duration, stock: tailStock });
771
- if (stop) {
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) {
772
789
  return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
773
790
  ...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
774
791
  }
792
+ tailUnmappedSources = stop?.unmappedSources ?? tailUnmappedSources;
793
+ tailUnmappedTracks = stop?.unmappedTracks ?? tailUnmappedTracks;
775
794
  }
776
795
  const firstRequiredStages = trackState.slice(0, requiredTracks).map((state, trackIndex) => {
777
796
  const first = state.segments.find(segment => segment.endSeconds > position);
@@ -860,6 +879,15 @@ export function predictPlaybackReadiness(input = {}) {
860
879
  const firstRequiredSegmentsSeconds = firstRequiredTrackSeconds.every(Number.isFinite)
861
880
  ? Math.max(0, ...firstRequiredTrackSeconds) : null;
862
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
+ }
863
891
  // THE STOCK THE START ASKS FOR HAS TO FIT. Starting without a stop while the
864
892
  // source comes slower than the film plays means holding, at the start, what
865
893
  // is already here and everything that arrives before it. Where that is more
@@ -870,10 +898,11 @@ export function predictPlaybackReadiness(input = {}) {
870
898
  // share (Home Assistant 2026-10-26, torrent-tv/meta#239). Said as such, and
871
899
  // the viewer keeps waiting rather than starting into stops.
872
900
  const storageBytes = Number(input.sourceStorageBytes);
873
- const tailParts = tailStock.filter(part => !part.skipped);
901
+ const tailParts = tailCushionStock.filter(part => !part.skipped);
874
902
  const cushionBytes = Number.isFinite(delay) && tailParts.length > 0
875
903
  ? tailParts.reduce((sum, part) => sum + Math.max(0, part.totalBytes - part.missingBytes) +
876
- Math.max(0, part.missingBytes - part.bytesPerSecond * Math.max(0, part.finish - delay)), 0)
904
+ Math.max(0, part.missingBytes - (part.planned ? (part.finish > delay ? part.missingBytes : 0)
905
+ : part.bytesPerSecond * Math.max(0, part.finish - delay))), 0)
877
906
  : null;
878
907
  if (Number.isFinite(delay) && Number.isFinite(storageBytes) && storageBytes >= 0 && tailParts.length > 0) {
879
908
  if (cushionBytes > storageBytes) {
@@ -963,20 +992,182 @@ function scheduleTransfers(jobs, link) {
963
992
  }
964
993
 
965
994
  /**
966
- * A duration-level estimate of the suffix after the exact near window. Source
967
- * work uses each file's aggregate byte density and measured download service;
968
- * processing uses each track's measured rate; delivery uses output bitrates and
969
- * measured link service. Held source bytes and made segments reduce the
970
- * estimate. This conditional forecast does not assert exact source ranges or
971
- * individual frame timing. Its work is bounded by sources and tracks, not by
972
- * the film's segment count.
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.
973
1160
  *
974
1161
  * @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
975
1162
  */
976
- function scheduleTail({ trackState, sourceState, runStarts, trackFinish, link,
977
- 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 }) {
978
1165
  const tailStart = Math.max(position, ...trackState.map(state => state.tailStartSeconds));
979
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;
980
1171
  const sourceDurations = new Map();
981
1172
  for (const state of trackState) {
982
1173
  const seconds = Math.max(0, state.tailEndSeconds - tailStart) * state.tailWorkFraction;
@@ -985,12 +1176,15 @@ function scheduleTail({ trackState, sourceState, runStarts, trackFinish, link,
985
1176
  workFraction: state.tailWorkFraction, tailSegments: state.tailSegmentCount });
986
1177
  continue;
987
1178
  }
988
- for (const sourceId of new Set(state.track.sourceIds ?? [])) {
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;
989
1184
  sourceDurations.set(sourceId, Math.max(sourceDurations.get(sourceId) ?? 0, seconds));
990
1185
  }
991
1186
  }
992
1187
 
993
- let sourceArrivalAt = 0;
994
1188
  for (const [sourceId, seconds] of sourceDurations) {
995
1189
  const source = sourceState.get(sourceId);
996
1190
  if (!source) return { reason: "source-measurement-unavailable" };
@@ -1027,8 +1221,11 @@ function scheduleTail({ trackState, sourceState, runStarts, trackFinish, link,
1027
1221
  sourceArrivalAt = Math.max(sourceArrivalAt, service.finish);
1028
1222
  // What this stretch weighs and what it still lacks: the stock the start
1029
1223
  // asks to hold is read from these once the start's delay is known.
1030
- stock?.push({ sourceId, seconds, windowEnd, totalBytes:estimateUnmappedSourceBytes(source.source, seconds, duration, []) ?? bytes,
1031
- missingBytes: bytes, bytesPerSecond: rate.rateAt(0), finish: service.finish });
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);
1032
1229
  const state = trackState.find(candidate => candidate.track.sourceIds?.includes(sourceId));
1033
1230
  const index = state?.segments.at(-1)?.index ?? 0;
1034
1231
  const ahead = Math.max(0, (state?.tailEndSeconds ?? tailStart) - position);
@@ -1081,7 +1278,8 @@ function scheduleTail({ trackState, sourceState, runStarts, trackFinish, link,
1081
1278
  constrain(completedAt - Math.max(0, ahead - reserve), "delivery", 0,
1082
1279
  trackState[0]?.segments.at(-1)?.index ?? 0, completedAt, ahead, false, sourceArrivalAt, false);
1083
1280
  }
1084
- return null;
1281
+ return { unmappedSources: exactSources?.unmappedSources ?? new Set(),
1282
+ unmappedTracks: exactSources?.unmappedTracks ?? new Set() };
1085
1283
  }
1086
1284
 
1087
1285
  /**
@@ -1209,11 +1407,12 @@ function withSeekStartTolerance(ranges, position, unit) {
1209
1407
  * before `horizonEnd`, the first piece, every piece that has been read and
1210
1408
  * every piece that is made. A piece ending just before a seek target is kept
1211
1409
  * as a prerequisite because its media timestamps can extend past its nominal
1212
- * playlist boundary. A piece
1213
- * that is none of these changes no exact answer: after the near window it is
1214
- * scheduled in seconds, it is not where the track starts, no unread piece is
1215
- * placed by it (only read pieces are), it lends no frame to what the page
1216
- * holds, and it has no size to measure the output's rate by.
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.
1217
1416
  *
1218
1417
  * @param {{ segments: object[], readySegmentIndices?: number[] }} track
1219
1418
  * @param {number} position - Seconds.