340 lines
6.9 KiB
Go
340 lines
6.9 KiB
Go
package playback
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type PlaylistEventKind uint8
|
|
|
|
const (
|
|
PlaylistEventReady PlaylistEventKind = iota
|
|
PlaylistEventFailed
|
|
)
|
|
|
|
type PlaylistEvent struct {
|
|
Revision uint64
|
|
Kind PlaylistEventKind
|
|
Failure Status
|
|
}
|
|
|
|
// PlaylistReadiness is retained as an alias for callers that only publish
|
|
// ready events. Its zero Kind is PlaylistEventReady.
|
|
type PlaylistReadiness = PlaylistEvent
|
|
|
|
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 {
|
|
playlist Playlist
|
|
retry RetryPolicy
|
|
sessions chan<- SessionCommand
|
|
now func() time.Time
|
|
newTimer playlistTimerFactory
|
|
|
|
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
|
|
Failure PlaylistFailure
|
|
HasFailure bool
|
|
}
|
|
|
|
var (
|
|
ErrNilSessionCommandChannel = errors.New("session-command channel is nil")
|
|
)
|
|
|
|
func NewPlaylistController(
|
|
playlist Playlist,
|
|
retry RetryPolicy,
|
|
sessions chan<- SessionCommand,
|
|
) (*PlaylistController, error) {
|
|
if err := playlist.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := retry.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
if sessions == nil {
|
|
return nil, ErrNilSessionCommandChannel
|
|
}
|
|
return &PlaylistController{
|
|
playlist: playlist,
|
|
retry: retry,
|
|
sessions: sessions,
|
|
now: time.Now,
|
|
newTimer: func(duration time.Duration) playlistTimer {
|
|
return realPlaylistTimer{timer: time.NewTimer(duration)}
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (c *PlaylistController) Run(
|
|
ctx context.Context,
|
|
commands <-chan PlaylistCommand,
|
|
readiness <-chan PlaylistReadiness,
|
|
) error {
|
|
state := PlaylistState{}
|
|
revision := uint64(0)
|
|
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 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
|
|
case command, ok := <-commands:
|
|
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,
|
|
state,
|
|
command,
|
|
c.retry,
|
|
)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if apply {
|
|
c.clearFailure()
|
|
stopTimer()
|
|
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)
|
|
|
|
case ready, ok := <-readiness:
|
|
if !ok {
|
|
readiness = nil
|
|
continue
|
|
}
|
|
if ready.Kind == PlaylistEventFailed {
|
|
if ready.Revision != revision {
|
|
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)
|
|
if c.playlist.OnFailure != PlaylistFailureNext {
|
|
c.publish(state, revision, timing)
|
|
continue
|
|
}
|
|
next, sessionCommand, apply, err := ApplyPlaylistSelection(
|
|
c.playlist,
|
|
state,
|
|
PlaylistCommand{Kind: PlaylistNext},
|
|
c.retry,
|
|
)
|
|
if err != nil || !apply {
|
|
c.publish(state, revision, timing)
|
|
continue
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case c.sessions <- sessionCommand:
|
|
}
|
|
revision++
|
|
state = next
|
|
entry, _ := next.Entry(c.playlist)
|
|
timing = NewPlaylistTiming(revision, entry.Duration)
|
|
c.publish(state, revision, timing)
|
|
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,
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *PlaylistController) Snapshot() (PlaylistSnapshot, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.snapshot, c.hasSnapshot
|
|
}
|
|
|
|
func (c *PlaylistController) publish(
|
|
state PlaylistState,
|
|
revision uint64,
|
|
timing PlaylistTimingState,
|
|
) {
|
|
entry, _ := state.Entry(c.playlist)
|
|
|
|
c.mu.Lock()
|
|
c.snapshot = PlaylistSnapshot{
|
|
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
|
|
}
|
|
select {
|
|
case <-timer.C():
|
|
default:
|
|
}
|
|
}
|