Compare commits

..

6 Commits

Author SHA1 Message Date
Dmitry Sergeev 1e21da0aac evision-safe playlist timing 2026-09-01 20:12:12 +03:00
Dmitry Sergeev 532e03ffca generation-aware playlist readiness 2026-09-01 20:05:00 +03:00
Dmitry Sergeev b160e3aba2 PlaylistController 2026-09-01 20:02:42 +03:00
Dmitry Sergeev ce4ea3fa00 playlist-to-session bridge 2026-09-01 19:53:01 +03:00
Dmitry Sergeev a79a2d9c7a pure playlist navigation 2026-09-01 19:41:52 +03:00
Dmitry Sergeev e50ad8adb5 playlist model 2026-09-01 19:13:12 +03:00
13 changed files with 1751 additions and 0 deletions
+91
View File
@@ -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,
}
}
+109
View File
@@ -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
}
}
+130
View File
@@ -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)
}
})
}
}
+52
View File
@@ -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,
}
}
+153
View File
@@ -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)
}
}
+52
View File
@@ -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
}
+119
View File
@@ -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)
}
}
+24
View File
@@ -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)
+66
View File
@@ -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()
}
}
}