@torrent-tv/proxy 2.80.3 → 2.80.5
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
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2.80.5
|
|
2
|
+
|
|
3
|
+
- **Fix**: A piece is kept only where the run PROVED it finished, which closes the half 2.80.4 left open. That release recognised the piece ffmpeg writes out on `SIGTERM` and named it by when it arrived on the ready channel — right for our own stop, and blind to every other way a run ends. Killed harder, or dying on its own, a run leaves the same piece half-written and with no name at all; it decodes just as well and is just as short. The question asked of the highest-numbered file in a run's stretch is no longer what it contains but whether the run named it while still running normally, so all three endings are covered by one fact the run already holds.
|
|
4
|
+
|
|
5
|
+
## 2.80.4
|
|
6
|
+
|
|
7
|
+
- **Fix**: The piece an encoder writes out while it is being stopped is thrown away instead of served. `-segment_list pipe:3` was taken as proof that a piece is whole — ffmpeg names a file when it closes it, so a named file is closed. That is true of the FILE and false of the SPAN: on `SIGTERM` ffmpeg writes out the piece it had open and names it like any other, so what lands on disk is a valid, decodable piece holding film only up to the instant of the stop, under a name whose playlist entry promises the whole span. Field 2026-09-06, one session, both tracks: `segment-00010.mp4` held 3.92 s of its declared 5.589 s — 96 frames — and the picture jumped 1.5 s at 1:02, while the soundtrack's own stopped run left the same shape at 17.5 s, 2.8 s wide. Both were the viewer's report of the picture and the sound jerking.
|
|
8
|
+
- **Fix**: Which piece that is comes from the run itself — the last name it wrote after being told to stop — so nothing is read, no span is measured and no tolerance is chosen. The cleanup already existed and already ran on every abnormal ending; it removed the piece only when the piece did not decode, and this one decodes.
|
|
9
|
+
- **Chore**: A piece genuinely finished a moment before the stop can be named here too, and is then made a second time. That is the cheaper of the two errors by the rule this project already holds: the other one is a hole the viewer sees.
|
|
10
|
+
|
|
1
11
|
## 2.80.3
|
|
2
12
|
|
|
3
13
|
- **Fix**: A boundary carries the keyframe it IS, instead of being looked up by value in the container's list. A cut is kept on the player's clock — `keyframe - startTime`, rounded to six places — and the seek used to add the start time back and search the file's own keyframes for the result. That round trip is lossy: a keyframe at 26.234 s in a container starting at 0.083 s comes back as 26.233999999999998, and "the keyframe at or before that" is then the PREVIOUS one. Two parts in a quadrillion became 8.717 seconds. Where each cut is on the file's own clock is now carried by index (`output/cut-grid.js`, `Timeline.sourceTimes`) and asked for directly, so a boundary cannot fail to find itself.
|
package/package.json
CHANGED
|
@@ -61,6 +61,10 @@ const MICROSECONDS_PER_SECOND = 1_000_000;
|
|
|
61
61
|
* @property {number} livedMs
|
|
62
62
|
* @property {boolean} normal - Whether this ending is the expected one.
|
|
63
63
|
* @property {string} lastError - The last thing ffmpeg said on stderr.
|
|
64
|
+
* @property {string | null} provenName - The last piece this run named while it
|
|
65
|
+
* was still running normally, and therefore the last one it is known to have
|
|
66
|
+
* finished. Anything on disk beyond it was open when the run ended, whatever
|
|
67
|
+
* ended it. Null where the run named nothing.
|
|
64
68
|
*/
|
|
65
69
|
|
|
66
70
|
/**
|
|
@@ -99,6 +103,29 @@ export class EncodeRun {
|
|
|
99
103
|
/** Half a name left over from the last chunk of the encoder's own channel. */
|
|
100
104
|
#closedTail = "";
|
|
101
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The last piece named on the ready channel while the run was still running
|
|
108
|
+
* normally — the last one it is KNOWN to have finished.
|
|
109
|
+
*
|
|
110
|
+
* "The encoder named it" was taken to mean "it is whole". That is true of the
|
|
111
|
+
* file and false of the span. On SIGTERM ffmpeg writes out the piece it had
|
|
112
|
+
* open and names it like any other; killed harder, or dying on its own, it
|
|
113
|
+
* leaves that piece unnamed and half-written. Both are readable, and neither
|
|
114
|
+
* covers the span its number promises. Field 2026-09-06: a run stopped
|
|
115
|
+
* mid-piece left `segment-00010.mp4` holding 3.92 s of the 5.589 s the
|
|
116
|
+
* playlist gives #10, and the viewer's picture jumped 1.5 s at 1:02; the
|
|
117
|
+
* soundtrack did the same at 17.5 s, 2.8 s wide, in the same session.
|
|
118
|
+
*
|
|
119
|
+
* Recorded by WHEN the name arrives, so nothing is read and no span is
|
|
120
|
+
* measured: a name that arrives after the stop was ordered is the flush and
|
|
121
|
+
* does not count as proof. A piece genuinely closed in the moment between the
|
|
122
|
+
* last normal name and the stop is then made a second time — the cheaper of
|
|
123
|
+
* the two errors, since the other is a hole the viewer sees.
|
|
124
|
+
*
|
|
125
|
+
* @type {string | null}
|
|
126
|
+
*/
|
|
127
|
+
#provenName = null;
|
|
128
|
+
|
|
102
129
|
/**
|
|
103
130
|
* When this run was told to stop, so the death itself can be priced.
|
|
104
131
|
*
|
|
@@ -426,6 +453,9 @@ export class EncodeRun {
|
|
|
426
453
|
if (name.length === 0) {
|
|
427
454
|
continue;
|
|
428
455
|
}
|
|
456
|
+
if (!this.#stopping) {
|
|
457
|
+
this.#provenName = name;
|
|
458
|
+
}
|
|
429
459
|
this.onClosed(name);
|
|
430
460
|
}
|
|
431
461
|
}
|
|
@@ -615,6 +645,7 @@ export class EncodeRun {
|
|
|
615
645
|
from: this.from,
|
|
616
646
|
to: this.to,
|
|
617
647
|
reached: this.reached,
|
|
648
|
+
provenName: this.#provenName,
|
|
618
649
|
livedMs,
|
|
619
650
|
// How long dying took, and how long the first output took to appear.
|
|
620
651
|
// Null where the run was never told to stop, or never produced anything:
|
|
@@ -350,12 +350,12 @@ export class SegmentStore {
|
|
|
350
350
|
* @param {((raw: Buffer) => boolean) | null} [judgeUsable]
|
|
351
351
|
* @returns {Promise<number | null>} The segment number removed, or null.
|
|
352
352
|
*/
|
|
353
|
-
async discardOpenPieceOf(key, within, judgeUsable = null) {
|
|
353
|
+
async discardOpenPieceOf(key, within, judgeUsable = null, provenName = null) {
|
|
354
354
|
const format = this.#formats.get(key);
|
|
355
355
|
if (!format) {
|
|
356
356
|
return null;
|
|
357
357
|
}
|
|
358
|
-
const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable);
|
|
358
|
+
const removed = await discardOpenPiece(this.directoryFor(key), format, within, judgeUsable, provenName);
|
|
359
359
|
if (removed !== null) {
|
|
360
360
|
this.#held.delete(key);
|
|
361
361
|
this.#logger?.info?.(
|
|
@@ -23,17 +23,39 @@ import path from "node:path";
|
|
|
23
23
|
* numbering and convinced the look-ahead to keep the encoder stopped for having
|
|
24
24
|
* "produced" it.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* **A piece is whole only if the run PROVED it, and reading it proves nothing.**
|
|
27
|
+
*
|
|
28
|
+
* The highest-numbered file in a run's stretch is the one it had open. How a
|
|
29
|
+
* run ends decides what became of that file, and all three outcomes leave it
|
|
30
|
+
* readable-looking: stopped with SIGTERM, ffmpeg writes it out and names it on
|
|
31
|
+
* the ready channel exactly as it names a finished one; killed harder, or dying
|
|
32
|
+
* on its own, it leaves the bytes it had written with no name at all. In every
|
|
33
|
+
* case the file decodes and holds film only up to the instant the run ended,
|
|
34
|
+
* under a number whose playlist entry promises a whole span. Field 2026-09-06:
|
|
35
|
+
* `segment-00010.mp4` held 3.92 s of its declared 5.589 s — 96 frames — and the
|
|
36
|
+
* picture jumped 1.5 s at 1:02; the soundtrack did the same at 17.5 s, 2.8 s
|
|
37
|
+
* wide, in the same session. Both decoded, which is how both reached the viewer.
|
|
38
|
+
*
|
|
39
|
+
* So the question asked here is not what the file contains but whether the run
|
|
40
|
+
* named it while it was still running normally. That is a fact the run holds,
|
|
41
|
+
* so there is no span to measure and no tolerance to choose.
|
|
42
|
+
*
|
|
43
|
+
* A piece finished in the moment between the last such name and the end is
|
|
44
|
+
* then made a second time. That is the cheaper error: the other is a hole the
|
|
45
|
+
* viewer sees.
|
|
28
46
|
*
|
|
29
47
|
* @param {string | null | undefined} runDirPath
|
|
30
48
|
* @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} segmentFormat
|
|
31
49
|
* @param {((raw: Buffer) => boolean) | null} judgeUsable - Whether a non-empty
|
|
32
50
|
* piece carries what it should. Null where nothing can say, and then only an
|
|
33
51
|
* empty file is removed.
|
|
52
|
+
* @param {string | null} [provenName] - The last piece the run named while it
|
|
53
|
+
* was running normally. A file beyond it was open when the run ended and goes
|
|
54
|
+
* whether or not it reads. Null where the run proved nothing, and then every
|
|
55
|
+
* piece it left is unproven.
|
|
34
56
|
* @returns {Promise<number | null>} The segment number removed, or null.
|
|
35
57
|
*/
|
|
36
|
-
export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeUsable) {
|
|
58
|
+
export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeUsable, provenName = null) {
|
|
37
59
|
if (!runDirPath || typeof segmentFormat?.isSegmentFileName !== "function") {
|
|
38
60
|
return null;
|
|
39
61
|
}
|
|
@@ -65,12 +87,16 @@ export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeU
|
|
|
65
87
|
return null;
|
|
66
88
|
}
|
|
67
89
|
const filePath = path.join(runDirPath, highest.name);
|
|
68
|
-
|
|
90
|
+
// Proven finished only if the run said so while it was running. Anything
|
|
91
|
+
// beyond that name was open when the run ended, and its contents cannot say
|
|
92
|
+
// so — it decodes.
|
|
93
|
+
const proven = typeof provenName === "string" && provenName === highest.name;
|
|
94
|
+
let unusable = !proven;
|
|
69
95
|
try {
|
|
70
96
|
const info = await stat(filePath);
|
|
71
97
|
if (info.size === 0) {
|
|
72
98
|
unusable = true;
|
|
73
|
-
} else if (typeof judgeUsable === "function") {
|
|
99
|
+
} else if (!unusable && typeof judgeUsable === "function") {
|
|
74
100
|
unusable = !judgeUsable(await readFile(filePath));
|
|
75
101
|
}
|
|
76
102
|
} catch {
|
|
@@ -474,10 +474,13 @@ export class EncodeOrchestrator {
|
|
|
474
474
|
this.#costs.note(ended);
|
|
475
475
|
// Exactly one ending is normal — the run reached the end of the stretch it
|
|
476
476
|
// was given and closed its last file. Every other leaves a piece open, and
|
|
477
|
-
// that file
|
|
477
|
+
// that file looks finished however the run ended: stopped, ffmpeg writes it
|
|
478
|
+
// out and names it like any other; killed harder, it leaves the bytes it
|
|
479
|
+
// had. Either way it decodes and holds less film than its number promises.
|
|
480
|
+
// So what is kept is what the run PROVED it finished, and nothing beyond.
|
|
478
481
|
if (ended.ending !== ENCODE_EXIT.COMPLETE && this.segmentStore) {
|
|
479
482
|
void this.segmentStore
|
|
480
|
-
.discardOpenPieceOf(ended.address, { from: ended.from, to: ended.to })
|
|
483
|
+
.discardOpenPieceOf(ended.address, { from: ended.from, to: ended.to }, null, ended.provenName)
|
|
481
484
|
.catch(() => {});
|
|
482
485
|
}
|
|
483
486
|
this.coverageOf(ended.address).release(ended.run);
|
|
@@ -0,0 +1,112 @@
|
|
|
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 { mkdtemp, rm, writeFile, readdir } from "node:fs/promises";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { discardOpenPiece } from "../services/encode/open-piece.js";
|
|
28
|
+
|
|
29
|
+
const format = {
|
|
30
|
+
isSegmentFileName: (name) => /^segment-\d{5}\.mp4$/.test(name),
|
|
31
|
+
segmentIndexFromName: (name) => Number(name.slice(8, 13))
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** A run directory holding pieces 0..last, every one of them readable. */
|
|
35
|
+
async function runDirectory(last) {
|
|
36
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "open-piece-"));
|
|
37
|
+
for (let index = 0; index <= last; index += 1) {
|
|
38
|
+
await writeFile(path.join(dir, `segment-${String(index).padStart(5, "0")}.mp4`), Buffer.alloc(64, 7));
|
|
39
|
+
}
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("the piece written out on the way to being stopped goes, though it reads", async () => {
|
|
44
|
+
// The field case: ffmpeg named #10 while shutting down, so #9 is the last it
|
|
45
|
+
// proved. Everything decodes, and nothing about #10's contents betrays it.
|
|
46
|
+
const dir = await runDirectory(10);
|
|
47
|
+
try {
|
|
48
|
+
const removed = await discardOpenPiece(
|
|
49
|
+
dir, format, { from: 0, to: 535 }, () => true, "segment-00009.mp4"
|
|
50
|
+
);
|
|
51
|
+
assert.equal(removed, 10, "the open piece was kept because it decodes");
|
|
52
|
+
const left = await readdir(dir);
|
|
53
|
+
assert.ok(!left.includes("segment-00010.mp4"));
|
|
54
|
+
assert.ok(left.includes("segment-00009.mp4"), "a piece the run proved was taken too");
|
|
55
|
+
} finally {
|
|
56
|
+
await rm(dir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a piece the run never named goes too, however it was killed", async () => {
|
|
61
|
+
// Killed harder than SIGTERM, or dead on its own: the open piece is left
|
|
62
|
+
// half-written and unnamed. It still decodes, and it is still short.
|
|
63
|
+
const dir = await runDirectory(10);
|
|
64
|
+
try {
|
|
65
|
+
const removed = await discardOpenPiece(
|
|
66
|
+
dir, format, { from: 0, to: 535 }, () => true, "segment-00009.mp4"
|
|
67
|
+
);
|
|
68
|
+
assert.equal(removed, 10);
|
|
69
|
+
} finally {
|
|
70
|
+
await rm(dir, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the piece the run proved finished stays", async () => {
|
|
75
|
+
// A run stopped in the moment after closing #10 and before opening #11
|
|
76
|
+
// proved #10. Removing it would mean encoding it a second time for nothing.
|
|
77
|
+
const dir = await runDirectory(10);
|
|
78
|
+
try {
|
|
79
|
+
const removed = await discardOpenPiece(
|
|
80
|
+
dir, format, { from: 0, to: 535 }, () => true, "segment-00010.mp4"
|
|
81
|
+
);
|
|
82
|
+
assert.equal(removed, null);
|
|
83
|
+
assert.ok((await readdir(dir)).includes("segment-00010.mp4"));
|
|
84
|
+
} finally {
|
|
85
|
+
await rm(dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a run that proved nothing keeps nothing", async () => {
|
|
90
|
+
const dir = await runDirectory(3);
|
|
91
|
+
try {
|
|
92
|
+
const removed = await discardOpenPiece(dir, format, { from: 0, to: 535 }, () => true, null);
|
|
93
|
+
assert.equal(removed, 3, "a run that named nothing had its last piece believed");
|
|
94
|
+
} finally {
|
|
95
|
+
await rm(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("only inside the stretch the ended run was given", async () => {
|
|
100
|
+
// Several runs write into one directory, kept apart by their intervals. The
|
|
101
|
+
// highest file overall may belong to a run that is still going.
|
|
102
|
+
const dir = await runDirectory(20);
|
|
103
|
+
try {
|
|
104
|
+
const removed = await discardOpenPiece(
|
|
105
|
+
dir, format, { from: 0, to: 10 }, () => true, "segment-00009.mp4"
|
|
106
|
+
);
|
|
107
|
+
assert.equal(removed, 10);
|
|
108
|
+
assert.ok((await readdir(dir)).includes("segment-00020.mp4"), "another run's piece was taken");
|
|
109
|
+
} finally {
|
|
110
|
+
await rm(dir, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
});
|