From 4b3f9bf48ad2d0dd959bb68e0728a9a9bc625568 Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Sat, 18 Jun 2022 14:41:47 -0700 Subject: feat(tools/music-organizer): add a CLI to organize my music This CLI can be used on the NAS to import an album in my music collection. Change-Id: I5749e34b55bead846e9341fca29e648d3859fc8f Reviewed-on: https://cl.fcuny.net/c/world/+/448 Tested-by: CI Reviewed-by: Franck Cuny --- tools/music-organizer/README.org | 21 +++ tools/music-organizer/default.nix | 15 +++ tools/music-organizer/go.mod | 5 + tools/music-organizer/go.sum | 4 + tools/music-organizer/main.go | 273 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 318 insertions(+) create mode 100644 tools/music-organizer/README.org create mode 100644 tools/music-organizer/default.nix create mode 100644 tools/music-organizer/go.mod create mode 100644 tools/music-organizer/go.sum create mode 100644 tools/music-organizer/main.go (limited to 'tools/music-organizer') diff --git a/tools/music-organizer/README.org b/tools/music-organizer/README.org new file mode 100644 index 0000000..a42a196 --- /dev/null +++ b/tools/music-organizer/README.org @@ -0,0 +1,21 @@ +#+TITLE: music organizer + +the tool takes a couple of arguments: +- ~-dest~: where will the music be stored +- a list of directories to scan + +all files that have tags that can be read will be processed and moved to the specified destination. + +files are organized like this: ={artist}/{album}/{track number} {track title}.{track format}= + +the tool ensures that files are not already present in the destination. if there's already a file with the same name, it checks that the md5 sum of the files are identical. if they are not, it logs a message. + +* build +#+BEGIN_SRC sh +go build +#+END_SRC + +* install +#+BEGIN_SRC sh +go install +#+END_SRC diff --git a/tools/music-organizer/default.nix b/tools/music-organizer/default.nix new file mode 100644 index 0000000..1242e34 --- /dev/null +++ b/tools/music-organizer/default.nix @@ -0,0 +1,15 @@ +{ pkgs, ... }: + +pkgs.buildGoModule rec { + name = "music-organizer"; + src = ./.; + vendorSha256 = "sha256-pQpattmS9VmO3ZIQUFn66az8GSmB4IvYhTTCFn6SUmo="; + nativeBuildInputs = with pkgs; [ go ]; + + meta = with pkgs.lib; { + description = "CLI to organize my music in folders."; + license = licenses.mit; + platforms = platforms.linux; + maintainers = [ ]; + }; +} diff --git a/tools/music-organizer/go.mod b/tools/music-organizer/go.mod new file mode 100644 index 0000000..ba9a1b8 --- /dev/null +++ b/tools/music-organizer/go.mod @@ -0,0 +1,5 @@ +module golang.fcuny.org/music-organizer + +go 1.17 + +require github.com/dhowden/tag v0.0.0-20220617232555-e66a190c9f5b diff --git a/tools/music-organizer/go.sum b/tools/music-organizer/go.sum new file mode 100644 index 0000000..3383f0e --- /dev/null +++ b/tools/music-organizer/go.sum @@ -0,0 +1,4 @@ +github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131/go.mod h1:eVWQJVQ67aMvYhpkDwaH2Goy2vo6v8JCMfGXfQ9sPtw= +github.com/dhowden/plist v0.0.0-20141002110153-5db6e0d9931a/go.mod h1:sLjdR6uwx3L6/Py8F+QgAfeiuY87xuYGwCDqRFrvCzw= +github.com/dhowden/tag v0.0.0-20220617232555-e66a190c9f5b h1:TG8R5ZZgd1Sj7iFWnkk5dNy94RG8fP8M4l24UYR8/HY= +github.com/dhowden/tag v0.0.0-20220617232555-e66a190c9f5b/go.mod h1:Z3Lomva4pyMWYezjMAU5QWRh0p1VvO4199OHlFnyKkM= diff --git a/tools/music-organizer/main.go b/tools/music-organizer/main.go new file mode 100644 index 0000000..30baa26 --- /dev/null +++ b/tools/music-organizer/main.go @@ -0,0 +1,273 @@ +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) + } + } +} -- cgit 1.4.1