@torrent-tv/proxy 2.107.29 → 2.107.31

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.29",
3
+ "version": "2.107.31",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -917,7 +917,7 @@ export async function startProxyServer({
917
917
  reviseBudget: () => { void outputParts.machineBudget.revise("memory", "media metadata").catch(error => logger.warn(`packet index budget: ${error.message}`)); },
918
918
  changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); outputParts.encodeInputs?.memoryChanged(); }
919
919
  });
920
- const containerOver = async ({ sourceKey, fileIndex, requestId, packetInterval, demand }) => {
920
+ const containerOver = async ({ sourceKey, fileIndex, requestId, packetInterval, demand, readPriority }) => {
921
921
  const record = sourceRegistry.get(sourceKey);
922
922
  if (!record) {
923
923
  return null;
@@ -932,6 +932,7 @@ export async function startProxyServer({
932
932
  fileIndex,
933
933
  requestId,
934
934
  packetInterval,
935
+ readPriority,
935
936
  packetMemory: indexMemory.forFile(sourceKey, fileIndex),
936
937
  downloadInterval: demand ? { from: demand.from, to: demand.to } : null,
937
938
  readRange: (start, end) =>
@@ -1023,7 +1024,7 @@ export async function startProxyServer({
1023
1024
  },
1024
1025
  // `timing`, when given, is told how long each read of a file waited behind
1025
1026
  // the reads asked before it and how long it took (torrent-tv/meta#166).
1026
- resolveEncodeInput: async (output, fromIndex, toIndex, timing = null, isCurrent = null, signal = null) => {
1027
+ resolveEncodeInput: async (output, fromIndex, toIndex, timing = null, isCurrent = null, signal = null, readPriority = null) => {
1027
1028
  const grid = output.timeline?.published ?? output.timeline?.boundaries;
1028
1029
  const from = grid?.[fromIndex], to = grid?.[toIndex + 1];
1029
1030
  if (!Number.isFinite(from) || !(to > from)) return { kind: "terminal", reason: "output-interval-not-declared" };
@@ -1041,7 +1042,7 @@ export async function startProxyServer({
1041
1042
  // reassembled packets otherwise.
1042
1043
  const files = [];
1043
1044
  for (const [fileIndex, choices] of selected) {
1044
- const params = await containerOver({ sourceKey: output.file.sourceKey, fileIndex });
1045
+ const params = await containerOver({ sourceKey: output.file.sourceKey, fileIndex, readPriority });
1045
1046
  if (!params) return { kind: "needs-source" };
1046
1047
  if (isCurrent) params.isCurrent = isCurrent;
1047
1048
  if (signal) params.signal = signal;
@@ -100,7 +100,8 @@ export class EncodeInputs {
100
100
  if (request.noted === state) return;
101
101
  request.noted = state;
102
102
  const serves = request.serves ?? request.from;
103
- const rank = this.#rankAt(request.output, serves);
103
+ const rank = request.readPriority?.serves === serves
104
+ ? request.readPriority : this.#rankAt(request.output, serves);
104
105
  this.#log(`encode input #${request.from}..#${request.to} of ${request.output.outputKey ?? request.output.id}: ${state}` +
105
106
  ` (held ${this.held()} of ${this.#allowed} allowed; for #${serves}` +
106
107
  `${rank ? `, rank ${rank.rank} of ${rank.topRank}` : ""})${detail}`);
@@ -115,10 +116,10 @@ export class EncodeInputs {
115
116
  }
116
117
 
117
118
  /** The container's statement of an interval, its time added to the request's account. */
118
- async #resolveTimed(timing, output, from, to, isCurrent = null, signal = null) {
119
+ async #resolveTimed(timing, output, from, to, isCurrent = null, signal = null, readPriority = null) {
119
120
  const startedAt = this.#now();
120
121
  try {
121
- return await this.#resolve(output, from, to, timing, isCurrent, signal);
122
+ return await this.#resolve(output, from, to, timing, isCurrent, signal, readPriority);
122
123
  } finally {
123
124
  timing.resolutions += 1;
124
125
  timing.resolveMs += this.#now() - startedAt;
@@ -409,6 +410,7 @@ export class EncodeInputs {
409
410
  #prepare(request) {
410
411
  request.controller ??= new AbortController();
411
412
  request.pending = true;
413
+ request.readPriority = this.#readPriority(request, request.from, request.from);
412
414
  request.revision = this.#revision;
413
415
  request.memoryRevision = this.#memoryRevision;
414
416
  request.arrivedMeanwhile = null;
@@ -425,7 +427,8 @@ export class EncodeInputs {
425
427
  // packet walk, left to run, held the file's reads and the piece the
426
428
  // viewer had seeked to waited 16.9 s behind it (r59, torrent-tv/meta#178).
427
429
  const resolved = await this.#resolveTimed(request.timing, request.output, request.from, request.from,
428
- () => this.#requests.get(request.key) === request && !request.controller.signal.aborted, request.controller.signal);
430
+ () => this.#requests.get(request.key) === request && !request.controller.signal.aborted, request.controller.signal,
431
+ request.readPriority);
429
432
  if (resolved.kind === "result") this.#original.set(request.output, resolved.sources?.every(source => source.input.original === true) === true);
430
433
  if (this.#requests.get(request.key) !== request) return;
431
434
  let result;
@@ -492,6 +495,23 @@ export class EncodeInputs {
492
495
  });
493
496
  }
494
497
 
498
+ /**
499
+ * The media reads that prepare one input keep the output map's urgency and
500
+ * the segment they serve, so a container can run an exact nearby interval
501
+ * before a speculative attempt to extend that input.
502
+ *
503
+ * @param {object} request
504
+ * @param {number} from
505
+ * @param {number} to
506
+ * @returns {{ rank: number, serves: number, from: number, to: number }}
507
+ */
508
+ #readPriority(request, from, to) {
509
+ const serves = request.serves ?? request.from;
510
+ const rank = this.#rankAt(request.output, serves);
511
+ return { rank: Number.isFinite(rank?.rank) ? rank.rank : 0, topRank: Number.isFinite(rank?.topRank) ? rank.topRank : 0,
512
+ serves, from, to };
513
+ }
514
+
495
515
  /**
496
516
  * Whether bytes came during a try that the try may have missed. A try that
497
517
  * waits for named bytes is repeated only when some of them came, or when an
@@ -563,7 +583,9 @@ export class EncodeInputs {
563
583
  if (!stretches.has(end)) {
564
584
  // A longer stretch that cannot be stated is not one to copy; the
565
585
  // shorter one already stated still runs.
566
- const resolved = await this.#resolveTimed(request.timing, output, from, end).catch(() => null);
586
+ const current = () => this.#requests.get(request.key) === request && !request.controller.signal.aborted;
587
+ const resolved = await this.#resolveTimed(request.timing, output, from, end, current, request.controller.signal,
588
+ this.#readPriority(request, from, end)).catch(() => null);
567
589
  stretches.set(end, resolved?.kind === "result" && resolved.sources?.every(source => source.input.original === true)
568
590
  ? { sources: resolved.sources, bytes: originalInputBytes(resolved.sources) } : null);
569
591
  }
@@ -32,6 +32,28 @@ import { logger } from "../../utils/logger.js";
32
32
  */
33
33
  export const CONTAINER_HEAD_BYTES = 256 * 1024;
34
34
 
35
+ /** Higher urgency, earlier served time, then the smaller source interval. */
36
+ function compareReadPriority(left, right) {
37
+ const leftRanked = Number.isFinite(left.readPriority?.rank) && left.readPriority.rank > 0;
38
+ const rightRanked = Number.isFinite(right.readPriority?.rank) && right.readPriority.rank > 0;
39
+ if (leftRanked !== rightRanked) return rightRanked - leftRanked;
40
+ if (!leftRanked) return left.sequence - right.sequence;
41
+ const leftRank = left.readPriority.rank;
42
+ const rightRank = right.readPriority.rank;
43
+ if (leftRank !== rightRank) return rightRank - leftRank;
44
+ const leftServes = Number.isFinite(left.readPriority?.serves) ? left.readPriority.serves : Number.POSITIVE_INFINITY;
45
+ const rightServes = Number.isFinite(right.readPriority?.serves) ? right.readPriority.serves : Number.POSITIVE_INFINITY;
46
+ if (leftServes !== rightServes) return leftServes - rightServes;
47
+ const leftSpan = Number.isFinite(left.readPriority?.from) && Number.isFinite(left.readPriority?.to)
48
+ ? Math.max(0, left.readPriority.to - left.readPriority.from) : Number.POSITIVE_INFINITY;
49
+ const rightSpan = Number.isFinite(right.readPriority?.from) && Number.isFinite(right.readPriority?.to)
50
+ ? Math.max(0, right.readPriority.to - right.readPriority.from) : Number.POSITIVE_INFINITY;
51
+ if (leftSpan !== rightSpan) return leftSpan - rightSpan;
52
+ const leftFrom = Number.isFinite(left.readPriority?.from) ? left.readPriority.from : Number.POSITIVE_INFINITY;
53
+ const rightFrom = Number.isFinite(right.readPriority?.from) ? right.readPriority.from : Number.POSITIVE_INFINITY;
54
+ return leftFrom - rightFrom || left.sequence - right.sequence;
55
+ }
56
+
35
57
  /**
36
58
  * The size of the file and its OpenSubtitles hash, from the two edges. Nothing
37
59
  * partial is kept: an edge that has not arrived is named instead.
@@ -101,6 +123,7 @@ export class ContainerOrchestrator {
101
123
  #lifetimes = new Map();
102
124
  #activityEpoch = 0;
103
125
  #activeReads = 0;
126
+ #readSequence = 0;
104
127
 
105
128
  activity() { return { epoch: this.#activityEpoch, active: this.#activeReads > 0 }; }
106
129
  constructor() {
@@ -147,9 +170,11 @@ export class ContainerOrchestrator {
147
170
  let lifetime = this.#lifetimes.get(key);
148
171
  if (!lifetime) this.#lifetimes.set(key, lifetime = {});
149
172
  const isCurrent = () => this.#lifetimes.get(key) === lifetime && params.isCurrent?.() !== false && params.signal?.aborted !== true;
150
- // Reads of one file run one after another, so an answer can wait for every
151
- // read asked before it. Told to whoever asked, apart from the read itself,
152
- // because the two call for different remedies (torrent-tv/meta#166).
173
+ // Reads of one file run one after another, ordered by playback-map urgency
174
+ // and then by interval size, so speculative extensions cannot sit ahead of
175
+ // an exact interval the viewer needs. Told to whoever asked, apart from
176
+ // the read itself, because the two call for different remedies
177
+ // (torrent-tv/meta#166, #ttv-245).
153
178
  // A statement answered from what was already read reads and parses
154
179
  // nothing, so it is answered beside the queue and holds nobody up: the
155
180
  // picture's first input of an AVI waited about 4 s behind the sound's
@@ -159,9 +184,8 @@ export class ContainerOrchestrator {
159
184
  // index. Keep it serial with other chapter reads, but out of the queue used
160
185
  // to prepare playback inputs for this file.
161
186
  const queueKey = statement === "chapters" ? `${key}:chapters` : key;
162
- const previous = aside ? Promise.resolve() : this.#reads.get(queueKey) ?? Promise.resolve();
163
187
  const askedAt = performance.now();
164
- const reading = previous.catch(() => undefined).then(async () => {
188
+ const perform = async () => {
165
189
  if (!isCurrent()) return { result: { kind: "terminal", reason: "request-obsolete", requestId: params.requestId } };
166
190
  const startedAt = performance.now();
167
191
  const revision = params.onReadStart?.(statement);
@@ -171,18 +195,70 @@ export class ContainerOrchestrator {
171
195
  params.onObserved?.({ statement, askedAt, startedAt, endedAt, queuedMs: startedAt - askedAt,
172
196
  readMs: endedAt - startedAt, cached: aside, result: result.kind, reason: result.reason, ranges: result.ranges });
173
197
  return { result, revision };
198
+ };
199
+ const reading = aside ? Promise.resolve().then(perform) : this.#queueRead(queueKey, { params, isCurrent, perform });
200
+ const { result, revision } = await reading;
201
+ if (!isCurrent()) return { kind: "terminal", reason: "request-obsolete", requestId: params.requestId };
202
+ if (result.kind === "needs-ranges") params.onNeedsRanges?.(result);
203
+ if (statement === "tracks" && result.kind === "result") params.onTracks?.(result.value);
204
+ await params.onReadResult?.(statement, result, revision);
205
+ return result;
206
+ }
207
+
208
+ /** Run one media read at a time per file, in the map's order of urgency. */
209
+ #queueRead(key, { params, isCurrent, perform }) {
210
+ let queue = this.#reads.get(key);
211
+ if (!queue) this.#reads.set(key, queue = { active: false, pending: [] });
212
+ return new Promise((resolve, reject) => {
213
+ const entry = { params, isCurrent, perform, resolve, reject, readPriority: params.readPriority,
214
+ sequence: this.#readSequence++, started: false, abort: null };
215
+ const cancel = () => {
216
+ if (entry.started) return;
217
+ const index = queue.pending.indexOf(entry);
218
+ if (index < 0) return;
219
+ queue.pending.splice(index, 1);
220
+ params.signal?.removeEventListener("abort", entry.abort);
221
+ resolve({ result: { kind: "terminal", reason: "request-obsolete", requestId: params.requestId } });
222
+ this.#drainReadQueue(key, queue);
223
+ };
224
+ entry.abort = cancel;
225
+ queue.pending.push(entry);
226
+ queue.pending.sort(compareReadPriority);
227
+ if (params.signal?.aborted) cancel();
228
+ else params.signal?.addEventListener("abort", cancel, { once: true });
229
+ this.#drainReadQueue(key, queue);
174
230
  });
175
- if (!aside) this.#reads.set(queueKey, reading);
176
- try {
177
- const { result, revision } = await reading;
178
- if (!isCurrent()) return { kind: "terminal", reason: "request-obsolete", requestId: params.requestId };
179
- if (result.kind === "needs-ranges") params.onNeedsRanges?.(result);
180
- if (statement === "tracks" && result.kind === "result") params.onTracks?.(result.value);
181
- await params.onReadResult?.(statement, result, revision);
182
- return result;
183
- } finally {
184
- if (this.#reads.get(queueKey) === reading) this.#reads.delete(queueKey);
231
+ }
232
+
233
+ #drainReadQueue(key, queue) {
234
+ if (queue.active) return;
235
+ while (queue.pending.length > 0) {
236
+ const entry = queue.pending.shift();
237
+ entry.params.signal?.removeEventListener("abort", entry.abort);
238
+ if (!entry.isCurrent()) {
239
+ entry.resolve({ result: { kind: "terminal", reason: "request-obsolete", requestId: entry.params.requestId } });
240
+ continue;
241
+ }
242
+ entry.started = true;
243
+ queue.active = true;
244
+ Promise.resolve().then(entry.perform).then(entry.resolve, entry.reject).finally(() => {
245
+ queue.active = false;
246
+ if (queue.pending.length === 0 && this.#reads.get(key) === queue) this.#reads.delete(key);
247
+ else this.#drainReadQueue(key, queue);
248
+ });
249
+ return;
250
+ }
251
+ if (this.#reads.get(key) === queue) this.#reads.delete(key);
252
+ }
253
+
254
+ #cancelQueuedReads(key) {
255
+ const queue = this.#reads.get(key);
256
+ if (!queue) return;
257
+ for (const entry of queue.pending.splice(0)) {
258
+ entry.params.signal?.removeEventListener("abort", entry.abort);
259
+ entry.resolve({ result: { kind: "terminal", reason: "request-obsolete", requestId: entry.params.requestId } });
185
260
  }
261
+ if (this.#reads.get(key) === queue) this.#reads.delete(key);
186
262
  }
187
263
 
188
264
  /** Whether a statement of this file is answered from what was already read: its track table kept here, or the container's word. */
@@ -380,7 +456,8 @@ export class ContainerOrchestrator {
380
456
  for (const key of this.#lifetimes.keys()) {
381
457
  if (fileIndex === undefined ? key.startsWith(prefix) : key === `${sourceKey}:${fileIndex}`) {
382
458
  this.#lifetimes.delete(key);
383
- this.#reads.delete(key);
459
+ this.#cancelQueuedReads(key);
460
+ this.#cancelQueuedReads(`${key}:chapters`);
384
461
  }
385
462
  }
386
463
  if (fileIndex === undefined) {
@@ -157,16 +157,17 @@ export function predictPlaybackReadiness(input = {}) {
157
157
  // starts), the read piece nearest before them (what an unread piece is placed
158
158
  // by) and read pieces inside what the page reports holding (their frames).
159
159
  //
160
- // THE NEAR WINDOW AND THE SUFFIX. The look-ahead is scheduled with exact
161
- // segment times and source ranges when known. Missing range metadata is priced
162
- // from aggregate file density and measured download service without claiming
163
- // an address or byte. Beyond the look-ahead, {@link scheduleTail} estimates
164
- // remaining source, encoder and delivery work without visiting every segment.
160
+ // THE NEAR WINDOW. The look-ahead is scheduled with exact segment times and
161
+ // source ranges when known. Missing range metadata is priced from aggregate
162
+ // file density and measured download service without claiming an address or
163
+ // byte. This forecast decides whether playback can start from work near the
164
+ // viewer; it does not make the first frame wait on an aggregate estimate of
165
+ // the whole remaining film. Each later poll advances this same window.
165
166
  const lookahead = finiteNonNegative(input.lookaheadSeconds);
166
167
  const horizonEnd = lookahead > 0 ? finiteNonNegative(input.positionSeconds) + lookahead : Number.POSITIVE_INFINITY;
167
168
  const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) =>
168
- Array.isArray(track.segments) ? { ...track, segmentCount: track.segmentCount ?? track.segments.length,
169
- segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds), horizonEnd) } : track);
169
+ Array.isArray(track.segments)
170
+ ? { ...track, segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds), horizonEnd) } : track);
170
171
  const duration = finiteNonNegative(input.durationSeconds);
171
172
  // Each track's served ticks are placed on the clock the page reports by the
172
173
  // offset its player applied (`timestampOffset`).
@@ -237,25 +238,11 @@ export function predictPlaybackReadiness(input = {}) {
237
238
  .filter((segment) => isPiece(segment) &&
238
239
  segment.endSeconds >= position - PLAYER_SEEK_TOLERANCE_SECONDS && segment.startSeconds < horizonEnd)
239
240
  .sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
240
- // The pieces after the near window, as the playlist cuts them, and the
241
- // offset the page's player placed them by.
242
- tail: [],
243
- tailStartSeconds: horizonEnd,
244
241
  durationSeconds: Number.isFinite(track.durationSeconds) ? track.durationSeconds : duration,
245
- segmentCount: Number.isSafeInteger(track.segmentCount) ? track.segmentCount
246
- : Math.max(0, ...track.segments.map(segment => segment.index + 1)),
247
242
  clockSeconds: Number.isFinite(track.clockOffsetSeconds) ? track.clockOffsetSeconds : 0
248
243
  }));
249
244
  for (const state of trackState) {
250
- state.tailEndSeconds = Number.isFinite(horizonEnd) && horizonEnd < duration
251
- ? Math.min(duration, state.durationSeconds + state.clockSeconds) : null;
252
- const endIndex = state.segments.reduce((last, segment) => segment.startSeconds < horizonEnd
253
- ? Math.max(last, segment.index) : last, -1);
254
- state.tailSegmentCount = Math.max(0, state.segmentCount - endIndex - 1);
255
- const readyTail = [...state.ready].reduce((count, index) => count + Number(index > endIndex), 0);
256
- state.tailWorkFraction = state.tailSegmentCount > 0
257
- ? Math.max(0, 1 - Math.min(state.tailSegmentCount, readyTail) / state.tailSegmentCount)
258
- : 0;
245
+ state.trackEndSeconds = Math.min(duration, state.durationSeconds + state.clockSeconds);
259
246
  }
260
247
  if (trackState.some(({ segments }) => segments.length === 0)) {
261
248
  return result(false, null, buffered, reserve, null, "timeline-unavailable", 0);
@@ -604,21 +591,12 @@ export function predictPlaybackReadiness(input = {}) {
604
591
  // Supply interruption coverage is stock held by the proxy as well as
605
592
  // the browser. It is not a browser buffer-size admission rule.
606
593
  // The reserve is bounded by what is left of the track, not of the window.
607
- const left = state.tailEndSeconds === null ? secondsAhead(trackEnd, before)
608
- : Math.max(0, state.tailEndSeconds - toSeconds(before));
594
+ const left = Math.max(0, state.trackEndSeconds - toSeconds(before));
609
595
  const ahead = Math.max(0, secondsAhead(before, at) - Math.min(reserve, left) * Number(production.requiresSource));
610
596
  constrain(production.at - ahead, "production", trackIndex, production.segment.index, production.at, ahead,
611
597
  production.requiresSource, production.sourceArrivalAt, production.sourcePlanned);
612
598
  }
613
599
  }
614
- if (trackState.some(({ tailEndSeconds }) => Number.isFinite(tailEndSeconds))) {
615
- const stop = scheduleTail({ trackState, sourceState, sourceByteCoverage, runStarts, trackFinish,
616
- link, position, buffered, reserve, transferFinish, constrain, duration });
617
- if (stop) {
618
- return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
619
- ...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
620
- }
621
- }
622
600
  const firstRequiredStages = trackState.slice(0, requiredTracks).map((state, trackIndex) => {
623
601
  const first = state.segments.find(segment => segment.endSeconds > position);
624
602
  if (!first) return { track: state.track.id ?? trackIndex, segment: null, alreadyBuffered: true,
@@ -683,93 +661,6 @@ function isPiece(segment) {
683
661
  Number.isFinite(segment?.endSeconds) && segment.endSeconds > segment.startSeconds;
684
662
  }
685
663
 
686
- /**
687
- * A duration-level estimate of the suffix after the exact near window. Source
688
- * work uses each file's aggregate byte density and measured download service;
689
- * processing uses each track's measured rate; delivery uses output bitrates and
690
- * measured link service. Held source bytes and made segments reduce the
691
- * estimate. This conditional forecast does not assert exact source ranges or
692
- * individual frame timing. Its work is bounded by sources and tracks, not by
693
- * the film's segment count.
694
- *
695
- * @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
696
- */
697
- function scheduleTail({ trackState, sourceState, sourceByteCoverage, runStarts, trackFinish, link,
698
- position, buffered, reserve, transferFinish, constrain, duration }) {
699
- const tailStart = Math.max(position, ...trackState.map(state => state.tailStartSeconds));
700
- if (!Number.isFinite(tailStart)) return null;
701
- const sourceDurations = new Map();
702
- for (const state of trackState) {
703
- const seconds = Math.max(0, state.tailEndSeconds - tailStart) * state.tailWorkFraction;
704
- if (!(seconds > 0)) continue;
705
- for (const sourceId of new Set(state.track.sourceIds ?? [])) {
706
- sourceDurations.set(sourceId, Math.max(sourceDurations.get(sourceId) ?? 0, seconds));
707
- }
708
- }
709
-
710
- let sourceArrivalAt = 0;
711
- for (const [sourceId, seconds] of sourceDurations) {
712
- const source = sourceState.get(sourceId);
713
- if (!source) return { reason: "source-measurement-unavailable" };
714
- if (source.source.complete === true) continue;
715
- const bytes = estimateUnmappedSourceBytes(source.source, seconds, duration,
716
- sourceByteCoverage.get(sourceId));
717
- if (bytes === null) return { reason: "source-measurement-unavailable",
718
- unavailableSource: { sourceId, phase: "tail" } };
719
- if (!(bytes > 0)) continue;
720
- const { service } = source;
721
- if (!service.rate || !(service.rate.rateAt(0) > 0)) {
722
- return { reason: service.rate ? "service-not-advancing" : "download-rate-unavailable",
723
- unavailableSource: { sourceId, estimatedBytes: bytes, phase: "tail" } };
724
- }
725
- service.finish = service.rate.finish(bytes, service.finish);
726
- sourceArrivalAt = Math.max(sourceArrivalAt, service.finish);
727
- const state = trackState.find(candidate => candidate.track.sourceIds?.includes(sourceId));
728
- const index = state?.segments.at(-1)?.index ?? 0;
729
- const ahead = Math.max(0, (state?.tailEndSeconds ?? tailStart) - position);
730
- constrain(service.finish - Math.max(0, ahead - reserve), "source", trackState.indexOf(state), index,
731
- service.finish, ahead, true, service.finish, false);
732
- }
733
-
734
- let outputBits = 0;
735
- let latestTrackFinish = 0;
736
- for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
737
- const state = trackState[trackIndex];
738
- const end = state.tailEndSeconds;
739
- const seconds = Math.max(0, end - tailStart);
740
- if (!(seconds > 0)) continue;
741
- const track = state.track;
742
- const segment = state.segments.at(-1) ?? { index: 0 };
743
- const remaining = seconds * state.tailWorkFraction;
744
- const work = Math.max(0, remaining - numericCoverage((track.processingRanges ?? [])
745
- .filter(range => Number.isFinite(range?.start) && Number.isFinite(range?.end))
746
- .map(range => ({ start: Math.max(tailStart, range.start), end: Math.min(end, range.end) }))
747
- .filter(range => range.end > range.start)).reduce((sum, range) => sum + range.end - range.start, 0));
748
- const processing = state.curve;
749
- if (work > 0 && !processing) return { reason: "encode-rate-unavailable" };
750
- const run = runStarts[trackIndex];
751
- const localStart = tailStart - state.clockSeconds;
752
- const inRun = run.spans.some(span => localStart >= span.start && localStart < span.end);
753
- const open = work > 0 && !inRun ? run.openSeconds : 0;
754
- const finishedAt = work > 0 ? processing.finish(work, trackFinish[trackIndex] + open) : trackFinish[trackIndex];
755
- latestTrackFinish = Math.max(latestTrackFinish, finishedAt);
756
- const deliveryStart = Math.max(tailStart, position + buffered);
757
- outputBits += Math.max(0, state.bitsPerMediaSecond) * Math.max(0, end - deliveryStart);
758
- const ahead = Math.max(0, end - position);
759
- constrain(finishedAt - Math.max(0, ahead - reserve), "production", trackIndex, segment.index,
760
- finishedAt, ahead, sourceArrivalAt > 0, sourceArrivalAt, false);
761
- }
762
-
763
- if (outputBits > 0) {
764
- const end = Math.max(...trackState.map(state => state.tailEndSeconds));
765
- const completedAt = link.finish(outputBits, Math.max(transferFinish, latestTrackFinish));
766
- const ahead = Math.max(0, end - position);
767
- constrain(completedAt - Math.max(0, ahead - reserve), "delivery", 0,
768
- trackState[0]?.segments.at(-1)?.index ?? 0, completedAt, ahead, false, sourceArrivalAt, false);
769
- }
770
- return null;
771
- }
772
-
773
664
  /**
774
665
  * Seconds from `from` to `to`, or zero when `to` is not later.
775
666
  *
@@ -1075,7 +966,7 @@ function numericCoverage(ranges) {
1075
966
  /**
1076
967
  * Estimate download work without claiming an address or held byte. The whole
1077
968
  * file's measured density, reduced by the held fraction, prices unresolved
1078
- * intervals and the aggregate suffix.
969
+ * intervals in the near window.
1079
970
  */
1080
971
  function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, coverage = null) {
1081
972
  const fileLength = Number(source.fileLength);