fix(player): keep live TV on its retry ladder when a stream 404s

The 404 branch added in 16668be5 ran ahead of the live-TV fallback chain, so a
transient live 404 — an HLS segment rolled off the playlist, or a transcode
session restarting under us — showed "file unavailable" and killed a stream
the bounded ladder would have recovered. Only on-demand playback can read a
404 as terminal, where it really does mean the file is unreadable. 500 stays
terminal for both, since a limit rejection is not something a retry clears.

The dispatch lived in a private extension on the screen state, where no test
could reach the decision. Extract it as resolvePlaybackFailureAction next to
runLiveStreamRetry, which already sets that precedent, and cover both the live
and on-demand paths plus the ladder's rungs.
This commit is contained in:
edde746
2026-07-31 21:45:33 +02:00
parent 56ad48824b
commit 8a46df2850
4 changed files with 184 additions and 35 deletions
@@ -0,0 +1,92 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/models.dart';
import 'package:plezy/screens/video_player/playback_failure_action.dart';
PlaybackFailureAction resolve({
String? cause,
Set<int> statuses = const <int>{},
bool isLive = false,
bool liveRetrying = false,
int liveFallbackLevel = 0,
bool liveRetryFailed = false,
}) {
return resolvePlaybackFailureAction(
cause: cause,
fatalHttpStatuses: statuses,
isLive: isLive,
liveRetrying: liveRetrying,
liveFallbackLevel: liveFallbackLevel,
liveRetryFailed: liveRetryFailed,
);
}
void main() {
group('HTTP 404', () {
test('on-demand playback treats it as terminal', () {
// The file behind the item is unreadable server-side (#1750); no retry,
// quality change, or backend switch recovers it.
expect(resolve(statuses: {404}), PlaybackFailureAction.mediaUnreadableDialog);
expect(resolve(cause: PlayerError.serverHttp404), PlaybackFailureAction.mediaUnreadableDialog);
});
test('live TV rides it out on the fallback ladder instead', () {
// An HLS segment that rolled off the playlist, or a transcode session
// restarting, answers 404 mid-stream. Showing "file unavailable" there
// would kill a recoverable stream.
expect(resolve(statuses: {404}, isLive: true), PlaybackFailureAction.liveRetry);
expect(resolve(cause: PlayerError.serverHttp404, isLive: true), PlaybackFailureAction.liveRetry);
});
test('live TV still exhausts the ladder before giving up', () {
expect(
resolve(statuses: {404}, isLive: true, liveFallbackLevel: maxLiveFallbackLevel, liveRetryFailed: true),
PlaybackFailureAction.liveInterrupted,
);
});
});
group('HTTP 500', () {
test('is terminal for on-demand and live alike', () {
// A bandwidth/transcoding limit rejection is not something a retry
// clears, so live TV gets the same modal.
expect(resolve(statuses: {500}), PlaybackFailureAction.serverLimitDialog);
expect(resolve(statuses: {500}, isLive: true), PlaybackFailureAction.serverLimitDialog);
expect(resolve(cause: PlayerError.serverHttp500, isLive: true), PlaybackFailureAction.serverLimitDialog);
});
test('outranks a 404 latched on the same open', () {
expect(resolve(statuses: {404, 500}), PlaybackFailureAction.serverLimitDialog);
});
});
group('live fallback ladder', () {
test('climbs every rung below the bound', () {
for (var level = 0; level < maxLiveFallbackLevel; level++) {
expect(resolve(isLive: true, liveFallbackLevel: level), PlaybackFailureAction.liveRetry);
}
});
test('an in-flight retry owns the error', () {
expect(resolve(isLive: true, liveRetrying: true), PlaybackFailureAction.ignore);
// Even a latched 404 must not preempt the running retry.
expect(resolve(statuses: {404}, isLive: true, liveRetrying: true), PlaybackFailureAction.ignore);
});
test('a failed last retry reports an interruption', () {
expect(
resolve(isLive: true, liveFallbackLevel: maxLiveFallbackLevel, liveRetryFailed: true),
PlaybackFailureAction.liveInterrupted,
);
});
test('an exhausted ladder that never failed falls through to the raw error', () {
expect(resolve(isLive: true, liveFallbackLevel: maxLiveFallbackLevel), PlaybackFailureAction.fatal);
});
});
test('an error with no server status is fatal for on-demand playback', () {
expect(resolve(), PlaybackFailureAction.fatal);
expect(resolve(statuses: {503}), PlaybackFailureAction.fatal);
expect(resolve(cause: 'some-decoder-fault'), PlaybackFailureAction.fatal);
});
}