From a0e46a7bb5158f497307b449adc1ea011f562d7a Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Wed, 2 Sep 2026 00:03:47 +0300 Subject: [PATCH] Playlist-level retry configuration --- cmd/mxl-player/config.go | 15 +++++++++ cmd/mxl-player/config_test.go | 46 +++++++++++++++++++++++++++- cmd/mxl-player/main.go | 18 +++++++++++ cmd/mxl-player/playlist_file.go | 45 +++++++++++++++++++++++++++ cmd/mxl-player/playlist_file_test.go | 39 +++++++++++++++++++++++ internal/playback/playlist.go | 6 ++++ playlists/sample-list-all.json | 5 +++ 7 files changed, 173 insertions(+), 1 deletion(-) diff --git a/cmd/mxl-player/config.go b/cmd/mxl-player/config.go index 56592eb..41d9cea 100644 --- a/cmd/mxl-player/config.go +++ b/cmd/mxl-player/config.go @@ -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{ diff --git a/cmd/mxl-player/config_test.go b/cmd/mxl-player/config_test.go index 06b0f8c..0f7e4c8 100644 --- a/cmd/mxl-player/config_test.go +++ b/cmd/mxl-player/config_test.go @@ -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) + } + }) + } +} diff --git a/cmd/mxl-player/main.go b/cmd/mxl-player/main.go index 1be6057..3feaa14 100644 --- a/cmd/mxl-player/main.go +++ b/cmd/mxl-player/main.go @@ -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 diff --git a/cmd/mxl-player/playlist_file.go b/cmd/mxl-player/playlist_file.go index 108280c..5c2b088 100644 --- a/cmd/mxl-player/playlist_file.go +++ b/cmd/mxl-player/playlist_file.go @@ -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{} diff --git a/cmd/mxl-player/playlist_file_test.go b/cmd/mxl-player/playlist_file_test.go index 1cd15c5..3f8edc9 100644 --- a/cmd/mxl-player/playlist_file_test.go +++ b/cmd/mxl-player/playlist_file_test.go @@ -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"}]}`, diff --git a/internal/playback/playlist.go b/internal/playback/playlist.go index 5825dda..ed7db74 100644 --- a/internal/playback/playlist.go +++ b/internal/playback/playlist.go @@ -56,6 +56,7 @@ type Playlist struct { Entries []PlaylistEntry Loop bool OnFailure PlaylistFailurePolicy + Retry *RetryPolicy } func (f PlaylistFeed) IsConfigured() bool { @@ -95,6 +96,11 @@ func (p Playlist) Validate() error { if err := p.OnFailure.Validate(); err != nil { return err } + if p.Retry != nil { + if err := p.Retry.Validate(); err != nil { + return fmt.Errorf("playlist retry: %w", err) + } + } for index, entry := range p.Entries { if err := entry.Validate(); err != nil { return fmt.Errorf("playlist entry %d: %w", index, err) diff --git a/playlists/sample-list-all.json b/playlists/sample-list-all.json index 6214a86..7a6f7bd 100644 --- a/playlists/sample-list-all.json +++ b/playlists/sample-list-all.json @@ -1,6 +1,11 @@ { "loop": true, "on_failure": "next", + "retry": { + "max_attempts": 3, + "initial_delay": "500ms", + "max_delay": "5s" + }, "entries": [ { "name": "timelapse",