@torrent-tv/proxy 2.9.116 → 2.9.118

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.9.118
2
+
3
+ - **New**: Suspending the encoder says what the decision was taken on — where the viewer is, how far the unbroken run of segments reaches from there, and how many segment files the session directory holds. Suspending stops the only thing that reads the input, so a wrong reading here stops the download as well: measured 2026-08-06, the log announced "135s ahead of the viewer" while three segments totalling 31 s lay on disk, and neither figure could be checked against the other because the line carried no evidence. The directory keeps the segments of every run a session has had, so which of them were counted is the whole question.
4
+
5
+ ## 2.9.117
6
+
7
+ - **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.
8
+ - **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`.
9
+
1
10
  ## 2.9.116
2
11
 
3
12
  - **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.118",
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,53 +1892,114 @@ 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 reading = this.#contiguousAheadSeconds(session, viewerSegment);
1913
+ const aheadSeconds = reading === null ? null : reading.seconds;
1914
+ if (aheadSeconds === null) {
1915
+ // The segment the viewer needs does not exist. Whatever else is on disk,
1916
+ // this encoder has work to do right now.
1917
+ if (session.encoderPaused) {
1918
+ this.#resumeEncoder(session, "the viewer needs a segment nobody has made");
1919
+ }
1843
1920
  return;
1844
1921
  }
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.
1922
+
1923
+ // Worth knowing when ffmpeg's own report and what exists disagree wildly
1924
+ // it is the only trace of whatever made it claim a position it had not
1925
+ // reached. Reported on its EDGES, because it is a state and not a stream.
1854
1926
  const claimed = Number(session.progress?.processedSeconds);
1927
+ const encodedTo = this.#segmentStartTime(session, viewerSegment) + aheadSeconds;
1855
1928
  const disagrees =
1856
1929
  Number.isFinite(claimed) && Math.abs(claimed - encodedTo) > LOOKAHEAD_PAUSE_SECONDS;
1857
1930
  if (disagrees && !session.lookAheadDisagreementSince) {
1858
1931
  session.lookAheadDisagreementSince = Date.now();
1859
1932
  logger.info(
1860
1933
  `transcode ${session.id} ffmpeg claims ${Math.round(claimed)}s processed ` +
1861
- `but has produced through ${Math.round(encodedTo)}s (segment #${producedThrough})`
1934
+ `but the viewer's own run of segments ends at ${Math.round(encodedTo)}s`
1862
1935
  );
1863
1936
  } else if (!disagrees && session.lookAheadDisagreementSince) {
1864
1937
  const lastedMs = Date.now() - session.lookAheadDisagreementSince;
1865
1938
  session.lookAheadDisagreementSince = 0;
1866
1939
  logger.info(
1867
1940
  `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)`
1941
+ `after ${(lastedMs / 1000).toFixed(1)}s (ready through ${Math.round(encodedTo)}s)`
1869
1942
  );
1870
1943
  }
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`);
1944
+
1945
+ if (!session.encoderPaused && aheadSeconds > LOOKAHEAD_PAUSE_SECONDS) {
1946
+ // The decision names what it was taken on. Suspending the encoder stops
1947
+ // the only thing that reads the input, so a wrong reading here stops the
1948
+ // download too — measured 2026-08-06: the log said "135s ahead" while
1949
+ // three segments totalling 31 s lay on disk, and neither figure could be
1950
+ // checked against the other because the line carried no evidence. The
1951
+ // directory holds segments from every run this session has had, so which
1952
+ // ones were counted is the whole question.
1953
+ this.#pauseEncoder(
1954
+ session,
1955
+ `${Math.round(aheadSeconds)}s ahead of the viewer ` +
1956
+ `(viewer at #${viewerSegment}, unbroken through #${reading.lastCovered}, ` +
1957
+ `${reading.total} segment file(s) present)`
1958
+ );
1959
+ } else if (session.encoderPaused && aheadSeconds <= LOOKAHEAD_RESUME_SECONDS) {
1960
+ this.#resumeEncoder(session, `${Math.round(aheadSeconds)}s ahead of the viewer`);
1881
1961
  }
1882
1962
  }
1883
1963
 
