Compare commits

...

3 commits

Author SHA1 Message Date
f3419ebe22
update readme 2026-06-23 19:08:08 +01:00
234caa159e
allow thumbnail.png to not exist 2026-06-23 19:07:51 +01:00
2fcbc31f77
automatically upload nearby thumbnail.png 2026-06-23 18:28:50 +01:00
5 changed files with 92 additions and 22 deletions

View file

@ -1,5 +1,5 @@
# Vodular # 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! 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! 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. 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 ```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`. **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`.

19
main.go
View file

@ -216,6 +216,19 @@ func main() {
enc.Encode(vodFiles) enc.Encode(vodFiles)
} }
// 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)
} else {
thumbnail = &yt.Thumbnail{
Filepath: thumbnailPath,
SizeBytes: thumbnailSizeBytes,
}
}
// build video template for upload // build video template for upload
video, err := yt.BuildVideo(metadata) video, err := yt.BuildVideo(metadata)
if err != nil { if err != nil {
@ -263,7 +276,7 @@ func main() {
// concatenate VOD segments into full VOD // concatenate VOD segments into full VOD
fullVodExists := func () bool { fullVodExists := func () bool {
// check if full VOD already exists with expected duration // 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 } if err != nil { return false }
video.SizeBytes = fullVodProbe.Format.Size video.SizeBytes = fullVodProbe.Format.Size
var totalLength float64 = 0 var totalLength float64 = 0
@ -317,7 +330,7 @@ func main() {
} }
// okay actually upload now! // okay actually upload now!
ytVideo, err := yt.UploadVideo(ctx, tokenSource, video, templates) ytVideo, err := yt.UploadVideo(ctx, tokenSource, video, thumbnail, templates)
if err != nil { if err != nil {
log.Fatalf("Failed to upload video: %v", err) log.Fatalf("Failed to upload video: %v", err)
os.Exit(1) os.Exit(1)
@ -341,7 +354,7 @@ func main() {
// delete full VOD after upload, if requested // delete full VOD after upload, if requested
if deleteFullVod { if deleteFullVod {
err = os.Remove(video.Filename) err = os.Remove(video.Filepath)
if err != nil { if err != nil {
log.Fatalf("Failed to delete full VOD: %v", err) log.Fatalf("Failed to delete full VOD: %v", err)
} }

View file

@ -2,6 +2,8 @@ package scanner
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt"
"os" "os"
"path" "path"
"strconv" "strconv"
@ -60,6 +62,21 @@ func ScanSegments(directory string, extension string) ([]string, error) {
return files, nil return files, nil
} }
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() {
return "", 0, fmt.Errorf("thumbnail.png is a directory")
}
return thumbnailPath, stat.Size(), nil
}
func ProbeSegment(filename string) (*FFprobeOutput, error) { func ProbeSegment(filename string) (*FFprobeOutput, error) {
out, err := ffmpeg_go.Probe(filename) out, err := ffmpeg_go.Probe(filename)
if err != nil { return nil, err } if err != nil { return nil, err }

View file

@ -7,6 +7,7 @@ import (
"os" "os"
"path" "path"
"strconv" "strconv"
"strings"
"arimelody.space/vodular/youtube" "arimelody.space/vodular/youtube"
ffmpeg "github.com/u2takey/ffmpeg-go" ffmpeg "github.com/u2takey/ffmpeg-go"
@ -23,15 +24,15 @@ type (
func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) (int64, error) { func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) (int64, error) {
fileListPath := path.Join( fileListPath := path.Join(
path.Dir(video.Filename), path.Dir(video.Filepath),
"files.txt", "files.txt",
) )
totalDuration := float64(0.0) totalDuration := float64(0.0)
fileListString := "" fileListString := strings.Builder{}
for _, file := range vodFiles { for _, file := range vodFiles {
fileListString += fmt.Sprintf("file '%s'\n", file) fmt.Fprintf(&fileListString, "file '%s'\n", file)
jsonProbe, err := ffmpeg.Probe(path.Join(path.Dir(video.Filename), file)) jsonProbe, err := ffmpeg.Probe(path.Join(path.Dir(video.Filepath), file))
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to probe file `%s`: %v", file, err) 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( err := os.WriteFile(
fileListPath, fileListPath,
[]byte(fileListString), []byte(fileListString.String()),
0644, 0644,
) )
if err != nil { if err != nil {
@ -55,7 +56,7 @@ func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) (int64,
stream := ffmpeg.Input(fileListPath, ffmpeg.KwArgs{ stream := ffmpeg.Input(fileListPath, ffmpeg.KwArgs{
"f": "concat", "f": "concat",
"safe": "0", "safe": "0",
}).Output(video.Filename, ffmpeg.KwArgs{ }).Output(video.Filepath, ffmpeg.KwArgs{
"c": "copy", "c": "copy",
}).OverWriteOutput() }).OverWriteOutput()
if verbose { stream = stream.ErrorToStdOut() } 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 // 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) } if err != nil { return 0, fmt.Errorf("failed to read output file: %v", err) }
return fileInfo.Size(), nil return fileInfo.Size(), nil

View file

@ -41,7 +41,12 @@ type (
Part int Part int
Date time.Time Date time.Time
Tags []string Tags []string
Filename string Filepath string
SizeBytes int64
}
Thumbnail struct {
Filepath string
SizeBytes int64 SizeBytes int64
} }
) )
@ -65,7 +70,7 @@ func BuildVideo(metadata *scanner.Metadata) (*Video, error) {
Part: metadata.Part, Part: metadata.Part,
Date: metadata.Date.AsTime(time.UTC), Date: metadata.Date.AsTime(time.UTC),
Tags: metadata.Tags, Tags: metadata.Tags,
Filename: path.Join( Filepath: path.Join(
metadata.FootageDir, metadata.FootageDir,
fmt.Sprintf( fmt.Sprintf(
"%s-fullvod.mkv", "%s-fullvod.mkv",
@ -216,6 +221,7 @@ func UploadVideo(
ctx context.Context, ctx context.Context,
tokenSource oauth2.TokenSource, tokenSource oauth2.TokenSource,
video *Video, video *Video,
thumbnail *Thumbnail,
templates *Template, templates *Template,
) (*youtube.Video, error) { ) (*youtube.Video, error) {
title, err := BuildTemplate(video, templates.Title) title, err := BuildTemplate(video, templates.Title)
@ -233,6 +239,8 @@ func UploadVideo(
return nil, err return nil, err
} }
// upload video
videoService := youtube.NewVideosService(service) videoService := youtube.NewVideosService(service)
categoryId := YT_CATEGORY_ENTERTAINMENT categoryId := YT_CATEGORY_ENTERTAINMENT
@ -242,7 +250,7 @@ func UploadVideo(
} }
} }
call := videoService.Insert([]string{ videoInsertCall := videoService.Insert([]string{
"snippet", "status", "snippet", "status",
}, &youtube.Video{ }, &youtube.Video{
Snippet: &youtube.VideoSnippet{ Snippet: &youtube.VideoSnippet{
@ -256,26 +264,51 @@ func UploadVideo(
}, },
}).NotifySubscribers(false) }).NotifySubscribers(false)
file, err := os.Open(video.Filename) videoFile, err := os.Open(video.Filepath)
if err != nil { if err != nil {
log.Fatalf("Failed to open file: %v\n", err) log.Fatalf("Failed to open video: %v\n", err)
return nil, err return nil, err
} }
call.Media(file) videoInsertCall.Media(videoFile)
log.Println("Uploading video...") log.Println("Uploading video...")
call.ProgressUpdater(func(current, total int64) { videoInsertCall.ProgressUpdater(func(current, total int64) {
// for some reason, this only returns 0. // for some reason, this only returns 0.
// instead, we pull the file size from the ffmpeg output directly. // instead, we pull the file size from the ffmpeg output directly.
if total == 0 { total = video.SizeBytes } 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 { if err != nil {
log.Fatalf("Failed to upload video: %v\n", err) log.Fatalf("Failed to upload video: %v\n", err)
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
} }