@torrent-tv/proxy 2.9.31 → 2.9.32

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,7 @@
1
+ ## 2.9.32
2
+
3
+ - **New**: Manual quality support (OpenSpec change `transcode-quality`, part 4). The playback plan now reports the source coded resolution (`videoWidth`/`videoHeight`, parsed from the ffprobe banner) so the browser can offer a quality menu. `POST /api/transcode-sessions` accepts `manualQuality: true`: the requested target box is then encoded exactly (capped to the source, never upscaled) with the realtime budget disabled for that session — no startup auto-downscale and no runtime downswitch — so a viewer-forced resolution stays constant for the whole session. `manualQuality` is part of the session key (a forced-quality session is distinct from Auto). Logged as `enc=WxH@fps quality=manual`. Auto (no flag) is unchanged: the realtime budget decides. Pairs with the server release that adds the player quality menu.
4
+
1
5
  ## 2.9.31
2
6
 
3
7
  - **New**: Realtime transcode budget — startup resolution + preset selection (OpenSpec change `transcode-quality`, part 2.1). For the software encoder the proxy now picks the output RESOLUTION as well as the libx264 preset from the startup benchmark: the client-requested box (capped to the source, never upscaled) is the ceiling, and the proxy chooses the highest resolution rung at or below it that the benchmark predicts encodes faster than realtime (with the existing margin), then the best preset at that resolution. On a weak host this downscales (e.g. a 720p60→30 stream that ran at ~0.9× on a Home Assistant box now encodes at ~480p in realtime) instead of dropping into sub-realtime playback with constant stalls. Capable hosts keep full resolution and spend the headroom on a higher-quality preset; hardware encoders and the no-benchmark case are unchanged. Also fixed the realtime-need calculation to use the session's actual output frame rate instead of the fixed 24 fps constant (it under-counted for 25/30 fps content). The chosen encode resolution is logged (`enc=WxH@fps budget=on`). This scales down from the orientation-independent ceiling the browser now sends (server 0.8.43).
@@ -94,3 +94,26 @@ steps, and a resolution floor, and SHALL reset its slow window on every encode
94
94
  - **WHEN** a downswitch (or a viewer seek) has just restarted the encode
95
95
  - **THEN** the slow window is reset and no further downswitch occurs until a new
96
96
  sustained-slow window elapses after the cooldown
97
+
98
+ ### Requirement: Manual quality forces a constant resolution
99
+
100
+ The viewer SHALL be able to force a specific output resolution instead of Auto.
101
+ When a resolution is forced the proxy SHALL encode exactly that box (capped to
102
+ the source, never upscaled) with the realtime budget disabled — no startup
103
+ auto-downscale and no runtime downswitch — so the resolution stays constant for
104
+ the whole session. The browser SHALL offer Auto plus resolutions at or below the
105
+ source height, built from the source resolution reported in the playback plan.
106
+ Selecting a quality SHALL re-open the stream at the new resolution with the
107
+ playback position preserved. Auto SHALL keep the current realtime-budget
108
+ behaviour.
109
+
110
+ #### Scenario: Forced resolution is constant
111
+ - **WHEN** the viewer forces a resolution (e.g. 480p)
112
+ - **THEN** the proxy encodes at that resolution for the whole session, with no
113
+ budget downscale or runtime downswitch, and playback resumes at the same
114
+ position
115
+
116
+ #### Scenario: Auto
117
+ - **WHEN** the viewer selects Auto
118
+ - **THEN** the proxy applies the realtime budget (startup selection + runtime
119
+ downswitch) as before
@@ -39,10 +39,17 @@
39
39
  - [ ] 3.1 Detect 10-bit/HDR; insert tonemap chain when re-encoding to 8-bit
40
40
  - [ ] 3.2 Guard on tonemap-filter availability in the ffmpeg build
41
41
 
42
- ## 4. Manual quality (planned)
43
-
44
- - [ ] 4.1 Proxy honours requested target height (already partly there)
45
- - [ ] 4.2 Server Quality menu (Auto + forced resolutions)
42
+ ## 4. Manual quality
43
+
44
+ - [x] 4.1 Proxy honours a forced resolution: `manualQuality` flag on the
45
+ transcode-session request encodes the requested box exactly (capped to
46
+ source, budget + runtime downswitch disabled); `manualQuality` in the
47
+ session key; playback plan reports source `videoWidth`/`videoHeight`.
48
+ - [x] 4.2 Server Quality menu (Auto + forced resolutions <= source). Custom
49
+ media-chrome submenu mirroring the audio menu; selection re-opens the
50
+ stream at the forced resolution with the position preserved (reuses the
51
+ audio-switch machinery); shared settings button shows for audio OR
52
+ quality.
46
53
 
