@torrent-tv/proxy 2.49.0 → 2.50.0

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,8 @@
1
+ ## 2.50.0
2
+
3
+ - **Fix**: An AVI seek is asked for late enough to survive the container's own arithmetic. AVI names a keyframe by its frame NUMBER, and `services/container-index/avi.js` turned that into a time by multiplying by the frame duration the header declares — which lands 10-44 ms from the presentation time the demuxer computes, always under one frame (measured 2026-08-21 against the files themselves: 1196 index entries against 1196 real keyframes and 901 against 901, the frames exactly right and only their names off). A name sitting just BELOW its real keyframe seeks to before it and lands on the one before that, which is the same fault the landing offset already exists for. The reader now declares how far its times may be, and the request carries that on top. Matroska and MP4 declare nothing, because they state instants outright — nine files and 11 665 keyframes with not one disagreement.
4
+ - **Fix**: A container with no keyframe index is re-encoded rather than copied against a grid nobody knows. MPEG-TS carries no index of any kind — measured the same day, 669 real keyframes and nothing to read them from without walking the file — and a copied picture can only be cut at the source's own keyframes, so declaring an even grid is a falsehood the player punishes: it walks the whole file to rebuild the timeline, or presents audio with no picture because a segment begins with nothing decodable, both field-observed 2026-08-02. Re-encoding PLACES keyframes on our own cuts, so the grid is right by construction whatever the container. A container whose index could not be read inside the budget lands here too, for the same reason. It costs an encoder, and the alternative was a broken playlist.
5
+
1
6
  ## 2.49.0
2
7
 
3
8
  - **Fix**: The torrent worker is allowed to END rather than being torn down under itself. A core dump read on 2026-08-21 named the fault the proxy has been dying of: `SIGSEGV` in `v8::Value::IsArrayBufferView` reached through `napi_get_buffer_info` from utp-native's `on_utp_accept`, called from its UDP read — all of it inside `node::Environment::CleanupHandles`, under `FreeEnvironment`, on `Worker::Run`. That is a teardown race, not a data fault, which is why neither patch our forked library already carries touched it: a datagram arriving while the environment is being freed walks into an isolate that no longer exists. `destroyAll` called `Worker.terminate()` immediately after destroying the client inside, and `terminate()` frees the environment with libuv's handle callbacks still queued. It now waits for the thread to exit by itself — once the client is destroyed nothing holds its loop open — with `terminate()` kept as a five-second fallback, because a shutdown that hangs is worse than one that is forced.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.49.0",
3
+ "version": "2.50.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -152,5 +152,16 @@ export async function readAviKeyframeTimes(readRange, fileSize) {
152
152
  }
153
153
  videoFrame += 1;
154
154
  }
155
- return times.length > 0 ? times : null;
155
+ if (times.length === 0) {
156
+ return null;
157
+ }
158
+ // AVI names a keyframe by its FRAME NUMBER, and the time above is that number
159
+ // multiplied by the frame duration the header declares. The frames are the
160
+ // right ones — measured 2026-08-21 against the files themselves, 1196 index
161
+ // entries against 1196 real keyframes and 901 against 901, exactly — but the
162
+ // names are 10-44 ms away from the presentation times the demuxer computes,
163
+ // always under one frame. So the caller is told how far a time here may be
164
+ // from the instant it refers to, and can ask for a seek late enough that it
165
+ // still lands on the frame rather than on the one before it.
166
+ return { times, tolerance: secondsPerFrame };
156
167
  }
