package main import ( "bytes" "encoding/json" "errors" "fmt" "io" "log" "os" "path" "regexp" "strings" "text/template" "time" "unicode" "slices" "golang.org/x/text/runes" "golang.org/x/text/transform" "golang.org/x/text/unicode/norm" "github.com/dhowden/tag" "github.com/joho/godotenv" ) type DownloadDirectoryComplete struct { Version int `json:"version"` LocalDirectoryName string `json:"localDirectoryName"` RemoteDirectoryName string `json:"remoteDirectoryName"` Username string `json:"username"` } var printLogs = true func (d *DownloadDirectoryComplete) WarnVersion() { supportedVersions := []int{ 0 } if !slices.Contains(supportedVersions, d.Version) { supportedVersionsStr := strings.Trim(strings.Join(strings.Fields(fmt.Sprint(supportedVersions)), ","), "[]") log.Printf("WARNING: Given data version not supported. Supported versions: %s. Given version: %d. Continuing, but there may be demons\n", supportedVersionsStr, d.Version) } } func main() { err := mainErr() if err != nil { log.Fatal("ERROR: ", err) } } func mainErr() error { if _, err := os.Stat(".env"); err == nil { if err := godotenv.Load(".env"); err != nil { return err } } execPath, _ := path.Split(os.Args[0]) localConfPath := path.Join(execPath, "music-rename.env") if _, err := os.Stat(localConfPath); err == nil { if err := godotenv.Load(localConfPath); err != nil { return err } } directory, err := getDir() if err != nil { return err } return mainPath(directory) } func getDir() (string, error) { if len(os.Args) > 1 { return os.Args[1], nil } else { dataArg, found := os.LookupEnv("SLSKD_SCRIPT_DATA") if !found { return "", errors.New("missing any reference to a path") } var data DownloadDirectoryComplete if err := json.Unmarshal([]byte(dataArg), &data); err != nil { return "", err } data.WarnVersion() printLogs = false return data.LocalDirectoryName, nil } } func mainPath(directory string) error { if err := testEnv(); err != nil { return err } files, err := os.ReadDir(directory) if err != nil { return err } if len(files) == 0 { return errors.New("no files found") } // read tags tags, _ := getTagsInDir(directory) // move files if tags != nil { if printLogs { log.Println("found tags, moving based on tags") } err := doMoveTags(directory, tags) if err != nil { return err } } else { if printLogs { log.Println("no tags found, moving to notags folder") } err := doMoveNotags(directory) if err != nil { return err } } return nil } func testEnv() error { _, ok := os.LookupEnv("LIBRARY_ROOT") if !ok { return errors.New("missing LIBRARY_ROOT") } _, ok = os.LookupEnv("LIBRARY_FILETEMPLATE_PATH") if !ok { return errors.New("missing LIBRARY_FILETEMPLATE_PATH") } _, ok = os.LookupEnv("LIBRARY_FILETEMPLATE_FILE") if !ok { return errors.New("missing LIBRARY_FILETEMPLATE_FILE") } _, ok = os.LookupEnv("LIBRARY_NOTAGS_ROOT") if !ok { return errors.New("missing LIBRARY_NOTAGS_ROOT") } return nil } func getTagsInDir(directory string) (tag.Metadata, error) { files, err := os.ReadDir(directory) if err != nil { return nil, err } missingTags := false for _, file := range files { fname := path.Join(directory, file.Name()) if file.IsDir() { continue } file, err := os.Open(fname) if err != nil { continue } defer file.Close() tags, err := tag.ReadFrom(file) if err != nil { continue } // if we found tags but they're incomplete, switch the flag if !checkTags(tags) { missingTags = true continue } // found tag data return tags, nil } // no tag data in any file if missingTags { return nil, errors.New("files have incomplete tag data") } else { return nil, errors.New("unable to read tag data") } } func checkTags(tags tag.Metadata) bool { // most important tags if tags.Album() == "" || tags.AlbumArtist() == "" || tags.Artist() == "" || tags.Title() == "" { return false } return true } func doMoveNotags(directory string) error { newRoot := os.Getenv("LIBRARY_NOTAGS_ROOT") _, folderName := path.Split(directory) toDirectory := path.Join(newRoot, folderName) return move(directory, toDirectory) } func doMoveTags(directory string, albumTags tag.Metadata) error { // move all tagged files files, err := os.ReadDir(directory) if err != nil { return err } for _, file := range files { if file.IsDir() { continue } if file.Name()[0] == '.' { continue } fname := path.Join(directory, file.Name()) file, err := os.Open(fname) if err != nil { continue } defer file.Close() tags, err := tag.ReadFrom(file) if err != nil { continue } err = doMoveTagged(fname, tags) if err != nil { return err } } // move the rest files, err = os.ReadDir(directory) if err != nil { return err } for _, file := range files { if file.Name()[0] == '.' { continue } fname := path.Join(directory, file.Name()) err = doMoveTaggedPathonly(fname, albumTags) if err != nil { return err } } // delete folder files, err = os.ReadDir(directory) if err != nil { return err } if len(files) == 0 { return os.Remove(directory) } // some files remain, wait a second to see if they go away time.Sleep(1 * time.Second) files, err = os.ReadDir(directory) if err != nil { return err } if len(files) == 0 { return os.Remove(directory) } return nil } type LibTemplatePath struct { Album string AlbumArtist string Genre string Year int } type LibTemplateFile struct { Title string Album string Artist string AlbumArtist string Composer string Genre string Year int Track int TrackCount int Disc int DiscCount int } func doMoveTagged(filePath string, fileTags tag.Metadata) error { libRoot := os.Getenv("LIBRARY_ROOT") libTemplatePath := os.Getenv("LIBRARY_FILETEMPLATE_PATH") libTemplateFile := os.Getenv("LIBRARY_FILETEMPLATE_FILE") fileExt := path.Ext(filePath) track, trackCount := fileTags.Track() disc, discCount := fileTags.Disc() tmplPath := template.Must(template.New("libPath").Parse(libTemplatePath)) tmplFile := template.Must(template.New("libFile").Parse(libTemplateFile)) tmplDataFile := LibTemplateFile{ Title: cleanTemplateVariable(fileTags.Title()), Album: cleanTemplateVariable(fileTags.Album()), Artist: cleanTemplateVariable(fileTags.Artist()), AlbumArtist: cleanTemplateVariable(fileTags.AlbumArtist()), Composer: cleanTemplateVariable(fileTags.Composer()), Genre: cleanTemplateVariable(fileTags.Genre()), Year: fileTags.Year(), Track: track, TrackCount: trackCount, Disc: disc, DiscCount: discCount, } tmplDataPath := LibTemplatePath{ Album: cleanTemplateVariable(fileTags.Album()), AlbumArtist: cleanTemplateVariable(fileTags.AlbumArtist()), Genre: cleanTemplateVariable(fileTags.Genre()), Year: fileTags.Year(), } bufPath := bytes.NewBufferString("") err := tmplPath.Execute(bufPath, tmplDataPath) if err != nil { return err } bufFile := bytes.NewBufferString("") err = tmplFile.Execute(bufFile, tmplDataFile) if err != nil { return err } libFile := path.Join(libRoot, bufPath.String(), bufFile.String() + fileExt) return move(filePath, libFile) } func doMoveTaggedPathonly(filePath string, albumTags tag.Metadata) error { libRoot := os.Getenv("LIBRARY_ROOT") libTemplatePath := os.Getenv("LIBRARY_FILETEMPLATE_PATH") _, fileName := path.Split(filePath) tmplPath := template.Must(template.New("libPath").Parse(libTemplatePath)) tmplData := LibTemplatePath{ Album: cleanTemplateVariable(albumTags.Album()), AlbumArtist: cleanTemplateVariable(albumTags.AlbumArtist()), Genre: cleanTemplateVariable(albumTags.Genre()), Year: albumTags.Year(), } bufPath := bytes.NewBufferString("") err := tmplPath.Execute(bufPath, tmplData) if err != nil { return err } libFile := path.Join(libRoot, bufPath.String(), fileName) return move(filePath, libFile) } func ensureDirectoryPath(filePath string) error { directoryPath, _ := path.Split(filePath) return os.MkdirAll(directoryPath, 0777) } func move(from, to string) error { to = cleanPath(to) if _, err := os.Stat(to); err == nil { return fmt.Errorf("file already exists: %s", to) } if err := ensureDirectoryPath(to); err != nil { return err } if printLogs { log.Printf("moving \"%s\" => \"%s\"\n", from, to) } // os.Rename gives error "invalid cross-device link" if err := os.Rename(from, to); err != nil { return moveFile(from, to) } return nil } func moveFile(source, destination string) (err error) { src, err := os.Open(source) if err != nil { return err } defer src.Close() fi, err := src.Stat() if err != nil { return err } flag := os.O_WRONLY | os.O_CREATE | os.O_TRUNC perm := fi.Mode() & os.ModePerm dst, err := os.OpenFile(destination, flag, perm) if err != nil { return err } defer dst.Close() _, err = io.Copy(dst, src) if err != nil { dst.Close() os.Remove(destination) return err } err = dst.Close() if err != nil { return err } err = src.Close() if err != nil { return err } err = os.Remove(source) if err != nil { return err } return nil } func cleanPath(str string) string { return doClean(str, `[<>:"|?*]`) } func cleanTemplateVariable(str string) string { return doClean(str, `[<>:"|?*/\\.]`) } func doClean(str string, reg string) string { re := regexp.MustCompile(reg) s := re.ReplaceAllString(str, "") t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) result, _, err := transform.String(t, s) if err != nil { panic(err) } return result }