Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca25bf88a7 | |||
| b0e7bc4cc3 | |||
| cb5321cd8b | |||
| 362ae15867 | |||
| 3b7662fea7 | |||
| 1e504f0f92 | |||
| cd0298b136 | |||
| d3ea99233d |
+175
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ const (
|
||||
PlaylistSelect PlaylistCommandKind = iota + 1
|
||||
PlaylistNext
|
||||
PlaylistPrevious
|
||||
PlaylistPause
|
||||
PlaylistResume
|
||||
)
|
||||
|
||||
type PlaylistCommand struct {
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user