From 49e092ea2ea768b6d00bfe06265ad578709e6cfc Mon Sep 17 00:00:00 2001 From: OpenWaygate <27287694+OpenWaygate@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:47:16 +0800 Subject: [PATCH] :bug: Preserve omitted video metadata --- cmd/video/BUILD.bazel | 1 - cmd/video/insert.go | 32 +-- cmd/video/reportAbuse.go | 2 +- cmd/video/update.go | 12 +- cmd/video/video.go | 40 ++-- pkg/video/video.go | 87 +++++--- pkg/video/video_test.go | 426 +++++++++++++++------------------------ 7 files changed, 262 insertions(+), 338 deletions(-) diff --git a/cmd/video/BUILD.bazel b/cmd/video/BUILD.bazel index f0af96b..1386826 100644 --- a/cmd/video/BUILD.bazel +++ b/cmd/video/BUILD.bazel @@ -23,6 +23,5 @@ go_library( "@com_github_google_jsonschema_go//jsonschema", "@com_github_modelcontextprotocol_go_sdk//mcp", "@com_github_spf13_cobra//:cobra", - "@com_github_spf13_pflag//:pflag", ], ) diff --git a/cmd/video/insert.go b/cmd/video/insert.go index 956f50f..4764419 100644 --- a/cmd/video/insert.go +++ b/cmd/video/insert.go @@ -47,7 +47,7 @@ var insertInSchema = &jsonschema.Schema{ "language": {Type: "string", Description: insertLangUsage}, "license": { Type: "string", Enum: []any{"youtube", "creativeCommon"}, - Description: licenseUsage, Default: jsontext.Value(`"youtube"`), + Description: licenseUsage, }, "thumbnail": {Type: "string", Description: thumbnailUsage}, "channel_id": {Type: "string", Description: chidUsage}, @@ -57,14 +57,18 @@ var insertInSchema = &jsonschema.Schema{ Type: "string", Description: privacyUsage, Enum: []any{"public", "private", "unlisted"}, }, - "for_kids": {Type: "boolean", Description: fkUsage}, - "embeddable": {Type: "boolean", Description: embeddableUsage}, - "contains_synthetic_media": {Type: "boolean", Description: csmUsage}, - "recording_date": {Type: "string", Description: rdUsage}, - "publish_at": {Type: "string", Description: paUsage}, - "stabilize": {Type: "boolean", Description: stabilizeUsage}, - "notify_subscribers": {Type: "boolean", Description: nsUsage}, - "public_stats_viewable": {Type: "boolean", Description: psvUsage}, + "for_kids": { + Type: "boolean", Description: fkUsage, Default: jsontext.Value("false"), + }, + "contains_synthetic_media": { + Type: "boolean", Description: csmUsage, Default: jsontext.Value("false"), + }, + "embeddable": {Type: "boolean", Description: embeddableUsage}, + "recording_date": {Type: "string", Description: rdUsage}, + "publish_at": {Type: "string", Description: paUsage}, + "stabilize": {Type: "boolean", Description: stabilizeUsage}, + "notify_subscribers": {Type: "boolean", Description: nsUsage}, + "public_stats_viewable": {Type: "boolean", Description: psvUsage}, "on_behalf_of_content_owner": { Type: "string", Description: pkg.OBOCOUsage, @@ -108,10 +112,10 @@ func init() { ) insertCmd.Flags().StringVarP(&file, "file", "f", "", fileUsage) insertCmd.Flags().StringVarP(&title, "title", "t", "", titleUsage) - insertCmd.Flags().StringVarP(&description, "description", "d", "", descUsage) + insertCmd.Flags().StringVarP(description, "description", "d", "", descUsage) insertCmd.Flags().StringSliceVarP(&tags, "tags", "a", []string{}, tagsUsage) - insertCmd.Flags().StringVarP(&language, "language", "l", "", insertLangUsage) - insertCmd.Flags().StringVarP(&license, "license", "L", "youtube", licenseUsage) + insertCmd.Flags().StringVarP(language, "language", "l", "", insertLangUsage) + insertCmd.Flags().StringVarP(&license, "license", "L", "", licenseUsage) insertCmd.Flags().StringVarP(&thumbnail, "thumbnail", "u", "", thumbnailUsage) insertCmd.Flags().StringVarP(&channelId, "channelId", "c", "", chidUsage) insertCmd.Flags().StringVarP(&playListId, "playlistId", "y", "", pidUsage) @@ -124,8 +128,8 @@ func init() { insertCmd.Flags().BoolVarP( containsSyntheticMedia, "containsSyntheticMedia", "M", false, csmUsage, ) - insertCmd.Flags().StringVarP(&recordingDate, "recordingDate", "D", "", rdUsage) - insertCmd.Flags().StringVarP(&publishAt, "publishAt", "U", "", paUsage) + insertCmd.Flags().StringVarP(recordingDate, "recordingDate", "D", "", rdUsage) + insertCmd.Flags().StringVarP(publishAt, "publishAt", "U", "", paUsage) insertCmd.Flags().BoolVarP(stabilize, "stabilize", "S", true, stabilizeUsage) insertCmd.Flags().BoolVarP( notifySubscribers, "notifySubscribers", "N", true, nsUsage, diff --git a/cmd/video/reportAbuse.go b/cmd/video/reportAbuse.go index 3eb3460..a854c45 100644 --- a/cmd/video/reportAbuse.go +++ b/cmd/video/reportAbuse.go @@ -83,7 +83,7 @@ func init() { reportAbuseCmd.Flags().StringVarP( &comments, "comments", "c", "", commentsUsage, ) - reportAbuseCmd.Flags().StringVarP(&language, "language", "l", "", raLangUsage) + reportAbuseCmd.Flags().StringVarP(language, "language", "l", "", raLangUsage) reportAbuseCmd.Flags().StringVarP( &onBehalfOfContentOwner, "onBehalfOfContentOwner", "b", "", pkg.OBOCOUsage, ) diff --git a/cmd/video/update.go b/cmd/video/update.go index 6389f60..65ece6d 100644 --- a/cmd/video/update.go +++ b/cmd/video/update.go @@ -53,7 +53,7 @@ var updateInSchema = &jsonschema.Schema{ "language": {Type: "string", Description: updateLangUsage}, "license": { Type: "string", Enum: []any{"youtube", "creativeCommon"}, - Description: licenseUsage, Default: jsontext.Value(`"youtube"`), + Description: licenseUsage, }, "thumbnail": {Type: "string", Description: thumbnailUsage}, "playlist_id": {Type: "string", Description: pidUsage}, @@ -99,22 +99,22 @@ func init() { updateCmd.Flags().StringSliceVarP(&ids, "id", "i", []string{}, updateIdUsage) updateCmd.Flags().StringVarP(&title, "title", "t", "", titleUsage) - updateCmd.Flags().StringVarP(&description, "description", "d", "", descUsage) + updateCmd.Flags().StringVarP(description, "description", "d", "", descUsage) updateCmd.Flags().StringSliceVarP(&tags, "tags", "a", []string{}, tagsUsage) - updateCmd.Flags().StringVarP(&language, "language", "l", "", updateLangUsage) - updateCmd.Flags().StringVarP(&license, "license", "L", "youtube", licenseUsage) + updateCmd.Flags().StringVarP(language, "language", "l", "", updateLangUsage) + updateCmd.Flags().StringVarP(&license, "license", "L", "", licenseUsage) updateCmd.Flags().StringVarP(&thumbnail, "thumbnail", "u", "", thumbnailUsage) updateCmd.Flags().StringVarP(&playListId, "playlistId", "y", "", pidUsage) updateCmd.Flags().StringVarP(&categoryId, "categoryId", "g", "", caidUsage) updateCmd.Flags().StringVarP(&privacy, "privacy", "p", "", privacyUsage) - updateCmd.Flags().StringVarP(&publishAt, "publishAt", "U", "", paUsage) + updateCmd.Flags().StringVarP(publishAt, "publishAt", "U", "", paUsage) updateCmd.Flags().BoolVarP( embeddable, "embeddable", "E", true, embeddableUsage, ) updateCmd.Flags().BoolVarP( containsSyntheticMedia, "containsSyntheticMedia", "M", false, csmUsage, ) - updateCmd.Flags().StringVarP(&recordingDate, "recordingDate", "D", "", rdUsage) + updateCmd.Flags().StringVarP(recordingDate, "recordingDate", "D", "", rdUsage) updateCmd.Flags().StringP("output", "o", "", pkg.SilentUsage) updateCmd.Flags().Bool("yes", false, pkg.ConfirmedUsage) _ = updateCmd.MarkFlagRequired("id") diff --git a/cmd/video/video.go b/cmd/video/video.go index 434bbbc..664cd77 100644 --- a/cmd/video/video.go +++ b/cmd/video/video.go @@ -6,7 +6,6 @@ package video import ( "github.com/eat-pray-ai/yutu/cmd" "github.com/eat-pray-ai/yutu/pkg/utils" - "github.com/spf13/pflag" "github.com/spf13/cobra" ) @@ -49,10 +48,10 @@ var ( autoLevels = new(false) file string title string - description string + description = new("") hl string tags []string - language string + language = new("") locale string license string thumbnail string @@ -65,8 +64,8 @@ var ( privacy string forKids = new(false) embeddable = new(false) - recordingDate string - publishAt string + recordingDate = new("") + publishAt = new("") regionCode string reasonId string secondaryReasonId string @@ -88,7 +87,7 @@ var videoCmd = &cobra.Command{ Short: short, Long: long, PersistentPreRun: func(cmd *cobra.Command, _ []string) { - resetFlags(cmd.Flags()) + resetFlags(cmd) }, Run: func(cmd *cobra.Command, _ []string) { _ = cmd.Help() @@ -99,16 +98,27 @@ func init() { cmd.RootCmd.AddCommand(videoCmd) } -func resetFlags(flagSet *pflag.FlagSet) { +func resetFlags(cmd *cobra.Command) { boolMap := map[string]**bool{ - "autoLevels": &autoLevels, - "forKids": &forKids, - "embeddable": &embeddable, - "containsSyntheticMedia": &containsSyntheticMedia, - "stabilize": &stabilize, - "notifySubscribers": ¬ifySubscribers, - "publicStatsViewable": &publicStatsViewable, + "autoLevels": &autoLevels, + "embeddable": &embeddable, + "stabilize": &stabilize, + "notifySubscribers": ¬ifySubscribers, + "publicStatsViewable": &publicStatsViewable, + } + stringFlag := map[string]**string{ + "description": &description, + "language": &language, + "publishAt": &publishAt, + "recordingDate": &recordingDate, } - utils.ResetBool(boolMap, flagSet) + if cmd.Name() == "update" { + boolMap["containsSyntheticMedia"] = &containsSyntheticMedia + } + + flagSet := cmd.Flags() + utils.ResetFlags(boolMap, flagSet) + utils.ResetFlags(stringFlag, flagSet) + utils.ResetFlags(map[string]*[]string{"tags": &tags}, flagSet) } diff --git a/pkg/video/video.go b/pkg/video/video.go index 84d64c6..72644f5 100644 --- a/pkg/video/video.go +++ b/pkg/video/video.go @@ -38,9 +38,9 @@ type Video struct { AutoLevels *bool `yaml:"auto_levels" json:"auto_levels,omitzero"` File string `yaml:"file" json:"file,omitempty"` Title string `yaml:"title" json:"title,omitempty"` - Description string `yaml:"description" json:"description,omitempty"` + Description *string `yaml:"description" json:"description,omitzero"` Tags []string `yaml:"tags" json:"tags,omitempty"` - Language string `yaml:"language" json:"language,omitempty"` + Language *string `yaml:"language" json:"language,omitzero"` Locale string `yaml:"locale" json:"locale,omitempty"` License string `yaml:"license" json:"license,omitempty"` Thumbnail string `yaml:"thumbnail" json:"thumbnail,omitempty"` @@ -52,18 +52,19 @@ type Video struct { Privacy string `yaml:"privacy" json:"privacy,omitempty"` ForKids *bool `yaml:"for_kids" json:"for_kids,omitzero"` Embeddable *bool `yaml:"embeddable" json:"embeddable,omitzero"` - PublishAt string `yaml:"publish_at" json:"publish_at,omitempty"` + PublishAt *string `yaml:"publish_at" json:"publish_at,omitzero"` RegionCode string `yaml:"region_code" json:"region_code,omitempty"` ReasonId string `yaml:"reason_id" json:"reason_id,omitempty"` Stabilize *bool `yaml:"stabilize" json:"stabilize,omitzero"` MaxHeight int64 `yaml:"max_height" json:"max_height,omitzero"` MaxWidth int64 `yaml:"max_width" json:"max_width,omitzero"` - RecordingDate string `yaml:"recording_date" json:"recording_date,omitempty"` - ContainsSyntheticMedia *bool `yaml:"contains_synthetic_media" json:"contains_synthetic_media,omitzero"` - SecondaryReasonId string `yaml:"secondary_reason_id" json:"secondary_reason_id,omitempty"` - NotifySubscribers *bool `yaml:"notify_subscribers" json:"notify_subscribers,omitzero"` - PublicStatsViewable *bool `yaml:"public_stats_viewable" json:"public_stats_viewable,omitzero"` + RecordingDate *string `yaml:"recording_date" json:"recording_date,omitzero"` + ContainsSyntheticMedia *bool `yaml:"contains_synthetic_media" json:"contains_synthetic_media,omitzero"` + SecondaryReasonId string `yaml:"secondary_reason_id" json:"secondary_reason_id,omitempty"` + NotifySubscribers *bool `yaml:"notify_subscribers" json:"notify_subscribers,omitzero"` + PublicStatsViewable *bool `yaml:"public_stats_viewable" json:"public_stats_viewable,omitzero"` + OnBehalfOfContentOwnerChannel string `yaml:"on_behalf_of_content_owner_channel" json:"on_behalf_of_content_owner_channel,omitempty"` } @@ -179,24 +180,33 @@ func (v *Video) Insert(writer io.Writer) error { video := &youtube.Video{ Snippet: &youtube.VideoSnippet{ - Title: v.Title, - Description: v.Description, - Tags: v.Tags, - CategoryId: v.CategoryId, - ChannelId: v.ChannelId, - DefaultLanguage: v.Language, - DefaultAudioLanguage: v.Language, + Title: v.Title, + Tags: v.Tags, + CategoryId: v.CategoryId, + ChannelId: v.ChannelId, }, Status: &youtube.VideoStatus{ License: v.License, - PublishAt: v.PublishAt, PrivacyStatus: v.Privacy, ForceSendFields: []string{"SelfDeclaredMadeForKids", "ContainsSyntheticMedia"}, }, } + if v.Description != nil { + video.Snippet.Description = *v.Description + } + if v.Language != nil { + video.Snippet.DefaultLanguage = *v.Language + video.Snippet.DefaultAudioLanguage = *v.Language + } + if v.PublishAt != nil { + video.Status.PublishAt = *v.PublishAt + } if v.Embeddable != nil { video.Status.Embeddable = *v.Embeddable + video.Status.ForceSendFields = append( + video.Status.ForceSendFields, "Embeddable", + ) } if v.ForKids != nil { video.Status.SelfDeclaredMadeForKids = *v.ForKids @@ -206,12 +216,15 @@ func (v *Video) Insert(writer io.Writer) error { } if v.PublicStatsViewable != nil { video.Status.PublicStatsViewable = *v.PublicStatsViewable + video.Status.ForceSendFields = append( + video.Status.ForceSendFields, "PublicStatsViewable", + ) } insertParts := "snippet,status" - if v.RecordingDate != "" { + if v.RecordingDate != nil && *v.RecordingDate != "" { video.RecordingDetails = &youtube.VideoRecordingDetails{ - RecordingDate: v.RecordingDate, + RecordingDate: *v.RecordingDate, } insertParts += ",recordingDetails" } @@ -269,6 +282,9 @@ func (v *Video) Insert(writer io.Writer) error { return nil } +// Update preserves nil Description, Language, PublishAt, and RecordingDate inputs. +// Non-nil empty strings clear those properties by omitting them from the updated +// resource part; timestamp clears must not be sent as empty timestamp strings. func (v *Video) Update(writer io.Writer) error { if err := v.EnsureService(); err != nil { return err @@ -291,7 +307,10 @@ func (v *Video) Update(writer io.Writer) error { video := &youtube.Video{ Id: original.Id, Snippet: &youtube.VideoSnippet{}, - Status: &youtube.VideoStatus{}, + Status: &youtube.VideoStatus{ + // Status updates replace existing values; omitempty must not drop false booleans. + ForceSendFields: []string{"Embeddable", "PublicStatsViewable", "SelfDeclaredMadeForKids", "ContainsSyntheticMedia"}, + }, } if original.Snippet != nil { video.Snippet.Title = original.Snippet.Title @@ -313,8 +332,8 @@ func (v *Video) Update(writer io.Writer) error { if v.Title != "" { video.Snippet.Title = v.Title } - if v.Description != "" { - video.Snippet.Description = v.Description + if v.Description != nil { + video.Snippet.Description = *v.Description } if v.Tags != nil { if !slices.Contains(v.Tags, "yutu🐰") { @@ -322,8 +341,8 @@ func (v *Video) Update(writer io.Writer) error { } video.Snippet.Tags = v.Tags } - if v.Language != "" { - video.Snippet.DefaultLanguage = v.Language + if v.Language != nil { + video.Snippet.DefaultLanguage = *v.Language } if v.License != "" { video.Status.License = v.License @@ -334,13 +353,13 @@ func (v *Video) Update(writer io.Writer) error { if v.Privacy != "" { video.Status.PrivacyStatus = v.Privacy } - if v.PublishAt != "" { + if v.PublishAt != nil { // YouTube only accepts a schedule on private videos. Do not coerce // public/unlisted to private here: that would take a live video offline. - if video.Status.PrivacyStatus != "private" { + if *v.PublishAt != "" && video.Status.PrivacyStatus != "private" { return errors.Join(errUpdateVideo, errScheduleNonPrivate) } - video.Status.PublishAt = v.PublishAt + video.Status.PublishAt = *v.PublishAt } if v.Embeddable != nil { video.Status.Embeddable = *v.Embeddable @@ -350,9 +369,9 @@ func (v *Video) Update(writer io.Writer) error { } updateParts := "snippet,status" - if v.RecordingDate != "" { + if v.RecordingDate != nil { video.RecordingDetails = &youtube.VideoRecordingDetails{ - RecordingDate: v.RecordingDate, + RecordingDate: *v.RecordingDate, } updateParts += ",recordingDetails" } @@ -469,11 +488,13 @@ func (v *Video) ReportAbuse(writer io.Writer) error { for _, id := range v.Ids { videoAbuseReport := &youtube.VideoAbuseReport{ Comments: v.Comments, - Language: v.Language, ReasonId: v.ReasonId, SecondaryReasonId: v.SecondaryReasonId, VideoId: id, } + if v.Language != nil { + videoAbuseReport.Language = *v.Language + } call := v.Service.Videos.ReportAbuse(videoAbuseReport) if v.OnBehalfOfContentOwner != "" { @@ -510,7 +531,7 @@ func WithTitle(title string) Option { } } -func WithDescription(description string) Option { +func WithDescription(description *string) Option { return func(v *Video) { v.Description = description } @@ -522,7 +543,7 @@ func WithTags(tags []string) Option { } } -func WithLanguage(language string) Option { +func WithLanguage(language *string) Option { return func(v *Video) { v.Language = language } @@ -608,13 +629,13 @@ func WithPublicStatsViewable(publicStatsViewable *bool) Option { } } -func WithPublishAt(publishAt string) Option { +func WithPublishAt(publishAt *string) Option { return func(v *Video) { v.PublishAt = publishAt } } -func WithRecordingDate(recordingDate string) Option { +func WithRecordingDate(recordingDate *string) Option { return func(v *Video) { v.RecordingDate = recordingDate } diff --git a/pkg/video/video_test.go b/pkg/video/video_test.go index 3e976ff..e78b90e 100644 --- a/pkg/video/video_test.go +++ b/pkg/video/video_test.go @@ -7,12 +7,10 @@ import ( "bytes" "encoding/json/v2" "io" - "math" "mime" "mime/multipart" "net/http" "os" - "reflect" "strings" "testing" @@ -21,265 +19,78 @@ import ( "google.golang.org/api/youtube/v3" ) -func TestNewVideo(t *testing.T) { - type args struct { - opts []Option - } - - autoLevelsTrue := true - autoLevelsFalse := false - forKidsTrue := true - forKidsFalse := false - embeddableTrue := true - embeddableFalse := false - containsSyntheticMediaTrue := true - containsSyntheticMediaFalse := false - stabilizeTrue := true - stabilizeFalse := false - notifySubscribersTrue := true - notifySubscribersFalse := false - publicStatsViewableTrue := true - publicStatsViewableFalse := false - svc := &youtube.Service{} - - tests := []struct { - name string - args args - want IVideo[youtube.Video] +func TestVideo_UpdateClearableMetadata(t *testing.T) { + for _, tt := range []struct { + name string + input string + privacy string + wantDescription string + wantLanguage string + wantPublishAt string + wantRecordingDate string + recordingPart bool }{ - { - name: "with all options", - args: args{ - opts: []Option{ - WithIds([]string{"video1", "video2"}), - WithAutoLevels(&autoLevelsTrue), - WithFile("/path/to/video.mp4"), - WithTitle("Test Video"), - WithDescription("Test video description"), - WithHl("en"), - WithTags([]string{"tag1", "tag2", "tag3"}), - WithLanguage("en"), - WithLocale("en_US"), - WithLicense("youtube"), - WithThumbnail("/path/to/thumbnail.jpg"), - WithRating("like"), - WithChart("mostPopular"), - WithChannelId("channel123"), - WithComments("Test comments"), - WithPlaylistId("playlist123"), - WithCategory("22"), - WithPrivacy("public"), - WithForKids(&forKidsTrue), - WithEmbeddable(&embeddableTrue), - WithContainsSyntheticMedia(&containsSyntheticMediaTrue), - WithRecordingDate("2024-06-15T10:00:00Z"), - WithPublishAt("2024-12-31T23:59:59Z"), - WithRegionCode("US"), - WithReasonId("reason123"), - WithSecondaryReasonId("secondaryReason123"), - WithStabilize(&stabilizeTrue), - WithMaxHeight(1080), - WithMaxWidth(1920), - WithMaxResults(50), - WithNotifySubscribers(¬ifySubscribersTrue), - WithPublicStatsViewable(&publicStatsViewableTrue), - WithOnBehalfOfContentOwner("owner123"), - WithOnBehalfOfContentOwnerChannel("ownerChannel123"), - WithParts([]string{"snippet", "contentDetails"}), - WithOutput("json"), - WithService(svc), - }, - }, - want: &Video{ - Service: svc, - Parts: []string{"snippet", "contentDetails"}, - Output: "json", - Ids: []string{"video1", "video2"}, - MaxResults: 50, - Hl: "en", - ChannelId: "channel123", - OnBehalfOfContentOwner: "owner123", - AutoLevels: &autoLevelsTrue, - File: "/path/to/video.mp4", - Title: "Test Video", - Description: "Test video description", - Tags: []string{"tag1", "tag2", "tag3"}, - Language: "en", - Locale: "en_US", - License: "youtube", - Thumbnail: "/path/to/thumbnail.jpg", - Rating: "like", - Chart: "mostPopular", - Comments: "Test comments", - PlaylistId: "playlist123", - CategoryId: "22", - Privacy: "public", - ForKids: &forKidsTrue, - Embeddable: &embeddableTrue, - ContainsSyntheticMedia: &containsSyntheticMediaTrue, - RecordingDate: "2024-06-15T10:00:00Z", - PublishAt: "2024-12-31T23:59:59Z", - RegionCode: "US", - ReasonId: "reason123", - SecondaryReasonId: "secondaryReason123", - Stabilize: &stabilizeTrue, - MaxHeight: 1080, - MaxWidth: 1920, - NotifySubscribers: ¬ifySubscribersTrue, - PublicStatsViewable: &publicStatsViewableTrue, - OnBehalfOfContentOwnerChannel: "ownerChannel123", - }, - }, - { - name: "with no options", - args: args{ - opts: []Option{}, - }, - want: &Video{Fields: common.Fields{}}, - }, - { - name: "with nil boolean options", - args: args{ - opts: []Option{ - WithAutoLevels(nil), - WithForKids(nil), - WithEmbeddable(nil), - WithContainsSyntheticMedia(nil), - WithStabilize(nil), - WithNotifySubscribers(nil), - WithPublicStatsViewable(nil), - }, - }, - want: &Video{Fields: common.Fields{}}, - }, - { - name: "with false boolean options", - args: args{ - opts: []Option{ - WithAutoLevels(&autoLevelsFalse), - WithForKids(&forKidsFalse), - WithEmbeddable(&embeddableFalse), - WithContainsSyntheticMedia(&containsSyntheticMediaFalse), - WithStabilize(&stabilizeFalse), - WithNotifySubscribers(¬ifySubscribersFalse), - WithPublicStatsViewable(&publicStatsViewableFalse), - }, - }, - want: &Video{ - Fields: common.Fields{}, - AutoLevels: &autoLevelsFalse, - ForKids: &forKidsFalse, - Embeddable: &embeddableFalse, - ContainsSyntheticMedia: &containsSyntheticMediaFalse, - Stabilize: &stabilizeFalse, - NotifySubscribers: ¬ifySubscribersFalse, - PublicStatsViewable: &publicStatsViewableFalse, - }, - }, - { - name: "with zero max results", - args: args{ - opts: []Option{ - WithMaxResults(0), - }, - }, - want: &Video{ - MaxResults: math.MaxInt64, - }, - }, - { - name: "with negative max results", - args: args{ - opts: []Option{ - WithMaxResults(-10), - }, - }, - want: &Video{ - MaxResults: 1, - }, - }, - { - name: "with empty string values", - args: args{ - opts: []Option{ - WithFile(""), - WithTitle(""), - WithDescription(""), - WithHl(""), - WithLanguage(""), - WithLocale(""), - WithLicense(""), - WithThumbnail(""), - WithRating(""), - WithChart(""), - WithChannelId(""), - WithComments(""), - WithPlaylistId(""), - WithCategory(""), - WithPrivacy(""), - WithRecordingDate(""), - WithPublishAt(""), - WithRegionCode(""), - WithReasonId(""), - WithSecondaryReasonId(""), - WithOnBehalfOfContentOwner(""), - WithOnBehalfOfContentOwnerChannel(""), - }, - }, - want: &Video{ - Fields: common.Fields{}, - File: "", - Title: "", - Description: "", - Language: "", - Locale: "", - License: "", - Thumbnail: "", - Rating: "", - Chart: "", - Comments: "", - PlaylistId: "", - CategoryId: "", - Privacy: "", - RecordingDate: "", - PublishAt: "", - RegionCode: "", - ReasonId: "", - SecondaryReasonId: "", - OnBehalfOfContentOwnerChannel: "", - }, - }, - { - name: "with partial options", - args: args{ - opts: []Option{ - WithTitle("My Video"), - WithDescription("A great video"), - WithTags([]string{"tutorial", "golang"}), - WithPrivacy("private"), - WithMaxResults(25), - WithForKids(&forKidsFalse), - }, - }, - want: &Video{ - MaxResults: 25, - Title: "My Video", - Description: "A great video", - Tags: []string{"tutorial", "golang"}, - Privacy: "private", - ForKids: &forKidsFalse, - }, - }, - } - - for _, tt := range tests { - t.Run( - tt.name, func(t *testing.T) { - if got := NewVideo(tt.args.opts...); !reflect.DeepEqual(got, tt.want) { - t.Errorf("%s\nNewVideo() = %v\nwant %v", tt.name, got, tt.want) + {name: "omitted preserves", input: `{}`, privacy: "private", wantDescription: "Original", wantLanguage: "en", wantPublishAt: "2027-01-01T00:00:00Z"}, + {name: "empty clears", input: `{"description":"","language":"","publish_at":"","recording_date":""}`, privacy: "private", recordingPart: true}, + {name: "clear does not require private", input: `{"publish_at":""}`, privacy: "public", wantDescription: "Original", wantLanguage: "en"}, + {name: "nonempty replaces", input: `{"description":"New","language":"ja","publish_at":"2028-01-01T00:00:00Z","recording_date":"2020-01-01T00:00:00Z"}`, privacy: "private", wantDescription: "New", wantLanguage: "ja", wantPublishAt: "2028-01-01T00:00:00Z", wantRecordingDate: "2020-01-01T00:00:00Z", recordingPart: true}, + } { + t.Run(tt.name, func(t *testing.T) { + var body map[string]map[string]any + var parts string + svc := common.NewTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, `{"items":[{"id":"video-id","snippet":{"title":"Title","categoryId":"22","description":"Original","defaultLanguage":"en"},"status":{"privacyStatus":"`+tt.privacy+`","publishAt":"2027-01-01T00:00:00Z"}}]}`) + return } - }, - ) + parts = r.URL.Query().Get("part") + var raw struct { + Snippet map[string]any `json:"snippet"` + Status map[string]any `json:"status"` + RecordingDetails map[string]any `json:"recordingDetails"` + } + if err := json.UnmarshalRead(r.Body, &raw); err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + body = map[string]map[string]any{"snippet": raw.Snippet, "status": raw.Status, "recordingDetails": raw.RecordingDetails} + _, _ = io.WriteString(w, `{"id":"video-id"}`) + })) + var input Video + if err := json.Unmarshal([]byte(tt.input), &input); err != nil { + t.Fatal(err) + } + input.Service, input.Ids, input.MaxResults, input.Output = svc, []string{"video-id"}, 1, "silent" + if err := input.Update(io.Discard); err != nil { + t.Fatal(err) + } + if body == nil { + t.Fatal("missing update request") + } + for _, field := range []struct{ part, key, want string }{ + {"snippet", "description", tt.wantDescription}, + {"snippet", "defaultLanguage", tt.wantLanguage}, + {"status", "publishAt", tt.wantPublishAt}, + {"recordingDetails", "recordingDate", tt.wantRecordingDate}, + } { + got, present := body[field.part][field.key] + if field.want == "" { + if present { + t.Errorf("%s.%s should be omitted for clearing, got %v", field.part, field.key, got) + } + } else if got != field.want { + t.Errorf("%s.%s = %v, want %q", field.part, field.key, got, field.want) + } + } + if strings.Contains(parts, "recordingDetails") != tt.recordingPart { + t.Errorf("unexpected update parts: %s", parts) + } + if tt.recordingPart && body["recordingDetails"] == nil { + t.Error("recordingDetails object missing") + } + }) } } @@ -558,6 +369,39 @@ func TestVideo_Insert(t *testing.T) { verify func(*http.Request) wantErr bool }{ + { + name: "insert explicit false status fields", + opts: []Option{ + WithFile("test_video.mp4"), + WithEmbeddable(new(false)), + WithPublicStatsViewable(new(false)), + }, + verify: func(r *http.Request) { + _, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Error(err) + return + } + part, err := multipart.NewReader(r.Body, params["boundary"]).NextPart() + if err != nil { + t.Error(err) + return + } + defer func() { _ = part.Close() }() + var body struct { + Status map[string]any `json:"status"` + } + if err := json.UnmarshalRead(part, &body); err != nil { + t.Error(err) + return + } + for _, field := range []string{"embeddable", "publicStatsViewable"} { + if value, present := body.Status[field]; !present || value != false { + t.Errorf("status.%s must be present and false, got %v (present=%v)", field, value, present) + } + } + }, + }, { name: "insert video", opts: []Option{ @@ -639,7 +483,7 @@ func TestVideo_Insert(t *testing.T) { WithFile("test_video.mp4"), WithTitle("Dated Video"), WithPrivacy("private"), - WithRecordingDate("2024-06-15T10:00:00Z"), + WithRecordingDate(new("2024-06-15T10:00:00Z")), WithForKids(&forKidsTrue), WithEmbeddable(&embeddableTrue), WithContainsSyntheticMedia(&containsSyntheticMediaTrue), @@ -815,7 +659,7 @@ func TestVideo_Update(t *testing.T) { opts: []Option{ WithIds([]string{"video-id"}), WithTitle("Updated Title"), - WithDescription("Updated Description"), + WithDescription(new("Updated Description")), WithMaxResults(1), }, getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}}]}`, @@ -875,7 +719,7 @@ func TestVideo_Update(t *testing.T) { opts: []Option{ WithIds([]string{"video-id"}), WithTags([]string{"new-tag"}), - WithLanguage("ja"), + WithLanguage(new("ja")), WithLicense("creativeCommon"), WithCategory("22"), WithPrivacy("unlisted"), @@ -924,7 +768,7 @@ func TestVideo_Update(t *testing.T) { name: "update video with publishAt on private video", opts: []Option{ WithIds([]string{"video-id"}), - WithPublishAt("2026-08-18T15:00:00Z"), + WithPublishAt(new("2026-08-18T15:00:00Z")), WithMaxResults(1), }, getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}, "status": {"privacyStatus": "private"}}]}`, @@ -955,7 +799,7 @@ func TestVideo_Update(t *testing.T) { opts: []Option{ WithIds([]string{"video-id"}), WithPrivacy("private"), - WithPublishAt("2026-08-18T15:00:00Z"), + WithPublishAt(new("2026-08-18T15:00:00Z")), WithMaxResults(1), }, getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}, "status": {"privacyStatus": "public"}}]}`, @@ -985,7 +829,7 @@ func TestVideo_Update(t *testing.T) { name: "update video with publishAt rejects public without explicit private", opts: []Option{ WithIds([]string{"video-id"}), - WithPublishAt("2026-08-18T15:00:00Z"), + WithPublishAt(new("2026-08-18T15:00:00Z")), WithMaxResults(1), }, getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}, "status": {"privacyStatus": "public"}}]}`, @@ -1000,7 +844,7 @@ func TestVideo_Update(t *testing.T) { name: "update video with publishAt rejects unlisted without explicit private", opts: []Option{ WithIds([]string{"video-id"}), - WithPublishAt("2026-08-18T15:00:00Z"), + WithPublishAt(new("2026-08-18T15:00:00Z")), WithMaxResults(1), }, getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}, "status": {"privacyStatus": "unlisted"}}]}`, @@ -1017,7 +861,7 @@ func TestVideo_Update(t *testing.T) { WithIds([]string{"video-id"}), WithEmbeddable(&embeddableTrue), WithContainsSyntheticMedia(&containsSyntheticMediaTrue), - WithRecordingDate("2024-01-01T00:00:00Z"), + WithRecordingDate(new("2024-01-01T00:00:00Z")), WithMaxResults(1), }, getResponse: `{"items": [{"id": "video-id", "snippet": {"title": "Old Title"}, "status": {"privacyStatus": "public"}}]}`, @@ -1091,6 +935,52 @@ func TestVideo_Update(t *testing.T) { } } +func TestVideo_UpdateFalseStatusFields(t *testing.T) { + for _, tt := range []struct { + name string + status string + opts []Option + }{ + { + name: "preserve existing false values", + status: `{"embeddable":false,"publicStatsViewable":false,"selfDeclaredMadeForKids":false,"containsSyntheticMedia":false}`, + }, + { + name: "explicit false overrides true", + status: `{"embeddable":true,"publicStatsViewable":false,"selfDeclaredMadeForKids":false,"containsSyntheticMedia":true}`, + opts: []Option{WithEmbeddable(new(false)), WithContainsSyntheticMedia(new(false))}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + var body struct { + Status map[string]any `json:"status"` + } + svc := common.NewTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, `{"items":[{"id":"video-id","snippet":{"title":"Old title","categoryId":"22"},"status":`+tt.status+`}]}`) + return + } + if err := json.UnmarshalRead(r.Body, &body); err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + _, _ = io.WriteString(w, `{"id":"video-id"}`) + })) + opts := append([]Option{WithService(svc), WithIds([]string{"video-id"}), WithMaxResults(1), WithTitle("New title"), WithOutput("silent")}, tt.opts...) + if err := NewVideo(opts...).Update(io.Discard); err != nil { + t.Fatal(err) + } + for _, field := range []string{"embeddable", "publicStatsViewable", "selfDeclaredMadeForKids", "containsSyntheticMedia"} { + if value, present := body.Status[field]; !present || value != false { + t.Errorf("status.%s must be present and false, got %v (present=%v)", field, value, present) + } + } + }) + } +} + func TestVideo_Rate(t *testing.T) { tests := []struct { name string @@ -1323,7 +1213,7 @@ func TestVideo_ReportAbuse(t *testing.T) { WithIds([]string{"video-id"}), WithReasonId("reason-id"), WithComments("spam"), - WithLanguage("en"), + WithLanguage(new("en")), WithSecondaryReasonId("secondary-reason"), }, verify: func(r *http.Request) {