Compare commits

...
3 Commits
Author SHA1 Message Date
Illusion e03fd68d5e appease linter 2025-08-24 10:29:52 +02:00
Illusion 3fdb0282f3 refactor: CLI help and command using urfave/cli; added format command 2025-08-09 16:01:54 +02:00
Illusion 810ce23ed2 helper functions
One for getting the channel id from a user handle.
Another for getting the members playlist id from the channel id.

This is in preparation for making the program function as a quick way to get ids to pass to yt-dlp
to get json data of videos, which in turn will be fed to the program to determine which videos are missing
from an archive
2025-05-26 02:01:27 +02:00
2 changed files with 305 additions and 77 deletions
+2
View File
@@ -1,3 +1,5 @@
module yt-db-completer module yt-db-completer
go 1.22.2 go 1.22.2
require github.com/urfave/cli/v3 v3.3.8
+305 -79
View File
@@ -2,138 +2,151 @@ package main
import ( import (
"bufio" "bufio"
"context"
"encoding/json" "encoding/json"
"flag"
"fmt" "fmt"
"io"
"log"
"net/http"
"os" "os"
"path/filepath"
"regexp"
"slices"
"sort" "sort"
"strings" "strings"
"github.com/urfave/cli/v3"
) )
const YT_DB_CMP_VERSION = "0.0.1"
const ( const (
InvalidCategory Category = "invalid"
All Category = "all" All Category = "all"
Shorts = "shorts" Shorts Category = "shorts"
Video = "video" Video Category = "video"
Livestream = "livestream" Livestream Category = "livestream"
Membership = "membership" Membership Category = "membership"
) )
type Category string type Category string
type Metadata struct { type Metadata struct {
Id string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Date string `json:"upload_date"` Date string `json:"upload_date"`
PlaylistTitle string `json:"playlist_title"` PlaylistTitle string `json:"playlist_title"`
} }
func containsStr(s []string, id string) bool { type CategoryFlag = cli.FlagBase[Category, cli.NoConfig, categoryValue]
for _, x := range s {
if x == id {
return true
}
}
return false type categoryValue struct {
destination *Category
} }
func containsMeta(s []Metadata, id string) bool { // Below functions are to satisfy the ValueCreator interface
for _, x := range s {
if x.Id == id {
return true
}
}
return false func (c categoryValue) Create(val Category, p *Category, config cli.NoConfig) cli.Value {
*p = val
cat := Category(*p)
return &categoryValue{
destination: &cat,
}
} }
func (c *Category) String() string { func (c categoryValue) ToString(val Category) string {
return string(*c) if val == "" {
return string(All)
}
return string(val)
} }
func (c *Category) Set(value string) error { func (c *categoryValue) String() string {
return string(*c.destination)
}
func (c *categoryValue) Get() any { return *c.destination }
func (c *categoryValue) Set(value string) error {
switch strings.ToLower(value) { switch strings.ToLower(value) {
case "all": case "all":
*c = All *c.destination = All
case "shorts": case "shorts":
*c = Shorts *c.destination = Shorts
case "video": case "video":
*c = Video *c.destination = Video
case "livestream": case "livestream":
*c = Livestream *c.destination = Livestream
case "membership": case "membership":
*c = Membership *c.destination = Membership
default: default:
panic(fmt.Sprintf("Invalid category: %s", value)) *c.destination = InvalidCategory
} }
return nil return nil
} }
func CategoryFlag(name string, value Category, usage string) *Category { func getChannelID(handle string) string {
c := value resp, err := http.Get(fmt.Sprintf("https://www.youtube.com/%s", handle))
flag.Var(&c, name, usage) if err != nil {
return &c log.Fatal(err)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
re := regexp.MustCompile(`<meta[^>]*property=["']og:url["'][^>]*content=["']([^"']+)["']`)
results := re.FindAllStringSubmatch(string(body), -1)
parsedURL := results[0][1]
splits := strings.Split(parsedURL, "/")
channelID := splits[len(splits)-1]
return channelID
} }
func main() { func getMembersPlaylistID(channelID string) string {
cat := CategoryFlag("category", All, "Category of videos to check against.\nValid options are all, shorts, video, livestream, membership") return fmt.Sprintf("UUMO%s", channelID[2:])
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) existingIds := make([]string, 0, 128)
duplicatedIds := make([]string, 0, 128)
missingMetadata := make([]Metadata, 0, 128) missingMetadata := make([]Metadata, 0, 128)
extraIds := make([]string, 0, 128) extraIds := make([]string, 0, 128)
allMetadata := make([]Metadata, 0, 1024) allMetadata := make([]Metadata, 0, 1024)
filePath := args[0]
dirPath := args[1]
dir, dirErr := os.Open(dirPath) dir, dirErr := os.Open(dirPath)
defer dir.Close()
if dirErr != nil { if dirErr != nil {
fmt.Printf("Error while opening provided directory: %v\n", dirErr) return fmt.Errorf("error while opening provided directory: %v", dirErr)
return
} }
defer dir.Close()
file, fileErr := os.Open(filePath) file, fileErr := os.Open(jsonPath)
defer file.Close()
if fileErr != nil { if fileErr != nil {
fmt.Printf("Error while opening provided json file: %v\n", fileErr) return fmt.Errorf("error while opening provided json file: %v", fileErr)
return
} }
defer file.Close()
stat, statErr := dir.Stat() stat, statErr := dir.Stat()
if statErr != nil { if statErr != nil {
fmt.Printf("Error while stating the provided directory: %v\n", statErr) return fmt.Errorf("error while stating the provided directory: %v", statErr)
return
} }
if !stat.IsDir() { if !stat.IsDir() {
fmt.Println("Second argument is not a directory") return fmt.Errorf("second argument is not a directory")
return
} }
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
for scanner.Scan() { for scanner.Scan() {
metadataJson := scanner.Text() metadataJSON := scanner.Text()
var metadata Metadata var metadata Metadata
json.Unmarshal([]byte(metadataJson), &metadata) json.Unmarshal([]byte(metadataJSON), &metadata)
switch *cat { switch category {
case All: // Do nothing case All: // Do nothing
case Shorts: case Shorts:
if !strings.HasSuffix(metadata.PlaylistTitle, "Shorts") { if !strings.HasSuffix(metadata.PlaylistTitle, "Shorts") {
@@ -162,7 +175,7 @@ func main() {
entries, dirErr := dir.ReadDir(0) entries, dirErr := dir.ReadDir(0)
if dirErr != nil { if dirErr != nil {
fmt.Printf("Error while reading directory contents: %v\n", dirErr) return fmt.Errorf("error while reading directory contents: %v", dirErr)
} }
for _, entry := range entries { for _, entry := range entries {
@@ -177,17 +190,20 @@ func main() {
name := entry.Name() name := entry.Name()
fields := strings.Fields(name) fields := strings.Fields(name)
id := strings.Trim(strings.Trim(fields[1], "["), "]") id := strings.Trim(strings.Trim(fields[1], "["), "]")
if slices.Contains(existingIds, id) {
duplicatedIds = append(duplicatedIds, id)
}
existingIds = append(existingIds, id) existingIds = append(existingIds, id)
} }
for _, meta := range allMetadata { for _, meta := range allMetadata {
if !containsStr(existingIds, meta.Id) { if !slices.Contains(existingIds, meta.ID) {
missingMetadata = append(missingMetadata, meta) missingMetadata = append(missingMetadata, meta)
} }
} }
for _, id := range existingIds { for _, id := range existingIds {
if !containsMeta(allMetadata, id) { if !slices.ContainsFunc(allMetadata, func(meta Metadata) bool { return meta.ID == id }) {
extraIds = append(extraIds, id) extraIds = append(extraIds, id)
} }
} }
@@ -195,7 +211,7 @@ func main() {
if len(missingMetadata) == 0 { if len(missingMetadata) == 0 {
fmt.Printf("Found %d IDs. No missing IDs. Congrats\n", len(existingIds)) fmt.Printf("Found %d IDs. No missing IDs. Congrats\n", len(existingIds))
} else { } else {
fmt.Printf("Found %d IDs. Still missing %d IDs. For a total of %d IDs\n", len(existingIds), len(allMetadata)-(len(existingIds) - len(extraIds)), len(allMetadata)) fmt.Printf("Found %d IDs. Still missing %d IDs. For a total of %d IDs\n", len(existingIds), len(allMetadata)-(len(existingIds)-len(extraIds)), len(allMetadata))
fmt.Println("Missing Videos are:") fmt.Println("Missing Videos are:")
fmt.Println(missingMetadata) fmt.Println(missingMetadata)
@@ -206,16 +222,226 @@ func main() {
fmt.Println(extraIds) fmt.Println(extraIds)
} }
if *dumpToFile { if len(duplicatedIds) != 0 {
file, fileErr := os.Create("./missing.txt") fmt.Println("Found duplicated videos:")
defer file.Close() fmt.Println(duplicatedIds)
if fileErr != nil {
fmt.Printf("Error while opening dump file: %v\n", fileErr)
return
} }
for _, meta := range missingMetadata { if dumpToFile {
file.WriteString(meta.Id + "\n") file, fileErr := os.Create("./missing.txt")
if fileErr != nil {
return fmt.Errorf("error while opening dump file: %v", fileErr)
} }
defer file.Close()
for _, meta := range missingMetadata {
file.WriteString(meta.ID + "\n")
}
}
return nil
}
func format(dirPath string, dryRun bool) error {
dir, dirErr := os.Open(dirPath)
if dirErr != nil {
return fmt.Errorf("error while opening provided directory: %v", dirErr)
}
defer dir.Close()
stat, statErr := dir.Stat()
if statErr != nil {
return fmt.Errorf("error while stating the provided directory: %v", 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", 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: "<json file> <directory>",
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 <json file> dumped from yt-dlp as the first argument", 1)
}
if dir == "" {
return cli.Exit("Please provide a <directory> 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 <dir> with the same name but a different extension into a directory with that name",
ArgsUsage: "<dir>",
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("<dir> is required", 1)
}
return format(dir, dryRun)
},
},
{
Name: "membership-id",
Usage: "prints the provided channel's membership playlist id",
ArgsUsage: "<username>",
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("<username> 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)
} }
} }