From 3fdb0282f39806f9b8473853960a11f40b3cacce Mon Sep 17 00:00:00 2001 From: IllusionMan1212 Date: Sat, 9 Aug 2025 16:01:54 +0200 Subject: [PATCH] refactor: CLI help and command using urfave/cli; added format command --- go.mod | 2 + main.go | 340 ++++++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 270 insertions(+), 72 deletions(-) diff --git a/go.mod b/go.mod index 9601584..d88fb15 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module yt-db-completer go 1.22.2 + +require github.com/urfave/cli/v3 v3.3.8 diff --git a/main.go b/main.go index a047aae..382d1ac 100644 --- a/main.go +++ b/main.go @@ -2,24 +2,31 @@ package main import ( "bufio" + "context" "encoding/json" - "flag" "fmt" "io" "log" "net/http" "os" + "path/filepath" "regexp" + "slices" "sort" "strings" + + "github.com/urfave/cli/v3" ) +const YT_DB_CMP_VERSION = "0.0.1" + const ( - All Category = "all" - Shorts = "shorts" - Video = "video" - Livestream = "livestream" - Membership = "membership" + InvalidCategory Category = "invalid" + All Category = "all" + Shorts Category = "shorts" + Video Category = "video" + Livestream Category = "livestream" + Membership Category = "membership" ) type Category string @@ -31,54 +38,53 @@ type Metadata struct { PlaylistTitle string `json:"playlist_title"` } -func containsStr(s []string, id string) bool { - for _, x := range s { - if x == id { - return true - } +type CategoryFlag = cli.FlagBase[Category, cli.NoConfig, categoryValue] + +type categoryValue struct { + destination *Category +} + +// Below functions are to satisfy the ValueCreator interface + +func (s categoryValue) Create(val Category, p *Category, c cli.NoConfig) cli.Value { + *p = val + cat := Category(*p) + return &categoryValue{ + destination: &cat, } - - return false } -func containsMeta(s []Metadata, id string) bool { - for _, x := range s { - if x.Id == id { - return true - } +func (c categoryValue) ToString(val Category) string { + if val == "" { + return string(All) } - - return false + return string(val) } -func (c *Category) String() string { - return string(*c) +func (c *categoryValue) String() string { + return string(*c.destination) } -func (c *Category) Set(value string) error { +func (c *categoryValue) Get() any { return *c.destination } + +func (c *categoryValue) Set(value string) error { switch strings.ToLower(value) { case "all": - *c = All + *c.destination = All case "shorts": - *c = Shorts + *c.destination = Shorts case "video": - *c = Video + *c.destination = Video case "livestream": - *c = Livestream + *c.destination = Livestream case "membership": - *c = Membership + *c.destination = Membership default: - panic(fmt.Sprintf("Invalid category: %s", value)) + *c.destination = InvalidCategory } return nil } -func CategoryFlag(name string, value Category, usage string) *Category { - c := value - flag.Var(&c, name, usage) - return &c -} - func getChannelID(handle string) string { resp, err := http.Get(fmt.Sprintf("https://www.youtube.com/%s", handle)) if err != nil { @@ -105,54 +111,31 @@ func getMembersPlaylistID(channelID string) string { return fmt.Sprintf("UUMO%s", channelID[2:]) } -func main() { - cat := CategoryFlag("category", All, "Category of videos to check against.\nValid options are all, shorts, video, livestream, membership") - dumpToFile := flag.Bool("dump-to-file", false, "The program will dump the missing IDs to a file that can be used with yt-dlp") - - flag.Parse() - - args := flag.Args() - - if len(args) < 1 { - fmt.Println("Please provide a json file dumped from yt-dlp as the first argument") - return - } - - if len(args) < 2 { - fmt.Println("Please provide a directory with folder names formatted like this \"[YYYYMMDD] [XXXXXXXXXXXX] ...\" where X is the video ID") - return - } - +func analyze(jsonPath, dirPath string, category Category, dumpToFile bool) error { existingIds := make([]string, 0, 128) + duplicatedIds := make([]string, 0, 128) missingMetadata := make([]Metadata, 0, 128) extraIds := make([]string, 0, 128) allMetadata := make([]Metadata, 0, 1024) - filePath := args[0] - dirPath := args[1] - dir, dirErr := os.Open(dirPath) defer dir.Close() if dirErr != nil { - fmt.Printf("Error while opening provided directory: %v\n", dirErr) - return + return fmt.Errorf("error while opening provided directory: %v\n", dirErr) } - file, fileErr := os.Open(filePath) + file, fileErr := os.Open(jsonPath) defer file.Close() if fileErr != nil { - fmt.Printf("Error while opening provided json file: %v\n", fileErr) - return + return fmt.Errorf("error while opening provided json file: %v\n", fileErr) } stat, statErr := dir.Stat() if statErr != nil { - fmt.Printf("Error while stating the provided directory: %v\n", statErr) - return + return fmt.Errorf("error while stating the provided directory: %v\n", statErr) } if !stat.IsDir() { - fmt.Println("Second argument is not a directory") - return + return fmt.Errorf("second argument is not a directory") } scanner := bufio.NewScanner(file) @@ -163,7 +146,7 @@ func main() { var metadata Metadata json.Unmarshal([]byte(metadataJson), &metadata) - switch *cat { + switch category { case All: // Do nothing case Shorts: if !strings.HasSuffix(metadata.PlaylistTitle, "Shorts") { @@ -192,7 +175,7 @@ func main() { entries, dirErr := dir.ReadDir(0) if dirErr != nil { - fmt.Printf("Error while reading directory contents: %v\n", dirErr) + return fmt.Errorf("error while reading directory contents: %v\n", dirErr) } for _, entry := range entries { @@ -207,17 +190,20 @@ func main() { name := entry.Name() fields := strings.Fields(name) id := strings.Trim(strings.Trim(fields[1], "["), "]") + if slices.Contains(existingIds, id) { + duplicatedIds = append(duplicatedIds, id) + } existingIds = append(existingIds, id) } for _, meta := range allMetadata { - if !containsStr(existingIds, meta.Id) { + if !slices.Contains(existingIds, meta.Id) { missingMetadata = append(missingMetadata, meta) } } for _, id := range existingIds { - if !containsMeta(allMetadata, id) { + if !slices.ContainsFunc(allMetadata, func(meta Metadata) bool { return meta.Id == id }) { extraIds = append(extraIds, id) } } @@ -236,16 +222,226 @@ func main() { fmt.Println(extraIds) } - if *dumpToFile { + if len(duplicatedIds) != 0 { + fmt.Println("Found duplicated videos:") + fmt.Println(duplicatedIds) + } + + if dumpToFile { file, fileErr := os.Create("./missing.txt") defer file.Close() if fileErr != nil { - fmt.Printf("Error while opening dump file: %v\n", fileErr) - return + return fmt.Errorf("error while opening dump file: %v\n", fileErr) } for _, meta := range missingMetadata { file.WriteString(meta.Id + "\n") } } + + return nil +} + +func format(dirPath string, dryRun bool) error { + dir, dirErr := os.Open(dirPath) + defer dir.Close() + if dirErr != nil { + return fmt.Errorf("error while opening provided directory: %v\n", dirErr) + } + + stat, statErr := dir.Stat() + if statErr != nil { + return fmt.Errorf("error while stating the provided directory: %v\n", statErr) + } + if !stat.IsDir() { + return fmt.Errorf("argument is not a directory") + } + + entries, dirErr := dir.ReadDir(0) + if dirErr != nil { + return fmt.Errorf("error while reading directory contents: %v\n", dirErr) + } + + groupedFiles := make(map[string][]string, 0) + + for _, entry := range entries { + if entry.IsDir() || entry.Name() == "archive.txt" { + continue + } + + base := filepath.Base(entry.Name()) + ext := filepath.Ext(base) + nameWithoutExt := base[:len(base)-len(ext)] + + // Remove the extension twice because json files can have up to 2 extensions e.g. .tar.gz, .info.json, etc... + // HACK: is there a better way of doing this?? + if ext == ".json" || ext == ".vtt" { + nameWithoutExt = nameWithoutExt[:len(nameWithoutExt)-len(filepath.Ext(nameWithoutExt))] + } + + groupedFiles[nameWithoutExt] = append(groupedFiles[nameWithoutExt], entry.Name()) + } + + if dryRun { + fmt.Printf("Found %v Groups\n", len(groupedFiles)) + for name := range groupedFiles { + fmt.Println(name) + } + } else { + for name, group := range groupedFiles { + err := os.Mkdir(filepath.Join(dirPath, name), 0o755) + if err != nil { + return err + } + + for _, file := range group { + // A bit dangerous because `Rename` may overwrite files if newpath already exists. + err = os.Rename(filepath.Join(dirPath, file), filepath.Join(dirPath, name, file)) + if err != nil { + return err + } + } + } + } + + return nil +} + +func main() { + cmd := &cli.Command{ + Name: "yt-db-completer", + // TODO: better description + Usage: "A program to help you categorize, sort, format, and archive youtube channels", + HideHelpCommand: true, + Commands: []*cli.Command{ + { + Name: "analyze", + Usage: "analyzes a yt-dlp json file", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "dump-to-file", + Usage: "dump the missing IDs to a file that can be used with yt-dlp", + DefaultText: "false", + }, + &CategoryFlag{ + Name: "category", + Usage: "category of videos to check against. Valid options are \"all\", \"shorts\", \"video\", \"livestream\", \"membership\"", + DefaultText: "all", + Action: func(ctx context.Context, c *cli.Command, s Category) error { + if s == "" { + return nil + } + + if s == InvalidCategory { + return cli.Exit("Invalid category. Valid categories are \"all\", \"shorts\", \"video\", \"livestream\", \"membership\"", 1) + } + + return nil + }, + }, + }, + ArgsUsage: " ", + Arguments: []cli.Argument{ + &cli.StringArg{ + Name: "json file", + Config: cli.StringConfig{TrimSpace: true}, + }, + &cli.StringArg{ + Name: "directory", + Config: cli.StringConfig{TrimSpace: true}, + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + // if --category is missing, assume "all". + category := All + + if v, ok := c.Value("category").(Category); ok && v != "" { + category = v + } + dumpToFile := c.Bool("dump-to-file") + + jsonPath := c.StringArg("json file") + dir := c.StringArg("directory") + + if jsonPath == "" { + return cli.Exit("Please provide a dumped from yt-dlp as the first argument", 1) + } + + if dir == "" { + return cli.Exit("Please provide a with folder names formatted like this \"[YYYYMMDD] [XXXXXXXXXXXX] ...\" where X is the video ID as the second argument", 1) + } + + return analyze(jsonPath, dir, category, dumpToFile) + }, + }, + { + Name: "format", + Usage: "collect files in with the same name but a different extension into a directory with that name", + ArgsUsage: "", + HideHelp: true, + Arguments: []cli.Argument{ + &cli.StringArg{ + Name: "dir", + Config: cli.StringConfig{TrimSpace: true}, + }, + }, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "dry-run", + Usage: "prints the files that will be moved and their count", + DefaultText: "false", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + dir := c.StringArg("dir") + dryRun := c.Bool("dry-run") + + if dir == "" { + return cli.Exit(" is required", 1) + } + + return format(dir, dryRun) + }, + }, + { + Name: "membership-id", + Usage: "prints the provided channel's membership playlist id", + ArgsUsage: "", + HideHelp: true, + Arguments: []cli.Argument{ + &cli.StringArg{ + Name: "username", + Config: cli.StringConfig{TrimSpace: true}, + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + username := c.StringArg("username") + + if username == "" { + return cli.Exit(" is required", 1) + } + + channelId := getChannelID(username) + playlistId := getMembersPlaylistID(channelId) + fmt.Printf("Channel ID for %v is: %v\n", username, channelId) + fmt.Printf("Membership playlist ID is: %v\n", playlistId) + + return nil + }, + }, + { + Name: "version", + Usage: "print program version and exit", + HideHelp: true, + Action: func(ctx context.Context, c *cli.Command) error { + fmt.Println(YT_DB_CMP_VERSION) + return nil + }, + }, + }, + } + + if err := cmd.Run(context.Background(), os.Args); err != nil { + log.Fatal(err) + } }