Files
plezy/test/services/plex_playback_data_request_test.dart
T
toluLikesToCode 8740a19f36 feat(player): start Plex transcodes at the resume position (#1817)
A Plex transcode session always starts producing at zero: the decision
request never sent offset=, so any non-zero open - resuming a
transcoded title, or switching from Direct Play to a transcoded
quality mid-playback - opened a session whose produced window begins
at the start of the file and seeked it. mpv immediately requests a
segment the transcoder has not produced, PMS answers 404 for it and
every subsequent segment, and playback buffers forever.

Send offset=<seconds> (6dp) with the decision and start request - the
view offset on initial open, the resolved resume position on every
in-place reload - so the session begins producing at the position the
player consumes first. The playlist timeline is unchanged: an offset
session's media playlist still covers the full title from segment
zero, so the player keeps opening with start: at the resume position
and in-stream seeks work as before.

Before a native player opens an offset playlist, waitForTranscodeReady
walks the master playlist, the media playlist, and the segment
containing the offset, because PMS can publish a manifest before that
segment is fetchable and mpv treats the 404 as an HLS error. The probe
is best-effort: it never fails an open, hands off immediately on HTTP
500 (on the response and exception paths alike) so the server-limit
dialog stays prompt, stops on cancellation, skips itself when the
playlist durations never reach the offset, and stays out of the
endpoint-failover cascade. In-place reloads resolve the replacement
source only after the old stop report has gone out, so Plex cannot use
that stop to terminate the replacement transcode.

close #1840
2026-08-09 06:28:59 +02:00

1386 lines
51 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/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},
}),
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),
'https://plex.example.com/video.mkv?X-Plex-Token=token',
);
}
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 wires a subtitle-free HLS request to the complete sidecar catalog', () 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},
}),
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',
transcodeOffset: const Duration(minutes: 8, seconds: 15),
),
);
final decisionRequest = requests.singleWhere(
(request) => request.url.path == '/video/:/transcode/universal/decision',
);
expect(decisionRequest.url.queryParameters['subtitles'], 'none');
expect(decisionRequest.url.queryParameters['offset'], '495.000000');
expect(decisionRequest.url.queryParameters.containsKey('subtitleStreamID'), isFalse);
expect(decisionRequest.url.queryParameters.containsKey('advancedSubtitles'), isFalse);
expect(decisionRequest.url.queryParameters['X-Plex-Incomplete-Segments'], '1');
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), [401, 402]);
expect(result.subtitleSidecars.every((sidecar) => sidecar.preload), isTrue);
expect(result.subtitleSidecars.first.track.isContainer, isTrue);
expect(result.subtitleSidecars.last.track.uri, contains('/library/streams/402.srt'));
});
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 includes embedded and keyed Plex streams', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
final subtitles = buildTranscodeSubtitles(client, [
MediaSubtitleTrack(id: 401, codec: 'ass', languageCode: 'eng', title: 'Embedded', selected: true, forced: false),
MediaSubtitleTrack(
id: 402,
codec: 'srt',
languageCode: 'swe',
title: 'External',
selected: false,
forced: false,
key: '/library/streams/402',
external: true,
),
]);
expect(subtitles, hasLength(2));
expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [401, 402]);
expect(subtitles.every((sidecar) => sidecar.preload), isTrue);
expect(subtitles.first.track.isContainer, isTrue);
expect(subtitles.first.track.uri, 'https://plex.example.com/video.mkv?X-Plex-Token=token');
expect(subtitles.last.track.isContainer, isFalse);
expect(
subtitles.last.track.uri,
'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token',
);
});
test('tokenless transcode keeps embedded and keyed subtitle sources', () {
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,
),
]),
'https://plex.example.com/video.mkv',
);
expect(subtitles, hasLength(2));
expect(subtitles.first.track.isContainer, isTrue);
expect(subtitles.first.track.uri, 'https://plex.example.com/video.mkv');
expect(subtitles.last.track.isContainer, isFalse);
expect(subtitles.last.track.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8');
});
test('video transcode stays subtitle-free while 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=mpegts',
),
);
expect(
profile,
contains(
'add-transcode-target(type=subtitleProfile&context=streaming'
'&protocol=hls&container=webvtt&subtitleCodec=webvtt)',
),
);
expect(profile, isNot(contains('protocol=http&container=mkv')));
});
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('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 use the shared container sidecar', () {
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: false, forced: false),
]);
expect(subtitles, hasLength(2));
expect(subtitles.every((sidecar) => sidecar.track.isContainer), isTrue);
expect(subtitles.map((sidecar) => sidecar.track.uri).toSet(), {
'https://plex.example.com/video.mkv?X-Plex-Token=token',
});
});
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>()));
});
});
}