Compare commits

...

3 commits

Author SHA1 Message Date
c961aa3b6c
new feature: update metadata on uploaded videos 2026-06-28 15:37:36 +01:00
3053c19dd0
code refactor: tidy-up 2026-06-28 13:40:08 +01:00
0ac59c5407
add --dry-run option 2026-06-28 13:35:06 +01:00
6 changed files with 320 additions and 226 deletions

301
main.go
View file

@ -7,17 +7,17 @@ import (
"fmt" "fmt"
"log" "log"
"math" "math"
"net/http"
"os" "os"
"path" "path"
"strings" "strings"
"sync"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/oauth2/google" "golang.org/x/oauth2/google"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3" "google.golang.org/api/youtube/v3"
"arimelody.space/vodular/config" "arimelody.space/vodular/config"
"arimelody.space/vodular/oauth"
"arimelody.space/vodular/scanner" "arimelody.space/vodular/scanner"
vid "arimelody.space/vodular/video" vid "arimelody.space/vodular/video"
yt "arimelody.space/vodular/youtube" yt "arimelody.space/vodular/youtube"
@ -36,28 +36,26 @@ func showHelp() {
} }
func main() { func main() {
ctx := context.Background()
// config // config
userConfigDir, err := os.UserConfigDir() userConfigDir, err := os.UserConfigDir()
if err != nil { if err != nil {
log.Fatalf("Could not determine user configuration directory: %v", err) log.Fatalf("Could not determine user configuration directory: %v", err)
os.Exit(1)
} }
config.CONFIG_FILENAME = path.Join(userConfigDir, "vodular", "config.toml") config.CONFIG_FILENAME = path.Join(userConfigDir, "vodular", "config.toml")
cfg, err := config.ReadConfig(config.CONFIG_FILENAME) cfg, err := config.ReadConfig(config.CONFIG_FILENAME)
if err != nil { if err != nil {
log.Fatalf("Failed to read config: %v", err) log.Fatalf("Failed to read config: %v", err)
os.Exit(1)
} }
if cfg == nil { if cfg == nil {
err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750) err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750)
if err != nil { if err != nil {
log.Fatalf("Failed to create config directory: %v", err) log.Fatalf("Failed to create config directory: %v", err)
os.Exit(1)
} }
err = config.GenerateConfig(config.CONFIG_FILENAME) err = config.GenerateConfig(config.CONFIG_FILENAME)
if err != nil { if err != nil {
log.Fatalf("Failed to generate config: %v", err) log.Fatalf("Failed to generate config: %v", err)
os.Exit(1)
} }
log.Printf( log.Printf(
"New config file created (%s). " + "New config file created (%s). " +
@ -77,6 +75,7 @@ func main() {
var logout bool = false var logout bool = false
var deleteFullVod bool = false var deleteFullVod bool = false
var forceUpload bool = false var forceUpload bool = false
var dryRun bool = false
var directory string = "." var directory string = "."
for i, arg := range os.Args { for i, arg := range os.Args {
@ -110,6 +109,9 @@ func main() {
case "--force": case "--force":
forceUpload = true forceUpload = true
case "--dry-run":
dryRun = true
default: default:
fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg) fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg)
os.Exit(1) os.Exit(1)
@ -132,7 +134,6 @@ func main() {
err = config.WriteConfig(cfg, config.CONFIG_FILENAME) err = config.WriteConfig(cfg, config.CONFIG_FILENAME)
if err != nil { if err != nil {
log.Fatalf("Failed to write config: %v", err) log.Fatalf("Failed to write config: %v", err)
os.Exit(1)
} }
log.Println("Logged out successfully.") log.Println("Logged out successfully.")
os.Exit(0) os.Exit(0)
@ -140,24 +141,21 @@ func main() {
// initialising directory (--init) // initialising directory (--init)
if initDirectory { if initDirectory {
err = initialiseDirectory(directory) err = scanner.InitialiseDirectory(directory)
if err != nil { if err != nil {
log.Fatalf("Failed to initialise directory: %v", err) log.Fatalf("Failed to initialise directory: %v", err)
os.Exit(1)
} }
log.Printf( log.Fatalf(
"Directory successfully initialised. " + "Directory successfully initialised. " +
"Be sure to update %s before uploading!", "Be sure to update %s before uploading!",
scanner.METADATA_FILENAME, scanner.METADATA_FILENAME,
) )
os.Exit(0)
} }
// good to have early on // good to have early on
templates, err := yt.FetchTemplates(path.Join(userConfigDir, "vodular", "templates")) templates, err := yt.FetchTemplates(path.Join(userConfigDir, "vodular", "templates"))
if err != nil { if err != nil {
log.Fatalf("Failed to fetch templates: %v", err) log.Fatalf("Failed to fetch templates: %v", err)
os.Exit(1)
} }
// read directory metadata // read directory metadata
@ -165,27 +163,14 @@ func main() {
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
log.Fatalf("Directory does not exist: %s", directory) log.Fatalf("Directory does not exist: %s", directory)
os.Exit(1)
} }
log.Fatalf("Failed to fetch VOD metadata: %v", err) log.Fatalf("Failed to fetch VOD metadata: %v", err)
os.Exit(1)
} }
if metadata == nil { if metadata == nil {
log.Fatal( log.Fatal(
"Directory contained no metadata. " + "Directory contained no metadata. " +
"Use `--init` to initialise this directory.", "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 // default footage directory
@ -198,14 +183,12 @@ func main() {
vodFiles, err := scanner.ScanSegments(footageDir, SEGMENT_EXTENSION) vodFiles, err := scanner.ScanSegments(footageDir, SEGMENT_EXTENSION)
if err != nil { if err != nil {
log.Fatalf("Failed to fetch VOD filenames: %v", err) log.Fatalf("Failed to fetch VOD filenames: %v", err)
os.Exit(1)
} }
if len(vodFiles) == 0 { if len(vodFiles) == 0 {
log.Fatalf( log.Fatalf(
"Directory contained no VOD files (expecting .%s)", "Directory contained no VOD files (expecting .%s)",
SEGMENT_EXTENSION, SEGMENT_EXTENSION,
) )
os.Exit(1)
} }
if verbose { if verbose {
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(os.Stdout)
@ -217,39 +200,35 @@ func main() {
} }
// scan for thumbnail // scan for thumbnail
var thumbnail *yt.Thumbnail var thumbnail *yt.ThumbnailMetadata
thumbnailPath, thumbnailSizeBytes, err := scanner.ScanThumbnail(directory) thumbnailPath, thumbnailSizeBytes, err := scanner.ScanThumbnail(directory)
if err != nil { if err != nil {
log.Fatalf("Failed to fetch thumbnail: %v", err) log.Fatalf("Failed to fetch thumbnail: %v", err)
os.Exit(1)
} else { } else {
thumbnail = &yt.Thumbnail{ thumbnail = &yt.ThumbnailMetadata{
Filepath: thumbnailPath, Filepath: thumbnailPath,
SizeBytes: thumbnailSizeBytes, SizeBytes: thumbnailSizeBytes,
} }
} }
// build video template for upload // build video template for upload
video, err := yt.BuildVideo(metadata) videoMeta, err := yt.BuildVideo(metadata)
if err != nil { if err != nil {
log.Fatalf("Failed to build video template: %v", err) 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 { if err != nil {
log.Fatalf("Failed to build video title: %v", err) 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 { if err != nil {
log.Fatalf("Failed to build video description: %v", err) log.Fatalf("Failed to build video description: %v", err)
os.Exit(1)
} }
if len(title) > 100 { if len(title) > 100 {
log.Fatalf( log.Fatalf(
"Video title length exceeds %d characters (%d). YouTube may reject this!", "Video title length exceeds %d characters (%d). YouTube may reject this!",
MAX_TITLE_LEN, MAX_TITLE_LEN,
len(video.Title), len(videoMeta.Title),
) )
} }
if len(description) > 5000 { if len(description) > 5000 {
@ -262,7 +241,7 @@ func main() {
if verbose { if verbose {
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(os.Stdout)
fmt.Printf("\nVideo template: ") fmt.Printf("\nVideo template: ")
enc.Encode(video) enc.Encode(videoMeta)
fmt.Printf( fmt.Printf(
"\n================================\n\n" + "\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 // youtube oauth flow
ctx := context.Background()
oauth2Config := &oauth2.Config{ oauth2Config := &oauth2.Config{
ClientID: cfg.Google.ClientID, ClientID: cfg.Google.ClientID,
ClientSecret: cfg.Google.ClientSecret, ClientSecret: cfg.Google.ClientSecret,
@ -311,135 +264,133 @@ func main() {
if cfg.Token != nil { if cfg.Token != nil {
token = cfg.Token token = cfg.Token
} else { } else {
token, err = generateOAuthToken(&ctx, oauth2Config, cfg) token, err = oauth.GenerateToken(&ctx, oauth2Config, cfg)
if err != nil { if err != nil {
log.Fatalf("OAuth flow failed: %v", err) log.Fatalf("OAuth flow failed: %v", err)
os.Exit(1)
} }
cfg.Token = token cfg.Token = token
} }
tokenSource := oauth2Config.TokenSource(ctx, token) tokenSource := oauth2Config.TokenSource(ctx, token)
if err != nil { if err != nil {
log.Fatalf("Failed to create OAuth2 token source: %v", err) 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) log.Fatalf("Failed to save OAuth token: %v", err)
} }
// okay actually upload now! service, err := youtube.NewService(
ytVideo, err := yt.UploadVideo(ctx, tokenSource, video, thumbnail, templates) ctx,
option.WithScopes(youtube.YoutubeUploadScope),
option.WithTokenSource(tokenSource),
)
if err != nil { if err != nil {
log.Fatalf("Failed to upload video: %v", err) log.Fatalf("Failed to create youtube service: %v\n", err)
os.Exit(1)
} }
if verbose {
jsonString, err := json.MarshalIndent(ytVideo, "", " ") // skip uploading if already done
if err != nil { if (metadata.Uploaded || len(metadata.UploadURL) > 0) && !forceUpload {
log.Fatalf("Failed to marshal video data json: %v", err) 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)) if !dryRun {
} log.Printf(
log.Print("Video uploaded successfully!") "To upload anyways, use --force or update %s.",
scanner.METADATA_FILENAME,
// 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 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() os.Exit(0)
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 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")
}
} }

73
oauth/oauth.go Normal file
View file

@ -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
}

View file

@ -8,6 +8,7 @@ OPTIONS:
--init: Initialise `directory` as a VOD directory. --init: Initialise `directory` as a VOD directory.
--logout: Logs out of the current YouTube account. --logout: Logs out of the current YouTube account.
-d, --deleteAfter: Deletes the full VOD after upload. -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. -f, --force: Force uploading the VOD, even if it already exists.
SOURCE: https://codeberg.org/arimelody/vodular SOURCE: https://codeberg.org/arimelody/vodular

View file

@ -26,7 +26,8 @@ type (
Date toml.LocalDate `toml:"date"` Date toml.LocalDate `toml:"date"`
Tags []string `toml:"tags"` Tags []string `toml:"tags"`
FootageDir string `toml:"footage_dir"` 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."` Category *Category `toml:"category" comment:"(Optional) Category details, for additional credits."`
} }
@ -42,6 +43,28 @@ type (
const METADATA_FILENAME = "metadata.toml" 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) { func ScanSegments(directory string, extension string) ([]string, error) {
entries, err := os.ReadDir(directory) entries, err := os.ReadDir(directory)
if err != nil { if err != nil {

View file

@ -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( fileListPath := path.Join(
path.Dir(video.Filepath), path.Dir(video.Filepath),
"files.txt", "files.txt",

View file

@ -3,6 +3,7 @@ package youtube
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -13,7 +14,6 @@ import (
"arimelody.space/vodular/scanner" "arimelody.space/vodular/scanner"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3" "google.golang.org/api/youtube/v3"
) )
@ -35,7 +35,7 @@ type (
Url string Url string
} }
Video struct { VideoMetadata struct {
Title string Title string
Category *Category Category *Category
Part int Part int
@ -45,13 +45,15 @@ type (
SizeBytes int64 SizeBytes int64
} }
Thumbnail struct { ThumbnailMetadata struct {
Filepath string Filepath string
SizeBytes int64 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 var category *Category = nil
if metadata.Category != nil { if metadata.Category != nil {
category = &Category{ category = &Category{
@ -64,7 +66,7 @@ func BuildVideo(metadata *scanner.Metadata) (*Video, error) {
if !ok { category.Type = CATEGORY_ENTERTAINMENT } if !ok { category.Type = CATEGORY_ENTERTAINMENT }
} }
return &Video{ return &VideoMetadata{
Title: metadata.Title, Title: metadata.Title,
Category: category, Category: category,
Part: metadata.Part, Part: metadata.Part,
@ -155,7 +157,7 @@ func FetchTemplates(templateDir string) (*Template, error) {
if titleFile, err := os.ReadFile(titlePath); err == nil { if titleFile, err := os.ReadFile(titlePath); err == nil {
tmpl.Title, err = titleTemplate.Parse(string(titleFile)) tmpl.Title, err = titleTemplate.Parse(string(titleFile))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse title template: %v", err) return nil, fmt.Errorf("parse title template: %v", err)
} }
} else { } else {
if !os.IsNotExist(err) { return nil, err } 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 { if descriptionFile, err := os.ReadFile(descriptionPath); err == nil {
tmpl.Description, err = descriptionTemplate.Parse(string(descriptionFile)) tmpl.Description, err = descriptionTemplate.Parse(string(descriptionFile))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse description template: %v", err) return nil, fmt.Errorf("parse description template: %v", err)
} }
} else { } else {
if !os.IsNotExist(err) { return nil, err } if !os.IsNotExist(err) { return nil, err }
@ -195,7 +197,7 @@ func FetchTemplates(templateDir string) (*Template, error) {
return &tmpl, nil return &tmpl, nil
} }
func BuildTemplate(video *Video, tmpl *template.Template) (string, error) { func BuildTemplate(video *VideoMetadata, tmpl *template.Template) (string, error) {
out := &bytes.Buffer{} out := &bytes.Buffer{}
var category *MetaCategory var category *MetaCategory
@ -220,26 +222,18 @@ func BuildTemplate(video *Video, tmpl *template.Template) (string, error) {
func UploadVideo( func UploadVideo(
ctx context.Context, ctx context.Context,
tokenSource oauth2.TokenSource, tokenSource oauth2.TokenSource,
video *Video, service *youtube.Service,
thumbnail *Thumbnail, video *VideoMetadata,
templates *Template, templates *Template,
) (*youtube.Video, error) { ) (*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) 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) description, err := BuildTemplate(video, templates.Description)
if err != nil { return nil, fmt.Errorf("failed to build description: %v", err) } if err != nil { return nil, fmt.Errorf("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) videoService := youtube.NewVideosService(service)
@ -263,6 +257,7 @@ func UploadVideo(
PrivacyStatus: "private", PrivacyStatus: "private",
}, },
}).NotifySubscribers(false) }).NotifySubscribers(false)
videoInsertCall.Context(ctx)
videoFile, err := os.Open(video.Filepath) videoFile, err := os.Open(video.Filepath)
if err != nil { if err != nil {
@ -285,30 +280,81 @@ func UploadVideo(
return nil, err 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 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
}