@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.80.3",
3
+ "version": "2.80.4",
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,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
- * 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
+ * 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
- let unusable = false;
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
+ });