Compare commits
6 Commits
9bc08109fc
...
1e21da0aac
| 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
|
statuses map[Unit]Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlaybackStatusSnapshot struct {
|
||||||
|
Generation uint64
|
||||||
|
|
||||||
|
Video Status
|
||||||
|
HasVideo bool
|
||||||
|
|
||||||
|
Audio Status
|
||||||
|
HasAudio bool
|
||||||
|
|
||||||
|
Sync Status
|
||||||
|
HasSync bool
|
||||||
|
}
|
||||||
|
|
||||||
func NewStatusStore() *StatusStore {
|
func NewStatusStore() *StatusStore {
|
||||||
return &StatusStore{
|
return &StatusStore{
|
||||||
statuses: make(map[Unit]Status),
|
statuses: make(map[Unit]Status),
|
||||||
@@ -34,6 +47,17 @@ func (s *StatusStore) Snapshot(unit Unit) (Status, bool) {
|
|||||||
return status, ok
|
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) {
|
func (s *StatusStore) Clear(unit Unit) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
delete(s.statuses, unit)
|
delete(s.statuses, unit)
|
||||||
|
|||||||
@@ -153,3 +153,69 @@ func TestStatusStoreKeepsEqualGenerationUnitsIndependent(t *testing.T) {
|
|||||||
t.Fatalf("audio Snapshot() = %#v, %t", got, ok)
|
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