Helper tool for stitching together livestream VOD segments and uploading them to YouTube!

update README, lots of polish

+201 -112
+46 -22
README.md
··· 1 - # ari's VOD uploader 1 + # Vodular 2 + 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. 2 3 3 - This tool stitches together livestream VOD segments and automatically uploads 4 - them to YouTube. 4 + 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! 5 5 6 - This tool expects to be run in a directory containing a [metadata](#metadata) 7 - file, and targeting a footage directory containing `.mkv` files (these are 8 - really quick and easy to stitch together). 6 + ## Quick Jump 7 + - [Basic Usage](#basic-usage) 8 + - [VOD Metadata](#vod-metadata) 9 + - [Templates](#templates) 9 10 10 - The template [title](template/title.txt) and 11 - [description](template/description.txt) files contain my current format 12 - for VOD upload metadata. They use generic Go templates 11 + ## Basic usage 12 + 1. Run the tool for the first time to generate a starter configuration file: 13 + ```sh 14 + $ vodular 15 + New config file created (config.toml). Please edit this file before running again! 16 + ``` 13 17 14 - ## Basic usage 18 + 2. Edit configuration file as necessary (You will need to create a [YouTube Data API v3](https://developers.google.com/youtube/v3) service and provide its credentials here). 19 + > [!IMPORTANT] `config.toml` contains very sensitive credentials. Do not share this file with anyone. 15 20 16 - Initialise a VOD directory: 21 + 3. Initialise a VOD directory: 17 22 ```sh 18 - vod-uploader --init /path/to/media 23 + $ vodular --init /path/to/vod 24 + Directory successfully initialised. Be sure to update metadata.toml before uploading! 19 25 ``` 20 26 21 - Upload a VOD, deleting the redundant full VOD export afterwards: 27 + 4. Modify your newly-created `metadata.toml` to your liking. 28 + 29 + 5. Upload a VOD (Optionally, delete the redundant full VOD export afterwards): 22 30 ```sh 23 - vod-uploader -d /path/to/media 31 + $ vodular --deleteAfter /path/to/vod 24 32 ``` 25 33 26 - ## Metadata 34 + > [!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`. 27 35 36 + ## VOD Metadata 28 37 When `--init`ialising a directory, a `metadata.toml` file is created. This is a 29 38 plain-text file providing some simple options to customise uploads per 30 39 directory. See this example file with additional comments: ··· 46 55 47 56 # (Optional) Category details, for additional credits. 48 57 [category] 49 - 58 + # Game titles and generic categories are applicable here, i.e. "Minecraft", "Art", etc. 50 59 name = 'This Thing' 51 60 # Valid types: gaming, other (default: other) 52 61 type = 'other' 53 62 url = 'https://example.org' 54 63 ``` 55 64 56 - ## Options 57 - - `-h`, --help`: Show a help message. 58 - - `-v`, --verbose`: Show verbose logging output. 59 - - `--init`: Initialise `directory` as a VOD directory. 60 - - `-d`, --deleteAfter`: Deletes the full VOD after upload. 61 - - `-f`, --force`: Force uploading the VOD, even if it already exists. 65 + ## Templates 66 + Template files can be created at `templates/title.txt`, 67 + `template/description.txt`, and `templates/tags.txt` respectively. These 68 + files can use Go's [text template format](https://pkg.go.dev/text/template) to 69 + customise VOD metadata on upload. 70 + 71 + You can use the following data in templates: 72 + - **`.Title`:** The title of the stream. 73 + - **`.Date`:** The date of the stream. 74 + - **`.Part`:** The part number of the stream (Good for episodic streams!) 75 + - **`.Category`:** Stream category details. (**NOTE:** Wrap usage in `{{if .Category}}` to ensure this field exists first!) 76 + - **`.Category.Name`:** The stream category name (Game titles and generic categories are applicable here, i.e. "Minecraft", "Art", etc.) 77 + - **`.Category.Type`:** At this time, should only ever be `"gaming"` or `"other"`. 78 + - **`.Category.Url`:** A URL relevant to the category. Use this to direct viewers to what you were checking out! 79 + 80 + Some helper functions are also provided: 81 + - **`FormatTime <time> <format>`:** Format the provided time (`.Date`) according to a [Go time format](https://go.dev/src/time/format.go). 82 + - **`ToLower <text>`:** Convert `text` to all-lowercase. 83 + - **`ToUpper <text>`:** Convert `text` to all-uppercase. 84 + 85 + For reference, you can find my personal templates [here](templates/). These should prove helpful if you aren't already familiar with Go's templating language! 62 86 63 87 *made with <3 by ari melody, 2026*
+1 -1
config/config.go
··· 38 38 func ReadConfig(filename string) (*Config, error) { 39 39 cfgBytes, err := os.ReadFile(filename) 40 40 if err != nil { 41 - if err == os.ErrNotExist { 41 + if os.IsNotExist(err) { 42 42 return nil, nil 43 43 } 44 44 return nil, fmt.Errorf("failed to open file: %v", err)
+1 -1
go.mod
··· 1 - module arimelody.space/live-vod-uploader 1 + module arimelody.space/vodular 2 2 3 3 go 1.25.3 4 4
+52 -34
main.go
··· 11 11 "path" 12 12 "strings" 13 13 "sync" 14 - "time" 15 14 16 15 "golang.org/x/oauth2" 17 16 "golang.org/x/oauth2/google" 18 17 "google.golang.org/api/youtube/v3" 19 18 20 - "arimelody.space/live-vod-uploader/config" 21 - "arimelody.space/live-vod-uploader/scanner" 22 - vid "arimelody.space/live-vod-uploader/video" 23 - yt "arimelody.space/live-vod-uploader/youtube" 19 + "arimelody.space/vodular/config" 20 + "arimelody.space/vodular/scanner" 21 + vid "arimelody.space/vodular/video" 22 + yt "arimelody.space/vodular/youtube" 24 23 ) 25 24 26 25 //go:embed res/help.txt ··· 35 34 } 36 35 37 36 func main() { 37 + // config 38 + cfg, err := config.ReadConfig(config.CONFIG_FILENAME) 39 + if err != nil { 40 + log.Fatalf("Failed to read config: %v", err) 41 + os.Exit(1) 42 + } 43 + if cfg == nil { 44 + err = config.GenerateConfig(config.CONFIG_FILENAME) 45 + if err != nil { 46 + log.Fatalf("Failed to generate config: %v", err) 47 + os.Exit(1) 48 + } 49 + log.Printf( 50 + "New config file created (%s). " + 51 + "Please edit this file before running again!", 52 + config.CONFIG_FILENAME, 53 + ) 54 + os.Exit(0) 55 + } 56 + 57 + // arguments 38 58 if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" { 39 59 showHelp() 40 60 os.Exit(0) ··· 42 62 43 63 var verbose bool = false 44 64 var initDirectory bool = false 65 + var logout bool = false 45 66 var deleteFullVod bool = false 46 67 var forceUpload bool = false 47 68 var directory string ··· 65 86 case "--init": 66 87 initDirectory = true 67 88 89 + case "--logout": 90 + logout = true 91 + 68 92 case "-d": 69 93 fallthrough 70 94 case "--deleteAfter": ··· 85 109 } 86 110 } 87 111 88 - // config 89 - cfg, err := config.ReadConfig(config.CONFIG_FILENAME) 90 - if err != nil { 91 - log.Fatalf("Failed to read config: %v", err) 92 - os.Exit(1) 93 - } 94 - if cfg == nil { 95 - err = config.GenerateConfig(config.CONFIG_FILENAME) 112 + // logout (--logout) 113 + if logout { 114 + cfg.Token = nil 115 + err = config.WriteConfig(cfg, config.CONFIG_FILENAME) 96 116 if err != nil { 97 - log.Fatalf("Failed to generate config: %v", err) 117 + log.Fatalf("Failed to write config: %v", err) 98 118 os.Exit(1) 99 119 } 100 - log.Printf( 101 - "New config file created (%s). " + 102 - "Please edit this file before running again!", 103 - config.CONFIG_FILENAME, 104 - ) 120 + log.Println("Logged out successfully.") 105 121 os.Exit(0) 106 - } 107 - 108 - // fetch default tags 109 - yt.DefaultTags, err = yt.GetDefaultTags(path.Join("template", "tags.txt")) 110 - if err != nil { 111 - log.Fatalf("Failed to fetch default tags: %v", err) 112 - os.Exit(1) 113 122 } 114 123 115 124 // initialising directory (--init) ··· 127 136 os.Exit(0) 128 137 } 129 138 139 + // good to have early on 140 + templates, err := yt.FetchTemplates() 141 + if err != nil { 142 + log.Fatalf("Failed to fetch templates: %v", err) 143 + os.Exit(1) 144 + } 145 + 130 146 // read directory metadata 131 147 metadata, err := scanner.ReadMetadata(directory) 132 148 if err != nil { ··· 154 170 // default footage directory 155 171 if len(metadata.FootageDir) == 0 { 156 172 metadata.FootageDir = directory 173 + } else if !strings.HasPrefix(metadata.FootageDir, "/") { 174 + metadata.FootageDir = path.Join(directory, metadata.FootageDir) 157 175 } 158 176 159 177 // scan for VOD segments ··· 189 207 fmt.Printf("\nVideo template: ") 190 208 enc.Encode(video) 191 209 192 - title, err := yt.BuildTitle(video) 210 + title, err := yt.BuildTemplate(video, templates.Title) 193 211 if err != nil { 194 212 log.Fatalf("Failed to build video title: %v", err) 195 213 os.Exit(1) 196 214 } 197 - description, err := yt.BuildDescription(video) 215 + description, err := yt.BuildTemplate(video, templates.Description) 198 216 if err != nil { 199 217 log.Fatalf("Failed to build video description: %v", err) 200 218 os.Exit(1) 201 219 } 202 220 fmt.Printf( 203 - "\nTITLE: %s\nDESCRIPTION: %s\n", 221 + "\n================================\n" + 222 + "TITLE:\n%s\n\n" + 223 + "DESCRIPTION:\n%s\n" + 224 + "\n================================\n", 204 225 title, description, 205 226 ) 206 227 } ··· 244 265 } 245 266 246 267 // okay actually upload now! 247 - ytVideo, err := yt.UploadVideo(ctx, tokenSource, video) 268 + ytVideo, err := yt.UploadVideo(ctx, tokenSource, video, templates) 248 269 if err != nil { 249 270 log.Fatalf("Failed to upload video: %v", err) 250 271 os.Exit(1) ··· 277 298 func initialiseDirectory(directory string) error { 278 299 dirInfo, err := os.Stat(directory) 279 300 if err != nil { 280 - if err == os.ErrNotExist { 301 + if os.IsNotExist(err) { 281 302 return fmt.Errorf("no such directory: %s", directory) 282 303 } 283 304 return fmt.Errorf("failed to open directory: %v", err) ··· 353 374 return nil, fmt.Errorf("http: %v", err) 354 375 } 355 376 wg.Wait() 356 - 357 - // TODO: save this token; look into token refresh 358 - log.Printf("Token expires at: %s\n", token.Expiry.Format(time.DateTime)) 359 377 360 378 return token, nil 361 379 }
+6 -6
res/help.txt
··· 1 - ari's VOD uploader 2 - 3 - USAGE: %s [options] [directory] 1 + Vodular 4 2 5 - This tool stitches together VOD segments and automatically uploads them to 6 - YouTube. `directory` is assumed to be a directory containing a `metadata.toml` 7 - (created with `--init`), and some `.mkv` files. 3 + USAGE: vodular [options] [directory] 8 4 9 5 OPTIONS: 10 6 -h, --help: Show this help message. 11 7 -v, --verbose: Show verbose logging output. 12 8 --init: Initialise `directory` as a VOD directory. 9 + --logout: Logs out of the current YouTube account. 13 10 -d, --deleteAfter: Deletes the full VOD after upload. 14 11 -f, --force: Force uploading the VOD, even if it already exists. 12 + 13 + SOURCE: https://codeberg.org/arimelody/vodular 14 + ISSUES: https://codeberg.org/arimelody/vodular/issues 15 15 16 16 made with <3 by ari melody, 2026
+1 -1
scanner/scanner.go
··· 54 54 os.O_RDONLY, os.ModePerm, 55 55 ) 56 56 if err != nil { 57 - if err == os.ErrNotExist { 57 + if os.IsNotExist(err) { 58 58 return nil, nil 59 59 } 60 60 return nil, err
+1 -1
template/description.txt templates/description.txt
··· 1 - streamed on {{.Date}} 1 + streamed on {{ToLower (FormatTime .Date "02 January 2006")}} 2 2 💚 watch ari melody LIVE: https://twitch.tv/arispacegirl 3 3 {{if .Category}}{{if eq .Category.Type "gaming"}} 4 4 🎮 play {{.Category.Name}}:
template/tags.txt templates/tags.txt
-1
template/title.txt
··· 1 - {{.Title}}{{if gt .Part 0}}, part {{.Part}}{{end}} | ari melody LIVE 💚 | {{.Date}}
+1
templates/title.txt
··· 1 + {{.Title}}{{if gt .Part 0}}, part {{.Part}}{{end}} | ari melody LIVE 💚 | {{ToLower (FormatTime .Date "02 Jan 2006")}}
+1 -1
video/video.go
··· 8 8 "path" 9 9 "strconv" 10 10 11 - "arimelody.space/live-vod-uploader/youtube" 11 + "arimelody.space/vodular/youtube" 12 12 ffmpeg "github.com/u2takey/ffmpeg-go" 13 13 ) 14 14
+91 -44
youtube/youtube.go
··· 11 11 "text/template" 12 12 "time" 13 13 14 - "arimelody.space/live-vod-uploader/scanner" 14 + "arimelody.space/vodular/scanner" 15 15 "golang.org/x/oauth2" 16 16 "google.golang.org/api/option" 17 17 "google.golang.org/api/youtube/v3" ··· 69 69 Category: category, 70 70 Part: metadata.Part, 71 71 Date: videoDate, 72 - Tags: DefaultTags, 72 + Tags: metadata.Tags, 73 73 Filename: path.Join( 74 74 metadata.FootageDir, 75 75 fmt.Sprintf( ··· 88 88 89 89 Metadata struct { 90 90 Title string 91 - Date string 91 + Date time.Time 92 92 Category *MetaCategory 93 93 Part int 94 94 } 95 + 96 + Template struct { 97 + Title *template.Template 98 + Description *template.Template 99 + Tags []string 100 + } 95 101 ) 96 102 97 103 var videoCategoryTypeStrings = map[CategoryType]string{ ··· 107 113 CATEGORY_ENTERTAINMENT: YT_CATEGORY_ENTERTAINMENT, 108 114 } 109 115 110 - var DefaultTags []string 111 - func GetDefaultTags(filepath string) ([]string, error) { 112 - file, err := os.ReadFile(filepath) 113 - if err != nil { return nil, err } 116 + var templateDir = "templates" 117 + var tagsPath = path.Join(templateDir, "tags.txt") 118 + var titlePath = path.Join(templateDir, "title.txt") 119 + var descriptionPath = path.Join(templateDir, "description.txt") 114 120 115 - tags := strings.Split(string(file), "\n") 116 - return tags, nil 121 + const defaultTitleTemplate = 122 + "{{.Title}} - {{FormatTime .Date \"02 Jan 2006\"}}" 123 + const defaultDescriptionTemplate = 124 + "Streamed on {{FormatTime .Date \"02 January 2006\"}}" 125 + 126 + var templateFuncs = template.FuncMap{ 127 + "FormatTime": func (time time.Time, format string) string { 128 + return time.Format(format) 129 + }, 130 + "ToLower": func (str string) string { 131 + return strings.ToLower(str) 132 + }, 133 + "ToUpper": func (str string) string { 134 + return strings.ToUpper(str) 135 + }, 117 136 } 118 137 119 - var titleTemplate *template.Template = template.Must( 120 - template.ParseFiles("template/title.txt"), 121 - ) 122 - func BuildTitle(video *Video) (string, error) { 123 - out := &bytes.Buffer{} 138 + func FetchTemplates() (*Template, error) { 139 + tmpl := Template{} 124 140 125 - var category *MetaCategory 126 - if video.Category != nil { 127 - category = &MetaCategory{ 128 - Name: video.Category.Name, 129 - Type: videoCategoryTypeStrings[video.Category.Type], 130 - Url: video.Category.Url, 141 + // tags 142 + if tagsFile, err := os.ReadFile(tagsPath); err == nil { 143 + tmpl.Tags = strings.Split(string(tagsFile), "\n") 144 + } else { 145 + if !os.IsNotExist(err) { return nil, err } 146 + 147 + log.Fatalf( 148 + "%s not found. No default tags will be used.", 149 + tagsPath, 150 + ) 151 + tmpl.Tags = []string{} 152 + } 153 + 154 + // title 155 + titleTemplate := template.New("title").Funcs(templateFuncs) 156 + if titleFile, err := os.ReadFile(titlePath); err == nil { 157 + tmpl.Title, err = titleTemplate.Parse(string(titleFile)) 158 + if err != nil { 159 + return nil, fmt.Errorf("failed to parse title template: %v", err) 131 160 } 161 + } else { 162 + if !os.IsNotExist(err) { return nil, err } 163 + 164 + log.Fatalf( 165 + "%s not found. Falling back to default template:\n%s", 166 + titlePath, 167 + defaultTitleTemplate, 168 + ) 169 + tmpl.Title, err = titleTemplate.Parse(defaultTitleTemplate) 170 + if err != nil { panic(err) } 171 + 172 + os.WriteFile(titlePath, []byte(defaultTitleTemplate), 0644) 132 173 } 133 174 134 - // TODO: give templates date format and lowercase functions 135 - // these should not be hard-coded! 136 - err := titleTemplate.Execute(out, Metadata{ 137 - Title: video.Title, 138 - Date: strings.ToLower(video.Date.Format("02 Jan 2006")), 139 - Category: category, 140 - Part: video.Part, 141 - }) 175 + // description 176 + descriptionTemplate := template.New("description").Funcs(templateFuncs,) 177 + if descriptionFile, err := os.ReadFile(descriptionPath); err == nil { 178 + tmpl.Description, err = descriptionTemplate.Parse(string(descriptionFile)) 179 + if err != nil { 180 + return nil, fmt.Errorf("failed to parse description template: %v", err) 181 + } 182 + } else { 183 + if !os.IsNotExist(err) { return nil, err } 184 + 185 + log.Fatalf( 186 + "%s not found. Falling back to default template:\n%s", 187 + descriptionPath, 188 + defaultDescriptionTemplate, 189 + ) 190 + tmpl.Description, err = descriptionTemplate.Parse(defaultDescriptionTemplate) 191 + if err != nil { panic(err) } 192 + 193 + os.WriteFile(descriptionPath, []byte(defaultDescriptionTemplate), 0644) 194 + } 142 195 143 - return strings.TrimSpace(out.String()), err 196 + return &tmpl, nil 144 197 } 145 198 146 - var descriptionTemplate *template.Template = template.Must( 147 - template.ParseFiles("template/description.txt"), 148 - ) 149 - func BuildDescription(video *Video) (string, error) { 199 + func BuildTemplate(video *Video, tmpl *template.Template) (string, error) { 150 200 out := &bytes.Buffer{} 151 201 152 202 var category *MetaCategory ··· 158 208 } 159 209 } 160 210 161 - err := descriptionTemplate.Execute(out, Metadata{ 211 + err := tmpl.Execute(out, Metadata{ 162 212 Title: video.Title, 163 - Date: strings.ToLower(video.Date.Format("02 Jan 2006")), 213 + Date: video.Date, 164 214 Category: category, 165 215 Part: video.Part, 166 216 }) ··· 172 222 ctx context.Context, 173 223 tokenSource oauth2.TokenSource, 174 224 video *Video, 225 + templates *Template, 175 226 ) (*youtube.Video, error) { 176 - title, err := BuildTitle(video) 177 - if err != nil { 178 - return nil, fmt.Errorf("failed to build title: %v", err) 179 - } 180 - description, err := BuildDescription(video) 181 - if err != nil { 182 - return nil, fmt.Errorf("failed to build description: %v", err) 183 - } 227 + title, err := BuildTemplate(video, templates.Title) 228 + if err != nil { return nil, fmt.Errorf("failed to build title: %v", err) } 229 + description, err := BuildTemplate(video, templates.Description) 230 + if err != nil { return nil, fmt.Errorf("failed to build description: %v", err) } 184 231 185 232 service, err := youtube.NewService( 186 233 ctx, ··· 207 254 Snippet: &youtube.VideoSnippet{ 208 255 Title: title, 209 256 Description: description, 210 - Tags: append(DefaultTags, video.Tags...), 257 + Tags: append(templates.Tags, video.Tags...), 211 258 CategoryId: categoryId, // gaming 212 259 }, 213 260 Status: &youtube.VideoStatus{