@torrent-tv/proxy 2.75.0 → 2.76.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 (113) hide show
  1. package/CHANGELOG.md +1509 -1461
  2. package/CLAUDE.md +11 -1
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +2 -1
  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 +206 -201
  18. package/services/container/AviContainer.js +1 -1
  19. package/services/container/Container.js +33 -1
  20. package/services/container/MatroskaContainer.js +1 -1
  21. package/services/container/Mp4Container.js +1 -1
  22. package/services/container/SubtitleFileContainer.js +0 -1
  23. package/services/controllers/SubtitleController.js +128 -128
  24. package/services/demand/index.js +7 -10
  25. package/services/download/registry.js +0 -14
  26. package/services/encode/CoverageMap.js +281 -0
  27. package/services/encode/EncodePlan.js +255 -0
  28. package/services/encode/EncodeRun.js +587 -0
  29. package/services/encode/Encoder.js +84 -0
  30. package/services/encode/NvencEncoder.js +45 -0
  31. package/services/encode/QsvEncoder.js +47 -0
  32. package/services/encode/SegmentDemand.js +0 -0
  33. package/services/encode/SegmentStore.js +529 -0
  34. package/services/encode/SoftwareEncoder.js +111 -0
  35. package/services/encode/V4l2m2mEncoder.js +53 -0
  36. package/services/encode/VaapiEncoder.js +53 -0
  37. package/services/encode/args.js +200 -0
  38. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  39. package/services/encode/index.js +9 -0
  40. package/services/encode/run-command.js +647 -0
  41. package/services/hls-session-manager.js +11100 -10711
  42. package/services/hwaccel.js +1688 -1992
  43. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  44. package/services/output/LiveOutputs.js +213 -0
  45. package/services/output/Output.js +94 -0
  46. package/services/output/OutputSpec.js +195 -0
  47. package/services/output/Timeline.js +220 -0
  48. package/services/output/index.js +1 -0
  49. package/services/output/ladder.js +26 -0
  50. package/services/playback-planner.js +806 -775
  51. package/services/produced-index.js +222 -300
  52. package/services/source/SourceFile.js +346 -0
  53. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  54. package/services/torrent/naming.js +619 -0
  55. package/services/torrent-worker/client.js +10 -0
  56. package/services/torrent-worker/container-tracks.js +71 -43
  57. package/services/torrent-worker/pool-adapter.js +18 -0
  58. package/services/torrent-worker/protocol.js +7 -0
  59. package/services/torrent-worker/subtitle-cues.js +549 -549
  60. package/services/torrent-worker/worker.js +18 -0
  61. package/services/tracks/TextSubtitleTrack.js +287 -287
  62. package/services/tracks/index.js +15 -14
  63. package/services/viewer/Viewer.js +145 -0
  64. package/services/viewer/Viewers.js +124 -0
  65. package/test/auto-quality-step.test.js +508 -506
  66. package/test/behind-head-repair.test.js +17 -7
  67. package/test/coverage-map.test.js +153 -0
  68. package/test/cut-times-timeline.test.js +6 -5
  69. package/test/cuts-follow-published-grid.test.js +4 -4
  70. package/test/decode-cost.test.js +31 -12
  71. package/test/encode-exit.test.js +1 -1
  72. package/test/encode-orchestrator.test.js +196 -0
  73. package/test/encode-plan.test.js +245 -0
  74. package/test/encode-run-state.test.js +2 -2
  75. package/test/encode-run.test.js +168 -0
  76. package/test/encoder-kinds.test.js +122 -0
  77. package/test/held-request-width.test.js +9 -3
  78. package/test/helpers/encode-run.js +128 -0
  79. package/test/keyframe-index-accuracy.test.js +19 -12
  80. package/test/keyframes-belong-to-the-file.test.js +132 -0
  81. package/test/orchestrator-wired.test.js +164 -0
  82. package/test/output-shape.test.js +68 -0
  83. package/test/output-spec.test.js +157 -0
  84. package/test/produced-copy-choice.test.js +58 -92
  85. package/test/produced-index.test.js +142 -188
  86. package/test/quality-variants.test.js +1079 -1075
  87. package/test/run-graph-drift.test.js +1 -1
  88. package/test/run-intervals.test.js +329 -0
  89. package/test/run-position-follows-published-grid.test.js +4 -4
  90. package/test/seek-landing.test.js +8 -8
  91. package/test/seek-target-not-superseded.test.js +21 -9
  92. package/test/segment-demand.test.js +82 -0
  93. package/test/segment-serve-wiring.test.js +47 -52
  94. package/test/segment-store.test.js +187 -0
  95. package/test/segments-are-shared.test.js +175 -0
  96. package/test/sidecar-naming.test.js +142 -0
  97. package/test/source-file.test.js +133 -0
  98. package/test/stale-request-after-seek.test.js +18 -12
  99. package/test/subtitle-language.test.js +252 -252
  100. package/test/timeline.test.js +95 -0
  101. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  102. package/test/torrent-naming.test.js +255 -0
  103. package/test/tracks-begin-together.test.js +44 -32
  104. package/test/two-viewers-one-picture.test.js +347 -0
  105. package/test/viewer-outputs.test.js +273 -0
  106. package/test/viewer.test.js +91 -0
  107. package/utils/perf.js +1 -63
  108. package/services/container/index.js +0 -6
  109. package/services/controllers/index.js +0 -2
  110. package/services/download/index.js +0 -8
  111. package/services/orchestrators/index.js +0 -2
  112. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  113. /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
- }
@@ -239,6 +239,24 @@ export class WorkerTorrentPool {
239
239
  return answer?.info ?? null;
240
240
  }
241
241
 
242
+ /**
243
+ * Where one file's keyframes are, from the container's own table. Null when
244
+ * this file has no readable index — which is a final answer about the file,
245
+ * and the reason a copy of it has to be re-encoded instead.
246
+ *
247
+ * @param {object} torrent
248
+ * @param {number} fileIndex
249
+ * @returns {Promise<{ times: number[], tolerance: number } | null>}
250
+ */
251
+ async getContainerKeyframes(torrent, fileIndex) {
252
+ const sourceKey = torrent?.sourceKey;
253
+ if (!sourceKey) {
254
+ return null;
255
+ }
256
+ const answer = await this.#client.getContainerKeyframes({ sourceKey, fileIndex });
257
+ return answer?.index ?? null;
258
+ }
259
+
242
260
  /**
243
261
  * The audio tracks one file declares, in the order ffmpeg numbers them
244
262
  * `0:a:N`.
@@ -88,6 +88,13 @@ export const Command = {
88
88
  * own HTTP reading the same bytes again.
89
89
  */
90
90
  CONTAINER_MEDIA_INFO: "container-media-info",
91
+ /**
92
+ * Where a file's keyframes are, from the container's own table. A property of
93
+ * immutable bytes, read once per file by the same container that answers the
94
+ * two above — and the answer decides whether a picture can be copied at all,
95
+ * which must be the same answer for every viewer of that file.
96
+ */
97
+ CONTAINER_KEYFRAMES: "container-keyframes",
91
98
  /**
92
99
  * Start fetching the region a viewer is about to resume at, named in seconds
93
100
  * and turned into bytes here, where the file's own duration can be read.