update README, lots of polish

This commit is contained in:
ari melody 2026-01-30 18:53:09 +00:00
parent 4624c56e54
commit ce836b2d46
Signed by: ari
GPG key ID: CF99829C92678188
11 changed files with 210 additions and 121 deletions

View file

@ -1,30 +1,39 @@
# ari's VOD uploader # 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 and automatically uploads 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!
them to YouTube.
This tool expects to be run in a directory containing a [metadata](#metadata) ## Quick Jump
file, and targeting a footage directory containing `.mkv` files (these are - [Basic Usage](#basic-usage)
really quick and easy to stitch together). - [VOD Metadata](#vod-metadata)
- [Templates](#templates)
The template [title](template/title.txt) and ## Basic usage
[description](template/description.txt) files contain my current format 1. Run the tool for the first time to generate a starter configuration file:
for VOD upload metadata. They use generic Go templates
## Basic usage
Initialise a VOD directory:
```sh ```sh
vod-uploader --init /path/to/media $ vodular
New config file created (config.toml). Please edit this file before running again!
``` ```
Upload a VOD, deleting the redundant full VOD export afterwards: 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).
> [!IMPORTANT] `config.toml` contains very sensitive credentials. Do not share this file with anyone.
3. Initialise a VOD directory:
```sh ```sh
vod-uploader -d /path/to/media $ vodular --init /path/to/vod
Directory successfully initialised. Be sure to update metadata.toml before uploading!
``` ```
## Metadata 4. Modify your newly-created `metadata.toml` to your liking.
5. Upload a VOD (Optionally, delete the redundant full VOD export afterwards):
```sh
$ 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`.
## VOD Metadata
When `--init`ialising a directory, a `metadata.toml` file is created. This is a When `--init`ialising a directory, a `metadata.toml` file is created. This is a
plain-text file providing some simple options to customise uploads per plain-text file providing some simple options to customise uploads per
directory. See this example file with additional comments: directory. See this example file with additional comments:
@ -46,18 +55,33 @@ uploaded = false
# (Optional) Category details, for additional credits. # (Optional) Category details, for additional credits.
[category] [category]
#  # Game titles and generic categories are applicable here, i.e. "Minecraft", "Art", etc.
name = 'This Thing' name = 'This Thing'
# Valid types: gaming, other (default: other) # Valid types: gaming, other (default: other)
type = 'other' type = 'other'
url = 'https://example.org' url = 'https://example.org'
``` ```
## Options ## Templates
- `-h`, --help`: Show a help message. Template files can be created at `templates/title.txt`,
- `-v`, --verbose`: Show verbose logging output. `template/description.txt`, and `templates/tags.txt` respectively. These
- `--init`: Initialise `directory` as a VOD directory. files can use Go's [text template format](https://pkg.go.dev/text/template) to
- `-d`, --deleteAfter`: Deletes the full VOD after upload. customise VOD metadata on upload.
- `-f`, --force`: Force uploading the VOD, even if it already exists.
You can use the following data in templates:
- **`.Title`:** The title of the stream.
- **`.Date`:** The date of the stream.
- **`.Part`:** The part number of the stream (Good for episodic streams!)
- **`.Category`:** Stream category details. (**NOTE:** Wrap usage in `{{if .Category}}` to ensure this field exists first!)
- **`.Category.Name`:** The stream category name (Game titles and generic categories are applicable here, i.e. "Minecraft", "Art", etc.)
- **`.Category.Type`:** At this time, should only ever be `"gaming"` or `"other"`.
- **`.Category.Url`:** A URL relevant to the category. Use this to direct viewers to what you were checking out!
Some helper functions are also provided:
- **`FormatTime <time> <format>`:** Format the provided time (`.Date`) according to a [Go time format](https://go.dev/src/time/format.go).
- **`ToLower <text>`:** Convert `text` to all-lowercase.
- **`ToUpper <text>`:** Convert `text` to all-uppercase.
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!
*made with <3 by ari melody, 2026* *made with <3 by ari melody, 2026*

View file

@ -38,7 +38,7 @@ const CONFIG_FILENAME = "config.toml"
func ReadConfig(filename string) (*Config, error) { func ReadConfig(filename string) (*Config, error) {
cfgBytes, err := os.ReadFile(filename) cfgBytes, err := os.ReadFile(filename)
if err != nil { if err != nil {
if err == os.ErrNotExist { if os.IsNotExist(err) {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("failed to open file: %v", err) return nil, fmt.Errorf("failed to open file: %v", err)

2
go.mod
View file

@ -1,4 +1,4 @@
module arimelody.space/live-vod-uploader module arimelody.space/vodular
go 1.25.3 go 1.25.3

86
main.go
View file

@ -11,16 +11,15 @@ import (
"path" "path"
"strings" "strings"
"sync" "sync"
"time"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/oauth2/google" "golang.org/x/oauth2/google"
"google.golang.org/api/youtube/v3" "google.golang.org/api/youtube/v3"
"arimelody.space/live-vod-uploader/config" "arimelody.space/vodular/config"
"arimelody.space/live-vod-uploader/scanner" "arimelody.space/vodular/scanner"
vid "arimelody.space/live-vod-uploader/video" vid "arimelody.space/vodular/video"
yt "arimelody.space/live-vod-uploader/youtube" yt "arimelody.space/vodular/youtube"
) )
//go:embed res/help.txt //go:embed res/help.txt
@ -35,6 +34,27 @@ func showHelp() {
} }
func main() { func main() {
// config
cfg, err := config.ReadConfig(config.CONFIG_FILENAME)
if err != nil {
log.Fatalf("Failed to read config: %v", err)
os.Exit(1)
}
if cfg == nil {
err = config.GenerateConfig(config.CONFIG_FILENAME)
if err != nil {
log.Fatalf("Failed to generate config: %v", err)
os.Exit(1)
}
log.Printf(
"New config file created (%s). " +
"Please edit this file before running again!",
config.CONFIG_FILENAME,
)
os.Exit(0)
}
// arguments
if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" { if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" {
showHelp() showHelp()
os.Exit(0) os.Exit(0)
@ -42,6 +62,7 @@ func main() {
var verbose bool = false var verbose bool = false
var initDirectory bool = false var initDirectory bool = false
var logout bool = false
var deleteFullVod bool = false var deleteFullVod bool = false
var forceUpload bool = false var forceUpload bool = false
var directory string var directory string
@ -65,6 +86,9 @@ func main() {
case "--init": case "--init":
initDirectory = true initDirectory = true
case "--logout":
logout = true
case "-d": case "-d":
fallthrough fallthrough
case "--deleteAfter": case "--deleteAfter":
@ -85,33 +109,18 @@ func main() {
} }
} }
// config // logout (--logout)
cfg, err := config.ReadConfig(config.CONFIG_FILENAME) if logout {
cfg.Token = nil
err = config.WriteConfig(cfg, config.CONFIG_FILENAME)
if err != nil { if err != nil {
log.Fatalf("Failed to read config: %v", err) log.Fatalf("Failed to write config: %v", err)
os.Exit(1) os.Exit(1)
} }
if cfg == nil { log.Println("Logged out successfully.")
err = config.GenerateConfig(config.CONFIG_FILENAME)
if err != nil {
log.Fatalf("Failed to generate config: %v", err)
os.Exit(1)
}
log.Printf(
"New config file created (%s). " +
"Please edit this file before running again!",
config.CONFIG_FILENAME,
)
os.Exit(0) os.Exit(0)
} }
// fetch default tags
yt.DefaultTags, err = yt.GetDefaultTags(path.Join("template", "tags.txt"))
if err != nil {
log.Fatalf("Failed to fetch default tags: %v", err)
os.Exit(1)
}
// initialising directory (--init) // initialising directory (--init)
if initDirectory { if initDirectory {
err = initialiseDirectory(directory) err = initialiseDirectory(directory)
@ -127,6 +136,13 @@ func main() {
os.Exit(0) os.Exit(0)
} }
// good to have early on
templates, err := yt.FetchTemplates()
if err != nil {
log.Fatalf("Failed to fetch templates: %v", err)
os.Exit(1)
}
// read directory metadata // read directory metadata
metadata, err := scanner.ReadMetadata(directory) metadata, err := scanner.ReadMetadata(directory)
if err != nil { if err != nil {
@ -154,6 +170,8 @@ func main() {
// default footage directory // default footage directory
if len(metadata.FootageDir) == 0 { if len(metadata.FootageDir) == 0 {
metadata.FootageDir = directory metadata.FootageDir = directory
} else if !strings.HasPrefix(metadata.FootageDir, "/") {
metadata.FootageDir = path.Join(directory, metadata.FootageDir)
} }
// scan for VOD segments // scan for VOD segments
@ -189,18 +207,21 @@ func main() {
fmt.Printf("\nVideo template: ") fmt.Printf("\nVideo template: ")
enc.Encode(video) enc.Encode(video)
title, err := yt.BuildTitle(video) title, err := yt.BuildTemplate(video, 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) os.Exit(1)
} }
description, err := yt.BuildDescription(video) description, err := yt.BuildTemplate(video, 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) os.Exit(1)
} }
fmt.Printf( fmt.Printf(
"\nTITLE: %s\nDESCRIPTION: %s\n", "\n================================\n" +
"TITLE:\n%s\n\n" +
"DESCRIPTION:\n%s\n" +
"\n================================\n",
title, description, title, description,
) )
} }
@ -244,7 +265,7 @@ func main() {
} }
// okay actually upload now! // okay actually upload now!
ytVideo, err := yt.UploadVideo(ctx, tokenSource, video) ytVideo, err := yt.UploadVideo(ctx, tokenSource, video, 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)
@ -277,7 +298,7 @@ func main() {
func initialiseDirectory(directory string) error { func initialiseDirectory(directory string) error {
dirInfo, err := os.Stat(directory) dirInfo, err := os.Stat(directory)
if err != nil { if err != nil {
if err == os.ErrNotExist { if os.IsNotExist(err) {
return fmt.Errorf("no such directory: %s", directory) return fmt.Errorf("no such directory: %s", directory)
} }
return fmt.Errorf("failed to open directory: %v", err) return fmt.Errorf("failed to open directory: %v", err)
@ -354,8 +375,5 @@ func generateOAuthToken(
} }
wg.Wait() wg.Wait()
// TODO: save this token; look into token refresh
log.Printf("Token expires at: %s\n", token.Expiry.Format(time.DateTime))
return token, nil return token, nil
} }

View file

@ -1,16 +1,16 @@
ari's VOD uploader Vodular
USAGE: %s [options] [directory] USAGE: vodular [options] [directory]
This tool stitches together VOD segments and automatically uploads them to
YouTube. `directory` is assumed to be a directory containing a `metadata.toml`
(created with `--init`), and some `.mkv` files.
OPTIONS: OPTIONS:
-h, --help: Show this help message. -h, --help: Show this help message.
-v, --verbose: Show verbose logging output. -v, --verbose: Show verbose logging output.
--init: Initialise `directory` as a VOD directory. --init: Initialise `directory` as a VOD directory.
--logout: Logs out of the current YouTube account.
-d, --deleteAfter: Deletes the full VOD after upload. -d, --deleteAfter: Deletes the full VOD after upload.
-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
ISSUES: https://codeberg.org/arimelody/vodular/issues
made with <3 by ari melody, 2026 made with <3 by ari melody, 2026

View file

@ -54,7 +54,7 @@ func ReadMetadata(directory string) (*Metadata, error) {
os.O_RDONLY, os.ModePerm, os.O_RDONLY, os.ModePerm,
) )
if err != nil { if err != nil {
if err == os.ErrNotExist { if os.IsNotExist(err) {
return nil, nil return nil, nil
} }
return nil, err return nil, err

View file

@ -1,4 +1,4 @@
streamed on {{.Date}} streamed on {{ToLower (FormatTime .Date "02 January 2006")}}
💚 watch ari melody LIVE: https://twitch.tv/arispacegirl 💚 watch ari melody LIVE: https://twitch.tv/arispacegirl
{{if .Category}}{{if eq .Category.Type "gaming"}} {{if .Category}}{{if eq .Category.Type "gaming"}}
🎮 play {{.Category.Name}}: 🎮 play {{.Category.Name}}:

View file

@ -1 +1 @@
{{.Title}}{{if gt .Part 0}}, part {{.Part}}{{end}} | ari melody LIVE 💚 | {{.Date}} {{.Title}}{{if gt .Part 0}}, part {{.Part}}{{end}} | ari melody LIVE 💚 | {{ToLower (FormatTime .Date "02 Jan 2006")}}

View file

@ -8,7 +8,7 @@ import (
"path" "path"
"strconv" "strconv"
"arimelody.space/live-vod-uploader/youtube" "arimelody.space/vodular/youtube"
ffmpeg "github.com/u2takey/ffmpeg-go" ffmpeg "github.com/u2takey/ffmpeg-go"
) )

View file

@ -11,7 +11,7 @@ import (
"text/template" "text/template"
"time" "time"
"arimelody.space/live-vod-uploader/scanner" "arimelody.space/vodular/scanner"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"google.golang.org/api/option" "google.golang.org/api/option"
"google.golang.org/api/youtube/v3" "google.golang.org/api/youtube/v3"
@ -69,7 +69,7 @@ func BuildVideo(metadata *scanner.Metadata) (*Video, error) {
Category: category, Category: category,
Part: metadata.Part, Part: metadata.Part,
Date: videoDate, Date: videoDate,
Tags: DefaultTags, Tags: metadata.Tags,
Filename: path.Join( Filename: path.Join(
metadata.FootageDir, metadata.FootageDir,
fmt.Sprintf( fmt.Sprintf(
@ -88,10 +88,16 @@ type (
Metadata struct { Metadata struct {
Title string Title string
Date string Date time.Time
Category *MetaCategory Category *MetaCategory
Part int Part int
} }
Template struct {
Title *template.Template
Description *template.Template
Tags []string
}
) )
var videoCategoryTypeStrings = map[CategoryType]string{ var videoCategoryTypeStrings = map[CategoryType]string{
@ -107,19 +113,90 @@ var videoYtCategory = map[CategoryType]string {
CATEGORY_ENTERTAINMENT: YT_CATEGORY_ENTERTAINMENT, CATEGORY_ENTERTAINMENT: YT_CATEGORY_ENTERTAINMENT,
} }
var DefaultTags []string var templateDir = "templates"
func GetDefaultTags(filepath string) ([]string, error) { var tagsPath = path.Join(templateDir, "tags.txt")
file, err := os.ReadFile(filepath) var titlePath = path.Join(templateDir, "title.txt")
if err != nil { return nil, err } var descriptionPath = path.Join(templateDir, "description.txt")
tags := strings.Split(string(file), "\n") const defaultTitleTemplate =
return tags, nil "{{.Title}} - {{FormatTime .Date \"02 Jan 2006\"}}"
const defaultDescriptionTemplate =
"Streamed on {{FormatTime .Date \"02 January 2006\"}}"
var templateFuncs = template.FuncMap{
"FormatTime": func (time time.Time, format string) string {
return time.Format(format)
},
"ToLower": func (str string) string {
return strings.ToLower(str)
},
"ToUpper": func (str string) string {
return strings.ToUpper(str)
},
} }
var titleTemplate *template.Template = template.Must( func FetchTemplates() (*Template, error) {
template.ParseFiles("template/title.txt"), tmpl := Template{}
)
func BuildTitle(video *Video) (string, error) { // tags
if tagsFile, err := os.ReadFile(tagsPath); err == nil {
tmpl.Tags = strings.Split(string(tagsFile), "\n")
} else {
if !os.IsNotExist(err) { return nil, err }
log.Fatalf(
"%s not found. No default tags will be used.",
tagsPath,
)
tmpl.Tags = []string{}
}
// title
titleTemplate := template.New("title").Funcs(templateFuncs)
if titleFile, err := os.ReadFile(titlePath); err == nil {
tmpl.Title, err = titleTemplate.Parse(string(titleFile))
if err != nil {
return nil, fmt.Errorf("failed to parse title template: %v", err)
}
} else {
if !os.IsNotExist(err) { return nil, err }
log.Fatalf(
"%s not found. Falling back to default template:\n%s",
titlePath,
defaultTitleTemplate,
)
tmpl.Title, err = titleTemplate.Parse(defaultTitleTemplate)
if err != nil { panic(err) }
os.WriteFile(titlePath, []byte(defaultTitleTemplate), 0644)
}
// description
descriptionTemplate := template.New("description").Funcs(templateFuncs,)
if descriptionFile, err := os.ReadFile(descriptionPath); err == nil {
tmpl.Description, err = descriptionTemplate.Parse(string(descriptionFile))
if err != nil {
return nil, fmt.Errorf("failed to parse description template: %v", err)
}
} else {
if !os.IsNotExist(err) { return nil, err }
log.Fatalf(
"%s not found. Falling back to default template:\n%s",
descriptionPath,
defaultDescriptionTemplate,
)
tmpl.Description, err = descriptionTemplate.Parse(defaultDescriptionTemplate)
if err != nil { panic(err) }
os.WriteFile(descriptionPath, []byte(defaultDescriptionTemplate), 0644)
}
return &tmpl, nil
}
func BuildTemplate(video *Video, tmpl *template.Template) (string, error) {
out := &bytes.Buffer{} out := &bytes.Buffer{}
var category *MetaCategory var category *MetaCategory
@ -131,36 +208,9 @@ func BuildTitle(video *Video) (string, error) {
} }
} }
// TODO: give templates date format and lowercase functions err := tmpl.Execute(out, Metadata{
// these should not be hard-coded!
err := titleTemplate.Execute(out, Metadata{
Title: video.Title, Title: video.Title,
Date: strings.ToLower(video.Date.Format("02 Jan 2006")), Date: video.Date,
Category: category,
Part: video.Part,
})
return strings.TrimSpace(out.String()), err
}
var descriptionTemplate *template.Template = template.Must(
template.ParseFiles("template/description.txt"),
)
func BuildDescription(video *Video) (string, error) {
out := &bytes.Buffer{}
var category *MetaCategory
if video.Category != nil {
category = &MetaCategory{
Name: video.Category.Name,
Type: videoCategoryTypeStrings[video.Category.Type],
Url: video.Category.Url,
}
}
err := descriptionTemplate.Execute(out, Metadata{
Title: video.Title,
Date: strings.ToLower(video.Date.Format("02 Jan 2006")),
Category: category, Category: category,
Part: video.Part, Part: video.Part,
}) })
@ -172,15 +222,12 @@ func UploadVideo(
ctx context.Context, ctx context.Context,
tokenSource oauth2.TokenSource, tokenSource oauth2.TokenSource,
video *Video, video *Video,
templates *Template,
) (*youtube.Video, error) { ) (*youtube.Video, error) {
title, err := BuildTitle(video) title, err := BuildTemplate(video, templates.Title)
if err != nil { if err != nil { return nil, fmt.Errorf("failed to build title: %v", err) }
return nil, fmt.Errorf("failed to build title: %v", err) description, err := BuildTemplate(video, templates.Description)
} if err != nil { return nil, fmt.Errorf("failed to build description: %v", err) }
description, err := BuildDescription(video)
if err != nil {
return nil, fmt.Errorf("failed to build description: %v", err)
}
service, err := youtube.NewService( service, err := youtube.NewService(
ctx, ctx,
@ -207,7 +254,7 @@ func UploadVideo(
Snippet: &youtube.VideoSnippet{ Snippet: &youtube.VideoSnippet{
Title: title, Title: title,
Description: description, Description: description,
Tags: append(DefaultTags, video.Tags...), Tags: append(templates.Tags, video.Tags...),
CategoryId: categoryId, // gaming CategoryId: categoryId, // gaming
}, },
Status: &youtube.VideoStatus{ Status: &youtube.VideoStatus{