diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index e1b74515..9bc79096 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -23,6 +23,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { Duration? timeout, // ignore: unused_element_parameter AbortController? abort, + bool allowEndpointFailover = true, }); Map? _getMediaContainer(MediaServerResponse response); @@ -131,6 +132,12 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { 'playbackTime': playbackTime, 'X-Plex-Session-Identifier': sessionIdentifier, }, + // A live timeline ping is a transcode-session keepalive, not a general + // library fetch. If one ping hits a transient transport/DNS failure, + // endpoint failover can close the client held by the active session; the + // server then expires the tuner a few minutes later. Let the next + // heartbeat retry the current session endpoint instead. + allowEndpointFailover: false, ); if (response.statusCode != 200) { appLogger.e('Live timeline returned ${response.statusCode}: ${response.data}'); diff --git a/test/services/live_tv_playback_session_test.dart b/test/services/live_tv_playback_session_test.dart index da913434..57e20e83 100644 --- a/test/services/live_tv_playback_session_test.dart +++ b/test/services/live_tv_playback_session_test.dart @@ -6,6 +6,7 @@ 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/models/plex/plex_config.dart'; import 'package:plezy/services/jellyfin_client.dart'; @@ -58,7 +59,10 @@ void main() { }, }; - PlexClient makeClient(Future Function(http.Request request) handler) => PlexClient.forTesting( + PlexClient makeClient( + Future Function(http.Request request) handler, { + List? prioritizedEndpoints, + }) => PlexClient.forTesting( config: PlexConfig( baseUrl: 'https://plex.example.com', token: 'tok', @@ -69,6 +73,7 @@ void main() { ), serverId: ServerId('machine-1'), httpClient: MockClient(handler), + prioritizedEndpoints: prioritizedEndpoints, ); test('startPlayback without a dvrKey returns null (tune requires a DVR)', () async { @@ -159,6 +164,28 @@ void main() { 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 {