···2233import (
44 "context"
55+ _ "embed"
56 "encoding/json"
67 "fmt"
78 "log"
···910 "path"
1011 "strings"
11121212- toml "github.com/pelletier/go-toml/v2"
1313 "golang.org/x/oauth2"
1414 "golang.org/x/oauth2/google"
1515 "google.golang.org/api/youtube/v3"
16161717+ "arimelody.space/live-vod-uploader/config"
1718 "arimelody.space/live-vod-uploader/scanner"
1819 vid "arimelody.space/live-vod-uploader/video"
1920 yt "arimelody.space/live-vod-uploader/youtube"
2021)
21222222-type (
2323- Config struct {
2424- Google GoogleConfig `toml:"google"`
2525- }
2626-2727- GoogleConfig struct {
2828- ApiKey string `toml:"api_key"`
2929- ClientID string `toml:"client_id"`
3030- ClientSecret string `toml:"client_secret"`
3131- }
3232-)
2323+const segmentExtension = "mkv"
33243434-const CONFIG_FILENAME = "config.toml"
2525+//go:embed res/help.txt
2626+var helpText string
35273628func showHelp() {
3729 execSplits := strings.Split(os.Args[0], "/")
3830 execName := execSplits[len(execSplits) - 1]
3939- fmt.Printf(
4040- "usage: %s [options] [directory]\n\n" +
4141- "options:\n" +
4242- "\t-h, --help: Show this help message.\n" +
4343- "\t-v, --verbose: Show verbose logging output.\n" +
4444- "\t--init: Initialise `directory` as a VOD directory.\n",
4545- execName)
4646- }
3131+ fmt.Printf(helpText, execName)
3232+}
47334834func main() {
4935 if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" {
···5137 os.Exit(0)
5238 }
53395454- var directory string
5555- var initDirectory bool = false
5640 var verbose bool = false
4141+ var initDirectory bool = false
4242+ var deleteFullVod bool = false
4343+ var forceUpload bool = false
4444+ var directory string
57455846 for i, arg := range os.Args {
5947 if i == 0 { continue }
···6654 showHelp()
6755 os.Exit(0)
68565757+ case "-v":
5858+ fallthrough
5959+ case "--verbose":
6060+ verbose = true
6161+6962 case "--init":
7063 initDirectory = true
71647272- case "-v":
6565+ case "-d":
7366 fallthrough
7474- case "--verbose":
7575- verbose = true
6767+ case "-deleteAfter":
6868+ deleteFullVod = true
6969+7070+ case "-f":
7171+ fallthrough
7272+ case "--force":
7373+ forceUpload = true
76747775 default:
7876 fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg)
···8482 }
8583 }
86848787- cfg := Config{}
8888- cfgBytes, err := os.ReadFile(CONFIG_FILENAME)
8585+ // config
8686+ cfg, err := config.ReadConfig(config.CONFIG_FILENAME)
8987 if err != nil {
9090- log.Fatalf("Failed to read config file: %v", err)
9191-9292- tomlBytes, err := toml.Marshal(&cfg)
9393- if err != nil {
9494- log.Fatalf("Failed to marshal json: %v", err)
9595- os.Exit(1)
9696- }
9797-9898- err = os.WriteFile(CONFIG_FILENAME, tomlBytes, 0o644)
9999- if err != nil {
100100- log.Fatalf("Failed to write config file: %v", err)
101101- os.Exit(1)
102102- }
103103-104104- log.Printf("New config file created. Please edit this before running again!")
8888+ log.Fatalf("Failed to read config: %v", err)
8989+ os.Exit(1)
9090+ }
9191+ if cfg == nil {
9292+ log.Printf(
9393+ "New config file created (%s). " +
9494+ "Please edit this file before running again!",
9595+ config.CONFIG_FILENAME,
9696+ )
10597 os.Exit(0)
106106- }
107107- err = toml.Unmarshal(cfgBytes, &cfg)
108108- if err != nil {
109109- log.Fatalf("Failed to parse config: %v", err)
110110- os.Exit(1)
11198 }
11299100100+ // initialising directory (--init)
113101 if initDirectory {
114114- dirInfo, err := os.Stat(directory)
102102+ err = initialiseDirectory(directory)
115103 if err != nil {
116116- if err == os.ErrNotExist {
117117- log.Fatalf("No such directory: %s", directory)
118118- os.Exit(1)
119119- }
120120- log.Fatalf("Failed to open directory: %v", err)
104104+ log.Fatalf("Failed to initialise directory: %v", err)
121105 os.Exit(1)
122106 }
123123- if !dirInfo.IsDir() {
124124- log.Fatalf("Not a directory: %s", directory)
125125- os.Exit(1)
126126- }
127127- dirEntry, err := os.ReadDir(directory)
128128- if err != nil {
129129- log.Fatalf("Failed to open directory: %v", err)
130130- os.Exit(1)
131131- }
132132- for _, entry := range dirEntry {
133133- if !entry.IsDir() && entry.Name() == "metadata.toml" {
134134- log.Printf("Directory `%s` already initialised", directory)
135135- os.Exit(0)
136136- return
137137- }
138138-139139- defaultMetadata := scanner.DefaultMetadata()
140140- metadataStr, _ := toml.Marshal(defaultMetadata)
141141- err = os.WriteFile(path.Join(directory, "metadata.toml"), metadataStr, 0o644)
142142- if err != nil {
143143- log.Fatalf("Failed to write to file: %v", err)
144144- os.Exit(1)
145145- }
146146- log.Printf("Directory successfully initialised")
147147- os.Exit(0)
148148- }
107107+ log.Printf("Directory successfully initialised")
149108 }
150109151151- metadata, err := scanner.FetchMetadata(directory)
110110+ // read directory metadata
111111+ metadata, err := scanner.ReadMetadata(directory)
152112 if err != nil {
153113 log.Fatalf("Failed to fetch VOD metadata: %v", err)
154114 os.Exit(1)
155115 }
156116 if metadata == nil {
157157- log.Fatal("Directory contained no metadata. Use `--init` to initialise this directory.")
117117+ log.Fatal(
118118+ "Directory contained no metadata. " +
119119+ "Use `--init` to initialise this directory.",
120120+ )
158121 os.Exit(1)
159122 }
160160- vodFiles, err := scanner.FetchVideos(metadata.FootageDir)
123123+124124+ // skip uploading if already done
125125+ if metadata.Uploaded == !forceUpload {
126126+ log.Printf(
127127+ "VOD has already been uploaded. " +
128128+ "Use --force to override, or update the %s.",
129129+ scanner.METADATA_FILENAME,
130130+ )
131131+ os.Exit(0)
132132+ }
133133+134134+ // scan for VOD segments
135135+ vodFiles, err := scanner.ScanSegments(metadata.FootageDir, segmentExtension)
161136 if err != nil {
162137 log.Fatalf("Failed to fetch VOD filenames: %v", err)
163138 os.Exit(1)
164139 }
165140 if len(vodFiles) == 0 {
166166- log.Fatal("Directory contained no VOD files (expecting .mkv)")
141141+ log.Fatalf(
142142+ "Directory contained no VOD files (expecting .%s)",
143143+ segmentExtension,
144144+ )
167145 os.Exit(1)
168146 }
169169-170147 if verbose {
171148 enc := json.NewEncoder(os.Stdout)
172149 enc.SetIndent("", "\t")
···176153 enc.Encode(vodFiles)
177154 }
178155156156+ // build video template for upload
179157 video, err := yt.BuildVideo(metadata)
180158 if err != nil {
181159 log.Fatalf("Failed to build video template: %v", err)
···202180 )
203181 }
204182205205- err = vid.ConcatVideo(video, vodFiles)
183183+ // concatenate VOD segments into full VOD
184184+ err = vid.ConcatVideo(video, vodFiles, verbose)
206185 if err != nil {
207207- log.Fatalf("Failed to concatenate VOD files: %v", err)
186186+ log.Fatalf("Failed to concatenate VOD segments: %v", err)
208187 os.Exit(1)
209188 }
210189211211- // okay actual youtube stuff now
190190+ // youtube oauth flow
191191+ ctx := context.Background()
192192+ token, err := completeOAuth(&ctx, cfg)
193193+ if err != nil {
194194+ log.Fatalf("OAuth flow failed: %v", err)
195195+ os.Exit(1)
196196+ }
212197213213- // TODO: tidy up oauth flow with localhost webserver
214214- ctx := context.Background()
215215- config := &oauth2.Config{
198198+ // okay actually upload now!
199199+ ytVideo, err := yt.UploadVideo(ctx, token, video)
200200+ if err != nil {
201201+ log.Fatalf("Failed to upload video: %v", err)
202202+ os.Exit(1)
203203+ }
204204+ if verbose {
205205+ jsonString, err := json.MarshalIndent(ytVideo, "", " ")
206206+ if err != nil {
207207+ log.Fatalf("Failed to marshal video data json: %v", err)
208208+ }
209209+ fmt.Println(string(jsonString))
210210+ }
211211+ log.Print("Video uploaded successfully!")
212212+213213+ // update metadata to reflect VOD is uploaded
214214+ metadata.Uploaded = true
215215+ err = scanner.WriteMetadata(directory, metadata)
216216+ if err != nil {
217217+ log.Fatalf("Failed to update metadata: %v", err)
218218+ }
219219+220220+ // delete full VOD after upload, if requested
221221+ if deleteFullVod {
222222+ err = os.Remove(path.Join(directory, scanner.METADATA_FILENAME))
223223+ if err != nil {
224224+ log.Fatalf("Failed to delete full VOD: %v", err)
225225+ }
226226+ }
227227+}
228228+229229+func initialiseDirectory(directory string) error {
230230+ dirInfo, err := os.Stat(directory)
231231+ if err != nil {
232232+ if err == os.ErrNotExist {
233233+ return fmt.Errorf("no such directory: %s", directory)
234234+ }
235235+ return fmt.Errorf("failed to open directory: %v", err)
236236+ }
237237+ if !dirInfo.IsDir() {
238238+ return fmt.Errorf("not a directory: %s", directory)
239239+ }
240240+241241+ _, err = os.Stat(path.Join(directory, scanner.METADATA_FILENAME))
242242+ if err == nil {
243243+ return fmt.Errorf("directory already initialised: %v", err)
244244+ }
245245+246246+ err = scanner.WriteMetadata(directory, scanner.DefaultMetadata())
247247+248248+ return err
249249+}
250250+251251+func completeOAuth(ctx *context.Context, cfg *config.Config) (*oauth2.Token, error) {
252252+ oauth2Config := &oauth2.Config{
216253 ClientID: cfg.Google.ClientID,
217254 ClientSecret: cfg.Google.ClientSecret,
218255 Endpoint: google.Endpoint,
···220257 RedirectURL: "http://localhost:8090",
221258 }
222259 verifier := oauth2.GenerateVerifier()
223223- url := config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
224224- log.Printf("Visit URL to initiate OAuth2: %s", url)
225225-260260+261261+ url := oauth2Config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier))
262262+ fmt.Printf("Sign in to YouTube: %s\n", url)
263263+264264+ // TODO: tidy up oauth flow with localhost webserver
226265 var code string
227266 fmt.Print("Enter OAuth2 code: ")
228267 if _, err := fmt.Scan(&code); err != nil {
229229- log.Fatalf("Failed to read oauth2 code: %v", err)
268268+ return nil, fmt.Errorf("failed to read code: %v", err)
230269 }
231270232232- token, err := config.Exchange(ctx, code, oauth2.VerifierOption(verifier))
233233- log.Printf("Token expires on %s\n", token.Expiry.Format("02 Jan 2006"))
271271+ token, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier))
234272 if err != nil {
235273 log.Fatalf("Could not exchange OAuth2 code: %v", err)
236274 os.Exit(1)
237275 }
238276239239- yt.UploadVideo(ctx, token, video)
277277+ // TODO: save this token; look into token refresh
278278+ log.Printf("Token expires on %s\n", token.Expiry.Format("02 Jan 2006"))
279279+280280+ return token, nil
240281}
+16
res/help.txt
···11+ari's VOD uploader
22+33+USAGE: %s [options] [directory]
44+55+This tool stitches together VOD segments and automatically uploads them to
66+YouTube. `directory` is assumed to be a directory containing a `metadata.toml`
77+(created with `--init`), and some `.mkv` files.
88+99+OPTIONS:
1010+ -h, --help: Show this help message.
1111+ -v, --verbose: Show verbose logging output.
1212+ --init: Initialise `directory` as a VOD directory.
1313+ -d, --deleteAfter: Deletes the full VOD after upload.
1414+ -f, --force: Force uploading the VOD, even if it already exists.
1515+1616+made with <3 by ari melody, 2026