361 lines
7.9 KiB
Go
361 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"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"
|
|
)
|
|
|
|
//go:embed res/help.txt
|
|
var helpText string
|
|
|
|
const segmentExtension = "mkv"
|
|
|
|
func showHelp() {
|
|
execSplits := strings.Split(os.Args[0], "/")
|
|
execName := execSplits[len(execSplits) - 1]
|
|
fmt.Printf(helpText, execName)
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" {
|
|
showHelp()
|
|
os.Exit(0)
|
|
}
|
|
|
|
var verbose bool = false
|
|
var initDirectory bool = false
|
|
var deleteFullVod bool = false
|
|
var forceUpload bool = false
|
|
var directory string
|
|
|
|
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 "-v":
|
|
fallthrough
|
|
case "--verbose":
|
|
verbose = true
|
|
|
|
case "--init":
|
|
initDirectory = true
|
|
|
|
case "-d":
|
|
fallthrough
|
|
case "--deleteAfter":
|
|
deleteFullVod = true
|
|
|
|
case "-f":
|
|
fallthrough
|
|
case "--force":
|
|
forceUpload = true
|
|
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg)
|
|
os.Exit(1)
|
|
|
|
}
|
|
} else {
|
|
directory = arg
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
if err != nil {
|
|
log.Fatalf("Failed to initialise directory: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
log.Printf(
|
|
"Directory successfully initialised. " +
|
|
"Be sure to update %s before uploading!",
|
|
scanner.METADATA_FILENAME,
|
|
)
|
|
os.Exit(0)
|
|
}
|
|
|
|
// read directory metadata
|
|
metadata, err := scanner.ReadMetadata(directory)
|
|
if err != nil {
|
|
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)
|
|
}
|
|
|
|
// skip uploading if already done
|
|
if metadata.Uploaded == !forceUpload {
|
|
log.Printf(
|
|
"VOD has already been uploaded. " +
|
|
"Use --force to override, or update the %s.",
|
|
scanner.METADATA_FILENAME,
|
|
)
|
|
os.Exit(0)
|
|
}
|
|
|
|
// default footage directory
|
|
if len(metadata.FootageDir) == 0 {
|
|
metadata.FootageDir = directory
|
|
}
|
|
|
|
// scan for VOD segments
|
|
vodFiles, err := scanner.ScanSegments(metadata.FootageDir, segmentExtension)
|
|
if err != nil {
|
|
log.Fatalf("Failed to fetch VOD filenames: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
if len(vodFiles) == 0 {
|
|
log.Fatalf(
|
|
"Directory contained no VOD files (expecting .%s)",
|
|
segmentExtension,
|
|
)
|
|
os.Exit(1)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// build video template for upload
|
|
video, err := yt.BuildVideo(metadata)
|
|
if err != nil {
|
|
log.Fatalf("Failed to build video template: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
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\n",
|
|
title, description,
|
|
)
|
|
}
|
|
|
|
// concatenate VOD segments into full VOD
|
|
video.SizeBytes, err = vid.ConcatVideo(video, vodFiles, verbose)
|
|
if err != nil {
|
|
log.Fatalf("Failed to concatenate VOD segments: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// youtube oauth flow
|
|
ctx := context.Background()
|
|
oauth2Config := &oauth2.Config{
|
|
ClientID: cfg.Google.ClientID,
|
|
ClientSecret: cfg.Google.ClientSecret,
|
|
Endpoint: google.Endpoint,
|
|
Scopes: []string{ youtube.YoutubeScope },
|
|
RedirectURL: cfg.RedirectUri,
|
|
}
|
|
var token *oauth2.Token
|
|
if cfg.Token != nil {
|
|
token = cfg.Token
|
|
} else {
|
|
token, err = generateOAuthToken(&ctx, oauth2Config, cfg)
|
|
if err != nil {
|
|
log.Fatalf("OAuth flow failed: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
cfg.Token = token
|
|
}
|
|
tokenSource := oauth2Config.TokenSource(ctx, token)
|
|
if err != nil {
|
|
log.Fatalf("Failed to create OAuth2 token source: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
err = config.WriteConfig(cfg, config.CONFIG_FILENAME)
|
|
if err != nil {
|
|
log.Fatalf("Failed to save OAuth token: %v", err)
|
|
}
|
|
|
|
// okay actually upload now!
|
|
ytVideo, err := yt.UploadVideo(ctx, tokenSource, video)
|
|
if err != nil {
|
|
log.Fatalf("Failed to upload video: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
if verbose {
|
|
jsonString, err := json.MarshalIndent(ytVideo, "", " ")
|
|
if err != nil {
|
|
log.Fatalf("Failed to marshal video data json: %v", err)
|
|
}
|
|
fmt.Println(string(jsonString))
|
|
}
|
|
log.Print("Video uploaded successfully!")
|
|
|
|
// update metadata to reflect VOD is uploaded
|
|
metadata.Uploaded = true
|
|
err = scanner.WriteMetadata(directory, metadata)
|
|
if err != nil {
|
|
log.Fatalf("Failed to update metadata: %v", err)
|
|
}
|
|
|
|
// delete full VOD after upload, if requested
|
|
if deleteFullVod {
|
|
err = os.Remove(video.Filename)
|
|
if err != nil {
|
|
log.Fatalf("Failed to delete full VOD: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func initialiseDirectory(directory string) error {
|
|
dirInfo, err := os.Stat(directory)
|
|
if err != nil {
|
|
if err == os.ErrNotExist {
|
|
return fmt.Errorf("no such directory: %s", directory)
|
|
}
|
|
return fmt.Errorf("failed to open directory: %v", err)
|
|
}
|
|
if !dirInfo.IsDir() {
|
|
return fmt.Errorf("not a directory: %s", directory)
|
|
}
|
|
|
|
_, err = os.Stat(path.Join(directory, scanner.METADATA_FILENAME))
|
|
if err == nil {
|
|
return fmt.Errorf("directory already initialised: %s", directory)
|
|
}
|
|
|
|
err = scanner.WriteMetadata(directory, scanner.DefaultMetadata())
|
|
|
|
return err
|
|
}
|
|
|
|
func generateOAuthToken(
|
|
ctx *context.Context,
|
|
oauth2Config *oauth2.Config,
|
|
cfg *config.Config,
|
|
) (*oauth2.Token, error) {
|
|
verifier := oauth2.GenerateVerifier()
|
|
|
|
var token *oauth2.Token
|
|
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
|
|
}
|
|
|
|
code := r.URL.Query().Get("code")
|
|
|
|
t, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier))
|
|
if err != nil {
|
|
log.Fatalf("Could not exchange OAuth2 code: %v", err)
|
|
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
|
|
log.Printf("Token expires at: %s\n", token.Expiry.Format(time.DateTime))
|
|
|
|
return token, nil
|
|
}
|