From 1f41ee8fda262ca4cd76f6fd894b62bbd79a6464 Mon Sep 17 00:00:00 2001 From: dreamjz <25699818+dreamjz@users.noreply.github.com> Date: Thu, 23 Nov 2023 00:51:15 +0800 Subject: [PATCH] feat: auto match and rename sub files Create ci.yml --- .github/workflows/ci.yml | 28 +++++++ .gitignore | 5 +- cmd/root.go | 100 ++++++++++++++++-------- go.mod | 5 ++ go.sum | 17 ++++ main.go | 4 - pkg/episode/episode.go | 73 ++++++++++++++++++ pkg/episode/episode_test.go | 150 ++++++++++++++++++++++++++++++++++++ pkg/log/log.go | 30 ++++++++ 9 files changed, 374 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 pkg/episode/episode.go create mode 100644 pkg/episode/episode_test.go create mode 100644 pkg/log/log.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..75afd78 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI +on: + push: + branches: + - main + pull_request: + branches: + - main + + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version-file: go.mod + cache: false + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v3.7.0 + with: + version: v1.54 + args: --verbose --timeout=3m --issues-exit-code=1 diff --git a/.gitignore b/.gitignore index ab4411d..a63b993 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,7 @@ go.work # Goland file -.idea \ No newline at end of file +.idea + +# test files +test-data \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index 861329c..0419c16 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,51 +1,85 @@ -/* -Copyright © 2023 NAME HERE - -*/ package cmd import ( - "os" - + "errors" + "github.com/dreamjz/sub-renamer/pkg/episode" + "github.com/dreamjz/sub-renamer/pkg/log" "github.com/spf13/cobra" + "log/slog" + "os" + "path/filepath" + "strings" ) - +var logLevel string // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "sub-renamer", - Short: "A brief description of your application", - Long: `A longer description that spans multiple lines and likely contains -examples and usage of using your application. For example: - -Cobra is a CLI library for Go that empowers applications. -This application is a tool to generate the needed files -to quickly create a Cobra application.`, - // Uncomment the following line if your bare application - // has an action associated with it: - // Run: func(cmd *cobra.Command, args []string) { }, + Short: "Auto-rename video and subtitle files", + Long: `TODO://`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 2 { + return errors.New("not enough args") + } + + logLevel, err := log.ParseLogLevel(logLevel) + if err != nil { + return err + } + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})) + slog.SetDefault(logger) + + vidDir, subDir := args[0], args[1] + if !filepath.IsAbs(vidDir) { + vidDir, err = filepath.Abs(vidDir) + if err != nil { + return err + } + } + if !filepath.IsAbs(subDir) { + subDir, err = filepath.Abs(subDir) + if err != nil { + return err + } + } + + vidMap, err := episode.ParseEpisodes(vidDir) + if err != nil { + return err + } + subMap, err := episode.ParseEpisodes(subDir) + if err != nil { + return err + } + + for ep, vidName := range vidMap { + subName, ok := subMap[ep] + if !ok { + continue + } + + subExt := filepath.Ext(subName) + + newSubName := strings.TrimSuffix(vidName, filepath.Ext(vidName)) + + err := os.Rename(filepath.Join(subDir, subName), filepath.Join(subDir, newSubName+subExt)) + if err != nil { + return err + } + } + + return nil + }, +} + +func init() { + rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "debug", "log level") } -// Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { err := rootCmd.Execute() if err != nil { os.Exit(1) } } - -func init() { - // Here you will define your flags and configuration settings. - // Cobra supports persistent flags, which, if defined here, - // will be global for your application. - - // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.sub-renamer.yaml)") - - // Cobra also supports local flags, which will only run - // when this action is called directly. - rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") -} - - diff --git a/go.mod b/go.mod index 4192e2f..6db7683 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,12 @@ module github.com/dreamjz/sub-renamer go 1.21 require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/cobra v1.8.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.1 // indirect + github.com/stretchr/testify v1.8.4 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d0e8c2c..6c45c1c 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,27 @@ github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0= +github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 22d1e00..c1be615 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,3 @@ -/* -Copyright © 2023 NAME HERE - -*/ package main import "github.com/dreamjz/sub-renamer/cmd" diff --git a/pkg/episode/episode.go b/pkg/episode/episode.go new file mode 100644 index 0000000..e359bbf --- /dev/null +++ b/pkg/episode/episode.go @@ -0,0 +1,73 @@ +package episode + +import ( + "errors" + "os" + "regexp" + "strconv" +) + +var () + +func ParseEpisodes(dir string) (map[int]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + if len(entries) < 2 { + return nil, errors.New("number of file must be greater than 2") + } + + epStartIndex, err := getEpPosInName(entries[0].Name(), entries[1].Name()) + if err != nil { + return nil, err + } + + nameEpMap := make(map[int]string, len(entries)) + for _, entry := range entries { + fileName := entry.Name() + epNum := getEpisodeNum(fileName, epStartIndex) + nameEpMap[epNum] = fileName + } + + return nameEpMap, nil +} + +func getEpPosInName(fileName1, fileName2 string) (int, error) { + r := regexp.MustCompile(`\d+`) + numMatchIndex1 := r.FindAllStringIndex(fileName1, -1) + numMatchIndex2 := r.FindAllStringIndex(fileName2, -1) + + if len(numMatchIndex1) != len(numMatchIndex2) { + return -1, errors.New("file names are not in same pattern") + } + + for _, subMatch := range numMatchIndex1 { + num1 := fileName1[subMatch[0]:subMatch[1]] + num2 := fileName2[subMatch[0]:subMatch[1]] + + if num1 != num2 { + return subMatch[0], nil + } + } + + return -1, errors.New("episode number not found") +} + +func getEpisodeNum(fileName string, start int) int { + end := start + 1 + for isDigit(fileName[end]) { + end++ + } + + ep, _ := strconv.Atoi(fileName[start:end]) + return ep +} + +func isDigit(b byte) bool { + if b >= '0' && b <= '9' { + return true + } + return false +} diff --git a/pkg/episode/episode_test.go b/pkg/episode/episode_test.go new file mode 100644 index 0000000..de773c9 --- /dev/null +++ b/pkg/episode/episode_test.go @@ -0,0 +1,150 @@ +package episode + +import ( + "errors" + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +func Test_getEpPosInName1(t *testing.T) { + for _, tc := range []struct { + name string + fileName1 string + fileName2 string + epStartIndex int + hasErr bool + diffPattenErr bool + notFoundErr bool + }{ + { + name: "Both empty string", + fileName1: "", + fileName2: "", + epStartIndex: -1, + hasErr: true, + diffPattenErr: false, + notFoundErr: true, + }, + { + name: "FileName1 is empty string", + fileName1: "", + fileName2: "[VCB-Studio] FAIRY TAIL [12][Ma10p_1080p][x265_flac].mkv", + epStartIndex: -1, + hasErr: true, + diffPattenErr: true, + notFoundErr: false, + }, + { + name: "FileName2 is empty string", + fileName1: "[VCB-Studio] FAIRY TAIL [12][Ma10p_1080p][x265_flac].mkv", + fileName2: "", + epStartIndex: -1, + hasErr: true, + diffPattenErr: true, + notFoundErr: false, + }, + { + name: "FileNames in different pattern", + fileName1: "[VCB-Studio] FAIRY TAIL [12][Ma10p_1080p].mkv", + fileName2: "[VCB-Studio] FAIRY TAIL [12][Ma10p_1080p][x265_flac].mkv", + epStartIndex: -1, + hasErr: true, + diffPattenErr: true, + notFoundErr: false, + }, + { + name: "FileNames in same pattern", + fileName1: "[VCB-Studio] FAIRY TAIL [12][Ma10p_1080p][x265_flac].mkv", + fileName2: "[VCB-Studio] FAIRY TAIL [13][Ma10p_1080p][x265_flac].mkv", + epStartIndex: 25, + hasErr: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + idx, err := getEpPosInName(tc.fileName1, tc.fileName2) + + assert.Equal(t, tc.epStartIndex, idx) + + if tc.hasErr { + assert.NotNil(t, err) + switch { + case tc.diffPattenErr: + assert.ErrorContains(t, err, "file names are not in same pattern") + case tc.notFoundErr: + assert.ErrorContains(t, err, "episode number not found") + } + } else { + assert.Nil(t, err) + } + }) + } +} + +func TestParseEpisodes(t *testing.T) { + for _, tc := range []struct { + name string + dir string + episodes map[int]string + wantErr bool + customErr bool + err error + }{ + { + name: "Empty string", + dir: "", + episodes: nil, + wantErr: true, + err: os.ErrNotExist, + }, + { + name: "Directory not exists", + dir: "not exists path", + episodes: nil, + wantErr: true, + err: os.ErrNotExist, + }, + { + name: "Number of files less than 2", + dir: "../../test-data/parse-episodes/case01", + episodes: nil, + wantErr: true, + customErr: true, + err: errors.New("number of file must be greater than 2"), + }, + { + name: "Files have different pattern", + dir: "../../test-data/parse-episodes/case02", + episodes: nil, + wantErr: true, + customErr: true, + err: errors.New("file names are not in same pattern"), + }, + { + name: "Parse episodes", + dir: "../../test-data/parse-episodes/case03", + episodes: map[int]string{ + 12: "[VCB-Studio] FAIRY TAIL [12][Ma10p_1080p][x265_flac].mkv", + 13: "[VCB-Studio] FAIRY TAIL [13][Ma10p_1080p][x265_flac].mkv", + 169: "[VCB-Studio] FAIRY TAIL [169][Ma10p_1080p][x265_flac].mkv", + }, + wantErr: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + eps, err := ParseEpisodes(tc.dir) + + assert.Equal(t, tc.episodes, eps) + + if tc.wantErr { + if !tc.customErr { + assert.ErrorIs(t, err, tc.err) + } else { + assert.ErrorContains(t, err, tc.err.Error()) + } + } else { + assert.Nil(t, err) + } + }) + } +} diff --git a/pkg/log/log.go b/pkg/log/log.go new file mode 100644 index 0000000..3c19c09 --- /dev/null +++ b/pkg/log/log.go @@ -0,0 +1,30 @@ +package log + +import ( + "fmt" + "log/slog" + "strings" +) + +const ( + DebugLevel = slog.LevelDebug + InfoLevel = slog.LevelInfo + WarnLevel = slog.LevelWarn + ErrorLevel = slog.LevelError +) + +func ParseLogLevel(lvl string) (slog.Level, error) { + switch strings.ToLower(lvl) { + case "debug": + return DebugLevel, nil + case "info": + return InfoLevel, nil + case "warn": + return WarnLevel, nil + case "error": + return ErrorLevel, nil + default: + return 0, fmt.Errorf("unrecognized level %s", lvl) + + } +}