Compare commits

...

8 Commits

Author SHA1 Message Date
Dmitry Sergeev ca25bf88a7 playlist timer pause/resume without affecting active feeds 2026-09-01 22:32:19 +03:00
Dmitry Sergeev b0e7bc4cc3 playlist GUI 2026-09-01 22:26:26 +03:00
Dmitry Sergeev cb5321cd8b CLI playlist support 2026-09-01 22:16:36 +03:00
Dmitry Sergeev 362ae15867 JSON playlist loader 2026-09-01 21:58:20 +03:00
Dmitry Sergeev 3b7662fea7 optional playlist runtime composition 2026-09-01 21:48:29 +03:00
Dmitry Sergeev 1e504f0f92 live playlist readiness coordinator 2026-09-01 21:28:53 +03:00
Dmitry Sergeev cd0298b136 source-aware playback statuses. 2026-09-01 21:22:30 +03:00
Dmitry Sergeev d3ea99233d automatic playlist timing into PlaylistController 2026-09-01 20:16:07 +03:00
22 changed files with 2521 additions and 62 deletions
+175 -1
View File
@@ -53,6 +53,7 @@ type appArgs struct {
ListGPU bool
SyncRequested bool
MaxAttempts int
PlaylistPath string
}
func printCliHelp(fs *pflag.FlagSet) {
@@ -129,6 +130,12 @@ func main() {
0,
"Maximum connection attempts per playback lifecycle; 0 retries indefinitely",
)
flagSet.StringVar(
&args.PlaylistPath,
"playlist",
"",
"Load playlist from a JSON file",
)
flagSet.BoolVarP(&args.IsFullscreen, "fullscreen", "f", false, "Run app in fullscreen mode")
flagSet.Uint32VarP(&args.GpuId, "gpu-id", "g", 0, "GPU id [TODO]")
flagSet.Uint32VarP(&args.PlaybackId, "playback-id", "p", 0, "Playback audio device id")
@@ -159,6 +166,16 @@ func main() {
fmt.Fprintln(os.Stderr, "invalid retry configuration:", err)
os.Exit(2)
}
configuredPlaylist := playback.Playlist{}
hasPlaylist := args.PlaylistPath != ""
if hasPlaylist {
playlist, err := loadPlaylistFile(args.PlaylistPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
configuredPlaylist = playlist
}
if args.VideoDomain == "" {
args.VideoDomain = args.Domain
}
@@ -415,6 +432,28 @@ func main() {
)
}()
var playlistRuntime *playerPlaylist
var playlistDone chan error
if hasPlaylist {
playlistRuntime, err = newPlayerPlaylist(
configuredPlaylist,
retryPolicy,
player,
)
if err != nil {
panic(err)
}
playlistDone = make(chan error, 1)
go func() {
playlistDone <- playlistRuntime.Run(ctx)
}()
if shouldAutoStartPlaylist(args, configuredPlaylist) &&
!playlistRuntime.Select(0) {
log.Print("playlist command queue is full")
}
}
running := true
resized := false
fullscreen := args.IsFullscreen
@@ -662,6 +701,136 @@ func main() {
drawFeedsSections()
}
if playlistRuntime != nil &&
cimgui.CollapsingHeaderTreeNodeFlagsV("Playlist", collapsingHeaderFlags) {
playlistSnapshot, hasPlaylistSnapshot :=
playlistRuntime.Controller.Snapshot()
cimgui.TextWrapped(fmt.Sprintf("File: %s", args.PlaylistPath))
cimgui.Text(fmt.Sprintf("Entries: %d", len(configuredPlaylist.Entries)))
if configuredPlaylist.Loop {
cimgui.Text("End behavior: loop")
} else {
cimgui.Text("End behavior: stop")
}
preview := "No entry selected"
if hasPlaylistSnapshot && playlistSnapshot.State.HasSelection {
preview = playlistEntryDisplayName(
playlistSnapshot.Entry,
playlistSnapshot.State.CurrentIndex,
)
}
if cimgui.BeginCombo("Entry##playlist", preview) {
for index, entry := range configuredPlaylist.Entries {
selected := hasPlaylistSnapshot &&
playlistSnapshot.State.HasSelection &&
playlistSnapshot.State.CurrentIndex == index
label := fmt.Sprintf(
"%s##playlist-entry-%d",
playlistEntryDisplayName(entry, index),
index,
)
if cimgui.SelectableBoolV(
label,
selected,
cimgui.SelectableFlagsNone,
cimgui.Vec2{},
) && !playlistRuntime.Select(index) {
log.Print("playlist command queue is full")
}
if selected {
cimgui.SetItemDefaultFocus()
}
}
cimgui.EndCombo()
}
if cimgui.Button("Previous##playlist") && !playlistRuntime.Previous() {
log.Print("playlist command queue is full")
}
cimgui.SameLine()
if cimgui.Button("Next##playlist") && !playlistRuntime.Next() {
log.Print("playlist command queue is full")
}
if hasPlaylistSnapshot &&
playlistSnapshot.State.HasSelection &&
playlistSnapshot.Entry.Duration > 0 &&
!playlistSnapshot.Timing.Expired {
cimgui.SameLine()
if playlistSnapshot.Timing.Paused {
if cimgui.Button("Resume timer##playlist") &&
!playlistRuntime.Resume() {
log.Print("playlist command queue is full")
}
} else if cimgui.Button("Pause timer##playlist") &&
!playlistRuntime.Pause() {
log.Print("playlist command queue is full")
}
}
if hasPlaylistSnapshot && playlistSnapshot.State.HasSelection {
entry := playlistSnapshot.Entry
cimgui.SeparatorText("Current entry")
cimgui.Text(fmt.Sprintf(
"%d of %d: %s",
playlistSnapshot.State.CurrentIndex+1,
len(configuredPlaylist.Entries),
playlistEntryDisplayName(entry, playlistSnapshot.State.CurrentIndex),
))
if entry.Video.IsConfigured() {
cimgui.TextWrapped(fmt.Sprintf(
"Video: %s (%s)",
entry.Video.UUID,
entry.Video.Domain,
))
}
if entry.Audio.IsConfigured() {
cimgui.TextWrapped(fmt.Sprintf(
"Audio: %s (%s)",
entry.Audio.UUID,
entry.Audio.Domain,
))
}
if entry.SyncRequested {
cimgui.Text("Synchronization: requested")
} else {
cimgui.Text("Synchronization: independent")
}
switch {
case entry.Duration == 0:
cimgui.Text("Timing: manual advance")
case playlistSnapshot.Timing.Paused:
fraction, remaining := playlistTimingProgress(
playlistSnapshot.Timing,
time.Now(),
)
cimgui.Text("Timing: paused")
cimgui.ProgressBarV(
fraction,
cimgui.Vec2{X: -1, Y: 0},
remaining.Round(time.Second).String(),
)
case playlistSnapshot.Timing.Started:
fraction, remaining := playlistTimingProgress(
playlistSnapshot.Timing,
time.Now(),
)
cimgui.Text(fmt.Sprintf("Duration: %s", entry.Duration))
cimgui.ProgressBarV(
fraction,
cimgui.Vec2{X: -1, Y: 0},
remaining.Round(time.Second).String(),
)
case playlistSnapshot.Timing.Expired:
cimgui.Text("Timing: finished")
default:
cimgui.Text("Timing: waiting for playback")
}
}
}
drawHotkeysSection := func() {
cimgui.Text("F1 - show/hide stats")
cimgui.Text("F2 - show/hide settings")
@@ -730,7 +899,7 @@ func main() {
}
}
if cimgui.CollapsingHeaderTreeNodeFlagsV("Debug Info", collapsingHeaderFlags) {
if cimgui.CollapsingHeaderTreeNodeFlagsV("Debug Info", cimgui.TreeNodeFlagsNone) {
drawDebugSection()
}
}
@@ -774,6 +943,11 @@ func main() {
if err := <-playbackDone; err != nil && !errors.Is(err, context.Canceled) {
log.Printf("playback controller: %v", err)
}
if playlistDone != nil {
if err := <-playlistDone; err != nil && !errors.Is(err, context.Canceled) {
log.Printf("playlist runtime: %v", err)
}
}
if err := player.Close(); err != nil {
log.Printf("close playback: %v", err)
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"time"
"mxl-player/internal/playback"
)
type playlistFile struct {
Entries []playlistFileEntry `json:"entries"`
Loop bool `json:"loop"`
}
type playlistFileEntry struct {
Name string `json:"name"`
Video *playlistFileFeed `json:"video"`
Audio *playlistFileFeed `json:"audio"`
Sync bool `json:"sync"`
Duration string `json:"duration"`
}
type playlistFileFeed struct {
Domain string `json:"domain"`
UUID string `json:"uuid"`
}
func loadPlaylistFile(path string) (playback.Playlist, error) {
file, err := os.Open(path)
if err != nil {
return playback.Playlist{}, fmt.Errorf("open playlist %q: %w", path, err)
}
defer file.Close()
playlist, err := decodePlaylistFile(file)
if err != nil {
return playback.Playlist{}, fmt.Errorf("decode playlist %q: %w", path, err)
}
return playlist, nil
}
func decodePlaylistFile(reader io.Reader) (playback.Playlist, error) {
decoder := json.NewDecoder(reader)
decoder.DisallowUnknownFields()
var file playlistFile
if err := decoder.Decode(&file); err != nil {
return playback.Playlist{}, fmt.Errorf("decode JSON: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err == nil {
return playback.Playlist{}, fmt.Errorf("decode JSON: multiple root values")
}
return playback.Playlist{}, fmt.Errorf("decode trailing JSON: %w", err)
}
playlist := playback.Playlist{
Entries: make([]playback.PlaylistEntry, len(file.Entries)),
Loop: file.Loop,
}
for index, entry := range file.Entries {
duration := time.Duration(0)
if entry.Duration != "" {
parsed, err := time.ParseDuration(entry.Duration)
if err != nil {
return playback.Playlist{}, fmt.Errorf(
"playlist entry %d duration %q: %w",
index,
entry.Duration,
err,
)
}
duration = parsed
}
playlist.Entries[index] = playback.PlaylistEntry{
Name: entry.Name,
Video: playlistFileFeedToPlayback(entry.Video),
Audio: playlistFileFeedToPlayback(entry.Audio),
SyncRequested: entry.Sync,
Duration: duration,
}
}
if err := playlist.Validate(); err != nil {
return playback.Playlist{}, fmt.Errorf("validate playlist: %w", err)
}
return playlist, nil
}
func playlistFileFeedToPlayback(feed *playlistFileFeed) playback.PlaylistFeed {
if feed == nil {
return playback.PlaylistFeed{}
}
return playback.PlaylistFeed{Domain: feed.Domain, UUID: feed.UUID}
}
+174
View File
@@ -0,0 +1,174 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"mxl-player/internal/playback"
)
func TestDecodePlaylistFile(t *testing.T) {
input := `{
"loop": true,
"entries": [
{
"name": "sync",
"video": {"domain": "/video", "uuid": "video-1"},
"audio": {"domain": "/audio", "uuid": "audio-1"},
"sync": true,
"duration": "10s"
},
{
"name": "video",
"video": {"domain": "/other-video", "uuid": "video-2"},
"duration": "250ms"
},
{
"name": "audio",
"audio": {"domain": "/other-audio", "uuid": "audio-3"},
"duration": "1m"
},
{
"name": "manual",
"video": {"domain": "/video", "uuid": "video-4"}
}
]
}`
got, err := decodePlaylistFile(strings.NewReader(input))
if err != nil {
t.Fatalf("decodePlaylistFile() error = %v", err)
}
want := playback.Playlist{
Loop: true,
Entries: []playback.PlaylistEntry{
{
Name: "sync",
Video: playback.PlaylistFeed{Domain: "/video", UUID: "video-1"},
Audio: playback.PlaylistFeed{Domain: "/audio", UUID: "audio-1"},
SyncRequested: true,
Duration: 10 * time.Second,
},
{
Name: "video",
Video: playback.PlaylistFeed{Domain: "/other-video", UUID: "video-2"},
Duration: 250 * time.Millisecond,
},
{
Name: "audio",
Audio: playback.PlaylistFeed{Domain: "/other-audio", UUID: "audio-3"},
Duration: time.Minute,
},
{
Name: "manual",
Video: playback.PlaylistFeed{Domain: "/video", UUID: "video-4"},
},
},
}
if len(got.Entries) != len(want.Entries) || got.Loop != want.Loop {
t.Fatalf("decodePlaylistFile() = %#v, want %#v", got, want)
}
for index := range want.Entries {
if got.Entries[index] != want.Entries[index] {
t.Fatalf("entry %d = %#v, want %#v", index, got.Entries[index], want.Entries[index])
}
}
}
func TestDecodePlaylistFileAllowsEmptyPlaylist(t *testing.T) {
got, err := decodePlaylistFile(strings.NewReader(`{"entries": []}`))
if err != nil {
t.Fatalf("decodePlaylistFile() error = %v", err)
}
if len(got.Entries) != 0 || got.Loop {
t.Fatalf("decodePlaylistFile() = %#v, want empty non-looping playlist", got)
}
}
func TestDecodePlaylistFileRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
input string
wantErr error
wantText string
}{
{name: "empty input", input: ``, wantText: "decode JSON"},
{name: "malformed JSON", input: `{"entries": [`, wantText: "decode JSON"},
{name: "unknown field", input: `{"unknown": true}`, wantText: "unknown field"},
{name: "multiple roots", input: `{"entries": []} {"entries": []}`, wantText: "multiple root values"},
{
name: "invalid duration",
input: `{"entries":[{"video":{"domain":"/video","uuid":"video"},"duration":"later"}]}`,
wantText: `playlist entry 0 duration "later"`,
},
{
name: "negative duration",
input: `{"entries":[{"video":{"domain":"/video","uuid":"video"},"duration":"-1s"}]}`,
wantErr: playback.ErrPlaylistDurationNegative,
},
{
name: "UUID without domain",
input: `{"entries":[{"video":{"uuid":"video"}}]}`,
wantErr: playback.ErrFeedDomainRequired,
},
{
name: "domain without UUID",
input: `{"entries":[{"audio":{"domain":"/audio"}}]}`,
wantErr: playback.ErrPlaylistFeedUUIDRequired,
},
{
name: "empty entry",
input: `{"entries":[{}]}`,
wantErr: playback.ErrPlaylistEntryEmpty,
},
{
name: "sync with one feed",
input: `{"entries":[{"video":{"domain":"/video","uuid":"video"},"sync":true}]}`,
wantErr: playback.ErrPlaylistSyncFeedsRequired,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := decodePlaylistFile(strings.NewReader(test.input))
if err == nil {
t.Fatal("decodePlaylistFile() error = nil")
}
if test.wantErr != nil && !errors.Is(err, test.wantErr) {
t.Fatalf("decodePlaylistFile() error = %v, want %v", err, test.wantErr)
}
if test.wantText != "" && !strings.Contains(err.Error(), test.wantText) {
t.Fatalf("decodePlaylistFile() error = %q, want text %q", err, test.wantText)
}
})
}
}
func TestLoadPlaylistFile(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "playlist.json")
input := []byte(`{"loop":true,"entries":[{"audio":{"domain":"/audio","uuid":"audio"}}]}`)
if err := os.WriteFile(path, input, 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
got, err := loadPlaylistFile(path)
if err != nil {
t.Fatalf("loadPlaylistFile() error = %v", err)
}
if !got.Loop || len(got.Entries) != 1 || got.Entries[0].Audio.UUID != "audio" {
t.Fatalf("loadPlaylistFile() = %#v", got)
}
}
func TestLoadPlaylistFileIncludesPathInErrors(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing.json")
_, err := loadPlaylistFile(path)
if err == nil || !strings.Contains(err.Error(), path) {
t.Fatalf("loadPlaylistFile() error = %v, want path %q", err, path)
}
}
+187
View File
@@ -0,0 +1,187 @@
package main
import (
"context"
"errors"
"fmt"
"time"
"mxl-player/internal/playback"
)
const playlistReadinessInterval = 10 * time.Millisecond
var (
ErrPlayerPlaybackRequired = errors.New("player playback is required")
ErrPlayerSessionControllerRequired = errors.New("player session controller is required")
ErrPlayerStatusStoreRequired = errors.New("player status store is required")
)
type playerPlaylist struct {
Controller *playback.PlaylistController
Coordinator *playback.PlaylistReadinessCoordinator
Commands chan playback.PlaylistCommand
Readiness chan playback.PlaylistReadiness
}
func newPlayerPlaylist(
playlist playback.Playlist,
retry playback.RetryPolicy,
player *playerPlayback,
) (*playerPlaylist, error) {
if player == nil {
return nil, ErrPlayerPlaybackRequired
}
if player.Controller == nil {
return nil, ErrPlayerSessionControllerRequired
}
if player.Status == nil {
return nil, ErrPlayerStatusStoreRequired
}
commands := make(chan playback.PlaylistCommand, 32)
readiness := make(chan playback.PlaylistReadiness, 8)
controller, err := playback.NewPlaylistController(
playlist,
retry,
player.Commands,
)
if err != nil {
return nil, err
}
coordinator, err := playback.NewPlaylistReadinessCoordinator(
controller,
player.Controller,
player.Status,
readiness,
playlistReadinessInterval,
)
if err != nil {
return nil, err
}
return &playerPlaylist{
Controller: controller,
Coordinator: coordinator,
Commands: commands,
Readiness: readiness,
}, nil
}
func (p *playerPlaylist) Run(ctx context.Context) error {
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
results := make(chan error, 2)
go func() {
results <- p.Controller.Run(runCtx, p.Commands, p.Readiness)
}()
go func() {
results <- p.Coordinator.Run(runCtx)
}()
first := <-results
cancel()
second := <-results
if ctx.Err() != nil {
return ctx.Err()
}
if err := playlistRuntimeError(first); err != nil {
return err
}
if err := playlistRuntimeError(second); err != nil {
return err
}
return nil
}
func (p *playerPlaylist) Select(index int) bool {
return p.enqueue(playback.PlaylistCommand{
Kind: playback.PlaylistSelect,
Index: index,
})
}
func (p *playerPlaylist) Next() bool {
return p.enqueue(playback.PlaylistCommand{Kind: playback.PlaylistNext})
}
func (p *playerPlaylist) Previous() bool {
return p.enqueue(playback.PlaylistCommand{Kind: playback.PlaylistPrevious})
}
func (p *playerPlaylist) Pause() bool {
return p.enqueue(playback.PlaylistCommand{Kind: playback.PlaylistPause})
}
func (p *playerPlaylist) Resume() bool {
return p.enqueue(playback.PlaylistCommand{Kind: playback.PlaylistResume})
}
func (p *playerPlaylist) enqueue(command playback.PlaylistCommand) bool {
select {
case p.Commands <- command:
return true
default:
return false
}
}
func playlistRuntimeError(err error) error {
if err == nil || errors.Is(err, context.Canceled) {
return nil
}
return err
}
func shouldAutoStartPlaylist(
args appArgs,
playlist playback.Playlist,
) bool {
return args.PlaylistPath != "" &&
args.VideoFlowId == "" &&
args.AudioFlowId == "" &&
len(playlist.Entries) > 0
}
func playlistEntryDisplayName(entry playback.PlaylistEntry, index int) string {
if entry.Name != "" {
return entry.Name
}
return fmt.Sprintf("Entry %d", index+1)
}
func playlistTimingProgress(
timing playback.PlaylistTimingState,
now time.Time,
) (float32, time.Duration) {
if timing.Duration <= 0 {
return 0, 0
}
if timing.Expired {
return 1, 0
}
remaining := timing.Remaining
if timing.Paused {
// Retain the remaining time captured when the owned timer stopped.
} else if !timing.Started {
return 0, timing.Duration
} else {
remaining = timing.Deadline.Sub(now)
}
if remaining < 0 {
remaining = 0
}
if remaining > timing.Duration {
remaining = timing.Duration
}
fraction := 1 - float32(remaining)/float32(timing.Duration)
if fraction < 0 {
fraction = 0
}
if fraction > 1 {
fraction = 1
}
return fraction, remaining
}
+446
View File
@@ -0,0 +1,446 @@
package main
import (
"context"
"errors"
"math"
"testing"
"time"
"mxl-player/internal/playback"
)
type playlistTestVideoSlot struct{}
func (playlistTestVideoSlot) Run(
ctx context.Context,
initial playback.FeedConfig,
commands <-chan playback.FeedConfig,
) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case _, ok := <-commands:
if !ok {
return nil
}
}
}
}
type playlistTestAudioSlot struct{}
func (playlistTestAudioSlot) Run(
ctx context.Context,
initial playback.FeedConfig,
commands <-chan playback.FeedConfig,
) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case _, ok := <-commands:
if !ok {
return nil
}
}
}
}
type playlistTestSyncSlot struct{}
func (playlistTestSyncSlot) Run(
ctx context.Context,
initial playback.SyncPairConfig,
commands <-chan playback.SyncPairConfig,
) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case _, ok := <-commands:
if !ok {
return nil
}
}
}
}
func TestNewPlayerPlaylistValidatesPlayer(t *testing.T) {
retry := playlistRuntimeRetry()
tests := []struct {
name string
player *playerPlayback
wantErr error
}{
{name: "nil player", wantErr: ErrPlayerPlaybackRequired},
{name: "nil controller", player: &playerPlayback{}, wantErr: ErrPlayerSessionControllerRequired},
{
name: "nil status store",
player: &playerPlayback{
Controller: newPlaylistTestSessionController(t),
},
wantErr: ErrPlayerStatusStoreRequired,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := newPlayerPlaylist(playback.Playlist{}, retry, test.player)
if !errors.Is(err, test.wantErr) {
t.Fatalf("newPlayerPlaylist() error = %v, want %v", err, test.wantErr)
}
if got != nil {
t.Fatalf("newPlayerPlaylist() = %#v, want nil", got)
}
})
}
}
func TestNewPlayerPlaylistWiresComponents(t *testing.T) {
player := newPlaylistTestPlayer(t)
runtime, err := newPlayerPlaylist(playback.Playlist{}, playlistRuntimeRetry(), player)
if err != nil {
t.Fatalf("newPlayerPlaylist() error = %v", err)
}
if runtime.Controller == nil || runtime.Coordinator == nil {
t.Fatalf("runtime components = %#v", runtime)
}
if runtime.Commands == nil || runtime.Readiness == nil {
t.Fatalf("runtime channels = %#v", runtime)
}
}
func TestPlayerPlaylistNavigationHelpers(t *testing.T) {
runtime, err := newPlayerPlaylist(
playback.Playlist{},
playlistRuntimeRetry(),
newPlaylistTestPlayer(t),
)
if err != nil {
t.Fatalf("newPlayerPlaylist() error = %v", err)
}
tests := []struct {
send func() bool
want playback.PlaylistCommand
}{
{send: func() bool { return runtime.Select(4) }, want: playback.PlaylistCommand{Kind: playback.PlaylistSelect, Index: 4}},
{send: runtime.Next, want: playback.PlaylistCommand{Kind: playback.PlaylistNext}},
{send: runtime.Previous, want: playback.PlaylistCommand{Kind: playback.PlaylistPrevious}},
{send: runtime.Pause, want: playback.PlaylistCommand{Kind: playback.PlaylistPause}},
{send: runtime.Resume, want: playback.PlaylistCommand{Kind: playback.PlaylistResume}},
}
for _, test := range tests {
if !test.send() {
t.Fatal("navigation helper returned false")
}
if got := <-runtime.Commands; got != test.want {
t.Fatalf("navigation command = %#v, want %#v", got, test.want)
}
}
}
func TestPlayerPlaylistNavigationQueueFull(t *testing.T) {
runtime, err := newPlayerPlaylist(
playback.Playlist{},
playlistRuntimeRetry(),
newPlaylistTestPlayer(t),
)
if err != nil {
t.Fatalf("newPlayerPlaylist() error = %v", err)
}
for range cap(runtime.Commands) {
if !runtime.Next() {
t.Fatal("queue filled before reaching capacity")
}
}
if runtime.Next() {
t.Fatal("Next() = true with full queue")
}
}
func TestPlayerPlaylistRunCancellationJoinsComponents(t *testing.T) {
runtime, err := newPlayerPlaylist(
playback.Playlist{},
playlistRuntimeRetry(),
newPlaylistTestPlayer(t),
)
if err != nil {
t.Fatalf("newPlayerPlaylist() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- runtime.Run(ctx) }()
cancel()
select {
case err := <-result:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for playlist runtime cancellation")
}
}
func TestPlayerPlaylistTimedEntryAdvances(t *testing.T) {
retry := playlistRuntimeRetry()
player := newPlaylistTestPlayer(t)
playlist := playback.Playlist{
Entries: []playback.PlaylistEntry{
{
Video: playback.PlaylistFeed{Domain: "domain", UUID: "video-1"},
Duration: 15 * time.Millisecond,
},
{
Video: playback.PlaylistFeed{Domain: "domain", UUID: "video-2"},
},
},
}
runtime, err := newPlayerPlaylist(playlist, retry, player)
if err != nil {
t.Fatalf("newPlayerPlaylist() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sessionResult := make(chan error, 1)
go func() {
sessionResult <- player.Controller.Run(
ctx,
playback.SessionConfig{Retry: retry},
player.Commands,
)
}()
playlistResult := make(chan error, 1)
go func() { playlistResult <- runtime.Run(ctx) }()
if !runtime.Next() {
t.Fatal("Next() = false")
}
first := waitForPlayerSession(t, player.Controller, func(snapshot playback.SessionSnapshot) bool {
return snapshot.Desired.Video.UUID == "video-1"
})
player.Status.Observe(playback.Status{
Unit: playback.UnitVideo,
State: playback.StatePlaying,
Generation: first.Generation,
Feed: first.Plan.Video,
})
second := waitForPlayerSession(t, player.Controller, func(snapshot playback.SessionSnapshot) bool {
return snapshot.Desired.Video.UUID == "video-2"
})
if second.Desired.Audio.IsConfigured() {
t.Fatalf("advanced session audio = %#v, want unconfigured", second.Desired.Audio)
}
cancel()
if err := waitForPlayerRuntimeResult(t, playlistResult); !errors.Is(err, context.Canceled) {
t.Fatalf("playlist Run() error = %v, want %v", err, context.Canceled)
}
if err := waitForPlayerRuntimeResult(t, sessionResult); !errors.Is(err, context.Canceled) {
t.Fatalf("session Run() error = %v, want %v", err, context.Canceled)
}
}
func TestShouldAutoStartPlaylist(t *testing.T) {
playlist := playback.Playlist{Entries: []playback.PlaylistEntry{
{Video: playback.PlaylistFeed{Domain: "domain", UUID: "video"}},
}}
tests := []struct {
name string
args appArgs
playlist playback.Playlist
want bool
}{
{
name: "playlist only",
args: appArgs{PlaylistPath: "playlist.json"},
playlist: playlist,
want: true,
},
{
name: "direct video",
args: appArgs{PlaylistPath: "playlist.json", VideoFlowId: "video"},
playlist: playlist,
},
{
name: "direct audio",
args: appArgs{PlaylistPath: "playlist.json", AudioFlowId: "audio"},
playlist: playlist,
},
{
name: "both direct feeds",
args: appArgs{
PlaylistPath: "playlist.json",
VideoFlowId: "video",
AudioFlowId: "audio",
},
playlist: playlist,
},
{
name: "empty playlist",
args: appArgs{PlaylistPath: "playlist.json"},
playlist: playback.Playlist{},
},
{
name: "no playlist flag",
args: appArgs{},
playlist: playlist,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := shouldAutoStartPlaylist(test.args, test.playlist); got != test.want {
t.Fatalf("shouldAutoStartPlaylist() = %v, want %v", got, test.want)
}
})
}
}
func TestPlaylistEntryDisplayName(t *testing.T) {
tests := []struct {
entry playback.PlaylistEntry
index int
want string
}{
{entry: playback.PlaylistEntry{Name: "News"}, index: 0, want: "News"},
{entry: playback.PlaylistEntry{}, index: 0, want: "Entry 1"},
{entry: playback.PlaylistEntry{}, index: 4, want: "Entry 5"},
}
for _, test := range tests {
if got := playlistEntryDisplayName(test.entry, test.index); got != test.want {
t.Fatalf("playlistEntryDisplayName() = %q, want %q", got, test.want)
}
}
}
func TestPlaylistTimingProgress(t *testing.T) {
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
timing playback.PlaylistTimingState
wantFraction float32
wantRemaining time.Duration
}{
{name: "manual"},
{
name: "waiting",
timing: playback.PlaylistTimingState{Duration: 10 * time.Second},
wantRemaining: 10 * time.Second,
},
{
name: "half complete",
timing: playback.PlaylistTimingState{
Duration: 10 * time.Second,
Started: true,
Deadline: now.Add(5 * time.Second),
},
wantFraction: 0.5,
wantRemaining: 5 * time.Second,
},
{
name: "expired",
timing: playback.PlaylistTimingState{Duration: 10 * time.Second, Expired: true},
wantFraction: 1,
},
{
name: "paused",
timing: playback.PlaylistTimingState{
Duration: 10 * time.Second,
Paused: true,
Remaining: 6 * time.Second,
},
wantFraction: 0.4,
wantRemaining: 6 * time.Second,
},
{
name: "deadline passed",
timing: playback.PlaylistTimingState{
Duration: 10 * time.Second,
Started: true,
Deadline: now.Add(-time.Second),
},
wantFraction: 1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fraction, remaining := playlistTimingProgress(test.timing, now)
if math.Abs(float64(fraction-test.wantFraction)) > 0.000001 ||
remaining != test.wantRemaining {
t.Fatalf(
"playlistTimingProgress() = %v, %v; want %v, %v",
fraction,
remaining,
test.wantFraction,
test.wantRemaining,
)
}
})
}
}
func newPlaylistTestPlayer(t *testing.T) *playerPlayback {
t.Helper()
return &playerPlayback{
Controller: newPlaylistTestSessionController(t),
Commands: make(chan playback.SessionCommand, 32),
Status: playback.NewStatusStore(),
}
}
func newPlaylistTestSessionController(t *testing.T) *playback.SessionController {
t.Helper()
controller, err := playback.NewSessionController(
playlistTestVideoSlot{},
playlistTestAudioSlot{},
playlistTestSyncSlot{},
func(video, audio playback.FeedConfig) bool { return video.Domain == audio.Domain },
)
if err != nil {
t.Fatalf("NewSessionController() error = %v", err)
}
return controller
}
func playlistRuntimeRetry() playback.RetryPolicy {
return playback.RetryPolicy{
MaxAttempts: 1,
InitialDelay: time.Millisecond,
MaxDelay: time.Millisecond,
}
}
func waitForPlayerSession(
t *testing.T,
controller *playback.SessionController,
predicate func(playback.SessionSnapshot) bool,
) playback.SessionSnapshot {
t.Helper()
deadline := time.Now().Add(2 * 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 session snapshot; latest = %#v", snapshot)
return playback.SessionSnapshot{}
}
func waitForPlayerRuntimeResult(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 runtime result")
return nil
}
}
+9 -8
View File
@@ -72,8 +72,9 @@ func (s *stabilityAudioSink) ConsumeAudio(
return err
}
func (w *AudioWorker) emit(ctx context.Context, status Status) {
func (w *AudioWorker) emit(ctx context.Context, config FeedConfig, status Status) {
status.Generation = generationFromContext(ctx)
status.Feed = config
if w.observer != nil {
w.observer(status)
}
@@ -100,7 +101,7 @@ func (w *AudioWorker) Run(
if attemptNumber > 1 {
state = StateReconnecting
}
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitAudio,
State: state,
Attempt: attemptNumber,
@@ -109,7 +110,7 @@ func (w *AudioWorker) Run(
attemptSink := &stabilityAudioSink{
sink: w.sink,
onStable: func() {
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitAudio,
State: StatePlaying,
Attempt: attemptNumber,
@@ -135,7 +136,7 @@ func (w *AudioWorker) Run(
return
}
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitAudio,
State: StateReconnecting,
Attempt: attemptNumber + 1,
@@ -155,11 +156,11 @@ func (w *AudioWorker) Run(
)
if ctx.Err() != nil {
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitAudio,
State: StateStopping,
})
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitAudio,
State: StateIdle,
})
@@ -167,7 +168,7 @@ func (w *AudioWorker) Run(
}
if err != nil {
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitAudio,
State: StateFailed,
Attempt: attemptNumber,
@@ -177,7 +178,7 @@ func (w *AudioWorker) Run(
return err
}
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitAudio,
State: StateIdle,
})
+5 -4
View File
@@ -127,10 +127,8 @@ func TestAudioWorkerStatusesInheritGeneration(t *testing.T) {
func(error) bool { return true },
func(status Status) { statuses = append(statuses, status) },
)
_ = worker.Run(
withGeneration(context.Background(), 8),
FeedConfig{Domain: "/audio", UUID: "audio", Active: true},
)
config := FeedConfig{Domain: "/audio", UUID: "audio", Active: true}
_ = worker.Run(withGeneration(context.Background(), 8), config)
if len(statuses) == 0 {
t.Fatal("no statuses emitted")
}
@@ -138,6 +136,9 @@ func TestAudioWorkerStatusesInheritGeneration(t *testing.T) {
if status.Generation != 8 {
t.Fatalf("status generation = %d, want 8: %+v", status.Generation, status)
}
if status.Feed != config {
t.Fatalf("status feed = %#v, want %#v", status.Feed, config)
}
}
}
+153 -3
View File
@@ -4,12 +4,33 @@ import (
"context"
"errors"
"sync"
"time"
)
type PlaylistReadiness struct {
Revision uint64
}
type playlistTimer interface {
C() <-chan time.Time
Stop() bool
}
type playlistTimerFactory func(time.Duration) playlistTimer
type realPlaylistTimer struct {
timer *time.Timer
}
func (t realPlaylistTimer) C() <-chan time.Time { return t.timer.C }
func (t realPlaylistTimer) Stop() bool { return t.timer.Stop() }
type PlaylistController struct {
playlist Playlist
retry RetryPolicy
sessions chan<- SessionCommand
now func() time.Time
newTimer playlistTimerFactory
mu sync.RWMutex
snapshot PlaylistSnapshot
@@ -20,6 +41,7 @@ type PlaylistSnapshot struct {
State PlaylistState
Entry PlaylistEntry
Revision uint64
Timing PlaylistTimingState
}
var (
@@ -44,16 +66,32 @@ func NewPlaylistController(
playlist: playlist,
retry: retry,
sessions: sessions,
now: time.Now,
newTimer: func(duration time.Duration) playlistTimer {
return realPlaylistTimer{timer: time.NewTimer(duration)}
},
}, nil
}
func (c *PlaylistController) Run(
ctx context.Context,
commands <-chan PlaylistCommand,
readiness <-chan PlaylistReadiness,
) error {
state := PlaylistState{}
revision := uint64(0)
c.publish(state, revision)
timing := PlaylistTimingState{}
var timer playlistTimer
var timerC <-chan time.Time
var timerRevision uint64
c.publish(state, revision, timing)
stopTimer := func() {
stopPlaylistTimer(timer)
timer = nil
timerC = nil
}
defer stopTimer()
for {
select {
@@ -64,6 +102,36 @@ func (c *PlaylistController) Run(
if !ok {
return nil
}
if command.Kind == PlaylistPause {
nextTiming, changed := PausePlaylistTiming(
timing,
revision,
c.now(),
)
if changed {
stopTimer()
timing = nextTiming
c.publish(state, revision, timing)
}
continue
}
if command.Kind == PlaylistResume {
nextTiming, changed := ResumePlaylistTiming(
timing,
revision,
c.now(),
)
if changed {
timing = nextTiming
if timing.Started {
timerRevision = timing.Revision
timer = c.newTimer(timing.Remaining)
timerC = timer.C()
}
c.publish(state, revision, timing)
}
continue
}
next, sessionCommand, apply, err := ApplyPlaylistSelection(
c.playlist,
@@ -75,16 +143,83 @@ func (c *PlaylistController) Run(
continue
}
if apply {
stopTimer()
select {
case <-ctx.Done():
return ctx.Err()
case c.sessions <- sessionCommand:
}
revision++
entry, _ := next.Entry(c.playlist)
timing = NewPlaylistTiming(revision, entry.Duration)
}
state = next
c.publish(state, revision)
c.publish(state, revision, timing)
case ready, ok := <-readiness:
if !ok {
readiness = nil
continue
}
if timing.Paused &&
ready.Revision == timing.Revision &&
timing.Duration > 0 &&
!timing.Expired {
timing.Ready = true
c.publish(state, revision, timing)
continue
}
nextTiming, started := StartPlaylistTiming(
timing,
ready.Revision,
c.now(),
)
if !started {
continue
}
timing = nextTiming
timerRevision = timing.Revision
timer = c.newTimer(timing.Duration)
timerC = timer.C()
c.publish(state, revision, timing)
case firedAt := <-timerC:
firedRevision := timerRevision
timer = nil
timerC = nil
nextTiming, expired := ExpirePlaylistTiming(
timing,
firedRevision,
firedAt,
)
if !expired {
continue
}
timing = nextTiming
next, sessionCommand, apply, err := ApplyPlaylistSelection(
c.playlist,
state,
PlaylistCommand{Kind: PlaylistNext},
c.retry,
)
if err != nil {
c.publish(state, revision, timing)
continue
}
if apply {
select {
case <-ctx.Done():
return ctx.Err()
case c.sessions <- sessionCommand:
}
revision++
entry, _ := next.Entry(c.playlist)
timing = NewPlaylistTiming(revision, entry.Duration)
}
state = next
c.publish(state, revision, timing)
}
}
}
@@ -95,7 +230,11 @@ func (c *PlaylistController) Snapshot() (PlaylistSnapshot, bool) {
return c.snapshot, c.hasSnapshot
}
func (c *PlaylistController) publish(state PlaylistState, revision uint64) {
func (c *PlaylistController) publish(
state PlaylistState,
revision uint64,
timing PlaylistTimingState,
) {
entry, _ := state.Entry(c.playlist)
c.mu.Lock()
@@ -103,7 +242,18 @@ func (c *PlaylistController) publish(state PlaylistState, revision uint64) {
State: state,
Entry: entry,
Revision: revision,
Timing: timing,
}
c.hasSnapshot = true
c.mu.Unlock()
}
func stopPlaylistTimer(timer playlistTimer) {
if timer == nil || timer.Stop() {
return
}
select {
case <-timer.C():
default:
}
}
@@ -173,7 +173,7 @@ func TestPlaylistControllerCommitsStateAfterSessionDelivery(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := make(chan error, 1)
go func() { result <- controller.Run(ctx, commands) }()
go func() { result <- controller.Run(ctx, commands, nil) }()
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return !snapshot.State.HasSelection
@@ -224,7 +224,7 @@ func TestPlaylistControllerCancellationWhileSending(t *testing.T) {
commands := make(chan PlaylistCommand, 1)
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- controller.Run(ctx, commands) }()
go func() { result <- controller.Run(ctx, commands, nil) }()
commands <- PlaylistCommand{Kind: PlaylistNext}
cancel()
@@ -323,7 +323,7 @@ func startPlaylistController(
commands := make(chan PlaylistCommand, 64)
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- controller.Run(ctx, commands) }()
go func() { result <- controller.Run(ctx, commands, nil) }()
return controller, commands, sessions, cancel, result
}
@@ -0,0 +1,418 @@
package playback
import (
"context"
"errors"
"sync"
"testing"
"time"
)
type fakePlaylistTimer struct {
ch chan time.Time
mu sync.Mutex
stopped bool
}
func newFakePlaylistTimer() *fakePlaylistTimer {
return &fakePlaylistTimer{ch: make(chan time.Time, 1)}
}
func (t *fakePlaylistTimer) C() <-chan time.Time { return t.ch }
func (t *fakePlaylistTimer) Stop() bool {
t.mu.Lock()
defer t.mu.Unlock()
alreadyStopped := t.stopped
t.stopped = true
return !alreadyStopped
}
func (t *fakePlaylistTimer) isStopped() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.stopped
}
func (t *fakePlaylistTimer) fire(at time.Time) {
t.ch <- at
}
func timedPlaylist(loop bool) Playlist {
return Playlist{
Entries: []PlaylistEntry{
{
Name: "first",
Video: PlaylistFeed{Domain: "domain", UUID: "video-1"},
Duration: 10 * time.Second,
},
{
Name: "second",
Audio: PlaylistFeed{Domain: "domain", UUID: "audio-2"},
Duration: 20 * time.Second,
},
},
Loop: loop,
}
}
func TestPlaylistControllerStartsOneTimerForMatchingReadiness(t *testing.T) {
controller, commands, readiness, sessions, timers, now, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 1
})
readiness <- PlaylistReadiness{Revision: 0}
assertNoPlaylistTimer(t, timers)
readiness <- PlaylistReadiness{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers)
snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started
})
if snapshot.Timing.Deadline != now.Add(10*time.Second) {
t.Fatalf("deadline = %v, want %v", snapshot.Timing.Deadline, now.Add(10*time.Second))
}
readiness <- PlaylistReadiness{Revision: 1}
assertNoPlaylistTimer(t, timers)
if timer.isStopped() {
t.Fatal("timer stopped after duplicate readiness")
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
if !timer.isStopped() {
t.Fatal("timer was not stopped when commands closed")
}
}
func TestPlaylistControllerTimerExpiryAdvances(t *testing.T) {
controller, commands, readiness, sessions, timers, now, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers)
timer.fire(now.Add(10 * time.Second))
session := receivePlaylistSession(t, sessions)
if session.Session.Audio.UUID != "audio-2" || session.Session.Video.IsConfigured() {
t.Fatalf("advanced session = %#v, want audio-only second entry", session.Session)
}
snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 2
})
if snapshot.State.CurrentIndex != 1 || snapshot.Timing.Started {
t.Fatalf("advanced snapshot = %#v", snapshot)
}
if snapshot.Timing.Duration != 20*time.Second {
t.Fatalf("next duration = %v, want %v", snapshot.Timing.Duration, 20*time.Second)
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerLoopingTimerExpiryWraps(t *testing.T) {
controller, commands, readiness, sessions, timers, now, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(true))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
_ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started
})
timer.fire(now.Add(20 * time.Second))
session := receivePlaylistSession(t, sessions)
if session.Session.Video.UUID != "video-1" {
t.Fatalf("wrapped session = %#v, want first entry", session.Session)
}
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 2 && snapshot.State.CurrentIndex == 0
})
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerFinalExpiryStopsWithoutCommand(t *testing.T) {
controller, commands, readiness, sessions, timers, now, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
_ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started
})
timer.fire(now.Add(20 * time.Second))
snapshot := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 1 && !snapshot.Timing.Started
})
if snapshot.State.CurrentIndex != 1 {
t.Fatalf("final snapshot state = %#v, want final entry", snapshot.State)
}
select {
case command := <-sessions:
t.Fatalf("unexpected session command: %#v", command)
case <-time.After(20 * time.Millisecond):
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerManualSelectionStopsOldTimer(t *testing.T) {
controller, commands, readiness, sessions, timers, now, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1}
oldTimer := receiveFakePlaylistTimer(t, timers)
commands <- PlaylistCommand{Kind: PlaylistSelect, Index: 1}
_ = receivePlaylistSession(t, sessions)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 2
})
if !oldTimer.isStopped() {
t.Fatal("old timer was not stopped by manual selection")
}
oldTimer.fire(now.Add(10 * time.Second))
select {
case command := <-sessions:
t.Fatalf("stale timer produced session command: %#v", command)
case <-time.After(20 * time.Millisecond):
}
snapshot, _ := controller.Snapshot()
if snapshot.Revision != 2 || snapshot.State.CurrentIndex != 1 {
t.Fatalf("stale timer changed snapshot: %#v", snapshot)
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerZeroDurationDoesNotCreateTimer(t *testing.T) {
playlist := timedPlaylist(false)
playlist.Entries[0].Duration = 0
_, commands, readiness, sessions, timers, _, cancel, result :=
startTimedPlaylistController(t, playlist)
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1}
assertNoPlaylistTimer(t, timers)
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerCancellationStopsTimer(t *testing.T) {
_, commands, readiness, sessions, timers, _, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1}
timer := receiveFakePlaylistTimer(t, timers)
cancel()
if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
}
if !timer.isStopped() {
t.Fatal("timer was not stopped on cancellation")
}
}
func TestPlaylistControllerPauseAndResumeTimer(t *testing.T) {
controller, commands, readiness, sessions, timers, now, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
readiness <- PlaylistReadiness{Revision: 1}
oldTimer := receiveFakePlaylistTimer(t, timers)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started
})
commands <- PlaylistCommand{Kind: PlaylistPause}
paused := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Paused
})
if paused.Revision != 1 || paused.Timing.Started {
t.Fatalf("paused snapshot = %#v", paused)
}
if !oldTimer.isStopped() {
t.Fatal("Pause did not stop active timer")
}
oldTimer.fire(now.Add(10 * time.Second))
select {
case command := <-sessions:
t.Fatalf("paused stale timer sent session command: %#v", command)
case <-time.After(20 * time.Millisecond):
}
commands <- PlaylistCommand{Kind: PlaylistResume}
_ = receiveFakePlaylistTimer(t, timers)
resumed := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started && !snapshot.Timing.Paused
})
if resumed.Revision != 1 {
t.Fatalf("resume changed revision: %#v", resumed)
}
select {
case command := <-sessions:
t.Fatalf("pause/resume sent session command: %#v", command)
default:
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerManualSelectionClearsPause(t *testing.T) {
controller, commands, _, sessions, _, _, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
commands <- PlaylistCommand{Kind: PlaylistPause}
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Paused
})
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
next := waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Revision == 2
})
if next.Timing.Paused || next.State.CurrentIndex != 1 {
t.Fatalf("new selection retained pause: %#v", next)
}
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func TestPlaylistControllerRecordsQueuedReadinessWhilePaused(t *testing.T) {
controller, commands, readiness, sessions, timers, _, cancel, result :=
startTimedPlaylistController(t, timedPlaylist(false))
defer cancel()
commands <- PlaylistCommand{Kind: PlaylistNext}
_ = receivePlaylistSession(t, sessions)
commands <- PlaylistCommand{Kind: PlaylistPause}
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Paused
})
readiness <- PlaylistReadiness{Revision: 1}
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Paused && snapshot.Timing.Ready
})
assertNoPlaylistTimer(t, timers)
commands <- PlaylistCommand{Kind: PlaylistResume}
_ = receiveFakePlaylistTimer(t, timers)
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
return snapshot.Timing.Started && !snapshot.Timing.Paused
})
close(commands)
if err := waitForPlaylistResult(t, result); err != nil {
t.Fatalf("Run() error = %v", err)
}
}
func startTimedPlaylistController(
t *testing.T,
playlist Playlist,
) (
*PlaylistController,
chan PlaylistCommand,
chan PlaylistReadiness,
chan SessionCommand,
chan *fakePlaylistTimer,
time.Time,
context.CancelFunc,
<-chan error,
) {
t.Helper()
sessions := make(chan SessionCommand, 16)
controller, err := NewPlaylistController(playlist, validPlaylistRetryPolicy(), sessions)
if err != nil {
t.Fatalf("NewPlaylistController() error = %v", err)
}
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
controller.now = func() time.Time { return now }
timers := make(chan *fakePlaylistTimer, 16)
controller.newTimer = func(time.Duration) playlistTimer {
timer := newFakePlaylistTimer()
timers <- timer
return timer
}
commands := make(chan PlaylistCommand, 16)
readiness := make(chan PlaylistReadiness, 16)
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- controller.Run(ctx, commands, readiness) }()
return controller, commands, readiness, sessions, timers, now, cancel, result
}
func receiveFakePlaylistTimer(t *testing.T, timers <-chan *fakePlaylistTimer) *fakePlaylistTimer {
t.Helper()
select {
case timer := <-timers:
return timer
case <-time.After(time.Second):
t.Fatal("timed out waiting for playlist timer")
return nil
}
}
func assertNoPlaylistTimer(t *testing.T, timers <-chan *fakePlaylistTimer) {
t.Helper()
select {
case timer := <-timers:
t.Fatalf("unexpected playlist timer: %#v", timer)
case <-time.After(20 * time.Millisecond):
}
}
+2
View File
@@ -8,6 +8,8 @@ const (
PlaylistSelect PlaylistCommandKind = iota + 1
PlaylistNext
PlaylistPrevious
PlaylistPause
PlaylistResume
)
type PlaylistCommand struct {
+170 -7
View File
@@ -1,5 +1,152 @@
package playback
import (
"context"
"errors"
"time"
)
type PlaylistSnapshotSource interface {
Snapshot() (PlaylistSnapshot, bool)
}
type SessionSnapshotSource interface {
Snapshot() (SessionSnapshot, bool)
}
type PlaybackStatusSnapshotSource interface {
SnapshotAll() PlaybackStatusSnapshot
}
type playlistReadinessTicker interface {
C() <-chan time.Time
Stop()
}
type playlistReadinessTickerFactory func(time.Duration) playlistReadinessTicker
type realPlaylistReadinessTicker struct {
ticker *time.Ticker
}
func (t realPlaylistReadinessTicker) C() <-chan time.Time { return t.ticker.C }
func (t realPlaylistReadinessTicker) Stop() { t.ticker.Stop() }
var (
ErrPlaylistSnapshotSourceRequired = errors.New("playlist snapshot source is required")
ErrSessionSnapshotSourceRequired = errors.New("session snapshot source is required")
ErrStatusSnapshotSourceRequired = errors.New("playback status snapshot source is required")
ErrPlaylistReadinessOutputRequired = errors.New("playlist readiness output channel is required")
ErrPlaylistReadinessInterval = errors.New("playlist readiness interval must be positive")
)
type PlaylistReadinessCoordinator struct {
playlist PlaylistSnapshotSource
session SessionSnapshotSource
statuses PlaybackStatusSnapshotSource
output chan<- PlaylistReadiness
interval time.Duration
newTicker playlistReadinessTickerFactory
}
func NewPlaylistReadinessCoordinator(
playlist PlaylistSnapshotSource,
session SessionSnapshotSource,
statuses PlaybackStatusSnapshotSource,
output chan<- PlaylistReadiness,
interval time.Duration,
) (*PlaylistReadinessCoordinator, error) {
if playlist == nil {
return nil, ErrPlaylistSnapshotSourceRequired
}
if session == nil {
return nil, ErrSessionSnapshotSourceRequired
}
if statuses == nil {
return nil, ErrStatusSnapshotSourceRequired
}
if output == nil {
return nil, ErrPlaylistReadinessOutputRequired
}
if interval <= 0 {
return nil, ErrPlaylistReadinessInterval
}
return &PlaylistReadinessCoordinator{
playlist: playlist,
session: session,
statuses: statuses,
output: output,
interval: interval,
newTicker: func(interval time.Duration) playlistReadinessTicker {
return realPlaylistReadinessTicker{ticker: time.NewTicker(interval)}
},
}, nil
}
func (c *PlaylistReadinessCoordinator) Run(ctx context.Context) error {
ticker := c.newTicker(c.interval)
defer ticker.Stop()
var emittedRevision uint64
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C():
playlistSnapshot, ok := c.playlist.Snapshot()
if !ok ||
!playlistSnapshot.State.HasSelection ||
playlistSnapshot.Revision == 0 ||
playlistSnapshot.Entry.Duration <= 0 ||
playlistSnapshot.Timing.Started ||
playlistSnapshot.Timing.Paused ||
playlistSnapshot.Revision == emittedRevision {
continue
}
sessionSnapshot, ok := c.session.Snapshot()
if !ok || !PlaylistEntryMatchesSession(
playlistSnapshot.Entry,
sessionSnapshot.Desired,
) {
continue
}
if !IsSessionPlaying(sessionSnapshot, c.statuses.SnapshotAll()) {
continue
}
ready := PlaylistReadiness{Revision: playlistSnapshot.Revision}
select {
case <-ctx.Done():
return ctx.Err()
case c.output <- ready:
emittedRevision = playlistSnapshot.Revision
}
}
}
}
func PlaylistEntryMatchesSession(entry PlaylistEntry, session SessionConfig) bool {
if err := entry.Validate(); err != nil {
return false
}
return playlistFeedMatchesSession(entry.Video, session.Video) &&
playlistFeedMatchesSession(entry.Audio, session.Audio) &&
entry.SyncRequested == session.SyncRequested
}
func playlistFeedMatchesSession(playlist PlaylistFeed, session FeedConfig) bool {
if !playlist.IsConfigured() {
return !session.IsConfigured() && !session.Active
}
return session.Active &&
playlist.Domain == session.Domain &&
playlist.UUID == session.UUID
}
func IsSessionPlaying(
session SessionSnapshot,
statuses PlaybackStatusSnapshot,
@@ -18,6 +165,7 @@ func IsSessionPlaying(
statuses.Video,
statuses.HasVideo,
session.Generation,
session.Plan.Video,
) {
return false
}
@@ -25,17 +173,17 @@ func IsSessionPlaying(
statuses.Audio,
statuses.HasAudio,
session.Generation,
session.Plan.Audio,
) {
return false
}
return true
case TopologySynchronized:
return statusIsPlaying(
statuses.Sync,
statuses.HasSync,
session.Generation,
)
return statuses.HasSync &&
statuses.Sync.Generation == session.Generation &&
statuses.Sync.State == StatePlaying &&
sameSyncSource(statuses.Sync.Pair, session.Plan.Sync)
case TopologyIdle:
return false
@@ -45,8 +193,23 @@ func IsSessionPlaying(
}
}
func statusIsPlaying(status Status, present bool, generation uint64) bool {
func statusIsPlaying(
status Status,
present bool,
generation uint64,
feed FeedConfig,
) bool {
return present &&
status.Generation == generation &&
status.State == StatePlaying
status.State == StatePlaying &&
sameFeedSource(status.Feed, feed)
}
func sameFeedSource(a, b FeedConfig) bool {
return a.Domain == b.Domain && a.UUID == b.UUID
}
func sameSyncSource(a, b SyncPairConfig) bool {
return sameFeedSource(a.Video, b.Video) &&
sameFeedSource(a.Audio, b.Audio)
}
@@ -0,0 +1,369 @@
package playback
import (
"context"
"errors"
"sync"
"testing"
"time"
)
type fakePlaylistSnapshotSource struct {
mu sync.RWMutex
snapshot PlaylistSnapshot
ok bool
}
func (s *fakePlaylistSnapshotSource) Snapshot() (PlaylistSnapshot, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.snapshot, s.ok
}
func (s *fakePlaylistSnapshotSource) set(snapshot PlaylistSnapshot, ok bool) {
s.mu.Lock()
s.snapshot = snapshot
s.ok = ok
s.mu.Unlock()
}
type fakeSessionSnapshotSource struct {
mu sync.RWMutex
snapshot SessionSnapshot
ok bool
}
func (s *fakeSessionSnapshotSource) Snapshot() (SessionSnapshot, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.snapshot, s.ok
}
func (s *fakeSessionSnapshotSource) set(snapshot SessionSnapshot, ok bool) {
s.mu.Lock()
s.snapshot = snapshot
s.ok = ok
s.mu.Unlock()
}
type fakePlaybackStatusSnapshotSource struct {
mu sync.RWMutex
snapshot PlaybackStatusSnapshot
}
func (s *fakePlaybackStatusSnapshotSource) SnapshotAll() PlaybackStatusSnapshot {
s.mu.RLock()
defer s.mu.RUnlock()
return s.snapshot
}
func (s *fakePlaybackStatusSnapshotSource) set(snapshot PlaybackStatusSnapshot) {
s.mu.Lock()
s.snapshot = snapshot
s.mu.Unlock()
}
type fakePlaylistReadinessTicker struct {
ch chan time.Time
mu sync.Mutex
stopped bool
}
func newFakePlaylistReadinessTicker() *fakePlaylistReadinessTicker {
return &fakePlaylistReadinessTicker{ch: make(chan time.Time, 16)}
}
func (t *fakePlaylistReadinessTicker) C() <-chan time.Time { return t.ch }
func (t *fakePlaylistReadinessTicker) Stop() {
t.mu.Lock()
t.stopped = true
t.mu.Unlock()
}
func (t *fakePlaylistReadinessTicker) tick() { t.ch <- time.Now() }
func (t *fakePlaylistReadinessTicker) isStopped() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.stopped
}
func TestNewPlaylistReadinessCoordinatorValidatesDependencies(t *testing.T) {
playlist := &fakePlaylistSnapshotSource{}
session := &fakeSessionSnapshotSource{}
statuses := &fakePlaybackStatusSnapshotSource{}
output := make(chan PlaylistReadiness)
tests := []struct {
name string
playlist PlaylistSnapshotSource
session SessionSnapshotSource
statuses PlaybackStatusSnapshotSource
output chan<- PlaylistReadiness
interval time.Duration
wantErr error
}{
{name: "playlist", session: session, statuses: statuses, output: output, interval: time.Millisecond, wantErr: ErrPlaylistSnapshotSourceRequired},
{name: "session", playlist: playlist, statuses: statuses, output: output, interval: time.Millisecond, wantErr: ErrSessionSnapshotSourceRequired},
{name: "statuses", playlist: playlist, session: session, output: output, interval: time.Millisecond, wantErr: ErrStatusSnapshotSourceRequired},
{name: "output", playlist: playlist, session: session, statuses: statuses, interval: time.Millisecond, wantErr: ErrPlaylistReadinessOutputRequired},
{name: "interval", playlist: playlist, session: session, statuses: statuses, output: output, wantErr: ErrPlaylistReadinessInterval},
{name: "valid", playlist: playlist, session: session, statuses: statuses, output: output, interval: time.Millisecond},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
coordinator, err := NewPlaylistReadinessCoordinator(
test.playlist,
test.session,
test.statuses,
test.output,
test.interval,
)
if !errors.Is(err, test.wantErr) {
t.Fatalf("NewPlaylistReadinessCoordinator() error = %v, want %v", err, test.wantErr)
}
if test.wantErr != nil && coordinator != nil {
t.Fatalf("coordinator = %#v, want nil", coordinator)
}
})
}
}
func TestPlaylistEntryMatchesSession(t *testing.T) {
entry := PlaylistEntry{
Video: PlaylistFeed{Domain: "video-domain", UUID: "video"},
Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"},
SyncRequested: true,
}
matching := entry.SessionConfig(validPlaylistRetryPolicy())
tests := []struct {
name string
entry PlaylistEntry
session SessionConfig
want bool
}{
{name: "matching", entry: entry, session: matching, want: true},
{name: "retry ignored", entry: entry, session: func() SessionConfig { value := matching; value.Retry.MaxAttempts = 99; return value }(), want: true},
{name: "wrong video UUID", entry: entry, session: func() SessionConfig { value := matching; value.Video.UUID = "other"; return value }()},
{name: "wrong audio domain", entry: entry, session: func() SessionConfig { value := matching; value.Audio.Domain = "other"; return value }()},
{name: "inactive video", entry: entry, session: func() SessionConfig { value := matching; value.Video.Active = false; return value }()},
{name: "wrong sync request", entry: entry, session: func() SessionConfig { value := matching; value.SyncRequested = false; return value }()},
{
name: "absent audio matches unconfigured inactive",
entry: PlaylistEntry{Video: entry.Video},
session: PlaylistEntry{Video: entry.Video}.SessionConfig(validPlaylistRetryPolicy()),
want: true,
},
{
name: "absent audio rejects configured audio",
entry: PlaylistEntry{Video: entry.Video},
session: SessionConfig{
Video: matching.Video,
Audio: matching.Audio,
Retry: matching.Retry,
},
},
{name: "invalid entry", entry: PlaylistEntry{}, session: matching},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := PlaylistEntryMatchesSession(test.entry, test.session); got != test.want {
t.Fatalf("PlaylistEntryMatchesSession() = %v, want %v", got, test.want)
}
})
}
}
func TestPlaylistReadinessCoordinatorEmitsOncePerRevision(t *testing.T) {
playlist, session, statuses := readyVideoSnapshots(1)
output := make(chan PlaylistReadiness, 4)
coordinator, ticker, cancel, result := startReadinessCoordinator(
t,
playlist,
session,
statuses,
output,
)
_ = coordinator
defer cancel()
ticker.tick()
if got := receivePlaylistReadiness(t, output); got.Revision != 1 {
t.Fatalf("readiness revision = %d, want 1", got.Revision)
}
ticker.tick()
assertNoPlaylistReadiness(t, output)
next := playlistSnapshotForVideo(2)
playlist.set(next, true)
ticker.tick()
if got := receivePlaylistReadiness(t, output); got.Revision != 2 {
t.Fatalf("readiness revision = %d, want 2", got.Revision)
}
cancel()
if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
}
if !ticker.isStopped() {
t.Fatal("ticker was not stopped")
}
}
func TestPlaylistReadinessCoordinatorWaitsForAllConditions(t *testing.T) {
playlist, session, statuses := readyVideoSnapshots(1)
output := make(chan PlaylistReadiness, 1)
_, ticker, cancel, result := startReadinessCoordinator(t, playlist, session, statuses, output)
defer cancel()
tests := []struct {
name string
mutate func()
}{
{name: "no playlist snapshot", mutate: func() { playlist.set(PlaylistSnapshot{}, false) }},
{name: "no selection", mutate: func() {
value := playlistSnapshotForVideo(1)
value.State.HasSelection = false
playlist.set(value, true)
}},
{name: "zero revision", mutate: func() { value := playlistSnapshotForVideo(1); value.Revision = 0; playlist.set(value, true) }},
{name: "zero duration", mutate: func() { value := playlistSnapshotForVideo(1); value.Entry.Duration = 0; playlist.set(value, true) }},
{name: "already started", mutate: func() { value := playlistSnapshotForVideo(1); value.Timing.Started = true; playlist.set(value, true) }},
{name: "paused", mutate: func() { value := playlistSnapshotForVideo(1); value.Timing.Paused = true; playlist.set(value, true) }},
{name: "session mismatch", mutate: func() {
playlist.set(playlistSnapshotForVideo(1), true)
value, _ := session.Snapshot()
value.Desired.Video.UUID = "other"
session.set(value, true)
}},
{name: "stale statuses", mutate: func() {
playlist.set(playlistSnapshotForVideo(1), true)
_, validSession, _ := readyVideoSnapshots(1)
value, _ := validSession.Snapshot()
session.set(value, true)
current := statuses.SnapshotAll()
current.Generation = 2
current.Video.Generation = 2
statuses.set(current)
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
validPlaylist, validSession, validStatuses := readyVideoSnapshots(1)
playlist.set(validPlaylist.snapshot, true)
session.set(validSession.snapshot, true)
statuses.set(validStatuses.snapshot)
test.mutate()
ticker.tick()
assertNoPlaylistReadiness(t, output)
})
}
cancel()
_ = waitForPlaylistResult(t, result)
}
func TestPlaylistReadinessCoordinatorCancellationWhileBlockedSending(t *testing.T) {
playlist, session, statuses := readyVideoSnapshots(1)
output := make(chan PlaylistReadiness)
_, ticker, cancel, result := startReadinessCoordinator(t, playlist, session, statuses, output)
ticker.tick()
time.Sleep(time.Millisecond)
cancel()
if err := waitForPlaylistResult(t, result); !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want %v", err, context.Canceled)
}
}
func readyVideoSnapshots(revision uint64) (
*fakePlaylistSnapshotSource,
*fakeSessionSnapshotSource,
*fakePlaybackStatusSnapshotSource,
) {
playlist := &fakePlaylistSnapshotSource{snapshot: playlistSnapshotForVideo(revision), ok: true}
entry := playlist.snapshot.Entry
desired := entry.SessionConfig(validPlaylistRetryPolicy())
session := &fakeSessionSnapshotSource{
snapshot: SessionSnapshot{
Desired: desired,
Plan: SessionPlan{Topology: TopologyIndependent, Video: desired.Video},
Generation: 5,
},
ok: true,
}
statuses := &fakePlaybackStatusSnapshotSource{
snapshot: PlaybackStatusSnapshot{
Generation: 5,
Video: Status{
Unit: UnitVideo,
State: StatePlaying,
Generation: 5,
Feed: desired.Video,
},
HasVideo: true,
},
}
return playlist, session, statuses
}
func playlistSnapshotForVideo(revision uint64) PlaylistSnapshot {
entry := PlaylistEntry{
Name: "video",
Video: PlaylistFeed{Domain: "domain", UUID: "video"},
Duration: 10 * time.Second,
}
return PlaylistSnapshot{
State: PlaylistState{CurrentIndex: 0, HasSelection: true},
Entry: entry,
Revision: revision,
Timing: NewPlaylistTiming(revision, entry.Duration),
}
}
func startReadinessCoordinator(
t *testing.T,
playlist PlaylistSnapshotSource,
session SessionSnapshotSource,
statuses PlaybackStatusSnapshotSource,
output chan<- PlaylistReadiness,
) (*PlaylistReadinessCoordinator, *fakePlaylistReadinessTicker, context.CancelFunc, <-chan error) {
t.Helper()
coordinator, err := NewPlaylistReadinessCoordinator(
playlist,
session,
statuses,
output,
time.Millisecond,
)
if err != nil {
t.Fatalf("NewPlaylistReadinessCoordinator() error = %v", err)
}
ticker := newFakePlaylistReadinessTicker()
coordinator.newTicker = func(time.Duration) playlistReadinessTicker { return ticker }
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() { result <- coordinator.Run(ctx) }()
return coordinator, ticker, cancel, result
}
func receivePlaylistReadiness(t *testing.T, output <-chan PlaylistReadiness) PlaylistReadiness {
t.Helper()
select {
case readiness := <-output:
return readiness
case <-time.After(time.Second):
t.Fatal("timed out waiting for playlist readiness")
return PlaylistReadiness{}
}
}
func assertNoPlaylistReadiness(t *testing.T, output <-chan PlaylistReadiness) {
t.Helper()
select {
case readiness := <-output:
t.Fatalf("unexpected playlist readiness: %#v", readiness)
case <-time.After(20 * time.Millisecond):
}
}
+74 -1
View File
@@ -5,7 +5,14 @@ import "testing"
func TestIsSessionPlaying(t *testing.T) {
const generation = 4
playing := func(unit Unit) Status {
return Status{Unit: unit, State: StatePlaying, Generation: generation}
status := Status{Unit: unit, State: StatePlaying, Generation: generation}
switch unit {
case UnitVideo:
status.Feed = FeedConfig{UUID: "video"}
case UnitAudio:
status.Feed = FeedConfig{UUID: "audio"}
}
return status
}
tests := []struct {
name string
@@ -171,6 +178,72 @@ func TestIsSessionPlaying(t *testing.T) {
HasSync: true,
},
},
{
name: "video status has wrong UUID",
session: SessionSnapshot{
Generation: generation,
Plan: SessionPlan{
Topology: TopologyIndependent,
Video: FeedConfig{Domain: "domain", UUID: "video", Active: true},
},
},
statuses: PlaybackStatusSnapshot{
Generation: generation,
Video: Status{
Unit: UnitVideo,
State: StatePlaying,
Generation: generation,
Feed: FeedConfig{Domain: "domain", UUID: "other"},
},
HasVideo: true,
},
},
{
name: "audio status has wrong domain",
session: SessionSnapshot{
Generation: generation,
Plan: SessionPlan{
Topology: TopologyIndependent,
Audio: FeedConfig{Domain: "audio-domain", UUID: "audio", Active: true},
},
},
statuses: PlaybackStatusSnapshot{
Generation: generation,
Audio: Status{
Unit: UnitAudio,
State: StatePlaying,
Generation: generation,
Feed: FeedConfig{Domain: "other-domain", UUID: "audio"},
},
HasAudio: true,
},
},
{
name: "sync status has wrong audio source",
session: SessionSnapshot{
Generation: generation,
Plan: SessionPlan{
Topology: TopologySynchronized,
Sync: SyncPairConfig{
Video: FeedConfig{Domain: "domain", UUID: "video"},
Audio: FeedConfig{Domain: "domain", UUID: "audio"},
},
},
},
statuses: PlaybackStatusSnapshot{
Generation: generation,
Sync: Status{
Unit: UnitSync,
State: StatePlaying,
Generation: generation,
Pair: SyncPairConfig{
Video: FeedConfig{Domain: "domain", UUID: "video"},
Audio: FeedConfig{Domain: "domain", UUID: "other-audio"},
},
},
HasSync: true,
},
},
{
name: "idle",
session: SessionSnapshot{
+73 -9
View File
@@ -3,10 +3,14 @@ package playback
import "time"
type PlaylistTimingState struct {
Revision uint64
Duration time.Duration
Started bool
Deadline time.Time
Revision uint64
Duration time.Duration
Ready bool
Started bool
Paused bool
Expired bool
Remaining time.Duration
Deadline time.Time
}
func NewPlaylistTiming(
@@ -14,8 +18,9 @@ func NewPlaylistTiming(
duration time.Duration,
) PlaylistTimingState {
return PlaylistTimingState{
Revision: revision,
Duration: duration,
Revision: revision,
Duration: duration,
Remaining: duration,
}
}
@@ -24,13 +29,69 @@ func StartPlaylistTiming(
revision uint64,
now time.Time,
) (PlaylistTimingState, bool) {
if revision != current.Revision || current.Duration <= 0 || current.Started {
if revision != current.Revision ||
current.Duration <= 0 ||
current.Started ||
current.Paused ||
current.Expired {
return current, false
}
next := current
if next.Remaining <= 0 {
next.Remaining = next.Duration
}
next.Ready = true
next.Started = true
next.Deadline = now.Add(next.Remaining)
return next, true
}
func PausePlaylistTiming(
current PlaylistTimingState,
revision uint64,
now time.Time,
) (PlaylistTimingState, bool) {
if revision != current.Revision ||
current.Duration <= 0 ||
current.Paused ||
current.Expired {
return current, false
}
next := current
next.Started = true
next.Deadline = now.Add(current.Duration)
if next.Started {
next.Remaining = next.Deadline.Sub(now)
if next.Remaining < 0 {
next.Remaining = 0
}
if next.Remaining > next.Duration {
next.Remaining = next.Duration
}
next.Started = false
next.Deadline = time.Time{}
}
next.Paused = true
return next, true
}
func ResumePlaylistTiming(
current PlaylistTimingState,
revision uint64,
now time.Time,
) (PlaylistTimingState, bool) {
if revision != current.Revision ||
current.Duration <= 0 ||
!current.Paused ||
current.Expired {
return current, false
}
next := current
next.Paused = false
if next.Ready {
next.Started = true
next.Deadline = now.Add(next.Remaining)
}
return next, true
}
@@ -47,6 +108,9 @@ func ExpirePlaylistTiming(
next := current
next.Started = false
next.Paused = false
next.Deadline = time.Time{}
next.Expired = true
next.Remaining = 0
return next, true
}
+91 -6
View File
@@ -7,7 +7,11 @@ import (
func TestNewPlaylistTimingResetsState(t *testing.T) {
got := NewPlaylistTiming(7, 10*time.Second)
want := PlaylistTimingState{Revision: 7, Duration: 10 * time.Second}
want := PlaylistTimingState{
Revision: 7,
Duration: 10 * time.Second,
Remaining: 10 * time.Second,
}
if got != want {
t.Fatalf("NewPlaylistTiming() = %#v, want %#v", got, want)
}
@@ -22,10 +26,12 @@ func TestStartPlaylistTiming(t *testing.T) {
t.Fatal("StartPlaylistTiming() started = false, want true")
}
want := PlaylistTimingState{
Revision: 4,
Duration: 10 * time.Second,
Started: true,
Deadline: now.Add(10 * time.Second),
Revision: 4,
Duration: 10 * time.Second,
Ready: true,
Started: true,
Remaining: 10 * time.Second,
Deadline: now.Add(10 * time.Second),
}
if got != want {
t.Fatalf("StartPlaylistTiming() = %#v, want %#v", got, want)
@@ -47,6 +53,8 @@ func TestStartPlaylistTimingIgnoresInapplicableReadiness(t *testing.T) {
{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},
{name: "paused", current: PlaylistTimingState{Revision: 4, Duration: time.Second, Paused: true, Remaining: time.Second}, revision: 4},
{name: "expired", current: PlaylistTimingState{Revision: 4, Duration: time.Second, Expired: true}, revision: 4},
}
for _, test := range tests {
@@ -70,7 +78,13 @@ func TestExpirePlaylistTiming(t *testing.T) {
if !expired {
t.Fatal("ExpirePlaylistTiming() expired = false, want true")
}
want := PlaylistTimingState{Revision: 9, Duration: 5 * time.Second}
want := PlaylistTimingState{
Revision: 9,
Duration: 5 * time.Second,
Ready: true,
Expired: true,
Remaining: 0,
}
if got != want {
t.Fatalf("ExpirePlaylistTiming() = %#v, want %#v", got, want)
}
@@ -117,3 +131,74 @@ func TestNewPlaylistTimingInvalidatesPreviousDeadline(t *testing.T) {
t.Fatalf("old expiry changed new timing: %#v, %v", got, expired)
}
}
func TestPauseAndResumePlaylistTimingBeforeReadiness(t *testing.T) {
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
current := NewPlaylistTiming(3, 10*time.Second)
paused, changed := PausePlaylistTiming(current, 3, now)
if !changed || !paused.Paused || paused.Ready || paused.Started {
t.Fatalf("PausePlaylistTiming() = %#v, %v", paused, changed)
}
resumed, changed := ResumePlaylistTiming(paused, 3, now.Add(time.Second))
if !changed || resumed.Paused || resumed.Ready || resumed.Started {
t.Fatalf("ResumePlaylistTiming() = %#v, %v", resumed, changed)
}
if resumed.Remaining != 10*time.Second {
t.Fatalf("remaining = %v, want 10s", resumed.Remaining)
}
}
func TestPauseAndResumeActivePlaylistTimingUsesRemaining(t *testing.T) {
now := time.Date(2026, time.September, 1, 12, 0, 0, 0, time.UTC)
current, _ := StartPlaylistTiming(NewPlaylistTiming(5, 10*time.Second), 5, now)
paused, changed := PausePlaylistTiming(current, 5, now.Add(4*time.Second))
if !changed || !paused.Paused || paused.Started || !paused.Ready {
t.Fatalf("PausePlaylistTiming() = %#v, %v", paused, changed)
}
if paused.Remaining != 6*time.Second || !paused.Deadline.IsZero() {
t.Fatalf("paused timing = %#v, want 6s remaining and no deadline", paused)
}
resumeAt := now.Add(20 * time.Second)
resumed, changed := ResumePlaylistTiming(paused, 5, resumeAt)
if !changed || resumed.Paused || !resumed.Started || !resumed.Ready {
t.Fatalf("ResumePlaylistTiming() = %#v, %v", resumed, changed)
}
if resumed.Deadline != resumeAt.Add(6*time.Second) {
t.Fatalf("resumed deadline = %v, want %v", resumed.Deadline, resumeAt.Add(6*time.Second))
}
}
func TestPauseAndResumePlaylistTimingIgnoreInvalidTransitions(t *testing.T) {
now := time.Now()
base := NewPlaylistTiming(2, time.Second)
paused, _ := PausePlaylistTiming(base, 2, now)
tests := []struct {
name string
state PlaylistTimingState
pause bool
revision uint64
}{
{name: "pause wrong revision", state: base, pause: true, revision: 1},
{name: "duplicate pause", state: paused, pause: true, revision: 2},
{name: "resume wrong revision", state: paused, revision: 1},
{name: "duplicate resume", state: base, revision: 2},
{name: "pause expired", state: PlaylistTimingState{Revision: 2, Duration: time.Second, Expired: true}, pause: true, revision: 2},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var got PlaylistTimingState
var changed bool
if test.pause {
got, changed = PausePlaylistTiming(test.state, test.revision, now)
} else {
got, changed = ResumePlaylistTiming(test.state, test.revision, now)
}
if changed || got != test.state {
t.Fatalf("transition = %#v, %v; want unchanged", got, changed)
}
})
}
}
+7 -3
View File
@@ -26,9 +26,13 @@ const (
)
type Status struct {
Unit Unit
State State
Generation uint64
Unit Unit
State State
Generation uint64
Feed FeedConfig // Video/Audio worker
Pair SyncPairConfig // Sync worker
Attempt int
FailedAttempts int
RetryIn time.Duration
+14 -8
View File
@@ -59,8 +59,13 @@ func NewSyncWorker(
}, nil
}
func (w *SyncWorker) emit(ctx context.Context, status Status) {
func (w *SyncWorker) emit(
ctx context.Context,
pair SyncPairConfig,
status Status,
) {
status.Generation = generationFromContext(ctx)
status.Pair = pair
if w.observer != nil {
w.observer(status)
}
@@ -80,6 +85,7 @@ func (w *SyncWorker) Run(
if !videoConfig.Active || !audioConfig.Active {
return ErrSyncFeedsInactive
}
pair := SyncPairConfig{Video: videoConfig, Audio: audioConfig}
attemptNumber := 0
var latestRetry retryEvent
@@ -91,7 +97,7 @@ func (w *SyncWorker) Run(
if attemptNumber > 1 {
state = StateReconnecting
}
w.emit(ctx, Status{
w.emit(ctx, pair, Status{
Unit: UnitSync,
State: state,
Attempt: attemptNumber,
@@ -100,7 +106,7 @@ func (w *SyncWorker) Run(
attemptAudioSink := &stabilityAudioSink{
sink: w.audioSink,
onStable: func() {
w.emit(ctx, Status{
w.emit(ctx, pair, Status{
Unit: UnitSync,
State: StatePlaying,
Attempt: attemptNumber,
@@ -134,7 +140,7 @@ func (w *SyncWorker) Run(
if !event.WillRetry {
return
}
w.emit(ctx, Status{
w.emit(ctx, pair, Status{
Unit: UnitSync,
State: StateReconnecting,
Attempt: attemptNumber + 1,
@@ -152,18 +158,18 @@ func (w *SyncWorker) Run(
observeRetry,
)
if ctx.Err() != nil {
w.emit(ctx, Status{
w.emit(ctx, pair, Status{
Unit: UnitSync,
State: StateStopping,
})
w.emit(ctx, Status{
w.emit(ctx, pair, Status{
Unit: UnitSync,
State: StateIdle,
})
return ctx.Err()
}
if err != nil {
w.emit(ctx, Status{
w.emit(ctx, pair, Status{
Unit: UnitSync,
State: StateFailed,
Attempt: attemptNumber,
@@ -172,7 +178,7 @@ func (w *SyncWorker) Run(
})
return err
}
w.emit(ctx, Status{
w.emit(ctx, pair, Status{
Unit: UnitSync,
State: StateIdle,
})
+4
View File
@@ -146,6 +146,10 @@ func TestSyncWorkerStatusesInheritGeneration(t *testing.T) {
if status.Generation != 9 {
t.Fatalf("status generation = %d, want 9: %+v", status.Generation, status)
}
wantPair := SyncPairConfig{Video: video, Audio: audio}
if status.Pair != wantPair {
t.Fatalf("status pair = %#v, want %#v", status.Pair, wantPair)
}
}
}
+9 -8
View File
@@ -72,8 +72,9 @@ func (s *stabilityVideoSink) ConsumeVideo(
return err
}
func (w *VideoWorker) emit(ctx context.Context, status Status) {
func (w *VideoWorker) emit(ctx context.Context, config FeedConfig, status Status) {
status.Generation = generationFromContext(ctx)
status.Feed = config
if w.observer != nil {
w.observer(status)
}
@@ -100,7 +101,7 @@ func (w *VideoWorker) Run(
if attemptNumber > 1 {
state = StateReconnecting
}
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitVideo,
State: state,
Attempt: attemptNumber,
@@ -109,7 +110,7 @@ func (w *VideoWorker) Run(
attemptSink := &stabilityVideoSink{
sink: w.sink,
onStable: func() {
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitVideo,
State: StatePlaying,
Attempt: attemptNumber,
@@ -135,7 +136,7 @@ func (w *VideoWorker) Run(
return
}
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitVideo,
State: StateReconnecting,
Attempt: attemptNumber + 1,
@@ -155,11 +156,11 @@ func (w *VideoWorker) Run(
)
if ctx.Err() != nil {
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitVideo,
State: StateStopping,
})
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitVideo,
State: StateIdle,
})
@@ -167,7 +168,7 @@ func (w *VideoWorker) Run(
}
if err != nil {
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitVideo,
State: StateFailed,
Attempt: attemptNumber,
@@ -177,7 +178,7 @@ func (w *VideoWorker) Run(
return err
}
w.emit(ctx, Status{
w.emit(ctx, config, Status{
Unit: UnitVideo,
State: StateIdle,
})
+5 -1
View File
@@ -162,7 +162,8 @@ func TestVideoWorkerStatusesInheritGeneration(t *testing.T) {
func(error) bool { return true },
func(status Status) { statuses = append(statuses, status) },
)
_ = worker.Run(withGeneration(context.Background(), 7), activeVideoConfig())
config := activeVideoConfig()
_ = worker.Run(withGeneration(context.Background(), 7), config)
if len(statuses) == 0 {
t.Fatal("no statuses emitted")
}
@@ -170,6 +171,9 @@ func TestVideoWorkerStatusesInheritGeneration(t *testing.T) {
if status.Generation != 7 {
t.Fatalf("status generation = %d, want 7: %+v", status.Generation, status)
}
if status.Feed != config {
t.Fatalf("status feed = %#v, want %#v", status.Feed, config)
}
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"loop": true,
"entries": [
{
"name": "timelapse",
"video": {
"domain": "/dev/shm/mxl",
"uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ed"
},
"audio": {
"domain": "/dev/shm/mxl",
"uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ec"
},
"sync": true,
"duration": "10s"
},
{
"name": "F1",
"video": {
"domain": "/dev/shm/mxl",
"uuid": "5fbec3b1-1b0f-417d-9059-8b94a47197ef"
},
"duration": "15s"
},
{
"name": "Costa Rica",
"audio": {
"domain": "/dev/shm/mxl",
"uuid": "9d2a041b-01cf-4ee4-bffa-188fe093c99b"
}
}
]
}