@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
@@ -2,45 +2,19 @@
2
2
  * @file Playback planner service.
3
3
  *
4
4
  * Determines whether a torrent file can be served directly or requires
5
- * HLS audio transcoding by probing the stream codecs with ffmpeg.
5
+ * HLS audio transcoding from the file's shared container declarations.
6
6
  * Results are cached indefinitely (keyed by source + file index).
7
7
  */
8
8
 
9
- import { spawn } from "node:child_process";
10
9
  import { logger } from "../../utils/logger.js";
11
10
  import { Container } from "./container/Container.js";
12
11
  import { buildAudioInventory, enrichAudioInventoryFromSidecar } from "./audio-inventory.js";
13
- import {
14
- parseFfmpegDurationSeconds,
15
- parseFfmpegStartTimeSeconds,
16
- parseFfmpegBitDepth,
17
- parseFfmpegBitrateKbps,
18
- parseFfmpegVideoFps,
19
- parseFfmpegHdr,
20
- parseFfmpegStreamCounts
21
- } from "./ffmpeg-banner.js";
12
+ import { waitForPlan } from "./await-plan.js";
13
+ import { playbackDeclarations } from "./playback-declarations.js";
22
14
 
23
15
  /** Audio codecs that browsers can decode natively without transcoding. */
24
16
  const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
25
17
 
26
- // Once the plan probe succeeds, warm the START of the file body so the
27
- // transcode session's ffmpeg reads hit downloaded data instead of paying
28
- // piece latency at encode time (the edge prefetch only covers head+tail for
29
- // the codec probe). ~16 MB ≈ the first segments of typical media.
30
- const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
31
-
32
- /**
33
- * How long the plan waits for a file's own header before offering its
34
- * soundtrack without what that header would have said.
35
- *
36
- * Not a measurement, and nothing is derived from it: it is the point past which
37
- * holding the viewer costs more than the language and flags being waited for —
38
- * which the folder name supplies anyway, from the torrent's file list, at no
39
- * cost. The read continues asynchronously after this response and is shared
40
- * with the refresh request, so a later answer can use the same container read.
41
- */
42
- const SIDECAR_HEADER_WAIT_MS = 3_000;
43
-
44
18
  /** Subtitle codecs that can be converted to WebVTT (text-based). */
45
19
  const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
46
20
 
@@ -177,81 +151,6 @@ export function parseStreamCodecs(ffmpegOutput) {
177
151
  };
178
152
  }
179
153
 
