@torrent-tv/proxy 2.91.0 → 2.92.1

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.
Files changed (145) hide show
  1. package/bin/cli.js +3 -2
  2. package/package.json +18 -2
  3. package/routes/api/playback-plan/post.js +21 -4
  4. package/routes/api/sources/files/get.js +20 -28
  5. package/routes/api/sources/post.js +4 -2
  6. package/routes/api/sources/stats/get.js +4 -11
  7. package/routes/api/sources/viewer/post.js +16 -0
  8. package/routes/api/sources/warm/post.js +6 -215
  9. package/routes/api/subtitles/get.js +22 -125
  10. package/routes/api/subtitles/post.js +26 -0
  11. package/routes/api/transcode-sessions/post.js +24 -2
  12. package/routes/media/get.js +80 -0
  13. package/routes/stream/get.js +26 -94
  14. package/routes/transcode/audio-warm/get.js +9 -21
  15. package/routes/transcode/variant-warm/get.js +8 -14
  16. package/server.js +410 -46
  17. package/services/encode/AdmittedInput.js +136 -0
  18. package/services/encode/CushionReport.js +1 -76
  19. package/services/encode/EncodeInputs.js +142 -0
  20. package/services/encode/EncodeOrchestrator.js +4 -1
  21. package/services/encode/EncodeRun.js +13 -4
  22. package/services/encode/EncodeRuns.js +132 -52
  23. package/services/encode/InputFailures.js +37 -0
  24. package/services/encode/MatroskaInput.js +158 -0
  25. package/services/encode/OutputOpening.js +16 -64
  26. package/services/encode/Renditions.js +1 -17
  27. package/services/encode/admitted-command.js +60 -0
  28. package/services/encode/annex-b-input.js +37 -0
  29. package/services/encode/bit-payload.js +29 -0
  30. package/services/encode/output/OutputCatalog.js +1 -1
  31. package/services/encode/output/Timeline.js +11 -0
  32. package/services/encode/pcm-normalization.js +30 -0
  33. package/services/encode/piece-completeness.js +54 -1
  34. package/services/encode/quality/HostLoad.js +19 -73
  35. package/services/encode/quality/QualityOffer.js +1 -1
  36. package/services/encode/run-command.js +1 -589
  37. package/services/encode/segment-formats/mp4-boxes.js +3 -1
  38. package/services/encode/segment-formats/presentation-segment.js +114 -0
  39. package/services/media/ContainerOrchestrator.js +118 -48
  40. package/services/media/KeyframeTables.js +16 -115
  41. package/services/media/MediaReadRequests.js +120 -0
  42. package/services/media/ProbeRequests.js +135 -0
  43. package/services/media/SegmentInputs.js +48 -0
  44. package/services/media/SourceFile.js +6 -0
  45. package/services/media/SubtitleOrchestrator.js +190 -4
  46. package/services/media/await-plan.js +47 -0
  47. package/services/media/container/AsfContainer.js +217 -0
  48. package/services/media/container/AviContainer.js +343 -34
  49. package/services/media/container/CadencedPictureOrder.js +45 -0
  50. package/services/media/container/Container.js +7 -3
  51. package/services/media/container/ContainerFactory.js +10 -3
  52. package/services/media/container/FfprobeContainer.js +222 -0
  53. package/services/media/container/MatroskaContainer.js +72 -46
  54. package/services/media/container/Mp4Container.js +472 -139
  55. package/services/media/container/MpegPsContainer.js +281 -0
  56. package/services/media/container/MpegTsContainer.js +277 -0
  57. package/services/media/container/PacketIndex.js +270 -0
  58. package/services/media/container/PacketRecords.js +237 -0
  59. package/services/media/container/RetainedBytes.js +39 -0
  60. package/services/media/container/RetainedReads.js +51 -0
  61. package/services/media/container/ac3-frame.js +46 -0
  62. package/services/media/container/asf-packets.js +153 -0
  63. package/services/media/container/avc-picture-order.js +50 -0
  64. package/services/media/container/avi-open-dml.js +47 -0
  65. package/services/media/container/dts-frame.js +51 -0
  66. package/services/media/container/ebml-stream.js +12 -1
  67. package/services/media/container/ffprobe-record.js +45 -0
  68. package/services/media/container/flv-packet-ranges.js +27 -0
  69. package/services/media/container/following-packet-time.js +37 -0
  70. package/services/media/container/h264-configuration.js +183 -0
  71. package/services/media/container/h264-elementary-index.js +245 -0
  72. package/services/media/container/hevc-configuration.js +165 -0
  73. package/services/media/container/hevc-elementary-index.js +132 -0
  74. package/services/media/container/hevc-picture-order.js +66 -0
  75. package/services/media/container/latm-frame.js +99 -0
  76. package/services/media/container/matroska-packets.js +207 -0
  77. package/services/media/container/memory-unavailable.js +8 -0
  78. package/services/media/container/mp3-packet-dependencies.js +41 -0
  79. package/services/media/container/mp4-fragments.js +148 -0
  80. package/services/media/container/mpeg-audio-frame.js +19 -0
  81. package/services/media/container/mpeg-audio-reservoir.js +27 -0
  82. package/services/media/container/mpeg-elementary-index.js +196 -0
  83. package/services/media/container/mpeg-sequence-reader.js +69 -0
  84. package/services/media/container/mpeg-ts-packets.js +93 -0
  85. package/services/media/container/mpeg-video-configuration.js +50 -0
  86. package/services/media/container/mpeg4-picture-timing.js +108 -0
  87. package/services/media/container/pes-header.js +34 -0
  88. package/services/media/playback-declarations.js +57 -0
  89. package/services/media/playback-planner.js +72 -230
  90. package/services/media/probe-packets.js +66 -0
  91. package/services/media/read-playback-declarations.js +18 -0
  92. package/services/media/tracks/AudioTrack.js +9 -0
  93. package/services/media/tracks/TextSubtitleTrack.js +4 -1
  94. package/services/server/OutputLifecycle.js +2 -0
  95. package/services/server/SegmentServing.js +40 -33
  96. package/services/server/ViewerRequests.js +22 -41
  97. package/services/server/controllers/PlaybackController.js +4 -0
  98. package/services/server/controllers/SubtitleController.js +8 -29
  99. package/services/server/transcode-session-files.js +58 -99
  100. package/services/server/wire-outputs.js +58 -15
  101. package/services/storage/IndexMemory.js +85 -0
  102. package/services/storage/MachineBudget.js +27 -3
  103. package/services/storage/allowance.js +8 -1
  104. package/services/storage/piece-store/piece-disk-store.js +18 -3
  105. package/services/storage/piece-store/piece-lru.js +8 -4
  106. package/services/storage/piece-store/shared-piece-store.js +26 -11
  107. package/services/storage/segment-store/SegmentStore.js +49 -18
  108. package/services/storage/wire.js +7 -0
  109. package/services/torrent/Contents.js +52 -20
  110. package/services/torrent/demand/DemandRegister.js +13 -4
  111. package/services/torrent/demand/Window.js +13 -2
  112. package/services/torrent/demand-progress.js +25 -0
  113. package/services/torrent/download/DeadlineScheduler.js +167 -0
  114. package/services/torrent/download/FutureDownload.js +166 -0
  115. package/services/torrent/download/SwarmSelection.js +30 -59
  116. package/services/torrent/download/registry.js +45 -5
  117. package/services/torrent/download/withdraw-claim.js +2 -0
  118. package/services/torrent/torrent-pool.js +39 -600
  119. package/services/torrent/worker/channel.js +8 -1
  120. package/services/torrent/worker/client.js +74 -72
  121. package/services/torrent/worker/files-in-use.js +9 -0
  122. package/services/torrent/worker/handle-state.js +12 -0
  123. package/services/torrent/worker/held-bytes.js +82 -99
  124. package/services/torrent/worker/held-file-bytes.js +17 -0
  125. package/services/torrent/worker/piece-reader.js +67 -683
  126. package/services/torrent/worker/pool-adapter.js +37 -123
  127. package/services/torrent/worker/protocol.js +6 -17
  128. package/services/torrent/worker/whole-file-stats.js +2 -0
  129. package/services/torrent/worker/worker.js +86 -156
  130. package/services/transport/data-channel-handler.js +56 -13
  131. package/services/viewer/DownloadMaps.js +168 -0
  132. package/services/viewer/NativeSourceMap.js +33 -0
  133. package/services/viewer/PriorityMap.js +49 -40
  134. package/services/viewer/PriorityOrchestrator.js +42 -13
  135. package/services/viewer/SourcePreparation.js +278 -0
  136. package/services/viewer/Viewer.js +11 -0
  137. package/services/viewer/Viewers.js +109 -2
  138. package/services/viewer/playback-readiness.js +50 -63
  139. package/services/viewer/segment-demands.js +37 -0
  140. package/services/viewer/urgent-output-ready.js +26 -0
  141. package/services/encode/run-inputs.js +0 -53
  142. package/services/media/keyframe-probe.js +0 -157
  143. package/services/media/media-info-probe.js +0 -93
  144. package/services/torrent/worker/background-fill.js +0 -213
  145. package/services/torrent/worker/resume-warm.js +0 -107
