@torrent-tv/proxy 2.9.81 → 2.9.82
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,5 +1,6 @@
|
|
|
1
|
-
## 2.9.
|
|
1
|
+
## 2.9.82
|
|
2
2
|
|
|
3
|
+
- **Fix**: The playlist and the real segments now describe the same thing. On the copy path ffmpeg was given only a target duration and chose its own cut points, while the playlist was built from the container keyframe index — two independent calculations tied together by nothing but the assumption that they agree. They do not: the index is a navigation table and is not obliged to list every keyframe. On a field file it held 1902 while ffmpeg found roughly twice as many and cut twice as often, so segment #876 meant 1:26:50 to the player and about minute 58 to ffmpeg. A seek into the middle landed at the end and the reported duration drifted. ffmpeg now receives the very boundaries the playlist was built from, via the `segment` muxer, which takes the list outright — agreement by construction instead of by luck. Verified on deliberately uneven keyframes: cuts requested at 4.44, 10.36, 16.28, 22.2 and 28.12 s landed exactly there. Two measured details are encoded in the code: those times count from the start of the RUN, not of the file (starting at 12 s and asking for 18 s put the cut at 29.4), and a tolerance absorbs rounding so a boundary recorded a hair late cannot skip to the next keyframe and silently double a segment. MPEG-TS only for now — fMP4 can do this too, but only as self-contained fragments, which removes the shared init segment and the `tfdt` rewriting built around it; that is a separate change and not one to make blind.
|
|
3
4
|
- **New**: The stream route says why a read failed. A body that failed mid-flight was dropped silently — the connection closed with no status and no log line, which from the client looks like the proxy died and from the log like nothing happened; found while probing the route by hand, where every ranged read closed the socket without a word. It now reports the file, the range, how many bytes had been sent, and the error.
|
|
4
5
|
|
|
5
6
|
## 2.9.80
|
package/package.json
CHANGED
|
@@ -606,6 +606,41 @@ function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segDur, key
|
|
|
606
606
|
return boundaries.length >= 2 ? boundaries : uniform();
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
/**
|
|
610
|
+
* The cut times to hand ffmpeg for a run that starts at `startIndex`.
|
|
611
|
+
*
|
|
612
|
+
* Two adjustments, both of which cost a broken session to learn:
|
|
613
|
+
*
|
|
614
|
+
* - **Rebased.** `-segment_times` is measured from the start of the run, not
|
|
615
|
+
* of the file. Measured: starting at 12 s and asking for a cut at 18 s put
|
|
616
|
+
* it at 29.4 s — 12 + 18. So every boundary has the run's own start
|
|
617
|
+
* subtracted.
|
|
618
|
+
* - **Interior only.** The first boundary is where the run begins and the last
|
|
619
|
+
* is where the file ends; neither is a cut. Sending them would produce an
|
|
620
|
+
* empty leading segment and a spurious trailing one.
|
|
621
|
+
*
|
|
622
|
+
* @param {number[]} boundaries - Segment start times, ascending, ending at the
|
|
623
|
+
* file duration (as {@link computeSegmentBoundaries} returns).
|
|
624
|
+
* @param {number} startIndex - Segment this run starts at.
|
|
625
|
+
* @returns {number[] | null} Times relative to the run start, or null when the
|
|
626
|
+
* boundaries cannot serve (missing, or the index is outside them).
|
|
627
|
+
*/
|
|
628
|
+
export function segmentCutTimesFrom(boundaries, startIndex) {
|
|
629
|
+
if (!Array.isArray(boundaries) || boundaries.length < 2) {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
const index = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
|
|
633
|
+
if (index >= boundaries.length - 1) {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
const base = boundaries[index];
|
|
637
|
+
const times = [];
|
|
638
|
+
for (let at = index + 1; at < boundaries.length - 1; at += 1) {
|
|
639
|
+
times.push(Number((boundaries[at] - base).toFixed(6)));
|
|
640
|
+
}
|
|
641
|
+
return times;
|
|
642
|
+
}
|
|
643
|
+
|
|
609
644
|
/**
|
|
610
645
|
* The largest keyframe time that does not exceed `target`, from a SORTED
|
|
611
646
|
* (ascending) array of keyframe times such as {@link probeVideoKeyframeTimes}
|
|
@@ -1766,24 +1801,68 @@ export class HlsSessionManager {
|
|
|
1766
1801
|
// Type-relative audio track chosen by the viewer (default 0).
|
|
1767
1802
|
`0:a:${session.audioTrackIndex ?? 0}?`,
|
|
1768
1803
|
...videoCodecArgs,
|
|
1769
|
-
...audioCodecArgs
|
|
1770
|
-
"-f",
|
|
1771
|
-
"hls",
|
|
1772
|
-
"-hls_time",
|
|
1773
|
-
String(this.segmentDurationSec),
|
|
1774
|
-
"-hls_list_size",
|
|
1775
|
-
"0",
|
|
1776
|
-
"-hls_flags",
|
|
1777
|
-
"independent_segments+temp_file",
|
|
1778
|
-
// Container selection + segment naming, from the active format module.
|
|
1779
|
-
...this.segmentFormat.muxerArgs(),
|
|
1780
|
-
"-start_number",
|
|
1781
|
-
String(safeIndex),
|
|
1782
|
-
// ffmpeg writes its own playlist here; we ignore it and serve the
|
|
1783
|
-
// synthetic VOD playlist instead (see getFileStream).
|
|
1784
|
-
PLAYLIST_FILE_NAME
|
|
1804
|
+
...audioCodecArgs
|
|
1785
1805
|
);
|
|
1786
1806
|
|
|
1807
|
+
// Where the cuts come from. On the copy path they are the source's own
|
|
1808
|
+
// keyframes, and until now they were only ever GUESSED: ffmpeg got a target
|
|
1809
|
+
// duration and chose its own cut points, while the playlist was built from
|
|
1810
|
+
// the container index — two independent calculations with nothing tying
|
|
1811
|
+
// them together but the hope that they agree. They do not. The index is a
|
|
1812
|
+
// navigation table and is not obliged to list every keyframe; for a field
|
|
1813
|
+
// file it held 1902 while ffmpeg found roughly twice as many and cut twice
|
|
1814
|
+
// as often. Segment #876 then meant 1:26:50 to the player and about minute
|
|
1815
|
+
// 58 to ffmpeg, which is why a seek landed nowhere near where it was aimed
|
|
1816
|
+
// and the reported duration drifted.
|
|
1817
|
+
//
|
|
1818
|
+
// So stop guessing and say it: the `segment` muxer takes the list of times
|
|
1819
|
+
// outright. Passing the very boundaries the playlist was built from makes
|
|
1820
|
+
// the two agree by construction. Only cut points already known to be real
|
|
1821
|
+
// keyframes are sent, so ffmpeg never has to move one forward.
|
|
1822
|
+
const explicitTimes = this.segmentFormat.explicitTimesMuxerArgs?.() ?? null;
|
|
1823
|
+
const cutTimes = explicitTimes && !session.transcodeVideo
|
|
1824
|
+
? segmentCutTimesFrom(session.segmentBoundaries, safeIndex)
|
|
1825
|
+
: null;
|
|
1826
|
+
|
|
1827
|
+
if (cutTimes && cutTimes.length > 0) {
|
|
1828
|
+
args.push(
|
|
1829
|
+
"-f",
|
|
1830
|
+
"segment",
|
|
1831
|
+
// Times are measured from the START OF THIS RUN, not from the start of
|
|
1832
|
+
// the file — verified: starting at 12 s and asking for a cut at 18 s
|
|
1833
|
+
// produced one at 29.4 s. `segmentCutTimesFrom` rebases them.
|
|
1834
|
+
"-segment_times",
|
|
1835
|
+
cutTimes.join(","),
|
|
1836
|
+
// A cut lands on the first keyframe at or after its time, so a boundary
|
|
1837
|
+
// recorded a hair late would skip to the next one and double the
|
|
1838
|
+
// segment. The tolerance absorbs that rounding.
|
|
1839
|
+
"-segment_time_delta",
|
|
1840
|
+
"0.05",
|
|
1841
|
+
"-segment_start_number",
|
|
1842
|
+
String(safeIndex),
|
|
1843
|
+
...explicitTimes,
|
|
1844
|
+
this.segmentFormat.segmentFileNameTemplate()
|
|
1845
|
+
);
|
|
1846
|
+
} else {
|
|
1847
|
+
args.push(
|
|
1848
|
+
"-f",
|
|
1849
|
+
"hls",
|
|
1850
|
+
"-hls_time",
|
|
1851
|
+
String(this.segmentDurationSec),
|
|
1852
|
+
"-hls_list_size",
|
|
1853
|
+
"0",
|
|
1854
|
+
"-hls_flags",
|
|
1855
|
+
"independent_segments+temp_file",
|
|
1856
|
+
// Container selection + segment naming, from the active format module.
|
|
1857
|
+
...this.segmentFormat.muxerArgs(),
|
|
1858
|
+
"-start_number",
|
|
1859
|
+
String(safeIndex),
|
|
1860
|
+
// ffmpeg writes its own playlist here; we ignore it and serve the
|
|
1861
|
+
// synthetic VOD playlist instead (see getFileStream).
|
|
1862
|
+
PLAYLIST_FILE_NAME
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1787
1866
|
const ffmpeg = spawn(this.ffmpegBin, args, {
|
|
1788
1867
|
cwd: session.dirPath,
|
|
1789
1868
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -37,6 +37,24 @@ export const fmp4Format = {
|
|
|
37
37
|
];
|
|
38
38
|
},
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Not supported on this format — deliberately, for now.
|
|
42
|
+
*
|
|
43
|
+
* The `segment` muxer can produce fMP4 (verified: explicit times cut exactly
|
|
44
|
+
* where asked), but only as self-contained fragments carrying their own
|
|
45
|
+
* `moov`. That removes the shared init segment this format is built around —
|
|
46
|
+
* `#EXT-X-MAP`, and with it the whole `tfdt` rewriting that took a field
|
|
47
|
+
* failure to get right. Changing all of that at once, on a path no current
|
|
48
|
+
* deployment exercises and that cannot be verified without a real browser, is
|
|
49
|
+
* how the last round of regressions happened. MPEG-TS, which is what runs in
|
|
50
|
+
* the field, gets the fix first.
|
|
51
|
+
*
|
|
52
|
+
* @returns {null}
|
|
53
|
+
*/
|
|
54
|
+
explicitTimesMuxerArgs() {
|
|
55
|
+
return null;
|
|
56
|
+
},
|
|
57
|
+
|
|
40
58
|
playlistHeaderLines() {
|
|
41
59
|
return [
|
|
42
60
|
// The init segment (codec config). Fetched once; applies to every media
|
|
@@ -31,6 +31,26 @@ export const mpegtsFormat = {
|
|
|
31
31
|
return ["-hls_segment_filename", "segment-%05d.ts"];
|
|
32
32
|
},
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Arguments for cutting at times we choose rather than times ffmpeg picks.
|
|
36
|
+
*
|
|
37
|
+
* The `hls` muxer takes only a target duration and finds its own cut points,
|
|
38
|
+
* which is why the playlist and the real segments drifted apart; the `segment`
|
|
39
|
+
* muxer takes the list. Self-contained segments make this straightforward
|
|
40
|
+
* here: no init segment to reconcile, so the only difference is the container
|
|
41
|
+
* and the file name template.
|
|
42
|
+
*
|
|
43
|
+
* @returns {string[]}
|
|
44
|
+
*/
|
|
45
|
+
explicitTimesMuxerArgs() {
|
|
46
|
+
return ["-segment_format", "mpegts"];
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/** The output path template for the `segment` muxer. */
|
|
50
|
+
segmentFileNameTemplate() {
|
|
51
|
+
return "segment-%05d.ts";
|
|
52
|
+
},
|
|
53
|
+
|
|
34
54
|
playlistHeaderLines() {
|
|
35
55
|
return []; // no `#EXT-X-MAP`
|
|
36
56
|
},
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The playlist and the real segments must describe the same thing.
|
|
3
|
+
*
|
|
4
|
+
* They did not. The playlist was built from the container index while ffmpeg
|
|
5
|
+
* chose its own cut points from a target duration, and the two only ever agreed
|
|
6
|
+
* by luck — on a field file the index listed 1902 keyframes, ffmpeg found about
|
|
7
|
+
* twice as many, and segment #876 meant 1:26:50 to the player and roughly
|
|
8
|
+
* minute 58 to ffmpeg. Seeks landed nowhere near where they were aimed.
|
|
9
|
+
*
|
|
10
|
+
* These tests pin the arithmetic that ties the two together.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { segmentCutTimesFrom } from "../services/hls-session-manager.js";
|
|
16
|
+
|
|
17
|
+
test("cut times are the interior boundaries, rebased on the run start", () => {
|
|
18
|
+
// Segments 0-10, 10-20, 20-30, 30-40 (file ends at 40).
|
|
19
|
+
const boundaries = [0, 10, 20, 30, 40];
|
|
20
|
+
|
|
21
|
+
assert.deepEqual(
|
|
22
|
+
segmentCutTimesFrom(boundaries, 0),
|
|
23
|
+
[10, 20, 30],
|
|
24
|
+
"from the start, the cuts are every boundary except 0 and the file end"
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// Restarting at segment 2 means the run begins at 20 s; a cut at 30 s is 10 s
|
|
28
|
+
// into the run. Sending 30 would put it at 50 — `-segment_times` counts from
|
|
29
|
+
// the run, which is what made a seek land in the wrong place.
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
segmentCutTimesFrom(boundaries, 2),
|
|
32
|
+
[10],
|
|
33
|
+
"after a restart the times must be relative to where the run starts"
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("uneven, real-world boundaries survive the rebasing exactly", () => {
|
|
38
|
+
// Keyframes never land on round numbers; rounding here is what would push a
|
|
39
|
+
// cut past its keyframe and silently double a segment.
|
|
40
|
+
// Starting at segment 1 leaves segments 1, 2 and 3 — three segments, so two
|
|
41
|
+
// cuts between them, each measured from the run start at 10.427.
|
|
42
|
+
const boundaries = [0, 10.427, 20.854, 31.281, 41.708];
|
|
43
|
+
assert.deepEqual(segmentCutTimesFrom(boundaries, 1), [10.427, 20.854]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("boundaries that cannot serve are refused rather than half-used", () => {
|
|
47
|
+
assert.equal(segmentCutTimesFrom(null, 0), null);
|
|
48
|
+
assert.equal(segmentCutTimesFrom([], 0), null);
|
|
49
|
+
assert.equal(segmentCutTimesFrom([0], 0), null, "a single boundary describes no segment");
|
|
50
|
+
assert.equal(
|
|
51
|
+
segmentCutTimesFrom([0, 10, 20], 2),
|
|
52
|
+
null,
|
|
53
|
+
"a start index at or past the last boundary has no run to describe"
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("the final run has no interior cuts and asks for none", () => {
|
|
58
|
+
assert.deepEqual(
|
|
59
|
+
segmentCutTimesFrom([0, 10, 20], 1),
|
|
60
|
+
[],
|
|
61
|
+
"starting at the last segment leaves nothing to cut, which is not an error"
|
|
62
|
+
);
|
|
63
|
+
});
|