more customisation, more QoL improvements

an all-around good time!
This commit is contained in:
ari melody 2026-01-30 14:14:54 +00:00
parent 2954689784
commit 45db651388
Signed by: ari
GPG key ID: CF99829C92678188
9 changed files with 315 additions and 147 deletions

63
README.md Normal file
View file

@ -0,0 +1,63 @@
# ari's VOD uploader
This tool stitches together livestream VOD segments and automatically uploads
them to YouTube.
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).
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:
```sh
vod-uploader --init /path/to/media
```
Upload a VOD, deleting the redundant full VOD export afterwards:
```sh
vod-uploader -d /path/to/media
```
## 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:
```toml
# The title of the stream
title = 'Untitled Stream'
# (Optional) The part of an episodic stream. 0 assumes this is not episodic.
part = 0
# The date of the stream
date = '2026-01-28'
# (Optional) Additional tags to add to this VOD's metadata.
tags = ['livestream', 'VOD']
# (Optional) Footage directory override, for more complex directory structures.
footage_dir = 'footage'
# Set to `true` by the tool when the VOD has been uploaded successfully.
# Prevents future uploads unless `--force` is used.
uploaded = false
# (Optional) Category details, for additional credits.
[category]
# 
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.
*made with <3 by ari melody, 2026*

View file

@ -5,21 +5,27 @@ import (
"os" "os"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
"golang.org/x/oauth2"
) )
type ( type (
Config struct { GoogleConfig struct {
Google GoogleConfig `toml:"google"` ApiKey string `toml:"api_key"`
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
} }
GoogleConfig struct { Config struct {
ApiKey string `toml:"api_key"` Host string `toml:"host" comment:"Address to host OAuth2 redirect flow"`
ClientID string `toml:"client_id"` RedirectUri string `toml:"redirect_uri" comment:"URI to use in Google OAuth2 flow"`
ClientSecret string `toml:"client_secret"` Google GoogleConfig `toml:"google"`
Token *oauth2.Token `toml:"token" comment:"This section is filled in automatically on a successful authentication flow."`
} }
) )
var defaultConfig = Config{ var defaultConfig = Config{
Host: "localhost:8090",
RedirectUri: "http://localhost:8090",
Google: GoogleConfig{ Google: GoogleConfig{
ApiKey: "<your API key here>", ApiKey: "<your API key here>",
ClientID: "<your client ID here>", ClientID: "<your client ID here>",
@ -38,19 +44,21 @@ func ReadConfig(filename string) (*Config, error) {
return nil, fmt.Errorf("failed to open file: %v", err) return nil, fmt.Errorf("failed to open file: %v", err)
} }
config := Config{} var config Config
err = toml.Unmarshal(cfgBytes, &config) err = toml.Unmarshal(cfgBytes, &config)
if err != nil { return &config, fmt.Errorf("failed to parse: %v", err) } if err != nil { return &config, fmt.Errorf("failed to parse: %v", err) }
return &config, nil return &config, nil
} }
func GenerateConfig(filename string) error { func WriteConfig(cfg *Config, filename string) error {
file, err := os.OpenFile(filename, os.O_CREATE, 0644) file, err := os.OpenFile(filename, os.O_CREATE | os.O_RDWR, 0644)
if err != nil { return err } if err != nil { return err }
err = toml.NewEncoder(file).Encode(defaultConfig) err = toml.NewEncoder(file).Encode(cfg)
if err != nil { return err } return err
}
return nil
func GenerateConfig(filename string) error {
return WriteConfig(&defaultConfig, filename)
} }

124
main.go
View file

@ -6,9 +6,12 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"net/http"
"os" "os"
"path" "path"
"strings" "strings"
"sync"
"time"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/oauth2/google" "golang.org/x/oauth2/google"
@ -20,11 +23,11 @@ import (
yt "arimelody.space/live-vod-uploader/youtube" yt "arimelody.space/live-vod-uploader/youtube"
) )
const segmentExtension = "mkv"
//go:embed res/help.txt //go:embed res/help.txt
var helpText string var helpText string
const segmentExtension = "mkv"
func showHelp() { func showHelp() {
execSplits := strings.Split(os.Args[0], "/") execSplits := strings.Split(os.Args[0], "/")
execName := execSplits[len(execSplits) - 1] execName := execSplits[len(execSplits) - 1]
@ -64,7 +67,7 @@ func main() {
case "-d": case "-d":
fallthrough fallthrough
case "-deleteAfter": case "--deleteAfter":
deleteFullVod = true deleteFullVod = true
case "-f": case "-f":
@ -89,6 +92,11 @@ func main() {
os.Exit(1) os.Exit(1)
} }
if cfg == nil { 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( log.Printf(
"New config file created (%s). " + "New config file created (%s). " +
"Please edit this file before running again!", "Please edit this file before running again!",
@ -97,6 +105,13 @@ func main() {
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)
@ -104,7 +119,12 @@ func main() {
log.Fatalf("Failed to initialise directory: %v", err) log.Fatalf("Failed to initialise directory: %v", err)
os.Exit(1) os.Exit(1)
} }
log.Printf("Directory successfully initialised") log.Printf(
"Directory successfully initialised. " +
"Be sure to update %s before uploading!",
scanner.METADATA_FILENAME,
)
os.Exit(0)
} }
// read directory metadata // read directory metadata
@ -131,6 +151,11 @@ func main() {
os.Exit(0) os.Exit(0)
} }
// default footage directory
if len(metadata.FootageDir) == 0 {
metadata.FootageDir = directory
}
// scan for VOD segments // scan for VOD segments
vodFiles, err := scanner.ScanSegments(metadata.FootageDir, segmentExtension) vodFiles, err := scanner.ScanSegments(metadata.FootageDir, segmentExtension)
if err != nil { if err != nil {
@ -175,13 +200,13 @@ func main() {
os.Exit(1) os.Exit(1)
} }
fmt.Printf( fmt.Printf(
"\nTITLE: %s\nDESCRIPTION: %s", "\nTITLE: %s\nDESCRIPTION: %s\n",
title, description, title, description,
) )
} }
// concatenate VOD segments into full VOD // concatenate VOD segments into full VOD
err = vid.ConcatVideo(video, vodFiles, verbose) video.SizeBytes, err = vid.ConcatVideo(video, vodFiles, verbose)
if err != nil { if err != nil {
log.Fatalf("Failed to concatenate VOD segments: %v", err) log.Fatalf("Failed to concatenate VOD segments: %v", err)
os.Exit(1) os.Exit(1)
@ -189,10 +214,20 @@ func main() {
// youtube oauth flow // youtube oauth flow
ctx := context.Background() ctx := context.Background()
token, err := completeOAuth(&ctx, cfg) var token *oauth2.Token
if err != nil { if cfg.Token != nil {
log.Fatalf("OAuth flow failed: %v", err) token = cfg.Token
os.Exit(1) } else {
token, err = generateOAuthToken(&ctx, cfg)
if err != nil {
log.Fatalf("OAuth flow failed: %v", err)
os.Exit(1)
}
cfg.Token = token
err = config.WriteConfig(cfg, config.CONFIG_FILENAME)
if err != nil {
log.Fatalf("Failed to save OAuth token: %v", err)
}
} }
// okay actually upload now! // okay actually upload now!
@ -219,7 +254,7 @@ func main() {
// delete full VOD after upload, if requested // delete full VOD after upload, if requested
if deleteFullVod { if deleteFullVod {
err = os.Remove(path.Join(directory, scanner.METADATA_FILENAME)) err = os.Remove(video.Filename)
if err != nil { if err != nil {
log.Fatalf("Failed to delete full VOD: %v", err) log.Fatalf("Failed to delete full VOD: %v", err)
} }
@ -240,7 +275,7 @@ func initialiseDirectory(directory string) error {
_, err = os.Stat(path.Join(directory, scanner.METADATA_FILENAME)) _, err = os.Stat(path.Join(directory, scanner.METADATA_FILENAME))
if err == nil { if err == nil {
return fmt.Errorf("directory already initialised: %v", err) return fmt.Errorf("directory already initialised: %s", directory)
} }
err = scanner.WriteMetadata(directory, scanner.DefaultMetadata()) err = scanner.WriteMetadata(directory, scanner.DefaultMetadata())
@ -248,34 +283,69 @@ func initialiseDirectory(directory string) error {
return err return err
} }
func completeOAuth(ctx *context.Context, cfg *config.Config) (*oauth2.Token, error) { func generateOAuthToken(ctx *context.Context, cfg *config.Config) (*oauth2.Token, error) {
oauth2Config := &oauth2.Config{ oauth2Config := &oauth2.Config{
ClientID: cfg.Google.ClientID, ClientID: cfg.Google.ClientID,
ClientSecret: cfg.Google.ClientSecret, ClientSecret: cfg.Google.ClientSecret,
Endpoint: google.Endpoint, Endpoint: google.Endpoint,
Scopes: []string{ youtube.YoutubeScope }, Scopes: []string{ youtube.YoutubeScope },
RedirectURL: "http://localhost:8090", RedirectURL: cfg.RedirectUri,
} }
verifier := oauth2.GenerateVerifier() verifier := oauth2.GenerateVerifier()
url := oauth2Config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) var token *oauth2.Token
fmt.Printf("Sign in to YouTube: %s\n", url) wg := sync.WaitGroup{}
var server http.Server
server.Addr = cfg.Host
server.Handler = http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if !r.URL.Query().Has("code") {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
// TODO: tidy up oauth flow with localhost webserver code := r.URL.Query().Get("code")
var code string
fmt.Print("Enter OAuth2 code: ")
if _, err := fmt.Scan(&code); err != nil {
return nil, fmt.Errorf("failed to read code: %v", err)
}
token, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier)) t, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier))
if err != nil { if err != nil {
log.Fatalf("Could not exchange OAuth2 code: %v", err) log.Fatalf("Could not exchange OAuth2 code: %v", err)
os.Exit(1) http.Error( w,
fmt.Sprintf("Could not exchange OAuth2 code: %v", err),
http.StatusBadRequest,
)
return
}
token = t
http.Error(
w,
"Authentication successful! You may now close this tab.",
http.StatusOK,
)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
wg.Done()
server.Close()
},
)
url := oauth2Config.AuthCodeURL(
"state",
oauth2.AccessTypeOffline,
oauth2.S256ChallengeOption(verifier),
)
fmt.Printf("\nSign in to YouTube: %s\n\n", url)
wg.Add(1)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
return nil, fmt.Errorf("http: %v", err)
} }
wg.Wait()
// TODO: save this token; look into token refresh // TODO: save this token; look into token refresh
log.Printf("Token expires on %s\n", token.Expiry.Format("02 Jan 2006")) log.Printf("Token expires at: %s\n", token.Expiry.Format(time.DateTime))
return token, nil return token, nil
} }

View file

@ -11,18 +11,19 @@ import (
type ( type (
Category struct { Category struct {
Name string Name string `toml:"name"`
Type string Type string `toml:"type" comment:"Valid types: gaming, other (default: other)"`
Url string Url string `toml:"url"`
} }
Metadata struct { Metadata struct {
Title string Title string `toml:"title"`
Date string Part int `toml:"part"`
Part int Date string `toml:"date"`
FootageDir string Tags []string `toml:"tags"`
Category *Category FootageDir string `toml:"footage_dir"`
Uploaded bool Uploaded bool `toml:"uploaded"`
Category *Category `toml:"category" comment:"(Optional) Category details, for additional credits."`
} }
) )
@ -39,6 +40,7 @@ func ScanSegments(directory string, extension string) ([]string, error) {
for _, item := range entries { for _, item := range entries {
if item.IsDir() { continue } if item.IsDir() { continue }
if !strings.HasSuffix(item.Name(), "." + extension) { continue } if !strings.HasSuffix(item.Name(), "." + extension) { continue }
if strings.HasSuffix(item.Name(), "-fullvod." + extension) { continue }
files = append(files, item.Name()) files = append(files, item.Name())
} }
@ -67,12 +69,11 @@ func ReadMetadata(directory string) (*Metadata, error) {
func WriteMetadata(directory string, metadata *Metadata) (error) { func WriteMetadata(directory string, metadata *Metadata) (error) {
file, err := os.OpenFile( file, err := os.OpenFile(
path.Join(directory, METADATA_FILENAME), path.Join(directory, METADATA_FILENAME),
os.O_CREATE, 0644, os.O_CREATE | os.O_RDWR, 0644,
) )
if err != nil { return err } if err != nil { return err }
err = toml.NewEncoder(file).Encode(metadata) err = toml.NewEncoder(file).Encode(metadata)
return err return err
} }

View file

@ -1,11 +1,11 @@
streamed on {{.Date}} streamed on {{.Date}}
💚 watch ari melody LIVE: https://twitch.tv/arispacegirl 💚 watch ari melody LIVE: https://twitch.tv/arispacegirl
{{if .Title}}{{if eq .Title.Type "game"}} {{if .Category}}{{if eq .Category.Type "gaming"}}
🎮 play {{.Title.Name}}: 🎮 play {{.Category.Name}}:
{{.Title.Url}} {{.Category.Url}}
{{else}} {{else}}
✨ check out {{.Title.Name}}: ✨ check out {{.Category.Name}}:
{{.Title.Url}} {{.Category.Url}}
{{end}}{{end}} {{end}}{{end}}
💫 ari's place: https://arimelody.space 💫 ari's place: https://arimelody.space
💬 ari melody discord: https://arimelody.space/discord 💬 ari melody discord: https://arimelody.space/discord

10
template/tags.txt Normal file
View file

@ -0,0 +1,10 @@
ari melody
ari melody LIVE
arispacegirl
livestream
vtuber
twitch
full VOD
VOD
stream
archive

View file

@ -1 +1 @@
{{.Title.Name}}{{if gt .Part 0}}, part {{.Part}}{{end}} | ari melody LIVE 💚 | {{.Date}} {{.Title}}{{if gt .Part 0}}, part {{.Part}}{{end}} | ari melody LIVE 💚 | {{.Date}}

View file

@ -20,7 +20,7 @@ type (
} }
) )
func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) error { func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) (int64, error) {
fileListPath := path.Join( fileListPath := path.Join(
path.Dir(video.Filename), path.Dir(video.Filename),
"files.txt", "files.txt",
@ -32,13 +32,13 @@ func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) error {
fileListString += fmt.Sprintf("file '%s'\n", file) fileListString += fmt.Sprintf("file '%s'\n", file)
jsonProbe, err := ffmpeg.Probe(path.Join(path.Dir(video.Filename), file)) jsonProbe, err := ffmpeg.Probe(path.Join(path.Dir(video.Filename), file))
if err != nil { if err != nil {
return fmt.Errorf("failed to probe file `%s`: %v", file, err) return 0, fmt.Errorf("failed to probe file `%s`: %v", file, err)
} }
probe := probeData{} probe := probeData{}
json.Unmarshal([]byte(jsonProbe), &probe) json.Unmarshal([]byte(jsonProbe), &probe)
duration, err := strconv.ParseFloat(probe.Format.Duration, 64) duration, err := strconv.ParseFloat(probe.Format.Duration, 64)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse duration of file `%s`: %v", file, err) return 0, fmt.Errorf("failed to parse duration of file `%s`: %v", file, err)
} }
totalDuration += duration totalDuration += duration
} }
@ -48,7 +48,7 @@ func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) error {
0644, 0644,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to write file list: %v", err) return 0, fmt.Errorf("failed to write file list: %v", err)
} }
stream := ffmpeg.Input(fileListPath, ffmpeg.KwArgs{ stream := ffmpeg.Input(fileListPath, ffmpeg.KwArgs{
@ -61,8 +61,11 @@ func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) error {
err = stream.Run() err = stream.Run()
if err != nil { if err != nil {
return fmt.Errorf("ffmpeg error: %v", err) return 0, fmt.Errorf("ffmpeg error: %v", err)
} }
return nil fileInfo, err := os.Stat(video.Filename)
if err != nil { return 0, fmt.Errorf("failed to read output file: %v", err) }
return fileInfo.Size(), nil
} }

View file

@ -17,70 +17,59 @@ import (
"google.golang.org/api/youtube/v3" "google.golang.org/api/youtube/v3"
) )
var DEFAULT_TAGS = []string{
"ari melody",
"ari melody LIVE",
"livestream",
"vtuber",
"twitch",
"gaming",
"let's play",
"full VOD",
"VOD",
"stream",
"archive",
}
const ( const (
CATEGORY_GAMING = "20" YT_CATEGORY_GAMING = "20"
CATEGORY_ENTERTAINMENT = "24" YT_CATEGORY_ENTERTAINMENT = "24"
) )
type TitleType int type CategoryType int
const ( const (
TITLE_GAME TitleType = iota CATEGORY_GAME CategoryType = iota
TITLE_OTHER CATEGORY_ENTERTAINMENT
) )
type ( type (
Title struct { Category struct {
Name string Name string
Type TitleType Type CategoryType
Url string Url string
} }
Video struct { Video struct {
Title *Title Title string
Part int Category *Category
Date time.Time Part int
Tags []string Date time.Time
Filename string Tags []string
Filename string
SizeBytes int64
} }
) )
func BuildVideo(metadata *scanner.Metadata) (*Video, error) { func BuildVideo(metadata *scanner.Metadata) (*Video, error) {
var titleType TitleType
switch metadata.Category.Type {
case "gaming":
titleType = TITLE_GAME
default:
titleType = TITLE_OTHER
}
videoDate, err := time.Parse("2006-01-02", metadata.Date) videoDate, err := time.Parse("2006-01-02", metadata.Date)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse date from metadata: %v", err) return nil, fmt.Errorf("failed to parse date from metadata: %v", err)
} }
return &Video{ var category *Category = nil
Title: &Title{ if metadata.Category != nil {
category = &Category{
Name: metadata.Category.Name, Name: metadata.Category.Name,
Type: titleType, Type: CATEGORY_ENTERTAINMENT,
Url: metadata.Category.Url, Url: metadata.Category.Url,
}, }
var ok bool
category.Type, ok = videoCategoryStringTypes[metadata.Category.Type]
if !ok { category.Type = CATEGORY_ENTERTAINMENT }
}
return &Video{
Title: metadata.Title,
Category: category,
Part: metadata.Part, Part: metadata.Part,
Date: videoDate, Date: videoDate,
Tags: DEFAULT_TAGS, Tags: DefaultTags,
Filename: path.Join( Filename: path.Join(
metadata.FootageDir, metadata.FootageDir,
fmt.Sprintf( fmt.Sprintf(
@ -91,73 +80,92 @@ func BuildVideo(metadata *scanner.Metadata) (*Video, error) {
} }
type ( type (
MetaTitle struct { MetaCategory struct {
Name string Name string
Type string Type string
Url string Url string
} }
Metadata struct { Metadata struct {
Date string Title string
Title *MetaTitle Date string
Part int Category *MetaCategory
Part int
} }
) )
var videoCategoryTypeStrings = map[CategoryType]string{
CATEGORY_GAME: "gaming",
CATEGORY_ENTERTAINMENT: "entertainment",
}
var videoCategoryStringTypes = map[string]CategoryType{
"gaming": CATEGORY_GAME,
"entertainment": CATEGORY_ENTERTAINMENT,
}
var videoYtCategory = map[CategoryType]string {
CATEGORY_GAME: YT_CATEGORY_GAMING,
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 }
tags := strings.Split(string(file), "\n")
return tags, nil
}
var titleTemplate *template.Template = template.Must( var titleTemplate *template.Template = template.Must(
template.ParseFiles("template/title.txt"), template.ParseFiles("template/title.txt"),
) )
func BuildTitle(video *Video) (string, error) { func BuildTitle(video *Video) (string, error) {
var titleType string out := &bytes.Buffer{}
switch video.Title.Type {
case TITLE_GAME: var category *MetaCategory
titleType = "game" if video.Category != nil {
case TITLE_OTHER: category = &MetaCategory{
fallthrough Name: video.Category.Name,
default: Type: videoCategoryTypeStrings[video.Category.Type],
titleType = "other" Url: video.Category.Url,
}
} }
out := &bytes.Buffer{} // TODO: give templates date format and lowercase functions
titleTemplate.Execute(out, Metadata{ // these should not be hard-coded!
err := titleTemplate.Execute(out, Metadata{
Title: video.Title,
Date: strings.ToLower(video.Date.Format("02 Jan 2006")), Date: strings.ToLower(video.Date.Format("02 Jan 2006")),
Title: &MetaTitle{ Category: category,
Name: video.Title.Name,
Type: titleType,
Url: video.Title.Url,
},
Part: video.Part, Part: video.Part,
}) })
return strings.TrimSpace(out.String()), nil return strings.TrimSpace(out.String()), err
} }
var descriptionTemplate *template.Template = template.Must( var descriptionTemplate *template.Template = template.Must(
template.ParseFiles("template/description.txt"), template.ParseFiles("template/description.txt"),
) )
func BuildDescription(video *Video) (string, error) { func BuildDescription(video *Video) (string, error) {
var titleType string out := &bytes.Buffer{}
switch video.Title.Type {
case TITLE_GAME: var category *MetaCategory
titleType = "game" if video.Category != nil {
case TITLE_OTHER: category = &MetaCategory{
fallthrough Name: video.Category.Name,
default: Type: videoCategoryTypeStrings[video.Category.Type],
titleType = "other" Url: video.Category.Url,
}
} }
out := &bytes.Buffer{} err := descriptionTemplate.Execute(out, Metadata{
descriptionTemplate.Execute(out, Metadata{ Title: video.Title,
Date: strings.ToLower(video.Date.Format("02 Jan 2006")), Date: strings.ToLower(video.Date.Format("02 Jan 2006")),
Title: &MetaTitle{ Category: category,
Name: video.Title.Name,
Type: titleType,
Url: video.Title.Url,
},
Part: video.Part, Part: video.Part,
}) })
return out.String(), nil return strings.TrimSpace(out.String()), err
} }
func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtube.Video, error) { func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtube.Video, error) {
@ -182,12 +190,11 @@ func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtu
videoService := youtube.NewVideosService(service) videoService := youtube.NewVideosService(service)
var categoryId string categoryId := YT_CATEGORY_ENTERTAINMENT
switch video.Title.Type { if video.Category != nil {
case TITLE_GAME: if cid, ok := videoYtCategory[video.Category.Type]; ok {
categoryId = CATEGORY_GAMING categoryId = cid
default: }
categoryId = CATEGORY_ENTERTAINMENT
} }
call := videoService.Insert([]string{ call := videoService.Insert([]string{
@ -196,7 +203,7 @@ func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtu
Snippet: &youtube.VideoSnippet{ Snippet: &youtube.VideoSnippet{
Title: title, Title: title,
Description: description, Description: description,
Tags: append(DEFAULT_TAGS, video.Tags...), Tags: append(DefaultTags, video.Tags...),
CategoryId: categoryId, // gaming CategoryId: categoryId, // gaming
}, },
Status: &youtube.VideoStatus{ Status: &youtube.VideoStatus{
@ -213,6 +220,12 @@ func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtu
log.Println("Uploading video...") log.Println("Uploading video...")
call.ProgressUpdater(func(current, total int64) {
// for some reason, this only returns 0.
// instead, we pull the file size from the ffmpeg output directly.
if total == 0 { total = video.SizeBytes }
fmt.Printf("Uploading... (%.2f%%)\n", float64(current) / float64(total))
})
ytVideo, err := call.Do() ytVideo, err := call.Do()
if err != nil { if err != nil {
log.Fatalf("Failed to upload video: %v\n", err) log.Fatalf("Failed to upload video: %v\n", err)