🐛 Preserve omitted video metadata

This commit is contained in:
OpenWaygate committed 2026-09-12 18:23:06 +08:00
1 parent afdb8c8305
commit 49e092ea2e
7 files changed
+262 -338

No files matched your search

-1
View File
@@ -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",
],
)
+18 -14
View File
@@ -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,
+1 -1
View File
@@ -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,
)
+6 -6
View File
@@ -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")
+25 -15
View File
@@ -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": &notifySubscribers,
"publicStatsViewable": &publicStatsViewable,
"autoLevels": &autoLevels,
"embeddable": &embeddable,
"stabilize": &stabilize,
"notifySubscribers": &notifySubscribers,
"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)
}
+54 -33
View File
@@ -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
}
+158 -268
View File
@@ -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(&notifySubscribersTrue),
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: &notifySubscribersTrue,
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(&notifySubscribersFalse),
WithPublicStatsViewable(&publicStatsViewableFalse),
},
},
want: &Video{
Fields: common.Fields{},
AutoLevels: &autoLevelsFalse,
ForKids: &forKidsFalse,
Embeddable: &embeddableFalse,
ContainsSyntheticMedia: &containsSyntheticMediaFalse,
Stabilize: &stabilizeFalse,
NotifySubscribers: &notifySubscribersFalse,
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) {