feat(plex): migrate video transcoding to HLS

This commit is contained in:
edde746
2026-07-14 18:11:04 +02:00
parent 9d812fa3cc
commit abf1027b77
17 changed files with 201 additions and 550 deletions
@@ -88,7 +88,9 @@ void main() {
final requests = <String>[];
final client = makeClient((request) async {
requests.add(request.url.path);
if (request.url.path.endsWith('/tune')) return jsonResponse(tuneResponse());
if (request.url.path.endsWith('/tune')) {
return jsonResponse(tuneResponse());
}
return jsonResponse(const {});
});
addTearDown(client.close);
@@ -106,10 +108,14 @@ void main() {
expect(requests, ['/livetv/dvrs/dvr-1/channels/ch-1/tune']);
});
test('streamUrlAt builds live-edge and offset URLs against one transcode session', () async {
test('streamUrlAt builds live-edge and offset HLS URLs against one transcode session', () 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);
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);
@@ -121,8 +127,12 @@ void main() {
expect(liveEdge, isNotNull);
final liveEdgeUri = Uri.parse(liveEdge!);
expect(liveEdgeUri.path, '/video/:/transcode/universal/start');
expect(liveEdgeUri.path, '/video/:/transcode/universal/start.m3u8');
expect(liveEdgeUri.queryParameters['path'], '/livetv/sessions/session-abc');
expect(liveEdgeUri.queryParameters['protocol'], 'hls');
expect(liveEdgeUri.queryParameters['X-Plex-Incomplete-Segments'], '1');
expect(liveEdgeUri.queryParameters.containsKey('X-Plex-Chunked'), isFalse);
expect(liveEdgeUri.queryParameters['X-Plex-Client-Profile-Extra'], contains('protocol=hls&container=mpegts'));
expect(liveEdgeUri.queryParameters['X-Plex-Token'], 'tok');
expect(liveEdgeUri.queryParameters.containsKey('offset'), isFalse);
@@ -136,7 +146,9 @@ void main() {
test('reportTimeline targets the tuned program and grows duration to the position', () async {
Map<String, String>? timelineQuery;
final client = makeClient((request) async {
if (request.url.path.endsWith('/tune')) return jsonResponse(tuneResponse());
if (request.url.path.endsWith('/tune')) {
return jsonResponse(tuneResponse());
}
if (request.url.path == '/:/timeline') {
timelineQuery = request.url.queryParameters;
return jsonResponse({
@@ -169,7 +181,9 @@ void main() {
final requests = <Uri>[];
final client = makeClient((request) async {
requests.add(request.url);
if (request.url.path.endsWith('/tune')) return jsonResponse(tuneResponse());
if (request.url.path.endsWith('/tune')) {
return jsonResponse(tuneResponse());
}
if (request.url.path == '/:/timeline') {
throw http.ClientException('temporary timeline DNS failure', request.url);
}
@@ -194,7 +208,9 @@ void main() {
tunes++;
return jsonResponse(tuneResponse());
}
if (request.url.path == '/video/:/transcode/universal/decision') return http.Response('ok', 200);
if (request.url.path == '/video/:/transcode/universal/decision') {
return http.Response('ok', 200);
}
return jsonResponse(const {});
});
addTearDown(client.close);
@@ -365,7 +365,7 @@ void main() {
expect(subtitles, isEmpty);
});
test('selected internal text subtitles are embedded in HTTP MKV transcode', () {
test('selected internal text subtitles are segmented into the HLS transcode', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
@@ -384,33 +384,42 @@ void main() {
),
);
expect(params['protocol'], 'http');
expect(params['subtitles'], 'embedded');
expect(params['protocol'], 'hls');
expect(params['subtitles'], 'segmented');
expect(params['subtitleStreamID'], '401');
expect(params['advancedSubtitles'], 'text');
expect(params['X-Plex-Chunked'], '1');
expect(params.containsKey('X-Plex-Incomplete-Segments'), isFalse);
expect(params['X-Plex-Client-Profile-Extra'], contains('add-settings(DirectPlayStreamSelection=true)'));
expect(params.containsKey('X-Plex-Chunked'), isFalse);
expect(params['X-Plex-Incomplete-Segments'], '1');
expect(params['X-Plex-Client-Profile-Name'], 'Generic');
final profile = params['X-Plex-Client-Profile-Extra'];
expect(profile, contains('add-settings(DirectPlayStreamSelection=true)'));
expect(
params['X-Plex-Client-Profile-Extra'],
profile,
contains(
'add-limitation(scope=videoCodec&scopeName=*&type=upperBound'
'&name=video.bitrate&value=3000&replace=true)',
),
);
expect(
profile,
contains(
'add-transcode-target(type=videoProfile&context=streaming'
'&protocol=http&container=mkv&videoCodec=h264%2Chevc%2C*'
'&audioCodec=opus%2Cvorbis%2Cflac%2C*&subtitleCodec=ass%2Cpgs%2Cvobsub%2C*)',
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video'
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)',
),
);
expect(
params['X-Plex-Client-Profile-Extra'],
profile,
contains(
'add-transcode-target-settings(type=videoProfile&context=streaming'
'&protocol=http&CopyMatroskaAttachments=true)',
'add-transcode-target(type=subtitleProfile&context=streaming'
'&protocol=hls&container=webvtt&subtitleCodec=webvtt)',
),
);
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('protocol=hls')));
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile')));
expect(profile, isNot(contains('protocol=http&container=mkv')));
});
test('transcode start path uses HTTP start endpoint without token', () {
test('transcode start path uses the HLS manifest endpoint without token', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
@@ -420,16 +429,13 @@ void main() {
preset: TranscodeQualityPreset.p720_3mbps,
sessionIdentifier: 'session-id',
transcodeSessionId: 'transcode-id',
offsetMs: 90500,
);
final startPath = client.buildTranscodeStartPathFromParamsForTesting(params);
expect(params['offset'], '90');
expect(startPath, startsWith('/video/:/transcode/universal/start?'));
expect(startPath, isNot(contains('start.m3u8')));
expect(startPath, contains('protocol=http'));
expect(startPath, contains('offset=90'));
expect(startPath, startsWith('/video/:/transcode/universal/start.m3u8?'));
expect(startPath, contains('protocol=hls'));
expect(startPath, isNot(contains('offset=')));
expect(startPath, isNot(contains('X-Plex-Token')));
});
@@ -450,7 +456,7 @@ void main() {
expect(params['partIndex'], '2');
});
test('selected image-based subtitles are embedded in HTTP MKV transcode without advancedSubtitles', () {
test('selected image subtitles are burned into HLS without advancedSubtitles', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
@@ -469,21 +475,18 @@ void main() {
),
);
// PGS is copied into the MKV as a stream; `advancedSubtitles=text` is
// text-only and must be absent so the server doesn't try to convert it.
expect(params['subtitles'], 'embedded');
expect(params['subtitles'], 'burn');
expect(params['subtitleStreamID'], '401');
expect(params['protocol'], 'http');
expect(params['protocol'], 'hls');
expect(params.containsKey('advancedSubtitles'), isFalse);
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile')));
});
test('image-based embedded subtitles are carried in the MKV, not as sidecars', () {
test('image-based embedded subtitles are rendered by HLS, not attached as sidecars', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
// Embedded bitmap streams have no Plex `key`, so there is no sidecar URL to
// build — they ride the main HTTP/MKV stream via `subtitles=embedded`.
// Embedded bitmap streams have no Plex `key`; the HLS request burns the
// selected track into the video rendition.
final subtitles = buildTranscodeSubtitles(client, [
MediaSubtitleTrack(id: 401, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: true, forced: false),