From c961aa3b6c6f8048de28962e79c71eaae8102125 Mon Sep 17 00:00:00 2001 From: ari melody Date: Sun, 28 Jun 2026 15:37:36 +0100 Subject: [PATCH] new feature: update metadata on uploaded videos --- main.go | 175 +++++++++++++++++++++++++-------------------- scanner/scanner.go | 3 +- video/video.go | 2 +- youtube/youtube.go | 144 ++++++++++++++++++++++++------------- 4 files changed, 196 insertions(+), 128 deletions(-) diff --git a/main.go b/main.go index ef109d8..26b7d03 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "golang.org/x/oauth2" "golang.org/x/oauth2/google" + "google.golang.org/api/option" "google.golang.org/api/youtube/v3" "arimelody.space/vodular/config" @@ -35,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). " + @@ -135,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) @@ -146,21 +144,18 @@ func main() { 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 @@ -168,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 @@ -201,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) @@ -220,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 { @@ -265,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" + @@ -276,12 +252,85 @@ func main() { ) } - if (!dryRun) { + // youtube oauth flow + oauth2Config := &oauth2.Config{ + ClientID: cfg.Google.ClientID, + ClientSecret: cfg.Google.ClientSecret, + Endpoint: google.Endpoint, + Scopes: []string{ youtube.YoutubeScope }, + RedirectURL: cfg.RedirectUri, + } + var token *oauth2.Token + if cfg.Token != nil { + token = cfg.Token + } else { + token, err = oauth.GenerateToken(&ctx, oauth2Config, cfg) + if err != nil { + log.Fatalf("OAuth flow failed: %v", err) + } + cfg.Token = token + } + tokenSource := oauth2Config.TokenSource(ctx, token) + if err != nil { + log.Fatalf("Failed to create OAuth2 token source: %v", err) + } + if err = config.WriteConfig(cfg, config.CONFIG_FILENAME); err != nil { + log.Fatalf("Failed to save OAuth token: %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) + } + + // 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(video.Filepath) + fullVodProbe, err := scanner.ProbeSegment(videoMeta.Filepath) if err == nil { - video.SizeBytes = fullVodProbe.Format.Size + videoMeta.SizeBytes = fullVodProbe.Format.Size var totalLength float64 = 0 for _, filename := range vodFiles { @@ -297,53 +346,20 @@ func main() { log.Print("Full VOD appears to already exist- uploading this file...") } else { // concatenate VOD segments into full VOD - video.SizeBytes, err = vid.ConcatVideo(video, vodFiles, verbose) + videoMeta.SizeBytes, err = vid.ConcatVideo(videoMeta, vodFiles, verbose) if err != nil { log.Fatalf("Failed to concatenate VOD segments: %v", err) - os.Exit(1) } } } else { log.Print("Dry run: Skipping VOD concatenation") } - // youtube oauth flow - ctx := context.Background() - oauth2Config := &oauth2.Config{ - ClientID: cfg.Google.ClientID, - ClientSecret: cfg.Google.ClientSecret, - Endpoint: google.Endpoint, - Scopes: []string{ youtube.YoutubeScope }, - RedirectURL: cfg.RedirectUri, - } - var token *oauth2.Token - if cfg.Token != nil { - token = cfg.Token - } else { - 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 { - log.Fatalf("Failed to save OAuth token: %v", err) - } - - if (!dryRun) { + if !dryRun { // okay actually upload now! - ytVideo, err := yt.UploadVideo(ctx, tokenSource, video, thumbnail, templates) + ytVideo, err := yt.UploadVideo(ctx, tokenSource, service, videoMeta, templates) if err != nil { log.Fatalf("Failed to upload video: %v", err) - os.Exit(1) } if verbose { jsonString, err := json.MarshalIndent(ytVideo, "", " ") @@ -354,9 +370,14 @@ func main() { } 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 + metadata.UploadURL = yt.VIDEO_URL_BASE + ytVideo.Id err = scanner.WriteMetadata(directory, metadata) if err != nil { log.Fatalf("Failed to update metadata: %v", err) @@ -364,7 +385,7 @@ func main() { // delete full VOD after upload, if requested if deleteFullVod { - err = os.Remove(video.Filepath) + err = os.Remove(videoMeta.Filepath) if err != nil { log.Fatalf("Failed to delete full VOD: %v", err) } diff --git a/scanner/scanner.go b/scanner/scanner.go index 157c449..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."` } 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 +}