Refactoring #3

Merged
itten merged 87 commits from refactoring into main 2026-09-01 23:52:36 +03:00
3 changed files with 429 additions and 6 deletions
Showing only changes of commit d3ea99233d - Show all commits
+115 -3
View File
@@ -4,12 +4,33 @@ import (
"context" "context"
"errors" "errors"
"sync" "sync"
"time"
) )
type PlaylistReadiness struct {
Revision uint64
}
type playlistTimer interface {
C() <-chan time.Time
Stop() bool
}
type playlistTimerFactory func(time.Duration) playlistTimer
type realPlaylistTimer struct {
timer *time.Timer
}
func (t realPlaylistTimer) C() <-chan time.Time { return t.timer.C }
func (t realPlaylistTimer) Stop() bool { return t.timer.Stop() }
type PlaylistController struct { type PlaylistController struct {
playlist Playlist playlist Playlist
retry RetryPolicy retry RetryPolicy
sessions chan<- SessionCommand sessions chan<- SessionCommand
now func() time.Time
newTimer playlistTimerFactory
mu sync.RWMutex mu sync.RWMutex
snapshot PlaylistSnapshot snapshot PlaylistSnapshot
@@ -20,6 +41,7 @@ type PlaylistSnapshot struct {
State PlaylistState State PlaylistState
Entry PlaylistEntry Entry PlaylistEntry
Revision uint64 Revision uint64
Timing PlaylistTimingState
} }
var ( var (
@@ -44,16 +66,32 @@ func NewPlaylistController(
playlist: playlist, playlist: playlist,
retry: retry, retry: retry,
sessions: sessions, sessions: sessions,
now: time.Now,
newTimer: func(duration time.Duration) playlistTimer {
return realPlaylistTimer{timer: time.NewTimer(duration)}
},
}, nil }, nil
} }
func (c *PlaylistController) Run( func (c *PlaylistController) Run(
ctx context.Context, ctx context.Context,
commands <-chan PlaylistCommand, commands <-chan PlaylistCommand,
readiness <-chan PlaylistReadiness,
) error { ) error {
state := PlaylistState{} state := PlaylistState{}
revision := uint64(0) revision := uint64(0)
c.publish(state, revision) timing := PlaylistTimingState{}
var timer playlistTimer
var timerC <-chan time.Time
var timerRevision uint64
c.publish(state, revision, timing)
stopTimer := func() {
stopPlaylistTimer(timer)
timer = nil
timerC = nil
}
defer stopTimer()
for { for {
select { select {
@@ -75,16 +113,75 @@ func (c *PlaylistController) Run(
continue continue
} }
if apply { if apply {
stopTimer()
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
case c.sessions <- sessionCommand: case c.sessions <- sessionCommand:
} }
revision++ revision++
entry, _ := next.Entry(c.playlist)
timing = NewPlaylistTiming(revision, entry.Duration)
} }
state = next state = next
c.publish(state, revision) c.publish(state, revision, timing)
case ready, ok := <-readiness:
if !ok {
readiness = nil
continue
}
nextTiming, started := StartPlaylistTiming(
timing,
ready.Revision,
c.now(),
)
if !started {
continue
}
timing = nextTiming
timerRevision = timing.Revision
timer = c.newTimer(timing.Duration)
timerC = timer.C()
c.publish(state, revision, timing)
case firedAt := <-timerC:
firedRevision := timerRevision
timer = nil
timerC = nil
nextTiming, expired := ExpirePlaylistTiming(
timing,
firedRevision,
firedAt,
)
if !expired {
continue
}
timing = nextTiming
next, sessionCommand, apply, err := ApplyPlaylistSelection(
c.playlist,
state,
PlaylistCommand{Kind: PlaylistNext},
c.retry,
)
if err != nil {
c.publish(state, revision, timing)
continue
}
if apply {
select {
case <-ctx.Done():
return ctx.Err()
case c.sessions <- sessionCommand:
}
revision++
entry, _ := next.Entry(c.playlist)
timing = NewPlaylistTiming(revision, entry.Duration)
}
state = next
c.publish(state, revision, timing)
} }
} }
} }
@@ -95,7 +192,11 @@ func (c *PlaylistController) Snapshot() (PlaylistSnapshot, bool) {
return c.snapshot, c.hasSnapshot return c.snapshot, c.hasSnapshot
} }
func (c *PlaylistController) publish(state PlaylistState, revision uint64) { func (c *PlaylistController) publish(
state PlaylistState,
revision uint64,
timing PlaylistTimingState,
) {
entry, _ := state.Entry(c.playlist) entry, _ := state.Entry(c.playlist)
c.mu.Lock() c.mu.Lock()
@@ -103,7 +204,18 @@ func (c *PlaylistController) publish(state PlaylistState, revision uint64) {
State: state, State: state,
Entry: entry, Entry: entry,
Revision: revision, Revision: revision,
Timing: timing,
} }
c.hasSnapshot = true c.hasSnapshot = true
c.mu.Unlock() c.mu.Unlock()
} }
func stopPlaylistTimer(timer playlistTimer) {
if timer == nil || timer.Stop() {
return
}
select {
case <-timer.C():
default:
}
}
@@ -173,7 +173,7 @@ func TestPlaylistControllerCommitsStateAfterSessionDelivery(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
result := make(chan error, 1) result := make(chan error, 1)
go func() { result <- controller.Run(ctx, commands) }() go func() { result <- controller.Run(ctx, commands, nil) }()
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool { waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return !snapshot.State.HasSelection return !snapshot.State.HasSelection
@@ -224,7 +224,7 @@ func TestPlaylistControllerCancellationWhileSending(t *testing.T) {
commands := make(chan PlaylistCommand, 1) commands := make(chan PlaylistCommand, 1)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1) result := make(chan error, 1)
go func() { result <- controller.Run(ctx, commands) }() go func() { result <- controller.Run(ctx, commands, nil) }()
commands <- PlaylistCommand{Kind: PlaylistNext} commands <- PlaylistCommand{Kind: PlaylistNext}
cancel() cancel()
@@ -323,7 +323,7 @@ func startPlaylistController(
commands := make(chan PlaylistCommand, 64) commands := make(chan PlaylistCommand, 64)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1) result := make(chan error, 1)
go func() { result <- controller.Run(ctx, commands) }() go func() { result <- controller.Run(ctx, commands, nil) }()
return controller, commands, sessions, cancel, result return controller, commands, sessions, cancel, result
} }
@@ -0,0 +1,311 @@
package playback
import (
"context"
"errors"
"sync"
"testing"
"time"
)
type fakePlaylistTimer struct {
ch chan time.Time
mu sync.Mutex
stopped bool
}
func newFakePlaylistTimer() *fakePlaylistTimer {
return &fakePlaylistTimer{ch: make(chan time.Time, 1)}
}
func (t *fakePlaylistTimer) C() <-chan time.Time { return t.ch }
func (t *fakePlaylistTimer) Stop() bool {
t.mu.Lock()
defer t.mu.Unlock()
alreadyStopped := t.stopped
t.stopped = true
return !alreadyStopped
}
func (t *fakePlaylistTimer) isStopped() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.stopped
}
func (t *fakePlaylistTimer) fire(at time.Time) {
t.ch <- at
}
func timedPlaylist(loop bool) Playlist {
return Playlist{
Entries: []PlaylistEntry{
{
Name: "first",
Video: PlaylistFeed{Domain: "domain", UUID: "video-1"},
Duration: 10 * time.Second,
},
{
Name: "second",
Audio: PlaylistFeed{Domain: "domain", UUID: "audio-2"},
Duration: 20 * time.Second,
},
},
Loop: loop,
}
}
func TestPlaylistControllerStartsOneTimerForMatchingReadiness(t *testing.T) {
controller, commands, readiness, sessions, timers, now, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 1
})
readiness <- PlaylistReadiness{Revision: 0}
assertNoPlaylistTimer(t, timers)
readiness <- PlaylistReadiness{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers)
snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started
})
if snapshot.Timing.Deadline != now.Add(10*time.Second) {
t.Fatalf("deadline = %v, want %v", snapshot.Timing.Deadline, now.Add(10*time.Second))
}
readiness <- PlaylistReadiness{Revision: 1}
assertNoPlaylistTimer(t, timers)
if timer.isStopped() {
t.Fatal("timer stopped after duplicate readiness")
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
if !timer.isStopped() {
t.Fatal("timer was not stopped when commands closed")
}
}
func TestPlaylistControllerTimerExpiryAdvances(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}
timer := receiveFakePlaylistTimer(t, timers)
timer.fire(now.Add(10 * time.Second))
session := receivePlaylistSession(t, sessions)
if session.Session.Audio.UUID != "audio-2" || session.Session.Video.IsConfigured() {
t.Fatalf("advanced session = %#v, want audio-only second entry", session.Session)
}
snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 2
})
if snapshot.State.CurrentIndex != 1 || snapshot.Timing.Started {
t.Fatalf("advanced snapshot = %#v", snapshot)
}
if snapshot.Timing.Duration != 20*time.Second {
t.Fatalf("next duration = %v, want %v", snapshot.Timing.Duration, 20*time.Second)
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerLoopingTimerExpiryWraps(t *testing.T) {
controller, commands, readiness, sessions, timers, now, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(true))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
_ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started
})
timer.fire(now.Add(20 * time.Second))
session := receivePlaylistSession(t, sessions)
if session.Session.Video.UUID != "video-1" {
t.Fatalf("wrapped session = %#v, want first entry", session.Session)
}
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 2 && snapshot.State.CurrentIndex == 0
})
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerFinalExpiryStopsWithoutCommand(t *testing.T) {
controller, commands, readiness, sessions, timers, now, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
_ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started
})
timer.fire(now.Add(20 * time.Second))
snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 1 && !snapshot.Timing.Started
})
if snapshot.State.CurrentIndex != 1 {
t.Fatalf("final snapshot state = %#v, want final entry", snapshot.State)
}
select {
case command := <-sessions:
t.Fatalf("unexpected session command: %#v", command)
case <-time.After(20 * time.Millisecond):
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerManualSelectionStopsOldTimer(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)
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
_ = receivePlaylistSession(t, sessions)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 2
})
if !oldTimer.isStopped() {
t.Fatal("old timer was not stopped by manual selection")
}
oldTimer.fire(now.Add(10 * time.Second))
select {
case command := <-sessions:
t.Fatalf("stale timer produced session command: %#v", command)
case <-time.After(20 * time.Millisecond):
}
snapshot, _ := controller.Snapshot()
if snapshot.Revision != 2 || snapshot.State.CurrentIndex != 1 {
t.Fatalf("stale timer changed snapshot: %#v", snapshot)
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerZeroDurationDoesNotCreateTimer(t *testing.T) {
playlist := timedPlaylist(false)
playlist.Entries[0].Duration = 0
_, commands, readiness, sessions, timers, _, cancel, result :=
startTimedPlaylistController(t, playlist)
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1}
assertNoPlaylistTimer(t, timers)
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerCancellationStopsTimer(t *testing.T) {
_, commands, readiness, sessions, timers, _, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers)
cancel()
if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
}
if !timer.isStopped() {
t.Fatal("timer was not stopped on cancellation")
}
}
func startTimedPlaylistController(
t *testing.T,
playlist Playlist,
) (
*PlaylistController,
chan PlaylistCommand,
chan PlaylistReadiness,
chan SessionCommand,
chan *fakePlaylistTimer,
time.Time,
context.CancelFunc,
<-chan error,
) {
t.Helper()
sessions := make(chan SessionCommand, 16)
controller, err := NewPlaylistController(playlist, validPlaylistRetryPolicy(), sessions)
if err != nil {
t.Fatalf("NewPlaylistController() error = %v", err)
}
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
controller.now = func() time.Time { return now }
timers := make(chan *fakePlaylistTimer, 16)
controller.newTimer = func(time.Duration) playlistTimer {
timer := newFakePlaylistTimer()
timers <- timer
return timer
}
commands := make(chan PlaylistCommand, 16)
readiness := make(chan PlaylistReadiness, 16)
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- controller.Run(ctx, commands, readiness) }()
return controller, commands, readiness, sessions, timers, now, cancel, result
}
func receiveFakePlaylistTimer(t *testing.T, timers <-chan *fakePlaylistTimer) *fakePlaylistTimer {
t.Helper()
select {
case timer := <-timers:
return timer
case <-time.After(time.Second):
t.Fatal("timed out waiting for playlist timer")
return nil
}
}
func assertNoPlaylistTimer(t *testing.T, timers <-chan *fakePlaylistTimer) {
t.Helper()
select {
case timer := <-timers:
t.Fatalf("unexpected playlist timer: %#v", timer)
case <-time.After(20 * time.Millisecond):
}
}