feat(plex): migrate video transcoding to HLS
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user