@torrent-tv/proxy 2.89.16 → 2.89.18

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 (299) hide show
  1. package/README.md +14 -3
  2. package/package.json +10 -2
  3. package/routes/api/client-logs/post.js +9 -2
  4. package/server.js +0 -11
  5. package/.claude/commands/opsx/apply.md +0 -155
  6. package/.claude/commands/opsx/archive.md +0 -160
  7. package/.claude/commands/opsx/explore.md +0 -174
  8. package/.claude/commands/opsx/propose.md +0 -109
  9. package/.claude/commands/opsx/sync.md +0 -143
  10. package/.claude/skills/openspec-apply-change/SKILL.md +0 -159
  11. package/.claude/skills/openspec-archive-change/SKILL.md +0 -117
  12. package/.claude/skills/openspec-explore/SKILL.md +0 -289
  13. package/.claude/skills/openspec-propose/SKILL.md +0 -113
  14. package/.claude/skills/openspec-sync-specs/SKILL.md +0 -147
  15. package/.dockerignore +0 -5
  16. package/AGENTS.md +0 -363
  17. package/CHANGELOG.md +0 -2373
  18. package/Dockerfile +0 -30
  19. package/biome.json +0 -412
  20. package/corpus.tmp.json +0 -1
  21. package/docs/browser-proxy-contract.md +0 -155
  22. package/docs/container-architecture.md +0 -273
  23. package/docs/download-architecture.md +0 -261
  24. package/docs/encode-architecture.md +0 -765
  25. package/docs/encode-run-state.md +0 -96
  26. package/docs/logs.md +0 -138
  27. package/docs/playback-start-forecast.md +0 -113
  28. package/docs/socks5-egress-research.md +0 -114
  29. package/docs/storage-architecture.md +0 -273
  30. package/knip.json +0 -14
  31. package/main2.cpuprofile +0 -1
  32. package/openspec/changes/adaptive-bitrate/.openspec.yaml +0 -0
  33. package/openspec/changes/adaptive-bitrate/design.md +0 -92
  34. package/openspec/changes/adaptive-bitrate/proposal.md +0 -56
  35. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +0 -51
  36. package/openspec/changes/adaptive-bitrate/tasks.md +0 -40
  37. package/openspec/changes/binary-distribution/.openspec.yaml +0 -2
  38. package/openspec/changes/binary-distribution/proposal.md +0 -53
  39. package/openspec/changes/chunked-request-bodies/design.md +0 -81
  40. package/openspec/changes/chunked-request-bodies/proposal.md +0 -53
  41. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +0 -30
  42. package/openspec/changes/chunked-request-bodies/tasks.md +0 -40
  43. package/openspec/changes/cold-start/design.md +0 -98
  44. package/openspec/changes/cold-start/proposal.md +0 -62
  45. package/openspec/changes/cold-start/specs/cold-start/spec.md +0 -47
  46. package/openspec/changes/cold-start/tasks.md +0 -48
  47. package/openspec/changes/disk-cap/.openspec.yaml +0 -2
  48. package/openspec/changes/disk-cap/proposal.md +0 -37
  49. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +0 -25
  50. package/openspec/changes/disk-cap/tasks.md +0 -19
  51. package/openspec/changes/proxy-observability/.openspec.yaml +0 -2
  52. package/openspec/changes/proxy-observability/design.md +0 -38
  53. package/openspec/changes/proxy-observability/proposal.md +0 -49
  54. package/openspec/changes/proxy-observability/specs/observability/spec.md +0 -33
  55. package/openspec/changes/proxy-observability/tasks.md +0 -21
  56. package/openspec/changes/seek-debounce/.openspec.yaml +0 -0
  57. package/openspec/changes/seek-debounce/design.md +0 -118
  58. package/openspec/changes/seek-debounce/proposal.md +0 -59
  59. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +0 -41
  60. package/openspec/changes/seek-debounce/tasks.md +0 -33
  61. package/openspec/changes/subtitle-language/.openspec.yaml +0 -2
  62. package/openspec/changes/subtitle-language/proposal.md +0 -50
  63. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +0 -34
  64. package/openspec/changes/subtitle-language/tasks.md +0 -26
  65. package/openspec/changes/track-selection/.openspec.yaml +0 -2
  66. package/openspec/changes/track-selection/design.md +0 -47
  67. package/openspec/changes/track-selection/proposal.md +0 -45
  68. package/openspec/changes/track-selection/specs/track-selection/spec.md +0 -41
  69. package/openspec/changes/track-selection/tasks.md +0 -20
  70. package/openspec/changes/transcode-quality/.openspec.yaml +0 -2
  71. package/openspec/changes/transcode-quality/proposal.md +0 -51
  72. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +0 -139
  73. package/openspec/changes/transcode-quality/tasks.md +0 -63
  74. package/openspec/config.yaml +0 -38
  75. package/public/.well-known/appspecific/com.chrome.devtools.json +0 -1
  76. package/research/delivery-wedge-stand-ladder-2026-09-13.md +0 -329
  77. package/research/double-spawn-2026-09-10.md +0 -171
  78. package/research/handover-2026-09-13.md +0 -209
  79. package/research/handover-reader-claims-removal-2026-09-12.md +0 -166
  80. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +0 -175
  81. package/research/preset-benchmark-size-scaling-2026-08-22.md +0 -55
  82. package/research/priority-map-is-the-truth-2026-09-12.md +0 -225
  83. package/research/valgrind-replica-2026-08-27/logs.tar.gz +0 -0
  84. package/scripts/build-audio-samples.mjs +0 -31
  85. package/scripts/render-run-graph.js +0 -173
  86. package/test/asset-paths.test.js +0 -28
  87. package/test/audio-buffer-continuity.test.js +0 -77
  88. package/test/audio-calibration.test.js +0 -23
  89. package/test/audio-inventory.test.js +0 -212
  90. package/test/audio-track-bitrate.test.js +0 -40
  91. package/test/audio-warm-recorded.test.js +0 -63
  92. package/test/auto-quality-step.test.js +0 -762
  93. package/test/available-share.test.js +0 -76
  94. package/test/background-fill.test.js +0 -190
  95. package/test/client-log-file.test.js +0 -126
  96. package/test/coalesce.test.js +0 -39
  97. package/test/completed-files.test.js +0 -115
  98. package/test/concurrent-cost.test.js +0 -148
  99. package/test/container-media-info.test.js +0 -228
  100. package/test/contention.test.js +0 -76
  101. package/test/core-dumps.test.js +0 -57
  102. package/test/coverage-follows-the-disk.test.js +0 -233
  103. package/test/coverage-map.test.js +0 -195
  104. package/test/cue-push-carries-what-the-walk-found.test.js +0 -58
  105. package/test/cut-times-timeline.test.js +0 -65
  106. package/test/cuts-follow-published-grid.test.js +0 -109
  107. package/test/data-channel-frame.test.js +0 -63
  108. package/test/data-channel-viewer-lifecycle.test.js +0 -193
  109. package/test/dead-torrent-handle.test.js +0 -52
  110. package/test/declared-rates.test.js +0 -116
  111. package/test/declared-tracks.test.js +0 -43
  112. package/test/decode-cost-fit.test.js +0 -202
  113. package/test/decode-cost.test.js +0 -475
  114. package/test/delivery-probe-field.test.js +0 -173
  115. package/test/delivery-probe.test.js +0 -382
  116. package/test/demand-register.test.js +0 -206
  117. package/test/dht-bootstrap.test.js +0 -72
  118. package/test/drain-threat.test.js +0 -65
  119. package/test/duplicate-add.test.js +0 -48
  120. package/test/encode-admission.test.js +0 -170
  121. package/test/encode-cost.test.js +0 -275
  122. package/test/encode-exit.test.js +0 -111
  123. package/test/encode-orchestrator.test.js +0 -426
  124. package/test/encode-plan-viewers.test.js +0 -725
  125. package/test/encode-plan.test.js +0 -545
  126. package/test/encode-run-state.test.js +0 -314
  127. package/test/encode-run.test.js +0 -248
  128. package/test/encode-slope.test.js +0 -46
  129. package/test/encoded-output.test.js +0 -39
  130. package/test/encoder-kinds.test.js +0 -122
  131. package/test/encoder-readings.test.js +0 -68
  132. package/test/episode-naming.test.js +0 -131
  133. package/test/every-claimant-is-told-its-share.test.js +0 -152
  134. package/test/failed-encoder-still-serves-what-exists.test.js +0 -354
  135. package/test/failed-start-breaker.test.js +0 -202
  136. package/test/fastest-wires.test.js +0 -143
  137. package/test/ffmpeg-stream-counts.test.js +0 -101
  138. package/test/file-edges.test.js +0 -191
  139. package/test/files-in-use.test.js +0 -34
  140. package/test/fixtures/audio-frame-boundary.json +0 -1
  141. package/test/fixtures/refused-audio-20261002.json +0 -10
  142. package/test/fmp4-presentation-clock.test.js +0 -148
  143. package/test/fmp4-self-contained.test.js +0 -80
  144. package/test/fmp4-timestamps.test.js +0 -140
  145. package/test/fmp4-track-completeness.test.js +0 -41
  146. package/test/format-keyed-outputs.test.js +0 -78
  147. package/test/health-metrics.test.js +0 -36
  148. package/test/held-read-ownership.test.js +0 -77
  149. package/test/held-request-width.test.js +0 -157
  150. package/test/helpers/encode-run.js +0 -248
  151. package/test/helpers/held-file.js +0 -77
  152. package/test/helpers/manager.js +0 -62
  153. package/test/helpers/matroska-file.js +0 -296
  154. package/test/helpers/output-spec.js +0 -63
  155. package/test/host-load.test.js +0 -68
  156. package/test/index-accuracy-is-about-the-file.test.js +0 -128
  157. package/test/init-compat.test.js +0 -219
  158. package/test/input-lost-quiets-the-plan.test.js +0 -261
  159. package/test/input-unavailable.test.js +0 -48
  160. package/test/installation-id.test.js +0 -39
  161. package/test/keeping-period.test.js +0 -83
  162. package/test/keyframe-index-accuracy.test.js +0 -188
  163. package/test/keyframe-table-budget.test.js +0 -123
  164. package/test/keyframes-belong-to-the-file.test.js +0 -249
  165. package/test/learned-median.test.js +0 -37
  166. package/test/letting-go-asks-who-is-watching.test.js +0 -191
  167. package/test/limit-row-by-area.test.js +0 -60
  168. package/test/link-budget.test.js +0 -111
  169. package/test/link-probe.test.js +0 -56
  170. package/test/local-encoding-data.test.js +0 -273
  171. package/test/logger-repeats.test.js +0 -134
  172. package/test/look-ahead-coverage.test.js +0 -56
  173. package/test/manager-surface.test.js +0 -291
  174. package/test/matroska-blocks.test.js +0 -0
  175. package/test/matroska-cluster-discovery.test.js +0 -323
  176. package/test/matroska-cues-track.test.js +0 -192
  177. package/test/media-time.test.js +0 -62
  178. package/test/memory-budget.test.js +0 -390
  179. package/test/move-cost.test.js +0 -216
  180. package/test/mp4-composition-times.test.js +0 -0
  181. package/test/mp4-subtitles.test.js +0 -173
  182. package/test/one-authority.test.js +0 -285
  183. package/test/one-budget.test.js +0 -149
  184. package/test/one-output-one-session.test.js +0 -273
  185. package/test/one-resource-divided.test.js +0 -185
  186. package/test/one-serving-output-per-height.test.js +0 -458
  187. package/test/orchestrator-wired.test.js +0 -207
  188. package/test/output-catalog.test.js +0 -39
  189. package/test/output-shape.test.js +0 -21
  190. package/test/output-spec.test.js +0 -251
  191. package/test/output-speed.test.js +0 -92
  192. package/test/outputs-being-prepared.test.js +0 -40
  193. package/test/packet-witness-ring.test.js +0 -236
  194. package/test/packet-witness.test.js +0 -148
  195. package/test/piece-completeness.test.js +0 -78
  196. package/test/piece-disk-store.test.js +0 -401
  197. package/test/piece-from-whole-file.test.js +0 -129
  198. package/test/piece-lru.test.js +0 -319
  199. package/test/piece-reader.test.js +0 -162
  200. package/test/piece-selection-offset.test.js +0 -85
  201. package/test/piece-store-eviction.test.js +0 -595
  202. package/test/piece-store-never-refuses.test.js +0 -153
  203. package/test/piece-store-reservations.test.js +0 -403
  204. package/test/piece-store-slow-disk.test.js +0 -159
  205. package/test/piece-tail.test.js +0 -97
  206. package/test/plan-host-timings.test.js +0 -68
  207. package/test/plan-is-steady.test.js +0 -225
  208. package/test/playback-readiness.test.js +0 -518
  209. package/test/playback-start-regressions.test.js +0 -101
  210. package/test/priority-map-download.test.js +0 -174
  211. package/test/priority-map-emptied.test.js +0 -204
  212. package/test/priority-map-per-output.test.js +0 -215
  213. package/test/priority-map.test.js +0 -163
  214. package/test/probe-wedge-certainty.test.js +0 -60
  215. package/test/produced-copy-choice.test.js +0 -213
  216. package/test/published-timeline.test.js +0 -83
  217. package/test/quality-offer.test.js +0 -160
  218. package/test/quality-variants.test.js +0 -1274
  219. package/test/rate-control-in-the-output.test.js +0 -181
  220. package/test/read-survives-withdrawal.test.js +0 -164
  221. package/test/read-window.test.js +0 -365
  222. package/test/reads-not-yet-arrived.test.js +0 -218
  223. package/test/resume-warm.test.js +0 -39
  224. package/test/run-graph-drift.test.js +0 -37
  225. package/test/run-inputs.test.js +0 -80
  226. package/test/run-intervals.test.js +0 -100
  227. package/test/run-position-follows-published-grid.test.js +0 -89
  228. package/test/run-progress.test.js +0 -64
  229. package/test/same-height-switch.test.js +0 -663
  230. package/test/seek-frees-the-viewers-requests.test.js +0 -217
  231. package/test/seek-landing.test.js +0 -109
  232. package/test/seek-target-not-superseded.test.js +0 -195
  233. package/test/segment-closed-on-both-branches.test.js +0 -114
  234. package/test/segment-cut-times.test.js +0 -63
  235. package/test/segment-demand.test.js +0 -80
  236. package/test/segment-hold-supersede.test.js +0 -91
  237. package/test/segment-judged-by-its-own-header.test.js +0 -222
  238. package/test/segment-serve-wiring.test.js +0 -481
  239. package/test/segment-store-eviction.test.js +0 -247
  240. package/test/segment-store.test.js +0 -352
  241. package/test/segment-true-start.test.js +0 -86
  242. package/test/segments-are-shared.test.js +0 -176
  243. package/test/send-chunk-bytes.test.js +0 -86
  244. package/test/serving-existing-output.test.js +0 -111
  245. package/test/serving-output.test.js +0 -102
  246. package/test/shared-piece-store.test.js +0 -310
  247. package/test/sidecar-naming.test.js +0 -142
  248. package/test/soundtrack-rate-bound.test.js +0 -289
  249. package/test/source-file.test.js +0 -133
  250. package/test/source-files-route.test.js +0 -113
  251. package/test/source-registry.test.js +0 -18
  252. package/test/split-a-stretch.test.js +0 -139
  253. package/test/stale-request-after-seek.test.js +0 -158
  254. package/test/startup-readings.test.js +0 -124
  255. package/test/stream-route.test.js +0 -237
  256. package/test/subtitle-cue-framing.test.js +0 -200
  257. package/test/subtitle-cue-source.test.js +0 -102
  258. package/test/subtitle-cue-walk.test.js +0 -385
  259. package/test/subtitle-cursor.test.js +0 -63
  260. package/test/subtitle-defaults.test.js +0 -118
  261. package/test/subtitle-language.test.js +0 -252
  262. package/test/subtitle-metadata.test.js +0 -15
  263. package/test/subtitle-plan-waits.test.js +0 -125
  264. package/test/subtitle-track-numbering.test.js +0 -391
  265. package/test/supply-margin.test.js +0 -190
  266. package/test/swarm-follows-readers.test.js +0 -174
  267. package/test/swarm-reach.test.js +0 -130
  268. package/test/swarm-selection.test.js +0 -220
  269. package/test/tail-duplication.test.js +0 -167
  270. package/test/the-channel-has-both-ends.test.js +0 -91
  271. package/test/the-torrent-thread-serves-bytes.test.js +0 -107
  272. package/test/thread-pool.test.js +0 -53
  273. package/test/timeline.test.js +0 -84
  274. package/test/torrent-contents.test.js +0 -235
  275. package/test/torrent-cost.test.js +0 -71
  276. package/test/torrent-files.test.js +0 -221
  277. package/test/torrent-naming.test.js +0 -255
  278. package/test/tracks-begin-together.test.js +0 -209
  279. package/test/tunnel-renewal.test.js +0 -167
  280. package/test/two-viewers-one-picture.test.js +0 -508
  281. package/test/upload-hurry.test.js +0 -186
  282. package/test/usrsctp-state.test.js +0 -131
  283. package/test/video-facts.test.js +0 -102
  284. package/test/viewer-assignments.test.js +0 -143
  285. package/test/viewer-outputs.test.js +0 -285
  286. package/test/viewer-presence.test.js +0 -116
  287. package/test/viewer-requests-admission.test.js +0 -92
  288. package/test/viewer-starts-on-map.test.js +0 -27
  289. package/test/viewer-states.test.js +0 -192
  290. package/test/viewer-subtitle-subscription.test.js +0 -96
  291. package/test/viewer.test.js +0 -139
  292. package/test/viewing-generations.test.js +0 -390
  293. package/test/visible-rung.test.js +0 -37
  294. package/test/wait-ledger.test.js +0 -100
  295. package/test/webrtc-shim.test.js +0 -149
  296. package/test/wedge-certainty.test.js +0 -131
  297. package/test/whole-file-held-read.test.js +0 -30
  298. package/test/withdraw-piece-claim.test.js +0 -212
  299. package/test/worker-channel.test.js +0 -158
