fix(plex): support no-burn transcode subtitles
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_source_info.dart';
|
||||
|
||||
void main() {
|
||||
group('MediaSubtitleTrack label', () {
|
||||
test('prefers explicit source title over generated display title', () {
|
||||
final track = MediaSubtitleTrack(
|
||||
id: 401,
|
||||
index: 0,
|
||||
codec: 'srt',
|
||||
languageCode: 'eng',
|
||||
title: 'Forced',
|
||||
displayTitle: 'English (SRT)',
|
||||
selected: false,
|
||||
forced: true,
|
||||
);
|
||||
|
||||
expect(track.labelForIndex(0), 'Forced · ENG · SRT');
|
||||
expect(track.label, 'Forced · ENG · SRT');
|
||||
});
|
||||
|
||||
test('falls back to display title when source title is empty', () {
|
||||
final track = MediaSubtitleTrack(
|
||||
id: 402,
|
||||
index: 1,
|
||||
codec: 'ass',
|
||||
languageCode: 'jpn',
|
||||
title: ' ',
|
||||
displayTitle: 'Japanese Signs/Songs',
|
||||
selected: false,
|
||||
forced: false,
|
||||
);
|
||||
|
||||
expect(track.labelForIndex(1), 'Japanese Signs/Songs · JPN · ASS');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -104,6 +104,49 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV maps server-offset streams to absolute timeline positions', () async {
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
await _withMockChannels(
|
||||
methodChannelName: 'com.plezy/mpv_player',
|
||||
eventChannelName: 'com.plezy/mpv_player/events',
|
||||
methodHandler: (call) {
|
||||
calls.add(call);
|
||||
switch (call.method) {
|
||||
case 'initialize':
|
||||
return Future.value(true);
|
||||
default:
|
||||
return Future.value(null);
|
||||
}
|
||||
},
|
||||
testBody: () async {
|
||||
final player = PlayerNative();
|
||||
try {
|
||||
await player.open(
|
||||
Media('https://example.test/transcode.mkv'),
|
||||
timelineOffset: const Duration(seconds: 10),
|
||||
timelineDuration: const Duration(seconds: 100),
|
||||
);
|
||||
|
||||
expect(player.state.position, const Duration(seconds: 10));
|
||||
expect(player.state.duration, const Duration(seconds: 100));
|
||||
|
||||
player.handlePropertyChange('duration', 90.0);
|
||||
expect(player.state.duration, const Duration(seconds: 100));
|
||||
|
||||
await player.seek(const Duration(seconds: 25));
|
||||
|
||||
final seekCall = calls.lastWhere((call) => call.method == 'command');
|
||||
final args = Map<Object?, Object?>.from(seekCall.arguments as Map)['args'] as List;
|
||||
expect(args, ['seek', '15.0', 'absolute']);
|
||||
expect(player.state.position, const Duration(seconds: 25));
|
||||
} finally {
|
||||
await player.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/media/media_source_info.dart';
|
||||
import 'package:plezy/mpv/mpv.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/models/transcode_quality_preset.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
PlexApiCache.initialize(db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) {
|
||||
return PlexClient.forTesting(
|
||||
config: PlexConfig(
|
||||
baseUrl: 'https://plex.example.com',
|
||||
token: 'token',
|
||||
clientIdentifier: 'client-id',
|
||||
product: 'Plezy',
|
||||
version: '1',
|
||||
),
|
||||
serverId: 'server-id',
|
||||
httpClient: MockClient(handler),
|
||||
);
|
||||
}
|
||||
|
||||
MediaSourceInfo mediaInfoWithSubtitles(List<MediaSubtitleTrack> subtitleTracks) {
|
||||
return MediaSourceInfo(
|
||||
videoUrl: 'https://plex.example.com/video.mkv',
|
||||
audioTracks: const [],
|
||||
subtitleTracks: subtitleTracks,
|
||||
chapters: const [],
|
||||
);
|
||||
}
|
||||
|
||||
List<SubtitleTrack> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
|
||||
return client.buildTranscodeSidecarSubtitlesForTesting(mediaInfoWithSubtitles(subtitleTracks));
|
||||
}
|
||||
|
||||
test('playback metadata request includes streams for transcode sidecar subtitles', () async {
|
||||
final requests = <Uri>[];
|
||||
final client = makeClient((request) async {
|
||||
requests.add(request.url);
|
||||
if (request.url.path != '/library/metadata/42') {
|
||||
return http.Response('not found', 404);
|
||||
}
|
||||
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'Metadata': [
|
||||
{
|
||||
'ratingKey': '42',
|
||||
'type': 'movie',
|
||||
'title': 'Movie',
|
||||
'Media': [
|
||||
{
|
||||
'id': 7,
|
||||
'container': 'mkv',
|
||||
'Part': [
|
||||
{
|
||||
'id': 99,
|
||||
'key': '/library/parts/99/file.mkv',
|
||||
'Stream': [
|
||||
{'streamType': 1, 'id': 300, 'codec': 'h264'},
|
||||
{'streamType': 2, 'id': 301, 'index': 0, 'languageCode': 'jpn', 'selected': true},
|
||||
{
|
||||
'streamType': 3,
|
||||
'id': 401,
|
||||
'index': 1,
|
||||
'codec': 'ass',
|
||||
'language': 'English',
|
||||
'languageCode': 'eng',
|
||||
'title': 'Signs/Songs',
|
||||
'selected': true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final data = await client.getVideoPlaybackData('42');
|
||||
|
||||
expect(requests, hasLength(1));
|
||||
expect(requests.single.queryParameters['includeStreams'], '1');
|
||||
expect(data.mediaInfo?.subtitleTracks, hasLength(1));
|
||||
expect(data.mediaInfo?.subtitleTracks.single.id, 401);
|
||||
expect(data.mediaInfo?.subtitleTracks.single.selected, isTrue);
|
||||
});
|
||||
|
||||
test('transcode subtitle sidecars only use real Plex stream keys', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final subtitles = buildTranscodeSubtitles(client, [
|
||||
MediaSubtitleTrack(id: 401, codec: 'srt', languageCode: 'eng', selected: false, forced: false),
|
||||
MediaSubtitleTrack(
|
||||
id: 402,
|
||||
codec: 'srt',
|
||||
languageCode: 'eng',
|
||||
selected: false,
|
||||
forced: false,
|
||||
key: '/library/streams/402',
|
||||
external: true,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(subtitles, hasLength(1));
|
||||
expect(subtitles.single.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token');
|
||||
});
|
||||
|
||||
test('selected internal text subtitles are not attached as external sidecars', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
final subtitles = buildTranscodeSubtitles(client, [
|
||||
MediaSubtitleTrack(
|
||||
id: 401,
|
||||
codec: 'ass',
|
||||
language: 'English',
|
||||
languageCode: 'eng',
|
||||
title: 'Signs/Songs',
|
||||
selected: true,
|
||||
forced: false,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(subtitles, isEmpty);
|
||||
});
|
||||
|
||||
test('selected internal text subtitles are embedded in HTTP MKV transcode', () {
|
||||
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',
|
||||
selectedSubtitleTrack: MediaSubtitleTrack(
|
||||
id: 401,
|
||||
codec: 'ass',
|
||||
languageCode: 'eng',
|
||||
selected: true,
|
||||
forced: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(params['protocol'], 'http');
|
||||
expect(params['subtitles'], 'embedded');
|
||||
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['X-Plex-Client-Profile-Extra'],
|
||||
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*)',
|
||||
),
|
||||
);
|
||||
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('protocol=hls')));
|
||||
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile')));
|
||||
});
|
||||
|
||||
test('transcode start path uses HTTP start endpoint without token', () {
|
||||
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',
|
||||
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, isNot(contains('X-Plex-Token')));
|
||||
});
|
||||
|
||||
test('unsupported embedded subtitles keep main transcode subtitles disabled', () {
|
||||
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',
|
||||
selectedSubtitleTrack: MediaSubtitleTrack(
|
||||
id: 401,
|
||||
codec: 'pgs',
|
||||
languageCode: 'eng',
|
||||
selected: true,
|
||||
forced: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(params['subtitles'], 'none');
|
||||
expect(params['protocol'], 'http');
|
||||
expect(params.containsKey('subtitleStreamID'), isFalse);
|
||||
expect(params.containsKey('advancedSubtitles'), isFalse);
|
||||
expect(params['X-Plex-Client-Profile-Extra'], isNot(contains('type=subtitleProfile')));
|
||||
});
|
||||
|
||||
test('bitmap embedded subtitles are skipped during transcode instead of burned', () {
|
||||
final client = makeClient((_) async => http.Response('not used', 500));
|
||||
addTearDown(client.close);
|
||||
|
||||
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),
|
||||
]);
|
||||
|
||||
expect(subtitles, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -225,6 +225,20 @@ void main() {
|
||||
expect(player.addSubtitleCalls.single.uri, 'https://example/c.srt');
|
||||
});
|
||||
|
||||
test('selects subtitle sidecars marked as default', () async {
|
||||
final player = _FakePlayer();
|
||||
final mgr = _make(player: player);
|
||||
addTearDown(mgr.dispose);
|
||||
|
||||
const subs = [
|
||||
SubtitleTrack(id: 'selected', uri: 'https://example/selected.srt', isExternal: true, isDefault: true),
|
||||
];
|
||||
await mgr.addExternalSubtitles(subs);
|
||||
|
||||
expect(player.addSubtitleCalls, hasLength(1));
|
||||
expect(player.addSubtitleCalls.single.select, isTrue);
|
||||
});
|
||||
|
||||
test('a player error on one entry does not prevent others from succeeding', () async {
|
||||
final player = _FakePlayer()..failAddSubtitleTimes = 1;
|
||||
final mgr = _make(player: player);
|
||||
|
||||
@@ -13,6 +13,7 @@ void main() {
|
||||
test('clears switchable version and quality state during offline playback', () {
|
||||
final version = MediaVersion(id: 'v1', videoResolution: '1080');
|
||||
final audio = MediaAudioTrack(id: 1, languageCode: 'eng', selected: false);
|
||||
final subtitle = MediaSubtitleTrack(id: 2, languageCode: 'eng', selected: false, forced: false);
|
||||
|
||||
final result = effectiveVersionQualityControls(
|
||||
isOfflinePlayback: true,
|
||||
@@ -21,6 +22,8 @@ void main() {
|
||||
isTranscoding: true,
|
||||
sourceAudioTracks: [audio],
|
||||
selectedAudioStreamId: 1,
|
||||
sourceSubtitleTracks: [subtitle],
|
||||
selectedSubtitleStreamId: 2,
|
||||
);
|
||||
|
||||
expect(result.canSwitch, isFalse);
|
||||
@@ -29,11 +32,14 @@ void main() {
|
||||
expect(result.isTranscoding, isFalse);
|
||||
expect(result.sourceAudioTracks, isEmpty);
|
||||
expect(result.selectedAudioStreamId, isNull);
|
||||
expect(result.sourceSubtitleTracks, isEmpty);
|
||||
expect(result.selectedSubtitleStreamId, isNull);
|
||||
});
|
||||
|
||||
test('keeps switchable state during online playback', () {
|
||||
final version = MediaVersion(id: 'v1', videoResolution: '1080');
|
||||
final audio = MediaAudioTrack(id: 1, languageCode: 'eng', selected: false);
|
||||
final subtitle = MediaSubtitleTrack(id: 2, languageCode: 'eng', selected: false, forced: false);
|
||||
|
||||
final result = effectiveVersionQualityControls(
|
||||
isOfflinePlayback: false,
|
||||
@@ -42,6 +48,8 @@ void main() {
|
||||
isTranscoding: true,
|
||||
sourceAudioTracks: [audio],
|
||||
selectedAudioStreamId: 1,
|
||||
sourceSubtitleTracks: [subtitle],
|
||||
selectedSubtitleStreamId: 2,
|
||||
);
|
||||
|
||||
expect(result.canSwitch, isTrue);
|
||||
@@ -50,6 +58,8 @@ void main() {
|
||||
expect(result.isTranscoding, isTrue);
|
||||
expect(result.sourceAudioTracks, [audio]);
|
||||
expect(result.selectedAudioStreamId, 1);
|
||||
expect(result.sourceSubtitleTracks, [subtitle]);
|
||||
expect(result.selectedSubtitleStreamId, 2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user