diff --git a/README.md b/README.md index d43cc49..25360ea 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,7 @@ # 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) @@ -14,40 +9,40 @@ encouraged! - [Templates](#templates) ## Basic usage -1. Run `vodular` for the first time to generate a starter configuration file: - +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! +``` 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 +$ vodular --init /path/to/vod +Directory successfully initialised. Be sure to update metadata.toml before uploading! ``` 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 -vodular /path/to/vod +# `--deleteAfter` deletes the redundant full VOD export afterwards +vodular --deleteAfter /path/to/vod ``` -**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`. +**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`. ## VOD Metadata When `--init`ialising a directory, a `metadata.toml` file is created. This is a @@ -61,12 +56,13 @@ title = 'Untitled Stream' part = 0 # The date of the stream date = '2026-01-28' -# (Optional) VOD-specific tags. Added to global tags template. +# (Optional) Additional tags to add to this VOD's metadata. tags = ['livestream', 'VOD'] # (Optional) Footage directory override, for more complex directory structures. footage_dir = 'footage' -# On successful upload, updated to a URL to the video. -upload_url = false +# Set to `true` by the tool when the VOD has been uploaded successfully. +# Prevents future uploads unless `--force` is used. +uploaded = false # (Optional) Category details, for additional credits. [category] diff --git a/config/config.go b/config/config.go index 3bdbd98..28e0378 100644 --- a/config/config.go +++ b/config/config.go @@ -5,6 +5,7 @@ import ( "os" "github.com/pelletier/go-toml/v2" + "golang.org/x/oauth2" ) type ( @@ -18,6 +19,7 @@ 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 9344d75..26b7d03 100644 --- a/main.go +++ b/main.go @@ -30,59 +30,14 @@ 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 { @@ -94,10 +49,12 @@ func main() { log.Fatalf("Failed to read config: %v", err) } if cfg == nil { - if err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750); err != nil { + err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750) + if err != nil { log.Fatalf("Failed to create config directory: %v", err) } - if err = config.GenerateConfig(config.CONFIG_FILENAME); err != nil { + err = config.GenerateConfig(config.CONFIG_FILENAME) + if err != nil { log.Fatalf("Failed to generate config: %v", err) } log.Printf( @@ -108,15 +65,60 @@ func main() { os.Exit(0) } - 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) + // 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 } } @@ -128,16 +130,19 @@ func main() { // logout (--logout) if logout { - if token == nil { log.Fatal("Not logged in.") } - if err := os.Remove(oauthTokenFilepath); err != nil { - log.Fatalf("Failed to delete %s: %v", oauthTokenFilepath, err) + cfg.Token = nil + err = config.WriteConfig(cfg, config.CONFIG_FILENAME) + if err != nil { + log.Fatalf("Failed to write config: %v", err) } - log.Fatalf("Logged out successfully.") + log.Println("Logged out successfully.") + os.Exit(0) } // initialising directory (--init) if initDirectory { - if err = scanner.InitialiseDirectory(directory); err != nil { + err = scanner.InitialiseDirectory(directory) + if err != nil { log.Fatalf("Failed to initialise directory: %v", err) } log.Fatalf( @@ -147,8 +152,6 @@ func main() { ) } - - // good to have early on templates, err := yt.FetchTemplates(path.Join(userConfigDir, "vodular", "templates")) if err != nil { @@ -190,18 +193,10 @@ func main() { if verbose { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", "\t") - 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, ", ")) + fmt.Printf("Directory metadata: ") + enc.Encode(metadata) + fmt.Printf("\nVOD files available: ") + enc.Encode(vodFiles) } // scan for thumbnail @@ -244,13 +239,19 @@ func main() { ) } if verbose { - log.Print() - log.Printf("[ TITLE ]\n\n%s\n\n", title) - log.Printf("[ DESCRIPTION ]\n\n%s\n\n", description) + 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, + ) } - - // youtube oauth flow oauth2Config := &oauth2.Config{ ClientID: cfg.Google.ClientID, @@ -259,17 +260,16 @@ func main() { Scopes: []string{ youtube.YoutubeScope }, RedirectURL: cfg.RedirectUri, } - - if token == nil { + var token *oauth2.Token + if cfg.Token != nil { + token = cfg.Token + } else { token, err = oauth.GenerateToken(&ctx, oauth2Config, cfg) - 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) + if err != nil { + log.Fatalf("OAuth flow failed: %v", err) } + cfg.Token = token } - tokenSource := oauth2Config.TokenSource(ctx, token) if err != nil { log.Fatalf("Failed to create OAuth2 token source: %v", err) @@ -278,8 +278,6 @@ func main() { log.Fatalf("Failed to save OAuth token: %v", err) } - - service, err := youtube.NewService( ctx, option.WithScopes(youtube.YoutubeUploadScope), @@ -289,8 +287,6 @@ 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 { @@ -329,50 +325,36 @@ 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 - - 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) + for _, filename := range vodFiles { + probe, err := scanner.ProbeSegment(path.Join(footageDir, filename)) + if err != nil { continue } + totalLength += probe.Format.Duration } - - 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 { - log.Print("Dry run: Skipping VOD concatenation") + // 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...") + } 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 { + log.Print("Dry run: Skipping VOD concatenation") } - - if !dryRun { // okay actually upload now! ytVideo, err := yt.UploadVideo(ctx, tokenSource, service, videoMeta, templates) @@ -396,14 +378,15 @@ func main() { // update metadata to reflect VOD is uploaded metadata.UploadURL = yt.VIDEO_URL_BASE + ytVideo.Id - if err = scanner.WriteMetadata(directory, metadata); err != nil { + err = scanner.WriteMetadata(directory, metadata) + if err != nil { log.Fatalf("Failed to update metadata: %v", err) } - // 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 { + // delete full VOD after upload, if requested + if deleteFullVod { + err = os.Remove(videoMeta.Filepath) + if err != nil { log.Fatalf("Failed to delete full VOD: %v", err) } } diff --git a/oauth/oauth.go b/oauth/oauth.go index 9102bb9..4dee4a8 100644 --- a/oauth/oauth.go +++ b/oauth/oauth.go @@ -61,7 +61,7 @@ func GenerateToken( oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier), ) - log.Printf("Sign in to YouTube: %s\n\n", url) + fmt.Printf("\nSign 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 980e0f1..317d416 100644 --- a/res/help.txt +++ b/res/help.txt @@ -5,12 +5,11 @@ 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 2c4e18c..681d9f7 100644 --- a/youtube/youtube.go +++ b/youtube/youtube.go @@ -72,6 +72,12 @@ 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 }