aboutsummaryrefslogtreecommitdiff
path: root/packages/music-organizer/main.go
diff options
context:
space:
mode:
authorFranck Cuny <franck@fcuny.net>2024-03-06 06:29:24 -0800
committerFranck Cuny <franck@fcuny.net>2024-03-06 06:29:24 -0800
commit1e4a5aa09c1c8f43722c9c260f011398799a8e8f (patch)
treecd73e0fb8ba53bd21cee6ccf2dcc85639bbbb93f /packages/music-organizer/main.go
parentset correct git email in the profiles (diff)
downloadinfra-1e4a5aa09c1c8f43722c9c260f011398799a8e8f.tar.gz
rename `tools` to `packages` to follow convention
The convention is to use `pkgs` or `packages` for overlays and definition of custom packages. Since I'm already using `pkg` for go, I prefer to use `packages` for my scripts.
Diffstat (limited to 'packages/music-organizer/main.go')
-rw-r--r--packages/music-organizer/main.go271
1 files changed, 271 insertions, 0 deletions
diff --git a/packages/music-organizer/main.go b/packages/music-organizer/main.go
new file mode 100644
index 0000000..253afef
--- /dev/null
+++ b/packages/music-organizer/main.go
@@ -0,0 +1,271 @@
+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, 0o777); 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, 0o666)
+ 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 {
+ 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)
+ }
+ }
+}