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
+26 -7
View File
@@ -758,20 +758,39 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
return _mapItem(data);
} on MediaServerHttpException catch (e) {
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;
} 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);
try {
final cached = await cache.get(ServerId(cacheServerId), endpoint);
if (cached is Map<String, dynamic>) return _mapItem(cached);
} catch (cacheError, st) {
appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: cacheError, stackTrace: st);
}
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 {
final cached = await cache.get(ServerId(cacheServerId), endpoint);
if (cached is Map<String, dynamic>) return _mapItem(cached);
} catch (e, st) {
appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: e, stackTrace: st);
}
return null;
}
@override
Future<List<MediaItem>> fetchChildren(String parentId) => _fetchChildrenInternal(parentId);