package main import ( "context" _ "embed" "encoding/json" "fmt" "log" "math" "os" "path" "strings" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "google.golang.org/api/option" "google.golang.org/api/youtube/v3" "arimelody.space/vodular/config" "arimelody.space/vodular/oauth" "arimelody.space/vodular/scanner" vid "arimelody.space/vodular/video" yt "arimelody.space/vodular/youtube" ) //go:embed res/help.txt var helpText string const SEGMENT_EXTENSION = "mkv" const MAX_TITLE_LEN = 100 const MAX_DESCRIPTION_LEN = 5000 func main() { ctx := context.Background() // config userConfigDir, err := os.UserConfigDir() if err != nil { log.Fatalf("Could not determine user configuration directory: %v", err) } config.CONFIG_FILENAME = path.Join(userConfigDir, "vodular", "config.toml") cfg, err := config.ReadConfig(config.CONFIG_FILENAME) if err != nil { log.Fatalf("Failed to read config: %v", err) } if cfg == nil { err = os.MkdirAll(path.Dir(config.CONFIG_FILENAME), 0750) if err != nil { log.Fatalf("Failed to create config directory: %v", err) } err = config.GenerateConfig(config.CONFIG_FILENAME) if err != nil { log.Fatalf("Failed to generate config: %v", err) } log.Printf( "New config file created (%s). " + "Please edit this file before running again!", config.CONFIG_FILENAME, ) os.Exit(0) } var verbose bool = false var initDirectory bool = false var logout bool = false var keepConcatVOD bool = false var forceUpload bool = false var dryRun bool = false var directory string = "" for i, arg := range os.Args { if i == 0 { continue } if strings.HasPrefix(arg, "-") { switch arg { case "-h": fallthrough case "--help": log.Fatal(helpText) case "-v": fallthrough case "--verbose": verbose = true case "--dry-run": dryRun = true case "-f": fallthrough case "--force": forceUpload = true case "--init": initDirectory = true case "--keep-vod": keepConcatVOD = true case "--logout": logout = true default: fmt.Fprintf(os.Stderr, "Unknown option `%s`\n", arg) os.Exit(1) } } else { directory = arg } } if !strings.HasPrefix(directory, "/") { if wd, err := os.Getwd(); err != nil { directory = path.Join(directory, wd) } } // logout (--logout) if logout { cfg.Token = nil err = config.WriteConfig(cfg, config.CONFIG_FILENAME) if err != nil { log.Fatalf("Failed to write config: %v", err) } log.Fatalf("Logged out successfully.") } // initialising directory (--init) if initDirectory { err = scanner.InitialiseDirectory(directory) if err != nil { log.Fatalf("Failed to initialise directory: %v", err) } log.Fatalf( "Directory successfully initialised. " + "Be sure to update %s before uploading!", scanner.METADATA_FILENAME, ) } // good to have early on templates, err := yt.FetchTemplates(path.Join(userConfigDir, "vodular", "templates")) if err != nil { log.Fatalf("Failed to fetch templates: %v", err) } // read directory metadata metadata, err := scanner.ReadMetadata(directory) if err != nil { if os.IsNotExist(err) { log.Fatalf("Directory does not exist: %s", directory) } log.Fatalf("Failed to fetch VOD metadata: %v", err) } if metadata == nil { log.Fatal( "Directory contained no metadata. " + "Use `--init` to initialise this directory.", ) } // default footage directory footageDir := directory if len(metadata.FootageDir) >= 0 && !strings.HasPrefix(metadata.FootageDir, "/") { footageDir = path.Join(directory, metadata.FootageDir) } // scan for VOD segments vodFiles, err := scanner.ScanSegments(footageDir, SEGMENT_EXTENSION) if err != nil { log.Fatalf("Failed to fetch VOD filenames: %v", err) } if len(vodFiles) == 0 { log.Fatalf( "Directory contained no VOD files (expecting .%s)", SEGMENT_EXTENSION, ) } if verbose { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", "\t") log.Print("[ Metadata ]") log.Printf("Title: %s", metadata.Title) log.Printf("Part: %d", metadata.Part) log.Printf("Date: %s", metadata.Date.String()) log.Printf( "Category: `%s` - %s (%s)", metadata.Category.Type, metadata.Category.Name, metadata.Category.Url, ) log.Printf("Tags: %s", strings.Join(metadata.Tags, ", ")) log.Printf("VOD files: [ %s ]", strings.Join(vodFiles, ", ")) } // scan for thumbnail var thumbnail *yt.ThumbnailMetadata thumbnailPath, thumbnailSizeBytes, err := scanner.ScanThumbnail(directory) if err != nil { log.Fatalf("Failed to fetch thumbnail: %v", err) } else { thumbnail = &yt.ThumbnailMetadata{ Filepath: thumbnailPath, SizeBytes: thumbnailSizeBytes, } } // build video template for upload videoMeta, err := yt.BuildVideo(metadata) if err != nil { log.Fatalf("Failed to build video template: %v", err) } title, err := yt.BuildTemplate(videoMeta, templates.Title) if err != nil { log.Fatalf("Failed to build video title: %v", err) } description, err := yt.BuildTemplate(videoMeta, templates.Description) if err != nil { log.Fatalf("Failed to build video description: %v", err) } if len(title) > 100 { log.Fatalf( "Video title length exceeds %d characters (%d). YouTube may reject this!", MAX_TITLE_LEN, len(videoMeta.Title), ) } if len(description) > 5000 { log.Fatalf( "Video description length exceeds %d characters (%d). YouTube may reject this!", MAX_DESCRIPTION_LEN, len(description), ) } if verbose { log.Print() log.Printf("[ TITLE ]\n\n%s\n\n", title) log.Printf("[ DESCRIPTION ]\n\n%s\n\n", description) } // youtube oauth flow oauth2Config := &oauth2.Config{ ClientID: cfg.Google.ClientID, ClientSecret: cfg.Google.ClientSecret, Endpoint: google.Endpoint, Scopes: []string{ youtube.YoutubeScope }, RedirectURL: cfg.RedirectUri, } var token *oauth2.Token if cfg.Token != nil { token = cfg.Token } else { token, err = oauth.GenerateToken(&ctx, oauth2Config, cfg) if err != nil { log.Fatalf("OAuth flow failed: %v", err) } cfg.Token = token } tokenSource := oauth2Config.TokenSource(ctx, token) if err != nil { log.Fatalf("Failed to create OAuth2 token source: %v", err) } if err = config.WriteConfig(cfg, config.CONFIG_FILENAME); err != nil { log.Fatalf("Failed to save OAuth token: %v", err) } service, err := youtube.NewService( ctx, option.WithScopes(youtube.YoutubeUploadScope), option.WithTokenSource(tokenSource), ) if err != nil { log.Fatalf("Failed to create youtube service: %v\n", err) } // skip uploading if already done if (metadata.Uploaded || len(metadata.UploadURL) > 0) && !forceUpload { if len(metadata.UploadURL) > 0 { log.Printf("VOD has already been uploaded: %s", metadata.UploadURL) } else { log.Println("VOD has already been uploaded.") } if !dryRun { log.Printf( "To upload anyways, use --force or update %s.", scanner.METADATA_FILENAME, ) } if len(metadata.UploadURL) > 0 { if !dryRun { videoId := strings.TrimPrefix(metadata.UploadURL, yt.VIDEO_URL_BASE) if videoId == metadata.UploadURL { log.Fatal("can't parse video ID from upload URL") } log.Println("Updating metadata...") if err = yt.UpdateMetadata(ctx, tokenSource, service, videoMeta, videoId, templates); err != nil { log.Printf("update metadata: %v", err) } log.Println("Uploading thumbnail...") if err = yt.UploadThumbnail(ctx, tokenSource, service, videoId, thumbnail); err != nil { log.Printf("upload thumbnail: %v", err) } } else { log.Print("Dry run: Skipping metadata update") } } os.Exit(0) } videoMeta.Filepath = path.Join(metadata.FootageDir, vodFiles[0]) // no need to concat if VOD is already one whole file if len(vodFiles) > 1 { videoMeta.Filepath = path.Join( metadata.FootageDir, fmt.Sprintf("%s-fullvod.mkv", metadata.Date.String()), ) if !dryRun { // check if full VOD already exists with expected duration concatVodExists := false concatVodProbe, err := scanner.ProbeSegment(videoMeta.Filepath) if err == nil { videoMeta.SizeBytes = concatVodProbe.Format.Size var totalLength float64 = 0 for _, filename := range vodFiles { probe, err := scanner.ProbeSegment(path.Join(footageDir, filename)) if err != nil { continue } totalLength += probe.Format.Duration } // full VOD may exist, but not be complete (interrupted concat) concatVodExists = math.Abs(concatVodProbe.Format.Duration - totalLength) < float64(0.1) } if concatVodExists { log.Print("Full VOD appears to already exist- uploading this file...") } else { // concatenate VOD segments into full VOD videoMeta.SizeBytes, err = vid.ConcatVideo(videoMeta, vodFiles, verbose) if err != nil { log.Fatalf("Failed to concatenate VOD segments: %v", err) } } } else { log.Print("Dry run: Skipping VOD concatenation") } } if !dryRun { // okay actually upload now! ytVideo, err := yt.UploadVideo(ctx, tokenSource, service, videoMeta, templates) if err != nil { log.Fatalf("Failed to upload video: %v", err) } if verbose { jsonString, err := json.MarshalIndent(ytVideo, "", " ") if err != nil { log.Fatalf("Failed to marshal video data json: %v", err) } fmt.Println(string(jsonString)) } log.Print("Video uploaded successfully!") // set the thumbnail log.Println("Uploading thumbnail...") if err = yt.UploadThumbnail(ctx, tokenSource, service, ytVideo.Id, thumbnail); err != nil { log.Printf("Failed to upload thumbnail: %v", err) } // update metadata to reflect VOD is uploaded metadata.UploadURL = yt.VIDEO_URL_BASE + ytVideo.Id err = scanner.WriteMetadata(directory, metadata) if err != nil { log.Fatalf("Failed to update metadata: %v", err) } // delete concatenated VOD after upload, unless specified otherwise // if len(vodFiles) == 1, the full VOD is the *only* VOD. do not delete this!!! if len(vodFiles) > 1 && !keepConcatVOD { if err = os.Remove(videoMeta.Filepath); err != nil { log.Fatalf("Failed to delete full VOD: %v", err) } } } else { log.Print("Dry run: Skipping video upload") } }