@@ -28,11 +28,6 @@ const SEGMENT_READ_HIGH_WATER_MARK = 4 * 1024 * 1024;
28
28
  * @param {number} ms
29
29
  * @returns {Promise<void>}
30
30
  */
31
- function delay(ms) {
32
- return new Promise((resolve) => {
33
- setTimeout(resolve, ms);
34
- });
35
- }
36
31
  /**
37
32
  * Guard against path traversal by restricting file names to the known
38
33
  * playlist and segment patterns produced by ffmpeg. Which segment names are
@@ -83,6 +78,18 @@ export class SegmentServing {
83
78
  state.waitListeners.clear();
84
79
  }
85
80
 
81
+ /** Observe storage and lifecycle before a request checks file availability. */
82
+ subscribeFileChange(sessionId) {
83
+ const output = isOutputName(sessionId) ? this.#host.outputs.get(sessionId) : null;
84
+ if (!output) return { changed: Promise.resolve(), release: () => {} };
85
+ const state = this.#stateFor(output);
86
+ let wake;
87
+ const changed = new Promise((resolve) => { wake = resolve; });
88
+ state.waitListeners.add(wake);
89
+ const stop = this.#host.segmentStore.subscribeChanges(output.outputKey ?? "", wake);
90
+ return { changed, release: () => { state.waitListeners.delete(wake); stop(); } };
91
+ }
92
+
86
93
  /**
87
94
  * Open a read stream for an HLS segment or playlist file from a session.
88
95
  *
@@ -295,6 +302,14 @@ export class SegmentServing {
295
302
  if (!isPlaylist && session.segmentFormat.needsSegmentRewrite) {
296
303
  const index = session.segmentFormat.segmentIndexFromName(fileName);
297
304
  const raw = await readFile(filePath);
305
+ // Players may fetch the init and first media segment concurrently.
306
+ // Both responses must use the same retained init before rewriting.
307
+ if (!this.#host.segmentStore.initOf(session.outputKey ?? "")) {
308
+ const init = cutsAtGivenTimes(session)
309
+ ? await this.#initFromFirstSegment(session)
310
+ : await readFile(path.join(this.#host.segmentStore.pathFor(session.outputKey ?? ""), session.segmentFormat.initFileName));
311
+ if (!this.#host.segmentStore.keepInit(session.outputKey ?? "", init)) return this.#failedOrWarming(session);
312
+ }
298
313
  // Self-contained pieces carry the init header; a media segment must not.
299
314
  const bytes = cutsAtGivenTimes(session) && session.segmentFormat.stripInit
300
315
  ? session.segmentFormat.stripInit(raw)
@@ -615,6 +630,16 @@ export class SegmentServing {
615
630
  }
616
631
 
617
632
  #holdForProduction(session, fileName, isPlaylist, options) {
633
+ let ranked = null;
634
+ if (!isPlaylist) {
635
+ const index = session.segmentFormat.segmentIndexFromName(fileName);
636
+ const address = session.outputKey ?? "";
637
+ const { rank, topRank } = this.#host.encodeOrchestrator.rankAt(address, index);
638
+ ranked = { address, rank, topRank };
639
+ if (Number.isFinite(index) && topRank > 0 && rank === 0) {
640
+ return { kind: "superseded", ranked };
641
+ }
642
+ }
618
643
  // The file is not there and production has ended in an error: nothing is
619
644
  // going to write it, so the viewer is told instead of held.
620
645
  if (this.#host.encodeRuns.hasFailed(session)) {
@@ -625,10 +650,8 @@ export class SegmentServing {
625
650
  // the truthful answer: nothing is broken and there is nothing for the
626
651
  // viewer to retry. Only what is NOT on disk is held for it — a piece that
627
652
  // exists is whole, and it is served whatever the encoder is doing.
628
- return { kind: "warming-up" };
653
+ return { kind: "warming-up", ranked };
629
654
  }
630
- /** @type {{ address: string, rank: number, topRank: number } | null} */
631
- let ranked = null;
632
655
  if (!isPlaylist) {
633
656
  this.#explainHold(session, fileName, "the file is not on disk");
634
657
  }