@@ -62,7 +62,7 @@ const READERS = [
62
62
  * @param {ReadRange} params.readRange
63
63
  * @param {number} params.fileSize
64
64
  * @param {string} [params.label] - For logging only.
65
- * @returns {Promise<{ times: number[] | null, format: string }>} Ascending
65
+ * @returns {Promise<{ times: number[] | null, format: string, tolerance: number }>} Ascending
66
66
  * seconds, or null times when this file has no readable index — the caller
67
67
  * must then not claim to know the grid. The format is which reader matched,
68
68
  * reported whether or not it produced anything: how often an index disagrees
@@ -71,27 +71,39 @@ const READERS = [
71
71
  */
72
72
  export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
73
73
  if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) {
74
- return { times: null, format: "unknown" };
74
+ return { times: null, format: "unknown", tolerance: 0 };
75
75
  }
76
76
 
77
77
  const startedAt = Date.now();
78
78
  let times = null;
79
79
  let format = "unrecognised";
80
+ // How far a time in `times` may be from the instant it names. Zero wherever
81
+ // the container states instants outright, which Matroska and MP4 both do.
82
+ let tolerance = 0;
80
83
  try {
81
84
  const sniff = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
82
85
  if (!sniff) {
83
- return { times: null, format: "unread" };
86
+ return { times: null, format: "unread", tolerance: 0 };
84
87
  }
85
88
  const reader = READERS.find((candidate) => candidate.matches(sniff));
86
89
  if (reader) {
87
90
  format = reader.name;
88
- times = await reader.read(readRange, fileSize);
91
+ const read = await reader.read(readRange, fileSize);
92
+ // A reader may answer with the times alone, or with how far those times
93
+ // may sit from the instants they name — which only AVI has to say,
94
+ // because only AVI computes them from frame numbers.
95
+ if (Array.isArray(read)) {
96
+ times = read;
97
+ } else if (read && Array.isArray(read.times)) {
98
+ times = read.times;
99
+ tolerance = Number.isFinite(read.tolerance) ? read.tolerance : 0;
100
+ }
89
101
  }
90
102
  } catch (error) {
91
103
  // A malformed or partially-downloaded index must never take playback down —
92
104
  // it only means the grid is unknown, which the caller already handles.
93
105
  logger.warn(`container-index: failed to read index for "${label}": ${error?.message ?? error}`);
94
- return { times: null, format };
106
+ return { times: null, format, tolerance: 0 };
95
107
  }
96
108
 
97
109
  const elapsedMs = Date.now() - startedAt;
@@ -102,5 +114,5 @@ export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
102
114
  } else {
103
115
  logger.info(`container-index: no usable index for "${label}" (${format}, ${elapsedMs}ms)`);
104
116
  }
105
- return { times, format };
117
+ return { times, format, tolerance };
106
118
  }
@@ -1196,12 +1196,17 @@ export function seekLandingOffsetFor(session, keyframe) {
1196
1196
  if (session?.transcodeVideo === true) {
1197
1197
  return 0;
1198
1198
  }
1199
+ // A grid whose times are approximate needs that error added on top, or a name
1200
+ // sitting just below its real keyframe seeks to before it and lands on the
1201
+ // one before that. Only AVI declares one.
1202
+ const tolerance = Number.isFinite(session?.keyframeTolerance) ? Math.max(0, session.keyframeTolerance) : 0;
1203
+ const wanted = SEEK_LANDING_OFFSET_SEC + tolerance;
1199
1204
  const times = Array.isArray(session?.keyframeTimes) ? session.keyframeTimes : [];
1200
1205
  const next = times.find((time) => time > keyframe + 0.001);
1201
1206
  if (next === undefined) {
1202
- return SEEK_LANDING_OFFSET_SEC;
1207
+ return wanted;
1203
1208
  }
1204
- return Math.min(SEEK_LANDING_OFFSET_SEC, (next - keyframe) / 2);
1209
+ return Math.min(wanted, (next - keyframe) / 2);
1205
1210
  }
1206
1211
 
