diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa2f491 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# ari's VOD uploader + +This tool stitches together livestream VOD segments and automatically uploads +them to YouTube. + +This tool expects to be run in a directory containing a [metadata](#metadata) +file, and targeting a footage directory containing `.mkv` files (these are +really quick and easy to stitch together). + +The template [title](template/title.txt) and +[description](template/description.txt) files contain my current format +for VOD upload metadata. They use generic Go templates + +## Basic usage + +Initialise a VOD directory: +```sh +vod-uploader --init /path/to/media +``` + +Upload a VOD, deleting the redundant full VOD export afterwards: +```sh +vod-uploader -d /path/to/media +``` + +## Metadata + +When `--init`ialising a directory, a `metadata.toml` file is created. This is a +plain-text file providing some simple options to customise uploads per +directory. See this example file with additional comments: + +```toml +# The title of the stream +title = 'Untitled Stream' +# (Optional) The part of an episodic stream. 0 assumes this is not episodic. +part = 0 +# The date of the stream +date = '2026-01-28' +# (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' +# 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] +#  +name = 'This Thing' +# Valid types: gaming, other (default: other) +type = 'other' +url = 'https://example.org' +``` + +## Options +- `-h`, --help`: Show a 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/config/config.go b/config/config.go index 32e26d0..4bed07f 100644 --- a/config/config.go +++ b/config/config.go @@ -5,21 +5,27 @@ import ( "os" "github.com/pelletier/go-toml/v2" + "golang.org/x/oauth2" ) type ( - Config struct { - Google GoogleConfig `toml:"google"` + GoogleConfig struct { + ApiKey string `toml:"api_key"` + ClientID string `toml:"client_id"` + ClientSecret string `toml:"client_secret"` } - GoogleConfig struct { - ApiKey string `toml:"api_key"` - ClientID string `toml:"client_id"` - ClientSecret string `toml:"client_secret"` + Config struct { + 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."` } ) var defaultConfig = Config{ + Host: "localhost:8090", + RedirectUri: "http://localhost:8090", Google: GoogleConfig{ ApiKey: "", ClientID: "", @@ -38,19 +44,21 @@ func ReadConfig(filename string) (*Config, error) { return nil, fmt.Errorf("failed to open file: %v", err) } - config := Config{} + var 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) +func WriteConfig(cfg *Config, filename string) error { + file, err := os.OpenFile(filename, os.O_CREATE | os.O_RDWR, 0644) if err != nil { return err } - err = toml.NewEncoder(file).Encode(defaultConfig) - if err != nil { return err } - - return nil + err = toml.NewEncoder(file).Encode(cfg) + return err +} + +func GenerateConfig(filename string) error { + return WriteConfig(&defaultConfig, filename) } diff --git a/main.go b/main.go index a61cb7a..261fd85 100644 --- a/main.go +++ b/main.go @@ -6,9 +6,12 @@ import ( "encoding/json" "fmt" "log" + "net/http" "os" "path" "strings" + "sync" + "time" "golang.org/x/oauth2" "golang.org/x/oauth2/google" @@ -20,11 +23,11 @@ import ( yt "arimelody.space/live-vod-uploader/youtube" ) -const segmentExtension = "mkv" - //go:embed res/help.txt var helpText string +const segmentExtension = "mkv" + func showHelp() { execSplits := strings.Split(os.Args[0], "/") execName := execSplits[len(execSplits) - 1] @@ -64,7 +67,7 @@ func main() { case "-d": fallthrough - case "-deleteAfter": + case "--deleteAfter": deleteFullVod = true case "-f": @@ -89,6 +92,11 @@ func main() { os.Exit(1) } if cfg == nil { + err = config.GenerateConfig(config.CONFIG_FILENAME) + if err != nil { + log.Fatalf("Failed to generate config: %v", err) + os.Exit(1) + } log.Printf( "New config file created (%s). " + "Please edit this file before running again!", @@ -97,6 +105,13 @@ func main() { os.Exit(0) } + // fetch default tags + yt.DefaultTags, err = yt.GetDefaultTags(path.Join("template", "tags.txt")) + if err != nil { + log.Fatalf("Failed to fetch default tags: %v", err) + os.Exit(1) + } + // initialising directory (--init) if initDirectory { err = initialiseDirectory(directory) @@ -104,7 +119,12 @@ func main() { log.Fatalf("Failed to initialise directory: %v", err) os.Exit(1) } - log.Printf("Directory successfully initialised") + log.Printf( + "Directory successfully initialised. " + + "Be sure to update %s before uploading!", + scanner.METADATA_FILENAME, + ) + os.Exit(0) } // read directory metadata @@ -131,6 +151,11 @@ func main() { os.Exit(0) } + // default footage directory + if len(metadata.FootageDir) == 0 { + metadata.FootageDir = directory + } + // scan for VOD segments vodFiles, err := scanner.ScanSegments(metadata.FootageDir, segmentExtension) if err != nil { @@ -175,13 +200,13 @@ func main() { os.Exit(1) } fmt.Printf( - "\nTITLE: %s\nDESCRIPTION: %s", + "\nTITLE: %s\nDESCRIPTION: %s\n", title, description, ) } // concatenate VOD segments into full VOD - err = vid.ConcatVideo(video, vodFiles, verbose) + video.SizeBytes, err = vid.ConcatVideo(video, vodFiles, verbose) if err != nil { log.Fatalf("Failed to concatenate VOD segments: %v", err) os.Exit(1) @@ -189,10 +214,20 @@ func main() { // youtube oauth flow ctx := context.Background() - token, err := completeOAuth(&ctx, cfg) - if err != nil { - log.Fatalf("OAuth flow failed: %v", err) - os.Exit(1) + var token *oauth2.Token + if cfg.Token != nil { + token = cfg.Token + } else { + token, err = generateOAuthToken(&ctx, cfg) + if err != nil { + log.Fatalf("OAuth flow failed: %v", err) + os.Exit(1) + } + cfg.Token = token + err = config.WriteConfig(cfg, config.CONFIG_FILENAME) + if err != nil { + log.Fatalf("Failed to save OAuth token: %v", err) + } } // okay actually upload now! @@ -219,7 +254,7 @@ func main() { // delete full VOD after upload, if requested if deleteFullVod { - err = os.Remove(path.Join(directory, scanner.METADATA_FILENAME)) + err = os.Remove(video.Filename) if err != nil { log.Fatalf("Failed to delete full VOD: %v", err) } @@ -240,7 +275,7 @@ func initialiseDirectory(directory string) error { _, err = os.Stat(path.Join(directory, scanner.METADATA_FILENAME)) if err == nil { - return fmt.Errorf("directory already initialised: %v", err) + return fmt.Errorf("directory already initialised: %s", directory) } err = scanner.WriteMetadata(directory, scanner.DefaultMetadata()) @@ -248,34 +283,69 @@ func initialiseDirectory(directory string) error { return err } -func completeOAuth(ctx *context.Context, cfg *config.Config) (*oauth2.Token, error) { +func generateOAuthToken(ctx *context.Context, cfg *config.Config) (*oauth2.Token, error) { oauth2Config := &oauth2.Config{ ClientID: cfg.Google.ClientID, ClientSecret: cfg.Google.ClientSecret, Endpoint: google.Endpoint, Scopes: []string{ youtube.YoutubeScope }, - RedirectURL: "http://localhost:8090", + RedirectURL: cfg.RedirectUri, } verifier := oauth2.GenerateVerifier() - url := oauth2Config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) - fmt.Printf("Sign in to YouTube: %s\n", url) + var token *oauth2.Token + wg := sync.WaitGroup{} + var server http.Server + server.Addr = cfg.Host + server.Handler = http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if !r.URL.Query().Has("code") { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } - // TODO: tidy up oauth flow with localhost webserver - var code string - fmt.Print("Enter OAuth2 code: ") - if _, err := fmt.Scan(&code); err != nil { - return nil, fmt.Errorf("failed to read code: %v", err) - } + code := r.URL.Query().Get("code") - token, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier)) - if err != nil { - log.Fatalf("Could not exchange OAuth2 code: %v", err) - os.Exit(1) + t, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier)) + if err != nil { + log.Fatalf("Could not exchange OAuth2 code: %v", err) + http.Error( w, + fmt.Sprintf("Could not exchange OAuth2 code: %v", err), + http.StatusBadRequest, + ) + return + } + + token = t + http.Error( + w, + "Authentication successful! You may now close this tab.", + http.StatusOK, + ) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + wg.Done() + server.Close() + }, + ) + + url := oauth2Config.AuthCodeURL( + "state", + oauth2.AccessTypeOffline, + oauth2.S256ChallengeOption(verifier), + ) + fmt.Printf("\nSign in to YouTube: %s\n\n", url) + + wg.Add(1) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + return nil, fmt.Errorf("http: %v", err) } + wg.Wait() // TODO: save this token; look into token refresh - log.Printf("Token expires on %s\n", token.Expiry.Format("02 Jan 2006")) + log.Printf("Token expires at: %s\n", token.Expiry.Format(time.DateTime)) return token, nil } diff --git a/scanner/scanner.go b/scanner/scanner.go index 5793e64..faf552b 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -11,18 +11,19 @@ import ( type ( Category struct { - Name string - Type string - Url string + Name string `toml:"name"` + Type string `toml:"type" comment:"Valid types: gaming, other (default: other)"` + Url string `toml:"url"` } Metadata struct { - Title string - Date string - Part int - FootageDir string - Category *Category - Uploaded bool + Title string `toml:"title"` + Part int `toml:"part"` + Date string `toml:"date"` + Tags []string `toml:"tags"` + FootageDir string `toml:"footage_dir"` + Uploaded bool `toml:"uploaded"` + Category *Category `toml:"category" comment:"(Optional) Category details, for additional credits."` } ) @@ -39,6 +40,7 @@ func ScanSegments(directory string, extension string) ([]string, error) { for _, item := range entries { if item.IsDir() { continue } if !strings.HasSuffix(item.Name(), "." + extension) { continue } + if strings.HasSuffix(item.Name(), "-fullvod." + extension) { continue } files = append(files, item.Name()) } @@ -67,12 +69,11 @@ func ReadMetadata(directory string) (*Metadata, error) { func WriteMetadata(directory string, metadata *Metadata) (error) { file, err := os.OpenFile( path.Join(directory, METADATA_FILENAME), - os.O_CREATE, 0644, + os.O_CREATE | os.O_RDWR, 0644, ) if err != nil { return err } err = toml.NewEncoder(file).Encode(metadata) - return err } diff --git a/template/description.txt b/template/description.txt index 2ee21a2..74abf0d 100644 --- a/template/description.txt +++ b/template/description.txt @@ -1,11 +1,11 @@ streamed on {{.Date}} 💚 watch ari melody LIVE: https://twitch.tv/arispacegirl -{{if .Title}}{{if eq .Title.Type "game"}} -🎮 play {{.Title.Name}}: -{{.Title.Url}} +{{if .Category}}{{if eq .Category.Type "gaming"}} +🎮 play {{.Category.Name}}: +{{.Category.Url}} {{else}} -✨ check out {{.Title.Name}}: -{{.Title.Url}} +✨ check out {{.Category.Name}}: +{{.Category.Url}} {{end}}{{end}} 💫 ari's place: https://arimelody.space 💬 ari melody discord: https://arimelody.space/discord diff --git a/template/tags.txt b/template/tags.txt new file mode 100644 index 0000000..f7e0ce8 --- /dev/null +++ b/template/tags.txt @@ -0,0 +1,10 @@ +ari melody +ari melody LIVE +arispacegirl +livestream +vtuber +twitch +full VOD +VOD +stream +archive diff --git a/template/title.txt b/template/title.txt index 8cf36a5..df07e62 100644 --- a/template/title.txt +++ b/template/title.txt @@ -1 +1 @@ -{{.Title.Name}}{{if gt .Part 0}}, part {{.Part}}{{end}} | ari melody LIVE 💚 | {{.Date}} +{{.Title}}{{if gt .Part 0}}, part {{.Part}}{{end}} | ari melody LIVE 💚 | {{.Date}} diff --git a/video/video.go b/video/video.go index 8a4aafd..06f4dd5 100644 --- a/video/video.go +++ b/video/video.go @@ -20,7 +20,7 @@ type ( } ) -func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) error { +func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) (int64, error) { fileListPath := path.Join( path.Dir(video.Filename), "files.txt", @@ -32,13 +32,13 @@ func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) error { fileListString += fmt.Sprintf("file '%s'\n", file) jsonProbe, err := ffmpeg.Probe(path.Join(path.Dir(video.Filename), file)) if err != nil { - return fmt.Errorf("failed to probe file `%s`: %v", file, err) + return 0, fmt.Errorf("failed to probe file `%s`: %v", file, err) } probe := probeData{} json.Unmarshal([]byte(jsonProbe), &probe) duration, err := strconv.ParseFloat(probe.Format.Duration, 64) if err != nil { - return fmt.Errorf("failed to parse duration of file `%s`: %v", file, err) + return 0, fmt.Errorf("failed to parse duration of file `%s`: %v", file, err) } totalDuration += duration } @@ -48,7 +48,7 @@ func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) error { 0644, ) if err != nil { - return fmt.Errorf("failed to write file list: %v", err) + return 0, fmt.Errorf("failed to write file list: %v", err) } stream := ffmpeg.Input(fileListPath, ffmpeg.KwArgs{ @@ -61,8 +61,11 @@ func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) error { err = stream.Run() if err != nil { - return fmt.Errorf("ffmpeg error: %v", err) + return 0, fmt.Errorf("ffmpeg error: %v", err) } - return nil + fileInfo, err := os.Stat(video.Filename) + if err != nil { return 0, fmt.Errorf("failed to read output file: %v", err) } + + return fileInfo.Size(), nil } diff --git a/youtube/youtube.go b/youtube/youtube.go index 14f66a1..4a16f91 100644 --- a/youtube/youtube.go +++ b/youtube/youtube.go @@ -17,70 +17,59 @@ import ( "google.golang.org/api/youtube/v3" ) -var DEFAULT_TAGS = []string{ - "ari melody", - "ari melody LIVE", - "livestream", - "vtuber", - "twitch", - "gaming", - "let's play", - "full VOD", - "VOD", - "stream", - "archive", -} - const ( - CATEGORY_GAMING = "20" - CATEGORY_ENTERTAINMENT = "24" + YT_CATEGORY_GAMING = "20" + YT_CATEGORY_ENTERTAINMENT = "24" ) -type TitleType int +type CategoryType int const ( - TITLE_GAME TitleType = iota - TITLE_OTHER + CATEGORY_GAME CategoryType = iota + CATEGORY_ENTERTAINMENT ) type ( - Title struct { - Name string - Type TitleType - Url string + Category struct { + Name string + Type CategoryType + Url string } Video struct { - Title *Title - Part int - Date time.Time - Tags []string - Filename string + Title string + Category *Category + Part int + Date time.Time + Tags []string + Filename string + SizeBytes int64 } ) func BuildVideo(metadata *scanner.Metadata) (*Video, error) { - var titleType TitleType - switch metadata.Category.Type { - case "gaming": - titleType = TITLE_GAME - default: - titleType = TITLE_OTHER - } - videoDate, err := time.Parse("2006-01-02", metadata.Date) if err != nil { return nil, fmt.Errorf("failed to parse date from metadata: %v", err) } - return &Video{ - Title: &Title{ + var category *Category = nil + if metadata.Category != nil { + category = &Category{ Name: metadata.Category.Name, - Type: titleType, + Type: CATEGORY_ENTERTAINMENT, Url: metadata.Category.Url, - }, + } + var ok bool + category.Type, ok = videoCategoryStringTypes[metadata.Category.Type] + if !ok { category.Type = CATEGORY_ENTERTAINMENT } + } + + return &Video{ + Title: metadata.Title, + Category: category, Part: metadata.Part, Date: videoDate, - Tags: DEFAULT_TAGS, + Tags: DefaultTags, Filename: path.Join( metadata.FootageDir, fmt.Sprintf( @@ -91,73 +80,92 @@ func BuildVideo(metadata *scanner.Metadata) (*Video, error) { } type ( - MetaTitle struct { + MetaCategory struct { Name string Type string Url string } Metadata struct { - Date string - Title *MetaTitle - Part int + Title string + Date string + Category *MetaCategory + Part int } ) +var videoCategoryTypeStrings = map[CategoryType]string{ + CATEGORY_GAME: "gaming", + CATEGORY_ENTERTAINMENT: "entertainment", +} +var videoCategoryStringTypes = map[string]CategoryType{ + "gaming": CATEGORY_GAME, + "entertainment": CATEGORY_ENTERTAINMENT, +} +var videoYtCategory = map[CategoryType]string { + CATEGORY_GAME: YT_CATEGORY_GAMING, + CATEGORY_ENTERTAINMENT: YT_CATEGORY_ENTERTAINMENT, +} + +var DefaultTags []string +func GetDefaultTags(filepath string) ([]string, error) { + file, err := os.ReadFile(filepath) + if err != nil { return nil, err } + + tags := strings.Split(string(file), "\n") + return tags, nil +} + var titleTemplate *template.Template = template.Must( template.ParseFiles("template/title.txt"), ) func BuildTitle(video *Video) (string, error) { - var titleType string - switch video.Title.Type { - case TITLE_GAME: - titleType = "game" - case TITLE_OTHER: - fallthrough - default: - titleType = "other" + out := &bytes.Buffer{} + + var category *MetaCategory + if video.Category != nil { + category = &MetaCategory{ + Name: video.Category.Name, + Type: videoCategoryTypeStrings[video.Category.Type], + Url: video.Category.Url, + } } - out := &bytes.Buffer{} - titleTemplate.Execute(out, Metadata{ + // TODO: give templates date format and lowercase functions + // these should not be hard-coded! + err := titleTemplate.Execute(out, Metadata{ + Title: video.Title, Date: strings.ToLower(video.Date.Format("02 Jan 2006")), - Title: &MetaTitle{ - Name: video.Title.Name, - Type: titleType, - Url: video.Title.Url, - }, + Category: category, Part: video.Part, }) - return strings.TrimSpace(out.String()), nil + return strings.TrimSpace(out.String()), err } var descriptionTemplate *template.Template = template.Must( template.ParseFiles("template/description.txt"), ) func BuildDescription(video *Video) (string, error) { - var titleType string - switch video.Title.Type { - case TITLE_GAME: - titleType = "game" - case TITLE_OTHER: - fallthrough - default: - titleType = "other" + out := &bytes.Buffer{} + + var category *MetaCategory + if video.Category != nil { + category = &MetaCategory{ + Name: video.Category.Name, + Type: videoCategoryTypeStrings[video.Category.Type], + Url: video.Category.Url, + } } - out := &bytes.Buffer{} - descriptionTemplate.Execute(out, Metadata{ + err := descriptionTemplate.Execute(out, Metadata{ + Title: video.Title, Date: strings.ToLower(video.Date.Format("02 Jan 2006")), - Title: &MetaTitle{ - Name: video.Title.Name, - Type: titleType, - Url: video.Title.Url, - }, + Category: category, Part: video.Part, }) - return out.String(), nil + return strings.TrimSpace(out.String()), err } func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtube.Video, error) { @@ -182,12 +190,11 @@ func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtu videoService := youtube.NewVideosService(service) - var categoryId string - switch video.Title.Type { - case TITLE_GAME: - categoryId = CATEGORY_GAMING - default: - categoryId = CATEGORY_ENTERTAINMENT + categoryId := YT_CATEGORY_ENTERTAINMENT + if video.Category != nil { + if cid, ok := videoYtCategory[video.Category.Type]; ok { + categoryId = cid + } } call := videoService.Insert([]string{ @@ -196,7 +203,7 @@ func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtu Snippet: &youtube.VideoSnippet{ Title: title, Description: description, - Tags: append(DEFAULT_TAGS, video.Tags...), + Tags: append(DefaultTags, video.Tags...), CategoryId: categoryId, // gaming }, Status: &youtube.VideoStatus{ @@ -213,6 +220,12 @@ func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtu log.Println("Uploading video...") + call.ProgressUpdater(func(current, total int64) { + // for some reason, this only returns 0. + // instead, we pull the file size from the ffmpeg output directly. + if total == 0 { total = video.SizeBytes } + fmt.Printf("Uploading... (%.2f%%)\n", float64(current) / float64(total)) + }) ytVideo, err := call.Do() if err != nil { log.Fatalf("Failed to upload video: %v\n", err)