mirror of
https://github.com/IllusionMan1212/yt-db-completer.git
synced 2026-10-24 10:12:27 +00:00
Compare commits
2
Commits
810ce23ed2
...
e03fd68d5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e03fd68d5e
|
||
|
|
3fdb0282f3
|
@@ -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
|
||||||
|
|||||||
@@ -2,83 +2,89 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const YT_DB_CMP_VERSION = "0.0.1"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
All Category = "all"
|
InvalidCategory Category = "invalid"
|
||||||
Shorts = "shorts"
|
All Category = "all"
|
||||||
Video = "video"
|
Shorts Category = "shorts"
|
||||||
Livestream = "livestream"
|
Video Category = "video"
|
||||||
Membership = "membership"
|
Livestream Category = "livestream"
|
||||||
|
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 {
|
type categoryValue struct {
|
||||||
return true
|
destination *Category
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Below functions are to satisfy the ValueCreator interface
|
||||||
|
|
||||||
|
func (c categoryValue) Create(val Category, p *Category, config cli.NoConfig) cli.Value {
|
||||||
|
*p = val
|
||||||
|
cat := Category(*p)
|
||||||
|
return &categoryValue{
|
||||||
|
destination: &cat,
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func containsMeta(s []Metadata, id string) bool {
|
func (c categoryValue) ToString(val Category) string {
|
||||||
for _, x := range s {
|
if val == "" {
|
||||||
if x.Id == id {
|
return string(All)
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return string(val)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Category) String() string {
|
func (c *categoryValue) String() string {
|
||||||
return string(*c)
|
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) {
|
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 {
|
|
||||||
c := value
|
|
||||||
flag.Var(&c, name, usage)
|
|
||||||
return &c
|
|
||||||
}
|
|
||||||
|
|
||||||
func getChannelID(handle string) string {
|
func getChannelID(handle string) string {
|
||||||
resp, err := http.Get(fmt.Sprintf("https://www.youtube.com/%s", handle))
|
resp, err := http.Get(fmt.Sprintf("https://www.youtube.com/%s", handle))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,9 +99,9 @@ func getChannelID(handle string) string {
|
|||||||
re := regexp.MustCompile(`<meta[^>]*property=["']og:url["'][^>]*content=["']([^"']+)["']`)
|
re := regexp.MustCompile(`<meta[^>]*property=["']og:url["'][^>]*content=["']([^"']+)["']`)
|
||||||
|
|
||||||
results := re.FindAllStringSubmatch(string(body), -1)
|
results := re.FindAllStringSubmatch(string(body), -1)
|
||||||
parsedUrl := results[0][1]
|
parsedURL := results[0][1]
|
||||||
|
|
||||||
splits := strings.Split(parsedUrl, "/")
|
splits := strings.Split(parsedURL, "/")
|
||||||
channelID := splits[len(splits)-1]
|
channelID := splits[len(splits)-1]
|
||||||
|
|
||||||
return channelID
|
return channelID
|
||||||
@@ -105,65 +111,42 @@ func getMembersPlaylistID(channelID string) string {
|
|||||||
return fmt.Sprintf("UUMO%s", channelID[2:])
|
return fmt.Sprintf("UUMO%s", channelID[2:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func analyze(jsonPath, dirPath string, category Category, dumpToFile bool) error {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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") {
|
||||||
@@ -192,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 {
|
||||||
@@ -207,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,16 +222,226 @@ func main() {
|
|||||||
fmt.Println(extraIds)
|
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")
|
file, fileErr := os.Create("./missing.txt")
|
||||||
defer file.Close()
|
|
||||||
if fileErr != nil {
|
if fileErr != nil {
|
||||||
fmt.Printf("Error while opening dump file: %v\n", fileErr)
|
return fmt.Errorf("error while opening dump file: %v", fileErr)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
for _, meta := range missingMetadata {
|
for _, meta := range missingMetadata {
|
||||||
file.WriteString(meta.Id + "\n")
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user