516 lines
10 KiB
Go
516 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"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")
|
|
}
|
|
|
|
if !testEnvFilenameLimit() {
|
|
return errors.New("unable to parse FILENAME_LIMIT")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func testEnvFilenameLimit() bool {
|
|
limit, ok := os.LookupEnv("FILENAME_LIMIT")
|
|
if !ok {
|
|
return true
|
|
}
|
|
_, err := strconv.Atoi(limit)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func getEnvFilenameLimit() int {
|
|
limit, ok := os.LookupEnv("FILENAME_LIMIT")
|
|
if !ok {
|
|
return 255
|
|
}
|
|
limit_i, err := strconv.Atoi(limit)
|
|
if err != nil {
|
|
return 255
|
|
}
|
|
return limit_i
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
filenameLimit := getEnvFilenameLimit() - len(fileExt)
|
|
filename := bufFile.String()
|
|
if len(filename) > filenameLimit {
|
|
filename = filename[:filenameLimit]
|
|
}
|
|
|
|
libFile := path.Join(libRoot, bufPath.String(), filename + 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(r rune) rune {
|
|
switch r {
|
|
case '<', '>', ':', '\'', '"', '|', '?', '*':
|
|
return '_'
|
|
}
|
|
return r
|
|
})
|
|
}
|
|
|
|
func cleanTemplateVariable(str string) string {
|
|
return doClean(str, func(r rune) rune {
|
|
switch r {
|
|
case '<', '>', ':', '\'', '"', '|', '?', '*', '/', '\\', '.':
|
|
return '_'
|
|
}
|
|
return r
|
|
})
|
|
}
|
|
|
|
func doClean(str string, mapping func(rune) rune) string {
|
|
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC, runes.Map(mapping))
|
|
result, _, err := transform.String(t, str)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return result
|
|
}
|