@torrent-tv/proxy 2.80.3 → 2.80.4
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,9 @@
|
|
|
1
|
+
## 2.80.4
|
|
2
|
+
|
|
3
|
+
- **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.
|
|
4
|
+
- **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.
|
|
5
|
+
- **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.
|
|
6
|
+
|
|
1
7
|
## 2.80.3
|
|
2
8
|
|
|
3
9
|
- **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,11 @@ 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} flushedName - The piece the encoder wrote out while
|
|
65
|
+
* it was shutting down, if it wrote one. It is closed and it is SHORT: it
|
|
66
|
+
* holds film only up to the instant the run was stopped, while its name
|
|
67
|
+
* promises the whole span the playlist gives that number. Null where the run
|
|
68
|
+
* was never told to stop.
|
|
64
69
|
*/
|
|
65
70
|
|
|
66
71
|
/**
|
|
@@ -99,6 +104,26 @@ export class EncodeRun {
|
|
|
99
104
|
/** Half a name left over from the last chunk of the encoder's own channel. */
|
|
100
105
|
#closedTail = "";
|
|
101
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The last piece named on the ready channel AFTER the run was told to stop.
|
|
109
|
+
*
|
|
110
|
+
* On SIGTERM ffmpeg writes out the piece it had open and names it like any
|
|
111
|
+
* other, so "the encoder closed it" stops meaning "it is whole". Field
|
|
112
|
+
* 2026-09-06: a run stopped mid-piece left `segment-00010.mp4` holding 3.92 s
|
|
113
|
+
* of the 5.589 s its name promises, and the viewer's picture jumped 1.5 s at
|
|
114
|
+
* 1:02. The soundtrack did the same at 17.5 s, 2.8 s wide, in the same
|
|
115
|
+
* session.
|
|
116
|
+
*
|
|
117
|
+
* Distinguished by WHEN the name arrives, which is exact and needs no reading
|
|
118
|
+
* of the file: a name that arrives after the stop was ordered is the flush.
|
|
119
|
+
* A piece genuinely closed a moment before the stop can land here too, and
|
|
120
|
+
* then it is made a second time — the cheaper of the two errors, since the
|
|
121
|
+
* other one is a hole the viewer sees.
|
|
122
|
+
*
|
|
123
|
+
* @type {string | null}
|
|
124
|
+
*/
|
|
125
|
+
#flushedName = null;
|
|
126
|
+
|
|
102
127
|
/**
|
|
103
128
|
* When this run was told to stop, so the death itself can be priced.
|
|
104
129
|
*
|
|
@@ -426,6 +451,9 @@ export class EncodeRun {
|
|
|
426
451
|
if (name.length === 0) {
|
|
427
452
|
continue;
|
|
428
453
|
}
|
|
454
|
+
if (this.#stopping) {
|
|
455
|
+
this.#flushedName = name;
|
|
456
|
+
}
|
|
429
457
|
this.onClosed(name);
|
|
430
458
|
}
|
|
431
459
|
}
|
|
@@ -615,6 +643,7 @@ export class EncodeRun {
|
|
|
615
643
|
from: this.from,
|
|
616
644
|
to: this.to,
|
|
617
645
|
reached: this.reached,
|
|
646
|
+
flushedName: this.#flushedName,
|
|
618
647
|
livedMs,
|
|
619
648
|
// How long dying took, and how long the first output took to appear.
|
|
620
649
|
// 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, flushedName = 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, flushedName);
|
|
359
359
|
if (removed !== null) {
|
|
360
360
|
this.#held.delete(key);
|
|
361
361
|
this.#logger?.info?.(
|
|
@@ -23,17 +23,37 @@ 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
|
+
* Two kinds of file are removed, and the second is the one a viewer feels.
|
|
27
|
+
*
|
|
28
|
+
* The first is unreadable — a run that died mid-write. The second READS
|
|
29
|
+
* perfectly and is SHORT: on SIGTERM ffmpeg writes out the piece it had open
|
|
30
|
+
* and names it on the ready channel like any other, so it is a valid fMP4
|
|
31
|
+
* holding film only up to the instant of the stop, under a name that promises
|
|
32
|
+
* the whole span the playlist gives that number. Field 2026-09-06:
|
|
33
|
+
* `segment-00010.mp4` held 3.92 s of its declared 5.589 s — 96 frames — and the
|
|
34
|
+
* picture jumped 1.5 s at 1:02; the soundtrack did the same at 17.5 s, 2.8 s
|
|
35
|
+
* wide, in the same session. Judging such a file by whether it decodes says
|
|
36
|
+
* yes, which is how both survived.
|
|
37
|
+
*
|
|
38
|
+
* Which file that is comes from the run itself — the last name it wrote after
|
|
39
|
+
* being told to stop — and not from reading the pieces, so there is no span to
|
|
40
|
+
* measure and no tolerance to choose.
|
|
41
|
+
*
|
|
42
|
+
* A piece that was genuinely finished a moment before the stop can be named
|
|
43
|
+
* here too, and is then made a second time. That is the cheaper error: the
|
|
44
|
+
* other one is a hole the viewer sees.
|
|
28
45
|
*
|
|
29
46
|
* @param {string | null | undefined} runDirPath
|
|
30
47
|
* @param {{ isSegmentFileName: (name: string) => boolean, segmentIndexFromName: (name: string) => number }} segmentFormat
|
|
31
48
|
* @param {((raw: Buffer) => boolean) | null} judgeUsable - Whether a non-empty
|
|
32
49
|
* piece carries what it should. Null where nothing can say, and then only an
|
|
33
50
|
* empty file is removed.
|
|
51
|
+
* @param {string | null} [flushedName] - The piece the encoder wrote out while
|
|
52
|
+
* shutting down. Removed whether or not it reads, because reading is not the
|
|
53
|
+
* question about it.
|
|
34
54
|
* @returns {Promise<number | null>} The segment number removed, or null.
|
|
35
55
|
*/
|
|
36
|
-
export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeUsable) {
|
|
56
|
+
export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeUsable, flushedName = null) {
|
|
37
57
|
if (!runDirPath || typeof segmentFormat?.isSegmentFileName !== "function") {
|
|
38
58
|
return null;
|
|
39
59
|
}
|
|
@@ -65,12 +85,16 @@ export async function discardOpenPiece(runDirPath, segmentFormat, within, judgeU
|
|
|
65
85
|
return null;
|
|
66
86
|
}
|
|
67
87
|
const filePath = path.join(runDirPath, highest.name);
|
|
68
|
-
|
|
88
|
+
// The encoder named this one on its way out, so it holds film up to the stop
|
|
89
|
+
// and no further. Nothing about its contents can say that — it decodes — so
|
|
90
|
+
// nothing about its contents is asked.
|
|
91
|
+
const wasFlushedOnTheWayOut = typeof flushedName === "string" && flushedName === highest.name;
|
|
92
|
+
let unusable = wasFlushedOnTheWayOut;
|
|
69
93
|
try {
|
|
70
94
|
const info = await stat(filePath);
|
|
71
95
|
if (info.size === 0) {
|
|
72
96
|
unusable = true;
|
|
73
|
-
} else if (typeof judgeUsable === "function") {
|
|
97
|
+
} else if (!unusable && typeof judgeUsable === "function") {
|
|
74
98
|
unusable = !judgeUsable(await readFile(filePath));
|
|
75
99
|
}
|
|
76
100
|
} 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's name is indistinguishable from a finished one's
|
|
477
|
+
// that file's name is indistinguishable from a finished one's: on SIGTERM
|
|
478
|
+
// ffmpeg writes the open piece out and names it on the ready channel like
|
|
479
|
+
// any other, so it is a valid file holding less film than its name
|
|
480
|
+
// promises. The run says which one that was.
|
|
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.flushedName)
|
|
481
484
|
.catch(() => {});
|
|
482
485
|
}
|
|
483
486
|
this.coverageOf(ended.address).release(ended.run);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The piece an encoder writes out on its way to being stopped.
|
|
3
|
+
*
|
|
4
|
+
* `-segment_list pipe:3` was taken as proof that a piece is whole: ffmpeg names
|
|
5
|
+
* a file when it closes it, so a named file is closed. True of the FILE and
|
|
6
|
+
* false of the SPAN. On SIGTERM ffmpeg writes out the piece it had open and
|
|
7
|
+
* names it like any other, so the result is a valid, decodable piece holding
|
|
8
|
+
* film only up to the instant of the stop — under a name whose playlist entry
|
|
9
|
+
* promises the whole span.
|
|
10
|
+
*
|
|
11
|
+
* Field 2026-09-06, one session, both tracks: `segment-00010.mp4` held 3.92 s
|
|
12
|
+
* of its declared 5.589 s (96 frames), and the picture jumped 1.5 s at 1:02;
|
|
13
|
+
* the soundtrack's own stopped run left the same shape at 17.5 s, 2.8 s wide.
|
|
14
|
+
* Judging such a file by whether it decodes answers yes, which is how both
|
|
15
|
+
* reached the viewer.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { mkdtemp, rm, writeFile, readdir } from "node:fs/promises";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { discardOpenPiece } from "../services/encode/open-piece.js";
|
|
24
|
+
|
|
25
|
+
const format = {
|
|
26
|
+
isSegmentFileName: (name) => /^segment-\d{5}\.mp4$/.test(name),
|
|
27
|
+
segmentIndexFromName: (name) => Number(name.slice(8, 13))
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A run directory holding pieces 0..last, every one of them readable. */
|
|
31
|
+
async function runDirectory(last) {
|
|
32
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "flushed-piece-"));
|
|
33
|
+
for (let index = 0; index <= last; index += 1) {
|
|
34
|
+
await writeFile(path.join(dir, `segment-${String(index).padStart(5, "0")}.mp4`), Buffer.alloc(64, 7));
|
|
35
|
+
}
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("the piece named on the way out goes, though it reads perfectly", async () => {
|
|
40
|
+
const dir = await runDirectory(10);
|
|
41
|
+
try {
|
|
42
|
+
const removed = await discardOpenPiece(
|
|
43
|
+
dir,
|
|
44
|
+
format,
|
|
45
|
+
{ from: 0, to: 535 },
|
|
46
|
+
// Everything decodes. This is the field case exactly: the short piece is
|
|
47
|
+
// a valid fMP4 and nothing about its contents betrays it.
|
|
48
|
+
() => true,
|
|
49
|
+
"segment-00010.mp4"
|
|
50
|
+
);
|
|
51
|
+
assert.equal(removed, 10, "the flushed 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 closed before the stop was taken too");
|
|
55
|
+
} finally {
|
|
56
|
+
await rm(dir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a run that named nothing on the way out loses no readable piece", async () => {
|
|
61
|
+
// A run that reached the end of its stretch closed its last file properly.
|
|
62
|
+
// Removing it would mean encoding it a second time for nothing.
|
|
63
|
+
const dir = await runDirectory(10);
|
|
64
|
+
try {
|
|
65
|
+
const removed = await discardOpenPiece(dir, format, { from: 0, to: 535 }, () => true, null);
|
|
66
|
+
assert.equal(removed, null);
|
|
67
|
+
assert.ok((await readdir(dir)).includes("segment-00010.mp4"));
|
|
68
|
+
} finally {
|
|
69
|
+
await rm(dir, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a piece that does not read still goes, named or not", async () => {
|
|
74
|
+
const dir = await runDirectory(10);
|
|
75
|
+
try {
|
|
76
|
+
const removed = await discardOpenPiece(dir, format, { from: 0, to: 535 }, () => false, null);
|
|
77
|
+
assert.equal(removed, 10, "an unreadable last piece survived");
|
|
78
|
+
} finally {
|
|
79
|
+
await rm(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("only inside the stretch the ended run was given", async () => {
|
|
84
|
+
// Several runs write into one directory, kept apart by their intervals. The
|
|
85
|
+
// highest file overall may belong to a run that is still going.
|
|
86
|
+
const dir = await runDirectory(20);
|
|
87
|
+
try {
|
|
88
|
+
const removed = await discardOpenPiece(
|
|
89
|
+
dir, format, { from: 0, to: 10 }, () => true, "segment-00010.mp4"
|
|
90
|
+
);
|
|
91
|
+
assert.equal(removed, 10);
|
|
92
|
+
assert.ok((await readdir(dir)).includes("segment-00020.mp4"), "another run's piece was taken");
|
|
93
|
+
} finally {
|
|
94
|
+
await rm(dir, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a name from another run's stretch takes nothing", async () => {
|
|
99
|
+
const dir = await runDirectory(20);
|
|
100
|
+
try {
|
|
101
|
+
const removed = await discardOpenPiece(
|
|
102
|
+
dir, format, { from: 0, to: 10 }, () => true, "segment-00020.mp4"
|
|
103
|
+
);
|
|
104
|
+
assert.equal(removed, null, "a name outside the stretch removed a piece anyway");
|
|
105
|
+
} finally {
|
|
106
|
+
await rm(dir, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
});
|