more customisation, more QoL improvements
an all-around good time!
This commit is contained in:
parent
2954689784
commit
45db651388
9 changed files with 315 additions and 147 deletions
63
README.md
Normal file
63
README.md
Normal 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*
|
||||||
|
|
@ -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
124
main.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
10
template/tags.txt
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
ari melody
|
||||||
|
ari melody LIVE
|
||||||
|
arispacegirl
|
||||||
|
livestream
|
||||||
|
vtuber
|
||||||
|
twitch
|
||||||
|
full VOD
|
||||||
|
VOD
|
||||||
|
stream
|
||||||
|
archive
|
||||||
|
|
@ -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}}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue