Compare commits

..

3 commits

6 changed files with 167 additions and 153 deletions

View file

@ -1,7 +1,12 @@
# Vodular # 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 ## Quick Jump
- [Basic Usage](#basic-usage) - [Basic Usage](#basic-usage)
@ -9,40 +14,40 @@ I built this to greatly simplify the process of getting my full-quality livestre
- [Templates](#templates) - [Templates](#templates)
## Basic usage ## Basic usage
1. Run the tool for the first time to generate a starter configuration file: 1. Run `vodular` 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, The directory which holds your configuration file and templates varies,
depending on platform: depending on platform:
- **Linux:** `~/.config/vodular/templates` - **Linux:** `~/.config/vodular/templates`
- **macOS:** `~/Library/Application Support/vodular/templates` - **macOS:** `~/Library/Application Support/vodular/templates`
- **Windows:** `%AppData%/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). 2. Edit your configuration file as necessary (You will need to create a
**IMPORTANT:** `config.toml` contains very sensitive credentials. Do not share this file with anyone. [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: 3. Initialise a VOD directory:
```sh ```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: This directory should contain:
- A `metadata.toml` file - 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` - Your thumbnail, specifically named `thumbnail.png`
4. Modify your newly-created `metadata.toml` to your liking. 4. Modify your newly-created `metadata.toml` to your liking.
5. Upload a VOD! 5. Upload a VOD!
```sh ```sh
# `--deleteAfter` deletes the redundant full VOD export afterwards vodular /path/to/vod
vodular --deleteAfter /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 ## VOD Metadata
When `--init`ialising a directory, a `metadata.toml` file is created. This is a When `--init`ialising a directory, a `metadata.toml` file is created. This is a
@ -56,13 +61,12 @@ title = 'Untitled Stream'
part = 0 part = 0
# The date of the stream # The date of the stream
date = '2026-01-28' 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'] tags = ['livestream', 'VOD']
# (Optional) Footage directory override, for more complex directory structures. # (Optional) Footage directory override, for more complex directory structures.
footage_dir = 'footage' footage_dir = 'footage'
# Set to `true` by the tool when the VOD has been uploaded successfully. # On successful upload, updated to a URL to the video.
# Prevents future uploads unless `--force` is used. upload_url = false
uploaded = false
# (Optional) Category details, for additional credits. # (Optional) Category details, for additional credits.
[category] [category]

View file

@ -5,7 +5,6 @@ import (
"os" "os"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
"golang.org/x/oauth2"
) )
type ( type (
@ -19,7 +18,6 @@ type (
Host string `toml:"host" comment:"Address to host OAuth2 redirect flow"` Host string `toml:"host" comment:"Address to host OAuth2 redirect flow"`
RedirectUri string `toml:"redirect_uri" comment:"URI to use in Google OAuth2 flow"` RedirectUri string `toml:"redirect_uri" comment:"URI to use in Google OAuth2 flow"`
Google GoogleConfig `toml:"google"` Google GoogleConfig `toml:"google"`
Token *oauth2.Token `toml:"token" comment:"This section is filled in automatically on a successful authentication flow."`
} }
) )

261
main.go
View file

