v6.9
This commit is contained in:
@@ -7,7 +7,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: '1.25.4'
|
GO_VERSION: '1.25.5'
|
||||||
NODE_VERSION: '24'
|
NODE_VERSION: '24'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -140,8 +140,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
|||||||
if req.OutputDir == "" {
|
if req.OutputDir == "" {
|
||||||
req.OutputDir = "."
|
req.OutputDir = "."
|
||||||
} else {
|
} else {
|
||||||
// Sanitize output directory path to remove invalid characters
|
// Only normalize path separators, don't sanitize user's existing folder names
|
||||||
req.OutputDir = backend.SanitizeFolderPath(req.OutputDir)
|
req.OutputDir = backend.NormalizePath(req.OutputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.AudioFormat == "" {
|
if req.AudioFormat == "" {
|
||||||
@@ -661,6 +661,7 @@ type ConvertAudioRequest struct {
|
|||||||
InputFiles []string `json:"input_files"`
|
InputFiles []string `json:"input_files"`
|
||||||
OutputFormat string `json:"output_format"`
|
OutputFormat string `json:"output_format"`
|
||||||
Bitrate string `json:"bitrate"`
|
Bitrate string `json:"bitrate"`
|
||||||
|
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertAudio converts audio files using ffmpeg
|
// ConvertAudio converts audio files using ffmpeg
|
||||||
@@ -669,6 +670,7 @@ func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResul
|
|||||||
InputFiles: req.InputFiles,
|
InputFiles: req.InputFiles,
|
||||||
OutputFormat: req.OutputFormat,
|
OutputFormat: req.OutputFormat,
|
||||||
Bitrate: req.Bitrate,
|
Bitrate: req.Bitrate,
|
||||||
|
Codec: req.Codec,
|
||||||
}
|
}
|
||||||
return backend.ConvertAudio(backendReq)
|
return backend.ConvertAudio(backendReq)
|
||||||
}
|
}
|
||||||
@@ -681,3 +683,74 @@ func (a *App) SelectAudioFiles() ([]string, error) {
|
|||||||
}
|
}
|
||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListDirectoryFiles lists files and folders in a directory
|
||||||
|
func (a *App) ListDirectoryFiles(dirPath string) ([]backend.FileInfo, error) {
|
||||||
|
if dirPath == "" {
|
||||||
|
return nil, fmt.Errorf("directory path is required")
|
||||||
|
}
|
||||||
|
return backend.ListDirectory(dirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAudioFilesInDir lists only audio files in a directory recursively
|
||||||
|
func (a *App) ListAudioFilesInDir(dirPath string) ([]backend.FileInfo, error) {
|
||||||
|
if dirPath == "" {
|
||||||
|
return nil, fmt.Errorf("directory path is required")
|
||||||
|
}
|
||||||
|
return backend.ListAudioFiles(dirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFileMetadata reads metadata from an audio file
|
||||||
|
func (a *App) ReadFileMetadata(filePath string) (*backend.AudioMetadata, error) {
|
||||||
|
if filePath == "" {
|
||||||
|
return nil, fmt.Errorf("file path is required")
|
||||||
|
}
|
||||||
|
return backend.ReadAudioMetadata(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewRenameFiles generates a preview of rename operations
|
||||||
|
func (a *App) PreviewRenameFiles(files []string, format string) []backend.RenamePreview {
|
||||||
|
return backend.PreviewRename(files, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameFilesByMetadata renames files based on their metadata
|
||||||
|
func (a *App) RenameFilesByMetadata(files []string, format string) []backend.RenameResult {
|
||||||
|
return backend.RenameFiles(files, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckFileExistenceRequest represents a track to check for existence
|
||||||
|
type CheckFileExistenceRequest struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
TrackName string `json:"track_name"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckFilesExistence checks if multiple files already exist in the output directory
|
||||||
|
// This is done in parallel for better performance
|
||||||
|
func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceRequest) []backend.FileExistenceResult {
|
||||||
|
// Convert to backend struct format
|
||||||
|
backendTracks := make([]struct {
|
||||||
|
ISRC string
|
||||||
|
TrackName string
|
||||||
|
ArtistName string
|
||||||
|
}, len(tracks))
|
||||||
|
|
||||||
|
for i, t := range tracks {
|
||||||
|
backendTracks[i] = struct {
|
||||||
|
ISRC string
|
||||||
|
TrackName string
|
||||||
|
ArtistName string
|
||||||
|
}{
|
||||||
|
ISRC: t.ISRC,
|
||||||
|
TrackName: t.TrackName,
|
||||||
|
ArtistName: t.ArtistName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return backend.CheckFilesExistParallel(outputDir, backendTracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkipDownloadItem marks a download item as skipped (file already exists)
|
||||||
|
func (a *App) SkipDownloadItem(itemID, filePath string) {
|
||||||
|
backend.SkipDownloadItem(itemID, filePath)
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -161,7 +161,7 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
|
|||||||
if outputDir == "" {
|
if outputDir == "" {
|
||||||
outputDir = GetDefaultMusicPath()
|
outputDir = GetDefaultMusicPath()
|
||||||
} else {
|
} else {
|
||||||
outputDir = SanitizeFolderPath(outputDir)
|
outputDir = NormalizePath(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
|||||||
+18
-2
@@ -273,7 +273,8 @@ func extractTarXz(tarXzPath, destDir string) error {
|
|||||||
type ConvertAudioRequest struct {
|
type ConvertAudioRequest struct {
|
||||||
InputFiles []string `json:"input_files"`
|
InputFiles []string `json:"input_files"`
|
||||||
OutputFormat string `json:"output_format"` // mp3, m4a
|
OutputFormat string `json:"output_format"` // mp3, m4a
|
||||||
Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k"
|
Bitrate string `json:"bitrate"` // e.g., "320k", "256k", "192k", "128k" (ignored for ALAC)
|
||||||
|
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless). Default: "aac"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertAudioResult represents the result of a single file conversion
|
// ConvertAudioResult represents the result of a single file conversion
|
||||||
@@ -378,12 +379,28 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
|||||||
// Map video stream if exists (for cover art)
|
// Map video stream if exists (for cover art)
|
||||||
args = append(args, "-map", "0:v?", "-c:v", "copy")
|
args = append(args, "-map", "0:v?", "-c:v", "copy")
|
||||||
case "m4a":
|
case "m4a":
|
||||||
|
// Determine codec: ALAC (lossless) or AAC (lossy)
|
||||||
|
codec := req.Codec
|
||||||
|
if codec == "" {
|
||||||
|
codec = "aac" // Default to AAC for backward compatibility
|
||||||
|
}
|
||||||
|
|
||||||
|
if codec == "alac" {
|
||||||
|
// ALAC - Apple Lossless (no bitrate needed)
|
||||||
|
args = append(args,
|
||||||
|
"-codec:a", "alac",
|
||||||
|
"-map", "0:a", // Map audio stream
|
||||||
|
"-map_metadata", "0", // Copy all metadata
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// AAC - lossy with bitrate
|
||||||
args = append(args,
|
args = append(args,
|
||||||
"-codec:a", "aac",
|
"-codec:a", "aac",
|
||||||
"-b:a", req.Bitrate,
|
"-b:a", req.Bitrate,
|
||||||
"-map", "0:a", // Map audio stream
|
"-map", "0:a", // Map audio stream
|
||||||
"-map_metadata", "0", // Copy all metadata
|
"-map_metadata", "0", // Copy all metadata
|
||||||
)
|
)
|
||||||
|
}
|
||||||
// Map video stream for cover art in M4A
|
// Map video stream for cover art in M4A
|
||||||
args = append(args, "-map", "0:v?", "-c:v", "copy", "-disposition:v:0", "attached_pic")
|
args = append(args, "-map", "0:v?", "-c:v", "copy", "-disposition:v:0", "attached_pic")
|
||||||
}
|
}
|
||||||
@@ -554,4 +571,3 @@ func InstallFFmpegFromFile(filePath string) error {
|
|||||||
fmt.Printf("[FFmpeg] Successfully installed from: %s\n", filePath)
|
fmt.Printf("[FFmpeg] Successfully installed from: %s\n", filePath)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
id3v2 "github.com/bogem/id3v2/v2"
|
||||||
|
"github.com/go-flac/flacvorbis"
|
||||||
|
"github.com/go-flac/go-flac"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileInfo represents information about a file or folder
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AudioMetadata represents metadata read from an audio file
|
||||||
|
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"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenamePreview represents a preview of file rename operation
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameResult represents the result of a rename operation
|
||||||
|
type RenameResult struct {
|
||||||
|
OldPath string `json:"old_path"`
|
||||||
|
NewPath string `json:"new_path"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDirectory lists files and folders in a directory
|
||||||
|
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 it's a directory, recursively list its contents
|
||||||
|
if entry.IsDir() {
|
||||||
|
children, err := ListDirectory(fileInfo.Path)
|
||||||
|
if err == nil {
|
||||||
|
fileInfo.Children = children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, fileInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAudioFiles lists only audio files (flac, mp3, m4a) in a directory recursively
|
||||||
|
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 // Skip files with errors
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAudioMetadata reads metadata from an audio file
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFlacMetadata reads metadata from a FLAC file
|
||||||
|
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
|
||||||
|
case "ISRC":
|
||||||
|
metadata.ISRC = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMp3Metadata reads metadata from an MP3 file
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Album Artist (TPE2)
|
||||||
|
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
|
||||||
|
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||||
|
metadata.AlbumArtist = textFrame.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Track Number
|
||||||
|
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
|
||||||
|
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||||
|
// Format might be "4" or "4/12"
|
||||||
|
trackStr := strings.Split(textFrame.Text, "/")[0]
|
||||||
|
if num, err := strconv.Atoi(trackStr); err == nil {
|
||||||
|
metadata.TrackNumber = num
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Disc Number
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ISRC (TSRC)
|
||||||
|
if frames := tag.GetFrames("TSRC"); len(frames) > 0 {
|
||||||
|
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||||
|
metadata.ISRC = textFrame.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readM4aMetadata reads metadata from an M4A file
|
||||||
|
func readM4aMetadata(_ string) (*AudioMetadata, error) {
|
||||||
|
// For M4A, we'll use a simpler approach - just return empty metadata
|
||||||
|
// Full M4A metadata reading would require additional libraries
|
||||||
|
return &AudioMetadata{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateFilename generates a new filename based on metadata and format template
|
||||||
|
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
|
||||||
|
if metadata == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
result := format
|
||||||
|
|
||||||
|
// Replace placeholders
|
||||||
|
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(metadata.Year))
|
||||||
|
|
||||||
|
// Track number with padding
|
||||||
|
if metadata.TrackNumber > 0 {
|
||||||
|
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
|
||||||
|
} else {
|
||||||
|
result = strings.ReplaceAll(result, "{track}", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disc number
|
||||||
|
if metadata.DiscNumber > 0 {
|
||||||
|
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
|
||||||
|
} else {
|
||||||
|
result = strings.ReplaceAll(result, "{disc}", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up multiple spaces and trim
|
||||||
|
result = strings.TrimSpace(result)
|
||||||
|
result = strings.Join(strings.Fields(result), " ")
|
||||||
|
|
||||||
|
// Remove leading/trailing separators
|
||||||
|
result = strings.Trim(result, " -._")
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return result + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeFilenameForRename removes invalid characters from filename (for rename operations)
|
||||||
|
func sanitizeFilenameForRename(name string) string {
|
||||||
|
// Remove characters that are invalid in filenames
|
||||||
|
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
|
||||||
|
result := name
|
||||||
|
for _, char := range invalid {
|
||||||
|
result = strings.ReplaceAll(result, char, "")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewRename generates a preview of rename operations
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameFiles renames files based on their metadata
|
||||||
|
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
|
||||||
|
|
||||||
|
// Check if new path already exists (and is different from old path)
|
||||||
|
if newPath != filePath {
|
||||||
|
if _, err := os.Stat(newPath); err == nil {
|
||||||
|
result.Error = "File already exists"
|
||||||
|
result.Success = false
|
||||||
|
results = append(results, result)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename the file
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -121,7 +121,15 @@ func sanitizeFilename(name string) string {
|
|||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizePath only normalizes path separators without modifying folder names
|
||||||
|
// Use this for user-provided paths that already exist on the filesystem
|
||||||
|
func NormalizePath(folderPath string) string {
|
||||||
|
// Normalize all forward slashes to backslashes on Windows
|
||||||
|
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|
||||||
// SanitizeFolderPath sanitizes each component of a folder path and normalizes separators
|
// SanitizeFolderPath sanitizes each component of a folder path and normalizes separators
|
||||||
|
// Use this only for NEW folders being created (artist names, album names, etc.)
|
||||||
func SanitizeFolderPath(folderPath string) string {
|
func SanitizeFolderPath(folderPath string) string {
|
||||||
// Normalize all forward slashes to backslashes on Windows
|
// Normalize all forward slashes to backslashes on Windows
|
||||||
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||||
|
|||||||
+1
-2
@@ -270,7 +270,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
return nil, "", fmt.Errorf("lyrics not found in any source")
|
return nil, "", fmt.Errorf("lyrics not found in any source")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ConvertToLRC converts lyrics response to LRC format
|
// ConvertToLRC converts lyrics response to LRC format
|
||||||
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
|
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
@@ -364,7 +363,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
|||||||
if outputDir == "" {
|
if outputDir == "" {
|
||||||
outputDir = GetDefaultMusicPath()
|
outputDir = GetDefaultMusicPath()
|
||||||
} else {
|
} else {
|
||||||
outputDir = SanitizeFolderPath(outputDir)
|
outputDir = NormalizePath(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
pathfilepath "path/filepath"
|
pathfilepath "path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
id3v2 "github.com/bogem/id3v2/v2"
|
id3v2 "github.com/bogem/id3v2/v2"
|
||||||
"github.com/go-flac/flacpicture"
|
"github.com/go-flac/flacpicture"
|
||||||
@@ -600,3 +601,86 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
|
|||||||
return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext)
|
return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileExistenceResult represents the result of checking if a file exists
|
||||||
|
type FileExistenceResult struct {
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Exists bool `json:"exists"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
TrackName string `json:"track_name,omitempty"`
|
||||||
|
ArtistName string `json:"artist_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckFilesExistParallel checks if multiple files exist in parallel
|
||||||
|
// It builds an ISRC index from the output directory once, then checks all tracks against it
|
||||||
|
func CheckFilesExistParallel(outputDir string, tracks []struct {
|
||||||
|
ISRC string
|
||||||
|
TrackName string
|
||||||
|
ArtistName string
|
||||||
|
}) []FileExistenceResult {
|
||||||
|
results := make([]FileExistenceResult, len(tracks))
|
||||||
|
|
||||||
|
// Build ISRC index from output directory (scan once)
|
||||||
|
isrcIndex := buildISRCIndex(outputDir)
|
||||||
|
|
||||||
|
// Check each track against the index (parallel)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, track := range tracks {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, t struct {
|
||||||
|
ISRC string
|
||||||
|
TrackName string
|
||||||
|
ArtistName string
|
||||||
|
}) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
result := FileExistenceResult{
|
||||||
|
ISRC: t.ISRC,
|
||||||
|
TrackName: t.TrackName,
|
||||||
|
ArtistName: t.ArtistName,
|
||||||
|
Exists: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.ISRC != "" {
|
||||||
|
if filePath, exists := isrcIndex[strings.ToUpper(t.ISRC)]; exists {
|
||||||
|
result.Exists = true
|
||||||
|
result.FilePath = filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[idx] = result
|
||||||
|
}(i, track)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
||||||
|
func buildISRCIndex(outputDir string) map[string]string {
|
||||||
|
index := make(map[string]string)
|
||||||
|
|
||||||
|
// Walk directory recursively - only check .flac files for SpotiFLAC
|
||||||
|
pathfilepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(pathfilepath.Ext(path))
|
||||||
|
if ext != ".flac" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read ISRC from file
|
||||||
|
isrc, err := ReadISRCFromFile(path)
|
||||||
|
if err != nil || isrc == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in index (uppercase for case-insensitive matching)
|
||||||
|
index[strings.ToUpper(isrc)] = path
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"generate-icon": "node scripts/generate-icon.js"
|
"generate-icon": "node scripts/generate-icon.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
@@ -39,18 +40,18 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@types/node": "^25.0.2",
|
"@types/node": "^25.0.3",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.49.0",
|
"typescript-eslint": "^8.50.0",
|
||||||
"vite": "^7.2.7"
|
"vite": "^7.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
d4b3974abd992c8ff941c6fde9f62062
|
07ce84ccf0f1355c8d93ec1d8bd235ea
|
||||||
Generated
+336
-306
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ import { DownloadQueue } from "@/components/DownloadQueue";
|
|||||||
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
|
||||||
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
||||||
import { AudioConverterPage } from "@/components/AudioConverterPage";
|
import { AudioConverterPage } from "@/components/AudioConverterPage";
|
||||||
|
import { FileManagerPage } from "@/components/FileManagerPage";
|
||||||
import { SettingsPage } from "@/components/SettingsPage";
|
import { SettingsPage } from "@/components/SettingsPage";
|
||||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||||
import type { HistoryItem } from "@/components/FetchHistory";
|
import type { HistoryItem } from "@/components/FetchHistory";
|
||||||
@@ -56,7 +57,7 @@ function App() {
|
|||||||
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 50;
|
const ITEMS_PER_PAGE = 50;
|
||||||
const CURRENT_VERSION = "6.8";
|
const CURRENT_VERSION = "6.9";
|
||||||
|
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
const metadata = useMetadata();
|
const metadata = useMetadata();
|
||||||
@@ -515,6 +516,8 @@ function App() {
|
|||||||
return <AudioAnalysisPage />;
|
return <AudioAnalysisPage />;
|
||||||
case "audio-converter":
|
case "audio-converter":
|
||||||
return <AudioConverterPage />;
|
return <AudioConverterPage />;
|
||||||
|
case "file-manager":
|
||||||
|
return <FileManagerPage />;
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ const BITRATE_OPTIONS = [
|
|||||||
{ value: "128k", label: "128k" },
|
{ value: "128k", label: "128k" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const M4A_CODEC_OPTIONS = [
|
||||||
|
{ value: "aac", label: "AAC" },
|
||||||
|
{ value: "alac", label: "ALAC" },
|
||||||
|
];
|
||||||
|
|
||||||
const STORAGE_KEY = "spotiflac_audio_converter_state";
|
const STORAGE_KEY = "spotiflac_audio_converter_state";
|
||||||
|
|
||||||
export function AudioConverterPage() {
|
export function AudioConverterPage() {
|
||||||
@@ -90,13 +95,27 @@ export function AudioConverterPage() {
|
|||||||
}
|
}
|
||||||
return "320k";
|
return "320k";
|
||||||
});
|
});
|
||||||
|
const [m4aCodec, setM4aCodec] = useState<"aac" | "alac">(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.m4aCodec === "aac" || parsed.m4aCodec === "alac") {
|
||||||
|
return parsed.m4aCodec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
return "aac";
|
||||||
|
});
|
||||||
const [converting, setConverting] = useState(false);
|
const [converting, setConverting] = useState(false);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isDraggingFFmpeg, setIsDraggingFFmpeg] = useState(false);
|
const [isDraggingFFmpeg, setIsDraggingFFmpeg] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
// Helper function to save state to sessionStorage
|
// Helper function to save state to sessionStorage
|
||||||
const saveState = useCallback((stateToSave: { files: AudioFile[]; outputFormat: "mp3" | "m4a"; bitrate: string }) => {
|
const saveState = useCallback((stateToSave: { files: AudioFile[]; outputFormat: "mp3" | "m4a"; bitrate: string; m4aCodec: "aac" | "alac" }) => {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -109,10 +128,10 @@ export function AudioConverterPage() {
|
|||||||
checkFfmpegInstallation();
|
checkFfmpegInstallation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Save state to sessionStorage whenever files, outputFormat, or bitrate changes
|
// Save state to sessionStorage whenever files, outputFormat, bitrate, or m4aCodec changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveState({ files, outputFormat, bitrate });
|
saveState({ files, outputFormat, bitrate, m4aCodec });
|
||||||
}, [files, outputFormat, bitrate, saveState]);
|
}, [files, outputFormat, bitrate, m4aCodec, saveState]);
|
||||||
|
|
||||||
// Auto-set output format to M4A if all files are MP3
|
// Auto-set output format to M4A if all files are MP3
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -364,6 +383,7 @@ export function AudioConverterPage() {
|
|||||||
input_files: inputPaths,
|
input_files: inputPaths,
|
||||||
output_format: outputFormat,
|
output_format: outputFormat,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
|
codec: outputFormat === "m4a" ? m4aCodec : "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update file statuses based on results
|
// Update file statuses based on results
|
||||||
@@ -578,6 +598,32 @@ export function AudioConverterPage() {
|
|||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Codec selection for M4A */}
|
||||||
|
{outputFormat === "m4a" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="whitespace-nowrap">Codec:</Label>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
variant="outline"
|
||||||
|
value={m4aCodec}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) setM4aCodec(value as "aac" | "alac");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{M4A_CODEC_OPTIONS.map((option) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
aria-label={option.label}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Bitrate selection - hide for ALAC (lossless) */}
|
||||||
|
{!(outputFormat === "m4a" && m4aCodec === "alac") && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Label className="whitespace-nowrap">Bitrate:</Label>
|
<Label className="whitespace-nowrap">Bitrate:</Label>
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
@@ -599,6 +645,7 @@ export function AudioConverterPage() {
|
|||||||
))}
|
))}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
|||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
|
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
|
||||||
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
|
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
|
||||||
<div className="flex items-center justify-between mb-4 pr-8">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
|
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (
|
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (
|
||||||
|
|||||||
@@ -0,0 +1,578 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
FolderOpen,
|
||||||
|
RefreshCw,
|
||||||
|
FileMusic,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
Pencil,
|
||||||
|
Eye,
|
||||||
|
Folder,
|
||||||
|
Info,
|
||||||
|
X,
|
||||||
|
RotateCcw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { SelectFolder } from "../../wailsjs/go/main/App";
|
||||||
|
import { backend } from "../../wailsjs/go/models";
|
||||||
|
|
||||||
|
// These functions will be available after Wails regenerates bindings
|
||||||
|
// For now, we call them directly via window.go
|
||||||
|
const ListDirectoryFiles = (path: string): Promise<backend.FileInfo[]> =>
|
||||||
|
(window as any)['go']['main']['App']['ListDirectoryFiles'](path);
|
||||||
|
const PreviewRenameFiles = (files: string[], format: string): Promise<backend.RenamePreview[]> =>
|
||||||
|
(window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
|
||||||
|
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> =>
|
||||||
|
(window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
|
||||||
|
import { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface FileNode {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
is_dir: boolean;
|
||||||
|
size: number;
|
||||||
|
children?: FileNode[];
|
||||||
|
expanded?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMAT_PRESETS: Record<string, { label: string; template: string }> = {
|
||||||
|
"title": { label: "Title", template: "{title}" },
|
||||||
|
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
|
||||||
|
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
|
||||||
|
"track-title": { label: "Track. Title", template: "{track}. {title}" },
|
||||||
|
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
|
||||||
|
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
|
||||||
|
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
|
||||||
|
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
|
||||||
|
"custom": { label: "Custom...", template: "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "spotiflac_file_manager_state";
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||||
|
}
|
||||||
|
const DEFAULT_PRESET = "title-artist";
|
||||||
|
const DEFAULT_CUSTOM_FORMAT = "{title} - {artist}";
|
||||||
|
|
||||||
|
export function FileManagerPage() {
|
||||||
|
const [rootPath, setRootPath] = useState(() => {
|
||||||
|
const settings = getSettings();
|
||||||
|
return settings.downloadPath || "";
|
||||||
|
});
|
||||||
|
const [files, setFiles] = useState<FileNode[]>([]);
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formatPreset, setFormatPreset] = useState<string>(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.formatPreset && FORMAT_PRESETS[parsed.formatPreset]) {
|
||||||
|
return parsed.formatPreset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
return DEFAULT_PRESET;
|
||||||
|
});
|
||||||
|
const [customFormat, setCustomFormat] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.customFormat) {
|
||||||
|
return parsed.customFormat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
return DEFAULT_CUSTOM_FORMAT;
|
||||||
|
});
|
||||||
|
|
||||||
|
const renameFormat = formatPreset === "custom" ? customFormat : FORMAT_PRESETS[formatPreset].template;
|
||||||
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [previewData, setPreviewData] = useState<backend.RenamePreview[]>([]);
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [previewOnly, setPreviewOnly] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
|
||||||
|
// Save state to sessionStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save state:", err);
|
||||||
|
}
|
||||||
|
}, [formatPreset, customFormat]);
|
||||||
|
|
||||||
|
// Detect fullscreen/maximized window
|
||||||
|
useEffect(() => {
|
||||||
|
const checkFullscreen = () => {
|
||||||
|
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
|
||||||
|
setIsFullscreen(isMaximized);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkFullscreen();
|
||||||
|
window.addEventListener("resize", checkFullscreen);
|
||||||
|
window.addEventListener("focus", checkFullscreen);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", checkFullscreen);
|
||||||
|
window.removeEventListener("focus", checkFullscreen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadFiles = useCallback(async () => {
|
||||||
|
if (!rootPath) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await ListDirectoryFiles(rootPath);
|
||||||
|
// Filter to only show audio files and folders containing audio files
|
||||||
|
const filtered = filterAudioFiles(result as FileNode[]);
|
||||||
|
setFiles(filtered);
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to load files", {
|
||||||
|
description: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [rootPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rootPath) {
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
}, [rootPath, loadFiles]);
|
||||||
|
|
||||||
|
const filterAudioFiles = (nodes: FileNode[]): FileNode[] => {
|
||||||
|
return nodes
|
||||||
|
.map((node) => {
|
||||||
|
if (node.is_dir && node.children) {
|
||||||
|
const filteredChildren = filterAudioFiles(node.children);
|
||||||
|
if (filteredChildren.length > 0) {
|
||||||
|
return { ...node, children: filteredChildren };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ext = node.name.toLowerCase();
|
||||||
|
if (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a")) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((node): node is FileNode => node !== null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectFolder = async () => {
|
||||||
|
try {
|
||||||
|
const path = await SelectFolder(rootPath);
|
||||||
|
if (path) {
|
||||||
|
setRootPath(path);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to select folder", {
|
||||||
|
description: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = (path: string) => {
|
||||||
|
setFiles((prev) => toggleNodeExpand(prev, path));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleNodeExpand = (nodes: FileNode[], path: string): FileNode[] => {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (node.path === path) {
|
||||||
|
return { ...node, expanded: !node.expanded };
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
return { ...node, children: toggleNodeExpand(node.children, path) };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (path: string, isDir: boolean) => {
|
||||||
|
if (isDir) return;
|
||||||
|
|
||||||
|
setSelectedFiles((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(path)) {
|
||||||
|
newSet.delete(path);
|
||||||
|
} else {
|
||||||
|
newSet.add(path);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
const allAudioFiles = getAllAudioFiles(files);
|
||||||
|
setSelectedFiles(new Set(allAudioFiles.map((f) => f.path)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deselectAll = () => {
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllAudioFiles = (nodes: FileNode[]): FileNode[] => {
|
||||||
|
const result: FileNode[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!node.is_dir) {
|
||||||
|
result.push(node);
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
result.push(...getAllAudioFiles(node.children));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetToDefault = () => {
|
||||||
|
setFormatPreset(DEFAULT_PRESET);
|
||||||
|
setCustomFormat(DEFAULT_CUSTOM_FORMAT);
|
||||||
|
setShowResetConfirm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreview = async (isPreviewOnly: boolean) => {
|
||||||
|
if (selectedFiles.size === 0) {
|
||||||
|
toast.error("No files selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
|
||||||
|
setPreviewData(result);
|
||||||
|
setPreviewOnly(isPreviewOnly);
|
||||||
|
setShowPreview(true);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to generate preview", {
|
||||||
|
description: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
if (selectedFiles.size === 0) return;
|
||||||
|
|
||||||
|
setRenaming(true);
|
||||||
|
try {
|
||||||
|
const result = await RenameFilesByMetadata(Array.from(selectedFiles), renameFormat);
|
||||||
|
const successCount = result.filter((r: backend.RenameResult) => r.success).length;
|
||||||
|
const failCount = result.filter((r: backend.RenameResult) => !r.success).length;
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success("Rename Complete", {
|
||||||
|
description: `${successCount} file(s) renamed${failCount > 0 ? `, ${failCount} failed` : ""}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error("Rename Failed", {
|
||||||
|
description: `All ${failCount} file(s) failed to rename`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowPreview(false);
|
||||||
|
setSelectedFiles(new Set());
|
||||||
|
loadFiles();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Rename Failed", {
|
||||||
|
description: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setRenaming(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFileTree = (nodes: FileNode[], depth = 0) => {
|
||||||
|
return nodes.map((node) => (
|
||||||
|
<div key={node.path}>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer ${
|
||||||
|
selectedFiles.has(node.path) ? "bg-primary/10" : ""
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||||
|
onClick={() => (node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path, node.is_dir))}
|
||||||
|
>
|
||||||
|
{node.is_dir ? (
|
||||||
|
<>
|
||||||
|
{node.expanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
<Folder className="h-4 w-4 text-yellow-500 shrink-0" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedFiles.has(node.path)}
|
||||||
|
onCheckedChange={() => toggleSelect(node.path, node.is_dir)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
<FileMusic className="h-4 w-4 text-primary shrink-0" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="truncate text-sm flex-1">{node.name}</span>
|
||||||
|
{!node.is_dir && (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{node.is_dir && node.expanded && node.children && (
|
||||||
|
<div>{renderFileTree(node.children, depth + 1)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const allAudioFiles = getAllAudioFiles(files);
|
||||||
|
const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
|
||||||
|
<div className="flex items-center justify-between shrink-0">
|
||||||
|
<h1 className="text-2xl font-bold">File Manager</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Path Selection */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Input
|
||||||
|
value={rootPath}
|
||||||
|
onChange={(e) => setRootPath(e.target.value)}
|
||||||
|
placeholder="Select a folder..."
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSelectFolder}>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={loadFiles} disabled={loading || !rootPath}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rename Format */}
|
||||||
|
<div className="space-y-2 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-sm">Rename Format</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={formatPreset} onValueChange={setFormatPreset}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (
|
||||||
|
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{formatPreset === "custom" && (
|
||||||
|
<Input
|
||||||
|
value={customFormat}
|
||||||
|
onChange={(e) => setCustomFormat(e.target.value)}
|
||||||
|
placeholder="{artist} - {title}"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setShowResetConfirm(true)}>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Reset to default</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Tree */}
|
||||||
|
<div className={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-0" : ""}`}>
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{selectedFiles.size} of {allAudioFiles.length} file(s) selected
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
|
||||||
|
{allSelected ? "Deselect All" : "Select All"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePreview(true)}
|
||||||
|
disabled={selectedFiles.size === 0 || loading}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePreview(false)}
|
||||||
|
disabled={selectedFiles.size === 0 || loading}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`overflow-y-auto p-2 ${isFullscreen ? "flex-1 min-h-0" : "max-h-[400px]"}`}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Spinner className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
{rootPath ? "No audio files found" : "Select a folder to browse"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderFileTree(files)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Confirmation Dialog */}
|
||||||
|
<AlertDialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Reset to Default?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will reset the rename format to "Title - Artist". Your custom format will be lost.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={resetToDefault}>Reset</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Preview Dialog */}
|
||||||
|
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between pr-2">
|
||||||
|
<DialogTitle>Rename Preview</DialogTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 rounded-full hover:bg-muted"
|
||||||
|
onClick={() => setShowPreview(false)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DialogDescription>
|
||||||
|
Review the changes before renaming. Files with errors will be skipped.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-2 py-4">
|
||||||
|
{previewData.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="text-muted-foreground truncate">{item.old_name}</div>
|
||||||
|
{item.error ? (
|
||||||
|
<div className="text-destructive text-xs mt-1">{item.error}</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-primary font-medium truncate mt-1">→ {item.new_name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{previewOnly ? (
|
||||||
|
<Button onClick={() => setShowPreview(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setShowPreview(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleRename} disabled={renaming}>
|
||||||
|
{renaming ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
Renaming...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Rename {previewData.filter((p) => !p.error).length} File(s)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,16 @@ import {
|
|||||||
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
|
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
|
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
|
||||||
import { themes, applyTheme } from "@/lib/themes";
|
import { themes, applyTheme } from "@/lib/themes";
|
||||||
@@ -44,6 +54,7 @@ export function SettingsPage() {
|
|||||||
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||||
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyThemeMode(savedSettings.themeMode);
|
applyThemeMode(savedSettings.themeMode);
|
||||||
@@ -94,6 +105,7 @@ export function SettingsPage() {
|
|||||||
applyThemeMode(defaultSettings.themeMode);
|
applyThemeMode(defaultSettings.themeMode);
|
||||||
applyTheme(defaultSettings.theme);
|
applyTheme(defaultSettings.theme);
|
||||||
applyFont(defaultSettings.fontFamily);
|
applyFont(defaultSettings.fontFamily);
|
||||||
|
setShowResetConfirm(false);
|
||||||
toast.success("Settings reset to default");
|
toast.success("Settings reset to default");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,6 +317,7 @@ export function SettingsPage() {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={tempSettings.folderPreset}
|
value={tempSettings.folderPreset}
|
||||||
onValueChange={(value: FolderPreset) => {
|
onValueChange={(value: FolderPreset) => {
|
||||||
@@ -316,7 +329,7 @@ export function SettingsPage() {
|
|||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -330,23 +343,17 @@ export function SettingsPage() {
|
|||||||
value={tempSettings.folderTemplate}
|
value={tempSettings.folderTemplate}
|
||||||
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
|
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
|
||||||
placeholder="{artist}/{album}"
|
placeholder="{artist}/{album}"
|
||||||
className="h-9 text-sm"
|
className="h-9 text-sm flex-1"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{tempSettings.folderTemplate && (
|
{tempSettings.folderTemplate && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{album\}/g, "1989").replace(/\{year\}/g, "2014")}/</span>
|
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Label htmlFor="use-album-artist" className="cursor-pointer text-sm">Use Album Artist</Label>
|
|
||||||
<Switch
|
|
||||||
id="use-album-artist"
|
|
||||||
checked={tempSettings.useAlbumArtist}
|
|
||||||
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, useAlbumArtist: checked }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="border-t" />
|
<div className="border-t" />
|
||||||
|
|
||||||
{/* Filename Format */}
|
{/* Filename Format */}
|
||||||
@@ -362,6 +369,7 @@ export function SettingsPage() {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={tempSettings.filenamePreset}
|
value={tempSettings.filenamePreset}
|
||||||
onValueChange={(value: FilenamePreset) => {
|
onValueChange={(value: FilenamePreset) => {
|
||||||
@@ -373,7 +381,7 @@ export function SettingsPage() {
|
|||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9">
|
<SelectTrigger className="h-9 w-fit">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -387,12 +395,13 @@ export function SettingsPage() {
|
|||||||
value={tempSettings.filenameTemplate}
|
value={tempSettings.filenameTemplate}
|
||||||
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
|
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
|
||||||
placeholder="{track}. {title}"
|
placeholder="{track}. {title}"
|
||||||
className="h-9 text-sm"
|
className="h-9 text-sm flex-1"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{tempSettings.filenameTemplate && (
|
{tempSettings.filenameTemplate && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Taylor Swift").replace(/\{title\}/g, "Shake It Off").replace(/\{track\}/g, "01").replace(/\{year\}/g, "2014")}.flac</span>
|
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -401,7 +410,7 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 justify-between pt-4 border-t">
|
<div className="flex gap-2 justify-between pt-4 border-t">
|
||||||
<Button variant="outline" onClick={handleReset} className="gap-1.5">
|
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
Reset to Default
|
Reset to Default
|
||||||
</Button>
|
</Button>
|
||||||
@@ -410,6 +419,22 @@ export function SettingsPage() {
|
|||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Confirmation Dialog */}
|
||||||
|
<AlertDialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Reset to Default?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will reset all settings to their default values. Your custom configurations will be lost.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleReset}>Reset</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Home, Settings, Bug, Activity, FileMusic, LayoutGrid } from "lucide-react";
|
import { Home, Settings, Bug, Activity, FileMusic, FilePen, LayoutGrid, Coffee, Github } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternal } from "@/lib/utils";
|
import { openExternal } from "@/lib/utils";
|
||||||
|
|
||||||
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter";
|
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
currentPage: PageType;
|
currentPage: PageType;
|
||||||
@@ -20,6 +20,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
{ id: "settings" as PageType, icon: Settings, label: "Settings" },
|
{ id: "settings" as PageType, icon: Settings, label: "Settings" },
|
||||||
{ id: "audio-analysis" as PageType, icon: Activity, label: "Audio Quality Analyzer" },
|
{ id: "audio-analysis" as PageType, icon: Activity, label: "Audio Quality Analyzer" },
|
||||||
{ id: "audio-converter" as PageType, icon: FileMusic, label: "Audio Converter" },
|
{ id: "audio-converter" as PageType, icon: FileMusic, label: "Audio Converter" },
|
||||||
|
{ id: "file-manager" as PageType, icon: FilePen, label: "File Manager" },
|
||||||
{ id: "debug" as PageType, icon: Bug, label: "Debug Logs" },
|
{ id: "debug" as PageType, icon: Bug, label: "Debug Logs" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* GitHub - below debug */}
|
{/* Bottom icons */}
|
||||||
|
<div className="mt-auto flex flex-col gap-2">
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -54,18 +56,13 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
className="h-10 w-10"
|
className="h-10 w-10"
|
||||||
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
|
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
<Github className="h-5 w-5" />
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>Report Bug</p>
|
<p>Report Bug</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Other Projects at bottom */}
|
|
||||||
<div className="mt-auto">
|
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -81,6 +78,21 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
<p>Other Projects</p>
|
<p>Other Projects</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() => openExternal("https://ko-fi.com/afkarxyz")}
|
||||||
|
>
|
||||||
|
<Coffee className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>Support me on Ko-fi</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
@@ -6,6 +6,27 @@ import { joinPath, sanitizePath } from "@/lib/utils";
|
|||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import type { TrackMetadata } from "@/types/api";
|
import type { TrackMetadata } from "@/types/api";
|
||||||
|
|
||||||
|
// Type definitions for new backend functions
|
||||||
|
interface CheckFileExistenceRequest {
|
||||||
|
isrc: string;
|
||||||
|
track_name: string;
|
||||||
|
artist_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileExistenceResult {
|
||||||
|
isrc: string;
|
||||||
|
exists: boolean;
|
||||||
|
file_path?: string;
|
||||||
|
track_name?: string;
|
||||||
|
artist_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// These functions will be available after Wails regenerates bindings
|
||||||
|
const CheckFilesExistence = (outputDir: string, tracks: CheckFileExistenceRequest[]): Promise<FileExistenceResult[]> =>
|
||||||
|
(window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, tracks);
|
||||||
|
const SkipDownloadItem = (itemID: string, filePath: string): Promise<void> =>
|
||||||
|
(window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath);
|
||||||
|
|
||||||
export function useDownload() {
|
export function useDownload() {
|
||||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
@@ -50,14 +71,10 @@ export function useDownload() {
|
|||||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
// Build template data for folder path
|
// Build template data for folder path
|
||||||
let artistFolderName = artistName;
|
|
||||||
if(settings.useAlbumArtist) {
|
|
||||||
artistFolderName = albumArtist || artistName;
|
|
||||||
}
|
|
||||||
logger.info("Using artist folder name: " + artistFolderName);
|
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: artistFolderName?.replace(/\//g, placeholder),
|
artist: artistName?.replace(/\//g, placeholder),
|
||||||
album: albumName?.replace(/\//g, placeholder),
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
|
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder),
|
||||||
title: trackName?.replace(/\//g, placeholder),
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: position,
|
track: position,
|
||||||
year: releaseYear,
|
year: releaseYear,
|
||||||
@@ -301,16 +318,12 @@ export function useDownload() {
|
|||||||
|
|
||||||
let outputDir = settings.downloadPath;
|
let outputDir = settings.downloadPath;
|
||||||
let useAlbumTrackNumber = false;
|
let useAlbumTrackNumber = false;
|
||||||
let artistFolderName = artistName;
|
|
||||||
if(settings.useAlbumArtist) {
|
|
||||||
artistFolderName = albumArtist || artistName;
|
|
||||||
}
|
|
||||||
logger.info("Using artist folder name: " + artistFolderName);
|
|
||||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||||
const templateData: TemplateData = {
|
const templateData: TemplateData = {
|
||||||
artist: artistFolderName?.replace(/\//g, placeholder),
|
artist: artistName?.replace(/\//g, placeholder),
|
||||||
album: albumName?.replace(/\//g, placeholder),
|
album: albumName?.replace(/\//g, placeholder),
|
||||||
|
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder),
|
||||||
title: trackName?.replace(/\//g, placeholder),
|
title: trackName?.replace(/\//g, placeholder),
|
||||||
track: position,
|
track: position,
|
||||||
year: releaseYear,
|
year: releaseYear,
|
||||||
@@ -606,7 +619,40 @@ export function useDownload() {
|
|||||||
setBulkDownloadType("selected");
|
setBulkDownloadType("selected");
|
||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
|
|
||||||
// Pre-add ALL tracks to the queue before starting downloads
|
// Build output directory path
|
||||||
|
let outputDir = settings.downloadPath;
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
if (folderName && !isAlbum) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get selected track objects
|
||||||
|
const selectedTrackObjects = selectedTracks
|
||||||
|
.map((isrc) => allTracks.find((t) => t.isrc === isrc))
|
||||||
|
.filter((t): t is TrackMetadata => t !== undefined);
|
||||||
|
|
||||||
|
// Check file existence in parallel first
|
||||||
|
logger.info(`checking existing files in parallel...`);
|
||||||
|
const existenceChecks = selectedTrackObjects.map((track) => ({
|
||||||
|
isrc: track.isrc,
|
||||||
|
track_name: track.name || "",
|
||||||
|
artist_name: track.artists || "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks);
|
||||||
|
const existingISRCs = new Set<string>();
|
||||||
|
const existingFilePaths = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const result of existenceResults) {
|
||||||
|
if (result.exists) {
|
||||||
|
existingISRCs.add(result.isrc);
|
||||||
|
existingFilePaths.set(result.isrc, result.file_path || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`found ${existingISRCs.size} existing files`);
|
||||||
|
|
||||||
|
// Pre-add ALL tracks to the queue and mark existing ones as skipped
|
||||||
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
||||||
const itemIDs: string[] = [];
|
const itemIDs: string[] = [];
|
||||||
for (const isrc of selectedTracks) {
|
for (const isrc of selectedTracks) {
|
||||||
@@ -618,65 +664,78 @@ export function useDownload() {
|
|||||||
track?.album_name || ""
|
track?.album_name || ""
|
||||||
);
|
);
|
||||||
itemIDs.push(itemID);
|
itemIDs.push(itemID);
|
||||||
|
|
||||||
|
// Mark existing files as skipped immediately
|
||||||
|
if (existingISRCs.has(isrc)) {
|
||||||
|
const filePath = existingFilePaths.get(isrc) || "";
|
||||||
|
setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
|
||||||
|
setSkippedTracks((prev) => new Set(prev).add(isrc));
|
||||||
|
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out existing tracks
|
||||||
|
const tracksToDownload = selectedTrackObjects.filter((track) => !existingISRCs.has(track.isrc));
|
||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
let skippedCount = 0;
|
let skippedCount = existingISRCs.size;
|
||||||
const total = selectedTracks.length;
|
const total = selectedTracks.length;
|
||||||
|
|
||||||
for (let i = 0; i < selectedTracks.length; i++) {
|
// Update progress to reflect already-skipped tracks
|
||||||
|
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
||||||
|
|
||||||
|
for (let i = 0; i < tracksToDownload.length; i++) {
|
||||||
if (shouldStopDownloadRef.current) {
|
if (shouldStopDownloadRef.current) {
|
||||||
toast.info(
|
toast.info(
|
||||||
`Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.`
|
`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isrc = selectedTracks[i];
|
const track = tracksToDownload[i];
|
||||||
const track = allTracks.find((t) => t.isrc === isrc);
|
const isrc = track.isrc;
|
||||||
const itemID = itemIDs[i];
|
// Find original index and itemID
|
||||||
|
const originalIndex = selectedTracks.indexOf(isrc);
|
||||||
|
const itemID = itemIDs[originalIndex];
|
||||||
|
|
||||||
setDownloadingTrack(isrc);
|
setDownloadingTrack(isrc);
|
||||||
|
|
||||||
if (track) {
|
|
||||||
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
|
// Extract year from release_date (format: YYYY-MM-DD or YYYY)
|
||||||
const releaseYear = track?.release_date?.substring(0, 4);
|
const releaseYear = track.release_date?.substring(0, 4);
|
||||||
|
|
||||||
// Download with pre-created itemID
|
// Download with pre-created itemID
|
||||||
const response = await downloadWithItemID(
|
const response = await downloadWithItemID(
|
||||||
isrc,
|
isrc,
|
||||||
settings,
|
settings,
|
||||||
itemID,
|
itemID,
|
||||||
track?.name,
|
track.name,
|
||||||
track?.artists,
|
track.artists,
|
||||||
track?.album_name,
|
track.album_name,
|
||||||
folderName,
|
folderName,
|
||||||
i + 1, // Sequential position based on selection order
|
originalIndex + 1, // Sequential position based on selection order
|
||||||
track?.spotify_id,
|
track.spotify_id,
|
||||||
track?.duration_ms,
|
track.duration_ms,
|
||||||
isAlbum,
|
isAlbum,
|
||||||
releaseYear,
|
releaseYear,
|
||||||
track?.album_artist || "", // Use album_artist from Spotify metadata
|
track.album_artist || "", // Use album_artist from Spotify metadata
|
||||||
track?.release_date,
|
track.release_date,
|
||||||
track?.images, // Spotify cover URL
|
track.images, // Spotify cover URL
|
||||||
track?.track_number, // Spotify album track number
|
track.track_number, // Spotify album track number
|
||||||
track?.disc_number, // Spotify disc number
|
track.disc_number, // Spotify disc number
|
||||||
track?.total_tracks // Total tracks in album
|
track.total_tracks // Total tracks in album
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
if (response.already_exists) {
|
if (response.already_exists) {
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
logger.info(`skipped: ${track?.name} - ${track?.artists} (already exists)`);
|
logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`);
|
||||||
setSkippedTracks((prev) => new Set(prev).add(isrc));
|
setSkippedTracks((prev) => new Set(prev).add(isrc));
|
||||||
} else {
|
} else {
|
||||||
successCount++;
|
successCount++;
|
||||||
logger.success(`downloaded: ${track?.name} - ${track?.artists}`);
|
logger.success(`downloaded: ${track.name} - ${track.artists}`);
|
||||||
}
|
}
|
||||||
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||||
setFailedTracks((prev) => {
|
setFailedTracks((prev) => {
|
||||||
@@ -686,19 +745,19 @@ export function useDownload() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
logger.error(`failed: ${track?.name} - ${track?.artists}`);
|
logger.error(`failed: ${track.name} - ${track.artists}`);
|
||||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
logger.error(`error: ${track?.name} - ${err}`);
|
logger.error(`error: ${track.name} - ${err}`);
|
||||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||||
// Mark item as failed in queue
|
// Mark item as failed in queue
|
||||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
setDownloadProgress(Math.round(((skippedCount + successCount + errorCount) / total) * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
setDownloadingTrack(null);
|
setDownloadingTrack(null);
|
||||||
@@ -749,7 +808,35 @@ export function useDownload() {
|
|||||||
setBulkDownloadType("all");
|
setBulkDownloadType("all");
|
||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
|
|
||||||
// Pre-add ALL tracks to the queue before starting downloads
|
// Build output directory path
|
||||||
|
let outputDir = settings.downloadPath;
|
||||||
|
const os = settings.operatingSystem;
|
||||||
|
if (folderName && !isAlbum) {
|
||||||
|
outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file existence in parallel first
|
||||||
|
logger.info(`checking existing files in parallel...`);
|
||||||
|
const existenceChecks = tracksWithIsrc.map((track) => ({
|
||||||
|
isrc: track.isrc,
|
||||||
|
track_name: track.name || "",
|
||||||
|
artist_name: track.artists || "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const existenceResults = await CheckFilesExistence(outputDir, existenceChecks);
|
||||||
|
const existingISRCs = new Set<string>();
|
||||||
|
const existingFilePaths = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const result of existenceResults) {
|
||||||
|
if (result.exists) {
|
||||||
|
existingISRCs.add(result.isrc);
|
||||||
|
existingFilePaths.set(result.isrc, result.file_path || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`found ${existingISRCs.size} existing files`);
|
||||||
|
|
||||||
|
// Pre-add ALL tracks to the queue and mark existing ones as skipped
|
||||||
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
|
||||||
const itemIDs: string[] = [];
|
const itemIDs: string[] = [];
|
||||||
for (const track of tracksWithIsrc) {
|
for (const track of tracksWithIsrc) {
|
||||||
@@ -760,23 +847,39 @@ export function useDownload() {
|
|||||||
track.album_name || ""
|
track.album_name || ""
|
||||||
);
|
);
|
||||||
itemIDs.push(itemID);
|
itemIDs.push(itemID);
|
||||||
|
|
||||||
|
// Mark existing files as skipped immediately
|
||||||
|
if (existingISRCs.has(track.isrc)) {
|
||||||
|
const filePath = existingFilePaths.get(track.isrc) || "";
|
||||||
|
setTimeout(() => SkipDownloadItem(itemID, filePath), 10);
|
||||||
|
setSkippedTracks((prev) => new Set(prev).add(track.isrc));
|
||||||
|
setDownloadedTracks((prev) => new Set(prev).add(track.isrc));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out existing tracks
|
||||||
|
const tracksToDownload = tracksWithIsrc.filter((track) => !existingISRCs.has(track.isrc));
|
||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
let skippedCount = 0;
|
let skippedCount = existingISRCs.size;
|
||||||
const total = tracksWithIsrc.length;
|
const total = tracksWithIsrc.length;
|
||||||
|
|
||||||
for (let i = 0; i < tracksWithIsrc.length; i++) {
|
// Update progress to reflect already-skipped tracks
|
||||||
|
setDownloadProgress(Math.round((skippedCount / total) * 100));
|
||||||
|
|
||||||
|
for (let i = 0; i < tracksToDownload.length; i++) {
|
||||||
if (shouldStopDownloadRef.current) {
|
if (shouldStopDownloadRef.current) {
|
||||||
toast.info(
|
toast.info(
|
||||||
`Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.`
|
`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const track = tracksWithIsrc[i];
|
const track = tracksToDownload[i];
|
||||||
const itemID = itemIDs[i];
|
// Find original index and itemID
|
||||||
|
const originalIndex = tracksWithIsrc.findIndex((t) => t.isrc === track.isrc);
|
||||||
|
const itemID = itemIDs[originalIndex];
|
||||||
|
|
||||||
setDownloadingTrack(track.isrc);
|
setDownloadingTrack(track.isrc);
|
||||||
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||||
@@ -793,7 +896,7 @@ export function useDownload() {
|
|||||||
track.artists,
|
track.artists,
|
||||||
track.album_name,
|
track.album_name,
|
||||||
folderName,
|
folderName,
|
||||||
i + 1,
|
originalIndex + 1,
|
||||||
track.spotify_id,
|
track.spotify_id,
|
||||||
track.duration_ms,
|
track.duration_ms,
|
||||||
isAlbum,
|
isAlbum,
|
||||||
@@ -835,7 +938,7 @@ export function useDownload() {
|
|||||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
setDownloadProgress(Math.round(((skippedCount + successCount + errorCount) / total) * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
setDownloadingTrack(null);
|
setDownloadingTrack(null);
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { GetDefaults } from "../../wailsjs/go/main/App";
|
|||||||
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans";
|
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk" | "noto-sans" | "nunito-sans" | "figtree" | "raleway" | "public-sans" | "outfit" | "jetbrains-mono" | "geist-sans";
|
||||||
|
|
||||||
// Folder structure presets
|
// Folder structure presets
|
||||||
export type FolderPreset = "none" | "artist" | "album" | "artist-album" | "artist-year-album" | "custom";
|
export type FolderPreset = "none" | "artist" | "album" | "artist-album" | "artist-year-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "custom";
|
||||||
|
|
||||||
// Filename format presets
|
// Filename format presets
|
||||||
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "custom";
|
export type FilenamePreset = "title" | "title-artist" | "artist-title" | "track-title" | "track-title-artist" | "track-artist-title" | "title-album-artist" | "track-title-album-artist" | "custom";
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
@@ -21,7 +21,6 @@ export interface Settings {
|
|||||||
filenameTemplate: string;
|
filenameTemplate: string;
|
||||||
// Legacy settings (kept for migration)
|
// Legacy settings (kept for migration)
|
||||||
filenameFormat?: "title-artist" | "artist-title" | "title";
|
filenameFormat?: "title-artist" | "artist-title" | "title";
|
||||||
useAlbumArtist?: boolean;
|
|
||||||
artistSubfolder?: boolean;
|
artistSubfolder?: boolean;
|
||||||
albumSubfolder?: boolean;
|
albumSubfolder?: boolean;
|
||||||
trackNumber: boolean;
|
trackNumber: boolean;
|
||||||
@@ -41,6 +40,9 @@ export const FOLDER_PRESETS: Record<FolderPreset, { label: string; template: str
|
|||||||
"album": { label: "Album", template: "{album}" },
|
"album": { label: "Album", template: "{album}" },
|
||||||
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
|
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
|
||||||
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
|
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
|
||||||
|
"album-artist": { label: "Album Artist", template: "{album_artist}" },
|
||||||
|
"album-artist-album": { label: "Album Artist / Album", template: "{album_artist}/{album}" },
|
||||||
|
"album-artist-year-album": { label: "Album Artist / [Year] Album", template: "{album_artist}/[{year}] {album}" },
|
||||||
"custom": { label: "Custom...", template: "" },
|
"custom": { label: "Custom...", template: "" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,18 +54,20 @@ export const FILENAME_PRESETS: Record<FilenamePreset, { label: string; template:
|
|||||||
"track-title": { label: "Track. Title", template: "{track}. {title}" },
|
"track-title": { label: "Track. Title", template: "{track}. {title}" },
|
||||||
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
|
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
|
||||||
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
|
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
|
||||||
|
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
|
||||||
|
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
|
||||||
"custom": { label: "Custom...", template: "" },
|
"custom": { label: "Custom...", template: "" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Available template variables
|
// Available template variables
|
||||||
export const TEMPLATE_VARIABLES = [
|
export const TEMPLATE_VARIABLES = [
|
||||||
{ key: "{artist}", description: "Artist name", example: "Taylor Swift" },
|
|
||||||
{ key: "{album}", description: "Album name", example: "1989" },
|
|
||||||
{ key: "{title}", description: "Track title", example: "Shake It Off" },
|
{ key: "{title}", description: "Track title", example: "Shake It Off" },
|
||||||
|
{ key: "{artist}", description: "Track artist", example: "Taylor Swift" },
|
||||||
|
{ key: "{album}", description: "Album name", example: "1989" },
|
||||||
|
{ key: "{album_artist}", description: "Album artist", example: "Taylor Swift" },
|
||||||
{ key: "{track}", description: "Track number", example: "01" },
|
{ key: "{track}", description: "Track number", example: "01" },
|
||||||
|
{ key: "{disc}", description: "Disc number", example: "1" },
|
||||||
{ key: "{year}", description: "Release year", example: "2014" },
|
{ key: "{year}", description: "Release year", example: "2014" },
|
||||||
{ key: "{isrc}", description: "ISRC code", example: "USCJY1431309" },
|
|
||||||
{ key: "{playlist}", description: "Playlist name", example: "My Playlist" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Auto-detect operating system
|
// Auto-detect operating system
|
||||||
@@ -195,8 +199,10 @@ export function getSettings(): Settings {
|
|||||||
export interface TemplateData {
|
export interface TemplateData {
|
||||||
artist?: string;
|
artist?: string;
|
||||||
album?: string;
|
album?: string;
|
||||||
|
album_artist?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
track?: number;
|
track?: number;
|
||||||
|
disc?: number;
|
||||||
year?: string;
|
year?: string;
|
||||||
isrc?: string;
|
isrc?: string;
|
||||||
playlist?: string;
|
playlist?: string;
|
||||||
@@ -208,10 +214,12 @@ export function parseTemplate(template: string, data: TemplateData): string {
|
|||||||
let result = template;
|
let result = template;
|
||||||
|
|
||||||
// Replace each variable
|
// Replace each variable
|
||||||
|
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
|
||||||
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
|
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
|
||||||
result = result.replace(/\{album\}/g, data.album || "Unknown Album");
|
result = result.replace(/\{album\}/g, data.album || "Unknown Album");
|
||||||
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
|
result = result.replace(/\{album_artist\}/g, data.album_artist || data.artist || "Unknown Artist");
|
||||||
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
|
result = result.replace(/\{track\}/g, data.track ? String(data.track).padStart(2, "0") : "00");
|
||||||
|
result = result.replace(/\{disc\}/g, data.disc ? String(data.disc) : "1");
|
||||||
result = result.replace(/\{year\}/g, data.year || "0000");
|
result = result.replace(/\{year\}/g, data.year || "0000");
|
||||||
result = result.replace(/\{isrc\}/g, data.isrc || "");
|
result = result.replace(/\{isrc\}/g, data.isrc || "");
|
||||||
result = result.replace(/\{playlist\}/g, data.playlist || "");
|
result = result.replace(/\{playlist\}/g, data.playlist || "");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module spotiflac
|
module spotiflac
|
||||||
|
|
||||||
go 1.25.4
|
go 1.25.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bogem/id3v2/v2 v2.1.4
|
github.com/bogem/id3v2/v2 v2.1.4
|
||||||
|
|||||||
@@ -6,7 +6,5 @@
|
|||||||
"wolf.qqdl.site",
|
"wolf.qqdl.site",
|
||||||
"tidal.kinoplus.online",
|
"tidal.kinoplus.online",
|
||||||
"tidal-api.binimum.org",
|
"tidal-api.binimum.org",
|
||||||
"tidal-api-2.binimum.org",
|
|
||||||
"dev-api.squid.wtf",
|
|
||||||
"triton.squid.wtf"
|
"triton.squid.wtf"
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"productName": "SpotiFLAC",
|
"productName": "SpotiFLAC",
|
||||||
"productVersion": "6.8"
|
"productVersion": "6.9"
|
||||||
},
|
},
|
||||||
"wailsjsdir": "./frontend",
|
"wailsjsdir": "./frontend",
|
||||||
"assetdir": "./frontend/dist",
|
"assetdir": "./frontend/dist",
|
||||||
|
|||||||
Reference in New Issue
Block a user