From a3a627d13f5695f09371c49243a55fec7080bb9b Mon Sep 17 00:00:00 2001 From: OpenWaygate <27287694+OpenWaygate@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:52:58 +0800 Subject: [PATCH] :sparkles: Add liveBroadcast resource package --- pkg/liveBroadcast/BUILD.bazel | 23 + pkg/liveBroadcast/liveBroadcast.go | 430 ++++++++++++++ pkg/liveBroadcast/liveBroadcast_test.go | 724 ++++++++++++++++++++++++ 3 files changed, 1177 insertions(+) create mode 100644 pkg/liveBroadcast/BUILD.bazel create mode 100644 pkg/liveBroadcast/liveBroadcast.go create mode 100644 pkg/liveBroadcast/liveBroadcast_test.go diff --git a/pkg/liveBroadcast/BUILD.bazel b/pkg/liveBroadcast/BUILD.bazel new file mode 100644 index 0000000..847b2a2 --- /dev/null +++ b/pkg/liveBroadcast/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "liveBroadcast", + srcs = ["liveBroadcast.go"], + importpath = "github.com/eat-pray-ai/yutu/pkg/liveBroadcast", + visibility = ["//visibility:public"], + deps = [ + "//pkg/common", + "@com_github_jedib0t_go_pretty_v6//table", + "@org_golang_google_api//youtube/v3:youtube", + ], +) + +go_test( + name = "liveBroadcast_test", + srcs = ["liveBroadcast_test.go"], + embed = [":liveBroadcast"], + deps = [ + "//pkg/common", + "@org_golang_google_api//youtube/v3:youtube", + ], +) diff --git a/pkg/liveBroadcast/liveBroadcast.go b/pkg/liveBroadcast/liveBroadcast.go new file mode 100644 index 0000000..6ff5e1b --- /dev/null +++ b/pkg/liveBroadcast/liveBroadcast.go @@ -0,0 +1,430 @@ +// Copyright 2026 eat-pray-ai & OpenWaygate +// SPDX-License-Identifier: Apache-2.0 + +package liveBroadcast + +import ( + "errors" + "fmt" + "io" + + "github.com/eat-pray-ai/yutu/pkg/common" + "github.com/jedib0t/go-pretty/v6/table" + "google.golang.org/api/youtube/v3" +) + +var ( + errGetLiveBroadcast = errors.New("failed to get live broadcast") + errInsertLiveBroadcast = errors.New("failed to insert live broadcast") + errUpdateLiveBroadcast = errors.New("failed to update live broadcast") + errDeleteLiveBroadcast = errors.New("failed to delete live broadcast") + errBindLiveBroadcast = errors.New("failed to bind live broadcast") + errTransitionLiveBroadcast = errors.New("failed to transition live broadcast") + errInsertCuepointLiveBroadcast = errors.New("failed to insert cuepoint") +) + +type LiveBroadcast struct { + common.Fields + Title string `yaml:"title" json:"title,omitempty"` + Description string `yaml:"description" json:"description,omitempty"` + Mine *bool `yaml:"mine" json:"mine,omitempty"` + BroadcastStatus string `yaml:"broadcast_status" json:"broadcast_status,omitempty"` + BroadcastType string `yaml:"broadcast_type" json:"broadcast_type,omitempty"` + PrivacyStatus string `yaml:"privacy_status" json:"privacy_status,omitempty"` + ScheduledStartTime string `yaml:"scheduled_start_time" json:"scheduled_start_time,omitempty"` + ScheduledEndTime string `yaml:"scheduled_end_time" json:"scheduled_end_time,omitempty"` + StreamId string `yaml:"stream_id" json:"stream_id,omitempty"` + + CueType string `yaml:"cue_type" json:"cue_type,omitempty"` + CueDurationSecs int64 `yaml:"cue_duration_secs" json:"cue_duration_secs,omitempty"` + CueInsertionOffsetMs int64 `yaml:"cue_insertion_offset_ms" json:"cue_insertion_offset_ms,omitempty"` + CueWalltimeMs uint64 `yaml:"cue_walltime_ms" json:"cue_walltime_ms,omitempty"` + + OnBehalfOfContentOwnerChannel string `yaml:"on_behalf_of_content_owner_channel" json:"on_behalf_of_content_owner_channel,omitempty"` +} + +type ILiveBroadcast[T any] interface { + List(io.Writer) error + Get() ([]*T, error) + Insert(io.Writer) error + Update(io.Writer) error + Delete(io.Writer) error + Bind(io.Writer) error + Transition(io.Writer) error + InsertCuepoint(io.Writer) error +} + +type Option func(*LiveBroadcast) + +func NewLiveBroadcast(opts ...Option) ILiveBroadcast[youtube.LiveBroadcast] { + b := &LiveBroadcast{Fields: common.Fields{}} + for _, opt := range opts { + opt(b) + } + return b +} + +func (b *LiveBroadcast) Get() ([]*youtube.LiveBroadcast, error) { + if err := b.EnsureService(); err != nil { + return nil, err + } + call := b.Service.LiveBroadcasts.List(b.Parts) + if len(b.Ids) > 0 { + call = call.Id(b.Ids...) + } + if b.Mine != nil && *b.Mine { + call = call.Mine(true) + } + if b.BroadcastStatus != "" { + call = call.BroadcastStatus(b.BroadcastStatus) + } + if b.BroadcastType != "" { + call = call.BroadcastType(b.BroadcastType) + } + if b.OnBehalfOfContentOwner != "" { + call = call.OnBehalfOfContentOwner(b.OnBehalfOfContentOwner) + } + if b.OnBehalfOfContentOwnerChannel != "" { + call = call.OnBehalfOfContentOwnerChannel(b.OnBehalfOfContentOwnerChannel) + } + + return common.Paginate( + &b.Fields, call, + func(r *youtube.LiveBroadcastListResponse) ([]*youtube.LiveBroadcast, string) { + return r.Items, r.NextPageToken + }, errGetLiveBroadcast, + ) +} + +func (b *LiveBroadcast) List(writer io.Writer) error { + broadcasts, err := b.Get() + if err != nil && broadcasts == nil { + return err + } + + common.PrintList( + b.Output, broadcasts, writer, + table.Row{"ID", "Title", "Status", "Privacy"}, + func(bc *youtube.LiveBroadcast) table.Row { + title := "" + status := "" + privacy := "" + if bc.Snippet != nil { + title = bc.Snippet.Title + } + if bc.Status != nil { + status = bc.Status.LifeCycleStatus + privacy = bc.Status.PrivacyStatus + } + return table.Row{bc.Id, title, status, privacy} + }, + ) + return err +} + +func (b *LiveBroadcast) Insert(writer io.Writer) error { + if err := b.EnsureService(); err != nil { + return err + } + broadcast := &youtube.LiveBroadcast{ + Snippet: &youtube.LiveBroadcastSnippet{ + Title: b.Title, + Description: b.Description, + ScheduledStartTime: b.ScheduledStartTime, + ScheduledEndTime: b.ScheduledEndTime, + }, + Status: &youtube.LiveBroadcastStatus{ + PrivacyStatus: b.PrivacyStatus, + }, + } + + call := b.Service.LiveBroadcasts.Insert(b.Parts, broadcast) + if b.OnBehalfOfContentOwner != "" { + call = call.OnBehalfOfContentOwner(b.OnBehalfOfContentOwner) + } + if b.OnBehalfOfContentOwnerChannel != "" { + call = call.OnBehalfOfContentOwnerChannel(b.OnBehalfOfContentOwnerChannel) + } + + res, err := call.Do() + if err != nil { + return errors.Join(errInsertLiveBroadcast, err) + } + + common.PrintResult( + b.Output, res, writer, "Live broadcast inserted: %s\n", res.Id, + ) + return nil +} + +func (b *LiveBroadcast) Update(writer io.Writer) error { + if err := b.EnsureService(); err != nil { + return err + } + b.Parts = []string{"id", "snippet", "status"} + broadcasts, err := b.Get() + if err != nil { + return errors.Join(errUpdateLiveBroadcast, err) + } + if len(broadcasts) == 0 { + return errGetLiveBroadcast + } + + original := broadcasts[0] + broadcast := &youtube.LiveBroadcast{ + Id: original.Id, + Snippet: &youtube.LiveBroadcastSnippet{ + Title: original.Snippet.Title, + Description: original.Snippet.Description, + ScheduledStartTime: original.Snippet.ScheduledStartTime, + ScheduledEndTime: original.Snippet.ScheduledEndTime, + }, + Status: &youtube.LiveBroadcastStatus{}, + } + if original.Status != nil { + broadcast.Status.PrivacyStatus = original.Status.PrivacyStatus + } + + if b.Title != "" { + broadcast.Snippet.Title = b.Title + } + if b.Description != "" { + broadcast.Snippet.Description = b.Description + } + if b.ScheduledStartTime != "" { + broadcast.Snippet.ScheduledStartTime = b.ScheduledStartTime + } + if b.ScheduledEndTime != "" { + broadcast.Snippet.ScheduledEndTime = b.ScheduledEndTime + } + if b.PrivacyStatus != "" { + broadcast.Status.PrivacyStatus = b.PrivacyStatus + } + + call := b.Service.LiveBroadcasts.Update( + []string{"snippet", "status"}, broadcast, + ) + if b.OnBehalfOfContentOwner != "" { + call = call.OnBehalfOfContentOwner(b.OnBehalfOfContentOwner) + } + if b.OnBehalfOfContentOwnerChannel != "" { + call = call.OnBehalfOfContentOwnerChannel(b.OnBehalfOfContentOwnerChannel) + } + + res, err := call.Do() + if err != nil { + return errors.Join(errUpdateLiveBroadcast, err) + } + + common.PrintResult( + b.Output, res, writer, "Live broadcast updated: %s\n", res.Id, + ) + return nil +} + +func (b *LiveBroadcast) Delete(writer io.Writer) error { + if err := b.EnsureService(); err != nil { + return err + } + for _, id := range b.Ids { + call := b.Service.LiveBroadcasts.Delete(id) + if b.OnBehalfOfContentOwner != "" { + call = call.OnBehalfOfContentOwner(b.OnBehalfOfContentOwner) + } + if b.OnBehalfOfContentOwnerChannel != "" { + call = call.OnBehalfOfContentOwnerChannel(b.OnBehalfOfContentOwnerChannel) + } + + err := call.Do() + if err != nil { + return errors.Join(errDeleteLiveBroadcast, err) + } + + _, _ = fmt.Fprintf(writer, "Live broadcast %s deleted\n", id) + } + return nil +} + +func (b *LiveBroadcast) Bind(writer io.Writer) error { + if err := b.EnsureService(); err != nil { + return err + } + for _, id := range b.Ids { + call := b.Service.LiveBroadcasts.Bind(id, b.Parts) + if b.StreamId != "" { + call = call.StreamId(b.StreamId) + } + if b.OnBehalfOfContentOwner != "" { + call = call.OnBehalfOfContentOwner(b.OnBehalfOfContentOwner) + } + if b.OnBehalfOfContentOwnerChannel != "" { + call = call.OnBehalfOfContentOwnerChannel(b.OnBehalfOfContentOwnerChannel) + } + + res, err := call.Do() + if err != nil { + return errors.Join(errBindLiveBroadcast, err) + } + + common.PrintResult( + b.Output, res, writer, "Live broadcast %s bound to stream %s\n", res.Id, + b.StreamId, + ) + } + return nil +} + +func (b *LiveBroadcast) Transition(writer io.Writer) error { + if err := b.EnsureService(); err != nil { + return err + } + for _, id := range b.Ids { + call := b.Service.LiveBroadcasts.Transition(b.BroadcastStatus, id, b.Parts) + if b.OnBehalfOfContentOwner != "" { + call = call.OnBehalfOfContentOwner(b.OnBehalfOfContentOwner) + } + if b.OnBehalfOfContentOwnerChannel != "" { + call = call.OnBehalfOfContentOwnerChannel(b.OnBehalfOfContentOwnerChannel) + } + + res, err := call.Do() + if err != nil { + return errors.Join(errTransitionLiveBroadcast, err) + } + + common.PrintResult( + b.Output, res, writer, + "Live broadcast %s transitioned to %s\n", id, b.BroadcastStatus, + ) + } + return nil +} + +func (b *LiveBroadcast) InsertCuepoint(writer io.Writer) error { + if err := b.EnsureService(); err != nil { + return err + } + cuepoint := &youtube.Cuepoint{ + CueType: b.CueType, + DurationSecs: b.CueDurationSecs, + InsertionOffsetTimeMs: b.CueInsertionOffsetMs, + WalltimeMs: b.CueWalltimeMs, + } + + for _, id := range b.Ids { + call := b.Service.LiveBroadcasts.InsertCuepoint(cuepoint).Id(id) + if b.OnBehalfOfContentOwner != "" { + call = call.OnBehalfOfContentOwner(b.OnBehalfOfContentOwner) + } + if b.OnBehalfOfContentOwnerChannel != "" { + call = call.OnBehalfOfContentOwnerChannel(b.OnBehalfOfContentOwnerChannel) + } + + res, err := call.Do() + if err != nil { + return errors.Join(errInsertCuepointLiveBroadcast, err) + } + + common.PrintResult( + b.Output, res, writer, "Cuepoint inserted for broadcast %s: %s\n", id, + res.Id, + ) + } + return nil +} + +func WithTitle(title string) Option { + return func(b *LiveBroadcast) { + b.Title = title + } +} + +func WithDescription(description string) Option { + return func(b *LiveBroadcast) { + b.Description = description + } +} + +func WithMine(mine *bool) Option { + return func(b *LiveBroadcast) { + if mine != nil { + b.Mine = mine + } + } +} + +func WithBroadcastStatus(broadcastStatus string) Option { + return func(b *LiveBroadcast) { + b.BroadcastStatus = broadcastStatus + } +} + +func WithBroadcastType(broadcastType string) Option { + return func(b *LiveBroadcast) { + b.BroadcastType = broadcastType + } +} + +func WithPrivacyStatus(privacyStatus string) Option { + return func(b *LiveBroadcast) { + b.PrivacyStatus = privacyStatus + } +} + +func WithScheduledStartTime(scheduledStartTime string) Option { + return func(b *LiveBroadcast) { + b.ScheduledStartTime = scheduledStartTime + } +} + +func WithScheduledEndTime(scheduledEndTime string) Option { + return func(b *LiveBroadcast) { + b.ScheduledEndTime = scheduledEndTime + } +} + +func WithStreamId(streamId string) Option { + return func(b *LiveBroadcast) { + b.StreamId = streamId + } +} + +func WithCueType(cueType string) Option { + return func(b *LiveBroadcast) { + b.CueType = cueType + } +} + +func WithCueDurationSecs(durationSecs int64) Option { + return func(b *LiveBroadcast) { + b.CueDurationSecs = durationSecs + } +} + +func WithCueInsertionOffsetMs(offsetMs int64) Option { + return func(b *LiveBroadcast) { + b.CueInsertionOffsetMs = offsetMs + } +} + +func WithCueWalltimeMs(walltimeMs uint64) Option { + return func(b *LiveBroadcast) { + b.CueWalltimeMs = walltimeMs + } +} + +func WithOnBehalfOfContentOwnerChannel(channel string) Option { + return func(b *LiveBroadcast) { + b.OnBehalfOfContentOwnerChannel = channel + } +} + +var ( + WithMaxResults = common.WithMaxResults[*LiveBroadcast] + WithParts = common.WithParts[*LiveBroadcast] + WithOutput = common.WithOutput[*LiveBroadcast] + WithService = common.WithService[*LiveBroadcast] + WithIds = common.WithIds[*LiveBroadcast] + + WithOnBehalfOfContentOwner = common.WithOnBehalfOfContentOwner[*LiveBroadcast] +) diff --git a/pkg/liveBroadcast/liveBroadcast_test.go b/pkg/liveBroadcast/liveBroadcast_test.go new file mode 100644 index 0000000..b05d95e --- /dev/null +++ b/pkg/liveBroadcast/liveBroadcast_test.go @@ -0,0 +1,724 @@ +// Copyright 2026 eat-pray-ai & OpenWaygate +// SPDX-License-Identifier: Apache-2.0 + +package liveBroadcast + +import ( + "bytes" + "encoding/json" + "io" + "math" + "net/http" + "reflect" + "testing" + + "github.com/eat-pray-ai/yutu/pkg/common" + "google.golang.org/api/youtube/v3" +) + +func TestNewLiveBroadcast(t *testing.T) { + type args struct { + opts []Option + } + + svc := &youtube.Service{} + mine := true + + tests := []struct { + name string + args args + want ILiveBroadcast[youtube.LiveBroadcast] + }{ + { + name: "with all options", + args: args{ + opts: []Option{ + WithIds([]string{"broadcast1", "broadcast2"}), + WithTitle("Test Broadcast"), + WithDescription("A test broadcast"), + WithMine(&mine), + WithBroadcastStatus("active"), + WithBroadcastType("event"), + WithPrivacyStatus("public"), + WithScheduledStartTime("2026-01-01T00:00:00Z"), + WithScheduledEndTime("2026-01-01T01:00:00Z"), + WithStreamId("stream123"), + WithCueType("cueTypeAd"), + WithCueDurationSecs(30), + WithCueInsertionOffsetMs(5000), + WithCueWalltimeMs(1234567890), + WithMaxResults(50), + WithParts([]string{"snippet", "status", "contentDetails"}), + WithOutput("json"), + WithService(svc), + WithOnBehalfOfContentOwner("owner123"), + WithOnBehalfOfContentOwnerChannel("channel123"), + }, + }, + want: &LiveBroadcast{ + Fields: common.Fields{ + Service: svc, + Parts: []string{"snippet", "status", "contentDetails"}, + Output: "json", + MaxResults: 50, + Ids: []string{"broadcast1", "broadcast2"}, + OnBehalfOfContentOwner: "owner123", + }, + Title: "Test Broadcast", + Description: "A test broadcast", + Mine: &mine, + BroadcastStatus: "active", + BroadcastType: "event", + PrivacyStatus: "public", + ScheduledStartTime: "2026-01-01T00:00:00Z", + ScheduledEndTime: "2026-01-01T01:00:00Z", + StreamId: "stream123", + CueType: "cueTypeAd", + CueDurationSecs: 30, + CueInsertionOffsetMs: 5000, + CueWalltimeMs: 1234567890, + OnBehalfOfContentOwnerChannel: "channel123", + }, + }, + { + name: "with no options", + args: args{ + opts: []Option{}, + }, + want: &LiveBroadcast{Fields: common.Fields{}}, + }, + { + name: "with zero max results", + args: args{ + opts: []Option{ + WithMaxResults(0), + }, + }, + want: &LiveBroadcast{ + Fields: common.Fields{MaxResults: math.MaxInt64}, + }, + }, + { + name: "with negative max results", + args: args{ + opts: []Option{ + WithMaxResults(-10), + }, + }, + want: &LiveBroadcast{ + Fields: common.Fields{MaxResults: 1}, + }, + }, + { + name: "with empty string values", + args: args{ + opts: []Option{ + WithTitle(""), + WithDescription(""), + WithBroadcastStatus(""), + WithBroadcastType(""), + WithPrivacyStatus(""), + WithScheduledStartTime(""), + WithScheduledEndTime(""), + WithStreamId(""), + }, + }, + want: &LiveBroadcast{ + Fields: common.Fields{}, + Title: "", + Description: "", + BroadcastStatus: "", + BroadcastType: "", + PrivacyStatus: "", + ScheduledStartTime: "", + ScheduledEndTime: "", + StreamId: "", + }, + }, + { + name: "with partial options", + args: args{ + opts: []Option{ + WithTitle("My Broadcast"), + WithPrivacyStatus("private"), + WithMaxResults(25), + WithParts([]string{"snippet"}), + }, + }, + want: &LiveBroadcast{ + Fields: common.Fields{ + Parts: []string{"snippet"}, + MaxResults: 25, + }, + Title: "My Broadcast", + PrivacyStatus: "private", + }, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + if got := NewLiveBroadcast(tt.args.opts...); !reflect.DeepEqual( + got, tt.want, + ) { + t.Errorf("%s\nNewLiveBroadcast() = %v\nwant %v", tt.name, got, tt.want) + } + }, + ) + } +} + +func TestLiveBroadcast_Get(t *testing.T) { + tests := []struct { + name string + opts []Option + verify func(*http.Request) + wantLen int + wantErr bool + }{ + { + name: "get live broadcasts by id", + opts: []Option{ + WithIds([]string{"broadcast-1"}), + WithMaxResults(1), + }, + verify: func(r *http.Request) { + q := r.URL.Query() + if q.Get("id") != "broadcast-1" { + t.Errorf( + "expected id=broadcast-1, got %s", + q.Get("id"), + ) + } + }, + wantLen: 1, + wantErr: false, + }, + { + name: "get live broadcasts with mine", + opts: func() []Option { + mine := true + return []Option{ + WithMine(&mine), + WithMaxResults(1), + } + }(), + verify: func(r *http.Request) { + q := r.URL.Query() + if q.Get("mine") != "true" { + t.Errorf( + "expected mine=true, got %s", + q.Get("mine"), + ) + } + }, + wantLen: 1, + wantErr: false, + }, + { + name: "get live broadcasts with status filter", + opts: []Option{ + WithBroadcastStatus("active"), + WithMaxResults(1), + }, + verify: func(r *http.Request) { + q := r.URL.Query() + if q.Get("broadcastStatus") != "active" { + t.Errorf( + "expected broadcastStatus=active, got %s", + q.Get("broadcastStatus"), + ) + } + }, + wantLen: 1, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + svc := common.NewTestService( + t, http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if tt.verify != nil { + tt.verify(r) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write( + []byte(`{ + "items": [ + { + "id": "broadcast-1", + "snippet": { + "title": "Test Broadcast", + "description": "A test broadcast" + }, + "status": { + "lifeCycleStatus": "ready", + "privacyStatus": "public" + } + } + ] + }`), + ) + }, + ), + ) + + opts := append([]Option{WithService(svc)}, tt.opts...) + b := NewLiveBroadcast(opts...) + got, err := b.Get() + if (err != nil) != tt.wantErr { + t.Errorf("LiveBroadcast.Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != tt.wantLen { + t.Errorf( + "LiveBroadcast.Get() got length = %v, want %v", len(got), tt.wantLen, + ) + } + }, + ) + } +} + +func TestLiveBroadcast_Get_Pagination(t *testing.T) { + svc := common.NewTestService(t, common.PaginationHandler("broadcast")) + + mine := true + b := NewLiveBroadcast( + WithService(svc), + WithMine(&mine), + WithMaxResults(22), + ) + got, err := b.Get() + if err != nil { + t.Errorf("LiveBroadcast.Get() error = %v", err) + } + if len(got) != 22 { + t.Errorf("LiveBroadcast.Get() got length = %v, want 22", len(got)) + } +} + +func TestLiveBroadcast_List(t *testing.T) { + mockResponse := `{ + "items": [ + { + "id": "broadcast-1", + "snippet": { + "title": "Test Broadcast", + "description": "A test broadcast" + }, + "status": { + "lifeCycleStatus": "ready", + "privacyStatus": "public" + } + } + ] + }` + + mine := true + common.RunListTest( + t, mockResponse, + func(svc *youtube.Service, output string) func(io.Writer) error { + b := NewLiveBroadcast( + WithService(svc), + WithMine(&mine), + WithOutput(output), + WithMaxResults(1), + ) + return b.List + }, + ) +} + +func TestLiveBroadcast_Insert(t *testing.T) { + tests := []struct { + name string + opts []Option + verify func(*http.Request) + wantErr bool + }{ + { + name: "insert live broadcast", + opts: []Option{ + WithTitle("New Broadcast"), + WithDescription("A new live broadcast"), + WithPrivacyStatus("public"), + WithScheduledStartTime("2026-01-01T00:00:00Z"), + WithScheduledEndTime("2026-01-01T01:00:00Z"), + WithParts([]string{"snippet", "status"}), + }, + verify: func(r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + + defer func() { _ = r.Body.Close() }() + var body struct { + Snippet struct { + Title string `json:"title"` + Description string `json:"description"` + ScheduledStartTime string `json:"scheduledStartTime"` + ScheduledEndTime string `json:"scheduledEndTime"` + } `json:"snippet"` + Status struct { + PrivacyStatus string `json:"privacyStatus"` + } `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + + if body.Snippet.Title != "New Broadcast" { + t.Errorf( + "expected snippet.title=New Broadcast, got %s", body.Snippet.Title, + ) + } + if body.Snippet.Description != "A new live broadcast" { + t.Errorf( + "expected snippet.description=A new live broadcast, got %s", + body.Snippet.Description, + ) + } + if body.Status.PrivacyStatus != "public" { + t.Errorf( + "expected status.privacyStatus=public, got %s", + body.Status.PrivacyStatus, + ) + } + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + svc := common.NewTestService( + t, http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if tt.verify != nil { + tt.verify(r) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": "new-broadcast-id"}`)) + }, + ), + ) + + opts := append([]Option{WithService(svc)}, tt.opts...) + b := NewLiveBroadcast(opts...) + var buf bytes.Buffer + if err := b.Insert(&buf); (err != nil) != tt.wantErr { + t.Errorf( + "LiveBroadcast.Insert() error = %v, wantErr %v", err, tt.wantErr, + ) + } + }, + ) + } +} + +func TestLiveBroadcast_Update(t *testing.T) { + tests := []struct { + name string + opts []Option + wantErr bool + }{ + { + name: "update live broadcast", + opts: []Option{ + WithIds([]string{"broadcast-1"}), + WithTitle("Updated Broadcast"), + WithDescription("Updated description"), + WithMaxResults(1), + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + callCount := 0 + svc := common.NewTestService( + t, http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + callCount++ + if callCount == 1 { + _, _ = w.Write( + []byte(`{ + "items": [{ + "id": "broadcast-1", + "snippet": { + "title": "Old Broadcast", + "description": "Old description", + "scheduledStartTime": "2026-01-01T00:00:00Z", + "scheduledEndTime": "2026-01-01T01:00:00Z" + }, + "status": {"privacyStatus": "public", "lifeCycleStatus": "ready"} + }] + }`), + ) + } else { + _, _ = w.Write([]byte(`{"id": "broadcast-1"}`)) + } + }, + ), + ) + + opts := append([]Option{WithService(svc)}, tt.opts...) + b := NewLiveBroadcast(opts...) + var buf bytes.Buffer + if err := b.Update(&buf); (err != nil) != tt.wantErr { + t.Errorf( + "LiveBroadcast.Update() error = %v, wantErr %v", err, tt.wantErr, + ) + } + }, + ) + } +} + +func TestLiveBroadcast_Delete(t *testing.T) { + tests := []struct { + name string + opts []Option + verify func(*http.Request) + wantErr bool + }{ + { + name: "delete live broadcast", + opts: []Option{ + WithIds([]string{"broadcast-id"}), + }, + verify: func(r *http.Request) { + if r.Method != "DELETE" { + t.Errorf("expected DELETE, got %s", r.Method) + } + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + svc := common.NewTestService( + t, http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if tt.verify != nil { + tt.verify(r) + } + w.WriteHeader(http.StatusNoContent) + }, + ), + ) + + opts := append([]Option{WithService(svc)}, tt.opts...) + b := NewLiveBroadcast(opts...) + var buf bytes.Buffer + if err := b.Delete(&buf); (err != nil) != tt.wantErr { + t.Errorf( + "LiveBroadcast.Delete() error = %v, wantErr %v", err, tt.wantErr, + ) + } + }, + ) + } +} + +func TestLiveBroadcast_Bind(t *testing.T) { + tests := []struct { + name string + opts []Option + verify func(*http.Request) + wantErr bool + }{ + { + name: "bind live broadcast to stream", + opts: []Option{ + WithIds([]string{"broadcast-id"}), + WithStreamId("stream-id"), + WithParts([]string{"id", "snippet", "contentDetails", "status"}), + }, + verify: func(r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + q := r.URL.Query() + if q.Get("id") != "broadcast-id" { + t.Errorf("expected id=broadcast-id, got %s", q.Get("id")) + } + if q.Get("streamId") != "stream-id" { + t.Errorf("expected streamId=stream-id, got %s", q.Get("streamId")) + } + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + svc := common.NewTestService( + t, http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if tt.verify != nil { + tt.verify(r) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": "broadcast-id"}`)) + }, + ), + ) + + opts := append([]Option{WithService(svc)}, tt.opts...) + b := NewLiveBroadcast(opts...) + var buf bytes.Buffer + if err := b.Bind(&buf); (err != nil) != tt.wantErr { + t.Errorf( + "LiveBroadcast.Bind() error = %v, wantErr %v", err, tt.wantErr, + ) + } + }, + ) + } +} + +func TestLiveBroadcast_Transition(t *testing.T) { + tests := []struct { + name string + opts []Option + verify func(*http.Request) + wantErr bool + }{ + { + name: "transition live broadcast", + opts: []Option{ + WithIds([]string{"broadcast-id"}), + WithBroadcastStatus("live"), + WithParts([]string{"id", "snippet", "status"}), + }, + verify: func(r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + q := r.URL.Query() + if q.Get("id") != "broadcast-id" { + t.Errorf("expected id=broadcast-id, got %s", q.Get("id")) + } + if q.Get("broadcastStatus") != "live" { + t.Errorf( + "expected broadcastStatus=live, got %s", + q.Get("broadcastStatus"), + ) + } + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + svc := common.NewTestService( + t, http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if tt.verify != nil { + tt.verify(r) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": "broadcast-id"}`)) + }, + ), + ) + + opts := append([]Option{WithService(svc)}, tt.opts...) + b := NewLiveBroadcast(opts...) + var buf bytes.Buffer + if err := b.Transition(&buf); (err != nil) != tt.wantErr { + t.Errorf( + "LiveBroadcast.Transition() error = %v, wantErr %v", err, tt.wantErr, + ) + } + }, + ) + } +} + +func TestLiveBroadcast_InsertCuepoint(t *testing.T) { + tests := []struct { + name string + opts []Option + verify func(*http.Request) + wantErr bool + }{ + { + name: "insert cuepoint", + opts: []Option{ + WithIds([]string{"broadcast-id"}), + WithCueType("cueTypeAd"), + WithCueDurationSecs(30), + WithCueInsertionOffsetMs(5000), + }, + verify: func(r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + q := r.URL.Query() + if q.Get("id") != "broadcast-id" { + t.Errorf("expected id=broadcast-id, got %s", q.Get("id")) + } + + defer func() { _ = r.Body.Close() }() + var body struct { + CueType string `json:"cueType"` + DurationSecs int64 `json:"durationSecs"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + if body.CueType != "cueTypeAd" { + t.Errorf("expected cueType=cueTypeAd, got %s", body.CueType) + } + if body.DurationSecs != 30 { + t.Errorf("expected durationSecs=30, got %d", body.DurationSecs) + } + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + svc := common.NewTestService( + t, http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if tt.verify != nil { + tt.verify(r) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": "cuepoint-id"}`)) + }, + ), + ) + + opts := append([]Option{WithService(svc)}, tt.opts...) + b := NewLiveBroadcast(opts...) + var buf bytes.Buffer + if err := b.InsertCuepoint(&buf); (err != nil) != tt.wantErr { + t.Errorf( + "LiveBroadcast.InsertCuepoint() error = %v, wantErr %v", err, + tt.wantErr, + ) + } + }, + ) + } +}