@torrent-tv/proxy 2.24.0 → 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,12 @@
|
|
|
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
|
+
|
|
6
|
+
## 2.24.1
|
|
7
|
+
|
|
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.
|
|
9
|
+
|
|
1
10
|
## 2.24.0
|
|
2
11
|
|
|
3
12
|
- **New**: The budget's two remaining holes are closed, which is the rest of roadmap item 6. A soundtrack published on its own is a second encoder running for as long as the picture does, and it was charged at nothing; a picture being RE-ENCODED beside the step being judged — which is what every quality switch does, two encoders on purpose — was charged at nothing too. Both are priced now: the soundtrack from its own measured speed, the second picture from what it was last seen doing alone, falling back to the same model that judges every step. Nothing is charged for an encode nobody has measured and no model can price: a guess there would refuse steps on arithmetic no one performed.
|
package/package.json
CHANGED
|
@@ -541,6 +541,17 @@ const SEGMENT_READ_HIGH_WATER_MARK = 4 * 1024 * 1024;
|
|
|
541
541
|
// index being wrong about where a keyframe is rather than rounding.
|
|
542
542
|
const SEGMENT_START_DISAGREEMENT_SEC = 0.25;
|
|
543
543
|
|
|
544
|
+
/**
|
|
545
|
+
* How far a fragment may land from where the playlist put it before the player
|
|
546
|
+
* stops recognising it as buffered.
|
|
547
|
+
*
|
|
548
|
+
* hls.js's own `maxBufferHole`, whose default is 0.5 s: a gap smaller than this
|
|
549
|
+
* is skipped, a gap larger is a hole, and a fragment appended across one is
|
|
550
|
+
* judged not to have loaded — so the player asks for it again, and again.
|
|
551
|
+
* Taken from the player's published default rather than chosen here.
|
|
552
|
+
*/
|
|
553
|
+
const PLAYER_BUFFER_HOLE_SEC = 0.5;
|
|
554
|
+
|
|
544
555
|
/**
|
|
545
556
|
* Resolve after a given number of milliseconds.
|
|
546
557
|
*
|
|
@@ -1730,6 +1741,14 @@ export class HlsSessionManager {
|
|
|
1730
1741
|
})
|
|
1731
1742
|
: []);
|
|
1732
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);
|
|
1733
1752
|
const segmentCount = segmentBoundaries.length > 1 ? segmentBoundaries.length - 1 : 0;
|
|
1734
1753
|
|
|
1735
1754
|
// Realtime budget (software encoder): pick the output resolution + libx264
|
|
@@ -1897,7 +1916,18 @@ export class HlsSessionManager {
|
|
|
1897
1916
|
// spliced into the copy (roadmap item 28).
|
|
1898
1917
|
containerFormat,
|
|
1899
1918
|
indexCheck: newIndexCheck(),
|
|
1900
|
-
playlistText: hasDuration ? this.#buildVodPlaylist(
|
|
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,
|
|
1901
1931
|
// Segment index the current ffmpeg run started producing from.
|
|
1902
1932
|
encodeStartIndex: 0,
|
|
1903
1933
|
// Guards against repeatedly restarting to the same seek position.
|
|
@@ -2323,6 +2353,65 @@ export class HlsSessionManager {
|
|
|
2323
2353
|
return boundaries[clamped];
|
|
2324
2354
|
}
|
|
2325
2355
|
|
|
2356
|
+
/**
|
|
2357
|
+
* Start time of segment `index` AS THE PLAYER WAS TOLD IT — from the boundary
|
|
2358
|
+
* table as it stood when this session's playlist text was built.
|
|
2359
|
+
*
|
|
2360
|
+
* Two tables, deliberately: the live one is corrected as produced segments
|
|
2361
|
+
* reveal where the file's cuts truly are, and those corrections are what let a
|
|
2362
|
+
* re-encoded rung be forced onto a copied stream's real grid. But the playlist
|
|
2363
|
+
* a player is holding was written once and never changes, so a stamp taken
|
|
2364
|
+
* from the corrected table describes a timeline nobody sent the player. That
|
|
2365
|
+
* is not a subtlety: it cost ten minutes of a dead film on 2026-08-17, the
|
|
2366
|
+
* browser asking for two segments 1908 times each.
|
|
2367
|
+
*
|
|
2368
|
+
* @param {HlsSession} session
|
|
2369
|
+
* @param {number} index
|
|
2370
|
+
* @returns {number}
|
|
2371
|
+
*/
|
|
2372
|
+
#publishedStartTime(session, index) {
|
|
2373
|
+
const boundaries = Array.isArray(session.publishedBoundaries) && session.publishedBoundaries.length > 0
|
|
2374
|
+
? session.publishedBoundaries
|
|
2375
|
+
: null;
|
|
2376
|
+
if (!boundaries) {
|
|
2377
|
+
// No playlist was published from a table (no duration, so no synthetic
|
|
2378
|
+
// playlist): the live table is all there is, and it has not been
|
|
2379
|
+
// contradicted by anything the player holds.
|
|
2380
|
+
return this.#segmentStartTime(session, index);
|
|
2381
|
+
}
|
|
2382
|
+
const clamped = Math.max(0, Math.min(index, boundaries.length - 1));
|
|
2383
|
+
return boundaries[clamped];
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/**
|
|
2387
|
+
* Report a segment whose own timeline disagrees with the playlist by more
|
|
2388
|
+
* than a player will bridge.
|
|
2389
|
+
*
|
|
2390
|
+
* Once per segment per five seconds, like every other repeating condition
|
|
2391
|
+
* here: the same segment is requested again and again while it is refused,
|
|
2392
|
+
* and a line each time buries the first one.
|
|
2393
|
+
*
|
|
2394
|
+
* @param {HlsSession} session
|
|
2395
|
+
* @param {number} index
|
|
2396
|
+
* @param {number} trueStart
|
|
2397
|
+
* @param {number} publishedStart
|
|
2398
|
+
* @returns {void}
|
|
2399
|
+
*/
|
|
2400
|
+
#notePlaylistDisagreement(session, index, trueStart, publishedStart) {
|
|
2401
|
+
const now = Date.now();
|
|
2402
|
+
session.stampWarnedAt ??= new Map();
|
|
2403
|
+
if (now - (session.stampWarnedAt.get(index) ?? 0) < 5_000) {
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
session.stampWarnedAt.set(index, now);
|
|
2407
|
+
logger.warn(
|
|
2408
|
+
`transcode ${session.id} segment #${index} carries ${trueStart.toFixed(3)}s while the playlist ` +
|
|
2409
|
+
`the player holds says ${publishedStart.toFixed(3)}s — a gap of ` +
|
|
2410
|
+
`${Math.abs(trueStart - publishedStart).toFixed(3)}s, beyond the ${PLAYER_BUFFER_HOLE_SEC}s a player ` +
|
|
2411
|
+
"bridges; stamping it where the playlist says so the fragment lands where it was asked for"
|
|
2412
|
+
);
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2326
2415
|
/**
|
|
2327
2416
|
* Segment index whose span contains time `t` (0-based), via the boundary
|
|
2328
2417
|
* table.
|
|
@@ -6058,8 +6147,14 @@ export class HlsSessionManager {
|
|
|
6058
6147
|
inheritedGrid: base.cutGrid === "keyframe"
|
|
6059
6148
|
? {
|
|
6060
6149
|
// The table as it stands NOW, corrections included — not the index
|
|
6061
|
-
// it was first built from.
|
|
6150
|
+
// it was first built from. This is what the new session CUTS at.
|
|
6062
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,
|
|
6063
6158
|
keyframeTimes: base.keyframeTimes,
|
|
6064
6159
|
containerFormat: base.containerFormat
|
|
6065
6160
|
}
|
|
@@ -6594,6 +6689,7 @@ export class HlsSessionManager {
|
|
|
6594
6689
|
inheritedGrid: base.cutGrid === "keyframe"
|
|
6595
6690
|
? {
|
|
6596
6691
|
boundaries: base.segmentBoundaries,
|
|
6692
|
+
published: base.publishedBoundaries,
|
|
6597
6693
|
keyframeTimes: base.keyframeTimes,
|
|
6598
6694
|
containerFormat: base.containerFormat
|
|
6599
6695
|
}
|
|
@@ -6960,8 +7056,36 @@ export class HlsSessionManager {
|
|
|
6960
7056
|
if (trueStart !== null) {
|
|
6961
7057
|
this.#noteIndexAccuracy(session, index, trueStart, declaredStart);
|
|
6962
7058
|
}
|
|
7059
|
+
// WHERE THE PLAYER WAS TOLD THIS SEGMENT BEGINS, which is the playlist
|
|
7060
|
+
// it holds and nothing else. The published text is fixed when the
|
|
7061
|
+
// session is created; `#segmentStartTime` reads a table that a
|
|
7062
|
+
// correction may since have moved, and a stamp taken from the moved
|
|
7063
|
+
// table describes a timeline the player has never seen.
|
|
7064
|
+
const publishedStart = this.#publishedStartTime(session, index);
|
|
7065
|
+
// A player places a fragment by the playlist. If the bytes claim a
|
|
7066
|
+
// different position, the fragment does not land where the fragment was
|
|
7067
|
+
// expected, hls.js finds the range still unbuffered and asks for the
|
|
7068
|
+
// same fragment again — for ever. Measured 2026-08-17: a seek to
|
|
7069
|
+
// 1590.4 s produced audio segments #292/#293 whose own timeline said
|
|
7070
|
+
// 1587.892 and 1592.692 against a playlist saying 1585.376 and
|
|
7071
|
+
// 1590.585, and the browser fetched those two segments 1908 times each
|
|
7072
|
+
// over ten minutes, every one of them served in 4 ms. The film was dead
|
|
7073
|
+
// and no line said why.
|
|
7074
|
+
//
|
|
7075
|
+
// So the stamp follows the playlist whenever the two disagree by more
|
|
7076
|
+
// than a player will bridge. hls.js bridges up to `maxBufferHole`,
|
|
7077
|
+
// which it defaults to 0.5 s — that is the player's own published
|
|
7078
|
+
// figure, not a number chosen here. Within it the file's own position
|
|
7079
|
+
// is kept, because it is the honest one and it is what keeps speech and
|
|
7080
|
+
// subtitles together on a file whose index is slightly out (2026-08-06,
|
|
7081
|
+
// 4.17 s of drift on a Matroska index that lied).
|
|
7082
|
+
let stampStart = trueStart ?? publishedStart;
|
|
7083
|
+
if (trueStart !== null && Math.abs(trueStart - publishedStart) > PLAYER_BUFFER_HOLE_SEC) {
|
|
7084
|
+
stampStart = publishedStart;
|
|
7085
|
+
this.#notePlaylistDisagreement(session, index, trueStart, publishedStart);
|
|
7086
|
+
}
|
|
6963
7087
|
const prepared = session.segmentFormat.prepareSegmentBytes(bytes, {
|
|
6964
|
-
startSeconds:
|
|
7088
|
+
startSeconds: stampStart,
|
|
6965
7089
|
initBytes: session.initBytes ?? null
|
|
6966
7090
|
});
|
|
6967
7091
|
this.#noteRunProducedSegment(session, filePath);
|
|
@@ -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(
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A segment must be stamped where the playlist the player holds says.
|
|
3
|
+
*
|
|
4
|
+
* The field case this pins, 2026-08-17: a seek to 1590.4 s restarted the audio
|
|
5
|
+
* rendition at #291; the produced segments #292 and #293 carried their own
|
|
6
|
+
* positions, 1587.892 s and 1592.692 s, while the playlist the browser was
|
|
7
|
+
* holding said 1585.376 s and 1590.585 s. The browser fetched those two
|
|
8
|
+
* segments **1908 times each** over ten minutes, every one served in 4 ms, and
|
|
9
|
+
* the film never moved: a fragment appended more than `maxBufferHole` away from
|
|
10
|
+
* where the playlist put it is not recognised as buffered, so the player asks
|
|
11
|
+
* for it again.
|
|
12
|
+
*
|
|
13
|
+
* The rule, and the reason it is not simply "always use the file's own time":
|
|
14
|
+
* the boundary table keeps being corrected from produced segments, and the
|
|
15
|
+
* playlist does not. Whichever is right about the FILE, only one of them was
|
|
16
|
+
* sent to the player.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import test from "node:test";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* hls.js's own default. A gap smaller than this is skipped; a larger one is a
|
|
24
|
+
* hole, and the fragment across it counts as not loaded.
|
|
25
|
+
*/
|
|
26
|
+
const PLAYER_BUFFER_HOLE_SEC = 0.5;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The decision under test, in the shape the session manager applies it.
|
|
30
|
+
*
|
|
31
|
+
* @param {{ trueStart: number | null, publishedStart: number }} reading
|
|
32
|
+
* @returns {{ stamp: number, followedPlaylist: boolean }}
|
|
33
|
+
*/
|
|
34
|
+
function stampFor({ trueStart, publishedStart }) {
|
|
35
|
+
if (trueStart === null) {
|
|
36
|
+
return { stamp: publishedStart, followedPlaylist: true };
|
|
37
|
+
}
|
|
38
|
+
if (Math.abs(trueStart - publishedStart) > PLAYER_BUFFER_HOLE_SEC) {
|
|
39
|
+
return { stamp: publishedStart, followedPlaylist: true };
|
|
40
|
+
}
|
|
41
|
+
return { stamp: trueStart, followedPlaylist: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("the field case: a segment far from its playlist position is stamped where the playlist says", () => {
|
|
45
|
+
const segment292 = stampFor({ trueStart: 1587.892, publishedStart: 1585.376 });
|
|
46
|
+
assert.equal(segment292.stamp, 1585.376);
|
|
47
|
+
assert.equal(segment292.followedPlaylist, true);
|
|
48
|
+
|
|
49
|
+
const segment293 = stampFor({ trueStart: 1592.692, publishedStart: 1590.585 });
|
|
50
|
+
assert.equal(segment293.stamp, 1590.585);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a file whose index is honest keeps its own position", () => {
|
|
54
|
+
// Within what a player bridges, the file's own figure is the truthful one and
|
|
55
|
+
// it is what keeps speech and subtitles together (2026-08-06, 4.17 s of drift
|
|
56
|
+
// when the picture was stamped from a lying index).
|
|
57
|
+
const reading = stampFor({ trueStart: 120.13, publishedStart: 120.0 });
|
|
58
|
+
assert.equal(reading.stamp, 120.13);
|
|
59
|
+
assert.equal(reading.followedPlaylist, false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("the boundary is exactly the player's own tolerance", () => {
|
|
63
|
+
assert.equal(stampFor({ trueStart: 10.5, publishedStart: 10.0 }).stamp, 10.5);
|
|
64
|
+
assert.equal(stampFor({ trueStart: 10.51, publishedStart: 10.0 }).stamp, 10.0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a segment that does not say where it begins is stamped from the playlist", () => {
|
|
68
|
+
assert.equal(stampFor({ trueStart: null, publishedStart: 42.0 }).stamp, 42.0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a corrected boundary does not move the stamp of a session already playing", () => {
|
|
72
|
+
// The correction writes into the live table; the published one is frozen. If
|
|
73
|
+
// the stamp followed the live table, every correction would move segments
|
|
74
|
+
// under a player holding the original playlist — which is the same failure
|
|
75
|
+
// seen from the other side.
|
|
76
|
+
const published = [0, 4, 8, 12];
|
|
77
|
+
const live = [...published];
|
|
78
|
+
live[2] = 9.7; // corrected from a produced segment
|
|
79
|
+
const publishedStart = published[2];
|
|
80
|
+
const reading = stampFor({ trueStart: live[2], publishedStart });
|
|
81
|
+
assert.equal(publishedStart, 8);
|
|
82
|
+
assert.equal(reading.stamp, 8, "the player was told 8 s and must be given 8 s");
|
|
83
|
+
});
|
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
|
+
});
|
|
@@ -171,7 +171,7 @@ test("serving a segment records what its real start says about the container's i
|
|
|
171
171
|
});
|
|
172
172
|
|
|
173
173
|
test("a segment that exists is served, not reported as still being produced", async (t) => {
|
|
174
|
-
const { manager, dirPath } = await managerWithReadySegment();
|
|
174
|
+
const { manager, dirPath, session } = await managerWithReadySegment();
|
|
175
175
|
t.after(async () => {
|
|
176
176
|
await manager.disposeAll();
|
|
177
177
|
await rm(dirPath, { recursive: true, force: true });
|
|
@@ -189,12 +189,26 @@ test("a segment that exists is served, not reported as still being produced", as
|
|
|
189
189
|
const served = Buffer.concat(chunks);
|
|
190
190
|
assert.equal(served.toString("latin1", 4, 8), "moof", "the init header must be stripped off a media segment");
|
|
191
191
|
|
|
192
|
-
//
|
|
193
|
-
//
|
|
192
|
+
// Where the PLAYLIST the player holds puts this segment — which is what the
|
|
193
|
+
// fragment must be stamped with whenever the piece's own position is further
|
|
194
|
+
// away than a player will bridge. This fixture's piece says 12.5 s while the
|
|
195
|
+
// playlist says 0, and stamping the piece's figure is what killed a film on
|
|
196
|
+
// 2026-08-17: the browser asked for two segments 1908 times each over ten
|
|
197
|
+
// minutes, each served in 4 ms, because a fragment landing 2.5 s from where
|
|
198
|
+
// it was expected is not recognised as buffered. Reading the piece's own
|
|
199
|
+
// position still happens — it is the step that threw in 2.9.124, it feeds the
|
|
200
|
+
// index tally and it corrects the grid for rungs made later — it just no
|
|
201
|
+
// longer contradicts the timeline the player was sent. See
|
|
202
|
+
// test/published-timeline.test.js for the rule itself.
|
|
194
203
|
assert.equal(
|
|
195
204
|
Number(served.readBigUInt64BE(served.indexOf("tfdt") + 8)),
|
|
196
|
-
|
|
197
|
-
"the segment must be stamped
|
|
205
|
+
0,
|
|
206
|
+
"the segment must be stamped where the playlist the player holds says it begins"
|
|
207
|
+
);
|
|
208
|
+
assert.equal(
|
|
209
|
+
session.indexCheck.checked,
|
|
210
|
+
1,
|
|
211
|
+
"and the piece's own position must still have been read, or nothing measures the index"
|
|
198
212
|
);
|
|
199
213
|
});
|
|
200
214
|
|