@torrent-tv/proxy 2.74.1 → 2.76.0

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 (124) hide show
  1. package/CHANGELOG.md +1504 -1453
  2. package/CLAUDE.md +19 -6
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +27 -6
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +9 -214
  18. package/services/container/AviContainer.js +266 -81
  19. package/services/container/Container.js +281 -80
  20. package/services/container/ContainerFactory.js +67 -0
  21. package/services/container/MatroskaContainer.js +327 -8
  22. package/services/container/Mp4Container.js +373 -27
  23. package/services/container/SubtitleFileContainer.js +0 -1
  24. package/services/controllers/SubtitleController.js +128 -128
  25. package/services/demand/index.js +7 -10
  26. package/services/download/registry.js +0 -14
  27. package/services/encode/CoverageMap.js +281 -0
  28. package/services/encode/EncodePlan.js +255 -0
  29. package/services/encode/EncodeRun.js +587 -0
  30. package/services/encode/Encoder.js +84 -0
  31. package/services/encode/NvencEncoder.js +45 -0
  32. package/services/encode/QsvEncoder.js +47 -0
  33. package/services/encode/SegmentDemand.js +0 -0
  34. package/services/encode/SegmentStore.js +529 -0
  35. package/services/encode/SoftwareEncoder.js +111 -0
  36. package/services/encode/V4l2m2mEncoder.js +53 -0
  37. package/services/encode/VaapiEncoder.js +53 -0
  38. package/services/encode/args.js +200 -0
  39. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  40. package/services/encode/index.js +9 -0
  41. package/services/encode/run-command.js +647 -0
  42. package/services/hls-session-manager.js +11073 -10711
  43. package/services/hwaccel.js +1688 -1992
  44. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  45. package/services/output/LiveOutputs.js +213 -0
  46. package/services/output/Output.js +94 -0
  47. package/services/output/OutputSpec.js +195 -0
  48. package/services/output/Timeline.js +220 -0
  49. package/services/output/index.js +1 -0
  50. package/services/output/ladder.js +26 -0
  51. package/services/playback-planner.js +806 -747
  52. package/services/produced-index.js +222 -300
  53. package/services/source/SourceFile.js +346 -0
  54. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  55. package/services/torrent/naming.js +619 -0
  56. package/services/torrent-worker/client.js +10 -0
  57. package/services/torrent-worker/container-tracks.js +71 -43
  58. package/services/torrent-worker/pool-adapter.js +370 -333
  59. package/services/torrent-worker/protocol.js +7 -0
  60. package/services/torrent-worker/subtitle-cues.js +549 -549
  61. package/services/torrent-worker/worker.js +18 -0
  62. package/services/tracks/AudioTrack.js +131 -40
  63. package/services/tracks/TextSubtitleTrack.js +287 -287
  64. package/services/tracks/index.js +15 -14
  65. package/services/viewer/Viewer.js +145 -0
  66. package/services/viewer/Viewers.js +124 -0
  67. package/test/audio-inventory.test.js +176 -177
  68. package/test/auto-quality-step.test.js +508 -506
  69. package/test/behind-head-repair.test.js +17 -7
  70. package/test/coverage-map.test.js +153 -0
  71. package/test/cut-times-timeline.test.js +6 -5
  72. package/test/cuts-follow-published-grid.test.js +4 -4
  73. package/test/decode-cost.test.js +31 -12
  74. package/test/encode-exit.test.js +1 -1
  75. package/test/encode-orchestrator.test.js +196 -0
  76. package/test/encode-plan.test.js +245 -0
  77. package/test/encode-run-state.test.js +2 -2
  78. package/test/encode-run.test.js +168 -0
  79. package/test/encoder-kinds.test.js +122 -0
  80. package/test/held-request-width.test.js +9 -3
  81. package/test/helpers/encode-run.js +128 -0
  82. package/test/keyframe-index-accuracy.test.js +19 -12
  83. package/test/keyframes-belong-to-the-file.test.js +132 -0
  84. package/test/matroska-cues-track.test.js +192 -192
  85. package/test/mp4-composition-times.test.js +0 -0
  86. package/test/orchestrator-wired.test.js +164 -0
  87. package/test/output-shape.test.js +68 -0
  88. package/test/output-spec.test.js +157 -0
  89. package/test/produced-copy-choice.test.js +58 -92
  90. package/test/produced-index.test.js +142 -188
  91. package/test/quality-variants.test.js +1079 -1075
  92. package/test/run-graph-drift.test.js +1 -1
  93. package/test/run-intervals.test.js +329 -0
  94. package/test/run-position-follows-published-grid.test.js +4 -4
  95. package/test/seek-landing.test.js +8 -8
  96. package/test/seek-target-not-superseded.test.js +21 -9
  97. package/test/segment-demand.test.js +82 -0
  98. package/test/segment-serve-wiring.test.js +47 -52
  99. package/test/segment-store.test.js +187 -0
  100. package/test/segments-are-shared.test.js +175 -0
  101. package/test/sidecar-naming.test.js +142 -0
  102. package/test/source-file.test.js +133 -0
  103. package/test/stale-request-after-seek.test.js +18 -12
  104. package/test/subtitle-language.test.js +252 -252
  105. package/test/timeline.test.js +95 -0
  106. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  107. package/test/torrent-naming.test.js +255 -0
  108. package/test/tracks-begin-together.test.js +44 -32
  109. package/test/two-viewers-one-picture.test.js +347 -0
  110. package/test/video-facts.test.js +102 -0
  111. package/test/viewer-outputs.test.js +273 -0
  112. package/test/viewer.test.js +91 -0
  113. package/utils/perf.js +1 -63
  114. package/services/container/index.js +0 -6
  115. package/services/container-index/avi.js +0 -167
  116. package/services/container-index/index.js +0 -118
  117. package/services/container-index/matroska.js +0 -336
  118. package/services/container-index/mp4.js +0 -358
  119. package/services/controllers/index.js +0 -2
  120. package/services/download/index.js +0 -8
  121. package/services/orchestrators/index.js +0 -2
  122. /package/services/{container-index → container}/ebml-reader.js +0 -0
  123. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  124. /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
