Since a1b6a8971 only the selected sidecar attached at open, so mpv's
track-list carried one external subtitle and the track sheet could only
offer the rest as primary source switches - tap-and-hold on a
non-selected external track selected it as primary instead of secondary.
Real external files are cheap static fetches, so Jellyfin, Plex direct
play, and offline discovery now mark them preload and they ride along in
sub-files at open, keeping every external track selectable as a
secondary subtitle without a reopen. Embedded rows extracted on a
transcode stay lazy: extraction can stall behind the transcoder, which
is exactly what used to trip the sidecar open guard.
close #1860
2011 lines
74 KiB
Dart
2011 lines
74 KiB
Dart
import 'dart:convert';
|
|
import 'package:fake_async/fake_async.dart';
|
|
import 'package:plezy/media/ids.dart';
|
|
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:plezy/database/app_database.dart';
|
|
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
|
import 'package:plezy/utils/media_server_http_client.dart' show AbortController;
|
|
import 'package:plezy/media/media_backend.dart';
|
|
|
|
import 'package:plezy/media/media_kind.dart';
|
|
import 'package:plezy/media/media_source_info.dart';
|
|
import 'package:plezy/mpv/mpv.dart';
|
|
import 'package:plezy/models/transcode_quality_preset.dart';
|
|
import 'package:plezy/services/subtitle_preference.dart';
|
|
import 'package:plezy/services/playback_initialization_types.dart';
|
|
import 'package:plezy/services/plex_api_cache.dart';
|
|
import 'package:plezy/services/plex_client.dart';
|
|
import 'package:plezy/utils/active_client_scope.dart';
|
|
|
|
import '../test_helpers/backend_client_fixtures.dart';
|
|
import '../test_helpers/media_items.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) =>
|
|
testPlexClient(serverId: ServerId('server-id'), handler: handler);
|
|
|
|
Future<({PlaybackInitializationResult result, Uri decisionUri})> initializeTranscodeAudio({
|
|
int? selectedAudioStreamId,
|
|
AudioTrack? preferredAudioTrack,
|
|
}) async {
|
|
late Uri decisionUri;
|
|
final client = makeClient((request) async {
|
|
if (request.url.path == '/library/metadata/42') {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'episode',
|
|
'title': 'Episode',
|
|
'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,
|
|
'codec': 'aac',
|
|
'languageCode': 'eng',
|
|
'title': 'Original',
|
|
'selected': true,
|
|
},
|
|
{
|
|
'streamType': 2,
|
|
'id': 305,
|
|
'index': 1,
|
|
'codec': 'flac',
|
|
'languageCode': 'jpn',
|
|
'title': 'Main',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
if (request.url.path == '/video/:/transcode/universal/decision') {
|
|
decisionUri = request.url;
|
|
return http.Response(
|
|
jsonEncode({
|
|
'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,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
return http.Response('unexpected request', 500);
|
|
});
|
|
try {
|
|
final result = await client.getPlaybackInitialization(
|
|
PlaybackInitializationOptions(
|
|
metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.episode, serverId: 'server-id'),
|
|
selectedMediaIndex: 0,
|
|
selectedAudioStreamId: selectedAudioStreamId,
|
|
preferredAudioTrack: preferredAudioTrack,
|
|
qualityPreset: TranscodeQualityPreset.p720_3mbps,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
),
|
|
);
|
|
return (result: result, decisionUri: decisionUri);
|
|
} finally {
|
|
client.close();
|
|
}
|
|
}
|
|
|
|
MediaSourceInfo mediaInfoWithSubtitles(List<MediaSubtitleTrack> subtitleTracks) {
|
|
return MediaSourceInfo(
|
|
videoUrl: 'https://plex.example.com/video.mkv',
|
|
audioTracks: const [],
|
|
subtitleTracks: subtitleTracks,
|
|
chapters: const [],
|
|
);
|
|
}
|
|
|
|
List<PlaybackSubtitleSidecar> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
|
|
return client.buildTranscodeSidecarSubtitlesForTesting(mediaInfoWithSubtitles(subtitleTracks));
|
|
}
|
|
|
|
test('selectStreams sends audio stream selection with allParts', () async {
|
|
final requests = <http.Request>[];
|
|
final client = makeClient((request) async {
|
|
requests.add(request);
|
|
return http.Response('', 200);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final saved = await client.selectStreams(99, audioStreamID: 301, allParts: true);
|
|
|
|
expect(saved, isTrue);
|
|
expect(requests, hasLength(1));
|
|
expect(requests.single.method, 'PUT');
|
|
expect(requests.single.url.path, '/library/parts/99');
|
|
expect(requests.single.url.queryParameters['audioStreamID'], '301');
|
|
expect(requests.single.url.queryParameters['allParts'], '1');
|
|
});
|
|
|
|
test('semantic carried audio is sent to the Plex transcode decision', () async {
|
|
final initialized = await initializeTranscodeAudio(
|
|
preferredAudioTrack: const AudioTrack(id: 'source:999', language: 'jpn', title: 'Main', codec: 'flac'),
|
|
);
|
|
|
|
expect(initialized.decisionUri.queryParameters['audioStreamID'], '305');
|
|
expect(initialized.result.activeAudioStreamId, 305);
|
|
});
|
|
|
|
test('explicit Plex audio stream wins over a conflicting semantic carry', () async {
|
|
final initialized = await initializeTranscodeAudio(
|
|
selectedAudioStreamId: 301,
|
|
preferredAudioTrack: const AudioTrack(id: 'source:999', language: 'jpn', title: 'Main', codec: 'flac'),
|
|
);
|
|
|
|
expect(initialized.decisionUri.queryParameters['audioStreamID'], '301');
|
|
expect(initialized.result.activeAudioStreamId, 301);
|
|
});
|
|
|
|
test('unresolvable semantic audio carry lets the Plex transcoder choose the stream', () async {
|
|
final initialized = await initializeTranscodeAudio(
|
|
preferredAudioTrack: const AudioTrack(id: 'source:999', language: 'swe'),
|
|
);
|
|
|
|
expect(initialized.decisionUri.queryParameters.containsKey('audioStreamID'), isFalse);
|
|
expect(initialized.result.activeAudioStreamId, isNull);
|
|
});
|
|
|
|
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(requests.single.queryParameters['checkFiles'], '1');
|
|
expect(requests.single.queryParameters.containsKey('checkFileAvailability'), isFalse);
|
|
expect(data.mediaInfo?.subtitleTracks, hasLength(1));
|
|
expect(data.mediaInfo?.subtitleTracks.single.id, 401);
|
|
expect(data.mediaInfo?.subtitleTracks.single.selected, isTrue);
|
|
});
|
|
|
|
test('transcode initialization burns the selected embedded stream and sidecars only the external file', () async {
|
|
final requests = <http.Request>[];
|
|
final client = makeClient((request) async {
|
|
requests.add(request);
|
|
if (request.url.path == '/library/metadata/42') {
|
|
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',
|
|
'languageCode': 'eng',
|
|
'selected': true,
|
|
},
|
|
{
|
|
'streamType': 3,
|
|
'id': 402,
|
|
'index': 2,
|
|
'codec': 'srt',
|
|
'languageCode': 'swe',
|
|
'key': '/library/streams/402',
|
|
'external': true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
if (request.url.path == '/video/:/transcode/universal/decision') {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'MediaContainer': {
|
|
'generalDecisionCode': 1001,
|
|
'transcodeDecisionCode': 1001,
|
|
'Metadata': [
|
|
{
|
|
'Media': [
|
|
{'container': 'mp4', 'protocol': 'hls', 'selected': true},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
if (request.method == 'PUT' && request.url.path == '/library/parts/99') {
|
|
return http.Response('', 200);
|
|
}
|
|
return http.Response('unexpected request', 500);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final result = await client.getPlaybackInitialization(
|
|
PlaybackInitializationOptions(
|
|
metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
|
|
selectedMediaIndex: 0,
|
|
qualityPreset: TranscodeQualityPreset.p720_3mbps,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
transcodeOffset: const Duration(minutes: 8, seconds: 15),
|
|
),
|
|
);
|
|
|
|
final decisionRequest = requests.singleWhere(
|
|
(request) => request.url.path == '/video/:/transcode/universal/decision',
|
|
);
|
|
expect(decisionRequest.url.queryParameters['subtitles'], 'burn');
|
|
expect(decisionRequest.url.queryParameters['offset'], '495.000000');
|
|
expect(
|
|
decisionRequest.url.queryParameters.containsKey('subtitleStreamID'),
|
|
isFalse,
|
|
reason: 'the universal transcoder ignores it; the part selection below is what picks the stream',
|
|
);
|
|
expect(decisionRequest.url.queryParameters.containsKey('advancedSubtitles'), isFalse);
|
|
expect(decisionRequest.url.queryParameters['X-Plex-Incomplete-Segments'], '1');
|
|
|
|
// What actually aims the burn: the embedded ASS track is selected on the
|
|
// part first, so the server burns 401 rather than whatever it had stored.
|
|
final selection = requests.singleWhere(
|
|
(request) => request.method == 'PUT' && request.url.path == '/library/parts/99',
|
|
);
|
|
expect(selection.url.queryParameters['subtitleStreamID'], '401');
|
|
expect(selection.url.queryParameters.containsKey('audioStreamID'), isFalse);
|
|
expect(result.isTranscoding, isTrue);
|
|
expect(result.videoUrl, contains('/video/:/transcode/universal/start.m3u8?'));
|
|
expect(Uri.parse(result.videoUrl!).queryParameters['offset'], '495.000000');
|
|
expect(PlexClient.transcodeStreamOffsetFromUrl(result.videoUrl!), const Duration(minutes: 8, seconds: 15));
|
|
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [402]);
|
|
expect(result.subtitleSidecars.single.preload, isTrue);
|
|
expect(result.subtitleSidecars.single.track.uri, contains('/library/streams/402.srt'));
|
|
});
|
|
|
|
test('direct play preloads every external subtitle file, not just the selected one', () async {
|
|
// Two sidecar files next to the video: only one is selected, but both must
|
|
// load with the media so the other stays selectable as a secondary
|
|
// subtitle without a reopen (#1860). The embedded row is the container's
|
|
// job on direct play and gets no sidecar.
|
|
final client = makeClient((request) async {
|
|
if (request.url.path == '/library/metadata/42') {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'title': 'Movie',
|
|
'Media': [
|
|
{
|
|
'id': 7,
|
|
'container': 'mp4',
|
|
'Part': [
|
|
{
|
|
'id': 99,
|
|
'key': '/library/parts/99/file.mp4',
|
|
'Stream': [
|
|
{'streamType': 1, 'id': 300, 'codec': 'h264'},
|
|
{'streamType': 2, 'id': 301, 'index': 0, 'languageCode': 'eng', 'selected': true},
|
|
{
|
|
'streamType': 3,
|
|
'id': 401,
|
|
'index': 1,
|
|
'codec': 'srt',
|
|
'languageCode': 'deu',
|
|
'key': '/library/streams/401',
|
|
'external': true,
|
|
'selected': true,
|
|
},
|
|
{
|
|
'streamType': 3,
|
|
'id': 402,
|
|
'index': 2,
|
|
'codec': 'srt',
|
|
'languageCode': 'fra',
|
|
'key': '/library/streams/402',
|
|
'external': true,
|
|
},
|
|
{'streamType': 3, 'id': 403, 'index': 3, 'codec': 'ass', 'languageCode': 'eng'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
return http.Response('unexpected request', 500);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final result = await client.getPlaybackInitialization(
|
|
PlaybackInitializationOptions(
|
|
metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
|
|
selectedMediaIndex: 0,
|
|
),
|
|
);
|
|
|
|
expect(result.playMethod, 'DirectPlay');
|
|
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [401, 402]);
|
|
expect(result.subtitleSidecars.map((sidecar) => sidecar.preload), everyElement(isTrue));
|
|
});
|
|
|
|
test('playback uses metadata availability flags without probing part URLs', () async {
|
|
final requests = <http.Request>[];
|
|
final client = makeClient((request) async {
|
|
requests.add(request);
|
|
if (request.url.path != '/library/metadata/42') {
|
|
return http.Response('unexpected request', 500);
|
|
}
|
|
|
|
return http.Response(
|
|
jsonEncode({
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'title': 'Movie',
|
|
'Media': [
|
|
{
|
|
'id': 7,
|
|
'container': 'mkv',
|
|
'Part': [
|
|
{'id': 10, 'key': '/library/parts/10/file.mkv', 'exists': 0, 'accessible': 1},
|
|
{'id': 20, 'key': '/library/parts/20/file.mkv', 'exists': 1, 'accessible': 1},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final data = await client.getVideoPlaybackData('42');
|
|
|
|
expect(requests, hasLength(1));
|
|
expect(requests.single.url.queryParameters['checkFiles'], '1');
|
|
expect(requests.single.url.queryParameters.containsKey('checkFileAvailability'), isFalse);
|
|
expect(data.videoUrl, 'https://plex.example.com/library/parts/20/file.mkv?X-Plex-Token=token');
|
|
expect(data.selectedMediaIndex, 0);
|
|
expect(data.selectedPartIndex, 1);
|
|
});
|
|
|
|
test('latest server metadata overwrites cached playback media fields', () async {
|
|
final cache = PlexApiCache.instance;
|
|
await cache.put(ServerId('server-id'), '/library/metadata/42', {
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'title': 'Playback title',
|
|
'Media': [
|
|
{
|
|
'id': 7,
|
|
'Part': [
|
|
{
|
|
'id': 99,
|
|
'key': '/library/parts/99/file.mkv',
|
|
'exists': true,
|
|
'accessible': true,
|
|
'Stream': [
|
|
{'streamType': 1, 'id': 300, 'codec': 'h264'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await cache.put(ServerId('server-id'), '/library/metadata/42', {
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'title': 'Detail title',
|
|
'Media': [
|
|
{
|
|
'id': 7,
|
|
'Part': [
|
|
{'id': 99, 'key': '/library/parts/99/weak.mkv'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
final cached = await cache.get(ServerId('server-id'), '/library/metadata/42');
|
|
final metadata = (cached!['MediaContainer'] as Map<String, dynamic>)['Metadata'] as List<dynamic>;
|
|
final item = metadata.single as Map<String, dynamic>;
|
|
final media = item['Media'] as List<dynamic>;
|
|
final part = ((media.single as Map<String, dynamic>)['Part'] as List<dynamic>).single as Map<String, dynamic>;
|
|
|
|
expect(item['title'], 'Detail title');
|
|
expect(part['key'], '/library/parts/99/weak.mkv');
|
|
expect(part.containsKey('exists'), isFalse);
|
|
expect(part.containsKey('accessible'), isFalse);
|
|
expect(part.containsKey('Stream'), isFalse);
|
|
});
|
|
|
|
test('network failure falls back to profile-scoped lean cached playback metadata', () async {
|
|
await PlexApiCache.instance.put(
|
|
buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile').cacheServerId,
|
|
'/library/metadata/42',
|
|
{
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'title': 'Movie',
|
|
'Media': [
|
|
{
|
|
'id': 7,
|
|
'Part': [
|
|
{'id': 10, 'key': '/library/parts/10/stale.mkv'},
|
|
],
|
|
},
|
|
{
|
|
'id': 8,
|
|
'Part': [
|
|
{'id': 20, 'key': '/library/parts/20/current.mkv'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
);
|
|
final requests = <http.Request>[];
|
|
final client = makeClient((request) async {
|
|
requests.add(request);
|
|
throw Exception('offline');
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final data = await client.getVideoPlaybackData('42');
|
|
|
|
expect(requests, hasLength(1));
|
|
expect(data.videoUrl, 'https://plex.example.com/library/parts/10/stale.mkv?X-Plex-Token=token');
|
|
expect(data.availableVersions, hasLength(2));
|
|
});
|
|
|
|
test('playback initialization exposes effective selected media index', () async {
|
|
final client = makeClient((request) async {
|
|
if (request.url.path != '/library/metadata/42') {
|
|
return http.Response('unexpected request', 500);
|
|
}
|
|
|
|
return http.Response(
|
|
jsonEncode({
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'title': 'Movie',
|
|
'Media': [
|
|
{
|
|
'id': 7,
|
|
'Part': [
|
|
{'id': 10, 'key': '/library/parts/10/stale.mkv', 'exists': false, 'accessible': false},
|
|
],
|
|
},
|
|
{
|
|
'id': 8,
|
|
'Part': [
|
|
{'id': 20, 'key': '/library/parts/20/current.mkv', 'exists': true, 'accessible': true},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final result = await client.getPlaybackInitialization(
|
|
PlaybackInitializationOptions(
|
|
metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
|
|
selectedMediaIndex: 0,
|
|
),
|
|
);
|
|
|
|
expect(result.videoUrl, 'https://plex.example.com/library/parts/20/current.mkv?X-Plex-Token=token');
|
|
expect(result.selectedMediaIndex, 1);
|
|
});
|
|
|
|
test('transcode subtitle catalog carries only real external subtitle files', () {
|
|
// Embedded streams are burned into the picture by the transcoder. Handing them
|
|
// back as sidecars on the source container would make the client range-read the
|
|
// whole remux over HTTP while the transcode is already streaming (#1815, #1738).
|
|
final client = makeClient((_) async => http.Response('not used', 500));
|
|
addTearDown(client.close);
|
|
|
|
final subtitles = buildTranscodeSubtitles(client, [
|
|
MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', languageCode: 'eng', selected: true, forced: false),
|
|
MediaSubtitleTrack(id: 402, index: 4, codec: 'pgs', languageCode: 'eng', selected: false, forced: false),
|
|
MediaSubtitleTrack(
|
|
id: 403,
|
|
index: 5,
|
|
codec: 'srt',
|
|
languageCode: 'swe',
|
|
title: 'External',
|
|
selected: false,
|
|
forced: false,
|
|
key: '/library/streams/403',
|
|
external: true,
|
|
),
|
|
]);
|
|
|
|
expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [403]);
|
|
expect(subtitles.single.preload, isTrue);
|
|
expect(subtitles.single.track.isContainer, isFalse);
|
|
expect(
|
|
subtitles.single.track.uri,
|
|
'https://plex.example.com/library/streams/403.srt?encoding=utf-8&X-Plex-Token=token',
|
|
);
|
|
});
|
|
|
|
test('tokenless transcode still sidecars the external subtitle file', () {
|
|
final client = testPlexClient(
|
|
serverId: ServerId('server-id'),
|
|
token: null,
|
|
handler: (_) async => http.Response('not used', 500),
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
final subtitles = client.buildTranscodeSidecarSubtitlesForTesting(
|
|
mediaInfoWithSubtitles([
|
|
MediaSubtitleTrack(id: 401, codec: 'ass', languageCode: 'eng', selected: true, forced: false),
|
|
MediaSubtitleTrack(
|
|
id: 402,
|
|
codec: 'srt',
|
|
languageCode: 'swe',
|
|
selected: false,
|
|
forced: false,
|
|
key: '/library/streams/402',
|
|
external: true,
|
|
),
|
|
]),
|
|
);
|
|
|
|
expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [402]);
|
|
expect(subtitles.single.track.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8');
|
|
});
|
|
|
|
test('transcode burns the selected embedded stream whatever its format', () {
|
|
// Burn covers text as well as image: it is the one mechanism that keeps ASS/SSA
|
|
// styling intact through an HLS transcode.
|
|
final client = makeClient((_) async => http.Response('not used', 500));
|
|
addTearDown(client.close);
|
|
|
|
Map<String, String> paramsForSelected(MediaSubtitleTrack track) => client.buildTranscodeParamsForTesting(
|
|
ratingKey: '42',
|
|
mediaIndex: 0,
|
|
preset: TranscodeQualityPreset.p720_3mbps,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
selectedSubtitleTrack: track,
|
|
);
|
|
|
|
final textParams = paramsForSelected(
|
|
MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', languageCode: 'eng', selected: true, forced: false),
|
|
);
|
|
expect(textParams['subtitles'], 'burn');
|
|
expect(textParams.containsKey('subtitleStreamID'), isFalse);
|
|
expect(textParams.containsKey('advancedSubtitles'), isFalse);
|
|
|
|
final imageParams = paramsForSelected(
|
|
MediaSubtitleTrack(id: 402, index: 4, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
|
|
);
|
|
expect(imageParams['subtitles'], 'burn');
|
|
expect(imageParams.containsKey('subtitleStreamID'), isFalse);
|
|
});
|
|
|
|
test('a burn aims at the caller track by selecting it on the part first', () async {
|
|
// The universal transcoder burns whatever the part has selected, so this
|
|
// PUT is the only thing that makes `subtitles=burn` hit the chosen stream.
|
|
final requests = <http.Request>[];
|
|
final client = makeClient((request) async {
|
|
requests.add(request);
|
|
return http.Response('', 200);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
await client.selectSubtitleStreamForBurn(
|
|
partId: 99,
|
|
track: MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', selected: true, forced: false),
|
|
);
|
|
|
|
final put = requests.singleWhere((request) => request.method == 'PUT');
|
|
expect(put.url.path, '/library/parts/99');
|
|
expect(put.url.queryParameters['subtitleStreamID'], '401');
|
|
});
|
|
|
|
test('a sidecarred external file leaves the server selection untouched', () async {
|
|
// External files are fetched directly and never burned; rewriting the
|
|
// part's selection here would change what other Plex clients see.
|
|
final requests = <http.Request>[];
|
|
final client = makeClient((request) async {
|
|
requests.add(request);
|
|
return http.Response('', 200);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
await client.selectSubtitleStreamForBurn(
|
|
partId: 99,
|
|
track: MediaSubtitleTrack(
|
|
id: 403,
|
|
index: 5,
|
|
codec: 'srt',
|
|
selected: true,
|
|
forced: false,
|
|
key: '/library/streams/403',
|
|
external: true,
|
|
),
|
|
);
|
|
await client.selectSubtitleStreamForBurn(partId: 99, track: null);
|
|
|
|
expect(requests, isEmpty);
|
|
});
|
|
|
|
test('an unaimable burn refuses rather than letting the server pick', () async {
|
|
// Proceeding would burn whatever the part already had selected, welding a
|
|
// language the viewer never chose into the picture.
|
|
final client = makeClient((_) async => http.Response('', 200));
|
|
addTearDown(client.close);
|
|
|
|
await expectLater(
|
|
client.selectSubtitleStreamForBurn(
|
|
partId: null,
|
|
track: MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', selected: true, forced: false),
|
|
),
|
|
throwsStateError,
|
|
);
|
|
});
|
|
|
|
test('a selection the server did not commit refuses the burn too', () async {
|
|
// 204 rather than 200: no HTTP error to raise, but nothing was stored
|
|
// either, so the burn target is still whatever the part had before.
|
|
final client = makeClient((_) async => http.Response('', 204));
|
|
addTearDown(client.close);
|
|
|
|
await expectLater(
|
|
client.selectSubtitleStreamForBurn(
|
|
partId: 99,
|
|
track: MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', selected: true, forced: false),
|
|
),
|
|
throwsStateError,
|
|
);
|
|
});
|
|
|
|
test('a burn that cannot be aimed never reaches the decision endpoint', () async {
|
|
// The whole transcode is abandoned: the caller falls back to direct play,
|
|
// where the native player reads the embedded track itself.
|
|
final requests = <http.Request>[];
|
|
final client = makeClient((request) async {
|
|
requests.add(request);
|
|
return http.Response(
|
|
jsonEncode({
|
|
'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001},
|
|
}),
|
|
request.method == 'PUT' ? 403 : 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',
|
|
partId: 99,
|
|
selectedSubtitleTrack: MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', selected: true, forced: false),
|
|
);
|
|
|
|
expect(result.outcome, TranscodeDecisionOutcome.failed);
|
|
expect(result.startPath, isNull);
|
|
expect(
|
|
requests.where((request) => request.url.path.contains('/transcode/universal')),
|
|
isEmpty,
|
|
reason: 'no burn may be requested once the target could not be selected',
|
|
);
|
|
});
|
|
|
|
test('transcode leaves a real external subtitle file to the sidecar instead of burning it', () {
|
|
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: 403,
|
|
index: 5,
|
|
codec: 'srt',
|
|
languageCode: 'swe',
|
|
selected: true,
|
|
forced: false,
|
|
key: '/library/streams/403',
|
|
external: true,
|
|
),
|
|
);
|
|
|
|
expect(params['subtitles'], 'none');
|
|
expect(params.containsKey('subtitleStreamID'), isFalse);
|
|
});
|
|
|
|
test('no burn keeps the per-preset directPlay/directStream pinning', () {
|
|
final client = makeClient((_) async => http.Response('not used', 500));
|
|
addTearDown(client.close);
|
|
|
|
final originalParams = client.buildTranscodeParamsForTesting(
|
|
ratingKey: '42',
|
|
mediaIndex: 0,
|
|
preset: TranscodeQualityPreset.original,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
);
|
|
expect(originalParams['directPlay'], '1');
|
|
expect(originalParams['directStream'], '1');
|
|
|
|
final cappedParams = client.buildTranscodeParamsForTesting(
|
|
ratingKey: '42',
|
|
mediaIndex: 0,
|
|
preset: TranscodeQualityPreset.p720_3mbps,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
);
|
|
expect(cappedParams['directPlay'], '0');
|
|
expect(cappedParams['directStream'], '0');
|
|
});
|
|
|
|
test('a burn withdraws directPlay, because painting the subtitle in is a re-encode', () {
|
|
// Not a preference: a real PMS answers HTTP 400 to `directPlay=1` combined
|
|
// with `subtitles=burn`, for text and image subtitles alike. With
|
|
// directPlay withdrawn it answers `decision=transcode` on the video stream
|
|
// and `decision=burn` on the subtitle.
|
|
final client = makeClient((_) async => http.Response('not used', 500));
|
|
addTearDown(client.close);
|
|
|
|
Map<String, String> paramsFor(String codec, TranscodeQualityPreset preset) => client.buildTranscodeParamsForTesting(
|
|
ratingKey: '42',
|
|
mediaIndex: 0,
|
|
preset: preset,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
selectedSubtitleTrack: MediaSubtitleTrack(id: 401, index: 3, codec: codec, selected: true, forced: false),
|
|
);
|
|
|
|
for (final codec in ['ass', 'srt', 'pgs']) {
|
|
final params = paramsFor(codec, TranscodeQualityPreset.p720_3mbps);
|
|
expect(params['subtitles'], 'burn', reason: '$codec should burn');
|
|
expect(params['directPlay'], '0', reason: '$codec burn must not also advertise direct play');
|
|
}
|
|
|
|
// Even an original-quality session must drop directPlay once it burns.
|
|
final originalBurn = paramsFor('ass', TranscodeQualityPreset.original);
|
|
expect(originalBurn['subtitles'], 'burn');
|
|
expect(originalBurn['directPlay'], '0');
|
|
expect(originalBurn['directStream'], '1');
|
|
});
|
|
|
|
test('video transcode requests no subtitles when none is selected, preserving the HLS profile', () {
|
|
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',
|
|
);
|
|
|
|
expect(params['protocol'], 'hls');
|
|
expect(params['subtitles'], 'none');
|
|
expect(params.containsKey('subtitleStreamID'), isFalse);
|
|
expect(params.containsKey('advancedSubtitles'), isFalse);
|
|
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(
|
|
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=hls&container=mp4&videoCodec=h264%2Chevc'
|
|
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)',
|
|
),
|
|
);
|
|
expect(
|
|
profile,
|
|
contains(
|
|
'add-transcode-target(type=subtitleProfile&context=streaming'
|
|
'&protocol=hls&container=webvtt&subtitleCodec=webvtt)',
|
|
),
|
|
);
|
|
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', () {
|
|
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',
|
|
);
|
|
|
|
final startPath = client.buildTranscodeStartPathFromParamsForTesting(params);
|
|
|
|
expect(startPath, startsWith('/video/:/transcode/universal/start.m3u8?'));
|
|
expect(startPath, contains('protocol=hls'));
|
|
// Without a requested offset the start path must stay offset-free so the
|
|
// session transcodes from the beginning exactly as before.
|
|
expect(startPath, isNot(contains('offset=')));
|
|
expect(startPath, isNot(contains('X-Plex-Token')));
|
|
});
|
|
|
|
test('transcode start path carries the requested decision offset', () {
|
|
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',
|
|
offset: const Duration(minutes: 8, seconds: 15),
|
|
);
|
|
|
|
final startPath = client.buildTranscodeStartPathFromParamsForTesting(params);
|
|
|
|
expect(startPath, startsWith('/video/:/transcode/universal/start.m3u8?'));
|
|
expect(Uri.parse(startPath).queryParameters['offset'], '495.000000');
|
|
expect(startPath, isNot(contains('X-Plex-Token')));
|
|
});
|
|
|
|
test('transcode stream offset is parsed from an offset start URL only', () {
|
|
expect(
|
|
PlexClient.transcodeStreamOffsetFromUrl(
|
|
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=495.000000',
|
|
),
|
|
const Duration(minutes: 8, seconds: 15),
|
|
);
|
|
|
|
expect(
|
|
PlexClient.transcodeStreamOffsetFromUrl(
|
|
'https://plex.example.com/video/:/transcode/universal/start.m3u8?session=abc',
|
|
),
|
|
isNull,
|
|
);
|
|
expect(
|
|
PlexClient.transcodeStreamOffsetFromUrl(
|
|
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=0.000000',
|
|
),
|
|
isNull,
|
|
);
|
|
expect(PlexClient.transcodeStreamOffsetFromUrl('https://plex.example.com/library/parts/1/file.mkv'), isNull);
|
|
// Percent-encoded spelling of the same path must not silently switch the
|
|
// probe off.
|
|
expect(
|
|
PlexClient.transcodeStreamOffsetFromUrl(
|
|
'https://plex.example.com/video/%3A/transcode/universal/start.m3u8?offset=495.000000',
|
|
),
|
|
const Duration(minutes: 8, seconds: 15),
|
|
);
|
|
});
|
|
|
|
test('transcode readiness probes the segment at the offset, not the first one', () async {
|
|
final requests = <Uri>[];
|
|
final rangeHeaders = <String?>[];
|
|
final acceptHeaders = <String?>[];
|
|
var segmentAttempts = 0;
|
|
// Plex media playlists cover the full title from segment zero; the probe
|
|
// must touch the offset's segment because requesting a segment is how a
|
|
// client seeks a Plex HLS session — probing 00000.ts would relocate the
|
|
// transcoder back to the start.
|
|
final mediaPlaylist = StringBuffer('#EXTM3U\n#EXT-X-TARGETDURATION:1\n');
|
|
for (var i = 0; i < 600; i++) {
|
|
mediaPlaylist.write('#EXTINF:1.000000,\n${i.toString().padLeft(5, '0')}.ts\n');
|
|
}
|
|
final client = makeClient((request) async {
|
|
requests.add(request.url);
|
|
rangeHeaders.add(request.headers['Range']);
|
|
acceptHeaders.add(request.headers['Accept']);
|
|
return switch (request.url.path) {
|
|
'/video/:/transcode/universal/start.m3u8' => http.Response(
|
|
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1500000\nindex.m3u8?session=transcode-id\n',
|
|
200,
|
|
),
|
|
'/video/:/transcode/universal/index.m3u8' => http.Response(mediaPlaylist.toString(), 200),
|
|
'/video/:/transcode/universal/00495.ts' =>
|
|
++segmentAttempts == 1
|
|
? http.Response('not ready', 404)
|
|
: http.Response('segment bytes', 206, headers: {'content-type': 'video/mp2t'}),
|
|
_ => http.Response('unexpected request', 500),
|
|
};
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final ready = await client.waitForTranscodeReady(
|
|
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=495.500000',
|
|
timeout: const Duration(seconds: 1),
|
|
pollInterval: Duration.zero,
|
|
);
|
|
|
|
expect(ready, isTrue);
|
|
expect(segmentAttempts, 2);
|
|
expect(requests.map((uri) => uri.path), [
|
|
'/video/:/transcode/universal/start.m3u8',
|
|
'/video/:/transcode/universal/index.m3u8',
|
|
'/video/:/transcode/universal/00495.ts',
|
|
'/video/:/transcode/universal/00495.ts',
|
|
]);
|
|
expect(requests[1].queryParameters['session'], 'transcode-id');
|
|
expect(rangeHeaders, [null, null, 'bytes=0-0', 'bytes=0-0']);
|
|
// The client's default Accept is application/json; the probe must mirror
|
|
// the player's request shape instead.
|
|
expect(acceptHeaders, everyElement('*/*'));
|
|
});
|
|
|
|
test('an embedded subtitle this server cannot burn falls back as a failure', () async {
|
|
// The row has no `key`, so it is embedded and a transcode would have to burn it; `dvb_teletext`
|
|
// is not a codec the burn path accepts. Treating that as "no burn requested" sent
|
|
// `subtitles=none` with the row still selected, so the caption vanished while state reported it
|
|
// active *and* the capped preset was silently retained.
|
|
final client = makeClient((request) async {
|
|
if (request.url.path == '/library/metadata/42') {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'Media': [
|
|
{
|
|
'id': 1,
|
|
'Part': [
|
|
{
|
|
'id': 11,
|
|
'key': '/library/parts/11/file.mkv',
|
|
'Stream': <dynamic>[
|
|
{'id': 31, 'streamType': 3, 'index': 2, 'codec': 'dvb_teletext', 'language': 'English'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
if (request.url.path == '/video/:/transcode/universal/decision') {
|
|
// The server answers direct-play-only, which is what refusing the burn looks like.
|
|
return http.Response(
|
|
jsonEncode({
|
|
'MediaContainer': {'generalDecisionCode': 1000, 'transcodeDecisionCode': 1000},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
return http.Response('unexpected request', 500);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final result = await client.getPlaybackInitialization(
|
|
PlaybackInitializationOptions(
|
|
metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
|
|
selectedMediaIndex: 0,
|
|
qualityPreset: TranscodeQualityPreset.p720_3mbps,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
preferredSubtitleTrack: const SubtitlePreference.track(
|
|
SubtitleTrack(id: 'source:31', codec: 'dvb_teletext', language: 'English'),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(result.playMethod, 'DirectPlay', reason: 'direct play is the route that can deliver it');
|
|
expect(result.fallbackReason, isNotNull, reason: 'a dropped burn is a refusal to warn about, not a silent success');
|
|
});
|
|
|
|
test('a valid transcode is refused when it cannot carry the selected subtitle', () async {
|
|
// The server happily transcodes the video, but the decision went out as `subtitles=none` because
|
|
// `dvb_teletext` cannot be burned. Accepting that stream left the row selected with nothing
|
|
// drawing it and no sidecar behind it, so the caption was simply gone.
|
|
final client = makeClient((request) async {
|
|
if (request.url.path == '/library/metadata/42') {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'Media': [
|
|
{
|
|
'id': 1,
|
|
'Part': [
|
|
{
|
|
'id': 11,
|
|
'key': '/library/parts/11/file.mkv',
|
|
'Stream': <dynamic>[
|
|
{'id': 31, 'streamType': 3, 'index': 2, 'codec': 'dvb_teletext', 'language': 'English'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
if (request.url.path == '/video/:/transcode/universal/decision') {
|
|
// 1001 = the server will transcode. Without the refusal this is accepted outright.
|
|
return http.Response(
|
|
jsonEncode({
|
|
'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001},
|
|
}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
}
|
|
return http.Response('unexpected request', 500);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final result = await client.getPlaybackInitialization(
|
|
PlaybackInitializationOptions(
|
|
metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
|
|
selectedMediaIndex: 0,
|
|
qualityPreset: TranscodeQualityPreset.p720_3mbps,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
preferredSubtitleTrack: const SubtitlePreference.track(
|
|
SubtitleTrack(id: 'source:31', codec: 'dvb_teletext', language: 'English'),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(result.isTranscoding, isFalse, reason: 'the stream could not carry the caption that was asked for');
|
|
expect(result.playMethod, 'DirectPlay', reason: 'direct play lets the native player read it');
|
|
});
|
|
|
|
test('readiness probe target selection walks segment durations to the offset', () {
|
|
const mediaPlaylist =
|
|
'#EXTM3U\n'
|
|
'#EXT-X-TARGETDURATION:2\n'
|
|
'#EXTINF:2.000000,\n00000.ts\n'
|
|
'#EXTINF:2.000000,\n00001.ts\n'
|
|
'#EXTINF:2.000000,\n00002.ts\n';
|
|
expect(PlexClient.selectReadinessProbeTarget(mediaPlaylist, Duration.zero), '00000.ts');
|
|
expect(PlexClient.selectReadinessProbeTarget(mediaPlaylist, const Duration(seconds: 3)), '00001.ts');
|
|
// Durations never cross the offset: the playlist cannot say where the
|
|
// offset lives, and probing a guessed segment would seek the session.
|
|
expect(PlexClient.selectReadinessProbeTarget(mediaPlaylist, const Duration(seconds: 30)), isNull);
|
|
// A master playlist has no segment durations: descend into the first variant.
|
|
const masterPlaylist = '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1500000\nindex.m3u8\nfallback.m3u8\n';
|
|
expect(PlexClient.selectReadinessProbeTarget(masterPlaylist, const Duration(minutes: 10)), 'index.m3u8');
|
|
});
|
|
|
|
test('transcode readiness skips probing when the playlist cannot locate the offset', () {
|
|
fakeAsync((async) {
|
|
final requestPaths = <String>[];
|
|
// The offset-relative playlist shape this client has never observed
|
|
// against a real PMS: segments named from the resume point, durations
|
|
// summing to a minute. Probing its last segment would ask the session
|
|
// to produce a minute past the resume point.
|
|
final mediaPlaylist = StringBuffer('#EXTM3U\n#EXT-X-TARGETDURATION:1\n');
|
|
for (var i = 8062; i <= 8121; i++) {
|
|
mediaPlaylist.write('#EXTINF:1.000000,\n${i.toString().padLeft(5, '0')}.ts\n');
|
|
}
|
|
final client = makeClient((request) async {
|
|
requestPaths.add(request.url.path);
|
|
if (request.url.path.endsWith('/start.m3u8')) {
|
|
return http.Response(mediaPlaylist.toString(), 200);
|
|
}
|
|
return http.Response('segment bytes', 206, headers: {'content-type': 'video/mp2t'});
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
bool? ready;
|
|
client
|
|
.waitForTranscodeReady(
|
|
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=8062.000000',
|
|
timeout: const Duration(seconds: 15),
|
|
pollInterval: const Duration(milliseconds: 500),
|
|
)
|
|
.then((value) => ready = value);
|
|
async.flushMicrotasks();
|
|
|
|
expect(ready, isTrue);
|
|
expect(requestPaths.where((path) => path.endsWith('.ts')), isEmpty);
|
|
});
|
|
});
|
|
|
|
test('transcode readiness hands off on a 500 that arrives inside a decode exception', () {
|
|
fakeAsync((async) {
|
|
var requestCount = 0;
|
|
// A proxy or gateway in front of PMS answering 500 with a JSON
|
|
// content-type and a non-JSON body: the client's body decode throws a
|
|
// status-bearing exception instead of returning the response, and the
|
|
// exception path must apply the same hand-off rule as the response
|
|
// path.
|
|
final client = makeClient((request) async {
|
|
requestCount++;
|
|
return http.Response('<html>gateway error</html>', 500, headers: {'content-type': 'application/json'});
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
bool? ready;
|
|
client
|
|
.waitForTranscodeReady(
|
|
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=495.000000',
|
|
timeout: const Duration(seconds: 15),
|
|
pollInterval: const Duration(milliseconds: 500),
|
|
)
|
|
.then((value) => ready = value);
|
|
// No elapse: like the response-path 500, the exception-path 500 must
|
|
// complete the probe without a single poll wait.
|
|
async.flushMicrotasks();
|
|
|
|
expect(ready, isFalse);
|
|
expect(requestCount, 1);
|
|
});
|
|
});
|
|
|
|
test('transcode readiness returns immediately when the probe is already aborted', () async {
|
|
var requestCount = 0;
|
|
final client = makeClient((request) async {
|
|
requestCount++;
|
|
return http.Response('#EXTM3U\n#EXTINF:1.0,\nmedia-00000.ts\n', 200);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
final abort = AbortController()..abort();
|
|
final ready = await client.waitForTranscodeReady(
|
|
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=0.500000',
|
|
abort: abort,
|
|
);
|
|
|
|
expect(ready, isFalse);
|
|
expect(requestCount, 0);
|
|
});
|
|
|
|
test('transcode readiness polls a bounded number of times while the segment stays unavailable', () {
|
|
fakeAsync((async) {
|
|
var requestCount = 0;
|
|
final client = makeClient((request) async {
|
|
requestCount++;
|
|
if (request.url.path.endsWith('/start.m3u8')) {
|
|
return http.Response('#EXTM3U\n#EXTINF:1.0,\nmedia-00000.ts\n', 200);
|
|
}
|
|
return http.Response('not ready', 404);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
bool? ready;
|
|
client
|
|
.waitForTranscodeReady(
|
|
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=0.500000',
|
|
timeout: const Duration(milliseconds: 600),
|
|
pollInterval: const Duration(milliseconds: 50),
|
|
)
|
|
.then((value) => ready = value);
|
|
async.elapse(const Duration(milliseconds: 700));
|
|
|
|
expect(ready, isFalse);
|
|
// The not-ready signal is a non-2xx *response*, not an exception: every
|
|
// failed probe must still wait out the poll interval, and after three
|
|
// consecutive failures the interval doubles to a 4x cap. Under a fake
|
|
// clock the cadence is exact: the playlist hop, then segment polls at
|
|
// 0/50/100/150/250/450ms. Drafts of this probe that skipped the delay
|
|
// on non-2xx or discarded the backoff measured 3,651 and 12 requests
|
|
// respectively in this same window; neither shape ever shipped.
|
|
expect(requestCount, 7);
|
|
});
|
|
});
|
|
|
|
test('transcode readiness requests bypass endpoint failover', () {
|
|
fakeAsync((async) {
|
|
var exhaustedSignals = 0;
|
|
var requestCount = 0;
|
|
final client = testPlexClient(
|
|
serverId: ServerId('server-id'),
|
|
prioritizedEndpoints: const ['https://plex.example.com'],
|
|
onAllEndpointsExhausted: () => exhaustedSignals++,
|
|
// 503 on the playlist itself: the segment leg goes through getRaw,
|
|
// which never enters the failover path, so the playlist request is
|
|
// the one that could drive the cascade.
|
|
handler: (request) async {
|
|
requestCount++;
|
|
return http.Response('busy', 503);
|
|
},
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
bool? ready;
|
|
client
|
|
.waitForTranscodeReady(
|
|
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=495.000000',
|
|
timeout: const Duration(milliseconds: 200),
|
|
pollInterval: const Duration(milliseconds: 50),
|
|
)
|
|
.then((value) => ready = value);
|
|
async.elapse(const Duration(milliseconds: 300));
|
|
|
|
expect(ready, isFalse);
|
|
expect(requestCount, greaterThanOrEqualTo(3));
|
|
// The probe carries its own retry budget, so it must not drive the
|
|
// endpoint-failover cascade: its URL is absolute (the retry re-hits the
|
|
// same host), each switch rewrites config.baseUrl underneath the
|
|
// videoUrl already handed to the open path, and on a single-endpoint
|
|
// server every failed poll fires the all-endpoints-exhausted signal —
|
|
// the manager's cue to flip server status and reconnect.
|
|
expect(exhaustedSignals, 0);
|
|
expect(client.config.baseUrl, 'https://plex.example.com');
|
|
});
|
|
});
|
|
|
|
test('transcode readiness stops on cancellation instead of sleeping out the window', () {
|
|
fakeAsync((async) {
|
|
var requestCount = 0;
|
|
late final PlexClient client;
|
|
client = testPlexClient(
|
|
serverId: ServerId('server-id'),
|
|
handler: (request) async {
|
|
requestCount++;
|
|
if (request.url.path.endsWith('/start.m3u8')) {
|
|
return http.Response('#EXTM3U\n#EXTINF:1.0,\nmedia-00000.ts\n', 200);
|
|
}
|
|
// The owner closes mid-probe; the next request must surface as a
|
|
// cancellation, not as one more not-ready round.
|
|
client.close();
|
|
return http.Response('not ready', 404);
|
|
},
|
|
);
|
|
|
|
bool? ready;
|
|
client
|
|
.waitForTranscodeReady(
|
|
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=0.500000',
|
|
timeout: const Duration(milliseconds: 600),
|
|
pollInterval: const Duration(milliseconds: 50),
|
|
)
|
|
.then((value) => ready = value);
|
|
// One poll interval is all it may consume after the close; a probe
|
|
// that counts cancellation as not-ready sleeps out the full 600ms.
|
|
async.elapse(const Duration(milliseconds: 100));
|
|
|
|
expect(ready, isFalse);
|
|
expect(requestCount, 2);
|
|
});
|
|
});
|
|
|
|
test('transcode readiness hands off immediately on HTTP 500', () {
|
|
fakeAsync((async) {
|
|
var requestCount = 0;
|
|
final client = makeClient((request) async {
|
|
requestCount++;
|
|
if (request.url.path.endsWith('/start.m3u8')) {
|
|
return http.Response('#EXTM3U\n#EXTINF:1.0,\nmedia-00000.ts\n', 200);
|
|
}
|
|
return http.Response('limit rejected', 500);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
bool? ready;
|
|
client
|
|
.waitForTranscodeReady(
|
|
'https://plex.example.com/video/:/transcode/universal/start.m3u8?offset=0.500000',
|
|
timeout: const Duration(seconds: 15),
|
|
pollInterval: const Duration(milliseconds: 500),
|
|
)
|
|
.then((value) => ready = value);
|
|
// No elapse: a 500 must complete the probe without a single poll wait,
|
|
// so the player opens promptly and the server-limit dialog path runs.
|
|
async.flushMicrotasks();
|
|
|
|
expect(ready, isFalse);
|
|
expect(requestCount, 2);
|
|
});
|
|
});
|
|
|
|
test('transcode readiness returns immediately for URLs without an offset', () async {
|
|
var requestCount = 0;
|
|
final client = makeClient((request) async {
|
|
requestCount++;
|
|
return http.Response('should not be called', 500);
|
|
});
|
|
addTearDown(client.close);
|
|
|
|
// Probing a no-offset playlist would touch segment zero, and requesting
|
|
// a segment is how a client seeks a Plex HLS session.
|
|
expect(
|
|
await client.waitForTranscodeReady('https://plex.example.com/video/:/transcode/universal/start.m3u8?session=x'),
|
|
isTrue,
|
|
);
|
|
expect(await client.waitForTranscodeReady('https://plex.example.com/library/parts/1/file.mkv'), isTrue);
|
|
expect(requestCount, 0);
|
|
});
|
|
|
|
test('transcode params preserve resolved media and part indices', () {
|
|
final client = makeClient((_) async => http.Response('not used', 500));
|
|
addTearDown(client.close);
|
|
|
|
final params = client.buildTranscodeParamsForTesting(
|
|
ratingKey: '42',
|
|
mediaIndex: 1,
|
|
partIndex: 2,
|
|
preset: TranscodeQualityPreset.p720_3mbps,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
);
|
|
|
|
expect(params['mediaIndex'], '1');
|
|
expect(params['partIndex'], '2');
|
|
});
|
|
|
|
test('image-based embedded subtitles are burned rather than sidecarred', () {
|
|
final client = makeClient((_) async => http.Response('not used', 500));
|
|
addTearDown(client.close);
|
|
|
|
final tracks = [
|
|
MediaSubtitleTrack(id: 401, index: 3, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
|
|
MediaSubtitleTrack(id: 402, index: 4, codec: 'dvd_subtitle', languageCode: 'eng', selected: false, forced: false),
|
|
];
|
|
|
|
expect(buildTranscodeSubtitles(client, tracks), isEmpty);
|
|
|
|
final params = client.buildTranscodeParamsForTesting(
|
|
ratingKey: '42',
|
|
mediaIndex: 0,
|
|
preset: TranscodeQualityPreset.p720_3mbps,
|
|
sessionIdentifier: 'session-id',
|
|
transcodeSessionId: 'transcode-id',
|
|
selectedSubtitleTrack: client.resolveTranscodeSubtitleTrackForTesting(mediaInfoWithSubtitles(tracks), null),
|
|
);
|
|
|
|
expect(params['subtitles'], 'burn');
|
|
expect(params.containsKey('subtitleStreamID'), isFalse);
|
|
});
|
|
|
|
group('playback metadata failure contract', () {
|
|
Map<String, dynamic> playableBody() => {
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'Media': [
|
|
{
|
|
'id': 7,
|
|
'Part': [
|
|
{'id': 10, 'key': '/library/parts/10/file.mkv'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
Map<String, dynamic> noPartBody() => {
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'ratingKey': '42',
|
|
'type': 'movie',
|
|
'Media': [
|
|
{'id': 7, 'Part': []},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
PlaybackInitializationOptions options() => PlaybackInitializationOptions(
|
|
metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
|
|
selectedMediaIndex: 0,
|
|
);
|
|
|
|
test('raw helper preserves 401 while initialization classifies authentication', () async {
|
|
final client = makeClient(
|
|
(_) async =>
|
|
http.Response(jsonEncode({'error': 'body-canary'}), 401, headers: {'content-type': 'application/json'}),
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
await expectLater(
|
|
client.getVideoPlaybackData('42'),
|
|
throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', 401)),
|
|
);
|
|
await expectLater(
|
|
client.getPlaybackInitialization(options()),
|
|
throwsA(
|
|
isA<PlaybackException>()
|
|
.having((error) => error.reason, 'reason', PlaybackFailureReason.authenticationRequired)
|
|
.having((error) => error.message, 'message', isNot(contains('body-canary'))),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('raw timeout survives and initialization classifies server unavailable', () async {
|
|
final client = makeClient(
|
|
(_) async => throw MediaServerHttpException(
|
|
type: MediaServerHttpErrorType.receiveTimeout,
|
|
message: 'timeout-canary',
|
|
requestUri: Uri.parse('https://private.invalid/library/metadata/42?secret=uri-canary'),
|
|
),
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
await expectLater(
|
|
client.getVideoPlaybackData('42'),
|
|
throwsA(
|
|
isA<MediaServerHttpException>().having(
|
|
(error) => error.type,
|
|
'type',
|
|
MediaServerHttpErrorType.receiveTimeout,
|
|
),
|
|
),
|
|
);
|
|
try {
|
|
await client.getPlaybackInitialization(options());
|
|
fail('Timeout must throw');
|
|
} on PlaybackException catch (error) {
|
|
expect(error.reason, PlaybackFailureReason.serverUnavailable);
|
|
expect(error.message, isNot(contains('timeout-canary')));
|
|
expect(error.toString(), isNot(anyOf(contains('private.invalid'), contains('uri-canary'))));
|
|
}
|
|
});
|
|
|
|
test('successful malformed envelope, Media, and Part collections are invalid data', () async {
|
|
final malformedBodies = <Map<String, dynamic>>[
|
|
{'notMediaContainer': true},
|
|
{
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{'Media': 'payload-canary'},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'Media': [
|
|
{'Part': 'payload-canary'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
for (final body in malformedBodies) {
|
|
final client = makeClient(
|
|
(_) async => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}),
|
|
);
|
|
addTearDown(client.close);
|
|
await expectLater(client.getVideoPlaybackData('42'), throwsA(isA<FormatException>()));
|
|
await expectLater(
|
|
client.getPlaybackInitialization(options()),
|
|
throwsA(
|
|
isA<PlaybackException>()
|
|
.having((error) => error.reason, 'reason', PlaybackFailureReason.invalidPlaybackData)
|
|
.having((error) => error.toString(), 'safe text', isNot(contains('payload-canary'))),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('playback validation preserves singleton and mixed valid Media/Part shapes', () async {
|
|
final bodies = <Map<String, dynamic>>[
|
|
{
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'Media': {
|
|
'id': 7,
|
|
'Part': {'id': 10, 'key': '/library/parts/10/singleton.mkv'},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
'MediaContainer': {
|
|
'Metadata': [
|
|
{
|
|
'Media': [
|
|
'ignored',
|
|
{
|
|
'id': 7,
|
|
'Part': [
|
|
'ignored',
|
|
{'id': 10, 'key': '/library/parts/10/mixed.mkv'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
for (final body in bodies) {
|
|
final client = makeClient(
|
|
(_) async => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}),
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
final data = await client.getVideoPlaybackData('42');
|
|
|
|
expect(data.hasValidVideoUrl, isTrue);
|
|
expect(data.videoUrl, contains('/library/parts/10/'));
|
|
}
|
|
});
|
|
|
|
test('invalid JSON and non-map top-level data classify as invalid playback data', () async {
|
|
final responses = [
|
|
http.Response('{', 200, headers: {'content-type': 'application/json'}),
|
|
http.Response(jsonEncode([]), 200, headers: {'content-type': 'application/json'}),
|
|
];
|
|
|
|
for (final response in responses) {
|
|
final client = makeClient((_) async => response);
|
|
addTearDown(client.close);
|
|
await expectLater(
|
|
client.getPlaybackInitialization(options()),
|
|
throwsA(
|
|
isA<PlaybackException>().having(
|
|
(error) => error.reason,
|
|
'reason',
|
|
PlaybackFailureReason.invalidPlaybackData,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('valid metadata without a part remains noPlayableSource', () async {
|
|
final client = makeClient(
|
|
(_) async => http.Response(jsonEncode(noPartBody()), 200, headers: {'content-type': 'application/json'}),
|
|
);
|
|
addTearDown(client.close);
|
|
|
|
final raw = await client.getVideoPlaybackData('42');
|
|
expect(raw.hasValidVideoUrl, isFalse);
|
|
await expectLater(
|
|
client.getPlaybackInitialization(options()),
|
|
throwsA(
|
|
isA<PlaybackException>().having((error) => error.reason, 'reason', PlaybackFailureReason.noPlayableSource),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('auth, server, malformed, and no-source failures expose distinct reasons and messages', () async {
|
|
Future<PlaybackException> capture(PlexClient client) async {
|
|
try {
|
|
await client.getPlaybackInitialization(options());
|
|
fail('Initialization must throw');
|
|
} on PlaybackException catch (error) {
|
|
return error;
|
|
}
|
|
}
|
|
|
|
final auth = makeClient((_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'}));
|
|
final server = makeClient((_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'}));
|
|
final malformed = makeClient(
|
|
(_) async => http.Response(
|
|
jsonEncode({'MediaContainer': 'invalid'}),
|
|
200,
|
|
headers: {'content-type': 'application/json'},
|
|
),
|
|
);
|
|
final noSource = makeClient(
|
|
(_) async => http.Response(jsonEncode(noPartBody()), 200, headers: {'content-type': 'application/json'}),
|
|
);
|
|
addTearDown(auth.close);
|
|
addTearDown(server.close);
|
|
addTearDown(malformed.close);
|
|
addTearDown(noSource.close);
|
|
|
|
final failures = [await capture(auth), await capture(server), await capture(malformed), await capture(noSource)];
|
|
expect(failures.map((failure) => failure.reason).toSet(), {
|
|
PlaybackFailureReason.authenticationRequired,
|
|
PlaybackFailureReason.serverUnavailable,
|
|
PlaybackFailureReason.invalidPlaybackData,
|
|
PlaybackFailureReason.noPlayableSource,
|
|
});
|
|
expect(failures.map((failure) => failure.message).toSet(), hasLength(4));
|
|
});
|
|
|
|
test('500, connection failure, and cancellation never become no-source', () async {
|
|
final cases = <(PlaybackFailureReason, Future<http.Response> Function(http.Request))>[
|
|
(
|
|
PlaybackFailureReason.serverUnavailable,
|
|
(_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'}),
|
|
),
|
|
(
|
|
PlaybackFailureReason.serverUnavailable,
|
|
(_) async =>
|
|
throw MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'unavailable'),
|
|
),
|
|
(PlaybackFailureReason.cancelled, (request) async => throw http.RequestAbortedException(request.url)),
|
|
];
|
|
|
|
for (final (reason, handler) in cases) {
|
|
final client = makeClient(handler);
|
|
addTearDown(client.close);
|
|
await expectLater(
|
|
client.getPlaybackInitialization(options()),
|
|
throwsA(isA<PlaybackException>().having((error) => error.reason, 'reason', reason)),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('unclassified failures use the safe unknown reason and message', () async {
|
|
final client = makeClient((_) async => throw StateError('unknown-cause-canary'));
|
|
addTearDown(client.close);
|
|
|
|
await expectLater(
|
|
client.getPlaybackInitialization(options()),
|
|
throwsA(
|
|
isA<PlaybackException>()
|
|
.having((error) => error.reason, 'reason', PlaybackFailureReason.unknown)
|
|
.having((error) => error.message, 'safe message', isNot(contains('unknown-cause-canary'))),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('status failure still serves a valid cached playable row', () async {
|
|
await PlexApiCache.instance.put(
|
|
buildPlexProfileScopeId(serverId: ServerId('server-id'), profileId: 'test-profile').cacheServerId,
|
|
'/library/metadata/42',
|
|
playableBody(),
|
|
);
|
|
final client = makeClient((_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'}));
|
|
addTearDown(client.close);
|
|
|
|
final data = await client.getVideoPlaybackData('42');
|
|
|
|
expect(data.hasValidVideoUrl, isTrue);
|
|
expect(data.videoUrl, contains('/library/parts/10/file.mkv'));
|
|
});
|
|
|
|
test('external URL and download resolution propagate typed request failures', () async {
|
|
final client = makeClient((_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'}));
|
|
addTearDown(client.close);
|
|
final item = testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id');
|
|
|
|
await expectLater(client.resolveExternalPlaybackUrl(item), throwsA(isA<MediaServerHttpException>()));
|
|
await expectLater(client.resolveDownload(item), throwsA(isA<MediaServerHttpException>()));
|
|
});
|
|
});
|
|
}
|