Helper tool for stitching together livestream VOD segments and uploading them to YouTube!

more customisation, more QoL improvements

an all-around good time!

+314 -146
+63
README.md
···
··· 1 + # ari's VOD uploader 2 + 3 + This tool stitches together livestream VOD segments and automatically uploads 4 + them to YouTube. 5 + 6 + This tool expects to be run in a directory containing a [metadata](#metadata) 7 + file, and targeting a footage directory containing `.mkv` files (these are 8 + really quick and easy to stitch together). 9 + 10 + The template [title](template/title.txt) and 11 + [description](template/description.txt) files contain my current format 12 + for VOD upload metadata. They use generic Go templates 13 + 14 + ## Basic usage 15 + 16 + Initialise a VOD directory: 17 + ```sh 18 + vod-uploader --init /path/to/media 19 + ``` 20 + 21 + Upload a VOD, deleting the redundant full VOD export afterwards: 22 + ```sh 23 + vod-uploader -d /path/to/media 24 + ``` 25 + 26 + ## Metadata 27 + 28 + When `--init`ialising a directory, a `metadata.toml` file is created. This is a 29 + plain-text file providing some simple options to customise uploads per 30 + directory. See this example file with additional comments: 31 + 32 + ```toml 33 + # The title of the stream 34 + title = 'Untitled Stream' 35 + # (Optional) The part of an episodic stream. 0 assumes this is not episodic. 36 + part = 0 37 + # The date of the stream 38 + date = '2026-01-28' 39 + # (Optional) Additional tags to add to this VOD's metadata. 40 + tags = ['livestream', 'VOD'] 41 + # (Optional) Footage directory override, for more complex directory structures. 42 + footage_dir = 'footage' 43 + # Set to `true` by the tool when the VOD has been uploaded successfully. 44 + # Prevents future uploads unless `--force` is used. 45 + uploaded = false 46 + 47 + # (Optional) Category details, for additional credits. 48 + [category] 49 + 50 + name = 'This Thing' 51 + # Valid types: gaming, other (default: other) 52 + type = 'other' 53 + url = 'https://example.org' 54 + ``` 55 + 56 + ## Options 57 + - `-h`, --help`: Show a help message. 58 + - `-v`, --verbose`: Show verbose logging output. 59 + - `--init`: Initialise `directory` as a VOD directory. 60 + - `-d`, --deleteAfter`: Deletes the full VOD after upload. 61 + - `-f`, --force`: Force uploading the VOD, even if it already exists. 62 + 63 + *made with <3 by ari melody, 2026*
+20 -12
config/config.go
··· 5 "os" 6 7 "github.com/pelletier/go-toml/v2" 8 ) 9 10 type ( 11 - Config struct { 12 - Google GoogleConfig `toml:"google"` 13 } 14 15 - GoogleConfig struct { 16 - ApiKey string `toml:"api_key"` 17 - ClientID string `toml:"client_id"` 18 - ClientSecret string `toml:"client_secret"` 19 } 20 ) 21 22 var defaultConfig = Config{ 23 Google: GoogleConfig{ 24 ApiKey: "<your API key here>", 25 ClientID: "<your client ID here>", ··· 38 return nil, fmt.Errorf("failed to open file: %v", err) 39 } 40 41 - config := Config{} 42 err = toml.Unmarshal(cfgBytes, &config) 43 if err != nil { return &config, fmt.Errorf("failed to parse: %v", err) } 44 45 return &config, nil 46 } 47 48 - func GenerateConfig(filename string) error { 49 - file, err := os.OpenFile(filename, os.O_CREATE, 0644) 50 if err != nil { return err } 51 52 - err = toml.NewEncoder(file).Encode(defaultConfig) 53 - if err != nil { return err } 54 55 - return nil 56 }
··· 5 "os" 6 7 "github.com/pelletier/go-toml/v2" 8 + "golang.org/x/oauth2" 9 ) 10 11 type ( 12 + GoogleConfig struct { 13 + ApiKey string `toml:"api_key"` 14 + ClientID string `toml:"client_id"` 15 + ClientSecret string `toml:"client_secret"` 16 } 17 18 + Config struct { 19 + Host string `toml:"host" comment:"Address to host OAuth2 redirect flow"` 20 + RedirectUri string `toml:"redirect_uri" comment:"URI to use in Google OAuth2 flow"` 21 + Google GoogleConfig `toml:"google"` 22 + Token *oauth2.Token `toml:"token" comment:"This section is filled in automatically on a successful authentication flow."` 23 } 24 ) 25 26 var defaultConfig = Config{ 27 + Host: "localhost:8090", 28 + RedirectUri: "http://localhost:8090", 29 Google: GoogleConfig{ 30 ApiKey: "<your API key here>", 31 ClientID: "<your client ID here>", ··· 44 return nil, fmt.Errorf("failed to open file: %v", err) 45 } 46 47 + var config Config 48 err = toml.Unmarshal(cfgBytes, &config) 49 if err != nil { return &config, fmt.Errorf("failed to parse: %v", err) } 50 51 return &config, nil 52 } 53 54 + func WriteConfig(cfg *Config, filename string) error { 55 + file, err := os.OpenFile(filename, os.O_CREATE | os.O_RDWR, 0644) 56 if err != nil { return err } 57 58 + err = toml.NewEncoder(file).Encode(cfg) 59 + return err 60 + } 61 62 + func GenerateConfig(filename string) error { 63 + return WriteConfig(&defaultConfig, filename) 64 }
+97 -27
main.go
··· 6 "encoding/json" 7 "fmt" 8 "log" 9 "os" 10 "path" 11 "strings" 12 13 "golang.org/x/oauth2" 14 "golang.org/x/oauth2/google" ··· 20 yt "arimelody.space/live-vod-uploader/youtube" 21 ) 22 23 - const segmentExtension = "mkv" 24 - 25 //go:embed res/help.txt 26 var helpText string 27 28 func showHelp() { 29 execSplits := strings.Split(os.Args[0], "/") ··· 64 65 case "-d": 66 fallthrough 67 - case "-deleteAfter": 68 deleteFullVod = true 69 70 case "-f": ··· 89 os.Exit(1) 90 } 91 if cfg == nil { 92 log.Printf( 93 "New config file created (%s). " + 94 "Please edit this file before running again!", ··· 97 os.Exit(0) 98 } 99 100 // initialising directory (--init) 101 if initDirectory { 102 err = initialiseDirectory(directory) ··· 104 log.Fatalf("Failed to initialise directory: %v", err) 105 os.Exit(1) 106 } 107 - log.Printf("Directory successfully initialised") 108 } 109 110 // read directory metadata ··· 131 os.Exit(0) 132 } 133 134 // scan for VOD segments 135 vodFiles, err := scanner.ScanSegments(metadata.FootageDir, segmentExtension) 136 if err != nil { ··· 175 os.Exit(1) 176 } 177 fmt.Printf( 178 - "\nTITLE: %s\nDESCRIPTION: %s", 179 title, description, 180 ) 181 } 182 183 // concatenate VOD segments into full VOD 184 - err = vid.ConcatVideo(video, vodFiles, verbose) 185 if err != nil { 186 log.Fatalf("Failed to concatenate VOD segments: %v", err) 187 os.Exit(1) ··· 189 190 // youtube oauth flow 191 ctx := context.Background() 192 - token, err := completeOAuth(&ctx, cfg) 193 - if err != nil { 194 - log.Fatalf("OAuth flow failed: %v", err) 195 - os.Exit(1) 196 } 197 198 // okay actually upload now! ··· 219 220 // delete full VOD after upload, if requested 221 if deleteFullVod { 222 - err = os.Remove(path.Join(directory, scanner.METADATA_FILENAME)) 223 if err != nil { 224 log.Fatalf("Failed to delete full VOD: %v", err) 225 } ··· 240 241 _, err = os.Stat(path.Join(directory, scanner.METADATA_FILENAME)) 242 if err == nil { 243 - return fmt.Errorf("directory already initialised: %v", err) 244 } 245 246 err = scanner.WriteMetadata(directory, scanner.DefaultMetadata()) ··· 248 return err 249 } 250 251 - func completeOAuth(ctx *context.Context, cfg *config.Config) (*oauth2.Token, error) { 252 oauth2Config := &oauth2.Config{ 253 ClientID: cfg.Google.ClientID, 254 ClientSecret: cfg.Google.ClientSecret, 255 Endpoint: google.Endpoint, 256 Scopes: []string{ youtube.YoutubeScope }, 257 - RedirectURL: "http://localhost:8090", 258 } 259 verifier := oauth2.GenerateVerifier() 260 261 - url := oauth2Config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) 262 - fmt.Printf("Sign in to YouTube: %s\n", url) 263 264 - // TODO: tidy up oauth flow with localhost webserver 265 - var code string 266 - fmt.Print("Enter OAuth2 code: ") 267 - if _, err := fmt.Scan(&code); err != nil { 268 - return nil, fmt.Errorf("failed to read code: %v", err) 269 - } 270 271 - token, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier)) 272 - if err != nil { 273 - log.Fatalf("Could not exchange OAuth2 code: %v", err) 274 - os.Exit(1) 275 } 276 277 // TODO: save this token; look into token refresh 278 - log.Printf("Token expires on %s\n", token.Expiry.Format("02 Jan 2006")) 279 280 return token, nil 281 }
··· 6 "encoding/json" 7 "fmt" 8 "log" 9 + "net/http" 10 "os" 11 "path" 12 "strings" 13 + "sync" 14 + "time" 15 16 "golang.org/x/oauth2" 17 "golang.org/x/oauth2/google" ··· 23 yt "arimelody.space/live-vod-uploader/youtube" 24 ) 25 26 //go:embed res/help.txt 27 var helpText string 28 + 29 + const segmentExtension = "mkv" 30 31 func showHelp() { 32 execSplits := strings.Split(os.Args[0], "/") ··· 67 68 case "-d": 69 fallthrough 70 + case "--deleteAfter": 71 deleteFullVod = true 72 73 case "-f": ··· 92 os.Exit(1) 93 } 94 if cfg == nil { 95 + err = config.GenerateConfig(config.CONFIG_FILENAME) 96 + if err != nil { 97 + log.Fatalf("Failed to generate config: %v", err) 98 + os.Exit(1) 99 + } 100 log.Printf( 101 "New config file created (%s). " + 102 "Please edit this file before running again!", ··· 105 os.Exit(0) 106 } 107 108 + // fetch default tags 109 + yt.DefaultTags, err = yt.GetDefaultTags(path.Join("template", "tags.txt")) 110 + if err != nil { 111 + log.Fatalf("Failed to fetch default tags: %v", err) 112 + os.Exit(1) 113 + } 114 + 115 // initialising directory (--init) 116 if initDirectory { 117 err = initialiseDirectory(directory) ··· 119 log.Fatalf("Failed to initialise directory: %v", err) 120 os.Exit(1) 121 } 122 + log.Printf( 123 + "Directory successfully initialised. " + 124 + "Be sure to update %s before uploading!", 125 + scanner.METADATA_FILENAME, 126 + ) 127 + os.Exit(0) 128 } 129 130 // read directory metadata ··· 151 os.Exit(0) 152 } 153 154 + // default footage directory 155 + if len(metadata.FootageDir) == 0 { 156 + metadata.FootageDir = directory 157 + } 158 + 159 // scan for VOD segments 160 vodFiles, err := scanner.ScanSegments(metadata.FootageDir, segmentExtension) 161 if err != nil { ··· 200 os.Exit(1) 201 } 202 fmt.Printf( 203 + "\nTITLE: %s\nDESCRIPTION: %s\n", 204 title, description, 205 ) 206 } 207 208 // concatenate VOD segments into full VOD 209 + video.SizeBytes, err = vid.ConcatVideo(video, vodFiles, verbose) 210 if err != nil { 211 log.Fatalf("Failed to concatenate VOD segments: %v", err) 212 os.Exit(1) ··· 214 215 // youtube oauth flow 216 ctx := context.Background() 217 + var token *oauth2.Token 218 + if cfg.Token != nil { 219 + token = cfg.Token 220 + } else { 221 + token, err = generateOAuthToken(&ctx, cfg) 222 + if err != nil { 223 + log.Fatalf("OAuth flow failed: %v", err) 224 + os.Exit(1) 225 + } 226 + cfg.Token = token 227 + err = config.WriteConfig(cfg, config.CONFIG_FILENAME) 228 + if err != nil { 229 + log.Fatalf("Failed to save OAuth token: %v", err) 230 + } 231 } 232 233 // okay actually upload now! ··· 254 255 // delete full VOD after upload, if requested 256 if deleteFullVod { 257 + err = os.Remove(video.Filename) 258 if err != nil { 259 log.Fatalf("Failed to delete full VOD: %v", err) 260 } ··· 275 276 _, err = os.Stat(path.Join(directory, scanner.METADATA_FILENAME)) 277 if err == nil { 278 + return fmt.Errorf("directory already initialised: %s", directory) 279 } 280 281 err = scanner.WriteMetadata(directory, scanner.DefaultMetadata()) ··· 283 return err 284 } 285 286 + func generateOAuthToken(ctx *context.Context, cfg *config.Config) (*oauth2.Token, error) { 287 oauth2Config := &oauth2.Config{ 288 ClientID: cfg.Google.ClientID, 289 ClientSecret: cfg.Google.ClientSecret, 290 Endpoint: google.Endpoint, 291 Scopes: []string{ youtube.YoutubeScope }, 292 + RedirectURL: cfg.RedirectUri, 293 } 294 verifier := oauth2.GenerateVerifier() 295 296 + var token *oauth2.Token 297 + wg := sync.WaitGroup{} 298 + var server http.Server 299 + server.Addr = cfg.Host 300 + server.Handler = http.HandlerFunc( 301 + func(w http.ResponseWriter, r *http.Request) { 302 + if !r.URL.Query().Has("code") { 303 + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 304 + return 305 + } 306 307 + code := r.URL.Query().Get("code") 308 309 + t, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier)) 310 + if err != nil { 311 + log.Fatalf("Could not exchange OAuth2 code: %v", err) 312 + http.Error( w, 313 + fmt.Sprintf("Could not exchange OAuth2 code: %v", err), 314 + http.StatusBadRequest, 315 + ) 316 + return 317 + } 318 + 319 + token = t 320 + http.Error( 321 + w, 322 + "Authentication successful! You may now close this tab.", 323 + http.StatusOK, 324 + ) 325 + if f, ok := w.(http.Flusher); ok { 326 + f.Flush() 327 + } 328 + 329 + wg.Done() 330 + server.Close() 331 + }, 332 + ) 333 + 334 + url := oauth2Config.AuthCodeURL( 335 + "state", 336 + oauth2.AccessTypeOffline, 337 + oauth2.S256ChallengeOption(verifier), 338 + ) 339 + fmt.Printf("\nSign in to YouTube: %s\n\n", url) 340 + 341 + wg.Add(1) 342 + if err := server.ListenAndServe(); err != http.ErrServerClosed { 343 + return nil, fmt.Errorf("http: %v", err) 344 } 345 + wg.Wait() 346 347 // TODO: save this token; look into token refresh 348 + log.Printf("Token expires at: %s\n", token.Expiry.Format(time.DateTime)) 349 350 return token, nil 351 }
+12 -11
scanner/scanner.go
··· 11 12 type ( 13 Category struct { 14 - Name string 15 - Type string 16 - Url string 17 } 18 19 Metadata struct { 20 - Title string 21 - Date string 22 - Part int 23 - FootageDir string 24 - Category *Category 25 - Uploaded bool 26 } 27 ) 28 ··· 39 for _, item := range entries { 40 if item.IsDir() { continue } 41 if !strings.HasSuffix(item.Name(), "." + extension) { continue } 42 files = append(files, item.Name()) 43 } 44 ··· 67 func WriteMetadata(directory string, metadata *Metadata) (error) { 68 file, err := os.OpenFile( 69 path.Join(directory, METADATA_FILENAME), 70 - os.O_CREATE, 0644, 71 ) 72 if err != nil { return err } 73 74 err = toml.NewEncoder(file).Encode(metadata) 75 - 76 return err 77 } 78
··· 11 12 type ( 13 Category struct { 14 + Name string `toml:"name"` 15 + Type string `toml:"type" comment:"Valid types: gaming, other (default: other)"` 16 + Url string `toml:"url"` 17 } 18 19 Metadata struct { 20 + Title string `toml:"title"` 21 + Part int `toml:"part"` 22 + Date string `toml:"date"` 23 + Tags []string `toml:"tags"` 24 + FootageDir string `toml:"footage_dir"` 25 + Uploaded bool `toml:"uploaded"` 26 + Category *Category `toml:"category" comment:"(Optional) Category details, for additional credits."` 27 } 28 ) 29 ··· 40 for _, item := range entries { 41 if item.IsDir() { continue } 42 if !strings.HasSuffix(item.Name(), "." + extension) { continue } 43 + if strings.HasSuffix(item.Name(), "-fullvod." + extension) { continue } 44 files = append(files, item.Name()) 45 } 46 ··· 69 func WriteMetadata(directory string, metadata *Metadata) (error) { 70 file, err := os.OpenFile( 71 path.Join(directory, METADATA_FILENAME), 72 + os.O_CREATE | os.O_RDWR, 0644, 73 ) 74 if err != nil { return err } 75 76 err = toml.NewEncoder(file).Encode(metadata) 77 return err 78 } 79
+5 -5
template/description.txt
··· 1 streamed on {{.Date}} 2 💚 watch ari melody LIVE: https://twitch.tv/arispacegirl 3 - {{if .Title}}{{if eq .Title.Type "game"}} 4 - 🎮 play {{.Title.Name}}: 5 - {{.Title.Url}} 6 {{else}} 7 - ✨ check out {{.Title.Name}}: 8 - {{.Title.Url}} 9 {{end}}{{end}} 10 💫 ari's place: https://arimelody.space 11 💬 ari melody discord: https://arimelody.space/discord
··· 1 streamed on {{.Date}} 2 💚 watch ari melody LIVE: https://twitch.tv/arispacegirl 3 + {{if .Category}}{{if eq .Category.Type "gaming"}} 4 + 🎮 play {{.Category.Name}}: 5 + {{.Category.Url}} 6 {{else}} 7 + ✨ check out {{.Category.Name}}: 8 + {{.Category.Url}} 9 {{end}}{{end}} 10 💫 ari's place: https://arimelody.space 11 💬 ari melody discord: https://arimelody.space/discord
+10
template/tags.txt
···
··· 1 + ari melody 2 + ari melody LIVE 3 + arispacegirl 4 + livestream 5 + vtuber 6 + twitch 7 + full VOD 8 + VOD 9 + stream 10 + archive
+1 -1
template/title.txt
··· 1 - {{.Title.Name}}{{if gt .Part 0}}, part {{.Part}}{{end}} | ari melody LIVE 💚 | {{.Date}}
··· 1 + {{.Title}}{{if gt .Part 0}}, part {{.Part}}{{end}} | ari melody LIVE 💚 | {{.Date}}
+9 -6
video/video.go
··· 20 } 21 ) 22 23 - func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) error { 24 fileListPath := path.Join( 25 path.Dir(video.Filename), 26 "files.txt", ··· 32 fileListString += fmt.Sprintf("file '%s'\n", file) 33 jsonProbe, err := ffmpeg.Probe(path.Join(path.Dir(video.Filename), file)) 34 if err != nil { 35 - return fmt.Errorf("failed to probe file `%s`: %v", file, err) 36 } 37 probe := probeData{} 38 json.Unmarshal([]byte(jsonProbe), &probe) 39 duration, err := strconv.ParseFloat(probe.Format.Duration, 64) 40 if err != nil { 41 - return fmt.Errorf("failed to parse duration of file `%s`: %v", file, err) 42 } 43 totalDuration += duration 44 } ··· 48 0644, 49 ) 50 if err != nil { 51 - return fmt.Errorf("failed to write file list: %v", err) 52 } 53 54 stream := ffmpeg.Input(fileListPath, ffmpeg.KwArgs{ ··· 61 err = stream.Run() 62 63 if err != nil { 64 - return fmt.Errorf("ffmpeg error: %v", err) 65 } 66 67 - return nil 68 }
··· 20 } 21 ) 22 23 + func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) (int64, error) { 24 fileListPath := path.Join( 25 path.Dir(video.Filename), 26 "files.txt", ··· 32 fileListString += fmt.Sprintf("file '%s'\n", file) 33 jsonProbe, err := ffmpeg.Probe(path.Join(path.Dir(video.Filename), file)) 34 if err != nil { 35 + return 0, fmt.Errorf("failed to probe file `%s`: %v", file, err) 36 } 37 probe := probeData{} 38 json.Unmarshal([]byte(jsonProbe), &probe) 39 duration, err := strconv.ParseFloat(probe.Format.Duration, 64) 40 if err != nil { 41 + return 0, fmt.Errorf("failed to parse duration of file `%s`: %v", file, err) 42 } 43 totalDuration += duration 44 } ··· 48 0644, 49 ) 50 if err != nil { 51 + return 0, fmt.Errorf("failed to write file list: %v", err) 52 } 53 54 stream := ffmpeg.Input(fileListPath, ffmpeg.KwArgs{ ··· 61 err = stream.Run() 62 63 if err != nil { 64 + return 0, fmt.Errorf("ffmpeg error: %v", err) 65 } 66 67 + fileInfo, err := os.Stat(video.Filename) 68 + if err != nil { return 0, fmt.Errorf("failed to read output file: %v", err) } 69 + 70 + return fileInfo.Size(), nil 71 }
+97 -84
youtube/youtube.go
··· 17 "google.golang.org/api/youtube/v3" 18 ) 19 20 - var DEFAULT_TAGS = []string{ 21 - "ari melody", 22 - "ari melody LIVE", 23 - "livestream", 24 - "vtuber", 25 - "twitch", 26 - "gaming", 27 - "let's play", 28 - "full VOD", 29 - "VOD", 30 - "stream", 31 - "archive", 32 - } 33 - 34 const ( 35 - CATEGORY_GAMING = "20" 36 - CATEGORY_ENTERTAINMENT = "24" 37 ) 38 39 - type TitleType int 40 const ( 41 - TITLE_GAME TitleType = iota 42 - TITLE_OTHER 43 ) 44 45 type ( 46 - Title struct { 47 - Name string 48 - Type TitleType 49 - Url string 50 } 51 52 Video struct { 53 - Title *Title 54 - Part int 55 - Date time.Time 56 - Tags []string 57 - Filename string 58 } 59 ) 60 61 func BuildVideo(metadata *scanner.Metadata) (*Video, error) { 62 - var titleType TitleType 63 - switch metadata.Category.Type { 64 - case "gaming": 65 - titleType = TITLE_GAME 66 - default: 67 - titleType = TITLE_OTHER 68 - } 69 - 70 videoDate, err := time.Parse("2006-01-02", metadata.Date) 71 if err != nil { 72 return nil, fmt.Errorf("failed to parse date from metadata: %v", err) 73 } 74 75 - return &Video{ 76 - Title: &Title{ 77 Name: metadata.Category.Name, 78 - Type: titleType, 79 Url: metadata.Category.Url, 80 - }, 81 Part: metadata.Part, 82 Date: videoDate, 83 - Tags: DEFAULT_TAGS, 84 Filename: path.Join( 85 metadata.FootageDir, 86 fmt.Sprintf( ··· 91 } 92 93 type ( 94 - MetaTitle struct { 95 Name string 96 Type string 97 Url string 98 } 99 100 Metadata struct { 101 - Date string 102 - Title *MetaTitle 103 - Part int 104 } 105 ) 106 107 var titleTemplate *template.Template = template.Must( 108 template.ParseFiles("template/title.txt"), 109 ) 110 func BuildTitle(video *Video) (string, error) { 111 - var titleType string 112 - switch video.Title.Type { 113 - case TITLE_GAME: 114 - titleType = "game" 115 - case TITLE_OTHER: 116 - fallthrough 117 - default: 118 - titleType = "other" 119 } 120 121 - out := &bytes.Buffer{} 122 - titleTemplate.Execute(out, Metadata{ 123 Date: strings.ToLower(video.Date.Format("02 Jan 2006")), 124 - Title: &MetaTitle{ 125 - Name: video.Title.Name, 126 - Type: titleType, 127 - Url: video.Title.Url, 128 - }, 129 Part: video.Part, 130 }) 131 132 - return strings.TrimSpace(out.String()), nil 133 } 134 135 var descriptionTemplate *template.Template = template.Must( 136 template.ParseFiles("template/description.txt"), 137 ) 138 func BuildDescription(video *Video) (string, error) { 139 - var titleType string 140 - switch video.Title.Type { 141 - case TITLE_GAME: 142 - titleType = "game" 143 - case TITLE_OTHER: 144 - fallthrough 145 - default: 146 - titleType = "other" 147 } 148 149 - out := &bytes.Buffer{} 150 - descriptionTemplate.Execute(out, Metadata{ 151 Date: strings.ToLower(video.Date.Format("02 Jan 2006")), 152 - Title: &MetaTitle{ 153 - Name: video.Title.Name, 154 - Type: titleType, 155 - Url: video.Title.Url, 156 - }, 157 Part: video.Part, 158 }) 159 160 - return out.String(), nil 161 } 162 163 func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtube.Video, error) { ··· 182 183 videoService := youtube.NewVideosService(service) 184 185 - var categoryId string 186 - switch video.Title.Type { 187 - case TITLE_GAME: 188 - categoryId = CATEGORY_GAMING 189 - default: 190 - categoryId = CATEGORY_ENTERTAINMENT 191 } 192 193 call := videoService.Insert([]string{ ··· 196 Snippet: &youtube.VideoSnippet{ 197 Title: title, 198 Description: description, 199 - Tags: append(DEFAULT_TAGS, video.Tags...), 200 CategoryId: categoryId, // gaming 201 }, 202 Status: &youtube.VideoStatus{ ··· 213 214 log.Println("Uploading video...") 215 216 ytVideo, err := call.Do() 217 if err != nil { 218 log.Fatalf("Failed to upload video: %v\n", err)
··· 17 "google.golang.org/api/youtube/v3" 18 ) 19 20 const ( 21 + YT_CATEGORY_GAMING = "20" 22 + YT_CATEGORY_ENTERTAINMENT = "24" 23 ) 24 25 + type CategoryType int 26 const ( 27 + CATEGORY_GAME CategoryType = iota 28 + CATEGORY_ENTERTAINMENT 29 ) 30 31 type ( 32 + Category struct { 33 + Name string 34 + Type CategoryType 35 + Url string 36 } 37 38 Video struct { 39 + Title string 40 + Category *Category 41 + Part int 42 + Date time.Time 43 + Tags []string 44 + Filename string 45 + SizeBytes int64 46 } 47 ) 48 49 func BuildVideo(metadata *scanner.Metadata) (*Video, error) { 50 videoDate, err := time.Parse("2006-01-02", metadata.Date) 51 if err != nil { 52 return nil, fmt.Errorf("failed to parse date from metadata: %v", err) 53 } 54 55 + var category *Category = nil 56 + if metadata.Category != nil { 57 + category = &Category{ 58 Name: metadata.Category.Name, 59 + Type: CATEGORY_ENTERTAINMENT, 60 Url: metadata.Category.Url, 61 + } 62 + var ok bool 63 + category.Type, ok = videoCategoryStringTypes[metadata.Category.Type] 64 + if !ok { category.Type = CATEGORY_ENTERTAINMENT } 65 + } 66 + 67 + return &Video{ 68 + Title: metadata.Title, 69 + Category: category, 70 Part: metadata.Part, 71 Date: videoDate, 72 + Tags: DefaultTags, 73 Filename: path.Join( 74 metadata.FootageDir, 75 fmt.Sprintf( ··· 80 } 81 82 type ( 83 + MetaCategory struct { 84 Name string 85 Type string 86 Url string 87 } 88 89 Metadata struct { 90 + Title string 91 + Date string 92 + Category *MetaCategory 93 + Part int 94 } 95 ) 96 97 + var videoCategoryTypeStrings = map[CategoryType]string{ 98 + CATEGORY_GAME: "gaming", 99 + CATEGORY_ENTERTAINMENT: "entertainment", 100 + } 101 + var videoCategoryStringTypes = map[string]CategoryType{ 102 + "gaming": CATEGORY_GAME, 103 + "entertainment": CATEGORY_ENTERTAINMENT, 104 + } 105 + var videoYtCategory = map[CategoryType]string { 106 + CATEGORY_GAME: YT_CATEGORY_GAMING, 107 + CATEGORY_ENTERTAINMENT: YT_CATEGORY_ENTERTAINMENT, 108 + } 109 + 110 + var DefaultTags []string 111 + func GetDefaultTags(filepath string) ([]string, error) { 112 + file, err := os.ReadFile(filepath) 113 + if err != nil { return nil, err } 114 + 115 + tags := strings.Split(string(file), "\n") 116 + return tags, nil 117 + } 118 + 119 var titleTemplate *template.Template = template.Must( 120 template.ParseFiles("template/title.txt"), 121 ) 122 func BuildTitle(video *Video) (string, error) { 123 + out := &bytes.Buffer{} 124 + 125 + var category *MetaCategory 126 + if video.Category != nil { 127 + category = &MetaCategory{ 128 + Name: video.Category.Name, 129 + Type: videoCategoryTypeStrings[video.Category.Type], 130 + Url: video.Category.Url, 131 + } 132 } 133 134 + // TODO: give templates date format and lowercase functions 135 + // these should not be hard-coded! 136 + err := titleTemplate.Execute(out, Metadata{ 137 + Title: video.Title, 138 Date: strings.ToLower(video.Date.Format("02 Jan 2006")), 139 + Category: category, 140 Part: video.Part, 141 }) 142 143 + return strings.TrimSpace(out.String()), err 144 } 145 146 var descriptionTemplate *template.Template = template.Must( 147 template.ParseFiles("template/description.txt"), 148 ) 149 func BuildDescription(video *Video) (string, error) { 150 + out := &bytes.Buffer{} 151 + 152 + var category *MetaCategory 153 + if video.Category != nil { 154 + category = &MetaCategory{ 155 + Name: video.Category.Name, 156 + Type: videoCategoryTypeStrings[video.Category.Type], 157 + Url: video.Category.Url, 158 + } 159 } 160 161 + err := descriptionTemplate.Execute(out, Metadata{ 162 + Title: video.Title, 163 Date: strings.ToLower(video.Date.Format("02 Jan 2006")), 164 + Category: category, 165 Part: video.Part, 166 }) 167 168 + return strings.TrimSpace(out.String()), err 169 } 170 171 func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtube.Video, error) { ··· 190 191 videoService := youtube.NewVideosService(service) 192 193 + categoryId := YT_CATEGORY_ENTERTAINMENT 194 + if video.Category != nil { 195 + if cid, ok := videoYtCategory[video.Category.Type]; ok { 196 + categoryId = cid 197 + } 198 } 199 200 call := videoService.Insert([]string{ ··· 203 Snippet: &youtube.VideoSnippet{ 204 Title: title, 205 Description: description, 206 + Tags: append(DefaultTags, video.Tags...), 207 CategoryId: categoryId, // gaming 208 }, 209 Status: &youtube.VideoStatus{ ··· 220 221 log.Println("Uploading video...") 222 223 + call.ProgressUpdater(func(current, total int64) { 224 + // for some reason, this only returns 0. 225 + // instead, we pull the file size from the ffmpeg output directly. 226 + if total == 0 { total = video.SizeBytes } 227 + fmt.Printf("Uploading... (%.2f%%)\n", float64(current) / float64(total)) 228 + }) 229 ytVideo, err := call.Do() 230 if err != nil { 231 log.Fatalf("Failed to upload video: %v\n", err)