Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e21da0aac | |||
| 532e03ffca | |||
| b160e3aba2 | |||
| ce4ea3fa00 | |||
| a79a2d9c7a | |||
| e50ad8adb5 |
@@ -0,0 +1,91 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPlaylistFeedUUIDRequired = errors.New("playlist feed UUID is required when its domain is configured")
|
||||
ErrPlaylistEntryEmpty = errors.New("playlist entry must contain at least one feed")
|
||||
ErrPlaylistSyncFeedsRequired = errors.New("synchronized playlist entry requires both video and audio feeds")
|
||||
ErrPlaylistDurationNegative = errors.New("playlist entry duration cannot be negative")
|
||||
)
|
||||
|
||||
type PlaylistFeed struct {
|
||||
Domain string
|
||||
UUID string
|
||||
}
|
||||
|
||||
type PlaylistEntry struct {
|
||||
Name string
|
||||
Video PlaylistFeed
|
||||
Audio PlaylistFeed
|
||||
SyncRequested bool
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
Entries []PlaylistEntry
|
||||
Loop bool
|
||||
}
|
||||
|
||||
func (f PlaylistFeed) IsConfigured() bool {
|
||||
return f.UUID != ""
|
||||
}
|
||||
|
||||
func (f PlaylistFeed) Validate() error {
|
||||
if f.UUID != "" && f.Domain == "" {
|
||||
return ErrFeedDomainRequired
|
||||
}
|
||||
if f.Domain != "" && f.UUID == "" {
|
||||
return ErrPlaylistFeedUUIDRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e PlaylistEntry) Validate() error {
|
||||
if err := e.Video.Validate(); err != nil {
|
||||
return fmt.Errorf("video: %w", err)
|
||||
}
|
||||
if err := e.Audio.Validate(); err != nil {
|
||||
return fmt.Errorf("audio: %w", err)
|
||||
}
|
||||
if !e.Video.IsConfigured() && !e.Audio.IsConfigured() {
|
||||
return ErrPlaylistEntryEmpty
|
||||
}
|
||||
if e.SyncRequested && (!e.Video.IsConfigured() || !e.Audio.IsConfigured()) {
|
||||
return ErrPlaylistSyncFeedsRequired
|
||||
}
|
||||
if e.Duration < 0 {
|
||||
return ErrPlaylistDurationNegative
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Playlist) Validate() error {
|
||||
for index, entry := range p.Entries {
|
||||
if err := entry.Validate(); err != nil {
|
||||
return fmt.Errorf("playlist entry %d: %w", index, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e PlaylistEntry) SessionConfig(retry RetryPolicy) SessionConfig {
|
||||
return SessionConfig{
|
||||
Video: FeedConfig{
|
||||
Domain: e.Video.Domain,
|
||||
UUID: e.Video.UUID,
|
||||
Active: e.Video.UUID != "",
|
||||
},
|
||||
Audio: FeedConfig{
|
||||
Domain: e.Audio.Domain,
|
||||
UUID: e.Audio.UUID,
|
||||
Active: e.Audio.UUID != "",
|
||||
},
|
||||
SyncRequested: e.SyncRequested,
|
||||
Retry: retry,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type PlaylistController struct {
|
||||
playlist Playlist
|
||||
retry RetryPolicy
|
||||
sessions chan<- SessionCommand
|
||||
|
||||
mu sync.RWMutex
|
||||
snapshot PlaylistSnapshot
|
||||
hasSnapshot bool
|
||||
}
|
||||
|
||||
type PlaylistSnapshot struct {
|
||||
State PlaylistState
|
||||
Entry PlaylistEntry
|
||||
Revision uint64
|
||||
}
|
||||
|
||||
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,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *PlaylistController) Run(
|
||||
ctx context.Context,
|
||||
commands <-chan PlaylistCommand,
|
||||
) error {
|
||||
state := PlaylistState{}
|
||||
revision := uint64(0)
|
||||
c.publish(state, revision)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
|
||||
case command, ok := <-commands:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
next, sessionCommand, apply, err := ApplyPlaylistSelection(
|
||||
c.playlist,
|
||||
state,
|
||||
command,
|
||||
c.retry,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if apply {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case c.sessions <- sessionCommand:
|
||||
}
|
||||
revision++
|
||||
}
|
||||
|
||||
state = next
|
||||
c.publish(state, revision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
entry, _ := state.Entry(c.playlist)
|
||||
|
||||
c.mu.Lock()
|
||||
c.snapshot = PlaylistSnapshot{
|
||||
State: state,
|
||||
Entry: entry,
|
||||
Revision: revision,
|
||||
}
|
||||
c.hasSnapshot = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewPlaylistControllerValidatesConfiguration(t *testing.T) {
|
||||
validPlaylist := navigationPlaylist(false)
|
||||
validRetry := validPlaylistRetryPolicy()
|
||||
validSessions := make(chan SessionCommand)
|
||||
tests := []struct {
|
||||
name string
|
||||
playlist Playlist
|
||||
retry RetryPolicy
|
||||
sessions chan<- SessionCommand
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "invalid playlist",
|
||||
playlist: Playlist{Entries: []PlaylistEntry{
|
||||
{},
|
||||
}},
|
||||
retry: validRetry,
|
||||
sessions: validSessions,
|
||||
wantErr: ErrPlaylistEntryEmpty,
|
||||
},
|
||||
{
|
||||
name: "invalid retry",
|
||||
playlist: validPlaylist,
|
||||
retry: RetryPolicy{},
|
||||
sessions: validSessions,
|
||||
wantErr: ErrInvalidRetryDelay,
|
||||
},
|
||||
{
|
||||
name: "nil session commands",
|
||||
playlist: validPlaylist,
|
||||
retry: validRetry,
|
||||
wantErr: ErrNilSessionCommandChannel,
|
||||
},
|
||||
{
|
||||
name: "valid",
|
||||
playlist: validPlaylist,
|
||||
retry: validRetry,
|
||||
sessions: validSessions,
|
||||
},
|
||||
{
|
||||
name: "empty playlist is valid",
|
||||
playlist: Playlist{},
|
||||
retry: validRetry,
|
||||
sessions: validSessions,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
controller, err := NewPlaylistController(test.playlist, test.retry, test.sessions)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("NewPlaylistController() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if test.wantErr != nil && controller != nil {
|
||||
t.Fatalf("NewPlaylistController() controller = %#v, want nil", controller)
|
||||
}
|
||||
if test.wantErr == nil && controller == nil {
|
||||
t.Fatal("NewPlaylistController() controller is nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistControllerPublishesInitialSnapshot(t *testing.T) {
|
||||
controller, commands, _, cancel, result := startPlaylistController(t, navigationPlaylist(false), 0)
|
||||
defer cancel()
|
||||
|
||||
snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
|
||||
return !snapshot.State.HasSelection
|
||||
})
|
||||
if snapshot.Entry != (PlaylistEntry{}) {
|
||||
t.Fatalf("initial entry = %#v, want zero value", snapshot.Entry)
|
||||
}
|
||||
|
||||
close(commands)
|
||||
if err := waitForPlaylistResult(t, result); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistControllerForwardsManualCommands(t *testing.T) {
|
||||
controller, commands, sessions, cancel, result := startPlaylistController(t, navigationPlaylist(true), 4)
|
||||
defer cancel()
|
||||
|
||||
tests := []struct {
|
||||
command PlaylistCommand
|
||||
wantIndex int
|
||||
wantUUID string
|
||||
}{
|
||||
{command: PlaylistCommand{Kind: PlaylistSelect, Index: 1}, wantIndex: 1, wantUUID: "audio-2"},
|
||||
{command: PlaylistCommand{Kind: PlaylistNext}, wantIndex: 2, wantUUID: "video-3"},
|
||||
{command: PlaylistCommand{Kind: PlaylistPrevious}, wantIndex: 1, wantUUID: "audio-2"},
|
||||
{command: PlaylistCommand{Kind: PlaylistSelect, Index: 1}, wantIndex: 1, wantUUID: "audio-2"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
commands <- test.command
|
||||
session := receivePlaylistSession(t, sessions)
|
||||
if session.Kind != CommandSetSession {
|
||||
t.Fatalf("session kind = %v, want %v", session.Kind, CommandSetSession)
|
||||
}
|
||||
gotUUID := session.Session.Video.UUID
|
||||
if gotUUID == "" {
|
||||
gotUUID = session.Session.Audio.UUID
|
||||
}
|
||||
if gotUUID != test.wantUUID {
|
||||
t.Fatalf("session UUID = %q, want %q", gotUUID, test.wantUUID)
|
||||
}
|
||||
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
|
||||
return snapshot.State.HasSelection && snapshot.State.CurrentIndex == test.wantIndex
|
||||
})
|
||||
}
|
||||
|
||||
close(commands)
|
||||
if err := waitForPlaylistResult(t, result); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistControllerNonLoopingBoundarySendsNothing(t *testing.T) {
|
||||
_, commands, sessions, cancel, result := startPlaylistController(t, navigationPlaylist(false), 2)
|
||||
defer cancel()
|
||||
|
||||
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 2}
|
||||
_ = receivePlaylistSession(t, sessions)
|
||||
commands <- PlaylistCommand{Kind: PlaylistNext}
|
||||
|
||||
select {
|
||||
case command := <-sessions:
|
||||
t.Fatalf("unexpected session command at boundary: %#v", command)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(commands)
|
||||
if err := waitForPlaylistResult(t, result); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistControllerIgnoresInvalidCommand(t *testing.T) {
|
||||
_, commands, sessions, cancel, result := startPlaylistController(t, navigationPlaylist(false), 2)
|
||||
defer cancel()
|
||||
|
||||
commands <- PlaylistCommand{}
|
||||
commands <- PlaylistCommand{Kind: PlaylistNext}
|
||||
if session := receivePlaylistSession(t, sessions); session.Session.Video.UUID != "video-1" {
|
||||
t.Fatalf("session after invalid command = %#v", session)
|
||||
}
|
||||
|
||||
close(commands)
|
||||
if err := waitForPlaylistResult(t, result); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistControllerCommitsStateAfterSessionDelivery(t *testing.T) {
|
||||
sessions := make(chan SessionCommand)
|
||||
controller, err := NewPlaylistController(navigationPlaylist(false), validPlaylistRetryPolicy(), sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPlaylistController() error = %v", err)
|
||||
}
|
||||
commands := make(chan PlaylistCommand, 1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- controller.Run(ctx, commands) }()
|
||||
|
||||
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
|
||||
return !snapshot.State.HasSelection
|
||||
})
|
||||
commands <- PlaylistCommand{Kind: PlaylistNext}
|
||||
time.Sleep(time.Millisecond)
|
||||
snapshot, ok := controller.Snapshot()
|
||||
if !ok || snapshot.State.HasSelection {
|
||||
t.Fatalf("snapshot before delivery = %#v, %v; want no selection", snapshot, ok)
|
||||
}
|
||||
|
||||
_ = receivePlaylistSession(t, sessions)
|
||||
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
|
||||
return snapshot.State.HasSelection && snapshot.State.CurrentIndex == 0
|
||||
})
|
||||
|
||||
close(commands)
|
||||
if err := waitForPlaylistResult(t, result); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistControllerClosedCommandsReturnsNil(t *testing.T) {
|
||||
_, commands, _, cancel, result := startPlaylistController(t, navigationPlaylist(false), 0)
|
||||
defer cancel()
|
||||
close(commands)
|
||||
|
||||
if err := waitForPlaylistResult(t, result); err != nil {
|
||||
t.Fatalf("Run() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistControllerCancellationWhileReceiving(t *testing.T) {
|
||||
_, _, _, cancel, result := startPlaylistController(t, navigationPlaylist(false), 0)
|
||||
cancel()
|
||||
|
||||
if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistControllerCancellationWhileSending(t *testing.T) {
|
||||
sessions := make(chan SessionCommand)
|
||||
controller, err := NewPlaylistController(navigationPlaylist(false), validPlaylistRetryPolicy(), sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPlaylistController() error = %v", err)
|
||||
}
|
||||
commands := make(chan PlaylistCommand, 1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- controller.Run(ctx, commands) }()
|
||||
|
||||
commands <- PlaylistCommand{Kind: PlaylistNext}
|
||||
cancel()
|
||||
if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistControllerSnapshotConcurrentReads(t *testing.T) {
|
||||
controller, commands, _, cancel, result := startPlaylistController(t, navigationPlaylist(true), 64)
|
||||
defer cancel()
|
||||
|
||||
var readers sync.WaitGroup
|
||||
for range 8 {
|
||||
readers.Add(1)
|
||||
go func() {
|
||||
defer readers.Done()
|
||||
for range 100 {
|
||||
_, _ = controller.Snapshot()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for range 32 {
|
||||
commands <- PlaylistCommand{Kind: PlaylistNext}
|
||||
}
|
||||
readers.Wait()
|
||||
close(commands)
|
||||
if err := waitForPlaylistResult(t, result); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
sessionBuffer int,
|
||||
) (*PlaylistController, chan PlaylistCommand, chan SessionCommand, context.CancelFunc, <-chan error) {
|
||||
t.Helper()
|
||||
sessions := make(chan SessionCommand, sessionBuffer)
|
||||
controller, err := NewPlaylistController(playlist, validPlaylistRetryPolicy(), sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPlaylistController() error = %v", err)
|
||||
}
|
||||
commands := make(chan PlaylistCommand, 64)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
result := make(chan error, 1)
|
||||
go func() { result <- controller.Run(ctx, commands) }()
|
||||
return controller, commands, sessions, cancel, result
|
||||
}
|
||||
|
||||
func waitForPlaylistSnapshot(
|
||||
t *testing.T,
|
||||
controller *PlaylistController,
|
||||
predicate func(PlaylistSnapshot) bool,
|
||||
) PlaylistSnapshot {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if snapshot, ok := controller.Snapshot(); ok && predicate(snapshot) {
|
||||
return snapshot
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
snapshot, _ := controller.Snapshot()
|
||||
t.Fatalf("timed out waiting for playlist snapshot; latest = %#v", snapshot)
|
||||
return PlaylistSnapshot{}
|
||||
}
|
||||
|
||||
func receivePlaylistSession(t *testing.T, sessions <-chan SessionCommand) SessionCommand {
|
||||
t.Helper()
|
||||
select {
|
||||
case command := <-sessions:
|
||||
return command
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for session command")
|
||||
return SessionCommand{}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPlaylistResult(t *testing.T, result <-chan error) error {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-result:
|
||||
return err
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for playlist controller")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package playback
|
||||
|
||||
import "errors"
|
||||
|
||||
type PlaylistCommandKind uint8
|
||||
|
||||
const (
|
||||
PlaylistSelect PlaylistCommandKind = iota + 1
|
||||
PlaylistNext
|
||||
PlaylistPrevious
|
||||
)
|
||||
|
||||
type PlaylistCommand struct {
|
||||
Kind PlaylistCommandKind
|
||||
Index int
|
||||
}
|
||||
|
||||
type PlaylistState struct {
|
||||
CurrentIndex int
|
||||
HasSelection bool
|
||||
}
|
||||
|
||||
var (
|
||||
ErrPlaylistEmpty = errors.New("playlist is empty")
|
||||
ErrPlaylistIndexOutOfRange = errors.New("playlist index is out of range")
|
||||
ErrPlaylistNoSelection = errors.New("playlist has no selected entry")
|
||||
ErrUnknownPlaylistCommand = errors.New("unknown playlist command")
|
||||
)
|
||||
|
||||
func ApplyPlaylistCommand(
|
||||
playlist Playlist,
|
||||
current PlaylistState,
|
||||
command PlaylistCommand,
|
||||
) (PlaylistState, error) {
|
||||
if err := playlist.Validate(); err != nil {
|
||||
return current, err
|
||||
}
|
||||
if len(playlist.Entries) == 0 {
|
||||
return current, ErrPlaylistEmpty
|
||||
}
|
||||
|
||||
lastIndex := len(playlist.Entries) - 1
|
||||
switch command.Kind {
|
||||
case PlaylistSelect:
|
||||
if command.Index < 0 || command.Index > lastIndex {
|
||||
return current, ErrPlaylistIndexOutOfRange
|
||||
}
|
||||
return PlaylistState{CurrentIndex: command.Index, HasSelection: true}, nil
|
||||
|
||||
case PlaylistNext:
|
||||
if !current.HasSelection {
|
||||
return PlaylistState{CurrentIndex: 0, HasSelection: true}, nil
|
||||
}
|
||||
if current.CurrentIndex < 0 || current.CurrentIndex > lastIndex {
|
||||
return current, ErrPlaylistIndexOutOfRange
|
||||
}
|
||||
if current.CurrentIndex == lastIndex {
|
||||
if playlist.Loop {
|
||||
return PlaylistState{CurrentIndex: 0, HasSelection: true}, nil
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
return PlaylistState{CurrentIndex: current.CurrentIndex + 1, HasSelection: true}, nil
|
||||
|
||||
case PlaylistPrevious:
|
||||
if !current.HasSelection {
|
||||
index := 0
|
||||
if playlist.Loop {
|
||||
index = lastIndex
|
||||
}
|
||||
return PlaylistState{CurrentIndex: index, HasSelection: true}, nil
|
||||
}
|
||||
if current.CurrentIndex < 0 || current.CurrentIndex > lastIndex {
|
||||
return current, ErrPlaylistIndexOutOfRange
|
||||
}
|
||||
if current.CurrentIndex == 0 {
|
||||
if playlist.Loop {
|
||||
return PlaylistState{CurrentIndex: lastIndex, HasSelection: true}, nil
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
return PlaylistState{CurrentIndex: current.CurrentIndex - 1, HasSelection: true}, nil
|
||||
|
||||
default:
|
||||
return current, ErrUnknownPlaylistCommand
|
||||
}
|
||||
}
|
||||
|
||||
func (s PlaylistState) Entry(playlist Playlist) (PlaylistEntry, bool) {
|
||||
if !s.HasSelection || s.CurrentIndex < 0 || s.CurrentIndex >= len(playlist.Entries) {
|
||||
return PlaylistEntry{}, false
|
||||
}
|
||||
return playlist.Entries[s.CurrentIndex], true
|
||||
}
|
||||
|
||||
func ApplyPlaylistSelection(
|
||||
playlist Playlist,
|
||||
current PlaylistState,
|
||||
command PlaylistCommand,
|
||||
retry RetryPolicy,
|
||||
) (
|
||||
next PlaylistState,
|
||||
sessionCommand SessionCommand,
|
||||
apply bool,
|
||||
err error,
|
||||
) {
|
||||
next, err = ApplyPlaylistCommand(playlist, current, command)
|
||||
if err != nil {
|
||||
return current, SessionCommand{}, false, err
|
||||
}
|
||||
|
||||
apply = command.Kind == PlaylistSelect || next != current
|
||||
if !apply {
|
||||
return next, SessionCommand{}, false, nil
|
||||
}
|
||||
|
||||
entry, ok := next.Entry(playlist)
|
||||
if !ok {
|
||||
return current, SessionCommand{}, false, ErrPlaylistNoSelection
|
||||
}
|
||||
session := entry.SessionConfig(retry)
|
||||
if err := session.Validate(); err != nil {
|
||||
return current, SessionCommand{}, false, err
|
||||
}
|
||||
|
||||
return next, SessionCommand{
|
||||
Kind: CommandSetSession,
|
||||
Session: session,
|
||||
}, true, nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func navigationPlaylist(loop bool) Playlist {
|
||||
return Playlist{
|
||||
Entries: []PlaylistEntry{
|
||||
{Name: "first", Video: PlaylistFeed{Domain: "domain", UUID: "video-1"}},
|
||||
{Name: "second", Audio: PlaylistFeed{Domain: "domain", UUID: "audio-2"}},
|
||||
{Name: "third", Video: PlaylistFeed{Domain: "domain", UUID: "video-3"}},
|
||||
},
|
||||
Loop: loop,
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlaylistCommandSelect(t *testing.T) {
|
||||
current := PlaylistState{CurrentIndex: 1, HasSelection: true}
|
||||
|
||||
got, err := ApplyPlaylistCommand(
|
||||
navigationPlaylist(false),
|
||||
current,
|
||||
PlaylistCommand{Kind: PlaylistSelect, Index: 2},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyPlaylistCommand() error = %v", err)
|
||||
}
|
||||
want := PlaylistState{CurrentIndex: 2, HasSelection: true}
|
||||
if got != want {
|
||||
t.Fatalf("ApplyPlaylistCommand() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlaylistCommandWithoutSelection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loop bool
|
||||
command PlaylistCommandKind
|
||||
want int
|
||||
}{
|
||||
{name: "next", command: PlaylistNext, want: 0},
|
||||
{name: "previous without loop", command: PlaylistPrevious, want: 0},
|
||||
{name: "previous with loop", loop: true, command: PlaylistPrevious, want: 2},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := ApplyPlaylistCommand(
|
||||
navigationPlaylist(test.loop),
|
||||
PlaylistState{},
|
||||
PlaylistCommand{Kind: test.command},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyPlaylistCommand() error = %v", err)
|
||||
}
|
||||
want := PlaylistState{CurrentIndex: test.want, HasSelection: true}
|
||||
if got != want {
|
||||
t.Fatalf("ApplyPlaylistCommand() = %#v, want %#v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlaylistCommandNavigation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loop bool
|
||||
current int
|
||||
command PlaylistCommandKind
|
||||
want int
|
||||
}{
|
||||
{name: "next", current: 1, command: PlaylistNext, want: 2},
|
||||
{name: "previous", current: 1, command: PlaylistPrevious, want: 0},
|
||||
{name: "next stops at end", current: 2, command: PlaylistNext, want: 2},
|
||||
{name: "previous stops at beginning", current: 0, command: PlaylistPrevious, want: 0},
|
||||
{name: "next wraps", loop: true, current: 2, command: PlaylistNext, want: 0},
|
||||
{name: "previous wraps", loop: true, current: 0, command: PlaylistPrevious, want: 2},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
current := PlaylistState{CurrentIndex: test.current, HasSelection: true}
|
||||
got, err := ApplyPlaylistCommand(
|
||||
navigationPlaylist(test.loop),
|
||||
current,
|
||||
PlaylistCommand{Kind: test.command},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyPlaylistCommand() error = %v", err)
|
||||
}
|
||||
want := PlaylistState{CurrentIndex: test.want, HasSelection: true}
|
||||
if got != want {
|
||||
t.Fatalf("ApplyPlaylistCommand() = %#v, want %#v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlaylistCommandErrorsLeaveStateUnchanged(t *testing.T) {
|
||||
current := PlaylistState{CurrentIndex: 1, HasSelection: true}
|
||||
tests := []struct {
|
||||
name string
|
||||
playlist Playlist
|
||||
command PlaylistCommand
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "empty playlist",
|
||||
playlist: Playlist{},
|
||||
command: PlaylistCommand{Kind: PlaylistNext},
|
||||
wantErr: ErrPlaylistEmpty,
|
||||
},
|
||||
{
|
||||
name: "negative selection",
|
||||
playlist: navigationPlaylist(false),
|
||||
command: PlaylistCommand{Kind: PlaylistSelect, Index: -1},
|
||||
wantErr: ErrPlaylistIndexOutOfRange,
|
||||
},
|
||||
{
|
||||
name: "selection past end",
|
||||
playlist: navigationPlaylist(false),
|
||||
command: PlaylistCommand{Kind: PlaylistSelect, Index: 3},
|
||||
wantErr: ErrPlaylistIndexOutOfRange,
|
||||
},
|
||||
{
|
||||
name: "stale current index",
|
||||
playlist: navigationPlaylist(false),
|
||||
command: PlaylistCommand{Kind: PlaylistNext},
|
||||
wantErr: ErrPlaylistIndexOutOfRange,
|
||||
},
|
||||
{
|
||||
name: "unknown command",
|
||||
playlist: navigationPlaylist(false),
|
||||
command: PlaylistCommand{},
|
||||
wantErr: ErrUnknownPlaylistCommand,
|
||||
},
|
||||
{
|
||||
name: "invalid playlist",
|
||||
playlist: Playlist{Entries: []PlaylistEntry{
|
||||
{},
|
||||
}},
|
||||
command: PlaylistCommand{Kind: PlaylistNext},
|
||||
wantErr: ErrPlaylistEntryEmpty,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
state := current
|
||||
if test.name == "stale current index" {
|
||||
state.CurrentIndex = 4
|
||||
}
|
||||
got, err := ApplyPlaylistCommand(test.playlist, state, test.command)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("ApplyPlaylistCommand() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if got != state {
|
||||
t.Fatalf("ApplyPlaylistCommand() = %#v, want unchanged %#v", got, state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistStateEntry(t *testing.T) {
|
||||
playlist := navigationPlaylist(false)
|
||||
tests := []struct {
|
||||
name string
|
||||
state PlaylistState
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{name: "no selection", state: PlaylistState{}},
|
||||
{name: "selected", state: PlaylistState{CurrentIndex: 1, HasSelection: true}, want: "second", ok: true},
|
||||
{name: "negative stale index", state: PlaylistState{CurrentIndex: -1, HasSelection: true}},
|
||||
{name: "stale index", state: PlaylistState{CurrentIndex: 3, HasSelection: true}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
entry, ok := test.state.Entry(playlist)
|
||||
if ok != test.ok {
|
||||
t.Fatalf("Entry() ok = %v, want %v", ok, test.ok)
|
||||
}
|
||||
if entry.Name != test.want {
|
||||
t.Fatalf("Entry() name = %q, want %q", entry.Name, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package playback
|
||||
|
||||
func IsSessionPlaying(
|
||||
session SessionSnapshot,
|
||||
statuses PlaybackStatusSnapshot,
|
||||
) bool {
|
||||
if statuses.Generation != session.Generation {
|
||||
return false
|
||||
}
|
||||
|
||||
switch session.Plan.Topology {
|
||||
case TopologyIndependent:
|
||||
hasActiveFeed := session.Plan.Video.Active || session.Plan.Audio.Active
|
||||
if !hasActiveFeed {
|
||||
return false
|
||||
}
|
||||
if session.Plan.Video.Active && !statusIsPlaying(
|
||||
statuses.Video,
|
||||
statuses.HasVideo,
|
||||
session.Generation,
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if session.Plan.Audio.Active && !statusIsPlaying(
|
||||
statuses.Audio,
|
||||
statuses.HasAudio,
|
||||
session.Generation,
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
case TopologySynchronized:
|
||||
return statusIsPlaying(
|
||||
statuses.Sync,
|
||||
statuses.HasSync,
|
||||
session.Generation,
|
||||
)
|
||||
|
||||
case TopologyIdle:
|
||||
return false
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func statusIsPlaying(status Status, present bool, generation uint64) bool {
|
||||
return present &&
|
||||
status.Generation == generation &&
|
||||
status.State == StatePlaying
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package playback
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsSessionPlaying(t *testing.T) {
|
||||
const generation = 4
|
||||
playing := func(unit Unit) Status {
|
||||
return Status{Unit: unit, State: StatePlaying, Generation: generation}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
session SessionSnapshot
|
||||
statuses PlaybackStatusSnapshot
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "video only playing",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{
|
||||
Topology: TopologyIndependent,
|
||||
Video: FeedConfig{UUID: "video", Active: true},
|
||||
},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{
|
||||
Generation: generation,
|
||||
Video: playing(UnitVideo),
|
||||
HasVideo: true,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "audio only playing",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{
|
||||
Topology: TopologyIndependent,
|
||||
Audio: FeedConfig{UUID: "audio", Active: true},
|
||||
},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{
|
||||
Generation: generation,
|
||||
Audio: playing(UnitAudio),
|
||||
HasAudio: true,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "both independent feeds playing",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{
|
||||
Topology: TopologyIndependent,
|
||||
Video: FeedConfig{UUID: "video", Active: true},
|
||||
Audio: FeedConfig{UUID: "audio", Active: true},
|
||||
},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{
|
||||
Generation: generation,
|
||||
Video: playing(UnitVideo),
|
||||
HasVideo: true,
|
||||
Audio: playing(UnitAudio),
|
||||
HasAudio: true,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "only video of independent pair playing",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{
|
||||
Topology: TopologyIndependent,
|
||||
Video: FeedConfig{UUID: "video", Active: true},
|
||||
Audio: FeedConfig{UUID: "audio", Active: true},
|
||||
},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{
|
||||
Generation: generation,
|
||||
Video: playing(UnitVideo),
|
||||
HasVideo: true,
|
||||
Audio: Status{Unit: UnitAudio, State: StateConnecting, Generation: generation},
|
||||
HasAudio: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "synchronized unit playing",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{Topology: TopologySynchronized},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{
|
||||
Generation: generation,
|
||||
Sync: playing(UnitSync),
|
||||
HasSync: true,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "sync ignores independent playing statuses",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{Topology: TopologySynchronized},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{
|
||||
Generation: generation,
|
||||
Video: playing(UnitVideo),
|
||||
HasVideo: true,
|
||||
Audio: playing(UnitAudio),
|
||||
HasAudio: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reconnecting is not playing",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{
|
||||
Topology: TopologyIndependent,
|
||||
Video: FeedConfig{UUID: "video", Active: true},
|
||||
},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{
|
||||
Generation: generation,
|
||||
Video: Status{Unit: UnitVideo, State: StateReconnecting, Generation: generation},
|
||||
HasVideo: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing status",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{
|
||||
Topology: TopologyIndependent,
|
||||
Video: FeedConfig{UUID: "video", Active: true},
|
||||
},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{Generation: generation},
|
||||
},
|
||||
{
|
||||
name: "older status snapshot",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{Topology: TopologySynchronized},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{
|
||||
Generation: generation - 1,
|
||||
Sync: Status{Unit: UnitSync, State: StatePlaying, Generation: generation - 1},
|
||||
HasSync: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "newer status snapshot",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{Topology: TopologySynchronized},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{
|
||||
Generation: generation + 1,
|
||||
Sync: Status{Unit: UnitSync, State: StatePlaying, Generation: generation + 1},
|
||||
HasSync: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "individual status has wrong generation",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{Topology: TopologySynchronized},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{
|
||||
Generation: generation,
|
||||
Sync: Status{Unit: UnitSync, State: StatePlaying, Generation: generation - 1},
|
||||
HasSync: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "idle",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{Topology: TopologyIdle},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{Generation: generation},
|
||||
},
|
||||
{
|
||||
name: "unknown topology",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{Topology: SessionTopology(255)},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{Generation: generation},
|
||||
},
|
||||
{
|
||||
name: "independent without active feeds",
|
||||
session: SessionSnapshot{
|
||||
Generation: generation,
|
||||
Plan: SessionPlan{Topology: TopologyIndependent},
|
||||
},
|
||||
statuses: PlaybackStatusSnapshot{Generation: generation},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := IsSessionPlaying(test.session, test.statuses); got != test.want {
|
||||
t.Fatalf("IsSessionPlaying() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestApplyPlaylistSelectionFirstNext(t *testing.T) {
|
||||
retry := validPlaylistRetryPolicy()
|
||||
|
||||
next, command, apply, err := ApplyPlaylistSelection(
|
||||
navigationPlaylist(false),
|
||||
PlaylistState{},
|
||||
PlaylistCommand{Kind: PlaylistNext},
|
||||
retry,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyPlaylistSelection() error = %v", err)
|
||||
}
|
||||
if !apply {
|
||||
t.Fatal("ApplyPlaylistSelection() apply = false, want true")
|
||||
}
|
||||
wantState := PlaylistState{CurrentIndex: 0, HasSelection: true}
|
||||
if next != wantState {
|
||||
t.Fatalf("ApplyPlaylistSelection() state = %#v, want %#v", next, wantState)
|
||||
}
|
||||
wantCommand := SessionCommand{
|
||||
Kind: CommandSetSession,
|
||||
Session: SessionConfig{
|
||||
Video: FeedConfig{Domain: "domain", UUID: "video-1", Active: true},
|
||||
Retry: retry,
|
||||
},
|
||||
}
|
||||
if command != wantCommand {
|
||||
t.Fatalf("ApplyPlaylistSelection() command = %#v, want %#v", command, wantCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlaylistSelectionUsesCompleteEntrySession(t *testing.T) {
|
||||
retry := validPlaylistRetryPolicy()
|
||||
playlist := Playlist{Entries: []PlaylistEntry{
|
||||
{
|
||||
Video: PlaylistFeed{Domain: "video-domain", UUID: "video"},
|
||||
Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"},
|
||||
SyncRequested: true,
|
||||
},
|
||||
{Video: PlaylistFeed{Domain: "next-domain", UUID: "next-video"}},
|
||||
}}
|
||||
|
||||
_, command, apply, err := ApplyPlaylistSelection(
|
||||
playlist,
|
||||
PlaylistState{CurrentIndex: 1, HasSelection: true},
|
||||
PlaylistCommand{Kind: PlaylistSelect, Index: 0},
|
||||
retry,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyPlaylistSelection() error = %v", err)
|
||||
}
|
||||
if !apply {
|
||||
t.Fatal("ApplyPlaylistSelection() apply = false, want true")
|
||||
}
|
||||
want := SessionConfig{
|
||||
Video: FeedConfig{Domain: "video-domain", UUID: "video", Active: true},
|
||||
Audio: FeedConfig{Domain: "audio-domain", UUID: "audio", Active: true},
|
||||
SyncRequested: true,
|
||||
Retry: retry,
|
||||
}
|
||||
if command.Kind != CommandSetSession || command.Session != want {
|
||||
t.Fatalf("ApplyPlaylistSelection() command = %#v, want session %#v", command, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlaylistSelectionVideoOnlyClearsAudio(t *testing.T) {
|
||||
_, command, apply, err := ApplyPlaylistSelection(
|
||||
navigationPlaylist(false),
|
||||
PlaylistState{CurrentIndex: 1, HasSelection: true},
|
||||
PlaylistCommand{Kind: PlaylistSelect, Index: 0},
|
||||
validPlaylistRetryPolicy(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyPlaylistSelection() error = %v", err)
|
||||
}
|
||||
if !apply {
|
||||
t.Fatal("ApplyPlaylistSelection() apply = false, want true")
|
||||
}
|
||||
if command.Session.Audio != (FeedConfig{}) {
|
||||
t.Fatalf("ApplyPlaylistSelection() audio = %#v, want zero value", command.Session.Audio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlaylistSelectionApplicationDecision(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loop bool
|
||||
current int
|
||||
command PlaylistCommand
|
||||
want int
|
||||
apply bool
|
||||
}{
|
||||
{name: "reselect current", current: 1, command: PlaylistCommand{Kind: PlaylistSelect, Index: 1}, want: 1, apply: true},
|
||||
{name: "move next", current: 1, command: PlaylistCommand{Kind: PlaylistNext}, want: 2, apply: true},
|
||||
{name: "next stops at end", current: 2, command: PlaylistCommand{Kind: PlaylistNext}, want: 2},
|
||||
{name: "previous stops at beginning", current: 0, command: PlaylistCommand{Kind: PlaylistPrevious}, want: 0},
|
||||
{name: "next wraps", loop: true, current: 2, command: PlaylistCommand{Kind: PlaylistNext}, want: 0, apply: true},
|
||||
{name: "previous wraps", loop: true, current: 0, command: PlaylistCommand{Kind: PlaylistPrevious}, want: 2, apply: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
next, command, apply, err := ApplyPlaylistSelection(
|
||||
navigationPlaylist(test.loop),
|
||||
PlaylistState{CurrentIndex: test.current, HasSelection: true},
|
||||
test.command,
|
||||
validPlaylistRetryPolicy(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyPlaylistSelection() error = %v", err)
|
||||
}
|
||||
wantState := PlaylistState{CurrentIndex: test.want, HasSelection: true}
|
||||
if next != wantState {
|
||||
t.Fatalf("ApplyPlaylistSelection() state = %#v, want %#v", next, wantState)
|
||||
}
|
||||
if apply != test.apply {
|
||||
t.Fatalf("ApplyPlaylistSelection() apply = %v, want %v", apply, test.apply)
|
||||
}
|
||||
if apply && command.Kind != CommandSetSession {
|
||||
t.Fatalf("ApplyPlaylistSelection() command kind = %v, want %v", command.Kind, CommandSetSession)
|
||||
}
|
||||
if !apply && command != (SessionCommand{}) {
|
||||
t.Fatalf("ApplyPlaylistSelection() command = %#v, want zero value", command)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlaylistSelectionErrorsDoNotApply(t *testing.T) {
|
||||
current := PlaylistState{CurrentIndex: 1, HasSelection: true}
|
||||
tests := []struct {
|
||||
name string
|
||||
command PlaylistCommand
|
||||
retry RetryPolicy
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "invalid command",
|
||||
command: PlaylistCommand{},
|
||||
retry: validPlaylistRetryPolicy(),
|
||||
wantErr: ErrUnknownPlaylistCommand,
|
||||
},
|
||||
{
|
||||
name: "invalid retry",
|
||||
command: PlaylistCommand{Kind: PlaylistNext},
|
||||
retry: RetryPolicy{},
|
||||
wantErr: ErrInvalidRetryDelay,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
next, command, apply, err := ApplyPlaylistSelection(
|
||||
navigationPlaylist(false),
|
||||
current,
|
||||
test.command,
|
||||
test.retry,
|
||||
)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("ApplyPlaylistSelection() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if next != current {
|
||||
t.Fatalf("ApplyPlaylistSelection() state = %#v, want unchanged %#v", next, current)
|
||||
}
|
||||
if apply {
|
||||
t.Fatal("ApplyPlaylistSelection() apply = true, want false")
|
||||
}
|
||||
if command != (SessionCommand{}) {
|
||||
t.Fatalf("ApplyPlaylistSelection() command = %#v, want zero value", command)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validPlaylistRetryPolicy() RetryPolicy {
|
||||
return RetryPolicy{
|
||||
MaxAttempts: 3,
|
||||
InitialDelay: time.Millisecond,
|
||||
MaxDelay: time.Second,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPlaylistEntryValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entry PlaylistEntry
|
||||
wantErr error
|
||||
}{
|
||||
{name: "video only", entry: PlaylistEntry{Video: PlaylistFeed{Domain: "video-domain", UUID: "video"}}},
|
||||
{name: "audio only", entry: PlaylistEntry{Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"}}},
|
||||
{
|
||||
name: "independent feeds from different domains",
|
||||
entry: PlaylistEntry{
|
||||
Video: PlaylistFeed{Domain: "video-domain", UUID: "video"},
|
||||
Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "synchronized pair",
|
||||
entry: PlaylistEntry{
|
||||
Video: PlaylistFeed{Domain: "domain", UUID: "video"},
|
||||
Audio: PlaylistFeed{Domain: "domain", UUID: "audio"},
|
||||
SyncRequested: true,
|
||||
Duration: 10 * time.Second,
|
||||
},
|
||||
},
|
||||
{name: "zero duration", entry: PlaylistEntry{Video: PlaylistFeed{Domain: "domain", UUID: "video"}}},
|
||||
{name: "empty entry", entry: PlaylistEntry{}, wantErr: ErrPlaylistEntryEmpty},
|
||||
{
|
||||
name: "video UUID without domain",
|
||||
entry: PlaylistEntry{Video: PlaylistFeed{UUID: "video"}},
|
||||
wantErr: ErrFeedDomainRequired,
|
||||
},
|
||||
{
|
||||
name: "audio domain without UUID",
|
||||
entry: PlaylistEntry{Audio: PlaylistFeed{Domain: "audio-domain"}},
|
||||
wantErr: ErrPlaylistFeedUUIDRequired,
|
||||
},
|
||||
{
|
||||
name: "sync without audio",
|
||||
entry: PlaylistEntry{
|
||||
Video: PlaylistFeed{Domain: "domain", UUID: "video"},
|
||||
SyncRequested: true,
|
||||
},
|
||||
wantErr: ErrPlaylistSyncFeedsRequired,
|
||||
},
|
||||
{
|
||||
name: "sync without video",
|
||||
entry: PlaylistEntry{
|
||||
Audio: PlaylistFeed{Domain: "domain", UUID: "audio"},
|
||||
SyncRequested: true,
|
||||
},
|
||||
wantErr: ErrPlaylistSyncFeedsRequired,
|
||||
},
|
||||
{
|
||||
name: "negative duration",
|
||||
entry: PlaylistEntry{
|
||||
Video: PlaylistFeed{Domain: "domain", UUID: "video"},
|
||||
Duration: -time.Second,
|
||||
},
|
||||
wantErr: ErrPlaylistDurationNegative,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := test.entry.Validate()
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Validate() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistEntrySessionConfig(t *testing.T) {
|
||||
retry := RetryPolicy{MaxAttempts: 3, InitialDelay: time.Second, MaxDelay: 5 * time.Second}
|
||||
entry := PlaylistEntry{
|
||||
Video: PlaylistFeed{Domain: "video-domain", UUID: "video"},
|
||||
Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"},
|
||||
SyncRequested: true,
|
||||
Duration: 10 * time.Second,
|
||||
}
|
||||
|
||||
got := entry.SessionConfig(retry)
|
||||
want := SessionConfig{
|
||||
Video: FeedConfig{Domain: "video-domain", UUID: "video", Active: true},
|
||||
Audio: FeedConfig{Domain: "audio-domain", UUID: "audio", Active: true},
|
||||
SyncRequested: true,
|
||||
Retry: retry,
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("SessionConfig() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistEntrySessionConfigLeavesMissingFeedInactive(t *testing.T) {
|
||||
entry := PlaylistEntry{Video: PlaylistFeed{Domain: "domain", UUID: "video"}}
|
||||
|
||||
got := entry.SessionConfig(RetryPolicy{})
|
||||
if !got.Video.Active {
|
||||
t.Fatal("SessionConfig() video is inactive, want active")
|
||||
}
|
||||
if got.Audio != (FeedConfig{}) {
|
||||
t.Fatalf("SessionConfig() audio = %#v, want zero value", got.Audio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistValidate(t *testing.T) {
|
||||
valid := PlaylistEntry{Video: PlaylistFeed{Domain: "domain", UUID: "video"}}
|
||||
tests := []struct {
|
||||
name string
|
||||
playlist Playlist
|
||||
wantErr error
|
||||
}{
|
||||
{name: "empty playlist", playlist: Playlist{}},
|
||||
{name: "valid entries", playlist: Playlist{Entries: []PlaylistEntry{valid, valid}, Loop: true}},
|
||||
{
|
||||
name: "invalid entry",
|
||||
playlist: Playlist{Entries: []PlaylistEntry{
|
||||
valid,
|
||||
{Audio: PlaylistFeed{UUID: "audio"}},
|
||||
}},
|
||||
wantErr: ErrFeedDomainRequired,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := test.playlist.Validate()
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Validate() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistValidateReportsEntryIndex(t *testing.T) {
|
||||
playlist := Playlist{Entries: []PlaylistEntry{
|
||||
{Video: PlaylistFeed{Domain: "domain", UUID: "video"}},
|
||||
{},
|
||||
}}
|
||||
|
||||
err := playlist.Validate()
|
||||
if err == nil || err.Error() != "playlist entry 1: playlist entry must contain at least one feed" {
|
||||
t.Fatalf("Validate() error = %v, want indexed entry error", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,19 @@ type StatusStore struct {
|
||||
statuses map[Unit]Status
|
||||
}
|
||||
|
||||
type PlaybackStatusSnapshot struct {
|
||||
Generation uint64
|
||||
|
||||
Video Status
|
||||
HasVideo bool
|
||||
|
||||
Audio Status
|
||||
HasAudio bool
|
||||
|
||||
Sync Status
|
||||
HasSync bool
|
||||
}
|
||||
|
||||
func NewStatusStore() *StatusStore {
|
||||
return &StatusStore{
|
||||
statuses: make(map[Unit]Status),
|
||||
@@ -34,6 +47,17 @@ func (s *StatusStore) Snapshot(unit Unit) (Status, bool) {
|
||||
return status, ok
|
||||
}
|
||||
|
||||
func (s *StatusStore) SnapshotAll() PlaybackStatusSnapshot {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
snapshot := PlaybackStatusSnapshot{Generation: s.generation}
|
||||
snapshot.Video, snapshot.HasVideo = s.statuses[UnitVideo]
|
||||
snapshot.Audio, snapshot.HasAudio = s.statuses[UnitAudio]
|
||||
snapshot.Sync, snapshot.HasSync = s.statuses[UnitSync]
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func (s *StatusStore) Clear(unit Unit) {
|
||||
s.mu.Lock()
|
||||
delete(s.statuses, unit)
|
||||
|
||||
@@ -153,3 +153,69 @@ func TestStatusStoreKeepsEqualGenerationUnitsIndependent(t *testing.T) {
|
||||
t.Fatalf("audio Snapshot() = %#v, %t", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusStoreSnapshotAll(t *testing.T) {
|
||||
store := NewStatusStore()
|
||||
wantVideo := Status{Unit: UnitVideo, State: StatePlaying, Generation: 5}
|
||||
wantAudio := Status{Unit: UnitAudio, State: StateReconnecting, Generation: 5}
|
||||
store.Observe(wantVideo)
|
||||
store.Observe(wantAudio)
|
||||
|
||||
got := store.SnapshotAll()
|
||||
if got.Generation != 5 {
|
||||
t.Fatalf("SnapshotAll() generation = %d, want 5", got.Generation)
|
||||
}
|
||||
if !got.HasVideo || got.Video != wantVideo {
|
||||
t.Fatalf("SnapshotAll() video = %#v, %v; want %#v, true", got.Video, got.HasVideo, wantVideo)
|
||||
}
|
||||
if !got.HasAudio || got.Audio != wantAudio {
|
||||
t.Fatalf("SnapshotAll() audio = %#v, %v; want %#v, true", got.Audio, got.HasAudio, wantAudio)
|
||||
}
|
||||
if got.HasSync {
|
||||
t.Fatalf("SnapshotAll() HasSync = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusStoreSnapshotAllClearsOldGenerationUnits(t *testing.T) {
|
||||
store := NewStatusStore()
|
||||
store.Observe(Status{Unit: UnitVideo, State: StatePlaying, Generation: 2})
|
||||
store.Observe(Status{Unit: UnitAudio, State: StatePlaying, Generation: 2})
|
||||
wantSync := Status{Unit: UnitSync, State: StateConnecting, Generation: 3}
|
||||
store.Observe(wantSync)
|
||||
|
||||
got := store.SnapshotAll()
|
||||
if got.Generation != 3 {
|
||||
t.Fatalf("SnapshotAll() generation = %d, want 3", got.Generation)
|
||||
}
|
||||
if got.HasVideo || got.HasAudio {
|
||||
t.Fatalf("SnapshotAll() retained old units: %#v", got)
|
||||
}
|
||||
if !got.HasSync || got.Sync != wantSync {
|
||||
t.Fatalf("SnapshotAll() sync = %#v, %v; want %#v, true", got.Sync, got.HasSync, wantSync)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusStoreSnapshotAllConcurrentObserve(t *testing.T) {
|
||||
store := NewStatusStore()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for generation := uint64(1); generation <= 1000; generation++ {
|
||||
store.Observe(Status{
|
||||
Unit: Unit(generation % 3),
|
||||
State: StatePlaying,
|
||||
Generation: generation,
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
_ = store.SnapshotAll()
|
||||
return
|
||||
default:
|
||||
_ = store.SnapshotAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user