@torrent-tv/proxy 2.9.116 → 2.9.117

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.9.117
2
+
3
+ - **Fix**: A seek backwards no longer kills playback. How far the encoder is ahead of the viewer was measured as the highest segment number lying in the session's directory, which equals the look-ahead only while a viewer moves forward through one run. Measured 2026-08-06: a seek forward left segments 662-665 on disk, the viewer seeked BACK to 646, and the limiter compared 6950 s of output against a viewer at 6700 s, called it "250s ahead" and suspended the new run **136 ms after it started**, before it had produced anything. With the encoder stopped nothing read the input, so no pieces were requested — `0 selection(s)` with 33 peers connected — and segment 646 was never made; the viewer sat on a spinner for four minutes while a stopped ffmpeg was held back for being too far ahead. The measure is now the unbroken run of segments starting where the viewer is, and a viewer whose own segment is missing is not "zero ahead" but waiting, which resumes the encoder instead of pausing it. Covered by tests, including the field case.
4
+ - **New**: Groundwork for switching quality without interrupting playback: the heights a source can be served at, the master playlist that offers them as HLS variants, and creation of a variant's encoder on first request. Not yet reachable — the routes come with the browser side. Reasoning in `research/seamless-switching-2026-08-06.md`.
5
+
1
6
  ## 2.9.116
2
7
 
3
8
  - **New**: A progress report says which height is being produced right now. Under automatic quality the proxy steps the resolution down when the host cannot encode in realtime or the viewer's link cannot carry the stream, and nothing said so — the menu read "Auto" whatever it had settled on. Zero when the video is copied, because then nothing is being chosen and the source's own height is what plays.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.116",
3
+ "version": "2.9.117",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -64,6 +64,60 @@ export function isInputUnavailable(message) {
64
64
  }
65
65
 
66
66
  const PLAYLIST_FILE_NAME = "index.m3u8";
67
+ // The resolutions a viewer may choose between. Only rungs at or below the
68
+ // source are offered: upscaling invents detail and costs the encoder more than
69
+ // the source itself.
70
+ const VARIANT_LADDER = [2160, 1440, 1080, 720, 540, 480, 360, 240];
71
+
72
+ /**
73
+ * The last index of the unbroken run of segments starting at `from`.
74
+ *
75
+ * Null when `from` itself is absent. A hole matters: segments beyond one are
76
+ * not look-ahead, because the viewer cannot reach them until it is filled.
77
+ *
78
+ * @param {Set<number>} present
79
+ * @param {number} from
80
+ * @returns {number | null}
81
+ */
82
+ export function contiguousEnd(present, from) {
83
+ if (!present.has(from)) {
84
+ return null;
85
+ }
86
+ let last = from;
87
+ while (present.has(last + 1)) {
88
+ last += 1;
89
+ }
90
+ return last;
91
+ }
92
+
93
+ /**
94
+ * The heights offered for a source of this height, largest first.
95
+ *
96
+ * @param {number} sourceHeight
97
+ * @returns {number[]}
98
+ */
99
+ export function variantHeightsFor(sourceHeight) {
100
+ if (!Number.isFinite(sourceHeight) || sourceHeight <= 0) {
101
+ return [];
102
+ }
103
+ const rungs = VARIANT_LADDER.filter((height) => height < sourceHeight);
104
+ return [Math.round(sourceHeight), ...rungs];
105
+ }
106
+
107
+ /**
108
+ * A rough bitrate for a height, in bits per second.
109
+ *
110
+ * `BANDWIDTH` is required on every variant by the HLS specification, and the
111
+ * player uses it to order them. It does not have to be exact — nothing here
112
+ * adapts on it, because the viewer chooses — so it is the usual H.264 rule of
113
+ * thumb rather than a measurement we do not have before encoding starts.
114
+ *
115
+ * @param {number} height
116
+ * @returns {number}
117
+ */
118
+ export function estimatedBitrateFor(height) {
119
+ return Math.max(400_000, Math.round(height * height * 3.2));
120
+ }
67
121
  const CLEANUP_INTERVAL_MS = 30_000;
