Compare commits
8 Commits
1e21da0aac
...
ca25bf88a7
| Author | SHA1 | Date | |
|---|---|---|---|
| ca25bf88a7 | |||
| b0e7bc4cc3 | |||
| cb5321cd8b | |||
| 362ae15867 | |||
| 3b7662fea7 | |||
| 1e504f0f92 | |||
| cd0298b136 | |||
| d3ea99233d |
+175
-1
@@ -53,6 +53,7 @@ type appArgs struct {
|
|||||||
ListGPU bool
|
ListGPU bool
|
||||||
SyncRequested bool
|
SyncRequested bool
|
||||||
MaxAttempts int
|
MaxAttempts int
|
||||||
|
PlaylistPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func printCliHelp(fs *pflag.FlagSet) {
|
func printCliHelp(fs *pflag.FlagSet) {
|
||||||
@@ -129,6 +130,12 @@ func main() {
|
|||||||
0,
|
0,
|
||||||
"Maximum connection attempts per playback lifecycle; 0 retries indefinitely",
|
"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.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.GpuId, "gpu-id", "g", 0, "GPU id [TODO]")
|
||||||
flagSet.Uint32VarP(&args.PlaybackId, "playback-id", "p", 0, "Playback audio device id")
|
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)
|
fmt.Fprintln(os.Stderr, "invalid retry configuration:", err)
|
||||||
os.Exit(2)
|
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 == "" {
|
if args.VideoDomain == "" {
|
||||||
args.VideoDomain = args.Domain
|
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
|
running := true
|
||||||
resized := false
|
resized := false
|
||||||
fullscreen := args.IsFullscreen
|
fullscreen := args.IsFullscreen
|
||||||
@@ -662,6 +701,136 @@ func main() {
|
|||||||
drawFeedsSections()
|
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() {
|
drawHotkeysSection := func() {
|
||||||
cimgui.Text("F1 - show/hide stats")
|
cimgui.Text("F1 - show/hide stats")
|
||||||
cimgui.Text("F2 - show/hide settings")
|
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()
|
drawDebugSection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -774,6 +943,11 @@ func main() {
|
|||||||
if err := <-playbackDone; err != nil && !errors.Is(err, context.Canceled) {
|
if err := <-playbackDone; err != nil && !errors.Is(err, context.Canceled) {
|
||||||
log.Printf("playback controller: %v", err)
|
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 {
|
if err := player.Close(); err != nil {
|
||||||
log.Printf("close playback: %v", err)
|
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
|
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.Generation = generationFromContext(ctx)
|
||||||
|
status.Feed = config
|
||||||
if w.observer != nil {
|
if w.observer != nil {
|
||||||
w.observer(status)
|
w.observer(status)
|
||||||
}
|
}
|
||||||
@@ -100,7 +101,7 @@ func (w *AudioWorker) Run(
|
|||||||
if attemptNumber > 1 {
|
if attemptNumber > 1 {
|
||||||
state = StateReconnecting
|
state = StateReconnecting
|
||||||
}
|
}
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitAudio,
|
Unit: UnitAudio,
|
||||||
State: state,
|
State: state,
|
||||||
Attempt: attemptNumber,
|
Attempt: attemptNumber,
|
||||||
@@ -109,7 +110,7 @@ func (w *AudioWorker) Run(
|
|||||||
attemptSink := &stabilityAudioSink{
|
attemptSink := &stabilityAudioSink{
|
||||||
sink: w.sink,
|
sink: w.sink,
|
||||||
onStable: func() {
|
onStable: func() {
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitAudio,
|
Unit: UnitAudio,
|
||||||
State: StatePlaying,
|
State: StatePlaying,
|
||||||
Attempt: attemptNumber,
|
Attempt: attemptNumber,
|
||||||
@@ -135,7 +136,7 @@ func (w *AudioWorker) Run(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitAudio,
|
Unit: UnitAudio,
|
||||||
State: StateReconnecting,
|
State: StateReconnecting,
|
||||||
Attempt: attemptNumber + 1,
|
Attempt: attemptNumber + 1,
|
||||||
@@ -155,11 +156,11 @@ func (w *AudioWorker) Run(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitAudio,
|
Unit: UnitAudio,
|
||||||
State: StateStopping,
|
State: StateStopping,
|
||||||
})
|
})
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitAudio,
|
Unit: UnitAudio,
|
||||||
State: StateIdle,
|
State: StateIdle,
|
||||||
})
|
})
|
||||||
@@ -167,7 +168,7 @@ func (w *AudioWorker) Run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitAudio,
|
Unit: UnitAudio,
|
||||||
State: StateFailed,
|
State: StateFailed,
|
||||||
Attempt: attemptNumber,
|
Attempt: attemptNumber,
|
||||||
@@ -177,7 +178,7 @@ func (w *AudioWorker) Run(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitAudio,
|
Unit: UnitAudio,
|
||||||
State: StateIdle,
|
State: StateIdle,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -127,10 +127,8 @@ func TestAudioWorkerStatusesInheritGeneration(t *testing.T) {
|
|||||||
func(error) bool { return true },
|
func(error) bool { return true },
|
||||||
func(status Status) { statuses = append(statuses, status) },
|
func(status Status) { statuses = append(statuses, status) },
|
||||||
)
|
)
|
||||||
_ = worker.Run(
|
config := FeedConfig{Domain: "/audio", UUID: "audio", Active: true}
|
||||||
withGeneration(context.Background(), 8),
|
_ = worker.Run(withGeneration(context.Background(), 8), config)
|
||||||
FeedConfig{Domain: "/audio", UUID: "audio", Active: true},
|
|
||||||
)
|
|
||||||
if len(statuses) == 0 {
|
if len(statuses) == 0 {
|
||||||
t.Fatal("no statuses emitted")
|
t.Fatal("no statuses emitted")
|
||||||
}
|
}
|
||||||
@@ -138,6 +136,9 @@ func TestAudioWorkerStatusesInheritGeneration(t *testing.T) {
|
|||||||
if status.Generation != 8 {
|
if status.Generation != 8 {
|
||||||
t.Fatalf("status generation = %d, want 8: %+v", status.Generation, status)
|
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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"sync"
|
"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 {
|
type PlaylistController struct {
|
||||||
playlist Playlist
|
playlist Playlist
|
||||||
retry RetryPolicy
|
retry RetryPolicy
|
||||||
sessions chan<- SessionCommand
|
sessions chan<- SessionCommand
|
||||||
|
now func() time.Time
|
||||||
|
newTimer playlistTimerFactory
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
snapshot PlaylistSnapshot
|
snapshot PlaylistSnapshot
|
||||||
@@ -20,6 +41,7 @@ type PlaylistSnapshot struct {
|
|||||||
State PlaylistState
|
State PlaylistState
|
||||||
Entry PlaylistEntry
|
Entry PlaylistEntry
|
||||||
Revision uint64
|
Revision uint64
|
||||||
|
Timing PlaylistTimingState
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -44,16 +66,32 @@ func NewPlaylistController(
|
|||||||
playlist: playlist,
|
playlist: playlist,
|
||||||
retry: retry,
|
retry: retry,
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
|
now: time.Now,
|
||||||
|
newTimer: func(duration time.Duration) playlistTimer {
|
||||||
|
return realPlaylistTimer{timer: time.NewTimer(duration)}
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *PlaylistController) Run(
|
func (c *PlaylistController) Run(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
commands <-chan PlaylistCommand,
|
commands <-chan PlaylistCommand,
|
||||||
|
readiness <-chan PlaylistReadiness,
|
||||||
) error {
|
) error {
|
||||||
state := PlaylistState{}
|
state := PlaylistState{}
|
||||||
revision := uint64(0)
|
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 {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -64,6 +102,36 @@ func (c *PlaylistController) Run(
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
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(
|
next, sessionCommand, apply, err := ApplyPlaylistSelection(
|
||||||
c.playlist,
|
c.playlist,
|
||||||
@@ -75,16 +143,83 @@ func (c *PlaylistController) Run(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if apply {
|
if apply {
|
||||||
|
stopTimer()
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case c.sessions <- sessionCommand:
|
case c.sessions <- sessionCommand:
|
||||||
}
|
}
|
||||||
revision++
|
revision++
|
||||||
|
entry, _ := next.Entry(c.playlist)
|
||||||
|
timing = NewPlaylistTiming(revision, entry.Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
state = next
|
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
|
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)
|
entry, _ := state.Entry(c.playlist)
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -103,7 +242,18 @@ func (c *PlaylistController) publish(state PlaylistState, revision uint64) {
|
|||||||
State: state,
|
State: state,
|
||||||
Entry: entry,
|
Entry: entry,
|
||||||
Revision: revision,
|
Revision: revision,
|
||||||
|
Timing: timing,
|
||||||
}
|
}
|
||||||
c.hasSnapshot = true
|
c.hasSnapshot = true
|
||||||
c.mu.Unlock()
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
result := make(chan error, 1)
|
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 {
|
waitForPlaylistSnapshot(t, controller, func(snapshot PlaylistSnapshot) bool {
|
||||||
return !snapshot.State.HasSelection
|
return !snapshot.State.HasSelection
|
||||||
@@ -224,7 +224,7 @@ func TestPlaylistControllerCancellationWhileSending(t *testing.T) {
|
|||||||
commands := make(chan PlaylistCommand, 1)
|
commands := make(chan PlaylistCommand, 1)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
result := make(chan error, 1)
|
result := make(chan error, 1)
|
||||||
go func() { result <- controller.Run(ctx, commands) }()
|
go func() { result <- controller.Run(ctx, commands, nil) }()
|
||||||
|
|
||||||
commands <- PlaylistCommand{Kind: PlaylistNext}
|
commands <- PlaylistCommand{Kind: PlaylistNext}
|
||||||
cancel()
|
cancel()
|
||||||
@@ -323,7 +323,7 @@ func startPlaylistController(
|
|||||||
commands := make(chan PlaylistCommand, 64)
|
commands := make(chan PlaylistCommand, 64)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
result := make(chan error, 1)
|
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
|
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
|
PlaylistSelect PlaylistCommandKind = iota + 1
|
||||||
PlaylistNext
|
PlaylistNext
|
||||||
PlaylistPrevious
|
PlaylistPrevious
|
||||||
|
PlaylistPause
|
||||||
|
PlaylistResume
|
||||||
)
|
)
|
||||||
|
|
||||||
type PlaylistCommand struct {
|
type PlaylistCommand struct {
|
||||||
|
|||||||
@@ -1,5 +1,152 @@
|
|||||||
package playback
|
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(
|
func IsSessionPlaying(
|
||||||
session SessionSnapshot,
|
session SessionSnapshot,
|
||||||
statuses PlaybackStatusSnapshot,
|
statuses PlaybackStatusSnapshot,
|
||||||
@@ -18,6 +165,7 @@ func IsSessionPlaying(
|
|||||||
statuses.Video,
|
statuses.Video,
|
||||||
statuses.HasVideo,
|
statuses.HasVideo,
|
||||||
session.Generation,
|
session.Generation,
|
||||||
|
session.Plan.Video,
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -25,17 +173,17 @@ func IsSessionPlaying(
|
|||||||
statuses.Audio,
|
statuses.Audio,
|
||||||
statuses.HasAudio,
|
statuses.HasAudio,
|
||||||
session.Generation,
|
session.Generation,
|
||||||
|
session.Plan.Audio,
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
case TopologySynchronized:
|
case TopologySynchronized:
|
||||||
return statusIsPlaying(
|
return statuses.HasSync &&
|
||||||
statuses.Sync,
|
statuses.Sync.Generation == session.Generation &&
|
||||||
statuses.HasSync,
|
statuses.Sync.State == StatePlaying &&
|
||||||
session.Generation,
|
sameSyncSource(statuses.Sync.Pair, session.Plan.Sync)
|
||||||
)
|
|
||||||
|
|
||||||
case TopologyIdle:
|
case TopologyIdle:
|
||||||
return false
|
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 &&
|
return present &&
|
||||||
status.Generation == generation &&
|
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) {
|
func TestIsSessionPlaying(t *testing.T) {
|
||||||
const generation = 4
|
const generation = 4
|
||||||
playing := func(unit Unit) Status {
|
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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -171,6 +178,72 @@ func TestIsSessionPlaying(t *testing.T) {
|
|||||||
HasSync: true,
|
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",
|
name: "idle",
|
||||||
session: SessionSnapshot{
|
session: SessionSnapshot{
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package playback
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type PlaylistTimingState struct {
|
type PlaylistTimingState struct {
|
||||||
Revision uint64
|
Revision uint64
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
Started bool
|
Ready bool
|
||||||
Deadline time.Time
|
Started bool
|
||||||
|
Paused bool
|
||||||
|
Expired bool
|
||||||
|
Remaining time.Duration
|
||||||
|
Deadline time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlaylistTiming(
|
func NewPlaylistTiming(
|
||||||
@@ -14,8 +18,9 @@ func NewPlaylistTiming(
|
|||||||
duration time.Duration,
|
duration time.Duration,
|
||||||
) PlaylistTimingState {
|
) PlaylistTimingState {
|
||||||
return PlaylistTimingState{
|
return PlaylistTimingState{
|
||||||
Revision: revision,
|
Revision: revision,
|
||||||
Duration: duration,
|
Duration: duration,
|
||||||
|
Remaining: duration,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,13 +29,69 @@ func StartPlaylistTiming(
|
|||||||
revision uint64,
|
revision uint64,
|
||||||
now time.Time,
|
now time.Time,
|
||||||
) (PlaylistTimingState, bool) {
|
) (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
|
return current, false
|
||||||
}
|
}
|
||||||
|
|
||||||
next := current
|
next := current
|
||||||
next.Started = true
|
if next.Started {
|
||||||
next.Deadline = now.Add(current.Duration)
|
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
|
return next, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +108,9 @@ func ExpirePlaylistTiming(
|
|||||||
|
|
||||||
next := current
|
next := current
|
||||||
next.Started = false
|
next.Started = false
|
||||||
|
next.Paused = false
|
||||||
next.Deadline = time.Time{}
|
next.Deadline = time.Time{}
|
||||||
|
next.Expired = true
|
||||||
|
next.Remaining = 0
|
||||||
return next, true
|
return next, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import (
|
|||||||
|
|
||||||
func TestNewPlaylistTimingResetsState(t *testing.T) {
|
func TestNewPlaylistTimingResetsState(t *testing.T) {
|
||||||
got := NewPlaylistTiming(7, 10*time.Second)
|
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 {
|
if got != want {
|
||||||
t.Fatalf("NewPlaylistTiming() = %#v, want %#v", 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")
|
t.Fatal("StartPlaylistTiming() started = false, want true")
|
||||||
}
|
}
|
||||||
want := PlaylistTimingState{
|
want := PlaylistTimingState{
|
||||||
Revision: 4,
|
Revision: 4,
|
||||||
Duration: 10 * time.Second,
|
Duration: 10 * time.Second,
|
||||||
Started: true,
|
Ready: true,
|
||||||
Deadline: now.Add(10 * time.Second),
|
Started: true,
|
||||||
|
Remaining: 10 * time.Second,
|
||||||
|
Deadline: now.Add(10 * time.Second),
|
||||||
}
|
}
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("StartPlaylistTiming() = %#v, want %#v", 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: "stale revision", current: NewPlaylistTiming(4, time.Second), revision: 3},
|
||||||
{name: "future revision", current: NewPlaylistTiming(4, time.Second), revision: 5},
|
{name: "future revision", current: NewPlaylistTiming(4, time.Second), revision: 5},
|
||||||
{name: "already started", current: started, revision: 4},
|
{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 {
|
for _, test := range tests {
|
||||||
@@ -70,7 +78,13 @@ func TestExpirePlaylistTiming(t *testing.T) {
|
|||||||
if !expired {
|
if !expired {
|
||||||
t.Fatal("ExpirePlaylistTiming() expired = false, want true")
|
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 {
|
if got != want {
|
||||||
t.Fatalf("ExpirePlaylistTiming() = %#v, want %#v", 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)
|
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 {
|
type Status struct {
|
||||||
Unit Unit
|
Unit Unit
|
||||||
State State
|
State State
|
||||||
Generation uint64
|
Generation uint64
|
||||||
|
|
||||||
|
Feed FeedConfig // Video/Audio worker
|
||||||
|
Pair SyncPairConfig // Sync worker
|
||||||
|
|
||||||
Attempt int
|
Attempt int
|
||||||
FailedAttempts int
|
FailedAttempts int
|
||||||
RetryIn time.Duration
|
RetryIn time.Duration
|
||||||
|
|||||||
@@ -59,8 +59,13 @@ func NewSyncWorker(
|
|||||||
}, nil
|
}, 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.Generation = generationFromContext(ctx)
|
||||||
|
status.Pair = pair
|
||||||
if w.observer != nil {
|
if w.observer != nil {
|
||||||
w.observer(status)
|
w.observer(status)
|
||||||
}
|
}
|
||||||
@@ -80,6 +85,7 @@ func (w *SyncWorker) Run(
|
|||||||
if !videoConfig.Active || !audioConfig.Active {
|
if !videoConfig.Active || !audioConfig.Active {
|
||||||
return ErrSyncFeedsInactive
|
return ErrSyncFeedsInactive
|
||||||
}
|
}
|
||||||
|
pair := SyncPairConfig{Video: videoConfig, Audio: audioConfig}
|
||||||
|
|
||||||
attemptNumber := 0
|
attemptNumber := 0
|
||||||
var latestRetry retryEvent
|
var latestRetry retryEvent
|
||||||
@@ -91,7 +97,7 @@ func (w *SyncWorker) Run(
|
|||||||
if attemptNumber > 1 {
|
if attemptNumber > 1 {
|
||||||
state = StateReconnecting
|
state = StateReconnecting
|
||||||
}
|
}
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, pair, Status{
|
||||||
Unit: UnitSync,
|
Unit: UnitSync,
|
||||||
State: state,
|
State: state,
|
||||||
Attempt: attemptNumber,
|
Attempt: attemptNumber,
|
||||||
@@ -100,7 +106,7 @@ func (w *SyncWorker) Run(
|
|||||||
attemptAudioSink := &stabilityAudioSink{
|
attemptAudioSink := &stabilityAudioSink{
|
||||||
sink: w.audioSink,
|
sink: w.audioSink,
|
||||||
onStable: func() {
|
onStable: func() {
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, pair, Status{
|
||||||
Unit: UnitSync,
|
Unit: UnitSync,
|
||||||
State: StatePlaying,
|
State: StatePlaying,
|
||||||
Attempt: attemptNumber,
|
Attempt: attemptNumber,
|
||||||
@@ -134,7 +140,7 @@ func (w *SyncWorker) Run(
|
|||||||
if !event.WillRetry {
|
if !event.WillRetry {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, pair, Status{
|
||||||
Unit: UnitSync,
|
Unit: UnitSync,
|
||||||
State: StateReconnecting,
|
State: StateReconnecting,
|
||||||
Attempt: attemptNumber + 1,
|
Attempt: attemptNumber + 1,
|
||||||
@@ -152,18 +158,18 @@ func (w *SyncWorker) Run(
|
|||||||
observeRetry,
|
observeRetry,
|
||||||
)
|
)
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, pair, Status{
|
||||||
Unit: UnitSync,
|
Unit: UnitSync,
|
||||||
State: StateStopping,
|
State: StateStopping,
|
||||||
})
|
})
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, pair, Status{
|
||||||
Unit: UnitSync,
|
Unit: UnitSync,
|
||||||
State: StateIdle,
|
State: StateIdle,
|
||||||
})
|
})
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, pair, Status{
|
||||||
Unit: UnitSync,
|
Unit: UnitSync,
|
||||||
State: StateFailed,
|
State: StateFailed,
|
||||||
Attempt: attemptNumber,
|
Attempt: attemptNumber,
|
||||||
@@ -172,7 +178,7 @@ func (w *SyncWorker) Run(
|
|||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, pair, Status{
|
||||||
Unit: UnitSync,
|
Unit: UnitSync,
|
||||||
State: StateIdle,
|
State: StateIdle,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ func TestSyncWorkerStatusesInheritGeneration(t *testing.T) {
|
|||||||
if status.Generation != 9 {
|
if status.Generation != 9 {
|
||||||
t.Fatalf("status generation = %d, want 9: %+v", status.Generation, status)
|
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
|
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.Generation = generationFromContext(ctx)
|
||||||
|
status.Feed = config
|
||||||
if w.observer != nil {
|
if w.observer != nil {
|
||||||
w.observer(status)
|
w.observer(status)
|
||||||
}
|
}
|
||||||
@@ -100,7 +101,7 @@ func (w *VideoWorker) Run(
|
|||||||
if attemptNumber > 1 {
|
if attemptNumber > 1 {
|
||||||
state = StateReconnecting
|
state = StateReconnecting
|
||||||
}
|
}
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitVideo,
|
Unit: UnitVideo,
|
||||||
State: state,
|
State: state,
|
||||||
Attempt: attemptNumber,
|
Attempt: attemptNumber,
|
||||||
@@ -109,7 +110,7 @@ func (w *VideoWorker) Run(
|
|||||||
attemptSink := &stabilityVideoSink{
|
attemptSink := &stabilityVideoSink{
|
||||||
sink: w.sink,
|
sink: w.sink,
|
||||||
onStable: func() {
|
onStable: func() {
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitVideo,
|
Unit: UnitVideo,
|
||||||
State: StatePlaying,
|
State: StatePlaying,
|
||||||
Attempt: attemptNumber,
|
Attempt: attemptNumber,
|
||||||
@@ -135,7 +136,7 @@ func (w *VideoWorker) Run(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitVideo,
|
Unit: UnitVideo,
|
||||||
State: StateReconnecting,
|
State: StateReconnecting,
|
||||||
Attempt: attemptNumber + 1,
|
Attempt: attemptNumber + 1,
|
||||||
@@ -155,11 +156,11 @@ func (w *VideoWorker) Run(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitVideo,
|
Unit: UnitVideo,
|
||||||
State: StateStopping,
|
State: StateStopping,
|
||||||
})
|
})
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitVideo,
|
Unit: UnitVideo,
|
||||||
State: StateIdle,
|
State: StateIdle,
|
||||||
})
|
})
|
||||||
@@ -167,7 +168,7 @@ func (w *VideoWorker) Run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitVideo,
|
Unit: UnitVideo,
|
||||||
State: StateFailed,
|
State: StateFailed,
|
||||||
Attempt: attemptNumber,
|
Attempt: attemptNumber,
|
||||||
@@ -177,7 +178,7 @@ func (w *VideoWorker) Run(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
w.emit(ctx, Status{
|
w.emit(ctx, config, Status{
|
||||||
Unit: UnitVideo,
|
Unit: UnitVideo,
|
||||||
State: StateIdle,
|
State: StateIdle,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -162,7 +162,8 @@ func TestVideoWorkerStatusesInheritGeneration(t *testing.T) {
|
|||||||
func(error) bool { return true },
|
func(error) bool { return true },
|
||||||
func(status Status) { statuses = append(statuses, status) },
|
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 {
|
if len(statuses) == 0 {
|
||||||
t.Fatal("no statuses emitted")
|
t.Fatal("no statuses emitted")
|
||||||
}
|
}
|
||||||
@@ -170,6 +171,9 @@ func TestVideoWorkerStatusesInheritGeneration(t *testing.T) {
|
|||||||
if status.Generation != 7 {
|
if status.Generation != 7 {
|
||||||
t.Fatalf("status generation = %d, want 7: %+v", status.Generation, status)
|
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