From 0abafd26c54c9aefb9c6b992d287c815dd44239e Mon Sep 17 00:00:00 2001 From: ari melody Date: Sun, 28 Jun 2026 16:22:36 +0100 Subject: [PATCH 1/3] improve verbose logging, skip concat if only one VOD file present --- main.go | 98 +++++++++++++++++++++++++--------------------- youtube/youtube.go | 6 --- 2 files changed, 54 insertions(+), 50 deletions(-) diff --git a/main.go b/main.go index 26b7d03..cd1a461 100644 --- a/main.go +++ b/main.go @@ -73,7 +73,7 @@ func main() { var verbose bool = false var initDirectory bool = false var logout bool = false - var deleteFullVod bool = false + var keepFullVOD bool = false var forceUpload bool = false var dryRun bool = false var directory string = "." @@ -101,8 +101,8 @@ func main() { case "-d": fallthrough - case "--deleteAfter": - deleteFullVod = true + case "--keep-vod": + keepFullVOD = true case "-f": fallthrough @@ -193,10 +193,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,17 +247,9 @@ 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 @@ -325,34 +325,44 @@ 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]) - for _, filename := range vodFiles { - probe, err := scanner.ProbeSegment(path.Join(footageDir, filename)) - if err != nil { continue } - totalLength += probe.Format.Duration + // 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 { @@ -384,9 +394,9 @@ func main() { } // delete full VOD after upload, if requested - if deleteFullVod { - err = os.Remove(videoMeta.Filepath) - if err != nil { + // if len(vodFiles) == 1, the full VOD is the *only* VOD. do not delete this!!! + if len(vodFiles) > 1 && !keepFullVOD { + if err = os.Remove(videoMeta.Filepath); err != nil { log.Fatalf("Failed to delete full VOD: %v", err) } } 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 } From 75534da70273388f8a7d35a3640ca6c16f3b5b57 Mon Sep 17 00:00:00 2001 From: ari melody Date: Sun, 28 Jun 2026 16:37:42 +0100 Subject: [PATCH 2/3] update documentation --- README.md | 42 +++++++++++++++++++++++------------------- main.go | 45 ++++++++++++++++----------------------------- res/help.txt | 7 ++++--- 3 files changed, 43 insertions(+), 51 deletions(-) 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/main.go b/main.go index cd1a461..b6cf248 100644 --- a/main.go +++ b/main.go @@ -30,11 +30,6 @@ 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() @@ -65,18 +60,13 @@ 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 keepFullVOD bool = false + var keepConcatVOD bool = false var forceUpload bool = false var dryRun bool = false - var directory string = "." + var directory string = "" for i, arg := range os.Args { if i == 0 { continue } @@ -86,31 +76,29 @@ func main() { case "-h": fallthrough case "--help": - showHelp() + log.Fatal(helpText) case "-v": fallthrough case "--verbose": verbose = true - case "--init": - initDirectory = true - - case "--logout": - logout = true - - case "-d": - fallthrough - case "--keep-vod": - keepFullVOD = true + case "--dry-run": + dryRun = true case "-f": fallthrough case "--force": forceUpload = true - case "--dry-run": - dryRun = true + case "--init": + initDirectory = true + + case "--keep-vod": + keepConcatVOD = true + + case "--logout": + logout = true default: fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg) @@ -135,8 +123,7 @@ func main() { if err != nil { log.Fatalf("Failed to write config: %v", err) } - log.Println("Logged out successfully.") - os.Exit(0) + log.Fatalf("Logged out successfully.") } // initialising directory (--init) @@ -393,9 +380,9 @@ func main() { log.Fatalf("Failed to update metadata: %v", err) } - // delete full VOD after upload, if requested + // 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 && !keepFullVOD { + 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/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 From 21b7a3926b9a20bde6d8521371a947d9e20cdfe4 Mon Sep 17 00:00:00 2001 From: ari melody Date: Sun, 28 Jun 2026 16:59:41 +0100 Subject: [PATCH 3/3] minor auth refactor, some tidy-up --- config/config.go | 2 - main.go | 104 ++++++++++++++++++++++++++++------------------- oauth/oauth.go | 2 +- 3 files changed, 63 insertions(+), 45 deletions(-) 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 b6cf248..9344d75 100644 --- a/main.go +++ b/main.go @@ -33,33 +33,6 @@ const MAX_DESCRIPTION_LEN = 5000 func main() { ctx := context.Background() - // config - userConfigDir, err := os.UserConfigDir() - if err != nil { - log.Fatalf("Could not determine user configuration directory: %v", err) - } - config.CONFIG_FILENAME = path.Join(userConfigDir, "vodular", "config.toml") - cfg, err := config.ReadConfig(config.CONFIG_FILENAME) - if err != nil { - log.Fatalf("Failed to read config: %v", err) - } - if cfg == nil { - err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750) - if err != nil { - log.Fatalf("Failed to create config directory: %v", err) - } - err = config.GenerateConfig(config.CONFIG_FILENAME) - if err != nil { - log.Fatalf("Failed to generate config: %v", err) - } - log.Printf( - "New config file created (%s). " + - "Please edit this file before running again!", - config.CONFIG_FILENAME, - ) - os.Exit(0) - } - var verbose bool = false var initDirectory bool = false var logout bool = false @@ -110,6 +83,43 @@ func main() { } } + // config + userConfigDir, err := os.UserConfigDir() + if err != nil { + log.Fatalf("Could not determine user configuration directory: %v", err) + } + config.CONFIG_FILENAME = path.Join(userConfigDir, "vodular", "config.toml") + cfg, err := config.ReadConfig(config.CONFIG_FILENAME) + if err != nil { + log.Fatalf("Failed to read config: %v", err) + } + if cfg == nil { + if err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750); err != nil { + log.Fatalf("Failed to create config directory: %v", err) + } + if err = config.GenerateConfig(config.CONFIG_FILENAME); err != nil { + log.Fatalf("Failed to generate config: %v", err) + } + log.Printf( + "New config file created (%s). " + + "Please edit this file before running again!", + config.CONFIG_FILENAME, + ) + 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) + } + } + if !strings.HasPrefix(directory, "/") { if wd, err := os.Getwd(); err != nil { directory = path.Join(directory, wd) @@ -118,18 +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.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( @@ -139,6 +147,8 @@ func main() { ) } + + // good to have early on templates, err := yt.FetchTemplates(path.Join(userConfigDir, "vodular", "templates")) if err != nil { @@ -239,6 +249,8 @@ func main() { log.Printf("[ DESCRIPTION ]\n\n%s\n\n", description) } + + // youtube oauth flow oauth2Config := &oauth2.Config{ ClientID: cfg.Google.ClientID, @@ -247,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) @@ -265,6 +278,8 @@ func main() { log.Fatalf("Failed to save OAuth token: %v", err) } + + service, err := youtube.NewService( ctx, option.WithScopes(youtube.YoutubeUploadScope), @@ -274,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 { @@ -312,6 +329,8 @@ func main() { os.Exit(0) } + + videoMeta.Filepath = path.Join(metadata.FootageDir, vodFiles[0]) // no need to concat if VOD is already one whole file @@ -352,6 +371,8 @@ func main() { } } + + if !dryRun { // okay actually upload now! ytVideo, err := yt.UploadVideo(ctx, tokenSource, service, videoMeta, templates) @@ -375,8 +396,7 @@ 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) } 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 {