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,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/video_player/completion_latch.dart';
import 'package:plezy/services/playback_initialization_types.dart';
void main() {
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);
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'));
});
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 {
final client = makeClient((_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'}));
addTearDown(client.close);