1964
+ /**
1965
+ * Seconds of playback ready without a gap, starting at the segment the viewer
1966
+ * is on.
1967
+ *
1968
+ * Null when that very segment is missing — which is not "zero ahead" but
1969
+ * "the viewer is waiting", and the two call for opposite decisions.
1970
+ *
1971
+ * @param {HlsSession} session
1972
+ * @param {number} viewerSegment
1973
+ * @returns {{ seconds: number, lastCovered: number, total: number } | null}
1974
+ */
1975
+ #contiguousAheadSeconds(session, viewerSegment) {
1976
+ let present;
1977
+ try {
1978
+ present = new Set();
1979
+ for (const name of readdirSync(session.dirPath, { withFileTypes: false })) {
1980
+ if (!this.segmentFormat.isSegmentFileName(name)) {
1981
+ continue;
1982
+ }
1983
+ const index = this.segmentFormat.segmentIndexFromName(name);
1984
+ if (index >= 0) {
1985
+ present.add(index);
1986
+ }
1987
+ }
1988
+ } catch {
1989
+ return null;
1990
+ }
1991
+ const lastCovered = contiguousEnd(present, viewerSegment);
1992
+ if (lastCovered === null) {
1993
+ return null;
1994
+ }
1995
+ const from = this.#segmentStartTime(session, viewerSegment);
1996
+ const to = this.#segmentStartTime(session, lastCovered + 1);
1997
+ if (!Number.isFinite(from) || !Number.isFinite(to)) {
1998
+ return null;
1999
+ }
2000
+ return { seconds: Math.max(0, to - from), lastCovered, total: present.size };
2001
+ }
2002
+
1884
2003
  /**
1885
2004
  * Suspend a session's encoder. No-op when already paused or unsupported here.
1886
2005
  *
@@ -3000,6 +3119,97 @@ export class HlsSessionManager {
3000
3119
  return highest;
3001
3120
  }
3002
3121
 
3122
+ /**
3123
+ * The session that produces a given height for the same file, created on
3124
+ * first request.
3125
+ *
3126
+ * A variant IS a session — same source, same file, a different encode — so
3127
+ * this makes one rather than inventing a parallel object. It is created only
3128
+ * when its playlist is actually asked for, which is what keeps a weak host
3129
+ * running one encoder: with the player's own bitrate adaptation off, no
3130
+ * variant is ever requested unless the viewer picked it.
3131
+ *
3132
+ * @param {string} baseSessionId
3133
+ * @param {number} height - Encode height; must be one of the offered rungs.
3134
+ * @returns {Promise<HlsSession | null>} Null when the base session is unknown.
3135
+ */
3136
+ async getVariantSession(baseSessionId, height) {
3137
+ const base = this.sessionsById.get(baseSessionId);
3138
+ if (!base || base.state === "disposed") {
3139
+ return null;
3140
+ }
3141
+ if (!Number.isInteger(height) || height <= 0) {
3142
+ return null;
3143
+ }
3144
+ // Where the viewer is now, so a variant created mid-film starts there
3145
+ // rather than at the beginning.
3146
+ const processed = Number.isFinite(base.progress?.processedSeconds)
3147
+ ? base.progress.processedSeconds
3148
+ : 0;
3149
+ return this.createOrGetSession({
3150
+ sourceKey: base.sourceKey,
3151
+ fileIndex: base.fileIndex,
3152
+ transcodeVideo: true,
3153
+ transcodeAudio: base.transcodeAudio,
3154
+ fileName: base.fileName,
3155
+ targetWidth: 0,
3156
+ targetHeight: height,
3157
+ startPositionSeconds: processed,
3158
+ audioTrackIndex: base.audioTrackIndex,
3159
+ // A variant is a resolution the viewer chose, so it is encoded at exactly
3160
+ // that size and the realtime budget does not move it — otherwise two
3161
+ // variants could drift onto the same height and the choice would mean
3162
+ // nothing.
3163
+ manualQuality: true,
3164
+ segmentFormatId: base.segmentFormat?.id ?? "",
3165
+ acquireSource: base.acquireSource
3166
+ });
3167
+ }
3168
+
3169
+ /**
3170
+ * The master playlist: every resolution this file can be served at, as HLS
3171
+ * variants.
3172
+ *
3173
+ * This is what makes a change of quality seamless. Our media playlist is VOD
3174
+ * and terminated with `#EXT-X-ENDLIST`, and hls.js only re-reads a playlist
3175
+ * that is live — so rewriting it underneath the player achieves nothing, and
3176
+ * a switch had to tear the player down and build a new session. Offered as
3177
+ * variants instead, the switch is the player's own: it fetches the other
3178
+ * variant, appends it after what is already buffered, and changes the
3179
+ * decoder's type if the codec parameters differ.
3180
+ *
3181
+ * @param {string} sessionId
3182
+ * @returns {string | null} The playlist text, or null when there is nothing
3183
+ * to choose between — a copied video, or a source too small to step down.
3184
+ */
3185
+ buildMasterPlaylist(sessionId) {
3186
+ const session = this.sessionsById.get(sessionId);
3187
+ if (!session || session.state === "disposed" || !session.transcodeVideo) {
3188
+ return null;
3189
+ }
3190
+ const sourceHeight = Number(session.sourceHeight) || 0;
3191
+ const rungs = variantHeightsFor(sourceHeight);
3192
+ if (rungs.length < 2) {
3193
+ return null;
3194
+ }
3195
+ const sourceWidth = Number(session.sourceWidth) || 0;
3196
+ const lines = ["#EXTM3U", "#EXT-X-VERSION:7"];
3197
+ for (const height of rungs) {
3198
+ const width = sourceHeight > 0 && sourceWidth > 0
3199
+ ? Math.round((sourceWidth / sourceHeight) * height / 2) * 2
3200
+ : 0;
3201
+ lines.push(
3202
+ `#EXT-X-STREAM-INF:BANDWIDTH=${estimatedBitrateFor(height)}` +
3203
+ (width > 0 ? `,RESOLUTION=${width}x${height}` : "")
3204
+ );
3205
+ // A subdirectory, so every relative name inside the variant's own
3206
+ // playlist — its segments and its init — resolves to that variant
3207
+ // without any of them having to change.
3208
+ lines.push(`v/${height}/${PLAYLIST_FILE_NAME}`);
3209
+ }
3210
+ return `${lines.join("\n")}\n`;
3211
+ }
3212
+
3003
3213
  /**
3004
3214
  * How many times the viewer has moved since this session started.
3005
3215
  *
@@ -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
+ });