@torrent-tv/proxy 2.80.18 → 2.81.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 +22 -0
- package/docs/encode-architecture.md +51 -2
- package/package.json +1 -1
- package/research/double-spawn-2026-09-10.md +171 -0
- package/services/disk/DiskSpace.js +146 -0
- package/services/disk/wire.js +60 -0
- package/services/encode/EncodeRun.js +37 -9
- package/services/encode/SegmentStore.js +284 -232
- package/services/encode/run-command.js +16 -1
- package/services/hls-session-manager.js +31 -128
- package/services/orchestrators/EncodeOrchestrator.js +52 -38
- package/services/piece-store/allowance.js +107 -0
- package/services/piece-store/piece-disk-store.js +365 -0
- package/services/piece-store/shared-piece-store.js +1549 -1535
- package/services/segment-formats/fmp4.js +54 -0
- package/services/segment-formats/mpegts.js +54 -0
- package/services/torrent-worker/client.js +32 -0
- package/services/torrent-worker/pool-adapter.js +15 -0
- package/services/torrent-worker/protocol.js +9 -0
- package/services/torrent-worker/worker.js +8 -1
- package/services/viewer/positions.js +48 -0
- package/test/audio-inventory.test.js +176 -176
- package/test/auto-quality-step.test.js +514 -514
- package/test/concurrent-cost.test.js +138 -138
- package/test/coverage-follows-the-disk.test.js +191 -187
- package/test/coverage-map.test.js +195 -195
- package/test/declared-tracks.test.js +35 -35
- package/test/disk-space.test.js +138 -0
- package/test/encode-orchestrator.test.js +0 -3
- package/test/encode-run.test.js +5 -12
- package/test/held-request-width.test.js +155 -155
- package/test/helpers/encode-run.js +2 -2
- package/test/matroska-blocks.test.js +0 -0
- package/test/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -0
- package/test/mp4-subtitles.test.js +173 -173
- package/test/one-authority.test.js +281 -220
- package/test/orchestrator-wired.test.js +199 -195
- package/test/packet-witness-ring.test.js +236 -236
- package/test/packet-witness.test.js +148 -148
- package/test/piece-disk-store.test.js +267 -0
- package/test/piece-reader.test.js +4 -4
- package/test/piece-store-eviction.test.js +17 -17
- package/test/piece-store-reservations.test.js +20 -1
- package/test/piece-store-slow-disk.test.js +16 -1
- package/test/produced-copy-choice.test.js +258 -358
- package/test/read-window.test.js +6 -6
- package/test/run-intervals.test.js +100 -100
- package/test/seek-landing.test.js +109 -109
- package/test/segment-serve-wiring.test.js +8 -9
- package/test/segment-store-eviction.test.js +232 -0
- package/test/segment-store.test.js +238 -216
- package/test/segments-are-shared.test.js +1 -1
- package/test/shared-piece-store.test.js +12 -12
- package/test/sidecar-naming.test.js +142 -142
- package/test/subtitle-cue-framing.test.js +200 -200
- package/test/subtitle-cue-walk.test.js +369 -369
- package/test/subtitle-defaults.test.js +97 -97
- package/test/subtitle-track-numbering.test.js +370 -370
- package/test/tail-duplication.test.js +167 -167
- package/test/tracks-begin-together.test.js +195 -195
- package/test/two-viewers-one-picture.test.js +374 -374
- package/test/video-facts.test.js +102 -102
- package/test/wedge-certainty.test.js +131 -131
- package/services/encode/open-piece.js +0 -135
- package/services/piece-store/disk-tier.js +0 -151
- package/test/open-piece.test.js +0 -152
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The second tier: pieces that no longer fit in memory.
|
|
3
|
-
*
|
|
4
|
-
* One sparse file per torrent, a piece at `index * chunkLength`. Not the
|
|
5
|
-
* torrent's real files — nobody reads those directly; playback goes through
|
|
6
|
-
* `/stream`, and the data is discarded when the torrent goes idle. A single
|
|
7
|
-
* file by piece index keeps the mapping arithmetic instead of bookkeeping, and
|
|
8
|
-
* sparseness means the untouched gaps cost nothing.
|
|
9
|
-
*
|
|
10
|
-
* Reads take a destination buffer rather than returning a fresh one. That is
|
|
11
|
-
* not a style preference — measured on the field host, an 8 MB piece costs
|
|
12
|
-
* 22.08 ms via `readFile`, which allocates, and **7.63 ms** read into a buffer
|
|
13
|
-
* we already hold. Two thirds of the apparent "disk" cost was allocation.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import fs from "node:fs/promises";
|
|
17
|
-
import { constants } from "node:fs";
|
|
18
|
-
import path from "node:path";
|
|
19
|
-
|
|
20
|
-
export class DiskTier {
|
|
21
|
-
#filePath;
|
|
22
|
-
#chunkLength;
|
|
23
|
-
/** @type {import("node:fs/promises").FileHandle | null} */
|
|
24
|
-
#handle = null;
|
|
25
|
-
/** Piece indices known to be on disk. */
|
|
26
|
-
#stored = new Set();
|
|
27
|
-
/** @type {Promise<void> | null} */
|
|
28
|
-
#opening = null;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* @param {object} params
|
|
32
|
-
* @param {string} params.directory - Where the backing file lives.
|
|
33
|
-
* @param {string} params.name - File name, unique per torrent.
|
|
34
|
-
* @param {number} params.chunkLength - Piece size; fixes the stride on disk.
|
|
35
|
-
*/
|
|
36
|
-
constructor({ directory, name, chunkLength }) {
|
|
37
|
-
this.#filePath = path.join(directory, name);
|
|
38
|
-
this.#chunkLength = chunkLength;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Where the backing file lives, for logging and cleanup. */
|
|
42
|
-
get filePath() {
|
|
43
|
-
return this.#filePath;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** How many pieces are currently held on disk. */
|
|
47
|
-
get size() {
|
|
48
|
-
return this.#stored.size;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Open the backing file, creating it and its directory if needed.
|
|
53
|
-
*
|
|
54
|
-
* Concurrent callers share one open — several evictions can start at once.
|
|
55
|
-
*
|
|
56
|
-
* @returns {Promise<import("node:fs/promises").FileHandle>}
|
|
57
|
-
*/
|
|
58
|
-
async #open() {
|
|
59
|
-
if (this.#handle) {
|
|
60
|
-
return this.#handle;
|
|
61
|
-
}
|
|
62
|
-
if (!this.#opening) {
|
|
63
|
-
this.#opening = (async () => {
|
|
64
|
-
await fs.mkdir(path.dirname(this.#filePath), { recursive: true });
|
|
65
|
-
// Read/write, created if absent — NOT append mode. Under `a+` POSIX
|
|
66
|
-
// ignores the position on every write and puts the bytes at the end of
|
|
67
|
-
// the file, so pieces would pile up in arrival order and each read
|
|
68
|
-
// would return whichever piece happened to land at that offset.
|
|
69
|
-
this.#handle = await fs.open(this.#filePath, constants.O_RDWR | constants.O_CREAT);
|
|
70
|
-
})();
|
|
71
|
-
}
|
|
72
|
-
await this.#opening;
|
|
73
|
-
if (!this.#handle) {
|
|
74
|
-
throw new Error(`Could not open the piece file at ${this.#filePath}.`);
|
|
75
|
-
}
|
|
76
|
-
return this.#handle;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* @param {number} index
|
|
81
|
-
* @returns {boolean}
|
|
82
|
-
*/
|
|
83
|
-
has(index) {
|
|
84
|
-
return this.#stored.has(index);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Write a piece out.
|
|
89
|
-
*
|
|
90
|
-
* @param {number} index
|
|
91
|
-
* @param {Uint8Array} bytes
|
|
92
|
-
* @returns {Promise<void>}
|
|
93
|
-
*/
|
|
94
|
-
async write(index, bytes) {
|
|
95
|
-
const handle = await this.#open();
|
|
96
|
-
await handle.write(bytes, 0, bytes.length, index * this.#chunkLength);
|
|
97
|
-
this.#stored.add(index);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Read a piece back into a buffer the caller already owns.
|
|
102
|
-
*
|
|
103
|
-
* @param {number} index
|
|
104
|
-
* @param {Uint8Array} target - Destination; its length is what gets read.
|
|
105
|
-
* @returns {Promise<number>} Bytes read.
|
|
106
|
-
*/
|
|
107
|
-
async read(index, target) {
|
|
108
|
-
if (!this.#stored.has(index)) {
|
|
109
|
-
throw new Error(`Piece ${index} is not on disk.`);
|
|
110
|
-
}
|
|
111
|
-
const handle = await this.#open();
|
|
112
|
-
const { bytesRead } = await handle.read(target, 0, target.length, index * this.#chunkLength);
|
|
113
|
-
return bytesRead;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Forget a piece. The bytes stay on disk until the file is removed — there is
|
|
118
|
-
* nothing to gain from punching them out, since the file is discarded whole.
|
|
119
|
-
*
|
|
120
|
-
* @param {number} index
|
|
121
|
-
* @returns {void}
|
|
122
|
-
*/
|
|
123
|
-
forget(index) {
|
|
124
|
-
this.#stored.delete(index);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Close the file, leaving its contents in place.
|
|
129
|
-
*
|
|
130
|
-
* @returns {Promise<void>}
|
|
131
|
-
*/
|
|
132
|
-
async close() {
|
|
133
|
-
const handle = this.#handle;
|
|
134
|
-
this.#handle = null;
|
|
135
|
-
this.#opening = null;
|
|
136
|
-
if (handle) {
|
|
137
|
-
await handle.close();
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Close and delete the backing file.
|
|
143
|
-
*
|
|
144
|
-
* @returns {Promise<void>}
|
|
145
|
-
*/
|
|
146
|
-
async destroy() {
|
|
147
|
-
await this.close();
|
|
148
|
-
this.#stored.clear();
|
|
149
|
-
await fs.rm(this.#filePath, { force: true });
|
|
150
|
-
}
|
|
151
|
-
}
|
package/test/open-piece.test.js
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The piece a run had open when it ended, and what proves one whole.
|
|
3
|
-
*
|
|
4
|
-
* `-segment_list pipe:3` was taken as proof: ffmpeg names a file when it closes
|
|
5
|
-
* it, so a named file is closed. True of the FILE and false of the SPAN. The
|
|
6
|
-
* highest-numbered file in a run's stretch is the one it had open, and how the
|
|
7
|
-
* run ended decides what became of it — stopped with SIGTERM, ffmpeg writes it
|
|
8
|
-
* out and names it exactly as it names a finished one; killed harder, or dying
|
|
9
|
-
* on its own, it leaves the bytes it had written. All three outcomes decode,
|
|
10
|
-
* and all three hold film only up to the instant the run ended.
|
|
11
|
-
*
|
|
12
|
-
* Field 2026-09-06, one session, both tracks: `segment-00010.mp4` held 3.92 s
|
|
13
|
-
* of its declared 5.589 s (96 frames), and the picture jumped 1.5 s at 1:02;
|
|
14
|
-
* the soundtrack's own stopped run left the same shape at 17.5 s, 2.8 s wide.
|
|
15
|
-
* Judging such a file by whether it decodes answers yes, which is how both
|
|
16
|
-
* reached the viewer.
|
|
17
|
-
*
|
|
18
|
-
* So what is kept is what the run PROVED it finished: the last piece it named
|
|
19
|
-
* while it was still running normally.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import test from "node:test";
|
|
23
|
-
import assert from "node:assert/strict";
|
|
24
|
-
import { existsSync } from "node:fs";
|
|
25
|
-
import { mkdtemp, rm, writeFile, readdir } from "node:fs/promises";
|
|
26
|
-
import os from "node:os";
|
|
27
|
-
import path from "node:path";
|
|
28
|
-
import { discardOpenPiece } from "../services/encode/open-piece.js";
|
|
29
|
-
|
|
30
|
-
const format = {
|
|
31
|
-
isSegmentFileName: (name) => /^segment-\d{5}\.mp4$/.test(name),
|
|
32
|
-
segmentIndexFromName: (name) => Number(name.slice(8, 13))
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
/** A run directory holding pieces 0..last, every one of them readable. */
|
|
36
|
-
async function runDirectory(last) {
|
|
37
|
-
const dir = await mkdtemp(path.join(os.tmpdir(), "open-piece-"));
|
|
38
|
-
for (let index = 0; index <= last; index += 1) {
|
|
39
|
-
await writeFile(path.join(dir, `segment-${String(index).padStart(5, "0")}.mp4`), Buffer.alloc(64, 7));
|
|
40
|
-
}
|
|
41
|
-
return dir;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
test("the piece written out on the way to being stopped goes, though it reads", async () => {
|
|
45
|
-
// The field case: ffmpeg named #10 while shutting down, so #9 is the last it
|
|
46
|
-
// proved. Everything decodes, and nothing about #10's contents betrays it.
|
|
47
|
-
const dir = await runDirectory(10);
|
|
48
|
-
try {
|
|
49
|
-
const removed = await discardOpenPiece(
|
|
50
|
-
dir, format, { from: 0, to: 535 }, () => true, "segment-00009.mp4"
|
|
51
|
-
);
|
|
52
|
-
assert.equal(removed, 10, "the open piece was kept because it decodes");
|
|
53
|
-
const left = await readdir(dir);
|
|
54
|
-
assert.ok(!left.includes("segment-00010.mp4"));
|
|
55
|
-
assert.ok(left.includes("segment-00009.mp4"), "a piece the run proved was taken too");
|
|
56
|
-
} finally {
|
|
57
|
-
await rm(dir, { recursive: true, force: true });
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("a piece the run never named goes too, however it was killed", async () => {
|
|
62
|
-
// Killed harder than SIGTERM, or dead on its own: the open piece is left
|
|
63
|
-
// half-written and unnamed. It still decodes, and it is still short.
|
|
64
|
-
const dir = await runDirectory(10);
|
|
65
|
-
try {
|
|
66
|
-
const removed = await discardOpenPiece(
|
|
67
|
-
dir, format, { from: 0, to: 535 }, () => true, "segment-00009.mp4"
|
|
68
|
-
);
|
|
69
|
-
assert.equal(removed, 10);
|
|
70
|
-
} finally {
|
|
71
|
-
await rm(dir, { recursive: true, force: true });
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("the piece the run proved finished stays", async () => {
|
|
76
|
-
// A run stopped in the moment after closing #10 and before opening #11
|
|
77
|
-
// proved #10. Removing it would mean encoding it a second time for nothing.
|
|
78
|
-
const dir = await runDirectory(10);
|
|
79
|
-
try {
|
|
80
|
-
const removed = await discardOpenPiece(
|
|
81
|
-
dir, format, { from: 0, to: 535 }, () => true, "segment-00010.mp4"
|
|
82
|
-
);
|
|
83
|
-
assert.equal(removed, null);
|
|
84
|
-
assert.ok((await readdir(dir)).includes("segment-00010.mp4"));
|
|
85
|
-
} finally {
|
|
86
|
-
await rm(dir, { recursive: true, force: true });
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("a run that proved nothing keeps nothing", async () => {
|
|
91
|
-
const dir = await runDirectory(3);
|
|
92
|
-
try {
|
|
93
|
-
const removed = await discardOpenPiece(dir, format, { from: 0, to: 535 }, () => true, null);
|
|
94
|
-
assert.equal(removed, 3, "a run that named nothing had its last piece believed");
|
|
95
|
-
} finally {
|
|
96
|
-
await rm(dir, { recursive: true, force: true });
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("only inside the stretch the ended run was given", async () => {
|
|
101
|
-
// Several runs write into one directory, kept apart by their intervals. The
|
|
102
|
-
// highest file overall may belong to a run that is still going.
|
|
103
|
-
const dir = await runDirectory(20);
|
|
104
|
-
try {
|
|
105
|
-
const removed = await discardOpenPiece(
|
|
106
|
-
dir, format, { from: 0, to: 10 }, () => true, "segment-00009.mp4"
|
|
107
|
-
);
|
|
108
|
-
assert.equal(removed, 10);
|
|
109
|
-
assert.ok((await readdir(dir)).includes("segment-00020.mp4"), "another run's piece was taken");
|
|
110
|
-
} finally {
|
|
111
|
-
await rm(dir, { recursive: true, force: true });
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("a run given no end never reaches past what it made, so a live run's piece is safe", async () => {
|
|
116
|
-
// Field 2026-09-06. Three encoders wrote into one directory; the first was
|
|
117
|
-
// stopped, its stretch was `#0..#-1` — "to the end of the track" — and the
|
|
118
|
-
// cleanup after it took the highest-numbered file anywhere in that directory,
|
|
119
|
-
// which a LIVE encoder had just finished. The number is spent for good,
|
|
120
|
-
// because names only grow, and the picture stood still for 647 seconds.
|
|
121
|
-
const dir = await mkdtemp(path.join(os.tmpdir(), "open-piece-"));
|
|
122
|
-
// What the stopped run made: #0 and #1, with #2 left open.
|
|
123
|
-
await writeFile(path.join(dir, "segment-00000.mp4"), Buffer.alloc(1000, 1));
|
|
124
|
-
await writeFile(path.join(dir, "segment-00001.mp4"), Buffer.alloc(1000, 1));
|
|
125
|
-
await writeFile(path.join(dir, "segment-00002.mp4"), Buffer.alloc(0));
|
|
126
|
-
// What a live encoder, working further along the same track, has finished.
|
|
127
|
-
await writeFile(path.join(dir, "segment-00040.mp4"), Buffer.alloc(9000, 1));
|
|
128
|
-
|
|
129
|
-
const removed = await discardOpenPiece(
|
|
130
|
-
dir,
|
|
131
|
-
format,
|
|
132
|
-
{ from: 0, to: -1 },
|
|
133
|
-
null,
|
|
134
|
-
"segment-00001.mp4"
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
assert.equal(removed, 2, "its own open piece goes");
|
|
138
|
-
assert.ok(existsSync(path.join(dir, "segment-00040.mp4")), "the live run's piece stays");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("a run that named nothing leaves only its own first piece", async () => {
|
|
142
|
-
// It opened one file and died — 548 ms after starting, in the field. Nothing
|
|
143
|
-
// above that can be its.
|
|
144
|
-
const dir = await mkdtemp(path.join(os.tmpdir(), "open-piece-"));
|
|
145
|
-
await writeFile(path.join(dir, "segment-00010.mp4"), Buffer.alloc(0));
|
|
146
|
-
await writeFile(path.join(dir, "segment-00011.mp4"), Buffer.alloc(9000, 1));
|
|
147
|
-
|
|
148
|
-
const removed = await discardOpenPiece(dir, format, { from: 10, to: -1 }, null, null);
|
|
149
|
-
|
|
150
|
-
assert.equal(removed, 10, "the one it opened");
|
|
151
|
-
assert.ok(existsSync(path.join(dir, "segment-00011.mp4")), "and nothing beyond it");
|
|
152
|
-
});
|