update README, lots of polish
This commit is contained in:
parent
4624c56e54
commit
ce836b2d46
11 changed files with 210 additions and 121 deletions
72
README.md
72
README.md
|
|
@ -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*
|
||||||
|
|
|
||||||
|
|
@ -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
2
go.mod
|
|
@ -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
86
main.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
res/help.txt
12
res/help.txt
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}}:
|
||||||
|
|
@ -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")}}
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue