2025-11-05 21:22:15 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
2026-01-28 10:48:14 +00:00
|
|
|
"log"
|
2025-11-05 21:22:15 +00:00
|
|
|
"os"
|
2026-01-28 10:48:14 +00:00
|
|
|
"path"
|
|
|
|
|
"strings"
|
2025-11-05 21:22:15 +00:00
|
|
|
|
|
|
|
|
toml "github.com/pelletier/go-toml/v2"
|
2026-01-28 10:48:14 +00:00
|
|
|
"golang.org/x/oauth2"
|
|
|
|
|
"golang.org/x/oauth2/google"
|
2025-11-05 21:22:15 +00:00
|
|
|
"google.golang.org/api/youtube/v3"
|
2026-01-28 10:48:14 +00:00
|
|
|
|
|
|
|
|
"arimelody.space/live-vod-uploader/scanner"
|
|
|
|
|
vid "arimelody.space/live-vod-uploader/video"
|
|
|
|
|
yt "arimelody.space/live-vod-uploader/youtube"
|
2025-11-05 21:22:15 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type (
|
|
|
|
|
Config struct {
|
|
|
|
|
Google GoogleConfig `toml:"google"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
GoogleConfig struct {
|
|
|
|
|
ApiKey string `toml:"api_key"`
|
|
|
|
|
ClientID string `toml:"client_id"`
|
|
|
|
|
ClientSecret string `toml:"client_secret"`
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-28 10:48:14 +00:00
|
|
|
const CONFIG_FILENAME = "config.toml"
|
|
|
|
|
|
|
|
|
|
func showHelp() {
|
|
|
|
|
execSplits := strings.Split(os.Args[0], "/")
|
|
|
|
|
execName := execSplits[len(execSplits) - 1]
|
|
|
|
|
fmt.Printf(
|
|
|
|
|
"usage: %s [options] [directory]\n\n" +
|
|
|
|
|
"options:\n" +
|
|
|
|
|
"\t-h, --help: Show this help message.\n" +
|
|
|
|
|
"\t-v, --verbose: Show verbose logging output.\n" +
|
|
|
|
|
"\t--init: Initialise `directory` as a VOD directory.\n",
|
|
|
|
|
execName)
|
|
|
|
|
}
|
2025-11-05 21:22:15 +00:00
|
|
|
|
|
|
|
|
func main() {
|
2026-01-28 10:48:14 +00:00
|
|
|
if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" {
|
|
|
|
|
showHelp()
|
2025-11-05 21:22:15 +00:00
|
|
|
os.Exit(0)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 10:48:14 +00:00
|
|
|
var directory string
|
|
|
|
|
var initDirectory bool = false
|
|
|
|
|
var verbose bool = false
|
|
|
|
|
|
|
|
|
|
for i, arg := range os.Args {
|
|
|
|
|
if i == 0 { continue }
|
|
|
|
|
if strings.HasPrefix(arg, "-") {
|
|
|
|
|
switch arg {
|
|
|
|
|
|
|
|
|
|
case "-h":
|
|
|
|
|
fallthrough
|
|
|
|
|
case "--help":
|
|
|
|
|
showHelp()
|
|
|
|
|
os.Exit(0)
|
|
|
|
|
|
|
|
|
|
case "--init":
|
|
|
|
|
initDirectory = true
|
|
|
|
|
|
|
|
|
|
case "-v":
|
|
|
|
|
fallthrough
|
|
|
|
|
case "--verbose":
|
|
|
|
|
verbose = true
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
directory = arg
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-05 21:22:15 +00:00
|
|
|
|
2026-01-28 10:48:14 +00:00
|
|
|
cfg := Config{}
|
|
|
|
|
cfgBytes, err := os.ReadFile(CONFIG_FILENAME)
|
2025-11-05 21:22:15 +00:00
|
|
|
if err != nil {
|
2026-01-28 10:48:14 +00:00
|
|
|
log.Fatalf("Failed to read config file: %v", err)
|
|
|
|
|
|
|
|
|
|
tomlBytes, err := toml.Marshal(&cfg)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Failed to marshal json: %v", err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = os.WriteFile(CONFIG_FILENAME, tomlBytes, 0o644)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Failed to write config file: %v", err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.Printf("New config file created. Please edit this before running again!")
|
|
|
|
|
os.Exit(0)
|
2025-11-05 21:22:15 +00:00
|
|
|
}
|
|
|
|
|
err = toml.Unmarshal(cfgBytes, &cfg)
|
|
|
|
|
if err != nil {
|
2026-01-28 10:48:14 +00:00
|
|
|
log.Fatalf("Failed to parse config: %v", err)
|
2025-11-05 21:22:15 +00:00
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 10:48:14 +00:00
|
|
|
if initDirectory {
|
|
|
|
|
dirInfo, err := os.Stat(directory)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if err == os.ErrNotExist {
|
|
|
|
|
log.Fatalf("No such directory: %s", directory)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
log.Fatalf("Failed to open directory: %v", err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
if !dirInfo.IsDir() {
|
|
|
|
|
log.Fatalf("Not a directory: %s", directory)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
dirEntry, err := os.ReadDir(directory)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Failed to open directory: %v", err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
for _, entry := range dirEntry {
|
|
|
|
|
if !entry.IsDir() && entry.Name() == "metadata.toml" {
|
|
|
|
|
log.Printf("Directory `%s` already initialised", directory)
|
|
|
|
|
os.Exit(0)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
defaultMetadata := scanner.DefaultMetadata()
|
|
|
|
|
metadataStr, _ := toml.Marshal(defaultMetadata)
|
|
|
|
|
err = os.WriteFile(path.Join(directory, "metadata.toml"), metadataStr, 0o644)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Failed to write to file: %v", err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
log.Printf("Directory successfully initialised")
|
|
|
|
|
os.Exit(0)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
metadata, err := scanner.FetchMetadata(directory)
|
2025-11-05 21:22:15 +00:00
|
|
|
if err != nil {
|
2026-01-28 10:48:14 +00:00
|
|
|
log.Fatalf("Failed to fetch VOD metadata: %v", err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
if metadata == nil {
|
|
|
|
|
log.Fatal("Directory contained no metadata. Use `--init` to initialise this directory.")
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
vodFiles, err := scanner.FetchVideos(metadata.FootageDir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Failed to fetch VOD filenames: %v", err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
if len(vodFiles) == 0 {
|
|
|
|
|
log.Fatal("Directory contained no VOD files (expecting .mkv)")
|
2025-11-05 21:22:15 +00:00
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 10:48:14 +00:00
|
|
|
if verbose {
|
|
|
|
|
enc := json.NewEncoder(os.Stdout)
|
|
|
|
|
enc.SetIndent("", "\t")
|
|
|
|
|
fmt.Printf("Directory metadata: ")
|
|
|
|
|
enc.Encode(metadata)
|
|
|
|
|
fmt.Printf("\nVOD files available: ")
|
|
|
|
|
enc.Encode(vodFiles)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
video, err := yt.BuildVideo(metadata)
|
2025-11-05 21:22:15 +00:00
|
|
|
if err != nil {
|
2026-01-28 10:48:14 +00:00
|
|
|
log.Fatalf("Failed to build video template: %v", err)
|
2025-11-05 21:22:15 +00:00
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
2026-01-28 10:48:14 +00:00
|
|
|
if verbose {
|
|
|
|
|
enc := json.NewEncoder(os.Stdout)
|
|
|
|
|
fmt.Printf("\nVideo template: ")
|
|
|
|
|
enc.Encode(video)
|
|
|
|
|
|
|
|
|
|
title, err := yt.BuildTitle(video)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Failed to build video title: %v", err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
description, err := yt.BuildDescription(video)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Failed to build video description: %v", err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
fmt.Printf(
|
|
|
|
|
"\nTITLE: %s\nDESCRIPTION: %s",
|
|
|
|
|
title, description,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err = vid.ConcatVideo(video, vodFiles)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Failed to concatenate VOD files: %v", err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// okay actual youtube stuff now
|
|
|
|
|
|
|
|
|
|
// TODO: tidy up oauth flow with localhost webserver
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
config := &oauth2.Config{
|
|
|
|
|
ClientID: cfg.Google.ClientID,
|
|
|
|
|
ClientSecret: cfg.Google.ClientSecret,
|
|
|
|
|
Endpoint: google.Endpoint,
|
|
|
|
|
Scopes: []string{ youtube.YoutubeScope },
|
|
|
|
|
RedirectURL: "http://localhost:8090",
|
|
|
|
|
}
|
|
|
|
|
verifier := oauth2.GenerateVerifier()
|
|
|
|
|
url := config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
|
|
|
|
|
log.Printf("Visit URL to initiate OAuth2: %s", url)
|
|
|
|
|
|
|
|
|
|
var code string
|
|
|
|
|
fmt.Print("Enter OAuth2 code: ")
|
|
|
|
|
if _, err := fmt.Scan(&code); err != nil {
|
|
|
|
|
log.Fatalf("Failed to read oauth2 code: %v", err)
|
|
|
|
|
}
|
2025-11-05 21:22:15 +00:00
|
|
|
|
2026-01-28 10:48:14 +00:00
|
|
|
token, err := config.Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
|
|
|
|
log.Printf("Token expires on %s\n", token.Expiry.Format("02 Jan 2006"))
|
2025-11-05 21:22:15 +00:00
|
|
|
if err != nil {
|
2026-01-28 10:48:14 +00:00
|
|
|
log.Fatalf("Could not exchange OAuth2 code: %v", err)
|
2025-11-05 21:22:15 +00:00
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 10:48:14 +00:00
|
|
|
yt.UploadVideo(ctx, token, video)
|
2025-11-05 21:22:15 +00:00
|
|
|
}
|