1207
1212
  /**
@@ -1746,6 +1751,13 @@ export class HlsSessionManager {
1746
1751
  // branches; on failure both fall back to their current behaviour (uniform
1747
1752
  // grid for boundaries, raw target for seeking) — no regression.
1748
1753
  let keyframeTimes = null;
1754
+ // How far a time in `keyframeTimes` may sit from the instant it names. Only
1755
+ // AVI has anything to declare here: it stores frame NUMBERS and the time is
1756
+ // that number times the frame duration, which lands 10-44 ms from the
1757
+ // presentation time the demuxer computes (measured 2026-08-21). A seek made
1758
+ // at such a name can fall just BELOW the real keyframe and land on the one
1759
+ // before it, which is the same fault the landing offset exists for.
1760
+ let keyframeTolerance = 0;
1749
1761
  let keyframeMs = -1; // -1 = not run (skipped), -2 = running in the background
1750
1762
  // Which container supplied the index, carried so the accuracy summary can
1751
1763
  // say what it is a summary OF.
@@ -1758,6 +1770,9 @@ export class HlsSessionManager {
1758
1770
  if (inheritedGrid) {
1759
1771
  keyframeTimes = inheritedGrid.keyframeTimes;
1760
1772
  containerFormat = inheritedGrid.containerFormat ?? "";
1773
+ keyframeTolerance = Number.isFinite(inheritedGrid.keyframeTolerance)
1774
+ ? inheritedGrid.keyframeTolerance
1775
+ : 0;
1761
1776
  } else if (hasDuration && !transcodeVideo && !audioOnly) {
1762
1777
  // Video-COPY path: keyframeTimes are REQUIRED to build correct segment
1763
1778
  // boundaries (the playlist itself), so this MUST block session creation —
@@ -1779,11 +1794,28 @@ export class HlsSessionManager {
1779
1794
  const index = await this.#readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName });
1780
1795
  keyframeTimes = index.times;
1781
1796
  containerFormat = index.format;
1797
+ keyframeTolerance = Number.isFinite(index.tolerance) ? index.tolerance : 0;
1782
1798
  keyframeMs = Date.now() - keyframeStartMs;
1783
1799
  if (!keyframeTimes) {
1800
+ // No index, so there is no honest grid for a COPY: a copied picture can
1801
+ // only be cut at the source's own keyframes, and we do not know where
1802
+ // they are. Declaring an even grid instead is a falsehood the player
1803
+ // punishes — it walks the whole file to rebuild the timeline, or shows
1804
+ // audio with no picture because a segment begins with nothing
1805
+ // decodable (both field-observed 2026-08-02).
1806
+ //
1807
+ // Re-encoding is the honest answer and costs an encoder: keyframes are
1808
+ // then PLACED at our own cut times rather than found, so the grid is
1809
+ // correct by construction whatever the container. MPEG-TS is the case
1810
+ // this exists for — measured 2026-08-21, 669 real keyframes and no
1811
+ // index of any kind to read them from — and a container whose index
1812
+ // could not be read in the budget lands here too, which is right for
1813
+ // the same reason.
1814
+ transcodeVideo = true;
1784
1815
  logger.warn(
1785
- `transcode ${sessionId}: no container keyframe index for "${logName}"; ` +
1786
- `falling back to a uniform grid segment boundaries will not match the media`
1816
+ `transcode ${sessionId}: no keyframe index in the ${containerFormat} container for ` +
1817
+ `"${logName}" a copied picture has no honest grid without one, so the video is ` +
1818
+ "re-encoded instead and its keyframes are placed on our own cuts"
1787
1819
  );
1788
1820
  }
1789
1821
  } else if (hasDuration && transcodeVideo) {
@@ -2035,6 +2067,9 @@ export class HlsSessionManager {
2035
2067
  // KNOWN valid position instead of trusting the container's own on-the-fly
2036
2068
  // seek at an arbitrary target — see the probe call above for why.
2037
2069
  keyframeTimes,
2070
+ // How far those times may sit from the instants they name — nonzero only
2071
+ // for AVI, which computes them from frame numbers.
2072
+ keyframeTolerance,
2038
2073
  // Which container the index came from, and how well it has held up. The
2039
2074
  // cut times of a copied video ARE its index, and an index can be wrong —
2040
2075
  // measured 2026-08-06, one claimed a keyframe four seconds from where the
@@ -6848,6 +6883,7 @@ export class HlsSessionManager {
6848
6883
  // creations (field 2026-08-17, corrections of 0.6-2.9 s).
6849
6884
  published: base.publishedBoundaries,
6850
6885
  keyframeTimes: base.keyframeTimes,
6886
+ keyframeTolerance: base.keyframeTolerance,
6851
6887
  containerFormat: base.containerFormat
6852
6888
  }
6853
6889
  : null,
@@ -48,3 +48,29 @@ test("no keyframe list is still answered", () => {
48
48
  assert.equal(seekLandingOffsetFor({ transcodeVideo: false }, 5), OFFSET);
49
49
  assert.equal(seekLandingOffsetFor(null, 5), OFFSET);
50
50
  });
51
+
52
+ test("a grid whose times are approximate is asked for that much later again", () => {
53
+ // AVI names a keyframe by its frame NUMBER and the time is that number times
54
+ // the frame duration, so a name can sit just BELOW the keyframe it refers to
55
+ // — measured 2026-08-21, 10-44 ms out on two files, always under one frame.
56
+ // Asking at the name alone would seek to before the real keyframe and land on
57
+ // the one before that, which is the fault this offset exists for.
58
+ const session = {
59
+ transcodeVideo: false,
60
+ keyframeTolerance: 0.04,
61
+ keyframeTimes: [0, 4.004, 8.008, 12.012]
62
+ };
63
+ assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET + 0.04);
64
+ });
65
+
66
+ test("an exact grid claims no tolerance", () => {
67
+ // Matroska and MP4 state instants outright — measured the same day, nine
68
+ // files and 11 665 keyframes with not one disagreement.
69
+ const session = { transcodeVideo: false, keyframeTolerance: 0, keyframeTimes: [0, 4.004, 8.008] };
70
+ assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET);
71
+ });
72
+
73
+ test("the bound still holds once a tolerance is added", () => {
74
+ const session = { transcodeVideo: false, keyframeTolerance: 1, keyframeTimes: [0, 0.1, 0.2] };
75
+ assert.equal(seekLandingOffsetFor(session, 0.1), 0.05);
76
+ });