180
- /**
181
- * Run a brief ffmpeg probe to identify the audio and video codecs of a stream.
182
- * Times out after `timeoutMs` and returns empty strings on failure.
183
- *
184
- * @param {object} options
185
- * @param {string} options.ffmpegBin
186
- * @param {string} options.inputUrl
187
- * @param {string} [options.userAgent=""]
188
- * @param {number} [options.timeoutMs=8000]
189
- * @returns {Promise<{ audioCodec: string, videoCodec: string, container: string, durationSeconds: number, videoWidth: number, videoHeight: number, audioTracks: object[], subtitleTracks: object[], stderr: string }>}
190
- * Parsed banner fields plus the raw `stderr`, so the caller can derive the
191
- * full media info (fps/startTime/HDR) without a second ffmpeg scan.
192
- */
193
- function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
194
- return new Promise((resolve) => {
195
- const args = ["-hide_banner", "-loglevel", "info"];
196
- if (typeof userAgent === "string" && userAgent.trim().length > 0) {
197
- args.push("-user_agent", userAgent.trim());
198
- }
199
- // Decode a tiny slice of all streams (no per-stream -map, so video-only
200
- // files probe correctly too). The ffmpeg banner that precedes decoding
201
- // gives us audio/video codecs, the container format and the duration in a
202
- // single pass.
203
- args.push("-i", inputUrl, "-t", "0.1", "-f", "null", "-");
204
-
205
- const ffmpeg = spawn(ffmpegBin, args, {
206
- stdio: ["ignore", "ignore", "pipe"],
207
- windowsHide: true
208
- });
209
- let stderr = "";
210
- let settled = false;
211
-
212
- const finish = (codecs) => {
213
- if (settled) {
214
- return;
215
- }
216
- settled = true;
217
- resolve(codecs);
218
- };
219
-
220
- const timeoutId = setTimeout(() => {
221
- if (!ffmpeg.killed) {
222
- ffmpeg.kill("SIGTERM");
223
- }
224
- finish({ ...parseStreamCodecs(stderr), stderr });
225
- }, timeoutMs);
226
-
227
- ffmpeg.stderr.on("data", (chunk) => {
228
- stderr += String(chunk);
229
- });
230
-
231
- ffmpeg.on("error", () => {
232
- clearTimeout(timeoutId);
233
- finish({ audioCodec: "", videoCodec: "", stderr: "" });
234
- });
235
-
236
- ffmpeg.on("exit", () => {
237
- clearTimeout(timeoutId);
238
- finish({ ...parseStreamCodecs(stderr), stderr });
239
- });
240
- });
241
- }
242
-
243
- /**
244
- * Resolve after a given number of milliseconds.
245
- *
246
- * @param {number} ms
247
- * @returns {Promise<void>}
248
- */
249
- function delay(ms) {
250
- return new Promise((resolve) => {
251
- setTimeout(resolve, ms);
252
- });
253
- }
254
-
255
154
  /**
256
155
  * Build the direct stream URL for a source file served by the local proxy.
257
156
  *
@@ -278,12 +177,11 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
278
177
  * @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
279
178
  * @property {number} videoWidth - Source coded width (0 if unknown).
280
179
  * @property {number} videoHeight - Source coded height (0 if unknown).
281
- * @property {boolean} audioTracksPending - At least one sidecar header timed out and can be refreshed later.
180
+ * @property {boolean} audioTracksPending - At least one sidecar declaration still needs source bytes.
282
181
  */
283
182
 
284
183
  /**
285
184
  * @typedef {Object} PlaybackPlannerOptions
286
- * @property {string} ffmpegBin
287
185
  * @property {boolean} transcodeAudioEnabled
288
186
  * @property {string} localBaseUrl
289
187
  * @property {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} sourceRegistry
@@ -298,7 +196,6 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
298
196
  * @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan>, refreshAudioTracks: (params: { sourceKey: string, fileIndex: number }) => Promise<{ audioTracks: object[], pending: boolean }> }}
299
197
  */
