From e50ad8adb5cb4ae8d9037669ab6304dbb2bd9fef Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Tue, 1 Sep 2026 19:13:12 +0300 Subject: [PATCH] playlist model --- internal/playback/playlist.go | 91 +++++++++++++++++ internal/playback/playlist_test.go | 153 +++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 internal/playback/playlist.go create mode 100644 internal/playback/playlist_test.go diff --git a/internal/playback/playlist.go b/internal/playback/playlist.go new file mode 100644 index 0000000..e29afc0 --- /dev/null +++ b/internal/playback/playlist.go @@ -0,0 +1,91 @@ +package playback + +import ( + "errors" + "fmt" + "time" +) + +var ( + ErrPlaylistFeedUUIDRequired = errors.New("playlist feed UUID is required when its domain is configured") + ErrPlaylistEntryEmpty = errors.New("playlist entry must contain at least one feed") + ErrPlaylistSyncFeedsRequired = errors.New("synchronized playlist entry requires both video and audio feeds") + ErrPlaylistDurationNegative = errors.New("playlist entry duration cannot be negative") +) + +type PlaylistFeed struct { + Domain string + UUID string +} + +type PlaylistEntry struct { + Name string + Video PlaylistFeed + Audio PlaylistFeed + SyncRequested bool + Duration time.Duration +} + +type Playlist struct { + Entries []PlaylistEntry + Loop bool +} + +func (f PlaylistFeed) IsConfigured() bool { + return f.UUID != "" +} + +func (f PlaylistFeed) Validate() error { + if f.UUID != "" && f.Domain == "" { + return ErrFeedDomainRequired + } + if f.Domain != "" && f.UUID == "" { + return ErrPlaylistFeedUUIDRequired + } + return nil +} + +func (e PlaylistEntry) Validate() error { + if err := e.Video.Validate(); err != nil { + return fmt.Errorf("video: %w", err) + } + if err := e.Audio.Validate(); err != nil { + return fmt.Errorf("audio: %w", err) + } + if !e.Video.IsConfigured() && !e.Audio.IsConfigured() { + return ErrPlaylistEntryEmpty + } + if e.SyncRequested && (!e.Video.IsConfigured() || !e.Audio.IsConfigured()) { + return ErrPlaylistSyncFeedsRequired + } + if e.Duration < 0 { + return ErrPlaylistDurationNegative + } + return nil +} + +func (p Playlist) Validate() error { + for index, entry := range p.Entries { + if err := entry.Validate(); err != nil { + return fmt.Errorf("playlist entry %d: %w", index, err) + } + } + return nil +} + +func (e PlaylistEntry) SessionConfig(retry RetryPolicy) SessionConfig { + return SessionConfig{ + Video: FeedConfig{ + Domain: e.Video.Domain, + UUID: e.Video.UUID, + Active: e.Video.UUID != "", + }, + Audio: FeedConfig{ + Domain: e.Audio.Domain, + UUID: e.Audio.UUID, + Active: e.Audio.UUID != "", + }, + SyncRequested: e.SyncRequested, + Retry: retry, + } +} diff --git a/internal/playback/playlist_test.go b/internal/playback/playlist_test.go new file mode 100644 index 0000000..cd7528e --- /dev/null +++ b/internal/playback/playlist_test.go @@ -0,0 +1,153 @@ +package playback + +import ( + "errors" + "testing" + "time" +) + +func TestPlaylistEntryValidate(t *testing.T) { + tests := []struct { + name string + entry PlaylistEntry + wantErr error + }{ + {name: "video only", entry: PlaylistEntry{Video: PlaylistFeed{Domain: "video-domain", UUID: "video"}}}, + {name: "audio only", entry: PlaylistEntry{Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"}}}, + { + name: "independent feeds from different domains", + entry: PlaylistEntry{ + Video: PlaylistFeed{Domain: "video-domain", UUID: "video"}, + Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"}, + }, + }, + { + name: "synchronized pair", + entry: PlaylistEntry{ + Video: PlaylistFeed{Domain: "domain", UUID: "video"}, + Audio: PlaylistFeed{Domain: "domain", UUID: "audio"}, + SyncRequested: true, + Duration: 10 * time.Second, + }, + }, + {name: "zero duration", entry: PlaylistEntry{Video: PlaylistFeed{Domain: "domain", UUID: "video"}}}, + {name: "empty entry", entry: PlaylistEntry{}, wantErr: ErrPlaylistEntryEmpty}, + { + name: "video UUID without domain", + entry: PlaylistEntry{Video: PlaylistFeed{UUID: "video"}}, + wantErr: ErrFeedDomainRequired, + }, + { + name: "audio domain without UUID", + entry: PlaylistEntry{Audio: PlaylistFeed{Domain: "audio-domain"}}, + wantErr: ErrPlaylistFeedUUIDRequired, + }, + { + name: "sync without audio", + entry: PlaylistEntry{ + Video: PlaylistFeed{Domain: "domain", UUID: "video"}, + SyncRequested: true, + }, + wantErr: ErrPlaylistSyncFeedsRequired, + }, + { + name: "sync without video", + entry: PlaylistEntry{ + Audio: PlaylistFeed{Domain: "domain", UUID: "audio"}, + SyncRequested: true, + }, + wantErr: ErrPlaylistSyncFeedsRequired, + }, + { + name: "negative duration", + entry: PlaylistEntry{ + Video: PlaylistFeed{Domain: "domain", UUID: "video"}, + Duration: -time.Second, + }, + wantErr: ErrPlaylistDurationNegative, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.entry.Validate() + if !errors.Is(err, test.wantErr) { + t.Fatalf("Validate() error = %v, want %v", err, test.wantErr) + } + }) + } +} + +func TestPlaylistEntrySessionConfig(t *testing.T) { + retry := RetryPolicy{MaxAttempts: 3, InitialDelay: time.Second, MaxDelay: 5 * time.Second} + entry := PlaylistEntry{ + Video: PlaylistFeed{Domain: "video-domain", UUID: "video"}, + Audio: PlaylistFeed{Domain: "audio-domain", UUID: "audio"}, + SyncRequested: true, + Duration: 10 * time.Second, + } + + got := entry.SessionConfig(retry) + want := SessionConfig{ + Video: FeedConfig{Domain: "video-domain", UUID: "video", Active: true}, + Audio: FeedConfig{Domain: "audio-domain", UUID: "audio", Active: true}, + SyncRequested: true, + Retry: retry, + } + if got != want { + t.Fatalf("SessionConfig() = %#v, want %#v", got, want) + } +} + +func TestPlaylistEntrySessionConfigLeavesMissingFeedInactive(t *testing.T) { + entry := PlaylistEntry{Video: PlaylistFeed{Domain: "domain", UUID: "video"}} + + got := entry.SessionConfig(RetryPolicy{}) + if !got.Video.Active { + t.Fatal("SessionConfig() video is inactive, want active") + } + if got.Audio != (FeedConfig{}) { + t.Fatalf("SessionConfig() audio = %#v, want zero value", got.Audio) + } +} + +func TestPlaylistValidate(t *testing.T) { + valid := PlaylistEntry{Video: PlaylistFeed{Domain: "domain", UUID: "video"}} + tests := []struct { + name string + playlist Playlist + wantErr error + }{ + {name: "empty playlist", playlist: Playlist{}}, + {name: "valid entries", playlist: Playlist{Entries: []PlaylistEntry{valid, valid}, Loop: true}}, + { + name: "invalid entry", + playlist: Playlist{Entries: []PlaylistEntry{ + valid, + {Audio: PlaylistFeed{UUID: "audio"}}, + }}, + wantErr: ErrFeedDomainRequired, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.playlist.Validate() + if !errors.Is(err, test.wantErr) { + t.Fatalf("Validate() error = %v, want %v", err, test.wantErr) + } + }) + } +} + +func TestPlaylistValidateReportsEntryIndex(t *testing.T) { + playlist := Playlist{Entries: []PlaylistEntry{ + {Video: PlaylistFeed{Domain: "domain", UUID: "video"}}, + {}, + }} + + err := playlist.Validate() + if err == nil || err.Error() != "playlist entry 1: playlist entry must contain at least one feed" { + t.Fatalf("Validate() error = %v, want indexed entry error", err) + } +}