···23import (
4 "context"
05 "encoding/json"
6 "fmt"
7 "log"
···9 "path"
10 "strings"
1112- 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"
16017 "arimelody.space/live-vod-uploader/scanner"
18 vid "arimelody.space/live-vod-uploader/video"
19 yt "arimelody.space/live-vod-uploader/youtube"
20)
2122-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-)
3334-const CONFIG_FILENAME = "config.toml"
03536func 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- }
4748func main() {
49 if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" {
···51 os.Exit(0)
52 }
5354- var directory string
55- var initDirectory bool = false
56 var verbose bool = false
00005758 for i, arg := range os.Args {
59 if i == 0 { continue }
···66 showHelp()
67 os.Exit(0)
680000069 case "--init":
70 initDirectory = true
7172- case "-v":
73 fallthrough
74- case "--verbose":
75- verbose = true
000007677 default:
78 fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg)
···84 }
85 }
8687- 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 }
1120113 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 }
150151- metadata, err := scanner.FetchMetadata(directory)
0152 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.")
000158 os.Exit(1)
159 }
160- vodFiles, err := scanner.FetchVideos(metadata.FootageDir)
000000000000161 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)")
000167 os.Exit(1)
168 }
169-170 if verbose {
171 enc := json.NewEncoder(os.Stdout)
172 enc.SetIndent("", "\t")
···176 enc.Encode(vodFiles)
177 }
1780179 video, err := yt.BuildVideo(metadata)
180 if err != nil {
181 log.Fatalf("Failed to build video template: %v", err)
···202 )
203 }
204205- err = vid.ConcatVideo(video, vodFiles)
0206 if err != nil {
207- log.Fatalf("Failed to concatenate VOD files: %v", err)
208 os.Exit(1)
209 }
210211- // okay actual youtube stuff now
000000212213- // TODO: tidy up oauth flow with localhost webserver
214- ctx := context.Background()
215- config := &oauth2.Config{
0000000000000000000000000000000000000000000000000000216 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-00226 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 }
231232- 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 }
238239- yt.UploadVideo(ctx, token, video)
000240}
···23import (
4 "context"
5+ _ "embed"
6 "encoding/json"
7 "fmt"
8 "log"
···10 "path"
11 "strings"
12013 "golang.org/x/oauth2"
14 "golang.org/x/oauth2/google"
15 "google.golang.org/api/youtube/v3"
1617+ "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)
2223+const segmentExtension = "mkv"
00000000002425+//go:embed res/help.txt
26+var helpText string
2728func showHelp() {
29 execSplits := strings.Split(os.Args[0], "/")
30 execName := execSplits[len(execSplits) - 1]
31+ fmt.Printf(helpText, execName)
32+}
0000003334func main() {
35 if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-h" {
···37 os.Exit(0)
38 }
390040 var verbose bool = false
41+ var initDirectory bool = false
42+ var deleteFullVod bool = false
43+ var forceUpload bool = false
44+ var directory string
4546 for i, arg := range os.Args {
47 if i == 0 { continue }
···54 showHelp()
55 os.Exit(0)
5657+ case "-v":
58+ fallthrough
59+ case "--verbose":
60+ verbose = true
61+62 case "--init":
63 initDirectory = true
6465+ case "-d":
66 fallthrough
67+ case "-deleteAfter":
68+ deleteFullVod = true
69+70+ case "-f":
71+ fallthrough
72+ case "--force":
73+ forceUpload = true
7475 default:
76 fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg)
···82 }
83 }
8485+ // 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+ )
00000097 os.Exit(0)
0000098 }
99100+ // initialising directory (--init)
101 if initDirectory {
102+ err = initialiseDirectory(directory)
103 if err != nil {
104+ log.Fatalf("Failed to initialise directory: %v", err)
0000105 os.Exit(1)
106 }
107+ log.Printf("Directory successfully initialised")
0000000000000000000000000108 }
109110+ // 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 }
0147 if verbose {
148 enc := json.NewEncoder(os.Stdout)
149 enc.SetIndent("", "\t")
···153 enc.Encode(vodFiles)
154 }
155156+ // 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 }
182183+ // 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 }
189190+ // 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+ }
197198+ // 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 }
270271+ token, err := oauth2Config.Exchange(*ctx, code, oauth2.VerifierOption(verifier))
0272 if err != nil {
273 log.Fatalf("Could not exchange OAuth2 code: %v", err)
274 os.Exit(1)
275 }
276277+ // 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
···0000000000000000
···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