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
them to YouTube.
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!
This tool expects to be run in a directory containing a [metadata](#metadata)
file, and targeting a footage directory containing `.mkv` files (these are
really quick and easy to stitch together).
## Quick Jump
- [Basic Usage](#basic-usage)
- [VOD Metadata](#vod-metadata)
- [Templates](#templates)
The template [title](template/title.txt) and
[description](template/description.txt) files contain my current format
for VOD upload metadata. They use generic Go templates
## Basic usage
Initialise a VOD directory:
## Basic usage
1. Run the tool for the first time to generate a starter configuration file:
```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
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
plain-text file providing some simple options to customise uploads per
directory. See this example file with additional comments:
@ -46,18 +55,33 @@ uploaded = false
# (Optional) Category details, for additional credits.
[category]
# 
# Game titles and generic categories are applicable here, i.e. "Minecraft", "Art", etc.
name = 'This Thing'
# Valid types: gaming, other (default: other)
type = 'other'
url = 'https://example.org'
```
## Options
- `-h`, --help`: Show a help message.
- `-v`, --verbose`: Show verbose logging output.
- `--init`: Initialise `directory` as a VOD directory.
- `-d`, --deleteAfter`: Deletes the full VOD after upload.
- `-f`, --force`: Force uploading the VOD, even if it already exists.
## Templates
Template files can be created at `templates/title.txt`,
`template/description.txt`, and `templates/tags.txt` respectively. These
files can use Go's [text template format](https://pkg.go.dev/text/template) to
customise VOD metadata on upload.
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*

View file

@ -38,7 +38,7 @@ const CONFIG_FILENAME = "config.toml"
func ReadConfig(filename string) (*Config, error) {
cfgBytes, err := os.ReadFile(filename)
if err != nil {
if err == os.ErrNotExist {
if os.IsNotExist(err) {
return nil, nil
}
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

86
main.go
View file

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

View file

@ -1,16 +1,16 @@
ari's VOD uploader
Vodular
USAGE: %s [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.
USAGE: vodular [options] [directory]
OPTIONS:
-h, --help: Show this help message.
-v, --verbose: Show verbose logging output.
--init: Initialise `directory` as a VOD directory.
--logout: Logs out of the current YouTube account.
-d, --deleteAfter: Deletes the full VOD after upload.
-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

View file

@ -54,7 +54,7 @@ func ReadMetadata(directory string) (*Metadata, error) {
os.O_RDONLY, os.ModePerm,
)
if err != nil {
if err == os.ErrNotExist {
if os.IsNotExist(err) {
return nil, nil
}
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
{{if .Category}}{{if eq .Category.Type "gaming"}}
🎮 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"
"strconv"
"arimelody.space/live-vod-uploader/youtube"
"arimelody.space/vodular/youtube"
ffmpeg "github.com/u2takey/ffmpeg-go"
)

View file

@ -11,7 +11,7 @@ import (
"text/template"
"time"
"arimelody.space/live-vod-uploader/scanner"
"arimelody.space/vodular/scanner"
"golang.org/x/oauth2"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3"
@ -69,7 +69,7 @@ func BuildVideo(metadata *scanner.Metadata) (*Video, error) {
Category: category,
Part: metadata.Part,
Date: videoDate,
Tags: DefaultTags,
Tags: metadata.Tags,
Filename: path.Join(
metadata.FootageDir,
fmt.Sprintf(
@ -88,10 +88,16 @@ type (
Metadata struct {
Title string
Date string
Date time.Time
Category *MetaCategory
Part int
}
Template struct {
Title *template.Template
Description *template.Template
Tags []string
}
)
var videoCategoryTypeStrings = map[CategoryType]string{
@ -107,19 +113,90 @@ var videoYtCategory = map[CategoryType]string {
CATEGORY_ENTERTAINMENT: YT_CATEGORY_ENTERTAINMENT,
}
var DefaultTags []string
func GetDefaultTags(filepath string) ([]string, error) {
file, err := os.ReadFile(filepath)
if err != nil { return nil, err }
var templateDir = "templates"
var tagsPath = path.Join(templateDir, "tags.txt")
var titlePath = path.Join(templateDir, "title.txt")
var descriptionPath = path.Join(templateDir, "description.txt")
tags := strings.Split(string(file), "\n")
return tags, nil
const defaultTitleTemplate =
"{{.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(
template.ParseFiles("template/title.txt"),
func FetchTemplates() (*Template, error) {
tmpl := Template{}
// 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,
)
func BuildTitle(video *Video) (string, error) {
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{}
var category *MetaCategory
@ -131,36 +208,9 @@ func BuildTitle(video *Video) (string, error) {
}
}
// TODO: give templates date format and lowercase functions
// these should not be hard-coded!
err := titleTemplate.Execute(out, Metadata{
err := tmpl.Execute(out, Metadata{
Title: video.Title,
Date: strings.ToLower(video.Date.Format("02 Jan 2006")),
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")),
Date: video.Date,
Category: category,
Part: video.Part,
})
@ -172,15 +222,12 @@ func UploadVideo(
ctx context.Context,
tokenSource oauth2.TokenSource,
video *Video,
templates *Template,
) (*youtube.Video, error) {
title, err := BuildTitle(video)
if err != nil {
return nil, fmt.Errorf("failed to build title: %v", err)
}
description, err := BuildDescription(video)
if err != nil {
return nil, fmt.Errorf("failed to build description: %v", err)
}
title, err := BuildTemplate(video, templates.Title)
if err != nil { 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) }
service, err := youtube.NewService(
ctx,
@ -207,7 +254,7 @@ func UploadVideo(
Snippet: &youtube.VideoSnippet{
Title: title,
Description: description,
Tags: append(DefaultTags, video.Tags...),
Tags: append(templates.Tags, video.Tags...),
CategoryId: categoryId, // gaming
},
Status: &youtube.VideoStatus{