@torrent-tv/proxy 2.107.18 → 2.107.20

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.18",
3
+ "version": "2.107.20",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -985,6 +985,9 @@ export class ViewerRequests {
985
985
  residence: stats?.residence,
986
986
  fileOffset: stats?.fileOffset,
987
987
  fileLength: stats?.fileLength,
988
+ bytesPerMediaSecond: Number.isFinite(stats?.fileLength) && stats.fileLength > 0 &&
989
+ Number.isFinite(session.file.durationSeconds) && session.file.durationSeconds > 0
990
+ ? stats.fileLength / session.file.durationSeconds : null,
988
991
  pieceLength: stats?.pieceLength,
989
992
  downloadRateReadings: this.#sourceRateReadings(measurement, output.file.sourceKey, stats, now),
990
993
  downloadRateSummaries: this.#sourceRateSummaries(measurement, output.file.sourceKey),
@@ -1022,6 +1025,8 @@ export class ViewerRequests {
1022
1025
  })),
1023
1026
  bitsPerMediaSecond: Number.isFinite(observedMbps) && observedMbps > 0 ? observedMbps * 1_000_000 :
1024
1027
  this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
1028
+ durationSeconds: output.timeline?.publishedStartOf(output.timeline.segmentCount) ?? session.file.durationSeconds,
1029
+ segmentCount: output.timeline?.segmentCount ?? segments.length,
1025
1030
  readings: trackRates,
1026
1031
  segments,
1027
1032
  geometry: this.#forecastPieces.get(output)?.geometry,
@@ -1247,7 +1252,27 @@ export class ViewerRequests {
1247
1252
  }
1248
1253
  const kept = forecast.pieces;
1249
1254
  const segments = [];
