Files
plezy/lib/screens/video_player/parts/episode_queue.dart
T
edde746 6663353895 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
2026-08-11 09:08:07 +02:00

183 lines
8.0 KiB
Dart

part of '../../video_player_screen.dart';
extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
/// Ensure a play queue exists for sequential episode playback
Future<void> _ensurePlayQueue() async {
if (!mounted) return;
// Download/offline library mode uses the local downloaded queue instead.
if (_offlineLibraryMode) return;
// Skip play queue for live TV (would interfere with tuner session)
if (widget.isLive) return;
if (!_currentMetadata.isEpisode) {
return;
}
// Plex-only — Jellyfin's local queue is published by
// EpisodeNavigationService._ensureLocalEpisodeQueue from
// _loadAdjacentEpisodes, so this method is a no-op for it.
if (_currentMetadata.backend != MediaBackend.plex) return;
try {
final client = context.getPlexClientForServer(ServerId(_currentMetadata.serverId!));
final playbackState = context.read<PlaybackStateProvider>();
// For episodes, grandparentId points to the show
final showRatingKey = _currentMetadata.grandparentId;
if (showRatingKey == null) {
appLogger.d('Episode missing grandparentId, skipping play queue creation');
return;
}
// Preserve any queue this item belongs to — playlist, collection,
// or same-show queue. `isItemInActiveQueue` is the same gate
// VideoPlayerScreen.initState uses; a context-key check alone would
// wipe a playlist queue (its key is the playlist id, not the show).
// Only when the active queue is genuinely stale (item not in it)
// do we clobber and create a fresh show queue.
if (playbackState.isItemInActiveQueue(_currentMetadata)) {
playbackState.setCurrentItem(_currentMetadata);
appLogger.d('Using existing play queue (context: ${playbackState.shuffleContextKey})');
return;
}
if (playbackState.isQueueActive) {
appLogger.d('Resetting stale play queue (was: ${playbackState.shuffleContextKey}, now: $showRatingKey)');
playbackState.clearShuffle();
}
appLogger.d('Creating sequential play queue for show $showRatingKey');
final playQueue = await client.createShowPlayQueue(
showRatingKey: showRatingKey,
shuffle: 0,
startingEpisodeKey: _currentMetadata.id,
librarySectionID: _currentMetadata.libraryId,
librarySectionTitle: _currentMetadata.libraryTitle,
);
if (playQueue != null && playQueue.items != null && playQueue.items!.isNotEmpty) {
await playbackState.setPlaybackFromPlayQueue(playQueue, showRatingKey);
playbackState.setPlayQueueWindowFetcher(
(id, {center, window = 50}) => client.getPlayQueue(
id,
center: center,
window: window,
librarySectionID: _currentMetadata.libraryId,
librarySectionTitle: _currentMetadata.libraryTitle,
),
);
appLogger.d('Sequential play queue created with ${playQueue.items!.length} items');
} else {
appLogger.w('Plex returned no usable sequential play queue; falling back to a local series queue');
}
} catch (e, st) {
appLogger.w('Could not create Plex play queue; falling back to a local series queue', error: e, stackTrace: st);
}
}
Future<AdjacentEpisodes> _loadAdjacentEpisodes({MediaItem? metadata, _PlaybackAttempt? attempt}) async {
if (!mounted || widget.isLive) return const AdjacentEpisodes.unavailable();
final targetMetadata = metadata ?? _currentMetadata;
try {
final adjacentEpisodes = _offlineLibraryMode
? _loadAdjacentEpisodesOffline(targetMetadata)
: await _episodeNavigation.loadAdjacentEpisodes(
context: context,
metadata: targetMetadata,
// The part actually being played, so the queue can skip sibling
// entries of a Plex multi-episode file (#1500). MediaSourceInfo
// carries the Plex numeric part id; MediaPart.id is its string form.
playedPartId: _currentMediaInfo?.partId?.toString(),
);
_commitAdjacentEpisodes(targetMetadata, adjacentEpisodes, attempt);
return adjacentEpisodes;
} catch (e, st) {
appLogger.w('Could not load adjacent episodes', error: e, stackTrace: st);
const failed = AdjacentEpisodes.failed();
_commitAdjacentEpisodes(targetMetadata, failed, attempt);
return failed;
}
}
/// Load next/previous episodes from locally downloaded content.
AdjacentEpisodes _loadAdjacentEpisodesOffline(MediaItem metadata) {
if (!metadata.isEpisode) return const AdjacentEpisodes.unavailable();
final showKey = metadata.grandparentId;
if (showKey == null) return const AdjacentEpisodes.unavailable();
try {
final downloadProvider = context.read<DownloadProvider>();
final episodes = downloadProvider.getDownloadedEpisodesForShow(showKey);
if (episodes.isEmpty) return const AdjacentEpisodes.failed();
// Aired watch order (Specials interleaved by air date) — the shared
// episode order, so offline next/prev matches streaming, what "download
// next N" selects, and the offline OnDeck list (#1416/#1414). Copy first
// so the provider's cached list isn't reordered.
final sorted = List<MediaItem>.from(episodes)..sort(compareEpisodesByWatchOrder);
final currentIdx = sorted.indexWhere((ep) => ep.id == metadata.id);
if (currentIdx == -1) return const AdjacentEpisodes.failed();
// Same-file siblings are skipped by file-path intersection of the
// stored metadata (#1500) — offline media info doesn't carry the
// server part id, so the helpers compare the items' own parts.
final previous = previousEpisodeSkippingSameFile(sorted, currentIdx);
final next = nextEpisodeSkippingSameFile(sorted, currentIdx);
return AdjacentEpisodes(
next: next,
previous: previous,
nextStatus: next == null ? QueueNavigationStatus.boundary : QueueNavigationStatus.found,
previousStatus: previous == null ? QueueNavigationStatus.boundary : QueueNavigationStatus.found,
);
} catch (e, st) {
appLogger.w('Could not load offline adjacent episodes', error: e, stackTrace: st);
return const AdjacentEpisodes.failed();
}
}
void _commitAdjacentEpisodes(MediaItem targetMetadata, AdjacentEpisodes adjacentEpisodes, _PlaybackAttempt? attempt) {
if (!mounted || _currentMetadata.globalKey != targetMetadata.globalKey || (attempt != null && !attempt.isCurrent)) {
return;
}
_setPlayerState(() {
_nextEpisode = adjacentEpisodes.next;
_previousEpisode = adjacentEpisodes.previous;
_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);
}
}());
}
}