fix(plex): use fMP4 HLS for video transcodes so HEVC presets stop corrupting
Non-Original presets advertised hevc inside the mpegts HLS target; a Plex
Pass server with HEVC encoding enabled obliges, and its HEVC encode -> TS
segmenter path emits parameter sets mpv rejects ("PPS changed between
slices"). The VOD target now requests fragmented MP4 (verified against
PMS 1.22-1.43), retrying once with an H.264-only TS profile when a
server's decision does not echo the mp4 container back, and falling back
to direct play when neither is honoured. Live TV keeps its own TS target:
live sessions copy broadcast hevc/mpeg2video streams, a path the encoder
bug does not touch.
Presets also now send the videoResolution/videoQuality caps their labels
promise; previously only the bitrate limitation went out, so a "1080p
8 Mbps" preset delivered 2160p at a starved 8 Mbps.
close #1859
This commit is contained in:
+115
-11
@@ -86,15 +86,51 @@ part 'plex_client/parts/metadata_edit.dart';
|
|||||||
const _plexVideoTranscodeBaseEndpoint = '/video/:/transcode/universal';
|
const _plexVideoTranscodeBaseEndpoint = '/video/:/transcode/universal';
|
||||||
const _plexVideoHlsStartEndpoint = '$_plexVideoTranscodeBaseEndpoint/start.m3u8';
|
const _plexVideoHlsStartEndpoint = '$_plexVideoTranscodeBaseEndpoint/start.m3u8';
|
||||||
const _plexVideoHlsProtocol = 'hls';
|
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'
|
'add-transcode-target(type=videoProfile&context=streaming'
|
||||||
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video'
|
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video'
|
||||||
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)';
|
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)';
|
||||||
|
|
||||||
const _plexHlsSubtitleTranscodeTarget =
|
const _plexHlsSubtitleTranscodeTarget =
|
||||||
'add-transcode-target(type=subtitleProfile&context=streaming'
|
'add-transcode-target(type=subtitleProfile&context=streaming'
|
||||||
'&protocol=hls&container=webvtt&subtitleCodec=webvtt)';
|
'&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 = <String>['add-settings(DirectPlayStreamSelection=true)'];
|
final clauses = <String>['add-settings(DirectPlayStreamSelection=true)'];
|
||||||
if (maxVideoBitrateKbps != null) {
|
if (maxVideoBitrateKbps != null) {
|
||||||
clauses.add(
|
clauses.add(
|
||||||
@@ -103,7 +139,7 @@ String _buildPlexHlsClientProfileExtra({int? maxVideoBitrateKbps}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
clauses
|
clauses
|
||||||
..add(_plexHlsVideoTranscodeTarget)
|
..add(videoTranscodeTarget)
|
||||||
..add(_plexHlsSubtitleTranscodeTarget);
|
..add(_plexHlsSubtitleTranscodeTarget);
|
||||||
return clauses.join('+');
|
return clauses.join('+');
|
||||||
}
|
}
|
||||||
@@ -2626,7 +2662,7 @@ class PlexClient
|
|||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
await selectSubtitleStreamForBurn(partId: partId, track: selectedSubtitleTrack);
|
await selectSubtitleStreamForBurn(partId: partId, track: selectedSubtitleTrack);
|
||||||
final allParams = _buildTranscodeParams(
|
Map<String, String> paramsFor({required bool useTsFallbackTarget}) => _buildTranscodeParams(
|
||||||
ratingKey: ratingKey,
|
ratingKey: ratingKey,
|
||||||
mediaIndex: mediaIndex,
|
mediaIndex: mediaIndex,
|
||||||
partIndex: partIndex,
|
partIndex: partIndex,
|
||||||
@@ -2636,12 +2672,35 @@ class PlexClient
|
|||||||
audioStreamId: audioStreamId,
|
audioStreamId: audioStreamId,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||||
|
useTsFallbackTarget: useTsFallbackTarget,
|
||||||
);
|
);
|
||||||
return await _runTranscodeDecision(
|
|
||||||
|
final primary = await _runTranscodeDecision(
|
||||||
startEndpoint: _plexVideoHlsStartEndpoint,
|
startEndpoint: _plexVideoHlsStartEndpoint,
|
||||||
allParams: allParams,
|
allParams: paramsFor(useTsFallbackTarget: false),
|
||||||
isOriginal: preset.isOriginal,
|
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) {
|
} catch (e, st) {
|
||||||
appLogger.e('Failed to build transcode start path', error: e, stackTrace: st);
|
appLogger.e('Failed to build transcode start path', error: e, stackTrace: st);
|
||||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||||
@@ -2899,11 +2958,12 @@ class PlexClient
|
|||||||
sessionIdentifier: sessionIdentifier,
|
sessionIdentifier: sessionIdentifier,
|
||||||
transcodeSessionId: transcodeSessionId,
|
transcodeSessionId: transcodeSessionId,
|
||||||
);
|
);
|
||||||
return await _runTranscodeDecision(
|
final result = await _runTranscodeDecision(
|
||||||
startEndpoint: _musicTranscodeStartEndpoint,
|
startEndpoint: _musicTranscodeStartEndpoint,
|
||||||
allParams: allParams,
|
allParams: allParams,
|
||||||
isOriginal: preset.isOriginal,
|
isOriginal: preset.isOriginal,
|
||||||
);
|
);
|
||||||
|
return (startPath: result.startPath, outcome: result.outcome);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
appLogger.e('Failed to build music transcode start path', error: e, stackTrace: st);
|
appLogger.e('Failed to build music transcode start path', error: e, stackTrace: st);
|
||||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||||
@@ -2917,10 +2977,17 @@ class PlexClient
|
|||||||
/// outcome via [_parseTranscodeDecisionOutcome], and hand back the start
|
/// outcome via [_parseTranscodeDecisionOutcome], and hand back the start
|
||||||
/// path (token stripped) on success. [startEndpoint] includes the container
|
/// path (token stripped) on success. [startEndpoint] includes the container
|
||||||
/// extension (`start.m3u8` / `start.mp3`).
|
/// 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 String startEndpoint,
|
||||||
required Map<String, String> allParams,
|
required Map<String, String> allParams,
|
||||||
required bool isOriginal,
|
required bool isOriginal,
|
||||||
|
String? requiredContainer,
|
||||||
}) async {
|
}) async {
|
||||||
final decisionEndpoint = '${startEndpoint.substring(0, startEndpoint.lastIndexOf('/'))}/decision';
|
final decisionEndpoint = '${startEndpoint.substring(0, startEndpoint.lastIndexOf('/'))}/decision';
|
||||||
|
|
||||||
@@ -2938,15 +3005,41 @@ class PlexClient
|
|||||||
|
|
||||||
if (decisionResponse.statusCode != 200) {
|
if (decisionResponse.statusCode != 200) {
|
||||||
appLogger.w('Transcode decision returned ${decisionResponse.statusCode}');
|
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);
|
final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal);
|
||||||
if (outcome == TranscodeDecisionOutcome.failed) {
|
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(
|
String _buildTranscodeStartPathFromParams(
|
||||||
@@ -2976,10 +3069,12 @@ class PlexClient
|
|||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
Duration? offset,
|
Duration? offset,
|
||||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||||
|
bool useTsFallbackTarget = false,
|
||||||
}) {
|
}) {
|
||||||
final isOriginal = preset.isOriginal;
|
final isOriginal = preset.isOriginal;
|
||||||
final selectedInternalSubtitle = _selectedInternalSubtitleForHls(selectedSubtitleTrack);
|
final selectedInternalSubtitle = _selectedInternalSubtitleForHls(selectedSubtitleTrack);
|
||||||
final clientProfileExtra = _buildPlexHlsClientProfileExtra(
|
final clientProfileExtra = _buildPlexHlsClientProfileExtra(
|
||||||
|
videoTranscodeTarget: useTsFallbackTarget ? _plexHlsVodTsVideoTranscodeTarget : _plexHlsVodVideoTranscodeTarget,
|
||||||
maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null,
|
maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3003,6 +3098,13 @@ class PlexClient
|
|||||||
'location': 'lan',
|
'location': 'lan',
|
||||||
'addDebugOverlay': '0',
|
'addDebugOverlay': '0',
|
||||||
'autoAdjustQuality': '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',
|
'directStreamAudio': '1',
|
||||||
'mediaBufferSize': '102400',
|
'mediaBufferSize': '102400',
|
||||||
'session': transcodeSessionId,
|
'session': transcodeSessionId,
|
||||||
@@ -3045,6 +3147,7 @@ class PlexClient
|
|||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
Duration? offset,
|
Duration? offset,
|
||||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||||
|
bool useTsFallbackTarget = false,
|
||||||
}) {
|
}) {
|
||||||
return _buildTranscodeParams(
|
return _buildTranscodeParams(
|
||||||
ratingKey: ratingKey,
|
ratingKey: ratingKey,
|
||||||
@@ -3056,6 +3159,7 @@ class PlexClient
|
|||||||
audioStreamId: audioStreamId,
|
audioStreamId: audioStreamId,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||||
|
useTsFallbackTarget: useTsFallbackTarget,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -696,7 +696,9 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
|
|||||||
'copyts': '0',
|
'copyts': '0',
|
||||||
'Accept-Language': 'en',
|
'Accept-Language': 'en',
|
||||||
'X-Plex-Session-Identifier': sessionIdentifier,
|
'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-Incomplete-Segments': '1',
|
||||||
'X-Plex-Product': config.product,
|
'X-Plex-Product': config.product,
|
||||||
'X-Plex-Version': config.version,
|
'X-Plex-Version': config.version,
|
||||||
|
|||||||
@@ -134,7 +134,14 @@ void main() {
|
|||||||
expect(liveEdgeUri.queryParameters['protocol'], 'hls');
|
expect(liveEdgeUri.queryParameters['protocol'], 'hls');
|
||||||
expect(liveEdgeUri.queryParameters['X-Plex-Incomplete-Segments'], '1');
|
expect(liveEdgeUri.queryParameters['X-Plex-Incomplete-Segments'], '1');
|
||||||
expect(liveEdgeUri.queryParameters.containsKey('X-Plex-Chunked'), isFalse);
|
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('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['subtitles'], 'none');
|
||||||
expect(liveEdgeUri.queryParameters.containsKey('subtitleStreamID'), isFalse);
|
expect(liveEdgeUri.queryParameters.containsKey('subtitleStreamID'), isFalse);
|
||||||
expect(liveEdgeUri.queryParameters.containsKey('advancedSubtitles'), isFalse);
|
expect(liveEdgeUri.queryParameters.containsKey('advancedSubtitles'), isFalse);
|
||||||
|
|||||||
@@ -97,7 +97,19 @@ void main() {
|
|||||||
decisionUri = request.url;
|
decisionUri = request.url;
|
||||||
return http.Response(
|
return http.Response(
|
||||||
jsonEncode({
|
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,
|
200,
|
||||||
headers: {'content-type': 'application/json'},
|
headers: {'content-type': 'application/json'},
|
||||||
@@ -302,7 +314,17 @@ void main() {
|
|||||||
if (request.url.path == '/video/:/transcode/universal/decision') {
|
if (request.url.path == '/video/:/transcode/universal/decision') {
|
||||||
return http.Response(
|
return http.Response(
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001},
|
'MediaContainer': {
|
||||||
|
'generalDecisionCode': 1001,
|
||||||
|
'transcodeDecisionCode': 1001,
|
||||||
|
'Metadata': [
|
||||||
|
{
|
||||||
|
'Media': [
|
||||||
|
{'container': 'mp4', 'protocol': 'hls', 'selected': true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
200,
|
200,
|
||||||
headers: {'content-type': 'application/json'},
|
headers: {'content-type': 'application/json'},
|
||||||
@@ -871,7 +893,8 @@ void main() {
|
|||||||
profile,
|
profile,
|
||||||
contains(
|
contains(
|
||||||
'add-transcode-target(type=videoProfile&context=streaming'
|
'add-transcode-target(type=videoProfile&context=streaming'
|
||||||
'&protocol=hls&container=mpegts',
|
'&protocol=hls&container=mp4&videoCodec=h264%2Chevc'
|
||||||
|
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
@@ -882,6 +905,161 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(profile, isNot(contains('protocol=http&container=mkv')));
|
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<Uri> decisions})> runGuardedDecision(
|
||||||
|
String Function(int decisionNumber) containerFor,
|
||||||
|
) async {
|
||||||
|
final decisions = <Uri>[];
|
||||||
|
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 = <Uri>[];
|
||||||
|
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', () {
|
test('transcode start path uses the HLS manifest endpoint without token', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user