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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user