diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 36a05e72..7a2ecb6c 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -2602,8 +2602,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin // For live TV, player position/duration are unreliable (often 0). // Use playbackTime as time, and program duration from tune metadata. + // Plex rejects timeline pings where time > duration; grow duration to + // match — otherwise Tunarr-style short synthetic programs 400 mid-stream. final time = playbackTime; - final duration = _liveDurationMs ?? 0; + final duration = max(_liveDurationMs ?? 0, time); final updatedBuffer = await client.updateLiveTimeline( ratingKey: ratingKey, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 47f31848..fc54a438 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -262,11 +262,21 @@ class PlexClient { } bool _shouldAttemptFailover(PlexHttpException e) { + if (e.isTransient) return true; final sc = e.statusCode; - return e.type == PlexHttpErrorType.connectionTimeout || - e.type == PlexHttpErrorType.receiveTimeout || - e.type == PlexHttpErrorType.connectionError || - (sc != null && sc >= 500 && sc <= 599); + return sc != null && sc >= 500 && sc <= 599; + } + + /// POST the tune endpoint with one retry on transient HTTP failure. + Future _postTuneWithRetry(String path, String sessionIdentifier) async { + final query = {'X-Plex-Session-Identifier': sessionIdentifier}; + try { + return await _http.post(path, queryParameters: query, timeout: ConnectionTimeouts.tune); + } on PlexHttpException catch (e) { + if (!e.isTransient) rethrow; + appLogger.w('Tune channel: transient failure, retrying once', error: e); + return await _http.post(path, queryParameters: query, timeout: ConnectionTimeouts.tune); + } } /// Fetch /media/providers and parse libraries + EPG providers from the response. @@ -2658,9 +2668,9 @@ class PlexClient { try { final sessionIdentifier = generateSessionIdentifier(); - final response = await _http.post( + final response = await _postTuneWithRetry( '/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune', - queryParameters: {'X-Plex-Session-Identifier': sessionIdentifier}, + sessionIdentifier, ); if (response.statusCode >= 400) { diff --git a/lib/utils/connection_constants.dart b/lib/utils/connection_constants.dart index 58049c34..4691aecd 100644 --- a/lib/utils/connection_constants.dart +++ b/lib/utils/connection_constants.dart @@ -11,6 +11,10 @@ class ConnectionTimeouts { /// HTTP connect timeout for individual HTTP requests to a Plex server. static const connect = Duration(seconds: 10); + /// HTTP timeout for the live-TV tune POST. Matches Plex web's value — the + /// default 10s connect budget is too tight on Fire-TV cold starts. + static const tune = Duration(seconds: 30); + /// Per-server connection budget: preferred probe + race + HTTPS upgrade attempt + 1s buffer. static const perServerConnect = Duration(milliseconds: 1500 + 2000 + 2000 + 1000); diff --git a/lib/utils/plex_http_exception.dart b/lib/utils/plex_http_exception.dart index 02a952a0..7252f943 100644 --- a/lib/utils/plex_http_exception.dart +++ b/lib/utils/plex_http_exception.dart @@ -45,6 +45,12 @@ class PlexHttpException implements Exception { return PlexHttpException(type: PlexHttpErrorType.unknown, message: error.toString(), requestUri: uri); } + /// Whether the error looks transient (network/timeout) and worth retrying. + bool get isTransient => + type == PlexHttpErrorType.connectionTimeout || + type == PlexHttpErrorType.connectionError || + type == PlexHttpErrorType.receiveTimeout; + @override String toString() => 'PlexHttpException(${type.name}: $message)'; } diff --git a/test/utils/plex_http_exception_test.dart b/test/utils/plex_http_exception_test.dart index 2e6cc393..263daf27 100644 --- a/test/utils/plex_http_exception_test.dart +++ b/test/utils/plex_http_exception_test.dart @@ -84,4 +84,28 @@ void main() { expect(e.toString(), 'PlexHttpException(cancelled: halt)'); }); }); + + group('PlexHttpException.isTransient', () { + PlexHttpException ex(PlexHttpErrorType t) => PlexHttpException(type: t); + + test('connectionTimeout is transient', () { + expect(ex(PlexHttpErrorType.connectionTimeout).isTransient, isTrue); + }); + + test('receiveTimeout is transient', () { + expect(ex(PlexHttpErrorType.receiveTimeout).isTransient, isTrue); + }); + + test('connectionError is transient', () { + expect(ex(PlexHttpErrorType.connectionError).isTransient, isTrue); + }); + + test('cancelled is NOT transient (user-driven abort)', () { + expect(ex(PlexHttpErrorType.cancelled).isTransient, isFalse); + }); + + test('unknown is NOT transient', () { + expect(ex(PlexHttpErrorType.unknown).isTransient, isFalse); + }); + }); }