68
122
  const DEFAULT_SEGMENT_DURATION_SEC = 4;
69
123
  // How many segments ahead of the current encode head a missing-segment request
@@ -1357,6 +1411,10 @@ export class HlsSessionManager {
1357
1411
  lastLoggedAt: 0
1358
1412
  }
1359
1413
  };
1414
+ // Kept so a variant of this session can take its own hold on the same
1415
+ // source: a variant is another encode of the same file and must keep the
1416
+ // torrent's data alive exactly as this one does.
1417
+ session.acquireSource = typeof acquireSource === "function" ? acquireSource : null;
1360
1418
  if (typeof acquireSource === "function") {
1361
1419
  try {
1362
1420
  session.releaseSource = acquireSource();
@@ -1834,51 +1892,99 @@ export class HlsSessionManager {
1834
1892
  // which nobody was now making — was held for 45.7 s until the viewer gave
1835
1893
  // up and seeked. A segment on disk is something the viewer can be served;
1836
1894
  // a number from ffmpeg is not.
1837
- const producedThrough = this.#latestProducedSegment(session);
1838
- if (producedThrough === null) {
1839
- return;
1840
- }
1841
- const encodedTo = this.#segmentStartTime(session, producedThrough + 1);
1842
- if (!Number.isFinite(encodedTo)) {
1895
+ // Where the viewer is. Before the first segment request, the position the
1896
+ // run started at — so a session nobody has read from yet is bounded too.
1897
+ const viewerSegment = Number.isInteger(session.lastRequestedSegment)
1898
+ ? session.lastRequestedSegment
1899
+ : (session.encodeStartIndex ?? 0);
1900
+
1901
+ // How much is ready CONTIGUOUSLY FROM WHERE THE VIEWER IS — not the highest
1902
+ // segment number lying in the directory. The two are the same only while a
1903
+ // viewer moves forward through one run, and the difference destroyed a
1904
+ // session on 2026-08-06: a seek forward left segments 662-665 on disk, the
1905
+ // viewer then seeked BACK to 646, and the limiter measured 6950 s of output
1906
+ // against a viewer at 6700 s, called it "250s ahead" and suspended a run
1907
+ // 136 ms after it started, before it had produced anything at all. Nothing
1908
+ // was then encoding, so nothing read the input, so no pieces were asked for
1909
+ // — `0 selection(s)` with 33 peers connected — and segment 646 was never
1910
+ // made. Segments beyond a hole are not look-ahead: the viewer cannot reach
1911
+ // them without the hole being filled first.
1912
+ const aheadSeconds = this.#contiguousAheadSeconds(session, viewerSegment);
1913
+ if (aheadSeconds === null) {
1914
+ // The segment the viewer needs does not exist. Whatever else is on disk,
1915
+ // this encoder has work to do right now.
1916
+ if (session.encoderPaused) {
1917
+ this.#resumeEncoder(session, "the viewer needs a segment nobody has made");
1918
+ }
1843
1919
  return;
1844
1920
  }
1845
- // Worth knowing when the two disagree wildly — it is the only trace of
1846
- // whatever made ffmpeg report a position it had not reached.
1847
- // Reported on its EDGES, because it is a state and not a stream: it starts
1848
- // when the two figures part company and ends when they meet again, however
1849
- // often this function happens to run. Printing it per call meant printing
1850
- // it at the rate of segment requests — three hundred times a minute after
1851
- // one seek — and a limit of one a minute would only have hidden that the
1852
- // line was in the wrong place. Two lines per occurrence also say how long
1853
- // it lasted, which no sampled version could.
1921
+
1922
+ // Worth knowing when ffmpeg's own report and what exists disagree wildly
1923
+ // it is the only trace of whatever made it claim a position it had not
1924
+ // reached. Reported on its EDGES, because it is a state and not a stream.
1854
1925
  const claimed = Number(session.progress?.processedSeconds);
1926
+ const encodedTo = this.#segmentStartTime(session, viewerSegment) + aheadSeconds;
1855
1927
  const disagrees =
1856
1928
  Number.isFinite(claimed) && Math.abs(claimed - encodedTo) > LOOKAHEAD_PAUSE_SECONDS;
1857
1929
  if (disagrees && !session.lookAheadDisagreementSince) {
1858
1930
  session.lookAheadDisagreementSince = Date.now();
1859
1931
  logger.info(
1860
1932
  `transcode ${session.id} ffmpeg claims ${Math.round(claimed)}s processed ` +
1861
- `but has produced through ${Math.round(encodedTo)}s (segment #${producedThrough})`
1933
+ `but the viewer's own run of segments ends at ${Math.round(encodedTo)}s`
1862
1934
  );
1863
1935
  } else if (!disagrees && session.lookAheadDisagreementSince) {
1864
1936
  const lastedMs = Date.now() - session.lookAheadDisagreementSince;
1865
1937
  session.lookAheadDisagreementSince = 0;
1866
1938
  logger.info(
1867
1939
  `transcode ${session.id} ffmpeg's position and the segments on disk agree again ` +
1868
- `after ${(lastedMs / 1000).toFixed(1)}s (produced through ${Math.round(encodedTo)}s)`
1940
+ `after ${(lastedMs / 1000).toFixed(1)}s (ready through ${Math.round(encodedTo)}s)`
1869
1941
  );
1870
1942
  }
1871
- // Where the viewer is. Before the first segment request, the position the
1872
- // run started at — so a session nobody has read from yet is bounded too.
1873
- const viewerAt = Number.isInteger(session.lastRequestedSegment)
1874
- ? this.#segmentStartTime(session, session.lastRequestedSegment)
1875
- : this.#segmentStartTime(session, session.encodeStartIndex ?? 0);
1876
- const ahead = encodedTo - viewerAt;
1877
- if (!session.encoderPaused && ahead > LOOKAHEAD_PAUSE_SECONDS) {
1878
- this.#pauseEncoder(session, `${Math.round(ahead)}s ahead of the viewer`);
1879
- } else if (session.encoderPaused && ahead <= LOOKAHEAD_RESUME_SECONDS) {
1880
- this.#resumeEncoder(session, `${Math.round(ahead)}s ahead of the viewer`);
1943
+
1944
+ if (!session.encoderPaused && aheadSeconds > LOOKAHEAD_PAUSE_SECONDS) {
1945
+ this.#pauseEncoder(session, `${Math.round(aheadSeconds)}s ahead of the viewer`);
1946
+ } else if (session.encoderPaused && aheadSeconds <= LOOKAHEAD_RESUME_SECONDS) {
1947
+ this.#resumeEncoder(session, `${Math.round(aheadSeconds)}s ahead of the viewer`);
1948
+ }
1949
+ }
1950
+
1951
+ /**
1952
+ * Seconds of playback ready without a gap, starting at the segment the viewer
1953
+ * is on.
1954
+ *
1955
+ * Null when that very segment is missing — which is not "zero ahead" but
1956
+ * "the viewer is waiting", and the two call for opposite decisions.
1957
+ *
1958
+ * @param {HlsSession} session
1959
+ * @param {number} viewerSegment
1960
+ * @returns {number | null}
1961
+ */
1962
+ #contiguousAheadSeconds(session, viewerSegment) {
1963
+ let present;
1964
+ try {
1965
+ present = new Set();
1966
+ for (const name of readdirSync(session.dirPath, { withFileTypes: false })) {
1967
+ if (!this.segmentFormat.isSegmentFileName(name)) {
1968
+ continue;
1969
+ }
1970
+ const index = this.segmentFormat.segmentIndexFromName(name);
1971
+ if (index >= 0) {
1972
+ present.add(index);
1973
+ }
1974
+ }
1975
+ } catch {
1976
+ return null;
1977
+ }
1978
+ const lastCovered = contiguousEnd(present, viewerSegment);
1979
+ if (lastCovered === null) {
1980
+ return null;
1881
1981
  }
1982
+ const from = this.#segmentStartTime(session, viewerSegment);
1983
+ const to = this.#segmentStartTime(session, lastCovered + 1);
1984
+ if (!Number.isFinite(from) || !Number.isFinite(to)) {
1985
+ return null;
1986
+ }
1987
+ return Math.max(0, to - from);
1882
1988
  }
1883
1989
 
1884
1990
  /**
@@ -3000,6 +3106,97 @@ export class HlsSessionManager {
3000
3106
  return highest;
3001
3107
  }
3002
3108
 
3109
+ /**
3110
+ * The session that produces a given height for the same file, created on
3111
+ * first request.
3112
+ *
3113
+ * A variant IS a session — same source, same file, a different encode — so
3114
+ * this makes one rather than inventing a parallel object. It is created only
3115
+ * when its playlist is actually asked for, which is what keeps a weak host
3116
+ * running one encoder: with the player's own bitrate adaptation off, no
3117
+ * variant is ever requested unless the viewer picked it.
3118
+ *
3119
+ * @param {string} baseSessionId
3120
+ * @param {number} height - Encode height; must be one of the offered rungs.
3121
+ * @returns {Promise<HlsSession | null>} Null when the base session is unknown.
3122
+ */
3123
+ async getVariantSession(baseSessionId, height) {
3124
+ const base = this.sessionsById.get(baseSessionId);
3125
+ if (!base || base.state === "disposed") {
3126
+ return null;
3127
+ }
3128
+ if (!Number.isInteger(height) || height <= 0) {
3129
+ return null;
3130
+ }
3131
+ // Where the viewer is now, so a variant created mid-film starts there
3132
+ // rather than at the beginning.
3133
+ const processed = Number.isFinite(base.progress?.processedSeconds)
3134
+ ? base.progress.processedSeconds
3135
+ : 0;
3136
+ return this.createOrGetSession({
3137
+ sourceKey: base.sourceKey,
3138
+ fileIndex: base.fileIndex,
3139
+ transcodeVideo: true,
3140
+ transcodeAudio: base.transcodeAudio,
3141
+ fileName: base.fileName,
3142
+ targetWidth: 0,
3143
+ targetHeight: height,
3144
+ startPositionSeconds: processed,
3145
+ audioTrackIndex: base.audioTrackIndex,
3146
+ // A variant is a resolution the viewer chose, so it is encoded at exactly
3147
+ // that size and the realtime budget does not move it — otherwise two
3148
+ // variants could drift onto the same height and the choice would mean
3149
+ // nothing.
3150
+ manualQuality: true,
3151
+ segmentFormatId: base.segmentFormat?.id ?? "",
3152
+ acquireSource: base.acquireSource
3153
+ });
3154
+ }
3155
+
3156
+ /**
3157
+ * The master playlist: every resolution this file can be served at, as HLS
3158
+ * variants.
3159
+ *
3160
+ * This is what makes a change of quality seamless. Our media playlist is VOD
3161
+ * and terminated with `#EXT-X-ENDLIST`, and hls.js only re-reads a playlist
3162
+ * that is live — so rewriting it underneath the player achieves nothing, and
3163
+ * a switch had to tear the player down and build a new session. Offered as
3164
+ * variants instead, the switch is the player's own: it fetches the other
3165
+ * variant, appends it after what is already buffered, and changes the
3166
+ * decoder's type if the codec parameters differ.
3167
+ *
3168
+ * @param {string} sessionId
3169
+ * @returns {string | null} The playlist text, or null when there is nothing
3170
+ * to choose between — a copied video, or a source too small to step down.
3171
+ */
3172
+ buildMasterPlaylist(sessionId) {
3173
+ const session = this.sessionsById.get(sessionId);
3174
+ if (!session || session.state === "disposed" || !session.transcodeVideo) {
3175
+ return null;
3176
+ }
3177
+ const sourceHeight = Number(session.sourceHeight) || 0;
3178
+ const rungs = variantHeightsFor(sourceHeight);
3179
+ if (rungs.length < 2) {
3180
+ return null;
3181
+ }
3182
+ const sourceWidth = Number(session.sourceWidth) || 0;
3183
+ const lines = ["#EXTM3U", "#EXT-X-VERSION:7"];
3184
+ for (const height of rungs) {
3185
+ const width = sourceHeight > 0 && sourceWidth > 0
3186
+ ? Math.round((sourceWidth / sourceHeight) * height / 2) * 2
3187
+ : 0;
3188
+ lines.push(
3189
+ `#EXT-X-STREAM-INF:BANDWIDTH=${estimatedBitrateFor(height)}` +
3190
+ (width > 0 ? `,RESOLUTION=${width}x${height}` : "")
3191
+ );
3192
+ // A subdirectory, so every relative name inside the variant's own
3193
+ // playlist — its segments and its init — resolves to that variant
3194
+ // without any of them having to change.
3195
+ lines.push(`v/${height}/${PLAYLIST_FILE_NAME}`);
3196
+ }
3197
+ return `${lines.join("\n")}\n`;
3198
+ }
3199
+
3003
3200
  /**
3004
3201
  * How many times the viewer has moved since this session started.
3005
3202
  *
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @file What "the encoder is ahead of the viewer" actually means.
3
+ *
4
+ * It was measured as the highest segment number lying in the session's
5
+ * directory. That equals the look-ahead only while a viewer moves forward
6
+ * through one encoder run, and the difference destroyed a session on
7
+ * 2026-08-06: a seek forward left segments 662-665 on disk, the viewer then
8
+ * seeked BACK to 646, and the limiter compared 6950 s of output against a
9
+ * viewer at 6700 s, called it "250s ahead", and suspended a run 136 ms after it
10
+ * started — before it had produced anything. With the encoder stopped nothing
11
+ * read the input, so no pieces were requested (`0 selection(s)` with 33 peers
12
+ * connected) and segment 646 was never made. The viewer sat on a spinner while
13
+ * a stopped ffmpeg was held for being "too far ahead".
14
+ *
15
+ * The measure is the unbroken run of segments starting where the viewer is.
16
+ * Segments beyond a hole are not look-ahead: the viewer cannot reach them until
17
+ * the hole is filled.
18
+ */
19
+
20
+ import test from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import { contiguousEnd } from "../services/hls-session-manager.js";
23
+
24
+ test("an unbroken run reports its last segment", () => {
25
+ assert.equal(contiguousEnd(new Set([10, 11, 12, 13]), 10), 13);
26
+ });
27
+
28
+ test("a hole stops the count, whatever lies beyond it", () => {
29
+ assert.equal(
30
+ contiguousEnd(new Set([10, 11, 662, 663, 664, 665]), 10),
31
+ 11,
32
+ "segments past a gap are unreachable and must not count as ready"
33
+ );
34
+ });
35
+
36
+ test("the field case: a backward seek onto a segment nobody has made", () => {
37
+ // What the directory held after the forward seek, and where the viewer went.
38
+ const onDisk = new Set([661, 662, 663, 664, 665]);
39
+ assert.equal(
40
+ contiguousEnd(onDisk, 646),
41
+ null,
42
+ "the viewer's own segment is missing — that is a wait, not a look-ahead"
43
+ );
44
+ });
45
+
46
+ test("a single segment counts as itself", () => {
47
+ assert.equal(contiguousEnd(new Set([5]), 5), 5);
48
+ });
49
+
50
+ test("nothing on disk is not zero ahead — it is unknown", () => {
51
+ assert.equal(
52
+ contiguousEnd(new Set(), 0),
53
+ null,
54
+ "zero and null lead to opposite decisions: pause versus encode now"
55
+ );
56
+ });