47
54
  ## 5. Release
48
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.31",
3
+ "version": "2.9.32",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -33,6 +33,10 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
33
33
  const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
34
34
  const targetWidth = Number(payload.targetWidth);
35
35
  const targetHeight = Number(payload.targetHeight);
36
+ // Manual quality: the target box is a user-forced resolution, encoded exactly
37
+ // (capped to source), with the realtime budget's auto-downscale + runtime
38
+ // downswitch disabled for the session.
39
+ const manualQuality = payload.manualQuality === true;
36
40
  const startPositionSeconds = Number(payload.startPositionSeconds);
37
41
  const audioTrackIndex = Number(payload.audioTrackIndex);
38
42
 
@@ -50,6 +54,7 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
50
54
  fileName,
51
55
  targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
52
56
  targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
57
+ manualQuality,
53
58
  startPositionSeconds:
54
59
  Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
55
60
  ? startPositionSeconds
@@ -709,6 +709,7 @@ export class HlsSessionManager {
709
709
  * @param {number} [options.targetHeight=0] - Target video height (0 = keep source).
710
710
  * @param {number} [options.startPositionSeconds=0] - Seek start position in seconds.
711
711
  * @param {number} [options.audioTrackIndex=0] - Type-relative audio track to map (0:a:N).
712
+ * @param {boolean} [options.manualQuality=false] - User-forced resolution: encode the target box exactly (capped to source), no budget downscale / runtime downswitch.
712
713
  * @returns {Promise<HlsSession>}
713
714
  */
714
715
  async createOrGetSession({
@@ -721,7 +722,8 @@ export class HlsSessionManager {
721
722
  targetWidth = 0,
722
723
  targetHeight = 0,
723
724
  startPositionSeconds = 0,
724
- audioTrackIndex = 0
725
+ audioTrackIndex = 0,
726
+ manualQuality = false
725
727
  }) {
726
728
  if (!this.enabled) {
727
729
  const error = new Error("Audio transcoding is disabled on this proxy.");
@@ -739,6 +741,7 @@ export class HlsSessionManager {
739
741
  : 0;
740
742
  const normalizedAudioTrack =
741
743
  Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0;
744
+ const forceManualQuality = manualQuality === true && transcodeVideo;
742
745
  const sourceMapKey = [
743
746
  sourceKey,
744
747
  String(fileIndex),
@@ -747,6 +750,7 @@ export class HlsSessionManager {
747
750
  `t${normalizedAudioTrack}`,
748
751
  String(normalizedTargetWidth),
749
752
  String(normalizedTargetHeight),
753
+ forceManualQuality ? "q-manual" : "q-auto",
750
754
  String(normalizedStartPosition)
751
755
  ].join(":");
752
756
  const existingId = this.sessionIdBySource.get(sourceMapKey);
@@ -835,14 +839,21 @@ export class HlsSessionManager {
835
839
  // instead of dropping into sub-realtime playback. Null for hardware
836
840
  // encoders or when the source size / benchmark is unavailable — the encode
837
841
  // then keeps the client target box and buildVideoArgs's default preset.
838
- const encodeBudget = this.#chooseEncodeBudget({
839
- transcodeVideo,
840
- targetWidth: normalizedTargetWidth,
841
- targetHeight: normalizedTargetHeight,
842
- sourceWidth,
843
- sourceHeight,
844
- outputFps
845
- });
842
+ //
843
+ // Manual quality bypasses the budget entirely: the user forced a specific
844
+ // resolution, so encode exactly that box (capped to source by the scale
845
+ // filter) with the default preset, and the runtime downswitch is skipped
846
+ // for the session (budgetLadder stays null).
847
+ const encodeBudget = forceManualQuality
848
+ ? null
849
+ : this.#chooseEncodeBudget({
850
+ transcodeVideo,
851
+ targetWidth: normalizedTargetWidth,
852
+ targetHeight: normalizedTargetHeight,
853
+ sourceWidth,
854
+ sourceHeight,
855
+ outputFps
856
+ });
846
857
  const softwarePreset = encodeBudget?.preset ?? null;
847
858
  // Effective encode box: the budget's downscaled resolution when applied,
848
859
  // otherwise the client target (0 = keep source, handled by buildVideoArgs).
@@ -932,9 +943,10 @@ export class HlsSessionManager {
932
943
  `branch=${transcodeVideo ? "A(reencode,fixed-gop)" : "B(copy,copyts)"} ` +
933
944
  `seg=${usingKeyframeBoundaries ? "keyframe" : "uniform"} ` +
934
945
  `${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
935
- // Effective encode resolution and whether the realtime budget downscaled
936
- // it below the client target (the orientation-independent ceiling).
946
+ // Effective encode resolution: budget-on (auto downscale from the
947
+ // ceiling), manual (user-forced, budget off), or unset (keep source).
937
948
  `${transcodeVideo && encodeBudget ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} budget=on ` : ""}` +
949
+ `${transcodeVideo && forceManualQuality ? `enc=${encodeWidth || "src"}x${encodeHeight || "src"}@${outputFps} quality=manual ` : ""}` +
938
950
  `${sourceStartTime ? `start=${sourceStartTime.toFixed(3)} ` : ""}` +
939
951
  `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
940
952
  );
@@ -68,6 +68,18 @@ function parseStreams(ffmpegOutput) {
68
68
  function parseStreamCodecs(ffmpegOutput) {
69
69
  const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
70
70
  const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
71
+ // Coded resolution from the video Stream line ("Video: h264 …, 1280x720, …").
72
+ // The first WxH is the coded size (any trailing "[SAR …]" is ignored).
73
+ const videoLineMatch = ffmpegOutput.match(/Video:[^\n]*/i);
74
+ let videoWidth = 0;
75
+ let videoHeight = 0;
76
+ if (videoLineMatch) {
77
+ const dim = videoLineMatch[0].match(/\b(\d{2,5})x(\d{2,5})\b/);
78
+ if (dim) {
79
+ videoWidth = Number(dim[1]);
80
+ videoHeight = Number(dim[2]);
81
+ }
82
+ }
71
83
  const containerMatch = ffmpegOutput.match(/Input #0,\s*([^,]+(?:,[^,]+)*?),\s*from/i);
72
84
  const durationMatch = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
73
85
  let durationSeconds = 0;
@@ -106,6 +118,8 @@ function parseStreamCodecs(ffmpegOutput) {
106
118
  videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
107
119
  container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
108
120
  durationSeconds,
121
+ videoWidth,
122
+ videoHeight,
109
123
  audioTracks,
110
124
  subtitleTracks
111
125
  };
@@ -208,6 +222,8 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
208
222
  * @property {string} videoCodec
209
223
  * @property {string} container - Demuxer/container name(s) reported by ffmpeg.
210
224
  * @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
225
+ * @property {number} videoWidth - Source coded width (0 if unknown).
226
+ * @property {number} videoHeight - Source coded height (0 if unknown).
211
227
  */
212
228
 
213
229
  /**
@@ -289,6 +305,8 @@ export function createPlaybackPlanner({
289
305
  videoCodec: "",
290
306
  container: "",
291
307
  durationSeconds: 0,
308
+ videoWidth: 0,
309
+ videoHeight: 0,
292
310
  audioTracks: [],
293
311
  subtitleTracks: []
294
312
  };
@@ -316,7 +334,7 @@ export function createPlaybackPlanner({
316
334
  await torrentPool.prefetchFileEdges(torrent, fileIndex);
317
335
  probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
318
336
  }
319
- const { audioCodec, videoCodec, container, durationSeconds, audioTracks, subtitleTracks } = probe;
337
+ const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
320
338
  const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
321
339
 
322
340
  // `mode` is advisory only (audio-codec based). The browser makes the
@@ -331,6 +349,10 @@ export function createPlaybackPlanner({
331
349
  videoCodec,
332
350
  container,
333
351
  durationSeconds,
352
+ // Source coded resolution — drives the browser's manual quality menu
353
+ // (list of forced resolutions <= source). 0 when unknown.
354
+ videoWidth,
355
+ videoHeight,
334
356
  // Full track inventory for the browser's audio/subtitle menus.
335
357
  audioTracks: audioTracks ?? [],
336
358
  subtitleTracks: subtitleTracks ?? []