@torrent-tv/proxy 2.62.0 → 2.64.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 (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CLAUDE.md +17 -0
  3. package/bin/cli.js +520 -512
  4. package/docs/container-architecture.md +86 -0
  5. package/package.json +1 -1
  6. package/routes/api/playback-plan/post.js +5 -6
  7. package/routes/api/subtitles/get.js +39 -208
  8. package/services/container/AviContainer.js +45 -0
  9. package/services/container/Container.js +59 -0
  10. package/services/container/ContainerFactory.js +31 -0
  11. package/services/container/MatroskaContainer.js +289 -0
  12. package/services/container/Mp4Container.js +242 -0
  13. package/services/container/index.js +5 -0
  14. package/services/controllers/PlaybackController.js +33 -0
  15. package/services/controllers/SubtitleController.js +126 -0
  16. package/services/controllers/index.js +2 -0
  17. package/services/delivery-probe.js +532 -480
  18. package/services/memory-report.js +120 -15
  19. package/services/orchestrators/ContainerOrchestrator.js +89 -0
  20. package/services/orchestrators/SubtitleOrchestrator.js +101 -0
  21. package/services/orchestrators/index.js +2 -0
  22. package/services/piece-store/shared-piece-store.js +870 -791
  23. package/services/torrent-worker/worker.js +738 -706
  24. package/services/tracks/AudioTrack.js +40 -0
  25. package/services/tracks/ContainerTrack.js +72 -0
  26. package/services/tracks/ExternalSubtitleFile.js +27 -0
  27. package/services/tracks/ImageSubtitleTrack.js +19 -0
  28. package/services/tracks/SubtitleTrack.js +38 -0
  29. package/services/tracks/TextSubtitleTrack.js +29 -0
  30. package/services/tracks/VideoTrack.js +33 -0
  31. package/services/tracks/index.js +7 -0
  32. package/test/delivery-probe.test.js +213 -158
  33. package/test/memory-budget.test.js +89 -2
  34. package/test/worker-source-race.test.js +0 -76
@@ -4,8 +4,12 @@ import assert from "node:assert/strict";
4
4
  import { describeMemory } from "../services/memory-report.js";
5
5
  import {
6
6
  budgetForNewStore,
7
+ SharedPieceStore,
7
8
  totalStoreBudgetBytes
8
9
  } from "../services/piece-store/shared-piece-store.js";
10
+ import { mkdtemp, rm } from "node:fs/promises";
11
+ import os from "node:os";
12
+ import path from "node:path";
9
13
 
10
14
  const MEGABYTE = 1024 * 1024;
11
15
  const GIGABYTE = 1024 * MEGABYTE;
@@ -50,12 +54,18 @@ test("the memory line says bytes, and names what it could not measure", () => {
50
54
  availableBytes: 1900 * MEGABYTE,
51
55
  availableMeasured: true,
52
56
  stores: [
53
- { name: "a film", residentBytes: 504 * MEGABYTE, budgetBytes: 512 * MEGABYTE }
57
+ {
58
+ name: "a film",
59
+ residentBytes: 504 * MEGABYTE,
60
+ committedBytes: 504 * MEGABYTE,
61
+ spilledBytes: 0,
62
+ budgetBytes: 512 * MEGABYTE
63
+ }
54
64
  ]
55
65
  });
56
66
  // The figures the kernel's own kill line quoted, so the two can be compared.
57
67
  assert.match(measured, /rss=2366MB/);
58
- assert.match(measured, /1 torrent store\(s\) holding 504MB of 512MB allowed/);
68
+ assert.match(measured, /1 torrent store\(s\) holding 504MB, committed 504MB of 512MB allowed/);
59
69
  assert.match(measured, /machine has 1900MB available$/);
60
70
 
61
71
  const estimated = describeMemory({
@@ -67,3 +77,80 @@ test("the memory line says bytes, and names what it could not measure", () => {
67
77
  assert.match(estimated, /no torrent stores/);
68
78
  assert.match(estimated, /estimated — \/proc\/meminfo could not be read/);
69
79
  });
80
+
81
+ test("holding and having taken are separate figures, and the line says both", () => {
82
+ // The shape that hid 650 MB on 2026-08-28: eighteen pieces held, sixty-four
83
+ // slots taken and never given back, and a line that only mentioned the first.
84
+ const line = describeMemory({
85
+ process: { rss: 893 * MEGABYTE, heapUsed: 29 * MEGABYTE, heapTotal: 34 * MEGABYTE, external: 11 * MEGABYTE, arrayBuffers: 7 * MEGABYTE },
86
+ availableBytes: 2287 * MEGABYTE,
87
+ availableMeasured: true,
88
+ anonymousBytes: 870 * MEGABYTE,
89
+ diskFreeBytes: 12000 * MEGABYTE,
90
+ stores: [
91
+ {
92
+ name: "a film",
93
+ residentBytes: 144 * MEGABYTE,
94
+ committedBytes: 512 * MEGABYTE,
95
+ spilledBytes: 2904 * MEGABYTE,
96
+ budgetBytes: 512 * MEGABYTE
97
+ }
98
+ ]
99
+ });
100
+ assert.match(line, /holding 144MB, committed 512MB of 512MB allowed, 2904MB spilled to disk/);
101
+ assert.match(line, /anon=870MB/);
102
+ assert.match(line, /12000MB free on disk$/);
103
+ });
104
+
105
+ test("a thread's reading leaves out what belongs to the process", () => {
106
+ // `rss` and the kernel's rollup are process-wide and are read once, on the
107
+ // main thread. What the torrent worker can say — and the only place it can be
108
+ // said — is its own isolate, where the piece pool actually lives.
109
+ const line = describeMemory({
110
+ scope: "thread",
111
+ label: "torrent worker",
112
+ process: { rss: 893 * MEGABYTE, heapUsed: 12 * MEGABYTE, heapTotal: 20 * MEGABYTE, external: 530 * MEGABYTE, arrayBuffers: 512 * MEGABYTE },
113
+ stores: [
114
+ {
115
+ name: "a film",
116
+ residentBytes: 144 * MEGABYTE,
117
+ committedBytes: 512 * MEGABYTE,
118
+ spilledBytes: 0,
119
+ budgetBytes: 512 * MEGABYTE
120
+ }
121
+ ]
122
+ });
123
+ assert.match(line, /^memory \(torrent worker\): heap=12MB\/20MB/);
124
+ assert.match(line, /arrayBuffers=512MB/);
125
+ assert.doesNotMatch(line, /rss=/);
126
+ assert.doesNotMatch(line, /machine has/);
127
+ });
128
+
129
+ test("a store's allowance follows the machine, and never passes its reservation", async () => {
130
+ // The defect: a store created on an idle machine kept an idle machine's
131
+ // allowance for life and went on growing into memory the host no longer had.
132
+ const directory = await mkdtemp(path.join(os.tmpdir(), "budget-revision-"));
133
+ const store = new SharedPieceStore(1024, {
134
+ path: directory,
135
+ name: "revision",
136
+ memoryBytes: 64 * 1024
137
+ });
138
+ try {
139
+ const born = store.stats().budgetBytes;
140
+ assert.ok(born > 0);
141
+
142
+ // The machine fills up: the ceiling comes down and growth stops there.
143
+ const lowered = store.reviseGrowthCeiling(8 * 1024);
144
+ assert.ok(lowered.ceilingBytes < born, "a busier machine buys fewer slots");
145
+ assert.equal(store.stats().budgetBytes, lowered.ceilingBytes, "the line says what is allowed now");
146
+
147
+ // The machine empties again: the ceiling may rise, but never above the
148
+ // reservation, because `maxByteLength` was fixed from it and `grow()`
149
+ // cannot pass it.
150
+ const raised = store.reviseGrowthCeiling(1024 * 1024 * 1024);
151
+ assert.equal(raised.ceilingBytes, born, "the reservation is the hard limit");
152
+ } finally {
153
+ await new Promise((resolve) => store.destroy(resolve));
154
+ await rm(directory, { recursive: true, force: true });
155
+ }
156
+ });
@@ -1,76 +0,0 @@
1
- /**
2
- * @file Naming a source that is still being added.
3
- *
4
- * Adding a magnet takes as long as its metadata does — seconds to tens of
5
- * seconds — while the browser is already polling stats and the planner is
6
- * already asking for a plan. Until 2.9.77 the worker registered the torrent
7
- * only once the add had finished, so everything arriving in that window was
8
- * told `Unknown source`, which is false: the source exists, it is not ready.
9
- * Reproduced with a magnet nobody seeds — stats, the file listing and a read
10
- * all failed instantly while the add was in flight.
11
- */
12
-
13
- import test from "node:test";
14
- import assert from "node:assert/strict";
15
- import crypto from "node:crypto";
16
- import { TorrentWorkerClient } from "../services/torrent-worker/client.js";
17
-
18
- /** A well-formed infohash nobody is seeding, so the add stays in flight. */
19
- function pendingMagnet() {
20
- return `magnet:?xt=urn:btih:${crypto.randomBytes(20).toString("hex")}&dn=nobody-has-this`;
21
- }
22
-
23
- /**
24
- * What a promise did within `ms` — settled how, or still waiting.
25
- *
26
- * @param {Promise<unknown>} promise
27
- * @param {number} ms
28
- * @returns {Promise<string>}
29
- */
30
- function outcomeWithin(promise, ms) {
31
- return Promise.race([
32
- promise.then(() => "resolved", (error) => `rejected: ${error?.message}`),
33
- new Promise((resolve) => setTimeout(() => resolve("waiting"), ms))
34
- ]);
35
- }
36
-
37
- test("commands wait for a source that is still being added", async () => {
38
- const client = new TorrentWorkerClient({ memoryBytes: 16 * 1024 * 1024 });
39
- try {
40
- // Without this the worker's own startup would keep the commands waiting and
41
- // the test would pass for the wrong reason.
42
- await client.listFiles("warm-up").catch(() => undefined);
43
-
44
- const sourceKey = "pending-source";
45
- // Deliberately not awaited: this is the window under test.
46
- const adding = client.addSource({ sourceKey, sourceType: "magnet", source: pendingMagnet() });
47
- adding.catch(() => undefined);
48
-
49
- await new Promise((resolve) => setTimeout(resolve, 500));
50
-
51
- const stats = await outcomeWithin(client.getFileStats({ sourceKey, fileIndex: 0 }), 3000);
52
- const files = await outcomeWithin(client.listFiles(sourceKey), 3000);
53
-
54
- assert.equal(stats, "waiting", `stats did not wait for the add: ${stats}`);
55
- assert.equal(files, "waiting", `the file listing did not wait for the add: ${files}`);
56
- } finally {
57
- await client.destroyAll();
58
- }
59
- });
60
-
61
- test("a source that was never added is still reported as unknown", async () => {
62
- const client = new TorrentWorkerClient({ memoryBytes: 16 * 1024 * 1024 });
63
- try {
64
- // Starting the worker takes several seconds — it builds a torrent client,
65
- // a DHT and the rest — so the first command measures startup, not the
66
- // behaviour under test. Wait for one to come back before timing anything.
67
- await client.listFiles("warm-up").catch(() => undefined);
68
-
69
- // Waiting forever for something nobody ever asked for would be worse than
70
- // an error — this case must stay an error.
71
- const outcome = await outcomeWithin(client.listFiles("never-added"), 5000);
72
- assert.match(outcome, /^rejected: .*never-added/, `expected an error, got "${outcome}"`);
73
- } finally {
74
- await client.destroyAll();
75
- }
76
- });