1250
- for (let index = 0; index < count; index++) {
1255
+ const lookahead = Number(this.#host.lookaheadSeconds);
1256
+ const start = Number.isFinite(positionSeconds) ? Math.max(0, positionSeconds) : 0;
1257
+ const end = Number.isFinite(lookahead) && lookahead > 0 ? start + lookahead : Number.POSITIVE_INFINITY;
1258
+ let low = 0, high = count;
1259
+ while (low < high) {
1260
+ const middle = (low + high) >>> 1;
1261
+ if (timeline.publishedStartOf(middle + 1) <= start) low = middle + 1;
1262
+ else high = middle;
1263
+ }
1264
+ const first = Math.max(0, low - 1);
1265
+ low = first;
1266
+ high = count;
1267
+ while (low < high) {
1268
+ const middle = (low + high) >>> 1;
1269
+ if (timeline.publishedStartOf(middle) < end) low = middle + 1;
1270
+ else high = middle;
1271
+ }
1272
+ const last = low;
1273
+ const indexes = new Set([0]);
1274
+ for (let index = first; index < last; index++) indexes.add(index);
1275
+ for (const index of indexes) {
1251
1276
  const endSeconds = timeline.publishedStartOf(index + 1);
1252
1277
  // Only a made piece has media intervals to read. Asked of every piece,
1253
1278
  // each one not made cost a `stat` of its file in the store's safety net
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Predict the minimum delay before playback can run to the end without
3
- * exhausting the continuous media available for the required tracks.
2
+ * Predict whether the viewer can start from the current position without
3
+ * exhausting the continuous media already available for the required tracks.
4
4
  *
5
5
  * This module contains the playback decision's only model. Its inputs are
6
6
  * measurements and physical limits: mapped whole-piece arrivals, encoded
7
7
  * media and encoder rate, segment sizes and client-link rate, existing
8
- * continuous media, and measured supply interruptions. Each service integral
9
- * extends its latest measured rate until the next observation. The forecast
10
- * is conditional on these measured services continuing. Browser storage
8
+ * continuous media, and measured supply interruptions. Exact scheduling is
9
+ * limited to the configured near window; the remaining duration is priced by
10
+ * aggregate work per source and track. Each service integral extends its latest
11
+ * measured rate until the next observation. The forecast is conditional on
12
+ * these measured services continuing. Browser storage
11
13
  * capacity is not an admission condition. There are no fitted weights or
12
14
  * rate multipliers, and no search over candidate start times.
13
15
  *
@@ -157,7 +159,7 @@ export class ProgressRate {
157
159
  * @param {number} input.now
158
160
  * @param {boolean} [input.requiredAudio]
159
161
  * @param {Array<{ id: string, serviceKey?: string, complete: boolean, fileOffset?: number,
160
- * fileLength?: number, pieceLength?: number, downloadRateReadings?: object,
162
+ * fileLength?: number, bytesPerMediaSecond?: number, pieceLength?: number, downloadRateReadings?: object,
161
163
  * downloadForecast?: { ranges: Array<{ start: number, end: number, availableAt: number | null }> } }>} input.sources
162
164
  * @param {Array<{ id: string, sourceIds: string[], processedSeconds: number,
163
165
  * bitsPerMediaSecond: number, readings: Array<{ at: number, value: number }>,
@@ -182,18 +184,15 @@ export function predictPlaybackReadiness(input = {}) {
182
184
  // starts), the read piece nearest before them (what an unread piece is placed
183
185
  // by) and read pieces inside what the page reports holding (their frames).
184
186
  //
185
- // THE NEAR WINDOW AND THE TAIL (torrent-tv/meta#185). Pieces starting within
186
- // the proxy's look-ahead of the viewer are placed in exact time, where whether
187
- // the media is continuous is decided, and their bytes are those the torrent
188
- // thread plans arrivals for. The pieces after it are scheduled by the same
189
- // rules in seconds, by {@link scheduleTail}: no exact time and no planned
190
- // arrival exists for them. Placing every remaining piece of a two-hour film
191
- // in exact time on every poll cost 70-1128 ms a call on Home Assistant (r73,
192
- // r74).
187
+ // THE NEAR WINDOW AND THE SUFFIX. The look-ahead is scheduled with exact
188
+ // segment times and source ranges when known. Missing range metadata is priced
189
+ // from aggregate file density and measured download service without claiming
190
+ // an address or byte. Beyond the look-ahead, {@link scheduleTail} estimates
191
+ // remaining source, encoder and delivery work without visiting every segment.
193
192
  const lookahead = finiteNonNegative(input.lookaheadSeconds);
194
193
  const horizonEnd = lookahead > 0 ? finiteNonNegative(input.positionSeconds) + lookahead : Number.POSITIVE_INFINITY;
195
194
  const tracks = (Array.isArray(input.tracks) ? input.tracks : []).map((track) =>
196
- Array.isArray(track.segments) ? { ...track,
195
+ Array.isArray(track.segments) ? { ...track, segmentCount: track.segmentCount ?? track.segments.length,
197
196
  segments: piecesThatMatter(track, finiteNonNegative(input.positionSeconds), horizonEnd) } : track);
198
197
  const duration = finiteNonNegative(input.durationSeconds);
199
198
  // Each track's served ticks are placed on the clock the page reports by the
@@ -266,15 +265,23 @@ export function predictPlaybackReadiness(input = {}) {
266
265
  .sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
267
266
  // The pieces after the near window, as the playlist cuts them, and the
268
267
  // offset the page's player placed them by.
269
- tail: horizonEnd === Number.POSITIVE_INFINITY ? [] :
270
- (Array.isArray(input.tracks[trackIndex].segments) ? input.tracks[trackIndex].segments : [])
271
- .filter((segment) => isPiece(segment) && segment.startSeconds >= horizonEnd && segment.endSeconds > position)
272
- .sort((left, right) => left.startSeconds - right.startSeconds || left.index - right.index),
268
+ tail: [],
269
+ tailStartSeconds: horizonEnd,
270
+ durationSeconds: Number.isFinite(track.durationSeconds) ? track.durationSeconds : duration,
271
+ segmentCount: Number.isSafeInteger(track.segmentCount) ? track.segmentCount
272
+ : Math.max(0, ...track.segments.map(segment => segment.index + 1)),
273
273
  clockSeconds: Number.isFinite(track.clockOffsetSeconds) ? track.clockOffsetSeconds : 0
274
274
  }));
275
275
  for (const state of trackState) {
276
- state.tailEndSeconds = state.tail.length > 0
277
- ? Math.min(duration, state.tail.at(-1).endSeconds + state.clockSeconds) : null;
276
+ state.tailEndSeconds = Number.isFinite(horizonEnd) && horizonEnd < duration
277
+ ? Math.min(duration, state.durationSeconds + state.clockSeconds) : null;
278
+ const endIndex = state.segments.reduce((last, segment) => segment.startSeconds < horizonEnd
279
+ ? Math.max(last, segment.index) : last, -1);
280
+ state.tailSegmentCount = Math.max(0, state.segmentCount - endIndex - 1);
281
+ const readyTail = [...state.ready].reduce((count, index) => count + Number(index > endIndex), 0);
282
+ state.tailWorkFraction = state.tailSegmentCount > 0
283
+ ? Math.max(0, 1 - Math.min(state.tailSegmentCount, readyTail) / state.tailSegmentCount)
284
+ : 0;
278
285
  }
279
286
  if (trackState.some(({ segments }) => segments.length === 0)) {
280
287
  return result(false, null, buffered, reserve, null, "timeline-unavailable", 0);
@@ -294,6 +301,13 @@ export function predictPlaybackReadiness(input = {}) {
294
301
  }
295
302
  sourceState.set(id, { source, service });
296
303
  }
304
+ const sourceUseCounts = new Map();
305
+ for (const { track } of trackState) {
306
+ for (const sourceId of new Set([...(track.sourceIds ?? []),
307
+ ...(track.segments ?? []).flatMap(segment => (segment.sourceInputs ?? []).map(item => item.sourceId))])) {
308
+ sourceUseCounts.set(sourceId, (sourceUseCounts.get(sourceId) ?? 0) + 1);
309
+ }
310
+ }
297
311
  const link = rateCurve(input.linkReadings, now);
298
312
  let unknownReason = null;
299
313
  let unavailableSource = null;
@@ -301,9 +315,7 @@ export function predictPlaybackReadiness(input = {}) {
301
315
  return result(false, null, buffered, reserve, null, "link-rate-unavailable", 0);
302
316
  }
303
317
 
304
- const preparedSegments = trackState.reduce((sum, { ready, segments, tail }) =>
305
- sum + segments.filter((segment) => ready.has(segment.index)).length +
306
- tail.filter((segment) => ready.has(segment.index)).length, 0);
318
+ let preparedSegments = trackState.reduce((sum, { ready }) => sum + ready.size, 0);
307
319
  const jobs = [];
308
320
 
309
321
  for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
@@ -344,6 +356,7 @@ export function predictPlaybackReadiness(input = {}) {
344
356
  const encodeWork = produced ? 0 : remainingProcessing.reduce((sum, part) => sum + part.end - part.start, 0);
345
357
 
346
358
  let sourceByteIntervals = NO_SOURCE_BYTES;
359
+ const estimatedSourceBytes = new Map();
347
360
  if (!produced && encodeWork > 0) {
348
361
  // Each source the piece reads, in order and once: the track's own, then
349
362
  // those its inputs name. Gathered with loops and no copies, because
@@ -361,11 +374,18 @@ export function predictPlaybackReadiness(input = {}) {
361
374
  unknownReason ??= "source-measurement-unavailable";
362
375
  } else if (source.source.complete !== true) {
363
376
  const ranges = exactInputs ? inputRangesOf(exactInputs, sourceId) : null;
364
- if (ranges === null) {
365
- if (unknownReason === null) {
377
+ if (ranges === null && exactInputs === null) {
378
+ const estimate = estimateUnmappedSourceBytes(source.source, segmentSeconds, input.durationSeconds);
379
+ if (estimate !== null) {
380
+ const bytes = estimate / Math.max(1, sourceUseCounts.get(sourceId) ?? 1);
381
+ estimatedSourceBytes.set(sourceId, bytes);
382
+ } else if (unknownReason === null) {
366
383
  unknownReason = "source-input-ranges-unavailable";
367
384
  unavailableSource = { sourceId, trackId: track.id, segmentIndex: segment.index, phase: "near" };
368
385
  }
386
+ } else if (ranges === null) {
387
+ unknownReason ??= "source-input-ranges-unavailable";
388
+ unavailableSource ??= { sourceId, trackId: track.id, segmentIndex: segment.index, phase: "near" };
369
389
  } else {
370
390
  if (sourceByteIntervals === NO_SOURCE_BYTES) sourceByteIntervals = new Map();
371
391
  sourceByteIntervals.set(sourceId, ranges);
@@ -384,11 +404,14 @@ export function predictPlaybackReadiness(input = {}) {
384
404
  sizeBits,
385
405
  encodeWork,
386
406
  sourceByteIntervals,
407
+ estimatedSourceBytes,
387
408
  produced
388
409
  });
389
410
  }
390
411
  }
391
412
 
413
+ preparedSegments = trackState.reduce((sum, { ready }) => sum + ready.size, 0);
414
+
392
415
  if (unknownReason) {
393
416
  return { ...result(false, null, buffered, reserve, null, unknownReason, preparedSegments),
394
417
  ...(unavailableSource ? { unavailableSource } : {}) };
@@ -450,7 +473,7 @@ export function predictPlaybackReadiness(input = {}) {
450
473
  const completions = [];
451
474
  const productions = [];
452
475
  for (const job of jobs) {
453
- const { track, trackIndex, segment, sizeBits, encodeWork, sourceByteIntervals } = job;
476
+ const { track, trackIndex, segment, sizeBits, encodeWork, sourceByteIntervals, estimatedSourceBytes } = job;
454
477
  let sourceArrivalAt = 0;
455
478
  const plan = { finish: 0 };
456
479
  for (const [sourceId, ranges] of sourceByteIntervals) {
@@ -498,6 +521,21 @@ export function predictPlaybackReadiness(input = {}) {
498
521
  sourceService.finish = finish;
499
522
  }
500
523
  }
524
+ for (const [sourceId, bytes] of estimatedSourceBytes) {
525
+ if (!(bytes > 0)) continue;
526
+ const source = sourceState.get(sourceId);
527
+ if (!source) {
528
+ return result(false, null, buffered, reserve, null, "source-measurement-unavailable", preparedSegments);
529
+ }
530
+ const { service } = source;
531
+ if (!service.rate || !(service.rate.rateAt(0) > 0)) {
532
+ return { ...result(false, null, buffered, reserve, null,
533
+ service.rate ? "service-not-advancing" : "download-rate-unavailable", preparedSegments),
534
+ unavailableSource: { sourceId, segmentIndex: segment.index, estimatedBytes: bytes } };
535
+ }
536
+ service.finish = service.rate.finish(bytes, Math.max(service.finish, sourceArrivalAt));
537
+ sourceArrivalAt = Math.max(sourceArrivalAt, service.finish);
538
+ }
501
539
  // Production accepts a complete held segment input. It cannot overlap its
502
540
  // own source download; reused input retains its original arrival time.
503
541
  const processing = trackState[trackIndex].curve;
@@ -592,9 +630,9 @@ export function predictPlaybackReadiness(input = {}) {
592
630
  production.requiresSource, production.sourceArrivalAt, production.sourcePlanned);
593
631
  }
594
632
  }
595
- if (trackState.some(({ tail }) => tail.length > 0)) {
596
- const stop = scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArrivals, runStarts, trackFinish,
597
- link, position, reserve, transferFinish, constrain });
633
+ if (trackState.some(({ tailEndSeconds }) => Number.isFinite(tailEndSeconds))) {
634
+ const stop = scheduleTail({ trackState, sourceState, sourceByteCoverage, runStarts, trackFinish,
635
+ link, position, buffered, reserve, transferFinish, constrain, duration });
598
636
  if (stop) {
599
637
  return { ...result(false, null, buffered, reserve, null, stop.reason, preparedSegments),
600
638
  ...(stop.unavailableSource ? { unavailableSource: stop.unavailableSource } : {}) };
@@ -665,185 +703,90 @@ function isPiece(segment) {
665
703
  }
666
704
 
667
705
  /**
668
- * The pieces after the near window, scheduled by the near window's rules in
669
- * seconds and constrained as they are scheduled: their source bytes arrive in
670
- * byte order at the source's measured service after the near window's bytes,
671
- * a piece whose bytes come after its output's last run was admitted starts a
672
- * run and pays the output's open, it is encoded at the output's measured rate
673
- * and delivered over the one link in media order.
674
- *
675
- * Where a byte stands in that order is the bytes still missing before it,
676
- * read off what is held, planned and brought by the near window
677
- * ({@link missingBefore}); a piece is placed by the last range of it that
678
- * misses bytes, so a range it reads that is already held — an AVI's index at
679
- * the end of the file — does not place it at the end of the file. Planned and
680
- * brought bytes come when they were to come. No exact time, list of arrivals
681
- * or per-piece object is built: this runs over the rest of the film on every
682
- * poll (torrent-tv/meta#185).
706
+ * A duration-level estimate of the suffix after the exact near window. Source
707
+ * work uses each file's aggregate byte density and measured download service;
708
+ * processing uses each track's measured rate; delivery uses output bitrates and
709
+ * measured link service. Held source bytes and made segments reduce the
710
+ * estimate. This conditional forecast does not assert exact source ranges or
711
+ * individual frame timing. Its work is bounded by sources and tracks, not by
712
+ * the film's segment count.
683
713
  *
684
714
  * @returns {{ reason: string, unavailableSource?: object } | null} Why there is no answer, or null.
685
715
  */
686
- function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArrivals, runStarts, trackFinish, link,
687
- position, reserve, transferFinish, constrain }) {
688
- // Per source: what is missing before a byte, and how far the tail has asked.
689
- const supply = new Map();
690
- const supplyOf = (sourceId) => {
691
- let state = supply.get(sourceId);
692
- if (!state) {
693
- const coverage = sourceByteCoverage.get(sourceId) ?? [];
694
- const heldBefore = [0];
695
- for (const range of coverage) heldBefore.push(heldBefore.at(-1) + range.end - range.start);
696
- state = { coverage, heldBefore, asked: null, orders: [], finishes: [] };
697
- supply.set(sourceId, state);
698
- }
699
- return state;
700
- };
701
- const next = trackState.map(() => 0);
702
- const plan = { finish: 0 };
703
- let delivered = transferFinish;
704
- for (;;) {
705
- // The next piece in media order across the tracks, as the near window orders them.
706
- let trackIndex = -1;
707
- for (let index = 0; index < trackState.length; index += 1) {
708
- const candidate = trackState[index].tail[next[index]];
709
- if (!candidate) continue;
710
- const best = trackIndex < 0 ? null : trackState[trackIndex].tail[next[trackIndex]];
711
- if (!best || candidate.startSeconds < best.startSeconds ||
712
- (candidate.startSeconds === best.startSeconds && candidate.endSeconds < best.endSeconds)) trackIndex = index;
713
- }
714
- if (trackIndex < 0) return null;
715
- const state = trackState[trackIndex];
716
- const { track } = state;
717
- const segment = state.tail[next[trackIndex]++];
718
- const startsAt = segment.startSeconds + state.clockSeconds;
719
- const endsAt = segment.endSeconds + state.clockSeconds;
720
- // Media the page already holds is not transferred again.
721
- if (Array.isArray(track.clientRanges) && track.clientRanges.some((range) =>
722
- range?.start <= Math.max(startsAt, position) && range?.end >= endsAt)) continue;
723
- const exactBytes = sizeOf(track.segmentSizesBytes, segment.index);
724
- const sizeBits = exactBytes > 0 ? exactBytes * 8 : state.bitsPerMediaSecond * (segment.endSeconds - segment.startSeconds);
725
- if (!(sizeBits > 0)) return { reason: "segment-size-unavailable" };
726
- const produced = state.ready.has(segment.index);
727
- let work = 0;
728
- if (!produced) {
729
- work = segment.endSeconds - segment.startSeconds;
730
- for (const range of Array.isArray(track.processingRanges) ? track.processingRanges : []) {
731
- if (Number.isFinite(range?.start) && Number.isFinite(range?.end)) {
732
- work -= Math.max(0, Math.min(range.end, segment.endSeconds) - Math.max(range.start, segment.startSeconds));
733
- }
734
- }
735
- work = Math.max(0, work);
736
- }
737
- let sourceArrivalAt = 0;
738
- plan.finish = 0;
739
- if (work > 0) {
740
- if (!state.curve) return { reason: "encode-rate-unavailable" };
741
- const trackSources = Array.isArray(track.sourceIds) ? track.sourceIds : [];
742
- const exactInputs = Array.isArray(segment.sourceInputs) ? segment.sourceInputs : null;
743
- const count = trackSources.length + (exactInputs?.length ?? 0);
744
- for (let place = 0; place < count; place += 1) {
745
- const sourceId = place < trackSources.length ? trackSources[place]
746
- : exactInputs[place - trackSources.length]?.sourceId;
747
- if (namedBefore(sourceId, place, trackSources, exactInputs)) continue;
748
- const source = sourceState.get(sourceId);
749
- if (!source) return { reason: "source-measurement-unavailable" };
750
- if (source.source.complete === true) continue;
751
- const ranges = exactInputs ? inputRangesOf(exactInputs, sourceId) : null;
752
- if (ranges === null) {
753
- // The tail extends beyond the ranges the playback map has asked to
754
- // resolve. Stop this projection at that boundary: the near window
755
- // remains exact, and later segments enter the map as the viewer
756
- // advances. Waiting for the whole tail here prevents a cold start.
757
- return null;
758
- }
759
- const bytes = supplyOf(sourceId);
760
- let order = 0;
761
- for (const range of ranges) {
762
- // Bytes the torrent thread planned, or the near window brought, come when they were to come.
763
- sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(range, sourceArrivals.get(sourceId), plan));
764
- const after = missingBefore(bytes, range.end);
765
- if (after > missingBefore(bytes, range.start)) order = Math.max(order, after);
766
- }
767
- if (order === 0) continue;
768
- const { service } = source;
769
- if (!service.rate || !(service.rate.rateAt(0) > 0)) {
770
- return { reason: service.rate ? "service-not-advancing" : "download-rate-unavailable",
771
- unavailableSource: { sourceId, segmentIndex: segment.index, missingBytes: order - (bytes.asked ?? 0) } };
772
- }
773
- // The tail's first asked byte is where this source's tail begins:
774
- // bytes missing before it are not this film's to wait for.
775
- bytes.asked ??= Math.max(0, order - missingWithin(bytes, ranges));
776
- let arrival;
777
- if (order > bytes.asked) {
778
- service.finish = service.rate.finish(order - bytes.asked, service.finish);
779
- bytes.asked = order;
780
- bytes.orders.push(order);
781
- bytes.finishes.push(service.finish);
782
- arrival = service.finish;
783
- } else {
784
- // Brought by an earlier piece's ask: when that ask reached it.
785
- let low = 0, high = bytes.orders.length - 1;
786
- while (low < high) {
787
- const middle = (low + high) >>> 1;
788
- if (bytes.orders[middle] < order) low = middle + 1;
789
- else high = middle;
790
- }
791
- arrival = bytes.finishes[low] ?? service.finish;
792
- }
793
- sourceArrivalAt = Math.max(sourceArrivalAt, arrival);
794
- }
716
+ function scheduleTail({ trackState, sourceState, sourceByteCoverage, runStarts, trackFinish, link,
717
+ position, buffered, reserve, transferFinish, constrain, duration }) {
718
+ const tailStart = Math.max(position, ...trackState.map(state => state.tailStartSeconds));
719
+ if (!Number.isFinite(tailStart)) return null;
720
+ const sourceDurations = new Map();
721
+ for (const state of trackState) {
722
+ const seconds = Math.max(0, state.tailEndSeconds - tailStart) * state.tailWorkFraction;
723
+ if (!(seconds > 0)) continue;
724
+ for (const sourceId of new Set(state.track.sourceIds ?? [])) {
725
+ sourceDurations.set(sourceId, Math.max(sourceDurations.get(sourceId) ?? 0, seconds));
795
726
  }
796
- let admittedAt = Math.max(trackFinish[trackIndex], sourceArrivalAt);
797
- if (work > 0) {
798
- const run = runStarts[trackIndex];
799
- const live = run.spans.some(span => segment.startSeconds >= span.start && segment.startSeconds < span.end);
800
- if (!live && (run.admittedAt === null || sourceArrivalAt > run.admittedAt)) {
801
- run.admittedAt = admittedAt;
802
- admittedAt += run.openSeconds;
803
- }
727
+ }
728
+
729
+ let sourceArrivalAt = 0;
730
+ for (const [sourceId, seconds] of sourceDurations) {
731
+ const source = sourceState.get(sourceId);
732
+ if (!source) return { reason: "source-measurement-unavailable" };
733
+ if (source.source.complete === true) continue;
734
+ const bytes = estimateUnmappedSourceBytes(source.source, seconds, duration,
735
+ sourceByteCoverage.get(sourceId));
736
+ if (bytes === null) return { reason: "source-measurement-unavailable",
737
+ unavailableSource: { sourceId, phase: "tail" } };
738
+ if (!(bytes > 0)) continue;
739
+ const { service } = source;
740
+ if (!service.rate || !(service.rate.rateAt(0) > 0)) {
741
+ return { reason: service.rate ? "service-not-advancing" : "download-rate-unavailable",
742
+ unavailableSource: { sourceId, estimatedBytes: bytes, phase: "tail" } };
804
743
  }
805
- const producedAt = work > 0 ? state.curve.finish(work, admittedAt) : 0;
806
- trackFinish[trackIndex] = Math.max(trackFinish[trackIndex], producedAt);
807
- delivered = link.finish(sizeBits, Math.max(delivered, producedAt));
808
- const completedAt = delivered + finiteNonNegative(track.appendSeconds);
809
- // The media before a piece is what the playlist puts before it: whether a
810
- // hole stops the media is decided in the near window, in exact time.
811
- const ahead = Math.max(0, startsAt - position);
812
- const requiresSource = sourceArrivalAt > 0;
813
- const sourcePlanned = requiresSource && plan.finish >= sourceArrivalAt;
814
- constrain(completedAt - ahead, "delivery", trackIndex, segment.index, completedAt, ahead, false, sourceArrivalAt,
815
- sourcePlanned);
816
- const covered = Math.max(0, ahead - Math.min(reserve, Math.max(0, state.tailEndSeconds - startsAt)) *
817
- Number(requiresSource));
818
- constrain(producedAt - covered, "production", trackIndex, segment.index, producedAt, covered, requiresSource,
819
- sourceArrivalAt, sourcePlanned);
744
+ service.finish = service.rate.finish(bytes, service.finish);
745
+ sourceArrivalAt = Math.max(sourceArrivalAt, service.finish);
746
+ const state = trackState.find(candidate => candidate.track.sourceIds?.includes(sourceId));
747
+ const index = state?.segments.at(-1)?.index ?? 0;
748
+ const ahead = Math.max(0, (state?.tailEndSeconds ?? tailStart) - position);
749
+ constrain(service.finish - Math.max(0, ahead - reserve), "source", trackState.indexOf(state), index,
750
+ service.finish, ahead, true, service.finish, false);
820
751
  }
821
- }
822
752
 
823
- /**
824
- * The bytes not held, and not brought by the near window, before `byte` of a
825
- * source: `byte` less what the sorted coverage holds below it.
826
- *
827
- * @param {{ coverage: Array<{ start: number, end: number }>, heldBefore: number[] }} bytes
828
- * @param {number} byte
829
- */
830
- function missingBefore(bytes, byte) {
831
- const { coverage, heldBefore } = bytes;
832
- let low = 0, high = coverage.length;
833
- while (low < high) {
834
- const middle = (low + high) >>> 1;
835
- if (coverage[middle].end <= byte) low = middle + 1;
836
- else high = middle;
753
+ let outputBits = 0;
754
+ let latestTrackFinish = 0;
755
+ for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
756
+ const state = trackState[trackIndex];
757
+ const end = state.tailEndSeconds;
758
+ const seconds = Math.max(0, end - tailStart);
759
+ if (!(seconds > 0)) continue;
760
+ const track = state.track;
761
+ const segment = state.segments.at(-1) ?? { index: 0 };
762
+ const remaining = seconds * state.tailWorkFraction;
763
+ const work = Math.max(0, remaining - numericCoverage((track.processingRanges ?? [])
764
+ .filter(range => Number.isFinite(range?.start) && Number.isFinite(range?.end))
765
+ .map(range => ({ start: Math.max(tailStart, range.start), end: Math.min(end, range.end) }))
766
+ .filter(range => range.end > range.start)).reduce((sum, range) => sum + range.end - range.start, 0));
767
+ const processing = state.curve;
768
+ if (work > 0 && !processing) return { reason: "encode-rate-unavailable" };
769
+ const run = runStarts[trackIndex];
770
+ const localStart = tailStart - state.clockSeconds;
771
+ const inRun = run.spans.some(span => localStart >= span.start && localStart < span.end);
772
+ const open = work > 0 && !inRun ? run.openSeconds : 0;
773
+ const finishedAt = work > 0 ? processing.finish(work, trackFinish[trackIndex] + open) : trackFinish[trackIndex];
774
+ latestTrackFinish = Math.max(latestTrackFinish, finishedAt);
775
+ const deliveryStart = Math.max(tailStart, position + buffered);
776
+ outputBits += Math.max(0, state.bitsPerMediaSecond) * Math.max(0, end - deliveryStart);
777
+ const ahead = Math.max(0, end - position);
778
+ constrain(finishedAt - Math.max(0, ahead - reserve), "production", trackIndex, segment.index,
779
+ finishedAt, ahead, sourceArrivalAt > 0, sourceArrivalAt, false);
837
780
  }
838
- const inside = low < coverage.length && coverage[low].start < byte ? byte - coverage[low].start : 0;
839
- return byte - heldBefore[low] - inside;
840
- }
841
781
 
842
- /** The missing bytes of a piece's ranges of one source. */
843
- function missingWithin(bytes, ranges) {
844
- let missing = 0;
845
- for (const range of ranges) missing += missingBefore(bytes, range.end) - missingBefore(bytes, range.start);
846
- return missing;
782
+ if (outputBits > 0) {
783
+ const end = Math.max(...trackState.map(state => state.tailEndSeconds));
784
+ const completedAt = link.finish(outputBits, Math.max(transferFinish, latestTrackFinish));
785
+ const ahead = Math.max(0, end - position);
786
+ constrain(completedAt - Math.max(0, ahead - reserve), "delivery", 0,
787
+ trackState[0]?.segments.at(-1)?.index ?? 0, completedAt, ahead, false, sourceArrivalAt, false);
788
+ }
789
+ return null;
847
790
  }
848
791
 
849
792
  /**
@@ -1125,6 +1068,25 @@ function numericCoverage(ranges) {
1125
1068
  return joined;
1126
1069
  }
1127
1070
 
1071
+ /**
1072
+ * Estimate download work without claiming an address or held byte. The whole
1073
+ * file's measured density, reduced by the held fraction, prices unresolved
1074
+ * intervals and the aggregate suffix.
1075
+ */
1076
+ function estimateUnmappedSourceBytes(source, mediaSeconds, durationSeconds, coverage = null) {
1077
+ const fileLength = Number(source.fileLength);
1078
+ const density = Number(source.bytesPerMediaSecond) > 0 ? Number(source.bytesPerMediaSecond)
1079
+ : fileLength > 0 && durationSeconds > 0 ? fileLength / durationSeconds : 0;
1080
+ if (!(density > 0) || !(mediaSeconds > 0)) return null;
1081
+ const heldRanges = coverage ?? source.residence;
1082
+ if (!(fileLength > 0) || !Array.isArray(heldRanges)) return density * mediaSeconds;
1083
+ const held = numericCoverage(heldRanges.filter(range =>
1084
+ range?.location !== "missing" && Number.isFinite(range?.start) && Number.isFinite(range?.end) &&
1085
+ range.end > range.start));
1086
+ const heldBytes = held.reduce((sum, range) => sum + range.end - range.start, 0);
1087
+ return density * mediaSeconds * Math.max(0, Math.min(1, (fileLength - heldBytes) / fileLength));
1088
+ }
1089
+
1128
1090
  /** Already held bytes have no arrival delay. Index future arrivals once. */
1129
1091
  function indexedArrivals(ranges) {
1130
1092
  let maximumEnd = 0;