fix(plex): honor the quality preset on Live TV instead of leaving the server to pick the tier

Playing a Plex live channel from outside the LAN always came back as a full
re-encode at the server's highest transcode tier (20 Mbps 1080p on the
reporter's server), no matter which quality the app was set to. The Plex live
path never sent a ceiling: a live source has no bitrate the server can check
against its remote-stream limit, so a remote session without a client cap
falls onto the server's own top tier. On the LAN the server remuxes anyway,
which hid the gap.

The live stream path now reads the same saved preset the library path and
the Jellyfin live path (#2198) already honor. Original is unchanged: a remux
(directStream=1) with no ceiling. A capped preset pins directStream=0 and
sends the bitrate limitation clause plus the videoResolution/videoQuality caps
so the encode lands at the chosen tier rather than the server's. The preset is
session state, so a recovery re-tune keeps the cap.

directPlay stays 0: a tuned Plex session is only reachable through the
transcoder's HLS output, so "no re-encode" on live means a remux, not direct
play. Capped sessions use the h264-only TS target because every codec in the
target becomes an encode output once directStream is off, and HEVC into TS is
the #1859 corruption; the broadcast-codec live target remains the Original
remux menu.

close #2072
This commit is contained in:
edde746
2026-09-05 04:15:17 +02:00
parent 12a380807d
commit 5efc4f384c
3 changed files with 118 additions and 14 deletions
+8 -5
View File
@@ -114,11 +114,14 @@ const _plexHlsVodTsVideoTranscodeTarget =
'&protocol=hls&container=mpegts&videoCodec=h264'
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)';
/// Live TV target: MPEG-TS with the broadcast codecs. Live sessions are
/// copy-dominant (TS→TS remux — hevc/mpeg2video here are copy targets, and
/// HEVC *copy* into TS is verified clean), so this deliberately does not
/// follow the VOD target to fMP4. Residual risk accepted: a Plex Pass server
/// electing to HEVC-*encode* a live channel would hit the same TS bug.
/// Live TV target for Original quality: MPEG-TS with the broadcast codecs.
/// Those sessions are copy-dominant (TS→TS remux — hevc/mpeg2video here are
/// copy targets, and HEVC *copy* into TS is verified clean), so this
/// deliberately does not follow the VOD target to fMP4. Residual risk
/// accepted: a Plex Pass server electing to HEVC-*encode* an Original live
/// channel would hit the same TS bug. A capped live preset pins
/// `directStream=0` and therefore uses [_plexHlsVodTsVideoTranscodeTarget]
/// instead, where HEVC is not on the encode menu.
const _plexHlsLiveVideoTranscodeTarget =
'add-transcode-target(type=videoProfile&context=streaming'
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video'
+43 -9
View File
@@ -698,15 +698,23 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
/// [burnSubtitle] asks the transcoder to burn the part's server-selected
/// subtitle stream into the video; the caller must have confirmed that
/// selection first (see [_PlexLiveTvPlaybackSession._confirmBurnSelection]).
/// [preset] is the viewer's quality ceiling. Original leaves the server free
/// to remux (`directStream=1`, no ceiling); a capped preset forces an encode
/// at that bitrate/resolution, the same way the library path does. Without a
/// client ceiling a remote session lands on the server's own top transcode
/// tier, because a live source has no bitrate the server can verify against
/// its remote-stream limit (issue #2072).
Future<String?> _buildLiveStreamPath({
required String sessionPath,
required String sessionIdentifier,
required String transcodeSessionId,
required TranscodeQualityPreset preset,
int? offsetSeconds,
bool directStream = true,
bool directStreamAudio = true,
bool burnSubtitle = false,
}) async {
final isOriginal = preset.isOriginal;
try {
final allParams = <String, String>{
'hasMDE': '1',
@@ -715,13 +723,22 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
'partIndex': '0',
'protocol': _plexVideoHlsProtocol,
'fastSeek': '1',
// Never `1`: a tuned Plex session is only reachable through the
// transcoder's HLS output, so "no re-encode" on live means a remux
// (`directStream=1`), not direct play — the Generic profile has no
// direct-play entry for hls/mpegts and the server says so in its MDE.
'directPlay': '0',
'directStream': directStream ? '1' : '0',
'directStream': directStream && isOriginal ? '1' : '0',
'subtitleSize': '100',
'audioBoost': '100',
'location': 'lan',
'addDebugOverlay': '0',
'autoAdjustQuality': '0',
// Resolution/quality caps ride as plain query params alongside the
// bitrate limitation clause, as on the library path (issue #1859).
// Null exactly for the original preset.
if (preset.videoResolution != null) 'videoResolution': preset.videoResolution!,
if (preset.videoQuality != null) 'videoQuality': preset.videoQuality!.toString(),
'directStreamAudio': directStreamAudio ? '1' : '0',
'mediaBufferSize': '157286',
'session': transcodeSessionId,
@@ -740,7 +757,12 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
'Accept-Language': 'en',
'X-Plex-Session-Identifier': sessionIdentifier,
'X-Plex-Client-Profile-Extra': _buildPlexHlsClientProfileExtra(
videoTranscodeTarget: _plexHlsLiveVideoTranscodeTarget,
// A capped preset pins `directStream=0`, so every codec in the target
// becomes an *encode* output. HEVC must not be one in an mpegts
// target (issue #1859), hence the h264-only TS target; the live
// target's hevc/mpeg2video entries are copy codecs for Original only.
videoTranscodeTarget: isOriginal ? _plexHlsLiveVideoTranscodeTarget : _plexHlsVodTsVideoTranscodeTarget,
maxVideoBitrateKbps: isOriginal ? null : preset.videoBitrateKbps,
),
'X-Plex-Incomplete-Segments': '1',
'X-Plex-Product': config.product,
@@ -863,8 +885,6 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
return getEpgGrid(beginsAt: toEpoch(from), endsAt: toEpoch(to));
}
/// [quality] is intentionally not consumed yet: the Plex live path still
/// hardcodes `directPlay=0` and sends no bitrate ceiling (#2072).
@override
Future<LiveTvPlaybackSession?> startPlayback(
String channelKey, {
@@ -875,7 +895,12 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
appLogger.w('Plex live playback requires a dvrKey to tune $channelKey');
return Future.value(null);
}
return _PlexLiveTvPlaybackSession.start(this as PlexClient, dvrKey: dvrKey, channelKey: channelKey);
return _PlexLiveTvPlaybackSession.start(
this as PlexClient,
dvrKey: dvrKey,
channelKey: channelKey,
quality: quality,
);
}
@override
@@ -898,10 +923,12 @@ class _PlexLiveTvPlaybackSession implements LiveTvPlaybackSession {
final String _transcodeSessionId;
final int? _partId;
/// Degradation flags are session state (a recovered session keeps its
/// degraded profile for every URL it builds), not per-call options.
/// Degradation flags and the quality ceiling are session state (a recovered
/// session keeps its degraded profile and its cap for every URL it builds),
/// not per-call options.
final bool _directStream;
final bool _directStreamAudio;
final TranscodeQualityPreset _quality;
@override
final LiveProgramInfo program;
@@ -929,7 +956,8 @@ class _PlexLiveTvPlaybackSession implements LiveTvPlaybackSession {
this._transcodeSessionId,
this._partId,
this._directStream,
this._directStreamAudio, {
this._directStreamAudio,
this._quality, {
required this.program,
required this.captureBuffer,
required this.subtitleTracks,
@@ -942,6 +970,7 @@ class _PlexLiveTvPlaybackSession implements LiveTvPlaybackSession {
PlexClient client, {
required String dvrKey,
required String channelKey,
required TranscodeQualityPreset quality,
bool directStream = true,
bool directStreamAudio = true,
}) async {
@@ -958,6 +987,7 @@ class _PlexLiveTvPlaybackSession implements LiveTvPlaybackSession {
tuneResult.partId,
directStream,
directStreamAudio,
quality,
program: LiveProgramInfo(
id: tuneResult.metadata.ratingKey,
durationMs: tuneResult.metadata.duration,
@@ -978,6 +1008,7 @@ class _PlexLiveTvPlaybackSession implements LiveTvPlaybackSession {
sessionPath: _sessionPath,
sessionIdentifier: _sessionIdentifier,
transcodeSessionId: _transcodeSessionId,
preset: _quality,
offsetSeconds: offsetSeconds,
directStream: _directStream,
directStreamAudio: _directStreamAudio,
@@ -1044,11 +1075,14 @@ class _PlexLiveTvPlaybackSession implements LiveTvPlaybackSession {
@override
Future<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio}) {
// Re-tune for a fresh capture session — the previous one expires while
// the player exhausts its reconnect attempts.
// the player exhausts its reconnect attempts. The cap carries over: a
// recovered session that dropped it would reopen the very session shape
// #2072 removed.
return start(
_client,
dvrKey: _dvrKey,
channelKey: _channelKey,
quality: _quality,
directStream: directStream,
directStreamAudio: directStreamAudio,
);
@@ -404,6 +404,73 @@ void main() {
expect(uri.queryParameters['directStream'], '0');
expect(uri.queryParameters['directStreamAudio'], '0');
});
test('Original quality asks for a remux with no ceiling', () async {
final client = makeClient((request) async {
if (request.url.path.endsWith('/tune')) {
return jsonResponse(tuneResponse());
}
if (request.url.path == '/video/:/transcode/universal/decision') {
return http.Response('ok', 200);
}
return jsonResponse(const {});
});
addTearDown(client.close);
final session = (await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1'))!;
final uri = Uri.parse((await session.streamUrlAt())!);
// A tuned session is only reachable through the transcoder's HLS
// output, so "no re-encode" on live is a remux, never direct play.
expect(uri.queryParameters['directPlay'], '0');
expect(uri.queryParameters['directStream'], '1');
expect(uri.queryParameters.containsKey('videoResolution'), isFalse);
expect(uri.queryParameters.containsKey('videoQuality'), isFalse);
expect(uri.queryParameters['X-Plex-Client-Profile-Extra'], isNot(contains('add-limitation')));
});
test('a capped preset forces an h264 encode at that ceiling and survives recovery', () async {
final decisions = <Uri>[];
final client = makeClient((request) async {
if (request.url.path.endsWith('/tune')) {
return jsonResponse(tuneResponse());
}
if (request.url.path == '/video/:/transcode/universal/decision') {
decisions.add(request.url);
return http.Response('ok', 200);
}
return jsonResponse(const {});
});
addTearDown(client.close);
final session = (await client.liveTv.startPlayback(
'ch-1',
dvrKey: 'dvr-1',
quality: TranscodeQualityPreset.p720_2mbps,
))!;
final uri = Uri.parse((await session.streamUrlAt())!);
// Without a client ceiling a remote session lands on the server's own
// top transcode tier (#2072): the cap must reach both the decision and
// the start request, as bitrate limitation plus resolution/quality caps.
expect(uri.queryParameters['directStream'], '0');
expect(uri.queryParameters['videoResolution'], '1280x720');
expect(uri.queryParameters['videoQuality'], '60');
final profile = uri.queryParameters['X-Plex-Client-Profile-Extra']!;
expect(profile, contains('name=video.bitrate&value=2000'));
// Every target codec is now an encode output; HEVC into TS is the #1859
// corruption, so the h264-only TS target replaces the broadcast one.
expect(profile, contains('container=mpegts&videoCodec=h264&'));
expect(profile, isNot(contains('hevc')));
expect(decisions.single.queryParameters['videoResolution'], '1280x720');
expect(decisions.single.queryParameters['X-Plex-Client-Profile-Extra'], contains('value=2000'));
// A re-tune keeps the cap; dropping it would reopen the uncapped shape.
final recovered = await session.recover(directStream: true, directStreamAudio: true);
final recoveredUri = Uri.parse((await recovered!.streamUrlAt())!);
expect(recoveredUri.queryParameters['directStream'], '0');
expect(recoveredUri.queryParameters['X-Plex-Client-Profile-Extra'], contains('value=2000'));
});
});
group('Jellyfin live playback session', () {