300
198
  export function createPlaybackPlanner({
301
- ffmpegBin,
302
199
  transcodeAudioEnabled,
303
200
  localBaseUrl,
304
201
  sourceRegistry,
@@ -320,6 +217,8 @@ export function createPlaybackPlanner({
320
217
  // happened in the torrent thread and came back over the channel. It happens
321
218
  // on this thread now, and what is passed here is the ordinary container read.
322
219
  declaredTracksOf = null,
220
+ readDeclarations = null,
221
+ subscribeSource = null,
323
222
  // Optional. Called once the file's edges are downloaded, so the keyframe
324
223
  // index — which reads the same tail of the file — is fetched alongside the
325
224
  // codec probe instead of after it. It goes straight to `KeyframeTables`,
@@ -333,6 +232,7 @@ export function createPlaybackPlanner({
333
232
  }) {
334
233
  /** @type {Map<string, PlaybackPlan>} */
335
234
  const cache = new Map();
235
+ const lifetimes = new Map();
336
236
  const pendingSidecarHeaders = new Map();
337
237
  const declaredAudioReads = new Map();
338
238
  /**
@@ -461,22 +361,8 @@ export function createPlaybackPlanner({
461
361
  () => { if (declaredAudioReads.get(readKey) === readPromise) declaredAudioReads.delete(readKey); }
462
362
  );
463
363
  }
464
- let timer = null;
465
364
  try {
466
- return await Promise.race([
467
- readPromise,
468
- new Promise((resolve) => {
469
- timer = setTimeout(() => resolve(null), SIDECAR_HEADER_WAIT_MS);
470
- timer.unref?.();
471
- })
472
- ]).then((tracks) => {
473
- if (tracks === null) {
474
- logger.info(
475
- `audio tracks: "${label}" did not answer within ${SIDECAR_HEADER_WAIT_MS / 1000}s — ` +
476
- "offered without what its header would say"
477
- );
478
- return { tracks: [], complete: false, timedOut: true };
479
- }
365
+ return await readPromise.then((tracks) => {
480
366
  return {
481
367
  tracks: Array.isArray(tracks?.tracks) ? tracks.tracks : [],
482
368
  complete: tracks?.complete === true,
@@ -486,8 +372,6 @@ export function createPlaybackPlanner({
486
372
  } catch (error) {
487
373
  logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
488
374
  return { tracks: [], complete: false, timedOut: false };
489
- } finally {
490
- if (timer !== null) clearTimeout(timer);
491
375
  }
492
376
  }
493
377
 
@@ -564,6 +448,7 @@ export function createPlaybackPlanner({
564
448
  for (const sidecar of sidecarFiles) {
565
449
  if (!pendingIndexes.has(sidecar.fileIndex)) continue;
566
450
  const read = await declaredAudioOf(sourceKey, sidecar.fileIndex, sidecar.name);
451
+ if (cache.get(cacheKey) !== plan) return { audioTracks: [], pending: false };
567
452
  if (read.timedOut) continue;
568
453
  if (read.tracks.length === 0) {
569
454
  if (read.complete) pendingIndexes.delete(sidecar.fileIndex);
@@ -589,6 +474,7 @@ export function createPlaybackPlanner({
589
474
  );
590
475
  }
591
476
  }
477
+ if (cache.get(cacheKey) !== plan) return { audioTracks: [], pending: false };
592
478
  pendingSidecarHeaders.set(cacheKey, [...pendingIndexes]);
593
479
  plan.audioTracksPending = pendingIndexes.size > 0;
594
480
  return { audioTracks: plan.audioTracks, pending: pendingIndexes.size > 0 };
@@ -638,6 +524,16 @@ export function createPlaybackPlanner({
638
524
  }
639
525
 
640
526
  return {
527
+ forget(sourceKey) {
528
+ for (const records of [cache, lifetimes, pendingSidecarHeaders, declaredAudioReads, mediaInfoCache]) {
529
+ for (const key of records.keys()) if (key.startsWith(`${sourceKey}:`)) records.delete(key);
530
+ }
531
+ },
532
+ getReadyPlan(params, { signal } = {}) {
533
+ if (!subscribeSource) throw new Error("Event-driven source preparation is unavailable.");
534
+ return waitForPlan({ read: () => this.getPlan(params), signal,
535
+ subscribe: listener => subscribeSource(params.sourceKey, params.fileIndex, listener) });
536
+ },
641
537
  /**
642
538
  * Media info the planner already probed for this file, or `null`. Lets the
643
539
  * HLS session manager skip its own duplicate `probeInputMediaInfo` scan.
@@ -670,23 +566,28 @@ export function createPlaybackPlanner({
670
566
  * Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
671
567
  * when the source or file cannot be located.
672
568
  *
673
- * When the file header has not downloaded yet (cold torrent, peers still
674
- * connecting) the codec probe cannot succeed. Rather than block the HTTP
675
- * response until it can, the planner prioritises the header, probes for at
676
- * most `maxWaitMs`, and if still undetectable returns a plan flagged
677
- * `pending: true` (NOT cached). The caller polls again — each call keeps the
678
- * header prioritised and downloading — until a real plan comes back. This
679
- * avoids a single long request racing the transport's request timeout.
569
+ * A probe reads only bytes already stored. Missing input becomes demand in
570
+ * the shared map and returns `pending: true`, which is never cached as a
571
+ * playback plan. Storage changes repeat unfinished probes; another request
572
+ * reads their latest result without creating a separate download request.
680
573
  *
681
574
  * @param {object} params
682
575
  * @param {string} params.sourceKey
683
576
  * @param {number} params.fileIndex
684
577
  * @param {string} [params.userAgent=""]
685
- * @param {number} [params.maxWaitMs=60000] - Max time to wait for the header within ONE call.
686
578
  * @returns {Promise<PlaybackPlan & { pending?: boolean }>}
687
579
  */
688
- async getPlan({ sourceKey, fileIndex, userAgent = "", maxWaitMs = 60_000, background = false }) {
580
+ async getPlan({ sourceKey, fileIndex, background = false }) {
689
581
  const cacheKey = `${sourceKey}:${fileIndex}`;
582
+ let lifetime = lifetimes.get(cacheKey);
583
+ if (!lifetime) lifetimes.set(cacheKey, lifetime = {});
584
+ const ensureCurrent = () => {
585
+ if (lifetimes.get(cacheKey) === lifetime) return;
586
+ const error = new Error("Source preparation was withdrawn.");
587
+ error.code = "SOURCE_FORGOTTEN";
588
+ error.canRetry = false;
589
+ throw error;
590
+ };
690
591
  const cached = cache.get(cacheKey);
691
592
  if (cached) {
692
593
  return withHostTimings(cached);
@@ -698,7 +599,6 @@ export function createPlaybackPlanner({
698
599
  // cached, and nothing said which part of it was slow.
699
600
  const planEntryMs = Date.now();
700
601
  let torrentReadyMs = 0;
701
- let edgesReadyMs = 0;
702
602
 
703
603
  const sourceRecord = sourceRegistry.get(sourceKey);
704
604
  if (!sourceRecord) {
@@ -708,6 +608,7 @@ export function createPlaybackPlanner({
708
608
  }
709
609
 
710
610
  const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
611
+ ensureCurrent();
711
612
  torrentReadyMs = Date.now() - planEntryMs;
712
613
  const file = torrent.files[fileIndex];
713
614
  if (!file) {
@@ -736,94 +637,47 @@ export function createPlaybackPlanner({
736
637
  return withHostTimings(plan);
737
638
  }
738
639
 
739
- // Pre-fetch file edges (head + tail), then probe — retrying while the
740
- // file header is still downloading. In a multi-file torrent the pieces
741
- // for a given file arrive unevenly, so the first probe can return empty
742
- // codecs. A transient empty probe must NOT be cached: otherwise the wrong
743
- // plan (file treated as directly playable) sticks permanently for this
744
- // file, and an unsupported codec like xvid gets copied → black video.
745
- // Awaited in the literal sense: this answer cannot be given until the
746
- // file has said what is in it, and a person is watching a loading screen
747
- // for as long as that takes.
748
- await torrentPool.prefetchFileEdges(torrent, fileIndex, {
749
- awaited: !background,
750
- timeoutMs: background ? 10_000 : 300_000
751
- });
752
- edgesReadyMs = Date.now() - planEntryMs;
753
- // The keyframe index reads the tail of the file, which the probe has just
754
- // waited for as well. Started here it overlaps the probe instead of
755
- // following the whole plan — worth 311-430 ms of the time before the
756
- // first segment. Fire and forget: a session that finds the table
757
- // unanswered joins this very read rather than starting a second one.
640
+ // Structural reads state their exact missing bytes through
641
+ // the shared download map. No separate edge or body demand is created.
642
+ if (typeof declaredTracksOf === "function") await declaredTracksOf({ sourceKey, fileIndex });
758
643
  if (!background) {
759
644
  warmKeyframeIndex?.({ sourceKey, fileIndex, logName: file.name });
760
645
  }
761
- let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
762
- const probeDeadline = Date.now() + Math.max(0, maxWaitMs);
763
- let attempt = 0;
764
- while (
765
- probe.audioCodec.length === 0 &&
766
- probe.videoCodec.length === 0 &&
767
- Date.now() < probeDeadline
768
- ) {
769
- attempt += 1;
770
- await delay(Math.min(3_000, 500 + attempt * 250));
771
- await torrentPool.prefetchFileEdges(torrent, fileIndex, {
772
- awaited: !background,
773
- timeoutMs: background ? 10_000 : 300_000
774
- });
775
- probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
646
+ if (typeof readDeclarations !== "function") throw new Error("Source container declarations are unavailable.");
647
+ const probeResult = await readDeclarations({ sourceKey, fileIndex });
648
+ ensureCurrent();
649
+ if (probeResult.kind === "terminal") {
650
+ const error = new Error(probeResult.message || probeResult.reason);
651
+ error.code = probeResult.reason;
652
+ error.canRetry = false;
653
+ throw error;
654
+ }
655
+ if (probeResult.kind === "cancelled") {
656
+ throw new DOMException("Playback preparation was cancelled.", "AbortError");
776
657
  }
658
+ const probe = probeResult.kind === "result" ? playbackDeclarations({ ...probeResult.value, fileBytes: file.length })
659
+ : { audioCodec: "", videoCodec: "", container: "", durationSeconds: null,
660
+ videoWidth: 0, videoHeight: 0, audioTracks: [], subtitleTracks: [] };
777
661
  const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
778
662
  const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
663
+ if (!codecsDetected && probeResult?.kind === "result") {
664
+ const error = new Error("The available source bytes declare no playable audio or video track.");
665
+ error.code = "media-probe-no-tracks";
666
+ error.canRetry = false;
667
+ throw error;
668
+ }
779
669
  logger.info(
780
670
  `plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
781
- `file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
782
- `total=${Date.now() - planEntryMs}ms attempts=${attempt + 1} ` +
783
- `${codecsDetected ? `${videoCodec || "-"}/${audioCodec || "-"}` : "codecs NOT detected (will be polled again)"}`
671
+ `probe=${Date.now() - planEntryMs - torrentReadyMs}ms total=${Date.now() - planEntryMs}ms ` +
672
+ `${codecsDetected ? `${videoCodec || "-"}/${audioCodec || "-"}` : "codec declaration awaits source bytes"}`
784
673
  );
785
674
 
786
- // The picture's own facts come from two readings and only one was ever
787
- // used: every figure the encode is planned from came from ffmpeg's
788
- // banner, while the `VideoTrack` the container declares was read and used
789
- // for nothing but a line in the log.
790
- let declaredVideo = null;
791
- if (typeof declaredTracksOf === "function") {
792
- try {
793
- // One, because ffmpeg's `0:v:0` is what everything downstream is built
794
- // on and a second video stream is a cover image far more often than a
795
- // second film.
796
- declaredVideo = (await declaredTracksOf({ sourceKey, fileIndex }))
797
- .filter((track) => track?.type === "video")
798
- .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0))[0] ?? null;
799
- } catch (error) {
800
- logger.info(`video track: could not be read (${error?.message ?? error})`);
801
- }
802
- }
803
- // `mode` is advisory only (audio-codec based). The browser makes the
804
- // authoritative decision independently per stream via canPlayType /
805
- // mediaCapabilities, transcoding only what it cannot play.
806
675
  const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
807
- // The two readings of the picture, lined up. Which one answers is decided
808
- // per field by what each IS — see `Container.mergeVideoFacts`.
809
- const videoFacts = Container.mergeVideoFacts(
810
- {
811
- width: videoWidth,
812
- height: videoHeight,
813
- fps: parseFfmpegVideoFps(probe.stderr),
814
- isHdr: parseFfmpegHdr(probe.stderr),
815
- bitDepth: parseFfmpegBitDepth(probe.stderr)
816
- },
817
- declaredVideo
818
- );
819
- if (videoFacts.disagreements.length > 0) {
820
- logger.info(
821
- `video track: the file and the probe disagree — ${videoFacts.disagreements.join("; ")}; ` +
822
- "the size and frame rate are the probe's, the bit depth and HDR the file's"
823
- );
824
- }
676
+ const videoFacts = Container.mergeVideoFacts({ width: videoWidth, height: videoHeight,
677
+ fps: probe.fps, isHdr: probe.isHdr, bitDepth: probe.bitDepth }, probe.video ?? null);
825
678
  const sidecars = sidecarsOf(torrent, fileIndex);
826
679
  const inventory = await buildInventory(sourceKey, torrent, fileIndex, audioTracks ?? []);
680
+ ensureCurrent();
827
681
  const plan = {
828
682
  mode: requiresTranscode ? "hls" : "direct",
829
683
  directUrl,
@@ -871,7 +725,7 @@ export function createPlaybackPlanner({
871
725
  width: videoFacts.width,
872
726
  height: videoFacts.height,
873
727
  fps: videoFacts.fps,
874
- bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
728
+ bitrateKbps: probe.bitrateKbps,
875
729
  // Which family of the decode measurement prices this source. A video
876
730
  // that has to be re-encoded is one the browser could not play, so it
877
731
  // is usually NOT H.264, and H.264 constants are wrong for it.
@@ -884,12 +738,12 @@ export function createPlaybackPlanner({
884
738
  fileIndex
885
739
  }
886
740
  };
741
+ ensureCurrent();
887
742
  // Only cache a plan whose codecs were actually detected. An empty probe is
888
743
  // a "header not downloaded yet" signal, not a valid result — caching it
889
744
  // would permanently mis-plan the file. In that case flag the plan
890
- // `pending` so the caller polls again (the header keeps downloading,
891
- // prioritised by the prefetch above).
892
- if (codecsDetected) {
745
+ // `pending`; the media request's missing ranges remain in the shared map.
746
+ if (codecsDetected && durationSeconds > 0) {
893
747
  cache.set(cacheKey, plan);
894
748
  pendingSidecarHeaders.set(cacheKey, inventory.pendingFileIndexes);
895
749
  // Cache the full media info from THIS probe's banner (same helpers the
@@ -906,29 +760,17 @@ export function createPlaybackPlanner({
906
760
  // guard expecting zero tracks and therefore accepting any header.
907
761
  videoCodec: plan.videoCodec,
908
762
  audioCodec: plan.audioCodec,
909
- durationSeconds: parseFfmpegDurationSeconds(probe.stderr),
763
+ durationSeconds: durationSeconds,
910
764
  width: videoFacts.width,
911
765
  height: videoFacts.height,
912
- bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
766
+ bitrateKbps: probe.bitrateKbps,
913
767
  fps: videoFacts.fps,
914
- startTime: parseFfmpegStartTimeSeconds(probe.stderr),
768
+ startTime: probe.startTimeSeconds,
915
769
  isHdr: videoFacts.isHdr,
916
770
  bitDepth: videoFacts.bitDepth,
917
- // What the source holds, from this same banner. A failed run reads it
918
- // to say whose fault an output with no stream is, and a session takes
919
- // its media info from this cache without probing again — so a field
920
- // left out here was "not recorded" on every failure (field 2026-09-29).
921
- streamCounts: parseFfmpegStreamCounts(probe.stderr)
771
+ // Retain every declared stream count for failed-run diagnostics.
772
+ streamCounts: probe.streamCounts
922
773
  });
923
- // Warm the file-body start for the transcode session that follows.
924
- // Fire-and-forget: never delays the plan response.
925
- void torrentPool
926
- .prefetchFileEdges(torrent, fileIndex, {
927
- headBytes: BODY_PREFETCH_BYTES,
928
- tailBytes: 0,
929
- timeoutMs: 60_000
930
- })
931
- .catch(() => {});
932
774
  return withHostTimings(plan);
933
775
  }
934
776
  return withHostTimings({ ...plan, pending: true });
@@ -0,0 +1,66 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { accessSync, constants } from "node:fs";
4
+ import { ffprobeRecord } from "./container/ffprobe-record.js";
5
+
6
+ const require = createRequire(import.meta.url);
7
+ function bundledProbe() {
8
+ try {
9
+ const binary = require("@ffprobe-installer/ffprobe").path;
10
+ accessSync(binary, constants.X_OK);
11
+ return binary;
12
+ }
13
+ catch { return "ffprobe"; }
14
+ }
15
+
16
+ /** Read only the strict available-byte endpoint; missing reads abort through ProbeRequests. */
17
+ export function probePackets({ url, statement, signal, onRecord = () => {}, ffprobeBin = bundledProbe(), spawnProcess = spawn }) {
18
+ if (!["streams", "packets"].includes(statement)) throw new TypeError("Unknown ffprobe statement.");
19
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Probe cancelled."));
20
+ onRecord({ kind: "scan-start" });
21
+ const entries = statement === "streams"
22
+ ? "stream=index,codec_type,codec_name,width,height,sample_rate,channels,bits_per_sample,bits_per_raw_sample,r_frame_rate,has_b_frames,start_time,duration,extradata,extradata_size:stream_tags=language,title:stream_disposition=default,forced:stream_side_data=:format=format_name,start_time,duration"
23
+ : "packet=stream_index,pts_time,dts_time,duration_time,pos,size,flags,data_hash:packet_side_data=";
24
+ const args = ["-v", "error", ...(statement === "streams" ? ["-show_streams", "-show_format", "-show_data"] : ["-show_packets", "-show_data_hash", "sha256"]),
25
+ "-show_entries", entries, "-of", "compact=p=1:nk=0:escape=c", "-i", url];
26
+ return new Promise((resolve, reject) => {
27
+ const records = [];
28
+ let remainder = "", diagnostics = "", failure = null;
29
+ const child = spawnProcess(ffprobeBin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
30
+ const aborted = () => { failure = signal.reason ?? new Error("Probe cancelled."); child.kill(); };
31
+ signal?.addEventListener("abort", aborted, { once: true });
32
+ if (signal?.aborted) aborted();
33
+ child.stdout.setEncoding("utf8");
34
+ child.stderr.setEncoding("utf8");
35
+ const accept = line => {
36
+ if (!line || failure) return;
37
+ const record = ffprobeRecord(line);
38
+ onRecord(record);
39
+ if (statement === "streams") records.push(record);
40
+ };
41
+ child.stdout.on("data", chunk => {
42
+ if (failure) return;
43
+ remainder += chunk;
44
+ try {
45
+ let newline;
46
+ while ((newline = remainder.indexOf("\n")) >= 0) {
47
+ accept(remainder.slice(0, newline));
48
+ remainder = remainder.slice(newline + 1);
49
+ }
50
+ } catch (error) { failure = error; child.kill(); }
51
+ });
52
+ child.stderr.on("data", chunk => { diagnostics = (diagnostics + chunk).slice(-8192); });
53
+ child.once("error", error => { failure = error; });
54
+ child.once("close", code => {
55
+ signal?.removeEventListener("abort", aborted);
56
+ try {
57
+ if (!failure && remainder) accept(remainder);
58
+ if (!failure && code === 0 && !diagnostics.trim()) onRecord({ kind: "scan-end" });
59
+ }
60
+ catch (error) { failure = error; }
61
+ if (failure) reject(failure);
62
+ else if (code !== 0 || diagnostics.trim()) reject(new Error(`ffprobe refused the input (${code}): ${diagnostics.trim()}`));
63
+ else resolve(records);
64
+ });
65
+ });
66
+ }
@@ -0,0 +1,18 @@
1
+ /** Request a complete timeline when the container header declares no duration. */
2
+ export async function readPlaybackDeclarations(orchestrator, params) {
3
+ const tracks = await orchestrator.inspect(params, "tracks");
4
+ if (tracks.kind !== "result") return tracks;
5
+ let media = await orchestrator.inspect(params, "media-info");
6
+ if (media.kind !== "result") return media;
7
+ if (!(media.value.durationSeconds > 0)) {
8
+ const packets = await orchestrator.inspect({ ...params, packetInterval: undefined }, "packets");
9
+ if (packets.kind !== "result") return packets;
10
+ media = await orchestrator.inspect(params, "media-info");
11
+ if (media.kind !== "result") return media;
12
+ if (!(media.value.durationSeconds > 0)) {
13
+ return { kind: "terminal", reason: "media-duration-unavailable",
14
+ message: "The complete source index does not declare a positive playback duration." };
15
+ }
16
+ }
17
+ return { kind: "result", value: { tracks: tracks.value, media: media.value } };
18
+ }
@@ -86,6 +86,8 @@ export class AudioTrack extends ContainerTrack {
86
86
  this.isVisualImpaired = params.isVisualImpaired === true;
87
87
  this.channels = Number.isFinite(params.channels) ? params.channels : null;
88
88
  this.samplingFrequency = Number.isFinite(params.samplingFrequency) ? params.samplingFrequency : null;
89
+ this.bitDepth = Number.isSafeInteger(params.bitDepth) && params.bitDepth > 0 ? params.bitDepth : null;
90
+ this.codecDelaySeconds = Number.isFinite(params.codecDelaySeconds) && params.codecDelaySeconds >= 0 ? params.codecDelaySeconds : 0;
89
91
  // What the codec is configured as, and the most that configuration allows
90
92
  // the track to carry — a statement of the file, read with the rest of the
91
93
  // track table (`audio-bound.js`). Own properties rather than getters, so a
@@ -97,6 +99,13 @@ export class AudioTrack extends ContainerTrack {
97
99
  samplingFrequency: this.samplingFrequency
98
100
  });
99
101
  this.peakKbps = peakKbpsOf(this.codecParameters);
102
+ if (this.codecParameters?.frameLength > 0 && this.codecParameters.sampleRate > 0) {
103
+ this.defaultDurationSeconds = this.codecParameters.frameLength / this.codecParameters.sampleRate;
104
+ this.seekPrerollSeconds = this.defaultDurationSeconds;
105
+ }
106
+ if (Number.isFinite(params.seekPrerollSeconds) && params.seekPrerollSeconds > 0) {
107
+ this.seekPrerollSeconds = Math.max(this.seekPrerollSeconds ?? 0, params.seekPrerollSeconds);
108
+ }
100
109
  }
101
110
 
102
111
  /**
@@ -28,7 +28,8 @@
28
28
  import { SubtitleTrack } from "./SubtitleTrack.js";
29
29
  import { detectLanguage } from "./language-detect.js";
30
30
 
31
- const TEXT_CODECS_MATROSKA = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA", "S_TEXT/WEBVTT"]);
31
+ const TEXT_CODECS_MATROSKA = new Set(["S_TEXT/UTF8", "S_TEXT/ASCII", "S_TEXT/ASS", "S_TEXT/SSA", "S_ASS", "S_SSA",
32
+ "S_TEXT/WEBVTT", "D_WEBVTT/SUBTITLES", "D_WEBVTT/CAPTIONS", "D_WEBVTT/DESCRIPTIONS", "D_WEBVTT/METADATA"]);
32
33
  const TEXT_FORMATS_MP4 = new Set(["tx3g", "text", "wvtt"]);
33
34
 
34
35
  /** How a cue's text is marked up, once the container's framing is off. */
@@ -55,6 +56,8 @@ export const MarkupKind = {
55
56
  const MARKUP_BY_CODEC = new Map([
56
57
  ["S_TEXT/ASS", MarkupKind.ASS],
57
58
  ["S_TEXT/SSA", MarkupKind.ASS],
59
+ ["S_ASS", MarkupKind.ASS],
60
+ ["S_SSA", MarkupKind.ASS],
58
61
  [".ass", MarkupKind.ASS],
59
62
  [".ssa", MarkupKind.ASS],
60
63
  ["S_TEXT/UTF8", MarkupKind.NONE],
@@ -389,6 +389,7 @@ export class OutputLifecycle {
389
389
  }
390
390
  const watched = this.#host.viewers.get(consumerId)?.outputs;
391
391
  if (!watched || watched.size === 0) {
392
+ this.#host.viewers.hasGone(consumerId);
392
393
  return 0;
393
394
  }
394
395
  // Copied before anything is released: releasing walks the same set.
@@ -396,6 +397,7 @@ export class OutputLifecycle {
396
397
  for (const outputId of outputs) {
397
398
  await this.releaseSessionConsumer(outputId, consumerId, because);
398
399
  }
400
+ this.#host.viewers.hasGone(consumerId);
399
401
  return outputs.length;
400
402
  }
401
403