@@ -1,131 +0,0 @@
1
- /**
2
- * @file The usrsctp state reader's gating and command construction.
3
- *
4
- * Roadmap item 11: reads usrsctp's live association state via gdb the moment
5
- * a wedge is declared. Everything here is the part that decides WHEN and WITH
6
- * WHAT ARGUMENTS — the spawning itself is thin glue around these, the same
7
- * shape as the packet witness (test/packet-witness.test.js).
8
- */
9
-
10
- import test from "node:test";
11
- import assert from "node:assert/strict";
12
- import { EventEmitter } from "node:events";
13
-
14
- import { createUsrsctpStateReader, SCTPSTATE_SCRIPT_PATH } from "../services/transport/usrsctp-state.js";
15
-
16
- class FakeChild extends EventEmitter {
17
- constructor(command, args) {
18
- super();
19
- this.command = command;
20
- this.args = args;
21
- this.stdout = new EventEmitter();
22
- this.stderr = new EventEmitter();
23
- }
24
-
25
- kill() {
26
- // Not exercised by these tests — every fake run finishes on its own.
27
- return true;
28
- }
29
- }
30
-
31
- /**
32
- * @param {string} output - What the fake gdb writes to stdout before closing.
33
- * @returns {{ spawnProcess: Function, calls: Array<{ command: string, args: string[] }> }}
34
- */
35
- function makeSpawn(output) {
36
- const calls = [];
37
- const spawnProcess = (command, args) => {
38
- calls.push({ command, args });
39
- const child = new FakeChild(command, args);
40
- setImmediate(() => {
41
- if (output) {
42
- child.stdout.emit("data", Buffer.from(output));
43
- }
44
- child.emit("close", 0);
45
- });
46
- return child;
47
- };
48
- return { spawnProcess, calls };
49
- }
50
-
51
- /** Wait until `check` holds, rather than for a chosen interval. */
52
- async function waitFor(check) {
53
- const deadline = Date.now() + 5_000;
54
- for (;;) {
55
- if (check()) {
56
- return;
57
- }
58
- if (Date.now() > deadline) {
59
- throw new Error("timed out waiting for the reading to be logged");
60
- }
61
- await new Promise((resolve) => setTimeout(resolve, 5));
62
- }
63
- }
64
-
65
- test("the deadline is counted outside the process gdb stops", async () => {
66
- const { spawnProcess, calls } = makeSpawn("state=8 rwnd=95890\n");
67
- const lines = [];
68
- const reader = createUsrsctpStateReader({
69
- log: (message) => lines.push(message),
70
- spawnProcess,
71
- pid: 4242
72
- });
73
- const started = reader.maybeRead("test wedge");
74
- assert.equal(started, true);
75
- await waitFor(() => lines.length > 0);
76
- assert.equal(calls.length, 1);
77
- // `timeout` is a separate process, so it keeps counting while gdb holds every
78
- // thread of this one. A `setTimeout` here cannot fire — measured in the field
79
- // on 2026-09-05, where gdb held the proxy for four minutes and the guard set
80
- // for fifteen seconds never ran.
81
- assert.equal(calls[0].command, "timeout");
82
- assert.deepEqual(calls[0].args, [
83
- "-s",
84
- "KILL",
85
- "15",
86
- "gdb",
87
- "-q",
88
- "-batch",
89
- "-p",
90
- "4242",
91
- "-x",
92
- SCTPSTATE_SCRIPT_PATH
93
- ]);
94
- assert.match(lines[0], /state=8 rwnd=95890/);
95
- assert.match(lines[0], /test wedge/);
96
- });
97
-
98
- test("no output is reported as no reading, not silence", async () => {
99
- const { spawnProcess } = makeSpawn("");
100
- const lines = [];
101
- const reader = createUsrsctpStateReader({ log: (message) => lines.push(message), spawnProcess, pid: 1 });
102
- reader.maybeRead("empty case");
103
- await waitFor(() => lines.length > 0);
104
- assert.match(lines[0], /no reading/);
105
- });
106
-
107
- test("a second read is refused within the cooldown, like the packet witness", async () => {
108
- const { spawnProcess, calls } = makeSpawn("state=8\n");
109
- const lines = [];
110
- const reader = createUsrsctpStateReader({
111
- log: (message) => lines.push(message),
112
- spawnProcess,
113
- pid: 1,
114
- cooldownMs: 60_000
115
- });
116
- assert.equal(reader.maybeRead("first"), true);
117
- await waitFor(() => lines.length > 0);
118
- assert.equal(reader.maybeRead("second, too soon"), false);
119
- assert.equal(calls.length, 1);
120
- });
121
-
122
- test("a missing gdb is reported, not thrown", async () => {
123
- const lines = [];
124
- const spawnProcess = () => {
125
- throw new Error("spawn gdb ENOENT");
126
- };
127
- const reader = createUsrsctpStateReader({ log: (message) => lines.push(message), spawnProcess, pid: 1 });
128
- reader.maybeRead("no gdb on this host");
129
- await waitFor(() => lines.length > 0);
130
- assert.match(lines[0], /could not start gdb/);
131
- });
@@ -1,102 +0,0 @@
1
- /**
2
- * @file The picture's facts, from the two readings that state them.
3
- *
4
- * Audio and subtitles have had this reconciliation since their flags were first
5
- * read from the file. Video never did: every figure the encode is planned from
6
- * came from ffmpeg's `-i` banner alone, and the `VideoTrack` the container
7
- * declares was read and then used for nothing but a line in the log — so a file
8
- * that states its bit depth or its HDR signalling, against a probe that does
9
- * not print them, disagreed with nobody watching.
10
- *
11
- * What each check pins is WHICH reading answers, and why: the size and the
12
- * frame rate are what the decoder will produce, so the probe answers; the bit
13
- * depth and the HDR signalling are the file saying how its samples are to be
14
- * read, so the container answers.
15
- */
16
-
17
- import test from "node:test";
18
- import assert from "node:assert/strict";
19
-
20
- import { Container } from "../services/media/container/Container.js";
21
-
22
- const banner = (fields) => ({ width: null, height: null, fps: null, isHdr: false, bitDepth: null, ...fields });
23
-
24
- test("with no container reading the probe answers for everything", () => {
25
- const facts = Container.mergeVideoFacts(
26
- banner({ width: 1920, height: 1080, fps: 23.976, isHdr: true, bitDepth: 10 }),
27
- null
28
- );
29
- assert.equal(facts.width, 1920);
30
- assert.equal(facts.height, 1080);
31
- assert.equal(facts.fps, 23.976);
32
- assert.equal(facts.isHdr, true);
33
- assert.equal(facts.bitDepth, 10);
34
- assert.deepEqual(facts.disagreements, []);
35
- });
36
-
37
- test("the size and the frame rate are the probe's, because that is what will be decoded", () => {
38
- const facts = Container.mergeVideoFacts(
39
- banner({ width: 1920, height: 1080, fps: 24 }),
40
- { width: 1440, height: 1080, fps: 25, displayWidth: 1920, displayHeight: 1080 }
41
- );
42
- assert.equal(facts.width, 1920, "the ladder and the scale filter are sized to the decoded frame");
43
- assert.equal(facts.height, 1080);
44
- assert.equal(facts.fps, 24);
45
- });
46
-
47
- test("the bit depth and the HDR signalling are the file's, because the file states them", () => {
48
- // A ten-bit HEVC whose probe printed neither: this is the case that decides
49
- // whether tone mapping runs and how the decode cost is priced.
50
- const facts = Container.mergeVideoFacts(
51
- banner({ width: 3840, height: 2160, fps: 24 }),
52
- { width: 3840, height: 2160, bitDepth: 10, isHdr: true }
53
- );
54
- assert.equal(facts.bitDepth, 10);
55
- assert.equal(facts.isHdr, true);
56
- assert.deepEqual(facts.disagreements, [], "one side saying nothing is not a disagreement");
57
- });
58
-
59
- test("a field only the probe states is still answered", () => {
60
- const facts = Container.mergeVideoFacts(
61
- banner({ width: 1280, height: 720, fps: 30, bitDepth: 8 }),
62
- { width: null, height: null, fps: null, bitDepth: null }
63
- );
64
- assert.equal(facts.width, 1280);
65
- assert.equal(facts.bitDepth, 8);
66
- });
67
-
68
- test("a real disagreement is reported, with both values and which is which", () => {
69
- const facts = Container.mergeVideoFacts(
70
- banner({ width: 1920, height: 1080, bitDepth: 8, isHdr: false }),
71
- { width: 1280, height: 720, bitDepth: 10, isHdr: true }
72
- );
73
- assert.equal(facts.disagreements.length, 3);
74
- assert.ok(facts.disagreements.some((line) => /width 1280 in the container against 1920 in the probe/.test(line)));
75
- assert.ok(facts.disagreements.some((line) => /bit depth 10 in the container against 8 in the probe/.test(line)));
76
- // HDR is not among them, and cannot be: see `mergeVideoFacts`.
77
- assert.ok(!facts.disagreements.some((line) => /HDR/.test(line)));
78
- // And the rule still decides: the probe for the size, the file for the rest.
79
- assert.equal(facts.width, 1920);
80
- assert.equal(facts.bitDepth, 10);
81
- assert.equal(facts.isHdr, true);
82
- });
83
-
84
- test("the display size is the container's alone — the probe has no such field", () => {
85
- const facts = Container.mergeVideoFacts(
86
- banner({ width: 1440, height: 1080 }),
87
- { width: 1440, height: 1080, displayWidth: 1920, displayHeight: 1080 }
88
- );
89
- assert.equal(facts.displayWidth, 1920);
90
- assert.equal(facts.displayHeight, 1080);
91
- });
92
-
93
- test("zero and nonsense are not values", () => {
94
- const facts = Container.mergeVideoFacts(
95
- banner({ width: 0, height: 0, fps: 0, bitDepth: 0 }),
96
- { width: 1920, height: 1080, fps: 24, bitDepth: 8 }
97
- );
98
- assert.equal(facts.width, 1920, "a probe that printed nothing does not outrank a file that speaks");
99
- assert.equal(facts.fps, 24);
100
- assert.equal(facts.bitDepth, 8);
101
- assert.deepEqual(facts.disagreements, []);
102
- });
@@ -1,143 +0,0 @@
1
- /**
2
- * @file What a viewer's own assignment to an output must do.
3
- *
4
- * The property under all of it: one viewer's link, seek or choice never moves
5
- * another viewer's output. That is roadmap item 97, and the record it replaces
6
- * — one entry per (file, height) for everybody — could not express it.
7
- */
8
-
9
- import test from "node:test";
10
- import assert from "node:assert/strict";
11
- import { Assignments } from "../services/viewer/Assignments.js";
12
-
13
- const WINDOW_MS = 30_000;
14
- const make = () => new Assignments({ acceptWindowMs: WINDOW_MS });
15
-
16
- test("the acceptance window is given, not invented", () => {
17
- assert.throws(() => new Assignments({}), TypeError);
18
- assert.throws(() => new Assignments({ acceptWindowMs: -1 }), TypeError);
19
- });
20
-
21
- test("a choice stands until the rule moves it, and a request never moves it", () => {
22
- const mine = make();
23
-
24
- mine.choose(720, "out-a");
25
- mine.give(mine.generation, 720, 4, "out-a");
26
-
27
- assert.equal(mine.chosenFor(720), "out-a");
28
- mine.give(mine.generation, 720, 5, "out-b");
29
- assert.equal(mine.chosenFor(720), "out-a", "being given something else does not re-choose");
30
- mine.choose(720, "out-b");
31
- assert.equal(mine.chosenFor(720), "out-b");
32
- });
33
-
34
- test("two viewers do not share an answer", () => {
35
- const thin = make();
36
- const thick = make();
37
-
38
- thin.choose(720, "out-540-slow");
39
- thick.choose(720, "out-720-full");
40
-
41
- assert.equal(thin.chosenFor(720), "out-540-slow");
42
- assert.equal(thick.chosenFor(720), "out-720-full", "the other viewer's link decided nothing here");
43
- });
44
-
45
- test("a repeat of a request is answered by whatever answered it, not decided again", () => {
46
- const mine = make();
47
- const first = mine.generation;
48
-
49
- mine.give(first, 720, 9, "out-a");
50
-
51
- assert.equal(mine.givenFor(first, 720, 9), "out-a");
52
- assert.equal(mine.givenFor(first, 720, 10), "", "a segment nothing answered says so");
53
- assert.equal(mine.givenFor(first + 1, 720, 9), "", "and a later generation is not the same question");
54
- });
55
-
56
- test("a request carries its own generation, so a late one is not read against the current", () => {
57
- const mine = make();
58
- const made = mine.generation;
59
-
60
- mine.statedGeneration(1, 1_000);
61
-
62
- assert.equal(mine.generation, 1);
63
- assert.notEqual(mine.generation, made);
64
- assert.equal(mine.accepts(made, 1_000 + WINDOW_MS), true, "inside the window it is still answered");
65
- assert.equal(mine.accepts(made, 1_000 + WINDOW_MS + 1), false, "past it, a new request is not");
66
- assert.equal(mine.accepts(mine.generation, 1_000 + 10 * WINDOW_MS), true, "the current one never runs out");
67
- });
68
-
69
- test("an accepted response keeps its output past its own generation's window", () => {
70
- const mine = make();
71
- const made = mine.generation;
72
- mine.give(made, 720, 3, "out-a");
73
- const token = mine.accept("out-a");
74
-
75
- mine.statedGeneration(1, 1_000);
76
- mine.choose(720, "out-b");
77
-
78
- const longAfter = 1_000 + 10 * WINDOW_MS;
79
- assert.equal(mine.accepts(made, longAfter), false, "no NEW request of that generation is taken");
80
- assert.ok(
81
- mine.heldKeys(longAfter).has("out-a"),
82
- "but the output whose bytes are still going out is held — dropping it is what the second deadline exists to prevent"
83
- );
84
-
85
- mine.release(token);
86
- assert.equal(mine.heldKeys(longAfter).has("out-a"), false, "and released once the response is done");
87
- });
88
-
89
- test("what was given inside a live generation holds its output, and is forgotten past the window", () => {
90
- const mine = make();
91
- const made = mine.generation;
92
- mine.give(made, 720, 3, "out-a");
93
- mine.statedGeneration(1, 1_000);
94
-
95
- assert.ok(mine.heldKeys(1_000 + WINDOW_MS).has("out-a"));
96
- assert.equal(
97
- mine.heldKeys(1_000 + WINDOW_MS + 1).has("out-a"),
98
- false,
99
- "past the window nothing stands on it, so it may be let go"
100
- );
101
- assert.equal(
102
- mine.givenFor(made, 720, 3),
103
- "",
104
- "and the generation is pruned rather than kept for the life of the viewing"
105
- );
106
- });
107
-
108
- test("a viewer leaving holds nothing, response in flight included", () => {
109
- const mine = make();
110
- mine.choose(720, "out-a");
111
- mine.accept("out-b");
112
- mine.give(mine.generation, 720, 1, "out-c");
113
-
114
- mine.clear();
115
-
116
- assert.equal(mine.heldKeys().size, 0);
117
- });
118
-
119
- test("the page owns the number, so a lower one does not take the viewer back", () => {
120
- const mine = make();
121
- mine.statedGeneration(4, 1_000);
122
-
123
- assert.equal(mine.statedGeneration(2, 2_000), 4, "a reordered or repeated message changes nothing");
124
- assert.equal(mine.statedGeneration(4, 2_000), 4, "and neither does the one already current");
125
- assert.equal(mine.generation, 4);
126
- });
127
-
128
- test("a request ahead of what the page has announced is still answered", () => {
129
- const mine = make();
130
-
131
- // The page raises its own number before it sends anything, so a segment
132
- // request can overtake the seek that announces the new viewing.
133
- assert.equal(mine.accepts(1), true, "being behind the page means this side has not heard yet");
134
- assert.equal(mine.accepts(7), true);
135
- });
136
-
137
- test("a request that states no generation is answered", () => {
138
- const mine = make();
139
- mine.statedGeneration(3, 1_000);
140
-
141
- assert.equal(mine.accepts(Number.NaN, 1_000 + 10 * WINDOW_MS), true);
142
- assert.equal(mine.accepts(undefined, 1_000 + 10 * WINDOW_MS), true, "a transport with no loader of ours builds no such parameter");
143
- });
@@ -1,285 +0,0 @@
1
- /**
2
- * @file What a viewer is watching, and what happens to it when they leave.
3
- *
4
- * The relation "this person watches this output" is stored on the viewer, and
5
- * these checks hold the operations that derive the opposite direction.
6
- *
7
- * 1. The viewer must never be dropped from the PICTURE. Their chosen
8
- * soundtrack, their position and their step are recorded there, and the
9
- * picture is the only id the browser knows. Dropping them was reachable by
10
- * an ordinary sequence — down a step, back to the picture's own height, down
11
- * again — and cost them the soundtrack they had picked.
12
- * 2. A viewer who leaves must be subtracted from EVERY output they were
13
- * watching, and an output with nobody left must be let go. Nothing outside
14
- * the session manager knows the id of a quality step or of a separately
15
- * published soundtrack, so nothing else can ever release one.
16
- * 3. Whatever removes a viewer from an output must release what their watching
17
- * claimed of production. The only place a claim is released is the plan's
18
- * pass over the output's viewers, so a viewer deleted by any other route
19
- * leaves a claim that nothing can reach.
20
- */
21
-
22
- import { chooseOutput } from "../services/viewer/choices.js";
23
- import test from "node:test";
24
- import assert from "node:assert/strict";
25
- import { mkdtemp, rm } from "node:fs/promises";
26
- import os from "node:os";
27
- import path from "node:path";
28
- import { wireOutputs } from "../services/server/wire-outputs.js";
29
- import { Viewers } from "../services/viewer/Viewers.js";
30
- import { SourceFile } from "../services/media/SourceFile.js";
31
- import { Timeline } from "../services/encode/output/Timeline.js";
32
- import { Output } from "../services/encode/output/Output.js";
33
- import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
34
- import { fakeProcess as fakeEncoder, startRunOn } from "./helpers/encode-run.js";
35
- import { outputSpec } from "./helpers/output-spec.js";
36
-
37
- const BASE_ID = "aaaaaaaabbbbcccc";
38
- const STEP_ID = "1111111122223333";
39
- const AUDIO_ID = "9999999988887777";
40
- const VIEWER = "viewer-one";
41
- const SEGMENT_SECONDS = 4;
42
-
43
- /**
44
- * A session shaped like a live one, without the ffmpeg run behind it.
45
- *
46
- * @param {{ id: string, dirPath: string, file: SourceFile, encodeHeight?: number,
47
- * audioOnly?: boolean, isStep?: boolean }} params
48
- * @returns {object}
49
- */
50
- function fakeSession({ id, dirPath, file, encodeHeight = 0, audioOnly = false, isStep = false }) {
51
- return {
52
- id,
53
- spec: outputSpec({
54
- sourceKey: file.sourceKey,
55
- fileIndex: file.fileIndex,
56
- transcodeVideo: !audioOnly && encodeHeight > 0,
57
- audioOnly,
58
- height: encodeHeight
59
- }),
60
- dirPath,
61
- timeline: new Timeline({
62
- boundaries: Array.from({ length: 101 }, (_, index) => index * SEGMENT_SECONDS),
63
- cutGrid: "uniform"
64
- }),
65
- state: "ready",
66
- file,
67
- get inputFile() { return this.file; },
68
- get audioFile() { return this.file; },
69
- startedAt: Date.now(),
70
- lastAccessedAt: Date.now(),
71
- ffmpeg: null,
72
- lastError: "",
73
- segmentFormat: fmp4Format,
74
- transcodeVideo: !audioOnly,
75
- transcodeAudio: true,
76
- audioTrackIndex: 0,
77
- audioOnly,
78
- isStep,
79
- variantHeight: isStep ? encodeHeight : undefined,
80
- // Left set so that disposal does not remove a directory the other sessions
81
- // of this output are still named by.
82
- outputKey: `output-${id}`,
83
- output: new Output({
84
- encodeWidth: 0,
85
- encodeHeight,
86
- outputFps: 24,
87
- softwarePreset: null,
88
- applyTonemap: false
89
- }),
90
- encodeRunGeneration: 0,
91
- failedStartAt: -1,
92
- failedStartCount: 0,
93
- seekSettleTimer: null,
94
- seekTarget: null,
95
- waitEpoch: 0,
96
- runs: new Set(),
97
- usesExplicitCuts: false,
98
- useSyntheticPlaylist: true,
99
- playlistText: "#EXTM3U\n",
100
- segmentCount: 100,
101
- progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0 }
102
- };
103
- }
104
-
105
- /**
106
- * A picture with one quality step and one separately published soundtrack, all
107
- * of one file, and one viewer watching all three — which is what an ordinary
108
- * session looks like once the viewer has picked a language and a quality.
109
- *
110
- * @returns {Promise<{ manager: object, base: object, step: object,
111
- * audio: object, dirPath: string, released: string[] }>}
112
- */
113
- async function pictureWithStepAndSoundtrack() {
114
- const dirPath = await mkdtemp(path.join(os.tmpdir(), "viewer-outputs-"));
115
- const manager = wireOutputs({
116
- enabled: true,
117
- ffmpegBin: "ffmpeg",
118
- localBindHost: "127.0.0.1",
119
- localPort: 9090
120
- });
121
- const file = new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" })
122
- .learn({ width: 1920, height: 1080, fps: 24, bitrateKbps: 8000 });
123
- const base = fakeSession({ id: BASE_ID, dirPath, file, encodeHeight: 812 });
124
- const step = fakeSession({ id: STEP_ID, dirPath, file, encodeHeight: 540, isStep: true });
125
- const audio = fakeSession({ id: AUDIO_ID, dirPath, file, audioOnly: true });
126
- // The browser never learns these two ids: what keeps them is who watches
127
- // them, and nothing else.
128
- for (const session of [base, step, audio]) {
129
- manager.outputs.set(session.id, session);
130
- }
131
- manager.outputs.markStep(step);
132
- return { manager, base, step, audio, dirPath };
133
- }
134
-
135
- test("a viewer who steps down, back to the picture's own height and down again keeps their record", async (t) => {
136
- const { manager, base, dirPath } = await pictureWithStepAndSoundtrack();
137
- t.after(() => rm(dirPath, { recursive: true, force: true }));
138
- startRunOn(base, { process: fakeEncoder() });
139
- const viewer = manager.viewers.of(base, VIEWER);
140
- viewer.audio = { trackIndex: 1, transcode: true };
141
- viewer.position = { segment: 25, seconds: 100, at: Date.now() };
142
- // The rule chose the step for them at 540p; the record is theirs.
143
- chooseOutput(manager.viewers, VIEWER, 540, manager.outputs.get(STEP_ID).outputKey);
144
-
145
- await manager.renditions.resolveVariantFile(BASE_ID, 540, "segment-00025.mp4", VIEWER);
146
- manager.renditions.viewerPlays(BASE_ID, VIEWER, 540, 100);
147
- await manager.renditions.resolveVariantFile(BASE_ID, 812, "segment-00026.mp4", VIEWER);
148
- manager.renditions.viewerPlays(BASE_ID, VIEWER, 812, 104);
149
- await manager.renditions.resolveVariantFile(BASE_ID, 540, "segment-00027.mp4", VIEWER);
150
- manager.renditions.viewerPlays(BASE_ID, VIEWER, 540, 108);
151
-
152
- const known = manager.viewers.forOutput(base).get(VIEWER);
153
- assert.ok(known, "the picture is the one id the browser holds — a viewer is never dropped from it");
154
- assert.deepEqual(
155
- known.audio,
156
- { trackIndex: 1, transcode: true },
157
- "the soundtrack they chose is recorded on the picture, and a quality switch does not touch it"
158
- );
159
- assert.equal(known.activeVariantId, STEP_ID, "and the step they moved to is where they are");
160
- });
161
-
162
- test("a viewer leaving is subtracted from every output, and one nobody is left watching is let go", async (t) => {
163
- const { manager, base, step, audio, dirPath } = await pictureWithStepAndSoundtrack();
164
- t.after(() => rm(dirPath, { recursive: true, force: true }));
165
- // Watching all three, which is what a viewer who picked a language and a
166
- // quality is doing.
167
- manager.viewers.of(base, VIEWER).position = { segment: 25, seconds: 100, at: Date.now() };
168
- manager.viewers.of(step, VIEWER);
169
- manager.viewers.of(audio, VIEWER);
170
- assert.deepEqual(
171
- [...manager.viewers.of(base, VIEWER).outputs].sort(),
172
- [BASE_ID, STEP_ID, AUDIO_ID].sort(),
173
- "one viewer, one object, and it knows all three outputs it is watching"
174
- );
175
-
176
- await manager.lifecycle.releaseSessionConsumer(BASE_ID, VIEWER, "the viewer left");
177
-
178
- assert.equal(manager.outputs.has(BASE_ID), false, "the picture goes with its last consumer");
179
- assert.equal(
180
- manager.outputs.has(STEP_ID),
181
- false,
182
- "and so does the quality step: nobody is watching it, and no one outside this class knows its id"
183
- );
184
- assert.equal(
185
- manager.outputs.has(AUDIO_ID),
186
- false,
187
- "and the soundtrack, for the same reason — it used to sit for half an hour holding an encoder, a directory and a claim on the torrent"
188
- );
189
- assert.equal(manager.viewers.size, 0, "and nobody is left in the registry, which must not be a map that only grows");
190
- });
191
-
192
- test("an output somebody else is still watching is kept when one viewer leaves", async (t) => {
193
- const { manager, base, step, audio, dirPath } = await pictureWithStepAndSoundtrack();
194
- t.after(() => rm(dirPath, { recursive: true, force: true }));
195
- const second = "viewer-two";
196
- manager.viewers.of(base, VIEWER).position = { segment: 25, seconds: 100, at: Date.now() };
197
- manager.viewers.of(step, VIEWER);
198
- manager.viewers.of(audio, VIEWER);
199
- // The second viewer is listening to the same soundtrack and watching the
200
- // picture at its own height.
201
- manager.viewers.of(base, second).position = { segment: 25, seconds: 100, at: Date.now() };
202
- manager.viewers.of(audio, second);
203
-
204
- await manager.lifecycle.releaseSessionConsumer(BASE_ID, VIEWER, "the first viewer left");
205
-
206
- assert.equal(manager.outputs.has(BASE_ID), true, "the picture stays: it still has a consumer");
207
- assert.equal(
208
- manager.outputs.has(AUDIO_ID),
209
- true,
210
- "and the soundtrack stays, because having no listeners is what kills it and it has one"
211
- );
212
- assert.equal(
213
- manager.viewers.forOutput(base).has(VIEWER),
214
- false,
215
- "the viewer who left is gone from the picture"
216
- );
217
- assert.equal(
218
- manager.viewers.forOutput(audio).has(VIEWER),
219
- false,
220
- "and from the soundtrack, which is the half of the relation the viewer holds"
221
- );
222
- assert.equal(manager.viewers.forOutput(step).size, 0, "the step they had is watched by nobody");
223
- });
224
-
225
- test("nothing is left wanting production once the last viewer has left", async (t) => {
226
- const { manager, base, step, audio, dirPath } = await pictureWithStepAndSoundtrack();
227
- t.after(() => rm(dirPath, { recursive: true, force: true }));
228
- manager.viewers.of(base, VIEWER).position = { segment: 25, seconds: 100, at: Date.now() };
229
- manager.viewers.of(step, VIEWER);
230
- manager.viewers.of(audio, VIEWER);
231
-
232
- await manager.lifecycle.releaseSessionConsumer(BASE_ID, VIEWER, "the viewer left");
233
-
234
- // NOTHING HAS TO BE RELEASED, and that is the point of the shape. A claim per
235
- // viewer per output used to be held in the encoding layer and taken back one
236
- // by one when they left — the viewer's own name as the key of a claim, which
237
- // is the coupling the layer rule forbids. What the plan is given now is a map
238
- // with nobody's name in it, rebuilt from whoever is watching; a viewer who has
239
- // left is simply not in the next one.
240
- for (const id of [BASE_ID, STEP_ID, AUDIO_ID]) {
241
- const session = manager.outputs.get(id);
242
- assert.equal(session, undefined, `${id.slice(0, 8)} is let go with its last viewer`);
243
- }
244
- manager.encodeRuns.planEncodersNow();
245
- for (const address of [base.outputKey, step.outputKey, audio.outputKey]) {
246
- assert.deepEqual(
247
- manager.encodeOrchestrator.wantedSegmentsOn(address),
248
- [],
249
- "and no output is left asking for an encoder"
250
- );
251
- }
252
- });
253
-
254
- test("the reverse view is derived from the relation stored on the viewer", () => {
255
- const viewers = new Viewers();
256
- const picture = { id: "picture" };
257
- const soundtrack = { id: "soundtrack" };
258
-
259
- const viewer = viewers.of(picture, VIEWER);
260
- viewers.of(soundtrack, VIEWER);
261
- assert.equal(viewers.of(soundtrack, VIEWER), viewer, "one person is one object, whatever they are watching");
262
- assert.deepEqual([...viewer.outputs].sort(), ["picture", "soundtrack"]);
263
- assert.equal(viewers.forOutput(picture).get(VIEWER), viewer);
264
-
265
- viewers.leaves(soundtrack, VIEWER);
266
- assert.deepEqual([...viewer.outputs], ["picture"], "the stored relation changes once");
267
- assert.equal(viewers.forOutput(soundtrack).has(VIEWER), false);
268
- assert.equal(viewers.size, 1, "and the person is still known, because they are still watching something");
269
-
270
- viewers.leaves(picture, VIEWER);
271
- assert.equal(viewers.size, 0, "watching nothing, they are forgotten");
272
- });
273
-
274
- test("a request that names nobody makes no viewer", () => {
275
- // A nameless viewer used to belong to whichever output met it: the same
276
- // person was a different object on each output, and two nameless people on
277
- // one output were one object. Every viewer has a name now.
278
- const viewers = new Viewers();
279
- const oneFilm = { id: "one" };
280
-
281
- assert.throws(() => viewers.of(oneFilm, ""), TypeError);
282
- assert.equal(viewers.size, 0, "nothing was registered");
283
- assert.equal(viewers.forOutput(oneFilm).size, 0, "and nobody watches the output");
284
- assert.equal(viewers.getForOutput(oneFilm, ""), null);
285
- });