aboutsummaryrefslogblamecommitdiff
path: root/tools/music-organizer/main.go
blob: 253afef4c0f5456c6a0290dd63a1568c697c1caa (plain) (tree)
























                                                                                 
                                                                                                                 







                                                                  

                                              

































                                                                               
                                                        




























                                                                       
                                                                














                                                                             




                                                                                 
                                                                         
                                                               











































































































































                                                                                                                                  
                                                            






                                                                                   
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)
		}
	}
}