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 }