@ -30,14 +30,59 @@ const SEGMENT_EXTENSION = "mkv"
const MAX_TITLE_LEN = 100 const MAX_TITLE_LEN = 100
const MAX_DESCRIPTION_LEN = 5000 const MAX_DESCRIPTION_LEN = 5000
func showHelp() {
fmt.Println(helpText)
os.Exit(0)
}
func main() { func main() {
ctx := context.Background() 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 // config
userConfigDir, err := os.UserConfigDir() userConfigDir, err := os.UserConfigDir()
if err != nil { if err != nil {
@ -49,12 +94,10 @@ func main() {
log.Fatalf("Failed to read config: %v", err) log.Fatalf("Failed to read config: %v", err)
} }
if cfg == nil { if cfg == nil {
err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750) if err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750); err != nil {
if err != nil {
log.Fatalf("Failed to create config directory: %v", err) log.Fatalf("Failed to create config directory: %v", err)
} }
err = config.GenerateConfig(config.CONFIG_FILENAME) if err = config.GenerateConfig(config.CONFIG_FILENAME); err != nil {
if err != nil {
log.Fatalf("Failed to generate config: %v", err) log.Fatalf("Failed to generate config: %v", err)
} }
log.Printf( log.Printf(
@ -65,60 +108,15 @@ func main() {
os.Exit(0) os.Exit(0)
} }
// arguments oauthTokenFilepath := path.Join(userConfigDir, "vodular", "youtube-auth.json")
if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" { var token *oauth2.Token
showHelp() if data, err := os.ReadFile(oauthTokenFilepath); err != nil {
} if !os.IsNotExist(err) {
log.Fatalf("Failed to open %s: %v", oauthTokenFilepath, err)
var verbose bool = false }
var initDirectory bool = false } else {
var logout bool = false if err := json.Unmarshal(data, &token); err != nil {
var deleteFullVod bool = false log.Fatalf("Failed to parse YouTube auth file: %v", err)
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
} }
} }
@ -130,19 +128,16 @@ func main() {
// logout (--logout) // logout (--logout)
if logout { if logout {
cfg.Token = nil if token == nil { log.Fatal("Not logged in.") }
err = config.WriteConfig(cfg, config.CONFIG_FILENAME) if err := os.Remove(oauthTokenFilepath); err != nil {
if err != nil { log.Fatalf("Failed to delete %s: %v", oauthTokenFilepath, err)
log.Fatalf("Failed to write config: %v", err)
} }
log.Println("Logged out successfully.") log.Fatalf("Logged out successfully.")
os.Exit(0)
} }
// initialising directory (--init) // initialising directory (--init)
if initDirectory { if initDirectory {
err = scanner.InitialiseDirectory(directory) if err = scanner.InitialiseDirectory(directory); err != nil {
if err != nil {
log.Fatalf("Failed to initialise directory: %v", err) log.Fatalf("Failed to initialise directory: %v", err)
} }
log.Fatalf( log.Fatalf(
@ -152,6 +147,8 @@ func main() {
) )
} }
// good to have early on // good to have early on
templates, err := yt.FetchTemplates(path.Join(userConfigDir, "vodular", "templates")) templates, err := yt.FetchTemplates(path.Join(userConfigDir, "vodular", "templates"))
if err != nil { if err != nil {
@ -193,10 +190,18 @@ func main() {
if verbose { if verbose {
enc := json.NewEncoder(os.Stdout) enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", "\t") enc.SetIndent("", "\t")
fmt.Printf("Directory metadata: ") log.Print("[ Metadata ]")
enc.Encode(metadata) log.Printf("Title: %s", metadata.Title)
fmt.Printf("\nVOD files available: ") log.Printf("Part: %d", metadata.Part)
enc.Encode(vodFiles) 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 // scan for thumbnail
@ -239,19 +244,13 @@ func main() {
) )
} }
if verbose { if verbose {
enc := json.NewEncoder(os.Stdout) log.Print()
fmt.Printf("\nVideo template: ") log.Printf("[ TITLE ]\n\n%s\n\n", title)
enc.Encode(videoMeta) log.Printf("[ DESCRIPTION ]\n\n%s\n\n", description)
fmt.Printf(
"\n================================\n\n" +
"< TITLE >\n%s\n\n" +
"< DESCRIPTION >\n%s\n" +
"\n================================\n",
title, description,
)
} }
// youtube oauth flow // youtube oauth flow
oauth2Config := &oauth2.Config{ oauth2Config := &oauth2.Config{
ClientID: cfg.Google.ClientID, ClientID: cfg.Google.ClientID,
@ -260,16 +259,17 @@ func main() {
Scopes: []string{ youtube.YoutubeScope }, Scopes: []string{ youtube.YoutubeScope },
RedirectURL: cfg.RedirectUri, RedirectURL: cfg.RedirectUri,
} }
var token *oauth2.Token
if cfg.Token != nil { if token == nil {
token = cfg.Token
} else {
token, err = oauth.GenerateToken(&ctx, oauth2Config, cfg) token, err = oauth.GenerateToken(&ctx, oauth2Config, cfg)
if err != nil { if err != nil { log.Fatalf("OAuth flow failed: %v", err) }
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) tokenSource := oauth2Config.TokenSource(ctx, token)
if err != nil { if err != nil {
log.Fatalf("Failed to create OAuth2 token source: %v", err) 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) log.Fatalf("Failed to save OAuth token: %v", err)
} }
service, err := youtube.NewService( service, err := youtube.NewService(
ctx, ctx,
option.WithScopes(youtube.YoutubeUploadScope), option.WithScopes(youtube.YoutubeUploadScope),
@ -287,6 +289,8 @@ func main() {
log.Fatalf("Failed to create youtube service: %v\n", err) log.Fatalf("Failed to create youtube service: %v\n", err)
} }
// skip uploading if already done // skip uploading if already done
if (metadata.Uploaded || len(metadata.UploadURL) > 0) && !forceUpload { if (metadata.Uploaded || len(metadata.UploadURL) > 0) && !forceUpload {
if len(metadata.UploadURL) > 0 { if len(metadata.UploadURL) > 0 {
@ -325,36 +329,50 @@ func main() {
os.Exit(0) 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)) videoMeta.Filepath = path.Join(metadata.FootageDir, vodFiles[0])
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 { if concatVodExists {
log.Print("Full VOD appears to already exist- uploading this file...") 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 { } else {
// concatenate VOD segments into full VOD log.Print("Dry run: Skipping VOD concatenation")
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 { if !dryRun {
// okay actually upload now! // okay actually upload now!
ytVideo, err := yt.UploadVideo(ctx, tokenSource, service, videoMeta, templates) ytVideo, err := yt.UploadVideo(ctx, tokenSource, service, videoMeta, templates)
@ -378,15 +396,14 @@ func main() {
// update metadata to reflect VOD is uploaded // update metadata to reflect VOD is uploaded
metadata.UploadURL = yt.VIDEO_URL_BASE + ytVideo.Id metadata.UploadURL = yt.VIDEO_URL_BASE + ytVideo.Id
err = scanner.WriteMetadata(directory, metadata) if err = scanner.WriteMetadata(directory, metadata); err != nil {
if err != nil {
log.Fatalf("Failed to update metadata: %v", err) log.Fatalf("Failed to update metadata: %v", err)
} }
// delete full VOD after upload, if requested // delete concatenated VOD after upload, unless specified otherwise
if deleteFullVod { // if len(vodFiles) == 1, the full VOD is the *only* VOD. do not delete this!!!
err = os.Remove(videoMeta.Filepath) if len(vodFiles) > 1 && !keepConcatVOD {
if err != nil { if err = os.Remove(videoMeta.Filepath); err != nil {
log.Fatalf("Failed to delete full VOD: %v", err) log.Fatalf("Failed to delete full VOD: %v", err)
} }
} }

View file

@ -61,7 +61,7 @@ func GenerateToken(
oauth2.AccessTypeOffline, oauth2.AccessTypeOffline,
oauth2.S256ChallengeOption(verifier), 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) wg.Add(1)
if err := server.ListenAndServe(); err != http.ErrServerClosed { if err := server.ListenAndServe(); err != http.ErrServerClosed {

View file

@ -5,11 +5,12 @@ USAGE: vodular [options] [directory]
OPTIONS: OPTIONS:
-h, --help: Show this help message. -h, --help: Show this help message.
-v, --verbose: Show verbose logging output. -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) --dry-run: Don't upload or modify any files (Useful with -v)
-f, --force: Force uploading the VOD, even if it already exists. -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 SOURCE: https://codeberg.org/arimelody/vodular
ISSUES: https://codeberg.org/arimelody/vodular/issues ISSUES: https://codeberg.org/arimelody/vodular/issues

View file

@ -72,12 +72,6 @@ func BuildVideo(metadata *scanner.Metadata) (*VideoMetadata, error) {
Part: metadata.Part, Part: metadata.Part,
Date: metadata.Date.AsTime(time.UTC), Date: metadata.Date.AsTime(time.UTC),
Tags: metadata.Tags, Tags: metadata.Tags,
Filepath: path.Join(
metadata.FootageDir,
fmt.Sprintf(
"%s-fullvod.mkv",
metadata.Date.String(),
)),
}, nil }, nil
} }