diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..32e26d0 --- /dev/null +++ b/config/config.go @@ -0,0 +1,56 @@ +package config + +import ( + "fmt" + "os" + + "github.com/pelletier/go-toml/v2" +) + +type ( + Config struct { + Google GoogleConfig `toml:"google"` + } + + GoogleConfig struct { + ApiKey string `toml:"api_key"` + ClientID string `toml:"client_id"` + ClientSecret string `toml:"client_secret"` + } +) + +var defaultConfig = Config{ + Google: GoogleConfig{ + ApiKey: "", + ClientID: "", + ClientSecret: "", + }, +} + +const CONFIG_FILENAME = "config.toml" + +func ReadConfig(filename string) (*Config, error) { + cfgBytes, err := os.ReadFile(filename) + if err != nil { + if err == os.ErrNotExist { + return nil, nil + } + return nil, fmt.Errorf("failed to open file: %v", err) + } + + config := Config{} + err = toml.Unmarshal(cfgBytes, &config) + if err != nil { return &config, fmt.Errorf("failed to parse: %v", err) } + + return &config, nil +} + +func GenerateConfig(filename string) error { + file, err := os.OpenFile(filename, os.O_CREATE, 0644) + if err != nil { return err } + + err = toml.NewEncoder(file).Encode(defaultConfig) + if err != nil { return err } + + return nil +} diff --git a/main.go b/main.go index 2dcb937..a61cb7a 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + _ "embed" "encoding/json" "fmt" "log" @@ -9,41 +10,26 @@ import ( "path" "strings" - toml "github.com/pelletier/go-toml/v2" "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" ) -type ( - Config struct { - Google GoogleConfig `toml:"google"` - } +const segmentExtension = "mkv" - GoogleConfig struct { - ApiKey string `toml:"api_key"` - ClientID string `toml:"client_id"` - ClientSecret string `toml:"client_secret"` - } -) - -const CONFIG_FILENAME = "config.toml" +//go:embed res/help.txt +var helpText string 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) - } + fmt.Printf(helpText, execName) +} func main() { if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" { @@ -51,9 +37,11 @@ func main() { os.Exit(0) } - var directory string - var initDirectory bool = false 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 } @@ -66,14 +54,24 @@ func main() { showHelp() os.Exit(0) - case "--init": - initDirectory = true - 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) @@ -84,89 +82,68 @@ func main() { } } - cfg := Config{} - cfgBytes, err := os.ReadFile(CONFIG_FILENAME) + // config + cfg, err := config.ReadConfig(config.CONFIG_FILENAME) if err != nil { - 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) - } - err = toml.Unmarshal(cfgBytes, &cfg) - if err != nil { - log.Fatalf("Failed to parse config: %v", err) + log.Fatalf("Failed to read config: %v", err) os.Exit(1) } - - 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) - } + if cfg == nil { + log.Printf( + "New config file created (%s). " + + "Please edit this file before running again!", + config.CONFIG_FILENAME, + ) + os.Exit(0) } - metadata, err := scanner.FetchMetadata(directory) + // 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") + } + + // 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.") + log.Fatal( + "Directory contained no metadata. " + + "Use `--init` to initialise this directory.", + ) os.Exit(1) } - vodFiles, err := scanner.FetchVideos(metadata.FootageDir) + + // 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) + } + + // 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.Fatal("Directory contained no VOD files (expecting .mkv)") + log.Fatalf( + "Directory contained no VOD files (expecting .%s)", + segmentExtension, + ) os.Exit(1) } - if verbose { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", "\t") @@ -176,6 +153,7 @@ func main() { 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) @@ -202,17 +180,76 @@ func main() { ) } - err = vid.ConcatVideo(video, vodFiles) + // concatenate VOD segments into full VOD + err = vid.ConcatVideo(video, vodFiles, verbose) if err != nil { - log.Fatalf("Failed to concatenate VOD files: %v", err) + log.Fatalf("Failed to concatenate VOD segments: %v", err) os.Exit(1) } - // okay actual youtube stuff now - - // TODO: tidy up oauth flow with localhost webserver + // youtube oauth flow ctx := context.Background() - config := &oauth2.Config{ + token, err := completeOAuth(&ctx, cfg) + if err != nil { + log.Fatalf("OAuth flow failed: %v", err) + os.Exit(1) + } + + // okay actually upload now! + ytVideo, err := yt.UploadVideo(ctx, token, 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(path.Join(directory, scanner.METADATA_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: %v", err) + } + + err = scanner.WriteMetadata(directory, scanner.DefaultMetadata()) + + return err +} + +func completeOAuth(ctx *context.Context, cfg *config.Config) (*oauth2.Token, error) { + oauth2Config := &oauth2.Config{ ClientID: cfg.Google.ClientID, ClientSecret: cfg.Google.ClientSecret, Endpoint: google.Endpoint, @@ -220,21 +257,25 @@ func main() { 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) - + + url := oauth2Config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) + fmt.Printf("Sign in to YouTube: %s\n", url) + + // TODO: tidy up oauth flow with localhost webserver var code string fmt.Print("Enter OAuth2 code: ") if _, err := fmt.Scan(&code); err != nil { - log.Fatalf("Failed to read oauth2 code: %v", err) + return nil, fmt.Errorf("failed to read code: %v", err) } - token, err := config.Exchange(ctx, code, oauth2.VerifierOption(verifier)) - log.Printf("Token expires on %s\n", token.Expiry.Format("02 Jan 2006")) + token, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier)) if err != nil { log.Fatalf("Could not exchange OAuth2 code: %v", err) os.Exit(1) } - yt.UploadVideo(ctx, token, video) + // TODO: save this token; look into token refresh + log.Printf("Token expires on %s\n", token.Expiry.Format("02 Jan 2006")) + + return token, nil } diff --git a/res/help.txt b/res/help.txt new file mode 100644 index 0000000..85af21b --- /dev/null +++ b/res/help.txt @@ -0,0 +1,16 @@ +ari's VOD uploader + +USAGE: %s [options] [directory] + +This tool stitches together VOD segments and automatically uploads them to +YouTube. `directory` is assumed to be a directory containing a `metadata.toml` +(created with `--init`), and some `.mkv` files. + +OPTIONS: + -h, --help: Show this 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 diff --git a/scanner/scanner.go b/scanner/scanner.go index 2de091c..5793e64 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -2,7 +2,7 @@ package scanner import ( "os" - "path/filepath" + "path" "strings" "time" @@ -22,10 +22,13 @@ type ( Part int FootageDir string Category *Category + Uploaded bool } ) -func FetchVideos(directory string) ([]string, error) { +const METADATA_FILENAME = "metadata.toml" + +func ScanSegments(directory string, extension string) ([]string, error) { entries, err := os.ReadDir(directory) if err != nil { return nil, err @@ -35,49 +38,46 @@ func FetchVideos(directory string) ([]string, error) { for _, item := range entries { if item.IsDir() { continue } - if !strings.HasSuffix(item.Name(), ".mkv") { continue } + if !strings.HasSuffix(item.Name(), "." + extension) { continue } files = append(files, item.Name()) } return files, nil } -func FetchMetadata(directory string) (*Metadata, error) { - entries, err := os.ReadDir(directory) - if err != nil { - return nil, err - } - - for _, item := range entries { - if item.IsDir() { continue } - if item.Name() == "metadata.toml" { - metadata, err := ParseMetadata(filepath.Join(directory, item.Name())) - if err != nil { - return nil, err - } - metadata.FootageDir = filepath.Join(directory, metadata.FootageDir) - return metadata, nil - } - } - - return nil, nil -} - -func ParseMetadata(filename string) (*Metadata, error) { +func ReadMetadata(directory string) (*Metadata, error) { metadata := &Metadata{} - file, err := os.OpenFile(filename, os.O_RDONLY, 0o644) + file, err := os.OpenFile( + path.Join(directory, METADATA_FILENAME), + os.O_RDONLY, os.ModePerm, + ) if err != nil { + if err == os.ErrNotExist { + return nil, nil + } return nil, err } + err = toml.NewDecoder(file).Decode(metadata) - if err != nil { - return nil, err - } + if err != nil { return nil, err } + return metadata, nil } -func DefaultMetadata() Metadata { - return Metadata{ +func WriteMetadata(directory string, metadata *Metadata) (error) { + file, err := os.OpenFile( + path.Join(directory, METADATA_FILENAME), + os.O_CREATE, 0644, + ) + if err != nil { return err } + + err = toml.NewEncoder(file).Encode(metadata) + + return err +} + +func DefaultMetadata() *Metadata { + return &Metadata{ Title: "Untitled Stream", Date: time.Now().Format("2006-01-02"), Part: 0, diff --git a/video/video.go b/video/video.go index b36a6e4..8a4aafd 100644 --- a/video/video.go +++ b/video/video.go @@ -20,7 +20,7 @@ type ( } ) -func ConcatVideo(video *youtube.Video, vodFiles []string) error { +func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) error { fileListPath := path.Join( path.Dir(video.Filename), "files.txt", @@ -45,18 +45,20 @@ func ConcatVideo(video *youtube.Video, vodFiles []string) error { err := os.WriteFile( fileListPath, []byte(fileListString), - 0o644, + 0644, ) if err != nil { return fmt.Errorf("failed to write file list: %v", err) } - err = ffmpeg.Input(fileListPath, ffmpeg.KwArgs{ + stream := ffmpeg.Input(fileListPath, ffmpeg.KwArgs{ "f": "concat", "safe": "0", }).Output(video.Filename, ffmpeg.KwArgs{ "c": "copy", - }).OverWriteOutput().ErrorToStdOut().Run() + }).OverWriteOutput() + if verbose { stream = stream.ErrorToStdOut() } + err = stream.Run() if err != nil { return fmt.Errorf("ffmpeg error: %v", err) diff --git a/youtube/youtube.go b/youtube/youtube.go index b9c6e41..14f66a1 100644 --- a/youtube/youtube.go +++ b/youtube/youtube.go @@ -3,7 +3,6 @@ package youtube import ( "bytes" "context" - "encoding/json" "fmt" "log" "os" @@ -161,14 +160,14 @@ func BuildDescription(video *Video) (string, error) { return out.String(), nil } -func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) error { +func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtube.Video, error) { title, err := BuildTitle(video) if err != nil { - return fmt.Errorf("failed to build title: %v", err) + return nil, fmt.Errorf("failed to build title: %v", err) } description, err := BuildDescription(video) if err != nil { - return fmt.Errorf("failed to build description: %v", err) + return nil, fmt.Errorf("failed to build description: %v", err) } service, err := youtube.NewService( @@ -178,7 +177,7 @@ func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) error { ) if err != nil { log.Fatalf("Failed to create youtube service: %v\n", err) - return err + return nil, err } videoService := youtube.NewVideosService(service) @@ -208,24 +207,17 @@ func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) error { file, err := os.Open(video.Filename) if err != nil { log.Fatalf("Failed to open file: %v\n", err) - return err + return nil, err } call.Media(file) log.Println("Uploading video...") - res, err := call.Do() + ytVideo, err := call.Do() if err != nil { log.Fatalf("Failed to upload video: %v\n", err) - return err + return nil, err } - data, err := json.MarshalIndent(res, "", " ") - if err != nil { - log.Fatalf("Failed to marshal video data json: %v\n", err) - return err - } - - fmt.Println(string(data)) - return nil + return ytVideo, err }