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

tidying up a *lot*; add QoL options

+255 -148
+56
config/config.go
···
··· 1 + package config 2 + 3 + import ( 4 + "fmt" 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>", 26 + ClientSecret: "<your client secret here>", 27 + }, 28 + } 29 + 30 + const CONFIG_FILENAME = "config.toml" 31 + 32 + func ReadConfig(filename string) (*Config, error) { 33 + cfgBytes, err := os.ReadFile(filename) 34 + if err != nil { 35 + if err == os.ErrNotExist { 36 + return nil, nil 37 + } 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 + }
+139 -98
main.go
··· 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log" ··· 9 "path" 10 "strings" 11 12 - toml "github.com/pelletier/go-toml/v2" 13 "golang.org/x/oauth2" 14 "golang.org/x/oauth2/google" 15 "google.golang.org/api/youtube/v3" 16 17 "arimelody.space/live-vod-uploader/scanner" 18 vid "arimelody.space/live-vod-uploader/video" 19 yt "arimelody.space/live-vod-uploader/youtube" 20 ) 21 22 - type ( 23 - Config struct { 24 - Google GoogleConfig `toml:"google"` 25 - } 26 - 27 - GoogleConfig struct { 28 - ApiKey string `toml:"api_key"` 29 - ClientID string `toml:"client_id"` 30 - ClientSecret string `toml:"client_secret"` 31 - } 32 - ) 33 34 - const CONFIG_FILENAME = "config.toml" 35 36 func showHelp() { 37 execSplits := strings.Split(os.Args[0], "/") 38 execName := execSplits[len(execSplits) - 1] 39 - fmt.Printf( 40 - "usage: %s [options] [directory]\n\n" + 41 - "options:\n" + 42 - "\t-h, --help: Show this help message.\n" + 43 - "\t-v, --verbose: Show verbose logging output.\n" + 44 - "\t--init: Initialise `directory` as a VOD directory.\n", 45 - execName) 46 - } 47 48 func main() { 49 if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" { ··· 51 os.Exit(0) 52 } 53 54 - var directory string 55 - var initDirectory bool = false 56 var verbose bool = false 57 58 for i, arg := range os.Args { 59 if i == 0 { continue } ··· 66 showHelp() 67 os.Exit(0) 68 69 case "--init": 70 initDirectory = true 71 72 - case "-v": 73 fallthrough 74 - case "--verbose": 75 - verbose = true 76 77 default: 78 fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg) ··· 84 } 85 } 86 87 - cfg := Config{} 88 - cfgBytes, err := os.ReadFile(CONFIG_FILENAME) 89 if err != nil { 90 - log.Fatalf("Failed to read config file: %v", err) 91 - 92 - tomlBytes, err := toml.Marshal(&cfg) 93 - if err != nil { 94 - log.Fatalf("Failed to marshal json: %v", err) 95 - os.Exit(1) 96 - } 97 - 98 - err = os.WriteFile(CONFIG_FILENAME, tomlBytes, 0o644) 99 - if err != nil { 100 - log.Fatalf("Failed to write config file: %v", err) 101 - os.Exit(1) 102 - } 103 - 104 - log.Printf("New config file created. Please edit this before running again!") 105 os.Exit(0) 106 - } 107 - err = toml.Unmarshal(cfgBytes, &cfg) 108 - if err != nil { 109 - log.Fatalf("Failed to parse config: %v", err) 110 - os.Exit(1) 111 } 112 113 if initDirectory { 114 - dirInfo, err := os.Stat(directory) 115 if err != nil { 116 - if err == os.ErrNotExist { 117 - log.Fatalf("No such directory: %s", directory) 118 - os.Exit(1) 119 - } 120 - log.Fatalf("Failed to open directory: %v", err) 121 os.Exit(1) 122 } 123 - if !dirInfo.IsDir() { 124 - log.Fatalf("Not a directory: %s", directory) 125 - os.Exit(1) 126 - } 127 - dirEntry, err := os.ReadDir(directory) 128 - if err != nil { 129 - log.Fatalf("Failed to open directory: %v", err) 130 - os.Exit(1) 131 - } 132 - for _, entry := range dirEntry { 133 - if !entry.IsDir() && entry.Name() == "metadata.toml" { 134 - log.Printf("Directory `%s` already initialised", directory) 135 - os.Exit(0) 136 - return 137 - } 138 - 139 - defaultMetadata := scanner.DefaultMetadata() 140 - metadataStr, _ := toml.Marshal(defaultMetadata) 141 - err = os.WriteFile(path.Join(directory, "metadata.toml"), metadataStr, 0o644) 142 - if err != nil { 143 - log.Fatalf("Failed to write to file: %v", err) 144 - os.Exit(1) 145 - } 146 - log.Printf("Directory successfully initialised") 147 - os.Exit(0) 148 - } 149 } 150 151 - metadata, err := scanner.FetchMetadata(directory) 152 if err != nil { 153 log.Fatalf("Failed to fetch VOD metadata: %v", err) 154 os.Exit(1) 155 } 156 if metadata == nil { 157 - log.Fatal("Directory contained no metadata. Use `--init` to initialise this directory.") 158 os.Exit(1) 159 } 160 - vodFiles, err := scanner.FetchVideos(metadata.FootageDir) 161 if err != nil { 162 log.Fatalf("Failed to fetch VOD filenames: %v", err) 163 os.Exit(1) 164 } 165 if len(vodFiles) == 0 { 166 - log.Fatal("Directory contained no VOD files (expecting .mkv)") 167 os.Exit(1) 168 } 169 - 170 if verbose { 171 enc := json.NewEncoder(os.Stdout) 172 enc.SetIndent("", "\t") ··· 176 enc.Encode(vodFiles) 177 } 178 179 video, err := yt.BuildVideo(metadata) 180 if err != nil { 181 log.Fatalf("Failed to build video template: %v", err) ··· 202 ) 203 } 204 205 - err = vid.ConcatVideo(video, vodFiles) 206 if err != nil { 207 - log.Fatalf("Failed to concatenate VOD files: %v", err) 208 os.Exit(1) 209 } 210 211 - // okay actual youtube stuff now 212 213 - // TODO: tidy up oauth flow with localhost webserver 214 - ctx := context.Background() 215 - config := &oauth2.Config{ 216 ClientID: cfg.Google.ClientID, 217 ClientSecret: cfg.Google.ClientSecret, 218 Endpoint: google.Endpoint, ··· 220 RedirectURL: "http://localhost:8090", 221 } 222 verifier := oauth2.GenerateVerifier() 223 - url := config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) 224 - log.Printf("Visit URL to initiate OAuth2: %s", url) 225 - 226 var code string 227 fmt.Print("Enter OAuth2 code: ") 228 if _, err := fmt.Scan(&code); err != nil { 229 - log.Fatalf("Failed to read oauth2 code: %v", err) 230 } 231 232 - token, err := config.Exchange(ctx, code, oauth2.VerifierOption(verifier)) 233 - log.Printf("Token expires on %s\n", token.Expiry.Format("02 Jan 2006")) 234 if err != nil { 235 log.Fatalf("Could not exchange OAuth2 code: %v", err) 236 os.Exit(1) 237 } 238 239 - yt.UploadVideo(ctx, token, video) 240 }
··· 2 3 import ( 4 "context" 5 + _ "embed" 6 "encoding/json" 7 "fmt" 8 "log" ··· 10 "path" 11 "strings" 12 13 "golang.org/x/oauth2" 14 "golang.org/x/oauth2/google" 15 "google.golang.org/api/youtube/v3" 16 17 + "arimelody.space/live-vod-uploader/config" 18 "arimelody.space/live-vod-uploader/scanner" 19 vid "arimelody.space/live-vod-uploader/video" 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], "/") 30 execName := execSplits[len(execSplits) - 1] 31 + fmt.Printf(helpText, execName) 32 + } 33 34 func main() { 35 if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" { ··· 37 os.Exit(0) 38 } 39 40 var verbose bool = false 41 + var initDirectory bool = false 42 + var deleteFullVod bool = false 43 + var forceUpload bool = false 44 + var directory string 45 46 for i, arg := range os.Args { 47 if i == 0 { continue } ··· 54 showHelp() 55 os.Exit(0) 56 57 + case "-v": 58 + fallthrough 59 + case "--verbose": 60 + verbose = true 61 + 62 case "--init": 63 initDirectory = true 64 65 + case "-d": 66 fallthrough 67 + case "-deleteAfter": 68 + deleteFullVod = true 69 + 70 + case "-f": 71 + fallthrough 72 + case "--force": 73 + forceUpload = true 74 75 default: 76 fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg) ··· 82 } 83 } 84 85 + // config 86 + cfg, err := config.ReadConfig(config.CONFIG_FILENAME) 87 if err != nil { 88 + log.Fatalf("Failed to read config: %v", err) 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!", 95 + config.CONFIG_FILENAME, 96 + ) 97 os.Exit(0) 98 } 99 100 + // initialising directory (--init) 101 if initDirectory { 102 + err = initialiseDirectory(directory) 103 if err != nil { 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 111 + metadata, err := scanner.ReadMetadata(directory) 112 if err != nil { 113 log.Fatalf("Failed to fetch VOD metadata: %v", err) 114 os.Exit(1) 115 } 116 if metadata == nil { 117 + log.Fatal( 118 + "Directory contained no metadata. " + 119 + "Use `--init` to initialise this directory.", 120 + ) 121 os.Exit(1) 122 } 123 + 124 + // skip uploading if already done 125 + if metadata.Uploaded == !forceUpload { 126 + log.Printf( 127 + "VOD has already been uploaded. " + 128 + "Use --force to override, or update the %s.", 129 + scanner.METADATA_FILENAME, 130 + ) 131 + os.Exit(0) 132 + } 133 + 134 + // scan for VOD segments 135 + vodFiles, err := scanner.ScanSegments(metadata.FootageDir, segmentExtension) 136 if err != nil { 137 log.Fatalf("Failed to fetch VOD filenames: %v", err) 138 os.Exit(1) 139 } 140 if len(vodFiles) == 0 { 141 + log.Fatalf( 142 + "Directory contained no VOD files (expecting .%s)", 143 + segmentExtension, 144 + ) 145 os.Exit(1) 146 } 147 if verbose { 148 enc := json.NewEncoder(os.Stdout) 149 enc.SetIndent("", "\t") ··· 153 enc.Encode(vodFiles) 154 } 155 156 + // build video template for upload 157 video, err := yt.BuildVideo(metadata) 158 if err != nil { 159 log.Fatalf("Failed to build video template: %v", err) ··· 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) 188 } 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! 199 + ytVideo, err := yt.UploadVideo(ctx, token, video) 200 + if err != nil { 201 + log.Fatalf("Failed to upload video: %v", err) 202 + os.Exit(1) 203 + } 204 + if verbose { 205 + jsonString, err := json.MarshalIndent(ytVideo, "", " ") 206 + if err != nil { 207 + log.Fatalf("Failed to marshal video data json: %v", err) 208 + } 209 + fmt.Println(string(jsonString)) 210 + } 211 + log.Print("Video uploaded successfully!") 212 + 213 + // update metadata to reflect VOD is uploaded 214 + metadata.Uploaded = true 215 + err = scanner.WriteMetadata(directory, metadata) 216 + if err != nil { 217 + log.Fatalf("Failed to update metadata: %v", err) 218 + } 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 + } 226 + } 227 + } 228 + 229 + func initialiseDirectory(directory string) error { 230 + dirInfo, err := os.Stat(directory) 231 + if err != nil { 232 + if err == os.ErrNotExist { 233 + return fmt.Errorf("no such directory: %s", directory) 234 + } 235 + return fmt.Errorf("failed to open directory: %v", err) 236 + } 237 + if !dirInfo.IsDir() { 238 + return fmt.Errorf("not a directory: %s", directory) 239 + } 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()) 247 + 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, ··· 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 }
+16
res/help.txt
···
··· 1 + ari's VOD uploader 2 + 3 + USAGE: %s [options] [directory] 4 + 5 + This tool stitches together VOD segments and automatically uploads them to 6 + YouTube. `directory` is assumed to be a directory containing a `metadata.toml` 7 + (created with `--init`), and some `.mkv` files. 8 + 9 + OPTIONS: 10 + -h, --help: Show this help message. 11 + -v, --verbose: Show verbose logging output. 12 + --init: Initialise `directory` as a VOD directory. 13 + -d, --deleteAfter: Deletes the full VOD after upload. 14 + -f, --force: Force uploading the VOD, even if it already exists. 15 + 16 + made with <3 by ari melody, 2026
+30 -30
scanner/scanner.go
··· 2 3 import ( 4 "os" 5 - "path/filepath" 6 "strings" 7 "time" 8 ··· 22 Part int 23 FootageDir string 24 Category *Category 25 } 26 ) 27 28 - func FetchVideos(directory string) ([]string, error) { 29 entries, err := os.ReadDir(directory) 30 if err != nil { 31 return nil, err ··· 35 36 for _, item := range entries { 37 if item.IsDir() { continue } 38 - if !strings.HasSuffix(item.Name(), ".mkv") { continue } 39 files = append(files, item.Name()) 40 } 41 42 return files, nil 43 } 44 45 - func FetchMetadata(directory string) (*Metadata, error) { 46 - entries, err := os.ReadDir(directory) 47 if err != nil { 48 return nil, err 49 } 50 51 - for _, item := range entries { 52 - if item.IsDir() { continue } 53 - if item.Name() == "metadata.toml" { 54 - metadata, err := ParseMetadata(filepath.Join(directory, item.Name())) 55 - if err != nil { 56 - return nil, err 57 - } 58 - metadata.FootageDir = filepath.Join(directory, metadata.FootageDir) 59 - return metadata, nil 60 - } 61 - } 62 63 - return nil, nil 64 } 65 66 - func ParseMetadata(filename string) (*Metadata, error) { 67 - metadata := &Metadata{} 68 - file, err := os.OpenFile(filename, os.O_RDONLY, 0o644) 69 - if err != nil { 70 - return nil, err 71 - } 72 - err = toml.NewDecoder(file).Decode(metadata) 73 - if err != nil { 74 - return nil, err 75 - } 76 - return metadata, nil 77 } 78 79 - func DefaultMetadata() Metadata { 80 - return Metadata{ 81 Title: "Untitled Stream", 82 Date: time.Now().Format("2006-01-02"), 83 Part: 0,
··· 2 3 import ( 4 "os" 5 + "path" 6 "strings" 7 "time" 8 ··· 22 Part int 23 FootageDir string 24 Category *Category 25 + Uploaded bool 26 } 27 ) 28 29 + const METADATA_FILENAME = "metadata.toml" 30 + 31 + func ScanSegments(directory string, extension string) ([]string, error) { 32 entries, err := os.ReadDir(directory) 33 if err != nil { 34 return nil, err ··· 38 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 45 return files, nil 46 } 47 48 + func ReadMetadata(directory string) (*Metadata, error) { 49 + metadata := &Metadata{} 50 + file, err := os.OpenFile( 51 + path.Join(directory, METADATA_FILENAME), 52 + os.O_RDONLY, os.ModePerm, 53 + ) 54 if err != nil { 55 + if err == os.ErrNotExist { 56 + return nil, nil 57 + } 58 return nil, err 59 } 60 61 + err = toml.NewDecoder(file).Decode(metadata) 62 + if err != nil { return nil, err } 63 64 + return metadata, nil 65 } 66 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 79 + func DefaultMetadata() *Metadata { 80 + return &Metadata{ 81 Title: "Untitled Stream", 82 Date: time.Now().Format("2006-01-02"), 83 Part: 0,
+6 -4
video/video.go
··· 20 } 21 ) 22 23 - func ConcatVideo(video *youtube.Video, vodFiles []string) error { 24 fileListPath := path.Join( 25 path.Dir(video.Filename), 26 "files.txt", ··· 45 err := os.WriteFile( 46 fileListPath, 47 []byte(fileListString), 48 - 0o644, 49 ) 50 if err != nil { 51 return fmt.Errorf("failed to write file list: %v", err) 52 } 53 54 - err = ffmpeg.Input(fileListPath, ffmpeg.KwArgs{ 55 "f": "concat", 56 "safe": "0", 57 }).Output(video.Filename, ffmpeg.KwArgs{ 58 "c": "copy", 59 - }).OverWriteOutput().ErrorToStdOut().Run() 60 61 if err != nil { 62 return fmt.Errorf("ffmpeg error: %v", err)
··· 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", ··· 45 err := os.WriteFile( 46 fileListPath, 47 []byte(fileListString), 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{ 55 "f": "concat", 56 "safe": "0", 57 }).Output(video.Filename, ffmpeg.KwArgs{ 58 "c": "copy", 59 + }).OverWriteOutput() 60 + if verbose { stream = stream.ErrorToStdOut() } 61 + err = stream.Run() 62 63 if err != nil { 64 return fmt.Errorf("ffmpeg error: %v", err)
+8 -16
youtube/youtube.go
··· 3 import ( 4 "bytes" 5 "context" 6 - "encoding/json" 7 "fmt" 8 "log" 9 "os" ··· 161 return out.String(), nil 162 } 163 164 - func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) error { 165 title, err := BuildTitle(video) 166 if err != nil { 167 - return fmt.Errorf("failed to build title: %v", err) 168 } 169 description, err := BuildDescription(video) 170 if err != nil { 171 - return fmt.Errorf("failed to build description: %v", err) 172 } 173 174 service, err := youtube.NewService( ··· 178 ) 179 if err != nil { 180 log.Fatalf("Failed to create youtube service: %v\n", err) 181 - return err 182 } 183 184 videoService := youtube.NewVideosService(service) ··· 208 file, err := os.Open(video.Filename) 209 if err != nil { 210 log.Fatalf("Failed to open file: %v\n", err) 211 - return err 212 } 213 call.Media(file) 214 215 log.Println("Uploading video...") 216 217 - res, err := call.Do() 218 if err != nil { 219 log.Fatalf("Failed to upload video: %v\n", err) 220 - return err 221 } 222 223 - data, err := json.MarshalIndent(res, "", " ") 224 - if err != nil { 225 - log.Fatalf("Failed to marshal video data json: %v\n", err) 226 - return err 227 - } 228 - 229 - fmt.Println(string(data)) 230 - return nil 231 }
··· 3 import ( 4 "bytes" 5 "context" 6 "fmt" 7 "log" 8 "os" ··· 160 return out.String(), nil 161 } 162 163 + func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtube.Video, error) { 164 title, err := BuildTitle(video) 165 if err != nil { 166 + return nil, fmt.Errorf("failed to build title: %v", err) 167 } 168 description, err := BuildDescription(video) 169 if err != nil { 170 + return nil, fmt.Errorf("failed to build description: %v", err) 171 } 172 173 service, err := youtube.NewService( ··· 177 ) 178 if err != nil { 179 log.Fatalf("Failed to create youtube service: %v\n", err) 180 + return nil, err 181 } 182 183 videoService := youtube.NewVideosService(service) ··· 207 file, err := os.Open(video.Filename) 208 if err != nil { 209 log.Fatalf("Failed to open file: %v\n", err) 210 + return nil, err 211 } 212 call.Media(file) 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) 219 + return nil, err 220 } 221 222 + return ytVideo, err 223 }