diff --git a/audioc.go b/audioc.go index 13c251b..e2f9408 100644 --- a/audioc.go +++ b/audioc.go @@ -2,35 +2,15 @@ package main import ( "os" - "fmt" "log" - "image" - "regexp" - "strings" - "strconv" - "runtime" - "io/ioutil" - "path/filepath" "github.com/jamlib/libaudio/ffmpeg" "github.com/jamlib/libaudio/ffprobe" - "github.com/jamlib/libaudio/fsutil" - "github.com/jamlib/audioc/albumart" - "github.com/jamlib/audioc/metadata" + "github.com/jamlib/audioc/audioc" ) -type audioc struct { - DirEntry string - Image string - Ffmpeg ffmpeg.Ffmpeger - Ffprobe ffprobe.Ffprober - Files []string - Workers int - Workdir string -} - func main() { - args, cont := processFlags() + c, cont := configFromFlags() if !cont { os.Exit(0) } @@ -45,315 +25,10 @@ func main() { log.Fatal(err) } - a := &audioc{ Ffmpeg: ffm, Ffprobe: ffp, Workers: runtime.NumCPU(), - DirEntry: filepath.Clean(args[0]) } + a := audioc.New(c, ffm, ffp) - err = a.process() + err = a.Process() if err != nil { log.Fatal(err) } } - -func skipFolder(base, path string) bool { - var alb string - m := metadata.New(base, path) - pa := strings.Split(m.Infodir, fsutil.PathSep) - - if flags.Collection { - // true if --collection & artist path contains " - " - if strings.Index(pa[0], " - ") != -1 { - return true - } - if len(pa) > 2 { - // Artist / Year / Album - alb = pa[2] - } - } else { - // if --artist, album should be innermost dir - alb = pa[len(pa)-1] - } - - // true if album folder matches metadata.ToAlbum - if alb != "" { - i := &metadata.Info{} - i.FromPath(alb) - - if i.ToAlbum() == alb { - return true - } - } - - return false -} - -// process album art once per folder of files -func (a *audioc) processArtwork(file string) error { - art := &albumart.AlbumArt{ Ffmpeg: a.Ffmpeg, Ffprobe: a.Ffprobe, - ImgDecode: image.DecodeConfig, WithParentDir: true, - Fullpath: filepath.Join(a.DirEntry, file) } - - if flags.Write { - var err error - a.Image, err = albumart.Process(art) - if err != nil { - return err - } - } - - return nil -} - -func (a *audioc) process() error { - if !flags.Write { - fmt.Printf("\n* To write changes to disk, please provide flag: --write\n") - } - - // ensure path is is valid directory - fi, err := os.Stat(a.DirEntry) - if err != nil || !fi.IsDir() { - return fmt.Errorf("Invalid directory: %s", a.DirEntry) - } - - // obtain audio file list - a.Files = fsutil.FilesAudio(a.DirEntry) - - // group files by parent directory - err = fsutil.BundleFiles(a.DirEntry, a.Files, a.processFolder) - if err != nil { - return err - } - - fmt.Printf("\naudioc finished.\n") - return nil -} - -func (a *audioc) processFolder(indexes []int) error { - fullDir := filepath.Dir(filepath.Join(a.DirEntry, a.Files[indexes[0]])) - - // skip if possible (unless --force) - if !flags.Force && skipFolder(a.DirEntry, a.Files[indexes[0]]) { - return nil - } - - fmt.Printf("\nProcessing: %v ...\n", fullDir) - - // process artwork once per folder - err := a.processArtwork(a.Files[indexes[0]]) - if err != nil { - return err - } - - // create new random workdir within current path - a.Workdir, err = ioutil.TempDir(fullDir, "") - if err != nil { - return err - } - defer os.RemoveAll(a.Workdir) - - // process folder via threads returning the resulting dir - dir, err := a.processThreaded(indexes) - if err != nil { - return err - } - - // if not same dir, rename directory to target dir - if fullDir != dir { - _, err = fsutil.MergeFolder(fullDir, dir, mergeFolderFunc) - if err != nil { - return err - } - } - - // remove parent folder if no longer contains audio files - parentDir := filepath.Dir(fullDir) - if len(fsutil.FilesAudio(parentDir)) == 0 { - err = os.RemoveAll(parentDir) - if err != nil { - return err - } - } - - return nil -} - -// passed to fsutil.MergeFolder -func mergeFolderFunc(f string) (int, string) { - // split filename from path - _, file := filepath.Split(f) - - // parse disc & track from filename - i := &metadata.Info{} - i.FromFile(file) - - disc, _ := strconv.Atoi(regexp.MustCompile(`^\d+`).FindString(i.Disc)) - track, _ := strconv.Atoi(regexp.MustCompile(`^\d+`).FindString(i.Track)) - - // combine disc & track into unique integer - return (disc*1000)+track, i.Title -} - -func (a *audioc) processThreaded(indexes []int) (string, error) { - var err error - jobs := make(chan int) - dir := make(chan string, a.Workers) - - // iterate through files sending them to worker processes - go func() { - for x := range indexes { - if err != nil { - break - } - jobs <- indexes[x] - } - close(jobs) - }() - - // start worker processes - for i := 0; i < a.Workers; i++ { - go func() { - var d string - - for job := range jobs { - var e error - d, e = a.processFile(job) - if e != nil { - err = e - break - } - } - - dir <- d - }() - } - - // wait for all workers to finish - var resultDir string - for i := 0; i < a.Workers; i++ { - resultDir = <-dir - } - - return resultDir, err -} - -func (a *audioc) processFile(index int) (string, error) { - m := metadata.New(a.DirEntry, a.Files[index]) - - // if --artist mode, remove innermost dir from basepath so it ends up in infodir - if flags.Artist != "" { - m.Artist = flags.Artist - m.Basepath = filepath.Dir(m.Basepath) - } - - // if --colleciton mode, artist comes from parent folder name - if flags.Collection { - m.Artist = strings.Split(a.Files[index], fsutil.PathSep)[0] - } - - m, i, err := m.NewInfo(a.Ffprobe) - if err != nil { - return "", err - } - - // skip if sources match (unless --force) - if m.Match && !flags.Force { - return m.Fulldir, nil - } - - // build resulting path - var path string - if flags.Collection { - // build from DirEntry; include artist then year - path = filepath.Join(a.DirEntry, i.Artist, i.Year) - } else { - // remove current dir from fullpath - path = strings.TrimSuffix(m.Fulldir, m.Infodir) - } - - // append directory generated from info - path = filepath.Join(path, i.ToAlbum()) - - // print changes to be made - p := fmt.Sprintf("%v\n", m.Fullpath) - if !m.Match { - p += fmt.Sprintf(" * update tags: %#v\n", i) - } - - // convert audio (if necessary) & update tags - ext := strings.ToLower(filepath.Ext(m.Fullpath)) - if ext != ".flac" || regexp.MustCompile(` - FLAC$`).FindString(m.Infodir) == "" { - // convert to mp3 except flac files with " - FLAC" in folder name - _, err := a.processMp3(m.Fullpath, i) - if err != nil { - return "", err - } - - // compare processed to current path - newPath := filepath.Join(path, i.ToFile() + ".mp3") - if m.Fullpath != newPath { - p += fmt.Sprintf(" * rename to: %v\n", newPath) - } - } else { - // TODO: use metaflac to edit flac metadata & embedd artwork - p += fmt.Sprintf("\n*** Flac processing with 'metaflac' not yet implemented.\n") - } - - // print to console all at once - fmt.Printf(p) - - // path is a directory - return path, nil -} - -func (a *audioc) processMp3(f string, i *metadata.Info) (string, error) { - // if already mp3, copy stream; do not convert - quality := flags.Bitrate - if strings.ToLower(filepath.Ext(f)) == ".mp3" { - quality = "copy" - } - - // TODO: specify lower bitrate if source file is of low bitrate - - // build metadata from tag info - ffmeta := ffmpeg.Metadata{ Artist: i.Artist, Album: i.ToAlbum(), - Disc: i.Disc, Track: i.Track, Title: i.Title, Artwork: a.Image } - - // save new file to Workdir subdir within current path - newFile := filepath.Join(a.Workdir, i.ToFile() + ".mp3") - - // process or convert to mp3 - c := &ffmpeg.Mp3Config{ f, quality, newFile, ffmeta, flags.Fix } - _, err := a.Ffmpeg.ToMp3(c) - if err != nil { - return newFile, err - } - - // ensure output file was written - fi, err := os.Stat(newFile) - if err != nil { - return newFile, err - } - - // if flagsWrite & resulting file has size - // TODO: ensure resulting file is good by reading & comparing metadata - if fi.Size() > 0 { - file := filepath.Join(filepath.Dir(f), i.ToFile() + ".mp3") - - if flags.Write { - // delete original - err = os.Remove(f) - if err != nil { - return file, err - } - - // move new to original directory - err = os.Rename(newFile, file) - if err != nil { - return file, err - } - } - - return file, nil - } - - return newFile, fmt.Errorf("File didn't have size") -} diff --git a/audioc/audioc.go b/audioc/audioc.go new file mode 100644 index 0000000..35d13a8 --- /dev/null +++ b/audioc/audioc.go @@ -0,0 +1,350 @@ +package audioc + +import ( + "os" + "fmt" + "image" + "regexp" + "strings" + "strconv" + "runtime" + "io/ioutil" + "path/filepath" + + "github.com/jamlib/libaudio/ffmpeg" + "github.com/jamlib/libaudio/ffprobe" + "github.com/jamlib/libaudio/fsutil" + "github.com/jamlib/audioc/albumart" + "github.com/jamlib/audioc/metadata" +) + +type Config struct { + DirEntry string + Flags flags +} + +type audioc struct { + DirEntry string + Flags flags + + Ffmpeg ffmpeg.Ffmpeger + Ffprobe ffprobe.Ffprober + Image string + Files []string + Workers int + Workdir string +} + +type flags struct { + Artist, Bitrate string + Collection, Fix, Force, Version, Write bool +} + +func New(c *Config, ffm ffmpeg.Ffmpeger, ffp ffprobe.Ffprober) *audioc { + return &audioc{ DirEntry: c.DirEntry, Flags: c.Flags, + Ffmpeg: ffm, Ffprobe: ffp, Workers: runtime.NumCPU() } +} + +func (a *audioc) Process() error { + if !a.Flags.Write { + fmt.Printf("\n* To write changes to disk, please provide flag: --write\n") + } + + // ensure path is is valid directory + fi, err := os.Stat(a.DirEntry) + if err != nil || !fi.IsDir() { + return fmt.Errorf("Invalid directory: %s", a.DirEntry) + } + + // obtain audio file list + a.Files = fsutil.FilesAudio(a.DirEntry) + + // group files by parent directory + err = fsutil.BundleFiles(a.DirEntry, a.Files, a.processFolder) + if err != nil { + return err + } + + fmt.Printf("\naudioc finished.\n") + return nil +} + +func (a *audioc) skipFolder(base, path string) bool { + var alb string + m := metadata.New(base, path) + pa := strings.Split(m.Infodir, fsutil.PathSep) + + if a.Flags.Collection { + // true if --collection & artist path contains " - " + if strings.Index(pa[0], " - ") != -1 { + return true + } + if len(pa) > 2 { + // Artist / Year / Album + alb = pa[2] + } + } else { + // if --artist, album should be innermost dir + alb = pa[len(pa)-1] + } + + // true if album folder matches metadata.ToAlbum + if alb != "" { + i := &metadata.Info{} + i.FromPath(alb) + + if i.ToAlbum() == alb { + return true + } + } + + return false +} + +// process album art once per folder of files +func (a *audioc) processArtwork(file string) error { + art := &albumart.AlbumArt{ Ffmpeg: a.Ffmpeg, Ffprobe: a.Ffprobe, + ImgDecode: image.DecodeConfig, WithParentDir: true, + Fullpath: filepath.Join(a.DirEntry, file) } + + if a.Flags.Write { + var err error + a.Image, err = albumart.Process(art) + if err != nil { + return err + } + } + + return nil +} + +func (a *audioc) processFolder(indexes []int) error { + fullDir := filepath.Dir(filepath.Join(a.DirEntry, a.Files[indexes[0]])) + + // skip if possible (unless --force) + if !a.Flags.Force && a.skipFolder(a.DirEntry, a.Files[indexes[0]]) { + return nil + } + + fmt.Printf("\nProcessing: %v ...\n", fullDir) + + // process artwork once per folder + err := a.processArtwork(a.Files[indexes[0]]) + if err != nil { + return err + } + + // create new random workdir within current path + a.Workdir, err = ioutil.TempDir(fullDir, "") + if err != nil { + return err + } + defer os.RemoveAll(a.Workdir) + + // process folder via threads returning the resulting dir + dir, err := a.processThreaded(indexes) + if err != nil { + return err + } + + // if not same dir, rename directory to target dir + if fullDir != dir { + _, err = fsutil.MergeFolder(fullDir, dir, mergeFolderFunc) + if err != nil { + return err + } + } + + // remove parent folder if no longer contains audio files + parentDir := filepath.Dir(fullDir) + if len(fsutil.FilesAudio(parentDir)) == 0 { + err = os.RemoveAll(parentDir) + if err != nil { + return err + } + } + + return nil +} + +// passed to fsutil.MergeFolder +func mergeFolderFunc(f string) (int, string) { + // split filename from path + _, file := filepath.Split(f) + + // parse disc & track from filename + i := &metadata.Info{} + i.FromFile(file) + + disc, _ := strconv.Atoi(regexp.MustCompile(`^\d+`).FindString(i.Disc)) + track, _ := strconv.Atoi(regexp.MustCompile(`^\d+`).FindString(i.Track)) + + // combine disc & track into unique integer + return (disc*1000)+track, i.Title +} + +func (a *audioc) processThreaded(indexes []int) (string, error) { + var err error + jobs := make(chan int) + dir := make(chan string, a.Workers) + + // iterate through files sending them to worker processes + go func() { + for x := range indexes { + if err != nil { + break + } + jobs <- indexes[x] + } + close(jobs) + }() + + // start worker processes + for i := 0; i < a.Workers; i++ { + go func() { + var d string + + for job := range jobs { + var e error + d, e = a.processFile(job) + if e != nil { + err = e + break + } + } + + dir <- d + }() + } + + // wait for all workers to finish + var resultDir string + for i := 0; i < a.Workers; i++ { + resultDir = <-dir + } + + return resultDir, err +} + +func (a *audioc) processFile(index int) (string, error) { + m := metadata.New(a.DirEntry, a.Files[index]) + + // if --artist mode, remove innermost dir from basepath so it ends up in infodir + if a.Flags.Artist != "" { + m.Artist = a.Flags.Artist + m.Basepath = filepath.Dir(m.Basepath) + } + + // if --colleciton mode, artist comes from parent folder name + if a.Flags.Collection { + m.Artist = strings.Split(a.Files[index], fsutil.PathSep)[0] + } + + m, i, err := m.NewInfo(a.Ffprobe) + if err != nil { + return "", err + } + + // skip if sources match (unless --force) + if m.Match && !a.Flags.Force { + return m.Fulldir, nil + } + + // build resulting path + var path string + if a.Flags.Collection { + // build from DirEntry; include artist then year + path = filepath.Join(a.DirEntry, i.Artist, i.Year) + } else { + // remove current dir from fullpath + path = strings.TrimSuffix(m.Fulldir, m.Infodir) + } + + // append directory generated from info + path = filepath.Join(path, i.ToAlbum()) + + // print changes to be made + p := fmt.Sprintf("\n%v\n", m.Fullpath) + if !m.Match { + p += fmt.Sprintf(" * update tags: %#v\n", i) + } + + // convert audio (if necessary) & update tags + ext := strings.ToLower(filepath.Ext(m.Fullpath)) + if ext != ".flac" || regexp.MustCompile(` - FLAC$`).FindString(m.Infodir) == "" { + // convert to mp3 except flac files with " - FLAC" in folder name + _, err := a.processMp3(m.Fullpath, i) + if err != nil { + return "", err + } + + // compare processed to current path + newPath := filepath.Join(path, i.ToFile() + ".mp3") + if m.Fullpath != newPath { + p += fmt.Sprintf(" * rename to: %v\n", newPath) + } + } else { + // TODO: use metaflac to edit flac metadata & embedd artwork + p += fmt.Sprintf("\n*** Flac processing with 'metaflac' not yet implemented.\n") + } + + // print to console all at once + fmt.Printf(p) + + // path is a directory + return path, nil +} + +func (a *audioc) processMp3(f string, i *metadata.Info) (string, error) { + // if already mp3, copy stream; do not convert + quality := a.Flags.Bitrate + if strings.ToLower(filepath.Ext(f)) == ".mp3" { + quality = "copy" + } + + // TODO: specify lower bitrate if source file is of low bitrate + + // build metadata from tag info + ffmeta := ffmpeg.Metadata{ Artist: i.Artist, Album: i.ToAlbum(), + Disc: i.Disc, Track: i.Track, Title: i.Title, Artwork: a.Image } + + // save new file to Workdir subdir within current path + newFile := filepath.Join(a.Workdir, i.ToFile() + ".mp3") + + // process or convert to mp3 + c := &ffmpeg.Mp3Config{ f, quality, newFile, ffmeta, a.Flags.Fix } + _, err := a.Ffmpeg.ToMp3(c) + if err != nil { + return newFile, err + } + + // ensure output file was written + fi, err := os.Stat(newFile) + if err != nil { + return newFile, err + } + + // if flagsWrite & resulting file has size + // TODO: ensure resulting file is good by reading & comparing metadata + if fi.Size() > 0 { + file := filepath.Join(filepath.Dir(f), i.ToFile() + ".mp3") + + if a.Flags.Write { + // delete original + err = os.Remove(f) + if err != nil { + return file, err + } + + // move new to original directory + err = os.Rename(newFile, file) + if err != nil { + return file, err + } + } + + return file, nil + } + + return newFile, fmt.Errorf("File didn't have size") +} diff --git a/audioc_test.go b/audioc/audioc_test.go similarity index 87% rename from audioc_test.go rename to audioc/audioc_test.go index 748304f..9a9af29 100644 --- a/audioc_test.go +++ b/audioc/audioc_test.go @@ -1,4 +1,4 @@ -package main +package audioc import ( "os" @@ -21,10 +21,9 @@ func TestSkipArtistOnCollection(t *testing.T) { } for i := range tests { - flags.Collection = tests[i].col - defer func() { flags.Collection = false }() + a := &audioc{ Flags: flags{ Collection: tests[i].col } } - r := skipFolder(tests[i].base, tests[i].path) + r := a.skipFolder(tests[i].base, tests[i].path) if r != tests[i].skip { t.Errorf("%v: Expected %v, got %v", tests[i].base+tests[i].path, tests[i].skip, r) } @@ -41,10 +40,9 @@ func TestSkipFolder(t *testing.T) { } for i := range tests { - flags.Collection = tests[i].col - defer func() { flags.Collection = false }() + a := &audioc{ Flags: flags{ Collection: tests[i].col } } - r := skipFolder(tests[i].base, tests[i].path) + r := a.skipFolder(tests[i].base, tests[i].path) if r != tests[i].skip { t.Errorf("%v: Expected %v, got %v", tests[i].base+tests[i].path, tests[i].skip, r) } @@ -53,7 +51,7 @@ func TestSkipFolder(t *testing.T) { func TestProcessDirDNE(t *testing.T) { a := &audioc{ DirEntry: "audioc-dir-def-dne" } - err := a.process() + err := a.Process() if err == nil { t.Errorf("Expected error, got none.") } @@ -98,14 +96,10 @@ func TestProcessMain(t *testing.T) { }) defer os.RemoveAll(a.DirEntry) - flags.Write = true - flags.Force = true - defer func() { - flags.Write = false - flags.Force = false - }() + a.Flags.Write = true + a.Flags.Force = true - err := a.process() + err := a.Process() // ensure no errors in process if err != nil { diff --git a/flag.go b/flag.go index 68965fd..26bc3ab 100644 --- a/flag.go +++ b/flag.go @@ -1,8 +1,12 @@ package main import ( + "os" "fmt" "flag" + "path/filepath" + + "github.com/jamlib/audioc/audioc" ) const ( @@ -49,59 +53,57 @@ Debug: print program version, then exit ` -type Flags struct { - Artist, Bitrate string - Collection, Fix, Force, Version, Write bool -} +func configFromFlags() (*audioc.Config, bool) { + c := audioc.Config{} + flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError) + + // set mode + flags.StringVar(&c.Flags.Artist, "artist", "", "") + flags.BoolVar(&c.Flags.Collection, "collection", false, "") -var flags = Flags{} - -func init() { - // setup mode - flag.StringVar(&flags.Artist, "artist", "", "") - flag.BoolVar(&flags.Collection, "collection", false, "") - // setup options - flag.StringVar(&flags.Bitrate, "bitrate", "V0", "") - flag.BoolVar(&flags.Fix, "fix", false, "") - flag.BoolVar(&flags.Force, "force", false, "") - flag.BoolVar(&flags.Write, "write", false, "") - // detup debug options - flag.BoolVar(&flags.Version, "version", false, "") - - // --help - flag.Usage = func() { + // set options + flags.StringVar(&c.Flags.Bitrate, "bitrate", "V0", "") + flags.BoolVar(&c.Flags.Fix, "fix", false, "") + flags.BoolVar(&c.Flags.Force, "force", false, "") + flags.BoolVar(&c.Flags.Write, "write", false, "") + + // set debug options + flags.BoolVar(&c.Flags.Version, "version", false, "") + + // create --help closure + flags.Usage = func() { fmt.Printf(printUsage, version, description, args) fmt.Println() } -} -func processFlags() ([]string, bool) { - flag.Parse() - a := flag.Args() + // process flags + flags.Parse(os.Args[1:]) + a := flags.Args() // --version - if flags.Version { + if c.Flags.Version { fmt.Printf("%s\n", version) - return a, false + return &c, false } // show --help unless args if len(a) != 1 { - flag.Usage() - return a, false + flags.Usage() + return &c, false } // must specify --artist OR --collection - if flags.Artist == "" && !flags.Collection { + if c.Flags.Artist == "" && !c.Flags.Collection { fmt.Printf("\nError: Must provide option --artist OR --collection\n") - flag.Usage() - return a, false + flags.Usage() + return &c, false } // default to V0 unless 320 specified - if flags.Bitrate != "320" { - flags.Bitrate = "V0" + if c.Flags.Bitrate != "320" { + c.Flags.Bitrate = "V0" } - return a, true + c.DirEntry = filepath.Clean(a[0]) + return &c, true } diff --git a/flag_test.go b/flag_test.go index b77a71b..c0ba402 100644 --- a/flag_test.go +++ b/flag_test.go @@ -7,9 +7,8 @@ import ( func TestProcessFlagsVersion(t *testing.T) { os.Args = []string{"audioc", "-version"} - defer func() { flags.Version = false }() - if _, cont := processFlags(); cont == true { + if _, cont := configFromFlags(); cont == true { t.Errorf("Expected %v, got %v", false, cont) } } @@ -17,7 +16,7 @@ func TestProcessFlagsVersion(t *testing.T) { func TestProcessFlagsUsage(t *testing.T) { os.Args = []string{"audioc"} - if _, cont := processFlags(); cont == true { + if _, cont := configFromFlags(); cont == true { t.Errorf("Expected %v, got %v", false, cont) } } @@ -25,29 +24,27 @@ func TestProcessFlagsUsage(t *testing.T) { func TestProcessFlagsNoArtistOrCollection(t *testing.T) { os.Args = []string{"audioc", "."} - if _, cont := processFlags(); cont == true { + if _, cont := configFromFlags(); cont == true { t.Errorf("Expected %v, got %v", false, cont) } } func TestProcessFlagsArtist(t *testing.T) { os.Args = []string{"audioc", "--artist", "Grateful Dead", "."} - defer func() { flags.Artist = "" }() - _, cont := processFlags() + c, cont := configFromFlags() if cont == false { t.Errorf("Expected %v, got %v", true, cont) } - if flags.Artist != os.Args[2] { - t.Errorf("Expected %v, got %v", os.Args[2], flags.Artist) + if c.Flags.Artist != os.Args[2] { + t.Errorf("Expected %v, got %v", os.Args[2], c.Flags.Artist) } } func TestProcessFlagsCollection(t *testing.T) { os.Args = []string{"audioc", "--collection", "."} - defer func() { flags.Collection = false }() - _, cont := processFlags() + _, cont := configFromFlags() if cont == false { t.Errorf("Expected %v, got %v", true, cont) }