@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.80.3",
3
+ "version": "2.80.5",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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
- * Only an unusable piece goes. A run stopped between two cuts leaves a finished
27
- * file behind, and deleting good output would mean making it a second time.
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
- let unusable = false;
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's name is indistinguishable from a finished one's.
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
+ });