Files
plezy/test/screens/video_player/playback_failure_action_test.dart
T
edde746 e6be5f9fef fix(player): surface a persistent HTTP 503 at open instead of retrying forever
ffmpeg's reconnect loop deliberately retries 503 without bound (#1520), so a
server that keeps refusing the stream at open time left a silent black screen:
ExoPlayer fell back to MPV, MPV reconnected forever, and no error ever reached
the screen. A new open-phase watchdog arms on the first 503 seen before any
frame renders and, after 20s without one, synthesizes a server-http-503 error
that shows an actionable dialog. Mid-stream 503s and live TV keep their
existing ride-out paths.

close #1830
2026-08-08 12:06:32 +02:00

113 lines
4.7 KiB
Dart

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('HTTP 503', () {
test('the open-phase watchdog tag is terminal for on-demand playback', () {
// The tag only exists once the watchdog has already waited out the
// reconnect loop's chances (#1830) — no further retry recovers it.
expect(resolve(cause: PlayerError.serverHttp503), PlaybackFailureAction.serverBusyDialog);
});
test('live TV keeps its fallback ladder', () {
// The watchdog never arms for live opens, but a defensively passed tag
// must still ride the ladder rather than kill a recoverable stream.
expect(resolve(cause: PlayerError.serverHttp503, isLive: true), PlaybackFailureAction.liveRetry);
});
test('a latched fatal status outranks the watchdog tag', () {
// A 500/404 seen on the same open is the more specific diagnosis.
expect(resolve(cause: PlayerError.serverHttp503, statuses: {500}), PlaybackFailureAction.serverLimitDialog);
expect(resolve(cause: PlayerError.serverHttp503, statuses: {404}), PlaybackFailureAction.mediaUnreadableDialog);
});
});
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);
});
}