@@ -205,22 +205,6 @@ async function readTracks(torrent, fileIndex, sourceKey, options) {
205
205
  }
206
206
  }
207
207
 
208
- /**
209
- * The audio tracks of one file, in the order ffmpeg numbers them `0:a:N`.
210
- *
211
- * @param {object} torrent
212
- * @param {number} fileIndex
213
- * @param {string} sourceKey
214
- * @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
215
- * @returns {Promise<object[]>}
216
- */
217
- export async function containerAudioTracksOf(torrent, fileIndex, sourceKey, options = {}) {
218
- const tracks = await containerTracksOf(torrent, fileIndex, sourceKey, options);
219
- return tracks
220
- .filter((track) => track.type === "audio")
221
- .sort((left, right) => left.declaredIndex - right.declaredIndex);
222
- }
223
-
224
208
  /** Bytes of a file's head worth fetching before its track table is read. */
225
209
  export const CONTAINER_HEAD_BYTES = HEAD_BYTES;
226
210
 
@@ -300,16 +284,78 @@ export async function warmResumePosition(torrent, fileIndex, sourceKey, position
300
284
  }
301
285
 
302
286
  /**
303
- * What one file declares about itself: format, duration, and where its own
304
- * timeline begins.
287
+ * Where one file's keyframes are, from the container's own table.
288
+ *
289
+ * A property of immutable bytes, like the duration and the track list, and read
290
+ * here for the same reason they are: the container is cached per file, so the
291
+ * table is read ONCE however many sessions ask for it, and two sessions created
292
+ * in the same moment join one read instead of making two. That is the two-viewer
293
+ * case exactly — measured 2026-09-03, two pictures of one file were created 13 ms
294
+ * apart, and each read the table for itself over the proxy's own HTTP.
305
295
  *
306
- * The same header the track table is read from, and the container instance is
307
- * cached per file, so asking for this after the tracks costs no read at all.
308
- * It exists because the alternative was a second reader: the session manager
309
- * used to spawn an ffmpeg over the proxy's own HTTP to learn where a sidecar
310
- * soundtrack's timeline begins, and that read cost 8121 ms in the field on
311
- * 2026-09-03 while this layer had read the same header in 8 ms in the same
312
- * second.
296
+ * It decides which branch a copy takes: a picture can only be cut where the
297
+ * source already has a keyframe, so a file with no readable index is re-encoded
298
+ * instead. That decision belongs to the FILE, and it is the same for everybody
299
+ * watching it.
300
+ *
301
+ * @param {object} torrent
302
+ * @param {number} fileIndex
303
+ * @param {string} sourceKey
304
+ * @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
305
+ * @returns {Promise<{ times: number[], tolerance: number } | null>}
306
+ */
307
+ export async function containerKeyframesOf(torrent, fileIndex, sourceKey, options = {}) {
308
+ const file = torrent?.files?.[fileIndex];
309
+ if (!file || !Number.isFinite(file.length) || file.length <= 0) {
310
+ return null;
311
+ }
312
+ if (typeof options.prefetchEdges === "function") {
313
+ try {
314
+ await options.prefetchEdges();
315
+ } catch {
316
+ // A prefetch that failed is not a reason to skip the read: the read
317
+ // fetches what it needs itself, only more slowly.
318
+ }
319
+ }
320
+ const readRange = async (start, end) =>
321
+ readFetching(file, start, Math.min(end, file.length - 1));
322
+ const startedAt = Date.now();
323
+ const params = {
324
+ sourceKey,
325
+ fileIndex,
326
+ readRange,
327
+ fileSize: file.length,
328
+ label: String(file.name ?? "")
329
+ };
330
+ try {
331
+ const index = await containerOrchestrator.getKeyframeIndex(params);
332
+ // Which container answered, reported whether or not it produced anything:
333
+ // how often an index disagrees with its own file is a question about the
334
+ // CONTAINER, and a measurement that does not say which one cannot answer
335
+ // it. Free — the container is the cached one that has just read the table.
336
+ const format = (await containerOrchestrator.getContainer(params))?.formatName ?? "unrecognised";
337
+ const times = Array.isArray(index?.times) && index.times.length > 0 ? index.times : null;
338
+ logger.info(
339
+ `container-keyframes: "${String(file.name).slice(0, 40)}" ` +
340
+ (times
341
+ ? `${times.length} keyframes from the ${format} index in ${Date.now() - startedAt}ms`
342
+ : `has no readable index (${format}, ${Date.now() - startedAt}ms)`)
343
+ );
344
+ return {
345
+ times,
346
+ format,
347
+ tolerance: Number.isFinite(index?.tolerance) ? index.tolerance : 0
348
+ };
349
+ } catch (error) {
350
+ logger.warn(
351
+ `container-keyframes: "${String(file.name).slice(0, 40)}" could not be read: ${error?.message ?? error}`
352
+ );
353
+ return null;
354
+ }
355
+ }
356
+
357
+ /**
358
+ * What one file declares about itself as a whole.
313
359
  *
314
360
  * @param {object} torrent
315
361
  * @param {number} fileIndex
@@ -357,21 +403,3 @@ export async function containerMediaInfoOf(torrent, fileIndex, sourceKey, option
357
403
  return null;
358
404
  }
359
405
  }
360
-
361
- /**
362
- * Forget one file's tracks, or every file of a source.
363
- *
364
- * @param {string} sourceKey
365
- * @param {number} [fileIndex]
366
- */
367
- export function forgetContainerTracks(sourceKey, fileIndex) {
368
- if (fileIndex === undefined) {
369
- for (const key of [...byFile.keys()]) {
370
- if (key.startsWith(`${sourceKey}:`)) {
371
- byFile.delete(key);
372
- }
373
- }
374
- return;
375
- }
376
- byFile.delete(`${sourceKey}:${fileIndex}`);
377
- }