From 2fcbc31f772a7073e86c34b97e9831bc3b18048d Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 23 Jun 2026 18:28:50 +0100 Subject: [PATCH 1/3] automatically upload nearby thumbnail.png --- main.go | 17 +++++++++++++--- scanner/scanner.go | 13 ++++++++++++ video/video.go | 15 +++++++------- youtube/youtube.go | 49 +++++++++++++++++++++++++++++++++++++--------- 4 files changed, 75 insertions(+), 19 deletions(-) diff --git a/main.go b/main.go index c4092c5..67cd1c2 100644 --- a/main.go +++ b/main.go @@ -216,6 +216,17 @@ func main() { enc.Encode(vodFiles) } + // scan for thumbnail + thumbnailPath, thumbnailSizeBytes, err := scanner.ScanThumbnail(directory) + if err != nil { + log.Fatalf("Failed to fetch thumbnail: %v", err) + os.Exit(1) + } + thumbnail := &yt.Thumbnail{ + Filepath: thumbnailPath, + SizeBytes: thumbnailSizeBytes, + } + // build video template for upload video, err := yt.BuildVideo(metadata) if err != nil { @@ -263,7 +274,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.Filename) + fullVodProbe, err := scanner.ProbeSegment(video.Filepath) if err != nil { return false } video.SizeBytes = fullVodProbe.Format.Size var totalLength float64 = 0 @@ -317,7 +328,7 @@ func main() { } // okay actually upload now! - ytVideo, err := yt.UploadVideo(ctx, tokenSource, video, templates) + ytVideo, err := yt.UploadVideo(ctx, tokenSource, video, thumbnail, templates) if err != nil { log.Fatalf("Failed to upload video: %v", err) os.Exit(1) @@ -341,7 +352,7 @@ func main() { // delete full VOD after upload, if requested if deleteFullVod { - err = os.Remove(video.Filename) + err = os.Remove(video.Filepath) if err != nil { log.Fatalf("Failed to delete full VOD: %v", err) } diff --git a/scanner/scanner.go b/scanner/scanner.go index eb5c420..606f269 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -2,6 +2,7 @@ package scanner import ( "encoding/json" + "fmt" "os" "path" "strconv" @@ -60,6 +61,18 @@ func ScanSegments(directory string, extension string) ([]string, error) { return files, nil } +func ScanThumbnail(directory string) (string, int64, error) { + thumbnailPath := path.Join(directory, "thumbnail.png") + stat, err := os.Stat(thumbnailPath) + if err != nil { + return "", 0, err + } + if stat.IsDir() { + return "", 0, fmt.Errorf("thumbnail.png is a directory") + } + return thumbnailPath, stat.Size(), nil +} + func ProbeSegment(filename string) (*FFprobeOutput, error) { out, err := ffmpeg_go.Probe(filename) if err != nil { return nil, err } diff --git a/video/video.go b/video/video.go index 4d13987..a9f54f1 100644 --- a/video/video.go +++ b/video/video.go @@ -7,6 +7,7 @@ import ( "os" "path" "strconv" + "strings" "arimelody.space/vodular/youtube" ffmpeg "github.com/u2takey/ffmpeg-go" @@ -23,15 +24,15 @@ type ( func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) (int64, error) { fileListPath := path.Join( - path.Dir(video.Filename), + path.Dir(video.Filepath), "files.txt", ) totalDuration := float64(0.0) - fileListString := "" + fileListString := strings.Builder{} for _, file := range vodFiles { - fileListString += fmt.Sprintf("file '%s'\n", file) - jsonProbe, err := ffmpeg.Probe(path.Join(path.Dir(video.Filename), file)) + fmt.Fprintf(&fileListString, "file '%s'\n", file) + jsonProbe, err := ffmpeg.Probe(path.Join(path.Dir(video.Filepath), file)) if err != nil { return 0, fmt.Errorf("failed to probe file `%s`: %v", file, err) } @@ -45,7 +46,7 @@ func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) (int64, } err := os.WriteFile( fileListPath, - []byte(fileListString), + []byte(fileListString.String()), 0644, ) if err != nil { @@ -55,7 +56,7 @@ func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) (int64, stream := ffmpeg.Input(fileListPath, ffmpeg.KwArgs{ "f": "concat", "safe": "0", - }).Output(video.Filename, ffmpeg.KwArgs{ + }).Output(video.Filepath, ffmpeg.KwArgs{ "c": "copy", }).OverWriteOutput() if verbose { stream = stream.ErrorToStdOut() } @@ -70,7 +71,7 @@ func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) (int64, // not the end of the world; move along } - fileInfo, err := os.Stat(video.Filename) + fileInfo, err := os.Stat(video.Filepath) if err != nil { return 0, fmt.Errorf("failed to read output file: %v", err) } return fileInfo.Size(), nil diff --git a/youtube/youtube.go b/youtube/youtube.go index f3f1873..5f0dc0f 100644 --- a/youtube/youtube.go +++ b/youtube/youtube.go @@ -41,7 +41,12 @@ type ( Part int Date time.Time Tags []string - Filename string + Filepath string + SizeBytes int64 + } + + Thumbnail struct { + Filepath string SizeBytes int64 } ) @@ -65,7 +70,7 @@ func BuildVideo(metadata *scanner.Metadata) (*Video, error) { Part: metadata.Part, Date: metadata.Date.AsTime(time.UTC), Tags: metadata.Tags, - Filename: path.Join( + Filepath: path.Join( metadata.FootageDir, fmt.Sprintf( "%s-fullvod.mkv", @@ -216,6 +221,7 @@ func UploadVideo( ctx context.Context, tokenSource oauth2.TokenSource, video *Video, + thumbnail *Thumbnail, templates *Template, ) (*youtube.Video, error) { title, err := BuildTemplate(video, templates.Title) @@ -233,6 +239,8 @@ func UploadVideo( return nil, err } + // upload video + videoService := youtube.NewVideosService(service) categoryId := YT_CATEGORY_ENTERTAINMENT @@ -242,7 +250,7 @@ func UploadVideo( } } - call := videoService.Insert([]string{ + videoInsertCall := videoService.Insert([]string{ "snippet", "status", }, &youtube.Video{ Snippet: &youtube.VideoSnippet{ @@ -256,26 +264,49 @@ func UploadVideo( }, }).NotifySubscribers(false) - file, err := os.Open(video.Filename) + videoFile, err := os.Open(video.Filepath) if err != nil { - log.Fatalf("Failed to open file: %v\n", err) + log.Fatalf("Failed to open video: %v\n", err) return nil, err } - call.Media(file) + videoInsertCall.Media(videoFile) log.Println("Uploading video...") - call.ProgressUpdater(func(current, total int64) { + videoInsertCall.ProgressUpdater(func(current, total int64) { // for some reason, this only returns 0. // instead, we pull the file size from the ffmpeg output directly. if total == 0 { total = video.SizeBytes } - fmt.Printf("Uploading... (%.2f%%)\n", float64(current) / float64(total) * 100) + fmt.Printf("\t(%.2f%%)\n", float64(current) / float64(total) * 100) }) - ytVideo, err := call.Do() + ytVideo, err := videoInsertCall.Do() if err != nil { log.Fatalf("Failed to upload video: %v\n", err) return nil, err } + // upload thumbnail + + thumbnailService := youtube.NewThumbnailsService(service) + thumbnailSetCall := thumbnailService.Set(ytVideo.Id) + thumbnailFile, err := os.Open(thumbnail.Filepath) + if err != nil { + log.Fatalf("Failed to open thumbnail: %v\n", err) + return nil, 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 } From 234caa159e0c8bb1bac99ec8c30226e0386f3ac1 Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 23 Jun 2026 19:07:51 +0100 Subject: [PATCH 2/3] allow thumbnail.png to not exist --- main.go | 10 ++++++---- scanner/scanner.go | 4 ++++ youtube/youtube.go | 38 ++++++++++++++++++++------------------ 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/main.go b/main.go index 67cd1c2..74572d1 100644 --- a/main.go +++ b/main.go @@ -217,14 +217,16 @@ func main() { } // scan for thumbnail + var thumbnail *yt.Thumbnail thumbnailPath, thumbnailSizeBytes, err := scanner.ScanThumbnail(directory) if err != nil { log.Fatalf("Failed to fetch thumbnail: %v", err) os.Exit(1) - } - thumbnail := &yt.Thumbnail{ - Filepath: thumbnailPath, - SizeBytes: thumbnailSizeBytes, + } else { + thumbnail = &yt.Thumbnail{ + Filepath: thumbnailPath, + SizeBytes: thumbnailSizeBytes, + } } // build video template for upload diff --git a/scanner/scanner.go b/scanner/scanner.go index 606f269..b124036 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -2,6 +2,7 @@ package scanner import ( "encoding/json" + "errors" "fmt" "os" "path" @@ -65,6 +66,9 @@ func ScanThumbnail(directory string) (string, int64, error) { thumbnailPath := path.Join(directory, "thumbnail.png") stat, err := os.Stat(thumbnailPath) if err != nil { + if os.IsNotExist(err) { + return "", 0, err + } return "", 0, err } if stat.IsDir() { diff --git a/youtube/youtube.go b/youtube/youtube.go index 5f0dc0f..96b18fc 100644 --- a/youtube/youtube.go +++ b/youtube/youtube.go @@ -285,27 +285,29 @@ func UploadVideo( return nil, err } - // upload thumbnail + if thumbnail != nil { + // upload thumbnail - thumbnailService := youtube.NewThumbnailsService(service) - thumbnailSetCall := thumbnailService.Set(ytVideo.Id) - thumbnailFile, err := os.Open(thumbnail.Filepath) - if err != nil { - log.Fatalf("Failed to open thumbnail: %v\n", err) - return nil, err - } - thumbnailSetCall.Media(thumbnailFile) + 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...") + 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) + 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 From f3419ebe22142a485bb99f9cfdc31f4bd3f031a2 Mon Sep 17 00:00:00 2001 From: ari melody Date: Tue, 23 Jun 2026 19:08:08 +0100 Subject: [PATCH 3/3] update readme --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ff53e8f..25360ea 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Vodular -This tool stitches together livestream VOD segments (in `.mkv`format) and automatically uploads them to YouTube, complete with customisable metadata such as titles, descriptions, and tags. +This tool stitches together livestream VOD segments (in `.mkv`format) and automatically uploads them to YouTube, complete with customisable metadata such as titles, descriptions, tags, and a thumbnail! I built this to greatly simplify the process of getting my full-quality livestream VODs onto YouTube, and I'm open-sourcing it in the hopes that it helps someone else with their workflow. As such, personal forks are welcome and encouraged! @@ -29,11 +29,17 @@ $ vodular --init /path/to/vod Directory successfully initialised. Be sure to update metadata.toml before uploading! ``` +This directory should contain: +- A `metadata.toml` file +- Your footage files, either at the root of the directory or in a specified subdirectory +- Your thumbnail, specifically named `thumbnail.png` + 4. Modify your newly-created `metadata.toml` to your liking. -5. Upload a VOD (Optionally, delete the redundant full VOD export afterwards): +5. Upload a VOD! ```sh -$ vodular --deleteAfter /path/to/vod +# `--deleteAfter` deletes the redundant full VOD export afterwards +vodular --deleteAfter /path/to/vod ``` **NOTE:** On first run, you will be prompted to sign in to YouTube with the channel you wish to upload to. To sign out, simply run `vodular --logout`.