package main import ( "archive/zip" "crypto/md5" "encoding/hex" "flag" "fmt" "io" "io/ioutil" "log" "os" "path/filepath" "strings" "github.com/dhowden/tag" ) const ( // the max lenght for a track can only be 255 characters minus 3 for the // track number (followed by a space), and 4 for the format. The limit of // 255 is coming from HFS+. TrackTitleMaxLenght = 255 - 3 - 4 ) var ( musicDest = flag.String("dest", fmt.Sprintf("%s/media/music", os.Getenv("HOME")), "where to store the music") ) // replace slashes with dashes func stripSlash(s string) string { return strings.ReplaceAll(s, "/", "-") } // return the name of the artist, album and the title of the track // the title of the track has the following format: // {track #} {track title}.{track format} func generatePath(m tag.Metadata) (string, string, string) { var artist, album, title string var track int // if there's no artist, let's fallback to "Unknown Artists" if len(m.Artist()) == 0 { artist = "Unknown Artists" } else { artist = stripSlash(m.Artist()) } // if there's no album name, let's fallback to "Unknown Album" if len(m.Album()) == 0 { album = "Unknown Album" } else { album = stripSlash(m.Album()) } track, _ = m.Track() // ok, there must be a better way format := strings.ToLower(string(m.FileType())) title = fmt.Sprintf("%02d %s.%s", track, stripSlash(m.Title()), format) if len(title) > TrackTitleMaxLenght { r := []rune(title) title = string(r[0:255]) } return artist, album, title } // create all the required directories. if we fail to create one, we die func makeParents(path string) error { if err := os.MkdirAll(path, 0777); err != nil { return fmt.Errorf("failed to create %s: %v", path, err) } return nil } func md5sum(path string) (string, error) { var sum string f, err := os.Open(path) if err != nil { return sum, err } defer f.Close() h := md5.New() if _, err := io.Copy(h, f); err != nil { return sum, err } sum = hex.EncodeToString(h.Sum(nil)[:16]) return sum, nil } func makeCopy(src, dst string) error { f, err := os.Open(src) if err != nil { return err } defer f.Close() t, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE, 0666) if err != nil { return err } defer t.Close() _, err = io.Copy(t, f) if err != nil { return err } log.Printf("copied %s → %s\n", src, dst) return nil } // ensure the file is named correctly and is moved to the correct destination // before we can do that, we need to: // 1. check if the track already exists, if it does, does it have the same md5 ? // if they are similar, we skip them. if they are not, we log and don't do // anything // 2. we can move the file to the destination // 3. we can delete the original file func renameFile(originalPath string, artist, album, title string) error { var directories = filepath.Join(*musicDest, artist, album) destination := filepath.Join(directories, title) // check if the file is present _, err := os.Stat(destination) if err == nil { var originalSum, destinationSum string if originalSum, err = md5sum(originalPath); err != nil { return err } if destinationSum, err = md5sum(destination); err != nil { return err } if destinationSum != originalSum { log.Printf("md5 sum are different: %s(%s) %s(%s)", originalPath, originalSum, destination, destinationSum) } return nil } if err := makeParents(directories); err != nil { return err } if err := makeCopy(originalPath, destination); err != nil { return err } // TODO delete original file // os.Remove(originalPath) return nil } // we try to open any files and read the metadata. // if the file has metadata we can read, we will try to move the file to the // correct destination func processFile(path string) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() m, err := tag.ReadFrom(f) if err != nil { // this is fine, this might not be a music file log.Printf("SKIP failed to read tags from %s: %v", path, err) return nil } var artist, album, title string artist, album, title = generatePath(m) if err := renameFile(path, artist, album, title); err != nil { return fmt.Errorf("failed to move %s: %v", path, err) } return nil } func processPath(path string, f os.FileInfo, err error) error { if stat, err := os.Stat(path); err == nil && !stat.IsDir() { if err := processFile(path); err != nil { return err } } return nil } // unzip takes two paths, a source and destination. The source is the // name of the archive and we will extract the content into the // destination directory. The destination directory has to already // exists, we are not going to create it here or delete it at the end. func unzip(src, dst string) error { r, err := zip.OpenReader(src) if err != nil { return err } defer r.Close() for _, f := range r.File { fpath := filepath.Join(dst, f.Name) outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { return err } rc, err := f.Open() if err != nil { return err } _, err = io.Copy(outFile, rc) if err != nil { log.Printf("failed to copy %s: %s", outFile.Name(), err) } outFile.Close() rc.Close() } return nil } func main() { flag.Parse() if *musicDest == "" { log.Fatal("-dest is required") } paths := make([]string, flag.NArg()) // For our temp directory, we use what ever the value of // XDG_RUNTIME_DIR is. If the value is unset, we will default to // the system default temp directory. tmpDir := os.Getenv("XDG_RUNTIME_DIR") for i, d := range flag.Args() { if filepath.Ext(d) == ".zip" { // If we have an extension and it's '.zip', we consider the // path to be an archive. In this case we want to create a new // temporary directory and extract the content of the archive // in that path. The temporary directory is removed once we're // done. out, err := ioutil.TempDir(tmpDir, "music-organizer") if err != nil { log.Printf("failed to create a temp directory to extract %s: %v", d, err) continue } defer os.RemoveAll(out) if err := unzip(d, out); err != nil { log.Printf("failed to extract %s: %v", d, err) continue } paths[i] = out } else { paths[i] = d } } for _, d := range paths { //XXX deal with filenames that are too long // scan the directory and try to find any file that we want to move err := filepath.Walk(d, processPath) if err != nil { log.Fatalf("error while processing files: %v", err) } } }