Playlist-level retry configuration

This commit is contained in:
Dmitry Sergeev
2026-09-02 00:03:47 +03:00
parent ea8c9eeddd
commit a0e46a7bb5
7 changed files with 173 additions and 1 deletions
+15
View File
@@ -17,6 +17,21 @@ func resolveDomain(shared, override string) string {
return shared
}
func resolveRetryPolicy(
cli playback.RetryPolicy,
cliMaxAttemptsSet bool,
playlist playback.Playlist,
) playback.RetryPolicy {
if playlist.Retry == nil {
return cli
}
resolved := *playlist.Retry
if cliMaxAttemptsSet {
resolved.MaxAttempts = cli.MaxAttempts
}
return resolved
}
func (a appArgs) playbackConfig() playback.SessionConfig {
return playback.SessionConfig{
Video: playback.FeedConfig{
+45 -1
View File
@@ -1,6 +1,11 @@
package main
import "testing"
import (
"testing"
"time"
"mxl-player/internal/playback"
)
func TestAppArgsPlaybackConfig(t *testing.T) {
tests := []struct {
@@ -139,3 +144,42 @@ func TestAppArgsPlaybackConfig(t *testing.T) {
})
}
}
func TestResolveRetryPolicy(t *testing.T) {
cli := playback.RetryPolicy{
MaxAttempts: 0, InitialDelay: time.Second, MaxDelay: 5 * time.Second,
}
fileRetry := playback.RetryPolicy{
MaxAttempts: 4, InitialDelay: 250 * time.Millisecond, MaxDelay: 2 * time.Second,
}
tests := []struct {
name string
playlist playback.Playlist
cliMaxAttemptsSet bool
want playback.RetryPolicy
}{
{name: "no playlist retry uses CLI", want: cli},
{
name: "playlist retry is used by default",
playlist: playback.Playlist{Retry: &fileRetry},
want: fileRetry,
},
{
name: "explicit CLI infinite overrides playlist attempts",
playlist: playback.Playlist{Retry: &fileRetry},
cliMaxAttemptsSet: true,
want: playback.RetryPolicy{
MaxAttempts: 0, InitialDelay: fileRetry.InitialDelay, MaxDelay: fileRetry.MaxDelay,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := resolveRetryPolicy(cli, test.cliMaxAttemptsSet, test.playlist); got != test.want {
t.Fatalf("resolveRetryPolicy() = %#v, want %#v", got, test.want)
}
})
}
}
+18
View File
@@ -176,6 +176,11 @@ func main() {
os.Exit(2)
}
configuredPlaylist = playlist
retryPolicy = resolveRetryPolicy(
retryPolicy,
flagSet.Changed("max-attempts"),
configuredPlaylist,
)
}
if args.VideoDomain == "" {
args.VideoDomain = args.Domain
@@ -815,6 +820,19 @@ func main() {
"Failure behavior: %s",
configuredPlaylist.OnFailure,
))
if retryPolicy.MaxAttempts == 0 {
cimgui.Text("Retries: infinite")
} else {
cimgui.Text(fmt.Sprintf(
"Attempts per entry: %d",
retryPolicy.MaxAttempts,
))
}
cimgui.Text(fmt.Sprintf(
"Retry delay: %s to %s",
retryPolicy.InitialDelay,
retryPolicy.MaxDelay,
))
if hasPlaylistSnapshot && playlistSnapshot.HasFailure {
failure := playlistSnapshot.Failure
+45
View File
@@ -14,6 +14,13 @@ type playlistFile struct {
Entries []playlistFileEntry `json:"entries"`
Loop bool `json:"loop"`
OnFailure string `json:"on_failure"`
Retry *playlistFileRetry `json:"retry"`
}
type playlistFileRetry struct {
MaxAttempts *int `json:"max_attempts"`
InitialDelay string `json:"initial_delay"`
MaxDelay string `json:"max_delay"`
}
type playlistFileEntry struct {
@@ -73,6 +80,13 @@ func decodePlaylistFile(reader io.Reader) (playback.Playlist, error) {
"on_failure %q: %w", file.OnFailure, playback.ErrPlaylistFailurePolicy,
)
}
if file.Retry != nil {
retry, err := decodePlaylistRetry(*file.Retry)
if err != nil {
return playback.Playlist{}, err
}
playlist.Retry = &retry
}
for index, entry := range file.Entries {
duration := time.Duration(0)
if entry.Duration != "" {
@@ -103,6 +117,37 @@ func decodePlaylistFile(reader io.Reader) (playback.Playlist, error) {
return playlist, nil
}
func decodePlaylistRetry(file playlistFileRetry) (playback.RetryPolicy, error) {
retry := playback.RetryPolicy{
InitialDelay: initialRetryDelay,
MaxDelay: maxRetryDelay,
}
if file.MaxAttempts != nil {
retry.MaxAttempts = *file.MaxAttempts
}
var err error
if file.InitialDelay != "" {
retry.InitialDelay, err = time.ParseDuration(file.InitialDelay)
if err != nil {
return playback.RetryPolicy{}, fmt.Errorf(
"retry initial_delay %q: %w", file.InitialDelay, err,
)
}
}
if file.MaxDelay != "" {
retry.MaxDelay, err = time.ParseDuration(file.MaxDelay)
if err != nil {
return playback.RetryPolicy{}, fmt.Errorf(
"retry max_delay %q: %w", file.MaxDelay, err,
)
}
}
if err := retry.Validate(); err != nil {
return playback.RetryPolicy{}, fmt.Errorf("validate playlist retry: %w", err)
}
return retry, nil
}
func playlistFileFeedToPlayback(feed *playlistFileFeed) playback.PlaylistFeed {
if feed == nil {
return playback.PlaylistFeed{}
+39
View File
@@ -91,6 +91,41 @@ func TestDecodePlaylistFileAllowsEmptyPlaylist(t *testing.T) {
}
}
func TestDecodePlaylistFileRetry(t *testing.T) {
input := `{
"retry": {
"max_attempts": 3,
"initial_delay": "250ms",
"max_delay": "2s"
},
"entries": []
}`
got, err := decodePlaylistFile(strings.NewReader(input))
if err != nil {
t.Fatalf("decodePlaylistFile() error = %v", err)
}
if got.Retry == nil {
t.Fatal("decodePlaylistFile() retry = nil")
}
want := playback.RetryPolicy{
MaxAttempts: 3, InitialDelay: 250 * time.Millisecond, MaxDelay: 2 * time.Second,
}
if *got.Retry != want {
t.Fatalf("retry = %#v, want %#v", *got.Retry, want)
}
}
func TestDecodePlaylistFileRetryDefaults(t *testing.T) {
got, err := decodePlaylistFile(strings.NewReader(`{"retry":{},"entries":[]}`))
if err != nil {
t.Fatalf("decodePlaylistFile() error = %v", err)
}
if got.Retry == nil || got.Retry.MaxAttempts != 0 ||
got.Retry.InitialDelay != initialRetryDelay || got.Retry.MaxDelay != maxRetryDelay {
t.Fatalf("retry = %#v", got.Retry)
}
}
func TestDecodePlaylistFileRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
@@ -103,6 +138,10 @@ func TestDecodePlaylistFileRejectsInvalidInput(t *testing.T) {
{name: "unknown field", input: `{"unknown": true}`, wantText: "unknown field"},
{name: "multiple roots", input: `{"entries": []} {"entries": []}`, wantText: "multiple root values"},
{name: "invalid failure policy", input: `{"on_failure":"skip","entries":[]}`, wantErr: playback.ErrPlaylistFailurePolicy},
{name: "negative retry attempts", input: `{"retry":{"max_attempts":-1},"entries":[]}`, wantErr: playback.ErrInvalidMaxAttempts},
{name: "invalid initial delay", input: `{"retry":{"initial_delay":"soon"},"entries":[]}`, wantText: "retry initial_delay"},
{name: "invalid maximum delay", input: `{"retry":{"max_delay":"later"},"entries":[]}`, wantText: "retry max_delay"},
{name: "invalid retry range", input: `{"retry":{"initial_delay":"2s","max_delay":"1s"},"entries":[]}`, wantErr: playback.ErrInvalidRetryRange},
{
name: "invalid duration",
input: `{"entries":[{"video":{"domain":"/video","uuid":"video"},"duration":"later"}]}`,