diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 8ae54ed6..1bd0fabc 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -86,15 +86,51 @@ part 'plex_client/parts/metadata_edit.dart'; const _plexVideoTranscodeBaseEndpoint = '/video/:/transcode/universal'; const _plexVideoHlsStartEndpoint = '$_plexVideoTranscodeBaseEndpoint/start.m3u8'; const _plexVideoHlsProtocol = 'hls'; -const _plexHlsVideoTranscodeTarget = + +/// VOD transcode target: HLS with fragmented-MP4 segments. +/// +/// Every non-Original request pins `directStream=0`, so this codec list is a +/// menu of *encode* outputs, never copy targets. HEVC must not be offered in +/// an mpegts target: a Plex Pass server with HEVC encoding enabled obliges, +/// and its hardware HEVC encode → TS segmenter path emits parameter sets mpv +/// rejects ("PPS changed between slices", issue #1859). Apple's HLS spec +/// likewise requires fMP4 for HEVC. fMP4 decisions and segment output were +/// verified against PMS 1.22 through 1.43; servers older than 1.22 fail the +/// decision request itself regardless of container, so no version gate. +const _plexHlsVodVideoTranscodeTarget = + 'add-transcode-target(type=videoProfile&context=streaming' + '&protocol=hls&container=mp4&videoCodec=h264%2Chevc' + '&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)'; + +/// Fallback VOD target for a server whose decision does not honour the fMP4 +/// container: H.264-only MPEG-TS, the combination Plex's own legacy clients +/// request. HEVC stays out — in a TS target it is reachable only as the +/// broken encode output described on [_plexHlsVodVideoTranscodeTarget]. +const _plexHlsVodTsVideoTranscodeTarget = + 'add-transcode-target(type=videoProfile&context=streaming' + '&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. +const _plexHlsLiveVideoTranscodeTarget = 'add-transcode-target(type=videoProfile&context=streaming' '&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video' '&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)'; + const _plexHlsSubtitleTranscodeTarget = 'add-transcode-target(type=subtitleProfile&context=streaming' '&protocol=hls&container=webvtt&subtitleCodec=webvtt)'; -String _buildPlexHlsClientProfileExtra({int? maxVideoBitrateKbps}) { +/// Containers the VOD decision must echo back before a start path is handed +/// to the player (see `requiredContainer` on [_runTranscodeDecision]). +const _plexHlsVodContainer = 'mp4'; +const _plexHlsVodTsContainer = 'mpegts'; + +String _buildPlexHlsClientProfileExtra({required String videoTranscodeTarget, int? maxVideoBitrateKbps}) { final clauses = ['add-settings(DirectPlayStreamSelection=true)']; if (maxVideoBitrateKbps != null) { clauses.add( @@ -103,7 +139,7 @@ String _buildPlexHlsClientProfileExtra({int? maxVideoBitrateKbps}) { ); } clauses - ..add(_plexHlsVideoTranscodeTarget) + ..add(videoTranscodeTarget) ..add(_plexHlsSubtitleTranscodeTarget); return clauses.join('+'); } @@ -2626,7 +2662,7 @@ class PlexClient }) async { try { await selectSubtitleStreamForBurn(partId: partId, track: selectedSubtitleTrack); - final allParams = _buildTranscodeParams( + Map paramsFor({required bool useTsFallbackTarget}) => _buildTranscodeParams( ratingKey: ratingKey, mediaIndex: mediaIndex, partIndex: partIndex, @@ -2636,12 +2672,35 @@ class PlexClient audioStreamId: audioStreamId, offset: offset, selectedSubtitleTrack: selectedSubtitleTrack, + useTsFallbackTarget: useTsFallbackTarget, ); - return await _runTranscodeDecision( + + final primary = await _runTranscodeDecision( startEndpoint: _plexVideoHlsStartEndpoint, - allParams: allParams, + allParams: paramsFor(useTsFallbackTarget: false), isOriginal: preset.isOriginal, + requiredContainer: _plexHlsVodContainer, ); + if (primary.containerHonored) { + return (startPath: primary.startPath, outcome: primary.outcome); + } + + // The decision succeeded but ignored the fMP4 target. Never hand the + // player a container it did not negotiate — a mis-declared stream is + // exactly the corruption mode of issue #1859 — so re-ask with the + // TS/H.264 fallback profile before giving up. + appLogger.i('Retrying transcode decision with the TS fallback profile'); + final fallback = await _runTranscodeDecision( + startEndpoint: _plexVideoHlsStartEndpoint, + allParams: paramsFor(useTsFallbackTarget: true), + isOriginal: preset.isOriginal, + requiredContainer: _plexHlsVodTsContainer, + ); + if (fallback.containerHonored) { + return (startPath: fallback.startPath, outcome: fallback.outcome); + } + appLogger.w('Transcode decision honoured neither requested container; falling back to direct play'); + return (startPath: null, outcome: TranscodeDecisionOutcome.failed); } catch (e, st) { appLogger.e('Failed to build transcode start path', error: e, stackTrace: st); return (startPath: null, outcome: TranscodeDecisionOutcome.failed); @@ -2899,11 +2958,12 @@ class PlexClient sessionIdentifier: sessionIdentifier, transcodeSessionId: transcodeSessionId, ); - return await _runTranscodeDecision( + final result = await _runTranscodeDecision( startEndpoint: _musicTranscodeStartEndpoint, allParams: allParams, isOriginal: preset.isOriginal, ); + return (startPath: result.startPath, outcome: result.outcome); } catch (e, st) { appLogger.e('Failed to build music transcode start path', error: e, stackTrace: st); return (startPath: null, outcome: TranscodeDecisionOutcome.failed); @@ -2917,10 +2977,17 @@ class PlexClient /// outcome via [_parseTranscodeDecisionOutcome], and hand back the start /// path (token stripped) on success. [startEndpoint] includes the container /// extension (`start.m3u8` / `start.mp3`). - Future<({String? startPath, TranscodeDecisionOutcome outcome})> _runTranscodeDecision({ + /// + /// When [requiredContainer] is set and the decision converts, the selected + /// media entry must echo that container back; `containerHonored: false` + /// otherwise. PMS applies whatever transcode target the client profile + /// names, so a mismatch means the server substituted a container the + /// player never negotiated — the caller must not open that stream. + Future<({String? startPath, TranscodeDecisionOutcome outcome, bool containerHonored})> _runTranscodeDecision({ required String startEndpoint, required Map allParams, required bool isOriginal, + String? requiredContainer, }) async { final decisionEndpoint = '${startEndpoint.substring(0, startEndpoint.lastIndexOf('/'))}/decision'; @@ -2938,15 +3005,41 @@ class PlexClient if (decisionResponse.statusCode != 200) { appLogger.w('Transcode decision returned ${decisionResponse.statusCode}'); - return (startPath: null, outcome: TranscodeDecisionOutcome.failed); + return (startPath: null, outcome: TranscodeDecisionOutcome.failed, containerHonored: true); } final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal); if (outcome == TranscodeDecisionOutcome.failed) { - return (startPath: null, outcome: outcome); + return (startPath: null, outcome: outcome, containerHonored: true); } - return (startPath: _buildTranscodeStartPathFromParams(allParams, endpoint: startEndpoint), outcome: outcome); + var containerHonored = true; + if (requiredContainer != null && outcome == TranscodeDecisionOutcome.transcodeOk) { + final selected = _decisionSelectedContainer(decisionResponse.data); + containerHonored = selected == requiredContainer; + if (!containerHonored) { + appLogger.w('Transcode decision did not honour container=$requiredContainer (got ${selected ?? 'none'})'); + } + } + + return ( + startPath: _buildTranscodeStartPathFromParams(allParams, endpoint: startEndpoint), + outcome: outcome, + containerHonored: containerHonored, + ); + } + + /// Container of the selected media entry in a transcode decision body, or + /// null when the decision carries no media selection. + static String? _decisionSelectedContainer(dynamic data) { + if (data is! Map) return null; + final container = data['MediaContainer']; + final metadata = container is Map ? container['Metadata'] : null; + final media = metadata is List && metadata.isNotEmpty && metadata.first is Map + ? (metadata.first as Map)['Media'] + : null; + final selected = media is List && media.isNotEmpty && media.first is Map ? media.first as Map : null; + return selected?['container']?.toString(); } String _buildTranscodeStartPathFromParams( @@ -2976,10 +3069,12 @@ class PlexClient int? audioStreamId, Duration? offset, MediaSubtitleTrack? selectedSubtitleTrack, + bool useTsFallbackTarget = false, }) { final isOriginal = preset.isOriginal; final selectedInternalSubtitle = _selectedInternalSubtitleForHls(selectedSubtitleTrack); final clientProfileExtra = _buildPlexHlsClientProfileExtra( + videoTranscodeTarget: useTsFallbackTarget ? _plexHlsVodTsVideoTranscodeTarget : _plexHlsVodVideoTranscodeTarget, maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null, ); @@ -3003,6 +3098,13 @@ class PlexClient 'location': 'lan', 'addDebugOverlay': '0', 'autoAdjustQuality': '0', + // The preset's resolution/quality caps ride as plain query params — the + // bitrate limitation clause alone leaves a 4K source at 2160p, starving + // the encode and breaking the picker's "1080p" promise (issue #1859). + // Both are honoured by the decision and start endpoints on a real PMS. + // Null exactly for the original preset. + if (preset.videoResolution != null) 'videoResolution': preset.videoResolution!, + if (preset.videoQuality != null) 'videoQuality': preset.videoQuality!.toString(), 'directStreamAudio': '1', 'mediaBufferSize': '102400', 'session': transcodeSessionId, @@ -3045,6 +3147,7 @@ class PlexClient int? audioStreamId, Duration? offset, MediaSubtitleTrack? selectedSubtitleTrack, + bool useTsFallbackTarget = false, }) { return _buildTranscodeParams( ratingKey: ratingKey, @@ -3056,6 +3159,7 @@ class PlexClient audioStreamId: audioStreamId, offset: offset, selectedSubtitleTrack: selectedSubtitleTrack, + useTsFallbackTarget: useTsFallbackTarget, ); } diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index c4af2880..f686baef 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -696,7 +696,9 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, 'copyts': '0', 'Accept-Language': 'en', 'X-Plex-Session-Identifier': sessionIdentifier, - 'X-Plex-Client-Profile-Extra': _buildPlexHlsClientProfileExtra(), + 'X-Plex-Client-Profile-Extra': _buildPlexHlsClientProfileExtra( + videoTranscodeTarget: _plexHlsLiveVideoTranscodeTarget, + ), 'X-Plex-Incomplete-Segments': '1', 'X-Plex-Product': config.product, 'X-Plex-Version': config.version, diff --git a/test/services/live_tv_playback_session_test.dart b/test/services/live_tv_playback_session_test.dart index 5b163e6b..11463639 100644 --- a/test/services/live_tv_playback_session_test.dart +++ b/test/services/live_tv_playback_session_test.dart @@ -134,7 +134,14 @@ void main() { expect(liveEdgeUri.queryParameters['protocol'], 'hls'); expect(liveEdgeUri.queryParameters['X-Plex-Incomplete-Segments'], '1'); expect(liveEdgeUri.queryParameters.containsKey('X-Plex-Chunked'), isFalse); + // Live TV deliberately keeps the TS target with the broadcast codecs: + // live sessions copy hevc/mpeg2video channels, unlike the VOD target + // which moved to fMP4 (issue #1859). expect(liveEdgeUri.queryParameters['X-Plex-Client-Profile-Extra'], contains('protocol=hls&container=mpegts')); + expect( + liveEdgeUri.queryParameters['X-Plex-Client-Profile-Extra'], + contains('videoCodec=h264%2Chevc%2Cmpeg2video'), + ); expect(liveEdgeUri.queryParameters['subtitles'], 'none'); expect(liveEdgeUri.queryParameters.containsKey('subtitleStreamID'), isFalse); expect(liveEdgeUri.queryParameters.containsKey('advancedSubtitles'), isFalse); diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index 74dbecf0..7c84116c 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -97,7 +97,19 @@ void main() { decisionUri = request.url; return http.Response( jsonEncode({ - 'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001}, + 'MediaContainer': { + 'generalDecisionCode': 1001, + 'transcodeDecisionCode': 1001, + // Real decisions echo the honoured target container back; the + // client refuses a transcode whose container it never asked for. + 'Metadata': [ + { + 'Media': [ + {'container': 'mp4', 'protocol': 'hls', 'selected': true}, + ], + }, + ], + }, }), 200, headers: {'content-type': 'application/json'}, @@ -302,7 +314,17 @@ void main() { if (request.url.path == '/video/:/transcode/universal/decision') { return http.Response( jsonEncode({ - 'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001}, + 'MediaContainer': { + 'generalDecisionCode': 1001, + 'transcodeDecisionCode': 1001, + 'Metadata': [ + { + 'Media': [ + {'container': 'mp4', 'protocol': 'hls', 'selected': true}, + ], + }, + ], + }, }), 200, headers: {'content-type': 'application/json'}, @@ -871,7 +893,8 @@ void main() { profile, contains( 'add-transcode-target(type=videoProfile&context=streaming' - '&protocol=hls&container=mpegts', + '&protocol=hls&container=mp4&videoCodec=h264%2Chevc' + '&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)', ), ); expect( @@ -882,6 +905,161 @@ void main() { ), ); expect(profile, isNot(contains('protocol=http&container=mkv'))); + // HEVC-in-TS is the mis-mux of issue #1859; the VOD profile must never + // reintroduce it. + expect(profile, isNot(contains('container=mpegts'))); + expect(profile, isNot(contains('mpeg2video'))); + }); + + test('non-original presets send the resolution and quality caps their labels promise', () { + final client = makeClient((_) async => http.Response('not used', 500)); + addTearDown(client.close); + + final capped = client.buildTranscodeParamsForTesting( + ratingKey: '42', + mediaIndex: 0, + preset: TranscodeQualityPreset.p1080_8mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + ); + expect(capped['videoResolution'], '1920x1080'); + expect(capped['videoQuality'], '60'); + + final original = client.buildTranscodeParamsForTesting( + ratingKey: '42', + mediaIndex: 0, + preset: TranscodeQualityPreset.original, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + ); + expect(original.containsKey('videoResolution'), isFalse); + expect(original.containsKey('videoQuality'), isFalse); + }); + + test('the TS fallback profile offers only H.264, never HEVC-in-TS', () { + final client = makeClient((_) async => http.Response('not used', 500)); + addTearDown(client.close); + + final params = client.buildTranscodeParamsForTesting( + ratingKey: '42', + mediaIndex: 0, + preset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + useTsFallbackTarget: true, + ); + + final profile = params['X-Plex-Client-Profile-Extra']; + expect( + profile, + contains( + 'add-transcode-target(type=videoProfile&context=streaming' + '&protocol=hls&container=mpegts&videoCodec=h264' + '&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)', + ), + ); + expect(profile, isNot(contains('hevc'))); + expect(profile, isNot(contains('container=mp4'))); + }); + + Future<({({String? startPath, TranscodeDecisionOutcome outcome}) result, List decisions})> runGuardedDecision( + String Function(int decisionNumber) containerFor, + ) async { + final decisions = []; + final client = makeClient((request) async { + decisions.add(request.url); + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'transcodeDecisionCode': 1001, + 'Metadata': [ + { + 'Media': [ + {'container': containerFor(decisions.length), 'protocol': 'hls', 'selected': true}, + ], + }, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + addTearDown(client.close); + + final result = await client.buildTranscodeStartPath( + ratingKey: '42', + mediaIndex: 0, + preset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + ); + return (result: result, decisions: decisions); + } + + test('a decision that honours the fMP4 container is used without a retry', () async { + final run = await runGuardedDecision((_) => 'mp4'); + + expect(run.result.outcome, TranscodeDecisionOutcome.transcodeOk); + expect(run.decisions, hasLength(1)); + final profile = Uri.parse(run.result.startPath!).queryParameters['X-Plex-Client-Profile-Extra']!; + expect(profile, contains('container=mp4&videoCodec=h264%2Chevc')); + }); + + test('a decision that ignores the fMP4 container is retried once with the TS/h264 profile', () async { + final run = await runGuardedDecision((decisionNumber) => decisionNumber == 1 ? 'mkv' : 'mpegts'); + + expect(run.result.outcome, TranscodeDecisionOutcome.transcodeOk); + expect(run.decisions, hasLength(2)); + final retryProfile = run.decisions[1].queryParameters['X-Plex-Client-Profile-Extra']!; + expect(retryProfile, contains('container=mpegts&videoCodec=h264&audioCodec')); + // The start path must carry the profile the server actually honoured, + // not the fMP4 one it ignored. + final startProfile = Uri.parse(run.result.startPath!).queryParameters['X-Plex-Client-Profile-Extra']!; + expect(startProfile, contains('container=mpegts&videoCodec=h264&audioCodec')); + }); + + test('a server honouring neither container falls back to direct play instead of a mis-mux', () async { + final run = await runGuardedDecision((_) => 'mkv'); + + expect(run.result.outcome, TranscodeDecisionOutcome.failed); + expect(run.result.startPath, isNull); + expect(run.decisions, hasLength(2)); + }); + + test('a direct-play-only decision passes through without a container retry', () async { + final decisions = []; + final client = makeClient((request) async { + decisions.add(request.url); + return http.Response( + jsonEncode({ + 'MediaContainer': { + 'transcodeDecisionCode': 1000, + 'Metadata': [ + { + 'Media': [ + {'container': 'mkv', 'selected': true}, + ], + }, + ], + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + addTearDown(client.close); + + final result = await client.buildTranscodeStartPath( + ratingKey: '42', + mediaIndex: 0, + preset: TranscodeQualityPreset.p720_3mbps, + sessionIdentifier: 'session-id', + transcodeSessionId: 'transcode-id', + ); + + expect(result.outcome, TranscodeDecisionOutcome.directPlayOnly); + expect(decisions, hasLength(1)); }); test('transcode start path uses the HLS manifest endpoint without token', () {