@@ -654,22 +677,6 @@ export class SegmentServing {
654
677
  // discarded at the one point where a viewer measurably waits for a named
655
678
  // segment; it goes back out with the answer, because the wait is measured
656
679
  // by whoever holds the request.
657
- const address = session.outputKey ?? "";
658
- const { rank, topRank } = this.#host.encodeOrchestrator.rankAt(address, requestedIndex);
659
- ranked = { address, rank, topRank };
660
- const nobodyIsComing = topRank > 0 && rank === 0;
661
- if (
662
- Number.isFinite(requestedIndex) &&
663
- requestedIndex < (this.#host.encodeRuns.earliestStartOf(session) ?? 0) &&
664
- nobodyIsComing &&
665
- this.#host.encodeRuns.liveRunsOf(session).length > 0
666
- ) {
667
- logger.info(
668
- `transcode ${session.id} segment #${requestedIndex} is ${(this.#host.encodeRuns.earliestStartOf(session) ?? 0) - requestedIndex} ` +
669
- "segments behind the run and in nobody's zone; answered as absent rather than held"
670
- );
671
- return { kind: "not-found", ranked };
672
- }
673
680
  this.#noteWanted(session, requestedIndex);
674
681
  }
675
682
  return { kind: "warming-up", ranked };
@@ -996,9 +1003,7 @@ export class SegmentServing {
996
1003
  }
997
1004
 
998
1005
  /**
999
- * Poll until the HLS playlist file exists and contains a valid `#EXTM3U`
1000
- * header, or until the session fails, or until the startup timeout elapses.
1001
- * Throws with message `"HLS playlist is still warming up."` on timeout.
1006
+ * Wait for a valid HLS playlist or a terminal output state, without polling.
1002
1007
  *
1003
1008
  * @param {HlsSession} session
1004
1009
  * @returns {Promise<void>}
@@ -1016,9 +1021,9 @@ export class SegmentServing {
1016
1021
  }
1017
1022
 
1018
1023
  const playlistPath = path.join(this.#host.segmentStore.pathFor(session.outputKey ?? ""), PLAYLIST_FILE_NAME);
1019
- const deadline = Date.now() + this.#host.startupWaitMs;
1020
-
1021
- while (Date.now() < deadline) {
1024
+ while (this.#host.outputs.get(session.id) === session) {
1025
+ const change = this.subscribeFileChange(session.id);
1026
+ try {
1022
1027
  if (this.#host.encodeRuns.hasFailed(session)) {
1023
1028
  throw new Error(this.#host.encodeRuns.lastErrorOf(session) || "ffmpeg failed to start HLS session.");
1024
1029
  }
@@ -1031,10 +1036,12 @@ export class SegmentServing {
1031
1036
  } catch (_error) {
1032
1037
  // Playlist is not ready yet.
1033
1038
  }
1034
- await delay(250);
1039
+ await change.changed;
1040
+ } finally {
1041
+ change.release();
1042
+ }
1035
1043
  }
1036
-
1037
- throw new Error("HLS playlist is still warming up.");
1044
+ throw new Error("HLS output was disposed before its playlist became available.");
1038
1045
  }
1039
1046
 
1040
1047
  /**
@@ -71,6 +71,7 @@ export class ViewerRequests {
71
71
  * @returns {Promise<import("../encode/output/EncodedOutput.js").EncodedOutput>}
72
72
  */
73
73
  async createOrGetSession({ consumerId = "", ...request }) {
74
+ request.signal?.throwIfAborted();
74
75
  const requestStartedAt = Date.now();
75
76
  // What this viewer's link last measured, where the request does not say:
76
77
  // a viewer who comes back to open another film has a link this proxy has
@@ -90,6 +91,7 @@ export class ViewerRequests {
90
91
  // state nothing has every encoder on it stopped.
91
92
  const claim = consumerId
92
93
  ? (candidate) => {
94
+ request.signal?.throwIfAborted();
93
95
  alreadyOn = this.#host.viewers.forOutput(candidate).has(consumerId);
94
96
  const answer = alreadyOn
95
97
  ? { admitted: true, reason: "already watching it", speedX: null }
@@ -105,6 +107,7 @@ export class ViewerRequests {
105
107
  viewerLinkMbps,
106
108
  claim
107
109
  });
110
+ request.signal?.throwIfAborted();
108
111
  if (consumerId) {
109
112
  const joined = existed && !alreadyOn;
110
113
  // What THIS viewer wants of the sound, which an output they are joining
@@ -558,6 +561,7 @@ export class ViewerRequests {
558
561
  }
559
562
 
560
563
  const sourceMeasurements = new Map();
564
+ const sourceForecasts = new Map();
561
565
  const tracks = [];
562
566
  for (const output of outputs) {
563
567
  const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
@@ -580,6 +584,7 @@ export class ViewerRequests {
580
584
  index,
581
585
  startSeconds: timeline.publishedStartOf(index),
582
586
  endSeconds: timeline.publishedStartOf(index + 1),
587
+ sourceInputs: this.#host.sourceInputsFor?.(output, index) ?? undefined,
583
588
  mediaRanges: ranges && output.segmentFormat?.servedMediaRanges ?
584
589
  output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined
585
590
  };
@@ -596,34 +601,25 @@ export class ViewerRequests {
596
601
  if (sourceMeasurements.has(sourceId)) {
597
602
  continue;
598
603
  }
599
- const sourceFile = fileIndex === output.file.fileIndex
600
- ? output.file
601
- : this.#host.sourceFiles.get(output.file.sourceKey, fileIndex);
602
604
  let stats = null;
603
605
  try {
604
- stats = await this.#host.getSourceStats?.(output.file.sourceKey, fileIndex) ?? null;
606
+ stats = await this.#host.getSourceStats?.(output.file.sourceKey, fileIndex,
607
+ { forecast: !sourceForecasts.has(output.file.sourceKey) }) ?? null;
605
608
  } catch {
606
609
  stats = null;
607
610
  }
608
- const sourceTrend = this.#downloadRateReadings(
609
- measurement,
610
- output.file.sourceKey,
611
- stats?.downloadSpeed,
612
- now
613
- );
614
- const fileLength = Number(stats?.fileLength);
615
- const downloaded = Number(stats?.fileDownloaded);
616
- const sourceDuration = Number(sourceFile?.durationSeconds);
611
+ if (!sourceForecasts.has(output.file.sourceKey)) sourceForecasts.set(output.file.sourceKey, stats?.downloadForecast ?? { ranges: [] });
612
+ const sharedForecast = sourceForecasts.get(output.file.sourceKey);
613
+ const downloadForecast = sharedForecast.global ? { measuredAt: sharedForecast.measuredAt,
614
+ ranges: !stats ? [] : sharedForecast.ranges.map(range => ({ ...range,
615
+ start: Math.max(0, range.start - stats.fileOffset),
616
+ end: Math.min(stats.fileLength, range.end - stats.fileOffset)
617
+ })).filter(range => range.end > range.start) } : sharedForecast;
617
618
  sourceMeasurements.set(sourceId, {
618
619
  id: sourceId,
619
- serviceId: output.file.sourceKey,
620
- complete: Number.isFinite(fileLength) && fileLength > 0 && downloaded >= fileLength,
621
- bytesPerMediaSecond: Number.isFinite(fileLength) && fileLength > 0 &&
622
- Number.isFinite(sourceDuration) && sourceDuration > 0
623
- ? fileLength / sourceDuration
624
- : 0,
625
- readings: sourceTrend,
620
+ complete: stats?.fileAvailable === true,
626
621
  residence: stats?.residence,
622
+ downloadForecast,
627
623
  });
628
624
  }
629
625
 
@@ -661,12 +657,6 @@ export class ViewerRequests {
661
657
  measurement.tracks.delete(id);
662
658
  }
663
659
  }
664
- const activeDownloadServices = new Set([...sourceMeasurements.values()].map(({ serviceId }) => serviceId));
665
- for (const id of measurement.downloads.keys()) {
666
- if (!activeDownloadServices.has(id)) {
667
- measurement.downloads.delete(id);
668
- }
669
- }
670
660
  const bufferedAheadSeconds = Number(viewerReading?.bufferedAheadSec);
671
661
  const positionSeconds = Number.isFinite(viewerReading?.positionSeconds)
672
662
  ? viewerReading.positionSeconds
@@ -727,7 +717,11 @@ export class ViewerRequests {
727
717
  bufferLimitSeconds: input.bufferLimitSeconds,
728
718
  lookaheadSeconds: input.lookaheadSeconds,
729
719
  link: input.linkReadings,
730
- sources: input.sources,
720
+ sources: input.sources.map(({ id, complete, residence, downloadForecast }) => ({
721
+ id, complete, residenceRanges: residence?.length ?? 0,
722
+ forecastAt: downloadForecast?.measuredAt ?? null, plannedPieces: downloadForecast?.ranges.length ?? 0,
723
+ unknownPieces: downloadForecast?.ranges.filter(range => range.availableAt === null).length ?? 0
724
+ })),
731
725
  tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
732
726
  id, processedSeconds, readings, segments: segments.length, prepared: readySegmentIndices.length
733
727
  }))
