@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.
- package/CHANGELOG.md +16 -0
- package/CLAUDE.md +17 -0
- package/bin/cli.js +520 -512
- package/docs/container-architecture.md +86 -0
- package/package.json +1 -1
- package/routes/api/playback-plan/post.js +5 -6
- package/routes/api/subtitles/get.js +39 -208
- package/services/container/AviContainer.js +45 -0
- package/services/container/Container.js +59 -0
- package/services/container/ContainerFactory.js +31 -0
- package/services/container/MatroskaContainer.js +289 -0
- package/services/container/Mp4Container.js +242 -0
- package/services/container/index.js +5 -0
- package/services/controllers/PlaybackController.js +33 -0
- package/services/controllers/SubtitleController.js +126 -0
- package/services/controllers/index.js +2 -0
- package/services/delivery-probe.js +532 -480
- package/services/memory-report.js +120 -15
- package/services/orchestrators/ContainerOrchestrator.js +89 -0
- package/services/orchestrators/SubtitleOrchestrator.js +101 -0
- package/services/orchestrators/index.js +2 -0
- package/services/piece-store/shared-piece-store.js +870 -791
- package/services/torrent-worker/worker.js +738 -706
- package/services/tracks/AudioTrack.js +40 -0
- package/services/tracks/ContainerTrack.js +72 -0
- package/services/tracks/ExternalSubtitleFile.js +27 -0
- package/services/tracks/ImageSubtitleTrack.js +19 -0
- package/services/tracks/SubtitleTrack.js +38 -0
- package/services/tracks/TextSubtitleTrack.js +29 -0
- package/services/tracks/VideoTrack.js +33 -0
- package/services/tracks/index.js +7 -0
- package/test/delivery-probe.test.js +213 -158
- package/test/memory-budget.test.js +89 -2
- 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
|
-
{
|
|
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
|
-
});
|