Files
music-rename/main.go
zomo 73502669ea added diacritics removal
fixed filename not being cleaned
2025-07-01 20:40:28 -05:00

473 lines
9.6 KiB
Go

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
}