···11-# ari's VOD uploader
11+# Vodular
22+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.
2333-This tool stitches together livestream VOD segments and automatically uploads
44-them to YouTube.
44+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!
5566-This tool expects to be run in a directory containing a [metadata](#metadata)
77-file, and targeting a footage directory containing `.mkv` files (these are
88-really quick and easy to stitch together).
66+## Quick Jump
77+- [Basic Usage](#basic-usage)
88+- [VOD Metadata](#vod-metadata)
99+- [Templates](#templates)
9101010-The template [title](template/title.txt) and
1111-[description](template/description.txt) files contain my current format
1212-for VOD upload metadata. They use generic Go templates
1111+## Basic usage
1212+1. Run the tool for the first time to generate a starter configuration file:
1313+```sh
1414+$ vodular
1515+New config file created (config.toml). Please edit this file before running again!
1616+```
13171414-## Basic usage
1818+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).
1919+> [!IMPORTANT] `config.toml` contains very sensitive credentials. Do not share this file with anyone.
15201616-Initialise a VOD directory:
2121+3. Initialise a VOD directory:
1722```sh
1818-vod-uploader --init /path/to/media
2323+$ vodular --init /path/to/vod
2424+Directory successfully initialised. Be sure to update metadata.toml before uploading!
1925```
20262121-Upload a VOD, deleting the redundant full VOD export afterwards:
2727+4. Modify your newly-created `metadata.toml` to your liking.
2828+2929+5. Upload a VOD (Optionally, delete the redundant full VOD export afterwards):
2230```sh
2323-vod-uploader -d /path/to/media
3131+$ vodular --deleteAfter /path/to/vod
2432```
25332626-## Metadata
3434+> [!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`.
27353636+## VOD Metadata
2837When `--init`ialising a directory, a `metadata.toml` file is created. This is a
2938plain-text file providing some simple options to customise uploads per
3039directory. See this example file with additional comments:
···46554756# (Optional) Category details, for additional credits.
4857[category]
4949-#
5858+# Game titles and generic categories are applicable here, i.e. "Minecraft", "Art", etc.
5059name = 'This Thing'
5160# Valid types: gaming, other (default: other)
5261type = 'other'
5362url = 'https://example.org'
5463```
55645656-## Options
5757-- `-h`, --help`: Show a help message.
5858-- `-v`, --verbose`: Show verbose logging output.
5959-- `--init`: Initialise `directory` as a VOD directory.
6060-- `-d`, --deleteAfter`: Deletes the full VOD after upload.
6161-- `-f`, --force`: Force uploading the VOD, even if it already exists.
6565+## Templates
6666+Template files can be created at `templates/title.txt`,
6767+`template/description.txt`, and `templates/tags.txt` respectively. These
6868+files can use Go's [text template format](https://pkg.go.dev/text/template) to
6969+customise VOD metadata on upload.
7070+7171+You can use the following data in templates:
7272+- **`.Title`:** The title of the stream.
7373+- **`.Date`:** The date of the stream.
7474+- **`.Part`:** The part number of the stream (Good for episodic streams!)
7575+- **`.Category`:** Stream category details. (**NOTE:** Wrap usage in `{{if .Category}}` to ensure this field exists first!)
7676+- **`.Category.Name`:** The stream category name (Game titles and generic categories are applicable here, i.e. "Minecraft", "Art", etc.)
7777+- **`.Category.Type`:** At this time, should only ever be `"gaming"` or `"other"`.
7878+- **`.Category.Url`:** A URL relevant to the category. Use this to direct viewers to what you were checking out!
7979+8080+Some helper functions are also provided:
8181+- **`FormatTime <time> <format>`:** Format the provided time (`.Date`) according to a [Go time format](https://go.dev/src/time/format.go).
8282+- **`ToLower <text>`:** Convert `text` to all-lowercase.
8383+- **`ToUpper <text>`:** Convert `text` to all-uppercase.
8484+8585+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!
62866387*made with <3 by ari melody, 2026*
+1-1
config/config.go
···3838func ReadConfig(filename string) (*Config, error) {
3939 cfgBytes, err := os.ReadFile(filename)
4040 if err != nil {
4141- if err == os.ErrNotExist {
4141+ if os.IsNotExist(err) {
4242 return nil, nil
4343 }
4444 return nil, fmt.Errorf("failed to open file: %v", err)
···1111 "path"
1212 "strings"
1313 "sync"
1414- "time"
15141615 "golang.org/x/oauth2"
1716 "golang.org/x/oauth2/google"
1817 "google.golang.org/api/youtube/v3"
19182020- "arimelody.space/live-vod-uploader/config"
2121- "arimelody.space/live-vod-uploader/scanner"
2222- vid "arimelody.space/live-vod-uploader/video"
2323- yt "arimelody.space/live-vod-uploader/youtube"
1919+ "arimelody.space/vodular/config"
2020+ "arimelody.space/vodular/scanner"
2121+ vid "arimelody.space/vodular/video"
2222+ yt "arimelody.space/vodular/youtube"
2423)
25242625//go:embed res/help.txt
···3534}
36353736func main() {
3737+ // config
3838+ cfg, err := config.ReadConfig(config.CONFIG_FILENAME)
3939+ if err != nil {
4040+ log.Fatalf("Failed to read config: %v", err)
4141+ os.Exit(1)
4242+ }
4343+ if cfg == nil {
4444+ err = config.GenerateConfig(config.CONFIG_FILENAME)
4545+ if err != nil {
4646+ log.Fatalf("Failed to generate config: %v", err)
4747+ os.Exit(1)
4848+ }
4949+ log.Printf(
5050+ "New config file created (%s). " +
5151+ "Please edit this file before running again!",
5252+ config.CONFIG_FILENAME,
5353+ )
5454+ os.Exit(0)
5555+ }
5656+5757+ // arguments
3858 if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" {
3959 showHelp()
4060 os.Exit(0)
···42624363 var verbose bool = false
4464 var initDirectory bool = false
6565+ var logout bool = false
4566 var deleteFullVod bool = false
4667 var forceUpload bool = false
4768 var directory string
···6586 case "--init":
6687 initDirectory = true
67888989+ case "--logout":
9090+ logout = true
9191+6892 case "-d":
6993 fallthrough
7094 case "--deleteAfter":
···85109 }
86110 }
871118888- // config
8989- cfg, err := config.ReadConfig(config.CONFIG_FILENAME)
9090- if err != nil {
9191- log.Fatalf("Failed to read config: %v", err)
9292- os.Exit(1)
9393- }
9494- if cfg == nil {
9595- err = config.GenerateConfig(config.CONFIG_FILENAME)
112112+ // logout (--logout)
113113+ if logout {
114114+ cfg.Token = nil
115115+ err = config.WriteConfig(cfg, config.CONFIG_FILENAME)
96116 if err != nil {
9797- log.Fatalf("Failed to generate config: %v", err)
117117+ log.Fatalf("Failed to write config: %v", err)
98118 os.Exit(1)
99119 }
100100- log.Printf(
101101- "New config file created (%s). " +
102102- "Please edit this file before running again!",
103103- config.CONFIG_FILENAME,
104104- )
120120+ log.Println("Logged out successfully.")
105121 os.Exit(0)
106106- }
107107-108108- // fetch default tags
109109- yt.DefaultTags, err = yt.GetDefaultTags(path.Join("template", "tags.txt"))
110110- if err != nil {
111111- log.Fatalf("Failed to fetch default tags: %v", err)
112112- os.Exit(1)
113122 }
114123115124 // initialising directory (--init)
···127136 os.Exit(0)
128137 }
129138139139+ // good to have early on
140140+ templates, err := yt.FetchTemplates()
141141+ if err != nil {
142142+ log.Fatalf("Failed to fetch templates: %v", err)
143143+ os.Exit(1)
144144+ }
145145+130146 // read directory metadata
131147 metadata, err := scanner.ReadMetadata(directory)
132148 if err != nil {
···154170 // default footage directory
155171 if len(metadata.FootageDir) == 0 {
156172 metadata.FootageDir = directory
173173+ } else if !strings.HasPrefix(metadata.FootageDir, "/") {
174174+ metadata.FootageDir = path.Join(directory, metadata.FootageDir)
157175 }
158176159177 // scan for VOD segments
···189207 fmt.Printf("\nVideo template: ")
190208 enc.Encode(video)
191209192192- title, err := yt.BuildTitle(video)
210210+ title, err := yt.BuildTemplate(video, templates.Title)
193211 if err != nil {
194212 log.Fatalf("Failed to build video title: %v", err)
195213 os.Exit(1)
196214 }
197197- description, err := yt.BuildDescription(video)
215215+ description, err := yt.BuildTemplate(video, templates.Description)
198216 if err != nil {
199217 log.Fatalf("Failed to build video description: %v", err)
200218 os.Exit(1)
201219 }
202220 fmt.Printf(
203203- "\nTITLE: %s\nDESCRIPTION: %s\n",
221221+ "\n================================\n" +
222222+ "TITLE:\n%s\n\n" +
223223+ "DESCRIPTION:\n%s\n" +
224224+ "\n================================\n",
204225 title, description,
205226 )
206227 }
···244265 }
245266246267 // okay actually upload now!
247247- ytVideo, err := yt.UploadVideo(ctx, tokenSource, video)
268268+ ytVideo, err := yt.UploadVideo(ctx, tokenSource, video, templates)
248269 if err != nil {
249270 log.Fatalf("Failed to upload video: %v", err)
250271 os.Exit(1)
···277298func initialiseDirectory(directory string) error {
278299 dirInfo, err := os.Stat(directory)
279300 if err != nil {
280280- if err == os.ErrNotExist {
301301+ if os.IsNotExist(err) {
281302 return fmt.Errorf("no such directory: %s", directory)
282303 }
283304 return fmt.Errorf("failed to open directory: %v", err)
···353374 return nil, fmt.Errorf("http: %v", err)
354375 }
355376 wg.Wait()
356356-357357- // TODO: save this token; look into token refresh
358358- log.Printf("Token expires at: %s\n", token.Expiry.Format(time.DateTime))
359377360378 return token, nil
361379}
+6-6
res/help.txt
···11-ari's VOD uploader
22-33-USAGE: %s [options] [directory]
11+Vodular
4255-This tool stitches together VOD segments and automatically uploads them to
66-YouTube. `directory` is assumed to be a directory containing a `metadata.toml`
77-(created with `--init`), and some `.mkv` files.
33+USAGE: vodular [options] [directory]
8495OPTIONS:
106 -h, --help: Show this help message.
117 -v, --verbose: Show verbose logging output.
128 --init: Initialise `directory` as a VOD directory.
99+ --logout: Logs out of the current YouTube account.
1310 -d, --deleteAfter: Deletes the full VOD after upload.
1411 -f, --force: Force uploading the VOD, even if it already exists.
1212+1313+SOURCE: https://codeberg.org/arimelody/vodular
1414+ISSUES: https://codeberg.org/arimelody/vodular/issues
15151616made with <3 by ari melody, 2026
···11-streamed on {{.Date}}
11+streamed on {{ToLower (FormatTime .Date "02 January 2006")}}
22💚 watch ari melody LIVE: https://twitch.tv/arispacegirl
33{{if .Category}}{{if eq .Category.Type "gaming"}}
44🎮 play {{.Category.Name}}:
template/tags.txt
templates/tags.txt
-1
template/title.txt
···11-{{.Title}}{{if gt .Part 0}}, part {{.Part}}{{end}} | ari melody LIVE 💚 | {{.Date}}
+1
templates/title.txt
···11+{{.Title}}{{if gt .Part 0}}, part {{.Part}}{{end}} | ari melody LIVE 💚 | {{ToLower (FormatTime .Date "02 Jan 2006")}}