Files
plezy/test/services/live_tv_playback_session_test.dart
T
edde746 ea356a6112 fix(plex): use fMP4 HLS for video transcodes so HEVC presets stop corrupting
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
2026-08-10 20:38:45 +02:00

357 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});
}