469 lines
10 KiB
Go
469 lines
10 KiB
Go
package backend
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
id3v2 "github.com/bogem/id3v2/v2"
|
|
"github.com/go-flac/flacvorbis"
|
|
"github.com/go-flac/go-flac"
|
|
)
|
|
|
|
type FileInfo struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
IsDir bool `json:"is_dir"`
|
|
Size int64 `json:"size"`
|
|
Children []FileInfo `json:"children,omitempty"`
|
|
}
|
|
|
|
type AudioMetadata struct {
|
|
Title string `json:"title"`
|
|
Artist string `json:"artist"`
|
|
Album string `json:"album"`
|
|
AlbumArtist string `json:"album_artist"`
|
|
TrackNumber int `json:"track_number"`
|
|
DiscNumber int `json:"disc_number"`
|
|
Year string `json:"year"`
|
|
}
|
|
|
|
type RenamePreview struct {
|
|
OldPath string `json:"old_path"`
|
|
OldName string `json:"old_name"`
|
|
NewName string `json:"new_name"`
|
|
NewPath string `json:"new_path"`
|
|
Error string `json:"error,omitempty"`
|
|
Metadata AudioMetadata `json:"metadata"`
|
|
}
|
|
|
|
type RenameResult struct {
|
|
OldPath string `json:"old_path"`
|
|
NewPath string `json:"new_path"`
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func ListDirectory(dirPath string) ([]FileInfo, error) {
|
|
entries, err := os.ReadDir(dirPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read directory: %w", err)
|
|
}
|
|
|
|
var result []FileInfo
|
|
for _, entry := range entries {
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
fileInfo := FileInfo{
|
|
Name: entry.Name(),
|
|
Path: filepath.Join(dirPath, entry.Name()),
|
|
IsDir: entry.IsDir(),
|
|
Size: info.Size(),
|
|
}
|
|
|
|
if entry.IsDir() {
|
|
children, err := ListDirectory(fileInfo.Path)
|
|
if err == nil {
|
|
fileInfo.Children = children
|
|
}
|
|
}
|
|
|
|
result = append(result, fileInfo)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func ListAudioFiles(dirPath string) ([]FileInfo, error) {
|
|
var result []FileInfo
|
|
|
|
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" {
|
|
result = append(result, FileInfo{
|
|
Name: info.Name(),
|
|
Path: path,
|
|
IsDir: false,
|
|
Size: info.Size(),
|
|
})
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to walk directory: %w", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
|
|
if !fileExists(filePath) {
|
|
return nil, fmt.Errorf("file does not exist")
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
|
|
switch ext {
|
|
case ".flac":
|
|
return readFlacMetadata(filePath)
|
|
case ".mp3":
|
|
return readMp3Metadata(filePath)
|
|
case ".m4a":
|
|
return readM4aMetadata(filePath)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported file format: %s", ext)
|
|
}
|
|
}
|
|
|
|
func readFlacMetadata(filePath string) (*AudioMetadata, error) {
|
|
f, err := flac.ParseFile(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
|
}
|
|
|
|
metadata := &AudioMetadata{}
|
|
|
|
for _, block := range f.Meta {
|
|
if block.Type == flac.VorbisComment {
|
|
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, comment := range cmt.Comments {
|
|
parts := strings.SplitN(comment, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
|
|
fieldName := strings.ToUpper(parts[0])
|
|
value := parts[1]
|
|
|
|
switch fieldName {
|
|
case "TITLE":
|
|
metadata.Title = value
|
|
case "ARTIST":
|
|
metadata.Artist = value
|
|
case "ALBUM":
|
|
metadata.Album = value
|
|
case "ALBUMARTIST":
|
|
metadata.AlbumArtist = value
|
|
case "TRACKNUMBER":
|
|
if num, err := strconv.Atoi(value); err == nil {
|
|
metadata.TrackNumber = num
|
|
}
|
|
case "DISCNUMBER":
|
|
if num, err := strconv.Atoi(value); err == nil {
|
|
metadata.DiscNumber = num
|
|
}
|
|
case "DATE", "YEAR":
|
|
metadata.Year = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
func readMp3Metadata(filePath string) (*AudioMetadata, error) {
|
|
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open MP3 file: %w", err)
|
|
}
|
|
defer tag.Close()
|
|
|
|
metadata := &AudioMetadata{
|
|
Title: tag.Title(),
|
|
Artist: tag.Artist(),
|
|
Album: tag.Album(),
|
|
Year: tag.Year(),
|
|
}
|
|
|
|
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
|
|
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
|
metadata.AlbumArtist = textFrame.Text
|
|
}
|
|
}
|
|
|
|
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
|
|
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
|
trackStr := strings.Split(textFrame.Text, "/")[0]
|
|
if num, err := strconv.Atoi(trackStr); err == nil {
|
|
metadata.TrackNumber = num
|
|
}
|
|
}
|
|
}
|
|
|
|
if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 {
|
|
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
|
discStr := strings.Split(textFrame.Text, "/")[0]
|
|
if num, err := strconv.Atoi(discStr); err == nil {
|
|
metadata.DiscNumber = num
|
|
}
|
|
}
|
|
}
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
|
ffprobePath, err := GetFFprobePath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := ValidateExecutable(ffprobePath); err != nil {
|
|
return nil, fmt.Errorf("invalid ffprobe executable: %w", err)
|
|
}
|
|
|
|
cmd := exec.Command(ffprobePath,
|
|
"-v", "quiet",
|
|
"-print_format", "json",
|
|
"-show_format",
|
|
"-show_streams",
|
|
filePath,
|
|
)
|
|
|
|
setHideWindow(cmd)
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var result struct {
|
|
Format struct {
|
|
Tags map[string]string `json:"tags"`
|
|
} `json:"format"`
|
|
Streams []struct {
|
|
Tags map[string]string `json:"tags"`
|
|
} `json:"streams"`
|
|
}
|
|
|
|
if err := json.Unmarshal(output, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
metadata := &AudioMetadata{}
|
|
|
|
allTags := make(map[string]string)
|
|
|
|
for _, stream := range result.Streams {
|
|
for key, value := range stream.Tags {
|
|
allTags[strings.ToLower(key)] = value
|
|
}
|
|
}
|
|
|
|
for key, value := range result.Format.Tags {
|
|
allTags[strings.ToLower(key)] = value
|
|
}
|
|
|
|
for key, value := range allTags {
|
|
switch key {
|
|
case "title":
|
|
metadata.Title = value
|
|
case "artist":
|
|
metadata.Artist = value
|
|
case "album":
|
|
metadata.Album = value
|
|
case "album_artist", "albumartist":
|
|
metadata.AlbumArtist = value
|
|
case "track":
|
|
|
|
trackStr := strings.Split(value, "/")[0]
|
|
if num, err := strconv.Atoi(trackStr); err == nil {
|
|
metadata.TrackNumber = num
|
|
}
|
|
case "disc":
|
|
discStr := strings.Split(value, "/")[0]
|
|
if num, err := strconv.Atoi(discStr); err == nil {
|
|
metadata.DiscNumber = num
|
|
}
|
|
case "date", "year":
|
|
if metadata.Year == "" || len(value) > len(metadata.Year) {
|
|
metadata.Year = value
|
|
}
|
|
}
|
|
}
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
|
|
metadata, err := readMetadataWithFFprobe(filePath)
|
|
if err != nil {
|
|
return &AudioMetadata{}, nil
|
|
}
|
|
return metadata, nil
|
|
}
|
|
|
|
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
|
|
if metadata == nil {
|
|
return ""
|
|
}
|
|
|
|
result := format
|
|
|
|
year := metadata.Year
|
|
if len(year) >= 4 {
|
|
year = year[:4]
|
|
}
|
|
|
|
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
|
|
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
|
|
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
|
|
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
|
|
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
|
|
result = strings.ReplaceAll(result, "{date}", sanitizeFilenameForRename(metadata.Year))
|
|
|
|
if metadata.TrackNumber > 0 {
|
|
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
|
|
} else {
|
|
result = strings.ReplaceAll(result, "{track}", "")
|
|
}
|
|
|
|
if metadata.DiscNumber > 0 {
|
|
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
|
|
} else {
|
|
result = strings.ReplaceAll(result, "{disc}", "")
|
|
}
|
|
|
|
result = strings.TrimSpace(result)
|
|
result = strings.Join(strings.Fields(result), " ")
|
|
|
|
result = strings.Trim(result, " -._")
|
|
|
|
if result == "" {
|
|
return ""
|
|
}
|
|
|
|
return result + ext
|
|
}
|
|
|
|
func sanitizeFilenameForRename(name string) string {
|
|
|
|
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
|
|
result := name
|
|
for _, char := range invalid {
|
|
result = strings.ReplaceAll(result, char, "")
|
|
}
|
|
return strings.TrimSpace(result)
|
|
}
|
|
|
|
func PreviewRename(files []string, format string) []RenamePreview {
|
|
var previews []RenamePreview
|
|
|
|
for _, filePath := range files {
|
|
preview := RenamePreview{
|
|
OldPath: filePath,
|
|
OldName: filepath.Base(filePath),
|
|
}
|
|
|
|
metadata, err := ReadAudioMetadata(filePath)
|
|
if err != nil {
|
|
preview.Error = err.Error()
|
|
previews = append(previews, preview)
|
|
continue
|
|
}
|
|
|
|
preview.Metadata = *metadata
|
|
|
|
ext := filepath.Ext(filePath)
|
|
newName := GenerateFilename(metadata, format, ext)
|
|
|
|
if newName == "" {
|
|
preview.Error = "Could not generate filename (missing metadata)"
|
|
previews = append(previews, preview)
|
|
continue
|
|
}
|
|
|
|
preview.NewName = newName
|
|
preview.NewPath = filepath.Join(filepath.Dir(filePath), newName)
|
|
|
|
previews = append(previews, preview)
|
|
}
|
|
|
|
return previews
|
|
}
|
|
|
|
func GetFileSizes(files []string) map[string]int64 {
|
|
result := make(map[string]int64)
|
|
for _, filePath := range files {
|
|
info, err := os.Stat(filePath)
|
|
if err == nil {
|
|
result[filePath] = info.Size()
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func RenameFiles(files []string, format string) []RenameResult {
|
|
var results []RenameResult
|
|
|
|
for _, filePath := range files {
|
|
result := RenameResult{
|
|
OldPath: filePath,
|
|
}
|
|
|
|
metadata, err := ReadAudioMetadata(filePath)
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
result.Success = false
|
|
results = append(results, result)
|
|
continue
|
|
}
|
|
|
|
ext := filepath.Ext(filePath)
|
|
newName := GenerateFilename(metadata, format, ext)
|
|
|
|
if newName == "" {
|
|
result.Error = "Could not generate filename (missing metadata)"
|
|
result.Success = false
|
|
results = append(results, result)
|
|
continue
|
|
}
|
|
|
|
newPath := filepath.Join(filepath.Dir(filePath), newName)
|
|
result.NewPath = newPath
|
|
|
|
if newPath != filePath {
|
|
if _, err := os.Stat(newPath); err == nil {
|
|
result.Error = "File already exists"
|
|
result.Success = false
|
|
results = append(results, result)
|
|
continue
|
|
}
|
|
}
|
|
|
|
if err := os.Rename(filePath, newPath); err != nil {
|
|
result.Error = err.Error()
|
|
result.Success = false
|
|
results = append(results, result)
|
|
continue
|
|
}
|
|
|
|
result.Success = true
|
|
results = append(results, result)
|
|
}
|
|
|
|
return results
|
|
}
|