@@ -745,25 +739,12 @@ export class ViewerRequests {
745
739
  const id = typeof consumerId === "string" ? consumerId : "";
746
740
  let state = byViewer.get(id);
747
741
  if (!state) {
748
- state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), downloads: new Map(), tracks: new Map() };
742
+ state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), tracks: new Map() };
749
743
  byViewer.set(id, state);
750
744
  }
751
745
  return state;
752
746
  }
753
747
 
754
- #downloadRateReadings(state, serviceId, downloadSpeed, now) {
755
- let trend = state.downloads.get(serviceId);
756
- if (!trend) {
757
- trend = new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds);
758
- state.downloads.set(serviceId, trend);
759
- }
760
- const speed = Number(downloadSpeed);
761
- if (Number.isFinite(speed) && speed >= 0) {
762
- trend.add(now, speed);
763
- }
764
- return trend.snapshot();
765
- }
766
-
767
748
  #trackRateReadings(state, output, now) {
768
749
  const id = output.outputKey;
769
750
  let trend = state.tracks.get(id);
@@ -34,6 +34,10 @@ export class PlaybackController {
34
34
  return this.playbackPlanner.getPlan(params);
35
35
  }
36
36
 
37
+ async getReadyPlan(params, options) {
38
+ return this.playbackPlanner.getReadyPlan(params, options);
39
+ }
40
+
37
41
  async refreshAudioTracks(params) {
38
42
  return this.playbackPlanner.refreshAudioTracks(params);
39
43
  }
