about summary refs log tree commit diff
diff options
context:
space:
mode:
authorFranck Cuny <franck@fcuny.net>2022-06-18 14:41:47 -0700
committerFranck Cuny <franck@fcuny.net>2022-06-18 14:43:39 -0700
commit4b3f9bf48ad2d0dd959bb68e0728a9a9bc625568 (patch)
tree335918a26d056583e74f59cc388d78084f700f35
parentfeat(tools/git-blame-stats): add the CLI with default git configuration (diff)
downloadworld-4b3f9bf48ad2d0dd959bb68e0728a9a9bc625568.tar.gz
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 <franck@fcuny.net>
-rw-r--r--tools/music-organizer/README.org21
-rw-r--r--tools/music-organizer/default.nix15
-rw-r--r--tools/music-organizer/go.mod5
-rw-r--r--tools/music-organizer/go.sum4
-rw-r--r--tools/music-organizer/main.go273
5 files changed, 318 insertions, 0 deletions
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)
+		}
+	}
+}