@torrent-tv/proxy 2.24.1 → 2.25.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.25.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: Picture and sound drifted apart after a seek, by exactly the amount the grid had been corrected. 2.24.1 made every segment stamp itself against the playlist its own session published — but each session froze that playlist at its own creation, and a soundtrack or a quality step is created later than the picture it accompanies, so it froze a table that had since been corrected. Two members of one family then stated the same moment differently, and the corrections measured on the field file are 0.6-2.9 s. A family now publishes ONE timeline: a session created inside a family takes its base's published table verbatim and writes its own playlist from it, while the live table goes on being corrected for cutting, which is what keeps a re-encoded step aligned with the copy it joins.
|
|
4
|
+
- **New**: The read window grows into a lead instead of staying a fixed length. Every wait that cost time widens it by a piece; every piece already in hand gives one back, down to the size the caller sized from the file's own byte rate. The ceiling is this reader's share of the store's memory, so widening can never ask for more than the store can hold. Measured 2026-08-17, the swarm delivered 5.1-5.9 MB/s against a film consumed at about 1 MB/s while the reader still blocked 47 times in two minutes — a fivefold surplus that never became distance ahead of the head.
|
|
5
|
+
|
|
1
6
|
## 2.24.1
|
|
2
7
|
|
|
3
8
|
- **Fix**: Seeking could leave a film dead. After a seek the encoder restarts at the segment before the target, and every segment it then produces states its own position, read out of the piece. On a file whose container index is wrong those positions disagree with the playlist the player is holding — measured 2026-08-17, a seek to 1590.4 s produced audio segments #292 and #293 carrying 1587.892 s and 1592.692 s against a playlist saying 1585.376 s and 1590.585 s. A fragment landing further from where the playlist put it than a player will bridge (hls.js bridges `maxBufferHole`, 0.5 s by default) is not recognised as buffered, so the browser asks for it again: those two segments were fetched **1908 times each over ten minutes**, every one served in 4 ms, with the picture frozen and nothing in either log saying why. A segment is now stamped where the playlist the player holds says it begins, whenever the piece's own figure is further away than that; within it the piece's own figure is kept, which is what keeps speech and subtitles together on a file whose index is slightly out. The boundary table goes on being corrected from produced segments — that is what lets a re-encoded step be cut like the copy it joins — but the correction no longer moves segments under a player holding the original playlist: the published table is frozen when the playlist text is written from it. Pinned by `test/published-timeline.test.js` with the field figures.
|
package/package.json
CHANGED
|
@@ -1741,6 +1741,14 @@ export class HlsSessionManager {
|
|
|
1741
1741
|
})
|
|
1742
1742
|
: []);
|
|
1743
1743
|
const usingKeyframeBoundaries = useKeyframeGrid;
|
|
1744
|
+
// What this session will PUBLISH. A member of a family takes its base's
|
|
1745
|
+
// published table verbatim; a session with no base publishes what it cuts
|
|
1746
|
+
// at. The two differ exactly by the corrections made since the family's
|
|
1747
|
+
// first playlist was written, and that difference is what must never reach
|
|
1748
|
+
// the player as two different timelines.
|
|
1749
|
+
const publishedGrid = Array.isArray(inheritedGrid?.published) && inheritedGrid.published.length > 1
|
|
1750
|
+
? inheritedGrid.published
|
|
1751
|
+
: (hasDuration ? [...segmentBoundaries] : null);
|
|
1744
1752
|
const segmentCount = segmentBoundaries.length > 1 ? segmentBoundaries.length - 1 : 0;
|
|
1745
1753
|
|
|
1746
1754
|
// Realtime budget (software encoder): pick the output resolution + libx264
|
|
@@ -1908,13 +1916,18 @@ export class HlsSessionManager {
|
|
|
1908
1916
|
// spliced into the copy (roadmap item 28).
|
|
1909
1917
|
containerFormat,
|
|
1910
1918
|
indexCheck: newIndexCheck(),
|
|
1911
|
-
playlistText: hasDuration ? this.#buildVodPlaylist(
|
|
1912
|
-
// The table AS PUBLISHED
|
|
1913
|
-
//
|
|
1914
|
-
//
|
|
1915
|
-
//
|
|
1916
|
-
//
|
|
1917
|
-
|
|
1919
|
+
playlistText: hasDuration ? this.#buildVodPlaylist(publishedGrid, segmentFormat) : "",
|
|
1920
|
+
// The table AS PUBLISHED — what every playlist of this family states, and
|
|
1921
|
+
// what every segment of it is stamped against. Inherited whole from the
|
|
1922
|
+
// base when there is one, so a rung or a soundtrack created later
|
|
1923
|
+
// publishes the same timeline as the picture it plays with; only a family
|
|
1924
|
+
// with no base freezes a copy of its own.
|
|
1925
|
+
//
|
|
1926
|
+
// `segmentBoundaries` keeps being corrected from produced segments — that
|
|
1927
|
+
// is what makes a re-encoded rung cut like the copy it joins — and those
|
|
1928
|
+
// corrections deliberately do NOT reach this copy: the player's timeline
|
|
1929
|
+
// was sent once and cannot be revised.
|
|
1930
|
+
publishedBoundaries: publishedGrid,
|
|
1918
1931
|
// Segment index the current ffmpeg run started producing from.
|
|
1919
1932
|
encodeStartIndex: 0,
|
|
1920
1933
|
// Guards against repeatedly restarting to the same seek position.
|
|
@@ -6134,8 +6147,14 @@ export class HlsSessionManager {
|
|
|
6134
6147
|
inheritedGrid: base.cutGrid === "keyframe"
|
|
6135
6148
|
? {
|
|
6136
6149
|
// The table as it stands NOW, corrections included — not the index
|
|
6137
|
-
// it was first built from.
|
|
6150
|
+
// it was first built from. This is what the new session CUTS at.
|
|
6138
6151
|
boundaries: base.segmentBoundaries,
|
|
6152
|
+
// And this is what it must SAY, which is not the same thing: every
|
|
6153
|
+
// member of a family has to publish one timeline, or two sessions
|
|
6154
|
+
// stamp the same moment differently and the picture and the sound
|
|
6155
|
+
// drift apart by exactly the corrections made between their two
|
|
6156
|
+
// creations (field 2026-08-17, corrections of 0.6-2.9 s).
|
|
6157
|
+
published: base.publishedBoundaries,
|
|
6139
6158
|
keyframeTimes: base.keyframeTimes,
|
|
6140
6159
|
containerFormat: base.containerFormat
|
|
6141
6160
|
}
|
|
@@ -6670,6 +6689,7 @@ export class HlsSessionManager {
|
|
|
6670
6689
|
inheritedGrid: base.cutGrid === "keyframe"
|
|
6671
6690
|
? {
|
|
6672
6691
|
boundaries: base.segmentBoundaries,
|
|
6692
|
+
published: base.publishedBoundaries,
|
|
6673
6693
|
keyframeTimes: base.keyframeTimes,
|
|
6674
6694
|
containerFormat: base.containerFormat
|
|
6675
6695
|
}
|
|
@@ -60,6 +60,37 @@ export function readWindowFor({ pieceIndex, lastPiece, windowPieces }) {
|
|
|
60
60
|
return { from: pieceIndex, to: Math.min(lastPiece, pieceIndex + span - 1) };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* How wide the window should be after a piece that made the reader wait — or
|
|
65
|
+
* did not.
|
|
66
|
+
*
|
|
67
|
+
* The swarm's surplus is what pays for this. Measured 2026-08-17 on the field
|
|
68
|
+
* torrent: 5.1-5.9 MB/s delivered against a film consumed at about 1 MB/s, and
|
|
69
|
+
* the reader still blocked 47 times in two minutes, median 1.5 s, worst 4.5 s.
|
|
70
|
+
* A fivefold surplus never became distance ahead of the head, because the
|
|
71
|
+
* window is a fixed number of seconds of playback and everything past it is
|
|
72
|
+
* ordinary background fill at no priority.
|
|
73
|
+
*
|
|
74
|
+
* So the window follows the evidence: every wait that mattered widens it by a
|
|
75
|
+
* piece, every piece that was already there narrows it back toward the size the
|
|
76
|
+
* caller asked for. Nothing here is chosen — the wait is measured, the
|
|
77
|
+
* threshold is the one that already defines "a wait worth recording", and the
|
|
78
|
+
* ceiling is this reader's share of the store's memory, so widening can never
|
|
79
|
+
* cost more than the store can hold.
|
|
80
|
+
*
|
|
81
|
+
* @param {{ current: number, base: number, ceiling: number, waitedMs: number, waitThresholdMs: number }} params
|
|
82
|
+
* @returns {number}
|
|
83
|
+
*/
|
|
84
|
+
export function nextWindowPieces({ current, base, ceiling, waitedMs, waitThresholdMs }) {
|
|
85
|
+
const floor = Math.max(1, Math.floor(base));
|
|
86
|
+
const top = Math.max(floor, Math.floor(ceiling));
|
|
87
|
+
const now = Math.min(top, Math.max(floor, Math.floor(current)));
|
|
88
|
+
if (waitedMs >= waitThresholdMs) {
|
|
89
|
+
return Math.min(top, now + 1);
|
|
90
|
+
}
|
|
91
|
+
return Math.max(floor, now - 1);
|
|
92
|
+
}
|
|
93
|
+
|
|
63
94
|
/**
|
|
64
95
|
* Add this reader's window to the download set as a stream selection.
|
|
65
96
|
*
|
|
@@ -310,7 +341,26 @@ export async function* readFragments({
|
|
|
310
341
|
// critical, and never deselected anything — so ffmpeg's opening
|
|
311
342
|
// `bytes 0-<EOF>` left a permanent selection over the entire file, and no
|
|
312
343
|
// later prioritisation could outrank it.
|
|
313
|
-
const
|
|
344
|
+
const basePieces = Math.max(1, Math.ceil(Math.max(1, windowBytes) / pieceLength));
|
|
345
|
+
// What the window is RIGHT NOW. It starts at what the caller sized in seconds
|
|
346
|
+
// of playback and grows while the reader keeps being made to wait — see
|
|
347
|
+
// `nextWindowPieces`.
|
|
348
|
+
let windowPieces = basePieces;
|
|
349
|
+
/**
|
|
350
|
+
* The widest this reader may go: its share of what the store can hold in
|
|
351
|
+
* memory. Measured rather than chosen — the capacity is the store's own, and
|
|
352
|
+
* the number of readers is how many windows are declared on it right now.
|
|
353
|
+
*
|
|
354
|
+
* @returns {number}
|
|
355
|
+
*/
|
|
356
|
+
const ceilingPieces = () => {
|
|
357
|
+
const capacity = Number(store?.capacity);
|
|
358
|
+
if (!Number.isFinite(capacity) || capacity <= 0) {
|
|
359
|
+
return basePieces;
|
|
360
|
+
}
|
|
361
|
+
const readers = Math.max(1, store.protectedRanges?.().length ?? 1);
|
|
362
|
+
return Math.max(basePieces, Math.floor(capacity / readers));
|
|
363
|
+
};
|
|
314
364
|
/** @type {{ from: number, to: number } | null} */
|
|
315
365
|
let window = null;
|
|
316
366
|
/** @type {{ from: number, to: number } | null} */
|
|
@@ -408,6 +458,20 @@ export async function* readFragments({
|
|
|
408
458
|
// whether that is the swarm, the picker, or ffmpeg. Logged only when the
|
|
409
459
|
// wait is long enough to matter, so ordinary sequential reading is silent.
|
|
410
460
|
const waitedMs = Date.now() - waitStartedAt;
|
|
461
|
+
// The window answers to what just happened: a wait means the lead was too
|
|
462
|
+
// short, an immediate hit means it is longer than it needs to be. Applied
|
|
463
|
+
// before the logging below so the line reports the window the next piece
|
|
464
|
+
// will actually use.
|
|
465
|
+
const widened = nextWindowPieces({
|
|
466
|
+
current: windowPieces,
|
|
467
|
+
base: basePieces,
|
|
468
|
+
ceiling: ceilingPieces(),
|
|
469
|
+
waitedMs,
|
|
470
|
+
waitThresholdMs: PIECE_WAIT_LOG_MS
|
|
471
|
+
});
|
|
472
|
+
if (widened !== windowPieces) {
|
|
473
|
+
windowPieces = widened;
|
|
474
|
+
}
|
|
411
475
|
if (waitedMs >= PIECE_WAIT_LOG_MS) {
|
|
412
476
|
const rateKbps = Math.round(pieceLength / 1024 / (waitedMs / 1000));
|
|
413
477
|
logger.info(
|
package/test/read-window.test.js
CHANGED
|
@@ -19,7 +19,11 @@ import { EventEmitter } from "node:events";
|
|
|
19
19
|
import os from "node:os";
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import fs from "node:fs/promises";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
nextWindowPieces,
|
|
24
|
+
readFragments,
|
|
25
|
+
readWindowFor
|
|
26
|
+
} from "../services/torrent-worker/piece-reader.js";
|
|
23
27
|
import { SharedPieceStore } from "../services/piece-store/shared-piece-store.js";
|
|
24
28
|
|
|
25
29
|
const PIECE = 1024;
|
|
@@ -276,3 +280,50 @@ test("a reader that is abandoned mid-fragment does not keep the piece pinned", a
|
|
|
276
280
|
await fs.rm(directory, { recursive: true, force: true });
|
|
277
281
|
}
|
|
278
282
|
});
|
|
283
|
+
|
|
284
|
+
// ------------------------------------------ the window that grows into a lead
|
|
285
|
+
|
|
286
|
+
test("a wait that mattered widens the window by a piece", () => {
|
|
287
|
+
// Field 2026-08-17: 5.1-5.9 MB/s delivered against ~1 MB/s consumed, and the
|
|
288
|
+
// reader still blocked 47 times in two minutes. The surplus never became
|
|
289
|
+
// distance ahead of the head.
|
|
290
|
+
assert.equal(
|
|
291
|
+
nextWindowPieces({ current: 4, base: 4, ceiling: 12, waitedMs: 1457, waitThresholdMs: 1000 }),
|
|
292
|
+
5
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("a piece that was already there gives a piece back", () => {
|
|
297
|
+
assert.equal(
|
|
298
|
+
nextWindowPieces({ current: 7, base: 4, ceiling: 12, waitedMs: 0, waitThresholdMs: 1000 }),
|
|
299
|
+
6
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("it never shrinks below what the caller asked for", () => {
|
|
304
|
+
assert.equal(
|
|
305
|
+
nextWindowPieces({ current: 4, base: 4, ceiling: 12, waitedMs: 0, waitThresholdMs: 1000 }),
|
|
306
|
+
4
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("it never grows past this reader's share of the store", () => {
|
|
311
|
+
assert.equal(
|
|
312
|
+
nextWindowPieces({ current: 12, base: 4, ceiling: 12, waitedMs: 4453, waitThresholdMs: 1000 }),
|
|
313
|
+
12
|
|
314
|
+
);
|
|
315
|
+
// A ceiling below the base cannot pull the window under it: the caller sized
|
|
316
|
+
// the base from the file's own byte rate, and a store too small to hold it is
|
|
317
|
+
// an argument about memory, not about what the reader needs next.
|
|
318
|
+
assert.equal(
|
|
319
|
+
nextWindowPieces({ current: 4, base: 4, ceiling: 1, waitedMs: 2000, waitThresholdMs: 1000 }),
|
|
320
|
+
4
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("a wait exactly at the threshold counts as a wait", () => {
|
|
325
|
+
assert.equal(
|
|
326
|
+
nextWindowPieces({ current: 4, base: 4, ceiling: 9, waitedMs: 1000, waitThresholdMs: 1000 }),
|
|
327
|
+
5
|
|
328
|
+
);
|
|
329
|
+
});
|