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),
+4 -4
View File
@@ -75,12 +75,12 @@ void main() {
}
});
test('isEmbeddableSubtitleCodec covers text and image, not unknown', () {
test('isTranscodableSubtitleCodec covers text and image, not unknown', () {
for (final codec in ['srt', 'ass', 'pgs', 'vobsub', 'dvd_subtitle']) {
expect(CodecUtils.isEmbeddableSubtitleCodec(codec), isTrue, reason: codec);
expect(CodecUtils.isTranscodableSubtitleCodec(codec), isTrue, reason: codec);
}
expect(CodecUtils.isEmbeddableSubtitleCodec('weird'), isFalse);
expect(CodecUtils.isEmbeddableSubtitleCodec(null), isFalse);
expect(CodecUtils.isTranscodableSubtitleCodec('weird'), isFalse);
expect(CodecUtils.isTranscodableSubtitleCodec(null), isFalse);
});
});
+1 -120
View File
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/mpv.dart' show BufferRange, Player, PlayerState;
import 'package:plezy/mpv/mpv.dart' show Player, PlayerState;
import 'package:plezy/utils/player_utils.dart';
void main() {
@@ -33,125 +33,6 @@ void main() {
expect(clampSeekPosition(player, const Duration(minutes: 6)), const Duration(minutes: 6));
});
});
group('resolvePlexTranscodeSeekAction', () {
test('uses native seek for backward targets inside a local buffer range', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(seconds: 20),
bufferRanges: const [BufferRange(start: Duration(seconds: 10), end: Duration(seconds: 50))],
),
PlexTranscodeSeekAction.nativeSeek,
);
});
test('restarts for backward targets outside local buffer ranges', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(seconds: 20),
bufferRanges: const [BufferRange(start: Duration(seconds: 25), end: Duration(seconds: 50))],
),
PlexTranscodeSeekAction.restartTranscode,
);
});
test('uses native seek for tiny backward or no-op seeks inside the deadband', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(milliseconds: 29500),
bufferRanges: const [],
),
PlexTranscodeSeekAction.nativeSeek,
);
});
test('uses native seek when the target is inside the local buffer range', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(seconds: 40),
bufferRanges: const [BufferRange(start: Duration(seconds: 30), end: Duration(seconds: 50))],
),
PlexTranscodeSeekAction.nativeSeek,
);
});
test('restarts when buffered native seeks are disabled for the active backend', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(seconds: 40),
bufferRanges: const [BufferRange(start: Duration(seconds: 30), end: Duration(seconds: 50))],
allowBufferedNativeSeek: false,
),
PlexTranscodeSeekAction.restartTranscode,
);
});
test('restarts near the tail of a buffer range to avoid optimistic cache edges', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(milliseconds: 49600),
bufferRanges: const [BufferRange(start: Duration(seconds: 30), end: Duration(seconds: 50))],
),
PlexTranscodeSeekAction.restartTranscode,
);
});
test('restarts when a forward target is outside local buffer ranges', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(seconds: 58),
bufferRanges: const [BufferRange(start: Duration(seconds: 30), end: Duration(seconds: 50))],
),
PlexTranscodeSeekAction.restartTranscode,
);
});
test('uses native seek when the target is inside a later cached range', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 5),
target: const Duration(seconds: 33),
bufferRanges: const [
BufferRange(start: Duration(seconds: 0), end: Duration(seconds: 10)),
BufferRange(start: Duration(seconds: 30), end: Duration(seconds: 35)),
],
),
PlexTranscodeSeekAction.nativeSeek,
);
});
test('restarts for gaps far beyond the active range even when a later range exists', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 5),
target: const Duration(seconds: 25),
bufferRanges: const [
BufferRange(start: Duration(seconds: 0), end: Duration(seconds: 10)),
BufferRange(start: Duration(seconds: 40), end: Duration(seconds: 50)),
],
),
PlexTranscodeSeekAction.restartTranscode,
);
});
test('restarts large seeks when no buffer information exists', () {
expect(
resolvePlexTranscodeSeekAction(
currentPosition: const Duration(seconds: 30),
target: const Duration(seconds: 35),
bufferRanges: const [],
),
PlexTranscodeSeekAction.restartTranscode,
);
});
});
}
class _FakePlayer implements Player {
+2 -19
View File
@@ -1192,10 +1192,9 @@ void main() {
});
group('shouldSkipDuplicateTimelineSeek', () {
test('skips a matching non-transcode final seek', () {
test('skips a matching final seek', () {
expect(
shouldSkipDuplicateTimelineSeek(
isTranscoding: false,
lastDispatchedSeek: const Duration(minutes: 7, seconds: 30),
finalSeek: const Duration(minutes: 7, seconds: 30),
),
@@ -1203,32 +1202,16 @@ void main() {
);
});
test('does not skip matching transcode seek', () {
expect(
shouldSkipDuplicateTimelineSeek(
isTranscoding: true,
lastDispatchedSeek: const Duration(minutes: 7, seconds: 30),
finalSeek: const Duration(minutes: 7, seconds: 30),
),
isFalse,
);
});
test('does not skip when no matching seek was already dispatched', () {
expect(
shouldSkipDuplicateTimelineSeek(
isTranscoding: false,
lastDispatchedSeek: const Duration(minutes: 7),
finalSeek: const Duration(minutes: 7, seconds: 30),
),
isFalse,
);
expect(
shouldSkipDuplicateTimelineSeek(
isTranscoding: false,
lastDispatchedSeek: null,
finalSeek: const Duration(minutes: 7, seconds: 30),
),
shouldSkipDuplicateTimelineSeek(lastDispatchedSeek: null, finalSeek: const Duration(minutes: 7, seconds: 30)),
isFalse,
);
});