fix(player): retry episode advances that fail on a transient server blip

An EOF-driven advance does one cold metadata fetch with a single endpoint
failover and no transient retry. When connectivity to the server drops for
the ~20s that fetch needs (issue log: both plex.direct endpoints connect
timed out, then the running stream's own TLS socket died), the reload
rolled back to the finished episode's last frame: black screen, progress
bar parked at the end, no way forward but the transport controls - while
pressing Next by hand seconds later succeeded. The per-item metadata cache
row could not absorb the blip either, because adjacency comes from queue
containers, so the next episode's row is cold at the exact moment the
transition needs it.

Three changes:

- A failed in-place reload now records its classified failure reason, and
  an advance that ran with the completion latch set re-presents the Play
  Next prompt when that reason is serverUnavailable. With auto-play
  enabled the countdown re-fires the advance up to two times before the
  prompt goes manual-only; Watch Together sessions and mid-episode Next
  presses (whose rolled-back stream is still valid) keep the existing
  handling. playNextRetryPresentation owns the decision and is unit-tested.

- Committing adjacency now best-effort prefetches the next episode's full
  metadata row through fetchItem, which writes the exact row playback
  initialization falls back to on both backends (Plex: same cache key and
  full playback query shape; Jellyfin: the /Users/{uid}/Items/{id} row the
  playback bundle reads). A warm row turns a blip at the transition into a
  normal start.

- JellyfinClient.fetchItem's documented "pure transport error -> cached
  row" fallback was dead code: the HTTP layer wraps transport errors into
  MediaServerHttpException, which the first catch rethrew unconditionally.
  Status-less, non-cancelled failures now take the fallback; answered
  requests (401/403/5xx) and cancellations surface unchanged.

Verified with new contract tests (Plex: cold row fails transiently ->
fetchItem primes -> the same failing fetch serves playback from cache;
Jellyfin: primed row survives a transport failure into fetchPlaybackBundle)
plus the full test/screens/video_player and test/services suites and
analyzer parity.

close #1867
This commit is contained in:
edde746
2026-08-11 09:08:07 +02:00
parent 83c50d93a2
commit 6663353895
9 changed files with 316 additions and 11 deletions
@@ -1,5 +1,6 @@
import 'dart:math' as math; import 'dart:math' as math;
import '../../providers/playback_state_provider.dart'; import '../../providers/playback_state_provider.dart';
import '../../services/playback_initialization_types.dart';
/// Position must be within this many ms of the best-known duration for a /// Position must be within this many ms of the best-known duration for a
/// player EOF signal to count as the real end of the media. /// player EOF signal to count as the real end of the media.
@@ -28,6 +29,54 @@ CompletionNavigationAction completionNavigationAction({
return CompletionNavigationAction.exit; return CompletionNavigationAction.exit;
} }
/// How many times an auto-play countdown may re-fire a transiently failed
/// EOF advance before the Play Next prompt goes manual-only (#1867). Retries
/// are spaced by the countdown plus the failed attempt itself (connect
/// timeout + endpoint failover), so two retries cover a short connectivity
/// blip without looping against a server that is genuinely down.
const int maxPlayNextTransientRetries = 2;
/// How a failed EOF-driven advance should be re-presented to the user.
enum PlayNextRetryPresentation {
/// Keep the existing failure handling (rollback + error snackbar).
none,
/// Re-present the Play Next prompt without a countdown — retry is the
/// user's move.
manual,
/// Re-present the Play Next prompt with the auto-play countdown so the
/// advance retries by itself.
countdown,
}
/// Decide whether a failed episode advance re-presents the Play Next prompt.
///
/// A transient server blip at the exact moment of an EOF transition used to
/// park the screen on the finished episode's last frame with no way forward
/// but the transport controls (#1867) — while a retry seconds later
/// typically succeeds. Only EOF-driven advances qualify: a mid-episode Next
/// press rolls back to a still-valid playing stream, and non-transient
/// failures (missing file, auth) must not retry-loop. Watch Together
/// sessions never auto-retry — the sync layer owns transitions — but the
/// manual prompt remains available, matching the Next button.
PlayNextRetryPresentation playNextRetryPresentation({
required bool wasAtCompletion,
required PlaybackFailureReason? failureReason,
required bool hasNext,
required bool autoPlayEnabled,
required bool inWatchTogetherSession,
required int autoRetriesUsed,
int maxAutoRetries = maxPlayNextTransientRetries,
}) {
if (!wasAtCompletion || !hasNext) return PlayNextRetryPresentation.none;
if (failureReason != PlaybackFailureReason.serverUnavailable) {
return PlayNextRetryPresentation.none;
}
final autoRetry = autoPlayEnabled && !inWatchTogetherSession && autoRetriesUsed < maxAutoRetries;
return autoRetry ? PlayNextRetryPresentation.countdown : PlayNextRetryPresentation.manual;
}
/// Classify a player EOF signal against the best-known media duration. /// Classify a player EOF signal against the best-known media duration.
/// ///
/// mpv reports a clean EOF when a network stream dies mid-file (a reaped /// mpv reports a clean EOF when a network stream dies mid-file (a reaped
@@ -53,6 +53,13 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
if (!mounted) return; if (!mounted) return;
if (_nextEpisode == null || _isLoadingNext) return; if (_nextEpisode == null || _isLoadingNext) return;
// EOF-driven advances (prompt confirm, auto-play countdown, PiP) run with
// the completion latch set; a mid-episode Next press does not. Captured
// before the prompt state below is cleared — a transiently failed advance
// from EOF re-presents the Play Next prompt instead of parking on the
// finished episode's last frame (#1867).
final wasAtCompletion = _completionLatch.triggered;
_autoPlayTimer?.cancel(); _autoPlayTimer?.cancel();
_unfocusPlayNextPrompt(); _unfocusPlayNextPrompt();
_dismissStillWatching(); _dismissStillWatching();
@@ -64,7 +71,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
_showPlayNextDialog = false; _showPlayNextDialog = false;
}); });
await _navigateToEpisode(_nextEpisode!); final outcome = await _navigateToEpisode(_nextEpisode!);
if (outcome == _MediaReloadOutcome.failed) {
_presentPlayNextRetryPrompt(wasAtCompletion: wasAtCompletion);
}
} }
Future<void> _playPrevious() async { Future<void> _playPrevious() async {
@@ -132,11 +142,17 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
} }
/// Navigates to a new episode by reusing the current player whenever possible. /// Navigates to a new episode by reusing the current player whenever possible.
Future<void> _navigateToEpisode(MediaItem episodeMetadata) async { ///
/// Returns the reload outcome so [_playNext] can distinguish a failed
/// in-place swap (previous session still on screen) from rejected or
/// superseded attempts. The screen-replacement fallback reports
/// [_MediaReloadOutcome.rejected]: no in-place reload ran.
Future<_MediaReloadOutcome> _navigateToEpisode(MediaItem episodeMetadata) async {
_lastMediaReloadFailureReason = null;
final currentPlayer = player; final currentPlayer = player;
if (currentPlayer == null) { if (currentPlayer == null) {
if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata)); if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata));
return; return _MediaReloadOutcome.rejected;
} }
// Callers fire this without awaiting (auto-play countdown, PiP, the prompt), so an escaping // Callers fire this without awaiting (auto-play countdown, PiP, the prompt), so an escaping
@@ -174,7 +190,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
nativeTrack: currentPlayer.state.track.secondarySubtitle, nativeTrack: currentPlayer.state.track.secondarySubtitle,
sessionPreference: _sessionSecondarySubtitlePreference, sessionPreference: _sessionSecondarySubtitlePreference,
); );
await _reloadMediaInPlace( return await _reloadMediaInPlace(
metadata: episodeMetadata, metadata: episodeMetadata,
selectedMediaIndex: _effectiveSelectedMediaIndex, selectedMediaIndex: _effectiveSelectedMediaIndex,
selectedMediaSourceId: null, selectedMediaSourceId: null,
@@ -193,6 +209,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
appLogger.e('Failed to navigate to the next item', error: e, stackTrace: stackTrace); appLogger.e('Failed to navigate to the next item', error: e, stackTrace: stackTrace);
_clearEpisodeLoadingFlags(); _clearEpisodeLoadingFlags();
if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
return _MediaReloadOutcome.failed;
} }
} }
@@ -939,6 +956,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
_nextEpisode = null; _nextEpisode = null;
_previousEpisode = null; _previousEpisode = null;
_nextEpisodeStatus = QueueNavigationStatus.failed; _nextEpisodeStatus = QueueNavigationStatus.failed;
// A successful swap restores the transient-retry budget for the
// next transition (#1867).
_playNextTransientRetryCount = 0;
}); });
try { try {
@@ -956,6 +976,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
return _MediaReloadOutcome.opened; return _MediaReloadOutcome.opened;
} catch (e) { } catch (e) {
if (!isCurrentReload()) return _MediaReloadOutcome.superseded; if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
// Record the classified reason so _playNext can re-present the Play
// Next prompt when an EOF-driven advance merely hit a transient
// server blip (#1867). Non-PlaybackException throws stay null —
// they never qualify for a retry prompt.
_lastMediaReloadFailureReason = e is PlaybackException ? e.reason : null;
_completionLatch.reset(); _completionLatch.reset();
if (!didOpenReplacement) { if (!didOpenReplacement) {
// Nothing was opened: the previous session is still committed, so // Nothing was opened: the previous session is still committed, so
@@ -149,5 +149,34 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
_previousEpisode = adjacentEpisodes.previous; _previousEpisode = adjacentEpisodes.previous;
_nextEpisodeStatus = adjacentEpisodes.nextStatus; _nextEpisodeStatus = adjacentEpisodes.nextStatus;
}); });
_primeNextEpisodePlaybackMetadata(adjacentEpisodes.next);
}
/// Best-effort prefetch of the next episode's full metadata row into the
/// API cache while the current episode plays (#1867).
///
/// Adjacency comes from queue containers, so the per-item metadata row
/// (Plex `/library/metadata/{id}`, Jellyfin `/Users/{uid}/Items/{id}`) is
/// cold at the exact moment the transition needs it. Both backends'
/// [MediaServerClient.fetchItem] fetch network-first and write that same
/// row — the one playback initialization falls back to when the server is
/// transiently unreachable — so a warm row turns a connectivity blip at
/// the transition into a normal start instead of a failed advance.
///
/// Documented best-effort: the transition path performs its own fetch and
/// error handling, so a failed prime costs nothing.
void _primeNextEpisodePlaybackMetadata(MediaItem? next) {
if (next == null || _offlineLibraryMode || !mounted) return;
if (_primedNextEpisodeGlobalKey == next.globalKey) return;
final client = context.tryGetMediaClientForServer(serverIdOrNull(next.serverId));
if (client == null) return;
_primedNextEpisodeGlobalKey = next.globalKey;
unawaited(() async {
try {
await client.fetchItem(next.id);
} catch (e) {
appLogger.d('Next-episode metadata prime failed', error: e);
}
}());
} }
} }
@@ -134,6 +134,58 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
}); });
} }
/// Re-present the Play Next prompt after an EOF-driven advance failed on a
/// transient server blip (#1867).
///
/// The failed reload rolled back to the finished episode's last frame, so
/// without a prompt the screen parks black until the device sleeps — while
/// a retry seconds later typically succeeds (skipping manually did exactly
/// that). [playNextRetryPresentation] owns the decision: only EOF-driven
/// advances that failed with [PlaybackFailureReason.serverUnavailable]
/// qualify, the auto-play countdown re-fires [_playNext] up to
/// [maxPlayNextTransientRetries] times, and after that (with auto-play
/// off, or in a Watch Together session) the prompt waits for a manual
/// retry.
void _presentPlayNextRetryPrompt({required bool wasAtCompletion}) async {
if (!mounted || !_canNavigateMediaItems()) return;
if (_isLoadingNext || _showPlayNextDialog || _showStillWatchingPrompt) return;
// Capture keyboard mode before the async gap, same as _onVideoCompleted.
final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context, listen: false);
final settings = await SettingsService.getInstance();
if (!mounted || _isLoadingNext || _showPlayNextDialog) return;
final presentation = playNextRetryPresentation(
wasAtCompletion: wasAtCompletion,
failureReason: _lastMediaReloadFailureReason,
hasNext: _nextEpisode != null,
autoPlayEnabled: settings.read(SettingsService.autoPlayNextEpisode),
inWatchTogetherSession: _activeWatchTogetherSession() != null,
autoRetriesUsed: _playNextTransientRetryCount,
);
if (presentation == PlayNextRetryPresentation.none) return;
// The failed reload's rollback reset the latch; re-latch so a duplicate
// EOF signal from the parked stream cannot stack a second prompt on top.
if (!_completionLatch.triggered) _completionLatch.latch();
final countdown = presentation == PlayNextRetryPresentation.countdown;
if (countdown) _playNextTransientRetryCount++;
_setPlayerState(() {
_showPlayNextDialog = true;
_autoPlayCountdown = countdown ? 5 : -1;
});
if (isKeyboardMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _playNextConfirmFocusNode.requestFocus();
});
}
if (countdown) _startAutoPlayTimer();
}
void _cancelAutoPlay() { void _cancelAutoPlay() {
_autoPlayTimer?.cancel(); _autoPlayTimer?.cancel();
_unfocusPlayNextPrompt(); _unfocusPlayNextPrompt();
+12
View File
@@ -461,6 +461,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// Retryable sentinel until the fire-and-forget initial adjacency load // Retryable sentinel until the fire-and-forget initial adjacency load
// commits found, boundary, or unavailable. // commits found, boundary, or unavailable.
QueueNavigationStatus _nextEpisodeStatus = QueueNavigationStatus.failed; QueueNavigationStatus _nextEpisodeStatus = QueueNavigationStatus.failed;
// globalKey of the adjacent episode whose playback metadata row was last
// prefetched into the API cache — see _primeNextEpisodePlaybackMetadata.
String? _primedNextEpisodeGlobalKey;
bool _isResolvingCompletionAdjacency = false; bool _isResolvingCompletionAdjacency = false;
bool _isLoadingNext = false; bool _isLoadingNext = false;
bool _isLoadingPrevious = false; bool _isLoadingPrevious = false;
@@ -568,6 +571,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
Timer? _autoPlayTimer; Timer? _autoPlayTimer;
int _autoPlayCountdown = 5; int _autoPlayCountdown = 5;
// Transient episode-transition failure retry (#1867). A failed in-place
// reload records the classified reason here so _playNext can distinguish
// "server momentarily unreachable" (re-present the Play Next prompt,
// optionally with an auto-retry countdown) from terminal failures.
// _navigateToEpisode clears the field before each attempt; the counter
// resets when a reload succeeds.
PlaybackFailureReason? _lastMediaReloadFailureReason;
int _playNextTransientRetryCount = 0;
// End-of-video Play Next latch. Completion comes from the player EOF signal; // End-of-video Play Next latch. Completion comes from the player EOF signal;
// position ticks only re-arm once playback is more than 2s from the end. // position ticks only re-arm once playback is more than 2s from the end.
final CompletionLatch _completionLatch = CompletionLatch(rearmWindowMs: 2000); final CompletionLatch _completionLatch = CompletionLatch(rearmWindowMs: 2000);
+24 -5
View File
@@ -758,18 +758,37 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
return _mapItem(data); return _mapItem(data);
} on MediaServerHttpException catch (e) { } on MediaServerHttpException catch (e) {
if (e.statusCode == 404) return null; if (e.statusCode == 404) return null;
// An answered request (401/403/5xx) or a client-side cancellation must
// surface as-is. Pure transport failures (dead socket, DNS, connect
// timeout) carry no status code — and the HTTP layer wraps them into
// [MediaServerHttpException], so they arrive here rather than in the
// generic catch below. Apply the documented cache fallback (#1867).
if (e.statusCode != null || e.isCancellation) rethrow;
appLogger.w('JellyfinClient.fetchItem network call failed', error: e);
final cached = await _cachedItemFallback(endpoint);
if (cached != null) return cached;
rethrow; rethrow;
} catch (e) { } catch (e) {
// Transport-layer failure: socket error, DNS, TLS, etc. Try cache. // Non-HTTP failure while handling the response (e.g. mapping). Same
// best-effort fallback before surfacing.
appLogger.w('JellyfinClient.fetchItem network call failed', error: e); appLogger.w('JellyfinClient.fetchItem network call failed', error: e);
final cached = await _cachedItemFallback(endpoint);
if (cached != null) return cached;
rethrow;
}
}
/// Best-effort cached-row read for [_fetchItemOnce]'s failure fallbacks.
/// Returns null on miss or on a cache/mapping error (logged) so the caller
/// rethrows its original failure.
Future<MediaItem?> _cachedItemFallback(String endpoint) async {
try { try {
final cached = await cache.get(ServerId(cacheServerId), endpoint); final cached = await cache.get(ServerId(cacheServerId), endpoint);
if (cached is Map<String, dynamic>) return _mapItem(cached); if (cached is Map<String, dynamic>) return _mapItem(cached);
} catch (cacheError, st) { } catch (e, st) {
appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: cacheError, stackTrace: st); appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: e, stackTrace: st);
}
rethrow;
} }
return null;
} }
@override @override
@@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/video_player/completion_latch.dart'; import 'package:plezy/screens/video_player/completion_latch.dart';
import 'package:plezy/services/playback_initialization_types.dart';
void main() { void main() {
CompletionLatch latch() => CompletionLatch(rearmWindowMs: 2000); CompletionLatch latch() => CompletionLatch(rearmWindowMs: 2000);
@@ -160,4 +161,59 @@ void main() {
); );
}); });
}); });
group('playNextRetryPresentation', () {
PlayNextRetryPresentation resolve({
bool wasAtCompletion = true,
PlaybackFailureReason? failureReason = PlaybackFailureReason.serverUnavailable,
bool hasNext = true,
bool autoPlayEnabled = true,
bool inWatchTogetherSession = false,
int autoRetriesUsed = 0,
}) {
return playNextRetryPresentation(
wasAtCompletion: wasAtCompletion,
failureReason: failureReason,
hasNext: hasNext,
autoPlayEnabled: autoPlayEnabled,
inWatchTogetherSession: inWatchTogetherSession,
autoRetriesUsed: autoRetriesUsed,
);
}
test('transient failure at EOF re-presents with a countdown', () {
expect(resolve(), PlayNextRetryPresentation.countdown);
});
test('a mid-episode Next press keeps the plain failure handling', () {
// The rolled-back stream is still valid and playing there — no prompt.
expect(resolve(wasAtCompletion: false), PlayNextRetryPresentation.none);
});
test('non-transient failures never retry-loop', () {
for (final reason in PlaybackFailureReason.values) {
if (reason == PlaybackFailureReason.serverUnavailable) continue;
expect(resolve(failureReason: reason), PlayNextRetryPresentation.none, reason: '$reason');
}
// Pre-classification throws carry no reason at all.
expect(resolve(failureReason: null), PlayNextRetryPresentation.none);
});
test('no next episode means nothing to re-present', () {
expect(resolve(hasNext: false), PlayNextRetryPresentation.none);
});
test('the countdown budget exhausts into a manual prompt', () {
expect(resolve(autoRetriesUsed: maxPlayNextTransientRetries - 1), PlayNextRetryPresentation.countdown);
expect(resolve(autoRetriesUsed: maxPlayNextTransientRetries), PlayNextRetryPresentation.manual);
});
test('auto-play off presents the prompt without a countdown', () {
expect(resolve(autoPlayEnabled: false), PlayNextRetryPresentation.manual);
});
test('Watch Together sessions never auto-retry but keep the manual prompt', () {
expect(resolve(inWatchTogetherSession: true), PlayNextRetryPresentation.manual);
});
});
} }
@@ -227,5 +227,35 @@ void main() {
expect(bundle!.chapters, isEmpty); expect(bundle!.chapters, isEmpty);
client.close(); client.close();
}); });
test('fetchItem-primed row serves fetchPlaybackBundle across a transport failure (#1867)', () async {
final body = jsonEncode({
'Id': 'item-9',
'Type': 'Movie',
'MediaSources': [
{'Id': 'src-9', 'Container': 'mkv'},
],
});
var failNetwork = false;
final client = testJellyfinClient(
connection: _conn(),
handler: (_) async {
if (failNetwork) throw http.ClientException('connect refused');
return http.Response(body, 200, headers: {'content-type': 'application/json'});
},
);
addTearDown(client.close);
// Adjacency discovery primes the per-item row.
expect(await client.fetchItem('item-9'), isNotNull);
// A pure transport failure (wrapped by the HTTP layer into a
// status-less MediaServerHttpException) must fall back to that row
// instead of failing the transition.
failNetwork = true;
final bundle = await client.fetchPlaybackBundle('item-9');
expect(bundle, isNotNull);
expect(bundle!.selectedSourceId, 'src-9');
});
}); });
} }
@@ -1636,6 +1636,39 @@ void main() {
expect(data.videoUrl, contains('/library/parts/10/file.mkv')); expect(data.videoUrl, contains('/library/parts/10/file.mkv'));
}); });
test('fetchItem primes the row a transiently failing playback fetch falls back to (#1867)', () async {
var failNetwork = false;
final client = makeClient((request) async {
if (failNetwork) {
throw MediaServerHttpException(
type: MediaServerHttpErrorType.connectionTimeout,
message: 'connect timed out',
);
}
return http.Response(jsonEncode(playableBody()), 200, headers: {'content-type': 'application/json'});
});
addTearDown(client.close);
// Cold row: a connectivity blip at the transition surfaces as a
// transient failure — this is the dead-end from the issue.
failNetwork = true;
await expectLater(
client.getVideoPlaybackData('42'),
throwsA(isA<MediaServerHttpException>().having((error) => error.isTransient, 'isTransient', isTrue)),
);
// Adjacency discovery primes the row through fetchItem: same cache key,
// same full playback query shape.
failNetwork = false;
expect(await client.fetchItem('42'), isNotNull);
// The same blip now falls back to the primed row and playback proceeds.
failNetwork = true;
final data = await client.getVideoPlaybackData('42');
expect(data.hasValidVideoUrl, isTrue);
expect(data.videoUrl, contains('/library/parts/10/file.mkv'));
});
test('external URL and download resolution propagate typed request failures', () async { test('external URL and download resolution propagate typed request failures', () async {
final client = makeClient((_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})); final client = makeClient((_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'}));
addTearDown(client.close); addTearDown(client.close);