@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.81
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.81",
3
+ "version": "2.9.82",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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
+ });