diff --git a/cmd/mxl-player/main.go b/cmd/mxl-player/main.go index 9d7e21d..1be6057 100644 --- a/cmd/mxl-player/main.go +++ b/cmd/mxl-player/main.go @@ -811,6 +811,33 @@ func main() { } else { cimgui.Text("End behavior: stop") } + cimgui.Text(fmt.Sprintf( + "Failure behavior: %s", + configuredPlaylist.OnFailure, + )) + + if hasPlaylistSnapshot && playlistSnapshot.HasFailure { + failure := playlistSnapshot.Failure + name := failure.EntryName + if name == "" { + name = fmt.Sprintf("Entry %d", failure.EntryIndex+1) + } + cimgui.SeparatorText("Last failure") + cimgui.TextWrapped(fmt.Sprintf( + "%s: %s failed", + name, + failure.Status.Unit, + )) + cimgui.Text(fmt.Sprintf( + "Attempts: %d | failed attempts: %d", + failure.Status.Attempt, + failure.Status.FailedAttempts, + )) + cimgui.Text(fmt.Sprintf("Policy: %s", failure.Policy)) + if failure.Status.Err != nil { + cimgui.TextWrapped(failure.Status.Err.Error()) + } + } preview := "No entry selected" if hasPlaylistSnapshot && playlistSnapshot.State.HasSelection { diff --git a/imgui.ini b/imgui.ini index d88865f..9d5a103 100644 --- a/imgui.ini +++ b/imgui.ini @@ -4,8 +4,8 @@ Size=400,400 Collapsed=0 [Window][Settings & Info] -Pos=580,0 -Size=700,720 +Pos=1220,0 +Size=700,1080 Collapsed=0 [Window][Stats] diff --git a/internal/playback/playlist_controller.go b/internal/playback/playlist_controller.go index 8446bba..57e4261 100644 --- a/internal/playback/playlist_controller.go +++ b/internal/playback/playlist_controller.go @@ -17,6 +17,7 @@ const ( type PlaylistEvent struct { Revision uint64 Kind PlaylistEventKind + Failure Status } // PlaylistReadiness is retained as an alias for callers that only publish @@ -47,13 +48,24 @@ type PlaylistController struct { mu sync.RWMutex snapshot PlaylistSnapshot hasSnapshot bool + failure PlaylistFailure +} + +type PlaylistFailure struct { + EntryIndex int + EntryName string + Revision uint64 + Policy PlaylistFailurePolicy + Status Status } type PlaylistSnapshot struct { - State PlaylistState - Entry PlaylistEntry - Revision uint64 - Timing PlaylistTimingState + State PlaylistState + Entry PlaylistEntry + Revision uint64 + Timing PlaylistTimingState + Failure PlaylistFailure + HasFailure bool } var ( @@ -155,6 +167,7 @@ func (c *PlaylistController) Run( continue } if apply { + c.clearFailure() stopTimer() select { case <-ctx.Done(): @@ -179,6 +192,13 @@ func (c *PlaylistController) Run( continue } stopTimer() + c.setFailure(PlaylistFailure{ + EntryIndex: state.CurrentIndex, + EntryName: c.playlist.Entries[state.CurrentIndex].Name, + Revision: revision, + Policy: c.playlist.OnFailure, + Status: ready.Failure, + }) // A failed entry must not retain a live or apparently active // duration clock, even when the policy is to wait. timing = NewPlaylistTiming(revision, timing.Duration) @@ -285,15 +305,29 @@ func (c *PlaylistController) publish( c.mu.Lock() c.snapshot = PlaylistSnapshot{ - State: state, - Entry: entry, - Revision: revision, - Timing: timing, + State: state, + Entry: entry, + Revision: revision, + Timing: timing, + Failure: c.failure, + HasFailure: c.failure.Revision != 0, } c.hasSnapshot = true c.mu.Unlock() } +func (c *PlaylistController) setFailure(failure PlaylistFailure) { + c.mu.Lock() + c.failure = failure + c.mu.Unlock() +} + +func (c *PlaylistController) clearFailure() { + c.mu.Lock() + c.failure = PlaylistFailure{} + c.mu.Unlock() +} + func stopPlaylistTimer(timer playlistTimer) { if timer == nil || timer.Stop() { return diff --git a/internal/playback/playlist_failure_test.go b/internal/playback/playlist_failure_test.go index 20269c5..6d1f92c 100644 --- a/internal/playback/playlist_failure_test.go +++ b/internal/playback/playlist_failure_test.go @@ -2,6 +2,7 @@ package playback import ( "context" + "errors" "testing" "time" ) @@ -152,7 +153,15 @@ func TestPlaylistControllerFailurePolicy(t *testing.T) { commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 0} <-sessions - events <- PlaylistEvent{Revision: 1, Kind: PlaylistEventFailed} + failureErr := errors.New("flow unavailable") + events <- PlaylistEvent{ + Revision: 1, + Kind: PlaylistEventFailed, + Failure: Status{ + Unit: UnitVideo, State: StateFailed, + Attempt: 2, FailedAttempts: 2, Err: failureErr, + }, + } if test.wantAdvance { select { @@ -160,16 +169,23 @@ func TestPlaylistControllerFailurePolicy(t *testing.T) { case <-time.After(time.Second): t.Fatal("failure did not advance playlist") } - snapshot, _ := controller.Snapshot() + snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.State.CurrentIndex == 1 && snapshot.Revision == 2 + }) if snapshot.State.CurrentIndex != 1 || snapshot.Revision != 2 { t.Fatalf("snapshot = %#v, want index 1 revision 2", snapshot) } + assertPlaylistFailure(t, snapshot, failureErr) } else { select { case command := <-sessions: t.Fatalf("unexpected session command: %#v", command) case <-time.After(20 * time.Millisecond): } + snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.HasFailure + }) + assertPlaylistFailure(t, snapshot, failureErr) } cancel() @@ -179,3 +195,16 @@ func TestPlaylistControllerFailurePolicy(t *testing.T) { }) } } + +func assertPlaylistFailure(t *testing.T, snapshot PlaylistSnapshot, wantErr error) { + t.Helper() + if !snapshot.HasFailure { + t.Fatal("snapshot has no playlist failure") + } + failure := snapshot.Failure + if failure.EntryIndex != 0 || failure.EntryName != "first" || + failure.Status.Unit != UnitVideo || failure.Status.Attempt != 2 || + failure.Status.FailedAttempts != 2 || !errors.Is(failure.Status.Err, wantErr) { + t.Fatalf("failure = %#v", failure) + } +} diff --git a/internal/playback/playlist_readiness.go b/internal/playback/playlist_readiness.go index 6670ea5..6aabd2d 100644 --- a/internal/playback/playlist_readiness.go +++ b/internal/playback/playlist_readiness.go @@ -112,13 +112,14 @@ func (c *PlaylistReadinessCoordinator) Run(ctx context.Context) error { continue } statuses := c.statuses.SnapshotAll() - if IsSessionFailed(sessionSnapshot, statuses) { + if failure, failed := SessionFailureStatus(sessionSnapshot, statuses); failed { if playlistSnapshot.Revision == emittedFailedRevision { continue } failed := PlaylistEvent{ Revision: playlistSnapshot.Revision, Kind: PlaylistEventFailed, + Failure: failure, } select { case <-ctx.Done(): @@ -217,24 +218,39 @@ func IsSessionFailed( session SessionSnapshot, statuses PlaybackStatusSnapshot, ) bool { + _, failed := SessionFailureStatus(session, statuses) + return failed +} + +func SessionFailureStatus( + session SessionSnapshot, + statuses PlaybackStatusSnapshot, +) (Status, bool) { if statuses.Generation != session.Generation { - return false + return Status{}, false } switch session.Plan.Topology { case TopologyIndependent: - return (session.Plan.Video.Active && statusIsFailed( + if session.Plan.Video.Active && statusIsFailed( statuses.Video, statuses.HasVideo, session.Generation, session.Plan.Video, - )) || (session.Plan.Audio.Active && statusIsFailed( + ) { + return statuses.Video, true + } + if session.Plan.Audio.Active && statusIsFailed( statuses.Audio, statuses.HasAudio, session.Generation, session.Plan.Audio, - )) + ) { + return statuses.Audio, true + } + return Status{}, false case TopologySynchronized: - return statuses.HasSync && + failed := statuses.HasSync && statuses.Sync.Generation == session.Generation && statuses.Sync.State == StateFailed && sameSyncSource(statuses.Sync.Pair, session.Plan.Sync) + return statuses.Sync, failed default: - return false + return Status{}, false } }