@torrent-tv/proxy 2.80.2 → 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,4 +1,19 @@
1
- ## 2.81.0
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
+
7
+ ## 2.80.3
8
+
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.
10
+ - **Fix**: A run on the source's keyframe grid is given no output-side trim. Beside `-copyts` that trim does the opposite of what it says: measured 2026-09-06 on a file with a 5 s keyframe interval, a run that landed at 15 s and was told to trim to the cut at 20 s produced its first piece starting at 10 s, and one told to trim to 17 s produced cuts at 13.129, 18.129, 23.129 — the whole grid moved back by the trim itself. The muxer's cut times are absolute under `-copyts` while the trim is not, so every cut of the run inherits the difference while the numbering, fixed when the process starts, does not.
11
+ - **Fix**: Those two together are why a film would not start on 2026-09-05. The run was asked for segment #36 at 247.873 s, landed at 230.439 s — one keyframe from each fault — and its pieces were named #36, #37, #38 while carrying film 17.4 s earlier: the proxy's own log said `segment #41 carries 267.976s while the playlist the player holds says 283.283s`, six times over. The soundtrack's run did the same with its own numbers, and the two tracks ended up with different holes. The player's picture buffer covered the playhead at 251.7 s; its sound buffer read `230.465..245.398` and `262.827..308.289` — a 17.4 s hole exactly across it. What a player can play is the overlap of the two, so nothing was playable, the cushion stood at 0.0 s for two minutes, and the viewer was told the proxy had sent no video. Every segment had in fact been delivered.
12
+ - **Chore**: Where a file is cut is decided in `services/output/cut-grid.js`, which is the layer that owns cutting; `hls-session-manager.js` is 48 lines shorter for it.
13
+
14
+ ## 2.80.2
15
+
16
+ - **Chore**: The same code as 2.80.1, republished. The release command ran twice when the session it was started from was interrupted, so both versions carry exactly the change described below; 2.80.2 is what `latest` points at.
2
17
 
3
18
  - **Fix**: Three encoders were started on one track within 200 ms, each into the road another was already writing, and the film came apart. Field 2026-09-05: fifteen readers on a piece store that holds sixteen pieces, half of all evictions taking a piece a reader had declared it wanted, two thirds of reads coming back from disk — and then `/stream` handing out bytes that were not the file's, twenty-two source-parse errors from ffmpeg at scattered offsets, a segment the player could not append, and an empty picture for six minutes while the proxy went on encoding. The placement rule was the cause: an encoder was put at the first unmade piece of the most urgent band, which is always the piece the working encoder is about to write.
4
19
  - **Fix**: One encoder's work now ends where the next one's begins. A free stretch may run to the end of the track, and an encoder given all of it walks into every stretch behind it — two LIVE encoders then write the same file names at the same time, and the file belongs to neither. That is the thing being prevented, and only that: driving through material that is already made is a legitimate outcome of the comparison, taken when encoding it again costs less than the death of this encoder plus the start of another. Bounded at the next start, two live stretches cannot overlap however the bands move. Bounding them at a band's own end was tried and is worse: a band edge travels with the viewer, so every step forward leaves a sliver just past the previous encoder and buys an encoder for it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.80.2",
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 {