diff --git a/main.go b/main.go index 26b7d03..74572d1 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,26 +36,28 @@ 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). " + @@ -75,7 +77,6 @@ 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 { @@ -109,9 +110,6 @@ func main() { case "--force": forceUpload = true - case "--dry-run": - dryRun = true - default: fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg) os.Exit(1) @@ -134,6 +132,7 @@ 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) @@ -141,21 +140,24 @@ func main() { // initialising directory (--init) if initDirectory { - err = scanner.InitialiseDirectory(directory) + err = initialiseDirectory(directory) if err != nil { log.Fatalf("Failed to initialise directory: %v", err) + os.Exit(1) } - log.Fatalf( + log.Printf( "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 @@ -163,14 +165,27 @@ 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 @@ -183,12 +198,14 @@ 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) @@ -200,35 +217,39 @@ func main() { } // scan for thumbnail - var thumbnail *yt.ThumbnailMetadata + var thumbnail *yt.Thumbnail thumbnailPath, thumbnailSizeBytes, err := scanner.ScanThumbnail(directory) if err != nil { log.Fatalf("Failed to fetch thumbnail: %v", err) + os.Exit(1) } else { - thumbnail = &yt.ThumbnailMetadata{ + thumbnail = &yt.Thumbnail{ Filepath: thumbnailPath, SizeBytes: thumbnailSizeBytes, } } // build video template for upload - videoMeta, err := yt.BuildVideo(metadata) + video, err := yt.BuildVideo(metadata) if err != nil { log.Fatalf("Failed to build video template: %v", err) + os.Exit(1) } - title, err := yt.BuildTemplate(videoMeta, templates.Title) + title, err := yt.BuildTemplate(video, templates.Title) if err != nil { log.Fatalf("Failed to build video title: %v", err) + os.Exit(1) } - description, err := yt.BuildTemplate(videoMeta, templates.Description) + description, err := yt.BuildTemplate(video, 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(videoMeta.Title), + len(video.Title), ) } if len(description) > 5000 { @@ -241,7 +262,7 @@ func main() { if verbose { enc := json.NewEncoder(os.Stdout) fmt.Printf("\nVideo template: ") - enc.Encode(videoMeta) + enc.Encode(video) fmt.Printf( "\n================================\n\n" + @@ -252,7 +273,33 @@ 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, @@ -264,133 +311,135 @@ func main() { if cfg.Token != nil { token = cfg.Token } else { - token, err = oauth.GenerateToken(&ctx, oauth2Config, cfg) + token, err = generateOAuthToken(&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) } - if err = config.WriteConfig(cfg, config.CONFIG_FILENAME); err != nil { + + err = config.WriteConfig(cfg, config.CONFIG_FILENAME) + if err != nil { log.Fatalf("Failed to save OAuth token: %v", err) } - service, err := youtube.NewService( - ctx, - option.WithScopes(youtube.YoutubeUploadScope), - option.WithTokenSource(tokenSource), - ) + // okay actually upload now! + ytVideo, err := yt.UploadVideo(ctx, tokenSource, video, thumbnail, templates) if err != nil { - log.Fatalf("Failed to create youtube service: %v\n", err) + log.Fatalf("Failed to upload video: %v", err) + os.Exit(1) } - - // 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.") - } - if !dryRun { - log.Printf( - "To upload anyways, use --force or update %s.", - scanner.METADATA_FILENAME, - ) - } - - 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") - } - } - - os.Exit(0) - } - - 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 verbose { + jsonString, err := json.MarshalIndent(ytVideo, "", " ") if err != nil { - log.Fatalf("Failed to upload video: %v", err) + log.Fatalf("Failed to marshal video data json: %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!") + 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 + // 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) + } - // update metadata to reflect VOD is uploaded - metadata.UploadURL = yt.VIDEO_URL_BASE + ytVideo.Id - err = scanner.WriteMetadata(directory, metadata) + // delete full VOD after upload, if requested + if deleteFullVod { + err = os.Remove(video.Filepath) if err != nil { - log.Fatalf("Failed to update metadata: %v", err) + log.Fatalf("Failed to delete full VOD: %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") } } + +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 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/oauth/oauth.go b/oauth/oauth.go deleted file mode 100644 index 4dee4a8..0000000 --- a/oauth/oauth.go +++ /dev/null @@ -1,73 +0,0 @@ -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 317d416..c63605a 100644 --- a/res/help.txt +++ b/res/help.txt @@ -8,7 +8,6 @@ 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 5d170a8..99aa504 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -26,8 +26,7 @@ type ( Date toml.LocalDate `toml:"date"` Tags []string `toml:"tags"` FootageDir string `toml:"footage_dir"` - Uploaded bool `toml:"uploaded"` // deprecated, held for backwards compatibility - UploadURL string `toml:"upload_url"` + Uploaded bool `toml:"uploaded"` Category *Category `toml:"category" comment:"(Optional) Category details, for additional credits."` } @@ -43,28 +42,6 @@ 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 0f319d6..a9f54f1 100644 --- a/video/video.go +++ b/video/video.go @@ -22,7 +22,7 @@ type ( } ) -func ConcatVideo(video *youtube.VideoMetadata, vodFiles []string, verbose bool) (int64, error) { +func ConcatVideo(video *youtube.Video, 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 681d9f7..96b18fc 100644 --- a/youtube/youtube.go +++ b/youtube/youtube.go @@ -3,7 +3,6 @@ package youtube import ( "bytes" "context" - "errors" "fmt" "log" "os" @@ -14,6 +13,7 @@ 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 } - VideoMetadata struct { + Video struct { Title string Category *Category Part int @@ -45,15 +45,13 @@ type ( SizeBytes int64 } - ThumbnailMetadata struct { + Thumbnail struct { Filepath string SizeBytes int64 } ) -const VIDEO_URL_BASE string = "https://www.youtube.com/watch?v=" - -func BuildVideo(metadata *scanner.Metadata) (*VideoMetadata, error) { +func BuildVideo(metadata *scanner.Metadata) (*Video, error) { var category *Category = nil if metadata.Category != nil { category = &Category{ @@ -66,7 +64,7 @@ func BuildVideo(metadata *scanner.Metadata) (*VideoMetadata, error) { if !ok { category.Type = CATEGORY_ENTERTAINMENT } } - return &VideoMetadata{ + return &Video{ Title: metadata.Title, Category: category, Part: metadata.Part, @@ -157,7 +155,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("parse title template: %v", err) + return nil, fmt.Errorf("failed to parse title template: %v", err) } } else { if !os.IsNotExist(err) { return nil, err } @@ -178,7 +176,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("parse description template: %v", err) + return nil, fmt.Errorf("failed to parse description template: %v", err) } } else { if !os.IsNotExist(err) { return nil, err } @@ -197,7 +195,7 @@ func FetchTemplates(templateDir string) (*Template, error) { return &tmpl, nil } -func BuildTemplate(video *VideoMetadata, tmpl *template.Template) (string, error) { +func BuildTemplate(video *Video, tmpl *template.Template) (string, error) { out := &bytes.Buffer{} var category *MetaCategory @@ -222,18 +220,26 @@ func BuildTemplate(video *VideoMetadata, tmpl *template.Template) (string, error func UploadVideo( ctx context.Context, tokenSource oauth2.TokenSource, - service *youtube.Service, - video *VideoMetadata, + video *Video, + thumbnail *Thumbnail, 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("build title: %v", err) } + if err != nil { return nil, fmt.Errorf("failed to build title: %v", err) } description, err := BuildTemplate(video, templates.Description) - if err != nil { return nil, fmt.Errorf("build description: %v", err) } + 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 videoService := youtube.NewVideosService(service) @@ -257,7 +263,6 @@ func UploadVideo( PrivacyStatus: "private", }, }).NotifySubscribers(false) - videoInsertCall.Context(ctx) videoFile, err := os.Open(video.Filepath) if err != nil { @@ -280,81 +285,30 @@ func UploadVideo( return nil, err } - return ytVideo, err -} + if thumbnail != nil { + // upload thumbnail -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") } + 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) - 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") } + log.Println("Uploading thumbnail...") - var video *youtube.Video - for _, v := range videoList.Items { - if v.Id == videoId { - video = v - break + 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) } } - 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 + return ytVideo, err }