@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
@@ -0,0 +1,255 @@
1
+ /**
2
+ * @file How many encoders there should be on one output, and where each of them
3
+ * belongs — decided from numbers alone.
4
+ *
5
+ * The decision is separated from carrying it out on purpose. Every rule below
6
+ * was previously a condition somewhere inside an eleven-thousand-line file,
7
+ * reachable only by starting a real ffmpeg, and each of them was written for
8
+ * one viewer:
9
+ *
10
+ * - a run was placed at the position of whoever asked, and never at the first
11
+ * thing missing, so a viewer moving into a stretch already on disk restarted
12
+ * an encoder to make it a second time;
13
+ * - a run had no end at all — neither `-to` nor `-t` appeared anywhere — so it
14
+ * ran until something killed it, and two runs on one output could not exist
15
+ * without writing over each other;
16
+ * - nothing stopped a run that had caught up with material somebody else had
17
+ * already made.
18
+ *
19
+ * The rule this file exists to express, stated by the user 2026-09-04:
20
+ *
21
+ * > Viewers are always independent and always reuse what can be reused. The
22
+ * > number of encoders is however many are needed; how many are needed follows
23
+ * > from which sets of output parameters are wanted and where the viewers stand
24
+ * > inside each. Segments produced by ANY encoder are available to ANY viewer,
25
+ * > and which viewer asked never enters the question.
26
+ *
27
+ * So no name of a viewer reaches this file. It is given what is wanted, what
28
+ * exists, what is being made, and what the machine can afford.
29
+ *
30
+ * **What a viewer wants decides WHERE a run starts and WHETHER it is needed,
31
+ * never where it stops.** A run's end comes from the coverage: it runs until it
32
+ * meets material somebody else has made or is making, or until the end of the
33
+ * film. Bounding it by the window instead was tried and is wrong, because a
34
+ * window travels forward as the viewer plays: a second viewer whose cushion
35
+ * reached two segments past the first run's end was given an encoder of their
36
+ * own to make those two, while the run already there had nowhere left to go.
37
+ * How far ahead of the viewers a run may get is a different question with its
38
+ * own answer — the look-ahead, which suspends a run rather than bounding it.
39
+ */
40
+
41
+ /**
42
+ * One encoder that is running now.
43
+ *
44
+ * @typedef {object} LiveRun
45
+ * @property {string} id
46
+ * @property {number} from - The first number it was given.
47
+ * @property {number} to - The last number it was given, inclusive.
48
+ * @property {number} head - The next number it will produce. Its position.
49
+ * @property {number} speedX - Measured encode speed against realtime, from
50
+ * ffmpeg's own progress. Zero or less means nothing has measured it yet, and
51
+ * then no comparison involving its speed can be made.
52
+ */
53
+
54
+ /**
55
+ * What a viewer is waiting for. Which viewer is deliberately absent.
56
+ *
57
+ * @typedef {object} WantedSpan
58
+ * @property {number} from
59
+ * @property {number} to
60
+ */
61
+
62
+ /**
63
+ * @typedef {{ type: "start", from: number, to: number, because: string }
64
+ * | { type: "move", run: object, from: number, to: number, because: string }
65
+ * | { type: "stop", run: object, because: string }
66
+ * | { type: "keep", run: object, from: number, to: number }} PlanAction
67
+ */
68
+
69
+ /**
70
+ * Whether a stretch and a window touch at all.
71
+ *
72
+ * @param {number} fromA
73
+ * @param {number} toA
74
+ * @param {number} fromB
75
+ * @param {number} toB
76
+ * @returns {boolean}
77
+ */
78
+ function overlaps(fromA, toA, fromB, toB) {
79
+ return fromA <= toB && fromB <= toA;
80
+ }
81
+
82
+ /**
83
+ * Decide what to do with the encoders on one output.
84
+ *
85
+ * @param {object} params
86
+ * @param {import("./CoverageMap.js").CoverageMap} params.coverage - What has
87
+ * been made and what is being made.
88
+ * @param {WantedSpan[]} params.windows - What viewers are waiting for, one
89
+ * window each. Empty means nobody is watching this output.
90
+ * @param {LiveRun[]} params.runs - The encoders running on it now.
91
+ * @param {number} params.maxRuns - How many encoders this machine can afford on
92
+ * this output. Comes from the same arithmetic that decides the quality offer;
93
+ * it is measured per host and never chosen here.
94
+ * @param {number} params.segmentSeconds - How much film one segment holds.
95
+ * @param {number} params.restartCostSec - What it costs to stop an encoder and
96
+ * start it somewhere else: process start plus opening the input. Measured —
97
+ * 0.12 s on the addon host, 0.5-0.6 s on a desktop.
98
+ * @returns {PlanAction[]} Stops first, then moves, then starts, so that a plan
99
+ * carried out in order never holds two encoders where it means to hold one.
100
+ */
101
+ export function planEncoders({
102
+ coverage,
103
+ windows,
104
+ runs,
105
+ maxRuns,
106
+ segmentSeconds,
107
+ restartCostSec
108
+ }) {
109
+ /** @type {PlanAction[]} */
110
+ const stops = [];
111
+ /** @type {PlanAction[]} */
112
+ const moves = [];
113
+ /** @type {PlanAction[]} */
114
+ const starts = [];
115
+ /** @type {PlanAction[]} */
116
+ const keeps = [];
117
+
118
+ const wanted = Array.isArray(windows) ? windows : [];
119
+ const live = Array.isArray(runs) ? runs : [];
120
+
121
+ // Nobody is watching this output: every encoder on it is making segments for
122
+ // no one. This is the case a look-ahead cannot answer, because look-ahead
123
+ // asks how far AHEAD of a viewer a run is and there is no viewer.
124
+ if (wanted.length === 0) {
125
+ for (const run of live) {
126
+ stops.push({ type: "stop", run, because: "nobody is watching this output" });
127
+ }
128
+ return stops;
129
+ }
130
+
131
+ // How far a search for a gap needs to look: past the furthest thing anybody
132
+ // is waiting for there is nothing to decide about.
133
+ const demandTo = Math.max(...wanted.map((span) => span.to));
134
+
135
+ /** Runs that survive this pass. @type {Set<object>} */
136
+ const surviving = new Set();
137
+
138
+ for (const run of live) {
139
+ // 1. Is anybody waiting for what this run was given? A run whose stretch
140
+ // touches no window is making material nobody has asked for.
141
+ const stillWanted = wanted.some((span) => overlaps(run.from, run.to, span.from, span.to));
142
+ if (!stillWanted) {
143
+ stops.push({ type: "stop", run, because: "nothing it was given is wanted" });
144
+ continue;
145
+ }
146
+
147
+ // 2. Has it arrived at material that already exists, or that another run is
148
+ // making? Its own claim does not count against it.
149
+ const coveredAhead = coverage.coveredRunFrom(run.head, run);
150
+ if (coveredAhead === 0) {
151
+ surviving.add(run);
152
+ keeps.push({ type: "keep", run, from: run.head, to: run.to });
153
+ continue;
154
+ }
155
+
156
+ // Where it would go instead: the first thing nobody has and nobody is
157
+ // making, at or after where it stands.
158
+ const gap = coverage.firstGapFrom(run.head, demandTo, run);
159
+ if (gap === null) {
160
+ stops.push({
161
+ type: "stop",
162
+ run,
163
+ because: "everything wanted ahead of it is already made or being made"
164
+ });
165
+ continue;
166
+ }
167
+
168
+ // Driving through costs its own encode time for material that exists.
169
+ // Moving costs one restart. Both are measured; neither is chosen here.
170
+ //
171
+ // A run whose speed nothing has measured yet cannot be compared, and then
172
+ // moving is the answer rather than a default: driving through is work that
173
+ // is certainly wasted, while the restart is a known and small cost.
174
+ const driveSec = run.speedX > 0 ? (coveredAhead * segmentSeconds) / run.speedX : null;
175
+ if (driveSec !== null && driveSec <= restartCostSec) {
176
+ surviving.add(run);
177
+ keeps.push({ type: "keep", run, from: run.head, to: run.to });
178
+ continue;
179
+ }
180
+
181
+ const free = coverage.freeRunFrom(gap, run);
182
+ surviving.add(run);
183
+ moves.push({
184
+ type: "move",
185
+ run,
186
+ from: gap,
187
+ to: gap + Math.max(1, free) - 1,
188
+ because: driveSec === null
189
+ ? `${coveredAhead} segment(s) ahead are already covered and its speed is not measured`
190
+ : `driving through ${coveredAhead} covered segment(s) costs ${driveSec.toFixed(2)}s ` +
191
+ `against ${restartCostSec.toFixed(2)}s to move`
192
+ });
193
+ }
194
+
195
+ // 3. Gaps somebody is waiting for that nobody is making. Taken in the order
196
+ // the viewers meet them — the lowest first — because that is the one a
197
+ // viewer is stopped at, and the budget may not stretch to all of them.
198
+ const gapsWanted = [];
199
+ for (const span of wanted) {
200
+ const gap = coverage.firstGapFrom(span.from, span.to);
201
+ if (gap !== null) {
202
+ gapsWanted.push(gap);
203
+ }
204
+ }
205
+ const alreadyPlanned = new Set(moves.map((action) => /** @type {{from:number}} */ (action).from));
206
+ const budget = Math.max(0, maxRuns - surviving.size);
207
+ for (const gap of [...new Set(gapsWanted)].sort((left, right) => left - right)) {
208
+ if (starts.length >= budget) {
209
+ break;
210
+ }
211
+ if (alreadyPlanned.has(gap)) {
212
+ continue;
213
+ }
214
+ const free = coverage.freeRunFrom(gap);
215
+ if (free === 0) {
216
+ continue;
217
+ }
218
+ alreadyPlanned.add(gap);
219
+ starts.push({
220
+ type: "start",
221
+ from: gap,
222
+ to: gap + free - 1,
223
+ because: `#${gap} is wanted and nobody is making it`
224
+ });
225
+ }
226
+
227
+ return [...stops, ...moves, ...starts, ...keeps];
228
+ }
229
+
230
+ /**
231
+ * The lowest number a viewer is waiting for that is not ready — what the plan
232
+ * is judged by.
233
+ *
234
+ * Not used to decide anything: it is the figure a log line carries, so that a
235
+ * plan that keeps producing while a viewer waits is visible rather than
236
+ * inferred.
237
+ *
238
+ * @param {import("./CoverageMap.js").CoverageMap} coverage
239
+ * @param {WantedSpan[]} windows
240
+ * @returns {number | null}
241
+ */
242
+ export function firstUnmetWant(coverage, windows) {
243
+ let lowest = null;
244
+ for (const span of windows ?? []) {
245
+ for (let at = span.from; at <= span.to; at += 1) {
246
+ if (!coverage.isReady(at)) {
247
+ if (lowest === null || at < lowest) {
248
+ lowest = at;
249
+ }
250
+ break;
251
+ }
252
+ }
253
+ }
254
+ return lowest;
255
+ }