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 2 3 3 import ( 4 4 "context" 5 + _ "embed" 5 6 "encoding/json" 6 7 "fmt" 7 8 "log" ··· 9 10 "path" 10 11 "strings" 11 12 12 - toml "github.com/pelletier/go-toml/v2" 13 13 "golang.org/x/oauth2" 14 14 "golang.org/x/oauth2/google" 15 15 "google.golang.org/api/youtube/v3" 16 16 17 + "arimelody.space/live-vod-uploader/config" 17 18 "arimelody.space/live-vod-uploader/scanner" 18 19 vid "arimelody.space/live-vod-uploader/video" 19 20 yt "arimelody.space/live-vod-uploader/youtube" 20 21 ) 21 22 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 - ) 23 + const segmentExtension = "mkv" 33 24 34 - const CONFIG_FILENAME = "config.toml" 25 + //go:embed res/help.txt 26 + var helpText string 35 27 36 28 func showHelp() { 37 29 execSplits := strings.Split(os.Args[0], "/") 38 30 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 - } 31 + fmt.Printf(helpText, execName) 32 + } 47 33 48 34 func main() { 49 35 if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" { ··· 51 37 os.Exit(0) 52 38 } 53 39 54 - var directory string 55 - var initDirectory bool = false 56 40 var verbose bool = false 41 + var initDirectory bool = false 42 + var deleteFullVod bool = false 43 + var forceUpload bool = false 44 + var directory string 57 45 58 46 for i, arg := range os.Args { 59 47 if i == 0 { continue } ··· 66 54 showHelp() 67 55 os.Exit(0) 68 56 57 + case "-v": 58 + fallthrough 59 + case "--verbose": 60 + verbose = true 61 + 69 62 case "--init": 70 63 initDirectory = true 71 64 72 - case "-v": 65 + case "-d": 73 66 fallthrough 74 - case "--verbose": 75 - verbose = true 67 + case "-deleteAfter": 68 + deleteFullVod = true 69 + 70 + case "-f": 71 + fallthrough 72 + case "--force": 73 + forceUpload = true 76 74 77 75 default: 78 76 fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg) ··· 84 82 } 85 83 } 86 84 87 - cfg := Config{} 88 - cfgBytes, err := os.ReadFile(CONFIG_FILENAME) 85 + // config 86 + cfg, err := config.ReadConfig(config.CONFIG_FILENAME) 89 87 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!") 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 + ) 105 97 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 98 } 112 99 100 + // initialising directory (--init) 113 101 if initDirectory { 114 - dirInfo, err := os.Stat(directory) 102 + err = initialiseDirectory(directory) 115 103 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) 104 + log.Fatalf("Failed to initialise directory: %v", err) 121 105 os.Exit(1) 122 106 } 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 - } 107 + log.Printf("Directory successfully initialised") 149 108 } 150 109 151 - metadata, err := scanner.FetchMetadata(directory) 110 + // read directory metadata 111 + metadata, err := scanner.ReadMetadata(directory) 152 112 if err != nil { 153 113 log.Fatalf("Failed to fetch VOD metadata: %v", err) 154 114 os.Exit(1) 155 115 } 156 116 if metadata == nil { 157 - log.Fatal("Directory contained no metadata. Use `--init` to initialise this directory.") 117 + log.Fatal( 118 + "Directory contained no metadata. " + 119 + "Use `--init` to initialise this directory.", 120 + ) 158 121 os.Exit(1) 159 122 } 160 - vodFiles, err := scanner.FetchVideos(metadata.FootageDir) 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) 161 136 if err != nil { 162 137 log.Fatalf("Failed to fetch VOD filenames: %v", err) 163 138 os.Exit(1) 164 139 } 165 140 if len(vodFiles) == 0 { 166 - log.Fatal("Directory contained no VOD files (expecting .mkv)") 141 + log.Fatalf( 142 + "Directory contained no VOD files (expecting .%s)", 143 + segmentExtension, 144 + ) 167 145 os.Exit(1) 168 146 } 169 - 170 147 if verbose { 171 148 enc := json.NewEncoder(os.Stdout) 172 149 enc.SetIndent("", "\t") ··· 176 153 enc.Encode(vodFiles) 177 154 } 178 155 156 + // build video template for upload 179 157 video, err := yt.BuildVideo(metadata) 180 158 if err != nil { 181 159 log.Fatalf("Failed to build video template: %v", err) ··· 202 180 ) 203 181 } 204 182 205 - err = vid.ConcatVideo(video, vodFiles) 183 + // concatenate VOD segments into full VOD 184 + err = vid.ConcatVideo(video, vodFiles, verbose) 206 185 if err != nil { 207 - log.Fatalf("Failed to concatenate VOD files: %v", err) 186 + log.Fatalf("Failed to concatenate VOD segments: %v", err) 208 187 os.Exit(1) 209 188 } 210 189 211 - // okay actual youtube stuff now 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 + } 212 197 213 - // TODO: tidy up oauth flow with localhost webserver 214 - ctx := context.Background() 215 - config := &oauth2.Config{ 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{ 216 253 ClientID: cfg.Google.ClientID, 217 254 ClientSecret: cfg.Google.ClientSecret, 218 255 Endpoint: google.Endpoint, ··· 220 257 RedirectURL: "http://localhost:8090", 221 258 } 222 259 verifier := oauth2.GenerateVerifier() 223 - url := config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) 224 - log.Printf("Visit URL to initiate OAuth2: %s", url) 225 - 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 226 265 var code string 227 266 fmt.Print("Enter OAuth2 code: ") 228 267 if _, err := fmt.Scan(&code); err != nil { 229 - log.Fatalf("Failed to read oauth2 code: %v", err) 268 + return nil, fmt.Errorf("failed to read code: %v", err) 230 269 } 231 270 232 - token, err := config.Exchange(ctx, code, oauth2.VerifierOption(verifier)) 233 - log.Printf("Token expires on %s\n", token.Expiry.Format("02 Jan 2006")) 271 + token, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier)) 234 272 if err != nil { 235 273 log.Fatalf("Could not exchange OAuth2 code: %v", err) 236 274 os.Exit(1) 237 275 } 238 276 239 - yt.UploadVideo(ctx, token, video) 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 240 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 2 3 3 import ( 4 4 "os" 5 - "path/filepath" 5 + "path" 6 6 "strings" 7 7 "time" 8 8 ··· 22 22 Part int 23 23 FootageDir string 24 24 Category *Category 25 + Uploaded bool 25 26 } 26 27 ) 27 28 28 - func FetchVideos(directory string) ([]string, error) { 29 + const METADATA_FILENAME = "metadata.toml" 30 + 31 + func ScanSegments(directory string, extension string) ([]string, error) { 29 32 entries, err := os.ReadDir(directory) 30 33 if err != nil { 31 34 return nil, err ··· 35 38 36 39 for _, item := range entries { 37 40 if item.IsDir() { continue } 38 - if !strings.HasSuffix(item.Name(), ".mkv") { continue } 41 + if !strings.HasSuffix(item.Name(), "." + extension) { continue } 39 42 files = append(files, item.Name()) 40 43 } 41 44 42 45 return files, nil 43 46 } 44 47 45 - func FetchMetadata(directory string) (*Metadata, error) { 46 - entries, err := os.ReadDir(directory) 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 + ) 47 54 if err != nil { 55 + if err == os.ErrNotExist { 56 + return nil, nil 57 + } 48 58 return nil, err 49 59 } 50 60 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 - } 61 + err = toml.NewDecoder(file).Decode(metadata) 62 + if err != nil { return nil, err } 62 63 63 - return nil, nil 64 + return metadata, nil 64 65 } 65 66 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 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 77 } 78 78 79 - func DefaultMetadata() Metadata { 80 - return Metadata{ 79 + func DefaultMetadata() *Metadata { 80 + return &Metadata{ 81 81 Title: "Untitled Stream", 82 82 Date: time.Now().Format("2006-01-02"), 83 83 Part: 0,
+6 -4
video/video.go
··· 20 20 } 21 21 ) 22 22 23 - func ConcatVideo(video *youtube.Video, vodFiles []string) error { 23 + func ConcatVideo(video *youtube.Video, vodFiles []string, verbose bool) error { 24 24 fileListPath := path.Join( 25 25 path.Dir(video.Filename), 26 26 "files.txt", ··· 45 45 err := os.WriteFile( 46 46 fileListPath, 47 47 []byte(fileListString), 48 - 0o644, 48 + 0644, 49 49 ) 50 50 if err != nil { 51 51 return fmt.Errorf("failed to write file list: %v", err) 52 52 } 53 53 54 - err = ffmpeg.Input(fileListPath, ffmpeg.KwArgs{ 54 + stream := ffmpeg.Input(fileListPath, ffmpeg.KwArgs{ 55 55 "f": "concat", 56 56 "safe": "0", 57 57 }).Output(video.Filename, ffmpeg.KwArgs{ 58 58 "c": "copy", 59 - }).OverWriteOutput().ErrorToStdOut().Run() 59 + }).OverWriteOutput() 60 + if verbose { stream = stream.ErrorToStdOut() } 61 + err = stream.Run() 60 62 61 63 if err != nil { 62 64 return fmt.Errorf("ffmpeg error: %v", err)
+8 -16
youtube/youtube.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 - "encoding/json" 7 6 "fmt" 8 7 "log" 9 8 "os" ··· 161 160 return out.String(), nil 162 161 } 163 162 164 - func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) error { 163 + func UploadVideo(ctx context.Context, token *oauth2.Token, video *Video) (*youtube.Video, error) { 165 164 title, err := BuildTitle(video) 166 165 if err != nil { 167 - return fmt.Errorf("failed to build title: %v", err) 166 + return nil, fmt.Errorf("failed to build title: %v", err) 168 167 } 169 168 description, err := BuildDescription(video) 170 169 if err != nil { 171 - return fmt.Errorf("failed to build description: %v", err) 170 + return nil, fmt.Errorf("failed to build description: %v", err) 172 171 } 173 172 174 173 service, err := youtube.NewService( ··· 178 177 ) 179 178 if err != nil { 180 179 log.Fatalf("Failed to create youtube service: %v\n", err) 181 - return err 180 + return nil, err 182 181 } 183 182 184 183 videoService := youtube.NewVideosService(service) ··· 208 207 file, err := os.Open(video.Filename) 209 208 if err != nil { 210 209 log.Fatalf("Failed to open file: %v\n", err) 211 - return err 210 + return nil, err 212 211 } 213 212 call.Media(file) 214 213 215 214 log.Println("Uploading video...") 216 215 217 - res, err := call.Do() 216 + ytVideo, err := call.Do() 218 217 if err != nil { 219 218 log.Fatalf("Failed to upload video: %v\n", err) 220 - return err 219 + return nil, err 221 220 } 222 221 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 222 + return ytVideo, err 231 223 }