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 body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); group('Plex live playback session', () { Map 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 Function(http.Request request) handler, { List? 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 = []; 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? 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 = []; 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()), ); 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 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()), 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() .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().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); }); }); }