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