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 5 "os" 6 6 7 7 "github.com/pelletier/go-toml/v2" 8 + "golang.org/x/oauth2" 8 9 ) 9 10 10 11 type ( 11 - Config struct { 12 - Google GoogleConfig `toml:"google"` 12 + GoogleConfig struct { 13 + ApiKey string `toml:"api_key"` 14 + ClientID string `toml:"client_id"` 15 + ClientSecret string `toml:"client_secret"` 13 16 } 14 17 15 - GoogleConfig struct { 16 - ApiKey string `toml:"api_key"` 17 - ClientID string `toml:"client_id"` 18 - ClientSecret string `toml:"client_secret"` 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."` 19 23 } 20 24 ) 21 25 22 26 var defaultConfig = Config{ 27 + Host: "localhost:8090", 28 + RedirectUri: "http://localhost:8090", 23 29 Google: GoogleConfig{ 24 30 ApiKey: "<your API key here>", 25 31 ClientID: "<your client ID here>", ··· 38 44 return nil, fmt.Errorf("failed to open file: %v", err) 39 45 } 40 46 41 - config := Config{} 47 + var config Config 42 48 err = toml.Unmarshal(cfgBytes, &config) 43 49 if err != nil { return &config, fmt.Errorf("failed to parse: %v", err) } 44 50 45 51 return &config, nil 46 52 } 47 53 48 - func GenerateConfig(filename string) error { 49 - file, err := os.OpenFile(filename, os.O_CREATE, 0644) 54 + func WriteConfig(cfg *Config, filename string) error { 55 + file, err := os.OpenFile(filename, os.O_CREATE | os.O_RDWR, 0644) 50 56 if err != nil { return err } 51 57 52 - err = toml.NewEncoder(file).Encode(defaultConfig) 53 - if err != nil { return err } 58 + err = toml.NewEncoder(file).Encode(cfg) 59 + return err 60 + } 54 61 55 - return nil 62 + func GenerateConfig(filename string) error { 63 + return WriteConfig(&defaultConfig, filename) 56 64 }
+97 -27
main.go
··· 6 6 "encoding/json" 7 7 "fmt" 8 8 "log" 9 + "net/http" 9 10 "os" 10 11 "path" 11 12 "strings" 13 + "sync" 14 + "time" 12 15 13 16 "golang.org/x/oauth2" 14 17 "golang.org/x/oauth2/google" ··· 20 23 yt "arimelody.space/live-vod-uploader/youtube" 21 24 ) 22 25 23 - const segmentExtension = "mkv" 24 - 25 26 //go:embed res/help.txt 26 27 var helpText string 28 + 29 + const segmentExtension = "mkv" 27 30 28 31 func showHelp() { 29 32 execSplits := strings.Split(os.Args[0], "/") ··· 64 67 65 68 case "-d": 66 69 fallthrough 67 - case "-deleteAfter": 70 + case "--deleteAfter": 68 71 deleteFullVod = true 69 72 70 73 case "-f": ··· 89 92 os.Exit(1) 90 93 } 91 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 + } 92 100 log.Printf( 93 101 "New config file created (%s). " + 94 102 "Please edit this file before running again!", ··· 97 105 os.Exit(0) 98 106 } 99 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 + 100 115 // initialising directory (--init) 101 116 if initDirectory { 102 117 err = initialiseDirectory(directory) ··· 104 119 log.Fatalf("Failed to initialise directory: %v", err) 105 120 os.Exit(1) 106 121 } 107 - log.Printf("Directory successfully initialised") 122 + log.Printf( 123 + "Directory successfully initialised. " + 124 + "Be sure to update %s before uploading!", 125 + scanner.METADATA_FILENAME, 126 + ) 127 + os.Exit(0) 108 128 } 109 129 110 130 // read directory metadata ··· 131 151 os.Exit(0) 132 152 } 133 153 154 + // default footage directory 155 + if len(metadata.FootageDir) == 0 { 156 + metadata.FootageDir = directory 157 + } 158 + 134 159 // scan for VOD segments 135 160 vodFiles, err := scanner.ScanSegments(metadata.FootageDir, segmentExtension) 136 161 if err != nil { ··· 175 200 os.Exit(1) 176 201 } 177 202 fmt.Printf( 178 - "\nTITLE: %s\nDESCRIPTION: %s", 203 + "\nTITLE: %s\nDESCRIPTION: %s\n", 179 204 title, description, 180 205 ) 181 206 } 182 207 183 208 // concatenate VOD segments into full VOD 184 - err = vid.ConcatVideo(video, vodFiles, verbose) 209 + video.SizeBytes, err = vid.ConcatVideo(video, vodFiles, verbose) 185 210 if err != nil { 186 211 log.Fatalf("Failed to concatenate VOD segments: %v", err) 187 212 os.Exit(1) ··· 189 214 190 215 // youtube oauth flow 191 216 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) 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 + } 196 231 } 197 232 198 233 // okay actually upload now! ··· 219 254 220 255 // delete full VOD after upload, if requested 221 256 if deleteFullVod { 222 - err = os.Remove(path.Join(directory, scanner.METADATA_FILENAME)) 257 + err = os.Remove(video.Filename) 223 258 if err != nil { 224 259 log.Fatalf("Failed to delete full VOD: %v", err) 225 260 } ··· 240 275 241 276 _, err = os.Stat(path.Join(directory, scanner.METADATA_FILENAME)) 242 277 if err == nil { 243 - return fmt.Errorf("directory already initialised: %v", err) 278 + return fmt.Errorf("directory already initialised: %s", directory) 244 279 } 245 280 246 281 err = scanner.WriteMetadata(directory, scanner.DefaultMetadata()) ··· 248 283 return err 249 284 } 250 285 251 - func completeOAuth(ctx *context.Context, cfg *config.Config) (*oauth2.Token, error) { 286 + func generateOAuthToken(ctx *context.Context, cfg *config.Config) (*oauth2.Token, error) { 252 287 oauth2Config := &oauth2.Config{ 253 288 ClientID: cfg.Google.ClientID, 254 289 ClientSecret: cfg.Google.ClientSecret, 255 290 Endpoint: google.Endpoint, 256 291 Scopes: []string{ youtube.YoutubeScope }, 257 - RedirectURL: "http://localhost:8090", 292 + RedirectURL: cfg.RedirectUri, 258 293 } 259 294 verifier := oauth2.GenerateVerifier() 260 295 261 - url := oauth2Config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) 262 - fmt.Printf("Sign in to YouTube: %s\n", url) 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 + } 263 306 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 - } 307 + code := r.URL.Query().Get("code") 270 308 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) 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) 275 344 } 345 + wg.Wait() 276 346 277 347 // TODO: save this token; look into token refresh 278 - log.Printf("Token expires on %s\n", token.Expiry.Format("02 Jan 2006")) 348 + log.Printf("Token expires at: %s\n", token.Expiry.Format(time.DateTime)) 279 349 280 350 return token, nil 281 351 }
+12 -11
scanner/scanner.go
··· 11 11 12 12 type ( 13 13 Category struct { 14 - Name string 15 - Type string 16 - Url string 14 + Name string `toml:"name"` 15 + Type string `toml:"type" comment:"Valid types: gaming, other (default: other)"` 16 + Url string `toml:"url"` 17 17 } 18 18 19 19 Metadata struct { 20 - Title string 21 - Date string 22 - Part int 23 - FootageDir string 24 - Category *Category 25 - Uploaded bool 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."` 26 27 } 27 28 ) 28 29 ··· 39 40 for _, item := range entries { 40 41 if item.IsDir() { continue } 41 42 if !strings.HasSuffix(item.Name(), "." + extension) { continue } 43 + if strings.HasSuffix(item.Name(), "-fullvod." + extension) { continue } 42 44 files = append(files, item.Name()) 43 45 } 44 46 ··· 67 69 func WriteMetadata(directory string, metadata *Metadata) (error) { 68 70 file, err := os.OpenFile( 69 71 path.Join(directory, METADATA_FILENAME), 70 - os.O_CREATE, 0644, 72 + os.O_CREATE | os.O_RDWR, 0644, 71 73 ) 72 74 if err != nil { return err } 73 75 74 76 err = toml.NewEncoder(file).Encode(metadata) 75 - 76 77 return err 77 78 } 78 79
+5 -5
template/description.txt
··· 1 1 streamed on {{.Date}} 2 2 💚 watch ari melody LIVE: https://twitch.tv/arispacegirl 3 - {{if .Title}}{{if eq .Title.Type "game"}} 4 - 🎮 play {{.Title.Name}}: 5 - {{.Title.Url}} 3 + {{if .Category}}{{if eq .Category.Type "gaming"}} 4 + 🎮 play {{.Category.Name}}: 5 + {{.Category.Url}} 6 6 {{else}} 7 - ✨ check out {{.Title.Name}}: 8 - {{.Title.Url}} 7 + ✨ check out {{.Category.Name}}: 8 + {{.Category.Url}} 9 9 {{end}}{{end}} 10 10 💫 ari's place: https://arimelody.space 11 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 20 } 21 21 ) 22 22 23 - func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) error { 23 + func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) (int64, error) { 24 24 fileListPath := path.Join( 25 25 path.Dir(video.Filename), 26 26 "files.txt", ··· 32 32 fileListString += fmt.Sprintf("file '%s'\n", file) 33 33 jsonProbe, err := ffmpeg.Probe(path.Join(path.Dir(video.Filename), file)) 34 34 if err != nil { 35 - return fmt.Errorf("failed to probe file `%s`: %v", file, err) 35 + return 0, fmt.Errorf("failed to probe file `%s`: %v", file, err) 36 36 } 37 37 probe := probeData{} 38 38 json.Unmarshal([]byte(jsonProbe), &probe) 39 39 duration, err := strconv.ParseFloat(probe.Format.Duration, 64) 40 40 if err != nil { 41 - return fmt.Errorf("failed to parse duration of file `%s`: %v", file, err) 41 + return 0, fmt.Errorf("failed to parse duration of file `%s`: %v", file, err) 42 42 } 43 43 totalDuration += duration 44 44 } ··· 48 48 0644, 49 49 ) 50 50 if err != nil { 51 - return fmt.Errorf("failed to write file list: %v", err) 51 + return 0, fmt.Errorf("failed to write file list: %v", err) 52 52 } 53 53 54 54 stream := ffmpeg.Input(fileListPath, ffmpeg.KwArgs{ ··· 61 61 err = stream.Run() 62 62 63 63 if err != nil { 64 - return fmt.Errorf("ffmpeg error: %v", err) 64 + return 0, fmt.Errorf("ffmpeg error: %v", err) 65 65 } 66 66 67 - return nil 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 68 71 }
+97 -84
youtube/youtube.go
··· 17 17 "google.golang.org/api/youtube/v3" 18 18 ) 19 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 20 const ( 35 - CATEGORY_GAMING = "20" 36 - CATEGORY_ENTERTAINMENT = "24" 21 + YT_CATEGORY_GAMING = "20" 22 + YT_CATEGORY_ENTERTAINMENT = "24" 37 23 ) 38 24 39 - type TitleType int 25 + type CategoryType int 40 26 const ( 41 - TITLE_GAME TitleType = iota 42 - TITLE_OTHER 27 + CATEGORY_GAME CategoryType = iota 28 + CATEGORY_ENTERTAINMENT 43 29 ) 44 30 45 31 type ( 46 - Title struct { 47 - Name string 48 - Type TitleType 49 - Url string 32 + Category struct { 33 + Name string 34 + Type CategoryType 35 + Url string 50 36 } 51 37 52 38 Video struct { 53 - Title *Title 54 - Part int 55 - Date time.Time 56 - Tags []string 57 - Filename string 39 + Title string 40 + Category *Category 41 + Part int 42 + Date time.Time 43 + Tags []string 44 + Filename string 45 + SizeBytes int64 58 46 } 59 47 ) 60 48 61 49 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 50 videoDate, err := time.Parse("2006-01-02", metadata.Date) 71 51 if err != nil { 72 52 return nil, fmt.Errorf("failed to parse date from metadata: %v", err) 73 53 } 74 54 75 - return &Video{ 76 - Title: &Title{ 55 + var category *Category = nil 56 + if metadata.Category != nil { 57 + category = &Category{ 77 58 Name: metadata.Category.Name, 78 - Type: titleType, 59 + Type: CATEGORY_ENTERTAINMENT, 79 60 Url: metadata.Category.Url, 80 - }, 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, 81 70 Part: metadata.Part, 82 71 Date: videoDate, 83 - Tags: DEFAULT_TAGS, 72 + Tags: DefaultTags, 84 73 Filename: path.Join( 85 74 metadata.FootageDir, 86 75 fmt.Sprintf( ··· 91 80 } 92 81 93 82 type ( 94 - MetaTitle struct { 83 + MetaCategory struct { 95 84 Name string 96 85 Type string 97 86 Url string 98 87 } 99 88 100 89 Metadata struct { 101 - Date string 102 - Title *MetaTitle 103 - Part int 90 + Title string 91 + Date string 92 + Category *MetaCategory 93 + Part int 104 94 } 105 95 ) 106 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 + 107 119 var titleTemplate *template.Template = template.Must( 108 120 template.ParseFiles("template/title.txt"), 109 121 ) 110 122 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" 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 + } 119 132 } 120 133 121 - out := &bytes.Buffer{} 122 - titleTemplate.Execute(out, Metadata{ 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, 123 138 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 - }, 139 + Category: category, 129 140 Part: video.Part, 130 141 }) 131 142 132 - return strings.TrimSpace(out.String()), nil 143 + return strings.TrimSpace(out.String()), err 133 144 } 134 145 135 146 var descriptionTemplate *template.Template = template.Must( 136 147 template.ParseFiles("template/description.txt"), 137 148 ) 138 149 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" 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 + } 147 159 } 148 160 149 - out := &bytes.Buffer{} 150 - descriptionTemplate.Execute(out, Metadata{ 161 + err := descriptionTemplate.Execute(out, Metadata{ 162 + Title: video.Title, 151 163 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 - }, 164 + Category: category, 157 165 Part: video.Part, 158 166 }) 159 167 160 - return out.String(), nil 168 + return strings.TrimSpace(out.String()), err 161 169 } 162 170 163 171 func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtube.Video, error) { ··· 182 190 183 191 videoService := youtube.NewVideosService(service) 184 192 185 - var categoryId string 186 - switch video.Title.Type { 187 - case TITLE_GAME: 188 - categoryId = CATEGORY_GAMING 189 - default: 190 - categoryId = CATEGORY_ENTERTAINMENT 193 + categoryId := YT_CATEGORY_ENTERTAINMENT 194 + if video.Category != nil { 195 + if cid, ok := videoYtCategory[video.Category.Type]; ok { 196 + categoryId = cid 197 + } 191 198 } 192 199 193 200 call := videoService.Insert([]string{ ··· 196 203 Snippet: &youtube.VideoSnippet{ 197 204 Title: title, 198 205 Description: description, 199 - Tags: append(DEFAULT_TAGS, video.Tags...), 206 + Tags: append(DefaultTags, video.Tags...), 200 207 CategoryId: categoryId, // gaming 201 208 }, 202 209 Status: &youtube.VideoStatus{ ··· 213 220 214 221 log.Println("Uploading video...") 215 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 + }) 216 229 ytVideo, err := call.Do() 217 230 if err != nil { 218 231 log.Fatalf("Failed to upload video: %v\n", err)