@@ -7,23 +7,6 @@
7
7
  * language detection) stays in orchestrator/domain.
8
8
  */
9
9
 
10
- const EXTERNAL_MAX_BYTES = 8 * 1024 * 1024;
11
-
12
- function readFileFully(file, maxBytes) {
13
- return new Promise((resolve, reject) => {
14
- const stream = file.createReadStream();
15
- const chunks = [];
16
- let total = 0;
17
- stream.on("data", (chunk) => {
18
- total += chunk.length;
19
- if (total > maxBytes) { stream.destroy(); reject(new Error("subtitle file exceeds size cap")); return; }
20
- chunks.push(chunk);
21
- });
22
- stream.on("end", () => resolve(Buffer.concat(chunks)));
23
- stream.on("error", reject);
24
- });
25
- }
26
-
27
10
  export class SubtitleController {
28
11
  /**
29
12
  * @param {object} deps
@@ -45,25 +28,19 @@ export class SubtitleController {
45
28
  * Serve external subtitle file or embedded track.
46
29
  * Returns { vtt, language, headers } or { error, status }.
47
30
  */
48
- async getSubtitle({ sourceKey, fileIndex, trackIndex, since, after }) {
31
+ async getSubtitle({ sourceKey, fileIndex, trackIndex, since, after, signal }) {
49
32
  const rec = this.sourceRegistry.get(sourceKey);
50
33
  if (!rec) return { error: "Source key was not found.", status: 404 };
51
- const torrent = await this.torrentPool.getTorrent(rec.sourceType, rec.source);
34
+ const torrent = this.torrentPool.knownTorrent(sourceKey);
35
+ if (!torrent) return { pending: true, status: 202 };
52
36
  const file = torrent.files[fileIndex];
53
37
  if (!file) return { error: "File index was not found in torrent.", status: 404 };
54
38
 
55
39
  const hasTrack = trackIndex !== undefined && trackIndex !== "" && Number.isFinite(Number(trackIndex));
56
40
  if (!hasTrack) {
57
- const name = file.name ?? "";
58
- const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
59
- try {
60
- const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
61
- const converted = this.orchestrator.fileAsVtt(bytes, ext);
62
- if (!converted) return { error: `Unsupported subtitle format: ${ext}`, status: 422 };
63
- return { ...converted, headers: {} };
64
- } catch (e) {
65
- return { error: `Could not read subtitle file: ${e?.message ?? e}`, status: 502 };
66
- }
41
+ const document = await this.orchestrator.waitForDocument(sourceKey, fileIndex, signal);
42
+ if (document.kind === "terminal") return { error: document.message ?? document.reason, status: 422 };
43
+ return { ...document.value, headers: {} };
67
44
  }
68
45
 
69
46
  const idx = Number(trackIndex);
@@ -72,6 +49,8 @@ export class SubtitleController {
72
49
  // Resolve via orchestrator (domain: cluster walk or MP4 sample ranges)
73
50
  const tracks = await this.orchestrator.getTracks(torrent, fileIndex, sourceKey);
74
51
  const track = Array.isArray(tracks) ? tracks.find((c) => c.declaredIndex === idx) ?? null : null;
52
+ if (!track && this.orchestrator.hasTrackDeclaration(sourceKey, fileIndex)) return { error: "Subtitle track index was not found.", status: 422 };
53
+ if (track?.isTextBased?.() === false) return { error: "The subtitle track is not supported.", status: 422 };
75
54
  // Also try domain's declaredIndex-agnostic lookup via getCues path — keep compat with existing subtitle-cues declaredIndex
76
55
  let held = null;
77
56
  try {
@@ -1,7 +1,6 @@
1
1
  import { logger } from "../../utils/logger.js";
2
2
  import { bandOf, waits } from "./WaitLedger.js";
3
3
 
4
- const HOLD_HEADER = "x-hold-ms";
5
4
  /**
6
5
  * Which viewer is asking, or an empty string where the transport names nobody.
7
6
  *
@@ -57,8 +56,7 @@ export function refusedAsStale(req, reply, viewerRequests, fileName) {
57
56
  `[hold] ${fileName} refused: made in generation ${stated} of ${consumerId}, ` +
58
57
  `a viewing they have left and whose window for new requests has passed`
59
58
  );
60
- reply.header("Retry-After", "0");
61
- reply.code(503).send({ error: "Made for a viewing that has been left." });
59
+ reply.code(409).send({ error: "Made for a viewing that has been left.", reason: "request-obsolete", canRetry: false });
62
60
  return true;
63
61
  }
64
62
 
@@ -83,22 +81,8 @@ export async function serveSessionFile(req, reply, { serving, viewerRequests, se
83
81
  // a plain HTTP transport, where no loader of ours builds the URL, and then
84
82
  // the single shared position decides as it always did.
85
83
  const consumerId = consumerOf(req);
86
- // Hold the request only briefly, then answer "retry" instead of waiting for
87
- // the segment. iOS's native HLS player (AVPlayer) enforces a hard ~3.5 s
88
- // deadline on RESPONSE HEADERS and raises -12889 ("No response for media
89
- // file") when it passes — it then cancels in-flight requests, probes
90
- // neighbouring positions and can restart the stream from the beginning. That
91
- // is exactly the post-seek "player thrashing" seen in the field, because a
92
- // seek restarts ffmpeg and the first segment then takes far longer than 3.5 s
93
- // to appear. Holding the connection for 30 s (as this did) guaranteed the
94
- // timeout on every seek. A short hold keeps the fast path intact (a ready or
95
- // nearly-ready segment is still served on the first request) while a slow one
96
- // gets a prompt retryable answer, which resets the player's own deadline.
97
- // hls.js is unaffected: it consumes the 503 through its retry policy, whose
98
- // budget the client widens to match (see hls-player.js fragLoadPolicy).
99
- // Instrumented wait. `clientAborted` flips when the player drops the
100
- // connection while we are still holding it — the single most informative
101
- // signal about its real patience, and observable only from this side.
84
+ // Readiness or a terminal state ends the wait. A caller leaving cancels it;
85
+ // elapsed time is not evidence that media preparation failed.
102
86
  const holdStartedAt = Date.now();
103
87
  let clientAborted = false;
104
88
  let onClientAbort = () => {};
@@ -108,17 +92,26 @@ export async function serveSessionFile(req, reply, { serving, viewerRequests, se
108
92
  resolve();
109
93
  };
110
94
  });
111
- req.raw.on("close", onClientAbort);
112
- const statedHoldMs = Number(req.headers?.[HOLD_HEADER]);
95
+ const onResponseClose = () => { if (!reply.raw?.writableEnded) onClientAbort(); };
96
+ req.raw.on("aborted", onClientAbort);
97
+ reply.raw?.on("close", onResponseClose);
113
98
 
114
- const result = await waitForSessionFile(serving, sessionId, fileName, {
115
- holdMs: Number.isFinite(statedHoldMs) && statedHoldMs > 0 ? statedHoldMs : Number.POSITIVE_INFINITY,
116
- until: clientGone,
117
- consumerId
118
- });
119
-
120
- req.raw.off("close", onClientAbort);
99
+ let result;
100
+ try {
101
+ result = await waitForSessionFile(serving, sessionId, fileName, {
102
+ until: clientGone,
103
+ consumerId
104
+ });
105
+ } finally {
106
+ req.raw.off("aborted", onClientAbort);
107
+ reply.raw?.off("close", onResponseClose);
108
+ }
121
109
  const heldMs = Date.now() - holdStartedAt;
110
+ if (clientAborted || result.kind === "cancelled") {
111
+ logger.info(`[hold] request=${req.id ?? "unknown"} ${fileName} cancelled after ${heldMs}ms`);
112
+ result.stream?.destroy?.();
113
+ return;
114
+ }
122
115
  if (result.isPlaylist !== true) {
123
116
  const outcome = clientAborted
124
117
  ? "client-aborted"
@@ -156,36 +149,10 @@ export async function serveSessionFile(req, reply, { serving, viewerRequests, se
156
149
  `[hold] ${fileName} refused: the viewer is at ` +
157
150
  `${viewerRequests.viewerPositionOf(sessionId, consumerId).toFixed(1)}s and this is not the segment there`
158
151
  );
159
- reply.header("Retry-After", "0");
160
- return reply.code(503).send({ error: "Superseded by a seek." });
161
- }
162
- if (result.kind === "warming-up") {
163
- // The segment is still being produced (e.g. just after a seek-restart).
164
- // Return a retryable 503 — never 202, which hls.js cannot consume as a
165
- // media segment — so the player retries the fetch shortly.
166
- reply.header("Retry-After", "1");
167
- // `Retry-After` tells the player to re-request THIS segment after a short
168
- // pause. Without it a bare 503 reads as "nothing here", and the player goes
169
- // looking elsewhere: because our synthetic VOD playlist lists every segment
170
- // of the file, it believes they all exist and SCANS them (field log: one
171
- // user seek produced probes at #617, #717, #732…). That scan is what used
172
- // to steer the encoder off the real target. Whether iOS's native player
173
- // honours the hint is not guaranteed — its behaviour is closed — but this
174
- // is the standard, correct way to say "wait, don't look elsewhere", and
175
- // hls.js already retries the same fragment regardless.
176
- reply.header("Retry-After", "1");
177
- // SAY WHY, to the viewer and not only to the log. The same refusal is
178
- // written into `proxy.log` with the rank the priority map gave this
179
- // segment; the page had no access to that and told the viewer it did not
180
- // know why. Field 2026-09-14: `rank 1 of 100` — nothing was making it —
181
- // printed sixty seconds before the page gave up saying the opposite.
182
- return reply.code(503).send({
183
- error: "Transcode segment is still being produced.",
184
- reason: warmingReason(result.ranked ?? null)
185
- });
152
+ return reply.code(409).send({ error: "Superseded by a seek.", reason: "request-obsolete", canRetry: false });
186
153
  }
187
154
  if (result.kind === "failed") {
188
- return reply.code(500).send({ error: result.message });
155
+ return reply.code(500).send({ error: result.message, canRetry: false });
189
156
  }
190
157
 
191
158
  if (result.isPlaylist) {
@@ -230,8 +197,7 @@ function holdWhileSending(reply, stream, release) {
230
197
  /**
231
198
  * Ask `serving.getFileStream()` again each time the file may have become
232
199
  * available — a segment's publication, or a waited-on invalidation — until it
233
- * is, the session fails, the requester's stated wait runs out, or the
234
- * requester goes.
200
+ * is, the session fails, the request becomes obsolete, or the requester goes.
235
201
  *
236
202
  * @param {object} serving - `services/server/SegmentServing.js`
237
203
  * @param {string} sessionId
@@ -246,8 +212,29 @@ function holdWhileSending(reply, stream, release) {
246
212
  * viewers, and the seek epoch belongs to all of them.
247
213
  * @returns {Promise<Awaited<ReturnType<import("./SegmentServing.js").SegmentServing["getFileStream"]>>>}
248
214
  */
249
- export async function waitForSessionFile(serving, sessionId, fileName, { holdMs, until = null, consumerId = "" }) {
250
- const startedAt = Date.now();
215
+ export async function waitForRequestedFile(req, reply, serving, sessionId, fileName, consumerId) {
216
+ let cancel;
217
+ const until = new Promise(resolve => { cancel = resolve; });
218
+ let gone = false;
219
+ const aborted = () => { gone = true; cancel(); };
220
+ const closed = () => { if (!reply.raw?.writableEnded) aborted(); };
221
+ req.raw?.on?.("aborted", aborted);
222
+ reply.raw?.on?.("close", closed);
223
+ if (req.raw?.aborted || reply.raw?.destroyed) aborted();
224
+ try {
225
+ const result = await waitForSessionFile(serving, sessionId, fileName, { until, consumerId });
226
+ if (gone) {
227
+ result.stream?.destroy?.();
228
+ return { kind: "cancelled" };
229
+ }
230
+ return result;
231
+ } finally {
232
+ req.raw?.off?.("aborted", aborted);
233
+ reply.raw?.off?.("close", closed);
234
+ }
235
+ }
236
+
237
+ export async function waitForSessionFile(serving, sessionId, fileName, { until = null, consumerId = "" } = {}) {
251
238
  let gone = false;
252
239
  const requesterGone = until ? until.then(() => { gone = true; }) : new Promise(() => {});
253
240
  // The viewer's position when this request was made. A seek makes every held
@@ -259,8 +246,14 @@ export async function waitForSessionFile(serving, sessionId, fileName, { holdMs,
259
246
  let seekEpoch = serving.seekEpoch(sessionId);
260
247
  /** @type {{ address: string, rank: number, topRank: number } | null} */
261
248
  let lastRanked = null;
262
- while (!gone && Date.now() - startedAt < holdMs) {
249
+ while (!gone) {
250
+ const change = serving.subscribeFileChange(sessionId);
251
+ try {
263
252
  const result = await serving.getFileStream(sessionId, fileName, { consumerId });
253
+ if (gone) {
254
+ result.stream?.destroy?.();
255
+ return { kind: "cancelled" };
256
+ }
264
257
  if (result.kind !== "warming-up") {
265
258
  return result;
266
259
  }
@@ -282,44 +275,10 @@ export async function waitForSessionFile(serving, sessionId, fileName, { holdMs,
282
275
  // patience is still counted against what it was promised. Kept across the
283
276
  // polls because the timeout path has no result of its own.
284
277
  lastRanked = result.ranked ?? lastRanked;
285
- const remaining = Math.max(0, holdMs - (Date.now() - startedAt));
286
- const waitedForSegment = await serving.waitForSegment(sessionId, fileName, remaining, until);
287
- if (!waitedForSegment && !gone) {
288
- // Playlists and init files are not segment publications.
289
- await Promise.race([delay(Math.min(300, remaining)), requesterGone]);
278
+ await Promise.race([change.changed, requesterGone]);
279
+ } finally {
280
+ change.release();
290
281
  }
291
282
  }
292
- return { kind: "warming-up", ranked: lastRanked };
293
- }
294
-
295
- /**
296
- * Resolve after a given number of milliseconds.
297
- *
298
- * @param {number} ms
299
- * @returns {Promise<void>}
300
- */
301
- function delay(ms) {
302
- return new Promise((resolve) => {
303
- setTimeout(resolve, ms);
304
- });
305
- }
306
-
307
- /**
308
- * What to tell the VIEWER when a segment is not there yet.
309
- *
310
- * In their terms — the film, and what is being done about it — never in ours: a
311
- * viewer cannot read a log, does not know what a rank is, and is not helped by
312
- * being told which of our parts is waiting for which other.
313
- *
314
- * @param {{ rank: number, topRank: number } | null} ranked - What the priority
315
- * map thought of this segment while the request was held.
316
- * @returns {string}
317
- */
318
- function warmingReason(ranked) {
319
- if (ranked && ranked.rank < ranked.topRank) {
320
- // The map is working somewhere else in the film. That is the answer the
321
- // failure of 2026-09-14 needed and nobody was given.
322
- return "This part of the film is not being prepared yet — the proxy is working further along.";
323
- }
324
- return "This part of the film is still being prepared.";
283
+ return { kind: "cancelled", ranked: lastRanked };
325
284
  }