diff --git a/README.md b/README.md index 25360ea..d43cc49 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,12 @@ # Vodular -This tool stitches together livestream VOD segments (in `.mkv`format) and automatically uploads them to YouTube, complete with customisable metadata such as titles, descriptions, tags, and a thumbnail! +This tool stitches together livestream VOD segments (in `.mkv` format) and +automatically uploads them to YouTube, complete with customisable metadata such +as titles, descriptions, tags, and a thumbnail! -I built this to greatly simplify the process of getting my full-quality livestream VODs onto YouTube, and I'm open-sourcing it in the hopes that it helps someone else with their workflow. As such, personal forks are welcome and encouraged! +I built this to greatly simplify the process of getting my full-quality +livestream VODs onto YouTube, and I'm open-sourcing it in the hopes that it +helps someone else with their workflow. As such, personal forks are welcome and +encouraged! ## Quick Jump - [Basic Usage](#basic-usage) @@ -9,40 +14,40 @@ I built this to greatly simplify the process of getting my full-quality livestre - [Templates](#templates) ## Basic usage -1. Run the tool for the first time to generate a starter configuration file: -```sh -$ vodular -New config file created (config.toml). Please edit this file before running again! -``` +1. Run `vodular` for the first time to generate a starter configuration file: + The directory which holds your configuration file and templates varies, depending on platform: - **Linux:** `~/.config/vodular/templates` - **macOS:** `~/Library/Application Support/vodular/templates` - **Windows:** `%AppData%/vodular/templates` -2. Edit your configuration file as necessary (You will need to create a [YouTube Data API v3](https://developers.google.com/youtube/v3) service and provide its credentials here). -**IMPORTANT:** `config.toml` contains very sensitive credentials. Do not share this file with anyone. +2. Edit your configuration file as necessary (You will need to create a +[YouTube Data API v3](https://developers.google.com/youtube/v3) service and +provide its credentials here). +**IMPORTANT:** `config.toml` contains very sensitive credentials. Do not share +this file with anyone. 3. Initialise a VOD directory: ```sh -$ vodular --init /path/to/vod -Directory successfully initialised. Be sure to update metadata.toml before uploading! +vodular --init /path/to/vod ``` This directory should contain: - A `metadata.toml` file -- Your footage files, either at the root of the directory or in a specified subdirectory +- Your footage files, either at the root of the directory or in a specified +subdirectory - Your thumbnail, specifically named `thumbnail.png` 4. Modify your newly-created `metadata.toml` to your liking. 5. Upload a VOD! ```sh -# `--deleteAfter` deletes the redundant full VOD export afterwards -vodular --deleteAfter /path/to/vod +vodular /path/to/vod ``` -**NOTE:** On first run, you will be prompted to sign in to YouTube with the channel you wish to upload to. To sign out, simply run `vodular --logout`. +**NOTE:** On first run, you will be prompted to log into YouTube with the +channel you wish to upload to. To log out, simply run `vodular --logout`. ## VOD Metadata When `--init`ialising a directory, a `metadata.toml` file is created. This is a @@ -56,13 +61,12 @@ title = 'Untitled Stream' part = 0 # The date of the stream date = '2026-01-28' -# (Optional) Additional tags to add to this VOD's metadata. +# (Optional) VOD-specific tags. Added to global tags template. 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 +# On successful upload, updated to a URL to the video. +upload_url = false # (Optional) Category details, for additional credits. [category] diff --git a/config/config.go b/config/config.go index 28e0378..3bdbd98 100644 --- a/config/config.go +++ b/config/config.go @@ -5,7 +5,6 @@ import ( "os" "github.com/pelletier/go-toml/v2" - "golang.org/x/oauth2" ) type ( @@ -19,7 +18,6 @@ type ( Host string `toml:"host" comment:"Address to host OAuth2 redirect flow"` RedirectUri string `toml:"redirect_uri" comment:"URI to use in Google OAuth2 flow"` Google GoogleConfig `toml:"google"` - Token *oauth2.Token `toml:"token" comment:"This section is filled in automatically on a successful authentication flow."` } ) diff --git a/main.go b/main.go index 26b7d03..9344d75 100644 --- a/main.go +++ b/main.go @@ -30,14 +30,59 @@ const SEGMENT_EXTENSION = "mkv" const MAX_TITLE_LEN = 100 const MAX_DESCRIPTION_LEN = 5000 -func showHelp() { - fmt.Println(helpText) - os.Exit(0) -} - func main() { ctx := context.Background() + var verbose bool = false + var initDirectory bool = false + var logout bool = false + var keepConcatVOD bool = false + var forceUpload bool = false + var dryRun 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": + log.Fatal(helpText) + + case "-v": + fallthrough + case "--verbose": + verbose = true + + case "--dry-run": + dryRun = true + + case "-f": + fallthrough + case "--force": + forceUpload = true + + case "--init": + initDirectory = true + + case "--keep-vod": + keepConcatVOD = true + + case "--logout": + logout = true + + default: + fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg) + os.Exit(1) + + } + } else { + directory = arg + } + } + // config userConfigDir, err := os.UserConfigDir() if err != nil { @@ -49,12 +94,10 @@ func main() { log.Fatalf("Failed to read config: %v", err) } if cfg == nil { - err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750) - if err != nil { + if err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750); err != nil { log.Fatalf("Failed to create config directory: %v", err) } - err = config.GenerateConfig(config.CONFIG_FILENAME) - if err != nil { + if err = config.GenerateConfig(config.CONFIG_FILENAME); err != nil { log.Fatalf("Failed to generate config: %v", err) } log.Printf( @@ -65,60 +108,15 @@ func main() { os.Exit(0) } - // arguments - if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" { - showHelp() - } - - var verbose bool = false - var initDirectory bool = false - var logout bool = false - var deleteFullVod bool = false - var forceUpload bool = false - var dryRun 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() - - case "-v": - fallthrough - case "--verbose": - verbose = true - - case "--init": - initDirectory = true - - case "--logout": - logout = true - - case "-d": - fallthrough - case "--deleteAfter": - deleteFullVod = true - - case "-f": - fallthrough - case "--force": - forceUpload = true - - case "--dry-run": - dryRun = true - - default: - fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg) - os.Exit(1) - - } - } else { - directory = arg + oauthTokenFilepath := path.Join(userConfigDir, "vodular", "youtube-auth.json") + var token *oauth2.Token + if data, err := os.ReadFile(oauthTokenFilepath); err != nil { + if !os.IsNotExist(err) { + log.Fatalf("Failed to open %s: %v", oauthTokenFilepath, err) + } + } else { + if err := json.Unmarshal(data, &token); err != nil { + log.Fatalf("Failed to parse YouTube auth file: %v", err) } } @@ -130,19 +128,16 @@ func main() { // logout (--logout) if logout { - cfg.Token = nil - err = config.WriteConfig(cfg, config.CONFIG_FILENAME) - if err != nil { - log.Fatalf("Failed to write config: %v", err) + if token == nil { log.Fatal("Not logged in.") } + if err := os.Remove(oauthTokenFilepath); err != nil { + log.Fatalf("Failed to delete %s: %v", oauthTokenFilepath, err) } - log.Println("Logged out successfully.") - os.Exit(0) + log.Fatalf("Logged out successfully.") } // initialising directory (--init) if initDirectory { - err = scanner.InitialiseDirectory(directory) - if err != nil { + if err = scanner.InitialiseDirectory(directory); err != nil { log.Fatalf("Failed to initialise directory: %v", err) } log.Fatalf( @@ -152,6 +147,8 @@ func main() { ) } + + // good to have early on templates, err := yt.FetchTemplates(path.Join(userConfigDir, "vodular", "templates")) if err != nil { @@ -193,10 +190,18 @@ func main() { 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) + log.Print("[ Metadata ]") + log.Printf("Title: %s", metadata.Title) + log.Printf("Part: %d", metadata.Part) + log.Printf("Date: %s", metadata.Date.String()) + log.Printf( + "Category: `%s` - %s (%s)", + metadata.Category.Type, + metadata.Category.Name, + metadata.Category.Url, + ) + log.Printf("Tags: %s", strings.Join(metadata.Tags, ", ")) + log.Printf("VOD files: [ %s ]", strings.Join(vodFiles, ", ")) } // scan for thumbnail @@ -239,19 +244,13 @@ func main() { ) } if verbose { - enc := json.NewEncoder(os.Stdout) - fmt.Printf("\nVideo template: ") - enc.Encode(videoMeta) - - fmt.Printf( - "\n================================\n\n" + - "< TITLE >\n%s\n\n" + - "< DESCRIPTION >\n%s\n" + - "\n================================\n", - title, description, - ) + log.Print() + log.Printf("[ TITLE ]\n\n%s\n\n", title) + log.Printf("[ DESCRIPTION ]\n\n%s\n\n", description) } + + // youtube oauth flow oauth2Config := &oauth2.Config{ ClientID: cfg.Google.ClientID, @@ -260,16 +259,17 @@ func main() { Scopes: []string{ youtube.YoutubeScope }, RedirectURL: cfg.RedirectUri, } - var token *oauth2.Token - if cfg.Token != nil { - token = cfg.Token - } else { + + if token == nil { token, err = oauth.GenerateToken(&ctx, oauth2Config, cfg) - if err != nil { - log.Fatalf("OAuth flow failed: %v", err) + if err != nil { log.Fatalf("OAuth flow failed: %v", err) } + tokenFile, err := os.OpenFile(oauthTokenFilepath, os.O_RDWR | os.O_CREATE, 0600) + if err != nil { log.Fatalf("Failed to open %s: %v", oauthTokenFilepath, err) } + if err := json.NewEncoder(tokenFile).Encode(token); err != nil { + log.Fatalf("Failed to write %s: %v", oauthTokenFilepath, err) } - cfg.Token = token } + tokenSource := oauth2Config.TokenSource(ctx, token) if err != nil { log.Fatalf("Failed to create OAuth2 token source: %v", err) @@ -278,6 +278,8 @@ func main() { log.Fatalf("Failed to save OAuth token: %v", err) } + + service, err := youtube.NewService( ctx, option.WithScopes(youtube.YoutubeUploadScope), @@ -287,6 +289,8 @@ func main() { log.Fatalf("Failed to create youtube service: %v\n", err) } + + // skip uploading if already done if (metadata.Uploaded || len(metadata.UploadURL) > 0) && !forceUpload { if len(metadata.UploadURL) > 0 { @@ -325,36 +329,50 @@ func main() { os.Exit(0) } - if !dryRun { - // check if full VOD already exists with expected duration - fullVodExists := false - fullVodProbe, err := scanner.ProbeSegment(videoMeta.Filepath) - if err == nil { - videoMeta.SizeBytes = fullVodProbe.Format.Size - var totalLength float64 = 0 - for _, filename := range vodFiles { - probe, err := scanner.ProbeSegment(path.Join(footageDir, filename)) - if err != nil { continue } - totalLength += probe.Format.Duration + + videoMeta.Filepath = path.Join(metadata.FootageDir, vodFiles[0]) + + // no need to concat if VOD is already one whole file + if len(vodFiles) > 1 { + videoMeta.Filepath = path.Join( + metadata.FootageDir, + fmt.Sprintf("%s-fullvod.mkv", metadata.Date.String()), + ) + + if !dryRun { + // check if full VOD already exists with expected duration + concatVodExists := false + concatVodProbe, err := scanner.ProbeSegment(videoMeta.Filepath) + if err == nil { + videoMeta.SizeBytes = concatVodProbe.Format.Size + var totalLength float64 = 0 + + for _, filename := range vodFiles { + probe, err := scanner.ProbeSegment(path.Join(footageDir, filename)) + if err != nil { continue } + totalLength += probe.Format.Duration + } + // full VOD may exist, but not be complete (interrupted concat) + concatVodExists = math.Abs(concatVodProbe.Format.Duration - totalLength) < float64(0.1) } - // full VOD may exist, but not be complete (interrupted concat) - fullVodExists = math.Abs(fullVodProbe.Format.Duration - totalLength) < float64(0.1) - } - if fullVodExists { - log.Print("Full VOD appears to already exist- uploading this file...") + if concatVodExists { + log.Print("Full VOD appears to already exist- uploading this file...") + } else { + // concatenate VOD segments into full VOD + videoMeta.SizeBytes, err = vid.ConcatVideo(videoMeta, vodFiles, verbose) + if err != nil { + log.Fatalf("Failed to concatenate VOD segments: %v", err) + } + } } else { - // concatenate VOD segments into full VOD - videoMeta.SizeBytes, err = vid.ConcatVideo(videoMeta, vodFiles, verbose) - if err != nil { - log.Fatalf("Failed to concatenate VOD segments: %v", err) - } + log.Print("Dry run: Skipping VOD concatenation") } - } else { - log.Print("Dry run: Skipping VOD concatenation") } + + if !dryRun { // okay actually upload now! ytVideo, err := yt.UploadVideo(ctx, tokenSource, service, videoMeta, templates) @@ -378,15 +396,14 @@ func main() { // update metadata to reflect VOD is uploaded metadata.UploadURL = yt.VIDEO_URL_BASE + ytVideo.Id - err = scanner.WriteMetadata(directory, metadata) - if err != nil { + if err = scanner.WriteMetadata(directory, metadata); err != nil { log.Fatalf("Failed to update metadata: %v", err) } - // delete full VOD after upload, if requested - if deleteFullVod { - err = os.Remove(videoMeta.Filepath) - if err != nil { + // delete concatenated VOD after upload, unless specified otherwise + // if len(vodFiles) == 1, the full VOD is the *only* VOD. do not delete this!!! + if len(vodFiles) > 1 && !keepConcatVOD { + if err = os.Remove(videoMeta.Filepath); err != nil { log.Fatalf("Failed to delete full VOD: %v", err) } } diff --git a/oauth/oauth.go b/oauth/oauth.go index 4dee4a8..9102bb9 100644 --- a/oauth/oauth.go +++ b/oauth/oauth.go @@ -61,7 +61,7 @@ func GenerateToken( oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier), ) - fmt.Printf("\nSign in to YouTube: %s\n\n", url) + log.Printf("Sign in to YouTube: %s\n\n", url) wg.Add(1) if err := server.ListenAndServe(); err != http.ErrServerClosed { diff --git a/res/help.txt b/res/help.txt index 317d416..980e0f1 100644 --- a/res/help.txt +++ b/res/help.txt @@ -5,11 +5,12 @@ USAGE: vodular [options] [directory] OPTIONS: -h, --help: Show this help message. -v, --verbose: Show verbose logging output. - --init: Initialise `directory` as a VOD directory. - --logout: Logs out of the current YouTube account. - -d, --deleteAfter: Deletes the full VOD after upload. + --dry-run: Don't upload or modify any files (Useful with -v) -f, --force: Force uploading the VOD, even if it already exists. + --init: Initialise `directory` as a VOD directory. + --keep-vod: Retains the concatenated VOD (if generated) after upload. + --logout: Logs out of the current YouTube account. SOURCE: https://codeberg.org/arimelody/vodular ISSUES: https://codeberg.org/arimelody/vodular/issues diff --git a/youtube/youtube.go b/youtube/youtube.go index 681d9f7..2c4e18c 100644 --- a/youtube/youtube.go +++ b/youtube/youtube.go @@ -72,12 +72,6 @@ func BuildVideo(metadata *scanner.Metadata) (*VideoMetadata, error) { Part: metadata.Part, Date: metadata.Date.AsTime(time.UTC), Tags: metadata.Tags, - Filepath: path.Join( - metadata.FootageDir, - fmt.Sprintf( - "%s-fullvod.mkv", - metadata.Date.String(), - )), }, nil }