diff --git a/internal/playback/playlist_controller.go b/internal/playback/playlist_controller.go index 5552555..d9f9c87 100644 --- a/internal/playback/playlist_controller.go +++ b/internal/playback/playlist_controller.go @@ -17,8 +17,9 @@ type PlaylistController struct { } type PlaylistSnapshot struct { - State PlaylistState - Entry PlaylistEntry + State PlaylistState + Entry PlaylistEntry + Revision uint64 } var ( @@ -51,7 +52,8 @@ func (c *PlaylistController) Run( commands <-chan PlaylistCommand, ) error { state := PlaylistState{} - c.publish(state) + revision := uint64(0) + c.publish(state, revision) for { select { @@ -78,10 +80,11 @@ func (c *PlaylistController) Run( return ctx.Err() case c.sessions <- sessionCommand: } + revision++ } state = next - c.publish(state) + c.publish(state, revision) } } } @@ -92,11 +95,15 @@ func (c *PlaylistController) Snapshot() (PlaylistSnapshot, bool) { return c.snapshot, c.hasSnapshot } -func (c *PlaylistController) publish(state PlaylistState) { +func (c *PlaylistController) publish(state PlaylistState, revision uint64) { entry, _ := state.Entry(c.playlist) c.mu.Lock() - c.snapshot = PlaylistSnapshot{State: state, Entry: entry} + c.snapshot = PlaylistSnapshot{ + State: state, + Entry: entry, + Revision: revision, + } c.hasSnapshot = true c.mu.Unlock() } diff --git a/internal/playback/playlist_controller_test.go b/internal/playback/playlist_controller_test.go index d2beba1..31ffddb 100644 --- a/internal/playback/playlist_controller_test.go +++ b/internal/playback/playlist_controller_test.go @@ -257,6 +257,58 @@ func TestPlaylistControllerSnapshotConcurrentReads(t *testing.T) { } } +func TestPlaylistControllerSnapshotRevision(t *testing.T) { + controller, commands, sessions, cancel, result := startPlaylistController(t, navigationPlaylist(false), 8) + defer cancel() + + initial := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return !snapshot.State.HasSelection + }) + if initial.Revision != 0 { + t.Fatalf("initial revision = %d, want 0", initial.Revision) + } + + commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1} + _ = receivePlaylistSession(t, sessions) + selected := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Revision == 1 + }) + if selected.State.CurrentIndex != 1 { + t.Fatalf("selected state = %#v, want index 1", selected.State) + } + + commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1} + _ = receivePlaylistSession(t, sessions) + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Revision == 2 + }) + + commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 2} + _ = receivePlaylistSession(t, sessions) + waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { + return snapshot.Revision == 3 + }) + + commands <- PlaylistCommand{Kind: PlaylistNext} + time.Sleep(time.Millisecond) + boundary, ok := controller.Snapshot() + if !ok || boundary.Revision != 3 { + t.Fatalf("boundary snapshot = %#v, %v; want revision 3", boundary, ok) + } + + commands <- PlaylistCommand{} + time.Sleep(time.Millisecond) + invalid, ok := controller.Snapshot() + if !ok || invalid.Revision != 3 { + t.Fatalf("invalid-command snapshot = %#v, %v; want revision 3", invalid, ok) + } + + close(commands) + if err := waitForPlaylistResult(t, result); err != nil { + t.Fatalf("Run() error = %v", err) + } +} + func startPlaylistController( t *testing.T, playlist Playlist, diff --git a/internal/playback/playlist_timing.go b/internal/playback/playlist_timing.go new file mode 100644 index 0000000..f766257 --- /dev/null +++ b/internal/playback/playlist_timing.go @@ -0,0 +1,52 @@ +package playback + +import "time" + +type PlaylistTimingState struct { + Revision uint64 + Duration time.Duration + Started bool + Deadline time.Time +} + +func NewPlaylistTiming( + revision uint64, + duration time.Duration, +) PlaylistTimingState { + return PlaylistTimingState{ + Revision: revision, + Duration: duration, + } +} + +func StartPlaylistTiming( + current PlaylistTimingState, + revision uint64, + now time.Time, +) (PlaylistTimingState, bool) { + if revision != current.Revision || current.Duration <= 0 || current.Started { + return current, false + } + + next := current + next.Started = true + next.Deadline = now.Add(current.Duration) + return next, true +} + +func ExpirePlaylistTiming( + current PlaylistTimingState, + revision uint64, + now time.Time, +) (PlaylistTimingState, bool) { + if revision != current.Revision || + !current.Started || + now.Before(current.Deadline) { + return current, false + } + + next := current + next.Started = false + next.Deadline = time.Time{} + return next, true +} diff --git a/internal/playback/playlist_timing_test.go b/internal/playback/playlist_timing_test.go new file mode 100644 index 0000000..9a73fd9 --- /dev/null +++ b/internal/playback/playlist_timing_test.go @@ -0,0 +1,119 @@ +package playback + +import ( + "testing" + "time" +) + +func TestNewPlaylistTimingResetsState(t *testing.T) { + got := NewPlaylistTiming(7, 10*time.Second) + want := PlaylistTimingState{Revision: 7, Duration: 10 * time.Second} + if got != want { + t.Fatalf("NewPlaylistTiming() = %#v, want %#v", got, want) + } +} + +func TestStartPlaylistTiming(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + current := NewPlaylistTiming(4, 10*time.Second) + + got, started := StartPlaylistTiming(current, 4, now) + if !started { + t.Fatal("StartPlaylistTiming() started = false, want true") + } + want := PlaylistTimingState{ + Revision: 4, + Duration: 10 * time.Second, + Started: true, + Deadline: now.Add(10 * time.Second), + } + if got != want { + t.Fatalf("StartPlaylistTiming() = %#v, want %#v", got, want) + } +} + +func TestStartPlaylistTimingIgnoresInapplicableReadiness(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + started, ok := StartPlaylistTiming(NewPlaylistTiming(4, time.Second), 4, now) + if !ok { + t.Fatal("initial StartPlaylistTiming() did not start") + } + tests := []struct { + name string + current PlaylistTimingState + revision uint64 + }{ + {name: "zero duration", current: NewPlaylistTiming(4, 0), revision: 4}, + {name: "stale revision", current: NewPlaylistTiming(4, time.Second), revision: 3}, + {name: "future revision", current: NewPlaylistTiming(4, time.Second), revision: 5}, + {name: "already started", current: started, revision: 4}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, changed := StartPlaylistTiming(test.current, test.revision, now.Add(time.Second)) + if changed { + t.Fatal("StartPlaylistTiming() changed = true, want false") + } + if got != test.current { + t.Fatalf("StartPlaylistTiming() = %#v, want unchanged %#v", got, test.current) + } + }) + } +} + +func TestExpirePlaylistTiming(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + current, _ := StartPlaylistTiming(NewPlaylistTiming(9, 5*time.Second), 9, now) + + got, expired := ExpirePlaylistTiming(current, 9, now.Add(5*time.Second)) + if !expired { + t.Fatal("ExpirePlaylistTiming() expired = false, want true") + } + want := PlaylistTimingState{Revision: 9, Duration: 5 * time.Second} + if got != want { + t.Fatalf("ExpirePlaylistTiming() = %#v, want %#v", got, want) + } + + again, expired := ExpirePlaylistTiming(got, 9, now.Add(6*time.Second)) + if expired || again != got { + t.Fatalf("duplicate ExpirePlaylistTiming() = %#v, %v; want unchanged, false", again, expired) + } +} + +func TestExpirePlaylistTimingIgnoresInapplicableEvents(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + current, _ := StartPlaylistTiming(NewPlaylistTiming(4, 10*time.Second), 4, now) + tests := []struct { + name string + revision uint64 + at time.Time + }{ + {name: "early", revision: 4, at: now.Add(9 * time.Second)}, + {name: "stale revision", revision: 3, at: now.Add(10 * time.Second)}, + {name: "future revision", revision: 5, at: now.Add(10 * time.Second)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, expired := ExpirePlaylistTiming(current, test.revision, test.at) + if expired { + t.Fatal("ExpirePlaylistTiming() expired = true, want false") + } + if got != current { + t.Fatalf("ExpirePlaylistTiming() = %#v, want unchanged %#v", got, current) + } + }) + } +} + +func TestNewPlaylistTimingInvalidatesPreviousDeadline(t *testing.T) { + now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC) + old, _ := StartPlaylistTiming(NewPlaylistTiming(1, time.Second), 1, now) + current := NewPlaylistTiming(2, 2*time.Second) + + got, expired := ExpirePlaylistTiming(current, old.Revision, old.Deadline) + if expired || got != current { + t.Fatalf("old expiry changed new timing: %#v, %v", got, expired) + } +}