playlist timer pause/resume without affecting active feeds

This commit is contained in:
Dmitry Sergeev
2026-09-01 22:32:19 +03:00
parent b0e7bc4cc3
commit ca25bf88a7
11 changed files with 371 additions and 28 deletions
+38
View File
@@ -102,6 +102,36 @@ func (c *PlaylistController) Run(
if !ok {
return nil
}
if command.Kind == PlaylistPause {
nextTiming, changed := PausePlaylistTiming(
timing,
revision,
c.now(),
)
if changed {
stopTimer()
timing = nextTiming
c.publish(state, revision, timing)
}
continue
}
if command.Kind == PlaylistResume {
nextTiming, changed := ResumePlaylistTiming(
timing,
revision,
c.now(),
)
if changed {
timing = nextTiming
if timing.Started {
timerRevision = timing.Revision
timer = c.newTimer(timing.Remaining)
timerC = timer.C()
}
c.publish(state, revision, timing)
}
continue
}
next, sessionCommand, apply, err := ApplyPlaylistSelection(
c.playlist,
@@ -132,6 +162,14 @@ func (c *PlaylistController) Run(
readiness = nil
continue
}
if timing.Paused &&
ready.Revision == timing.Revision &&
timing.Duration > 0 &&
!timing.Expired {
timing.Ready = true
c.publish(state, revision, timing)
continue
}
nextTiming, started := StartPlaylistTiming(
timing,
ready.Revision,
@@ -255,6 +255,113 @@ func TestPlaylistControllerCancellationStopsTimer(t *testing.T) {
}
}
func TestPlaylistControllerPauseAndResumeTimer(t *testing.T) {
controller, commands, readiness, sessions, timers, now, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1}
oldTimer := receiveFakePlaylistTimer(t, timers)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started
})
commands <- PlaylistCommand{Kind: PlaylistPause}
paused := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Paused
})
if paused.Revision != 1 || paused.Timing.Started {
t.Fatalf("paused snapshot = %#v", paused)
}
if !oldTimer.isStopped() {
t.Fatal("Pause did not stop active timer")
}
oldTimer.fire(now.Add(10 * time.Second))
select {
case command := <-sessions:
t.Fatalf("paused stale timer sent session command: %#v", command)
case <-time.After(20 * time.Millisecond):
}
commands <- PlaylistCommand{Kind: PlaylistResume}
_ = receiveFakePlaylistTimer(t, timers)
resumed := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started && !snapshot.Timing.Paused
})
if resumed.Revision != 1 {
t.Fatalf("resume changed revision: %#v", resumed)
}
select {
case command := <-sessions:
t.Fatalf("pause/resume sent session command: %#v", command)
default:
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerManualSelectionClearsPause(t *testing.T) {
controller, commands, _, sessions, _, _, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
commands <- PlaylistCommand{Kind: PlaylistPause}
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Paused
})
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
next := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 2
})
if next.Timing.Paused || next.State.CurrentIndex != 1 {
t.Fatalf("new selection retained pause: %#v", next)
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerRecordsQueuedReadinessWhilePaused(t *testing.T) {
controller, commands, readiness, sessions, timers, _, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
commands <- PlaylistCommand{Kind: PlaylistPause}
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Paused
})
readiness <- PlaylistReadiness{Revision: 1}
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Paused && snapshot.Timing.Ready
})
assertNoPlaylistTimer(t, timers)
commands <- PlaylistCommand{Kind: PlaylistResume}
_ = receiveFakePlaylistTimer(t, timers)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started && !snapshot.Timing.Paused
})
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func startTimedPlaylistController(
t *testing.T,
playlist Playlist,
+2
View File
@@ -8,6 +8,8 @@ const (
PlaylistSelect PlaylistCommandKind = iota + 1
PlaylistNext
PlaylistPrevious
PlaylistPause
PlaylistResume
)
type PlaylistCommand struct {
+1
View File
@@ -102,6 +102,7 @@ func (c *PlaylistReadinessCoordinator) Run(ctx context.Context) error {
playlistSnapshot.Revision == 0 ||
playlistSnapshot.Entry.Duration <= 0 ||
playlistSnapshot.Timing.Started ||
playlistSnapshot.Timing.Paused ||
playlistSnapshot.Revision == emittedRevision {
continue
}
@@ -229,6 +229,7 @@ func TestPlaylistReadinessCoordinatorWaitsForAllConditions(t *testing.T) {
{name: "zero revision", mutate: func() { value := playlistSnapshotForVideo(1); value.Revision = 0; playlist.set(value, true) }},
{name: "zero duration", mutate: func() { value := playlistSnapshotForVideo(1); value.Entry.Duration = 0; playlist.set(value, true) }},
{name: "already started", mutate: func() { value := playlistSnapshotForVideo(1); value.Timing.Started = true; playlist.set(value, true) }},
{name: "paused", mutate: func() { value := playlistSnapshotForVideo(1); value.Timing.Paused = true; playlist.set(value, true) }},
{name: "session mismatch", mutate: func() {
playlist.set(playlistSnapshotForVideo(1), true)
value, _ := session.Snapshot()
+68 -9
View File
@@ -3,11 +3,14 @@ package playback
import "time"
type PlaylistTimingState struct {
Revision uint64
Duration time.Duration
Started bool
Expired bool
Deadline time.Time
Revision uint64
Duration time.Duration
Ready bool
Started bool
Paused bool
Expired bool
Remaining time.Duration
Deadline time.Time
}
func NewPlaylistTiming(
@@ -15,9 +18,9 @@ func NewPlaylistTiming(
duration time.Duration,
) PlaylistTimingState {
return PlaylistTimingState{
Revision: revision,
Duration: duration,
Expired: false,
Revision: revision,
Duration: duration,
Remaining: duration,
}
}
@@ -29,12 +32,66 @@ func StartPlaylistTiming(
if revision != current.Revision ||
current.Duration <= 0 ||
current.Started ||
current.Paused ||
current.Expired {
return current, false
}
next := current
if next.Remaining <= 0 {
next.Remaining = next.Duration
}
next.Ready = true
next.Started = true
next.Deadline = now.Add(current.Duration)
next.Deadline = now.Add(next.Remaining)
return next, true
}
func PausePlaylistTiming(
current PlaylistTimingState,
revision uint64,
now time.Time,
) (PlaylistTimingState, bool) {
if revision != current.Revision ||
current.Duration <= 0 ||
current.Paused ||
current.Expired {
return current, false
}
next := current
if next.Started {
next.Remaining = next.Deadline.Sub(now)
if next.Remaining < 0 {
next.Remaining = 0
}
if next.Remaining > next.Duration {
next.Remaining = next.Duration
}
next.Started = false
next.Deadline = time.Time{}
}
next.Paused = true
return next, true
}
func ResumePlaylistTiming(
current PlaylistTimingState,
revision uint64,
now time.Time,
) (PlaylistTimingState, bool) {
if revision != current.Revision ||
current.Duration <= 0 ||
!current.Paused ||
current.Expired {
return current, false
}
next := current
next.Paused = false
if next.Ready {
next.Started = true
next.Deadline = now.Add(next.Remaining)
}
return next, true
}
@@ -51,7 +108,9 @@ func ExpirePlaylistTiming(
next := current
next.Started = false
next.Paused = false
next.Deadline = time.Time{}
next.Expired = true
next.Remaining = 0
return next, true
}
+90 -6
View File
@@ -7,7 +7,11 @@ import (
func TestNewPlaylistTimingResetsState(t *testing.T) {
got := NewPlaylistTiming(7, 10*time.Second)
want := PlaylistTimingState{Revision: 7, Duration: 10 * time.Second}
want := PlaylistTimingState{
Revision: 7,
Duration: 10 * time.Second,
Remaining: 10 * time.Second,
}
if got != want {
t.Fatalf("NewPlaylistTiming() = %#v, want %#v", got, want)
}
@@ -22,10 +26,12 @@ func TestStartPlaylistTiming(t *testing.T) {
t.Fatal("StartPlaylistTiming() started = false, want true")
}
want := PlaylistTimingState{
Revision: 4,
Duration: 10 * time.Second,
Started: true,
Deadline: now.Add(10 * time.Second),
Revision: 4,
Duration: 10 * time.Second,
Ready: true,
Started: true,
Remaining: 10 * time.Second,
Deadline: now.Add(10 * time.Second),
}
if got != want {
t.Fatalf("StartPlaylistTiming() = %#v, want %#v", got, want)
@@ -47,6 +53,7 @@ func TestStartPlaylistTimingIgnoresInapplicableReadiness(t *testing.T) {
{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},
{name: "paused", current: PlaylistTimingState{Revision: 4, Duration: time.Second, Paused: true, Remaining: time.Second}, revision: 4},
{name: "expired", current: PlaylistTimingState{Revision: 4, Duration: time.Second, Expired: true}, revision: 4},
}
@@ -71,7 +78,13 @@ func TestExpirePlaylistTiming(t *testing.T) {
if !expired {
t.Fatal("ExpirePlaylistTiming() expired = false, want true")
}
want := PlaylistTimingState{Revision: 9, Duration: 5 * time.Second, Expired: true}
want := PlaylistTimingState{
Revision: 9,
Duration: 5 * time.Second,
Ready: true,
Expired: true,
Remaining: 0,
}
if got != want {
t.Fatalf("ExpirePlaylistTiming() = %#v, want %#v", got, want)
}
@@ -118,3 +131,74 @@ func TestNewPlaylistTimingInvalidatesPreviousDeadline(t *testing.T) {
t.Fatalf("old expiry changed new timing: %#v, %v", got, expired)
}
}
func TestPauseAndResumePlaylistTimingBeforeReadiness(t *testing.T) {
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
current := NewPlaylistTiming(3, 10*time.Second)
paused, changed := PausePlaylistTiming(current, 3, now)
if !changed || !paused.Paused || paused.Ready || paused.Started {
t.Fatalf("PausePlaylistTiming() = %#v, %v", paused, changed)
}
resumed, changed := ResumePlaylistTiming(paused, 3, now.Add(time.Second))
if !changed || resumed.Paused || resumed.Ready || resumed.Started {
t.Fatalf("ResumePlaylistTiming() = %#v, %v", resumed, changed)
}
if resumed.Remaining != 10*time.Second {
t.Fatalf("remaining = %v, want 10s", resumed.Remaining)
}
}
func TestPauseAndResumeActivePlaylistTimingUsesRemaining(t *testing.T) {
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
current, _ := StartPlaylistTiming(NewPlaylistTiming(5, 10*time.Second), 5, now)
paused, changed := PausePlaylistTiming(current, 5, now.Add(4*time.Second))
if !changed || !paused.Paused || paused.Started || !paused.Ready {
t.Fatalf("PausePlaylistTiming() = %#v, %v", paused, changed)
}
if paused.Remaining != 6*time.Second || !paused.Deadline.IsZero() {
t.Fatalf("paused timing = %#v, want 6s remaining and no deadline", paused)
}
resumeAt := now.Add(20 * time.Second)
resumed, changed := ResumePlaylistTiming(paused, 5, resumeAt)
if !changed || resumed.Paused || !resumed.Started || !resumed.Ready {
t.Fatalf("ResumePlaylistTiming() = %#v, %v", resumed, changed)
}
if resumed.Deadline != resumeAt.Add(6*time.Second) {
t.Fatalf("resumed deadline = %v, want %v", resumed.Deadline, resumeAt.Add(6*time.Second))
}
}
func TestPauseAndResumePlaylistTimingIgnoreInvalidTransitions(t *testing.T) {
now := time.Now()
base := NewPlaylistTiming(2, time.Second)
paused, _ := PausePlaylistTiming(base, 2, now)
tests := []struct {
name string
state PlaylistTimingState
pause bool
revision uint64
}{
{name: "pause wrong revision", state: base, pause: true, revision: 1},
{name: "duplicate pause", state: paused, pause: true, revision: 2},
{name: "resume wrong revision", state: paused, revision: 1},
{name: "duplicate resume", state: base, revision: 2},
{name: "pause expired", state: PlaylistTimingState{Revision: 2, Duration: time.Second, Expired: true}, pause: true, revision: 2},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var got PlaylistTimingState
var changed bool
if test.pause {
got, changed = PausePlaylistTiming(test.state, test.revision, now)
} else {
got, changed = ResumePlaylistTiming(test.state, test.revision, now)
}
if changed || got != test.state {
t.Fatalf("transition = %#v, %v; want unchanged", got, changed)
}
})
}
}