diff --git a/main.go b/main.go index 74572d1..26b7d03 100644 --- a/main.go +++ b/main.go @@ -7,17 +7,17 @@ import ( "fmt" "log" "math" - "net/http" "os" "path" "strings" - "sync" "golang.org/x/oauth2" "golang.org/x/oauth2/google" + "google.golang.org/api/option" "google.golang.org/api/youtube/v3" "arimelody.space/vodular/config" + "arimelody.space/vodular/oauth" "arimelody.space/vodular/scanner" vid "arimelody.space/vodular/video" yt "arimelody.space/vodular/youtube" @@ -36,28 +36,26 @@ func showHelp() { } func main() { + ctx := context.Background() + // config userConfigDir, err := os.UserConfigDir() if err != nil { log.Fatalf("Could not determine user configuration directory: %v", err) - os.Exit(1) } config.CONFIG_FILENAME = path.Join(userConfigDir, "vodular", "config.toml") cfg, err := config.ReadConfig(config.CONFIG_FILENAME) if err != nil { log.Fatalf("Failed to read config: %v", err) - os.Exit(1) } if cfg == nil { err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750) if err != nil { log.Fatalf("Failed to create config directory: %v", err) - os.Exit(1) } err = config.GenerateConfig(config.CONFIG_FILENAME) if err != nil { log.Fatalf("Failed to generate config: %v", err) - os.Exit(1) } log.Printf( "New config file created (%s). " + @@ -77,6 +75,7 @@ func main() { var logout bool = false var deleteFullVod bool = false var forceUpload bool = false + var dryRun bool = false var directory string = "." for i, arg := range os.Args { @@ -110,6 +109,9 @@ func main() { case "--force": forceUpload = true + case "--dry-run": + dryRun = true + default: fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg) os.Exit(1) @@ -132,7 +134,6 @@ func main() { err = config.WriteConfig(cfg, config.CONFIG_FILENAME) if err != nil { log.Fatalf("Failed to write config: %v", err) - os.Exit(1) } log.Println("Logged out successfully.") os.Exit(0) @@ -140,24 +141,21 @@ func main() { // initialising directory (--init) if initDirectory { - err = initialiseDirectory(directory) + err = scanner.InitialiseDirectory(directory) if err != nil { log.Fatalf("Failed to initialise directory: %v", err) - os.Exit(1) } - log.Printf( + log.Fatalf( "Directory successfully initialised. " + "Be sure to update %s before uploading!", scanner.METADATA_FILENAME, ) - os.Exit(0) } // good to have early on templates, err := yt.FetchTemplates(path.Join(userConfigDir, "vodular", "templates")) if err != nil { log.Fatalf("Failed to fetch templates: %v", err) - os.Exit(1) } // read directory metadata @@ -165,27 +163,14 @@ func main() { if err != nil { if os.IsNotExist(err) { log.Fatalf("Directory does not exist: %s", directory) - os.Exit(1) } log.Fatalf("Failed to fetch VOD metadata: %v", err) - os.Exit(1) } if metadata == nil { log.Fatal( "Directory contained no metadata. " + "Use `--init` to initialise this directory.", ) - os.Exit(1) - } - - // skip uploading if already done - if metadata.Uploaded == !forceUpload { - log.Printf( - "VOD has already been uploaded. " + - "Use --force to override, or update the %s.", - scanner.METADATA_FILENAME, - ) - os.Exit(0) } // default footage directory @@ -198,14 +183,12 @@ func main() { vodFiles, err := scanner.ScanSegments(footageDir, SEGMENT_EXTENSION) if err != nil { log.Fatalf("Failed to fetch VOD filenames: %v", err) - os.Exit(1) } if len(vodFiles) == 0 { log.Fatalf( "Directory contained no VOD files (expecting .%s)", SEGMENT_EXTENSION, ) - os.Exit(1) } if verbose { enc := json.NewEncoder(os.Stdout) @@ -217,39 +200,35 @@ func main() { } // scan for thumbnail - var thumbnail *yt.Thumbnail + var thumbnail *yt.ThumbnailMetadata thumbnailPath, thumbnailSizeBytes, err := scanner.ScanThumbnail(directory) if err != nil { log.Fatalf("Failed to fetch thumbnail: %v", err) - os.Exit(1) } else { - thumbnail = &yt.Thumbnail{ + thumbnail = &yt.ThumbnailMetadata{ Filepath: thumbnailPath, SizeBytes: thumbnailSizeBytes, } } // build video template for upload - video, err := yt.BuildVideo(metadata) + videoMeta, err := yt.BuildVideo(metadata) if err != nil { log.Fatalf("Failed to build video template: %v", err) - os.Exit(1) } - title, err := yt.BuildTemplate(video, templates.Title) + title, err := yt.BuildTemplate(videoMeta, templates.Title) if err != nil { log.Fatalf("Failed to build video title: %v", err) - os.Exit(1) } - description, err := yt.BuildTemplate(video, templates.Description) + description, err := yt.BuildTemplate(videoMeta, templates.Description) if err != nil { log.Fatalf("Failed to build video description: %v", err) - os.Exit(1) } if len(title) > 100 { log.Fatalf( "Video title length exceeds %d characters (%d). YouTube may reject this!", MAX_TITLE_LEN, - len(video.Title), + len(videoMeta.Title), ) } if len(description) > 5000 { @@ -262,7 +241,7 @@ func main() { if verbose { enc := json.NewEncoder(os.Stdout) fmt.Printf("\nVideo template: ") - enc.Encode(video) + enc.Encode(videoMeta) fmt.Printf( "\n================================\n\n" + @@ -273,33 +252,7 @@ func main() { ) } - // concatenate VOD segments into full VOD - fullVodExists := func () bool { - // check if full VOD already exists with expected duration - fullVodProbe, err := scanner.ProbeSegment(video.Filepath) - if err != nil { return false } - video.SizeBytes = fullVodProbe.Format.Size - var totalLength float64 = 0 - - for _, filename := range vodFiles { - probe, err := scanner.ProbeSegment(path.Join(footageDir, filename)) - if err != nil { continue } - totalLength += probe.Format.Duration - } - return math.Abs(fullVodProbe.Format.Duration - totalLength) < float64(0.1) - }() - if fullVodExists { - log.Print("Full VOD appears to already exist- uploading this file...") - } else { - video.SizeBytes, err = vid.ConcatVideo(video, vodFiles, verbose) - if err != nil { - log.Fatalf("Failed to concatenate VOD segments: %v", err) - os.Exit(1) - } - } - // youtube oauth flow - ctx := context.Background() oauth2Config := &oauth2.Config{ ClientID: cfg.Google.ClientID, ClientSecret: cfg.Google.ClientSecret, @@ -311,135 +264,133 @@ func main() { if cfg.Token != nil { token = cfg.Token } else { - token, err = generateOAuthToken(&ctx, oauth2Config, cfg) + token, err = oauth.GenerateToken(&ctx, oauth2Config, cfg) if err != nil { log.Fatalf("OAuth flow failed: %v", err) - os.Exit(1) } cfg.Token = token } tokenSource := oauth2Config.TokenSource(ctx, token) if err != nil { log.Fatalf("Failed to create OAuth2 token source: %v", err) - os.Exit(1) } - - err = config.WriteConfig(cfg, config.CONFIG_FILENAME) - if err != nil { + if err = config.WriteConfig(cfg, config.CONFIG_FILENAME); err != nil { log.Fatalf("Failed to save OAuth token: %v", err) } - // okay actually upload now! - ytVideo, err := yt.UploadVideo(ctx, tokenSource, video, thumbnail, templates) + service, err := youtube.NewService( + ctx, + option.WithScopes(youtube.YoutubeUploadScope), + option.WithTokenSource(tokenSource), + ) if err != nil { - log.Fatalf("Failed to upload video: %v", err) - os.Exit(1) + log.Fatalf("Failed to create youtube service: %v\n", err) } - if verbose { - jsonString, err := json.MarshalIndent(ytVideo, "", " ") - if err != nil { - log.Fatalf("Failed to marshal video data json: %v", err) + + // skip uploading if already done + if (metadata.Uploaded || len(metadata.UploadURL) > 0) && !forceUpload { + if len(metadata.UploadURL) > 0 { + log.Printf("VOD has already been uploaded: %s", metadata.UploadURL) + } else { + log.Println("VOD has already been uploaded.") } - fmt.Println(string(jsonString)) - } - log.Print("Video uploaded successfully!") - - // update metadata to reflect VOD is uploaded - // TODO: rather than a boolean flag, link to actual video - metadata.Uploaded = true - err = scanner.WriteMetadata(directory, metadata) - if err != nil { - log.Fatalf("Failed to update metadata: %v", err) - } - - // delete full VOD after upload, if requested - if deleteFullVod { - err = os.Remove(video.Filepath) - if err != nil { - log.Fatalf("Failed to delete full VOD: %v", err) - } - } -} - -func initialiseDirectory(directory string) error { - dirInfo, err := os.Stat(directory) - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("no such directory: %s", directory) - } - return fmt.Errorf("failed to open directory: %v", err) - } - if !dirInfo.IsDir() { - return fmt.Errorf("not a directory: %s", directory) - } - - _, err = os.Stat(path.Join(directory, scanner.METADATA_FILENAME)) - if err == nil { - return fmt.Errorf("directory already initialised: %s", directory) - } - - err = scanner.WriteMetadata(directory, scanner.DefaultMetadata()) - - return err -} - -func generateOAuthToken( - ctx *context.Context, - oauth2Config *oauth2.Config, - cfg *config.Config, -) (*oauth2.Token, error) { - verifier := oauth2.GenerateVerifier() - - var token *oauth2.Token - wg := sync.WaitGroup{} - var server http.Server - server.Addr = cfg.Host - server.Handler = http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - if !r.URL.Query().Has("code") { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - code := r.URL.Query().Get("code") - - t, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier)) - if err != nil { - log.Fatalf("Could not exchange OAuth2 code: %v", err) - http.Error( w, - fmt.Sprintf("Could not exchange OAuth2 code: %v", err), - http.StatusBadRequest, - ) - return - } - - token = t - http.Error( - w, - "Authentication successful! You may now close this tab.", - http.StatusOK, + if !dryRun { + log.Printf( + "To upload anyways, use --force or update %s.", + scanner.METADATA_FILENAME, ) - if f, ok := w.(http.Flusher); ok { - f.Flush() + } + + if len(metadata.UploadURL) > 0 { + if !dryRun { + videoId := strings.TrimPrefix(metadata.UploadURL, yt.VIDEO_URL_BASE) + if videoId == metadata.UploadURL { + log.Fatal("can't parse video ID from upload URL") + } + + log.Println("Updating metadata...") + if err = yt.UpdateMetadata(ctx, tokenSource, service, videoMeta, videoId, templates); err != nil { + log.Printf("update metadata: %v", err) + } + + log.Println("Uploading thumbnail...") + if err = yt.UploadThumbnail(ctx, tokenSource, service, videoId, thumbnail); err != nil { + log.Printf("upload thumbnail: %v", err) + } + } else { + log.Print("Dry run: Skipping metadata update") } + } - wg.Done() - server.Close() - }, - ) - - url := oauth2Config.AuthCodeURL( - "state", - oauth2.AccessTypeOffline, - oauth2.S256ChallengeOption(verifier), - ) - fmt.Printf("\nSign in to YouTube: %s\n\n", url) - - wg.Add(1) - if err := server.ListenAndServe(); err != http.ErrServerClosed { - return nil, fmt.Errorf("http: %v", err) + os.Exit(0) } - wg.Wait() - return token, nil + if !dryRun { + // check if full VOD already exists with expected duration + fullVodExists := false + fullVodProbe, err := scanner.ProbeSegment(videoMeta.Filepath) + if err == nil { + videoMeta.SizeBytes = fullVodProbe.Format.Size + var totalLength float64 = 0 + + for _, filename := range vodFiles { + probe, err := scanner.ProbeSegment(path.Join(footageDir, filename)) + if err != nil { continue } + totalLength += probe.Format.Duration + } + // full VOD may exist, but not be complete (interrupted concat) + fullVodExists = math.Abs(fullVodProbe.Format.Duration - totalLength) < float64(0.1) + } + + if fullVodExists { + log.Print("Full VOD appears to already exist- uploading this file...") + } else { + // concatenate VOD segments into full VOD + videoMeta.SizeBytes, err = vid.ConcatVideo(videoMeta, vodFiles, verbose) + if err != nil { + log.Fatalf("Failed to concatenate VOD segments: %v", err) + } + } + } else { + log.Print("Dry run: Skipping VOD concatenation") + } + + if !dryRun { + // okay actually upload now! + ytVideo, err := yt.UploadVideo(ctx, tokenSource, service, videoMeta, templates) + if err != nil { + log.Fatalf("Failed to upload video: %v", err) + } + if verbose { + jsonString, err := json.MarshalIndent(ytVideo, "", " ") + if err != nil { + log.Fatalf("Failed to marshal video data json: %v", err) + } + fmt.Println(string(jsonString)) + } + log.Print("Video uploaded successfully!") + + // set the thumbnail + log.Println("Uploading thumbnail...") + if err = yt.UploadThumbnail(ctx, tokenSource, service, ytVideo.Id, thumbnail); err != nil { + log.Printf("Failed to upload thumbnail: %v", err) + } + + // update metadata to reflect VOD is uploaded + metadata.UploadURL = yt.VIDEO_URL_BASE + ytVideo.Id + err = scanner.WriteMetadata(directory, metadata) + if err != nil { + log.Fatalf("Failed to update metadata: %v", err) + } + + // delete full VOD after upload, if requested + if deleteFullVod { + err = os.Remove(videoMeta.Filepath) + if err != nil { + log.Fatalf("Failed to delete full VOD: %v", err) + } + } + } else { + log.Print("Dry run: Skipping video upload") + } } diff --git a/oauth/oauth.go b/oauth/oauth.go new file mode 100644 index 0000000..4dee4a8 --- /dev/null +++ b/oauth/oauth.go @@ -0,0 +1,73 @@ +package oauth + +import ( + "context" + "fmt" + "log" + "net/http" + "sync" + + "arimelody.space/vodular/config" + "golang.org/x/oauth2" +) + +func GenerateToken( + ctx *context.Context, + oauth2Config *oauth2.Config, + cfg *config.Config, +) (*oauth2.Token, error) { + verifier := oauth2.GenerateVerifier() + + var token *oauth2.Token + wg := sync.WaitGroup{} + var server http.Server + server.Addr = cfg.Host + server.Handler = http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if !r.URL.Query().Has("code") { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + code := r.URL.Query().Get("code") + + t, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier)) + if err != nil { + log.Fatalf("Could not exchange OAuth2 code: %v", err) + http.Error( w, + fmt.Sprintf("Could not exchange OAuth2 code: %v", err), + http.StatusBadRequest, + ) + return + } + + token = t + http.Error( + w, + "Authentication successful! You may now close this tab.", + http.StatusOK, + ) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + wg.Done() + server.Close() + }, + ) + + url := oauth2Config.AuthCodeURL( + "state", + oauth2.AccessTypeOffline, + oauth2.S256ChallengeOption(verifier), + ) + fmt.Printf("\nSign in to YouTube: %s\n\n", url) + + wg.Add(1) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + return nil, fmt.Errorf("http: %v", err) + } + wg.Wait() + + return token, nil +} diff --git a/res/help.txt b/res/help.txt index c63605a..317d416 100644 --- a/res/help.txt +++ b/res/help.txt @@ -8,6 +8,7 @@ OPTIONS: --init: Initialise `directory` as a VOD directory. --logout: Logs out of the current YouTube account. -d, --deleteAfter: Deletes the full VOD after upload. + --dry-run: Don't upload or modify any files (Useful with -v) -f, --force: Force uploading the VOD, even if it already exists. SOURCE: https://codeberg.org/arimelody/vodular diff --git a/scanner/scanner.go b/scanner/scanner.go index 99aa504..5d170a8 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -26,7 +26,8 @@ type ( Date toml.LocalDate `toml:"date"` Tags []string `toml:"tags"` FootageDir string `toml:"footage_dir"` - Uploaded bool `toml:"uploaded"` + Uploaded bool `toml:"uploaded"` // deprecated, held for backwards compatibility + UploadURL string `toml:"upload_url"` Category *Category `toml:"category" comment:"(Optional) Category details, for additional credits."` } @@ -42,6 +43,28 @@ type ( const METADATA_FILENAME = "metadata.toml" +func InitialiseDirectory(directory string) error { + dirInfo, err := os.Stat(directory) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("no such directory: %s", directory) + } + return fmt.Errorf("failed to open directory: %v", err) + } + if !dirInfo.IsDir() { + return fmt.Errorf("not a directory: %s", directory) + } + + _, err = os.Stat(path.Join(directory, METADATA_FILENAME)) + if err == nil { + return fmt.Errorf("directory already initialised: %s", directory) + } + + err = WriteMetadata(directory, DefaultMetadata()) + + return err +} + func ScanSegments(directory string, extension string) ([]string, error) { entries, err := os.ReadDir(directory) if err != nil { diff --git a/video/video.go b/video/video.go index a9f54f1..0f319d6 100644 --- a/video/video.go +++ b/video/video.go @@ -22,7 +22,7 @@ type ( } ) -func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) (int64, error) { +func ConcatVideo(video *youtube.VideoMetadata, vodFiles []string, verbose bool) (int64, error) { fileListPath := path.Join( path.Dir(video.Filepath), "files.txt", diff --git a/youtube/youtube.go b/youtube/youtube.go index 96b18fc..681d9f7 100644 --- a/youtube/youtube.go +++ b/youtube/youtube.go @@ -3,6 +3,7 @@ package youtube import ( "bytes" "context" + "errors" "fmt" "log" "os" @@ -13,7 +14,6 @@ import ( "arimelody.space/vodular/scanner" "golang.org/x/oauth2" - "google.golang.org/api/option" "google.golang.org/api/youtube/v3" ) @@ -35,7 +35,7 @@ type ( Url string } - Video struct { + VideoMetadata struct { Title string Category *Category Part int @@ -45,13 +45,15 @@ type ( SizeBytes int64 } - Thumbnail struct { + ThumbnailMetadata struct { Filepath string SizeBytes int64 } ) -func BuildVideo(metadata *scanner.Metadata) (*Video, error) { +const VIDEO_URL_BASE string = "https://www.youtube.com/watch?v=" + +func BuildVideo(metadata *scanner.Metadata) (*VideoMetadata, error) { var category *Category = nil if metadata.Category != nil { category = &Category{ @@ -64,7 +66,7 @@ func BuildVideo(metadata *scanner.Metadata) (*Video, error) { if !ok { category.Type = CATEGORY_ENTERTAINMENT } } - return &Video{ + return &VideoMetadata{ Title: metadata.Title, Category: category, Part: metadata.Part, @@ -155,7 +157,7 @@ func FetchTemplates(templateDir string) (*Template, error) { if titleFile, err := os.ReadFile(titlePath); err == nil { tmpl.Title, err = titleTemplate.Parse(string(titleFile)) if err != nil { - return nil, fmt.Errorf("failed to parse title template: %v", err) + return nil, fmt.Errorf("parse title template: %v", err) } } else { if !os.IsNotExist(err) { return nil, err } @@ -176,7 +178,7 @@ func FetchTemplates(templateDir string) (*Template, error) { if descriptionFile, err := os.ReadFile(descriptionPath); err == nil { tmpl.Description, err = descriptionTemplate.Parse(string(descriptionFile)) if err != nil { - return nil, fmt.Errorf("failed to parse description template: %v", err) + return nil, fmt.Errorf("parse description template: %v", err) } } else { if !os.IsNotExist(err) { return nil, err } @@ -195,7 +197,7 @@ func FetchTemplates(templateDir string) (*Template, error) { return &tmpl, nil } -func BuildTemplate(video *Video, tmpl *template.Template) (string, error) { +func BuildTemplate(video *VideoMetadata, tmpl *template.Template) (string, error) { out := &bytes.Buffer{} var category *MetaCategory @@ -220,26 +222,18 @@ func BuildTemplate(video *Video, tmpl *template.Template) (string, error) { func UploadVideo( ctx context.Context, tokenSource oauth2.TokenSource, - video *Video, - thumbnail *Thumbnail, + service *youtube.Service, + video *VideoMetadata, templates *Template, ) (*youtube.Video, error) { + if templates == nil { return nil, fmt.Errorf("templates cannot be nil") } + if templates.Title == nil { return nil, fmt.Errorf("title template cannot be nil") } + if templates.Description == nil { return nil, fmt.Errorf("description template cannot be nil") } + title, err := BuildTemplate(video, templates.Title) - if err != nil { return nil, fmt.Errorf("failed to build title: %v", err) } + if err != nil { return nil, fmt.Errorf("build title: %v", err) } description, err := BuildTemplate(video, templates.Description) - if err != nil { return nil, fmt.Errorf("failed to build description: %v", err) } - - service, err := youtube.NewService( - ctx, - option.WithScopes(youtube.YoutubeUploadScope), - option.WithTokenSource(tokenSource), - ) - if err != nil { - log.Fatalf("Failed to create youtube service: %v\n", err) - return nil, err - } - - // upload video + if err != nil { return nil, fmt.Errorf("build description: %v", err) } videoService := youtube.NewVideosService(service) @@ -263,6 +257,7 @@ func UploadVideo( PrivacyStatus: "private", }, }).NotifySubscribers(false) + videoInsertCall.Context(ctx) videoFile, err := os.Open(video.Filepath) if err != nil { @@ -285,30 +280,81 @@ func UploadVideo( return nil, err } - if thumbnail != nil { - // upload thumbnail - - thumbnailService := youtube.NewThumbnailsService(service) - thumbnailSetCall := thumbnailService.Set(ytVideo.Id) - thumbnailFile, err := os.Open(thumbnail.Filepath) - if err != nil { - log.Printf("Failed to open thumbnail: %v\n", err) - return ytVideo, err - } - thumbnailSetCall.Media(thumbnailFile) - - log.Println("Uploading thumbnail...") - - thumbnailSetCall.ProgressUpdater(func(current, total int64) { - if total == 0 { total = thumbnail.SizeBytes } - fmt.Printf("\t(%.2f%%)\n", float64(current) / float64(total) * 100) - }) - // kinda don't care about the response here- so long as it works! - _, err = thumbnailSetCall.Do() - if err != nil { - log.Printf("Failed to upload thumbnail: %v\n", err) - } - } - return ytVideo, err } + +func UpdateMetadata( + ctx context.Context, + tokenSource oauth2.TokenSource, + service *youtube.Service, + metadata *VideoMetadata, + videoId string, + templates *Template, +) error { + if templates == nil { return fmt.Errorf("templates cannot be nil") } + if templates.Title == nil { return fmt.Errorf("title template cannot be nil") } + if templates.Description == nil { return fmt.Errorf("description template cannot be nil") } + + metadataService := youtube.NewVideosService(service) + videoFetchCall := metadataService.List([]string{"id", "snippet"}) + videoFetchCall.Context(ctx) + videoFetchCall.Id(videoId) + videoList, err := videoFetchCall.Do() + if err != nil { return fmt.Errorf("fetch video: %v", err) } + if len(videoList.Items) == 0 { return errors.New("video not found") } + + var video *youtube.Video + for _, v := range videoList.Items { + if v.Id == videoId { + video = v + break + } + } + if video == nil { return errors.New("Could not find video. Was it deleted?") } + if video.Snippet == nil { return errors.New("video snippet is nil") } + + video.Snippet.Title, err = BuildTemplate(metadata, templates.Title) + if err != nil { return fmt.Errorf("build title: %v", err) } + video.Snippet.Description, err = BuildTemplate(metadata, templates.Description) + if err != nil { return fmt.Errorf("build description: %v", err) } + video.Snippet.Tags = metadata.Tags + video.Snippet.CategoryId = videoYtCategory[metadata.Category.Type] + + metadataSetCall := metadataService.Update([]string{"snippet"}, video) + metadataSetCall.Context(ctx) + if video, err = metadataSetCall.Do(); err != nil { + return fmt.Errorf("set metadata: %v", err) + } + + return nil +} + +func UploadThumbnail( + ctx context.Context, + tokenSource oauth2.TokenSource, + service *youtube.Service, + videoId string, + thumbnail *ThumbnailMetadata, +) error { + if thumbnail == nil { + return fmt.Errorf("thumbnail cannot be nil") + } + + thumbnailService := youtube.NewThumbnailsService(service) + thumbnailSetCall := thumbnailService.Set(videoId) + thumbnailSetCall.Context(ctx) + thumbnailFile, err := os.Open(thumbnail.Filepath) + if err != nil { return fmt.Errorf("open thumbnail: %v", err) } + thumbnailSetCall.Media(thumbnailFile) + + thumbnailSetCall.ProgressUpdater(func(current, total int64) { + if total == 0 { total = thumbnail.SizeBytes } + fmt.Printf("\t(%.2f%%)\n", float64(current) / float64(total) * 100) + }) + _, err = thumbnailSetCall.Do() + if err != nil { + log.Printf("Failed to upload thumbnail: %v\n", err) + } + + return nil +}