Non-Original presets advertised hevc inside the mpegts HLS target; a Plex
Pass server with HEVC encoding enabled obliges, and its HEVC encode -> TS
segmenter path emits parameter sets mpv rejects ("PPS changed between
slices"). The VOD target now requests fragmented MP4 (verified against
PMS 1.22-1.43), retrying once with an H.264-only TS profile when a
server's decision does not echo the mp4 container back, and falling back
to direct play when neither is honoured. Live TV keeps its own TS target:
live sessions copy broadcast hevc/mpeg2video streams, a path the encoder
bug does not touch.
Presets also now send the videoResolution/videoQuality caps their labels
promise; previously only the bitrate limitation went out, so a "1080p
8 Mbps" preset delivered 2160p at a starved 8 Mbps.
close #1859
357 lines
14 KiB
Dart
357 lines
14 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:http/testing.dart';
|
||
import 'package:plezy/connection/connection.dart';
|
||
import 'package:plezy/database/app_database.dart';
|
||
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||
import 'package:plezy/media/ids.dart';
|
||
import 'package:plezy/media/live_tv_support.dart';
|
||
import 'package:plezy/models/plex/plex_config.dart';
|
||
import 'package:plezy/services/jellyfin_client.dart';
|
||
import 'package:plezy/services/plex_api_cache.dart';
|
||
import 'package:plezy/services/plex_client.dart';
|
||
import 'package:plezy/services/playback_initialization_types.dart';
|
||
import '../test_helpers/backend_client_fixtures.dart';
|
||
|
||
/// Pins the [LiveTvPlaybackSession] lifecycle on both backends — the
|
||
/// per-backend protocol that used to be hand-rolled (3×) inside the player's
|
||
/// live methods: tune → lazy stream URL, time-shift offsets reusing the
|
||
/// transcode session, the Tunarr duration-grow guard on heartbeats, and
|
||
/// recover-with-degradation.
|
||
void main() {
|
||
late AppDatabase db;
|
||
|
||
setUp(() async {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
PlexApiCache.initialize(db);
|
||
});
|
||
|
||
tearDown(() async {
|
||
await db.close();
|
||
});
|
||
|
||
http.Response jsonResponse(Map<String, dynamic> body) =>
|
||
http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'});
|
||
|
||
group('Plex live playback session', () {
|
||
Map<String, dynamic> tuneResponse() => {
|
||
'MediaContainer': {
|
||
'MediaSubscription': [
|
||
{
|
||
'MediaGrabOperation': [
|
||
{
|
||
'Metadata': {
|
||
'ratingKey': 'prog-1',
|
||
'key': '/livetv/sessions/session-abc',
|
||
'type': 'clip',
|
||
'duration': 1800000,
|
||
'Media': [
|
||
{'beginsAt': '1700000000'},
|
||
],
|
||
},
|
||
},
|
||
],
|
||
},
|
||
],
|
||
'TranscodeSession': [
|
||
{'timeStamp': '1700000100', 'minOffsetAvailable': '0', 'maxOffsetAvailable': '120'},
|
||
],
|
||
},
|
||
};
|
||
|
||
PlexClient makeClient(
|
||
Future<http.Response> Function(http.Request request) handler, {
|
||
List<String>? prioritizedEndpoints,
|
||
}) => testPlexClient(
|
||
config: PlexConfig(
|
||
baseUrl: 'https://plex.example.com',
|
||
token: 'tok',
|
||
clientIdentifier: 'client',
|
||
product: 'Plezy',
|
||
version: '1',
|
||
machineIdentifier: 'machine-1',
|
||
),
|
||
serverId: ServerId('machine-1'),
|
||
httpClient: MockClient(handler),
|
||
prioritizedEndpoints: prioritizedEndpoints,
|
||
);
|
||
|
||
test('startPlayback without a dvrKey returns null (tune requires a DVR)', () async {
|
||
final client = makeClient((request) async => fail('no request expected'));
|
||
addTearDown(client.close);
|
||
|
||
expect(await client.liveTv.startPlayback('ch-1'), isNull);
|
||
});
|
||
|
||
test('startPlayback tunes and exposes program + capture buffer; URL is built lazily', () async {
|
||
final requests = <String>[];
|
||
final client = makeClient((request) async {
|
||
requests.add(request.url.path);
|
||
if (request.url.path.endsWith('/tune')) {
|
||
return jsonResponse(tuneResponse());
|
||
}
|
||
return jsonResponse(const {});
|
||
});
|
||
addTearDown(client.close);
|
||
|
||
final session = await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1');
|
||
|
||
expect(session, isNotNull);
|
||
expect(session!.program.id, 'prog-1');
|
||
expect(session.program.durationMs, 1800000);
|
||
expect(session.program.beginsAt, 1700000000);
|
||
expect(session.captureBuffer, isNotNull);
|
||
expect(session.canTimeShift, isTrue);
|
||
// Tune only — no transcode decision until the caller asks for a URL
|
||
// (a watch-from-start dialog sits between the two).
|
||
expect(requests, ['/livetv/dvrs/dvr-1/channels/ch-1/tune']);
|
||
});
|
||
|
||
test('streamUrlAt builds live-edge and offset HLS URLs against one transcode session', () async {
|
||
final client = makeClient((request) async {
|
||
if (request.url.path.endsWith('/tune')) {
|
||
return jsonResponse(tuneResponse());
|
||
}
|
||
if (request.url.path == '/video/:/transcode/universal/decision') {
|
||
return http.Response('ok', 200);
|
||
}
|
||
return jsonResponse(const {});
|
||
});
|
||
addTearDown(client.close);
|
||
|
||
final session = (await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1'))!;
|
||
|
||
final liveEdge = await session.streamUrlAt();
|
||
final shifted = await session.streamUrlAt(offsetSeconds: 90);
|
||
|
||
expect(liveEdge, isNotNull);
|
||
final liveEdgeUri = Uri.parse(liveEdge!);
|
||
expect(liveEdgeUri.path, '/video/:/transcode/universal/start.m3u8');
|
||
expect(liveEdgeUri.queryParameters['path'], '/livetv/sessions/session-abc');
|
||
expect(liveEdgeUri.queryParameters['protocol'], 'hls');
|
||
expect(liveEdgeUri.queryParameters['X-Plex-Incomplete-Segments'], '1');
|
||
expect(liveEdgeUri.queryParameters.containsKey('X-Plex-Chunked'), isFalse);
|
||
// Live TV deliberately keeps the TS target with the broadcast codecs:
|
||
// live sessions copy hevc/mpeg2video channels, unlike the VOD target
|
||
// which moved to fMP4 (issue #1859).
|
||
expect(liveEdgeUri.queryParameters['X-Plex-Client-Profile-Extra'], contains('protocol=hls&container=mpegts'));
|
||
expect(
|
||
liveEdgeUri.queryParameters['X-Plex-Client-Profile-Extra'],
|
||
contains('videoCodec=h264%2Chevc%2Cmpeg2video'),
|
||
);
|
||
expect(liveEdgeUri.queryParameters['subtitles'], 'none');
|
||
expect(liveEdgeUri.queryParameters.containsKey('subtitleStreamID'), isFalse);
|
||
expect(liveEdgeUri.queryParameters.containsKey('advancedSubtitles'), isFalse);
|
||
expect(liveEdgeUri.queryParameters['X-Plex-Token'], 'tok');
|
||
expect(liveEdgeUri.queryParameters.containsKey('offset'), isFalse);
|
||
|
||
final shiftedUri = Uri.parse(shifted!);
|
||
expect(shiftedUri.queryParameters['offset'], '90');
|
||
// Same transcode session across rebuilds so the server reuses its
|
||
// capture buffer.
|
||
expect(shiftedUri.queryParameters['session'], liveEdgeUri.queryParameters['session']);
|
||
});
|
||
|
||
test('reportTimeline targets the tuned program and grows duration to the position', () async {
|
||
Map<String, String>? timelineQuery;
|
||
final client = makeClient((request) async {
|
||
if (request.url.path.endsWith('/tune')) {
|
||
return jsonResponse(tuneResponse());
|
||
}
|
||
if (request.url.path == '/:/timeline') {
|
||
timelineQuery = request.url.queryParameters;
|
||
return jsonResponse({
|
||
'MediaContainer': {
|
||
'TranscodeSession': [
|
||
{'timeStamp': '1700000100', 'minOffsetAvailable': '0', 'maxOffsetAvailable': '300'},
|
||
],
|
||
},
|
||
});
|
||
}
|
||
return jsonResponse(const {});
|
||
});
|
||
addTearDown(client.close);
|
||
|
||
final session = (await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1'))!;
|
||
// Position past the program duration — Plex 400s when time > duration
|
||
// (Tunarr-style short synthetic programs), so duration must grow.
|
||
final updated = await session.reportTimeline(state: 'playing', positionMs: 2000000, durationMs: 1800000);
|
||
|
||
expect(timelineQuery!['ratingKey'], 'prog-1');
|
||
expect(timelineQuery!['key'], '/livetv/sessions/session-abc');
|
||
expect(timelineQuery!['state'], 'playing');
|
||
expect(timelineQuery!['time'], '2000000');
|
||
expect(timelineQuery!['duration'], '2000000');
|
||
expect(updated, isNotNull);
|
||
expect(updated!.seekableDurationSeconds, 300);
|
||
});
|
||
|
||
test('reportTimeline does not fail over because it keeps the active live session alive', () async {
|
||
final requests = <Uri>[];
|
||
final client = makeClient((request) async {
|
||
requests.add(request.url);
|
||
if (request.url.path.endsWith('/tune')) {
|
||
return jsonResponse(tuneResponse());
|
||
}
|
||
if (request.url.path == '/:/timeline') {
|
||
throw http.ClientException('temporary timeline DNS failure', request.url);
|
||
}
|
||
return jsonResponse(const {});
|
||
}, prioritizedEndpoints: const ['https://plex.example.com', 'https://fallback.example.com']);
|
||
addTearDown(client.close);
|
||
|
||
final session = (await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1'))!;
|
||
|
||
await expectLater(
|
||
session.reportTimeline(state: 'playing', positionMs: 10000, durationMs: 1800000),
|
||
throwsA(isA<MediaServerHttpException>()),
|
||
);
|
||
expect(requests.where((uri) => uri.path == '/:/timeline'), hasLength(1));
|
||
expect(client.config.baseUrl, 'https://plex.example.com');
|
||
});
|
||
|
||
test('recover re-tunes and the fresh session builds degraded URLs', () async {
|
||
var tunes = 0;
|
||
final client = makeClient((request) async {
|
||
if (request.url.path.endsWith('/tune')) {
|
||
tunes++;
|
||
return jsonResponse(tuneResponse());
|
||
}
|
||
if (request.url.path == '/video/:/transcode/universal/decision') {
|
||
return http.Response('ok', 200);
|
||
}
|
||
return jsonResponse(const {});
|
||
});
|
||
addTearDown(client.close);
|
||
|
||
final session = (await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1'))!;
|
||
final recovered = await session.recover(directStream: false, directStreamAudio: false);
|
||
|
||
expect(tunes, 2);
|
||
final url = await recovered!.streamUrlAt();
|
||
final uri = Uri.parse(url!);
|
||
expect(uri.queryParameters['directStream'], '0');
|
||
expect(uri.queryParameters['directStreamAudio'], '0');
|
||
});
|
||
});
|
||
|
||
group('Jellyfin live playback session', () {
|
||
JellyfinConnection conn() => JellyfinConnection(
|
||
id: 'srv-1/user-1',
|
||
baseUrl: 'https://jf.example.com',
|
||
serverName: 'Home',
|
||
serverMachineId: 'srv-1',
|
||
userId: 'user-1',
|
||
userName: 'edde',
|
||
accessToken: 'tok-abc',
|
||
deviceId: 'dev-xyz',
|
||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||
);
|
||
|
||
test('startPlayback negotiates one HLS URL; no time-shift; recover reuses it', () async {
|
||
final client = JellyfinClient.forTesting(
|
||
connection: conn(),
|
||
httpClient: MockClient((request) async {
|
||
if (request.url.path.contains('PlaybackInfo')) {
|
||
return jsonResponse({
|
||
'PlaySessionId': 'play-1',
|
||
'MediaSources': [
|
||
{
|
||
'Id': 'source-1',
|
||
'Container': 'ts',
|
||
'LiveStreamId': 'live-1',
|
||
'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=play-1',
|
||
},
|
||
],
|
||
});
|
||
}
|
||
return jsonResponse(const {});
|
||
}),
|
||
);
|
||
addTearDown(client.close);
|
||
|
||
final session = await client.liveTv.startPlayback('channel-1');
|
||
|
||
expect(session, isNotNull);
|
||
expect(session!.program.id, isNull);
|
||
expect(session.captureBuffer, isNull);
|
||
expect(session.canTimeShift, isFalse);
|
||
expect(session.backgroundPolicy, LiveTvBackgroundPolicy.stopAndExit);
|
||
|
||
final url = await session.streamUrlAt();
|
||
expect(url, isNotNull);
|
||
expect(Uri.parse(url!).path, '/Videos/channel-1/live.m3u8');
|
||
expect(Uri.parse(url).queryParameters['PlaySessionId'], 'play-1');
|
||
|
||
// Time-shift unsupported — an offset request must not silently play live.
|
||
expect(await session.streamUrlAt(offsetSeconds: 60), isNull);
|
||
|
||
// Recovery re-opens the negotiated HLS URL.
|
||
expect(await session.recover(directStream: false, directStreamAudio: false), same(session));
|
||
});
|
||
|
||
test('startPlayback propagates status and cancellation failures', () async {
|
||
final handlers = <(String, Future<http.Response> Function(http.Request))>[
|
||
('401', (_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})),
|
||
('500', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})),
|
||
('cancelled', (request) async => throw http.RequestAbortedException(request.url)),
|
||
];
|
||
|
||
for (final (name, handler) in handlers) {
|
||
final client = JellyfinClient.forTesting(connection: conn(), httpClient: MockClient(handler));
|
||
addTearDown(client.close);
|
||
await expectLater(
|
||
client.liveTv.startPlayback('channel-1'),
|
||
throwsA(isA<MediaServerHttpException>()),
|
||
reason: name,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('malformed successful playback data throws distinctly', () async {
|
||
final missingSources = JellyfinClient.forTesting(
|
||
connection: conn(),
|
||
httpClient: MockClient((_) async => jsonResponse({'PlaySessionId': 'play-1'})),
|
||
);
|
||
addTearDown(missingSources.close);
|
||
await expectLater(
|
||
missingSources.liveTv.startPlayback('channel-1'),
|
||
throwsA(
|
||
isA<MediaServerHttpException>()
|
||
.having((error) => error.statusCode, 'statusCode', 200)
|
||
.having((error) => error.responseData, 'responseData', isNull),
|
||
),
|
||
);
|
||
|
||
final malformedSource = JellyfinClient.forTesting(
|
||
connection: conn(),
|
||
httpClient: MockClient(
|
||
(_) async => jsonResponse({
|
||
'MediaSources': ['invalid'],
|
||
}),
|
||
),
|
||
);
|
||
addTearDown(malformedSource.close);
|
||
await expectLater(
|
||
malformedSource.liveTv.startPlayback('channel-1'),
|
||
throwsA(
|
||
isA<PlaybackException>().having((error) => error.reason, 'reason', PlaybackFailureReason.invalidPlaybackData),
|
||
),
|
||
);
|
||
});
|
||
|
||
test('only a valid empty source list returns no live stream', () async {
|
||
final client = JellyfinClient.forTesting(
|
||
connection: conn(),
|
||
httpClient: MockClient((_) async => jsonResponse({'MediaSources': []})),
|
||
);
|
||
addTearDown(client.close);
|
||
|
||
expect(await client.liveTv.startPlayback('channel-1'), isNull);
|
||
});
|
||
});
|
||
}
|